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 |
@@ -98,6 +98,10 @@ if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then
|
||||
echo "Dynamic question promotion failed after 3 attempts." >&2
|
||||
exit 1
|
||||
fi
|
||||
kubectl exec -n "${KUBE_NAMESPACE}" "${API_POD}" -- \
|
||||
dotnet QuestionsApi.dll promote-ccs-cases \
|
||||
--source /app/content/ccs/duodenal-ulcer-office-001.json \
|
||||
--apply
|
||||
fi
|
||||
|
||||
kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'tools/**'
|
||||
- 'tests/**'
|
||||
- 'tests/*.gd'
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -77,6 +77,24 @@ Promotion is idempotent and does not delete or implicitly unpublish PRD content.
|
||||
|
||||
## Verification and rollback
|
||||
|
||||
## Step 3 mastery system backfill
|
||||
|
||||
The mastery dashboard uses a canonical 14-system classification stored on each
|
||||
question. After deploying the mastery-capable API, inspect and apply the
|
||||
idempotent backfill in UAT:
|
||||
|
||||
```bash
|
||||
kubectl exec deployment/step3-api -n step3-uat -- \
|
||||
dotnet QuestionsApi.dll backfill-step3-systems
|
||||
kubectl exec deployment/step3-api -n step3-uat -- \
|
||||
dotnet QuestionsApi.dll backfill-step3-systems --apply --confirm BACKFILL_STEP3_SYSTEMS
|
||||
```
|
||||
|
||||
Review the reported unmapped count before acceptance testing. Repeat the dry run
|
||||
and confirmed apply in `step3-prd` after the tested revision is promoted to the
|
||||
production branch. The command is safe to rerun and does not modify attempts or
|
||||
user data.
|
||||
|
||||
## Rz Coin Store attempt-cycle migration
|
||||
|
||||
The QBank reset preserves every attempt and increments a user's eligibility cycle. After deploying
|
||||
|
||||
@@ -91,6 +91,52 @@ public sealed class CcsCaseTests
|
||||
Assert.True(request!.Cases.Single().ScoringCriteria.Count >= 12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuodenalUlcerInterviewArtifactMatchesTheCaseSchema()
|
||||
{
|
||||
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../content/ccs/duodenal-ulcer-office-001.json"));
|
||||
var request = JsonSerializer.Deserialize<CcsImportRequest>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
Assert.NotNull(request);
|
||||
Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet<string>()));
|
||||
Assert.Equal(CcsExperienceModes.GraphicalInterview, request.Cases.Single().ExperienceMode);
|
||||
var topics = request.Cases.Single().InitialInterview.Topics;
|
||||
Assert.Equal([
|
||||
"chief_concern", "dizziness_character", "fatigue_course", "abdominal_character",
|
||||
"relieving_factors", "associated_gi", "exposure_history", "past_medical_history"
|
||||
], topics.Select(value => value.Id));
|
||||
Assert.Equal(6, topics.Count(value => value.Required));
|
||||
Assert.Equal(["fatigue_course"], topics.Single(value => value.Id == "abdominal_character").PrerequisiteTopicIds);
|
||||
Assert.All(topics.Where(value => value.Id is "relieving_factors" or "associated_gi" or "exposure_history"),
|
||||
value => Assert.Equal(["abdominal_character"], value.PrerequisiteTopicIds));
|
||||
Assert.False(topics.Single(value => value.Id == "past_medical_history").Required);
|
||||
Assert.Contains("cholesterol was high", topics.Single(value => value.Id == "past_medical_history").PatientText,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(4, request.Cases.Single().GraphicalPresentation.InterviewRevision);
|
||||
Assert.Equal("Hyperlipidemia", CcsStore.MedicalHistory(request.Cases.Single()));
|
||||
var exams = request.Cases.Single().Exams;
|
||||
Assert.Equal([
|
||||
"physical_appearance", "skin", "breasts", "lymph_nodes", "heent_neck", "chest_lungs",
|
||||
"heart_cardiovascular", "abdomen", "genitalia", "rectal", "extremities_spine", "neuro_psych"
|
||||
], exams.Select(value => value.Code));
|
||||
Assert.All(exams, value => Assert.Equal(1, value.DurationMinutes));
|
||||
Assert.Contains("mild epigastric tenderness", exams.Single(value => value.Code == "abdomen").Finding,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("brown stool", exams.Single(value => value.Code == "rectal").Finding,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(request.Orders, value => value.Code == "h_pylori_breath");
|
||||
Assert.Contains(request.Orders, value => value.Code == "bismuth_quadruple");
|
||||
Assert.Contains(request.Cases.Single().OrderResponses,
|
||||
value => value.OrderCode == "upper_endoscopy" && value.Result.Contains("duodenal", StringComparison.OrdinalIgnoreCase));
|
||||
var endoscopy = request.Cases.Single().OrderResponses.Single(value => value.OrderCode == "upper_endoscopy");
|
||||
Assert.Equal("endoscopy_suite", endoscopy.PerformanceLocation);
|
||||
Assert.True(endoscopy.RequiresPatientVisit);
|
||||
Assert.All(request.Cases.Single().OrderResponses.Where(value => value.RequiresPatientVisit),
|
||||
value => Assert.False(string.IsNullOrWhiteSpace(value.PerformanceLocation)));
|
||||
Assert.Contains(request.Cases.Single().ScoringCriteria,
|
||||
value => value.TargetCode == "ibuprofen" && value.Points < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedCaseRequiresReferencesAndKnownOrders()
|
||||
{
|
||||
@@ -135,6 +181,150 @@ public sealed class CcsCaseTests
|
||||
Assert.Empty(CcsCaseValidator.ValidateImport(ValidImport(), new HashSet<string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteractiveInterviewRequiresAReachableAcyclicGraph()
|
||||
{
|
||||
var request = ValidImport();
|
||||
request.Cases[0].InitialInterview = ValidInterview();
|
||||
Assert.Empty(CcsCaseValidator.ValidateImport(request, new HashSet<string>()));
|
||||
|
||||
request.Cases[0].InitialInterview.Nodes[0].Choices[0].NextNodeId = "missing";
|
||||
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
||||
Assert.Contains(errors, value => value.Contains("unknown next node", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
request = ValidImport();
|
||||
request.Cases[0].InitialInterview = ValidInterview();
|
||||
request.Cases[0].InitialInterview.Nodes[1].IsTerminal = false;
|
||||
request.Cases[0].InitialInterview.Nodes[1].Choices = [new() { Id = "loop", Text = "Start over", NextNodeId = "greeting" }];
|
||||
errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
||||
Assert.Contains(errors, value => value.Contains("cycles", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
request = ValidImport();
|
||||
request.Cases[0].InitialInterview = ValidInterview();
|
||||
request.Cases[0].InitialInterview.Nodes.Add(new() { Id = "orphan", PatientText = "Unreachable", IsTerminal = true });
|
||||
errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
||||
Assert.Contains(errors, value => value.Contains("reachable", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DialogueChoicesPersistAndCompleteWithoutAdvancingSimulatedTime()
|
||||
{
|
||||
var definition = ValidImport().Cases[0];
|
||||
definition.InitialInterview = ValidInterview();
|
||||
var attempt = new CcsAttempt { InterviewNodeId = "greeting", SimulatedMinute = 7, OpeningCompleted = false };
|
||||
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow);
|
||||
|
||||
Assert.Equal("done", attempt.InterviewNodeId);
|
||||
Assert.Equal(["ask_reason"], attempt.InterviewChoiceIds);
|
||||
Assert.Equal(7, attempt.SimulatedMinute);
|
||||
Assert.True(attempt.OpeningCompleted);
|
||||
Assert.Contains(attempt.Actions, value => value.Type == "dialogue_choice" && value.Code == "ask_reason");
|
||||
Assert.Throws<InvalidOperationException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyCaseGetsInterviewFromExistingChartNarrative()
|
||||
{
|
||||
var definition = ValidImport().Cases[0];
|
||||
definition.InitialInterview = new();
|
||||
definition.InitialChart.HistorySections =
|
||||
[
|
||||
new() { Code = "hpi", Title = "History of present illness", Text = "The symptoms began this morning." },
|
||||
new() { Code = "medications", Title = "Medications", Text = "I take no regular medicines." }
|
||||
];
|
||||
|
||||
var interview = CcsStore.EffectiveInterview(definition);
|
||||
var attempt = new CcsAttempt { InterviewNodeId = interview.StartNodeId, OpeningCompleted = false };
|
||||
|
||||
Assert.Equal("opening", interview.StartNodeId);
|
||||
Assert.Equal(5, interview.Nodes.Count);
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow);
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_history_1", DateTime.UtcNow);
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_history_2", DateTime.UtcNow);
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow);
|
||||
Assert.Equal("complete", attempt.InterviewNodeId);
|
||||
Assert.True(attempt.OpeningCompleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredInterviewRemainsPreferredOverGeneratedFallback()
|
||||
{
|
||||
var definition = ValidImport().Cases[0];
|
||||
definition.InitialInterview = ValidInterview();
|
||||
|
||||
Assert.Same(definition.InitialInterview, CcsStore.EffectiveInterview(definition));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopicInterviewBranchesAndUnlocksFinishAfterRequiredTopics()
|
||||
{
|
||||
var definition = ValidImport().Cases[0];
|
||||
definition.InitialInterview = TopicInterview();
|
||||
var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = false };
|
||||
|
||||
var opening = CcsStore.CurrentInterviewNode(attempt, definition)!;
|
||||
Assert.True(opening.SuppressPatientText);
|
||||
Assert.Equal(["chief_concern"], opening.Choices.Select(value => value.Id));
|
||||
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow));
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow);
|
||||
var chiefConcern = CcsStore.CurrentInterviewNode(attempt, definition)!;
|
||||
Assert.False(chiefConcern.SuppressPatientText);
|
||||
Assert.Equal("I feel tired.", chiefConcern.PatientText);
|
||||
Assert.Equal(["required_history", "optional_history"], chiefConcern.Choices.Select(value => value.Id));
|
||||
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow));
|
||||
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow);
|
||||
var finishable = CcsStore.CurrentInterviewNode(attempt, definition)!;
|
||||
Assert.Contains(finishable.Choices, value => value.Id == "finish_interview");
|
||||
Assert.DoesNotContain(finishable.Choices, value => value.Id == "chief_concern");
|
||||
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow));
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow);
|
||||
Assert.True(attempt.OpeningCompleted);
|
||||
Assert.True(CcsStore.CurrentInterviewNode(attempt, definition)!.IsTerminal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopicInterviewRepairsLegacyOpeningCompletionFlag()
|
||||
{
|
||||
var definition = ValidImport().Cases[0];
|
||||
definition.InitialInterview = TopicInterview();
|
||||
var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = true };
|
||||
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow);
|
||||
|
||||
Assert.False(attempt.OpeningCompleted);
|
||||
Assert.Equal("chief_concern", attempt.InterviewNodeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopicInterviewRejectsPrerequisiteCycles()
|
||||
{
|
||||
var request = ValidImport();
|
||||
request.Cases[0].InitialInterview = TopicInterview();
|
||||
request.Cases[0].InitialInterview.Topics[0].PrerequisiteTopicIds = ["required_history"];
|
||||
|
||||
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
||||
|
||||
Assert.Contains(errors, value => value.Contains("prerequisites cannot contain cycles", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaseContentComparisonIgnoresRevisionMetadata()
|
||||
{
|
||||
var first = ValidImport().Cases[0];
|
||||
var second = ValidImport().Cases[0];
|
||||
first.Id = "507f1f77bcf86cd799439011";
|
||||
first.Version = 4;
|
||||
first.IsCurrent = false;
|
||||
first.CreatedUtc = DateTime.UtcNow.AddDays(-2);
|
||||
second.InitialChart.SimulatedStartUtc = first.InitialChart.SimulatedStartUtc;
|
||||
|
||||
Assert.Equal(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second));
|
||||
second.Title = "Changed title";
|
||||
Assert.NotEqual(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CcsExpectedScoreUsesSeparateUserAndCaseElo()
|
||||
{
|
||||
@@ -201,4 +391,33 @@ public sealed class CcsCaseTests
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
private static CcsInitialInterview ValidInterview() => new()
|
||||
{
|
||||
StartNodeId = "greeting",
|
||||
Nodes =
|
||||
[
|
||||
new() { Id = "greeting", PatientText = "Hello, doctor.", Choices = [new() { Id = "ask_reason", Text = "What brings you in?", NextNodeId = "done" }] },
|
||||
new() { Id = "done", PatientText = "Thank you.", IsTerminal = true }
|
||||
]
|
||||
};
|
||||
|
||||
private static CcsInitialInterview TopicInterview() => new()
|
||||
{
|
||||
OpeningPatientText = "Good morning.", ClosingPatientText = "Thank you.", FinishChoiceText = "Finish",
|
||||
Topics =
|
||||
[
|
||||
new() { Id = "chief_concern", ClinicianText = "What brings you in?", PatientText = "I feel tired." },
|
||||
new()
|
||||
{
|
||||
Id = "required_history", ClinicianText = "Tell me more.", PatientText = "It started recently.", Required = true,
|
||||
PrerequisiteTopicIds = ["chief_concern"]
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "optional_history", ClinicianText = "Anything else?", PatientText = "No.",
|
||||
PrerequisiteTopicIds = ["chief_concern"]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,6 +92,33 @@ public sealed class ChoicePoolTests
|
||||
Assert.Contains("every incorrect choice", QuestionValidator.Validate(request));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("prompt")]
|
||||
[InlineData("teaching")]
|
||||
[InlineData("correct")]
|
||||
[InlineData("choices")]
|
||||
public void PublishedQuestionRejectsClonedDistractorFollowups(string duplicate)
|
||||
{
|
||||
var request = BuildRequest(5);
|
||||
var first = request.WrongAnswerFollowups["B"];
|
||||
var second = request.WrongAnswerFollowups["C"];
|
||||
if (duplicate == "prompt") second.Prompt = first.Prompt;
|
||||
if (duplicate == "teaching") second.TeachingPoint = first.TeachingPoint;
|
||||
if (duplicate == "correct") second.Choices.Single(x => x.Id == second.CorrectChoiceId).Text =
|
||||
first.Choices.Single(x => x.Id == first.CorrectChoiceId).Text;
|
||||
if (duplicate == "choices") second.Choices = first.Choices.Select(x => new QuestionChoice { Id=x.Id, Text=x.Text }).ToList();
|
||||
|
||||
Assert.Contains("different", QuestionValidator.Validate(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedQuestionRequiresThreeDistractorFollowupChoices()
|
||||
{
|
||||
var request = BuildRequest(5);
|
||||
request.WrongAnswerFollowups["B"].Choices.RemoveAt(2);
|
||||
Assert.Contains("exactly three", QuestionValidator.Validate(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionalReviewFollowupsRequireValidAuthoredContent()
|
||||
{
|
||||
@@ -275,14 +302,15 @@ public sealed class ChoicePoolTests
|
||||
id => id,
|
||||
id => new WrongAnswerFollowup
|
||||
{
|
||||
Prompt = "Which finding makes this less appropriate?",
|
||||
Prompt = $"Which property of Choice {id} makes it less appropriate?",
|
||||
Choices =
|
||||
[
|
||||
new QuestionChoice { Id = "A", Text = "Finding one" },
|
||||
new QuestionChoice { Id = "B", Text = "Finding two" }
|
||||
new QuestionChoice { Id = "A", Text = $"Disqualifying property of Choice {id}" },
|
||||
new QuestionChoice { Id = "B", Text = $"Plausible property one for Choice {id}" },
|
||||
new QuestionChoice { Id = "C", Text = $"Plausible property two for Choice {id}" }
|
||||
],
|
||||
CorrectChoiceId = "A",
|
||||
TeachingPoint = "Teaching point"
|
||||
TeachingPoint = $"Choice {id} fails because of its specific disqualifying property."
|
||||
}),
|
||||
ChoiceSelectionPercentages = ids.ToDictionary(id => id, _ => 0),
|
||||
Discipline = "Medicine",
|
||||
|
||||
@@ -8,10 +8,25 @@ public sealed class CreditEconomyTests
|
||||
[Fact]
|
||||
public void EarningPolicy_UsesTheAgreedRollingLimit()
|
||||
{
|
||||
Assert.Equal(20, CreditEconomy.RollingEarningLimit);
|
||||
Assert.Equal(30, CreditEconomy.RollingEarningLimit);
|
||||
Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(3, 1)]
|
||||
[InlineData(4, 2)]
|
||||
[InlineData(5, 3)]
|
||||
public void FirstCorrectAwardsScaleWithStoredDifficulty(int difficulty, int expected) =>
|
||||
Assert.Equal(expected, CreditEconomy.FirstCorrectAmount(difficulty));
|
||||
|
||||
[Fact]
|
||||
public void DelayedRecoveryReplacesTheNormalReviewAward()
|
||||
{
|
||||
Assert.Equal(1, CreditEconomy.ReviewAmount(false));
|
||||
Assert.Equal(3, CreditEconomy.ReviewAmount(true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers()
|
||||
{
|
||||
@@ -21,6 +36,14 @@ public sealed class CreditEconomyTests
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class DailyMissionPolicyTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void DueReviewsArePrioritizedAndCappedAtTen()
|
||||
{
|
||||
var candidates = DailyMissionStore.BuildCandidates(Dashboard(17), Questions(), Now);
|
||||
Assert.Equal(DailyMissionTypes.DueReviews, candidates[0].Type);
|
||||
Assert.Equal(10, candidates[0].Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoReviewsFallsBackToCoverageGaps()
|
||||
{
|
||||
var candidates = DailyMissionStore.BuildCandidates(Dashboard(0), Questions(), Now);
|
||||
Assert.DoesNotContain(candidates, value => value.Type == DailyMissionTypes.DueReviews);
|
||||
Assert.True(candidates.Count >= 3);
|
||||
Assert.Contains(candidates.Take(3), value => value.Type == DailyMissionTypes.DifficultyFourCorrect);
|
||||
Assert.All(candidates.Where(value => value.Type == DailyMissionTypes.WeakSystemCorrect),
|
||||
value => Assert.Equal(3, value.Target));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HardMissionRequiresThreeEligiblePublishedQuestions()
|
||||
{
|
||||
var questions = Questions();
|
||||
questions.RemoveAll(value => value.Step3System == Step3SystemCatalog.All[1] && value.Difficulty >= 4);
|
||||
var candidates = DailyMissionStore.BuildCandidates(Dashboard(0), questions, Now);
|
||||
Assert.DoesNotContain(candidates, value => value.Type == DailyMissionTypes.DifficultyFourCorrect
|
||||
&& value.Step3System == Step3SystemCatalog.All[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuestionProgressRequiresCorrectEligibleActivityAfterMissionStart()
|
||||
{
|
||||
var system = Step3SystemCatalog.All[0];
|
||||
var mission = new DailyMission
|
||||
{
|
||||
Type = DailyMissionTypes.DifficultyFourCorrect, Step3System = system, StartsUtc = Now
|
||||
};
|
||||
var question = new Question { Step3System = system, Difficulty = 4 };
|
||||
Assert.True(DailyMissionStore.QualifiesQuestion(mission,
|
||||
new QuestionAttempt { IsCorrect = true, CreatedUtc = Now }, question));
|
||||
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
|
||||
new QuestionAttempt { IsCorrect = false, CreatedUtc = Now }, question));
|
||||
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
|
||||
new QuestionAttempt { IsCorrect = true, IsSkip = true, CreatedUtc = Now }, question));
|
||||
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
|
||||
new QuestionAttempt { IsCorrect = true, CreatedUtc = Now.AddSeconds(-1) }, question));
|
||||
question.Difficulty = 3;
|
||||
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
|
||||
new QuestionAttempt { IsCorrect = true, CreatedUtc = Now }, question));
|
||||
}
|
||||
|
||||
private static DashboardResponse Dashboard(int pending) => new()
|
||||
{
|
||||
MissedQuestionCount = pending,
|
||||
Systems = Step3SystemCatalog.All.Take(4).Select((system, index) => new DashboardSystemPerformance
|
||||
{
|
||||
System = system, MasteryState = index == 0 ? MasteryStates.Unseen : MasteryStates.Learning,
|
||||
MasteryScore = index * 10, DistinctQuestions = index, Difficulty4PlusSuccessful = index
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static List<Question> Questions() => Step3SystemCatalog.All.Take(4)
|
||||
.SelectMany(system => Enumerable.Range(0, 6).Select(index => new Question
|
||||
{
|
||||
ExternalId = $"{system}-{index}", Step3System = system, Difficulty = index < 3 ? 4 : 2,
|
||||
IsPublished = true
|
||||
})).ToList();
|
||||
}
|
||||
@@ -72,6 +72,9 @@ public sealed class DashboardTests
|
||||
[InlineData("Pregnancy", "Pregnancy/Childbirth & Female Reproductive System & Breast")]
|
||||
[InlineData("Biostatistics", "Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature")]
|
||||
[InlineData("Medical Ethics", "Social Sciences: Communication Skills/Ethics/Patient Safety")]
|
||||
[InlineData("Integumentary System", "Skin & Subcutaneous Tissue")]
|
||||
[InlineData("Eye & Adnexa", "Nervous System & Special Senses")]
|
||||
[InlineData("Toxicology", "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders")]
|
||||
public void SystemAliasesMapToOfficialCategories(string source, string expected)
|
||||
{
|
||||
Assert.Equal(expected, DashboardStore.NormalizeStep3System(source));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,19 @@ public sealed class CreditsController(CreditStore credits, ExamSimulationStore s
|
||||
[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)
|
||||
{
|
||||
|
||||
@@ -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, CreditStore credits) : 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()));
|
||||
@@ -113,7 +118,12 @@ public class ExamSimulationsController(ExamSimulationStore simulations, CreditSt
|
||||
{
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -132,7 +142,36 @@ public class ExamSimulationsController(ExamSimulationStore simulations, CreditSt
|
||||
{
|
||||
var simulation = await simulations.GetOwnedAsync(id, UserId());
|
||||
if (simulation is null) return NotFound();
|
||||
try { return Ok(await simulations.ToResponseAsync(await simulations.SubmitBlockAsync(simulation))); }
|
||||
try
|
||||
{
|
||||
var userId = UserId();
|
||||
await masteryAwards.PrepareAsync(userId);
|
||||
await progression.PrepareAsync(userId);
|
||||
simulation = await simulations.SubmitBlockAsync(simulation);
|
||||
var response = await simulations.ToResponseAsync(simulation);
|
||||
var session = await studySessions.CompleteSimulationBlockAsync(userId, simulation.Id ?? id, simulation.CurrentBlock);
|
||||
response.StudySessionProgress = session.Progress;
|
||||
response.SessionSummary = session.Summary;
|
||||
if (session.Summary is not null)
|
||||
response.CreditAwards.AddRange(session.Summary.CoinAwards.Where(value =>
|
||||
value.ReasonCode == CreditReasonCodes.SessionGoalComplete));
|
||||
if (simulation.State == ExamSimulationStates.Completed && !simulation.IsSample)
|
||||
{
|
||||
var result = await credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
$"simulation:{simulation.Id}", 10, CreditReasonCodes.ExamSimulationComplete,
|
||||
"Completed a Step 3 exam simulation", "exam_simulation", simulation.Id ?? "", false));
|
||||
if (result.Award is not null) response.CreditAwards.Add(result.Award);
|
||||
response.CreditAwards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
|
||||
response.Credits = await credits.GetStatusAsync(userId);
|
||||
}
|
||||
else if (session.Summary is not null)
|
||||
response.Credits = await credits.GetStatusAsync(userId);
|
||||
var progressionResult = await progression.RefreshAsync(userId);
|
||||
response.Progression = progressionResult.State;
|
||||
response.CreditAwards.AddRange(progressionResult.Awards);
|
||||
if (progressionResult.Awards.Count > 0) response.Credits = await credits.GetStatusAsync(userId);
|
||||
return Ok(response);
|
||||
}
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController, Route("api/progression"), Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class ProgressionController(ProgressionStore progression) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name ?? "";
|
||||
return string.IsNullOrWhiteSpace(userId) ? Unauthorized() : Ok(await progression.GetAsync(userId));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ public class QuestionsController : ControllerBase
|
||||
private readonly QuestionBookmarkStore _bookmarks;
|
||||
private readonly ReviewQueueStore _reviewQueue;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly MasteryAwardService _masteryAwards;
|
||||
private readonly StudySessionStore _studySessions;
|
||||
private readonly DailyMissionStore _dailyMissions;
|
||||
private readonly ProgressionStore _progression;
|
||||
|
||||
public QuestionsController(
|
||||
QuestionStore questions,
|
||||
@@ -30,7 +34,11 @@ public class QuestionsController : ControllerBase
|
||||
ServiceMetrics metrics,
|
||||
QuestionBookmarkStore bookmarks,
|
||||
ReviewQueueStore reviewQueue,
|
||||
CreditStore credits)
|
||||
CreditStore credits,
|
||||
MasteryAwardService masteryAwards,
|
||||
StudySessionStore studySessions,
|
||||
DailyMissionStore dailyMissions,
|
||||
ProgressionStore progression)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
@@ -41,6 +49,10 @@ public class QuestionsController : ControllerBase
|
||||
_bookmarks = bookmarks;
|
||||
_reviewQueue = reviewQueue;
|
||||
_credits = credits;
|
||||
_masteryAwards = masteryAwards;
|
||||
_studySessions = studySessions;
|
||||
_dailyMissions = dailyMissions;
|
||||
_progression = progression;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -55,7 +67,9 @@ public class QuestionsController : ControllerBase
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Search([FromBody] QuestionSearchRequest req)
|
||||
{
|
||||
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
|
||||
List<Question> questions;
|
||||
try { questions = await _questions.SearchAsync(req, User.IsInRole("SUPER")); }
|
||||
catch (ArgumentException exception) { return BadRequest(exception.Message); }
|
||||
var percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
|
||||
var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId));
|
||||
var bookmarkedIds = await _bookmarks.GetBookmarkedIdsAsync(UserId(), questions.Select(q => q.ExternalId));
|
||||
@@ -150,6 +164,9 @@ public class QuestionsController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
|
||||
await _masteryAwards.PrepareAsync(userId);
|
||||
await _progression.PrepareAsync(userId);
|
||||
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
userId,
|
||||
@@ -176,8 +193,18 @@ public class QuestionsController : ControllerBase
|
||||
_metrics.AttemptDuplicate();
|
||||
}
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit)
|
||||
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
|
||||
var creditAwards = new List<CreditAward>();
|
||||
if (record.Inserted && record.Attempt.IsCorrect)
|
||||
{
|
||||
var amount = CreditEconomy.FirstCorrectAmount(question.Difficulty);
|
||||
var difficult = question.Difficulty >= 4;
|
||||
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
$"answer:{record.Attempt.Id}", amount,
|
||||
difficult ? CreditReasonCodes.DifficultCorrect : CreditReasonCodes.FirstCorrect,
|
||||
difficult ? $"Difficulty {question.Difficulty} first correct answer" : "First correct answer",
|
||||
"question_attempt", record.Attempt.Id ?? ""));
|
||||
if (award.Award is not null) creditAwards.Add(award.Award);
|
||||
}
|
||||
|
||||
var ratingOwner = Guid.NewGuid().ToString("N");
|
||||
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
|
||||
@@ -203,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,
|
||||
@@ -211,7 +251,12 @@ public class QuestionsController : ControllerBase
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating,
|
||||
Credits = await _credits.GetStatusAsync(userId)
|
||||
Credits = await _credits.GetStatusAsync(userId),
|
||||
CreditAwards = creditAwards,
|
||||
StudySessionProgress = studySession?.Progress,
|
||||
SessionSummary = studySession?.Summary,
|
||||
DailyMissions = dailyMissions?.State,
|
||||
Progression = progression.State
|
||||
});
|
||||
}
|
||||
|
||||
@@ -272,6 +317,8 @@ public class QuestionsController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
|
||||
await _progression.PrepareAsync(userId);
|
||||
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
userId,
|
||||
@@ -304,6 +351,7 @@ public class QuestionsController : ControllerBase
|
||||
throw;
|
||||
}
|
||||
|
||||
var progression = await _progression.RefreshAsync(userId);
|
||||
return Ok(new SkipQuestionResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
@@ -312,7 +360,8 @@ public class QuestionsController : ControllerBase
|
||||
question,
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating
|
||||
Rating = rating,
|
||||
Progression = progression.State
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ public sealed class ReviewQueueController(
|
||||
QuestionStore questions,
|
||||
AnswerStatisticsStore statistics,
|
||||
RatingStore ratings,
|
||||
QuestionBookmarkStore bookmarks) : ControllerBase
|
||||
QuestionBookmarkStore bookmarks,
|
||||
CreditStore credits,
|
||||
MasteryAwardService masteryAwards,
|
||||
StudySessionStore studySessions,
|
||||
DailyMissionStore dailyMissions,
|
||||
ProgressionStore progression) : ControllerBase
|
||||
{
|
||||
[HttpPost("next")]
|
||||
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
|
||||
@@ -22,9 +27,11 @@ public sealed class ReviewQueueController(
|
||||
var userId = UserId();
|
||||
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||
await queue.EnsureUserQueueAsync(userId);
|
||||
var item = await queue.GetRandomPendingAsync(userId, req.ExcludeExternalIds);
|
||||
ReviewQueueItem? item;
|
||||
try { item = await queue.GetRandomPendingAsync(userId, req.ExcludeExternalIds, req.Step3System); }
|
||||
catch (ArgumentException exception) { return BadRequest(exception.Message); }
|
||||
if (item is null)
|
||||
return Ok(new ReviewQueueNextResponse { PendingCount = checked((int)await queue.CountPendingAsync(userId)) });
|
||||
return Ok(new ReviewQueueNextResponse { PendingCount = checked((int)await queue.CountPendingAsync(userId, req.Step3System)) });
|
||||
|
||||
var question = await questions.GetByExternalIdAsync(item.QuestionExternalId);
|
||||
if (question is null || !question.IsPublished) return NotFound();
|
||||
@@ -39,7 +46,7 @@ public sealed class ReviewQueueController(
|
||||
{
|
||||
Question = response,
|
||||
Cycle = item.Cycle,
|
||||
PendingCount = checked((int)await queue.CountPendingAsync(userId))
|
||||
PendingCount = checked((int)await queue.CountPendingAsync(userId, req.Step3System))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,6 +76,8 @@ public sealed class ReviewQueueController(
|
||||
return BadRequest("presentedChoiceIds must include valid selected and correct choices");
|
||||
}
|
||||
|
||||
await masteryAwards.PrepareAsync(userId);
|
||||
await progression.PrepareAsync(userId);
|
||||
var result = await queue.RecordReviewAsync(
|
||||
userId,
|
||||
req.ClientAttemptId,
|
||||
@@ -79,12 +88,44 @@ public sealed class ReviewQueueController(
|
||||
choiceId == question.CorrectChoiceId,
|
||||
req.DurationSeconds);
|
||||
if (result is null) return Conflict("review cycle is no longer active");
|
||||
var awards = new List<CreditAward>();
|
||||
var delayed = false;
|
||||
if (result.Attempt.IsCorrect)
|
||||
{
|
||||
delayed = result.Item is not null
|
||||
&& result.Attempt.CreatedUtc - result.Item.CreatedUtc >= TimeSpan.FromHours(24);
|
||||
var award = await credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
$"review:{result.Attempt.Id}", CreditEconomy.ReviewAmount(delayed),
|
||||
delayed ? CreditReasonCodes.DelayedRecovery : CreditReasonCodes.ScheduledReview,
|
||||
delayed ? "Delayed recovery of a missed question" : "Correct scheduled review",
|
||||
"question_review", result.Attempt.Id));
|
||||
if (award.Award is not null) awards.Add(award.Award);
|
||||
}
|
||||
awards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
|
||||
StudySessionEnvelope? studySession = null;
|
||||
if (!string.IsNullOrWhiteSpace(req.StudySessionId))
|
||||
studySession = await studySessions.RecordReviewAsync(
|
||||
req.StudySessionId, userId, result.Attempt, delayed, awards);
|
||||
if (studySession?.Summary is not null)
|
||||
awards.AddRange(studySession.Summary.CoinAwards.Where(value =>
|
||||
value.ReasonCode == CreditReasonCodes.SessionGoalComplete
|
||||
&& awards.All(existing => existing.ReasonCode != value.ReasonCode)));
|
||||
var missionResult = await dailyMissions.RecordReviewAsync(userId, result.Attempt);
|
||||
if (missionResult is not null) awards.AddRange(missionResult.CreditAwards);
|
||||
var progressionResult = await progression.RefreshAsync(userId);
|
||||
awards.AddRange(progressionResult.Awards);
|
||||
return Ok(new ReviewQueueAttemptResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
IsCorrect = result.Attempt.IsCorrect,
|
||||
QueueState = result.Item?.State ?? ReviewQueueStates.Pending,
|
||||
PendingCount = checked((int)await queue.CountPendingAsync(userId))
|
||||
PendingCount = checked((int)await queue.CountPendingAsync(userId)),
|
||||
Credits = await credits.GetStatusAsync(userId),
|
||||
CreditAwards = awards,
|
||||
StudySessionProgress = studySession?.Progress,
|
||||
SessionSummary = studySession?.Summary,
|
||||
DailyMissions = missionResult?.State,
|
||||
Progression = progressionResult.State
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/study-sessions")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class StudySessionsController(StudySessionStore sessions) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateStudySessionRequest request)
|
||||
{
|
||||
try { return Ok(sessions.Envelope(await sessions.CreateAsync(UserId(), request))); }
|
||||
catch (ArgumentException exception) { return BadRequest(exception.Message); }
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
}
|
||||
|
||||
[HttpGet("active")]
|
||||
public async Task<IActionResult> Active()
|
||||
{
|
||||
var session = await sessions.GetActiveAsync(UserId());
|
||||
return session is null ? NoContent() : Ok(sessions.Envelope(session));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/finish")]
|
||||
public async Task<IActionResult> Finish(string id)
|
||||
{
|
||||
var result = await sessions.FinishEarlyAsync(id, UserId());
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/summary")]
|
||||
public async Task<IActionResult> Summary(string id)
|
||||
{
|
||||
var session = await sessions.GetOwnedAsync(id, UserId());
|
||||
return session is null ? NotFound() : Ok(sessions.Envelope(session));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/check")]
|
||||
public async Task<IActionResult> Check(string id)
|
||||
{
|
||||
var result = await sessions.CheckAsync(id, UserId());
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/weekly-goals")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class WeeklyGoalsController(
|
||||
WeeklyGoalStore goals, DashboardStore dashboard, QuestionStore questions) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var userId = UserId();
|
||||
var now = DateTime.UtcNow;
|
||||
return Ok(await goals.GetAsync(userId, await dashboard.GetAsync(userId, now),
|
||||
await questions.GetPublishedAsync()));
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException();
|
||||
}
|
||||
@@ -9,6 +9,7 @@ RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj"
|
||||
|
||||
COPY api/QuestionsApi/ api/QuestionsApi/
|
||||
COPY content/dynamic_questions/ content/dynamic_questions/
|
||||
COPY content/ccs/ content/ccs/
|
||||
WORKDIR /src/api/QuestionsApi
|
||||
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION}
|
||||
|
||||
@@ -17,6 +18,7 @@ ARG BUILD_REVISION
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions
|
||||
COPY --from=build /src/content/ccs ./content/ccs
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:5008 \
|
||||
ASPNETCORE_ENVIRONMENT=Production \
|
||||
|
||||
@@ -64,7 +64,10 @@ public class CcsCaseRevision
|
||||
public int RealTimeMinutes { get; set; } = 20;
|
||||
public int Difficulty { get; set; } = 2;
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
|
||||
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
|
||||
public CcsInitialChart InitialChart { get; set; } = new();
|
||||
public CcsInitialInterview InitialInterview { get; set; } = new();
|
||||
public List<string> AllowedLocations { get; set; } = [];
|
||||
public List<CcsExamDefinition> Exams { get; set; } = [];
|
||||
public List<CcsCaseOrderDefinition> OrderResponses { get; set; } = [];
|
||||
@@ -74,6 +77,62 @@ public class CcsCaseRevision
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class CcsInitialInterview
|
||||
{
|
||||
public string StartNodeId { get; set; } = "";
|
||||
public List<CcsDialogueNode> Nodes { get; set; } = [];
|
||||
public string OpeningPatientText { get; set; } = "";
|
||||
public List<CcsInterviewTopic> Topics { get; set; } = [];
|
||||
public string FinishChoiceText { get; set; } = "Finish the interview";
|
||||
public string ClosingPatientText { get; set; } = "Thank you, doctor.";
|
||||
public CcsInterviewDebrief Debrief { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CcsInterviewTopic
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string ClinicianText { get; set; } = "";
|
||||
public string PatientText { get; set; } = "";
|
||||
public bool Required { get; set; }
|
||||
public List<string> PrerequisiteTopicIds { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsInterviewDebrief
|
||||
{
|
||||
public string LikelyDiagnosis { get; set; } = "";
|
||||
public string Summary { get; set; } = "";
|
||||
public List<string> TeachingPoints { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsGraphicalPresentation
|
||||
{
|
||||
public string SceneKey { get; set; } = "";
|
||||
public string PatientAssetKey { get; set; } = "";
|
||||
public int InterviewRevision { get; set; }
|
||||
}
|
||||
|
||||
public static class CcsExperienceModes
|
||||
{
|
||||
public const string Management = "management";
|
||||
public const string GraphicalInterview = "graphical_interview";
|
||||
}
|
||||
|
||||
public class CcsDialogueNode
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string PatientText { get; set; } = "";
|
||||
public bool SuppressPatientText { get; set; }
|
||||
public bool IsTerminal { get; set; }
|
||||
public List<CcsDialogueChoice> Choices { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsDialogueChoice
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Text { get; set; } = "";
|
||||
public string NextNodeId { get; set; } = "";
|
||||
}
|
||||
|
||||
public class CcsInitialChart
|
||||
{
|
||||
public string PatientSummary { get; set; } = "";
|
||||
@@ -105,6 +164,8 @@ public class CcsCaseOrderDefinition
|
||||
{
|
||||
public string OrderCode { get; set; } = "";
|
||||
public int? TurnaroundMinutes { get; set; }
|
||||
public string PerformanceLocation { get; set; } = "";
|
||||
public bool RequiresPatientVisit { get; set; }
|
||||
public string Result { get; set; } = "";
|
||||
public string ResultSection { get; set; } = "";
|
||||
public Dictionary<string, string> VitalPatches { get; set; } = [];
|
||||
@@ -148,6 +209,8 @@ public class CcsAttempt
|
||||
public int Revision { get; set; }
|
||||
public int SimulatedMinute { get; set; }
|
||||
[BsonIgnoreIfNull] public bool? OpeningCompleted { get; set; }
|
||||
public string InterviewNodeId { get; set; } = "";
|
||||
public List<string> InterviewChoiceIds { get; set; } = [];
|
||||
public string Location { get; set; } = "";
|
||||
public Dictionary<string, string> CurrentVitals { get; set; } = [];
|
||||
public List<CcsAttemptAction> Actions { get; set; } = [];
|
||||
@@ -182,6 +245,8 @@ public class CcsPendingResult
|
||||
public string Category { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public int DueMinute { get; set; }
|
||||
public string PerformanceLocation { get; set; } = "";
|
||||
public bool RequiresPatientVisit { get; set; }
|
||||
public string Result { get; set; } = "";
|
||||
public Dictionary<string, string> VitalPatches { get; set; } = [];
|
||||
}
|
||||
@@ -210,10 +275,22 @@ public class CcsActiveOrder
|
||||
public string Frequency { get; set; } = "";
|
||||
public int OrderedMinute { get; set; }
|
||||
public int? ReportMinute { get; set; }
|
||||
public int? DueMinute { get; set; }
|
||||
public string PerformanceLocation { get; set; } = "";
|
||||
public bool RequiresPatientVisit { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public bool IsPending { get; set; }
|
||||
public bool HasResult { get; set; }
|
||||
}
|
||||
|
||||
public class CcsOrderOption
|
||||
{
|
||||
public string Code { get; set; } = "";
|
||||
public int TurnaroundMinutes { get; set; }
|
||||
public string PerformanceLocation { get; set; } = "";
|
||||
public bool RequiresPatientVisit { get; set; }
|
||||
}
|
||||
|
||||
public class CcsCaseScore
|
||||
{
|
||||
public double Percent { get; set; }
|
||||
@@ -246,6 +323,7 @@ public class CcsImportResponse
|
||||
{
|
||||
public int OrdersUpserted { get; set; }
|
||||
public int CasesCreated { get; set; }
|
||||
public int CasesSkipped { get; set; }
|
||||
public List<string> Errors { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -287,6 +365,10 @@ public class CcsCaseSummary
|
||||
public int RealTimeMinutes { get; set; }
|
||||
public int Difficulty { get; set; }
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public bool InteractiveInterviewAvailable { get; set; }
|
||||
public bool GraphicalPresentationAvailable { get; set; }
|
||||
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
|
||||
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CcsAttemptResponse
|
||||
@@ -307,24 +389,38 @@ public class CcsAttemptResponse
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public int RealTimeMinutes { get; set; }
|
||||
public int ElapsedSeconds { get; set; }
|
||||
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
|
||||
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
|
||||
public string PatientSummary { get; set; } = "";
|
||||
public string ReasonForEncounter { get; set; } = "";
|
||||
public string Appearance { get; set; } = "";
|
||||
public string History { get; set; } = "";
|
||||
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
|
||||
public string MedicalHistory { get; set; } = "";
|
||||
public Dictionary<string, string> Vitals { get; set; } = [];
|
||||
public List<CcsChartEntry> Chart { get; set; } = [];
|
||||
public List<string> ActiveOrderCodes { get; set; } = [];
|
||||
public List<CcsActiveOrder> ActiveOrders { get; set; } = [];
|
||||
public List<CcsOrderOption> OrderOptions { get; set; } = [];
|
||||
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
|
||||
public List<CcsPendingResult> PendingResults { get; set; } = [];
|
||||
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
|
||||
public List<string> AllowedLocations { get; set; } = [];
|
||||
public CcsInterviewStateResponse? Interview { get; set; }
|
||||
public CcsInterviewDebrief? InterviewDebrief { get; set; }
|
||||
public CcsCaseScore? Score { get; set; }
|
||||
public CcsRatingChange? Rating { get; set; }
|
||||
public List<QuestionReference> References { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsInterviewStateResponse
|
||||
{
|
||||
public string CurrentNodeId { get; set; } = "";
|
||||
public CcsDialogueNode CurrentNode { get; set; } = new();
|
||||
public List<string> VisitedChoiceIds { get; set; } = [];
|
||||
public bool Completed { get; set; }
|
||||
}
|
||||
|
||||
public class CcsHistoryItem
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace QuestionsApi.Models;
|
||||
|
||||
public static class CreditEconomy
|
||||
{
|
||||
public const int RollingEarningLimit = 20;
|
||||
public const int RollingEarningLimit = 30;
|
||||
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
|
||||
public const string ExamTokenSku = "exam_token_1";
|
||||
public const string QBankResetTokenSku = "qbank_reset_1";
|
||||
@@ -34,6 +34,9 @@ public static class CreditEconomy
|
||||
|
||||
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
|
||||
@@ -92,6 +95,69 @@ public sealed class RzStoreProduct
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -122,6 +188,7 @@ public sealed class UserCreditAccount
|
||||
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; } = [];
|
||||
@@ -142,6 +209,15 @@ public sealed class CreditStatusResponse
|
||||
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; } = "";
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class DailyMissionTypes
|
||||
{
|
||||
public const string DueReviews = "due_reviews";
|
||||
public const string WeakSystemCorrect = "weak_system_correct";
|
||||
public const string DifficultyFourCorrect = "difficulty_four_correct";
|
||||
}
|
||||
|
||||
public sealed class DailyMission
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Type { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string Step3System { get; set; } = "";
|
||||
public int DifficultyMin { get; set; }
|
||||
public int Target { get; set; }
|
||||
public int Progress { get; set; }
|
||||
public DateTime StartsUtc { get; set; }
|
||||
public DateTime? CompletedUtc { get; set; }
|
||||
public List<string> AppliedEventIds { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class DailyMissionSet
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public string DateUtc { get; set; } = "";
|
||||
public List<DailyMission> Missions { get; set; } = [];
|
||||
public List<DailyMission> ReplacementCandidates { get; set; } = [];
|
||||
public bool ReplacementUsed { get; set; }
|
||||
public bool ExpirationRecorded { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime ExpiresUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DailyMissionResponse
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string Step3System { get; set; } = "";
|
||||
public int DifficultyMin { get; set; }
|
||||
public int Target { get; set; }
|
||||
public int Progress { get; set; }
|
||||
public bool Completed { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DailyMissionSetResponse
|
||||
{
|
||||
public string DateUtc { get; set; } = "";
|
||||
public DateTime ExpiresUtc { get; set; }
|
||||
public bool ReplacementAvailable { get; set; }
|
||||
public List<DailyMissionResponse> Missions { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class DailyMissionEnvelope
|
||||
{
|
||||
public DailyMissionSetResponse State { get; set; } = new();
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public sealed class DashboardResponse
|
||||
public List<DashboardSystemPerformance> Systems { get; set; } = [];
|
||||
public List<DashboardActivityDay> WeeklyActivity { get; set; } = [];
|
||||
public List<DashboardRecentActivity> RecentActivity { get; set; } = [];
|
||||
public ProgressionResponse? Progression { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DashboardTrendPoint
|
||||
@@ -49,6 +50,32 @@ public sealed class DashboardSystemPerformance
|
||||
public int Correct { get; set; }
|
||||
public int Total { get; set; }
|
||||
public int Accuracy { get; set; }
|
||||
public int MasteryScore { get; set; }
|
||||
public string MasteryState { get; set; } = MasteryStates.Unseen;
|
||||
public int DistinctQuestions { get; set; }
|
||||
public int Difficulty4PlusSuccessful { get; set; }
|
||||
public int PendingReviews { get; set; }
|
||||
public DateTime? LastEvidenceUtc { get; set; }
|
||||
public bool RefreshDue { get; set; }
|
||||
public DashboardMasteryAction RecommendedAction { get; set; } = new();
|
||||
}
|
||||
|
||||
public static class MasteryStates
|
||||
{
|
||||
public const string Unseen = "unseen";
|
||||
public const string Learning = "learning";
|
||||
public const string Practicing = "practicing";
|
||||
public const string Strong = "strong";
|
||||
public const string Mastered = "mastered";
|
||||
}
|
||||
|
||||
public sealed class DashboardMasteryAction
|
||||
{
|
||||
public string Type { get; set; } = "practice";
|
||||
public string Label { get; set; } = "Start learning";
|
||||
public int QuestionCount { get; set; } = 10;
|
||||
public int? DifficultyMin { get; set; }
|
||||
public int? DifficultyMax { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DashboardActivityDay
|
||||
|
||||
@@ -117,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; } = [];
|
||||
|
||||
@@ -3,6 +3,7 @@ namespace QuestionsApi.Models;
|
||||
public class QuestionSearchRequest
|
||||
{
|
||||
public string? System { get; set; }
|
||||
public string? Step3System { get; set; }
|
||||
public string? Discipline { get; set; }
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public int? DifficultyMin { get; set; }
|
||||
@@ -50,4 +51,5 @@ public class FirstQuestionAttemptRequest
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public int DurationSeconds { get; set; }
|
||||
public bool MetTimeLimit { get; set; } = true;
|
||||
public string? StudySessionId { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using QuestionsApi.Services;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public class QuestionResponse
|
||||
@@ -18,6 +20,7 @@ public class QuestionResponse
|
||||
public string ChoiceSelectionRateBasis { get; set; } = "presented";
|
||||
public string Discipline { get; set; } = "";
|
||||
public string System { get; set; } = "";
|
||||
public string Step3System { get; set; } = "";
|
||||
public string PerformanceCategory { get; set; } = "";
|
||||
public int Difficulty { get; set; }
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
@@ -59,6 +62,7 @@ public class QuestionResponse
|
||||
.ToDictionary(),
|
||||
Discipline = question.Discipline,
|
||||
System = question.System,
|
||||
Step3System = Step3SystemCatalog.Normalize(question) ?? "",
|
||||
PerformanceCategory = RatingCategory.Normalize(question.PerformanceCategory, question.System, question.Discipline),
|
||||
Difficulty = question.Difficulty,
|
||||
QuestionElo = questionElo,
|
||||
@@ -142,6 +146,11 @@ public class FirstQuestionAttemptResponse
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||
public StudySessionSummary? SessionSummary { get; set; }
|
||||
public DailyMissionSetResponse? DailyMissions { get; set; }
|
||||
public ProgressionResponse? Progression { get; set; }
|
||||
}
|
||||
|
||||
public class SkipQuestionResponse
|
||||
@@ -151,6 +160,7 @@ public class SkipQuestionResponse
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public ProgressionResponse? Progression { get; set; }
|
||||
}
|
||||
|
||||
public static class RatingCategory
|
||||
|
||||
@@ -55,6 +55,7 @@ public sealed class ReviewQueueNextRequest
|
||||
{
|
||||
public List<string> ExcludeExternalIds { get; set; } = [];
|
||||
public bool SupportsChoicePools { get; set; } = true;
|
||||
public string? Step3System { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueNextResponse
|
||||
@@ -72,6 +73,7 @@ public sealed class ReviewQueueAttemptRequest
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public int DurationSeconds { get; set; }
|
||||
public string? StudySessionId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueAttemptResponse
|
||||
@@ -80,6 +82,12 @@ public sealed class ReviewQueueAttemptResponse
|
||||
public bool IsCorrect { get; set; }
|
||||
public string QueueState { get; set; } = ReviewQueueStates.Pending;
|
||||
public int PendingCount { get; set; }
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||
public StudySessionSummary? SessionSummary { get; set; }
|
||||
public DailyMissionSetResponse? DailyMissions { get; set; }
|
||||
public ProgressionResponse? Progression { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueRecordResult
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class StudyGoalTypes
|
||||
{
|
||||
public const string QuestionCount = "question_count";
|
||||
public const string Duration = "duration";
|
||||
public const string WeakSystem = "weak_system";
|
||||
public const string DueReviews = "due_reviews";
|
||||
public const string SimulationBlock = "simulation_block";
|
||||
public static readonly HashSet<string> All = [QuestionCount, Duration, WeakSystem, DueReviews, SimulationBlock];
|
||||
}
|
||||
|
||||
public static class StudySessionStates
|
||||
{
|
||||
public const string Active = "active";
|
||||
public const string Completed = "completed";
|
||||
public const string EndedEarly = "ended_early";
|
||||
public const string Interrupted = "interrupted";
|
||||
}
|
||||
|
||||
public sealed class StudySession
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public string ClientSessionId { get; set; } = "";
|
||||
public string GoalType { get; set; } = StudyGoalTypes.QuestionCount;
|
||||
public string Mode { get; set; } = "Tutor";
|
||||
public string Step3System { get; set; } = "";
|
||||
public int TargetValue { get; set; } = 10;
|
||||
public string State { get; set; } = StudySessionStates.Active;
|
||||
public int QuestionsCompleted { get; set; }
|
||||
public int Correct { get; set; }
|
||||
public int ReviewsCompleted { get; set; }
|
||||
public int Recovered { get; set; }
|
||||
public int CoinsEarned { get; set; }
|
||||
public List<CreditAward> CoinAwards { get; set; } = [];
|
||||
public List<string> AppliedEventIds { get; set; } = [];
|
||||
public int? InitialMasteryScore { get; set; }
|
||||
public string InitialMasteryState { get; set; } = "";
|
||||
public int? FinalMasteryScore { get; set; }
|
||||
public string FinalMasteryState { get; set; } = "";
|
||||
public int? InitialReadiness { get; set; }
|
||||
public int? FinalReadiness { get; set; }
|
||||
public string SimulationId { get; set; } = "";
|
||||
public int? SimulationBlock { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; }
|
||||
public DateTime? TargetEndUtc { get; set; }
|
||||
public DateTime? CompletedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateStudySessionRequest
|
||||
{
|
||||
public string ClientSessionId { get; set; } = "";
|
||||
public string GoalType { get; set; } = StudyGoalTypes.QuestionCount;
|
||||
public int TargetValue { get; set; } = 10;
|
||||
public string Mode { get; set; } = "Tutor";
|
||||
public string Step3System { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class StudySessionProgress
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string State { get; set; } = "";
|
||||
public string GoalType { get; set; } = "";
|
||||
public int TargetValue { get; set; }
|
||||
public int CompletedValue { get; set; }
|
||||
public int QuestionsCompleted { get; set; }
|
||||
public int SecondsRemaining { get; set; }
|
||||
public bool GoalReached { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StudySessionSummary
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string State { get; set; } = "";
|
||||
public string GoalType { get; set; } = "";
|
||||
public int TargetValue { get; set; }
|
||||
public int CompletedValue { get; set; }
|
||||
public int QuestionsCompleted { get; set; }
|
||||
public int Correct { get; set; }
|
||||
public int Accuracy { get; set; }
|
||||
public int ReviewsCompleted { get; set; }
|
||||
public int Recovered { get; set; }
|
||||
public int ElapsedSeconds { get; set; }
|
||||
public int CoinsEarned { get; set; }
|
||||
public List<CreditAward> CoinAwards { get; set; } = [];
|
||||
public string Step3System { get; set; } = "";
|
||||
public int? MasteryScoreChange { get; set; }
|
||||
public string InitialMasteryState { get; set; } = "";
|
||||
public string FinalMasteryState { get; set; } = "";
|
||||
public int? ReadinessChange { get; set; }
|
||||
public string ReadinessExplanation { get; set; } = "A completed exam simulation is required to update readiness.";
|
||||
public bool GoalBonusAwarded { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StudySessionEnvelope
|
||||
{
|
||||
public StudySessionProgress Progress { get; set; } = new();
|
||||
public StudySessionSummary? Summary { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class WeeklyRemediationTypes
|
||||
{
|
||||
public const string Reviews = "reviews";
|
||||
public const string HardCorrect = "hard_correct";
|
||||
}
|
||||
|
||||
public sealed class WeeklyGoal
|
||||
{
|
||||
[BsonId] public string Id { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public DateTime WeekStartUtc { get; set; }
|
||||
public DateTime ExpiresUtc { get; set; }
|
||||
public string RemediationType { get; set; } = WeeklyRemediationTypes.Reviews;
|
||||
public int RemediationTarget { get; set; }
|
||||
public List<string> EligibleSystems { get; set; } = [];
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime? CompletedUtc { get; set; }
|
||||
public bool RewardIssued { get; set; }
|
||||
public int RewardAmount { get; set; }
|
||||
public bool ExpirationRecorded { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WeeklyGoalComponentResponse
|
||||
{
|
||||
public string Type { get; set; } = "";
|
||||
public string Label { get; set; } = "";
|
||||
public int Progress { get; set; }
|
||||
public int Target { get; set; }
|
||||
public bool Completed { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WeeklyGoalResponse
|
||||
{
|
||||
public DateTime WeekStartUtc { get; set; }
|
||||
public DateTime ExpiresUtc { get; set; }
|
||||
public bool Completed { get; set; }
|
||||
public string RemediationType { get; set; } = "";
|
||||
public List<string> EligibleSystems { get; set; } = [];
|
||||
public string SuggestedSystem { get; set; } = "";
|
||||
public int RewardAmount { get; set; }
|
||||
public int RewardMaximum { get; set; } = 10;
|
||||
public List<WeeklyGoalComponentResponse> Components { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class WeeklyGoalEnvelope
|
||||
{
|
||||
public WeeklyGoalResponse State { get; set; } = new();
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
}
|
||||
@@ -29,6 +29,12 @@ if (args.FirstOrDefault() == "promote-dynamic-questions")
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "promote-ccs-cases")
|
||||
{
|
||||
Environment.ExitCode = await CcsPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "export-dynamic-review")
|
||||
{
|
||||
Environment.ExitCode = await DynamicReviewExportCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
@@ -66,6 +72,17 @@ if (args.FirstOrDefault() == "migrate-question-attempt-cycles")
|
||||
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 =>
|
||||
@@ -79,6 +96,8 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
builder.Services.AddSingleton<MongoDatabaseProvider>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<CreditStore>();
|
||||
builder.Services.AddSingleton<MasteryAwardService>();
|
||||
builder.Services.AddHostedService<CreditLedgerReconciliationService>();
|
||||
builder.Services.AddSingleton<QuestionStore>();
|
||||
builder.Services.AddSingleton<QuestionAttemptStore>();
|
||||
builder.Services.AddSingleton<AnswerStatisticsStore>();
|
||||
@@ -98,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>();
|
||||
|
||||
@@ -57,6 +57,14 @@ least one embedded authoritative verification reference. Legacy published
|
||||
questions without references remain readable but must add references before a
|
||||
future authoring update.
|
||||
|
||||
Wrong-answer follow-ups are distractor-specific remediation, not generic review
|
||||
questions. Each distractor must have a different three-choice quiz about the
|
||||
mechanism, indication, contraindication, timing, or other property that makes
|
||||
that option inappropriate in the vignette. Its teaching point must explain why
|
||||
that distractor fails without revealing the main correct answer. Publication
|
||||
validation rejects duplicated prompts, correct follow-up answers, choice sets,
|
||||
and teaching points within a question.
|
||||
|
||||
- `GET /readyz` verifies MongoDB connectivity for readiness checks.
|
||||
- `GET /metrics` exposes Prometheus-format service and reconciliation metrics.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace QuestionsApi.Services;
|
||||
|
||||
public static class CcsCaseValidator
|
||||
{
|
||||
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"];
|
||||
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "dialogue_choice", "end_case", "exit_case"];
|
||||
|
||||
public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
|
||||
{
|
||||
@@ -34,10 +34,17 @@ public static class CcsCaseValidator
|
||||
if (string.IsNullOrWhiteSpace(item.PresentingLocation)) errors.Add(prefix + "presentingLocation required");
|
||||
if (item.RealTimeMinutes is not (10 or 20)) errors.Add(prefix + "realTimeMinutes must be 10 or 20");
|
||||
if (item.Difficulty is < 1 or > 5) errors.Add(prefix + "difficulty must be between 1 and 5");
|
||||
if (item.ExperienceMode is not (CcsExperienceModes.Management or CcsExperienceModes.GraphicalInterview))
|
||||
errors.Add(prefix + "experienceMode is invalid");
|
||||
if (item.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
&& (string.IsNullOrWhiteSpace(item.GraphicalPresentation.SceneKey)
|
||||
|| string.IsNullOrWhiteSpace(item.GraphicalPresentation.PatientAssetKey)))
|
||||
errors.Add(prefix + "graphical interviews require sceneKey and patientAssetKey");
|
||||
if (string.IsNullOrWhiteSpace(item.InitialChart.PatientSummary) || string.IsNullOrWhiteSpace(item.InitialChart.ReasonForEncounter))
|
||||
errors.Add(prefix + "initial patient summary and reason are required");
|
||||
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
|
||||
errors.Add(prefix + "allowedLocations must include presentingLocation");
|
||||
ValidateInterview(item.InitialInterview, prefix, errors);
|
||||
if (item.IsPublished)
|
||||
{
|
||||
var referenceError = QuestionValidator.ValidateReferences(item.References, true);
|
||||
@@ -53,6 +60,9 @@ public static class CcsCaseValidator
|
||||
foreach (var response in item.OrderResponses)
|
||||
{
|
||||
if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}");
|
||||
if (response.TurnaroundMinutes < 0) errors.Add(prefix + $"order response {response.OrderCode} turnaroundMinutes cannot be negative");
|
||||
if (response.RequiresPatientVisit && string.IsNullOrWhiteSpace(response.PerformanceLocation))
|
||||
errors.Add(prefix + $"order response {response.OrderCode} requires a performanceLocation");
|
||||
if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection))
|
||||
errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid");
|
||||
}
|
||||
@@ -72,6 +82,10 @@ public static class CcsCaseValidator
|
||||
if (criterion.ActionType == "order" && !orderCodes.Contains(target)) errors.Add(prefix + $"criterion {criterion.Id} references unknown order {target}");
|
||||
if (criterion.ActionType == "exam" && !examCodes.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown exam {target}");
|
||||
if (criterion.ActionType == "location" && !item.AllowedLocations.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown location {target}");
|
||||
if (criterion.ActionType == "dialogue_choice"
|
||||
&& !item.InitialInterview.Topics.Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase))
|
||||
&& !item.InitialInterview.Nodes.SelectMany(value => value.Choices).Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase)))
|
||||
errors.Add(prefix + $"criterion {criterion.Id} references unknown dialogue choice {target}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +104,7 @@ public static class CcsCaseValidator
|
||||
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
|
||||
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
|
||||
if (!CommandTypes.Contains(request.Type)) return "unsupported command type";
|
||||
if ((request.Type is "exam" or "order" or "cancel_order" or "location") && string.IsNullOrWhiteSpace(request.Code)) return "code required";
|
||||
if ((request.Type is "exam" or "order" or "cancel_order" or "location" or "dialogue_choice") && string.IsNullOrWhiteSpace(request.Code)) return "code required";
|
||||
if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600";
|
||||
return null;
|
||||
}
|
||||
@@ -119,4 +133,96 @@ public static class CcsCaseValidator
|
||||
return "history and examinations cannot be mixed with order changes in one batch";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ValidateInterview(CcsInitialInterview interview, string prefix, List<string> errors)
|
||||
{
|
||||
if (interview.Topics.Count > 0)
|
||||
{
|
||||
if (interview.Nodes.Count > 0 || !string.IsNullOrWhiteSpace(interview.StartNodeId))
|
||||
errors.Add(prefix + "initialInterview cannot combine topics with nodes");
|
||||
if (string.IsNullOrWhiteSpace(interview.OpeningPatientText))
|
||||
errors.Add(prefix + "topic interview requires openingPatientText");
|
||||
if (string.IsNullOrWhiteSpace(interview.FinishChoiceText) || string.IsNullOrWhiteSpace(interview.ClosingPatientText))
|
||||
errors.Add(prefix + "topic interview requires finishChoiceText and closingPatientText");
|
||||
var topicIds = interview.Topics.Select(value => value.Id).ToList();
|
||||
if (topicIds.Any(string.IsNullOrWhiteSpace)
|
||||
|| topicIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != topicIds.Count)
|
||||
errors.Add(prefix + "interview topic ids must be present and unique");
|
||||
if (topicIds.Contains("finish_interview", StringComparer.OrdinalIgnoreCase))
|
||||
errors.Add(prefix + "finish_interview is a reserved interview topic id");
|
||||
if (!interview.Topics.Any(value => value.Required))
|
||||
errors.Add(prefix + "topic interview requires at least one required topic");
|
||||
foreach (var topic in interview.Topics)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(topic.ClinicianText))
|
||||
errors.Add(prefix + $"interview topic {topic.Id} requires clinicianText");
|
||||
if (string.IsNullOrWhiteSpace(topic.PatientText))
|
||||
errors.Add(prefix + $"interview topic {topic.Id} requires patientText");
|
||||
foreach (var prerequisite in topic.PrerequisiteTopicIds)
|
||||
if (!topicIds.Contains(prerequisite, StringComparer.OrdinalIgnoreCase)
|
||||
|| prerequisite.Equals(topic.Id, StringComparison.OrdinalIgnoreCase))
|
||||
errors.Add(prefix + $"interview topic {topic.Id} has an invalid prerequisite {prerequisite}");
|
||||
}
|
||||
var topicMap = interview.Topics.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
|
||||
var resolved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var resolving = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
bool ResolveTopic(string topicId)
|
||||
{
|
||||
if (resolving.Contains(topicId))
|
||||
{
|
||||
errors.Add(prefix + "interview topic prerequisites cannot contain cycles");
|
||||
return false;
|
||||
}
|
||||
if (resolved.Contains(topicId)) return true;
|
||||
resolving.Add(topicId);
|
||||
if (topicMap.TryGetValue(topicId, out var topic))
|
||||
foreach (var prerequisite in topic.PrerequisiteTopicIds.Where(topicMap.ContainsKey))
|
||||
ResolveTopic(prerequisite);
|
||||
resolving.Remove(topicId);
|
||||
resolved.Add(topicId);
|
||||
return true;
|
||||
}
|
||||
foreach (var topic in interview.Topics) ResolveTopic(topic.Id);
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(interview.StartNodeId) && interview.Nodes.Count == 0) return;
|
||||
if (string.IsNullOrWhiteSpace(interview.StartNodeId)) errors.Add(prefix + "initialInterview.startNodeId required");
|
||||
if (interview.Nodes.Count == 0) { errors.Add(prefix + "initialInterview.nodes required"); return; }
|
||||
var nodeIds = interview.Nodes.Select(value => value.Id).ToList();
|
||||
if (nodeIds.Any(string.IsNullOrWhiteSpace) || nodeIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != nodeIds.Count)
|
||||
errors.Add(prefix + "interview node ids must be present and unique");
|
||||
var nodeMap = interview.Nodes.Where(value => !string.IsNullOrWhiteSpace(value.Id))
|
||||
.GroupBy(value => value.Id, StringComparer.OrdinalIgnoreCase).ToDictionary(value => value.Key, value => value.First(), StringComparer.OrdinalIgnoreCase);
|
||||
if (!nodeMap.ContainsKey(interview.StartNodeId)) errors.Add(prefix + "interview start node does not exist");
|
||||
var choiceIds = interview.Nodes.SelectMany(value => value.Choices).Select(value => value.Id).ToList();
|
||||
if (choiceIds.Any(string.IsNullOrWhiteSpace) || choiceIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != choiceIds.Count)
|
||||
errors.Add(prefix + "interview choice ids must be present and globally unique");
|
||||
foreach (var node in interview.Nodes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.PatientText)) errors.Add(prefix + $"interview node {node.Id} requires patientText");
|
||||
if (node.IsTerminal && node.Choices.Count > 0) errors.Add(prefix + $"terminal interview node {node.Id} cannot have choices");
|
||||
if (!node.IsTerminal && node.Choices.Count == 0) errors.Add(prefix + $"nonterminal interview node {node.Id} requires choices");
|
||||
foreach (var choice in node.Choices)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(choice.Text)) errors.Add(prefix + $"interview choice {choice.Id} requires text");
|
||||
if (string.IsNullOrWhiteSpace(choice.NextNodeId) || !nodeMap.ContainsKey(choice.NextNodeId))
|
||||
errors.Add(prefix + $"interview choice {choice.Id} references an unknown next node");
|
||||
}
|
||||
}
|
||||
if (!nodeMap.ContainsKey(interview.StartNodeId)) return;
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var visiting = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
bool Walk(string nodeId)
|
||||
{
|
||||
if (visiting.Contains(nodeId)) { errors.Add(prefix + "interview graph cannot contain cycles"); return false; }
|
||||
if (visited.Contains(nodeId)) return true;
|
||||
visiting.Add(nodeId);
|
||||
if (nodeMap.TryGetValue(nodeId, out var node))
|
||||
foreach (var choice in node.Choices.Where(value => nodeMap.ContainsKey(value.NextNodeId))) Walk(choice.NextNodeId);
|
||||
visiting.Remove(nodeId); visited.Add(nodeId); return true;
|
||||
}
|
||||
Walk(interview.StartNodeId);
|
||||
if (visited.Count != nodeMap.Count) errors.Add(prefix + "every interview node must be reachable from startNodeId");
|
||||
if (!interview.Nodes.Any(value => value.IsTerminal)) errors.Add(prefix + "interview requires a terminal node");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class CcsPromotionCommand
|
||||
{
|
||||
public static async Task<int> RunAsync(string[] args, TextWriter output)
|
||||
{
|
||||
var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase);
|
||||
var sourceIndex = Array.FindIndex(args, value => value.Equals("--source", StringComparison.OrdinalIgnoreCase));
|
||||
var source = sourceIndex >= 0 && sourceIndex + 1 < args.Length
|
||||
? args[sourceIndex + 1]
|
||||
: Path.Combine(AppContext.BaseDirectory, "content", "ccs");
|
||||
var files = File.Exists(source)
|
||||
? new[] { source }
|
||||
: Directory.Exists(source)
|
||||
? Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories).ToArray()
|
||||
: [];
|
||||
if (files.Length == 0)
|
||||
{
|
||||
await output.WriteLineAsync($"CCS content not found: {source}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true };
|
||||
var request = new CcsImportRequest();
|
||||
foreach (var file in files)
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<CcsImportRequest>(await File.ReadAllTextAsync(file), json)
|
||||
?? throw new InvalidDataException($"Could not parse CCS content: {file}");
|
||||
request.Orders.AddRange(parsed.Orders);
|
||||
request.Cases.AddRange(parsed.Cases);
|
||||
}
|
||||
var duplicateOrders = request.Orders.GroupBy(value => value.Code, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(value => value.Count() > 1).Select(value => value.Key).ToList();
|
||||
if (duplicateOrders.Count > 0)
|
||||
throw new InvalidDataException($"Duplicate CCS order codes: {string.Join(", ", duplicateOrders)}");
|
||||
|
||||
var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables().Build();
|
||||
var mongo = new MongoDatabaseProvider(configuration);
|
||||
var orders = mongo.Database.GetCollection<CcsOrderDefinition>("CcsOrderCatalog");
|
||||
var cases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
|
||||
var existingCodes = (await orders.Find(Builders<CcsOrderDefinition>.Filter.Empty)
|
||||
.Project(value => value.Code).ToListAsync()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var errors = CcsCaseValidator.ValidateImport(request, existingCodes);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
foreach (var error in errors) await output.WriteLineAsync(error);
|
||||
return 1;
|
||||
}
|
||||
await output.WriteLineAsync($"Validated {request.Cases.Count} CCS case(s) and {request.Orders.Count} order(s).");
|
||||
if (!apply)
|
||||
{
|
||||
await output.WriteLineAsync("Dry run only. Pass --apply to publish.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var order in request.Orders)
|
||||
{
|
||||
order.Code = order.Code.Trim().ToLowerInvariant();
|
||||
order.UpdatedUtc = DateTime.UtcNow;
|
||||
await orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
var created = 0;
|
||||
var unchanged = 0;
|
||||
foreach (var incoming in request.Cases)
|
||||
{
|
||||
var current = await cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
|
||||
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
|
||||
if (current is not null && CcsStore.CaseContentDocument(current).Equals(CcsStore.CaseContentDocument(incoming)))
|
||||
{
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
if (current is not null)
|
||||
await cases.UpdateOneAsync(value => value.Id == current.Id,
|
||||
Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
|
||||
incoming.Id = null;
|
||||
incoming.Version = (current?.Version ?? 0) + 1;
|
||||
incoming.IsCurrent = true;
|
||||
incoming.CreatedUtc = DateTime.UtcNow;
|
||||
await cases.InsertOneAsync(incoming);
|
||||
created++;
|
||||
}
|
||||
await output.WriteLineAsync($"Promotion complete: {created} created, {unchanged} unchanged.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,16 @@ public sealed class CcsStore
|
||||
await _orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
var created = 0;
|
||||
var skipped = 0;
|
||||
foreach (var incoming in request.Cases)
|
||||
{
|
||||
var current = await _cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
|
||||
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
|
||||
if (current is not null && CaseContentDocument(current).Equals(CaseContentDocument(incoming)))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (current is not null)
|
||||
await _cases.UpdateOneAsync(value => value.Id == current.Id, Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
|
||||
incoming.Id = null;
|
||||
@@ -63,7 +69,17 @@ public sealed class CcsStore
|
||||
await _cases.InsertOneAsync(incoming);
|
||||
created++;
|
||||
}
|
||||
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created };
|
||||
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created, CasesSkipped = skipped };
|
||||
}
|
||||
|
||||
internal static BsonDocument CaseContentDocument(CcsCaseRevision value)
|
||||
{
|
||||
var document = value.ToBsonDocument();
|
||||
document.Remove("_id");
|
||||
document.Remove(nameof(CcsCaseRevision.Version));
|
||||
document.Remove(nameof(CcsCaseRevision.IsCurrent));
|
||||
document.Remove(nameof(CcsCaseRevision.CreatedUtc));
|
||||
return document;
|
||||
}
|
||||
|
||||
public async Task<List<CcsCaseSummary>> ListCasesAsync() =>
|
||||
@@ -108,6 +124,8 @@ public sealed class CcsStore
|
||||
}
|
||||
if (definition is null) throw new KeyNotFoundException("CCS case not found.");
|
||||
var now = DateTime.UtcNow;
|
||||
var interview = EffectiveInterview(definition);
|
||||
var interviewStartNodeId = definition.InitialInterview.Topics.Count > 0 ? "opening" : interview.StartNodeId;
|
||||
var attempt = new CcsAttempt
|
||||
{
|
||||
UserId = userId,
|
||||
@@ -117,6 +135,7 @@ public sealed class CcsStore
|
||||
CaseVersion = definition.Version,
|
||||
Mode = CcsAttemptModes.Timed,
|
||||
OpeningCompleted = false,
|
||||
InterviewNodeId = interviewStartNodeId,
|
||||
Location = definition.PresentingLocation,
|
||||
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
|
||||
VitalHistory =
|
||||
@@ -124,9 +143,11 @@ public sealed class CcsStore
|
||||
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
|
||||
],
|
||||
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
|
||||
HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes),
|
||||
RatingEligible = true,
|
||||
DeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? null : now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
|
||||
HardDeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? null : now.AddMinutes(definition.RealTimeMinutes),
|
||||
RatingEligible = definition.ExperienceMode != CcsExperienceModes.GraphicalInterview,
|
||||
Chart =
|
||||
[
|
||||
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
|
||||
@@ -227,6 +248,7 @@ public sealed class CcsStore
|
||||
case "acknowledge_opening":
|
||||
attempt.OpeningCompleted = true;
|
||||
break;
|
||||
case "dialogue_choice": ApplyDialogueChoice(attempt, definition, command.Code, now); break;
|
||||
case "end_case": EnterFinalOrders(attempt); break;
|
||||
case "exit_case": await CompleteAsync(attempt); break;
|
||||
}
|
||||
@@ -389,6 +411,23 @@ public sealed class CcsStore
|
||||
{
|
||||
var definition = await DefinitionAsync(attempt);
|
||||
var completed = attempt.State == CcsAttemptStates.Completed;
|
||||
var optionCodes = definition.OrderResponses.Select(response => response.OrderCode).ToList();
|
||||
List<CcsOrderDefinition> optionCatalog = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? await _orders.Find(value => optionCodes.Contains(value.Code)).ToListAsync()
|
||||
: [];
|
||||
var orderOptions = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? definition.OrderResponses.Select(response =>
|
||||
{
|
||||
var catalog = optionCatalog.FirstOrDefault(value => value.Code.Equals(response.OrderCode, StringComparison.OrdinalIgnoreCase));
|
||||
return new CcsOrderOption
|
||||
{
|
||||
Code = response.OrderCode,
|
||||
TurnaroundMinutes = response.TurnaroundMinutes ?? catalog?.DefaultTurnaroundMinutes ?? 0,
|
||||
PerformanceLocation = PerformanceLocation(response, catalog, attempt.Location),
|
||||
RequiresPatientVisit = response.RequiresPatientVisit
|
||||
};
|
||||
}).ToList()
|
||||
: [];
|
||||
List<CcsOrderDefinition> activeCatalog = attempt.ActiveOrderCodes.Count == 0
|
||||
? []
|
||||
: await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync();
|
||||
@@ -401,44 +440,105 @@ public sealed class CcsStore
|
||||
var reported = attempt.Chart.LastOrDefault(value =>
|
||||
value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase)
|
||||
&& value.Category is not CcsChartCategories.Orders);
|
||||
var response = definition.OrderResponses.FirstOrDefault(value =>
|
||||
value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase));
|
||||
var performanceLocation = pending?.PerformanceLocation ?? PerformanceLocation(response, order, attempt.Location);
|
||||
var requiresPatientVisit = pending?.RequiresPatientVisit ?? response?.RequiresPatientVisit == true;
|
||||
return new CcsActiveOrder
|
||||
{
|
||||
Code = order.Code, Name = order.CanonicalName, Category = order.Category,
|
||||
Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute,
|
||||
ReportMinute = pending is not null ? null : reported?.SimulatedMinute,
|
||||
DueMinute = pending?.DueMinute, PerformanceLocation = performanceLocation,
|
||||
RequiresPatientVisit = requiresPatientVisit,
|
||||
Status = pending is not null ? (requiresPatientVisit ? "scheduled" : "pending")
|
||||
: reported is not null ? "completed" : "active",
|
||||
IsPending = pending is not null, HasResult = reported is not null
|
||||
};
|
||||
}).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList();
|
||||
List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0
|
||||
? attempt.VitalHistory
|
||||
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
|
||||
CcsInterviewStateResponse? interview = null;
|
||||
var currentInterviewNode = CurrentInterviewNode(attempt, definition);
|
||||
var openingCompleted = attempt.OpeningCompleted ?? true;
|
||||
if (definition.InitialInterview.Topics.Count > 0 && currentInterviewNode?.IsTerminal != true)
|
||||
openingCompleted = false;
|
||||
if (currentInterviewNode is not null)
|
||||
{
|
||||
interview = new CcsInterviewStateResponse
|
||||
{
|
||||
CurrentNodeId = currentInterviewNode.Id,
|
||||
CurrentNode = currentInterviewNode,
|
||||
VisitedChoiceIds = attempt.InterviewChoiceIds,
|
||||
Completed = openingCompleted
|
||||
};
|
||||
}
|
||||
return new CcsAttemptResponse
|
||||
{
|
||||
Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId,
|
||||
CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}",
|
||||
Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision,
|
||||
SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc,
|
||||
OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location,
|
||||
OpeningCompleted = openingCompleted, Location = attempt.Location,
|
||||
DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc,
|
||||
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
|
||||
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
|
||||
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
|
||||
ExperienceMode = definition.ExperienceMode, GraphicalPresentation = definition.GraphicalPresentation,
|
||||
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
|
||||
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
|
||||
HistorySections = definition.InitialChart.HistorySections,
|
||||
Appearance = definition.InitialChart.Appearance,
|
||||
History = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
|
||||
? "" : definition.InitialChart.History,
|
||||
HistorySections = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
|
||||
? [] : definition.InitialChart.HistorySections,
|
||||
MedicalHistory = MedicalHistory(definition),
|
||||
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
|
||||
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
|
||||
ActiveOrders = activeOrders, OrderOptions = orderOptions, VitalHistory = vitalHistory,
|
||||
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
|
||||
{
|
||||
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title
|
||||
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title,
|
||||
PerformanceLocation = value.PerformanceLocation, RequiresPatientVisit = value.RequiresPatientVisit
|
||||
}).ToList(),
|
||||
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
|
||||
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
|
||||
Interview = interview,
|
||||
InterviewDebrief = completed && definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? definition.InitialInterview.Debrief : null,
|
||||
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
|
||||
References = completed ? definition.References : []
|
||||
};
|
||||
}
|
||||
|
||||
public static void ApplyDialogueChoice(CcsAttempt attempt, CcsCaseRevision definition, string choiceId, DateTime now)
|
||||
{
|
||||
if (definition.InitialInterview.Topics.Count > 0)
|
||||
{
|
||||
if (attempt.OpeningCompleted == true
|
||||
&& attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("The initial interview is already complete.");
|
||||
// Attempts that passed through the legacy classic opening can have
|
||||
// this flag set before any graphical topic was completed.
|
||||
attempt.OpeningCompleted = false;
|
||||
ApplyTopicChoice(attempt, definition.InitialInterview, choiceId, now);
|
||||
return;
|
||||
}
|
||||
if (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete.");
|
||||
var interview = EffectiveInterview(definition);
|
||||
var node = interview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException("The interactive interview is unavailable.");
|
||||
var choice = node.Choices.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
|
||||
var next = interview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(choice.NextNodeId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException("The dialogue choice target is unavailable.");
|
||||
attempt.Actions.Add(Action("dialogue_choice", choice.Id, attempt.SimulatedMinute, now));
|
||||
attempt.InterviewChoiceIds.Add(choice.Id);
|
||||
attempt.InterviewNodeId = next.Id;
|
||||
if (next.IsTerminal) attempt.OpeningCompleted = true;
|
||||
}
|
||||
|
||||
public async Task AbandonAsync(CcsAttempt attempt)
|
||||
{
|
||||
attempt.State = CcsAttemptStates.Abandoned;
|
||||
@@ -451,23 +551,31 @@ public sealed class CcsStore
|
||||
{
|
||||
code = code.Trim().ToLowerInvariant();
|
||||
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code.");
|
||||
if (catalog.AvailableLocations.Count > 0 && !catalog.AvailableLocations.Contains(attempt.Location, StringComparer.OrdinalIgnoreCase))
|
||||
var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
|
||||
if (definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && response is null)
|
||||
throw new InvalidOperationException("That order is not configured for this case.");
|
||||
if (response is null && catalog.AvailableLocations.Count > 0 && !catalog.AvailableLocations.Contains(attempt.Location, StringComparer.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("That order is unavailable at the current location.");
|
||||
attempt.Actions.Add(Action("order", code, attempt.SimulatedMinute, now));
|
||||
if (!attempt.ActiveOrderCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) attempt.ActiveOrderCodes.Add(code);
|
||||
var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
|
||||
var delay = response?.TurnaroundMinutes ?? catalog.DefaultTurnaroundMinutes;
|
||||
var performanceLocation = PerformanceLocation(response, catalog, attempt.Location);
|
||||
var requiresPatientVisit = response?.RequiresPatientVisit == true;
|
||||
var resultCategory = ResultCategory(response?.ResultSection, catalog.ResultSection, catalog.Category);
|
||||
if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0))
|
||||
attempt.PendingResults.Add(new CcsPendingResult
|
||||
{
|
||||
OrderCode = code, Category = resultCategory, Title = catalog.CanonicalName,
|
||||
DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches
|
||||
DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches,
|
||||
PerformanceLocation = performanceLocation, RequiresPatientVisit = requiresPatientVisit
|
||||
});
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName,
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}"
|
||||
SimulatedMinute = attempt.SimulatedMinute,
|
||||
Text = requiresPatientVisit
|
||||
? $"Scheduled: {catalog.CanonicalName} at {Humanize(performanceLocation)}; expected in {delay} minutes."
|
||||
: $"Ordered: {catalog.CanonicalName}"
|
||||
});
|
||||
if (delay == 0 && processImmediately) ProcessDue(attempt);
|
||||
}
|
||||
@@ -532,6 +640,13 @@ public sealed class CcsStore
|
||||
var vitalsChanged = false;
|
||||
foreach (var result in due)
|
||||
{
|
||||
if (result.RequiresPatientVisit)
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Progress, Code = $"{result.OrderCode}_completed", Title = "Patient returned",
|
||||
SimulatedMinute = attempt.SimulatedMinute,
|
||||
Text = $"The patient completed {result.Title} at {Humanize(result.PerformanceLocation)} and returned."
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(result.Result))
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
@@ -545,6 +660,13 @@ public sealed class CcsStore
|
||||
if (vitalsChanged) SnapshotVitals(attempt);
|
||||
}
|
||||
|
||||
private static string PerformanceLocation(CcsCaseOrderDefinition? response, CcsOrderDefinition? catalog, string currentLocation)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(response?.PerformanceLocation)) return response.PerformanceLocation;
|
||||
if (catalog?.AvailableLocations.Contains(currentLocation, StringComparer.OrdinalIgnoreCase) == true) return currentLocation;
|
||||
return catalog?.AvailableLocations.FirstOrDefault() ?? currentLocation;
|
||||
}
|
||||
|
||||
private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition)
|
||||
{
|
||||
var activeOrders = attempt.Actions.Where(value => value.Type == "order").Select(value => value.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -693,8 +815,137 @@ public sealed class CcsStore
|
||||
{
|
||||
ExternalId = value.ExternalId, Title = value.Title, System = value.System,
|
||||
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
|
||||
Difficulty = value.Difficulty, Tags = value.Tags
|
||||
Difficulty = value.Difficulty, Tags = value.Tags,
|
||||
InteractiveInterviewAvailable = value.InitialInterview.Topics.Count > 0 || EffectiveInterview(value).Nodes.Count > 0,
|
||||
GraphicalPresentationAvailable = !string.IsNullOrWhiteSpace(value.GraphicalPresentation.SceneKey)
|
||||
&& !string.IsNullOrWhiteSpace(value.GraphicalPresentation.PatientAssetKey),
|
||||
ExperienceMode = value.ExperienceMode, GraphicalPresentation = value.GraphicalPresentation
|
||||
};
|
||||
|
||||
internal static CcsDialogueNode? CurrentInterviewNode(CcsAttempt attempt, CcsCaseRevision definition)
|
||||
{
|
||||
var topicInterview = definition.InitialInterview;
|
||||
if (topicInterview.Topics.Count > 0)
|
||||
{
|
||||
if (attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
|
||||
return new CcsDialogueNode
|
||||
{
|
||||
Id = "complete", PatientText = topicInterview.ClosingPatientText, IsTerminal = true
|
||||
};
|
||||
var patientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase)
|
||||
? topicInterview.OpeningPatientText
|
||||
: topicInterview.Topics.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))?.PatientText;
|
||||
if (string.IsNullOrWhiteSpace(patientText)) return null;
|
||||
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var choices = topicInterview.Topics.Where(value => !visited.Contains(value.Id)
|
||||
&& value.PrerequisiteTopicIds.All(visited.Contains))
|
||||
.Select(value => new CcsDialogueChoice
|
||||
{
|
||||
Id = value.Id, Text = value.ClinicianText, NextNodeId = value.Id
|
||||
}).ToList();
|
||||
if (topicInterview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
|
||||
choices.Add(new CcsDialogueChoice
|
||||
{
|
||||
Id = "finish_interview", Text = topicInterview.FinishChoiceText, NextNodeId = "complete"
|
||||
});
|
||||
return new CcsDialogueNode
|
||||
{
|
||||
Id = attempt.InterviewNodeId, PatientText = patientText,
|
||||
SuppressPatientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase),
|
||||
Choices = choices
|
||||
};
|
||||
}
|
||||
var interview = EffectiveInterview(definition);
|
||||
return interview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static void ApplyTopicChoice(CcsAttempt attempt, CcsInitialInterview interview, string choiceId, DateTime now)
|
||||
{
|
||||
if (choiceId.Equals("finish_interview", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
if (!interview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
|
||||
throw new ArgumentException("Complete the core interview topics before finishing.");
|
||||
attempt.Actions.Add(Action("dialogue_choice", "finish_interview", attempt.SimulatedMinute, now));
|
||||
attempt.InterviewChoiceIds.Add("finish_interview");
|
||||
attempt.InterviewNodeId = "complete";
|
||||
attempt.OpeningCompleted = true;
|
||||
return;
|
||||
}
|
||||
var topic = interview.Topics.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
|
||||
if (attempt.InterviewChoiceIds.Contains(topic.Id, StringComparer.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("That interview topic has already been covered.");
|
||||
if (!topic.PrerequisiteTopicIds.All(value => attempt.InterviewChoiceIds.Contains(value, StringComparer.OrdinalIgnoreCase)))
|
||||
throw new ArgumentException("That interview topic is not available yet.");
|
||||
attempt.Actions.Add(Action("dialogue_choice", topic.Id, attempt.SimulatedMinute, now));
|
||||
attempt.InterviewChoiceIds.Add(topic.Id);
|
||||
attempt.InterviewNodeId = topic.Id;
|
||||
}
|
||||
|
||||
internal static CcsInitialInterview EffectiveInterview(CcsCaseRevision definition)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(definition.InitialInterview.StartNodeId)
|
||||
&& definition.InitialInterview.Nodes.Count > 0)
|
||||
return definition.InitialInterview;
|
||||
|
||||
var narrative = definition.InitialChart.HistorySections
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value.Text))
|
||||
.Select((value, index) => new
|
||||
{
|
||||
Id = $"history_{index + 1}",
|
||||
Prompt = string.IsNullOrWhiteSpace(value.Title)
|
||||
? "Can you tell me more about your history?"
|
||||
: $"Can you tell me about your {value.Title.Trim().ToLowerInvariant()}?",
|
||||
value.Text
|
||||
}).ToList();
|
||||
if (narrative.Count == 0 && !string.IsNullOrWhiteSpace(definition.InitialChart.History))
|
||||
narrative.Add(new { Id = "history_1", Prompt = "Can you tell me more about your history?", Text = definition.InitialChart.History });
|
||||
|
||||
var nodes = new List<CcsDialogueNode>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "opening", PatientText = "Hello, doctor.", SuppressPatientText = true,
|
||||
Choices = [new() { Id = "ask_reason", Text = "What brings you in today?", NextNodeId = "reason" }]
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "reason", PatientText = definition.InitialChart.ReasonForEncounter,
|
||||
Choices = [new()
|
||||
{
|
||||
Id = narrative.Count > 0 ? "ask_history_1" : "finish_interview",
|
||||
Text = narrative.Count > 0 ? narrative[0].Prompt : "Is there anything else you would like me to know?",
|
||||
NextNodeId = narrative.Count > 0 ? narrative[0].Id : "complete"
|
||||
}]
|
||||
}
|
||||
};
|
||||
for (var index = 0; index < narrative.Count; index++)
|
||||
{
|
||||
var next = index + 1 < narrative.Count ? narrative[index + 1] : null;
|
||||
nodes.Add(new CcsDialogueNode
|
||||
{
|
||||
Id = narrative[index].Id, PatientText = narrative[index].Text,
|
||||
Choices = [new()
|
||||
{
|
||||
Id = next is null ? "finish_interview" : $"ask_history_{index + 2}",
|
||||
Text = next?.Prompt ?? "Is there anything else you would like me to know?",
|
||||
NextNodeId = next?.Id ?? "complete"
|
||||
}]
|
||||
});
|
||||
}
|
||||
nodes.Add(new CcsDialogueNode
|
||||
{
|
||||
Id = "complete", PatientText = "That is everything I can think of, doctor.", IsTerminal = true
|
||||
});
|
||||
return new CcsInitialInterview { StartNodeId = "opening", Nodes = nodes };
|
||||
}
|
||||
|
||||
internal static string MedicalHistory(CcsCaseRevision definition) =>
|
||||
definition.InitialChart.HistorySections.FirstOrDefault(value =>
|
||||
value.Code.Equals("past_medical_history", StringComparison.OrdinalIgnoreCase))?.Text ?? "";
|
||||
internal static int Rank(CcsOrderDefinition order, string query)
|
||||
{
|
||||
var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant());
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class CreditLedgerReconciliationService(
|
||||
CreditStore credits,
|
||||
ILogger<CreditLedgerReconciliationService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await credits.ReconcilePendingAsync(stoppingToken); }
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; }
|
||||
catch (Exception exception) { logger.LogError(exception, "Credit ledger reconciliation failed."); }
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,43 +7,167 @@ namespace QuestionsApi.Services;
|
||||
public sealed class CreditStore
|
||||
{
|
||||
private readonly IMongoCollection<UserCreditAccount> _accounts;
|
||||
private readonly IMongoCollection<CreditLedgerEntry> _ledger;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
|
||||
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
|
||||
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock, ServiceMetrics metrics)
|
||||
{
|
||||
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
|
||||
_ledger = mongo.Database.GetCollection<CreditLedgerEntry>("CreditLedgerEntries");
|
||||
_clock = clock;
|
||||
_metrics = metrics;
|
||||
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
|
||||
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
|
||||
_ledger.Indexes.CreateOne(new CreateIndexModel<CreditLedgerEntry>(
|
||||
Builders<CreditLedgerEntry>.IndexKeys.Ascending(value => value.UserId)
|
||||
.Descending(value => value.CreatedUtc).Descending(value => value.Id),
|
||||
new CreateIndexOptions { Name = "credit_ledger_user_time" }));
|
||||
}
|
||||
|
||||
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
|
||||
public async Task<CreditAwardResult> AwardAsync(string userId, CreditAwardRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attemptId)) return false;
|
||||
if (string.IsNullOrWhiteSpace(request.OperationId) || request.Amount <= 0)
|
||||
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
|
||||
var filters = Builders<UserCreditAccount>.Filter;
|
||||
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
|
||||
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
|
||||
var entryId = LedgerId(userId, request.OperationId);
|
||||
var entry = await _ledger.Find(value => value.Id == entryId).FirstOrDefaultAsync();
|
||||
if (entry is not null && (entry.ReasonCode != request.ReasonCode || entry.SourceId != request.SourceId))
|
||||
throw new InvalidOperationException("The credit operation ID was already used for another event.");
|
||||
if (entry is null)
|
||||
{
|
||||
var alreadyApplied = await _accounts.Find(value => value.UserId == userId
|
||||
&& value.AppliedOperations.Contains(request.OperationId)).AnyAsync();
|
||||
if (alreadyApplied) return new CreditAwardResult { Status = await GetStatusAsync(userId) };
|
||||
entry = new CreditLedgerEntry
|
||||
{
|
||||
new BsonDocument("$size", "$RecentEarnings"),
|
||||
CreditEconomy.RollingEarningLimit
|
||||
})));
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.ElemMatch(value => value.RecentEarnings,
|
||||
earning => earning.OperationId == attemptId)) &
|
||||
belowCap;
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.Credits, 1)
|
||||
.Push(value => value.RecentEarnings, new CreditEvent
|
||||
{
|
||||
OperationId = attemptId,
|
||||
Id = entryId, UserId = userId, OperationId = request.OperationId,
|
||||
RequestedAmount = request.Amount, Amount = request.SubjectToRollingCap ? 0 : request.Amount,
|
||||
ReasonCode = request.ReasonCode,
|
||||
Description = request.Description, SourceType = request.SourceType,
|
||||
SourceId = request.SourceId, SubjectToRollingCap = request.SubjectToRollingCap,
|
||||
CreatedUtc = now
|
||||
})
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
};
|
||||
try { await _ledger.InsertOneAsync(entry); }
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{ entry = await _ledger.Find(value => value.Id == entryId).FirstAsync(); }
|
||||
}
|
||||
if (entry.State == "applied")
|
||||
{
|
||||
_metrics.CreditDuplicate(request.ReasonCode);
|
||||
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
|
||||
}
|
||||
|
||||
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
if (account.AppliedOperations.Contains(request.OperationId))
|
||||
{
|
||||
var appliedAmount = AppliedAmount(account, entry);
|
||||
entry.Amount = appliedAmount;
|
||||
await MarkLedgerAppliedAsync(entry, appliedAmount, now);
|
||||
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
|
||||
}
|
||||
var amount = request.Amount;
|
||||
var appliedNow = false;
|
||||
while (true)
|
||||
{
|
||||
var earned = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount));
|
||||
amount = request.SubjectToRollingCap
|
||||
? Math.Min(request.Amount, Math.Max(0, CreditEconomy.RollingEarningLimit - earned))
|
||||
: request.Amount;
|
||||
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId)
|
||||
& Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [request.OperationId]);
|
||||
if (request.SubjectToRollingCap && amount > 0)
|
||||
filter &= RollingEarnedAtMost(CreditEconomy.RollingEarningLimit - amount);
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.AddToSet(value => value.AppliedOperations, request.OperationId)
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
if (amount > 0)
|
||||
{
|
||||
update = update.Inc(value => value.Credits, amount);
|
||||
if (request.SubjectToRollingCap)
|
||||
update = update.Push(value => value.RecentEarnings,
|
||||
new CreditEvent { OperationId = request.OperationId, CoinAmount = amount, CreatedUtc = now });
|
||||
}
|
||||
if ((await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1)
|
||||
{
|
||||
appliedNow = true;
|
||||
break;
|
||||
}
|
||||
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
if (account.AppliedOperations.Contains(request.OperationId))
|
||||
{
|
||||
amount = AppliedAmount(account, entry);
|
||||
break;
|
||||
}
|
||||
}
|
||||
entry.Amount = amount;
|
||||
await MarkLedgerAppliedAsync(entry, amount, now);
|
||||
if (appliedNow && amount > 0) _metrics.CreditAward(request.ReasonCode, amount);
|
||||
if (appliedNow && request.SubjectToRollingCap && amount < request.Amount)
|
||||
_metrics.CreditCapped(request.ReasonCode);
|
||||
var status = await GetStatusAsync(userId);
|
||||
return appliedNow ? Result(entry, status) : new CreditAwardResult { Status = status };
|
||||
}
|
||||
|
||||
public async Task InitializeMasteryBaselineAsync(string userId, IEnumerable<string> operationIds)
|
||||
{
|
||||
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.Set(value => value.MasteryAwardBaselineApplied, true);
|
||||
foreach (var operationId in operationIds.Distinct(StringComparer.Ordinal))
|
||||
update = update.AddToSet(value => value.AppliedOperations, operationId);
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId && !value.MasteryAwardBaselineApplied, update);
|
||||
}
|
||||
|
||||
public async Task<bool> IsMasteryBaselineAppliedAsync(string userId)
|
||||
{
|
||||
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
|
||||
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).MasteryAwardBaselineApplied;
|
||||
}
|
||||
|
||||
public async Task<HashSet<string>> GetAppliedOperationsAsync(string userId)
|
||||
{
|
||||
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
|
||||
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
return account.AppliedOperations.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public async Task<CreditLedgerPage> GetLedgerAsync(string userId, int limit, DateTime? before)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 50);
|
||||
var filter = Builders<CreditLedgerEntry>.Filter.Eq(value => value.UserId, userId)
|
||||
& Builders<CreditLedgerEntry>.Filter.Eq(value => value.State, "applied")
|
||||
& Builders<CreditLedgerEntry>.Filter.Ne(value => value.Amount, 0);
|
||||
if (before.HasValue) filter &= Builders<CreditLedgerEntry>.Filter.Lt(value => value.CreatedUtc, before.Value);
|
||||
var entries = await _ledger.Find(filter).SortByDescending(value => value.CreatedUtc)
|
||||
.ThenByDescending(value => value.Id).Limit(limit + 1).ToListAsync();
|
||||
var hasMore = entries.Count > limit;
|
||||
if (hasMore) entries.RemoveAt(entries.Count - 1);
|
||||
return new CreditLedgerPage
|
||||
{
|
||||
Items = entries.Select(value => new CreditLedgerEntryResponse
|
||||
{
|
||||
Amount = value.Amount, ReasonCode = value.ReasonCode,
|
||||
Description = value.Description, CreatedUtc = value.CreatedUtc
|
||||
}).ToList(),
|
||||
NextBefore = hasMore && entries.Count > 0 ? entries[^1].CreatedUtc : null
|
||||
};
|
||||
}
|
||||
|
||||
public async Task ReconcilePendingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pending = await _ledger.Find(value => value.State == "pending")
|
||||
.SortBy(value => value.CreatedUtc).Limit(100).ToListAsync(cancellationToken);
|
||||
foreach (var entry in pending)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await AwardAsync(entry.UserId, new CreditAwardRequest(
|
||||
entry.OperationId, entry.RequestedAmount, entry.ReasonCode, entry.Description,
|
||||
entry.SourceType, entry.SourceId, entry.SubjectToRollingCap));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CreditStatusResponse> GetStatusAsync(string userId)
|
||||
@@ -54,6 +178,16 @@ public sealed class CreditStore
|
||||
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);
|
||||
@@ -68,9 +202,12 @@ public sealed class CreditStore
|
||||
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
var prior = existing.Purchases.FirstOrDefault(value => value.OperationId == operationId);
|
||||
if (prior is not null)
|
||||
return Response(prior.Sku == product.Sku
|
||||
? CreditOperationResult.Succeeded
|
||||
: CreditOperationResult.OperationConflict, existing);
|
||||
{
|
||||
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) &
|
||||
@@ -95,14 +232,21 @@ public sealed class CreditStore
|
||||
{
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
|
||||
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)
|
||||
return Response(prior.Sku == product.Sku
|
||||
? CreditOperationResult.Succeeded
|
||||
: CreditOperationResult.OperationConflict, account);
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -223,6 +367,7 @@ public sealed class CreditStore
|
||||
.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, [])
|
||||
@@ -273,9 +418,9 @@ public sealed class CreditStore
|
||||
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
|
||||
{
|
||||
Credits = account.Credits,
|
||||
EarnedInRollingWindow = account.RecentEarnings.Count,
|
||||
EarnedInRollingWindow = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount)),
|
||||
RemainingEarningCapacity = Math.Max(
|
||||
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count),
|
||||
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount))),
|
||||
ExamTokens = account.ExamTokens,
|
||||
QBankResetTokens = account.QBankResetTokens,
|
||||
BankCycle = account.BankCycle
|
||||
@@ -297,4 +442,51 @@ public sealed class CreditStore
|
||||
CoinsWon = spinEvent?.CoinsWon ?? 0,
|
||||
Status = ToStatus(account)
|
||||
};
|
||||
|
||||
private static string LedgerId(string userId, string operationId) => $"{userId}:{operationId}";
|
||||
|
||||
private static int AppliedAmount(UserCreditAccount account, CreditLedgerEntry entry) =>
|
||||
entry.SubjectToRollingCap
|
||||
? account.RecentEarnings.FirstOrDefault(value => value.OperationId == entry.OperationId)?.CoinAmount ?? 0
|
||||
: entry.RequestedAmount;
|
||||
|
||||
private static FilterDefinition<UserCreditAccount> RollingEarnedAtMost(int maximum) =>
|
||||
new BsonDocumentFilterDefinition<UserCreditAccount>(new BsonDocument("$expr",
|
||||
new BsonDocument("$lte", new BsonArray
|
||||
{
|
||||
new BsonDocument("$sum", new BsonDocument("$map", new BsonDocument
|
||||
{
|
||||
{ "input", new BsonDocument("$ifNull", new BsonArray { "$RecentEarnings", new BsonArray() }) },
|
||||
{ "as", "earning" },
|
||||
{ "in", new BsonDocument("$ifNull", new BsonArray { "$$earning.CoinAmount", 1 }) }
|
||||
})),
|
||||
maximum
|
||||
})));
|
||||
|
||||
private Task MarkLedgerAppliedAsync(CreditLedgerEntry entry, int amount, DateTime now) =>
|
||||
_ledger.UpdateOneAsync(value => value.Id == entry.Id,
|
||||
Builders<CreditLedgerEntry>.Update.Set(value => value.Amount, amount)
|
||||
.Set(value => value.State, "applied").Set(value => value.AppliedUtc, now));
|
||||
|
||||
private Task RecordPurchaseLedgerAsync(string userId, RzStoreProduct product, string operationId, DateTime createdUtc) =>
|
||||
_ledger.ReplaceOneAsync(
|
||||
value => value.Id == LedgerId(userId, $"purchase:{operationId}"),
|
||||
new CreditLedgerEntry
|
||||
{
|
||||
Id = LedgerId(userId, $"purchase:{operationId}"), UserId = userId,
|
||||
OperationId = $"purchase:{operationId}", RequestedAmount = -product.CoinPrice,
|
||||
Amount = -product.CoinPrice, ReasonCode = CreditReasonCodes.Purchase,
|
||||
Description = $"Purchased {product.Name}", SourceType = "store_product",
|
||||
SourceId = product.Sku, State = "applied", CreatedUtc = createdUtc, AppliedUtc = createdUtc
|
||||
},
|
||||
new ReplaceOptions { IsUpsert = true });
|
||||
|
||||
private static CreditAwardResult Result(CreditLedgerEntry entry, CreditStatusResponse status) => new()
|
||||
{
|
||||
Award = entry.Amount <= 0 ? null : new CreditAward
|
||||
{
|
||||
Amount = entry.Amount, ReasonCode = entry.ReasonCode, Description = entry.Description
|
||||
},
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class DailyMissionStore
|
||||
{
|
||||
private readonly IMongoCollection<DailyMissionSet> _sets;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
|
||||
public DailyMissionStore(MongoDatabaseProvider mongo, CreditStore credits, TimeProvider clock, ServiceMetrics metrics)
|
||||
{
|
||||
_sets = mongo.Database.GetCollection<DailyMissionSet>("DailyMissionSets");
|
||||
_credits = credits;
|
||||
_clock = clock;
|
||||
_metrics = metrics;
|
||||
_sets.Indexes.CreateOne(new CreateIndexModel<DailyMissionSet>(
|
||||
Builders<DailyMissionSet>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.DateUtc),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_daily_mission_user_date" }));
|
||||
}
|
||||
|
||||
public async Task<DailyMissionEnvelope> GetOrCreateAsync(
|
||||
string userId, DashboardResponse dashboard, IReadOnlyCollection<Question> published)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await RecordExpiredAsync(userId, now);
|
||||
var key = DateKey(now);
|
||||
var existing = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
|
||||
if (existing is not null) return Envelope(existing);
|
||||
var candidates = BuildCandidates(dashboard, published, now);
|
||||
if (candidates.Count < 3) throw new InvalidOperationException("Not enough eligible question-bank coverage to create daily missions.");
|
||||
var set = new DailyMissionSet
|
||||
{
|
||||
Id = $"{userId}|{key}", UserId = userId, DateUtc = key,
|
||||
Missions = candidates.Take(3).ToList(), ReplacementCandidates = candidates.Skip(3).ToList(),
|
||||
CreatedUtc = now, ExpiresUtc = now.Date.AddDays(1)
|
||||
};
|
||||
try
|
||||
{
|
||||
await _sets.InsertOneAsync(set);
|
||||
foreach (var mission in set.Missions) _metrics.DailyMissionAssigned(mission.Type);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstAsync();
|
||||
}
|
||||
return Envelope(set);
|
||||
}
|
||||
|
||||
public async Task<DailyMissionEnvelope?> ReplaceAsync(string userId, string missionId)
|
||||
{
|
||||
var key = DateKey(_clock.GetUtcNow().UtcDateTime);
|
||||
var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
|
||||
if (set is null) return null;
|
||||
if (set.ReplacementUsed) throw new InvalidOperationException("Today's mission replacement has already been used.");
|
||||
var index = set.Missions.FindIndex(value => value.Id == missionId);
|
||||
if (index < 0) throw new ArgumentException("Mission not found.");
|
||||
if (set.Missions[index].CompletedUtc.HasValue) throw new InvalidOperationException("Completed missions cannot be replaced.");
|
||||
if (set.ReplacementCandidates.Count == 0) throw new InvalidOperationException("No replacement mission is available.");
|
||||
var replacement = set.ReplacementCandidates[0];
|
||||
replacement.StartsUtc = _clock.GetUtcNow().UtcDateTime;
|
||||
set.Missions[index] = replacement;
|
||||
set.ReplacementCandidates.RemoveAt(0);
|
||||
set.ReplacementUsed = true;
|
||||
var changed = await _sets.ReplaceOneAsync(value => value.Id == set.Id && !value.ReplacementUsed, set);
|
||||
if (changed.ModifiedCount != 1) throw new InvalidOperationException("Today's mission replacement has already been used.");
|
||||
_metrics.DailyMissionReplaced();
|
||||
return Envelope(set);
|
||||
}
|
||||
|
||||
public Task<DailyMissionEnvelope?> RecordQuestionAsync(string userId, QuestionAttempt attempt, Question question) =>
|
||||
!attempt.IsCorrect || attempt.IsSkip ? Task.FromResult<DailyMissionEnvelope?>(null) :
|
||||
RecordAsync(userId, $"question:{attempt.Id}", attempt.CreatedUtc,
|
||||
mission => QualifiesQuestion(mission, attempt, question));
|
||||
|
||||
public Task<DailyMissionEnvelope?> RecordReviewAsync(string userId, QuestionReviewAttempt attempt) =>
|
||||
!attempt.IsCorrect ? Task.FromResult<DailyMissionEnvelope?>(null) :
|
||||
RecordAsync(userId, $"review:{attempt.Id}", attempt.CreatedUtc,
|
||||
mission => QualifiesReview(mission, attempt));
|
||||
|
||||
public static bool QualifiesQuestion(DailyMission mission, QuestionAttempt attempt, Question question)
|
||||
{
|
||||
if (!attempt.IsCorrect || attempt.IsSkip || attempt.CreatedUtc < mission.StartsUtc) return false;
|
||||
var system = Step3SystemCatalog.Normalize(question);
|
||||
return mission.Type == DailyMissionTypes.WeakSystemCorrect && mission.Step3System == system
|
||||
|| mission.Type == DailyMissionTypes.DifficultyFourCorrect
|
||||
&& mission.Step3System == system && question.Difficulty >= 4;
|
||||
}
|
||||
|
||||
public static bool QualifiesReview(DailyMission mission, QuestionReviewAttempt attempt) =>
|
||||
attempt.IsCorrect && attempt.CreatedUtc >= mission.StartsUtc && mission.Type == DailyMissionTypes.DueReviews;
|
||||
|
||||
private async Task<DailyMissionEnvelope?> RecordAsync(
|
||||
string userId, string eventId, DateTime eventUtc, Func<DailyMission, bool> qualifies)
|
||||
{
|
||||
var key = DateKey(_clock.GetUtcNow().UtcDateTime);
|
||||
var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
|
||||
if (set is null) return null;
|
||||
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null
|
||||
&& value.StartsUtc <= eventUtc && qualifies(value)))
|
||||
{
|
||||
var missionFilter = Builders<DailyMission>.Filter.Eq(value => value.Id, mission.Id)
|
||||
& Builders<DailyMission>.Filter.Eq(value => value.CompletedUtc, null)
|
||||
& Builders<DailyMission>.Filter.AnyNin(value => value.AppliedEventIds, [eventId]);
|
||||
var filter = Builders<DailyMissionSet>.Filter.Eq(value => value.Id, set.Id)
|
||||
& Builders<DailyMissionSet>.Filter.ElemMatch(value => value.Missions, missionFilter);
|
||||
await _sets.UpdateOneAsync(filter, Builders<DailyMissionSet>.Update
|
||||
.Inc("Missions.$.Progress", 1).AddToSet("Missions.$.AppliedEventIds", eventId));
|
||||
}
|
||||
set = await _sets.Find(value => value.Id == set.Id).FirstAsync();
|
||||
var awards = new List<CreditAward>();
|
||||
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null && value.Progress >= value.Target))
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var filter = Builders<DailyMissionSet>.Filter.Eq(value => value.Id, set.Id)
|
||||
& Builders<DailyMissionSet>.Filter.ElemMatch(value => value.Missions,
|
||||
item => item.Id == mission.Id && item.CompletedUtc == null && item.Progress >= item.Target);
|
||||
var completed = await _sets.UpdateOneAsync(filter,
|
||||
Builders<DailyMissionSet>.Update.Set("Missions.$.CompletedUtc", now));
|
||||
if (completed.ModifiedCount != 1) continue;
|
||||
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
$"mission:{mission.Id}:complete", 3, CreditReasonCodes.DailyMissionComplete,
|
||||
$"Completed daily mission: {mission.Title}", "daily_mission", mission.Id, true));
|
||||
if (award.Award is not null) awards.Add(award.Award);
|
||||
_metrics.DailyMissionCompleted(mission.Type);
|
||||
}
|
||||
set = await _sets.Find(value => value.Id == set.Id).FirstAsync();
|
||||
return new DailyMissionEnvelope { State = Response(set), CreditAwards = awards };
|
||||
}
|
||||
|
||||
public static List<DailyMission> BuildCandidates(
|
||||
DashboardResponse dashboard, IReadOnlyCollection<Question> published, DateTime now)
|
||||
{
|
||||
var availability = published.GroupBy(Step3SystemCatalog.Normalize)
|
||||
.Where(group => !string.IsNullOrWhiteSpace(group.Key))
|
||||
.ToDictionary(group => group.Key!, group => (All: group.Count(), Hard: group.Count(q => q.Difficulty >= 4)));
|
||||
var systems = dashboard.Systems.Where(value => availability.ContainsKey(value.System))
|
||||
.OrderBy(value => MasteryRank(value.MasteryState)).ThenBy(value => value.MasteryScore)
|
||||
.ThenBy(value => value.DistinctQuestions).ThenBy(value => value.System).ToList();
|
||||
var result = new List<DailyMission>();
|
||||
if (dashboard.MissedQuestionCount > 0)
|
||||
result.Add(New(DailyMissionTypes.DueReviews,
|
||||
$"Complete {Math.Min(10, dashboard.MissedQuestionCount)} scheduled reviews", "", 0,
|
||||
Math.Min(10, dashboard.MissedQuestionCount), now));
|
||||
var weak = systems.Where(value => availability[value.System].All >= 3).Select(system =>
|
||||
New(DailyMissionTypes.WeakSystemCorrect,
|
||||
$"Get 3 first-attempt answers correct in {system.System}", system.System, 0, 3, now));
|
||||
var hard = systems.Where(value => availability[value.System].Hard >= 3)
|
||||
.OrderBy(value => value.Difficulty4PlusSuccessful).ThenBy(value => value.MasteryScore)
|
||||
.Select(system => New(DailyMissionTypes.DifficultyFourCorrect,
|
||||
$"Get 3 difficulty 4+ answers correct in {system.System}", system.System, 4, 3, now));
|
||||
var weakList = weak.ToList();
|
||||
var hardList = hard.ToList();
|
||||
if (weakList.Count > 0) result.Add(weakList[0]);
|
||||
var firstHard = hardList.FirstOrDefault(value => value.Step3System != weakList.FirstOrDefault()?.Step3System)
|
||||
?? hardList.FirstOrDefault();
|
||||
if (firstHard is not null) result.Add(firstHard);
|
||||
result.AddRange(weakList.Skip(1));
|
||||
result.AddRange(hardList.Where(value => value.Id != firstHard?.Id));
|
||||
return result.GroupBy(value => $"{value.Type}|{value.Step3System}").Select(group => group.First()).ToList();
|
||||
}
|
||||
|
||||
private static DailyMission New(string type, string title, string system, int difficulty, int target, DateTime now) =>
|
||||
new() { Type = type, Title = title, Step3System = system, DifficultyMin = difficulty,
|
||||
Target = target, StartsUtc = now };
|
||||
|
||||
private static int MasteryRank(string state) => state switch
|
||||
{
|
||||
MasteryStates.Unseen => 0, MasteryStates.Learning => 1, MasteryStates.Practicing => 2,
|
||||
MasteryStates.Strong => 3, _ => 4
|
||||
};
|
||||
|
||||
private static string DateKey(DateTime now) => now.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private async Task RecordExpiredAsync(string userId, DateTime now)
|
||||
{
|
||||
var expired = await _sets.Find(value => value.UserId == userId && value.ExpiresUtc <= now
|
||||
&& !value.ExpirationRecorded).ToListAsync();
|
||||
foreach (var set in expired)
|
||||
{
|
||||
var changed = await _sets.UpdateOneAsync(value => value.Id == set.Id && !value.ExpirationRecorded,
|
||||
Builders<DailyMissionSet>.Update.Set(value => value.ExpirationRecorded, true));
|
||||
if (changed.ModifiedCount != 1) continue;
|
||||
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null))
|
||||
_metrics.DailyMissionExpired(mission.Type);
|
||||
}
|
||||
}
|
||||
private static DailyMissionEnvelope Envelope(DailyMissionSet set) => new() { State = Response(set) };
|
||||
private static DailyMissionSetResponse Response(DailyMissionSet set) => new()
|
||||
{
|
||||
DateUtc = set.DateUtc, ExpiresUtc = set.ExpiresUtc,
|
||||
ReplacementAvailable = !set.ReplacementUsed && set.ReplacementCandidates.Count > 0
|
||||
&& set.Missions.Any(value => value.CompletedUtc is null),
|
||||
Missions = set.Missions.Select(value => new DailyMissionResponse
|
||||
{
|
||||
Id = value.Id, Type = value.Type, Title = value.Title, Step3System = value.Step3System,
|
||||
DifficultyMin = value.DifficultyMin, Target = value.Target,
|
||||
Progress = Math.Min(value.Progress, value.Target), Completed = value.CompletedUtc.HasValue
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
@@ -22,23 +22,7 @@ public sealed class DashboardStore
|
||||
private readonly CcsRatingStore _ccsRatingStore;
|
||||
private readonly IMongoCollection<UserRating> _ratings;
|
||||
|
||||
public static readonly string[] Step3Systems =
|
||||
[
|
||||
"Human Development",
|
||||
"Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders",
|
||||
"Behavioral Health",
|
||||
"Nervous System & Special Senses",
|
||||
"Skin & Subcutaneous Tissue",
|
||||
"Musculoskeletal System",
|
||||
"Cardiovascular System",
|
||||
"Respiratory System",
|
||||
"Gastrointestinal System",
|
||||
"Renal/Urinary & Male Reproductive Systems",
|
||||
"Pregnancy/Childbirth & Female Reproductive System & Breast",
|
||||
"Endocrine System",
|
||||
"Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature",
|
||||
"Social Sciences: Communication Skills/Ethics/Patient Safety"
|
||||
];
|
||||
public static readonly string[] Step3Systems = Step3SystemCatalog.All;
|
||||
|
||||
public DashboardStore(
|
||||
MongoDatabaseProvider mongo,
|
||||
@@ -69,6 +53,7 @@ public sealed class DashboardStore
|
||||
|
||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||
{
|
||||
await _reviewQueue.EnsureUserQueueAsync(userId);
|
||||
var today = nowUtc.Date;
|
||||
var trendStart = today.AddDays(-29);
|
||||
var weekStart = today.AddDays(-6);
|
||||
@@ -86,22 +71,25 @@ public sealed class DashboardStore
|
||||
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
|
||||
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
|
||||
var allReviewAttemptsTask = _reviewQueue.GetUserReviewsAsync(userId);
|
||||
var reviewItemsTask = _reviewQueue.GetUserItemsAsync(userId);
|
||||
var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart);
|
||||
var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart);
|
||||
var dynamicReviewsTask = _dynamicQuestions.DueReviewsAsync(userId, nowUtc);
|
||||
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, ccsAllAttemptsTask, completedCcsCountTask,
|
||||
simulationsTask, completedSimulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask,
|
||||
flashcardReviewsTask, dynamicReviewsTask);
|
||||
flashcardReviewsTask, dynamicReviewsTask, allReviewAttemptsTask, reviewItemsTask);
|
||||
|
||||
var attempts = attemptsTask.Result;
|
||||
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
|
||||
var recentAnswered = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Take(100).ToList();
|
||||
var previousHundred = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Skip(100).Take(100).ToList();
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(attempts.Select(value => value.QuestionExternalId));
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(attempts.Select(value => value.QuestionExternalId)
|
||||
.Concat(reviewItemsTask.Result.Select(value => value.QuestionExternalId))
|
||||
.Concat(allReviewAttemptsTask.Result.Select(value => value.QuestionExternalId)));
|
||||
var ccsAttempts = ccsAttemptsTask.Result;
|
||||
var simulations = simulationsTask.Result;
|
||||
var completedCcs = ccsAttempts.Where(value => value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue).ToList();
|
||||
await _reviewQueue.EnsureUserQueueAsync(userId);
|
||||
var pendingReviewCount = await _reviewQueue.CountPendingAsync(userId);
|
||||
|
||||
var qbankElo = ratingTask.Result.OverallElo;
|
||||
@@ -148,7 +136,8 @@ public sealed class DashboardStore
|
||||
};
|
||||
|
||||
response.PerformanceTrend = performanceTrend;
|
||||
response.Systems = BuildSystemPerformance(answeredAttempts, questionMap);
|
||||
response.Systems = MasteryCalculator.Calculate(
|
||||
attempts, allReviewAttemptsTask.Result, reviewItemsTask.Result, questionMap, nowUtc);
|
||||
|
||||
response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset =>
|
||||
{
|
||||
@@ -303,59 +292,10 @@ public sealed class DashboardStore
|
||||
}
|
||||
|
||||
public static string? NormalizeStep3System(Question question)
|
||||
{
|
||||
foreach (var candidate in new[] { question.System, question.PerformanceCategory, question.Discipline })
|
||||
{
|
||||
var normalized = NormalizeStep3System(candidate);
|
||||
if (normalized is not null)
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
=> Step3SystemCatalog.Normalize(question);
|
||||
|
||||
public static string? NormalizeStep3System(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
var exact = Step3Systems.FirstOrDefault(
|
||||
system => string.Equals(system, value.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (exact is not null)
|
||||
return exact;
|
||||
|
||||
var lower = value.Trim().ToLowerInvariant();
|
||||
if (ContainsAny(lower, "human development", "well patient", "preventive", "health maintenance", "geriatr", "age-related"))
|
||||
return Step3Systems[0];
|
||||
if (ContainsAny(lower, "social science", "communication", "ethic", "patient safety", "professionalism", "systems-based"))
|
||||
return Step3Systems[13];
|
||||
if (ContainsAny(lower, "pregnan", "obstetric", "gynec", "female reproductive", "breast", "puerper"))
|
||||
return Step3Systems[10];
|
||||
if (ContainsAny(lower, "renal", "urinary", "male reproductive", "urolog", "nephro"))
|
||||
return Step3Systems[9];
|
||||
if (ContainsAny(lower, "biostat", "epidemi", "population health", "medical literature"))
|
||||
return Step3Systems[12];
|
||||
if (ContainsAny(lower, "immune", "immun", "blood", "hemat", "lymph", "multisystem", "infectious"))
|
||||
return Step3Systems[1];
|
||||
if (ContainsAny(lower, "behavior", "psychi", "mental health"))
|
||||
return Step3Systems[2];
|
||||
if (ContainsAny(lower, "nervous", "neuro", "special sense", "ophthalm", "otolaryng", " ent"))
|
||||
return Step3Systems[3];
|
||||
if (ContainsAny(lower, "skin", "derm", "subcutaneous"))
|
||||
return Step3Systems[4];
|
||||
if (ContainsAny(lower, "musculo", "orthop", "rheumat"))
|
||||
return Step3Systems[5];
|
||||
if (ContainsAny(lower, "cardio", "circulatory"))
|
||||
return Step3Systems[6];
|
||||
if (ContainsAny(lower, "resp", "pulmon"))
|
||||
return Step3Systems[7];
|
||||
if (ContainsAny(lower, "gastro", "digest", "hepato"))
|
||||
return Step3Systems[8];
|
||||
if (ContainsAny(lower, "endocr", "metabolic"))
|
||||
return Step3Systems[11];
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string value, params string[] fragments) =>
|
||||
fragments.Any(value.Contains);
|
||||
=> Step3SystemCatalog.Normalize(value);
|
||||
|
||||
private async Task<List<DashboardRecentActivity>> BuildRecentActivityAsync(
|
||||
List<QuestionAttempt> attempts,
|
||||
|
||||
@@ -283,7 +283,8 @@ public sealed class DynamicQuestionGenerator
|
||||
IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
var result = new Dictionary<string, WrongAnswerFollowup>();
|
||||
foreach (var distractor in distractors)
|
||||
var distractorList = distractors.ToList();
|
||||
foreach (var distractor in distractorList)
|
||||
{
|
||||
if (templates.TryGetValue(distractor.Id, out var template))
|
||||
{
|
||||
@@ -297,16 +298,18 @@ public sealed class DynamicQuestionGenerator
|
||||
};
|
||||
continue;
|
||||
}
|
||||
var alternatives = distractorList.Where(value => value.Id != distractor.Id).Take(2).ToArray();
|
||||
result[distractor.Id] = new WrongAnswerFollowup
|
||||
{
|
||||
Prompt = "Which option best addresses the decisive finding in this case?",
|
||||
Prompt = $"Which limitation of option {distractor.Id} should have ruled it out in this case?",
|
||||
Choices =
|
||||
[
|
||||
new QuestionChoice { Id = correct.Id, Text = Render(correct.TextTemplate, values) },
|
||||
new QuestionChoice { Id = distractor.Id, Text = Render(distractor.TextTemplate, values) }
|
||||
new QuestionChoice { Id = "A", Text = Render(distractor.ExplanationTemplate, values) },
|
||||
new QuestionChoice { Id = "B", Text = Render(alternatives[0].ExplanationTemplate, values) },
|
||||
new QuestionChoice { Id = "C", Text = Render(alternatives[1].ExplanationTemplate, values) }
|
||||
],
|
||||
CorrectChoiceId = correct.Id,
|
||||
TeachingPoint = $"{Render(distractor.ExplanationTemplate, values)} {Render(correct.ExplanationTemplate, values)}"
|
||||
CorrectChoiceId = "A",
|
||||
TeachingPoint = Render(distractor.ExplanationTemplate, values)
|
||||
};
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -21,6 +21,10 @@ public class QuestionStore
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<Question>(systemIndex));
|
||||
var tagsIndex = Builders<Question>.IndexKeys.Ascending(q => q.Tags);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<Question>(tagsIndex));
|
||||
var step3SystemIndex = Builders<Question>.IndexKeys.Ascending(q => q.Step3System)
|
||||
.Ascending(q => q.IsPublished).Ascending(q => q.FlaggedForReviewUtc).Ascending(q => q.Difficulty);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<Question>(step3SystemIndex,
|
||||
new CreateIndexOptions { Name = "step3_system_visibility_difficulty" }));
|
||||
}
|
||||
|
||||
public async Task<List<Question>> SearchAsync(QuestionSearchRequest req, bool includeUnpublished)
|
||||
@@ -55,10 +59,18 @@ public class QuestionStore
|
||||
|
||||
var filter = filters.Count == 0 ? builder.Empty : builder.And(filters);
|
||||
var limit = Math.Clamp(req.Limit, 1, 80);
|
||||
return await _col.Aggregate()
|
||||
.Match(filter)
|
||||
.Sample(limit)
|
||||
.ToListAsync();
|
||||
if (string.IsNullOrWhiteSpace(req.Step3System))
|
||||
return await _col.Aggregate().Match(filter).Sample(limit).ToListAsync();
|
||||
|
||||
var requested = Step3SystemCatalog.Normalize(req.Step3System)
|
||||
?? throw new ArgumentException("step3System must be an official Step 3 system");
|
||||
// Include legacy records until the idempotent Step3System backfill has run.
|
||||
var systemFilter = builder.Eq(question => question.Step3System, requested)
|
||||
| builder.Exists(nameof(Question.Step3System), false)
|
||||
| builder.Eq(question => question.Step3System, "");
|
||||
var candidates = await _col.Find(filter & systemFilter).ToListAsync();
|
||||
return candidates.Where(question => Step3SystemCatalog.Normalize(question) == requested)
|
||||
.OrderBy(_ => Random.Shared.Next()).Take(limit).ToList();
|
||||
}
|
||||
|
||||
public async Task<Question?> GetByIdAsync(string id) =>
|
||||
@@ -280,6 +292,11 @@ public class QuestionStore
|
||||
ChoiceSelectionPercentages = req.ChoiceSelectionPercentages,
|
||||
Discipline = req.Discipline.Trim(),
|
||||
System = req.System.Trim(),
|
||||
Step3System = Step3SystemCatalog.Derive(new Question
|
||||
{
|
||||
ExternalId = req.ExternalId.Trim(), System = req.System.Trim(),
|
||||
PerformanceCategory = req.PerformanceCategory.Trim(), Discipline = req.Discipline.Trim()
|
||||
}) ?? "",
|
||||
PerformanceCategory = RatingCategory.Normalize(req.PerformanceCategory, req.System, req.Discipline),
|
||||
Difficulty = req.Difficulty,
|
||||
Labs = req.Labs,
|
||||
|
||||
@@ -49,6 +49,21 @@ public static class QuestionValidator
|
||||
if (!entry.Value.Choices.Any(choice => choice.Id == entry.Value.CorrectChoiceId))
|
||||
return "wrongAnswerFollowups correctChoiceId must match a follow-up choice id";
|
||||
}
|
||||
if (req.IsPublished)
|
||||
{
|
||||
if (req.WrongAnswerFollowups.Any(entry => entry.Value.Choices.Count != 3))
|
||||
return "published wrongAnswerFollowups require exactly three choices per distractor";
|
||||
if (!AllDistinct(req.WrongAnswerFollowups.Values.Select(value => value.Prompt)))
|
||||
return "published wrongAnswerFollowups require a different prompt for every distractor";
|
||||
if (!AllDistinct(req.WrongAnswerFollowups.Values.Select(value => value.TeachingPoint)))
|
||||
return "published wrongAnswerFollowups require a different teachingPoint for every distractor";
|
||||
if (!AllDistinct(req.WrongAnswerFollowups.Values.Select(value =>
|
||||
value.Choices.Single(choice => choice.Id == value.CorrectChoiceId).Text)))
|
||||
return "published wrongAnswerFollowups require a different correct follow-up answer for every distractor";
|
||||
if (!AllDistinct(req.WrongAnswerFollowups.Values.Select(value => string.Join("|",
|
||||
value.Choices.Select(choice => Normalize(choice.Text)).OrderBy(text => text, StringComparer.Ordinal)))))
|
||||
return "published wrongAnswerFollowups require a different choice set for every distractor";
|
||||
}
|
||||
|
||||
if (req.ReviewFollowups is null) return "reviewFollowups must be an array";
|
||||
if (req.ReviewFollowups.Count > 10) return "questions support at most ten review follow-ups";
|
||||
@@ -75,6 +90,16 @@ public static class QuestionValidator
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool AllDistinct(IEnumerable<string> values)
|
||||
{
|
||||
var normalized = values.Select(Normalize).ToList();
|
||||
return normalized.All(value => value.Length > 0)
|
||||
&& normalized.Distinct(StringComparer.Ordinal).Count() == normalized.Count;
|
||||
}
|
||||
|
||||
private static string Normalize(string value) => string.Join(' ', value.Trim().ToLowerInvariant()
|
||||
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
public static string? ValidateLabs(IReadOnlyCollection<QuestionLab>? labs, bool requireComplete)
|
||||
{
|
||||
if (labs is null)
|
||||
|
||||
@@ -46,15 +46,20 @@ public sealed class ReviewQueueStore
|
||||
var update = Builders<ReviewQueueItem>.Update
|
||||
.SetOnInsert(value => value.UserId, attempt.UserId)
|
||||
.SetOnInsert(value => value.QuestionExternalId, attempt.QuestionExternalId)
|
||||
.SetOnInsert(value => value.SourceAttemptId, attempt.Id ?? "")
|
||||
.SetOnInsert(value => value.State, ReviewQueueStates.Pending)
|
||||
.SetOnInsert(value => value.Cycle, 1)
|
||||
.SetOnInsert(value => value.CreatedUtc, attempt.CreatedUtc)
|
||||
.SetOnInsert(value => value.UpdatedUtc, now);
|
||||
.Set(value => value.SourceAttemptId, attempt.Id ?? "")
|
||||
.Set(value => value.State, ReviewQueueStates.Pending)
|
||||
.Set(value => value.MasteredUtc, null)
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
var filter = Builders<ReviewQueueItem>.Filter.Eq(value => value.UserId, attempt.UserId)
|
||||
& Builders<ReviewQueueItem>.Filter.Eq(value => value.QuestionExternalId, attempt.QuestionExternalId)
|
||||
& (Builders<ReviewQueueItem>.Filter.Ne(value => value.State, ReviewQueueStates.Mastered)
|
||||
| Builders<ReviewQueueItem>.Filter.Ne(value => value.SourceAttemptId, attempt.Id ?? ""));
|
||||
try
|
||||
{
|
||||
await _items.UpdateOneAsync(
|
||||
value => value.UserId == attempt.UserId && value.QuestionExternalId == attempt.QuestionExternalId,
|
||||
filter,
|
||||
update,
|
||||
new UpdateOptions { IsUpsert = true });
|
||||
}
|
||||
@@ -66,26 +71,36 @@ public sealed class ReviewQueueStore
|
||||
|
||||
public async Task EnsureUserQueueAsync(string userId)
|
||||
{
|
||||
var checkpoint = await _backfills.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||
var scanThroughUtc = DateTime.UtcNow;
|
||||
var filter = Builders<QuestionAttempt>.Filter.Where(value => value.UserId == userId && !value.IsCorrect && !value.IsSkip);
|
||||
if (checkpoint is not null)
|
||||
filter &= Builders<QuestionAttempt>.Filter.Gte(value => value.CreatedUtc, checkpoint.CompletedUtc);
|
||||
filter &= Builders<QuestionAttempt>.Filter.Lte(value => value.CreatedUtc, scanThroughUtc);
|
||||
var misses = await _firstAttempts.Find(filter).ToListAsync();
|
||||
var allMisses = await _firstAttempts.Find(filter).ToListAsync();
|
||||
var existingItems = (await _items.Find(value => value.UserId == userId).ToListAsync())
|
||||
.ToDictionary(value => value.QuestionExternalId, StringComparer.Ordinal);
|
||||
var misses = allMisses.GroupBy(value => value.QuestionExternalId, StringComparer.Ordinal)
|
||||
.Select(group => group.OrderByDescending(value => value.CreatedUtc).First())
|
||||
.Where(attempt => !existingItems.TryGetValue(attempt.QuestionExternalId, out var item)
|
||||
|| (item.State == ReviewQueueStates.Mastered
|
||||
&& item.SourceAttemptId != (attempt.Id ?? "")
|
||||
&& (!item.MasteredUtc.HasValue || attempt.CreatedUtc > item.MasteredUtc.Value)))
|
||||
.ToList();
|
||||
if (misses.Count > 0)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var writes = misses.Select(attempt => new UpdateOneModel<ReviewQueueItem>(
|
||||
Builders<ReviewQueueItem>.Filter.Where(value => value.UserId == userId && value.QuestionExternalId == attempt.QuestionExternalId),
|
||||
Builders<ReviewQueueItem>.Filter.Eq(value => value.UserId, userId)
|
||||
& Builders<ReviewQueueItem>.Filter.Eq(value => value.QuestionExternalId, attempt.QuestionExternalId)
|
||||
& (Builders<ReviewQueueItem>.Filter.Ne(value => value.State, ReviewQueueStates.Mastered)
|
||||
| Builders<ReviewQueueItem>.Filter.Ne(value => value.SourceAttemptId, attempt.Id ?? "")),
|
||||
Builders<ReviewQueueItem>.Update
|
||||
.SetOnInsert(value => value.UserId, userId)
|
||||
.SetOnInsert(value => value.QuestionExternalId, attempt.QuestionExternalId)
|
||||
.SetOnInsert(value => value.SourceAttemptId, attempt.Id ?? "")
|
||||
.SetOnInsert(value => value.State, ReviewQueueStates.Pending)
|
||||
.SetOnInsert(value => value.Cycle, 1)
|
||||
.SetOnInsert(value => value.CreatedUtc, attempt.CreatedUtc)
|
||||
.SetOnInsert(value => value.UpdatedUtc, now))
|
||||
.Set(value => value.SourceAttemptId, attempt.Id ?? "")
|
||||
.Set(value => value.State, ReviewQueueStates.Pending)
|
||||
.Set(value => value.MasteredUtc, null)
|
||||
.Set(value => value.UpdatedUtc, now))
|
||||
{ IsUpsert = true }).ToList();
|
||||
try
|
||||
{
|
||||
@@ -115,27 +130,50 @@ public sealed class ReviewQueueStore
|
||||
public Task<long> CountPendingAsync(string userId) =>
|
||||
_items.CountDocumentsAsync(value => value.UserId == userId && value.State == ReviewQueueStates.Pending);
|
||||
|
||||
public async Task<ReviewQueueItem?> GetRandomPendingAsync(string userId, IEnumerable<string> excludedIds)
|
||||
public async Task<long> CountPendingAsync(string userId, string? step3System)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(step3System)) return await CountPendingAsync(userId);
|
||||
var requested = Step3SystemCatalog.Normalize(step3System)
|
||||
?? throw new ArgumentException("step3System must be an official Step 3 system");
|
||||
var pending = await _items.Find(value => value.UserId == userId && value.State == ReviewQueueStates.Pending).ToListAsync();
|
||||
var count = 0L;
|
||||
foreach (var item in pending)
|
||||
{
|
||||
var question = await _questions.GetByExternalIdAsync(item.QuestionExternalId);
|
||||
if (question is not null && Step3SystemCatalog.Normalize(question) == requested) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public Task<List<ReviewQueueItem>> GetUserItemsAsync(string userId) =>
|
||||
_items.Find(value => value.UserId == userId).ToListAsync();
|
||||
|
||||
public Task<List<QuestionReviewAttempt>> GetUserReviewsAsync(string userId) =>
|
||||
_reviews.Find(value => value.UserId == userId).ToListAsync();
|
||||
|
||||
public async Task<ReviewQueueItem?> GetRandomPendingAsync(
|
||||
string userId, IEnumerable<string> excludedIds, string? step3System = null)
|
||||
{
|
||||
var excluded = excludedIds.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToList();
|
||||
for (var pass = 0; pass < 5; pass++)
|
||||
var requestedSystem = string.IsNullOrWhiteSpace(step3System) ? null : Step3SystemCatalog.Normalize(step3System);
|
||||
if (!string.IsNullOrWhiteSpace(step3System) && requestedSystem is null)
|
||||
throw new ArgumentException("step3System must be an official Step 3 system");
|
||||
var filter = Builders<ReviewQueueItem>.Filter.Eq(value => value.UserId, userId)
|
||||
& Builders<ReviewQueueItem>.Filter.Eq(value => value.State, ReviewQueueStates.Pending);
|
||||
if (excluded.Count > 0) filter &= Builders<ReviewQueueItem>.Filter.Nin(value => value.QuestionExternalId, excluded);
|
||||
var candidates = await _items.Find(filter).ToListAsync();
|
||||
foreach (var candidate in candidates.OrderBy(_ => Random.Shared.Next()))
|
||||
{
|
||||
var filter = Builders<ReviewQueueItem>.Filter.Eq(value => value.UserId, userId)
|
||||
& Builders<ReviewQueueItem>.Filter.Eq(value => value.State, ReviewQueueStates.Pending);
|
||||
if (excluded.Count > 0)
|
||||
filter &= Builders<ReviewQueueItem>.Filter.Nin(value => value.QuestionExternalId, excluded);
|
||||
var candidates = await _items.Aggregate().Match(filter).Sample(20).ToListAsync();
|
||||
if (candidates.Count == 0) return null;
|
||||
foreach (var candidate in candidates)
|
||||
var question = await _questions.GetByExternalIdAsync(candidate.QuestionExternalId);
|
||||
if (question is { IsPublished: true, FlaggedForReviewUtc: null })
|
||||
{
|
||||
var question = await _questions.GetByExternalIdAsync(candidate.QuestionExternalId);
|
||||
if (question is { IsPublished: true, FlaggedForReviewUtc: null }) return candidate;
|
||||
await _items.UpdateOneAsync(
|
||||
value => value.Id == candidate.Id && value.State == ReviewQueueStates.Pending,
|
||||
Builders<ReviewQueueItem>.Update
|
||||
.Set(value => value.State, ReviewQueueStates.Unavailable)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
if (requestedSystem is null || Step3SystemCatalog.Normalize(question) == requestedSystem) return candidate;
|
||||
continue;
|
||||
}
|
||||
await _items.UpdateOneAsync(
|
||||
value => value.Id == candidate.Id && value.State == ReviewQueueStates.Pending,
|
||||
Builders<ReviewQueueItem>.Update.Set(value => value.State, ReviewQueueStates.Unavailable)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,18 @@ public sealed class ServiceMetrics
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicAttempts = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicCorrect = new();
|
||||
private long _dynamicTargetGapTotal;
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditAwards = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCoins = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditDuplicates = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCapped = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionAssigned = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionCompleted = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionExpired = new();
|
||||
private long _dailyMissionReplaced;
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _weeklyGoalAssigned = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _weeklyGoalCompleted = new();
|
||||
private long _weeklyGoalExpiredIncomplete;
|
||||
private long _weeklyGoalLateFinalized;
|
||||
|
||||
public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
|
||||
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
|
||||
@@ -43,6 +55,21 @@ public sealed class ServiceMetrics
|
||||
_dynamicAttempts.AddOrUpdate(tier, 1, (_, value) => value + 1);
|
||||
if (correct) _dynamicCorrect.AddOrUpdate(tier, 1, (_, value) => value + 1);
|
||||
}
|
||||
public void CreditAward(string reason, int amount)
|
||||
{
|
||||
_creditAwards.AddOrUpdate(reason, 1, (_, value) => value + 1);
|
||||
_creditCoins.AddOrUpdate(reason, amount, (_, value) => value + amount);
|
||||
}
|
||||
public void CreditDuplicate(string reason) => _creditDuplicates.AddOrUpdate(reason, 1, (_, value) => value + 1);
|
||||
public void CreditCapped(string reason) => _creditCapped.AddOrUpdate(reason, 1, (_, value) => value + 1);
|
||||
public void DailyMissionAssigned(string type) => _dailyMissionAssigned.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void DailyMissionCompleted(string type) => _dailyMissionCompleted.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void DailyMissionExpired(string type) => _dailyMissionExpired.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void DailyMissionReplaced() => Interlocked.Increment(ref _dailyMissionReplaced);
|
||||
public void WeeklyGoalAssigned(string type) => _weeklyGoalAssigned.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void WeeklyGoalCompleted(string type) => _weeklyGoalCompleted.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void WeeklyGoalExpiredIncomplete() => Interlocked.Increment(ref _weeklyGoalExpiredIncomplete);
|
||||
public void WeeklyGoalLateFinalized() => Interlocked.Increment(ref _weeklyGoalLateFinalized);
|
||||
|
||||
public string RenderPrometheus()
|
||||
{
|
||||
@@ -63,6 +90,36 @@ public sealed class ServiceMetrics
|
||||
AppendLabeledSample(output, "step3_dynamic_correct_total", tier, _dynamicCorrect.GetValueOrDefault(tier));
|
||||
}
|
||||
AppendCounter(output, "step3_dynamic_target_gap_points_total", Interlocked.Read(ref _dynamicTargetGapTotal));
|
||||
output.AppendLine("# TYPE step3_credit_awards_total counter");
|
||||
output.AppendLine("# TYPE step3_credit_coins_awarded_total counter");
|
||||
output.AppendLine("# TYPE step3_credit_award_duplicates_total counter");
|
||||
output.AppendLine("# TYPE step3_credit_award_capped_total counter");
|
||||
foreach (var reason in _creditAwards.Keys.Concat(_creditDuplicates.Keys).Concat(_creditCapped.Keys).Distinct().Order())
|
||||
{
|
||||
AppendReasonSample(output, "step3_credit_awards_total", reason, _creditAwards.GetValueOrDefault(reason));
|
||||
AppendReasonSample(output, "step3_credit_coins_awarded_total", reason, _creditCoins.GetValueOrDefault(reason));
|
||||
AppendReasonSample(output, "step3_credit_award_duplicates_total", reason, _creditDuplicates.GetValueOrDefault(reason));
|
||||
AppendReasonSample(output, "step3_credit_award_capped_total", reason, _creditCapped.GetValueOrDefault(reason));
|
||||
}
|
||||
output.AppendLine("# TYPE step3_daily_missions_assigned_total counter");
|
||||
output.AppendLine("# TYPE step3_daily_missions_completed_total counter");
|
||||
output.AppendLine("# TYPE step3_daily_missions_expired_incomplete_total counter");
|
||||
foreach (var type in _dailyMissionAssigned.Keys.Concat(_dailyMissionCompleted.Keys).Concat(_dailyMissionExpired.Keys).Distinct().Order())
|
||||
{
|
||||
AppendMissionSample(output, "step3_daily_missions_assigned_total", type, _dailyMissionAssigned.GetValueOrDefault(type));
|
||||
AppendMissionSample(output, "step3_daily_missions_completed_total", type, _dailyMissionCompleted.GetValueOrDefault(type));
|
||||
AppendMissionSample(output, "step3_daily_missions_expired_incomplete_total", type, _dailyMissionExpired.GetValueOrDefault(type));
|
||||
}
|
||||
AppendCounter(output, "step3_daily_missions_replaced_total", Interlocked.Read(ref _dailyMissionReplaced));
|
||||
output.AppendLine("# TYPE step3_weekly_goals_assigned_total counter");
|
||||
output.AppendLine("# TYPE step3_weekly_goals_completed_total counter");
|
||||
foreach (var type in _weeklyGoalAssigned.Keys.Concat(_weeklyGoalCompleted.Keys).Distinct().Order())
|
||||
{
|
||||
AppendMissionSample(output, "step3_weekly_goals_assigned_total", type, _weeklyGoalAssigned.GetValueOrDefault(type));
|
||||
AppendMissionSample(output, "step3_weekly_goals_completed_total", type, _weeklyGoalCompleted.GetValueOrDefault(type));
|
||||
}
|
||||
AppendCounter(output, "step3_weekly_goals_expired_incomplete_total", Interlocked.Read(ref _weeklyGoalExpiredIncomplete));
|
||||
AppendCounter(output, "step3_weekly_goals_late_finalized_total", Interlocked.Read(ref _weeklyGoalLateFinalized));
|
||||
output.AppendLine("# TYPE step3_http_request_duration_seconds summary");
|
||||
output.Append("step3_http_request_duration_seconds_count ")
|
||||
.Append(Interlocked.Read(ref _httpRequests))
|
||||
@@ -91,4 +148,10 @@ public sealed class ServiceMetrics
|
||||
{
|
||||
output.Append(name).Append("{tier=\"").Append(tier.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
|
||||
}
|
||||
|
||||
private static void AppendReasonSample(StringBuilder output, string name, string reason, long value) =>
|
||||
output.Append(name).Append("{reason=\"").Append(reason.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
|
||||
|
||||
private static void AppendMissionSample(StringBuilder output, string name, string type, long value) =>
|
||||
output.Append(name).Append("{type=\"").Append(type.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class Step3SystemBackfillCommand
|
||||
{
|
||||
public const string Confirmation = "BACKFILL_STEP3_SYSTEMS";
|
||||
|
||||
public static async Task<int> RunAsync(IConfiguration configuration, string[] args, TextWriter output)
|
||||
{
|
||||
var apply = args.Contains("--apply", StringComparer.Ordinal);
|
||||
var confirmIndex = Array.IndexOf(args, "--confirm");
|
||||
var confirmation = confirmIndex >= 0 && confirmIndex + 1 < args.Length ? args[confirmIndex + 1] : "";
|
||||
var database = new MongoDatabaseProvider(configuration).Database;
|
||||
var questions = database.GetCollection<Question>("Questions");
|
||||
var all = await questions.Find(FilterDefinition<Question>.Empty).ToListAsync();
|
||||
var classified = all.Select(question => (Question: question, System: Step3SystemCatalog.Derive(question) ?? "")).ToList();
|
||||
var changes = classified.Where(item => item.System.Length > 0 && item.Question.Step3System != item.System).ToList();
|
||||
var unmapped = classified.Count(item => item.System.Length == 0);
|
||||
await output.WriteLineAsync($"Questions requiring Step3System backfill: {changes.Count}; unmapped: {unmapped}.");
|
||||
foreach (var group in classified.Where(item => item.System.Length == 0)
|
||||
.GroupBy(item => $"{item.Question.System} | {item.Question.PerformanceCategory} | {item.Question.Discipline}")
|
||||
.OrderByDescending(group => group.Count()).ThenBy(group => group.Key, StringComparer.Ordinal))
|
||||
await output.WriteLineAsync($"Unmapped ({group.Count()}): {group.Key}; ids: {string.Join(", ", group.Select(item => item.Question.ExternalId).Take(10))}");
|
||||
if (!apply)
|
||||
{
|
||||
await output.WriteLineAsync($"Dry run only. Re-run with --apply --confirm {Confirmation}.");
|
||||
return 0;
|
||||
}
|
||||
if (confirmation != Confirmation)
|
||||
{
|
||||
await output.WriteLineAsync($"Refusing migration: --confirm {Confirmation} is required.");
|
||||
return 2;
|
||||
}
|
||||
if (changes.Count > 0)
|
||||
{
|
||||
var writes = changes.Select(item => new UpdateOneModel<Question>(
|
||||
Builders<Question>.Filter.Eq(question => question.Id, item.Question.Id),
|
||||
Builders<Question>.Update.Set(question => question.Step3System, item.System))).ToList();
|
||||
await questions.BulkWriteAsync(writes, new BulkWriteOptions { IsOrdered = false });
|
||||
}
|
||||
var remaining = await questions.CountDocumentsAsync(new BsonDocumentFilterDefinition<Question>(
|
||||
new BsonDocument("$or", new BsonArray
|
||||
{
|
||||
new BsonDocument(nameof(Question.Step3System), new BsonDocument("$exists", false)),
|
||||
new BsonDocument(nameof(Question.Step3System), "")
|
||||
})));
|
||||
await output.WriteLineAsync($"Backfilled {changes.Count}; unmapped or empty Step3System: {remaining}.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class Step3SystemCatalog
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> Overrides = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["d5-expansion-pediatrics-urea-cycle-hyperammonemia-001"] = "Endocrine System",
|
||||
["d5-expansion-pediatrics-neonatal-hsv-disseminated-001"] = "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders",
|
||||
["d5-expansion-pediatrics-cdh-delivery-stabilization-001"] = "Respiratory System",
|
||||
["d5-expansion-pediatrics-hie-therapeutic-hypothermia-001"] = "Nervous System & Special Senses",
|
||||
["d5-expansion-pediatrics-fnait-intracranial-bleeding-001"] = "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders",
|
||||
["nbme6-s3-i029-infant-hyperchloremic-acidosis-growth-risk"] = "Renal/Urinary & Male Reproductive Systems",
|
||||
["form7-s1-i042-genitourinary-syndrome-menopause"] = "Pregnancy/Childbirth & Female Reproductive System & Breast",
|
||||
["free137-i019-inguinal-node-vulvar-source"] = "Pregnancy/Childbirth & Female Reproductive System & Breast",
|
||||
["free137-i061-klinefelter-confirmation"] = "Renal/Urinary & Male Reproductive Systems",
|
||||
["form7-s1-i009-cremasteric-reflex"] = "Renal/Urinary & Male Reproductive Systems",
|
||||
["form7-s2-i015-acrosome-head"] = "Renal/Urinary & Male Reproductive Systems",
|
||||
["nbme6-s1-i033-prostate-primary-osteoblastic-metastases"] = "Renal/Urinary & Male Reproductive Systems",
|
||||
["nbme6-s3-i043-lytic-lesion-hematuria-staging"] = "Renal/Urinary & Male Reproductive Systems"
|
||||
};
|
||||
public static readonly string[] All =
|
||||
[
|
||||
"Human Development",
|
||||
"Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders",
|
||||
"Behavioral Health",
|
||||
"Nervous System & Special Senses",
|
||||
"Skin & Subcutaneous Tissue",
|
||||
"Musculoskeletal System",
|
||||
"Cardiovascular System",
|
||||
"Respiratory System",
|
||||
"Gastrointestinal System",
|
||||
"Renal/Urinary & Male Reproductive Systems",
|
||||
"Pregnancy/Childbirth & Female Reproductive System & Breast",
|
||||
"Endocrine System",
|
||||
"Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature",
|
||||
"Social Sciences: Communication Skills/Ethics/Patient Safety"
|
||||
];
|
||||
|
||||
public static string? Normalize(Question question) =>
|
||||
Normalize(question.Step3System)
|
||||
?? Derive(question);
|
||||
|
||||
public static string? Derive(Question question) =>
|
||||
Overrides.GetValueOrDefault(question.ExternalId)
|
||||
?? Normalize(question.System) ?? Normalize(question.PerformanceCategory) ?? Normalize(question.Discipline);
|
||||
|
||||
public static string? Normalize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var trimmed = value.Trim();
|
||||
var exact = All.FirstOrDefault(system => string.Equals(system, trimmed, StringComparison.OrdinalIgnoreCase));
|
||||
if (exact is not null) return exact;
|
||||
var lower = trimmed.ToLowerInvariant();
|
||||
if (Has(lower, "human development", "well patient", "preventive", "health maintenance", "geriatr", "age-related", "neonat")) return All[0];
|
||||
if (Has(lower, "social science", "communication", "ethic", "patient safety", "professionalism", "systems-based")) return All[13];
|
||||
if (Has(lower, "pregnan", "obstetric", "gynec", "female reproductive", "breast", "puerper")) return All[10];
|
||||
if (Has(lower, "renal", "urinary", "male reproductive", "urolog", "nephro")) return All[9];
|
||||
if (Has(lower, "biostat", "epidemi", "population health", "medical literature")) return All[12];
|
||||
if (Has(lower, "immune", "immun", "blood", "hemat", "lymph", "multisystem", "infectious", "toxicolog", "poisoning", "oncolog", "trauma")) return All[1];
|
||||
if (Has(lower, "behavior", "psychi", "mental health")) return All[2];
|
||||
if (Has(lower, "nervous", "neuro", "special sense", "ophthalm", "eye & adnexa", "otolaryng", "ear, nose", " ent")) return All[3];
|
||||
if (Has(lower, "skin", "derm", "subcutaneous", "integument")) return All[4];
|
||||
if (Has(lower, "musculo", "orthop", "rheumat")) return All[5];
|
||||
if (Has(lower, "cardio", "circulatory", "peripheral vascular")) return All[6];
|
||||
if (Has(lower, "resp", "pulmon")) return All[7];
|
||||
if (Has(lower, "gastro", "digest", "hepato", "oral/dental")) return All[8];
|
||||
if (Has(lower, "endocr", "metabolic")) return All[11];
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool Has(string value, params string[] fragments) => fragments.Any(value.Contains);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class StudySessionStore
|
||||
{
|
||||
private readonly IMongoCollection<StudySession> _sessions;
|
||||
private readonly DashboardStore _dashboard;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly ReviewQueueStore _reviews;
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
public StudySessionStore(
|
||||
MongoDatabaseProvider mongo,
|
||||
DashboardStore dashboard,
|
||||
CreditStore credits,
|
||||
ReviewQueueStore reviews,
|
||||
TimeProvider clock)
|
||||
{
|
||||
_sessions = mongo.Database.GetCollection<StudySession>("StudySessions");
|
||||
_dashboard = dashboard;
|
||||
_credits = credits;
|
||||
_reviews = reviews;
|
||||
_clock = clock;
|
||||
_sessions.Indexes.CreateOne(new CreateIndexModel<StudySession>(
|
||||
Builders<StudySession>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ClientSessionId),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_study_session_client" }));
|
||||
_sessions.Indexes.CreateOne(new CreateIndexModel<StudySession>(
|
||||
Builders<StudySession>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State),
|
||||
new CreateIndexOptions { Name = "study_session_user_state" }));
|
||||
}
|
||||
|
||||
public async Task<StudySession> CreateAsync(string userId, CreateStudySessionRequest request)
|
||||
{
|
||||
Validate(request);
|
||||
var existing = await _sessions.Find(value => value.UserId == userId
|
||||
&& value.ClientSessionId == request.ClientSessionId).FirstOrDefaultAsync();
|
||||
if (existing is not null) return existing;
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await CloseStaleAsync(userId, now);
|
||||
var activeSimulation = await _sessions.Find(value => value.UserId == userId
|
||||
&& value.State == StudySessionStates.Active && value.GoalType == StudyGoalTypes.SimulationBlock)
|
||||
.AnyAsync();
|
||||
if (activeSimulation) throw new InvalidOperationException("Finish the active simulation block before starting another study session.");
|
||||
await _sessions.UpdateManyAsync(
|
||||
value => value.UserId == userId && value.State == StudySessionStates.Active,
|
||||
Builders<StudySession>.Update.Set(value => value.State, StudySessionStates.Interrupted)
|
||||
.Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now));
|
||||
var dashboard = await _dashboard.GetAsync(userId, now);
|
||||
var normalizedSystem = string.IsNullOrWhiteSpace(request.Step3System)
|
||||
? "" : Step3SystemCatalog.Normalize(request.Step3System) ?? throw new ArgumentException("Invalid Step 3 system.");
|
||||
var target = request.GoalType switch
|
||||
{
|
||||
StudyGoalTypes.QuestionCount => Math.Clamp(request.TargetValue, 5, 40),
|
||||
StudyGoalTypes.Duration => Math.Clamp(request.TargetValue, 10, 60),
|
||||
StudyGoalTypes.WeakSystem => 10,
|
||||
StudyGoalTypes.DueReviews => Math.Clamp(request.TargetValue, 1, 20),
|
||||
_ => request.TargetValue
|
||||
};
|
||||
if (request.GoalType == StudyGoalTypes.DueReviews)
|
||||
{
|
||||
var pending = checked((int)await _reviews.CountPendingAsync(userId, normalizedSystem));
|
||||
if (pending == 0) throw new ArgumentException("No reviews are currently due.");
|
||||
target = Math.Min(target, pending);
|
||||
}
|
||||
var mastery = dashboard.Systems.FirstOrDefault(value => value.System == normalizedSystem);
|
||||
var session = new StudySession
|
||||
{
|
||||
UserId = userId, ClientSessionId = request.ClientSessionId,
|
||||
GoalType = request.GoalType, TargetValue = target, Mode = request.Mode,
|
||||
Step3System = normalizedSystem, State = StudySessionStates.Active,
|
||||
CreatedUtc = now, UpdatedUtc = now,
|
||||
TargetEndUtc = request.GoalType == StudyGoalTypes.Duration ? now.AddMinutes(target) : null,
|
||||
InitialMasteryScore = mastery?.MasteryScore, InitialMasteryState = mastery?.MasteryState ?? "",
|
||||
InitialReadiness = dashboard.ExamReadinessProjectedPassPercent
|
||||
};
|
||||
await _sessions.InsertOneAsync(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
public async Task<StudySession?> GetActiveAsync(string userId)
|
||||
{
|
||||
await CloseStaleAsync(userId, _clock.GetUtcNow().UtcDateTime);
|
||||
return await _sessions.Find(value => value.UserId == userId && value.State == StudySessionStates.Active)
|
||||
.SortByDescending(value => value.CreatedUtc).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<StudySession?> GetOwnedAsync(string id, string userId) =>
|
||||
await _sessions.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<StudySessionEnvelope?> RecordQuestionAsync(
|
||||
string id, string userId, QuestionAttempt attempt, Question question, IReadOnlyCollection<CreditAward> awards)
|
||||
{
|
||||
var session = await GetOwnedAsync(id, userId);
|
||||
if (session is null || session.State != StudySessionStates.Active) return null;
|
||||
if (session.GoalType == StudyGoalTypes.DueReviews || session.GoalType == StudyGoalTypes.SimulationBlock) return null;
|
||||
if (session.GoalType == StudyGoalTypes.WeakSystem
|
||||
&& Step3SystemCatalog.Normalize(question) != session.Step3System) return null;
|
||||
if (!attempt.IsSkip)
|
||||
await ApplyEventAsync(session, $"question:{attempt.Id}", 1, attempt.IsCorrect ? 1 : 0, 0, 0, awards);
|
||||
return await EvaluateAsync(id, userId);
|
||||
}
|
||||
|
||||
public async Task<StudySessionEnvelope?> RecordReviewAsync(
|
||||
string id, string userId, QuestionReviewAttempt attempt, bool recovered, IReadOnlyCollection<CreditAward> awards)
|
||||
{
|
||||
var session = await GetOwnedAsync(id, userId);
|
||||
if (session is null || session.State != StudySessionStates.Active || session.GoalType != StudyGoalTypes.DueReviews)
|
||||
return null;
|
||||
await ApplyEventAsync(session, $"review:{attempt.Id}", 0, attempt.IsCorrect ? 1 : 0, 1, recovered ? 1 : 0, awards);
|
||||
return await EvaluateAsync(id, userId);
|
||||
}
|
||||
|
||||
public async Task<StudySessionEnvelope?> FinishEarlyAsync(string id, string userId)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await _sessions.UpdateOneAsync(value => value.Id == id && value.UserId == userId
|
||||
&& value.State == StudySessionStates.Active,
|
||||
Builders<StudySession>.Update.Set(value => value.State, StudySessionStates.EndedEarly)
|
||||
.Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now));
|
||||
var session = await GetOwnedAsync(id, userId);
|
||||
if (session is null) return null;
|
||||
await SnapshotFinalAsync(session);
|
||||
return Envelope(session);
|
||||
}
|
||||
|
||||
public Task<StudySessionEnvelope?> CheckAsync(string id, string userId) => EvaluateAsync(id, userId);
|
||||
|
||||
public async Task<StudySession> EnsureSimulationBlockAsync(string userId, string simulationId, int block)
|
||||
{
|
||||
var clientId = $"simulation:{simulationId}:{block}";
|
||||
var existing = await _sessions.Find(value => value.UserId == userId && value.ClientSessionId == clientId).FirstOrDefaultAsync();
|
||||
if (existing is not null) return existing;
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await _sessions.UpdateManyAsync(value => value.UserId == userId && value.State == StudySessionStates.Active,
|
||||
Builders<StudySession>.Update.Set(value => value.State, StudySessionStates.Interrupted)
|
||||
.Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now));
|
||||
var dashboard = await _dashboard.GetAsync(userId, now);
|
||||
var session = new StudySession
|
||||
{
|
||||
UserId = userId, ClientSessionId = clientId, GoalType = StudyGoalTypes.SimulationBlock,
|
||||
TargetValue = 1, Mode = "Simulation", State = StudySessionStates.Active,
|
||||
SimulationId = simulationId, SimulationBlock = block, CreatedUtc = now, UpdatedUtc = now,
|
||||
InitialReadiness = dashboard.ExamReadinessProjectedPassPercent
|
||||
};
|
||||
try { await _sessions.InsertOneAsync(session); }
|
||||
catch (MongoWriteException) { return await _sessions.Find(value => value.UserId == userId && value.ClientSessionId == clientId).FirstAsync(); }
|
||||
return session;
|
||||
}
|
||||
|
||||
public async Task<StudySessionEnvelope> CompleteSimulationBlockAsync(string userId, string simulationId, int block)
|
||||
{
|
||||
var session = await EnsureSimulationBlockAsync(userId, simulationId, block);
|
||||
if (session.State == StudySessionStates.Active)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await _sessions.UpdateOneAsync(value => value.Id == session.Id && value.State == StudySessionStates.Active,
|
||||
Builders<StudySession>.Update.Set(value => value.QuestionsCompleted, 1)
|
||||
.Set(value => value.State, StudySessionStates.Completed).Set(value => value.CompletedUtc, now)
|
||||
.Set(value => value.UpdatedUtc, now));
|
||||
session = (await GetOwnedAsync(session.Id!, userId))!;
|
||||
await ApplyCompletionBonusAsync(session);
|
||||
await SnapshotFinalAsync(session);
|
||||
}
|
||||
return Envelope((await GetOwnedAsync(session.Id!, userId))!);
|
||||
}
|
||||
|
||||
public StudySessionEnvelope Envelope(StudySession session) => new()
|
||||
{
|
||||
Progress = Progress(session),
|
||||
Summary = session.State == StudySessionStates.Active ? null : Summary(session)
|
||||
};
|
||||
|
||||
private async Task ApplyEventAsync(
|
||||
StudySession session, string eventId, int questions, int correct, int reviews, int recovered,
|
||||
IReadOnlyCollection<CreditAward> awards)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var filter = Builders<StudySession>.Filter.Eq(value => value.Id, session.Id)
|
||||
& Builders<StudySession>.Filter.Eq(value => value.State, StudySessionStates.Active)
|
||||
& Builders<StudySession>.Filter.AnyNin(value => value.AppliedEventIds, [eventId]);
|
||||
var update = Builders<StudySession>.Update.AddToSet(value => value.AppliedEventIds, eventId)
|
||||
.Inc(value => value.QuestionsCompleted, questions).Inc(value => value.Correct, correct)
|
||||
.Inc(value => value.ReviewsCompleted, reviews).Inc(value => value.Recovered, recovered)
|
||||
.Inc(value => value.CoinsEarned, awards.Sum(value => value.Amount)).Set(value => value.UpdatedUtc, now);
|
||||
foreach (var award in awards) update = update.Push(value => value.CoinAwards, award);
|
||||
await _sessions.UpdateOneAsync(filter, update);
|
||||
}
|
||||
|
||||
private async Task<StudySessionEnvelope?> EvaluateAsync(string id, string userId)
|
||||
{
|
||||
var session = await GetOwnedAsync(id, userId);
|
||||
if (session is null) return null;
|
||||
if (session.State == StudySessionStates.Active && Reached(session, _clock.GetUtcNow().UtcDateTime))
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var changed = await _sessions.UpdateOneAsync(value => value.Id == id && value.UserId == userId
|
||||
&& value.State == StudySessionStates.Active,
|
||||
Builders<StudySession>.Update.Set(value => value.State, StudySessionStates.Completed)
|
||||
.Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now));
|
||||
session = (await GetOwnedAsync(id, userId))!;
|
||||
if (changed.ModifiedCount == 1)
|
||||
{
|
||||
await ApplyCompletionBonusAsync(session);
|
||||
await SnapshotFinalAsync(session);
|
||||
session = (await GetOwnedAsync(id, userId))!;
|
||||
}
|
||||
}
|
||||
return Envelope(session);
|
||||
}
|
||||
|
||||
private async Task ApplyCompletionBonusAsync(StudySession session)
|
||||
{
|
||||
if (!BonusEligible(session)) return;
|
||||
var result = await _credits.AwardAsync(session.UserId, new CreditAwardRequest(
|
||||
$"session:{session.Id}:complete", 3, CreditReasonCodes.SessionGoalComplete, "Completed a study session goal",
|
||||
"study_session", session.Id!, true));
|
||||
if (result.Award is null) return;
|
||||
await _sessions.UpdateOneAsync(value => value.Id == session.Id,
|
||||
Builders<StudySession>.Update.Inc(value => value.CoinsEarned, result.Award.Amount)
|
||||
.Push(value => value.CoinAwards, result.Award));
|
||||
}
|
||||
|
||||
private async Task SnapshotFinalAsync(StudySession session)
|
||||
{
|
||||
var dashboard = await _dashboard.GetAsync(session.UserId, _clock.GetUtcNow().UtcDateTime);
|
||||
var mastery = dashboard.Systems.FirstOrDefault(value => value.System == session.Step3System);
|
||||
await _sessions.UpdateOneAsync(value => value.Id == session.Id,
|
||||
Builders<StudySession>.Update.Set(value => value.FinalMasteryScore, mastery == null ? null : mastery.MasteryScore)
|
||||
.Set(value => value.FinalMasteryState, mastery?.MasteryState ?? "")
|
||||
.Set(value => value.FinalReadiness, dashboard.ExamReadinessProjectedPassPercent));
|
||||
}
|
||||
|
||||
private async Task CloseStaleAsync(string userId, DateTime now) =>
|
||||
await _sessions.UpdateManyAsync(value => value.UserId == userId && value.State == StudySessionStates.Active
|
||||
&& value.UpdatedUtc < now.AddHours(-12),
|
||||
Builders<StudySession>.Update.Set(value => value.State, StudySessionStates.Interrupted)
|
||||
.Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now));
|
||||
|
||||
private static bool Reached(StudySession session, DateTime now) => session.GoalType switch
|
||||
{
|
||||
StudyGoalTypes.Duration => session.TargetEndUtc <= now && session.QuestionsCompleted > 0,
|
||||
StudyGoalTypes.DueReviews => session.ReviewsCompleted >= session.TargetValue,
|
||||
_ => session.QuestionsCompleted >= session.TargetValue
|
||||
};
|
||||
|
||||
private static bool BonusEligible(StudySession session) => session.GoalType switch
|
||||
{
|
||||
StudyGoalTypes.Duration => session.TargetValue >= 10 && session.QuestionsCompleted >= 5,
|
||||
StudyGoalTypes.DueReviews => session.ReviewsCompleted >= 5,
|
||||
StudyGoalTypes.SimulationBlock => true,
|
||||
_ => session.QuestionsCompleted >= 5
|
||||
};
|
||||
|
||||
private StudySessionProgress Progress(StudySession session)
|
||||
{
|
||||
var completed = session.GoalType == StudyGoalTypes.DueReviews ? session.ReviewsCompleted
|
||||
: session.GoalType == StudyGoalTypes.Duration
|
||||
? Math.Min(session.TargetValue * 60, (int)((session.CompletedUtc ?? _clock.GetUtcNow().UtcDateTime) - session.CreatedUtc).TotalSeconds)
|
||||
: session.QuestionsCompleted;
|
||||
return new StudySessionProgress
|
||||
{
|
||||
Id = session.Id ?? "", State = session.State, GoalType = session.GoalType,
|
||||
TargetValue = session.TargetValue, CompletedValue = completed,
|
||||
QuestionsCompleted = session.QuestionsCompleted,
|
||||
SecondsRemaining = session.TargetEndUtc.HasValue
|
||||
? Math.Max(0, (int)(session.TargetEndUtc.Value - _clock.GetUtcNow().UtcDateTime).TotalSeconds) : 0,
|
||||
GoalReached = session.State == StudySessionStates.Completed
|
||||
};
|
||||
}
|
||||
|
||||
private StudySessionSummary Summary(StudySession session)
|
||||
{
|
||||
var progress = Progress(session);
|
||||
return new StudySessionSummary
|
||||
{
|
||||
Id = session.Id ?? "", State = session.State, GoalType = session.GoalType,
|
||||
TargetValue = session.TargetValue, CompletedValue = progress.CompletedValue,
|
||||
QuestionsCompleted = session.QuestionsCompleted, Correct = session.Correct,
|
||||
Accuracy = session.QuestionsCompleted == 0 ? 0 : (int)Math.Round(100.0 * session.Correct / session.QuestionsCompleted),
|
||||
ReviewsCompleted = session.ReviewsCompleted, Recovered = session.Recovered,
|
||||
ElapsedSeconds = Math.Max(0, (int)((session.CompletedUtc ?? _clock.GetUtcNow().UtcDateTime) - session.CreatedUtc).TotalSeconds),
|
||||
CoinsEarned = session.CoinsEarned, CoinAwards = session.CoinAwards,
|
||||
Step3System = session.Step3System,
|
||||
MasteryScoreChange = session.InitialMasteryScore.HasValue && session.FinalMasteryScore.HasValue
|
||||
? session.FinalMasteryScore - session.InitialMasteryScore : null,
|
||||
InitialMasteryState = session.InitialMasteryState, FinalMasteryState = session.FinalMasteryState,
|
||||
ReadinessChange = session.InitialReadiness.HasValue && session.FinalReadiness.HasValue
|
||||
? session.FinalReadiness - session.InitialReadiness : null,
|
||||
ReadinessExplanation = session.FinalReadiness != session.InitialReadiness
|
||||
? "Readiness changed after a completed exam simulation."
|
||||
: "A completed exam simulation is required to update readiness.",
|
||||
GoalBonusAwarded = session.CoinAwards.Any(value => value.ReasonCode == CreditReasonCodes.SessionGoalComplete)
|
||||
};
|
||||
}
|
||||
|
||||
private static void Validate(CreateStudySessionRequest request)
|
||||
{
|
||||
if (!Guid.TryParse(request.ClientSessionId, out _)) throw new ArgumentException("clientSessionId must be a UUID.");
|
||||
if (!StudyGoalTypes.All.Contains(request.GoalType) || request.GoalType == StudyGoalTypes.SimulationBlock)
|
||||
throw new ArgumentException("Unsupported study goal type.");
|
||||
if (request.GoalType == StudyGoalTypes.WeakSystem && string.IsNullOrWhiteSpace(request.Step3System))
|
||||
throw new ArgumentException("A Step 3 system is required for a weak-system goal.");
|
||||
if (request.Mode is not ("Tutor" or "Timed")) throw new ArgumentException("mode must be Tutor or Timed.");
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,8 @@ public sealed class UserProfileStore
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"UserCreditAccounts"
|
||||
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets", "WeeklyGoals",
|
||||
"UserProgressions", "ProgressionWeeklySnapshots"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class WeeklyGoalStore
|
||||
{
|
||||
private readonly IMongoCollection<WeeklyGoal> _goals;
|
||||
private readonly IMongoCollection<QuestionAttempt> _attempts;
|
||||
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
|
||||
private readonly IMongoCollection<ReviewQueueItem> _queue;
|
||||
private readonly IMongoCollection<FlashcardReviewLog> _flashReviews;
|
||||
private readonly IMongoCollection<FlashcardSchedule> _flashSchedules;
|
||||
private readonly IMongoCollection<FlashcardNote> _flashNotes;
|
||||
private readonly IMongoCollection<CcsAttempt> _ccsAttempts;
|
||||
private readonly IMongoCollection<StudySession> _studySessions;
|
||||
private readonly IMongoCollection<ExamSimulation> _simulations;
|
||||
private readonly QuestionStore _questions;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
|
||||
public WeeklyGoalStore(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits,
|
||||
TimeProvider clock, ServiceMetrics metrics)
|
||||
{
|
||||
var db = mongo.Database;
|
||||
_goals = db.GetCollection<WeeklyGoal>("WeeklyGoals");
|
||||
_attempts = db.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_reviews = db.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
|
||||
_queue = db.GetCollection<ReviewQueueItem>("ReviewQueueItems");
|
||||
_flashReviews = db.GetCollection<FlashcardReviewLog>("FlashcardReviewLogs");
|
||||
_flashSchedules = db.GetCollection<FlashcardSchedule>("FlashcardSchedules");
|
||||
_flashNotes = db.GetCollection<FlashcardNote>("FlashcardNotes");
|
||||
_ccsAttempts = db.GetCollection<CcsAttempt>("CcsAttempts");
|
||||
_studySessions = db.GetCollection<StudySession>("StudySessions");
|
||||
_simulations = db.GetCollection<ExamSimulation>("ExamSimulations");
|
||||
_questions = questions;
|
||||
_credits = credits;
|
||||
_clock = clock;
|
||||
_metrics = metrics;
|
||||
_goals.Indexes.CreateOne(new CreateIndexModel<WeeklyGoal>(
|
||||
Builders<WeeklyGoal>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.WeekStartUtc),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_weekly_goal_user_start" }));
|
||||
}
|
||||
|
||||
public async Task<WeeklyGoalEnvelope> GetAsync(
|
||||
string userId, DashboardResponse dashboard, IReadOnlyCollection<Question> published)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var awards = await FinalizeExpiredAsync(userId, now);
|
||||
var start = WeekStart(now);
|
||||
var goal = await _goals.Find(value => value.UserId == userId && value.WeekStartUtc == start)
|
||||
.FirstOrDefaultAsync();
|
||||
if (goal is null)
|
||||
{
|
||||
var reviewed = await _reviews.CountDocumentsAsync(value => value.UserId == userId && value.IsCorrect
|
||||
&& value.CreatedUtc >= start && value.CreatedUtc < start.AddDays(7));
|
||||
var pending = await _queue.CountDocumentsAsync(value => value.UserId == userId
|
||||
&& value.State == ReviewQueueStates.Pending);
|
||||
var availableReviews = checked((int)Math.Min(int.MaxValue, reviewed + pending));
|
||||
var remediation = ChooseRemediation(availableReviews);
|
||||
var eligible = SelectHardSystems(dashboard, published);
|
||||
goal = new WeeklyGoal
|
||||
{
|
||||
Id = $"{userId}|{start:yyyy-MM-dd}", UserId = userId, WeekStartUtc = start,
|
||||
ExpiresUtc = start.AddDays(7), CreatedUtc = now,
|
||||
RemediationType = remediation.Type, RemediationTarget = remediation.Target,
|
||||
EligibleSystems = eligible
|
||||
};
|
||||
try
|
||||
{
|
||||
await _goals.InsertOneAsync(goal);
|
||||
_metrics.WeeklyGoalAssigned(goal.RemediationType);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
goal = await _goals.Find(value => value.UserId == userId && value.WeekStartUtc == start).FirstAsync();
|
||||
}
|
||||
}
|
||||
var evaluated = await EvaluateAsync(goal);
|
||||
awards.AddRange(evaluated.Awards);
|
||||
return new WeeklyGoalEnvelope
|
||||
{
|
||||
State = evaluated.Response, CreditAwards = awards,
|
||||
Credits = await _credits.GetStatusAsync(userId)
|
||||
};
|
||||
}
|
||||
|
||||
public static DateTime WeekStart(DateTime value)
|
||||
{
|
||||
var date = value.ToUniversalTime().Date;
|
||||
var offset = ((int)date.DayOfWeek + 6) % 7;
|
||||
return date.AddDays(-offset);
|
||||
}
|
||||
|
||||
public static List<string> SelectHardSystems(
|
||||
DashboardResponse dashboard, IReadOnlyCollection<Question> published)
|
||||
{
|
||||
var counts = published.Where(question => question.Difficulty >= 4)
|
||||
.GroupBy(Step3SystemCatalog.Normalize).Where(group => group.Key is not null)
|
||||
.ToDictionary(group => group.Key!, group => group.Count());
|
||||
var ordered = dashboard.Systems.Where(system => counts.GetValueOrDefault(system.System) > 0)
|
||||
.OrderBy(system => MasteryRank(system.MasteryState)).ThenBy(system => system.MasteryScore)
|
||||
.ThenBy(system => system.Difficulty4PlusSuccessful).Select(system => system.System).ToList();
|
||||
var result = new List<string>();
|
||||
var available = 0;
|
||||
foreach (var system in ordered)
|
||||
{
|
||||
result.Add(system);
|
||||
available += counts[system];
|
||||
if (available >= 5 || result.Count == 3) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static (string Type, int Target) ChooseRemediation(int availableReviews) =>
|
||||
availableReviews >= 5
|
||||
? (WeeklyRemediationTypes.Reviews, Math.Min(15, availableReviews))
|
||||
: (WeeklyRemediationTypes.HardCorrect, 5);
|
||||
|
||||
public static int CountActiveDays(IEnumerable<DateTime> timestamps) =>
|
||||
timestamps.Select(value => value.ToUniversalTime().Date).Distinct().Count();
|
||||
|
||||
public static bool QualifiesHardAttempt(WeeklyGoal goal, QuestionAttempt attempt, Question question) =>
|
||||
!attempt.IsSkip && attempt.IsCorrect && attempt.CreatedUtc >= goal.WeekStartUtc
|
||||
&& attempt.CreatedUtc < goal.ExpiresUtc && question.Difficulty >= 4
|
||||
&& goal.EligibleSystems.Contains(Step3SystemCatalog.Normalize(question) ?? "");
|
||||
|
||||
private async Task<List<CreditAward>> FinalizeExpiredAsync(string userId, DateTime now)
|
||||
{
|
||||
var awards = new List<CreditAward>();
|
||||
var expired = await _goals.Find(value => value.UserId == userId && value.ExpiresUtc <= now
|
||||
&& !value.ExpirationRecorded).ToListAsync();
|
||||
foreach (var goal in expired)
|
||||
{
|
||||
var wasCompleted = goal.CompletedUtc.HasValue;
|
||||
var result = await EvaluateAsync(goal);
|
||||
awards.AddRange(result.Awards);
|
||||
var latest = await _goals.Find(value => value.Id == goal.Id).FirstAsync();
|
||||
var recorded = await _goals.UpdateOneAsync(value => value.Id == goal.Id && !value.ExpirationRecorded,
|
||||
Builders<WeeklyGoal>.Update.Set(value => value.ExpirationRecorded, true));
|
||||
if (recorded.ModifiedCount == 1 && !latest.CompletedUtc.HasValue) _metrics.WeeklyGoalExpiredIncomplete();
|
||||
if (recorded.ModifiedCount == 1 && !wasCompleted && latest.CompletedUtc.HasValue)
|
||||
_metrics.WeeklyGoalLateFinalized();
|
||||
}
|
||||
return awards;
|
||||
}
|
||||
|
||||
private async Task<(WeeklyGoalResponse Response, List<CreditAward> Awards)> EvaluateAsync(WeeklyGoal goal)
|
||||
{
|
||||
var evidence = await EvidenceAsync(goal);
|
||||
var complete = evidence.ActiveDays >= 4 && evidence.Systems.Count >= 3
|
||||
&& evidence.Remediation >= goal.RemediationTarget;
|
||||
if (complete && !goal.CompletedUtc.HasValue)
|
||||
{
|
||||
var completedUtc = _clock.GetUtcNow().UtcDateTime;
|
||||
var changed = await _goals.UpdateOneAsync(value => value.Id == goal.Id && value.CompletedUtc == null,
|
||||
Builders<WeeklyGoal>.Update.Set(value => value.CompletedUtc, completedUtc));
|
||||
goal.CompletedUtc = completedUtc;
|
||||
if (changed.ModifiedCount == 1) _metrics.WeeklyGoalCompleted(goal.RemediationType);
|
||||
}
|
||||
var awards = new List<CreditAward>();
|
||||
if (complete && !goal.RewardIssued)
|
||||
{
|
||||
var operationId = $"weekly-goal:{goal.Id}:complete";
|
||||
var result = await _credits.AwardAsync(goal.UserId, new CreditAwardRequest(
|
||||
operationId, 10, CreditReasonCodes.WeeklyGoalComplete,
|
||||
"Completed the balanced weekly study goal", "weekly_goal", goal.Id, true));
|
||||
if (result.Award is not null) awards.Add(result.Award);
|
||||
var applied = result.Award ?? await _credits.GetAppliedAwardAsync(goal.UserId, operationId);
|
||||
var amount = applied?.Amount ?? 0;
|
||||
await _goals.UpdateOneAsync(value => value.Id == goal.Id && !value.RewardIssued,
|
||||
Builders<WeeklyGoal>.Update.Set(value => value.RewardIssued, true)
|
||||
.Set(value => value.RewardAmount, amount));
|
||||
goal.RewardIssued = true;
|
||||
goal.RewardAmount = amount;
|
||||
}
|
||||
return (Response(goal, evidence), awards);
|
||||
}
|
||||
|
||||
private async Task<WeeklyEvidence> EvidenceAsync(WeeklyGoal goal)
|
||||
{
|
||||
var start = goal.WeekStartUtc;
|
||||
var end = goal.ExpiresUtc;
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == goal.UserId && !value.IsSkip
|
||||
&& value.CreatedUtc >= start && value.CreatedUtc < end).ToListAsync();
|
||||
var reviewsTask = _reviews.Find(value => value.UserId == goal.UserId && value.IsCorrect
|
||||
&& value.CreatedUtc >= start && value.CreatedUtc < end).ToListAsync();
|
||||
var flashTask = _flashReviews.Find(value => value.UserId == goal.UserId && value.UndoneUtc == null
|
||||
&& value.ReviewedUtc >= start && value.ReviewedUtc < end).ToListAsync();
|
||||
var ccsTask = _ccsAttempts.Find(value => value.UserId == goal.UserId
|
||||
&& value.State == CcsAttemptStates.Completed && value.CompletedUtc >= start && value.CompletedUtc < end)
|
||||
.ToListAsync();
|
||||
var blocksTask = _studySessions.Find(value => value.UserId == goal.UserId
|
||||
&& value.GoalType == StudyGoalTypes.SimulationBlock && value.State == StudySessionStates.Completed
|
||||
&& value.CompletedUtc >= start && value.CompletedUtc < end).ToListAsync();
|
||||
var simulationsTask = _simulations.Find(value => value.UserId == goal.UserId
|
||||
&& value.Responses.Any(response => response.UpdatedUtc >= start && response.UpdatedUtc < end)).ToListAsync();
|
||||
await Task.WhenAll(attemptsTask, reviewsTask, flashTask, ccsTask, blocksTask, simulationsTask);
|
||||
|
||||
var attempts = attemptsTask.Result;
|
||||
var reviews = reviewsTask.Result;
|
||||
var flash = flashTask.Result;
|
||||
var dates = attempts.Select(value => value.CreatedUtc)
|
||||
.Concat(reviews.Select(value => value.CreatedUtc.Date))
|
||||
.Concat(flash.Select(value => value.ReviewedUtc))
|
||||
.Concat(ccsTask.Result.Select(value => value.CompletedUtc!.Value))
|
||||
.Concat(blocksTask.Result.Select(value => value.CompletedUtc!.Value)).ToList();
|
||||
|
||||
var questionIds = attempts.Select(value => value.QuestionExternalId)
|
||||
.Concat(reviews.Select(value => value.QuestionExternalId))
|
||||
.Concat(simulationsTask.Result.SelectMany(value => value.Responses)
|
||||
.Where(value => value.UpdatedUtc >= start && value.UpdatedUtc < end && !string.IsNullOrWhiteSpace(value.ChoiceId))
|
||||
.Select(value => value.QuestionExternalId)).ToHashSet(StringComparer.Ordinal);
|
||||
if (flash.Count > 0)
|
||||
{
|
||||
var cardIds = flash.Select(value => value.CardId).Distinct().ToList();
|
||||
var schedules = await _flashSchedules.Find(value => cardIds.Contains(value.Id!)).ToListAsync();
|
||||
var noteIds = schedules.Select(value => value.NoteId).Distinct().ToList();
|
||||
var notes = await _flashNotes.Find(value => noteIds.Contains(value.Id!)
|
||||
&& value.SourceQuestionExternalId != "").ToListAsync();
|
||||
foreach (var note in notes) questionIds.Add(note.SourceQuestionExternalId);
|
||||
}
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(questionIds);
|
||||
var systems = questionMap.Values.Select(Step3SystemCatalog.Normalize)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!).Distinct().ToHashSet();
|
||||
var hardCorrect = attempts.Count(attempt => questionMap.TryGetValue(attempt.QuestionExternalId, out var question)
|
||||
&& QualifiesHardAttempt(goal, attempt, question));
|
||||
return new WeeklyEvidence
|
||||
{
|
||||
ActiveDays = CountActiveDays(dates), Systems = systems,
|
||||
Remediation = goal.RemediationType == WeeklyRemediationTypes.Reviews ? reviews.Count : hardCorrect
|
||||
};
|
||||
}
|
||||
|
||||
private static WeeklyGoalResponse Response(WeeklyGoal goal, WeeklyEvidence evidence)
|
||||
{
|
||||
var components = new List<WeeklyGoalComponentResponse>
|
||||
{
|
||||
Component("active_days", "Study on 4 different days", evidence.ActiveDays, 4),
|
||||
Component("system_breadth", "Study 3 Step 3 systems", evidence.Systems.Count, 3),
|
||||
Component(goal.RemediationType,
|
||||
goal.RemediationType == WeeklyRemediationTypes.Reviews
|
||||
? $"Complete {goal.RemediationTarget} scheduled reviews"
|
||||
: "Get 5 difficulty 4+ answers correct in coverage gaps",
|
||||
evidence.Remediation, goal.RemediationTarget)
|
||||
};
|
||||
return new WeeklyGoalResponse
|
||||
{
|
||||
WeekStartUtc = goal.WeekStartUtc, ExpiresUtc = goal.ExpiresUtc,
|
||||
Completed = components.All(value => value.Completed), RemediationType = goal.RemediationType,
|
||||
EligibleSystems = goal.EligibleSystems,
|
||||
SuggestedSystem = goal.EligibleSystems.FirstOrDefault(value => !evidence.Systems.Contains(value))
|
||||
?? goal.EligibleSystems.FirstOrDefault() ?? "",
|
||||
RewardAmount = goal.RewardAmount, Components = components
|
||||
};
|
||||
}
|
||||
|
||||
private static WeeklyGoalComponentResponse Component(string type, string label, int progress, int target) =>
|
||||
new() { Type = type, Label = label, Progress = Math.Min(progress, target), Target = target,
|
||||
Completed = progress >= target };
|
||||
|
||||
private static int MasteryRank(string state) => state switch
|
||||
{
|
||||
MasteryStates.Unseen => 0, MasteryStates.Learning => 1, MasteryStates.Practicing => 2,
|
||||
MasteryStates.Strong => 3, _ => 4
|
||||
};
|
||||
|
||||
private sealed class WeeklyEvidence
|
||||
{
|
||||
public int ActiveDays { get; set; }
|
||||
public HashSet<string> Systems { get; set; } = [];
|
||||
public int Remediation { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/>
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cj8q5fugoyfhq"
|
||||
path="res://.godot/imported/medical-history.svg-8cd75462cd9d8822133509e0f23e2799.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/icons/medical-history.svg"
|
||||
dest_files=["res://.godot/imported/medical-history.svg-8cd75462cd9d8822133509e0f23e2799.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z"/>
|
||||
<path d="M3.22 12H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b8juqb8k774q4"
|
||||
path="res://.godot/imported/vitals.svg-aeab46849d322f67e09ecb738933ea93.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/icons/vitals.svg"
|
||||
dest_files=["res://.godot/imported/vitals.svg-aeab46849d322f67e09ecb738933ea93.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://crss7xdb4asxw"
|
||||
path="res://.godot/imported/computer-base.png-c22fe210cc788b2a30551b9078564447.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/images/computer/computer-base.png"
|
||||
dest_files=["res://.godot/imported/computer-base.png-c22fe210cc788b2a30551b9078564447.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 912 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://20mgki4ctdt2"
|
||||
path="res://.godot/imported/chat-bubble.png-deb8723b28b12c2c18ca72693079a109.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/images/doctor/chat-bubble.png"
|
||||
dest_files=["res://.godot/imported/chat-bubble.png-deb8723b28b12c2c18ca72693079a109.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://coe5g5bmhx4da"
|
||||
path="res://.godot/imported/clock.png-1472633dccc280df9899fa995cf51944.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/images/doctor/clock.png"
|
||||
dest_files=["res://.godot/imported/clock.png-1472633dccc280df9899fa995cf51944.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 788 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ciyh3pu7oheu7"
|
||||
path="res://.godot/imported/dialogue-choice-hover.png-74920c58e8a5f4b150faa8f67c77995b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/images/doctor/dialogue-choice-hover.png"
|
||||
dest_files=["res://.godot/imported/dialogue-choice-hover.png-74920c58e8a5f4b150faa8f67c77995b.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 866 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cmgb8fmmsu746"
|
||||
path="res://.godot/imported/dialogue-choice-normal.png-474d0ee8b44edafede7ea4bf1717544e.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/images/doctor/dialogue-choice-normal.png"
|
||||
dest_files=["res://.godot/imported/dialogue-choice-normal.png-474d0ee8b44edafede7ea4bf1717544e.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 830 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dwkq4b24bxaj3"
|
||||
path="res://.godot/imported/dialogue-choice-pressed.png-ddffca745e1cd0dd9cea64607a4049cb.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/images/doctor/dialogue-choice-pressed.png"
|
||||
dest_files=["res://.godot/imported/dialogue-choice-pressed.png-ddffca745e1cd0dd9cea64607a4049cb.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://blvljbm5ylu14"
|
||||
path="res://.godot/imported/office-backdrop.png-c4ff7cf7fa21dca6db6c6e4cf3183251.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/images/doctor/office-backdrop.png"
|
||||
dest_files=["res://.godot/imported/office-backdrop.png-c4ff7cf7fa21dca6db6c6e4cf3183251.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 464 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://d4fyqjakmkr1g"
|
||||
path="res://.godot/imported/patient-44-white-male.png-01861ff1e5efd318a354f28d52214f12.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/images/doctor/patient-44-white-male.png"
|
||||
dest_files=["res://.godot/imported/patient-44-white-male.png-01861ff1e5efd318a354f28d52214f12.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2"/>
|
||||
<line x1="8" x2="16" y1="21" y2="21"/>
|
||||
<line x1="12" x2="12" y1="17" y2="21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 324 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://diqn3ntrortw"
|
||||
path="res://.godot/imported/monitor.svg-1101ac90716471f71a4ffd9814ccca4c.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/monitor.svg"
|
||||
dest_files=["res://.godot/imported/monitor.svg-1101ac90716471f71a4ffd9814ccca4c.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -47,6 +47,61 @@
|
||||
"simulatedStartUtc": "2026-07-15T08:00:00Z",
|
||||
"initialOrderCodes": []
|
||||
},
|
||||
"initialInterview": {
|
||||
"startNodeId": "greeting",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "greeting",
|
||||
"patientText": "Doctor, I feel awful. I can barely keep my eyes open.",
|
||||
"isTerminal": false,
|
||||
"choices": [
|
||||
{ "id": "ask_chief_concern", "text": "Tell me what brought you in today.", "nextNodeId": "chief_concern" },
|
||||
{ "id": "ask_current_problem", "text": "What has been happening recently?", "nextNodeId": "chief_concern" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chief_concern",
|
||||
"patientText": "I have been vomiting for two days, my stomach hurts, and I have become more and more tired.",
|
||||
"isTerminal": false,
|
||||
"choices": [
|
||||
{ "id": "ask_insulin_use", "text": "Have you been taking your insulin?", "nextNodeId": "insulin_history" },
|
||||
{ "id": "ask_diabetes_treatment", "text": "How have you managed your diabetes during this illness?", "nextNodeId": "insulin_history" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "insulin_history",
|
||||
"patientText": "I stopped all of my insulin about a day and a half ago because I was not eating.",
|
||||
"isTerminal": false,
|
||||
"choices": [
|
||||
{ "id": "ask_associated_symptoms", "text": "Have you noticed any other symptoms?", "nextNodeId": "associated_symptoms" },
|
||||
{ "id": "ask_infection_symptoms", "text": "Any fever, cough, urinary symptoms, or diarrhea?", "nextNodeId": "associated_symptoms" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "associated_symptoms",
|
||||
"patientText": "No cough, burning with urination, or diarrhea. I do not think I have had a fever.",
|
||||
"isTerminal": false,
|
||||
"choices": [
|
||||
{ "id": "ask_medical_history", "text": "Tell me about your medical history and medications.", "nextNodeId": "background" },
|
||||
{ "id": "ask_allergies", "text": "Do you have kidney problems, take other medicines, or have medication allergies?", "nextNodeId": "background" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "background",
|
||||
"patientText": "I have type 1 diabetes and normally use long-acting and mealtime insulin. I have no kidney disease and no known drug allergies.",
|
||||
"isTerminal": false,
|
||||
"choices": [
|
||||
{ "id": "finish_interview", "text": "Thank you. I am going to examine you and begin your evaluation.", "nextNodeId": "interview_complete" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "interview_complete",
|
||||
"patientText": "Okay, doctor.",
|
||||
"isTerminal": true,
|
||||
"choices": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"allowedLocations": ["emergency_department", "icu", "ward"],
|
||||
"exams": [
|
||||
{ "code": "general_exam", "name": "General and hydration examination", "durationMinutes": 1, "finding": "Lethargic but arousable; dry mucous membranes, delayed capillary refill, and poor skin turgor." },
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"orders": [
|
||||
{ "code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "aliases": ["cbc", "blood count", "hemoglobin"], "defaultTurnaroundMinutes": 20, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
|
||||
{ "code": "cmp", "canonicalName": "Comprehensive metabolic panel", "category": "laboratory", "aliases": ["cmp", "chemistry", "bun", "creatinine"], "defaultTurnaroundMinutes": 25, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
|
||||
{ "code": "iron_studies", "canonicalName": "Iron studies and ferritin", "category": "laboratory", "aliases": ["iron", "ferritin", "transferrin saturation"], "defaultTurnaroundMinutes": 30, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
|
||||
{ "code": "h_pylori_breath", "canonicalName": "Helicobacter pylori urea breath test", "category": "diagnostic", "aliases": ["h pylori", "urea breath test", "helicobacter"], "defaultTurnaroundMinutes": 45, "availableLocations": ["office"], "displayRoute": "Breath", "displayFrequency": "Once", "resultSection": "other_test" },
|
||||
{ "code": "upper_endoscopy", "canonicalName": "Upper endoscopy with biopsy", "category": "procedure", "aliases": ["egd", "endoscopy", "gastroscopy", "biopsy"], "defaultTurnaroundMinutes": 60, "availableLocations": ["office"], "displayRoute": "Endoscopic", "displayFrequency": "Once", "resultSection": "other_test" },
|
||||
{ "code": "omeprazole", "canonicalName": "Omeprazole acid suppression", "category": "medication", "aliases": ["ppi", "proton pump inhibitor", "acid suppression"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Oral", "displayFrequency": "Twice daily", "resultSection": "treatment" },
|
||||
{ "code": "bismuth_quadruple", "canonicalName": "Optimized bismuth quadruple therapy for 14 days", "category": "medication", "aliases": ["h pylori treatment", "bismuth", "tetracycline", "metronidazole"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Oral", "displayFrequency": "14-day course", "resultSection": "treatment" },
|
||||
{ "code": "h_pylori_test_of_cure", "canonicalName": "H. pylori test of cure after therapy", "category": "diagnostic", "aliases": ["test of cure", "repeat breath test", "eradication testing"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Outpatient", "displayFrequency": "At least 4 weeks after therapy", "resultSection": "other_test" },
|
||||
{ "code": "gastro_follow_up", "canonicalName": "Gastroenterology follow-up", "category": "disposition", "aliases": ["gi follow up", "gastroenterology", "follow-up"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Outpatient", "displayFrequency": "Follow-up", "resultSection": "treatment" },
|
||||
{ "code": "abdominal_ct", "canonicalName": "CT abdomen and pelvis with contrast", "category": "imaging", "aliases": ["ct abdomen", "cat scan"], "defaultTurnaroundMinutes": 60, "availableLocations": [], "displayRoute": "Imaging", "displayFrequency": "Once", "resultSection": "imaging" },
|
||||
{ "code": "clarithromycin_triple", "canonicalName": "Empiric clarithromycin triple therapy", "category": "medication", "aliases": ["clarithromycin", "triple therapy", "prevpac"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Oral", "displayFrequency": "14-day course", "resultSection": "treatment" },
|
||||
{ "code": "ibuprofen", "canonicalName": "Ibuprofen for abdominal pain", "category": "medication", "aliases": ["nsaid", "motrin", "advil"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Oral", "displayFrequency": "As needed", "resultSection": "treatment" }
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"externalId": "ccs-gastro-duodenal-ulcer-office-001",
|
||||
"isPublished": true,
|
||||
"title": "Office Visit — 44-year-old man",
|
||||
"system": "Gastrointestinal System",
|
||||
"presentingLocation": "office",
|
||||
"realTimeMinutes": 10,
|
||||
"difficulty": 1,
|
||||
"tags": ["history taking", "dyspepsia", "duodenal ulcer", "Helicobacter pylori"],
|
||||
"experienceMode": "graphical_interview",
|
||||
"graphicalPresentation": {
|
||||
"sceneKey": "doctor_office",
|
||||
"patientAssetKey": "patient_44_white_male",
|
||||
"interviewRevision": 4
|
||||
},
|
||||
"initialChart": {
|
||||
"patientSummary": "44-year-old man attending an office visit",
|
||||
"reasonForEncounter": "The reason for the visit has not yet been elicited.",
|
||||
"appearance": "He is seated comfortably, attentive, and in no acute distress.",
|
||||
"history": "Several weeks of intermittent lightheadedness and fatigue accompany three weeks of sleep-disrupting, burning epigastric discomfort rated 4/10. Food and antacids provide temporary relief. He reports belching, fullness, and occasional black stool without frank blood. He has hyperlipidemia and no recent sick contacts, travel, or antibiotic use.",
|
||||
"historySections": [
|
||||
{ "code": "chief_concern", "title": "Chief concern", "text": "Intermittent lightheadedness and fatigue for several weeks." },
|
||||
{ "code": "abdominal_symptoms", "title": "Abdominal symptoms", "text": "Burning, non-radiating epigastric discomfort has been present for three weeks, is rated 4/10, is unaffected by position or movement, and improves temporarily after food or antacids." },
|
||||
{ "code": "associated_symptoms", "title": "Associated symptoms", "text": "Belching, fullness, sleep disruption, and occasional black stool without frank blood." },
|
||||
{ "code": "past_medical_history", "title": "Past medical history", "text": "Hyperlipidemia" },
|
||||
{ "code": "exposure_history", "title": "Exposure history", "text": "No recent sick contacts, travel, or antibiotic use." }
|
||||
],
|
||||
"vitals": {
|
||||
"temperature": "36.8 C (98.2 F)",
|
||||
"bloodPressure": "122/76 mm Hg",
|
||||
"pulse": "76/min",
|
||||
"respirations": "14/min",
|
||||
"oxygenSaturation": "99% on room air"
|
||||
},
|
||||
"simulatedStartUtc": "2026-08-05T09:00:00Z",
|
||||
"initialOrderCodes": []
|
||||
},
|
||||
"initialInterview": {
|
||||
"openingPatientText": "Good morning, doctor.",
|
||||
"finishChoiceText": "Thank you. I have enough history for now.",
|
||||
"closingPatientText": "Thank you, doctor. I hope we can figure out what has been going on.",
|
||||
"topics": [
|
||||
{
|
||||
"id": "chief_concern",
|
||||
"clinicianText": "Good morning. What brings you in today?",
|
||||
"patientText": "For the past few weeks, I have felt worn out, and every so often I get lightheaded—especially when I stand up quickly.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"id": "dizziness_character",
|
||||
"clinicianText": "Tell me more about the dizziness. Does the room spin, and have you fainted?",
|
||||
"patientText": "The room does not spin. It is more like I might black out for a second when I get up, but I have never actually fainted. I have had no weakness, numbness, trouble speaking, chest pain, or racing heartbeat.",
|
||||
"required": true,
|
||||
"prerequisiteTopicIds": ["chief_concern"]
|
||||
},
|
||||
{
|
||||
"id": "fatigue_course",
|
||||
"clinicianText": "How has the fatigue affected you, and how have you been sleeping and eating?",
|
||||
"patientText": "My energy is down by the afternoon. My appetite is about the same, but lately a burning discomfort high in my abdomen sometimes wakes me at night.",
|
||||
"required": true,
|
||||
"prerequisiteTopicIds": ["chief_concern"]
|
||||
},
|
||||
{
|
||||
"id": "abdominal_character",
|
||||
"clinicianText": "Tell me more about the abdominal discomfort—where is it, what does it feel like, and how long has it been happening?",
|
||||
"patientText": "It started about three weeks ago. It is a burning pain in the upper middle part of my abdomen. It does not spread anywhere, I would rate it about a four out of ten, and changing position or moving does not make it better or worse.",
|
||||
"required": true,
|
||||
"prerequisiteTopicIds": ["fatigue_course"]
|
||||
},
|
||||
{
|
||||
"id": "relieving_factors",
|
||||
"clinicianText": "Does anything make the discomfort better or worse?",
|
||||
"patientText": "Eating usually settles it for a while, and antacids help too. Position and movement do not seem to change it.",
|
||||
"required": true,
|
||||
"prerequisiteTopicIds": ["abdominal_character"]
|
||||
},
|
||||
{
|
||||
"id": "associated_gi",
|
||||
"clinicianText": "Have you noticed belching, fullness, vomiting, or any blood or change in the color of your stool?",
|
||||
"patientText": "I have been belching more and sometimes feel unusually full. I have not vomited or seen red blood, but my stool has looked black on occasion.",
|
||||
"required": true,
|
||||
"prerequisiteTopicIds": ["abdominal_character"]
|
||||
},
|
||||
{
|
||||
"id": "exposure_history",
|
||||
"clinicianText": "Have you had any sick contacts, recent travel, or antibiotic use?",
|
||||
"patientText": "No. I have not been around anyone sick, traveled recently, or taken antibiotics.",
|
||||
"required": true,
|
||||
"prerequisiteTopicIds": ["abdominal_character"]
|
||||
},
|
||||
{
|
||||
"id": "past_medical_history",
|
||||
"clinicianText": "Do you have any past medical conditions?",
|
||||
"patientText": "A few years ago, my doctor told me my cholesterol was high. Other than that, I have not been diagnosed with any major medical problems.",
|
||||
"required": false,
|
||||
"prerequisiteTopicIds": ["chief_concern"]
|
||||
}
|
||||
],
|
||||
"debrief": {
|
||||
"likelyDiagnosis": "Peptic ulcer disease with possible intermittent upper gastrointestinal bleeding",
|
||||
"summary": "The history identifies orthostatic lightheadedness and fatigue accompanying burning epigastric pain relieved by food and antacids, with occasional black stool concerning for intermittent upper gastrointestinal bleeding.",
|
||||
"teachingPoints": [
|
||||
"Clarify whether dizziness is vertigo, presyncope, or disequilibrium and screen for neurologic and cardiac warning symptoms.",
|
||||
"Characterize epigastric pain by timing, quality, severity, radiation, triggers, and relieving factors.",
|
||||
"Ask specifically about melena or hematemesis because occult or intermittent bleeding can present with fatigue and lightheadedness."
|
||||
]
|
||||
}
|
||||
},
|
||||
"allowedLocations": ["office"],
|
||||
"exams": [
|
||||
{ "code": "physical_appearance", "name": "Physical Appearance", "durationMinutes": 1, "finding": "The patient is alert, cooperative, and in no acute distress. He appears well nourished." },
|
||||
{ "code": "skin", "name": "Skin", "durationMinutes": 1, "finding": "Skin is warm and dry without rash, jaundice, bruising, or visible pallor." },
|
||||
{ "code": "breasts", "name": "Breasts", "durationMinutes": 1, "finding": "With the patient's consent and a chaperone present, examination shows no masses, tenderness, skin changes, or nipple discharge." },
|
||||
{ "code": "lymph_nodes", "name": "Lymph Nodes", "durationMinutes": 1, "finding": "No cervical, supraclavicular, axillary, or inguinal lymphadenopathy is present." },
|
||||
{ "code": "heent_neck", "name": "HEENT / Neck", "durationMinutes": 1, "finding": "Conjunctivae and oral mucosa are normal. Oropharynx is clear, neck is supple, and there is no thyromegaly or jugular venous distention." },
|
||||
{ "code": "chest_lungs", "name": "Chest / Lungs", "durationMinutes": 1, "finding": "Breathing is unlabored. Lungs are clear to auscultation bilaterally without wheezes, crackles, or rhonchi." },
|
||||
{ "code": "heart_cardiovascular", "name": "Heart / Cardiovascular", "durationMinutes": 1, "finding": "Heart has a regular rate and rhythm with normal S1 and S2 and no murmurs, rubs, or gallops. Peripheral pulses are symmetric." },
|
||||
{ "code": "abdomen", "name": "Abdomen", "durationMinutes": 1, "finding": "The abdomen is soft and nondistended with mild epigastric tenderness. There is no guarding, rebound tenderness, palpable mass, or hepatosplenomegaly. Bowel sounds are normal." },
|
||||
{ "code": "genitalia", "name": "Genitalia", "durationMinutes": 1, "finding": "With the patient's consent and a chaperone present, external genital examination is normal without lesions, masses, tenderness, or hernia." },
|
||||
{ "code": "rectal", "name": "Rectal", "durationMinutes": 1, "finding": "With the patient's consent and a chaperone present, rectal tone is normal and no mass or tenderness is present. Brown stool without gross blood is noted." },
|
||||
{ "code": "extremities_spine", "name": "Extremities / Spine", "durationMinutes": 1, "finding": "Extremities have full range of motion without edema, clubbing, cyanosis, or joint swelling. The spine is nontender." },
|
||||
{ "code": "neuro_psych", "name": "Neuro / Psych", "durationMinutes": 1, "finding": "He is alert and fully oriented with normal speech, strength, sensation, coordination, gait, mood, and affect." }
|
||||
],
|
||||
"orderResponses": [
|
||||
{ "orderCode": "cbc", "turnaroundMinutes": 20, "performanceLocation": "outpatient_laboratory", "requiresPatientVisit": true, "result": "Hemoglobin is 10.4 g/dL, hematocrit 32%, MCV 74 fL, leukocyte count 7,100/mm3, and platelet count 312,000/mm3.", "resultSection": "lab" },
|
||||
{ "orderCode": "cmp", "turnaroundMinutes": 25, "performanceLocation": "outpatient_laboratory", "requiresPatientVisit": true, "result": "BUN is 27 mg/dL and creatinine 1.0 mg/dL. Electrolytes, glucose, aminotransferases, bilirubin, and albumin are within reference ranges.", "resultSection": "lab" },
|
||||
{ "orderCode": "iron_studies", "turnaroundMinutes": 30, "performanceLocation": "outpatient_laboratory", "requiresPatientVisit": true, "result": "Ferritin is 10 ng/mL, serum iron 31 mcg/dL, and transferrin saturation 9%, supporting iron deficiency.", "resultSection": "lab" },
|
||||
{ "orderCode": "h_pylori_breath", "turnaroundMinutes": 45, "performanceLocation": "office_diagnostic_area", "requiresPatientVisit": true, "result": "Urea breath test is positive for Helicobacter pylori.", "resultSection": "other_test" },
|
||||
{ "orderCode": "upper_endoscopy", "turnaroundMinutes": 60, "performanceLocation": "endoscopy_suite", "requiresPatientVisit": true, "result": "Upper endoscopy shows an 8 mm clean-based ulcer in the duodenal bulb without active bleeding. Gastric biopsy is positive for H. pylori and shows no dysplasia or malignancy.", "resultSection": "other_test" },
|
||||
{ "orderCode": "omeprazole", "turnaroundMinutes": 0, "result": "Proton-pump inhibitor therapy is started.", "resultSection": "treatment" },
|
||||
{ "orderCode": "bismuth_quadruple", "turnaroundMinutes": 0, "result": "A 14-day optimized bismuth quadruple regimen is prescribed.", "resultSection": "treatment" },
|
||||
{ "orderCode": "h_pylori_test_of_cure", "turnaroundMinutes": 0, "result": "Test-of-cure follow-up is scheduled for at least four weeks after therapy, with PPI held for two weeks before testing.", "resultSection": "treatment" },
|
||||
{ "orderCode": "gastro_follow_up", "turnaroundMinutes": 0, "result": "Gastroenterology follow-up is arranged to review healing, anemia, and eradication testing.", "resultSection": "treatment" },
|
||||
{ "orderCode": "abdominal_ct", "turnaroundMinutes": 60, "performanceLocation": "imaging_center", "requiresPatientVisit": true, "result": "CT of the abdomen and pelvis shows no acute intra-abdominal abnormality.", "resultSection": "imaging" },
|
||||
{ "orderCode": "clarithromycin_triple", "turnaroundMinutes": 0, "result": "Empiric clarithromycin-based therapy is prescribed without susceptibility confirmation.", "resultSection": "treatment" },
|
||||
{ "orderCode": "ibuprofen", "turnaroundMinutes": 0, "result": "Ibuprofen is prescribed despite suspected peptic ulcer disease and possible gastrointestinal bleeding.", "resultSection": "treatment" }
|
||||
],
|
||||
"events": [],
|
||||
"scoringCriteria": [
|
||||
{ "id": "elicit_chief_concern", "title": "Elicit the chief concern", "actionType": "dialogue_choice", "targetCode": "chief_concern", "points": 10, "rationale": "An open-ended opening allows the patient to identify lightheadedness and fatigue in his own words." },
|
||||
{ "id": "characterize_dizziness", "title": "Characterize the dizziness", "actionType": "dialogue_choice", "targetCode": "dizziness_character", "points": 15, "rationale": "Distinguishing presyncope from vertigo and screening for neurologic and cardiac warning symptoms focuses the differential." },
|
||||
{ "id": "explore_fatigue", "title": "Explore fatigue, sleep, and intake", "actionType": "dialogue_choice", "targetCode": "fatigue_course", "points": 10, "rationale": "The course and associated symptoms help characterize the nonspecific fatigue." },
|
||||
{ "id": "characterize_abdominal_pain", "title": "Characterize the abdominal discomfort", "actionType": "dialogue_choice", "targetCode": "abdominal_character", "points": 15, "rationale": "Location, quality, duration, severity, and radiation help characterize the source of pain." },
|
||||
{ "id": "identify_relief", "title": "Identify relieving and aggravating factors", "actionType": "dialogue_choice", "targetCode": "relieving_factors", "points": 10, "rationale": "Temporary relief with food or antacids supports an acid-related pattern." },
|
||||
{ "id": "screen_gi_bleeding", "title": "Screen for gastrointestinal bleeding", "actionType": "dialogue_choice", "targetCode": "associated_gi", "points": 15, "rationale": "Occasional black stool may represent melena and requires recognition." },
|
||||
{ "id": "review_exposures", "title": "Review relevant exposures", "actionType": "dialogue_choice", "targetCode": "exposure_history", "points": 5, "rationale": "Recent illness, travel, and antibiotic exposure help refine the gastrointestinal differential." },
|
||||
{ "id": "assess_appearance", "title": "Assess physical appearance", "actionType": "exam", "targetCode": "physical_appearance", "points": 5, "rationale": "General appearance helps assess acuity and physiologic compromise." },
|
||||
{ "id": "examine_skin", "title": "Examine the skin", "actionType": "exam", "targetCode": "skin", "points": 5, "rationale": "Skin examination screens for pallor, jaundice, bruising, and other systemic clues." },
|
||||
{ "id": "examine_lungs", "title": "Examine the chest and lungs", "actionType": "exam", "targetCode": "chest_lungs", "points": 5, "rationale": "A cardiopulmonary examination helps evaluate associated fatigue and lightheadedness." },
|
||||
{ "id": "examine_heart", "title": "Examine the cardiovascular system", "actionType": "exam", "targetCode": "heart_cardiovascular", "points": 5, "rationale": "Cardiovascular examination helps assess presyncope and hemodynamic status." },
|
||||
{ "id": "examine_abdomen", "title": "Examine the abdomen", "actionType": "exam", "targetCode": "abdomen", "points": 15, "rationale": "Focused abdominal examination identifies epigastric tenderness and screens for peritoneal signs or masses." },
|
||||
{ "id": "perform_rectal_exam", "title": "Perform a rectal examination", "actionType": "exam", "targetCode": "rectal", "points": 10, "rationale": "Rectal examination can assess for current gross gastrointestinal bleeding when the history includes black stool." },
|
||||
{ "id": "perform_neurologic_exam", "title": "Perform a neurologic examination", "actionType": "exam", "targetCode": "neuro_psych", "points": 5, "rationale": "A focused neurologic examination helps evaluate lightheadedness and exclude focal deficits." },
|
||||
{ "id": "check_cbc", "title": "Assess anemia with a CBC", "actionType": "order", "targetCode": "cbc", "points": 12, "rationale": "Fatigue, lightheadedness, and intermittent melena warrant assessment for blood-loss anemia." },
|
||||
{ "id": "check_iron", "title": "Confirm iron deficiency", "actionType": "order", "targetCode": "iron_studies", "points": 6, "rationale": "Iron studies characterize the microcytic anemia." },
|
||||
{ "id": "test_h_pylori", "title": "Test for H. pylori", "actionType": "order", "targetCode": "h_pylori_breath", "points": 12, "rationale": "H. pylori testing identifies a treatable cause of peptic ulcer disease." },
|
||||
{ "id": "perform_egd", "title": "Evaluate suspected bleeding with upper endoscopy", "actionType": "order", "targetCode": "upper_endoscopy", "points": 12, "rationale": "Endoscopy confirms the ulcer and evaluates bleeding risk." },
|
||||
{ "id": "start_ppi", "title": "Begin proton-pump inhibitor therapy", "actionType": "order", "targetCode": "omeprazole", "points": 8, "rationale": "Acid suppression promotes ulcer healing." },
|
||||
{ "id": "eradicate_h_pylori", "title": "Treat H. pylori appropriately", "actionType": "order", "targetCode": "bismuth_quadruple", "points": 15, "afterTargetCode": "h_pylori_breath", "rationale": "Optimized bismuth quadruple therapy is an appropriate empiric eradication regimen." },
|
||||
{ "id": "confirm_eradication", "title": "Arrange H. pylori test of cure", "actionType": "order", "targetCode": "h_pylori_test_of_cure", "points": 8, "afterTargetCode": "bismuth_quadruple", "rationale": "Eradication should be confirmed after treatment." },
|
||||
{ "id": "avoid_clarithromycin", "title": "Avoid unsupported clarithromycin triple therapy", "actionType": "order", "targetCode": "clarithromycin_triple", "points": -8, "rationale": "Empiric clarithromycin therapy should be avoided without demonstrated susceptibility." },
|
||||
{ "id": "avoid_nsaid", "title": "Avoid NSAIDs", "actionType": "order", "targetCode": "ibuprofen", "points": -12, "rationale": "NSAIDs may worsen ulcer injury and gastrointestinal bleeding." }
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"title": "Peptic Ulcer Disease",
|
||||
"publisher": "American College of Gastroenterology",
|
||||
"url": "https://gi.org/topics/peptic-ulcer-disease/",
|
||||
"supports": "Typical ulcer symptoms, bleeding warning symptoms, NSAID history, H. pylori evaluation, and endoscopic diagnosis",
|
||||
"accessedOn": "2026-08-05",
|
||||
"reviewer": "Question bank editorial review"
|
||||
},
|
||||
{
|
||||
"title": "ACG Guideline on Treatment of Helicobacter pylori: New Recommendations",
|
||||
"publisher": "American College of Gastroenterology",
|
||||
"url": "https://gi.org/journals-publications/ebgi/schoenfeld_sep2024/",
|
||||
"supports": "H. pylori treatment principles and confirmation of eradication",
|
||||
"accessedOn": "2026-08-05",
|
||||
"reviewer": "Question bank editorial review"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"validateOnly": false
|
||||
}
|
||||
@@ -4,9 +4,14 @@ CCS content is imported through `POST /api/ccs/admin/import` by a `SUPER` user.
|
||||
|
||||
Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validateOnly` to `true` first, then submit the same payload with `false`. Updating an existing `externalId` creates a new immutable revision; active and completed attempts remain attached to the revision they started with.
|
||||
|
||||
Topic-based graphical interviews use `initialInterview.openingPatientText` plus a `topics` array instead of `startNodeId` and `nodes`. Each topic supplies a stable id, clinician prompt, patient response, and optional `required` and `prerequisiteTopicIds` fields. Required topics gate `finish_interview`; visited topics are removed from the available choices. Set `experienceMode` to `graphical_interview` and provide a supported `graphicalPresentation` to complete in CCS Cases 2 with an authored debrief instead of handing off to classic patient management.
|
||||
|
||||
UAT deployment promotes `content/ccs/duodenal-ulcer-office-001.json` through the idempotent `promote-ccs-cases` command. An unchanged case is skipped; changed content creates the next immutable revision.
|
||||
|
||||
## Case structure
|
||||
|
||||
- `initialChart` defines the opening history, appearance, vitals, simulated start, and standing orders. Use ordered `historySections` (`code`, `title`, `text`) for the HPI, medical history, medications, allergies, family/social history, and review of systems. The legacy `history` field remains the fallback.
|
||||
- `initialInterview` optionally defines the graphical office interview. It contains a `startNodeId` and an acyclic set of dialogue nodes. Each node has stable `id`, authored `patientText`, `isTerminal`, and clinician `choices` with globally unique IDs and valid `nextNodeId` references. Every branch must reach a terminal node.
|
||||
- `allowedLocations` controls patient movement.
|
||||
- `exams` defines selectable examination components and their simulated duration.
|
||||
- Catalog orders may define `displayRoute`, `displayFrequency`, and `resultSection`. These are presentation defaults, not user-entered dosing instructions.
|
||||
@@ -23,4 +28,6 @@ Valid result sections are `lab`, `imaging`, `other_test`, and `treatment`; progr
|
||||
|
||||
New CCS attempts are timed. `realTimeMinutes` is the total wall-clock allowance: the runtime reserves its final two minutes for final orders, so a 20-minute case has 18 minutes of active management plus two minutes of case-end orders. Opening dialogs count against this limit and reconnecting does not pause it.
|
||||
|
||||
Graphical interview choices are persisted as attempt actions and support reconnect/resume. They consume real deadline time but do not advance simulated time or contribute directly to scoring. Cases without `initialInterview` remain available in classic CCS and are omitted from CCS Cases 2.
|
||||
|
||||
Only the first completed timed attempt for each distinct case contributes to the CCS estimate. Legacy practice attempts remain reviewable but cannot be newly created. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula.
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# Question bank gamification
|
||||
|
||||
## Objective
|
||||
|
||||
Make studying easy to begin, satisfying to continue, and valuable to repeat while
|
||||
optimizing for durable learning rather than compulsive use. The core engagement
|
||||
loop is:
|
||||
|
||||
```text
|
||||
Answer -> immediate insight -> visible progress -> useful reward -> clear next goal
|
||||
```
|
||||
|
||||
Success means learners return consistently, improve weak areas, complete spaced
|
||||
reviews, and become more exam-ready. Raw time in the application is not itself a
|
||||
success metric.
|
||||
|
||||
## Product principles
|
||||
|
||||
- Reward demonstrated learning, recovery from errors, and breadth of coverage.
|
||||
- Make reward rules understandable and resistant to farming.
|
||||
- Give every study session a user-selected finish line and satisfying conclusion.
|
||||
- Keep stopping controls as prominent as continuation controls.
|
||||
- Do not lock core questions, explanations, or remediation behind RZ Coin.
|
||||
- Prefer weekly consistency over an indefinitely escalating daily streak.
|
||||
- Keep social participation optional and private by default.
|
||||
- Never use paid randomized rewards, near-miss effects, disguised odds, or
|
||||
loss-heavy messaging.
|
||||
|
||||
## Mastery paths
|
||||
|
||||
Show mastery for each system, topic, and tested concept. Mastery should combine:
|
||||
|
||||
- First-attempt correctness
|
||||
- Question difficulty
|
||||
- Recency and spaced-review status
|
||||
- Successful recovery after an incorrect answer
|
||||
- Breadth across related concepts
|
||||
|
||||
Mastery must not be a reskinned count of questions answered. Each topic should
|
||||
show its current state, evidence behind that state, and the next useful action.
|
||||
Suggested states are `Unseen`, `Learning`, `Practicing`, `Strong`, and `Mastered`.
|
||||
|
||||
The dashboard should highlight progress in weak areas and recently mastered
|
||||
concepts. A system cannot reach its highest rank by repeatedly farming a narrow
|
||||
set of easy questions.
|
||||
|
||||
## RZ Coin economy
|
||||
|
||||
Award RZ Coin for learning behaviors:
|
||||
|
||||
- A first correct attempt
|
||||
- Correctly answering a genuinely difficult question
|
||||
- Completing a scheduled review
|
||||
- Converting a previously missed concept into a correct delayed review
|
||||
- Improving a weak topic
|
||||
- Completing a timed or simulated-exam block
|
||||
- Meeting a balanced daily or weekly study goal
|
||||
|
||||
Do not primarily reward elapsed time, retries, repeated easy questions, or raw
|
||||
volume. Apply diminishing or zero rewards to repeated content where necessary.
|
||||
All awards should be server-authoritative, idempotent, and recorded in the coin
|
||||
ledger with a reason code.
|
||||
|
||||
Appropriate purchases include:
|
||||
|
||||
- Question-bank reset tokens
|
||||
- Cosmetic themes, avatars, profile frames, titles, and study environments
|
||||
- Optional analytics presentations
|
||||
- Limited streak protection
|
||||
- Collectible medical artifacts and achievement displays
|
||||
|
||||
Purchases must disclose their exact price and effect. Randomized paid purchases
|
||||
and purchasable power over exam scoring or question correctness are prohibited.
|
||||
|
||||
## Daily and weekly missions
|
||||
|
||||
Offer three short daily missions that reflect the learner's needs, for example:
|
||||
|
||||
- Complete 10 scheduled reviews.
|
||||
- Answer five difficulty-4 questions.
|
||||
- Improve one weak system.
|
||||
- Complete a timed block without skipping.
|
||||
|
||||
Allow a learner to replace a mission that does not fit the current study plan.
|
||||
Missions should not require excessive daily volume or encourage answering after
|
||||
fatigue has clearly degraded performance.
|
||||
|
||||
Weekly goals should emphasize consistency and breadth, such as studying on four
|
||||
days, reviewing three systems, or clearing a reasonable number of overdue
|
||||
reviews. A flexible weekly target is preferred to an endless daily streak.
|
||||
|
||||
## Session goals and completion
|
||||
|
||||
At the beginning of a session, let the learner choose a finish line:
|
||||
|
||||
- A number of questions
|
||||
- A study duration
|
||||
- One weak topic
|
||||
- One simulated-exam block
|
||||
- All currently due reviews, subject to a reasonable cap
|
||||
|
||||
At the finish line, present a completion screen before offering more work. The
|
||||
primary actions should give equal visual weight to `Finish for today` and a
|
||||
clearly sized optional next block. Do not create artificial unfinished states to
|
||||
pressure continuation.
|
||||
|
||||
The end-of-session summary should include:
|
||||
|
||||
- Questions completed and first-attempt accuracy
|
||||
- Concepts newly mastered
|
||||
- Previously missed concepts recovered
|
||||
- Weak areas improved
|
||||
- RZ Coin earned, with award reasons
|
||||
- Readiness change, with an explanation of how it was calculated
|
||||
- A recommended next session
|
||||
|
||||
## Progression and personal records
|
||||
|
||||
Introduce skill-based ranks such as `Intern`, `Resident`, `Chief`, and
|
||||
`Attending`. Rank advancement should require mastery across several systems,
|
||||
successful delayed reviews, and performance on appropriately difficult items.
|
||||
It must not depend only on account age or total answers.
|
||||
|
||||
Personal records should measure meaningful improvement:
|
||||
|
||||
- Best timed-block accuracy at a defined difficulty mix
|
||||
- Longest first-attempt correct streak at difficulty 4 or higher
|
||||
- Most concepts mastered in a week
|
||||
- Largest reduction in overdue reviews
|
||||
- Greatest improvement in a previously weak content area
|
||||
|
||||
Record definitions must remain stable and visible so the learner understands
|
||||
what was achieved.
|
||||
|
||||
## Social features
|
||||
|
||||
Potential opt-in features include private study groups, cooperative weekly
|
||||
goals, friend challenges, and anonymous percentile bands. Prefer cooperative
|
||||
goals over leaderboards based on raw volume.
|
||||
|
||||
Do not expose failure, study time, predicted score, or inactivity publicly by
|
||||
default. Users must be able to leave a group, hide a statistic, and disable
|
||||
social notifications.
|
||||
|
||||
## Notifications and streak safeguards
|
||||
|
||||
- Let users choose notification categories, quiet hours, and frequency.
|
||||
- Use factual reminders rather than guilt, urgency, or threatened loss.
|
||||
- Allow recovery days and planned breaks.
|
||||
- Cap streak-protection use and describe it transparently.
|
||||
- Never send repeated prompts after a learner chooses to finish a session.
|
||||
- Detect unusually long sessions and offer a neutral break reminder.
|
||||
|
||||
## Recommended delivery order
|
||||
|
||||
### Phase 1: foundation
|
||||
|
||||
- [x] Define the mastery model and expose topic-level progress.
|
||||
- [x] Add server-authoritative RZ Coin awards for approved learning events.
|
||||
- [x] Add user-selected session goals and an end-of-session summary.
|
||||
- [x] Add daily missions with one replacement per day.
|
||||
|
||||
### Phase 2: progression
|
||||
|
||||
1. [x] Add weekly consistency goals.
|
||||
2. [x] Add breadth-based ranks and personal records.
|
||||
3. Expand the RZ Coin store with cosmetic and utility items.
|
||||
4. Add limited streak protection and recovery days.
|
||||
|
||||
### Phase 3: social and optimization
|
||||
|
||||
1. Add private cooperative groups and friend challenges.
|
||||
2. Personalize missions using weak-area and spaced-review data.
|
||||
3. Experiment with presentation and pacing under the guardrails below.
|
||||
|
||||
## Measurement
|
||||
|
||||
Primary metrics should measure learning and healthy retention:
|
||||
|
||||
- Weekly active learners completing at least one meaningful study goal
|
||||
- Scheduled-review completion rate
|
||||
- Delayed retention after 7, 21, and 45 days
|
||||
- Recovery rate for previously missed concepts
|
||||
- Breadth of systems studied
|
||||
- Timed-block and simulation improvement
|
||||
- Percentage of sessions ending at the learner's selected finish line
|
||||
|
||||
Monitor guardrail metrics alongside product experiments:
|
||||
|
||||
- Extremely long sessions and late-night continuation
|
||||
- Rapid repetitive answering consistent with coin farming
|
||||
- Notification opt-out and mute rates
|
||||
- Mission abandonment after oversized goals
|
||||
- Increased accuracy volatility associated with fatigue
|
||||
- Support reports involving pressure, anxiety, or confusing rewards
|
||||
|
||||
Do not optimize an experiment solely for session length, questions per session,
|
||||
notification opens, or coin spending. A change should improve at least one
|
||||
learning or healthy-retention metric without materially degrading the guardrails.
|
||||
|
||||
## Initial acceptance criteria
|
||||
|
||||
The first release is complete when:
|
||||
|
||||
- Mastery is computed from correctness, difficulty, recency, and review outcomes.
|
||||
- Coin awards are idempotent and cannot be earned repeatedly from the same event.
|
||||
- Every award and purchase has a ledger entry and visible reason.
|
||||
- A learner can set and complete a bounded session goal.
|
||||
- The completion screen offers both stopping and continuing without deceptive
|
||||
emphasis.
|
||||
- Daily missions target due reviews or genuine coverage gaps and can be replaced.
|
||||
- Core educational content remains available without coin purchases.
|
||||
- Product analytics include the primary and guardrail metrics above.
|
||||
@@ -1,19 +1,22 @@
|
||||
# Question-completion credits
|
||||
|
||||
This branch introduces credit earning only. It does not impose a question
|
||||
limit, redeem credits, sell products, activate passes, consume exam tokens, or
|
||||
reset QBank progress.
|
||||
RZ Coin is awarded by the API for verified learning outcomes. The client displays
|
||||
award results but cannot choose award amounts or reason codes.
|
||||
|
||||
- A successfully stored first answer earns one credit regardless of whether it
|
||||
is correct.
|
||||
- A repeated answer does not earn another credit.
|
||||
- A skipped question does not earn credit.
|
||||
- At most 20 credits can be earned in a rolling 24-hour window.
|
||||
- A first correct answer earns 1 coin at difficulty 1-3, 2 at difficulty 4,
|
||||
and 3 at difficulty 5.
|
||||
- A correct scheduled review earns 1 coin, or 3 when it recovers a miss after
|
||||
at least 24 hours.
|
||||
- First-time system promotions to Practicing, Strong, and Mastered earn 3, 5,
|
||||
and 10 coins. Completing a real exam simulation earns 10 coins.
|
||||
- Incorrect answers, skips, repeated submissions, and sample simulations earn nothing.
|
||||
- Repeatable question and review rewards are capped at 30 coins in a rolling
|
||||
24-hour window; milestone and simulation awards are outside that cap.
|
||||
- Historical attempts are not backfilled.
|
||||
- `GET /api/credits` returns the authenticated user's balance and current
|
||||
rolling-window capacity.
|
||||
- The first-attempt response also includes the updated credit status.
|
||||
|
||||
The API is authoritative. Credit grants use the immutable attempt ID as their
|
||||
idempotency key, so retrying the same completed question cannot grant another
|
||||
credit. Credit accounts are included in account export and deletion.
|
||||
The API is authoritative. Immutable attempt, review, simulation, and mastery
|
||||
identifiers make grants idempotent. Awards and purchases appear in the user's
|
||||
coin ledger. Credit accounts are included in account export and deletion.
|
||||
|
||||
@@ -40,6 +40,9 @@ BookmarkService="*res://scripts/autoload/bookmark_service.gd"
|
||||
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
||||
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
||||
CreditsService="*res://scripts/autoload/credits_service.gd"
|
||||
StudySessionService="*res://scripts/autoload/study_session_service.gd"
|
||||
DailyMissionService="*res://scripts/autoload/daily_mission_service.gd"
|
||||
WeeklyGoalService="*res://scripts/autoload/weekly_goal_service.gd"
|
||||
FlashcardService="*res://scripts/autoload/flashcard_service.gd"
|
||||
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
|
||||
DonationService="*res://scripts/autoload/donation_service.gd"
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
[gd_scene load_steps=14 format=3]
|
||||
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/office-backdrop.png" id="1_backdrop"]
|
||||
[ext_resource type="Script" path="res://scripts/ui/ccs_cases_2/doctor_office.gd" id="2_script"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/clock.png" id="3_face"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/patient-44-white-male.png" id="4_patient"]
|
||||
[ext_resource type="Shader" path="res://shaders/ccs_cases_2/patient_idle.gdshader" id="5_patient_fit"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/chat-bubble.png" id="6_chat_bubble"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/computer/computer-base.png" id="7_computer"]
|
||||
[ext_resource type="Texture2D" path="res://assets/icons/lucide/monitor.svg" id="8_monitor"]
|
||||
[ext_resource type="Texture2D" path="res://assets/icons/lucide/x.svg" id="9_close"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/icons/vitals.svg" id="10_vitals"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/icons/medical-history.svg" id="11_history"]
|
||||
[ext_resource type="Texture2D" path="res://assets/icons/lucide/clipboard-list.svg" id="12_orders"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"]
|
||||
shader = ExtResource("5_patient_fit")
|
||||
shader_parameter/patient_scale = 0.82
|
||||
shader_parameter/anchor = Vector2(0.5, 0.985)
|
||||
shader_parameter/patient_offset = Vector2(0, 0)
|
||||
shader_parameter/breathing_speed = 1.0
|
||||
shader_parameter/breathing_amount = 0.012
|
||||
shader_parameter/blink_amount = 0.0
|
||||
|
||||
[node name="DoctorScene" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("2_script")
|
||||
|
||||
[node name="Backdrop" type="TextureRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
texture = ExtResource("1_backdrop")
|
||||
expand_mode = 1
|
||||
stretch_mode = 6
|
||||
|
||||
[node name="Patient" type="TextureRect" parent="."]
|
||||
material = SubResource("ShaderMaterial_patient_fit")
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
texture = ExtResource("4_patient")
|
||||
expand_mode = 1
|
||||
stretch_mode = 6
|
||||
metadata/age = 44
|
||||
metadata/sex = "male"
|
||||
metadata/ethnicity = "white"
|
||||
metadata/demographic_label = "44-year-old white male"
|
||||
|
||||
[node name="ClockMount" type="Control" parent="."]
|
||||
z_index = 10
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
scale = Vector2(0.75, 0.75)
|
||||
anchor_left = 0.695
|
||||
anchor_top = 0.145
|
||||
anchor_right = 0.695
|
||||
anchor_bottom = 0.145
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Face" type="Sprite2D" parent="ClockMount"]
|
||||
position = Vector2(0, 1.2)
|
||||
scale = Vector2(0.095, 0.095)
|
||||
texture = ExtResource("3_face")
|
||||
|
||||
[node name="HourHand" type="Node2D" parent="ClockMount"]
|
||||
|
||||
[node name="Shape" type="Polygon2D" parent="ClockMount/HourHand"]
|
||||
polygon = PackedVector2Array(-3.2, 5, -2.5, -23, 0, -29, 2.5, -23, 3.2, 5)
|
||||
color = Color(0.035, 0.055, 0.08, 1)
|
||||
antialiased = true
|
||||
|
||||
[node name="MinuteHand" type="Node2D" parent="ClockMount"]
|
||||
|
||||
[node name="Shape" type="Polygon2D" parent="ClockMount/MinuteHand"]
|
||||
polygon = PackedVector2Array(-2.4, 6, -1.7, -34, 0, -40, 1.7, -34, 2.4, 6)
|
||||
color = Color(0.035, 0.055, 0.08, 1)
|
||||
antialiased = true
|
||||
|
||||
[node name="SecondHand" type="Node2D" parent="ClockMount"]
|
||||
|
||||
[node name="Shape" type="Polygon2D" parent="ClockMount/SecondHand"]
|
||||
polygon = PackedVector2Array(-0.9, 8, -0.8, -38, 0, -43, 0.8, -38, 0.9, 8)
|
||||
color = Color(0.55, 0.09, 0.065, 1)
|
||||
antialiased = true
|
||||
|
||||
[node name="CenterCap" type="Polygon2D" parent="ClockMount"]
|
||||
polygon = PackedVector2Array(3.8, 0, 3.29, 1.9, 1.9, 3.29, 0, 3.8, -1.9, 3.29, -3.29, 1.9, -3.8, 0, -3.29, -1.9, -1.9, -3.29, 0, -3.8, 1.9, -3.29, 3.29, -1.9)
|
||||
color = Color(0.04, 0.055, 0.075, 1)
|
||||
antialiased = true
|
||||
|
||||
[node name="CenterPin" type="Polygon2D" parent="ClockMount"]
|
||||
polygon = PackedVector2Array(1.8, 0, 1.27, 1.27, 0, 1.8, -1.27, 1.27, -1.8, 0, -1.27, -1.27, 0, -1.8, 1.27, -1.27)
|
||||
color = Color(0.9, 0.64, 0.19, 1)
|
||||
antialiased = true
|
||||
|
||||
[node name="DialogueBubble" type="Control" parent="."]
|
||||
z_index = 30
|
||||
clip_contents = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchor_left = 0.53
|
||||
anchor_top = 0.04
|
||||
anchor_right = 0.53
|
||||
anchor_bottom = 0.04
|
||||
offset_right = 650.0
|
||||
offset_bottom = 220.0
|
||||
mouse_default_cursor_shape = 2
|
||||
focus_mode = 2
|
||||
|
||||
[node name="Background" type="TextureRect" parent="DialogueBubble"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
self_modulate = Color(0.92, 0.89, 0.82, 0.86)
|
||||
texture = ExtResource("6_chat_bubble")
|
||||
expand_mode = 1
|
||||
stretch_mode = 0
|
||||
|
||||
[node name="Text" type="RichTextLabel" parent="DialogueBubble"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 96.0
|
||||
offset_top = 31.0
|
||||
offset_right = -64.0
|
||||
offset_bottom = -64.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_colors/font_color = Color(0.055, 0.105, 0.145, 1)
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "Hello, doctor."
|
||||
fit_content = false
|
||||
scroll_active = false
|
||||
autowrap_mode = 2
|
||||
visible_characters_behavior = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="ContinueHint" type="Label" parent="DialogueBubble"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 3
|
||||
anchor_left = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -330.0
|
||||
offset_top = -55.0
|
||||
offset_right = -90.0
|
||||
offset_bottom = -27.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.12, 0.22, 0.28, 0.82)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "Click to continue"
|
||||
horizontal_alignment = 2
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="DialogueChoiceArea" type="Control" parent="."]
|
||||
z_index = 29
|
||||
layout_mode = 1
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 18.0
|
||||
offset_top = -194.0
|
||||
offset_right = -18.0
|
||||
offset_bottom = -4.0
|
||||
|
||||
[node name="ChoicePager" type="HBoxContainer" parent="DialogueChoiceArea"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
offset_bottom = 42.0
|
||||
grow_horizontal = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 1
|
||||
|
||||
[node name="Previous" type="Button" parent="DialogueChoiceArea/ChoicePager"]
|
||||
custom_minimum_size = Vector2(108, 38)
|
||||
layout_mode = 2
|
||||
text = "Previous"
|
||||
|
||||
[node name="Page" type="Label" parent="DialogueChoiceArea/ChoicePager"]
|
||||
custom_minimum_size = Vector2(100, 38)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.94, 0.95, 0.91, 1)
|
||||
theme_override_colors/font_shadow_color = Color(0.02, 0.035, 0.05, 0.9)
|
||||
theme_override_constants/shadow_offset_x = 1
|
||||
theme_override_constants/shadow_offset_y = 2
|
||||
theme_override_font_sizes/font_size = 17
|
||||
text = "Page 1 of 2"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Next" type="Button" parent="DialogueChoiceArea/ChoicePager"]
|
||||
custom_minimum_size = Vector2(108, 38)
|
||||
layout_mode = 2
|
||||
text = "Next"
|
||||
|
||||
[node name="DialogueChoices" type="HBoxContainer" parent="DialogueChoiceArea"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = 46.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 10
|
||||
alignment = 1
|
||||
|
||||
[node name="ComputerLauncher" type="Button" parent="."]
|
||||
z_index = 40
|
||||
layout_mode = 0
|
||||
offset_left = 24.0
|
||||
offset_top = 24.0
|
||||
offset_right = 188.0
|
||||
offset_bottom = 78.0
|
||||
mouse_default_cursor_shape = 2
|
||||
tooltip_text = "Open simulated computer"
|
||||
icon = ExtResource("8_monitor")
|
||||
icon_max_width = 25
|
||||
expand_icon = true
|
||||
text = "Computer"
|
||||
|
||||
[node name="ExaminationLauncher" type="Button" parent="."]
|
||||
visible = false
|
||||
z_index = 40
|
||||
layout_mode = 0
|
||||
offset_left = 202.0
|
||||
offset_top = 24.0
|
||||
offset_right = 394.0
|
||||
offset_bottom = 78.0
|
||||
mouse_default_cursor_shape = 2
|
||||
tooltip_text = "Perform physical examination"
|
||||
icon = ExtResource("10_vitals")
|
||||
icon_max_width = 25
|
||||
expand_icon = true
|
||||
text = "Examination"
|
||||
|
||||
[node name="ComputerOverlay" type="Control" parent="."]
|
||||
visible = false
|
||||
z_index = 100
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 0
|
||||
|
||||
[node name="Dimmer" type="ColorRect" parent="ComputerOverlay"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 0
|
||||
color = Color(0.015, 0.025, 0.04, 0.86)
|
||||
|
||||
[node name="Screen" type="TextureRect" parent="ComputerOverlay"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
texture = ExtResource("7_computer")
|
||||
expand_mode = 1
|
||||
stretch_mode = 0
|
||||
|
||||
[node name="VitalsLauncher" type="Button" parent="ComputerOverlay"]
|
||||
layout_mode = 0
|
||||
offset_left = 30.0
|
||||
offset_top = 32.0
|
||||
offset_right = 142.0
|
||||
offset_bottom = 138.0
|
||||
mouse_default_cursor_shape = 2
|
||||
icon = ExtResource("10_vitals")
|
||||
icon_max_width = 58
|
||||
expand_icon = true
|
||||
icon_alignment = 1
|
||||
vertical_icon_alignment = 0
|
||||
|
||||
[node name="Label" type="Label" parent="ComputerOverlay/VitalsLauncher"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -34.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.96, 0.98, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.01, 0.04, 0.07, 0.95)
|
||||
theme_override_constants/outline_size = 4
|
||||
theme_override_font_sizes/font_size = 17
|
||||
text = "Vitals"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="MedicalHistoryLauncher" type="Button" parent="ComputerOverlay"]
|
||||
layout_mode = 0
|
||||
offset_left = 30.0
|
||||
offset_top = 154.0
|
||||
offset_right = 166.0
|
||||
offset_bottom = 260.0
|
||||
mouse_default_cursor_shape = 2
|
||||
icon = ExtResource("11_history")
|
||||
icon_max_width = 58
|
||||
expand_icon = true
|
||||
icon_alignment = 1
|
||||
vertical_icon_alignment = 0
|
||||
|
||||
[node name="OrdersLauncher" type="Button" parent="ComputerOverlay"]
|
||||
layout_mode = 0
|
||||
offset_left = 30.0
|
||||
offset_top = 276.0
|
||||
offset_right = 142.0
|
||||
offset_bottom = 382.0
|
||||
mouse_default_cursor_shape = 2
|
||||
icon = ExtResource("12_orders")
|
||||
icon_max_width = 58
|
||||
expand_icon = true
|
||||
icon_alignment = 1
|
||||
vertical_icon_alignment = 0
|
||||
|
||||
[node name="Label" type="Label" parent="ComputerOverlay/OrdersLauncher"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -34.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.96, 0.98, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.01, 0.04, 0.07, 0.95)
|
||||
theme_override_constants/outline_size = 4
|
||||
theme_override_font_sizes/font_size = 17
|
||||
text = "Orders"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Label" type="Label" parent="ComputerOverlay/MedicalHistoryLauncher"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -34.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.96, 0.98, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.01, 0.04, 0.07, 0.95)
|
||||
theme_override_constants/outline_size = 4
|
||||
theme_override_font_sizes/font_size = 16
|
||||
text = "Medical History"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="VitalsWindow" type="PanelContainer" parent="ComputerOverlay"]
|
||||
visible = false
|
||||
z_index = 2
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -360.0
|
||||
offset_top = -270.0
|
||||
offset_right = 360.0
|
||||
offset_bottom = 270.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="ComputerOverlay/VitalsWindow"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 32
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 32
|
||||
theme_override_constants/margin_bottom = 28
|
||||
|
||||
[node name="Content" type="VBoxContainer" parent="ComputerOverlay/VitalsWindow/Margin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="Header" type="HBoxContainer" parent="ComputerOverlay/VitalsWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
mouse_default_cursor_shape = 13
|
||||
|
||||
[node name="Title" type="Label" parent="ComputerOverlay/VitalsWindow/Margin/Content/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 30
|
||||
text = "Vitals"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Close" type="Button" parent="ComputerOverlay/VitalsWindow/Margin/Content/Header"]
|
||||
custom_minimum_size = Vector2(48, 48)
|
||||
layout_mode = 2
|
||||
mouse_default_cursor_shape = 2
|
||||
tooltip_text = "Close vital signs"
|
||||
icon = ExtResource("9_close")
|
||||
icon_max_width = 24
|
||||
expand_icon = true
|
||||
|
||||
[node name="Divider" type="HSeparator" parent="ComputerOverlay/VitalsWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Rows" type="GridContainer" parent="ComputerOverlay/VitalsWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/h_separation = 40
|
||||
theme_override_constants/v_separation = 12
|
||||
columns = 2
|
||||
|
||||
[node name="Empty" type="Label" parent="ComputerOverlay/VitalsWindow/Margin/Content"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "No nurse vital signs are available."
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="MedicalHistoryWindow" type="PanelContainer" parent="ComputerOverlay"]
|
||||
visible = false
|
||||
z_index = 2
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -330.0
|
||||
offset_top = -210.0
|
||||
offset_right = 330.0
|
||||
offset_bottom = 210.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="ComputerOverlay/MedicalHistoryWindow"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 32
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 32
|
||||
theme_override_constants/margin_bottom = 28
|
||||
|
||||
[node name="Content" type="VBoxContainer" parent="ComputerOverlay/MedicalHistoryWindow/Margin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="Header" type="HBoxContainer" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
mouse_default_cursor_shape = 13
|
||||
|
||||
[node name="Title" type="Label" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 30
|
||||
text = "Medical History"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Close" type="Button" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header"]
|
||||
custom_minimum_size = Vector2(48, 48)
|
||||
layout_mode = 2
|
||||
mouse_default_cursor_shape = 2
|
||||
tooltip_text = "Close medical history"
|
||||
icon = ExtResource("9_close")
|
||||
icon_max_width = 24
|
||||
expand_icon = true
|
||||
|
||||
[node name="Divider" type="HSeparator" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="SectionTitle" type="Label" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.18, 0.25, 0.31, 1)
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "Past Medical History"
|
||||
|
||||
[node name="Text" type="Label" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.04, 0.09, 0.13, 1)
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "No past medical history is available."
|
||||
|
||||
[node name="Close" type="Button" parent="ComputerOverlay"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -88.0
|
||||
offset_top = 20.0
|
||||
offset_right = -24.0
|
||||
offset_bottom = 84.0
|
||||
grow_horizontal = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
tooltip_text = "Close computer"
|
||||
icon = ExtResource("9_close")
|
||||
icon_max_width = 30
|
||||
expand_icon = true
|
||||
@@ -29,7 +29,6 @@ var simulation_total_blocks := 12
|
||||
var simulation_deadline_unix := 0.0
|
||||
var review_first_submission_recorded := false
|
||||
var core_block_size := DEFAULT_CORE_BLOCK_SIZE
|
||||
var show_review_followups := true
|
||||
|
||||
const CORE_MIN_TARGET_SECONDS := 45.0
|
||||
const CORE_MAX_TARGET_SECONDS := 150.0
|
||||
@@ -40,12 +39,10 @@ const CORE_BLOCK_SIZE_MAX := 40
|
||||
const SETTINGS_PATH := "user://settings.cfg"
|
||||
const SETTINGS_SECTION := "session"
|
||||
const SETTINGS_KEY_CORE_BLOCK_SIZE := "core_block_size"
|
||||
const SETTINGS_KEY_SHOW_REVIEW_FOLLOWUPS := "show_review_followups"
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_load_core_block_size()
|
||||
_load_show_review_followups()
|
||||
|
||||
|
||||
func set_core_block_size(value: int) -> void:
|
||||
@@ -60,17 +57,6 @@ func set_core_block_size(value: int) -> void:
|
||||
push_warning("Unable to save core question block size preference.")
|
||||
|
||||
|
||||
func set_show_review_followups(value: bool) -> void:
|
||||
if show_review_followups == value:
|
||||
return
|
||||
show_review_followups = value
|
||||
var config := ConfigFile.new()
|
||||
config.load(SETTINGS_PATH)
|
||||
config.set_value(SETTINGS_SECTION, SETTINGS_KEY_SHOW_REVIEW_FOLLOWUPS, show_review_followups)
|
||||
if config.save(SETTINGS_PATH) != OK:
|
||||
push_warning("Unable to save follow-up question preference.")
|
||||
|
||||
|
||||
func _load_core_block_size() -> void:
|
||||
var config := ConfigFile.new()
|
||||
if config.load(SETTINGS_PATH) == OK:
|
||||
@@ -79,12 +65,6 @@ func _load_core_block_size() -> void:
|
||||
CORE_BLOCK_SIZE_MIN, CORE_BLOCK_SIZE_MAX)
|
||||
|
||||
|
||||
func _load_show_review_followups() -> void:
|
||||
var config := ConfigFile.new()
|
||||
if config.load(SETTINGS_PATH) == OK:
|
||||
show_review_followups = bool(config.get_value(SETTINGS_SECTION, SETTINGS_KEY_SHOW_REVIEW_FOLLOWUPS, true))
|
||||
|
||||
|
||||
func start_dynamic_question(question: Dictionary) -> bool:
|
||||
if question.is_empty():
|
||||
return false
|
||||
@@ -284,6 +264,7 @@ func answer_current(choice_id: String, eliminated: Array, flagged: bool, is_subm
|
||||
"question": question.duplicate(true),
|
||||
"selected_choice_id": choice_id,
|
||||
"is_correct": choice_id == str(question.get("correct_choice_id", "")),
|
||||
"first_attempt_is_correct": choice_id == str(question.get("correct_choice_id", "")) and (session_kind != "core" or not core_current_had_wrong_attempt),
|
||||
"eliminated_choice_ids": eliminated.duplicate(),
|
||||
"flagged": flagged,
|
||||
"elapsed_seconds": get_current_elapsed_seconds(),
|
||||
@@ -311,6 +292,7 @@ func submit_core_attempt(choice_id: String, eliminated: Array, flagged: bool) ->
|
||||
"question": question.duplicate(true),
|
||||
"selected_choice_id": choice_id,
|
||||
"is_correct": choice_id == str(question.get("correct_choice_id", "")),
|
||||
"first_attempt_is_correct": choice_id == str(question.get("correct_choice_id", "")) and not core_current_had_wrong_attempt,
|
||||
"eliminated_choice_ids": eliminated.duplicate(),
|
||||
"flagged": flagged,
|
||||
"elapsed_seconds": get_current_elapsed_seconds(),
|
||||
@@ -343,26 +325,50 @@ func submit_core_attempt(choice_id: String, eliminated: Array, flagged: bool) ->
|
||||
}
|
||||
|
||||
|
||||
func submit_bookmark_attempt(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary:
|
||||
func submit_tutor_attempt(choice_id: String, eliminated: Array, flagged: bool, had_wrong_attempt: bool) -> Dictionary:
|
||||
var question := get_current_question()
|
||||
if question.is_empty() or mode != "Tutor" or session_kind not in ["block", "dynamic"]:
|
||||
return {}
|
||||
var current_correct := choice_id == str(question.get("correct_choice_id", ""))
|
||||
var answer := {
|
||||
"question": question.duplicate(true),
|
||||
"selected_choice_id": choice_id,
|
||||
"is_correct": current_correct and not had_wrong_attempt,
|
||||
"first_attempt_is_correct": current_correct and not had_wrong_attempt,
|
||||
"resolved_correct": current_correct,
|
||||
"eliminated_choice_ids": eliminated.duplicate(),
|
||||
"flagged": flagged,
|
||||
"elapsed_seconds": get_current_elapsed_seconds(),
|
||||
"target_seconds": 0.0
|
||||
}
|
||||
if current_correct:
|
||||
answers[current_index] = answer
|
||||
session_changed.emit()
|
||||
return {"answer": answer, "is_correct": current_correct, "final": current_correct}
|
||||
|
||||
|
||||
func submit_bookmark_attempt(choice_id: String, eliminated: Array, flagged: bool, had_wrong_attempt := false) -> Dictionary:
|
||||
var question := get_current_question()
|
||||
if session_kind != "bookmark" or question.is_empty():
|
||||
return {}
|
||||
var answer := {
|
||||
"question": question.duplicate(true),
|
||||
"selected_choice_id": choice_id,
|
||||
"is_correct": choice_id == str(question.get("correct_choice_id", "")),
|
||||
"is_correct": choice_id == str(question.get("correct_choice_id", "")) and not had_wrong_attempt,
|
||||
"first_attempt_is_correct": choice_id == str(question.get("correct_choice_id", "")) and not had_wrong_attempt,
|
||||
"eliminated_choice_ids": eliminated.duplicate(),
|
||||
"flagged": flagged,
|
||||
"elapsed_seconds": get_current_elapsed_seconds(),
|
||||
"target_seconds": 0.0
|
||||
}
|
||||
if bool(answer.is_correct):
|
||||
var current_correct := choice_id == str(question.get("correct_choice_id", ""))
|
||||
if current_correct:
|
||||
answers[current_index] = answer
|
||||
session_changed.emit()
|
||||
return {"answer": answer, "is_correct": bool(answer.is_correct), "final": bool(answer.is_correct)}
|
||||
return {"answer": answer, "is_correct": current_correct, "final": current_correct}
|
||||
|
||||
|
||||
func submit_review_attempt(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary:
|
||||
func submit_review_attempt(choice_id: String, eliminated: Array, flagged: bool, had_wrong_attempt := false) -> Dictionary:
|
||||
var question := get_current_question()
|
||||
if session_kind != "review" or question.is_empty():
|
||||
return {}
|
||||
@@ -370,20 +376,22 @@ func submit_review_attempt(choice_id: String, eliminated: Array, flagged: bool)
|
||||
var answer := {
|
||||
"question": question.duplicate(true),
|
||||
"selected_choice_id": choice_id,
|
||||
"is_correct": choice_id == str(question.get("correct_choice_id", "")),
|
||||
"is_correct": choice_id == str(question.get("correct_choice_id", "")) and not had_wrong_attempt,
|
||||
"first_attempt_is_correct": choice_id == str(question.get("correct_choice_id", "")) and not had_wrong_attempt,
|
||||
"eliminated_choice_ids": eliminated.duplicate(),
|
||||
"flagged": flagged,
|
||||
"elapsed_seconds": get_current_elapsed_seconds(),
|
||||
"target_seconds": 0.0
|
||||
}
|
||||
review_first_submission_recorded = true
|
||||
if bool(answer.is_correct):
|
||||
var current_correct := choice_id == str(question.get("correct_choice_id", ""))
|
||||
if current_correct:
|
||||
answers[current_index] = answer
|
||||
session_changed.emit()
|
||||
return {
|
||||
"answer": answer,
|
||||
"is_correct": bool(answer.is_correct),
|
||||
"final": bool(answer.is_correct),
|
||||
"is_correct": current_correct,
|
||||
"final": current_correct,
|
||||
"first_submission": first_submission
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ var queue: Array = []
|
||||
var text_scale := 1.0
|
||||
var highlights := {}
|
||||
var request_access_token := ""
|
||||
var launch_origin := "ccs"
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -77,6 +78,10 @@ func send_command(type: String, code: String = "", minutes: int = 0) -> void:
|
||||
"minutes": minutes
|
||||
})
|
||||
|
||||
|
||||
func send_dialogue_choice(choice_id: String) -> void:
|
||||
send_command("dialogue_choice", choice_id)
|
||||
|
||||
func send_batch(commands: Array) -> void:
|
||||
if attempt.is_empty() or commands.is_empty():
|
||||
return
|
||||
@@ -272,6 +277,7 @@ func _escape_bbcode(value: String) -> String:
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if not AuthState.is_logged_in:
|
||||
launch_origin = "ccs"
|
||||
highlights.clear()
|
||||
text_scale = 1.0
|
||||
attempt = {}
|
||||
|
||||
@@ -5,8 +5,11 @@ signal credits_failed(message: String)
|
||||
signal store_operation_completed(message: String)
|
||||
signal spin_resolved(reward_key: String, reward_label: String, coins_won: int)
|
||||
signal spin_config_loaded
|
||||
signal award_received(amount: int, reason: String)
|
||||
signal ledger_changed
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const FeatureFlags := preload("res://scripts/config/feature_flags.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
|
||||
|
||||
var credits: int = 0
|
||||
@@ -21,9 +24,13 @@ var loading := false
|
||||
var request: HTTPRequest
|
||||
var action_request: HTTPRequest
|
||||
var spin_config_request: HTTPRequest
|
||||
var ledger_request: HTTPRequest
|
||||
var request_token := ""
|
||||
var api_url := DEFAULT_URL
|
||||
var pending_store_action := ""
|
||||
var ledger: Array = []
|
||||
var ledger_next_before := ""
|
||||
var ledger_append := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -42,6 +49,10 @@ func _ready() -> void:
|
||||
spin_config_request.timeout = 20.0
|
||||
spin_config_request.request_completed.connect(_on_spin_config_completed)
|
||||
add_child(spin_config_request)
|
||||
ledger_request = HTTPRequest.new()
|
||||
ledger_request.timeout = 20.0
|
||||
ledger_request.request_completed.connect(_on_ledger_completed)
|
||||
add_child(ledger_request)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
|
||||
|
||||
@@ -67,6 +78,44 @@ func apply_status(parsed: Dictionary) -> void:
|
||||
credits_changed.emit()
|
||||
|
||||
|
||||
func apply_award_response(parsed: Dictionary) -> void:
|
||||
var status: Variant = parsed.get("credits", null)
|
||||
if typeof(status) == TYPE_DICTIONARY:
|
||||
apply_status(status)
|
||||
for award_variant in parsed.get("creditAwards", []):
|
||||
if typeof(award_variant) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var award: Dictionary = award_variant
|
||||
var amount := int(award.get("amount", 0))
|
||||
var reason_code := str(award.get("reasonCode", award.get("reason_code", "")))
|
||||
if amount > 0 and (FeatureFlags.progression_enabled() or not FeatureFlags.is_progression_reward(reason_code)):
|
||||
award_received.emit(amount, str(award.get("description", "Learning reward")))
|
||||
|
||||
|
||||
func load_ledger(before: String = "") -> void:
|
||||
if not AuthState.can_send_authenticated_request() or ledger_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
ledger_append = not before.is_empty()
|
||||
var suffix := "/ledger?limit=20"
|
||||
if not before.is_empty():
|
||||
suffix += "&before=%s" % before.uri_encode()
|
||||
ledger_request.request(api_url + suffix, PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token]), HTTPClient.METHOD_GET)
|
||||
|
||||
|
||||
func _on_ledger_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
credits_failed.emit("Coin history could not be loaded.")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
return
|
||||
var items: Array = parsed.get("items", [])
|
||||
ledger = ledger + items if ledger_append else items
|
||||
var cursor: Variant = parsed.get("nextBefore", null)
|
||||
ledger_next_before = "" if cursor == null else str(cursor)
|
||||
ledger_changed.emit()
|
||||
|
||||
|
||||
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
loading = false
|
||||
if response_code == 401:
|
||||
@@ -173,6 +222,8 @@ func _on_auth_changed() -> void:
|
||||
bank_cycle = 0
|
||||
products = []
|
||||
spin_rewards = []
|
||||
ledger = []
|
||||
ledger_next_before = ""
|
||||
loaded = false
|
||||
loading = false
|
||||
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
extends Node
|
||||
|
||||
signal missions_changed
|
||||
signal missions_failed(message: String)
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/daily-missions"
|
||||
|
||||
var api_url := DEFAULT_URL
|
||||
var request: HTTPRequest
|
||||
var state: Dictionary = {}
|
||||
var loading := false
|
||||
var pending_action := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
api_url = EndpointConfig.resolve(DEFAULT_URL, "/api/daily-missions")
|
||||
request = HTTPRequest.new()
|
||||
request.timeout = 20.0
|
||||
request.request_completed.connect(_on_completed)
|
||||
add_child(request)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
set_process(true)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if loading or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
_send("refresh", HTTPClient.METHOD_GET, "")
|
||||
|
||||
|
||||
func replace(mission_id: String) -> void:
|
||||
if loading or mission_id.is_empty() or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
_send("replace", HTTPClient.METHOD_POST, "/%s/replace" % mission_id.uri_encode())
|
||||
|
||||
|
||||
func apply_response(parsed: Dictionary) -> void:
|
||||
var next_state: Variant = parsed.get("dailyMissions", null)
|
||||
if typeof(next_state) == TYPE_DICTIONARY:
|
||||
state = next_state
|
||||
missions_changed.emit()
|
||||
|
||||
|
||||
func _send(action: String, method: int, suffix: String) -> void:
|
||||
loading = true
|
||||
pending_action = action
|
||||
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
|
||||
var error := request.request(api_url + suffix, headers, method)
|
||||
if error != OK:
|
||||
loading = false
|
||||
pending_action = ""
|
||||
missions_failed.emit("Daily missions could not be loaded.")
|
||||
|
||||
|
||||
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
loading = false
|
||||
pending_action = ""
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(AuthState.access_token)
|
||||
return
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
missions_failed.emit("Daily mission request failed.")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY or typeof(parsed.get("state", null)) != TYPE_DICTIONARY:
|
||||
missions_failed.emit("The daily mission response was invalid.")
|
||||
return
|
||||
state = parsed.get("state", {})
|
||||
CreditsService.apply_award_response(parsed)
|
||||
missions_changed.emit()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if state.is_empty() or loading or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
var expires := Time.get_unix_time_from_datetime_string(str(state.get("expiresUtc", "")).trim_suffix("Z"))
|
||||
if expires > 0 and Time.get_unix_time_from_system() >= expires:
|
||||
state = {}
|
||||
refresh()
|
||||
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if not AuthState.is_logged_in:
|
||||
state = {}
|
||||
loading = false
|
||||
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
request.cancel_request()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwafdsrqdxleo
|
||||
@@ -196,6 +196,8 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
last_failed_action = action
|
||||
request_failed.emit("The simulation API returned an invalid response.")
|
||||
return
|
||||
CreditsService.apply_award_response(parsed)
|
||||
StudySessionService.apply_response(parsed)
|
||||
if action == "readiness":
|
||||
readiness = parsed
|
||||
readiness_changed.emit()
|
||||
|
||||
@@ -26,7 +26,7 @@ func calculate_block_rewards(answers: Array, mode: String, system_name: String)
|
||||
var ending_streak := running_streak
|
||||
|
||||
for answer in answers:
|
||||
if bool(answer.get("is_correct", false)):
|
||||
if bool(answer.get("first_attempt_is_correct", answer.get("is_correct", false))):
|
||||
correct += 1
|
||||
ending_streak += 1
|
||||
longest_streak = max(longest_streak, ending_streak)
|
||||
|
||||
@@ -53,6 +53,11 @@ var rating_request_token := ""
|
||||
var report_request_token := ""
|
||||
var pending_excluded_question_ids: Array = []
|
||||
var pending_require_media := false
|
||||
var pending_step3_system := ""
|
||||
var pending_difficulty_min := 0
|
||||
var pending_difficulty_max := 0
|
||||
var pending_limit := 1
|
||||
var targeted_fallback_used := false
|
||||
var image_question_search_attempts := 0
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -107,7 +112,8 @@ func _ready() -> void:
|
||||
_process_attempt_queue()
|
||||
|
||||
|
||||
func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Array = [], require_media: bool = false) -> void:
|
||||
func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Array = [], require_media: bool = false,
|
||||
step3_system: String = "", difficulty_min: int = 0, difficulty_max: int = 0, requested_limit: int = 1) -> void:
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
_set_load_error("Login required before loading questions.", false)
|
||||
return
|
||||
@@ -115,6 +121,11 @@ func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Ar
|
||||
if reset_retry:
|
||||
pending_excluded_question_ids = excluded_question_ids.duplicate()
|
||||
pending_require_media = require_media
|
||||
pending_step3_system = step3_system
|
||||
pending_difficulty_min = difficulty_min
|
||||
pending_difficulty_max = difficulty_max
|
||||
pending_limit = clampi(requested_limit, 1, 40)
|
||||
targeted_fallback_used = false
|
||||
image_question_search_attempts = 0
|
||||
retry_attempt = 0
|
||||
next_retry_seconds = 0
|
||||
@@ -144,11 +155,17 @@ func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Ar
|
||||
"Authorization: Bearer %s" % questions_request_token
|
||||
])
|
||||
var payload := {
|
||||
"limit": 1,
|
||||
"limit": pending_limit,
|
||||
"supportsChoicePools": true,
|
||||
"requireMedia": pending_require_media,
|
||||
"excludeExternalIds": pending_excluded_question_ids
|
||||
}
|
||||
if not pending_step3_system.is_empty():
|
||||
payload["step3System"] = pending_step3_system
|
||||
if pending_difficulty_min > 0:
|
||||
payload["difficultyMin"] = pending_difficulty_min
|
||||
if pending_difficulty_max > 0:
|
||||
payload["difficultyMax"] = pending_difficulty_max
|
||||
var err := request.request(questions_api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
|
||||
if err != OK:
|
||||
_set_load_error("Unable to connect to the question service.")
|
||||
@@ -177,6 +194,12 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS
|
||||
api_questions.append(normalized)
|
||||
|
||||
if api_questions.is_empty():
|
||||
if not pending_step3_system.is_empty() and (pending_difficulty_min > 0 or pending_difficulty_max > 0) and not targeted_fallback_used:
|
||||
targeted_fallback_used = true
|
||||
pending_difficulty_min = 0
|
||||
pending_difficulty_max = 0
|
||||
call_deferred("load_questions_from_api", false, pending_excluded_question_ids, pending_require_media)
|
||||
return
|
||||
_set_load_error("Questions API returned no usable questions.", false)
|
||||
return
|
||||
if pending_require_media and not _contains_question_with_media(api_questions):
|
||||
@@ -200,6 +223,10 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS
|
||||
questions_changed.emit()
|
||||
|
||||
|
||||
func load_targeted_questions(step3_system: String, question_count: int, difficulty_min: int = 0, difficulty_max: int = 0) -> void:
|
||||
load_questions_from_api(true, [], false, step3_system, difficulty_min, difficulty_max, question_count)
|
||||
|
||||
|
||||
func retry_now() -> void:
|
||||
retry_attempt = 0
|
||||
next_retry_seconds = 0
|
||||
@@ -256,6 +283,11 @@ func clear_questions() -> void:
|
||||
pending_report_question_id = ""
|
||||
pending_excluded_question_ids.clear()
|
||||
pending_require_media = false
|
||||
pending_step3_system = ""
|
||||
pending_difficulty_min = 0
|
||||
pending_difficulty_max = 0
|
||||
pending_limit = 1
|
||||
targeted_fallback_used = false
|
||||
image_question_search_attempts = 0
|
||||
questions_changed.emit()
|
||||
ratings_changed.emit()
|
||||
@@ -337,7 +369,7 @@ func get_questions_for_system(system_name: String) -> Array:
|
||||
|
||||
var filtered: Array = []
|
||||
for question in questions:
|
||||
if str(question.get("system", "")) == system_name:
|
||||
if str(question.get("step3_system", question.get("system", ""))) == system_name or str(question.get("system", "")) == system_name:
|
||||
filtered.append(question.duplicate(true))
|
||||
return filtered
|
||||
|
||||
@@ -405,6 +437,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
|
||||
"presented_choice_ids": presented_choice_ids.duplicate(),
|
||||
"duration_seconds": maxi(0, duration_seconds),
|
||||
"met_time_limit": met_time_limit,
|
||||
"study_session_id": StudySessionService.active_session_id,
|
||||
"retry_count": 0
|
||||
})
|
||||
_save_attempt_queue()
|
||||
@@ -440,6 +473,11 @@ func _process_attempt_queue() -> void:
|
||||
"durationSeconds": int(queued.get("duration_seconds", 0)),
|
||||
"metTimeLimit": bool(queued.get("met_time_limit", true))
|
||||
})
|
||||
var study_session_id := str(queued.get("study_session_id", ""))
|
||||
if not study_session_id.is_empty():
|
||||
var decoded: Dictionary = JSON.parse_string(body)
|
||||
decoded["studySessionId"] = study_session_id
|
||||
body = JSON.stringify(decoded)
|
||||
if str(queued.get("kind", "answer")) == "skip":
|
||||
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
|
||||
body = ""
|
||||
@@ -513,14 +551,21 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
||||
var rating: Dictionary = parsed.get("rating", {})
|
||||
_update_question_elo(question_id, question_elo)
|
||||
_apply_rating_change(rating)
|
||||
var credits_status: Variant = parsed.get("credits", null)
|
||||
if typeof(credits_status) == TYPE_DICTIONARY:
|
||||
CreditsService.apply_status(credits_status)
|
||||
CreditsService.apply_award_response(parsed)
|
||||
DailyMissionService.apply_response(parsed)
|
||||
if typeof(parsed.get("progression")) == TYPE_DICTIONARY:
|
||||
DashboardService.data["progression"] = parsed.get("progression")
|
||||
DashboardService.dashboard_changed.emit()
|
||||
# Let Tutor users inspect the final answer before the summary replaces the player.
|
||||
StudySessionService.apply_response(parsed, true)
|
||||
choice_percentages_updated.emit(question_id, percentages)
|
||||
question_rating_updated.emit(question_id, question_elo, rating)
|
||||
|
||||
|
||||
func _apply_skip_response(parsed: Dictionary) -> void:
|
||||
if typeof(parsed.get("progression")) == TYPE_DICTIONARY:
|
||||
DashboardService.data["progression"] = parsed.get("progression")
|
||||
DashboardService.dashboard_changed.emit()
|
||||
var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||
var correct_choice_id := str(parsed.get("correctChoiceId", parsed.get("correct_choice_id", "")))
|
||||
var percentages: Dictionary = parsed.get("choiceSelectionPercentages", parsed.get("choice_selection_percentages", {}))
|
||||
@@ -688,6 +733,7 @@ func _normalize_api_question(item: Variant) -> Dictionary:
|
||||
"choice_selection_percentages": item.get("choiceSelectionPercentages", item.get("choice_selection_percentages", {})),
|
||||
"discipline": str(item.get("discipline", "")),
|
||||
"system": str(item.get("system", "")),
|
||||
"step3_system": str(item.get("step3System", item.get("step3_system", ""))),
|
||||
"performance_category": str(item.get("performanceCategory", item.get("performance_category", ""))),
|
||||
"difficulty": int(item.get("difficulty", 1)),
|
||||
"question_elo": int(item.get("questionElo", item.get("question_elo", 1000))),
|
||||
|
||||
@@ -24,6 +24,8 @@ var cleared_count := 0
|
||||
var retained_count := 0
|
||||
var outbox: Array = []
|
||||
var active_account_key := ""
|
||||
var step3_system := ""
|
||||
var target_count := 20
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -48,7 +50,9 @@ func _ready() -> void:
|
||||
_on_auth_changed()
|
||||
|
||||
|
||||
func start_pass() -> void:
|
||||
func start_pass(requested_step3_system: String = "", requested_count: int = 20) -> void:
|
||||
step3_system = requested_step3_system
|
||||
target_count = clampi(requested_count, 1, 20)
|
||||
excluded_question_ids.clear()
|
||||
reviewed_count = 0
|
||||
cleared_count = 0
|
||||
@@ -57,6 +61,10 @@ func start_pass() -> void:
|
||||
|
||||
|
||||
func load_next() -> void:
|
||||
if reviewed_count >= target_count:
|
||||
StudySessionService.emit_deferred_summary()
|
||||
pass_completed.emit(get_summary())
|
||||
return
|
||||
if loading:
|
||||
return
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
@@ -68,6 +76,8 @@ func load_next() -> void:
|
||||
"excludeExternalIds": excluded_question_ids,
|
||||
"supportsChoicePools": true
|
||||
}
|
||||
if not step3_system.is_empty():
|
||||
payload["step3System"] = step3_system
|
||||
var error := next_request.request(
|
||||
"%s/next" % api_url,
|
||||
_headers(),
|
||||
@@ -95,7 +105,8 @@ func record_first_submission(question: Dictionary, choice_id: String, presented_
|
||||
"cycle": current_cycle,
|
||||
"choiceId": choice_id,
|
||||
"presentedChoiceIds": presented_choice_ids.duplicate(),
|
||||
"durationSeconds": clampi(duration_seconds, 0, 86400)
|
||||
"durationSeconds": clampi(duration_seconds, 0, 86400),
|
||||
"studySessionId": StudySessionService.active_session_id
|
||||
}
|
||||
outbox.append({"account": _account_key(), "payload": payload, "retry_count": 0})
|
||||
_save_outbox()
|
||||
@@ -186,6 +197,12 @@ func _on_attempt_completed(result: int, response_code: int, _headers_value: Pack
|
||||
var synced_question_id := ""
|
||||
var synced_is_correct := false
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
CreditsService.apply_award_response(parsed)
|
||||
DailyMissionService.apply_response(parsed)
|
||||
if typeof(parsed.get("progression")) == TYPE_DICTIONARY:
|
||||
DashboardService.data["progression"] = parsed.get("progression")
|
||||
DashboardService.dashboard_changed.emit()
|
||||
StudySessionService.apply_response(parsed, true)
|
||||
pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count)))
|
||||
synced_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||
synced_is_correct = bool(parsed.get("isCorrect", parsed.get("is_correct", false)))
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
extends Node
|
||||
|
||||
signal session_started
|
||||
signal progress_changed
|
||||
signal session_completed(summary: Dictionary)
|
||||
signal session_failed(message: String)
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/study-sessions"
|
||||
|
||||
var api_url := DEFAULT_URL
|
||||
var request: HTTPRequest
|
||||
var active_session_id := ""
|
||||
var progress: Dictionary = {}
|
||||
var summary: Dictionary = {}
|
||||
var pending_action := ""
|
||||
var pending_launch: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
api_url = EndpointConfig.resolve(DEFAULT_URL, "/api/study-sessions")
|
||||
request = HTTPRequest.new()
|
||||
request.timeout = 25.0
|
||||
request.request_completed.connect(_on_completed)
|
||||
add_child(request)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
|
||||
|
||||
func start(goal_type: String, target_value: int, mode: String, step3_system: String = "", launch: Dictionary = {}) -> void:
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
session_failed.emit("Login is required to start a study session.")
|
||||
return
|
||||
pending_launch = launch.duplicate(true)
|
||||
summary = {}
|
||||
_send("create", HTTPClient.METHOD_POST, "", {
|
||||
"clientSessionId": _new_uuid(), "goalType": goal_type, "targetValue": target_value,
|
||||
"mode": mode, "step3System": step3_system
|
||||
})
|
||||
|
||||
|
||||
func finish_early() -> void:
|
||||
if active_session_id.is_empty():
|
||||
return
|
||||
_send("finish", HTTPClient.METHOD_POST, "/%s/finish" % active_session_id.uri_encode())
|
||||
|
||||
|
||||
func check_completion() -> void:
|
||||
if active_session_id.is_empty():
|
||||
return
|
||||
_send("check", HTTPClient.METHOD_POST, "/%s/check" % active_session_id.uri_encode())
|
||||
|
||||
|
||||
func apply_response(parsed: Dictionary, defer_completion: bool = false) -> void:
|
||||
var next_progress: Variant = parsed.get("studySessionProgress", null)
|
||||
if typeof(next_progress) == TYPE_DICTIONARY:
|
||||
progress = next_progress
|
||||
active_session_id = str(progress.get("id", active_session_id))
|
||||
progress_changed.emit()
|
||||
var next_summary: Variant = parsed.get("sessionSummary", null)
|
||||
if typeof(next_summary) == TYPE_DICTIONARY and not next_summary.is_empty():
|
||||
summary = next_summary
|
||||
active_session_id = ""
|
||||
if not defer_completion:
|
||||
session_completed.emit(summary)
|
||||
|
||||
|
||||
func emit_deferred_summary() -> void:
|
||||
if not summary.is_empty():
|
||||
session_completed.emit(summary)
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return not active_session_id.is_empty()
|
||||
|
||||
|
||||
func seconds_remaining() -> int:
|
||||
return int(progress.get("secondsRemaining", 0))
|
||||
|
||||
|
||||
func _send(action: String, method: int, suffix: String, payload: Dictionary = {}) -> void:
|
||||
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
pending_action = action
|
||||
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
|
||||
var body := ""
|
||||
if not payload.is_empty():
|
||||
headers.append("Content-Type: application/json")
|
||||
body = JSON.stringify(payload)
|
||||
var error := request.request(api_url + suffix, headers, method, body)
|
||||
if error != OK:
|
||||
pending_action = ""
|
||||
session_failed.emit("The study session could not be started.")
|
||||
|
||||
|
||||
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
var action := pending_action
|
||||
pending_action = ""
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(AuthState.access_token)
|
||||
return
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
session_failed.emit("The study session request failed.")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
session_failed.emit("The study session response was invalid.")
|
||||
return
|
||||
var next_progress: Dictionary = parsed.get("progress", {})
|
||||
progress = next_progress
|
||||
active_session_id = str(progress.get("id", "")) if str(progress.get("state", "")) == "active" else ""
|
||||
var next_summary: Variant = parsed.get("summary", null)
|
||||
if typeof(next_summary) == TYPE_DICTIONARY:
|
||||
summary = next_summary
|
||||
progress_changed.emit()
|
||||
if action == "create":
|
||||
session_started.emit()
|
||||
elif not summary.is_empty():
|
||||
session_completed.emit(summary)
|
||||
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if not AuthState.is_logged_in:
|
||||
active_session_id = ""
|
||||
progress = {}
|
||||
summary = {}
|
||||
|
||||
|
||||
func _new_uuid() -> String:
|
||||
var bytes := Crypto.new().generate_random_bytes(16)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
var value := bytes.hex_encode()
|
||||
return "%s-%s-%s-%s-%s" % [value.substr(0, 8), value.substr(8, 4), value.substr(12, 4), value.substr(16, 4), value.substr(20, 12)]
|
||||
@@ -0,0 +1 @@
|
||||
uid://da63hc4qgjlso
|
||||
@@ -0,0 +1,67 @@
|
||||
extends Node
|
||||
|
||||
signal goal_changed
|
||||
signal goal_failed(message: String)
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/weekly-goals"
|
||||
|
||||
var api_url := DEFAULT_URL
|
||||
var request: HTTPRequest
|
||||
var state: Dictionary = {}
|
||||
var loading := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
api_url = EndpointConfig.resolve(DEFAULT_URL, "/api/weekly-goals")
|
||||
request = HTTPRequest.new()
|
||||
request.timeout = 25.0
|
||||
request.request_completed.connect(_on_completed)
|
||||
add_child(request)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
set_process(true)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if loading or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
loading = true
|
||||
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
|
||||
var error := request.request(api_url, headers, HTTPClient.METHOD_GET)
|
||||
if error != OK:
|
||||
loading = false
|
||||
goal_failed.emit("Weekly goal could not be loaded.")
|
||||
|
||||
|
||||
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
loading = false
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(AuthState.access_token)
|
||||
return
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
goal_failed.emit("Weekly goal request failed.")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY or typeof(parsed.get("state", null)) != TYPE_DICTIONARY:
|
||||
goal_failed.emit("The weekly goal response was invalid.")
|
||||
return
|
||||
state = parsed.get("state", {})
|
||||
CreditsService.apply_award_response(parsed)
|
||||
goal_changed.emit()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if state.is_empty() or loading or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
var expires := Time.get_unix_time_from_datetime_string(str(state.get("expiresUtc", "")).trim_suffix("Z"))
|
||||
if expires > 0 and Time.get_unix_time_from_system() >= expires:
|
||||
state = {}
|
||||
refresh()
|
||||
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if not AuthState.is_logged_in:
|
||||
state = {}
|
||||
loading = false
|
||||
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
request.cancel_request()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cosnvqogps38d
|
||||