Author SHA1 Message Date
hz e03ad9cf80 Add ready question production promotion 2026-07-26 01:21:26 -05:00
338 changed files with 1825 additions and 103803 deletions
+1 -42
View File
@@ -34,7 +34,7 @@ docker system df
df -h
docker build -f api/QuestionsApi.Tests/Dockerfile -t "${TEST_IMAGE}" .
docker build --build-arg BUILD_REVISION="${BUILD_TAG}" -f api/QuestionsApi/Dockerfile -t "${API_IMAGE}" .
docker build --build-arg BUILD_REVISION="${BUILD_TAG}" -t "${API_IMAGE}" api/QuestionsApi
docker build \
--build-arg BUILD_REVISION="${BUILD_TAG}" \
--build-arg DEPLOYMENT_ENVIRONMENT="${DEPLOYMENT_ENVIRONMENT}" \
@@ -70,48 +70,7 @@ kubectl kustomize "k8s/overlays/${DEPLOYMENT_ENVIRONMENT}" \
kubectl rollout restart deployment/step3-api -n "${KUBE_NAMESPACE}"
kubectl rollout status deployment/step3-api -n "${KUBE_NAMESPACE}" --timeout=240s
kubectl get --raw "/api/v1/namespaces/${KUBE_NAMESPACE}/services/step3-api:http/proxy/readyz" >/dev/null
if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then
# A completed old replica can briefly remain visible immediately after a
# rollout. Select the newest pod that is both Running and container-ready
# instead of relying on the first item returned by Kubernetes.
PROMOTION_SUCCEEDED=0
for attempt in 1 2 3; do
API_POD="$(kubectl get pods -n "${KUBE_NAMESPACE}" -l app=step3-api \
--field-selector=status.phase=Running \
--sort-by=.metadata.creationTimestamp \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[0].ready}{"\n"}{end}' \
| awk '$2 == "true" { name = $1 } END { print name }')"
if [ -n "${API_POD}" ] \
&& kubectl wait -n "${KUBE_NAMESPACE}" --for=condition=Ready "pod/${API_POD}" --timeout=60s \
&& kubectl exec -n "${KUBE_NAMESPACE}" "${API_POD}" -- \
dotnet QuestionsApi.dll promote-dynamic-questions \
--source /app/content/dynamic_questions \
--apply; then
PROMOTION_SUCCEEDED=1
break
fi
echo "Dynamic question promotion attempt ${attempt} did not find a stable ready API pod; retrying." >&2
sleep 2
done
if [ "${PROMOTION_SUCCEEDED}" -ne 1 ]; then
echo "Dynamic question promotion failed after 3 attempts." >&2
exit 1
fi
fi
kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s
kubectl get --raw "/api/v1/namespaces/${KUBE_NAMESPACE}/services/step3-web:http/proxy/healthz" >/dev/null
if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then
# The images are already imported on every Kubernetes node. Reclaim runner
# space before pulling the Playwright browser image.
docker image rm "${API_IMAGE}" "${WEB_IMAGE}" "${TEST_IMAGE}" 2>/dev/null || true
docker builder prune -af >/dev/null || true
UAT_API_ORIGIN="${PUBLIC_API_ORIGIN}" \
UAT_WEB_ORIGIN="${PUBLIC_WEB_ORIGIN:-https://ustep3.ranaze.com}" \
EXPECTED_REVISION="${BUILD_TAG}" \
bash .gitea/scripts/run-uat-smoke.sh
fi
echo "Deployed ${DEPLOYMENT_ENVIRONMENT} revision ${BUILD_TAG}."
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
: "${UAT_API_ORIGIN:=https://us3q.ranaze.com}"
: "${UAT_WEB_ORIGIN:=https://ustep3.ranaze.com}"
IMAGE_TAG="step3-uat-smoke:${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_NUMBER:-0}"
ARTIFACT_DIR="${SMOKE_ARTIFACT_DIR:-artifacts/uat-smoke}"
ARTIFACT_ABS="$(pwd)/${ARTIFACT_DIR}"
cleanup() {
docker image rm "${IMAGE_TAG}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
rm -rf "${ARTIFACT_DIR}"
mkdir -p "${ARTIFACT_DIR}"
docker build -f tests/smoke/Dockerfile -t "${IMAGE_TAG}" tests/smoke
docker run --rm --ipc=host \
-e UAT_API_ORIGIN="${UAT_API_ORIGIN}" \
-e UAT_WEB_ORIGIN="${UAT_WEB_ORIGIN}" \
-e EXPECTED_REVISION="${EXPECTED_REVISION:-}" \
-v "${ARTIFACT_ABS}:/work/test-results" \
"${IMAGE_TAG}"
-4
View File
@@ -4,10 +4,6 @@ on:
push:
branches:
- production
paths-ignore:
- '**.md'
- 'tools/**'
- 'tests/**'
workflow_dispatch: {}
concurrency:
-14
View File
@@ -4,10 +4,6 @@ on:
push:
branches:
- main
paths-ignore:
- '**.md'
- 'tools/**'
- 'tests/**'
workflow_dispatch: {}
concurrency:
@@ -36,7 +32,6 @@ jobs:
DEPLOYMENT_ENVIRONMENT: uat
KUBE_NAMESPACE: step3-uat
PUBLIC_API_ORIGIN: https://us3q.ranaze.com
PUBLIC_WEB_ORIGIN: https://ustep3.ranaze.com
MONGODB_CONNECTION_STRING: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }}
AUTH_JWT_KEY: ${{ secrets.UAT_AUTH_JWT_KEY }}
INTERNAL_PURGE_SECRET: ${{ secrets.UAT_INTERNAL_PURGE_SECRET }}
@@ -45,12 +40,3 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.UAT_GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.UAT_GOOGLE_CLIENT_SECRET }}
run: bash .gitea/scripts/deploy-environment.sh
- name: Upload UAT smoke-test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: uat-smoke-${{ github.run_number }}-${{ github.run_attempt }}
path: artifacts/uat-smoke
if-no-files-found: warn
retention-days: 14
-30
View File
@@ -1,30 +0,0 @@
name: Smoke Test STEP3 UAT
on:
workflow_dispatch: {}
concurrency:
group: step3-uat-smoke
cancel-in-progress: false
jobs:
smoke:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run UAT smoke tests
env:
UAT_API_ORIGIN: https://us3q.ranaze.com
UAT_WEB_ORIGIN: https://ustep3.ranaze.com
run: bash .gitea/scripts/run-uat-smoke.sh
- name: Upload smoke-test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: uat-smoke-${{ github.run_number }}-${{ github.run_attempt }}
path: artifacts/uat-smoke
if-no-files-found: warn
retention-days: 14
-5
View File
@@ -10,8 +10,3 @@
# Local editor/user files
*.user
*.suo
# UAT smoke-test output
/artifacts/uat-smoke/
**/node_modules/
/tests/smoke/local-results/
+1 -36
View File
@@ -71,47 +71,12 @@ The hostname change creates a new browser origin, so testers should expect to si
## Question promotion behavior
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. Any promoted question containing media is flagged again in PRD and remains hidden from students until a SUPER user explicitly approves it in production. UAT media approval is not treated as production approval. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
## Verification and rollback
## Step 3 mastery system backfill
The mastery dashboard uses a canonical 14-system classification stored on each
question. After deploying the mastery-capable API, inspect and apply the
idempotent backfill in UAT:
```bash
kubectl exec deployment/step3-api -n step3-uat -- \
dotnet QuestionsApi.dll backfill-step3-systems
kubectl exec deployment/step3-api -n step3-uat -- \
dotnet QuestionsApi.dll backfill-step3-systems --apply --confirm BACKFILL_STEP3_SYSTEMS
```
Review the reported unmapped count before acceptance testing. Repeat the dry run
and confirmed apply in `step3-prd` after the tested revision is promoted to the
production branch. The command is safe to rerun and does not modify attempts or
user data.
## Rz Coin Store attempt-cycle migration
The QBank reset preserves every attempt and increments a user's eligibility cycle. After deploying
the store-capable API to an environment, inspect and then apply the one-time index migration before
using QBank reset tokens:
```bash
kubectl exec deployment/step3-api -n step3-uat -- \
dotnet QuestionsApi.dll migrate-question-attempt-cycles
kubectl exec deployment/step3-api -n step3-uat -- \
dotnet QuestionsApi.dll migrate-question-attempt-cycles --apply --confirm MIGRATE_ATTEMPT_CYCLES
```
Repeat with namespace `step3-prd` after the tested revision is promoted to `production`. The dry run
reports missing cycle fields and both index states. Apply backfills existing attempts to cycle zero,
creates the three-field unique index, and only then removes the legacy two-field unique index.
Check both internal and public readiness after every rollout:
```bash
-106
View File
@@ -8,41 +8,6 @@ namespace QuestionsApi.Tests;
public sealed class CcsCaseTests
{
[Fact]
public void NewCcsAttemptsAreTimedOnly()
{
Assert.Null(CcsCaseValidator.ValidateCreate(new CreateCcsAttemptRequest()));
Assert.Equal("new CCS attempts must be timed",
CcsCaseValidator.ValidateCreate(new CreateCcsAttemptRequest { Mode = CcsAttemptModes.Practice }));
}
[Fact]
public void CcsBatchCommandsAreBoundedAndHomogeneous()
{
var valid = new CcsBatchCommandRequest
{
CommandId = "batch-1",
Commands =
[
new() { Type = "exam", Code = "general" },
new() { Type = "exam", Code = "cardiac" }
]
};
Assert.Null(CcsCaseValidator.ValidateBatch(valid));
valid.Commands.Add(new() { Type = "order", Code = "bmp" });
Assert.Contains("cannot be mixed", CcsCaseValidator.ValidateBatch(valid), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CcsTimingReservesTwoMinutesWithoutExtendingTheHardDeadline()
{
Assert.Equal(18, CcsStore.ActivePhaseMinutes(20));
Assert.Equal(8, CcsStore.ActivePhaseMinutes(10));
var now = new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc);
Assert.Equal(now.AddMinutes(2), CcsStore.FinalOrdersDeadline(now, now.AddMinutes(5)));
Assert.Equal(now.AddMinutes(1), CcsStore.FinalOrdersDeadline(now, now.AddMinutes(1)));
}
[Fact]
public void CcsHistoryCursorRoundTripsTimestampAndId()
{
@@ -135,48 +100,6 @@ 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 CcsExpectedScoreUsesSeparateUserAndCaseElo()
{
@@ -185,25 +108,6 @@ public sealed class CcsCaseTests
Assert.True(CcsRatingStore.ExpectedScore(800, 1000) < 0.25);
}
[Theory]
[InlineData("medication", "medication")]
[InlineData("medication", "medications")]
[InlineData("medication", "treatment")]
[InlineData("procedure", "procedures")]
[InlineData("laboratory", "tests")]
[InlineData("diagnostic", "study")]
public void CcsOrderSearchMatchesClinicalCategories(string category, string query)
{
var order = new CcsOrderDefinition
{
Code = "order",
CanonicalName = "Unrelated canonical name",
Category = category
};
Assert.True(CcsStore.Rank(order, query) < 1000);
}
[Fact]
public void ScoringReviewIncludesExpectedAndObservedEvidence()
{
@@ -243,14 +147,4 @@ 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 }
]
};
}
+4 -71
View File
@@ -92,33 +92,6 @@ 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()
{
@@ -175,45 +148,6 @@ public sealed class ChoicePoolTests
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void PublishedQuestionRequiresCompleteLabReferenceRanges()
{
var request = BuildRequest(5);
request.Labs =
[
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
];
Assert.Contains("requires a reference range", QuestionValidator.Validate(request));
request.Labs[0].Reference = "136–145 mEq/L";
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void DraftQuestionMaySaveLabWithoutReferenceRange()
{
var request = BuildRequest(5);
request.IsPublished = false;
request.Labs =
[
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
];
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void LabEntriesAlwaysRequireNamesAndValues()
{
var request = BuildRequest(5);
request.Labs = [new QuestionLab { Name = "", Value = "128", Reference = "136–145 mEq/L" }];
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
request.Labs = [new QuestionLab { Name = "Sodium", Value = "", Reference = "136–145 mEq/L" }];
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
}
[Theory]
[InlineData("http://example.org/guideline", "HTTPS")]
[InlineData("https://example.org/guideline", null)]
@@ -302,15 +236,14 @@ public sealed class ChoicePoolTests
id => id,
id => new WrongAnswerFollowup
{
Prompt = $"Which property of Choice {id} makes it less appropriate?",
Prompt = "Which finding makes this less appropriate?",
Choices =
[
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}" }
new QuestionChoice { Id = "A", Text = "Finding one" },
new QuestionChoice { Id = "B", Text = "Finding two" }
],
CorrectChoiceId = "A",
TeachingPoint = $"Choice {id} fails because of its specific disqualifying property."
TeachingPoint = "Teaching point"
}),
ChoiceSelectionPercentages = ids.ToDictionary(id => id, _ => 0),
Discipline = "Medicine",
@@ -49,44 +49,6 @@ public sealed class ContentPromotionTests
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false)));
}
[Fact]
public void ProductionPromotionFlagsMediaForSeparateApproval()
{
var updated = new DateTime(2026, 7, 30, 12, 0, 0, DateTimeKind.Utc);
var source = new BsonDocument
{
["ExternalId"] = "media-question",
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value,
["UpdatedUtc"] = updated,
["Media"] = new BsonArray { ObjectId.GenerateNewId().ToString() }
};
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
Assert.Equal(updated, prepared["FlaggedForReviewUtc"].ToUniversalTime());
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewReason, prepared["FlagReason"].AsString);
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewActor, prepared["FlaggedByUserId"].AsString);
Assert.True(source["FlaggedForReviewUtc"].IsBsonNull);
}
[Fact]
public void ProductionPromotionLeavesMediaFreeQuestionApprovalStateAlone()
{
var source = new BsonDocument
{
["ExternalId"] = "text-question",
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value,
["Media"] = new BsonArray()
};
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
Assert.True(prepared["FlaggedForReviewUtc"].IsBsonNull);
Assert.False(prepared.Contains("FlagReason"));
}
[Fact]
public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds()
{
@@ -1,67 +0,0 @@
using QuestionsApi.Models;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class CreditEconomyTests
{
[Fact]
public void EarningPolicy_UsesTheAgreedRollingLimit()
{
Assert.Equal(30, CreditEconomy.RollingEarningLimit);
Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow);
}
[Theory]
[InlineData(1, 1)]
[InlineData(3, 1)]
[InlineData(4, 2)]
[InlineData(5, 3)]
public void FirstCorrectAwardsScaleWithStoredDifficulty(int difficulty, int expected) =>
Assert.Equal(expected, CreditEconomy.FirstCorrectAmount(difficulty));
[Fact]
public void DelayedRecoveryReplacesTheNormalReviewAward()
{
Assert.Equal(1, CreditEconomy.ReviewAmount(false));
Assert.Equal(3, CreditEconomy.ReviewAmount(true));
}
[Fact]
public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers()
{
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId)));
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings)));
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.Purchases)));
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.AppliedOperations)));
}
[Fact]
public void PublicLedgerEntry_DoesNotExposeUserOrOperationIdentifiers()
{
Assert.Null(typeof(CreditLedgerEntryResponse).GetProperty(nameof(CreditLedgerEntry.UserId)));
Assert.Null(typeof(CreditLedgerEntryResponse).GetProperty(nameof(CreditLedgerEntry.OperationId)));
Assert.Null(typeof(CreditLedgerEntryResponse).GetProperty(nameof(CreditLedgerEntry.SourceId)));
}
[Fact]
public void CatalogHasStableCoinOnlyUtilityPrices()
{
var exam = CreditEconomy.FindProduct(CreditEconomy.ExamTokenSku);
var reset = CreditEconomy.FindProduct(CreditEconomy.QBankResetTokenSku);
Assert.NotNull(exam);
Assert.Equal(100, exam.CoinPrice);
Assert.Equal(RzStoreGrant.ExamToken, exam.Grant);
Assert.NotNull(reset);
Assert.Equal(300, reset.CoinPrice);
Assert.Equal(RzStoreGrant.QBankResetToken, reset.Grant);
Assert.Equal(2, CreditEconomy.Products.Count);
}
[Fact]
public void UnknownSkuIsRejectedByCatalog()
{
Assert.Null(CreditEconomy.FindProduct("cash_purchase"));
}
}
@@ -1,78 +0,0 @@
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();
}
-206
View File
@@ -1,4 +1,3 @@
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
@@ -28,209 +27,4 @@ public sealed class DashboardTests
var dates = new[] { today, today.AddDays(-1), today.AddDays(-3) };
Assert.Equal(2, DashboardStore.CalculateCurrentStreak(dates, today));
}
[Fact]
public void SystemPerformanceReturnsEveryOfficialSystemInOrder()
{
var attempts = new[]
{
Attempt("cardio-1", true),
Attempt("cardio-2", false),
Attempt("ethics-1", true),
Attempt("ignored", true),
Attempt("skipped-cardio", false, true)
};
var questions = new Dictionary<string, Question>
{
["cardio-1"] = Question("Cardiology"),
["cardio-2"] = Question("Cardiovascular System"),
["ethics-1"] = Question("Medical Ethics"),
["ignored"] = Question("Unmapped Specialty"),
["skipped-cardio"] = Question("Cardiology")
};
var result = DashboardStore.BuildSystemPerformance(attempts, questions);
Assert.Equal(14, result.Count);
Assert.Equal(DashboardStore.Step3Systems, result.Select(value => value.System));
var cardiovascular = result.Single(value => value.System == "Cardiovascular System");
Assert.Equal(1, cardiovascular.Correct);
Assert.Equal(2, cardiovascular.Total);
Assert.Equal(50, cardiovascular.Accuracy);
var social = result.Single(value => value.System.StartsWith("Social Sciences"));
Assert.Equal(1, social.Correct);
Assert.Equal(1, social.Total);
Assert.All(result.Where(value => value.System != cardiovascular.System && value.System != social.System),
value => Assert.Equal(0, value.Total));
}
[Theory]
[InlineData("Preventive Medicine", "Human Development")]
[InlineData("Infectious Disease", "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders")]
[InlineData("Dermatology", "Skin & Subcutaneous Tissue")]
[InlineData("Pulmonary", "Respiratory System")]
[InlineData("Pregnancy", "Pregnancy/Childbirth & Female Reproductive System & Breast")]
[InlineData("Biostatistics", "Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature")]
[InlineData("Medical Ethics", "Social Sciences: Communication Skills/Ethics/Patient Safety")]
[InlineData("Integumentary System", "Skin & Subcutaneous Tissue")]
[InlineData("Eye & Adnexa", "Nervous System & Special Senses")]
[InlineData("Toxicology", "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders")]
public void SystemAliasesMapToOfficialCategories(string source, string expected)
{
Assert.Equal(expected, DashboardStore.NormalizeStep3System(source));
}
[Fact]
public void TrendCarriesRatingForwardAndLeavesInactiveAccuracyAsGapData()
{
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
var baselineDate = today.AddDays(-2).AddHours(8);
var attempts = new[]
{
Attempt("one", true, createdUtc: today.AddDays(-2).AddHours(9),
rating: 1018, ratingUtc: today.AddDays(-2).AddHours(9)),
Attempt("two", false, createdUtc: today.AddDays(-2).AddHours(10),
rating: 1002, ratingUtc: today.AddDays(-2).AddHours(10)),
Attempt("three", true, createdUtc: today.AddHours(11),
rating: 1020, ratingUtc: today.AddHours(11)),
Attempt("skip", false, true, today.AddDays(-1).AddHours(4),
990, today.AddDays(-1).AddHours(4))
};
var rating = new UserRating
{
OverallElo = 1020,
HistoryBaselineElo = 1000,
HistoryStartedUtc = baselineDate
};
var result = DashboardStore.BuildPerformanceTrend(
attempts, rating, Array.Empty<CcsAttempt>(), new CcsUserRating(), today);
Assert.Equal(30, result.Count);
Assert.Null(result[^4].QuestionBankRating);
Assert.Equal(1002, result[^3].QuestionBankRating);
Assert.Equal(50, result[^3].Accuracy);
Assert.Equal(2, result[^3].Total);
Assert.Equal(990, result[^2].QuestionBankRating);
Assert.Equal(0, result[^2].Total);
Assert.Equal(1020, result[^1].QuestionBankRating);
Assert.Equal(100, result[^1].Accuracy);
}
[Fact]
public void TrendCarriesCcsRatingForwardIndependentlyOfQuestionBankRating()
{
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
var baselineDate = today.AddDays(-2).AddHours(8);
var rating = new UserRating();
var ccsRating = new CcsUserRating
{
Elo = 1050,
HistoryBaselineElo = 1000,
HistoryStartedUtc = baselineDate
};
var ccsAttempts = new[]
{
RatedCcsAttempt("one", today.AddDays(-1).AddHours(9), 1030),
RatedCcsAttempt("two", today.AddHours(10), 1050)
};
var result = DashboardStore.BuildPerformanceTrend(
Array.Empty<QuestionAttempt>(), rating, ccsAttempts, ccsRating, today);
Assert.Equal(30, result.Count);
Assert.Equal(1000, result[^3].CcsRating);
Assert.Equal(1030, result[^2].CcsRating);
Assert.Equal(1050, result[^1].CcsRating);
}
private static CcsAttempt RatedCcsAttempt(string caseExternalId, DateTime appliedUtc, int userElo) => new()
{
CaseExternalId = caseExternalId,
Rating = new CcsRatingChange { Rated = true, UserElo = userElo, AppliedUtc = appliedUtc }
};
private static QuestionAttempt Attempt(
string questionId,
bool correct,
bool skip = false,
DateTime? createdUtc = null,
int? rating = null,
DateTime? ratingUtc = null) => new()
{
QuestionExternalId = questionId,
IsCorrect = correct,
IsSkip = skip,
CreatedUtc = createdUtc ?? DateTime.UtcNow,
UserOverallEloAfter = rating,
RatingAppliedUtc = ratingUtc
};
private static Question Question(string system) => new()
{
ExternalId = system,
System = system
};
[Fact]
public void PercentileReturnsNullWithNoUsers()
{
Assert.Null(DashboardStore.Percentile(0, 0));
}
[Fact]
public void PercentileComputesTopPercentFromUsersRatedHigher()
{
// 18 of 100 users rate higher than this user - "top 18%"
Assert.Equal(18, DashboardStore.Percentile(18, 100));
}
[Fact]
public void PercentileNeverReturnsZero()
{
// Even the single best-rated user should read as "top 1%", not "top 0%"
Assert.Equal(1, DashboardStore.Percentile(0, 100));
}
[Theory]
[InlineData(0.85, "High")]
[InlineData(0.7, "High")]
[InlineData(0.55, "Medium")]
[InlineData(0.4, "Medium")]
[InlineData(0.1, "Low")]
public void ReadinessLabelUsesPassProbabilityThresholds(double passProbability, string expected)
{
Assert.Equal(expected, DashboardStore.ReadinessLabel(passProbability));
}
[Fact]
public void SelectLatestReadinessScoreSkipsSampleSimulationsEvenWhenMostRecent()
{
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
var simulations = new[]
{
CompletedSimulation(isSample: true, completedUtc: today, passProbability: 0.98),
CompletedSimulation(isSample: false, completedUtc: today.AddDays(-3), passProbability: 0.12)
};
var score = DashboardStore.SelectLatestReadinessScore(simulations);
Assert.NotNull(score);
Assert.Equal(0.12, score.PassProbability);
}
[Fact]
public void SelectLatestReadinessScoreReturnsNullWhenOnlySamplesExist()
{
var simulations = new[] { CompletedSimulation(isSample: true, completedUtc: DateTime.UtcNow, passProbability: 0.98) };
Assert.Null(DashboardStore.SelectLatestReadinessScore(simulations));
}
private static ExamSimulation CompletedSimulation(bool isSample, DateTime completedUtc, double passProbability) => new()
{
IsSample = isSample,
CompletedUtc = completedUtc,
Score = new ExamSimulationScore { PassProbability = passProbability }
};
}
@@ -1,271 +0,0 @@
using Microsoft.Extensions.Configuration;
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class DynamicQuestionTests
{
[Fact]
public void Generator_IsDeterministic_AndPreservesCorrectAnswer()
{
var blueprint = Blueprint();
var generator = new DynamicQuestionGenerator();
var first = generator.Generate(blueprint, "same-seed", "one").Question;
var second = generator.Generate(blueprint, "same-seed", "two").Question;
Assert.Equal(first.Stem, second.Stem);
Assert.Equal(first.Choices.Select(value => value.Text), second.Choices.Select(value => value.Text));
Assert.Equal("correct", first.CorrectChoiceId);
Assert.Null(DynamicQuestionGenerator.Validate(blueprint));
}
[Fact]
public void SignedTokens_AreUserBound_AndRejectTampering()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["DynamicQuestions:SigningKey"] = "unit-test-key" })
.Build();
var service = new DynamicQuestionTokenService(configuration);
var claims = new DynamicGenerationClaims
{
UserId = "user-one",
BlueprintExternalId = "test",
BlueprintVersion = 1,
ObjectiveId = "objective",
Seed = "seed",
Nonce = "nonce",
GeneratorVersion = 1,
ExpiresUtc = DateTime.UtcNow.AddMinutes(5)
};
var token = service.SignGeneration(claims);
Assert.Equal("seed", service.ReadGeneration(token, "user-one").Seed);
Assert.Throws<ArgumentException>(() => service.ReadGeneration(token, "user-two"));
Assert.ThrowsAny<Exception>(() => service.ReadGeneration(token + "x", "user-one"));
}
[Fact]
public void GoldGenerator_UsesCaseFamilies_AndProducesAuthoredTeachingShape()
{
var blueprint = GoldBlueprint();
var generator = new DynamicQuestionGenerator();
var generated = Enumerable.Range(0, 100)
.Select(seed => generator.Generate(blueprint, seed.ToString(), seed.ToString()))
.ToList();
Assert.Equal(3, generated.Select(value => value.CaseFamilyId).Distinct().Count());
Assert.All(generated, value =>
{
Assert.True(value.Question.Stem.Length >= 220);
Assert.Equal(5, value.Question.Choices.Count);
Assert.Equal(5, value.Question.ChoiceExplanations.Count);
Assert.Single(value.Question.ReviewFollowups);
Assert.Equal(4, value.Question.WrongAnswerFollowups.Count);
Assert.Contains("quality-standard:2", value.Question.Tags);
});
Assert.Null(DynamicQuestionGenerator.Validate(blueprint));
}
[Fact]
public void LegacyGeneratorVersion_RemainsDeterministic()
{
var generator = new DynamicQuestionGenerator();
var first = generator.Generate(Blueprint(), "legacy-seed", "one", 1).Question;
var regenerated = generator.Generate(Blueprint(), "legacy-seed", "two", 1).Question;
Assert.Equal(first.Stem, regenerated.Stem);
Assert.Equal(first.Choices.Select(value => value.Id), regenerated.Choices.Select(value => value.Id));
}
[Fact]
public void DifficultyPolicy_BlendsThenSpecializes_AndTargetsSixtyPercent()
{
Assert.Equal(1200, DynamicDifficultyPolicy.EffectiveAbility(1100, 1300, null));
var early = new DynamicObjectiveMastery { Elo = 1500, Attempts = 1 };
var established = new DynamicObjectiveMastery { Elo = 1500, Attempts = 10 };
Assert.Equal(1224, DynamicDifficultyPolicy.EffectiveAbility(1100, 1300, early));
Assert.Equal(1440, DynamicDifficultyPolicy.EffectiveAbility(1100, 1300, established));
Assert.Equal(1370, DynamicDifficultyPolicy.TargetFamilyElo(1440));
}
[Fact]
public void DifficultyPolicy_SelectsClosestNonrecentFamily()
{
var families = new List<DynamicCaseFamily>
{
new() { Id = "foundation", DifficultyTier = "foundation", NominalElo = 850 },
new() { Id = "standard", DifficultyTier = "standard", NominalElo = 1050 },
new() { Id = "advanced-a", DifficultyTier = "advanced", NominalElo = 1250 },
new() { Id = "advanced-b", DifficultyTier = "advanced", NominalElo = 1250 },
new() { Id = "expert", DifficultyTier = "expert", NominalElo = 1450 }
};
var selected = DynamicDifficultyPolicy.SelectFamily(
families, new Dictionary<string, DynamicFamilyRating>(), 1260,
new HashSet<string> { "advanced-a" }, 0);
Assert.Equal("advanced-b", selected.Id);
}
[Fact]
public void DifficultyPolicy_RatingsMoveInOppositeDirections()
{
Assert.True(DynamicDifficultyPolicy.LearnerDelta(1000, 1050, true, 0) > 0);
Assert.True(DynamicDifficultyPolicy.FamilyDelta(1000, 1050, true, 0) < 0);
Assert.True(DynamicDifficultyPolicy.LearnerDelta(1000, 1050, false, 0) < 0);
Assert.True(DynamicDifficultyPolicy.FamilyDelta(1000, 1050, false, 0) > 0);
Assert.Equal(600, DynamicDifficultyPolicy.Clamp(-100));
Assert.Equal(1800, DynamicDifficultyPolicy.Clamp(5000));
}
[Fact]
public void ReviewPolicy_UsesFixedIntervals_AndResetsAfterAMiss()
{
var now = new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc);
Assert.Equal(now.AddDays(1), DynamicReviewPolicy.FirstDue(now));
Assert.Equal((1, now.AddDays(3), false), DynamicReviewPolicy.AfterReview(0, true, now));
Assert.Equal((2, now.AddDays(7), false), DynamicReviewPolicy.AfterReview(1, true, now));
Assert.Equal((3, now.AddDays(21), false), DynamicReviewPolicy.AfterReview(2, true, now));
Assert.Equal((4, now.AddDays(45), false), DynamicReviewPolicy.AfterReview(3, true, now));
Assert.Equal((5, null, true), DynamicReviewPolicy.AfterReview(4, true, now));
Assert.Equal((0, now.AddDays(1), false), DynamicReviewPolicy.AfterReview(3, false, now));
}
[Fact]
public void QualityStandardFour_RequiresCoverageReviewAndMisconceptionMetadata()
{
var options = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)
{ PropertyNameCaseInsensitive = true };
var root = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "..", "..", "content", "dynamic_questions"));
var source = Directory.EnumerateFiles(root, "*.json")
.SelectMany(file => System.Text.Json.JsonSerializer.Deserialize<List<DynamicQuestionBlueprint>>(
File.ReadAllText(file), options) ?? [])
.ToList();
var blueprint = DynamicBlueprintResolver.ResolveAll(source)
.First(value => value.QualityStandardVersion == 3);
blueprint.QualityStandardVersion = 4;
blueprint.BlueprintWeight = 6;
Assert.Contains("encounterFrame", DynamicQuestionGenerator.Validate(blueprint));
blueprint.System = "Endocrine";
blueprint.PhysicianTask = "Pharmacotherapy";
blueprint.EncounterFrame = "Urgent Intervention";
blueprint.SiteOfCare = "Emergency Department";
blueprint.ConceptIds = ["test.concept"];
blueprint.MedicallyReviewedUtc = DateTime.UtcNow.AddDays(-1);
blueprint.MedicalReviewExpiresUtc = DateTime.UtcNow.AddYears(1);
blueprint.MedicalReviewer = "Medical editor";
foreach (var distractor in blueprint.Distractors)
distractor.MisconceptionId = $"misconception.{distractor.Id}";
Assert.Null(DynamicQuestionGenerator.Validate(blueprint));
}
[Fact]
public void CoveragePolicy_UsesOfficialDefaults_AndAllowsVersionedOverrides()
{
Assert.Equal(12, DynamicBlueprintCoveragePolicy.Weight(
new DynamicQuestionBlueprint { System = "Biostatistics" }));
Assert.Equal(10, DynamicBlueprintCoveragePolicy.Weight(
new DynamicQuestionBlueprint { System = "Cardiovascular" }));
Assert.Equal(2.5, DynamicBlueprintCoveragePolicy.Weight(
new DynamicQuestionBlueprint { System = "Cardiovascular", BlueprintWeight = 2.5 }));
}
[Fact]
public void AdaptiveContent_ResolvesToFourCompleteTiers()
{
var options = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)
{ PropertyNameCaseInsensitive = true };
var root = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "..", "..", "content", "dynamic_questions"));
var source = Directory.EnumerateFiles(root, "*.json")
.SelectMany(file => System.Text.Json.JsonSerializer.Deserialize<List<DynamicQuestionBlueprint>>(
File.ReadAllText(file), options) ?? [])
.ToList();
var adaptive = DynamicBlueprintResolver.ResolveAll(source)
.Where(value => value.QualityStandardVersion >= 3).ToList();
Assert.Equal(5, adaptive.Count);
Assert.All(adaptive, blueprint =>
{
Assert.Equal(8, blueprint.CaseFamilies.Count);
Assert.All(DynamicDifficultyPolicy.TierElos.Keys, tier =>
Assert.Equal(2, blueprint.CaseFamilies.Count(value => value.DifficultyTier == tier)));
Assert.All(blueprint.CaseFamilies, family =>
Assert.False(family.StemTemplate.EndsWith(family.LeadIn, StringComparison.Ordinal)));
Assert.Null(DynamicQuestionGenerator.Validate(blueprint));
});
}
private static DynamicQuestionBlueprint Blueprint() => new()
{
ExternalId = "test",
ObjectiveId = "objective",
StemTemplate = "Patient age {{age}}.",
LeadIn = "What is correct?",
Variables = new()
{
["age"] = new DynamicVariableDefinition { Minimum = 20, Maximum = 80, Step = 1 }
},
CorrectAnswer = new DynamicAnswerTemplate { Id = "correct", TextTemplate = "Correct" },
Distractors =
[
new() { Id = "a", TextTemplate = "A" },
new() { Id = "b", TextTemplate = "B" },
new() { Id = "c", TextTemplate = "C" },
new() { Id = "d", TextTemplate = "D" }
],
References = [new QuestionReference { Title = "Reference", Url = "https://example.test" }]
};
private static DynamicQuestionBlueprint GoldBlueprint()
{
var stem = string.Join(" ", Enumerable.Repeat(
"A patient presents with a carefully correlated history, examination, vital signs, and diagnostic findings that establish the clinical context without naming the diagnosis.", 3));
return new DynamicQuestionBlueprint
{
ExternalId = "gold-test",
Version = 2,
ObjectiveId = "gold.objective",
QualityStandardVersion = 2,
CorrectConceptId = "gold.correct",
CorrectAnswer = new DynamicAnswerTemplate
{ Id = "correct", TextTemplate = "Correct action", ExplanationTemplate = "This addresses the decisive finding." },
Distractors = Enumerable.Range(1, 6).Select(index => new DynamicAnswerTemplate
{
Id = $"d{index}",
TextTemplate = $"Distractor {index}",
ExplanationTemplate = $"Distractor {index} does not address the decisive finding."
}).ToList(),
ExplanationTemplate = "A complete explanation connects the presentation to the action.",
KeyTakeaway = "Use the decisive finding.",
UsmlePearl = "Sequence urgent care correctly.",
ReviewFollowups =
[
new DynamicReviewFollowupTemplate
{
PromptTemplate = "What is decisive?",
Choices =
[
new() { Id = "yes", TextTemplate = "The unstable finding" },
new() { Id = "no", TextTemplate = "An incidental finding" }
],
CorrectChoiceId = "yes",
TeachingPointTemplate = "The unstable finding determines management."
}
],
CaseFamilies =
[
new() { Id = "family-a", StemTemplate = stem, LeadIn = "What is next?" },
new() { Id = "family-b", StemTemplate = stem + " Family B.", LeadIn = "What is next?" },
new() { Id = "family-c", StemTemplate = stem + " Family C.", LeadIn = "What is next?" }
],
References = [new QuestionReference { Title = "Reference", Url = "https://example.test" }]
};
}
}
+3 -154
View File
@@ -44,104 +44,8 @@ public sealed class ExamSimulationTests
Assert.Equal(0, failing.Correct);
}
[Theory]
[InlineData(0, 50)]
[InlineData(20, 100)]
[InlineData(60, 200)]
[InlineData(100, 300)]
public void NeutralDifficultyUsesTransparentScoreAnchors(int correctCount, int expectedScore)
{
var items = BuildItems(100);
var questions = BuildQuestions(100);
var responses = items.Take(correctCount).ToDictionary(
item => item.QuestionExternalId,
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
var score = ExamSimulationScoring.Calculate(items, responses, questions);
Assert.Equal(expectedScore, score.PredictedScore);
Assert.Equal(correctCount / 100.0, score.RawAccuracy, 4);
Assert.Equal(correctCount / 100.0, score.DifficultyAdjustedAccuracy, 4);
}
[Fact]
public void CorrectHarderQuestionsProduceAHigherAdjustedPrediction()
{
var questions = BuildQuestions(10);
var hardCorrect = BuildItems(10);
var easyCorrect = BuildItems(10);
for (var index = 0; index < 10; index++)
{
hardCorrect[index].QuestionDifficulty = index < 5 ? 5 : 1;
easyCorrect[index].QuestionDifficulty = index < 5 ? 1 : 5;
}
var responses = hardCorrect.Take(5).ToDictionary(
item => item.QuestionExternalId,
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
var hardScore = ExamSimulationScoring.Calculate(hardCorrect, responses, questions);
var easyScore = ExamSimulationScoring.Calculate(easyCorrect, responses, questions);
Assert.Equal(hardScore.RawAccuracy, easyScore.RawAccuracy);
Assert.True(hardScore.DifficultyAdjustedAccuracy > easyScore.DifficultyAdjustedAccuracy);
Assert.True(hardScore.PredictedScore > easyScore.PredictedScore);
}
[Fact]
public void EmpiricalQuestionEloDoesNotAffectPrediction()
{
var questions = BuildQuestions(20);
var lowElos = BuildItems(20);
var highElos = BuildItems(20);
foreach (var item in lowElos) item.QuestionElo = 100;
foreach (var item in highElos) item.QuestionElo = 1900;
var responses = lowElos.Take(10).ToDictionary(
item => item.QuestionExternalId,
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
var lowEloScore = ExamSimulationScoring.Calculate(lowElos, responses, questions);
var highEloScore = ExamSimulationScoring.Calculate(highElos, responses, questions);
Assert.Equal(lowEloScore.PredictedScore, highEloScore.PredictedScore);
Assert.Equal(lowEloScore.PassProbability, highEloScore.PassProbability);
}
[Fact]
public void SkippedAndUnansweredQuestionsCountAsIncorrect()
{
var items = BuildItems(10);
var questions = BuildQuestions(10);
var responses = new Dictionary<string, ExamSimulationResponse>
{
[items[0].QuestionExternalId] = new() { QuestionExternalId = items[0].QuestionExternalId, ChoiceId = "A" },
[items[1].QuestionExternalId] = new() { QuestionExternalId = items[1].QuestionExternalId, ChoiceId = "" }
};
var score = ExamSimulationScoring.Calculate(items, responses, questions);
Assert.Equal(1, score.Correct);
Assert.Equal(10, score.Total);
Assert.Equal(0.1, score.RawAccuracy, 4);
}
[Fact]
public void LowAccuracyFullLengthSimulationIsNotInflated()
{
var items = BuildItems(232);
var questions = BuildQuestions(232);
var responses = items.Take(49).ToDictionary(
item => item.QuestionExternalId,
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
var score = ExamSimulationScoring.Calculate(items, responses, questions);
Assert.Equal(103, score.PredictedScore);
Assert.True(score.PassProbability < 0.001);
Assert.True(score.RangeHigh < 130);
}
[Fact]
public void SeenItemsAreReportedButDoNotChangeScoring()
public void SeenItemsAreReportedAndCarryLessScoringWeight()
{
var questions = BuildQuestions(20);
var unseen = BuildItems(20);
@@ -155,9 +59,7 @@ public sealed class ExamSimulationTests
Assert.Equal(0, unseenScore.SeenCount);
Assert.Equal(20, seenScore.SeenCount);
Assert.Equal(unseenScore.PredictedScore, seenScore.PredictedScore);
Assert.Null(unseenScore.AbilityElo);
Assert.Null(seenScore.AbilityElo);
Assert.True(unseenScore.AbilityElo > seenScore.AbilityElo);
}
[Theory]
@@ -209,64 +111,11 @@ public sealed class ExamSimulationTests
Assert.False(ExamSimulationStore.TryDecodeHistoryCursor("not-a-cursor", out _));
}
[Theory]
[InlineData(ExamSimulationStates.BetweenBlocks, true)]
[InlineData(ExamSimulationStates.Completed, true)]
[InlineData(ExamSimulationStates.BlockActive, false)]
[InlineData(ExamSimulationStates.Abandoned, false)]
public void RepeatedBlockSubmissionIsIdempotentOnlyAfterSubmission(string state, bool expected)
{
Assert.Equal(expected, ExamSimulationStore.IsAlreadySubmittedState(state));
}
[Fact]
public void SubmittedSimulationResponsesAreLocked()
{
Assert.True(ExamSimulationStore.IsResponseLocked(
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = true }));
Assert.False(ExamSimulationStore.IsResponseLocked(
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = false }));
Assert.True(ExamSimulationStore.IsResponseLocked(
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = null }));
Assert.False(ExamSimulationStore.IsResponseLocked(
new ExamSimulationResponse { ChoiceId = "", IsSubmitted = null }));
}
[Fact]
public void SubmittedResponseRetriesMustPreserveAnswerContent()
{
var response = new ExamSimulationResponse
{
ChoiceId = "B",
EliminatedChoiceIds = ["A"],
Flagged = true,
IsSubmitted = true
};
var same = new SaveSimulationResponseRequest
{
ChoiceId = "B",
EliminatedChoiceIds = ["A"],
Flagged = true,
IsSubmitted = true
};
var changed = new SaveSimulationResponseRequest
{
ChoiceId = "C",
EliminatedChoiceIds = ["A"],
Flagged = true,
IsSubmitted = true
};
Assert.True(ExamSimulationStore.ResponseContentMatches(response, same, ["A", "B", "C"]));
Assert.False(ExamSimulationStore.ResponseContentMatches(response, changed, ["A", "B", "C"]));
}
private static List<ExamSimulationItem> BuildItems(int count, bool wasSeen = false) =>
Enumerable.Range(0, count).Select(index => new ExamSimulationItem
{
QuestionExternalId = $"question-{index}",
QuestionElo = 1000,
QuestionDifficulty = 3,
WasSeen = wasSeen,
BlueprintCategory = "Cardiovascular"
}).ToList();
@@ -274,5 +123,5 @@ public sealed class ExamSimulationTests
private static Dictionary<string, Question> BuildQuestions(int count) =>
Enumerable.Range(0, count).ToDictionary(
index => $"question-{index}",
index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A", Difficulty = 3 });
index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A" });
}
@@ -1,94 +0,0 @@
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardClozeParserTests
{
[Fact]
public void Parse_ExtractsAnswerAndHint()
{
var deletions = FlashcardClozeParser.Parse("The capital of France is {{c1::Paris::country capital}}.");
var deletion = Assert.Single(deletions);
Assert.Equal(1, deletion.Number);
Assert.Equal("Paris", deletion.Answer);
Assert.Equal("country capital", deletion.Hint);
}
[Fact]
public void Parse_WithoutHint_LeavesHintNull()
{
var deletions = FlashcardClozeParser.Parse("{{c1::Paris}}");
Assert.Null(Assert.Single(deletions).Hint);
}
[Fact]
public void DistinctClozeNumbers_DeduplicatesRepeatedNumbersAndSorts()
{
var text = "{{c2::b}} some text {{c1::a}} more {{c1::a-again}} {{c3::c}}";
var numbers = FlashcardClozeParser.DistinctClozeNumbers(text);
Assert.Equal([1, 2, 3], numbers);
}
[Theory]
[InlineData("no cloze markers here")]
[InlineData("{{c0::invalid, number must be >= 1}}")]
[InlineData("{{cX::not a number}}")]
[InlineData("{{c1::}}")]
public void HasValidCloze_IsFalse_ForMalformedOrMissingMarkers(string text)
{
Assert.False(FlashcardClozeParser.HasValidCloze(text));
}
[Fact]
public void HasValidCloze_IsTrue_WhenAtLeastOneValidMarkerExists()
{
Assert.True(FlashcardClozeParser.HasValidCloze("prefix {{c1::answer}} suffix"));
}
[Fact]
public void RenderFront_HidesTargetNumber_RevealsOthers()
{
var text = "{{c1::Paris}} is the capital of {{c2::France}}";
var front1 = FlashcardClozeParser.RenderFront(text, 1);
var front2 = FlashcardClozeParser.RenderFront(text, 2);
Assert.Equal("[...] is the capital of France", front1);
Assert.Equal("Paris is the capital of [...]", front2);
}
[Fact]
public void RenderFront_UsesHintWhenPresent()
{
var text = "{{c1::Paris::capital city}} is in France";
var front = FlashcardClozeParser.RenderFront(text, 1);
Assert.Equal("[capital city] is in France", front);
}
[Fact]
public void RenderBack_RevealsAllAnswers()
{
var text = "{{c1::Paris}} is the capital of {{c2::France::hint}}";
var back = FlashcardClozeParser.RenderBack(text, 1);
Assert.Equal("Paris is the capital of France", back);
}
[Fact]
public void Parse_SupportsMultilineClozeContent()
{
var text = "Line one\n{{c1::multi\nline\nanswer}}\nLine two";
var deletion = Assert.Single(FlashcardClozeParser.Parse(text));
Assert.Equal("multi\nline\nanswer", deletion.Answer);
}
}
@@ -1,75 +0,0 @@
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardCsvTests
{
[Fact]
public void ParseRows_HandlesQuotedMultilineFieldsAndEmbeddedCommas()
{
var csv = "stable_id,deck,type,prompt,answer,extra,tags,source_question_external_id\n"
+ "abc-1,Cardio,basic,\"What is the first line,\nsecond line?\",\"Answer, with comma\",,tag1;tag2,q-1\n";
var rows = FlashcardCsv.ParseRows(csv);
Assert.Equal(2, rows.Count);
var data = rows[1];
Assert.Equal("abc-1", data[0]);
Assert.Equal("What is the first line,\nsecond line?", data[3]);
Assert.Equal("Answer, with comma", data[4]);
Assert.Equal("tag1;tag2", data[6]);
}
[Fact]
public void ParseRows_HandlesEscapedDoubleQuotes()
{
var csv = "a,b\n\"she said \"\"hi\"\"\",plain\n";
var rows = FlashcardCsv.ParseRows(csv);
Assert.Equal("she said \"hi\"", rows[1][0]);
Assert.Equal("plain", rows[1][1]);
}
[Fact]
public void ParseRows_SupportsUnicodeContent()
{
var csv = "a,b\néè中文,\U0001F600\n";
var rows = FlashcardCsv.ParseRows(csv);
Assert.Equal("éè中文", rows[1][0]);
Assert.Equal("😀", rows[1][1]);
}
[Fact]
public void ParseRows_HandlesFileWithoutTrailingNewline()
{
var csv = "a,b\n1,2";
var rows = FlashcardCsv.ParseRows(csv);
Assert.Equal(2, rows.Count);
Assert.Equal(["1", "2"], rows[1]);
}
[Fact]
public void WriteRow_QuotesFieldsContainingSpecialCharacters()
{
var row = FlashcardCsv.WriteRow(["plain", "has,comma", "has\"quote", "has\nnewline"]);
Assert.Equal("plain,\"has,comma\",\"has\"\"quote\",\"has\nnewline\"", row);
}
[Fact]
public void WriteThenParse_RoundTrips()
{
var original = new[] { "id-1", "Deck, Name", "basic", "prompt with \"quotes\" and\nnewline", "answer", "", "a;b", "q-1" };
var line = FlashcardCsv.WriteRow(original);
var rows = FlashcardCsv.ParseRows("header\n" + line + "\n");
Assert.Equal(original, rows[1]);
}
}
@@ -1,92 +0,0 @@
using System.Text.Json;
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardImportExportModelTests
{
[Fact]
public void JsonExport_RoundTrips_NotesCardsAndReviewHistory()
{
var export = new FlashcardJsonExport
{
SchemaVersion = 1,
Settings = new FlashcardSettings { UserId = "user-1", NewCardsPerDay = 20, ReviewsPerDay = 200 },
Decks = [new FlashcardDeck { Id = "deck-1", UserId = "user-1", Name = "Cardio", IsDefault = true }],
Notes =
[
new FlashcardJsonExportNote
{
Note = new FlashcardNote
{
Id = "note-1", UserId = "user-1", DeckId = "deck-1", StableId = "stable-1",
Type = FlashcardTypes.Cloze, Prompt = "{{c1::Paris}} is the capital of France",
Tags = ["geography"]
},
Cards =
[
new FlashcardSchedule
{
Id = "card-1", UserId = "user-1", NoteId = "note-1", Variant = 1,
State = FlashcardScheduleStates.Review, Stability = 12.3, Difficulty = 4.5,
DueUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
}
],
ReviewLogs =
[
new FlashcardReviewLog
{
Id = "log-1", UserId = "user-1", CardId = "card-1", Rating = FlashcardRatings.Good,
ReviewedUtc = new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc)
}
],
Images = [new FlashcardJsonExportImage { AssetId = "asset-1", FileName = "x.png", ContentType = "image/png", Base64Bytes = Convert.ToBase64String([1, 2, 3]) }]
}
]
};
var json = JsonSerializer.Serialize(export);
var roundTripped = JsonSerializer.Deserialize<FlashcardJsonExport>(json);
Assert.NotNull(roundTripped);
var note = Assert.Single(roundTripped!.Notes);
Assert.Equal("stable-1", note.Note.StableId);
Assert.Equal(FlashcardTypes.Cloze, note.Note.Type);
var card = Assert.Single(note.Cards);
Assert.Equal(1, card.Variant);
Assert.Equal(12.3, card.Stability);
var log = Assert.Single(note.ReviewLogs);
Assert.Equal(FlashcardRatings.Good, log.Rating);
var image = Assert.Single(note.Images);
Assert.Equal([1, 2, 3], Convert.FromBase64String(image.Base64Bytes));
}
[Fact]
public void Fingerprint_IsStable_ForSameTypePromptAndAnswer_IgnoringSurroundingWhitespace()
{
var a = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, " What is 2+2? ", "4");
var b = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "4");
Assert.Equal(a, b);
}
[Fact]
public void Fingerprint_DiffersWhenAnswerDiffers()
{
var a = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "4");
var b = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "five");
Assert.NotEqual(a, b);
}
[Fact]
public void Fingerprint_DiffersByType_ForSamePromptAndAnswer()
{
var basic = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "text", "text");
var cloze = FlashcardNoteStore.Fingerprint(FlashcardTypes.Cloze, "text", "text");
Assert.NotEqual(basic, cloze);
}
}
@@ -1,32 +0,0 @@
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardOwnershipTests
{
[Theory]
[InlineData(false, false, true)]
[InlineData(false, true, false)]
[InlineData(true, false, true)]
[InlineData(true, true, true)]
public void CanMutate_MatchesTruthTable(bool isSuper, bool targetIsSystem, bool expected)
{
Assert.Equal(expected, FlashcardOwnershipGuard.CanMutate(isSuper, targetIsSystem));
}
[Fact]
public void FlashcardDeck_IsSystem_TrueOnlyForTheReservedOwner()
{
Assert.True(new FlashcardDeck { UserId = FlashcardOwners.System }.IsSystem);
Assert.False(new FlashcardDeck { UserId = "a-real-user-id" }.IsSystem);
}
[Fact]
public void FlashcardNote_IsSystem_TrueOnlyForTheReservedOwner()
{
Assert.True(new FlashcardNote { UserId = FlashcardOwners.System }.IsSystem);
Assert.False(new FlashcardNote { UserId = "a-real-user-id" }.IsSystem);
}
}
@@ -1,152 +0,0 @@
using FSRS.Core.Constants;
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
// Validates FsrsFlashcardScheduler against the official FSRS formulas (not just against the
// library's own outputs), using the exact weights FSRS.Core 1.0.7 ships via FsrsConstants so
// this keeps validating the formula even if the shipped defaults are ever bumped upstream.
//
// S0(rating) = w[(int)rating - 1] (initial stability)
// D0(rating) = clamp(w4 - (e^(w5*((int)rating-1)) - 1), 1, 10) (initial difficulty)
// I(r, S) = round(S) when desired_retention == the FSRS reference retention (0.9), because
// the interval formula collapses to the stability itself at that specific retention.
public sealed class FlashcardSchedulerTests
{
private static readonly double[] W = FsrsConstants.DefaultParameters;
private static readonly DateTime Start = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static FlashcardSchedule NewSchedule() => new()
{
UserId = "user-1",
NoteId = "note-1",
State = FlashcardScheduleStates.New,
DueUtc = Start,
LastReviewUtc = null
};
[Theory]
[InlineData(FlashcardRatings.Again, 0)]
[InlineData(FlashcardRatings.Hard, 1)]
[InlineData(FlashcardRatings.Good, 2)]
[InlineData(FlashcardRatings.Easy, 3)]
public void InitialStability_MatchesFsrsFormula(string rating, int weightIndex)
{
var scheduler = new FsrsFlashcardScheduler();
var result = scheduler.Review(NewSchedule(), rating, Start, 0);
Assert.Equal(W[weightIndex], result.Stability, precision: 6);
}
[Theory]
[InlineData(FlashcardRatings.Again, 1)]
[InlineData(FlashcardRatings.Hard, 2)]
[InlineData(FlashcardRatings.Good, 3)]
[InlineData(FlashcardRatings.Easy, 4)]
public void InitialDifficulty_MatchesFsrsFormula(string rating, int fsrsRatingValue)
{
var scheduler = new FsrsFlashcardScheduler();
var result = scheduler.Review(NewSchedule(), rating, Start, 0);
var expected = Math.Clamp(W[4] - (Math.Exp(W[5] * (fsrsRatingValue - 1)) - 1), 1, 10);
Assert.Equal(expected, result.Difficulty, precision: 6);
}
[Fact]
public void Interval_EqualsRoundedNewStability_AtPinnedDesiredRetention()
{
// At desired_retention == 0.9 (the reference retention baked into the FSRS interval
// formula), I(0.9, S) reduces to S regardless of the decay parameter - this holds for
// any state, so it's a strong end-to-end check that both the stability update and the
// interval formula are wired correctly, without hand-deriving decay/factor constants.
var scheduler = new FsrsFlashcardScheduler();
var schedule = NewSchedule();
var t = Start;
foreach (var rating in new[] { FlashcardRatings.Good, FlashcardRatings.Good, FlashcardRatings.Good })
{
var result = scheduler.Review(schedule, rating, t, 0);
schedule = ToSchedule(schedule, result);
t = result.DueUtc;
}
var final = scheduler.Review(schedule, FlashcardRatings.Good, t, 0);
var intervalDays = (final.DueUtc - t).TotalDays;
Assert.Equal(Math.Round(final.Stability, MidpointRounding.AwayFromZero), Math.Round(intervalDays), precision: 6);
}
[Fact]
public void MaximumInterval_NeverExceeds36500Days()
{
var scheduler = new FsrsFlashcardScheduler();
var schedule = NewSchedule();
schedule.State = FlashcardScheduleStates.Review;
schedule.Stability = 1_000_000;
schedule.Difficulty = 5;
schedule.DueUtc = Start;
schedule.LastReviewUtc = Start.AddDays(-1);
var result = scheduler.Review(schedule, FlashcardRatings.Good, Start, 0);
Assert.True((result.DueUtc - Start).TotalDays <= FsrsFlashcardScheduler.MaximumIntervalDays);
}
[Fact]
public void Again_MovesToRelearning_AndReducesStabilityBelowPriorReview()
{
var scheduler = new FsrsFlashcardScheduler();
var schedule = NewSchedule();
var t = Start;
FlashcardScheduleResult result = null!;
foreach (var rating in new[] { FlashcardRatings.Good, FlashcardRatings.Good })
{
result = scheduler.Review(schedule, rating, t, 0);
schedule = ToSchedule(schedule, result);
t = result.DueUtc;
}
var stabilityBeforeLapse = schedule.Stability;
var lapsed = scheduler.Review(schedule, FlashcardRatings.Again, t, 0);
Assert.Equal(FlashcardScheduleStates.Relearning, lapsed.State);
Assert.True(lapsed.Stability < stabilityBeforeLapse);
}
[Fact]
public void PreviewRatingOptions_ReturnsAllFourRatings_WithNonDecreasingDueDates()
{
var scheduler = new FsrsFlashcardScheduler();
var schedule = NewSchedule();
schedule.State = FlashcardScheduleStates.Review;
schedule.Stability = 10;
schedule.Difficulty = 5;
schedule.LastReviewUtc = Start.AddDays(-10);
schedule.DueUtc = Start;
var options = scheduler.PreviewRatingOptions(schedule, Start);
Assert.Equal(FlashcardRatings.All.Length, options.Count);
Assert.Equal(FlashcardRatings.All.ToHashSet(), options.Select(o => o.Rating).ToHashSet());
var again = options.Single(o => o.Rating == FlashcardRatings.Again).DueUtc;
var hard = options.Single(o => o.Rating == FlashcardRatings.Hard).DueUtc;
var good = options.Single(o => o.Rating == FlashcardRatings.Good).DueUtc;
var easy = options.Single(o => o.Rating == FlashcardRatings.Easy).DueUtc;
Assert.True(again <= hard);
Assert.True(hard <= good);
Assert.True(good <= easy);
}
private static FlashcardSchedule ToSchedule(FlashcardSchedule prior, FlashcardScheduleResult result) => new()
{
UserId = prior.UserId,
NoteId = prior.NoteId,
Variant = prior.Variant,
State = result.State,
Step = result.Step,
Stability = result.Stability,
Difficulty = result.Difficulty,
DueUtc = result.DueUtc,
LastReviewUtc = result.LastReviewUtc
};
}
@@ -1,177 +0,0 @@
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardSchedulingTests
{
[Fact]
public void NextStudyDayStart_IsMidnightOfTheFollowingDay()
{
var studyDayStart = new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc);
var next = FlashcardScheduling.NextStudyDayStart(studyDayStart);
Assert.Equal(new DateTime(2026, 3, 6, 0, 0, 0, DateTimeKind.Utc), next);
}
[Fact]
public void NextStudyDayStart_TruncatesTimeOfDayBeforeAdvancing()
{
// A session that started mid-day still buries siblings until the *next calendar day*,
// not exactly 24h later.
var studyDayStart = new DateTime(2026, 3, 5, 14, 30, 0, DateTimeKind.Utc);
var next = FlashcardScheduling.NextStudyDayStart(studyDayStart);
Assert.Equal(new DateTime(2026, 3, 6, 0, 0, 0, DateTimeKind.Utc), next);
}
[Theory]
[InlineData(20, null, 0, 20)]
[InlineData(20, null, 15, 5)]
[InlineData(20, null, 25, 0)]
[InlineData(20, 5, 0, 5)]
[InlineData(20, 0, 0, 0)]
public void ResolveDailyBudget_DeckOverrideWinsOverGlobal_ClampedAtZero(
int globalLimit, int? deckOverride, int consumedToday, int expected)
{
Assert.Equal(expected, FlashcardScheduling.ResolveDailyBudget(globalLimit, deckOverride, consumedToday));
}
private static readonly DateTime Today = new(2026, 3, 10, 0, 0, 0, DateTimeKind.Utc);
[Fact]
public void ComputeStreakDays_NoReviews_IsZero()
{
Assert.Equal(0, FlashcardScheduling.ComputeStreakDays([], Today));
}
[Fact]
public void ComputeStreakDays_ReviewedTodayOnly_IsOne()
{
var timestamps = new List<DateTime> { Today.AddHours(9) };
Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void ComputeStreakDays_ThreeConsecutiveDaysEndingToday_IsThree()
{
var timestamps = new List<DateTime>
{
Today.AddDays(-2).AddHours(8), Today.AddDays(-1).AddHours(20), Today.AddHours(7)
};
Assert.Equal(3, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void ComputeStreakDays_NotYetReviewedToday_StillCountsThroughYesterday()
{
// The streak shouldn't visibly reset to 0 the instant a new calendar day begins, before
// the user has had a chance to study today.
var timestamps = new List<DateTime> { Today.AddDays(-2).AddHours(8), Today.AddDays(-1).AddHours(20) };
Assert.Equal(2, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void ComputeStreakDays_GapBeforeYesterday_BreaksTheStreak()
{
var timestamps = new List<DateTime>
{
Today.AddDays(-10).AddHours(8), Today.AddDays(-1).AddHours(20), Today.AddHours(7)
};
Assert.Equal(2, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void ComputeStreakDays_MissedYesterdayAndToday_IsZero()
{
var timestamps = new List<DateTime> { Today.AddDays(-3).AddHours(8) };
Assert.Equal(0, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void ComputeStreakDays_MultipleReviewsSameDay_CountOnceTowardTheStreak()
{
var timestamps = new List<DateTime> { Today.AddHours(7), Today.AddHours(8), Today.AddHours(9) };
Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void PickIndex_NonShuffle_ReturnsFirstNonExcluded()
{
var pool = new List<string> { "a", "b", "c" };
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a" }, shuffle: false, n => throw new InvalidOperationException("should not randomize"));
Assert.Equal(1, picked);
}
[Fact]
public void PickIndex_NonShuffle_EverythingExcluded_ReturnsNull()
{
var pool = new List<string> { "a", "b" };
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a", "b" }, shuffle: false, n => 0);
Assert.Null(picked);
}
[Fact]
public void PickIndex_EmptyPool_ReturnsNullRegardlessOfShuffle()
{
Assert.Null(FlashcardScheduling.PickIndex([], new HashSet<string>(), shuffle: false, n => 0));
Assert.Null(FlashcardScheduling.PickIndex([], new HashSet<string>(), shuffle: true, n => 0));
}
[Fact]
public void PickIndex_Shuffle_StubReturningFirstCandidate_PicksFirstNonExcluded()
{
var pool = new List<string> { "a", "b", "c" };
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a" }, shuffle: true, n => 0);
// Candidates (excluding "a") are ["b", "c"]; stub index 0 -> "b" -> original pool index 1.
Assert.Equal(1, picked);
}
[Fact]
public void PickIndex_Shuffle_CanReturnSomethingOtherThanTheOldestCandidate()
{
var pool = new List<string> { "a", "b", "c" };
// Stub picks the last candidate instead of always the first (oldest), proving this isn't
// hardcoded to FirstOrDefault-style behavior.
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string>(), shuffle: true, n => n - 1);
Assert.Equal(2, picked);
}
[Fact]
public void PickIndex_Shuffle_ExcludedItemsDoNotShiftTheOriginalIndexMapping()
{
var pool = new List<string> { "a", "b", "c", "d" };
// Candidates (excluding "b") are ["a", "c", "d"] at original indices [0, 2, 3]; stub
// index 2 should map back to original index 3 ("d"), not 2 ("c").
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "b" }, shuffle: true, n => 2);
Assert.Equal(3, picked);
}
[Fact]
public void PickIndex_Shuffle_EverythingExcluded_ReturnsNull()
{
var pool = new List<string> { "a", "b" };
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a", "b" }, shuffle: true, n => 0);
Assert.Null(picked);
}
}
@@ -1,86 +0,0 @@
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
};
}
@@ -1,57 +0,0 @@
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;
}
}
@@ -1,60 +0,0 @@
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class QuestionStoreTests
{
[Fact]
public void ApplyMediaReviewGate_FlagsUnflaggedQuestionWithMedia()
{
var question = new Question { Media = ["asset-1"] };
QuestionStore.ApplyMediaReviewGate(question);
Assert.NotNull(question.FlaggedForReviewUtc);
Assert.Equal("system", question.FlaggedByUserId);
Assert.False(string.IsNullOrWhiteSpace(question.FlagReason));
}
[Fact]
public void ApplyMediaReviewGate_LeavesMediaFreeQuestionsAlone()
{
var question = new Question { Media = [] };
QuestionStore.ApplyMediaReviewGate(question);
Assert.Null(question.FlaggedForReviewUtc);
}
[Fact]
public void ApplyMediaReviewGate_DoesNotOverwriteAnExistingFlag()
{
var flaggedAt = DateTime.UtcNow.AddDays(-1);
var question = new Question
{
Media = ["asset-1"],
FlaggedForReviewUtc = flaggedAt,
FlagReason = "Reported by student",
FlaggedByUserId = "user-42"
};
QuestionStore.ApplyMediaReviewGate(question);
Assert.Equal(flaggedAt, question.FlaggedForReviewUtc);
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);
}
}
@@ -1,17 +0,0 @@
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class QuestionVisibilityTests
{
[Theory]
[InlineData(true, false, true)]
[InlineData(true, true, false)]
[InlineData(false, false, false)]
[InlineData(false, true, false)]
public void IsVisibleToStudent_MatchesTruthTable(bool isPublished, bool isFlagged, bool expected)
{
Assert.Equal(expected, QuestionVisibility.IsVisibleToStudent(isPublished, isFlagged));
}
}
@@ -1,22 +0,0 @@
using QuestionsApi.Models;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class UserProfileModelsTests
{
[Theory]
[InlineData(null, null, true)]
[InlineData(1.0, 1.0, true)]
[InlineData(0.85, 1.35, true)]
[InlineData(0.84, null, false)]
[InlineData(null, 1.36, false)]
public void UpdateTextScaleRequestValidator_EnforcesRange(double? questionScale, double? ccsScale, bool expectedValid)
{
var request = new UpdateTextScaleRequest { QuestionTextScale = questionScale, CcsTextScale = ccsScale };
var error = UpdateTextScaleRequestValidator.Validate(request);
Assert.Equal(expectedValid, error is null);
}
}
@@ -1,51 +0,0 @@
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));
}
}
@@ -51,16 +51,6 @@ public sealed class CcsController(CcsStore store, CcsRatingStore ratings) : Cont
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("attempts/{id}/commands/batch")]
public async Task<IActionResult> BatchCommand(string id, CcsBatchCommandRequest request)
{
var attempt = await store.GetOwnedAsync(id, UserId());
if (attempt is null) return NotFound();
try { return Ok(await store.ToResponseAsync(await store.ApplyBatchCommandAsync(attempt, request))); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("attempts/{id}/abandon")]
public async Task<IActionResult> Abandon(string id)
{
@@ -1,92 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using QuestionsApi.Models;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/credits")]
[Authorize(Roles = "USER,SUPER")]
public sealed class CreditsController(CreditStore credits, ExamSimulationStore simulations) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
[HttpGet("ledger")]
public async Task<IActionResult> Ledger([FromQuery] int limit = 20, [FromQuery] string? before = null)
{
DateTime? cursor = null;
if (!string.IsNullOrWhiteSpace(before))
{
if (!DateTime.TryParse(before, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
return BadRequest(new { code = "invalid_cursor", message = "The ledger cursor is invalid." });
cursor = parsed;
}
return Ok(await credits.GetLedgerAsync(UserId(), limit, cursor));
}
[HttpPost("purchases")]
public async Task<IActionResult> Purchase([FromBody] CreditPurchaseRequest request)
{
var product = CreditEconomy.FindProduct(request.Sku);
if (product is null) return BadRequest(new { code = "unknown_sku", message = "Unknown store product." });
if (!ValidOperationId(request.OperationId))
return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." });
var result = await credits.PurchaseAsync(UserId(), product, request.OperationId);
return result.Result switch
{
CreditOperationResult.Succeeded => Ok(result.Status),
CreditOperationResult.InsufficientCredits => Conflict(new
{ code = "insufficient_coins", message = "Not enough Rz Coins.", credits = result.Status }),
_ => Conflict(new
{ code = "operation_conflict", message = "That operation ID was already used.", credits = result.Status })
};
}
[HttpPost("qbank-reset")]
public async Task<IActionResult> ResetQBank([FromBody] QBankResetRequest request)
{
if (request.Confirmation != "RESET_QBANK")
return BadRequest(new { code = "confirmation_required", message = "Enter RESET_QBANK to confirm." });
if (!ValidOperationId(request.OperationId))
return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." });
if (await simulations.FindActiveAsync(UserId()) is not null)
return Conflict(new { code = "active_exam", message = "Finish or end the active exam before resetting the QBank." });
var result = await credits.ApplyQBankResetAsync(UserId(), request.OperationId);
return result.Result == CreditOperationResult.Succeeded
? Ok(result.Status)
: Conflict(new
{ code = "reset_token_required", message = "A QBank Reset Token is required.", credits = result.Status });
}
[HttpGet("spin-config")]
public IActionResult SpinConfig() => Ok(new SpinConfigResponse());
[HttpPost("spin")]
public async Task<IActionResult> Spin([FromBody] SpinRequest request)
{
if (!ValidOperationId(request.OperationId))
return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." });
var result = await credits.SpinAsync(UserId(), request.OperationId);
return result.Result switch
{
CreditOperationResult.Succeeded => Ok(result),
CreditOperationResult.InsufficientCredits => Conflict(new
{ code = "insufficient_coins", message = "Not enough Rz Coins.", credits = result.Status }),
CreditOperationResult.RateLimited => Conflict(new
{ code = "rate_limited", message = "Too many spins - try again later.", credits = result.Status }),
_ => Conflict(new
{ code = "operation_conflict", message = "That operation ID was already used.", credits = result.Status })
};
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
private static bool ValidOperationId(string value) =>
!string.IsNullOrWhiteSpace(value) && value.Length <= 100;
}
@@ -1,36 +0,0 @@
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, ProgressionStore progression) : ControllerBase
public sealed class DashboardController(DashboardStore dashboard) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get()
@@ -18,8 +18,6 @@ public sealed class DashboardController(DashboardStore dashboard, ProgressionSto
?? User.Identity?.Name
?? "";
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var response = await dashboard.GetAsync(userId, DateTime.UtcNow);
response.Progression = await progression.GetAsync(userId);
return Ok(response);
return Ok(await dashboard.GetAsync(userId, DateTime.UtcNow));
}
}
@@ -1,68 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController, Route("api/dynamic-questions"), Authorize(Roles = "USER,SUPER")]
public sealed class DynamicQuestionsController : ControllerBase
{
private readonly DynamicQuestionService _questions;
private readonly QuestionReportStore _reports;
public DynamicQuestionsController(DynamicQuestionService questions, QuestionReportStore reports)
{
_questions = questions;
_reports = reports;
}
[HttpGet("status")]
public async Task<ActionResult<DynamicQuestionStatusResponse>> Status() =>
Ok(await _questions.StatusAsync(UserId()));
[HttpPost("next")]
public async Task<ActionResult<DynamicQuestionEnvelope>> Next([FromBody] DynamicNextRequest request)
{
try { return Ok(await _questions.NextAsync(UserId(), request.RunToken)); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("attempt")]
public async Task<ActionResult<DynamicAttemptResponse>> Attempt([FromBody] DynamicAttemptRequest request)
{
if (string.IsNullOrWhiteSpace(request.GenerationToken))
return BadRequest("generationToken required");
try { return Ok(await _questions.AttemptAsync(UserId(), request)); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("report")]
public async Task<IActionResult> Report([FromBody] DynamicReportRequest request)
{
var category = request.Category.Trim().ToLowerInvariant();
var details = request.Details.Trim();
if (!QuestionReportCategory.All.Contains(category))
return BadRequest("category is invalid");
if (details.Length is < 10 or > 2000)
return BadRequest("details must be between 10 and 2000 characters");
try
{
var (claims, _) = await _questions.RegenerateAsync(UserId(), request.GenerationToken);
var report = await _reports.CreateDynamicAsync(claims, UserId(), category, details);
return StatusCode(StatusCodes.Status201Created, new { id = report.Id, questionExternalId = report.QuestionExternalId });
}
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (KeyNotFoundException) { return NotFound(); }
}
private string UserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
}
@@ -9,12 +9,7 @@ namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/exam-simulations")]
[Authorize(Roles = "USER,SUPER")]
public class ExamSimulationsController(
ExamSimulationStore simulations,
CreditStore credits,
MasteryAwardService masteryAwards,
StudySessionStore studySessions,
ProgressionStore progression) : ControllerBase
public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase
{
[HttpGet("readiness")]
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
@@ -22,33 +17,12 @@ public class ExamSimulationsController(
[HttpPost]
public async Task<IActionResult> Create()
{
var userId = UserId();
if (await simulations.FindActiveAsync(userId) is not null)
return Conflict(new { code = "active_exam", message = "An active Step 3 simulation already exists." });
var operationId = $"exam-create:{Guid.NewGuid():N}";
var bypass = User.IsInRole("SUPER");
if (!bypass && !await credits.TryConsumeExamTokenAsync(userId, operationId))
return Conflict(new
{
code = "exam_token_required",
message = "An Exam Simulation Token is required.",
credits = await credits.GetStatusAsync(userId)
});
try
{
var simulation = await simulations.CreateAsync(userId);
var simulation = await simulations.CreateAsync(UserId());
return Ok(await simulations.ToResponseAsync(simulation));
}
catch (InvalidOperationException exception)
{
if (!bypass) await credits.RefundExamTokenAsync(userId, operationId);
return Conflict(new { code = "exam_creation_failed", message = exception.Message });
}
catch
{
if (!bypass) await credits.RefundExamTokenAsync(userId, operationId);
throw;
}
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpGet("preview-results")]
@@ -118,12 +92,7 @@ public class ExamSimulationsController(
{
var simulation = await simulations.GetOwnedAsync(id, UserId());
if (simulation is null) return NotFound();
try
{
simulation = await simulations.StartNextBlockAsync(simulation);
await studySessions.EnsureSimulationBlockAsync(UserId(), simulation.Id ?? id, simulation.CurrentBlock);
return Ok(await simulations.ToResponseAsync(simulation));
}
try { return Ok(await simulations.ToResponseAsync(await simulations.StartNextBlockAsync(simulation))); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
@@ -142,36 +111,7 @@ public class ExamSimulationsController(
{
var simulation = await simulations.GetOwnedAsync(id, UserId());
if (simulation is null) return NotFound();
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);
}
try { return Ok(await simulations.ToResponseAsync(await simulations.SubmitBlockAsync(simulation))); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
@@ -1,415 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
using System.Security.Claims;
using System.Text;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/flashcards")]
[Authorize(Roles = "USER,SUPER")]
public sealed class FlashcardsController(
FlashcardDeckStore decks,
FlashcardNoteStore notes,
FlashcardScheduleStore schedules,
FlashcardSettingsStore settings,
FlashcardMediaStore media,
FlashcardStudyService study,
FlashcardImportExportService importExport) : ControllerBase
{
[HttpGet("decks")]
public async Task<IActionResult> ListDecks()
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
await decks.GetOrCreateDefaultDeckAsync(userId);
var now = DateTime.UtcNow;
var summaries = await study.GetDeckSummariesAsync(userId, now, now.Date);
return Ok(summaries);
}
[HttpPost("decks")]
public async Task<IActionResult> CreateDeck([FromBody] UpsertFlashcardDeckRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required");
var result = await decks.UpsertAsync(userId, request, null);
if (!result.Succeeded) return Conflict(result.Current);
return Ok(result.Deck);
}
[HttpPut("decks/{deckId}")]
public async Task<IActionResult> UpdateDeck(string deckId, [FromBody] UpsertFlashcardDeckRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required");
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true))
return NotFound();
var result = await decks.UpsertAsync(userId, request, deckId);
if (!result.Succeeded) return Conflict(result.Current);
return Ok(result.Deck);
}
[HttpPost("decks/{deckId}/archive")]
public async Task<IActionResult> ArchiveDeck(string deckId)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true))
return NotFound();
var result = await decks.ArchiveAsync(userId, deckId, true);
if (!result.Succeeded) return Conflict("cannot archive the default deck, or deck not found");
return Ok(result.Deck);
}
[HttpPost("decks/{deckId}/restore")]
public async Task<IActionResult> RestoreDeck(string deckId)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true))
return NotFound();
var result = await decks.ArchiveAsync(userId, deckId, false);
if (!result.Succeeded) return NotFound();
return Ok(result.Deck);
}
[HttpPost("notes/search")]
public async Task<IActionResult> SearchNotes([FromBody] FlashcardLibraryQuery query)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var (items, total) = await notes.SearchVisibleAsync(userId, query);
return Ok(new FlashcardLibraryResponse { Items = items, Total = total });
}
[HttpGet("notes/{noteId}")]
public async Task<IActionResult> GetNote(string noteId)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var note = await notes.GetVisibleAsync(userId, noteId);
if (note is null) return NotFound();
return Ok(note);
}
[HttpPost("notes")]
public async Task<IActionResult> CreateNote([FromBody] UpsertFlashcardNoteRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required");
if (string.IsNullOrWhiteSpace(request.DeckId))
request.DeckId = (await decks.GetOrCreateDefaultDeckAsync(userId)).Id!;
else if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(request.DeckId))?.IsSystem == true))
return BadRequest("cannot add cards to a system deck");
try
{
var result = await notes.UpsertAsync(userId, request, null);
if (!result.Succeeded) return Conflict(result.Current);
return Ok(result.Note);
}
catch (FlashcardValidationException exception)
{
return BadRequest(exception.Message);
}
}
[HttpPut("notes/{noteId}")]
public async Task<IActionResult> UpdateNote(string noteId, [FromBody] UpsertFlashcardNoteRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required");
var isSuper = User.IsInRole("SUPER");
if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true))
return NotFound();
if (!string.IsNullOrWhiteSpace(request.DeckId)
&& !FlashcardOwnershipGuard.CanMutate(isSuper, (await decks.GetAnyAsync(request.DeckId))?.IsSystem == true))
return BadRequest("cannot move cards into a system deck");
try
{
var result = await notes.UpsertAsync(userId, request, noteId);
if (!result.Succeeded) return Conflict(result.Current);
return Ok(result.Note);
}
catch (FlashcardValidationException exception)
{
return BadRequest(exception.Message);
}
}
[HttpPost("notes/move")]
public async Task<IActionResult> MoveNotes([FromBody] FlashcardMoveRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.TargetDeckId)) return BadRequest("targetDeckId required");
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(request.TargetDeckId))?.IsSystem == true))
return BadRequest("cannot move cards into a system deck");
var moved = await notes.MoveAsync(userId, request.NoteIds, request.TargetDeckId);
return Ok(new { moved });
}
[HttpPost("notes/archive")]
public async Task<IActionResult> ArchiveNotes([FromBody] FlashcardBulkNoteIdsRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var isSuper = User.IsInRole("SUPER");
var results = new List<FlashcardNote>();
foreach (var noteId in request.NoteIds)
{
if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) continue;
var result = await notes.ArchiveAsync(userId, noteId, true);
if (result.Note is not null) results.Add(result.Note);
}
return Ok(results);
}
[HttpPost("notes/restore")]
public async Task<IActionResult> RestoreNotes([FromBody] FlashcardBulkNoteIdsRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var isSuper = User.IsInRole("SUPER");
var results = new List<FlashcardNote>();
foreach (var noteId in request.NoteIds)
{
if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) continue;
var result = await notes.ArchiveAsync(userId, noteId, false);
if (result.Note is not null) results.Add(result.Note);
}
return Ok(results);
}
[HttpPost("cards/suspend")]
public async Task<IActionResult> SuspendCards([FromBody] FlashcardSuspendRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var changed = await schedules.SetSuspendedAsync(userId, request.CardIds, request.Suspended);
return Ok(new { changed });
}
[HttpPost("cards/bury")]
public async Task<IActionResult> BuryCards([FromBody] FlashcardBuryRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var until = request.UntilUtc == default ? DateTime.UtcNow.Date.AddDays(1) : request.UntilUtc;
var changed = await schedules.BuryAsync(userId, request.CardIds, until);
return Ok(new { changed });
}
[HttpPost("study/next")]
public async Task<IActionResult> NextCard([FromBody] FlashcardStudyRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var response = await study.GetNextCardAsync(userId, request);
return Ok(response);
}
[HttpGet("counts")]
public async Task<IActionResult> GetCounts()
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var now = DateTime.UtcNow;
var counts = await study.GetDashboardCountsAsync(userId, now, now.Date.AddDays(-6));
return Ok(counts);
}
[HttpGet("settings")]
public async Task<IActionResult> GetSettings()
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
return Ok(await settings.GetAsync(userId));
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] UpdateFlashcardSettingsRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
return Ok(await settings.UpdateAsync(userId, request));
}
[HttpPost("reviews")]
public async Task<IActionResult> SubmitReviews([FromBody] FlashcardBatchReviewRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var results = new List<FlashcardReviewResponse>();
foreach (var review in request.Reviews)
{
if (!Guid.TryParse(review.ClientReviewId, out _)) return BadRequest("clientReviewId must be a UUID");
if (!FlashcardRatings.All.Contains(review.Rating)) return BadRequest("rating must be again/hard/good/easy");
var outcome = await schedules.SubmitReviewAsync(userId, review);
switch (outcome.Kind)
{
case FlashcardReviewOutcomeKind.NotFound:
return NotFound($"card {review.CardId} not found");
case FlashcardReviewOutcomeKind.Conflict:
return Conflict(new { cardId = review.CardId, current = outcome.Schedule });
case FlashcardReviewOutcomeKind.Duplicate:
results.Add(new FlashcardReviewResponse { Card = outcome.Schedule!, Duplicate = true });
break;
default:
results.Add(new FlashcardReviewResponse
{
Card = outcome.Schedule!,
RatingOptions = schedules.PreviewRatingOptions(outcome.Schedule!, DateTime.UtcNow)
});
break;
}
}
return Ok(new FlashcardBatchReviewResponse { Results = results });
}
[HttpPost("reviews/undo")]
public async Task<IActionResult> UndoReview([FromBody] FlashcardUndoRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var outcome = await schedules.UndoAsync(userId, request.ReviewLogId);
return outcome.Kind switch
{
FlashcardReviewOutcomeKind.NotFound => NotFound(),
FlashcardReviewOutcomeKind.Conflict => Conflict(outcome.Schedule),
_ => Ok(outcome.Schedule)
};
}
[HttpPost("media")]
[RequestSizeLimit(5_000_000)]
public async Task<IActionResult> UploadMedia(IFormFile file)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (file is null || file.Length == 0) return BadRequest("file required");
if (file.Length > 5_000_000) return BadRequest("file must be 5 MB or smaller");
var contentType = file.ContentType.Trim().ToLowerInvariant();
var allowedTypes = new HashSet<string> { "image/png", "image/jpeg", "image/webp" };
if (!allowedTypes.Contains(contentType)) return BadRequest("file must be a png, jpeg, or webp image");
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
var asset = await media.CreateAsync(userId, file.FileName, contentType, memory.ToArray());
return Ok(new { id = asset.Id ?? "", url = $"/api/flashcards/media/{asset.Id}" });
}
[HttpGet("media/{id}")]
public async Task<IActionResult> GetMedia(string id)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var asset = await media.GetAsync(userId, id.Trim());
if (asset is null) return NotFound();
return File(asset.Bytes, asset.ContentType);
}
[HttpGet("export/csv")]
public async Task<IActionResult> ExportCsv()
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var (csv, omissions) = await importExport.ExportCsvAsync(userId);
Response.Headers.Append("X-Flashcard-Export-Omissions", string.Join(';', omissions.Select(v => v.Kind)));
return File(Encoding.UTF8.GetBytes(csv), "text/csv", "flashcards.csv");
}
[HttpGet("export/json")]
public async Task<IActionResult> ExportJson()
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var export = await importExport.ExportJsonAsync(userId);
return Ok(export);
}
[HttpPost("import/csv/preview")]
[RequestSizeLimit(25 * 1024 * 1024)]
public async Task<IActionResult> PreviewCsvImport(IFormFile file)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (file is null || file.Length == 0) return BadRequest("file required");
try
{
FlashcardImportExportService.ValidateSize(file.Length);
using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8);
var text = await reader.ReadToEndAsync();
return Ok(await importExport.PreviewCsvAsync(userId, text));
}
catch (FlashcardValidationException exception)
{
return BadRequest(exception.Message);
}
}
[HttpPost("import/json/preview")]
[RequestSizeLimit(25 * 1024 * 1024)]
public async Task<IActionResult> PreviewJsonImport(IFormFile file)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (file is null || file.Length == 0) return BadRequest("file required");
try
{
FlashcardImportExportService.ValidateSize(file.Length);
using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8);
var text = await reader.ReadToEndAsync();
return Ok(await importExport.PreviewJsonAsync(userId, text));
}
catch (FlashcardValidationException exception)
{
return BadRequest(exception.Message);
}
}
[HttpPost("import/commit")]
public async Task<IActionResult> CommitImport([FromBody] FlashcardImportCommitRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
try
{
return Ok(await importExport.CommitAsync(userId, request.ImportToken));
}
catch (FlashcardValidationException exception)
{
return BadRequest(exception.Message);
}
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
}
public sealed class FlashcardSuspendRequest
{
public List<string> CardIds { get; set; } = [];
public bool Suspended { get; set; } = true;
}
public sealed class FlashcardBuryRequest
{
public List<string> CardIds { get; set; } = [];
public DateTime UntilUtc { get; set; }
}
@@ -27,14 +27,6 @@ public sealed class ProfileController(UserProfileStore profiles, IConfiguration
return Ok(await profiles.UpsertAsync(UserId(), request));
}
[HttpPut("me/text-scale")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> UpdateTextScale(UpdateTextScaleRequest request)
{
if (UpdateTextScaleRequestValidator.Validate(request) is { } error) return BadRequest(error);
return Ok(await profiles.UpdateTextScaleAsync(UserId(), request));
}
[HttpGet("me/export")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Export() => Ok(new
@@ -1,18 +0,0 @@
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));
}
}
@@ -39,7 +39,7 @@ public class QuestionReportsController : ControllerBase
return BadRequest($"details must be between {MinDetailsLength} and {MaxDetailsLength} characters");
var question = await _questions.GetByExternalIdAsync(questionExternalId);
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
return NotFound();
var userId = CurrentUserId();
@@ -19,11 +19,6 @@ public class QuestionsController : ControllerBase
private readonly ServiceMetrics _metrics;
private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue;
private readonly CreditStore _credits;
private readonly MasteryAwardService _masteryAwards;
private readonly StudySessionStore _studySessions;
private readonly DailyMissionStore _dailyMissions;
private readonly ProgressionStore _progression;
public QuestionsController(
QuestionStore questions,
@@ -33,12 +28,7 @@ public class QuestionsController : ControllerBase
AnswerStatisticsStore statistics,
ServiceMetrics metrics,
QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue,
CreditStore credits,
MasteryAwardService masteryAwards,
StudySessionStore studySessions,
DailyMissionStore dailyMissions,
ProgressionStore progression)
ReviewQueueStore reviewQueue)
{
_questions = questions;
_attempts = attempts;
@@ -48,11 +38,6 @@ public class QuestionsController : ControllerBase
_metrics = metrics;
_bookmarks = bookmarks;
_reviewQueue = reviewQueue;
_credits = credits;
_masteryAwards = masteryAwards;
_studySessions = studySessions;
_dailyMissions = dailyMissions;
_progression = progression;
}
[HttpGet("systems")]
@@ -67,9 +52,7 @@ public class QuestionsController : ControllerBase
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Search([FromBody] QuestionSearchRequest req)
{
List<Question> questions;
try { questions = await _questions.SearchAsync(req, User.IsInRole("SUPER")); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
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));
@@ -111,7 +94,7 @@ public class QuestionsController : ControllerBase
public async Task<IActionResult> Get(string id)
{
var question = await _questions.GetByIdAsync(id);
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
return NotFound();
var percentages = await _statistics.GetPercentagesForQuestionAsync(question.ExternalId);
@@ -136,7 +119,7 @@ public class QuestionsController : ControllerBase
return BadRequest("choiceId required");
var question = await _questions.GetByExternalIdAsync(req.QuestionExternalId.Trim());
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
return NotFound();
if (!question.Choices.Any(c => c.Id == req.ChoiceId.Trim()))
return BadRequest("choiceId must match a choice id");
@@ -164,9 +147,6 @@ 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,
@@ -174,8 +154,7 @@ public class QuestionsController : ControllerBase
req.ChoiceId.Trim() == question.CorrectChoiceId,
false,
presentedChoiceIds,
req.DurationSeconds,
await _credits.GetBankCycleAsync(userId));
req.DurationSeconds);
if (record.Inserted)
{
_metrics.AttemptCreated();
@@ -193,18 +172,6 @@ public class QuestionsController : ControllerBase
_metrics.AttemptDuplicate();
}
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
var creditAwards = new List<CreditAward>();
if (record.Inserted && record.Attempt.IsCorrect)
{
var amount = CreditEconomy.FirstCorrectAmount(question.Difficulty);
var difficult = question.Difficulty >= 4;
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
$"answer:{record.Attempt.Id}", amount,
difficult ? CreditReasonCodes.DifficultCorrect : CreditReasonCodes.FirstCorrect,
difficult ? $"Difficulty {question.Difficulty} first correct answer" : "First correct answer",
"question_attempt", record.Attempt.Id ?? ""));
if (award.Award is not null) creditAwards.Add(award.Award);
}
var ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
@@ -220,7 +187,7 @@ public class QuestionsController : ControllerBase
record.Attempt.IsCorrect,
shouldRate);
if (shouldRate)
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo);
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner);
}
catch
{
@@ -230,19 +197,6 @@ 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,
@@ -250,13 +204,7 @@ public class QuestionsController : ControllerBase
question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating,
Credits = await _credits.GetStatusAsync(userId),
CreditAwards = creditAwards,
StudySessionProgress = studySession?.Progress,
SessionSummary = studySession?.Summary,
DailyMissions = dailyMissions?.State,
Progression = progression.State
Rating = rating
});
}
@@ -310,22 +258,19 @@ public class QuestionsController : ControllerBase
return BadRequest("externalId required");
var question = await _questions.GetByExternalIdAsync(externalId.Trim());
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
return NotFound();
var userId = CurrentUserId();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
await _progression.PrepareAsync(userId);
var record = await _attempts.RecordFirstAttemptAsync(
question.ExternalId,
userId,
"",
false,
true,
bankCycle: await _credits.GetBankCycleAsync(userId));
true);
if (record.Inserted)
_metrics.AttemptCreated();
else
@@ -341,7 +286,7 @@ public class QuestionsController : ControllerBase
{
rating = await _ratings.ApplyFirstResultAsync(userId, question, false, shouldRate);
if (shouldRate)
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo);
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner);
}
catch
{
@@ -351,7 +296,6 @@ public class QuestionsController : ControllerBase
throw;
}
var progression = await _progression.RefreshAsync(userId);
return Ok(new SkipQuestionResponse
{
QuestionExternalId = question.ExternalId,
@@ -360,8 +304,7 @@ public class QuestionsController : ControllerBase
question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating,
Progression = progression.State
Rating = rating
});
}
@@ -461,37 +404,6 @@ public class QuestionsController : ControllerBase
return Ok("Deleted");
}
[HttpPost("{id}/flag")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Flag(string id, [FromBody] FlagQuestionRequest req)
{
if (string.IsNullOrWhiteSpace(req.Reason))
return BadRequest("reason required");
var flagged = await _questions.FlagAsync(id, req.Reason.Trim(), CurrentUserId());
if (flagged is null)
return NotFound();
return Ok(QuestionFlagResponse.FromModel(flagged));
}
[HttpPost("{id}/unflag")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Unflag(string id)
{
var unflagged = await _questions.UnflagAsync(id);
if (unflagged is null)
return NotFound();
return Ok(QuestionFlagResponse.FromModel(unflagged));
}
[HttpGet("admin/flagged")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Flagged()
{
var flagged = await _questions.GetFlaggedAsync();
return Ok(flagged.Select(QuestionFlagResponse.FromModel));
}
private string CurrentUserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
@@ -14,12 +14,7 @@ public sealed class ReviewQueueController(
QuestionStore questions,
AnswerStatisticsStore statistics,
RatingStore ratings,
QuestionBookmarkStore bookmarks,
CreditStore credits,
MasteryAwardService masteryAwards,
StudySessionStore studySessions,
DailyMissionStore dailyMissions,
ProgressionStore progression) : ControllerBase
QuestionBookmarkStore bookmarks) : ControllerBase
{
[HttpPost("next")]
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
@@ -27,11 +22,9 @@ public sealed class ReviewQueueController(
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
await queue.EnsureUserQueueAsync(userId);
ReviewQueueItem? item;
try { item = await queue.GetRandomPendingAsync(userId, req.ExcludeExternalIds, req.Step3System); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
var item = await queue.GetRandomPendingAsync(userId, req.ExcludeExternalIds);
if (item is null)
return Ok(new ReviewQueueNextResponse { PendingCount = checked((int)await queue.CountPendingAsync(userId, req.Step3System)) });
return Ok(new ReviewQueueNextResponse { PendingCount = checked((int)await queue.CountPendingAsync(userId)) });
var question = await questions.GetByExternalIdAsync(item.QuestionExternalId);
if (question is null || !question.IsPublished) return NotFound();
@@ -46,7 +39,7 @@ public sealed class ReviewQueueController(
{
Question = response,
Cycle = item.Cycle,
PendingCount = checked((int)await queue.CountPendingAsync(userId, req.Step3System))
PendingCount = checked((int)await queue.CountPendingAsync(userId))
});
}
@@ -76,8 +69,6 @@ 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,
@@ -88,44 +79,12 @@ 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)),
Credits = await credits.GetStatusAsync(userId),
CreditAwards = awards,
StudySessionProgress = studySession?.Progress,
SessionSummary = studySession?.Summary,
DailyMissions = missionResult?.State,
Progression = progressionResult.State
PendingCount = checked((int)await queue.CountPendingAsync(userId))
});
}
@@ -1,52 +0,0 @@
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();
}
@@ -1,25 +0,0 @@
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();
}
+3 -6
View File
@@ -4,19 +4,16 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_REVISION
WORKDIR /src
COPY ["api/QuestionsApi/QuestionsApi.csproj", "api/QuestionsApi/"]
RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj"
COPY ["QuestionsApi.csproj", "./"]
RUN dotnet restore "QuestionsApi.csproj"
COPY api/QuestionsApi/ api/QuestionsApi/
COPY content/dynamic_questions/ content/dynamic_questions/
WORKDIR /src/api/QuestionsApi
COPY . .
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION}
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
ARG BUILD_REVISION
WORKDIR /app
COPY --from=build /app/publish .
COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions
ENV ASPNETCORE_URLS=http://+:5008 \
ASPNETCORE_ENVIRONMENT=Production \
+2 -116
View File
@@ -17,26 +17,6 @@ public static class CcsAttemptModes
public const string Timed = "timed";
}
public static class CcsChartCategories
{
public const string Orders = "orders";
public const string Progress = "progress";
public const string Vitals = "vitals";
public const string Lab = "lab";
public const string Imaging = "imaging";
public const string OtherTest = "other_test";
public const string Treatment = "treatment";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Orders, Progress, Vitals, Lab, Imaging, OtherTest, Treatment
};
public static readonly IReadOnlySet<string> ResultSections = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Lab, Imaging, OtherTest, Treatment
};
}
public class CcsOrderDefinition
{
[BsonId] public string Code { get; set; } = "";
@@ -45,9 +25,6 @@ public class CcsOrderDefinition
public List<string> Aliases { get; set; } = [];
public int DefaultTurnaroundMinutes { get; set; }
public List<string> AvailableLocations { get; set; } = [];
public string DisplayRoute { get; set; } = "";
public string DisplayFrequency { get; set; } = "";
public string ResultSection { get; set; } = "";
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -65,7 +42,6 @@ public class CcsCaseRevision
public int Difficulty { get; set; } = 2;
public List<string> Tags { get; set; } = [];
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; } = [];
@@ -75,46 +51,17 @@ 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 class CcsDialogueNode
{
public string Id { get; set; } = "";
public string PatientText { get; set; } = "";
public bool IsTerminal { get; set; }
public List<CcsDialogueChoice> Choices { get; set; } = [];
}
public class CcsDialogueChoice
{
public string Id { get; set; } = "";
public string Text { get; set; } = "";
public string NextNodeId { get; set; } = "";
}
public class CcsInitialChart
{
public 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 Dictionary<string, string> Vitals { get; set; } = [];
public DateTime SimulatedStartUtc { get; set; } = DateTime.UtcNow;
public List<string> InitialOrderCodes { get; set; } = [];
}
public class CcsNarrativeSection
{
public string Code { get; set; } = "";
public string Title { get; set; } = "";
public string Text { get; set; } = "";
}
public class CcsExamDefinition
{
public string Code { get; set; } = "";
@@ -128,7 +75,6 @@ public class CcsCaseOrderDefinition
public string OrderCode { get; set; } = "";
public int? TurnaroundMinutes { get; set; }
public string Result { get; set; } = "";
public string ResultSection { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = [];
}
@@ -165,24 +111,19 @@ public class CcsAttempt
public string CaseExternalId { get; set; } = "";
[BsonRepresentation(BsonType.ObjectId)] public string CaseRevisionId { get; set; } = "";
public int CaseVersion { get; set; }
public string Mode { get; set; } = CcsAttemptModes.Timed;
public string Mode { get; set; } = CcsAttemptModes.Practice;
public string State { get; set; } = CcsAttemptStates.Active;
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; } = [];
public List<CcsPendingResult> PendingResults { get; set; } = [];
public List<CcsChartEntry> Chart { get; set; } = [];
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
public List<string> ActiveOrderCodes { get; set; } = [];
public List<string> ProcessedEventIds { get; set; } = [];
public List<string> ProcessedCommandIds { get; set; } = [];
public DateTime? DeadlineUtc { get; set; }
public DateTime? HardDeadlineUtc { get; set; }
public DateTime? FinalOrdersDeadlineUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
@@ -203,8 +144,6 @@ public class CcsAttemptAction
public class CcsPendingResult
{
public string OrderCode { get; set; } = "";
public string Category { get; set; } = "";
public string Title { get; set; } = "";
public int DueMinute { get; set; }
public string Result { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = [];
@@ -214,30 +153,9 @@ public class CcsChartEntry
{
public string Category { get; set; } = "progress";
public int SimulatedMinute { get; set; }
public string Code { get; set; } = "";
public string Title { get; set; } = "";
public string Text { get; set; } = "";
}
public class CcsVitalSnapshot
{
public int SimulatedMinute { get; set; }
public Dictionary<string, string> Values { get; set; } = [];
}
public class CcsActiveOrder
{
public string Code { get; set; } = "";
public string Name { get; set; } = "";
public string Category { get; set; } = "";
public string Route { get; set; } = "";
public string Frequency { get; set; } = "";
public int OrderedMinute { get; set; }
public int? ReportMinute { get; set; }
public bool IsPending { get; set; }
public bool HasResult { get; set; }
}
public class CcsCaseScore
{
public double Percent { get; set; }
@@ -276,7 +194,7 @@ public class CcsImportResponse
public class CreateCcsAttemptRequest
{
public string CaseExternalId { get; set; } = "";
public string Mode { get; set; } = CcsAttemptModes.Timed;
public string Mode { get; set; } = CcsAttemptModes.Practice;
}
public class CcsCommandRequest
@@ -288,20 +206,6 @@ public class CcsCommandRequest
public int Minutes { get; set; }
}
public class CcsBatchCommandRequest
{
public string CommandId { get; set; } = "";
public int ExpectedRevision { get; set; }
public List<CcsBatchCommandItem> Commands { get; set; } = [];
}
public class CcsBatchCommandItem
{
public string Type { get; set; } = "";
public string Code { get; set; } = "";
public int Minutes { get; set; }
}
public class CcsCaseSummary
{
public string ExternalId { get; set; } = "";
@@ -311,7 +215,6 @@ 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 class CcsAttemptResponse
@@ -323,11 +226,8 @@ public class CcsAttemptResponse
public string State { get; set; } = "";
public int Revision { get; set; }
public int SimulatedMinute { get; set; }
public DateTime SimulatedStartUtc { get; set; }
public bool OpeningCompleted { get; set; }
public string Location { get; set; } = "";
public DateTime? DeadlineUtc { get; set; }
public DateTime? HardDeadlineUtc { get; set; }
public DateTime? FinalOrdersDeadlineUtc { get; set; }
public DateTime CreatedUtc { get; set; }
public int RealTimeMinutes { get; set; }
@@ -336,29 +236,17 @@ public class CcsAttemptResponse
public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = "";
public string History { get; set; } = "";
public List<CcsNarrativeSection> HistorySections { 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<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 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; } = "";
@@ -383,8 +271,6 @@ public class CcsUserRating
public string UserId { get; set; } = "";
public int Elo { get; set; } = 1000;
public int RatedCaseCount { get; set; }
public int? HistoryBaselineElo { get; set; }
public DateTime? HistoryStartedUtc { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
-259
View File
@@ -1,259 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class CreditEconomy
{
public const int RollingEarningLimit = 30;
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
public const string ExamTokenSku = "exam_token_1";
public const string QBankResetTokenSku = "qbank_reset_1";
public const int ExamTokenPrice = 100;
public const int QBankResetTokenPrice = 300;
public static readonly IReadOnlyList<RzStoreProduct> Products =
[
new()
{
Sku = ExamTokenSku,
Name = "Exam Simulation Token",
Description = "Starts one new full exam simulation.",
CoinPrice = ExamTokenPrice,
Grant = RzStoreGrant.ExamToken
},
new()
{
Sku = QBankResetTokenSku,
Name = "QBank Reset Token",
Description = "Makes every QBank question eligible again without erasing progress.",
CoinPrice = QBankResetTokenPrice,
Grant = RzStoreGrant.QBankResetToken
}
];
public static RzStoreProduct? FindProduct(string sku) =>
Products.FirstOrDefault(value => value.Sku.Equals(sku, StringComparison.Ordinal));
public static int FirstCorrectAmount(int difficulty) => difficulty >= 5 ? 3 : difficulty >= 4 ? 2 : 1;
public static int ReviewAmount(bool delayedRecovery) => delayedRecovery ? 3 : 1;
}
public static class SlotMachineEconomy
{
public const int SpinCost = 0; // TODO: restore to 50 once testing is done
public const int RollingSpinLimit = 200;
public static readonly TimeSpan SpinWindow = TimeSpan.FromHours(24);
public static readonly IReadOnlyList<SpinReward> Rewards =
[
new() { Key = "jackpot", Label = "10,000 RZ Coins", Description = "Jackpot!", Grant = RzStoreGrant.Coins, CoinAmount = 10000, Probability = 0.005 },
new() { Key = "epic", Label = "2,000 RZ Coins", Description = "Epic Reward", Grant = RzStoreGrant.Coins, CoinAmount = 2000, Probability = 0.02 },
new() { Key = "great", Label = "500 RZ Coins", Description = "Great Reward", Grant = RzStoreGrant.Coins, CoinAmount = 500, Probability = 0.10 },
new() { Key = "exam_token", Label = "Exam Simulation Token", Description = "Starts one new full exam simulation.", Grant = RzStoreGrant.ExamToken, Probability = 0.15 },
new() { Key = "qbank_reset", Label = "QBank Reset Token", Description = "Makes every QBank question eligible again.", Grant = RzStoreGrant.QBankResetToken, Probability = 0.20 },
new() { Key = "nice", Label = "100 RZ Coins", Description = "Nice!", Grant = RzStoreGrant.Coins, CoinAmount = 100, Probability = 0.30 },
new() { Key = "good_luck", Label = "50 RZ Coins", Description = "Good luck!", Grant = RzStoreGrant.Coins, CoinAmount = 50, Probability = 0.225 },
];
public static SpinReward RollReward(double roll)
{
var cumulative = 0.0;
foreach (var reward in Rewards)
{
cumulative += reward.Probability;
if (roll < cumulative) return reward;
}
return Rewards[^1];
}
public static SpinReward? FindReward(string key) =>
Rewards.FirstOrDefault(value => value.Key.Equals(key, StringComparison.Ordinal));
}
public enum RzStoreGrant { ExamToken, QBankResetToken, Coins }
public sealed class SpinReward
{
public string Key { get; set; } = "";
public string Label { get; set; } = "";
public string Description { get; set; } = "";
public RzStoreGrant Grant { get; set; }
public int CoinAmount { get; set; }
public double Probability { get; set; }
}
public sealed class RzStoreProduct
{
public string Sku { get; set; } = "";
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public int CoinPrice { get; set; }
public RzStoreGrant Grant { get; set; }
}
public sealed class CreditEvent
{
public string OperationId { get; set; } = "";
public int CoinAmount { get; set; } = 1;
public DateTime CreatedUtc { get; set; }
}
public static class CreditReasonCodes
{
public const string FirstCorrect = "qbank_first_correct";
public const string DifficultCorrect = "qbank_difficult_correct";
public const string ScheduledReview = "scheduled_review_correct";
public const string DelayedRecovery = "delayed_recovery";
public const string MasteryPracticing = "mastery_practicing";
public const string MasteryStrong = "mastery_strong";
public const string MasteryMastered = "mastery_mastered";
public const string ExamSimulationComplete = "exam_simulation_complete";
public const string SessionGoalComplete = "session_goal_complete";
public const string DailyMissionComplete = "daily_mission_complete";
public const string WeeklyGoalComplete = "weekly_goal_complete";
public const string RankPromotion = "rank_promotion";
public const string Purchase = "purchase";
}
public sealed class CreditLedgerEntry
{
[BsonId]
public string Id { get; set; } = "";
public string UserId { get; set; } = "";
public string OperationId { get; set; } = "";
public int RequestedAmount { get; set; }
public int Amount { get; set; }
public string ReasonCode { get; set; } = "";
public string Description { get; set; } = "";
public string SourceType { get; set; } = "";
public string SourceId { get; set; } = "";
public bool SubjectToRollingCap { get; set; }
public string State { get; set; } = "pending";
public DateTime CreatedUtc { get; set; }
public DateTime? AppliedUtc { get; set; }
}
public sealed class CreditAward
{
public int Amount { get; set; }
public string ReasonCode { get; set; } = "";
public string Description { get; set; } = "";
}
public sealed class CreditAwardResult
{
public CreditAward? Award { get; set; }
public CreditStatusResponse Status { get; set; } = new();
}
public sealed class CreditLedgerPage
{
public List<CreditLedgerEntryResponse> Items { get; set; } = [];
public DateTime? NextBefore { get; set; }
}
public sealed class CreditLedgerEntryResponse
{
public int Amount { get; set; }
public string ReasonCode { get; set; } = "";
public string Description { get; set; } = "";
public DateTime CreatedUtc { get; set; }
}
public sealed class CreditPurchaseEvent
{
public string OperationId { get; set; } = "";
public string Sku { get; set; } = "";
public int CoinCost { get; set; }
public DateTime CreatedUtc { get; set; }
}
public sealed class SpinEvent
{
public string OperationId { get; set; } = "";
public string RewardKey { get; set; } = "";
public int CoinsWon { get; set; }
public DateTime CreatedUtc { get; set; }
}
public sealed class UserCreditAccount
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public int Credits { get; set; }
public int ExamTokens { get; set; }
public int QBankResetTokens { get; set; }
public int BankCycle { get; set; }
public bool InitialExamGrantApplied { get; set; }
public bool MasteryAwardBaselineApplied { get; set; }
public List<CreditEvent> RecentEarnings { get; set; } = [];
public List<CreditPurchaseEvent> Purchases { get; set; } = [];
public List<SpinEvent> Spins { get; set; } = [];
public List<CreditEvent> RecentSpins { get; set; } = [];
public List<string> AppliedOperations { get; set; } = [];
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class CreditStatusResponse
{
public int Credits { get; set; }
public int EarnedInRollingWindow { get; set; }
public int RemainingEarningCapacity { get; set; }
public int RollingWindowHours { get; set; } = 24;
public int ExamTokens { get; set; }
public int QBankResetTokens { get; set; }
public int BankCycle { get; set; }
public IReadOnlyList<RzStoreProduct> Products { get; set; } = CreditEconomy.Products;
}
public sealed record CreditAwardRequest(
string OperationId,
int Amount,
string ReasonCode,
string Description,
string SourceType,
string SourceId,
bool SubjectToRollingCap = true);
public sealed class CreditPurchaseRequest
{
public string Sku { get; set; } = "";
public string OperationId { get; set; } = "";
}
public sealed class QBankResetRequest
{
public string Confirmation { get; set; } = "";
public string OperationId { get; set; } = "";
}
public sealed class SpinRequest
{
public string OperationId { get; set; } = "";
}
public enum CreditOperationResult { Succeeded, InsufficientCredits, InsufficientTokens, OperationConflict, RateLimited }
public sealed class CreditOperationResponse
{
public CreditOperationResult Result { get; set; }
public CreditStatusResponse Status { get; set; } = new();
}
public sealed class SpinConfigResponse
{
public int SpinCost { get; set; } = SlotMachineEconomy.SpinCost;
public IReadOnlyList<SpinReward> Rewards { get; set; } = SlotMachineEconomy.Rewards;
}
public sealed class SpinResponse
{
public CreditOperationResult Result { get; set; }
public string RewardKey { get; set; } = "";
public string RewardLabel { get; set; } = "";
public int CoinsWon { get; set; }
public CreditStatusResponse Status { get; set; } = new();
}
@@ -1,64 +0,0 @@
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class DailyMissionTypes
{
public const string DueReviews = "due_reviews";
public const string WeakSystemCorrect = "weak_system_correct";
public const string DifficultyFourCorrect = "difficulty_four_correct";
}
public sealed class DailyMission
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Type { get; set; } = "";
public string Title { get; set; } = "";
public string Step3System { get; set; } = "";
public int DifficultyMin { get; set; }
public int Target { get; set; }
public int Progress { get; set; }
public DateTime StartsUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
public List<string> AppliedEventIds { get; set; } = [];
}
public sealed class DailyMissionSet
{
[BsonId]
public string Id { get; set; } = "";
public string UserId { get; set; } = "";
public string DateUtc { get; set; } = "";
public List<DailyMission> Missions { get; set; } = [];
public List<DailyMission> ReplacementCandidates { get; set; } = [];
public bool ReplacementUsed { get; set; }
public bool ExpirationRecorded { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime ExpiresUtc { get; set; }
}
public sealed class DailyMissionResponse
{
public string Id { get; set; } = "";
public string Type { get; set; } = "";
public string Title { get; set; } = "";
public string Step3System { get; set; } = "";
public int DifficultyMin { get; set; }
public int Target { get; set; }
public int Progress { get; set; }
public bool Completed { get; set; }
}
public sealed class DailyMissionSetResponse
{
public string DateUtc { get; set; } = "";
public DateTime ExpiresUtc { get; set; }
public bool ReplacementAvailable { get; set; }
public List<DailyMissionResponse> Missions { get; set; } = [];
}
public sealed class DailyMissionEnvelope
{
public DailyMissionSetResponse State { get; set; } = new();
public List<CreditAward> CreditAwards { get; set; } = [];
}
@@ -10,28 +10,16 @@ public sealed class DashboardResponse
public int CcsCasesCompleted { get; set; }
public int FirstAttemptAccuracy { get; set; }
public int AccuracySampleSize { get; set; }
public int? AccuracyDeltaVsPreviousSample { get; set; }
public int? QuestionBankRatingDeltaThisWeek { get; set; }
public int? QuestionBankPercentile { get; set; }
public int? CcsRatingDeltaThisWeek { get; set; }
public int? CcsPercentile { get; set; }
public string? ExamReadinessLabel { get; set; }
public int? ExamReadinessProjectedPassPercent { get; set; }
public int QuestionsThisWeek { get; set; }
public int StudyTimeSecondsThisWeek { get; set; }
public int CurrentStreak { get; set; }
public int MissedQuestionCount { get; set; }
public int DynamicReviewsDue { get; set; }
public int BookmarkCount { get; set; }
public int NoteCount { get; set; }
public int FlashcardsDue { get; set; }
public int FlashcardsNewAvailable { get; set; }
public int FlashcardsReviewedThisWeek { get; set; }
public List<DashboardTrendPoint> PerformanceTrend { get; set; } = [];
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
@@ -40,8 +28,6 @@ public sealed class DashboardTrendPoint
public int Correct { get; set; }
public int Total { get; set; }
public int Accuracy { get; set; }
public int? QuestionBankRating { get; set; }
public int? CcsRating { get; set; }
}
public sealed class DashboardSystemPerformance
@@ -50,32 +36,6 @@ 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
@@ -1,319 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public sealed class DynamicQuestionBlueprint
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string ExternalId { get; set; } = "";
public int Version { get; set; } = 1;
public string ObjectiveId { get; set; } = "";
public string Title { get; set; } = "";
public string System { get; set; } = "";
public string Discipline { get; set; } = "";
public string PerformanceCategory { get; set; } = "";
public string PhysicianTask { get; set; } = "";
public string EncounterFrame { get; set; } = "";
public string SiteOfCare { get; set; } = "";
public double BlueprintWeight { get; set; }
public List<string> ConceptIds { get; set; } = [];
public List<string> PrerequisiteConceptIds { get; set; } = [];
public DateTime? MedicallyReviewedUtc { get; set; }
public DateTime? MedicalReviewExpiresUtc { get; set; }
public string MedicalReviewer { get; set; } = "";
public int Difficulty { get; set; } = 3;
public int QualityStandardVersion { get; set; } = 1;
public string CorrectConceptId { get; set; } = "";
public int BaseBlueprintVersion { get; set; }
public bool IsPublished { get; set; } = true;
public string StemTemplate { get; set; } = "";
public string LeadIn { get; set; } = "";
public Dictionary<string, DynamicVariableDefinition> Variables { get; set; } = [];
public Dictionary<string, DynamicDerivedDefinition> Derived { get; set; } = [];
public List<DynamicConstraint> Constraints { get; set; } = [];
public List<DynamicLabTemplate> Labs { get; set; } = [];
public DynamicAnswerTemplate CorrectAnswer { get; set; } = new();
public List<DynamicAnswerTemplate> Distractors { get; set; } = [];
public string ExplanationTemplate { get; set; } = "";
public string KeyTakeaway { get; set; } = "";
public string UsmlePearl { get; set; } = "";
public List<QuestionReference> References { get; set; } = [];
public List<string> Tags { get; set; } = [];
public List<DynamicReviewFollowupTemplate> ReviewFollowups { get; set; } = [];
public Dictionary<string, DynamicWrongAnswerFollowupTemplate> WrongAnswerFollowups { get; set; } = [];
public List<DynamicCaseFamily> CaseFamilies { get; set; } = [];
}
public sealed class DynamicCaseFamily
{
public string Id { get; set; } = "";
public string BaseFamilyId { get; set; } = "";
public string DifficultyTier { get; set; } = "standard";
public int NominalElo { get; set; } = 1050;
public string StemTemplate { get; set; } = "";
public string LeadIn { get; set; } = "";
public Dictionary<string, DynamicVariableDefinition> Variables { get; set; } = [];
public List<Dictionary<string, string>> ValueBundles { get; set; } = [];
public Dictionary<string, DynamicDerivedDefinition> Derived { get; set; } = [];
public List<DynamicConstraint> Constraints { get; set; } = [];
public List<DynamicLabTemplate> Labs { get; set; } = [];
public DynamicAnswerTemplate CorrectAnswer { get; set; } = new();
public List<DynamicAnswerTemplate> Distractors { get; set; } = [];
public string ExplanationTemplate { get; set; } = "";
public string KeyTakeaway { get; set; } = "";
public string UsmlePearl { get; set; } = "";
public List<DynamicReviewFollowupTemplate> ReviewFollowups { get; set; } = [];
public Dictionary<string, DynamicWrongAnswerFollowupTemplate> WrongAnswerFollowups { get; set; } = [];
}
public sealed class DynamicReviewFollowupTemplate
{
public string PromptTemplate { get; set; } = "";
public List<DynamicAnswerTemplate> Choices { get; set; } = [];
public string CorrectChoiceId { get; set; } = "";
public string TeachingPointTemplate { get; set; } = "";
}
public sealed class DynamicWrongAnswerFollowupTemplate
{
public string PromptTemplate { get; set; } = "";
public List<DynamicAnswerTemplate> Choices { get; set; } = [];
public string CorrectChoiceId { get; set; } = "";
public string TeachingPointTemplate { get; set; } = "";
}
public sealed class DynamicVariableDefinition
{
public string Type { get; set; } = "integer";
public double Minimum { get; set; }
public double Maximum { get; set; }
public double Step { get; set; } = 1;
public int Precision { get; set; }
public List<string> Values { get; set; } = [];
}
public sealed class DynamicDerivedDefinition
{
public string Operation { get; set; } = "";
public string Left { get; set; } = "";
public string Right { get; set; } = "";
public int Precision { get; set; }
}
public sealed class DynamicConstraint
{
public string Field { get; set; } = "";
public string Operator { get; set; } = "";
public double Value { get; set; }
}
public sealed class DynamicLabTemplate
{
public string Name { get; set; } = "";
public string ValueTemplate { get; set; } = "";
public string Unit { get; set; } = "";
public string Reference { get; set; } = "";
}
public sealed class DynamicAnswerTemplate
{
public string Id { get; set; } = "";
public string TextTemplate { get; set; } = "";
public string ExplanationTemplate { get; set; } = "";
public string MisconceptionId { get; set; } = "";
public List<string> RemediationConceptIds { get; set; } = [];
}
public sealed class DynamicGenerationClaims
{
public string UserId { get; set; } = "";
public string BlueprintExternalId { get; set; } = "";
public int BlueprintVersion { get; set; }
public string ObjectiveId { get; set; } = "";
public string Seed { get; set; } = "";
public string Nonce { get; set; } = "";
public int GeneratorVersion { get; set; } = 1;
public string CaseFamilyId { get; set; } = "";
public string DifficultyTier { get; set; } = "";
public int EffectiveAbilityElo { get; set; }
public int FamilyElo { get; set; }
public bool IsScheduledReview { get; set; }
public string ReviewItemId { get; set; } = "";
public int ReviewStage { get; set; }
public DateTime ExpiresUtc { get; set; }
}
public sealed class DynamicRunClaims
{
public string UserId { get; set; } = "";
public List<string> SeenObjectiveIds { get; set; } = [];
public string PreviousObjectiveId { get; set; } = "";
public int Cycle { get; set; }
public List<string> RecentCaseFamilyIds { get; set; } = [];
public DateTime ExpiresUtc { get; set; }
}
public sealed class DynamicGeneratedQuestion
{
public Question Question { get; set; } = new();
public DynamicQuestionBlueprint Blueprint { get; set; } = new();
public string CaseFamilyId { get; set; } = "";
}
public sealed class DynamicQuestionEnvelope
{
public QuestionResponse Question { get; set; } = new();
public string Source { get; set; } = "dynamic";
public string ObjectiveId { get; set; } = "";
public string ObjectiveTitle { get; set; } = "";
public string GenerationToken { get; set; } = "";
public string RunToken { get; set; } = "";
public int CycleSeen { get; set; }
public int TotalObjectives { get; set; }
}
public sealed class DynamicNextRequest
{
public string RunToken { get; set; } = "";
}
public sealed class DynamicAttemptRequest
{
public string GenerationToken { get; set; } = "";
public string ChoiceId { get; set; } = "";
public bool IsSkip { get; set; }
public int DurationSeconds { get; set; }
}
public sealed class DynamicAttemptResponse
{
public bool Inserted { get; set; }
public bool IsCorrect { get; set; }
public string CorrectChoiceId { get; set; } = "";
public string ObjectiveId { get; set; } = "";
public int ObjectiveElo { get; set; }
public int RatingChange { get; set; }
public string SelectedMisconceptionId { get; set; } = "";
public bool IsScheduledReview { get; set; }
public DateTime? NextReviewUtc { get; set; }
}
public sealed class DynamicReportRequest
{
public string GenerationToken { get; set; } = "";
public string Category { get; set; } = "";
public string Details { get; set; } = "";
}
public sealed class DynamicQuestionStatusResponse
{
public bool Available { get; set; }
public int PublishedObjectives { get; set; }
public int AttemptedObjectives { get; set; }
public int TotalAttempts { get; set; }
public int AverageObjectiveElo { get; set; } = 1000;
public int ReviewsDue { get; set; }
public List<DynamicCoverageSummary> SystemCoverage { get; set; } = [];
public List<DynamicCoverageSummary> PhysicianTaskCoverage { get; set; } = [];
}
public sealed class DynamicCoverageSummary
{
public string Id { get; set; } = "";
public int PublishedObjectives { get; set; }
public int AttemptedObjectives { get; set; }
public int Attempts { get; set; }
public double CoveragePercent { get; set; }
public int AverageElo { get; set; } = 1000;
}
public sealed class DynamicQuestionAttempt
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public string Nonce { get; set; } = "";
public string BlueprintExternalId { get; set; } = "";
public int BlueprintVersion { get; set; }
public string ObjectiveId { get; set; } = "";
public string CaseFamilyId { get; set; } = "";
public string DifficultyTier { get; set; } = "";
public int EffectiveAbilityElo { get; set; }
public int FamilyElo { get; set; }
public string ChoiceId { get; set; } = "";
public bool IsCorrect { get; set; }
public bool IsSkip { get; set; }
public string SelectedMisconceptionId { get; set; } = "";
public bool IsScheduledReview { get; set; }
public int DurationSeconds { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
public static class DynamicReviewStates
{
public const string Pending = "pending";
public const string Mastered = "mastered";
}
public sealed class DynamicReviewItem
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public string ObjectiveId { get; set; } = "";
public string BlueprintExternalId { get; set; } = "";
public int BlueprintVersion { get; set; }
public string MisconceptionId { get; set; } = "";
public string State { get; set; } = DynamicReviewStates.Pending;
public int Stage { get; set; }
public int ReviewCount { get; set; }
public DateTime DueUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class DynamicObjectiveMastery
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public string ObjectiveId { get; set; } = "";
public int Elo { get; set; } = 1000;
public int Attempts { get; set; }
public int Correct { get; set; }
public DateTime LastSeenUtc { get; set; }
}
public sealed class DynamicFamilyRating
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string BlueprintExternalId { get; set; } = "";
public int BlueprintVersion { get; set; }
public string CaseFamilyId { get; set; } = "";
public string DifficultyTier { get; set; } = "";
public int NominalElo { get; set; }
public int Elo { get; set; }
public int Attempts { get; set; }
public int Correct { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class DynamicGenerationDescriptor
{
public string BlueprintExternalId { get; set; } = "";
public int BlueprintVersion { get; set; }
public int GeneratorVersion { get; set; }
public string Seed { get; set; } = "";
public string CaseFamilyId { get; set; } = "";
public string DifficultyTier { get; set; } = "";
public int FamilyElo { get; set; }
public int EffectiveAbilityElo { get; set; }
}
+1 -11
View File
@@ -41,7 +41,6 @@ public class ExamSimulationItem
public int Position { get; set; }
public List<string> PresentedChoiceIds { get; set; } = [];
public int QuestionElo { get; set; } = 1000;
public int QuestionDifficulty { get; set; }
public bool WasSeen { get; set; }
public string BlueprintCategory { get; set; } = "Other";
}
@@ -53,7 +52,6 @@ public class ExamSimulationResponse
public List<string> EliminatedChoiceIds { get; set; } = [];
public bool Flagged { get; set; }
public int ElapsedSeconds { get; set; }
public bool? IsSubmitted { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -66,9 +64,7 @@ public class ExamSimulationScore
public int Correct { get; set; }
public int Total { get; set; }
public int SeenCount { get; set; }
public double RawAccuracy { get; set; }
public double DifficultyAdjustedAccuracy { get; set; }
public double? AbilityElo { get; set; }
public double AbilityElo { get; set; }
public Dictionary<string, ExamSimulationCategoryScore> Categories { get; set; } = [];
}
@@ -85,7 +81,6 @@ public class SaveSimulationResponseRequest
public List<string> EliminatedChoiceIds { get; set; } = [];
public bool Flagged { get; set; }
public int ElapsedSeconds { get; set; }
public bool IsSubmitted { get; set; }
}
public class ExamSimulationQuestionResponse
@@ -117,11 +112,6 @@ 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
-396
View File
@@ -1,396 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class FlashcardTypes
{
public const string Basic = "basic";
public const string Cloze = "cloze";
public static readonly HashSet<string> All = [Basic, Cloze];
}
// A reserved, never-authenticatable owner id. Decks/notes owned by this id are shared,
// admin-curated content visible to every user (studied via their own independent schedule
// rows) rather than a real user's private content.
public static class FlashcardOwners
{
public const string System = "system";
}
public static class FlashcardRatings
{
public const string Again = "again";
public const string Hard = "hard";
public const string Good = "good";
public const string Easy = "easy";
public static readonly string[] All = [Again, Hard, Good, Easy];
}
public static class FlashcardScheduleStates
{
public const string New = "new";
public const string Learning = "learning";
public const string Review = "review";
public const string Relearning = "relearning";
}
public sealed class FlashcardDeck
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public string StableId { get; set; } = Guid.NewGuid().ToString();
public string Name { get; set; } = "";
public string NormalizedName { get; set; } = "";
public string Description { get; set; } = "";
public bool IsDefault { get; set; }
public int? NewCardsPerDay { get; set; }
public int? ReviewsPerDay { get; set; }
public double? DesiredRetention { get; set; }
public long Revision { get; set; } = 1;
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? ArchivedUtc { get; set; }
[BsonIgnore] public bool IsSystem => UserId == FlashcardOwners.System;
}
public sealed class FlashcardImageReference
{
public string Kind { get; set; } = "owned";
public string AssetId { get; set; } = "";
}
public sealed class FlashcardNote
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public string StableId { get; set; } = Guid.NewGuid().ToString();
public string DeckId { get; set; } = "";
public string Type { get; set; } = FlashcardTypes.Basic;
public string Prompt { get; set; } = "";
public string Answer { get; set; } = "";
public string Extra { get; set; } = "";
public List<string> Tags { get; set; } = [];
public List<FlashcardImageReference> Images { get; set; } = [];
public string SourceQuestionExternalId { get; set; } = "";
public string ContentFingerprint { get; set; } = "";
public long Revision { get; set; } = 1;
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? ArchivedUtc { get; set; }
[BsonIgnore] public bool IsSystem => UserId == FlashcardOwners.System;
}
public sealed class FlashcardSchedule
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public string NoteId { get; set; } = "";
public int Variant { get; set; }
public string State { get; set; } = FlashcardScheduleStates.New;
public int? Step { get; set; }
public double? Stability { get; set; }
public double? Difficulty { get; set; }
public DateTime DueUtc { get; set; } = DateTime.UtcNow;
public DateTime? LastReviewUtc { get; set; }
public DateTime? BuriedUntilUtc { get; set; }
public bool IsSuspended { get; set; }
public long Revision { get; set; } = 1;
public string AlgorithmVersion { get; set; } = "fsrs-core-1.0.7";
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class FlashcardReviewLog
{
[BsonId] public string Id { get; set; } = "";
public string UserId { get; set; } = "";
public string CardId { get; set; } = "";
public string Rating { get; set; } = "";
public DateTime ReviewedUtc { get; set; }
public int DurationSeconds { get; set; }
public FlashcardScheduleSnapshot Before { get; set; } = new();
public FlashcardScheduleSnapshot After { get; set; } = new();
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? UndoneUtc { get; set; }
}
public sealed class FlashcardScheduleSnapshot
{
public string State { get; set; } = FlashcardScheduleStates.New;
public int? Step { get; set; }
public double? Stability { get; set; }
public double? Difficulty { get; set; }
public DateTime DueUtc { get; set; }
public DateTime? LastReviewUtc { get; set; }
public long Revision { get; set; }
}
public sealed class FlashcardMediaAsset
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public string FileName { get; set; } = "";
public string ContentType { get; set; } = "";
public byte[] Bytes { get; set; } = [];
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class FlashcardSettings
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public int NewCardsPerDay { get; set; } = 20;
public int ReviewsPerDay { get; set; } = 200;
public double DesiredRetention { get; set; } = 0.90;
public long Revision { get; set; } = 1;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class UpsertFlashcardDeckRequest
{
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public int? NewCardsPerDay { get; set; }
public int? ReviewsPerDay { get; set; }
public double? DesiredRetention { get; set; }
public long? ExpectedRevision { get; set; }
}
public sealed class UpsertFlashcardNoteRequest
{
public string DeckId { get; set; } = "";
public string StableId { get; set; } = "";
public string Type { get; set; } = FlashcardTypes.Basic;
public string Prompt { get; set; } = "";
public string Answer { get; set; } = "";
public string Extra { get; set; } = "";
public List<string> Tags { get; set; } = [];
public List<FlashcardImageReference> Images { get; set; } = [];
public string SourceQuestionExternalId { get; set; } = "";
public long? ExpectedRevision { get; set; }
}
public sealed class UpdateFlashcardSettingsRequest
{
public int NewCardsPerDay { get; set; } = 20;
public int ReviewsPerDay { get; set; } = 200;
public double DesiredRetention { get; set; } = 0.90;
public long? ExpectedRevision { get; set; }
}
public sealed class FlashcardDeckSummary
{
public FlashcardDeck Deck { get; set; } = new();
public int Total { get; set; }
public int Due { get; set; }
public int New { get; set; }
public int Suspended { get; set; }
public int Learning { get; set; }
public int Review { get; set; }
public int Mastered { get; set; }
}
public sealed class FlashcardLibraryResponse
{
public List<FlashcardNote> Items { get; set; } = [];
public int Total { get; set; }
}
public sealed class FlashcardLibraryQuery
{
public string? DeckId { get; set; }
public string? Tag { get; set; }
public string? Type { get; set; }
public string? State { get; set; }
public string? Search { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
}
public sealed class FlashcardStudyRequest
{
public string DeckId { get; set; } = "";
public List<string> ExcludeCardIds { get; set; } = [];
public DateTime StudyDayStartUtc { get; set; } = DateTime.UtcNow.Date;
public bool Shuffle { get; set; }
}
public sealed class FlashcardBulkNoteIdsRequest
{
public List<string> NoteIds { get; set; } = [];
}
public sealed class FlashcardMoveRequest
{
public List<string> NoteIds { get; set; } = [];
public string TargetDeckId { get; set; } = "";
}
public sealed class FlashcardBatchReviewRequest
{
public List<FlashcardReviewRequest> Reviews { get; set; } = [];
}
public sealed class FlashcardBatchReviewResponse
{
public List<FlashcardReviewResponse> Results { get; set; } = [];
}
public sealed class FlashcardUndoRequest
{
public string ReviewLogId { get; set; } = "";
}
public sealed class FlashcardRatingOption
{
public string Rating { get; set; } = "";
public string IntervalLabel { get; set; } = "";
public DateTime DueUtc { get; set; }
}
public sealed class FlashcardStudyCardResponse
{
public FlashcardNote? Note { get; set; }
public FlashcardSchedule? Card { get; set; }
public List<FlashcardRatingOption> RatingOptions { get; set; } = [];
public int DueRemaining { get; set; }
public int NewRemaining { get; set; }
}
public sealed class FlashcardReviewRequest
{
public string ClientReviewId { get; set; } = "";
public string CardId { get; set; } = "";
public string Rating { get; set; } = "";
public long ExpectedRevision { get; set; }
public DateTime ReviewedUtc { get; set; }
public int DurationSeconds { get; set; }
public DateTime StudyDayStartUtc { get; set; } = DateTime.UtcNow.Date;
}
public sealed class FlashcardReviewResponse
{
public FlashcardSchedule Card { get; set; } = new();
public List<FlashcardRatingOption> RatingOptions { get; set; } = [];
public bool Duplicate { get; set; }
}
public sealed class FlashcardDashboardCounts
{
public int Due { get; set; }
public int NewAvailable { get; set; }
public int ReviewedThisWeek { get; set; }
public int Learning { get; set; }
public int Review { get; set; }
public int CurrentStreakDays { get; set; }
public int ReviewsToday { get; set; }
public int DailyGoal { get; set; }
}
public static class FlashcardImportFormats
{
public const string Csv = "csv";
public const string Json = "json";
}
public static class FlashcardImportRowActions
{
public const string Create = "create";
public const string Update = "update";
public const string Skip = "skip";
public const string Error = "error";
}
public sealed class FlashcardImportPendingRecord
{
public int RowNumber { get; set; }
public string Action { get; set; } = FlashcardImportRowActions.Create;
public string? Error { get; set; }
public string StableId { get; set; } = "";
public string DeckName { get; set; } = "";
public string Type { get; set; } = FlashcardTypes.Basic;
public string Prompt { get; set; } = "";
public string Answer { get; set; } = "";
public string Extra { get; set; } = "";
public List<string> Tags { get; set; } = [];
public string SourceQuestionExternalId { get; set; } = "";
public List<FlashcardSchedule> Cards { get; set; } = [];
public List<FlashcardReviewLog> ReviewLogs { get; set; } = [];
}
public sealed class FlashcardImportPreview
{
[BsonId] public string Id { get; set; } = "";
public string UserId { get; set; } = "";
public string Format { get; set; } = FlashcardImportFormats.Csv;
public List<FlashcardImportPendingRecord> Records { get; set; } = [];
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime ExpiresUtc { get; set; } = DateTime.UtcNow.AddHours(1);
}
public sealed class FlashcardImportPreviewRow
{
public int RowNumber { get; set; }
public string Action { get; set; } = FlashcardImportRowActions.Create;
public string? Error { get; set; }
public string StableId { get; set; } = "";
public string Deck { get; set; } = "";
public string Type { get; set; } = FlashcardTypes.Basic;
public string Prompt { get; set; } = "";
public string Answer { get; set; } = "";
}
public sealed class FlashcardImportPreviewResponse
{
public string ImportToken { get; set; } = "";
public int CreateCount { get; set; }
public int UpdateCount { get; set; }
public int SkipCount { get; set; }
public int ErrorCount { get; set; }
public List<FlashcardImportPreviewRow> Rows { get; set; } = [];
}
public sealed class FlashcardImportCommitRequest
{
public string ImportToken { get; set; } = "";
}
public sealed class FlashcardImportCommitResponse
{
public int Created { get; set; }
public int Updated { get; set; }
public int Skipped { get; set; }
public int Errored { get; set; }
public List<FlashcardImportPreviewRow> Errors { get; set; } = [];
}
public sealed class FlashcardExportOmission
{
public string Kind { get; set; } = "";
public string Reason { get; set; } = "";
}
public sealed class FlashcardJsonExport
{
public int SchemaVersion { get; set; } = 1;
public DateTime ExportedUtc { get; set; } = DateTime.UtcNow;
public FlashcardSettings Settings { get; set; } = new();
public List<FlashcardDeck> Decks { get; set; } = [];
public List<FlashcardJsonExportNote> Notes { get; set; } = [];
}
public sealed class FlashcardJsonExportNote
{
public FlashcardNote Note { get; set; } = new();
public List<FlashcardSchedule> Cards { get; set; } = [];
public List<FlashcardReviewLog> ReviewLogs { get; set; } = [];
public List<FlashcardJsonExportImage> Images { get; set; } = [];
}
public sealed class FlashcardJsonExportImage
{
public string AssetId { get; set; } = "";
public string FileName { get; set; } = "";
public string ContentType { get; set; } = "";
public string Base64Bytes { get; set; } = "";
}
@@ -1,98 +0,0 @@
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; } = "";
}
-4
View File
@@ -23,7 +23,6 @@ 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; } = [];
@@ -31,9 +30,6 @@ public class Question
public List<string> Tags { get; set; } = [];
public List<QuestionReference> References { get; set; } = [];
public bool IsPublished { get; set; } = true;
public DateTime? FlaggedForReviewUtc { get; set; }
public string FlagReason { get; set; } = "";
public string FlaggedByUserId { get; set; } = "";
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -11,7 +11,6 @@ public class QuestionAttempt
public string QuestionExternalId { get; set; } = "";
public string UserId { get; set; } = "";
public int BankCycle { get; set; }
public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = [];
public bool TracksPresentation { get; set; }
@@ -20,8 +19,6 @@ public class QuestionAttempt
public int DurationSeconds { get; set; }
public int CounterBucket { get; set; }
public bool? RatingApplied { get; set; }
public int? UserOverallEloAfter { get; set; }
public DateTime? RatingAppliedUtc { get; set; }
public string RatingLeaseOwner { get; set; } = "";
public DateTime RatingLeaseUntilUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
@@ -45,8 +45,6 @@ public class QuestionReport
public string Details { get; set; } = "";
public string Status { get; set; } = QuestionReportStatus.Open;
public QuestionReportSnapshot QuestionSnapshot { get; set; } = new();
public string SourceType { get; set; } = "authored";
public DynamicGenerationDescriptor? DynamicGeneration { get; set; }
public string ReviewedByUserId { get; set; } = "";
public string ReviewerNote { get; set; } = "";
public DateTime? ReviewedUtc { get; set; }
@@ -3,7 +3,6 @@ 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; }
@@ -39,17 +38,10 @@ public class UpsertQuestionRequest
public bool IsPublished { get; set; } = true;
}
public class FlagQuestionRequest
{
public string Reason { get; set; } = "";
}
public class FirstQuestionAttemptRequest
{
public string QuestionExternalId { get; set; } = "";
public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = [];
public int DurationSeconds { get; set; }
public bool MetTimeLimit { get; set; } = true;
public string? StudySessionId { get; set; }
}
@@ -1,5 +1,3 @@
using QuestionsApi.Services;
namespace QuestionsApi.Models;
public class QuestionResponse
@@ -20,7 +18,6 @@ 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;
@@ -62,7 +59,6 @@ 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,
@@ -115,42 +111,12 @@ public class QuestionResponse
}
}
// Admin-only shape for the flag/unflag/list-flagged endpoints - deliberately separate from
// QuestionResponse (which is student-facing and never exposes IsPublished or the flag fields).
public class QuestionFlagResponse
{
public string Id { get; set; } = "";
public string ExternalId { get; set; } = "";
public string Stem { get; set; } = "";
public bool IsPublished { get; set; }
public DateTime? FlaggedForReviewUtc { get; set; }
public string FlagReason { get; set; } = "";
public string FlaggedByUserId { get; set; } = "";
public static QuestionFlagResponse FromModel(Question question) => new()
{
Id = question.Id ?? "",
ExternalId = question.ExternalId,
Stem = question.Stem,
IsPublished = question.IsPublished,
FlaggedForReviewUtc = question.FlaggedForReviewUtc,
FlagReason = question.FlagReason,
FlaggedByUserId = question.FlaggedByUserId
};
}
public class FirstQuestionAttemptResponse
{
public string QuestionExternalId { get; set; } = "";
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; }
public CreditStatusResponse? Credits { get; set; }
public List<CreditAward> CreditAwards { get; set; } = [];
public StudySessionProgress? StudySessionProgress { get; set; }
public StudySessionSummary? SessionSummary { get; set; }
public DailyMissionSetResponse? DailyMissions { get; set; }
public ProgressionResponse? Progression { get; set; }
}
public class SkipQuestionResponse
@@ -160,7 +126,6 @@ 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
-4
View File
@@ -13,8 +13,6 @@ public class UserRating
public int OverallElo { get; set; } = 1000;
public Dictionary<string, int> CategoryElos { get; set; } = [];
public int RatedQuestionCount { get; set; }
public int? HistoryBaselineElo { get; set; }
public DateTime? HistoryStartedUtc { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -47,6 +45,4 @@ public class RatingProfileResponse
public int OverallElo { get; set; }
public int RatedQuestionCount { get; set; }
public Dictionary<string, int> CategoryElos { get; set; } = [];
public string Tier { get; set; } = "";
public int TierDivision { get; set; }
}
@@ -55,7 +55,6 @@ 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
@@ -73,7 +72,6 @@ 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
@@ -82,12 +80,6 @@ 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
@@ -1,106 +0,0 @@
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; }
}
@@ -13,8 +13,6 @@ public sealed class UserStudyProfile
public string PreparationStage { get; set; } = "pre_dedicated";
public int WeeklyGoalHours { get; set; } = 10;
public string PreferredStudyMode { get; set; } = "mixed";
public double QuestionTextScale { get; set; } = 1.0;
public double CcsTextScale { get; set; } = 1.0;
public int Revision { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -27,12 +25,6 @@ public sealed class UpdateUserStudyProfileRequest
public string PreferredStudyMode { get; set; } = "mixed";
}
public sealed class UpdateTextScaleRequest
{
public double? QuestionTextScale { get; set; }
public double? CcsTextScale { get; set; }
}
public sealed class DeletedUserTombstone
{
[BsonId]
@@ -56,18 +48,3 @@ public static class UserStudyProfileValidator
return null;
}
}
public static class UpdateTextScaleRequestValidator
{
private const double Min = 0.85;
private const double Max = 1.35;
public static string? Validate(UpdateTextScaleRequest request)
{
if (request.QuestionTextScale is { } questionScale && (questionScale < Min || questionScale > Max))
return "Question text scale must be between 0.85 and 1.35.";
if (request.CcsTextScale is { } ccsScale && (ccsScale < Min || ccsScale > Max))
return "CCS text scale must be between 0.85 and 1.35.";
return null;
}
}
@@ -1,54 +0,0 @@
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; } = [];
}
-76
View File
@@ -4,7 +4,6 @@ using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using QuestionsApi.Services;
using QuestionsApi.Services.Flashcards;
using MongoDB.Bson;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
@@ -23,60 +22,6 @@ if (args.FirstOrDefault() == "promote-questions")
return;
}
if (args.FirstOrDefault() == "promote-dynamic-questions")
{
Environment.ExitCode = await DynamicBlueprintCommand.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);
return;
}
if (args.FirstOrDefault() == "seed-flashcards")
{
var seedConfiguration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
Environment.ExitCode = await SeedFlashcardsCommand.RunAsync(seedConfiguration, Console.Out);
return;
}
if (args.FirstOrDefault() == "flag-media-questions")
{
var flagConfiguration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
Environment.ExitCode = await FlagMediaQuestionsCommand.RunAsync(flagConfiguration, Console.Out);
return;
}
if (args.FirstOrDefault() == "migrate-question-attempt-cycles")
{
var migrationConfiguration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
Environment.ExitCode = await QuestionAttemptCycleMigrationCommand.RunAsync(
migrationConfiguration, args.Skip(1).ToArray(), Console.Out);
return;
}
if (args.FirstOrDefault() == "backfill-step3-systems")
{
var migrationConfiguration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
Environment.ExitCode = await Step3SystemBackfillCommand.RunAsync(
migrationConfiguration, args.Skip(1).ToArray(), Console.Out);
return;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
@@ -88,20 +33,12 @@ 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>();
builder.Services.AddSingleton<RatingStore>();
builder.Services.AddSingleton<QuestionMediaStore>();
builder.Services.AddSingleton<QuestionReportStore>();
builder.Services.AddSingleton<DynamicQuestionStore>();
builder.Services.AddSingleton<DynamicQuestionGenerator>();
builder.Services.AddSingleton<DynamicQuestionTokenService>();
builder.Services.AddSingleton<DynamicQuestionService>();
builder.Services.AddSingleton<ExamSimulationStore>();
builder.Services.AddSingleton<CcsStore>();
builder.Services.AddSingleton<CcsRatingStore>();
@@ -111,19 +48,7 @@ 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>();
builder.Services.AddSingleton<FlashcardScheduleStore>();
builder.Services.AddSingleton<FlashcardNoteStore>();
builder.Services.AddSingleton<FlashcardSettingsStore>();
builder.Services.AddSingleton<FlashcardMediaStore>();
builder.Services.AddSingleton<FlashcardStudyService>();
builder.Services.AddSingleton<FlashcardImportExportService>();
builder.Services.AddSingleton<AuthUserStore>();
builder.Services.AddSingleton<TokenBlacklistStore>();
builder.Services.AddSingleton<JwtTokenService>();
@@ -142,7 +67,6 @@ builder.Services.AddSingleton<DonationStore>();
builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>();
builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
builder.Services.AddHostedService<ExamSimulationScoringMigrationService>();
builder.Services.AddHttpClient();
builder.Services.AddRateLimiter(options =>
{
-1
View File
@@ -8,7 +8,6 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FSRS.Core" Version="1.0.7" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
-8
View File
@@ -57,14 +57,6 @@ 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 -79
View File
@@ -4,7 +4,8 @@ namespace QuestionsApi.Services;
public static class CcsCaseValidator
{
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "dialogue_choice", "end_case", "exit_case"];
private static readonly HashSet<string> Modes = [CcsAttemptModes.Practice, CcsAttemptModes.Timed];
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "end_case", "exit_case"];
public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
{
@@ -17,8 +18,6 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(order.CanonicalName)) errors.Add(prefix + "canonicalName required");
if (string.IsNullOrWhiteSpace(order.Category)) errors.Add(prefix + "category required");
if (order.DefaultTurnaroundMinutes < 0) errors.Add(prefix + "defaultTurnaroundMinutes cannot be negative");
if (!string.IsNullOrWhiteSpace(order.ResultSection) && !CcsChartCategories.ResultSections.Contains(order.ResultSection))
errors.Add(prefix + "resultSection is invalid");
}
if (request.Orders.Select(order => order.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Orders.Count)
errors.Add("order codes must be unique within an import");
@@ -38,7 +37,6 @@ public static class CcsCaseValidator
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);
@@ -52,11 +50,7 @@ public static class CcsCaseValidator
if (eventIds.Any(string.IsNullOrWhiteSpace) || eventIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != eventIds.Count)
errors.Add(prefix + "event ids must be present and unique");
foreach (var response in item.OrderResponses)
{
if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}");
if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection))
errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid");
}
foreach (var code in item.InitialChart.InitialOrderCodes.Concat(item.Events.SelectMany(value => value.RequiresOrderCodes.Concat(value.RequiresAnyOrderCodes))))
if (!orderCodes.Contains(code)) errors.Add(prefix + $"unknown order code {code}");
var criteriaIds = item.ScoringCriteria.Select(value => value.Id).ToList();
@@ -81,8 +75,7 @@ public static class CcsCaseValidator
public static string? ValidateCreate(CreateCcsAttemptRequest request)
{
if (!request.Mode.Equals(CcsAttemptModes.Timed, StringComparison.OrdinalIgnoreCase))
return "new CCS attempts must be timed";
if (!Modes.Contains(request.Mode)) return "mode must be practice or timed";
return null;
}
@@ -91,76 +84,8 @@ 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" or "dialogue_choice") && string.IsNullOrWhiteSpace(request.Code)) return "code required";
if ((request.Type is "exam" or "order" or "cancel_order" or "location") && 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;
}
public static string? ValidateBatch(CcsBatchCommandRequest request)
{
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
if (request.Commands.Count is < 1 or > 50) return "commands must contain between 1 and 50 items";
foreach (var item in request.Commands)
{
var error = ValidateCommand(new CcsCommandRequest
{
CommandId = request.CommandId,
ExpectedRevision = request.ExpectedRevision,
Type = item.Type,
Code = item.Code,
Minutes = item.Minutes
});
if (error is not null) return error;
if (item.Type is not ("exam" or "interval_history" or "order" or "cancel_order"))
return "batch commands support history/exams, orders, and order cancellations only";
}
var hasClinicalAssessment = request.Commands.Any(value => value.Type is "exam" or "interval_history");
if (hasClinicalAssessment && request.Commands.Any(value => value.Type is not ("exam" or "interval_history")))
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 (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");
}
}
@@ -31,12 +31,6 @@ public sealed class CcsRatingStore
return new CcsRatingProfileResponse { Elo = rating.Elo, RatedCaseCount = rating.RatedCaseCount };
}
public async Task<CcsUserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
{
var rating = await GetOrCreateUserAsync(userId);
return await EnsureHistoryBaselineAsync(rating, nowUtc);
}
public async Task<CcsRatingChange> ApplyAsync(CcsAttempt attempt)
{
var actual = Math.Clamp((attempt.Score?.Percent ?? 0) / 100.0, 0, 1);
@@ -58,7 +52,6 @@ public sealed class CcsRatingStore
}
var user = await GetOrCreateUserAsync(attempt.UserId);
user = await EnsureHistoryBaselineAsync(user, DateTime.UtcNow);
var caseRating = await GetOrCreateCaseAsync(attempt.CaseExternalId);
var expected = ExpectedScore(user.Elo, caseRating.Elo);
var userDelta = (int)Math.Round(UserK(user.RatedCaseCount) * (actual - expected), MidpointRounding.AwayFromZero);
@@ -144,26 +137,6 @@ public sealed class CcsRatingStore
}
}
private async Task<CcsUserRating> EnsureHistoryBaselineAsync(CcsUserRating rating, DateTime nowUtc)
{
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
return rating;
var result = await _users.UpdateOneAsync(
value => value.UserId == rating.UserId && value.HistoryStartedUtc == null,
Builders<CcsUserRating>.Update
.Set(value => value.HistoryBaselineElo, rating.Elo)
.Set(value => value.HistoryStartedUtc, nowUtc));
if (result.ModifiedCount > 0)
{
rating.HistoryBaselineElo = rating.Elo;
rating.HistoryStartedUtc = nowUtc;
return rating;
}
return await _users.Find(value => value.UserId == rating.UserId).FirstAsync();
}
private async Task<CcsCaseRating> UpdateCaseAsync(CcsCaseRating current, int delta)
{
while (true)
+24 -255
View File
@@ -115,23 +115,16 @@ public sealed class CcsStore
CaseExternalId = definition.ExternalId,
CaseRevisionId = definition.Id ?? "",
CaseVersion = definition.Version,
Mode = CcsAttemptModes.Timed,
OpeningCompleted = false,
InterviewNodeId = definition.InitialInterview.StartNodeId,
Mode = request.Mode,
Location = definition.PresentingLocation,
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
VitalHistory =
[
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 = request.Mode == CcsAttemptModes.Timed ? now.AddMinutes(definition.RealTimeMinutes) : null,
RatingEligible = request.Mode == CcsAttemptModes.Timed,
Chart =
[
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "initial_appearance", Title = "Initial appearance", Text = definition.InitialChart.Appearance }
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.ReasonForEncounter },
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.Appearance }
]
};
try { await _attempts.InsertOneAsync(attempt); }
@@ -180,12 +173,7 @@ public sealed class CcsStore
if (attempt.State == CcsAttemptStates.Active && attempt.DeadlineUtc <= DateTime.UtcNow)
{
attempt.State = CcsAttemptStates.FinalOrders;
attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc);
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions",
SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders."
});
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.AddMinutes(2);
attempt.Revision++;
await SaveAsync(attempt);
}
@@ -218,17 +206,9 @@ public sealed class CcsStore
case "advance": Advance(attempt, definition, command.Minutes); break;
case "interval_history":
attempt.Actions.Add(Action("interval_history", "interval_history", attempt.SimulatedMinute, now));
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history",
SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change."
});
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change." });
Advance(attempt, definition, 1);
break;
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;
}
@@ -240,63 +220,6 @@ public sealed class CcsStore
return attempt;
}
public async Task<CcsAttempt> ApplyBatchCommandAsync(CcsAttempt attempt, CcsBatchCommandRequest request)
{
var validation = CcsCaseValidator.ValidateBatch(request);
if (validation is not null) throw new ArgumentException(validation);
if (attempt.ProcessedCommandIds.Contains(request.CommandId, StringComparer.Ordinal)) return attempt;
if (attempt.Revision != request.ExpectedRevision) throw new InvalidOperationException("Attempt revision is stale.");
if (attempt.State is CcsAttemptStates.Completed or CcsAttemptStates.Abandoned)
throw new InvalidOperationException("The CCS attempt is closed.");
if (attempt.State == CcsAttemptStates.FinalOrders && request.Commands.Any(value => value.Type is not ("order" or "cancel_order")))
throw new InvalidOperationException("Only final orders may be changed now.");
var definition = await DefinitionAsync(attempt);
var now = DateTime.UtcNow;
if (request.Commands.Any(value => value.Type is "exam" or "interval_history"))
{
var selected = request.Commands.Where(item => item.Type == "exam").Select(item =>
definition.Exams.FirstOrDefault(value => value.Code.Equals(item.Code, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException($"Unknown examination component: {item.Code}.")).ToList();
var includeHistory = request.Commands.Any(item => item.Type == "interval_history");
var startingMinute = attempt.SimulatedMinute;
foreach (var exam in selected)
attempt.Actions.Add(Action("exam", exam.Code, startingMinute, now));
if (includeHistory)
attempt.Actions.Add(Action("interval_history", "interval_history", startingMinute, now));
Advance(attempt, definition, selected.Sum(value => Math.Max(1, value.DurationMinutes)) + (includeHistory ? 1 : 0));
if (includeHistory)
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history",
SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change."
});
foreach (var exam in selected)
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name,
SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding
});
}
else
{
foreach (var item in request.Commands)
{
if (item.Type == "order") await ApplyOrderAsync(attempt, definition, item.Code, now, false);
else CancelOrder(attempt, item.Code, now);
}
ProcessDue(attempt);
}
attempt.ProcessedCommandIds.Add(request.CommandId);
if (attempt.ProcessedCommandIds.Count > 100)
attempt.ProcessedCommandIds.RemoveRange(0, attempt.ProcessedCommandIds.Count - 100);
attempt.Revision++;
attempt.UpdatedUtc = now;
await SaveAsync(attempt);
return attempt;
}
public async Task<CcsHistoryPage> HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null)
{
limit = Math.Clamp(limit, 1, 50);
@@ -391,87 +314,26 @@ public sealed class CcsStore
{
var definition = await DefinitionAsync(attempt);
var completed = attempt.State == CcsAttemptStates.Completed;
List<CcsOrderDefinition> activeCatalog = attempt.ActiveOrderCodes.Count == 0
? []
: await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync();
var activeOrders = activeCatalog.Select(order =>
{
var orderedMinute = attempt.Actions.LastOrDefault(value =>
value.Type == "order" && value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase))?.SimulatedMinute ?? 0;
var pending = attempt.PendingResults.FirstOrDefault(value =>
value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase));
var reported = attempt.Chart.LastOrDefault(value =>
value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase)
&& value.Category is not CcsChartCategories.Orders);
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,
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 = definition.InitialInterview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
if (currentInterviewNode is not null)
{
interview = new CcsInterviewStateResponse
{
CurrentNodeId = currentInterviewNode.Id,
CurrentNode = currentInterviewNode,
VisitedChoiceIds = attempt.InterviewChoiceIds,
Completed = attempt.OpeningCompleted ?? false
};
}
return new CcsAttemptResponse
{
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,
DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc,
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
SimulatedMinute = attempt.SimulatedMinute, Location = attempt.Location,
DeadlineUtc = attempt.DeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
HistorySections = definition.InitialChart.HistorySections,
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
{
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title
}).ToList(),
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult { OrderCode = value.OrderCode, DueMinute = value.DueMinute }).ToList(),
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Interview = interview,
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 (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete.");
var node = definition.InitialInterview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException("The interactive interview is unavailable.");
var choice = node.Choices.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
var next = definition.InitialInterview.Nodes.FirstOrDefault(value =>
value.Id.Equals(choice.NextNodeId, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException("The dialogue choice target is unavailable.");
attempt.Actions.Add(Action("dialogue_choice", choice.Id, attempt.SimulatedMinute, now));
attempt.InterviewChoiceIds.Add(choice.Id);
attempt.InterviewNodeId = next.Id;
if (next.IsTerminal) attempt.OpeningCompleted = true;
}
public async Task AbandonAsync(CcsAttempt attempt)
{
attempt.State = CcsAttemptStates.Abandoned;
@@ -480,7 +342,7 @@ public sealed class CcsStore
await SaveAsync(attempt);
}
private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now, bool processImmediately = true)
private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now)
{
code = code.Trim().ToLowerInvariant();
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code.");
@@ -490,30 +352,17 @@ public sealed class CcsStore
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 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
});
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName,
SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}"
});
if (delay == 0 && processImmediately) ProcessDue(attempt);
attempt.PendingResults.Add(new CcsPendingResult { OrderCode = code, DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches });
attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}" });
if (delay == 0) ProcessDue(attempt);
}
private static void ApplyExam(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now)
{
var exam = definition.Exams.FirstOrDefault(value => value.Code.Equals(code, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException("Unknown examination component.");
attempt.Actions.Add(Action("exam", exam.Code, attempt.SimulatedMinute, now));
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name,
SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding
});
attempt.Chart.Add(new CcsChartEntry { Category = "exam", SimulatedMinute = attempt.SimulatedMinute, Text = $"{exam.Name}: {exam.Finding}" });
Advance(attempt, definition, Math.Max(1, exam.DurationMinutes));
}
@@ -522,11 +371,7 @@ public sealed class CcsStore
attempt.Actions.Add(Action("cancel_order", code, attempt.SimulatedMinute, now));
attempt.ActiveOrderCodes.RemoveAll(value => value.Equals(code, StringComparison.OrdinalIgnoreCase));
attempt.PendingResults.RemoveAll(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Orders, Code = code, Title = Humanize(code),
SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {Humanize(code)}"
});
attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {code}" });
}
private static void ChangeLocation(CcsAttempt attempt, CcsCaseRevision definition, string location, DateTime now)
@@ -534,11 +379,7 @@ public sealed class CcsStore
if (!definition.AllowedLocations.Contains(location, StringComparer.OrdinalIgnoreCase)) throw new ArgumentException("Unsupported location.");
attempt.Location = location;
attempt.Actions.Add(Action("location", location, attempt.SimulatedMinute, now));
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "location", Title = "Location change",
SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {Humanize(location)}."
});
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {location}." });
}
private static void Advance(CcsAttempt attempt, CcsCaseRevision definition, int minutes)
@@ -562,20 +403,13 @@ public sealed class CcsStore
private static void ProcessDue(CcsAttempt attempt)
{
var due = attempt.PendingResults.Where(value => value.DueMinute <= attempt.SimulatedMinute).ToList();
var vitalsChanged = false;
foreach (var result in due)
{
if (!string.IsNullOrWhiteSpace(result.Result))
attempt.Chart.Add(new CcsChartEntry
{
Category = string.IsNullOrWhiteSpace(result.Category) ? CcsChartCategories.OtherTest : result.Category,
Code = result.OrderCode, Title = result.Title, SimulatedMinute = attempt.SimulatedMinute, Text = result.Result
});
attempt.Chart.Add(new CcsChartEntry { Category = "results", SimulatedMinute = attempt.SimulatedMinute, Text = result.Result });
foreach (var patch in result.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value;
vitalsChanged |= result.VitalPatches.Count > 0;
attempt.PendingResults.Remove(result);
}
if (vitalsChanged) SnapshotVitals(attempt);
}
private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition)
@@ -585,20 +419,13 @@ public sealed class CcsStore
&& value.TriggerMinute <= attempt.SimulatedMinute
&& value.RequiresOrderCodes.All(activeOrders.Contains)
&& (value.RequiresAnyOrderCodes.Count == 0 || value.RequiresAnyOrderCodes.Any(activeOrders.Contains))).ToList();
var vitalsChanged = false;
foreach (var item in events)
{
attempt.ProcessedEventIds.Add(item.Id);
foreach (var patch in item.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value;
vitalsChanged |= item.VitalPatches.Count > 0;
if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = item.Id, Title = "Clinical update",
SimulatedMinute = attempt.SimulatedMinute, Text = item.Message
});
if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = item.Message });
if (item.EndCase) EnterFinalOrders(attempt);
}
if (vitalsChanged) SnapshotVitals(attempt);
return events.Any(value => !string.IsNullOrWhiteSpace(value.Message) || value.EndCase);
}
@@ -606,21 +433,9 @@ public sealed class CcsStore
{
if (attempt.State != CcsAttemptStates.Active) return;
attempt.State = CcsAttemptStates.FinalOrders;
if (attempt.HardDeadlineUtc is not null)
{
attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc);
}
else
{
var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow;
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add(
remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2));
}
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions",
SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders."
});
var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow;
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add(remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2));
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "Case-end instructions: finalize patient care orders." });
}
private async Task CompleteAsync(CcsAttempt attempt)
@@ -674,44 +489,12 @@ public sealed class CcsStore
};
}
public static int ActivePhaseMinutes(int totalMinutes) => Math.Max(1, totalMinutes - 2);
public static DateTime FinalOrdersDeadline(DateTime now, DateTime? hardDeadline)
{
var proposed = now.AddMinutes(2);
return hardDeadline is null || proposed <= hardDeadline ? proposed : hardDeadline.Value;
}
public async Task<CcsCaseRevision> DefinitionAsync(CcsAttempt attempt) =>
await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync()
?? throw new InvalidOperationException("The case revision is unavailable.");
private Task SaveAsync(CcsAttempt attempt) => _attempts.ReplaceOneAsync(value => value.Id == attempt.Id, attempt);
private static CcsAttemptAction Action(string type, string code, int minute, DateTime now) => new() { Type = type, Code = code, SimulatedMinute = minute, RealUtc = now };
private static void SnapshotVitals(CcsAttempt attempt)
{
if (attempt.VitalHistory.Count > 0
&& attempt.VitalHistory[^1].SimulatedMinute == attempt.SimulatedMinute
&& attempt.VitalHistory[^1].Values.OrderBy(value => value.Key).SequenceEqual(attempt.CurrentVitals.OrderBy(value => value.Key)))
return;
attempt.VitalHistory.Add(new CcsVitalSnapshot
{
SimulatedMinute = attempt.SimulatedMinute,
Values = new Dictionary<string, string>(attempt.CurrentVitals)
});
}
private static string ResultCategory(string? responseSection, string catalogSection, string catalogCategory)
{
var explicitSection = string.IsNullOrWhiteSpace(responseSection) ? catalogSection : responseSection;
if (!string.IsNullOrWhiteSpace(explicitSection)) return explicitSection;
return catalogCategory.ToLowerInvariant() switch
{
"laboratory" => CcsChartCategories.Lab,
"imaging" => CcsChartCategories.Imaging,
"medication" or "procedure" or "monitoring" => CcsChartCategories.Treatment,
_ => CcsChartCategories.OtherTest
};
}
private static string Humanize(string value) => string.Join(' ', value.Split('_', StringSplitOptions.RemoveEmptyEntries).Select(word => char.ToUpperInvariant(word[0]) + word[1..]));
private static string DescribeExpected(CcsScoringCriterion criterion, List<string> targets)
{
@@ -726,28 +509,14 @@ 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,
InteractiveInterviewAvailable = !string.IsNullOrWhiteSpace(value.InitialInterview.StartNodeId) && value.InitialInterview.Nodes.Count > 0
Difficulty = value.Difficulty, Tags = value.Tags
};
internal static int Rank(CcsOrderDefinition order, string query)
private static int Rank(CcsOrderDefinition order, string query)
{
var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant());
if (values.Any(value => value == query)) return 0;
if (values.Any(value => value.StartsWith(query))) return 1;
if (values.Any(value => value.Contains(query))) return 2;
if (CategorySearchTerms(order.Category).Contains(query, StringComparer.OrdinalIgnoreCase)) return 3;
return 1000;
}
private static IReadOnlyList<string> CategorySearchTerms(string category) =>
category.Trim().ToLowerInvariant() switch
{
"laboratory" => ["laboratory", "laboratories", "lab", "labs", "test", "tests", "diagnostic", "diagnostics", "study", "studies"],
"diagnostic" => ["diagnostic", "diagnostics", "test", "tests", "study", "studies"],
"medication" => ["medication", "medications", "medicine", "medicines", "drug", "drugs", "treatment", "treatments", "therapy", "therapies"],
"procedure" => ["procedure", "procedures", "intervention", "interventions", "treatment", "treatments"],
"monitoring" => ["monitor", "monitoring", "treatment", "treatments"],
"disposition" => ["disposition", "admission", "admit", "transfer", "discharge"],
_ => [category.Trim().ToLowerInvariant()]
};
}
@@ -25,11 +25,8 @@ public static class ContentPromotionCommand
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
var questions = await source.GetCollection<BsonDocument>("Questions")
.Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
var questions = sourceQuestions
.Select(QuestionPromotionCommand.PrepareForProduction)
.ToList();
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
@@ -1,17 +0,0 @@
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);
}
}
}
-492
View File
@@ -1,492 +0,0 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class CreditStore
{
private readonly IMongoCollection<UserCreditAccount> _accounts;
private readonly IMongoCollection<CreditLedgerEntry> _ledger;
private readonly TimeProvider _clock;
private readonly ServiceMetrics _metrics;
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock, ServiceMetrics metrics)
{
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_ledger = mongo.Database.GetCollection<CreditLedgerEntry>("CreditLedgerEntries");
_clock = clock;
_metrics = metrics;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
_ledger.Indexes.CreateOne(new CreateIndexModel<CreditLedgerEntry>(
Builders<CreditLedgerEntry>.IndexKeys.Ascending(value => value.UserId)
.Descending(value => value.CreatedUtc).Descending(value => value.Id),
new CreateIndexOptions { Name = "credit_ledger_user_time" }));
}
public async Task<CreditAwardResult> AwardAsync(string userId, CreditAwardRequest request)
{
if (string.IsNullOrWhiteSpace(request.OperationId) || request.Amount <= 0)
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var entryId = LedgerId(userId, request.OperationId);
var entry = await _ledger.Find(value => value.Id == entryId).FirstOrDefaultAsync();
if (entry is not null && (entry.ReasonCode != request.ReasonCode || entry.SourceId != request.SourceId))
throw new InvalidOperationException("The credit operation ID was already used for another event.");
if (entry is null)
{
var alreadyApplied = await _accounts.Find(value => value.UserId == userId
&& value.AppliedOperations.Contains(request.OperationId)).AnyAsync();
if (alreadyApplied) return new CreditAwardResult { Status = await GetStatusAsync(userId) };
entry = new CreditLedgerEntry
{
Id = entryId, UserId = userId, OperationId = request.OperationId,
RequestedAmount = request.Amount, Amount = request.SubjectToRollingCap ? 0 : request.Amount,
ReasonCode = request.ReasonCode,
Description = request.Description, SourceType = request.SourceType,
SourceId = request.SourceId, SubjectToRollingCap = request.SubjectToRollingCap,
CreatedUtc = now
};
try { await _ledger.InsertOneAsync(entry); }
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{ entry = await _ledger.Find(value => value.Id == entryId).FirstAsync(); }
}
if (entry.State == "applied")
{
_metrics.CreditDuplicate(request.ReasonCode);
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
}
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
if (account.AppliedOperations.Contains(request.OperationId))
{
var appliedAmount = AppliedAmount(account, entry);
entry.Amount = appliedAmount;
await MarkLedgerAppliedAsync(entry, appliedAmount, now);
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
}
var amount = request.Amount;
var appliedNow = false;
while (true)
{
var earned = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount));
amount = request.SubjectToRollingCap
? Math.Min(request.Amount, Math.Max(0, CreditEconomy.RollingEarningLimit - earned))
: request.Amount;
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId)
& Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [request.OperationId]);
if (request.SubjectToRollingCap && amount > 0)
filter &= RollingEarnedAtMost(CreditEconomy.RollingEarningLimit - amount);
var update = Builders<UserCreditAccount>.Update
.AddToSet(value => value.AppliedOperations, request.OperationId)
.Set(value => value.UpdatedUtc, now);
if (amount > 0)
{
update = update.Inc(value => value.Credits, amount);
if (request.SubjectToRollingCap)
update = update.Push(value => value.RecentEarnings,
new CreditEvent { OperationId = request.OperationId, CoinAmount = amount, CreatedUtc = now });
}
if ((await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1)
{
appliedNow = true;
break;
}
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
if (account.AppliedOperations.Contains(request.OperationId))
{
amount = AppliedAmount(account, entry);
break;
}
}
entry.Amount = amount;
await MarkLedgerAppliedAsync(entry, amount, now);
if (appliedNow && amount > 0) _metrics.CreditAward(request.ReasonCode, amount);
if (appliedNow && request.SubjectToRollingCap && amount < request.Amount)
_metrics.CreditCapped(request.ReasonCode);
var status = await GetStatusAsync(userId);
return appliedNow ? Result(entry, status) : new CreditAwardResult { Status = status };
}
public async Task InitializeMasteryBaselineAsync(string userId, IEnumerable<string> operationIds)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
var update = Builders<UserCreditAccount>.Update
.Set(value => value.MasteryAwardBaselineApplied, true);
foreach (var operationId in operationIds.Distinct(StringComparer.Ordinal))
update = update.AddToSet(value => value.AppliedOperations, operationId);
await _accounts.UpdateOneAsync(
value => value.UserId == userId && !value.MasteryAwardBaselineApplied, update);
}
public async Task<bool> IsMasteryBaselineAppliedAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).MasteryAwardBaselineApplied;
}
public async Task<HashSet<string>> GetAppliedOperationsAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
return account.AppliedOperations.ToHashSet(StringComparer.Ordinal);
}
public async Task<CreditLedgerPage> GetLedgerAsync(string userId, int limit, DateTime? before)
{
limit = Math.Clamp(limit, 1, 50);
var filter = Builders<CreditLedgerEntry>.Filter.Eq(value => value.UserId, userId)
& Builders<CreditLedgerEntry>.Filter.Eq(value => value.State, "applied")
& Builders<CreditLedgerEntry>.Filter.Ne(value => value.Amount, 0);
if (before.HasValue) filter &= Builders<CreditLedgerEntry>.Filter.Lt(value => value.CreatedUtc, before.Value);
var entries = await _ledger.Find(filter).SortByDescending(value => value.CreatedUtc)
.ThenByDescending(value => value.Id).Limit(limit + 1).ToListAsync();
var hasMore = entries.Count > limit;
if (hasMore) entries.RemoveAt(entries.Count - 1);
return new CreditLedgerPage
{
Items = entries.Select(value => new CreditLedgerEntryResponse
{
Amount = value.Amount, ReasonCode = value.ReasonCode,
Description = value.Description, CreatedUtc = value.CreatedUtc
}).ToList(),
NextBefore = hasMore && entries.Count > 0 ? entries[^1].CreatedUtc : null
};
}
public async Task ReconcilePendingAsync(CancellationToken cancellationToken = default)
{
var pending = await _ledger.Find(value => value.State == "pending")
.SortBy(value => value.CreatedUtc).Limit(100).ToListAsync(cancellationToken);
foreach (var entry in pending)
{
cancellationToken.ThrowIfCancellationRequested();
await AwardAsync(entry.UserId, new CreditAwardRequest(
entry.OperationId, entry.RequestedAmount, entry.ReasonCode, entry.Description,
entry.SourceType, entry.SourceId, entry.SubjectToRollingCap));
}
}
public async Task<CreditStatusResponse> GetStatusAsync(string userId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
return ToStatus(account);
}
public async Task<CreditAward?> GetAppliedAwardAsync(string userId, string operationId)
{
var entry = await _ledger.Find(value => value.Id == LedgerId(userId, operationId)
&& value.State == "applied").FirstOrDefaultAsync();
return entry is null || entry.Amount <= 0 ? null : new CreditAward
{
Amount = entry.Amount, ReasonCode = entry.ReasonCode, Description = entry.Description
};
}
public async Task<int> GetBankCycleAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
}
public async Task<CreditOperationResponse> PurchaseAsync(
string userId, RzStoreProduct product, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
var prior = existing.Purchases.FirstOrDefault(value => value.OperationId == operationId);
if (prior is not null)
{
if (prior.Sku != product.Sku)
return Response(CreditOperationResult.OperationConflict, existing);
await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc);
return Response(CreditOperationResult.Succeeded, existing);
}
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) &
Builders<UserCreditAccount>.Filter.Not(
Builders<UserCreditAccount>.Filter.ElemMatch(
value => value.Purchases, purchase => purchase.OperationId == operationId));
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, -product.CoinPrice)
.Push(value => value.Purchases, new CreditPurchaseEvent
{
OperationId = operationId,
Sku = product.Sku,
CoinCost = product.CoinPrice,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now);
update = product.Grant == RzStoreGrant.ExamToken
? update.Inc(value => value.ExamTokens, 1)
: update.Inc(value => value.QBankResetTokens, 1);
var account = await _accounts.FindOneAndUpdateAsync(
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
{
ReturnDocument = ReturnDocument.After
});
if (account is not null)
{
await RecordPurchaseLedgerAsync(userId, product, operationId, now);
return Response(CreditOperationResult.Succeeded, account);
}
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
prior = account.Purchases.FirstOrDefault(value => value.OperationId == operationId);
if (prior is not null)
{
if (prior.Sku != product.Sku)
return Response(CreditOperationResult.OperationConflict, account);
await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc);
return Response(CreditOperationResult.Succeeded, account);
}
return Response(CreditOperationResult.InsufficientCredits, account);
}
public async Task<CreditOperationResponse> ApplyQBankResetAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gt(value => value.QBankResetTokens, 0) &
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.QBankResetTokens, -1)
.Inc(value => value.BankCycle, 1)
.AddToSet(value => value.AppliedOperations, operationId)
.Set(value => value.UpdatedUtc, now);
var account = await _accounts.FindOneAndUpdateAsync(
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
{
ReturnDocument = ReturnDocument.After
});
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
if (account.AppliedOperations.Contains(operationId))
return Response(CreditOperationResult.Succeeded, account);
return Response(CreditOperationResult.InsufficientTokens, account);
}
public async Task<SpinResponse> SpinAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
var priorSpin = existing.Spins.FirstOrDefault(value => value.OperationId == operationId);
if (priorSpin is not null)
return SpinResult(CreditOperationResult.Succeeded, existing, priorSpin);
var reward = SlotMachineEconomy.RollReward(Random.Shared.NextDouble());
var netCreditDelta = reward.Grant == RzStoreGrant.Coins
? reward.CoinAmount - SlotMachineEconomy.SpinCost
: -SlotMachineEconomy.SpinCost;
var spinEvent = new SpinEvent
{
OperationId = operationId,
RewardKey = reward.Key,
CoinsWon = reward.Grant == RzStoreGrant.Coins ? reward.CoinAmount : 0,
CreatedUtc = now
};
var belowSpinCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
{
new BsonDocument("$size", new BsonDocument("$ifNull", new BsonArray { "$RecentSpins", new BsonArray() })),
SlotMachineEconomy.RollingSpinLimit
})));
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, SlotMachineEconomy.SpinCost) &
Builders<UserCreditAccount>.Filter.Not(
Builders<UserCreditAccount>.Filter.ElemMatch(
value => value.Spins, spin => spin.OperationId == operationId)) &
belowSpinCap;
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, netCreditDelta)
.Push(value => value.Spins, spinEvent)
.Push(value => value.RecentSpins, new CreditEvent { OperationId = operationId, CreatedUtc = now })
.Set(value => value.UpdatedUtc, now);
update = reward.Grant switch
{
RzStoreGrant.ExamToken => update.Inc(value => value.ExamTokens, 1),
RzStoreGrant.QBankResetToken => update.Inc(value => value.QBankResetTokens, 1),
_ => update
};
var account = await _accounts.FindOneAndUpdateAsync(
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
{
ReturnDocument = ReturnDocument.After
});
if (account is not null) return SpinResult(CreditOperationResult.Succeeded, account, spinEvent);
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
priorSpin = account.Spins.FirstOrDefault(value => value.OperationId == operationId);
if (priorSpin is not null) return SpinResult(CreditOperationResult.Succeeded, account, priorSpin);
if (account.Credits < SlotMachineEconomy.SpinCost)
return SpinResult(CreditOperationResult.InsufficientCredits, account, null);
return SpinResult(CreditOperationResult.RateLimited, account, null);
}
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gt(value => value.ExamTokens, 0) &
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, -1)
.AddToSet(value => value.AppliedOperations, operationId)
.Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public Task RefundExamTokenAsync(string userId, string operationId) =>
_accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.AnyEq(value => value.AppliedOperations, operationId),
Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, 1)
.Pull(value => value.AppliedOperations, operationId)
.Set(value => value.UpdatedUtc, _clock.GetUtcNow().UtcDateTime));
private async Task EnsureAndCleanAsync(string userId, DateTime now)
{
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.Credits, 0)
.SetOnInsert(value => value.ExamTokens, 1)
.SetOnInsert(value => value.QBankResetTokens, 0)
.SetOnInsert(value => value.BankCycle, 0)
.SetOnInsert(value => value.InitialExamGrantApplied, true)
.SetOnInsert(value => value.MasteryAwardBaselineApplied, false)
.SetOnInsert(value => value.RecentEarnings, [])
.SetOnInsert(value => value.Purchases, [])
.SetOnInsert(value => value.Spins, [])
.SetOnInsert(value => value.RecentSpins, [])
.SetOnInsert(value => value.AppliedOperations, [])
.SetOnInsert(value => value.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true });
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Ne(value => value.InitialExamGrantApplied, true),
Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, 1)
.Set(value => value.InitialExamGrantApplied, true));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Purchases), false),
Builders<UserCreditAccount>.Update.Set(value => value.Purchases, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Spins), false),
Builders<UserCreditAccount>.Update.Set(value => value.Spins, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentSpins), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentSpins, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false),
Builders<UserCreditAccount>.Update.Set(value => value.AppliedOperations, []));
var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff));
var spinCutoff = now - SlotMachineEconomy.SpinWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentSpins,
spin => spin.CreatedUtc <= spinCutoff));
}
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
{
Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount)),
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount))),
ExamTokens = account.ExamTokens,
QBankResetTokens = account.QBankResetTokens,
BankCycle = account.BankCycle
};
private static CreditOperationResponse Response(
CreditOperationResult result, UserCreditAccount account) => new()
{
Result = result,
Status = ToStatus(account)
};
private static SpinResponse SpinResult(
CreditOperationResult result, UserCreditAccount account, SpinEvent? spinEvent) => new()
{
Result = result,
RewardKey = spinEvent?.RewardKey ?? "",
RewardLabel = spinEvent is null ? "" : SlotMachineEconomy.FindReward(spinEvent.RewardKey)?.Label ?? "",
CoinsWon = spinEvent?.CoinsWon ?? 0,
Status = ToStatus(account)
};
private static string LedgerId(string userId, string operationId) => $"{userId}:{operationId}";
private static int AppliedAmount(UserCreditAccount account, CreditLedgerEntry entry) =>
entry.SubjectToRollingCap
? account.RecentEarnings.FirstOrDefault(value => value.OperationId == entry.OperationId)?.CoinAmount ?? 0
: entry.RequestedAmount;
private static FilterDefinition<UserCreditAccount> RollingEarnedAtMost(int maximum) =>
new BsonDocumentFilterDefinition<UserCreditAccount>(new BsonDocument("$expr",
new BsonDocument("$lte", new BsonArray
{
new BsonDocument("$sum", new BsonDocument("$map", new BsonDocument
{
{ "input", new BsonDocument("$ifNull", new BsonArray { "$RecentEarnings", new BsonArray() }) },
{ "as", "earning" },
{ "in", new BsonDocument("$ifNull", new BsonArray { "$$earning.CoinAmount", 1 }) }
})),
maximum
})));
private Task MarkLedgerAppliedAsync(CreditLedgerEntry entry, int amount, DateTime now) =>
_ledger.UpdateOneAsync(value => value.Id == entry.Id,
Builders<CreditLedgerEntry>.Update.Set(value => value.Amount, amount)
.Set(value => value.State, "applied").Set(value => value.AppliedUtc, now));
private Task RecordPurchaseLedgerAsync(string userId, RzStoreProduct product, string operationId, DateTime createdUtc) =>
_ledger.ReplaceOneAsync(
value => value.Id == LedgerId(userId, $"purchase:{operationId}"),
new CreditLedgerEntry
{
Id = LedgerId(userId, $"purchase:{operationId}"), UserId = userId,
OperationId = $"purchase:{operationId}", RequestedAmount = -product.CoinPrice,
Amount = -product.CoinPrice, ReasonCode = CreditReasonCodes.Purchase,
Description = $"Purchased {product.Name}", SourceType = "store_product",
SourceId = product.Sku, State = "applied", CreatedUtc = createdUtc, AppliedUtc = createdUtc
},
new ReplaceOptions { IsUpsert = true });
private static CreditAwardResult Result(CreditLedgerEntry entry, CreditStatusResponse status) => new()
{
Award = entry.Amount <= 0 ? null : new CreditAward
{
Amount = entry.Amount, ReasonCode = entry.ReasonCode, Description = entry.Description
},
Status = status
};
}
@@ -1,203 +0,0 @@
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()
};
}
+43 -197
View File
@@ -1,12 +1,12 @@
using MongoDB.Driver;
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
namespace QuestionsApi.Services;
public sealed class DashboardStore
{
private readonly IMongoCollection<QuestionAttempt> _attempts;
private readonly IMongoCollection<UserRating> _ratings;
private readonly IMongoCollection<CcsUserRating> _ccsRatings;
private readonly IMongoCollection<CcsAttempt> _ccsAttempts;
private readonly IMongoCollection<CcsCaseRevision> _ccsCases;
@@ -15,129 +15,90 @@ public sealed class DashboardStore
private readonly IMongoCollection<UserNotepad> _notes;
private readonly QuestionStore _questions;
private readonly ReviewQueueStore _reviewQueue;
private readonly DynamicQuestionStore _dynamicQuestions;
private readonly FlashcardStudyService _flashcards;
private readonly FlashcardScheduleStore _flashcardSchedules;
private readonly RatingStore _ratingStore;
private readonly CcsRatingStore _ccsRatingStore;
private readonly IMongoCollection<UserRating> _ratings;
public static readonly string[] Step3Systems = Step3SystemCatalog.All;
public DashboardStore(
MongoDatabaseProvider mongo,
QuestionStore questions,
ReviewQueueStore reviewQueue,
DynamicQuestionStore dynamicQuestions,
FlashcardStudyService flashcards,
FlashcardScheduleStore flashcardSchedules,
RatingStore ratingStore,
CcsRatingStore ccsRatingStore)
public DashboardStore(MongoDatabaseProvider mongo, QuestionStore questions, ReviewQueueStore reviewQueue)
{
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
_ccsAttempts = mongo.Database.GetCollection<CcsAttempt>("CcsAttempts");
_ccsCases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
_simulations = mongo.Database.GetCollection<ExamSimulation>("ExamSimulations");
_bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks");
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
_questions = questions;
_reviewQueue = reviewQueue;
_dynamicQuestions = dynamicQuestions;
_flashcards = flashcards;
_flashcardSchedules = flashcardSchedules;
_ratingStore = ratingStore;
_ccsRatingStore = ccsRatingStore;
}
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
{
await _reviewQueue.EnsureUserQueueAsync(userId);
var today = nowUtc.Date;
var trendStart = today.AddDays(-29);
var weekStart = today.AddDays(-6);
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
var ccsRatingTask = _ccsRatingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
var ratingTask = _ratings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
var ccsAllAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId).ToListAsync();
var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId
&& value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue);
var simulationsTask = _simulations.Find(value => value.UserId == userId && !value.IsSample && value.CreatedUtc >= trendStart).ToListAsync();
var completedSimulationsTask = _simulations.Find(value => value.UserId == userId
&& value.State == ExamSimulationStates.Completed && value.Score != null)
.SortByDescending(value => value.CompletedUtc).ToListAsync();
var simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
var allReviewAttemptsTask = _reviewQueue.GetUserReviewsAsync(userId);
var reviewItemsTask = _reviewQueue.GetUserItemsAsync(userId);
var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart);
var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart);
var dynamicReviewsTask = _dynamicQuestions.DueReviewsAsync(userId, nowUtc);
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, ccsAllAttemptsTask, completedCcsCountTask,
simulationsTask, completedSimulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask,
flashcardReviewsTask, dynamicReviewsTask, allReviewAttemptsTask, reviewItemsTask);
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask,
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask);
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)
.Concat(reviewItemsTask.Result.Select(value => value.QuestionExternalId))
.Concat(allReviewAttemptsTask.Result.Select(value => value.QuestionExternalId)));
var questionMap = await _questions.GetByExternalIdsAsync(attempts.Select(value => value.QuestionExternalId));
var ccsAttempts = ccsAttemptsTask.Result;
var simulations = simulationsTask.Result;
var completedCcs = ccsAttempts.Where(value => value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue).ToList();
await _reviewQueue.EnsureUserQueueAsync(userId);
var pendingReviewCount = await _reviewQueue.CountPendingAsync(userId);
var qbankElo = ratingTask.Result.OverallElo;
var ccsElo = ccsRatingTask.Result.Elo;
var qbankHigherTask = _ratings.CountDocumentsAsync(value => value.OverallElo > qbankElo);
var qbankTotalTask = _ratings.CountDocumentsAsync(FilterDefinition<UserRating>.Empty);
var ccsHigherTask = _ccsRatings.CountDocumentsAsync(value => value.Elo > ccsElo);
var ccsTotalTask = _ccsRatings.CountDocumentsAsync(FilterDefinition<CcsUserRating>.Empty);
await Task.WhenAll(qbankHigherTask, qbankTotalTask, ccsHigherTask, ccsTotalTask);
var latestScore = SelectLatestReadinessScore(completedSimulationsTask.Result);
var accuracySampleSize = recentAnswered.Count;
var firstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count);
var performanceTrend = BuildPerformanceTrend(attempts, ratingTask.Result, ccsAllAttemptsTask.Result, ccsRatingTask.Result, today);
var weekAgoTrend = performanceTrend[^8];
var response = new DashboardResponse
{
QuestionBankRating = qbankElo,
RatedQuestionCount = ratingTask.Result.RatedQuestionCount,
CcsRating = ccsElo,
RatedCcsCaseCount = ccsRatingTask.Result.RatedCaseCount,
QuestionBankRating = ratingTask.Result?.OverallElo ?? 1000,
RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0,
CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
QuestionsCompleted = answeredAttempts.Count,
CcsCasesCompleted = checked((int)completedCcsCountTask.Result),
AccuracySampleSize = accuracySampleSize,
FirstAttemptAccuracy = firstAttemptAccuracy,
AccuracyDeltaVsPreviousSample = previousHundred.Count == 0
? null
: firstAttemptAccuracy - Percentage(previousHundred.Count(value => value.IsCorrect), previousHundred.Count),
QuestionBankRatingDeltaThisWeek = weekAgoTrend.QuestionBankRating is null ? null : qbankElo - weekAgoTrend.QuestionBankRating,
QuestionBankPercentile = Percentile(qbankHigherTask.Result, qbankTotalTask.Result),
CcsRatingDeltaThisWeek = weekAgoTrend.CcsRating is null ? null : ccsElo - weekAgoTrend.CcsRating,
CcsPercentile = Percentile(ccsHigherTask.Result, ccsTotalTask.Result),
ExamReadinessLabel = latestScore is null ? null : ReadinessLabel(latestScore.PassProbability),
ExamReadinessProjectedPassPercent = latestScore is null ? null : (int)Math.Round(latestScore.PassProbability * 100),
AccuracySampleSize = recentAnswered.Count,
FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count),
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
BookmarkCount = checked((int)bookmarkCountTask.Result),
NoteCount = CountNotes(notepadTask.Result?.Content),
MissedQuestionCount = checked((int)pendingReviewCount),
DynamicReviewsDue = dynamicReviewsTask.Result.Count,
FlashcardsDue = flashcardCountsTask.Result.Due,
FlashcardsNewAvailable = flashcardCountsTask.Result.NewAvailable,
FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek
MissedQuestionCount = checked((int)pendingReviewCount)
};
response.PerformanceTrend = performanceTrend;
response.Systems = MasteryCalculator.Calculate(
attempts, allReviewAttemptsTask.Result, reviewItemsTask.Result, questionMap, nowUtc);
response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset =>
{
var date = trendStart.AddDays(offset);
var daily = answeredAttempts.Where(value => value.CreatedUtc.Date == date).ToList();
return new DashboardTrendPoint
{
DateUtc = date,
Correct = daily.Count(value => value.IsCorrect),
Total = daily.Count,
Accuracy = Percentage(daily.Count(value => value.IsCorrect), daily.Count)
};
}).ToList();
response.Systems = answeredAttempts
.Where(value => questionMap.ContainsKey(value.QuestionExternalId))
.GroupBy(value => NormalizeSystem(questionMap[value.QuestionExternalId].System))
.Select(group => new DashboardSystemPerformance
{
System = group.Key,
Correct = group.Count(value => value.IsCorrect),
Total = group.Count(),
Accuracy = Percentage(group.Count(value => value.IsCorrect), group.Count())
})
.OrderByDescending(value => value.Total).ThenByDescending(value => value.Accuracy)
.ToList();
response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset =>
{
@@ -154,14 +115,12 @@ public sealed class DashboardStore
+ dailyCcs.Sum(CcsDurationSeconds)
+ dailySimulations.Sum(value => value.Responses.Sum(item => Math.Max(0, item.ElapsedSeconds)))
+ reviewAttemptsTask.Result.Where(value => value.CreatedUtc.Date == date).Sum(value => value.DurationSeconds)
+ flashcardReviewsTask.Result.Where(value => value.ReviewedUtc.Date == date).Sum(value => value.DurationSeconds)
};
}).ToList();
response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds);
var activeDates = attempts.Select(value => value.CreatedUtc.Date)
.Concat(completedCcs.Select(value => value.CompletedUtc!.Value.Date))
.Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date))
.Concat(flashcardReviewsTask.Result.Select(value => value.ReviewedUtc.Date));
.Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date));
response.CurrentStreak = CalculateCurrentStreak(activeDates, today);
response.RecentActivity = await BuildRecentActivityAsync(answeredAttempts, questionMap, completedCcs, simulations);
return response;
@@ -170,22 +129,6 @@ public sealed class DashboardStore
public static int Percentage(int correct, int total) =>
total <= 0 ? 0 : (int)Math.Round(correct * 100.0 / total, MidpointRounding.AwayFromZero);
public static int? Percentile(long usersRatedHigher, long totalUsers) =>
totalUsers <= 0 ? null : Math.Max(1, (int)Math.Round(usersRatedHigher * 100.0 / totalUsers, MidpointRounding.AwayFromZero));
// Thresholds are a reasonable default for labeling an existing, tested
// pass-probability estimate (ExamSimulationScoring.Calculate) - tune freely,
// this isn't a validated clinical cutoff.
public static string ReadinessLabel(double passProbability) =>
passProbability >= 0.7 ? "High" : passProbability >= 0.4 ? "Medium" : "Low";
// completedSimulationsByRecency must already be sorted newest-first (by CompletedUtc).
// Sample simulations (QA-only, seeded via the SUPER-role "preview results" tooling with
// scripted answers) never reflect real performance, so they're skipped here rather than
// in the Mongo filter - keeping the "which one counts" decision unit-testable.
public static ExamSimulationScore? SelectLatestReadinessScore(IEnumerable<ExamSimulation> completedSimulationsByRecency) =>
completedSimulationsByRecency.FirstOrDefault(value => !value.IsSample)?.Score;
public static int CalculateCurrentStreak(IEnumerable<DateTime> activeDates, DateTime todayUtc)
{
var dates = activeDates.Select(value => value.Date).ToHashSet();
@@ -200,103 +143,6 @@ public sealed class DashboardStore
return streak;
}
public static List<DashboardTrendPoint> BuildPerformanceTrend(
IEnumerable<QuestionAttempt> attempts,
UserRating rating,
IEnumerable<CcsAttempt> ccsAttempts,
CcsUserRating ccsRating,
DateTime todayUtc)
{
var attemptList = attempts.ToList();
var ccsAttemptList = ccsAttempts.ToList();
var trendStart = todayUtc.Date.AddDays(-29);
var ratingEvents = new List<(DateTime AtUtc, int Elo)>();
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
ratingEvents.Add((rating.HistoryStartedUtc.Value, rating.HistoryBaselineElo.Value));
ratingEvents.AddRange(attemptList
.Where(value => value.RatingAppliedUtc.HasValue && value.UserOverallEloAfter.HasValue)
.Select(value => (value.RatingAppliedUtc!.Value, value.UserOverallEloAfter!.Value)));
ratingEvents.Sort((left, right) => left.AtUtc.CompareTo(right.AtUtc));
var ccsRatingEvents = new List<(DateTime AtUtc, int Elo)>();
if (ccsRating.HistoryBaselineElo.HasValue && ccsRating.HistoryStartedUtc.HasValue)
ccsRatingEvents.Add((ccsRating.HistoryStartedUtc.Value, ccsRating.HistoryBaselineElo.Value));
ccsRatingEvents.AddRange(ccsAttemptList
.Where(value => value.Rating is { AppliedUtc: not null })
.Select(value => (value.Rating!.AppliedUtc!.Value, value.Rating!.UserElo)));
ccsRatingEvents.Sort((left, right) => left.AtUtc.CompareTo(right.AtUtc));
int? currentRating = null;
int? currentCcsRating = null;
var eventIndex = 0;
var ccsEventIndex = 0;
var result = new List<DashboardTrendPoint>();
foreach (var offset in Enumerable.Range(0, 30))
{
var date = trendStart.AddDays(offset);
var nextDate = date.AddDays(1);
while (eventIndex < ratingEvents.Count && ratingEvents[eventIndex].AtUtc < nextDate)
{
currentRating = ratingEvents[eventIndex].Elo;
eventIndex++;
}
while (ccsEventIndex < ccsRatingEvents.Count && ccsRatingEvents[ccsEventIndex].AtUtc < nextDate)
{
currentCcsRating = ccsRatingEvents[ccsEventIndex].Elo;
ccsEventIndex++;
}
var daily = attemptList
.Where(value => !value.IsSkip && value.CreatedUtc >= date && value.CreatedUtc < nextDate)
.ToList();
var correct = daily.Count(value => value.IsCorrect);
result.Add(new DashboardTrendPoint
{
DateUtc = date,
Correct = correct,
Total = daily.Count,
Accuracy = Percentage(correct, daily.Count),
QuestionBankRating = currentRating,
CcsRating = currentCcsRating
});
}
return result;
}
public static List<DashboardSystemPerformance> BuildSystemPerformance(
IEnumerable<QuestionAttempt> attempts,
IReadOnlyDictionary<string, Question> questionMap)
{
var totals = Step3Systems.ToDictionary(
system => system,
_ => (Correct: 0, Total: 0),
StringComparer.Ordinal);
foreach (var attempt in attempts.Where(value => !value.IsSkip))
{
if (!questionMap.TryGetValue(attempt.QuestionExternalId, out var question))
continue;
var system = NormalizeStep3System(question);
if (system is null)
continue;
var current = totals[system];
totals[system] = (current.Correct + (attempt.IsCorrect ? 1 : 0), current.Total + 1);
}
return Step3Systems.Select(system => new DashboardSystemPerformance
{
System = system,
Correct = totals[system].Correct,
Total = totals[system].Total,
Accuracy = Percentage(totals[system].Correct, totals[system].Total)
}).ToList();
}
public static string? NormalizeStep3System(Question question)
=> Step3SystemCatalog.Normalize(question);
public static string? NormalizeStep3System(string? value)
=> Step3SystemCatalog.Normalize(value);
private async Task<List<DashboardRecentActivity>> BuildRecentActivityAsync(
List<QuestionAttempt> attempts,
Dictionary<string, Question> questionMap,
@@ -1,54 +0,0 @@
using Microsoft.Extensions.Configuration;
using QuestionsApi.Models;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class DynamicBlueprintCommand
{
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", "dynamic_questions");
if (!Directory.Exists(source))
{
await output.WriteLineAsync($"Dynamic blueprint directory not found: {source}");
return 2;
}
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true };
var blueprints = new List<DynamicQuestionBlueprint>();
foreach (var file in Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories))
blueprints.AddRange(JsonSerializer.Deserialize<List<DynamicQuestionBlueprint>>(await File.ReadAllTextAsync(file), json) ?? []);
blueprints = DynamicBlueprintResolver.ResolveAll(blueprints);
var generator = new DynamicQuestionGenerator();
var generatedVariants = 0;
foreach (var blueprint in blueprints)
{
var error = DynamicQuestionGenerator.Validate(blueprint);
if (error is not null)
throw new InvalidDataException($"{blueprint.ExternalId} v{blueprint.Version}: {error}");
var validationSeeds = blueprint.QualityStandardVersion >= 2 ? 10_000 : 1_000;
for (var seed = 0; seed < validationSeeds; seed++)
generator.Generate(blueprint, seed.ToString(), seed.ToString());
generatedVariants += validationSeeds;
}
await output.WriteLineAsync($"Validated {blueprints.Count} blueprints across {generatedVariants:N0} generated variants.");
if (!apply)
{
await output.WriteLineAsync("Dry run only. Pass --apply to publish.");
return 0;
}
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
var store = new DynamicQuestionStore(new MongoDatabaseProvider(configuration));
var result = await store.UpsertBlueprintsAsync(blueprints);
await output.WriteLineAsync($"Promotion complete: {result.Inserted} inserted, {result.Unchanged} unchanged.");
return 0;
}
}
@@ -1,37 +0,0 @@
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public static class DynamicBlueprintCoveragePolicy
{
private static readonly Dictionary<string, double> SystemWeights =
new(StringComparer.OrdinalIgnoreCase)
{
["Nutrition"] = 17.5,
["Biostatistics"] = 12,
["Cardiovascular"] = 10,
["Neurologic"] = 9,
["Nervous System"] = 9,
["Pulmonary"] = 9,
["Respiratory"] = 9,
["Pregnancy"] = 8,
["Social Sciences"] = 8,
["Gastrointestinal"] = 7,
["Immune System"] = 7,
["Hematology"] = 7,
["Multisystem"] = 7,
["Infectious Disease"] = 7,
["Endocrine"] = 6,
["Musculoskeletal"] = 6,
["Behavioral Health"] = 5,
["Renal"] = 5,
["Skin"] = 5,
["Human Development"] = 2
};
public static double Weight(DynamicQuestionBlueprint blueprint)
{
if (blueprint.BlueprintWeight > 0) return blueprint.BlueprintWeight;
return SystemWeights.GetValueOrDefault(blueprint.System, 5);
}
}
@@ -1,72 +0,0 @@
using QuestionsApi.Models;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class DynamicBlueprintResolver
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public static List<DynamicQuestionBlueprint> ResolveAll(IEnumerable<DynamicQuestionBlueprint> source)
{
var all = source.ToList();
var resolved = new List<DynamicQuestionBlueprint>();
foreach (var blueprint in all.OrderBy(value => value.Version))
{
if (blueprint.BaseBlueprintVersion <= 0)
{
resolved.Add(blueprint);
continue;
}
var baseBlueprint = resolved.FirstOrDefault(value =>
value.ExternalId == blueprint.ExternalId
&& value.Version == blueprint.BaseBlueprintVersion)
?? throw new InvalidDataException(
$"{blueprint.ExternalId} v{blueprint.Version} base version {blueprint.BaseBlueprintVersion} not found");
var materialized = Clone(baseBlueprint);
materialized.Id = null;
materialized.Version = blueprint.Version;
materialized.BaseBlueprintVersion = blueprint.BaseBlueprintVersion;
materialized.QualityStandardVersion = blueprint.QualityStandardVersion;
materialized.IsPublished = blueprint.IsPublished;
materialized.EncounterFrame = blueprint.EncounterFrame;
materialized.SiteOfCare = blueprint.SiteOfCare;
materialized.BlueprintWeight = blueprint.BlueprintWeight;
materialized.ConceptIds = blueprint.ConceptIds;
materialized.PrerequisiteConceptIds = blueprint.PrerequisiteConceptIds;
materialized.MedicallyReviewedUtc = blueprint.MedicallyReviewedUtc;
materialized.MedicalReviewExpiresUtc = blueprint.MedicalReviewExpiresUtc;
materialized.MedicalReviewer = blueprint.MedicalReviewer;
if (blueprint.Distractors.Count > 0)
materialized.Distractors = blueprint.Distractors;
if (blueprint.CaseFamilies.Count > 0)
materialized.CaseFamilies = blueprint.CaseFamilies.Select(overlay =>
{
var baseFamily = baseBlueprint.CaseFamilies.FirstOrDefault(value => value.Id == overlay.BaseFamilyId)
?? throw new InvalidDataException(
$"{blueprint.ExternalId} v{blueprint.Version} base family {overlay.BaseFamilyId} not found");
var family = Clone(baseFamily);
family.Id = overlay.Id;
family.BaseFamilyId = overlay.BaseFamilyId;
family.DifficultyTier = overlay.DifficultyTier;
family.NominalElo = overlay.NominalElo;
if (!string.IsNullOrWhiteSpace(family.LeadIn)
&& family.StemTemplate.EndsWith(family.LeadIn, StringComparison.Ordinal))
family.StemTemplate = family.StemTemplate[..^family.LeadIn.Length].TrimEnd();
if (!string.IsNullOrWhiteSpace(overlay.StemTemplate))
family.StemTemplate = $"{family.StemTemplate} {overlay.StemTemplate}".Trim();
if (!string.IsNullOrWhiteSpace(overlay.LeadIn))
family.LeadIn = overlay.LeadIn;
if (overlay.Distractors.Count > 0)
family.Distractors = overlay.Distractors;
return family;
}).ToList();
resolved.Add(materialized);
}
return resolved;
}
private static T Clone<T>(T value) =>
JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(value, Json), Json)
?? throw new InvalidOperationException("Blueprint clone failed.");
}
@@ -1,69 +0,0 @@
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public static class DynamicDifficultyPolicy
{
public const int MinimumElo = 600;
public const int MaximumElo = 1800;
public const int TargetOffset = 70;
public static readonly IReadOnlyDictionary<string, int> TierElos =
new Dictionary<string, int>(StringComparer.Ordinal)
{
["foundation"] = 850,
["standard"] = 1050,
["advanced"] = 1250,
["expert"] = 1450
};
public static int EffectiveAbility(
int overallElo,
int categoryElo,
DynamicObjectiveMastery? objective)
{
var bankAbility = (int)Math.Round((overallElo + categoryElo) / 2.0);
if (objective is null || objective.Attempts <= 0)
return Clamp(bankAbility);
var objectiveWeight = Math.Min(0.8, objective.Attempts * 0.08);
return Clamp((int)Math.Round(bankAbility * (1.0 - objectiveWeight) + objective.Elo * objectiveWeight));
}
public static int TargetFamilyElo(int effectiveAbility) => Clamp(effectiveAbility - TargetOffset);
public static double ExpectedScore(int playerElo, int familyElo) =>
1.0 / (1.0 + Math.Pow(10, (familyElo - playerElo) / 400.0));
public static int LearnerDelta(int playerElo, int familyElo, bool correct, int attempts) =>
(int)Math.Round((attempts < 5 ? 40 : 24)
* ((correct ? 1.0 : 0.0) - ExpectedScore(playerElo, familyElo)));
public static int FamilyDelta(int playerElo, int familyElo, bool correct, int attempts) =>
(int)Math.Round((attempts < 20 ? 32 : 24)
* (ExpectedScore(playerElo, familyElo) - (correct ? 1.0 : 0.0)));
public static DynamicCaseFamily SelectFamily(
IReadOnlyList<DynamicCaseFamily> families,
IReadOnlyDictionary<string, DynamicFamilyRating> ratings,
int targetElo,
ISet<string> recentFamilyIds,
int tieBreaker)
{
if (families.Count == 0) throw new InvalidOperationException("No case families are available.");
var eligible = families.Where(value => !recentFamilyIds.Contains(value.Id)).ToList();
if (eligible.Count == 0) eligible = families.ToList();
var ranked = eligible
.Select(value => new
{
Family = value,
Elo = ratings.TryGetValue(value.Id, out var rating) ? rating.Elo : value.NominalElo
})
.OrderBy(value => Math.Abs(value.Elo - targetElo))
.ThenBy(value => value.Family.Id, StringComparer.Ordinal)
.ToList();
var bestDistance = Math.Abs(ranked[0].Elo - targetElo);
var tied = ranked.Where(value => Math.Abs(value.Elo - targetElo) == bestDistance).ToList();
return tied[Math.Abs(tieBreaker) % tied.Count].Family;
}
public static int Clamp(int elo) => Math.Clamp(elo, MinimumElo, MaximumElo);
}
@@ -1,451 +0,0 @@
using QuestionsApi.Models;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace QuestionsApi.Services;
public sealed class DynamicQuestionGenerator
{
public const int CurrentVersion = 2;
private static readonly Regex Placeholder = new(@"\{\{([a-zA-Z0-9_]+)\}\}", RegexOptions.Compiled);
public DynamicGeneratedQuestion Generate(
DynamicQuestionBlueprint blueprint,
string seed,
string nonce,
int generatorVersion = CurrentVersion,
string caseFamilyId = "")
=> blueprint.QualityStandardVersion >= 2
? GenerateV2(blueprint, seed, nonce, caseFamilyId)
: GenerateLegacy(blueprint, seed, nonce, generatorVersion);
private DynamicGeneratedQuestion GenerateLegacy(DynamicQuestionBlueprint blueprint, string seed, string nonce, int generatorVersion)
{
var draw = new DeterministicDraw($"{blueprint.ExternalId}|{blueprint.Version}|{generatorVersion}|{seed}");
var values = new Dictionary<string, string>(StringComparer.Ordinal);
var numbers = new Dictionary<string, double>(StringComparer.Ordinal);
foreach (var item in blueprint.Variables.OrderBy(value => value.Key, StringComparer.Ordinal))
{
var definition = item.Value;
if (definition.Type == "enum")
{
if (definition.Values.Count == 0) throw new InvalidOperationException($"{item.Key} requires values");
values[item.Key] = definition.Values[draw.Next(definition.Values.Count)];
continue;
}
var step = definition.Step <= 0 ? 1 : definition.Step;
var count = (int)Math.Floor((definition.Maximum - definition.Minimum) / step) + 1;
if (count < 1) throw new InvalidOperationException($"{item.Key} range is invalid");
var number = definition.Minimum + draw.Next(count) * step;
number = Math.Round(number, Math.Clamp(definition.Precision, 0, 8));
numbers[item.Key] = number;
values[item.Key] = Format(number, definition.Precision);
}
foreach (var item in blueprint.Derived)
{
var definition = item.Value;
var left = ResolveNumber(definition.Left, numbers);
var right = ResolveNumber(definition.Right, numbers);
var number = definition.Operation switch
{
"add" => left + right,
"subtract" => left - right,
"multiply" => left * right,
"divide" when Math.Abs(right) > double.Epsilon => left / right,
_ => throw new InvalidOperationException($"{item.Key} operation is invalid")
};
number = Math.Round(number, Math.Clamp(definition.Precision, 0, 8));
numbers[item.Key] = number;
values[item.Key] = Format(number, definition.Precision);
}
foreach (var constraint in blueprint.Constraints)
{
if (!numbers.TryGetValue(constraint.Field, out var actual))
throw new InvalidOperationException($"constraint field {constraint.Field} is not numeric");
var valid = constraint.Operator switch
{
"lt" => actual < constraint.Value,
"lte" => actual <= constraint.Value,
"gt" => actual > constraint.Value,
"gte" => actual >= constraint.Value,
"eq" => Math.Abs(actual - constraint.Value) < 0.0000001,
"neq" => Math.Abs(actual - constraint.Value) >= 0.0000001,
_ => false
};
if (!valid) throw new InvalidOperationException($"generated value violates {constraint.Field} {constraint.Operator}");
}
var distractors = blueprint.Distractors.OrderBy(_ => draw.Next(int.MaxValue)).Take(4).ToList();
if (distractors.Count != 4) throw new InvalidOperationException("at least four distractors are required");
var answerTemplates = distractors.Append(blueprint.CorrectAnswer)
.OrderBy(_ => draw.Next(int.MaxValue)).ToList();
var choices = answerTemplates.Select(answer => new QuestionChoice
{
Id = answer.Id,
Text = Render(answer.TextTemplate, values)
}).ToList();
if (choices.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != 5
|| choices.Select(value => value.Text).Distinct(StringComparer.OrdinalIgnoreCase).Count() != 5)
throw new InvalidOperationException("generated choices must be unique");
var question = new Question
{
Id = $"dynamic-{nonce}",
ExternalId = $"dynamic-{nonce}",
Stem = Render(blueprint.StemTemplate, values),
LeadIn = Render(blueprint.LeadIn, values),
Choices = choices,
CorrectChoiceId = blueprint.CorrectAnswer.Id,
Explanation = Render(blueprint.ExplanationTemplate, values),
KeyTakeaway = Render(blueprint.KeyTakeaway, values),
UsmlePearl = Render(blueprint.UsmlePearl, values),
ChoiceExplanations = answerTemplates.ToDictionary(
value => value.Id,
value => Render(value.ExplanationTemplate, values)),
Discipline = blueprint.Discipline,
System = blueprint.System,
PerformanceCategory = blueprint.PerformanceCategory,
Difficulty = blueprint.Difficulty,
Labs = blueprint.Labs.Select(lab => new QuestionLab
{
Name = lab.Name,
Value = Render(lab.ValueTemplate, values),
Unit = lab.Unit,
Reference = lab.Reference
}).ToList(),
Tags = [.. blueprint.Tags, "dynamic", $"objective:{blueprint.ObjectiveId}"],
References = blueprint.References,
IsPublished = true
};
return new DynamicGeneratedQuestion { Question = question, Blueprint = blueprint };
}
private DynamicGeneratedQuestion GenerateV2(DynamicQuestionBlueprint blueprint, string seed, string nonce, string caseFamilyId)
{
if (blueprint.CaseFamilies.Count == 0)
throw new InvalidOperationException("quality-standard-2 blueprints require case families");
var draw = new DeterministicDraw($"{blueprint.ExternalId}|{blueprint.Version}|{CurrentVersion}|{seed}");
var orderedFamilies = blueprint.CaseFamilies.OrderBy(value => value.Id, StringComparer.Ordinal).ToList();
var family = string.IsNullOrWhiteSpace(caseFamilyId)
? orderedFamilies[draw.Next(orderedFamilies.Count)]
: orderedFamilies.FirstOrDefault(value => value.Id == caseFamilyId)
?? throw new InvalidOperationException($"case family {caseFamilyId} is unavailable");
var values = new Dictionary<string, string>(StringComparer.Ordinal);
var numbers = new Dictionary<string, double>(StringComparer.Ordinal);
if (family.ValueBundles.Count > 0)
foreach (var item in family.ValueBundles[draw.Next(family.ValueBundles.Count)])
values[item.Key] = item.Value;
PopulateVariables(family.Variables, values, numbers, draw);
PopulateDerived(family.Derived, values, numbers);
ValidateConstraints(family.Constraints, numbers);
var correctAnswer = string.IsNullOrWhiteSpace(family.CorrectAnswer.Id) ? blueprint.CorrectAnswer : family.CorrectAnswer;
var distractorPool = family.Distractors.Count == 0 ? blueprint.Distractors : family.Distractors;
var reviewTemplates = family.ReviewFollowups.Count == 0 ? blueprint.ReviewFollowups : family.ReviewFollowups;
var wrongTemplates = family.WrongAnswerFollowups.Count == 0 ? blueprint.WrongAnswerFollowups : family.WrongAnswerFollowups;
var explanationTemplate = string.IsNullOrWhiteSpace(family.ExplanationTemplate) ? blueprint.ExplanationTemplate : family.ExplanationTemplate;
var keyTakeaway = string.IsNullOrWhiteSpace(family.KeyTakeaway) ? blueprint.KeyTakeaway : family.KeyTakeaway;
var usmlePearl = string.IsNullOrWhiteSpace(family.UsmlePearl) ? blueprint.UsmlePearl : family.UsmlePearl;
var distractors = distractorPool.OrderBy(_ => draw.Next(int.MaxValue)).Take(4).ToList();
if (distractors.Count != 4) throw new InvalidOperationException($"{family.Id} requires at least four distractors");
var answerTemplates = distractors.Append(correctAnswer).OrderBy(_ => draw.Next(int.MaxValue)).ToList();
var choices = answerTemplates.Select(answer => new QuestionChoice
{
Id = answer.Id,
Text = Render(answer.TextTemplate, values)
}).ToList();
if (choices.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != 5
|| choices.Select(value => value.Text).Distinct(StringComparer.OrdinalIgnoreCase).Count() != 5)
throw new InvalidOperationException($"{family.Id} generated choices must be unique");
var question = new Question
{
Id = $"dynamic-{nonce}",
ExternalId = $"dynamic-{nonce}",
Stem = Render(family.StemTemplate, values),
LeadIn = Render(family.LeadIn, values),
Choices = choices,
CorrectChoiceId = correctAnswer.Id,
Explanation = Render(explanationTemplate, values),
KeyTakeaway = Render(keyTakeaway, values),
UsmlePearl = Render(usmlePearl, values),
ChoiceExplanations = answerTemplates.ToDictionary(
value => value.Id, value => Render(value.ExplanationTemplate, values)),
ReviewFollowups = reviewTemplates.Select(followup => new QuestionReviewFollowup
{
Prompt = Render(followup.PromptTemplate, values),
Choices = followup.Choices.Select(choice => new QuestionChoice
{ Id = choice.Id, Text = Render(choice.TextTemplate, values) }).ToList(),
CorrectChoiceId = followup.CorrectChoiceId,
TeachingPoint = Render(followup.TeachingPointTemplate, values)
}).ToList(),
WrongAnswerFollowups = BuildWrongAnswerFollowups(
distractors, correctAnswer, wrongTemplates, values),
Discipline = blueprint.Discipline,
System = blueprint.System,
PerformanceCategory = blueprint.PerformanceCategory,
Difficulty = family.DifficultyTier switch
{
"foundation" => 1,
"standard" => 2,
"advanced" => 4,
"expert" => 5,
_ => blueprint.Difficulty
},
Labs = family.Labs.Select(lab => new QuestionLab
{
Name = lab.Name,
Value = Render(lab.ValueTemplate, values),
Unit = lab.Unit,
Reference = lab.Reference
}).ToList(),
Tags = [.. blueprint.Tags, "dynamic", "quality-standard:2", $"objective:{blueprint.ObjectiveId}", $"family:{family.Id}"],
References = blueprint.References,
IsPublished = true
};
return new DynamicGeneratedQuestion { Question = question, Blueprint = blueprint, CaseFamilyId = family.Id };
}
private static void PopulateVariables(
IReadOnlyDictionary<string, DynamicVariableDefinition> definitions,
IDictionary<string, string> values,
IDictionary<string, double> numbers,
DeterministicDraw draw)
{
foreach (var item in definitions.OrderBy(value => value.Key, StringComparer.Ordinal))
{
var definition = item.Value;
if (definition.Type == "enum")
{
if (definition.Values.Count == 0) throw new InvalidOperationException($"{item.Key} requires values");
values[item.Key] = definition.Values[draw.Next(definition.Values.Count)];
continue;
}
var step = definition.Step <= 0 ? 1 : definition.Step;
var count = (int)Math.Floor((definition.Maximum - definition.Minimum) / step) + 1;
if (count < 1) throw new InvalidOperationException($"{item.Key} range is invalid");
var number = Math.Round(definition.Minimum + draw.Next(count) * step, Math.Clamp(definition.Precision, 0, 8));
numbers[item.Key] = number;
values[item.Key] = Format(number, definition.Precision);
}
}
private static void PopulateDerived(
IReadOnlyDictionary<string, DynamicDerivedDefinition> definitions,
IDictionary<string, string> values,
Dictionary<string, double> numbers)
{
foreach (var item in definitions)
{
var definition = item.Value;
var left = ResolveNumber(definition.Left, numbers);
var right = ResolveNumber(definition.Right, numbers);
var number = definition.Operation switch
{
"add" => left + right,
"subtract" => left - right,
"multiply" => left * right,
"divide" when Math.Abs(right) > double.Epsilon => left / right,
_ => throw new InvalidOperationException($"{item.Key} operation is invalid")
};
number = Math.Round(number, Math.Clamp(definition.Precision, 0, 8));
numbers[item.Key] = number;
values[item.Key] = Format(number, definition.Precision);
}
}
private static void ValidateConstraints(IEnumerable<DynamicConstraint> constraints, IReadOnlyDictionary<string, double> numbers)
{
foreach (var constraint in constraints)
{
if (!numbers.TryGetValue(constraint.Field, out var actual))
throw new InvalidOperationException($"constraint field {constraint.Field} is not numeric");
var valid = constraint.Operator switch
{
"lt" => actual < constraint.Value,
"lte" => actual <= constraint.Value,
"gt" => actual > constraint.Value,
"gte" => actual >= constraint.Value,
"eq" => Math.Abs(actual - constraint.Value) < 0.0000001,
"neq" => Math.Abs(actual - constraint.Value) >= 0.0000001,
_ => false
};
if (!valid) throw new InvalidOperationException($"generated value violates {constraint.Field} {constraint.Operator}");
}
}
private static Dictionary<string, WrongAnswerFollowup> BuildWrongAnswerFollowups(
IEnumerable<DynamicAnswerTemplate> distractors,
DynamicAnswerTemplate correct,
IReadOnlyDictionary<string, DynamicWrongAnswerFollowupTemplate> templates,
IReadOnlyDictionary<string, string> values)
{
var result = new Dictionary<string, WrongAnswerFollowup>();
var distractorList = distractors.ToList();
foreach (var distractor in distractorList)
{
if (templates.TryGetValue(distractor.Id, out var template))
{
result[distractor.Id] = new WrongAnswerFollowup
{
Prompt = Render(template.PromptTemplate, values),
Choices = template.Choices.Select(choice => new QuestionChoice
{ Id = choice.Id, Text = Render(choice.TextTemplate, values) }).ToList(),
CorrectChoiceId = template.CorrectChoiceId,
TeachingPoint = Render(template.TeachingPointTemplate, values)
};
continue;
}
var alternatives = distractorList.Where(value => value.Id != distractor.Id).Take(2).ToArray();
result[distractor.Id] = new WrongAnswerFollowup
{
Prompt = $"Which limitation of option {distractor.Id} should have ruled it out in this case?",
Choices =
[
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 = "A",
TeachingPoint = Render(distractor.ExplanationTemplate, values)
};
}
return result;
}
public static string? Validate(DynamicQuestionBlueprint blueprint)
{
if (string.IsNullOrWhiteSpace(blueprint.ExternalId)) return "externalId required";
if (string.IsNullOrWhiteSpace(blueprint.ObjectiveId)) return "objectiveId required";
if (blueprint.Version < 1) return "version must be positive";
if (blueprint.BlueprintWeight < 0) return "blueprintWeight cannot be negative";
if (blueprint.IsPublished
&& blueprint.MedicalReviewExpiresUtc is { } expiresUtc
&& expiresUtc <= DateTime.UtcNow)
return "medical review has expired";
if (blueprint.QualityStandardVersion >= 2)
return ValidateV2(blueprint);
if (string.IsNullOrWhiteSpace(blueprint.StemTemplate) || string.IsNullOrWhiteSpace(blueprint.LeadIn)) return "stemTemplate and leadIn required";
if (string.IsNullOrWhiteSpace(blueprint.CorrectAnswer.Id) || string.IsNullOrWhiteSpace(blueprint.CorrectAnswer.TextTemplate)) return "correctAnswer required";
if (blueprint.Distractors.Count < 4) return "at least four distractors required";
var ids = blueprint.Distractors.Select(value => value.Id).Append(blueprint.CorrectAnswer.Id).ToList();
if (ids.Any(string.IsNullOrWhiteSpace) || ids.Distinct(StringComparer.Ordinal).Count() != ids.Count) return "answer ids must be unique";
if (blueprint.References.Count == 0) return "at least one reference required";
try
{
var generator = new DynamicQuestionGenerator();
for (var index = 0; index < 100; index++)
generator.Generate(blueprint, $"validation-{index}", $"validation-{index}");
}
catch (Exception exception) { return exception.Message; }
return null;
}
private static string? ValidateV2(DynamicQuestionBlueprint blueprint)
{
if (string.IsNullOrWhiteSpace(blueprint.CorrectConceptId)) return "correctConceptId required";
if (blueprint.CaseFamilies.Count < 3) return "at least three case families required";
if (blueprint.References.Count == 0) return "at least one reference required";
if (blueprint.CaseFamilies.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != blueprint.CaseFamilies.Count)
return "case family ids must be unique";
if (blueprint.QualityStandardVersion >= 3)
{
foreach (var tier in DynamicDifficultyPolicy.TierElos)
{
var tierFamilies = blueprint.CaseFamilies
.Where(value => value.DifficultyTier == tier.Key).ToList();
if (tierFamilies.Count < 2) return $"at least two {tier.Key} case families required";
if (tierFamilies.Any(value => Math.Abs(value.NominalElo - tier.Value) > 100))
return $"{tier.Key} nominal Elo must be within 100 points of {tier.Value}";
}
if (blueprint.CaseFamilies.Any(value => !DynamicDifficultyPolicy.TierElos.ContainsKey(value.DifficultyTier)))
return "difficultyTier must be foundation, standard, advanced, or expert";
}
if (blueprint.QualityStandardVersion >= 4)
{
if (blueprint.BlueprintWeight <= 0) return "quality standard 4 requires blueprintWeight";
if (string.IsNullOrWhiteSpace(blueprint.System)
|| string.IsNullOrWhiteSpace(blueprint.PhysicianTask)
|| string.IsNullOrWhiteSpace(blueprint.EncounterFrame)
|| string.IsNullOrWhiteSpace(blueprint.SiteOfCare))
return "quality standard 4 requires system, physicianTask, encounterFrame, and siteOfCare";
if (blueprint.ConceptIds.Count == 0) return "quality standard 4 requires conceptIds";
if (blueprint.MedicallyReviewedUtc is null
|| blueprint.MedicalReviewExpiresUtc is null
|| string.IsNullOrWhiteSpace(blueprint.MedicalReviewer))
return "quality standard 4 requires medical review metadata";
if (blueprint.Distractors.Any(value => string.IsNullOrWhiteSpace(value.MisconceptionId)))
return "quality standard 4 requires a misconceptionId for every distractor";
}
foreach (var family in blueprint.CaseFamilies)
{
if (string.IsNullOrWhiteSpace(family.Id)) return "case family id required";
if (family.StemTemplate.Length < 220) return $"{family.Id} stem is too short for quality standard 2";
if (string.IsNullOrWhiteSpace(family.LeadIn)) return $"{family.Id} leadIn required";
if (!string.IsNullOrWhiteSpace(family.CorrectAnswer.Id))
return $"{family.Id} must use the blueprint-level invariant correct answer";
var correctAnswer = string.IsNullOrWhiteSpace(family.CorrectAnswer.Id) ? blueprint.CorrectAnswer : family.CorrectAnswer;
var distractors = family.Distractors.Count == 0 ? blueprint.Distractors : family.Distractors;
var reviews = family.ReviewFollowups.Count == 0 ? blueprint.ReviewFollowups : family.ReviewFollowups;
var wrong = family.WrongAnswerFollowups.Count == 0 ? blueprint.WrongAnswerFollowups : family.WrongAnswerFollowups;
if (distractors.Count < 6) return $"{family.Id} requires at least six distractors";
if (blueprint.QualityStandardVersion >= 4
&& distractors.Any(value => string.IsNullOrWhiteSpace(value.MisconceptionId)))
return $"{family.Id} every distractor requires a misconceptionId";
var answers = distractors.Append(correctAnswer).ToList();
if (answers.Any(value => string.IsNullOrWhiteSpace(value.Id)
|| string.IsNullOrWhiteSpace(value.TextTemplate)
|| string.IsNullOrWhiteSpace(value.ExplanationTemplate)))
return $"{family.Id} every answer requires id, text, and explanation";
if (answers.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != answers.Count)
return $"{family.Id} answer ids must be unique";
if (reviews.Count == 0) return $"{family.Id} review followup required";
if (string.IsNullOrWhiteSpace(family.ExplanationTemplate) && string.IsNullOrWhiteSpace(blueprint.ExplanationTemplate)
|| string.IsNullOrWhiteSpace(family.KeyTakeaway) && string.IsNullOrWhiteSpace(blueprint.KeyTakeaway)
|| string.IsNullOrWhiteSpace(family.UsmlePearl) && string.IsNullOrWhiteSpace(blueprint.UsmlePearl))
return $"{family.Id} complete teaching content required";
}
try
{
var generator = new DynamicQuestionGenerator();
for (var index = 0; index < 300; index++)
{
var generated = generator.Generate(blueprint, $"validation-{index}", $"validation-{index}");
if (generated.Question.ChoiceExplanations.Count != 5) return "every displayed choice requires an explanation";
}
}
catch (Exception exception) { return exception.Message; }
return null;
}
private static string Render(string template, IReadOnlyDictionary<string, string> values)
{
var rendered = Placeholder.Replace(template ?? "", match =>
values.TryGetValue(match.Groups[1].Value, out var value) ? value : match.Value);
if (Placeholder.IsMatch(rendered)) throw new InvalidOperationException($"unresolved template in {rendered}");
return rendered;
}
private static double ResolveNumber(string operand, IReadOnlyDictionary<string, double> numbers) =>
numbers.TryGetValue(operand, out var value)
? value
: double.TryParse(operand, NumberStyles.Float, CultureInfo.InvariantCulture, out value)
? value
: throw new InvalidOperationException($"unknown numeric operand {operand}");
private static string Format(double value, int precision) =>
value.ToString(precision > 0 ? $"F{precision}" : "0", CultureInfo.InvariantCulture);
private sealed class DeterministicDraw(string source)
{
private int _counter;
public int Next(int maximum)
{
if (maximum <= 0) throw new ArgumentOutOfRangeException(nameof(maximum));
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{source}|{_counter++}"));
return (int)(BitConverter.ToUInt32(bytes, 0) % (uint)maximum);
}
}
}
@@ -1,192 +0,0 @@
using QuestionsApi.Models;
using System.Security.Cryptography;
namespace QuestionsApi.Services;
public sealed class DynamicQuestionService
{
private readonly DynamicQuestionStore _store;
private readonly DynamicQuestionGenerator _generator;
private readonly DynamicQuestionTokenService _tokens;
private readonly RatingStore _ratings;
private readonly ServiceMetrics _metrics;
public DynamicQuestionService(
DynamicQuestionStore store,
DynamicQuestionGenerator generator,
DynamicQuestionTokenService tokens,
RatingStore ratings,
ServiceMetrics metrics)
{
_store = store;
_generator = generator;
_tokens = tokens;
_ratings = ratings;
_metrics = metrics;
}
public Task<DynamicQuestionStatusResponse> StatusAsync(string userId) => _store.StatusAsync(userId);
public async Task<DynamicQuestionEnvelope> NextAsync(string userId, string runToken)
{
var blueprints = await _store.PublishedAsync();
if (blueprints.Count == 0)
throw new InvalidOperationException("No dynamic question blueprints are published.");
var run = string.IsNullOrWhiteSpace(runToken)
? NewRun(userId)
: _tokens.ReadRun(runToken, userId);
var byObjective = blueprints.GroupBy(value => value.ObjectiveId).ToList();
var objectiveIds = byObjective.Select(value => value.Key).ToHashSet(StringComparer.Ordinal);
run.SeenObjectiveIds = run.SeenObjectiveIds.Where(objectiveIds.Contains).Distinct(StringComparer.Ordinal).ToList();
var unseen = byObjective.Where(group => !run.SeenObjectiveIds.Contains(group.Key, StringComparer.Ordinal)).ToList();
if (unseen.Count == 0)
{
run.Cycle++;
run.SeenObjectiveIds.Clear();
unseen = byObjective;
}
var mastery = await _store.MasteryAsync(userId);
var dueReviews = await _store.DueReviewsAsync(userId, DateTime.UtcNow);
var dueByObjective = dueReviews.ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal);
var candidates = unseen
.Where(group => group.Key != run.PreviousObjectiveId || unseen.Count == 1)
.ToList();
var roll = RandomNumberGenerator.GetInt32(100);
IGrouping<string, DynamicQuestionBlueprint> selectedObjective;
var dueCandidates = candidates.Where(value => dueByObjective.ContainsKey(value.Key)).ToList();
if (roll < 50 && dueCandidates.Count == 0 && dueByObjective.Count > 0)
dueCandidates = byObjective
.Where(value => dueByObjective.ContainsKey(value.Key)
&& (value.Key != run.PreviousObjectiveId || dueByObjective.Count == 1))
.ToList();
if (roll < 50 && dueCandidates.Count > 0)
{
selectedObjective = dueCandidates
.OrderBy(value => dueByObjective[value.Key].DueUtc)
.ThenBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue))
.First();
}
else if (roll < 80)
{
var systemAttempts = byObjective
.GroupBy(group => group.OrderByDescending(value => value.Version).First().System)
.ToDictionary(
group => group.Key,
group => group.Sum(objective => mastery.TryGetValue(objective.Key, out var value) ? value.Attempts : 0),
StringComparer.Ordinal);
selectedObjective = candidates
.OrderBy(group =>
{
var latest = group.OrderByDescending(value => value.Version).First();
var weight = DynamicBlueprintCoveragePolicy.Weight(latest);
return (systemAttempts.GetValueOrDefault(latest.System) + 1) / weight;
})
.ThenBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue))
.First();
}
else
{
var weakestBand = candidates
.OrderBy(group => mastery.TryGetValue(group.Key, out var value) ? value.Elo : 1000)
.Take(Math.Min(4, candidates.Count))
.ToList();
selectedObjective = weakestBand[RandomNumberGenerator.GetInt32(weakestBand.Count)];
}
var blueprint = selectedObjective
.OrderByDescending(value => value.Version)
.First();
var ratingProfile = await _ratings.GetProfileAsync(userId);
mastery.TryGetValue(blueprint.ObjectiveId, out var objectiveMastery);
var category = RatingCategory.Normalize(
blueprint.PerformanceCategory, blueprint.System, blueprint.Discipline);
var categoryElo = ratingProfile.CategoryElos.GetValueOrDefault(category, ratingProfile.OverallElo);
var effectiveAbility = DynamicDifficultyPolicy.EffectiveAbility(
ratingProfile.OverallElo, categoryElo, objectiveMastery);
var targetElo = DynamicDifficultyPolicy.TargetFamilyElo(effectiveAbility);
var familyRatings = await _store.FamilyRatingsAsync(blueprint);
var blueprintFamilyIds = blueprint.CaseFamilies.Select(value => value.Id).ToHashSet(StringComparer.Ordinal);
var recentForObjective = run.RecentCaseFamilyIds
.Where(blueprintFamilyIds.Contains)
.TakeLast(Math.Max(1, blueprint.CaseFamilies.Count / 4))
.ToHashSet(StringComparer.Ordinal);
var selectedFamily = DynamicDifficultyPolicy.SelectFamily(
blueprint.CaseFamilies,
familyRatings,
targetElo,
recentForObjective,
RandomNumberGenerator.GetInt32(int.MaxValue));
var selectedFamilyElo = familyRatings.TryGetValue(selectedFamily.Id, out var selectedRating)
? selectedRating.Elo
: selectedFamily.NominalElo;
_metrics.DynamicSelected(selectedFamily.DifficultyTier, selectedFamilyElo - targetElo);
var claims = new DynamicGenerationClaims
{
UserId = userId,
BlueprintExternalId = blueprint.ExternalId,
BlueprintVersion = blueprint.Version,
ObjectiveId = blueprint.ObjectiveId,
Seed = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
Nonce = Guid.NewGuid().ToString("N"),
GeneratorVersion = DynamicQuestionGenerator.CurrentVersion,
CaseFamilyId = selectedFamily.Id,
DifficultyTier = selectedFamily.DifficultyTier,
EffectiveAbilityElo = effectiveAbility,
FamilyElo = selectedFamilyElo,
IsScheduledReview = dueByObjective.TryGetValue(blueprint.ObjectiveId, out var reviewItem),
ReviewItemId = reviewItem?.Id ?? "",
ReviewStage = reviewItem?.Stage ?? 0,
ExpiresUtc = DateTime.UtcNow.AddDays(7)
};
var generated = _generator.Generate(
blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion, claims.CaseFamilyId);
if (!run.SeenObjectiveIds.Contains(blueprint.ObjectiveId, StringComparer.Ordinal))
run.SeenObjectiveIds.Add(blueprint.ObjectiveId);
run.PreviousObjectiveId = blueprint.ObjectiveId;
run.RecentCaseFamilyIds.Add(generated.CaseFamilyId);
run.RecentCaseFamilyIds = run.RecentCaseFamilyIds.TakeLast(30).ToList();
run.ExpiresUtc = DateTime.UtcNow.AddDays(7);
return new DynamicQuestionEnvelope
{
Question = QuestionResponse.FromModel(generated.Question, questionElo: selectedFamilyElo),
ObjectiveId = blueprint.ObjectiveId,
ObjectiveTitle = blueprint.Title,
GenerationToken = _tokens.SignGeneration(claims),
RunToken = _tokens.SignRun(run),
CycleSeen = run.SeenObjectiveIds.Count,
TotalObjectives = byObjective.Count
};
}
public async Task<DynamicAttemptResponse> AttemptAsync(string userId, DynamicAttemptRequest request)
{
var (claims, generated) = await RegenerateAsync(userId, request.GenerationToken);
return await _store.RecordAsync(claims, generated, request);
}
public async Task<(DynamicGenerationClaims Claims, DynamicGeneratedQuestion Generated)> RegenerateAsync(
string userId,
string generationToken)
{
var claims = _tokens.ReadGeneration(generationToken, userId);
if (claims.GeneratorVersion is < 1 or > DynamicQuestionGenerator.CurrentVersion)
throw new InvalidOperationException("This generated question version is no longer supported.");
var blueprint = await _store.GetAsync(claims.BlueprintExternalId, claims.BlueprintVersion)
?? throw new KeyNotFoundException("The question blueprint is no longer available.");
var generated = _generator.Generate(
blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion, claims.CaseFamilyId);
if (!string.IsNullOrWhiteSpace(claims.CaseFamilyId)
&& claims.CaseFamilyId != generated.CaseFamilyId)
throw new InvalidOperationException("Generated case family does not match its signed descriptor.");
return (claims, generated);
}
private static DynamicRunClaims NewRun(string userId) => new()
{
UserId = userId,
ExpiresUtc = DateTime.UtcNow.AddDays(7)
};
}
@@ -1,364 +0,0 @@
using MongoDB.Driver;
using MongoDB.Bson;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class DynamicQuestionStore
{
private readonly IMongoCollection<DynamicQuestionBlueprint> _blueprints;
private readonly IMongoCollection<DynamicQuestionAttempt> _attempts;
private readonly IMongoCollection<DynamicObjectiveMastery> _mastery;
private readonly IMongoCollection<DynamicFamilyRating> _familyRatings;
private readonly IMongoCollection<DynamicReviewItem> _reviews;
private readonly ServiceMetrics? _metrics;
public DynamicQuestionStore(MongoDatabaseProvider mongo, ServiceMetrics? metrics = null)
{
_metrics = metrics;
_blueprints = mongo.Database.GetCollection<DynamicQuestionBlueprint>("DynamicQuestionBlueprints");
_attempts = mongo.Database.GetCollection<DynamicQuestionAttempt>("DynamicQuestionAttempts");
_mastery = mongo.Database.GetCollection<DynamicObjectiveMastery>("DynamicObjectiveMastery");
_familyRatings = mongo.Database.GetCollection<DynamicFamilyRating>("DynamicFamilyRatings");
_reviews = mongo.Database.GetCollection<DynamicReviewItem>("DynamicReviewItems");
_blueprints.Indexes.CreateOne(new CreateIndexModel<DynamicQuestionBlueprint>(
Builders<DynamicQuestionBlueprint>.IndexKeys.Ascending(value => value.ExternalId).Ascending(value => value.Version),
new CreateIndexOptions { Unique = true }));
_attempts.Indexes.CreateOne(new CreateIndexModel<DynamicQuestionAttempt>(
Builders<DynamicQuestionAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.Nonce),
new CreateIndexOptions { Unique = true }));
_mastery.Indexes.CreateOne(new CreateIndexModel<DynamicObjectiveMastery>(
Builders<DynamicObjectiveMastery>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ObjectiveId),
new CreateIndexOptions { Unique = true }));
_familyRatings.Indexes.CreateOne(new CreateIndexModel<DynamicFamilyRating>(
Builders<DynamicFamilyRating>.IndexKeys
.Ascending(value => value.BlueprintExternalId)
.Ascending(value => value.BlueprintVersion)
.Ascending(value => value.CaseFamilyId),
new CreateIndexOptions { Unique = true }));
_reviews.Indexes.CreateMany([
new CreateIndexModel<DynamicReviewItem>(
Builders<DynamicReviewItem>.IndexKeys
.Ascending(value => value.UserId)
.Ascending(value => value.ObjectiveId),
new CreateIndexOptions { Unique = true, Name = "dynamic_review_user_objective" }),
new CreateIndexModel<DynamicReviewItem>(
Builders<DynamicReviewItem>.IndexKeys
.Ascending(value => value.UserId)
.Ascending(value => value.State)
.Ascending(value => value.DueUtc),
new CreateIndexOptions { Name = "dynamic_review_user_due" })
]);
}
public async Task<List<DynamicQuestionBlueprint>> PublishedAsync() =>
await _blueprints.Find(value => value.IsPublished && value.QualityStandardVersion >= 2).ToListAsync();
public async Task<DynamicQuestionBlueprint?> GetAsync(string externalId, int version) =>
await _blueprints.Find(value => value.ExternalId == externalId && value.Version == version).FirstOrDefaultAsync();
public async Task<Dictionary<string, DynamicObjectiveMastery>> MasteryAsync(string userId) =>
(await _mastery.Find(value => value.UserId == userId).ToListAsync())
.ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal);
public async Task<List<DynamicReviewItem>> DueReviewsAsync(string userId, DateTime nowUtc) =>
await _reviews.Find(value => value.UserId == userId
&& value.State == DynamicReviewStates.Pending
&& value.DueUtc <= nowUtc).ToListAsync();
public async Task<Dictionary<string, DynamicFamilyRating>> FamilyRatingsAsync(DynamicQuestionBlueprint blueprint)
{
var ratings = await _familyRatings.Find(value =>
value.BlueprintExternalId == blueprint.ExternalId
&& value.BlueprintVersion == blueprint.Version).ToListAsync();
return ratings.ToDictionary(value => value.CaseFamilyId, StringComparer.Ordinal);
}
public async Task<DynamicAttemptResponse> RecordAsync(
DynamicGenerationClaims claims,
DynamicGeneratedQuestion generated,
DynamicAttemptRequest request)
{
var isCorrect = !request.IsSkip && request.ChoiceId == generated.Question.CorrectChoiceId;
var selectedAnswer = generated.Blueprint.Distractors
.Concat(generated.Blueprint.CaseFamilies
.Where(value => value.Id == claims.CaseFamilyId)
.SelectMany(value => value.Distractors))
.FirstOrDefault(value => value.Id == request.ChoiceId);
var misconceptionId = isCorrect || request.IsSkip
? ""
: selectedAnswer?.MisconceptionId ?? "";
var attempt = new DynamicQuestionAttempt
{
UserId = claims.UserId,
Nonce = claims.Nonce,
BlueprintExternalId = claims.BlueprintExternalId,
BlueprintVersion = claims.BlueprintVersion,
ObjectiveId = claims.ObjectiveId,
CaseFamilyId = claims.CaseFamilyId,
DifficultyTier = claims.DifficultyTier,
EffectiveAbilityElo = claims.EffectiveAbilityElo,
FamilyElo = claims.FamilyElo,
ChoiceId = request.ChoiceId,
IsCorrect = isCorrect,
IsSkip = request.IsSkip,
SelectedMisconceptionId = misconceptionId,
IsScheduledReview = claims.IsScheduledReview,
DurationSeconds = Math.Clamp(request.DurationSeconds, 0, 86_400),
CreatedUtc = DateTime.UtcNow
};
try { await _attempts.InsertOneAsync(attempt); }
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
var existingMastery = await _mastery.Find(value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstOrDefaultAsync();
return new DynamicAttemptResponse
{
Inserted = false,
IsCorrect = isCorrect,
CorrectChoiceId = generated.Question.CorrectChoiceId,
ObjectiveId = claims.ObjectiveId,
ObjectiveElo = existingMastery?.Elo ?? 1000
};
}
_metrics?.DynamicAttempt(claims.DifficultyTier, isCorrect);
await EnsureMasteryAsync(claims.UserId, claims.ObjectiveId);
var current = await _mastery.Find(value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstAsync()
?? throw new InvalidOperationException("Dynamic objective mastery could not be initialized.");
var oldElo = current.Elo;
var familyElo = claims.FamilyElo > 0
? claims.FamilyElo
: 800 + Math.Clamp(generated.Blueprint.Difficulty, 1, 5) * 100;
var delta = DynamicDifficultyPolicy.LearnerDelta(oldElo, familyElo, isCorrect, current.Attempts);
var now = DateTime.UtcNow;
var update = Builders<DynamicObjectiveMastery>.Update
.Inc(value => value.Elo, delta)
.Inc(value => value.Attempts, 1)
.Inc(value => value.Correct, isCorrect ? 1 : 0)
.Set(value => value.LastSeenUtc, now);
var updated = await _mastery.FindOneAndUpdateAsync(
value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId,
update,
new FindOneAndUpdateOptions<DynamicObjectiveMastery> { ReturnDocument = ReturnDocument.After });
await _mastery.UpdateOneAsync(
value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId,
Builders<DynamicObjectiveMastery>.Update
.Max(value => value.Elo, DynamicDifficultyPolicy.MinimumElo));
await _mastery.UpdateOneAsync(
value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId,
Builders<DynamicObjectiveMastery>.Update
.Min(value => value.Elo, DynamicDifficultyPolicy.MaximumElo));
if (!string.IsNullOrWhiteSpace(claims.CaseFamilyId))
await UpdateFamilyRatingAsync(claims, generated, isCorrect);
DateTime? nextReviewUtc = null;
if (!request.IsSkip)
nextReviewUtc = await UpdateReviewAsync(claims, misconceptionId, isCorrect);
return new DynamicAttemptResponse
{
Inserted = true,
IsCorrect = isCorrect,
CorrectChoiceId = generated.Question.CorrectChoiceId,
ObjectiveId = claims.ObjectiveId,
ObjectiveElo = DynamicDifficultyPolicy.Clamp(updated.Elo),
RatingChange = delta,
SelectedMisconceptionId = misconceptionId,
IsScheduledReview = claims.IsScheduledReview,
NextReviewUtc = nextReviewUtc
};
}
private async Task<DateTime?> UpdateReviewAsync(
DynamicGenerationClaims claims,
string misconceptionId,
bool isCorrect)
{
var now = DateTime.UtcNow;
var existing = await _reviews.Find(value =>
value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstOrDefaultAsync();
if (!claims.IsScheduledReview)
{
if (isCorrect) return existing?.DueUtc;
var due = DynamicReviewPolicy.FirstDue(now);
var update = Builders<DynamicReviewItem>.Update
.SetOnInsert(value => value.UserId, claims.UserId)
.SetOnInsert(value => value.ObjectiveId, claims.ObjectiveId)
.SetOnInsert(value => value.CreatedUtc, now)
.Set(value => value.BlueprintExternalId, claims.BlueprintExternalId)
.Set(value => value.BlueprintVersion, claims.BlueprintVersion)
.Set(value => value.MisconceptionId, misconceptionId)
.Set(value => value.State, DynamicReviewStates.Pending)
.Set(value => value.Stage, 0)
.Set(value => value.DueUtc, due)
.Set(value => value.UpdatedUtc, now);
await _reviews.UpdateOneAsync(
value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId,
update,
new UpdateOptions { IsUpsert = true });
return due;
}
if (existing is null || existing.Id != claims.ReviewItemId || existing.Stage != claims.ReviewStage)
return null;
var next = DynamicReviewPolicy.AfterReview(existing.Stage, isCorrect, now);
var reviewUpdate = Builders<DynamicReviewItem>.Update
.Set(value => value.Stage, next.Stage)
.Set(value => value.State, next.Mastered ? DynamicReviewStates.Mastered : DynamicReviewStates.Pending)
.Set(value => value.MisconceptionId, isCorrect ? existing.MisconceptionId : misconceptionId)
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.ReviewCount, 1);
if (next.DueUtc.HasValue)
reviewUpdate = reviewUpdate.Set(value => value.DueUtc, next.DueUtc.Value);
await _reviews.UpdateOneAsync(
value => value.Id == existing.Id && value.Stage == claims.ReviewStage,
reviewUpdate);
return next.DueUtc;
}
private async Task EnsureMasteryAsync(string userId, string objectiveId)
{
try
{
await _mastery.UpdateOneAsync(
value => value.UserId == userId && value.ObjectiveId == objectiveId,
Builders<DynamicObjectiveMastery>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.ObjectiveId, objectiveId)
.SetOnInsert(value => value.Elo, 1000)
.SetOnInsert(value => value.Attempts, 0)
.SetOnInsert(value => value.Correct, 0),
new UpdateOptions { IsUpsert = true });
}
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { }
}
private async Task UpdateFamilyRatingAsync(
DynamicGenerationClaims claims,
DynamicGeneratedQuestion generated,
bool isCorrect)
{
var family = generated.Blueprint.CaseFamilies.First(value => value.Id == claims.CaseFamilyId);
try
{
await _familyRatings.UpdateOneAsync(
value => value.BlueprintExternalId == claims.BlueprintExternalId
&& value.BlueprintVersion == claims.BlueprintVersion
&& value.CaseFamilyId == claims.CaseFamilyId,
Builders<DynamicFamilyRating>.Update
.SetOnInsert(value => value.BlueprintExternalId, claims.BlueprintExternalId)
.SetOnInsert(value => value.BlueprintVersion, claims.BlueprintVersion)
.SetOnInsert(value => value.CaseFamilyId, claims.CaseFamilyId)
.SetOnInsert(value => value.DifficultyTier, family.DifficultyTier)
.SetOnInsert(value => value.NominalElo, family.NominalElo)
.SetOnInsert(value => value.Elo, family.NominalElo)
.SetOnInsert(value => value.Attempts, 0)
.SetOnInsert(value => value.Correct, 0),
new UpdateOptions { IsUpsert = true });
}
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { }
var current = await _familyRatings.Find(value =>
value.BlueprintExternalId == claims.BlueprintExternalId
&& value.BlueprintVersion == claims.BlueprintVersion
&& value.CaseFamilyId == claims.CaseFamilyId).FirstAsync();
var delta = DynamicDifficultyPolicy.FamilyDelta(
claims.EffectiveAbilityElo > 0 ? claims.EffectiveAbilityElo : 1000,
claims.FamilyElo > 0 ? claims.FamilyElo : current.Elo,
isCorrect,
current.Attempts);
await _familyRatings.UpdateOneAsync(
value => value.Id == current.Id,
Builders<DynamicFamilyRating>.Update
.Inc(value => value.Elo, delta)
.Inc(value => value.Attempts, 1)
.Inc(value => value.Correct, isCorrect ? 1 : 0)
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
await _familyRatings.UpdateOneAsync(
value => value.Id == current.Id,
Builders<DynamicFamilyRating>.Update
.Max(value => value.Elo, DynamicDifficultyPolicy.MinimumElo));
await _familyRatings.UpdateOneAsync(
value => value.Id == current.Id,
Builders<DynamicFamilyRating>.Update
.Min(value => value.Elo, DynamicDifficultyPolicy.MaximumElo));
}
public async Task<DynamicQuestionStatusResponse> StatusAsync(string userId)
{
var blueprints = (await PublishedAsync())
.GroupBy(value => value.ObjectiveId)
.Select(value => value.OrderByDescending(item => item.Version).First())
.ToList();
var objectiveCount = blueprints.Count;
var mastery = await _mastery.Find(value => value.UserId == userId).ToListAsync();
var masteryByObjective = mastery.ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal);
var due = await _reviews.CountDocumentsAsync(value => value.UserId == userId
&& value.State == DynamicReviewStates.Pending
&& value.DueUtc <= DateTime.UtcNow);
return new DynamicQuestionStatusResponse
{
Available = objectiveCount > 0,
PublishedObjectives = objectiveCount,
AttemptedObjectives = mastery.Count,
TotalAttempts = mastery.Sum(value => value.Attempts),
AverageObjectiveElo = mastery.Count == 0 ? 1000 : (int)Math.Round(mastery.Average(value => value.Elo)),
ReviewsDue = checked((int)due),
SystemCoverage = Coverage(blueprints, masteryByObjective, value => value.System),
PhysicianTaskCoverage = Coverage(blueprints, masteryByObjective, value => value.PhysicianTask)
};
}
private static List<DynamicCoverageSummary> Coverage(
IReadOnlyCollection<DynamicQuestionBlueprint> blueprints,
IReadOnlyDictionary<string, DynamicObjectiveMastery> mastery,
Func<DynamicQuestionBlueprint, string> keySelector) =>
blueprints
.GroupBy(value => string.IsNullOrWhiteSpace(keySelector(value)) ? "Unclassified" : keySelector(value))
.Select(group =>
{
var attempts = group
.Where(value => mastery.ContainsKey(value.ObjectiveId))
.Select(value => mastery[value.ObjectiveId])
.ToList();
return new DynamicCoverageSummary
{
Id = group.Key,
PublishedObjectives = group.Count(),
AttemptedObjectives = attempts.Count,
Attempts = attempts.Sum(value => value.Attempts),
CoveragePercent = group.Count() == 0 ? 0 : Math.Round(attempts.Count * 100.0 / group.Count(), 1),
AverageElo = attempts.Count == 0 ? 1000 : (int)Math.Round(attempts.Average(value => value.Elo))
};
})
.OrderBy(value => value.CoveragePercent)
.ThenBy(value => value.Id)
.ToList();
public async Task<(int Inserted, int Unchanged)> UpsertBlueprintsAsync(IEnumerable<DynamicQuestionBlueprint> blueprints)
{
var inserted = 0;
var unchanged = 0;
foreach (var blueprint in blueprints)
{
var existing = await _blueprints.Find(
value => value.ExternalId == blueprint.ExternalId && value.Version == blueprint.Version)
.FirstOrDefaultAsync();
if (existing is not null)
{
blueprint.Id = existing.Id;
var incomingDocument = blueprint.ToBsonDocument();
var existingDocument = existing.ToBsonDocument();
incomingDocument.Remove("_id");
existingDocument.Remove("_id");
if (incomingDocument != existingDocument)
throw new InvalidDataException(
$"{blueprint.ExternalId} v{blueprint.Version} is immutable; publish changes with a new version");
unchanged++;
continue;
}
blueprint.Id = ObjectId.GenerateNewId().ToString();
await _blueprints.InsertOneAsync(blueprint);
inserted++;
}
return (inserted, unchanged);
}
}
@@ -1,67 +0,0 @@
using QuestionsApi.Models;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace QuestionsApi.Services;
public sealed class DynamicQuestionTokenService
{
private readonly byte[] _key;
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public DynamicQuestionTokenService(IConfiguration configuration)
{
var key = configuration["DynamicQuestions:SigningKey"]
?? configuration["Jwt:Key"]
?? "development-dynamic-question-signing-key-change-me";
_key = SHA256.HashData(Encoding.UTF8.GetBytes(key));
}
public string SignGeneration(DynamicGenerationClaims claims) => Sign(claims);
public string SignRun(DynamicRunClaims claims) => Sign(claims);
public DynamicGenerationClaims ReadGeneration(string token, string userId) => Read<DynamicGenerationClaims>(token, userId);
public DynamicRunClaims ReadRun(string token, string userId) => Read<DynamicRunClaims>(token, userId);
private string Sign<T>(T value)
{
var payload = Base64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, Json)));
using var hmac = new HMACSHA256(_key);
return $"{payload}.{Base64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)))}";
}
private T Read<T>(string token, string userId) where T : class
{
var parts = (token ?? "").Split('.');
if (parts.Length != 2) throw new ArgumentException("token is invalid");
using var hmac = new HMACSHA256(_key);
var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes(parts[0]));
var supplied = FromBase64Url(parts[1]);
if (!CryptographicOperations.FixedTimeEquals(expected, supplied)) throw new ArgumentException("token is invalid");
var value = JsonSerializer.Deserialize<T>(FromBase64Url(parts[0]), Json)
?? throw new ArgumentException("token is invalid");
var tokenUser = value switch
{
DynamicGenerationClaims generation => generation.UserId,
DynamicRunClaims run => run.UserId,
_ => ""
};
var expires = value switch
{
DynamicGenerationClaims generation => generation.ExpiresUtc,
DynamicRunClaims run => run.ExpiresUtc,
_ => DateTime.MinValue
};
if (!string.Equals(tokenUser, userId, StringComparison.Ordinal) || expires < DateTime.UtcNow)
throw new ArgumentException("token is expired or belongs to another user");
return value;
}
private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[] FromBase64Url(string value)
{
value = value.Replace('-', '+').Replace('_', '/');
value += new string('=', (4 - value.Length % 4) % 4);
return Convert.FromBase64String(value);
}
}
@@ -1,75 +0,0 @@
using Microsoft.Extensions.Configuration;
using QuestionsApi.Models;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class DynamicReviewExportCommand
{
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
static string Argument(string[] values, string name, string fallback = "")
{
var index = Array.FindIndex(values, value => value.Equals(name, StringComparison.OrdinalIgnoreCase));
return index >= 0 && index + 1 < values.Length ? values[index + 1] : fallback;
}
var source = Argument(args, "--source", Path.Combine(AppContext.BaseDirectory, "content", "dynamic_questions"));
var destination = Argument(args, "--output");
if (!Directory.Exists(source) || string.IsNullOrWhiteSpace(destination))
{
await output.WriteLineAsync("Usage: export-dynamic-review --source <blueprints> --output <directory>");
return 2;
}
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{ PropertyNameCaseInsensitive = true, WriteIndented = true };
var blueprints = new List<DynamicQuestionBlueprint>();
foreach (var file in Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories))
blueprints.AddRange(JsonSerializer.Deserialize<List<DynamicQuestionBlueprint>>(
await File.ReadAllTextAsync(file), jsonOptions) ?? []);
blueprints = DynamicBlueprintResolver.ResolveAll(blueprints);
blueprints = blueprints.Where(value => value.IsPublished && value.QualityStandardVersion >= 2).ToList();
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
var authored = (await new QuestionStore(new MongoDatabaseProvider(configuration)).GetPublishedAsync())
.OrderBy(value => value.ExternalId, StringComparer.Ordinal)
.Take(blueprints.Count * 3)
.ToList();
if (authored.Count < blueprints.Count * 3)
throw new InvalidOperationException("Not enough published authored questions for a balanced review packet.");
var generator = new DynamicQuestionGenerator();
var entries = new List<(Question Question, string Source, string Detail)>();
foreach (var blueprint in blueprints)
for (var seed = 0; seed < 3; seed++)
{
var generated = generator.Generate(blueprint, $"review-{seed}", $"review-{blueprint.ExternalId}-{seed}");
entries.Add((generated.Question, "dynamic", $"{blueprint.ExternalId}:{generated.CaseFamilyId}"));
}
entries.AddRange(authored.Select(value => (value, "authored", value.ExternalId)));
var ordered = entries.OrderBy(value => Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value.Detail)))).ToList();
Directory.CreateDirectory(destination);
var packet = ordered.Select((entry, index) => new
{
reviewId = $"Q{index + 1:D2}",
entry.Question.Stem,
entry.Question.LeadIn,
entry.Question.Choices,
entry.Question.Labs,
entry.Question.Explanation,
entry.Question.ChoiceExplanations,
entry.Question.ReviewFollowups,
entry.Question.WrongAnswerFollowups,
scoring = new { realism = 0, distractors = 0, explanation = 0, step3Relevance = 0, criticalIssue = "", predictedSource = "" }
});
var key = ordered.Select((entry, index) => new { reviewId = $"Q{index + 1:D2}", entry.Source, entry.Detail });
await File.WriteAllTextAsync(Path.Combine(destination, "review-packet.json"), JsonSerializer.Serialize(packet, jsonOptions));
await File.WriteAllTextAsync(Path.Combine(destination, "answer-key.json"), JsonSerializer.Serialize(key, jsonOptions));
await output.WriteLineAsync($"Exported {ordered.Count} blinded questions to {destination}.");
return 0;
}
}
@@ -1,22 +0,0 @@
namespace QuestionsApi.Services;
public static class DynamicReviewPolicy
{
private static readonly int[] IntervalsDays = [1, 3, 7, 21, 45];
public static DateTime FirstDue(DateTime nowUtc) => nowUtc.AddDays(IntervalsDays[0]);
public static (int Stage, DateTime? DueUtc, bool Mastered) AfterReview(
int currentStage,
bool isCorrect,
DateTime nowUtc)
{
if (!isCorrect)
return (0, FirstDue(nowUtc), false);
var nextStage = Math.Clamp(currentStage + 1, 1, IntervalsDays.Length);
if (nextStage >= IntervalsDays.Length)
return (nextStage, null, true);
return (nextStage, nowUtc.AddDays(IntervalsDays[nextStage]), false);
}
}
@@ -4,35 +4,33 @@ namespace QuestionsApi.Services;
public static class ExamSimulationScoring
{
public const string ModelVersion = "provisional-2";
private const double PassingAccuracy = 0.60;
private const double ScoreIntercept = 50.0;
private const double ScoreScale = 250.0;
public const string ModelVersion = "provisional-1";
private const double PriorMean = 1000.0;
private const double PriorSd = 400.0;
public static ExamSimulationScore Calculate(
IReadOnlyList<ExamSimulationItem> items,
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
IReadOnlyDictionary<string, Question> questions)
{
var ability = EstimateAbility(items, responses, questions);
var predicted = Math.Clamp((int)Math.Round(227.0 + (ability - 1000.0) * 15.0 / 200.0), 1, 300);
var information = 1.0 / (PriorSd * PriorSd);
var correct = 0;
var seen = 0;
var weightedCorrect = 0.0;
var weightSum = 0.0;
var squaredWeightSum = 0.0;
var categories = new Dictionary<string, ExamSimulationCategoryScore>();
foreach (var item in items)
{
questions.TryGetValue(item.QuestionExternalId, out var question);
if (!questions.TryGetValue(item.QuestionExternalId, out var question))
continue;
responses.TryGetValue(item.QuestionExternalId, out var response);
var isCorrect = question is not null && response?.ChoiceId == question.CorrectChoiceId;
var isCorrect = response?.ChoiceId == question.CorrectChoiceId;
if (isCorrect) correct++;
if (item.WasSeen) seen++;
var difficulty = ResolveDifficulty(item, question);
var weight = DifficultyWeight(difficulty);
weightSum += weight;
squaredWeightSum += weight * weight;
if (isCorrect) weightedCorrect += weight;
var weight = item.WasSeen ? 0.5 : 1.0;
var probability = Expected(ability, item.QuestionElo);
information += weight * probability * (1.0 - probability) * Math.Pow(Math.Log(10.0) / 400.0, 2);
if (!categories.TryGetValue(item.BlueprintCategory, out var category))
{
category = new ExamSimulationCategoryScore();
@@ -42,53 +40,51 @@ public static class ExamSimulationScoring
if (isCorrect) category.Correct++;
}
var rawAccuracy = items.Count == 0 ? 0.0 : (double)correct / items.Count;
var adjustedAccuracy = weightSum == 0.0 ? 0.0 : weightedCorrect / weightSum;
var predicted = ToScore(adjustedAccuracy);
var effectiveSampleSize = squaredWeightSum == 0.0 ? 0.0 : weightSum * weightSum / squaredWeightSum;
var accuracySe = effectiveSampleSize == 0.0
? 0.0
: Math.Sqrt(adjustedAccuracy * (1.0 - adjustedAccuracy) / effectiveSampleSize);
var accuracyMargin = 1.96 * accuracySe;
var rangeLow = ToScore(Math.Clamp(adjustedAccuracy - accuracyMargin, 0.0, 1.0));
var rangeHigh = ToScore(Math.Clamp(adjustedAccuracy + accuracyMargin, 0.0, 1.0));
var passProbability = accuracySe == 0.0
? adjustedAccuracy >= PassingAccuracy ? 1.0 : 0.0
: NormalCdf((adjustedAccuracy - PassingAccuracy) / accuracySe);
var abilitySe = Math.Sqrt(1.0 / information);
var scoreSe = abilitySe * 15.0 / 200.0;
var combinedSe = Math.Sqrt(7.0 * 7.0 + 15.0 * 15.0 + scoreSe * scoreSe);
var range = (int)Math.Ceiling(combinedSe);
var passProbability = NormalCdf((predicted - 200.0) / combinedSe);
return new ExamSimulationScore
{
PredictedScore = predicted,
RangeLow = rangeLow,
RangeHigh = rangeHigh,
RangeLow = Math.Clamp(predicted - range, 1, 300),
RangeHigh = Math.Clamp(predicted + range, 1, 300),
PassProbability = Math.Round(passProbability, 4),
Correct = correct,
Total = items.Count,
SeenCount = seen,
RawAccuracy = Math.Round(rawAccuracy, 4),
DifficultyAdjustedAccuracy = Math.Round(adjustedAccuracy, 4),
AbilityElo = null,
AbilityElo = Math.Round(ability, 1),
Categories = categories
};
}
public static int ResolveDifficulty(ExamSimulationItem item, Question? question = null)
private static double EstimateAbility(
IReadOnlyList<ExamSimulationItem> items,
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
IReadOnlyDictionary<string, Question> questions)
{
if (item.QuestionDifficulty is >= 1 and <= 5) return item.QuestionDifficulty;
return question?.Difficulty is >= 1 and <= 5 ? question.Difficulty : 3;
var low = 100.0;
var high = 1900.0;
for (var iteration = 0; iteration < 80; iteration++)
{
var ability = (low + high) / 2.0;
var derivative = -(ability - PriorMean) / (PriorSd * PriorSd);
foreach (var item in items)
{
if (!questions.TryGetValue(item.QuestionExternalId, out var question)) continue;
responses.TryGetValue(item.QuestionExternalId, out var response);
var actual = response?.ChoiceId == question.CorrectChoiceId ? 1.0 : 0.0;
var weight = item.WasSeen ? 0.5 : 1.0;
derivative += weight * (actual - Expected(ability, item.QuestionElo)) * Math.Log(10.0) / 400.0;
}
if (derivative > 0) low = ability; else high = ability;
}
return (low + high) / 2.0;
}
private static double DifficultyWeight(int difficulty) => difficulty switch
{
1 => 0.8,
2 => 0.9,
3 => 1.0,
4 => 1.1,
5 => 1.2,
_ => 1.0
};
private static int ToScore(double accuracy) =>
Math.Clamp((int)Math.Round(ScoreIntercept + ScoreScale * accuracy), 1, 300);
private static double Expected(double ability, int difficulty) =>
1.0 / (1.0 + Math.Pow(10.0, (difficulty - ability) / 400.0));
private static double NormalCdf(double value) => 0.5 * (1.0 + Erf(value / Math.Sqrt(2.0)));
@@ -1,37 +0,0 @@
namespace QuestionsApi.Services;
public sealed class ExamSimulationScoringMigrationService : BackgroundService
{
private readonly ExamSimulationStore _simulations;
private readonly ILogger<ExamSimulationScoringMigrationService> _logger;
public ExamSimulationScoringMigrationService(
ExamSimulationStore simulations,
ILogger<ExamSimulationScoringMigrationService> logger)
{
_simulations = simulations;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
var result = await _simulations.BackfillScoringModelAsync(stoppingToken);
_logger.LogInformation(
"Exam simulation scoring migration completed; recalculated {UpdatedCount} simulation(s)",
result.UpdatedCount);
foreach (var reference in result.MissingQuestionIds)
_logger.LogWarning(
"Exam simulation scoring migration used neutral difficulty for missing question {Reference}",
reference);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
catch (Exception exception)
{
_logger.LogError(exception, "Exam simulation scoring migration failed");
}
}
}
@@ -13,7 +13,6 @@ public class ExamSimulationStore
private readonly IMongoCollection<Step3Outcome> _outcomes;
private readonly QuestionStore _questions;
private readonly QuestionAttemptStore _attempts;
private readonly CreditStore _credits;
private readonly AnswerStatisticsStore _statistics;
private readonly RatingStore _ratings;
private readonly ServiceMetrics _metrics;
@@ -23,7 +22,6 @@ public class ExamSimulationStore
MongoDatabaseProvider mongo,
QuestionStore questions,
QuestionAttemptStore attempts,
CreditStore credits,
AnswerStatisticsStore statistics,
RatingStore ratings,
ServiceMetrics metrics,
@@ -33,7 +31,6 @@ public class ExamSimulationStore
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
_questions = questions;
_attempts = attempts;
_credits = credits;
_statistics = statistics;
_ratings = ratings;
_metrics = metrics;
@@ -71,8 +68,7 @@ public class ExamSimulationStore
var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList();
if (bank.Count < RequiredQuestions)
throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required.");
var bankCycle = await _credits.GetBankCycleAsync(userId);
var seen = (await _attempts.GetUserQuestionIdsAsync(userId, bankCycle)).ToHashSet(StringComparer.Ordinal);
var seen = (await _attempts.GetUserQuestionIdsAsync(userId)).ToHashSet(StringComparer.Ordinal);
var elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId));
var targets = BuildTargets();
var selected = SelectForm(bank, seen, targets);
@@ -91,7 +87,6 @@ public class ExamSimulationStore
Position = offset,
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
QuestionElo = elos.GetValueOrDefault(question.ExternalId, 1000),
QuestionDifficulty = question.Difficulty,
WasSeen = seen.Contains(question.ExternalId),
BlueprintCategory = BlueprintCategory(question)
});
@@ -129,7 +124,6 @@ public class ExamSimulationStore
Position = PositionWithinBlock(index),
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
QuestionElo = 1000,
QuestionDifficulty = question.Difficulty,
WasSeen = false,
BlueprintCategory = BlueprintCategory(question)
}).ToList();
@@ -175,22 +169,15 @@ public class ExamSimulationStore
(item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive))
.SortByDescending(item => item.UpdatedUtc).FirstOrDefaultAsync();
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId)
{
var simulation = await _simulations
.Find(item => item.UserId == userId && item.State == ExamSimulationStates.Completed && item.Score != null)
.SortByDescending(item => item.CompletedUtc)
.FirstOrDefaultAsync();
return simulation is null ? null : (await UpgradeScoringModelAsync(simulation)).Score;
}
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId) =>
(await _simulations.Find(item => item.UserId == userId && item.State == ExamSimulationStates.Completed && item.Score != null)
.SortByDescending(item => item.CompletedUtc).FirstOrDefaultAsync())?.Score;
public async Task<ExamSimulation?> GetOwnedAsync(string id, string userId)
{
var simulation = await _simulations.Find(item => item.Id == id && item.UserId == userId).FirstOrDefaultAsync();
if (simulation is not null && simulation.State == ExamSimulationStates.BlockActive && simulation.BlockDeadlineUtc <= DateTime.UtcNow)
simulation = await SubmitBlockAsync(simulation, true);
if (simulation is not null)
simulation = await UpgradeScoringModelAsync(simulation);
return simulation;
}
@@ -204,7 +191,6 @@ public class ExamSimulationStore
var simulations = await _simulations.Find(filter).SortByDescending(item => item.CompletedUtc).Limit(limit + 1).ToListAsync();
var hasMore = simulations.Count > limit;
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
await UpgradeScoringModelsAsync(simulations);
return new CompletedExamSimulationPage
{
Items = simulations.Select(item => new CompletedExamSimulationSummary
@@ -271,7 +257,6 @@ public class ExamSimulationStore
var simulations = await _simulations.Find(filter).Sort(sortDefinition).Limit(limit + 1).ToListAsync();
var hasMore = simulations.Count > limit;
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
await UpgradeScoringModelsAsync(simulations.Where(item => item.State == ExamSimulationStates.Completed));
var last = simulations.LastOrDefault();
return new ExamSimulationHistoryPage
{
@@ -348,53 +333,23 @@ public class ExamSimulationStore
if (!item.PresentedChoiceIds.Contains(request.ChoiceId, StringComparer.Ordinal) && request.ChoiceId != "")
throw new ArgumentException("Choice is not presented for this question.");
var response = simulation.Responses.FirstOrDefault(candidate => candidate.QuestionExternalId == request.QuestionExternalId);
if (response is not null && IsResponseLocked(response))
{
if (ResponseContentMatches(response, request, item.PresentedChoiceIds))
return simulation;
throw new InvalidOperationException("A submitted simulation answer cannot be changed.");
}
if (response is null)
{
response = new ExamSimulationResponse
{
QuestionExternalId = request.QuestionExternalId,
IsSubmitted = false
};
response = new ExamSimulationResponse { QuestionExternalId = request.QuestionExternalId };
simulation.Responses.Add(response);
}
response.ChoiceId = request.ChoiceId;
response.EliminatedChoiceIds = request.EliminatedChoiceIds.Where(item.PresentedChoiceIds.Contains).Distinct().ToList();
response.Flagged = request.Flagged;
response.ElapsedSeconds = Math.Clamp(request.ElapsedSeconds, 0, 1800);
response.IsSubmitted = request.IsSubmitted;
response.UpdatedUtc = DateTime.UtcNow;
simulation.UpdatedUtc = DateTime.UtcNow;
await ReplaceAsync(simulation);
return simulation;
}
public static bool IsResponseLocked(ExamSimulationResponse response) =>
response.IsSubmitted == true ||
(response.IsSubmitted is null && !string.IsNullOrEmpty(response.ChoiceId));
public static bool ResponseContentMatches(
ExamSimulationResponse response,
SaveSimulationResponseRequest request,
IReadOnlyCollection<string> presentedChoiceIds)
{
var eliminated = request.EliminatedChoiceIds
.Where(presentedChoiceIds.Contains)
.ToHashSet(StringComparer.Ordinal);
return response.ChoiceId == request.ChoiceId &&
response.Flagged == request.Flagged &&
response.EliminatedChoiceIds.ToHashSet(StringComparer.Ordinal).SetEquals(eliminated);
}
public async Task<ExamSimulation> SubmitBlockAsync(ExamSimulation simulation, bool expired = false)
{
if (IsAlreadySubmittedState(simulation.State))
return simulation;
if (simulation.State != ExamSimulationStates.BlockActive)
throw new InvalidOperationException("There is no active block to submit.");
if (!expired && simulation.BlockDeadlineUtc is not null)
@@ -411,9 +366,6 @@ public class ExamSimulationStore
return simulation;
}
public static bool IsAlreadySubmittedState(string state) =>
state is ExamSimulationStates.BetweenBlocks or ExamSimulationStates.Completed;
public async Task AbandonAsync(ExamSimulation simulation)
{
simulation.State = ExamSimulationStates.Abandoned;
@@ -481,9 +433,7 @@ public class ExamSimulationStore
{
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
SnapshotDifficulties(simulation, questionMap);
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
simulation.State = ExamSimulationStates.Completed;
simulation.CompletedUtc = DateTime.UtcNow;
simulation.UpdatedUtc = DateTime.UtcNow;
@@ -493,10 +443,7 @@ public class ExamSimulationStore
if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue;
responses.TryGetValue(item.QuestionExternalId, out var response);
var choiceId = response?.ChoiceId ?? "";
var record = await _attempts.RecordFirstAttemptAsync(
item.QuestionExternalId, simulation.UserId, choiceId,
choiceId == question.CorrectChoiceId, choiceId == "",
item.PresentedChoiceIds, bankCycle: await _credits.GetBankCycleAsync(simulation.UserId));
var record = await _attempts.RecordFirstAttemptAsync(item.QuestionExternalId, simulation.UserId, choiceId, choiceId == question.CorrectChoiceId, choiceId == "", item.PresentedChoiceIds);
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (!record.Inserted) continue;
_metrics.AttemptCreated();
@@ -507,8 +454,8 @@ public class ExamSimulationStore
try
{
shouldRate = await _attempts.TryAcquireRatingLeaseAsync(attemptId, owner, TimeSpan.FromSeconds(30));
var rating = await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate);
if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner, rating.UserOverallElo);
await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate);
if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner);
}
catch
{
@@ -521,71 +468,6 @@ public class ExamSimulationStore
private Task ReplaceAsync(ExamSimulation simulation) =>
_simulations.ReplaceOneAsync(item => item.Id == simulation.Id && item.UserId == simulation.UserId, simulation);
public async Task<ExamSimulationBackfillResult> BackfillScoringModelAsync(CancellationToken cancellationToken = default)
{
var filter = Builders<ExamSimulation>.Filter.Eq(item => item.State, ExamSimulationStates.Completed) &
Builders<ExamSimulation>.Filter.Ne(item => item.ModelVersion, ExamSimulationScoring.ModelVersion);
var simulations = await _simulations.Find(filter).ToListAsync(cancellationToken);
var missingQuestionIds = new List<string>();
var updated = 0;
foreach (var simulation in simulations)
{
cancellationToken.ThrowIfCancellationRequested();
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
missingQuestionIds.AddRange(simulation.Items
.Where(item => !questionMap.ContainsKey(item.QuestionExternalId))
.Select(item => $"{simulation.Id}:{item.QuestionExternalId}"));
SnapshotDifficulties(simulation, questionMap);
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
simulation.UpdatedUtc = DateTime.UtcNow;
await ReplaceAsync(simulation);
updated++;
}
return new ExamSimulationBackfillResult(updated, missingQuestionIds);
}
private async Task UpgradeScoringModelsAsync(IEnumerable<ExamSimulation> simulations)
{
foreach (var simulation in simulations)
await UpgradeScoringModelAsync(simulation);
}
private async Task<ExamSimulation> UpgradeScoringModelAsync(ExamSimulation simulation)
{
var needsDifficultySnapshot = simulation.Items.Any(item => item.QuestionDifficulty is < 1 or > 5);
var needsScoreUpgrade = simulation.State == ExamSimulationStates.Completed &&
simulation.ModelVersion != ExamSimulationScoring.ModelVersion;
if (!needsDifficultySnapshot && !needsScoreUpgrade)
return simulation;
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
SnapshotDifficulties(simulation, questionMap);
if (simulation.State == ExamSimulationStates.Completed)
{
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
}
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
simulation.UpdatedUtc = DateTime.UtcNow;
await ReplaceAsync(simulation);
return simulation;
}
private static void SnapshotDifficulties(
ExamSimulation simulation,
IReadOnlyDictionary<string, Question> questions)
{
foreach (var item in simulation.Items)
{
questions.TryGetValue(item.QuestionExternalId, out var question);
item.QuestionDifficulty = ExamSimulationScoring.ResolveDifficulty(item, question);
}
}
public sealed record ExamSimulationBackfillResult(int UpdatedCount, IReadOnlyList<string> MissingQuestionIds);
private static int BlockIndexForPosition(int position)
{
var consumed = 0;
@@ -1,21 +0,0 @@
namespace QuestionsApi.Services;
// One-time sweep: flags every question that currently has at least one image so it can be
// pulled from student rotation for review, without touching IsPublished. Idempotent (only
// touches questions not already flagged, via QuestionStore.FlagAllWithMediaAsync's filter) -
// safe to re-run.
public static class FlagMediaQuestionsCommand
{
private const string Reason = "Bulk-flagged for image review";
private const string FlaggedBy = "system";
public static async Task<int> RunAsync(IConfiguration configuration, TextWriter output)
{
var mongo = new MongoDatabaseProvider(configuration);
var questions = new QuestionStore(mongo);
var flagged = await questions.FlagAllWithMediaAsync(FlaggedBy, Reason);
await output.WriteLineAsync($"Flagged {flagged} question(s) with images for review.");
return 0;
}
}
@@ -1,57 +0,0 @@
using System.Text.RegularExpressions;
namespace QuestionsApi.Services.Flashcards;
public sealed record ClozeDeletion(int Number, string Answer, string? Hint);
// {{c1::answer}} or {{c1::answer::hint}}. Numbers need not be contiguous or start at 1;
// each distinct number becomes its own independently scheduled card (a "sibling").
public static partial class FlashcardClozeParser
{
[GeneratedRegex(@"\{\{c(\d+)::((?:(?!\}\}).)*)\}\}", RegexOptions.Singleline)]
private static partial Regex ClozeRegex();
public static List<ClozeDeletion> Parse(string text)
{
if (string.IsNullOrEmpty(text)) return [];
var results = new List<ClozeDeletion>();
foreach (Match match in ClozeRegex().Matches(text))
{
if (!int.TryParse(match.Groups[1].Value, out var number) || number < 1)
continue;
var inner = match.Groups[2].Value;
var parts = inner.Split("::", 2);
var answer = parts[0].Trim();
var hint = parts.Length > 1 ? parts[1].Trim() : null;
if (answer.Length == 0) continue;
results.Add(new ClozeDeletion(number, answer, string.IsNullOrEmpty(hint) ? null : hint));
}
return results;
}
public static List<int> DistinctClozeNumbers(string text) =>
Parse(text).Select(v => v.Number).Distinct().OrderBy(v => v).ToList();
public static bool HasValidCloze(string text) => DistinctClozeNumbers(text).Count > 0;
// Renders the front for a given cloze number: that number's answer is hidden (replaced
// by its hint or "..."), all other cloze numbers are revealed as their answer text.
public static string RenderFront(string text, int clozeNumber) =>
ClozeRegex().Replace(text, match =>
{
if (!int.TryParse(match.Groups[1].Value, out var number)) return match.Value;
var inner = match.Groups[2].Value;
var parts = inner.Split("::", 2);
if (number != clozeNumber) return parts[0].Trim();
var hint = parts.Length > 1 ? parts[1].Trim() : "";
return hint.Length > 0 ? $"[{hint}]" : "[...]";
});
public static string RenderBack(string text, int clozeNumber) =>
ClozeRegex().Replace(text, match =>
{
var inner = match.Groups[2].Value;
var parts = inner.Split("::", 2);
return parts[0].Trim();
});
}
@@ -1,64 +0,0 @@
using System.Text;
namespace QuestionsApi.Services.Flashcards;
// Minimal RFC 4180 CSV reader/writer (quoted fields, embedded commas/newlines, "" escaping).
// UTF-8, text-only, matches the flashcard CSV contract exactly.
public static class FlashcardCsv
{
public static readonly string[] Headers =
["stable_id", "deck", "type", "prompt", "answer", "extra", "tags", "source_question_external_id"];
public static List<string[]> ParseRows(string text)
{
var rows = new List<string[]>();
var field = new StringBuilder();
var row = new List<string>();
var inQuotes = false;
var i = 0;
var n = text.Length;
while (i < n)
{
var c = text[i];
if (inQuotes)
{
if (c == '"')
{
if (i + 1 < n && text[i + 1] == '"') { field.Append('"'); i += 2; continue; }
inQuotes = false; i++; continue;
}
field.Append(c); i++; continue;
}
switch (c)
{
case '"':
inQuotes = true; i++; break;
case ',':
row.Add(field.ToString()); field.Clear(); i++; break;
case '\r':
i++; break;
case '\n':
row.Add(field.ToString()); field.Clear();
rows.Add(row.ToArray()); row = [];
i++; break;
default:
field.Append(c); i++; break;
}
}
if (field.Length > 0 || row.Count > 0)
{
row.Add(field.ToString());
rows.Add(row.ToArray());
}
return rows;
}
public static string WriteRow(IEnumerable<string> fields) => string.Join(",", fields.Select(Escape));
private static string Escape(string field) =>
field.IndexOfAny(['"', ',', '\n', '\r']) >= 0
? "\"" + field.Replace("\"", "\"\"") + "\""
: field;
}
@@ -1,143 +0,0 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed record FlashcardDeckUpdateResult(FlashcardDeck? Deck, FlashcardDeck? Current)
{
public bool Succeeded => Deck is not null;
}
public sealed class FlashcardDeckStore
{
private readonly IMongoCollection<FlashcardDeck> _decks;
public FlashcardDeckStore(MongoDatabaseProvider mongo)
{
_decks = mongo.Database.GetCollection<FlashcardDeck>("FlashcardDecks");
_decks.Indexes.CreateMany([
new CreateIndexModel<FlashcardDeck>(
Builders<FlashcardDeck>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NormalizedName),
new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_name" }),
new CreateIndexModel<FlashcardDeck>(
Builders<FlashcardDeck>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId),
new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_stable" })
]);
}
public static string Normalize(string name) => name.Trim().ToLowerInvariant();
public Task<List<FlashcardDeck>> ListAsync(string userId) =>
_decks.Find(v => v.UserId == userId && v.ArchivedUtc == null).SortBy(v => v.Name).ToListAsync();
public async Task<FlashcardDeck?> GetAsync(string userId, string deckId) =>
await _decks.Find(v => v.UserId == userId && v.Id == deckId).FirstOrDefaultAsync();
// Read-only: the caller's own decks plus every system (shared, admin-curated) deck. Never
// used to back a write path - mutations must stay strictly own-user via ListAsync/GetAsync.
public Task<List<FlashcardDeck>> ListVisibleAsync(string userId) =>
_decks.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null)
.SortBy(v => v.Name).ToListAsync();
public async Task<FlashcardDeck?> GetVisibleAsync(string userId, string deckId) =>
await _decks.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.Id == deckId)
.FirstOrDefaultAsync();
// No owner filter at all - used only by the controller's mutation guard to check IsSystem
// regardless of who's asking.
public async Task<FlashcardDeck?> GetAnyAsync(string deckId) =>
await _decks.Find(v => v.Id == deckId).FirstOrDefaultAsync();
public async Task<FlashcardDeck> GetOrCreateDefaultDeckAsync(string userId)
{
var existing = await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync();
if (existing is not null) return existing;
var deck = new FlashcardDeck
{
UserId = userId,
Name = "Default",
NormalizedName = Normalize("Default"),
IsDefault = true
};
try
{
await _decks.InsertOneAsync(deck);
return deck;
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync()
?? throw new InvalidOperationException("default deck race with no winner");
}
}
public async Task<FlashcardDeckUpdateResult> UpsertAsync(string userId, UpsertFlashcardDeckRequest request, string? deckId)
{
var now = DateTime.UtcNow;
var normalized = Normalize(request.Name);
if (string.IsNullOrWhiteSpace(deckId))
{
var created = new FlashcardDeck
{
UserId = userId,
Name = request.Name.Trim(),
NormalizedName = normalized,
Description = request.Description.Trim(),
NewCardsPerDay = request.NewCardsPerDay,
ReviewsPerDay = request.ReviewsPerDay,
DesiredRetention = request.DesiredRetention,
CreatedUtc = now,
UpdatedUtc = now
};
try
{
await _decks.InsertOneAsync(created);
return new FlashcardDeckUpdateResult(created, null);
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return new FlashcardDeckUpdateResult(null, await GetByNameAsync(userId, normalized));
}
}
var expectedRevision = request.ExpectedRevision ?? 0;
var updated = await _decks.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == deckId && v.Revision == expectedRevision,
Builders<FlashcardDeck>.Update
.Set(v => v.Name, request.Name.Trim())
.Set(v => v.NormalizedName, normalized)
.Set(v => v.Description, request.Description.Trim())
.Set(v => v.NewCardsPerDay, request.NewCardsPerDay)
.Set(v => v.ReviewsPerDay, request.ReviewsPerDay)
.Set(v => v.DesiredRetention, request.DesiredRetention)
.Set(v => v.UpdatedUtc, now)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardDeck> { ReturnDocument = ReturnDocument.After });
return updated is not null
? new FlashcardDeckUpdateResult(updated, null)
: new FlashcardDeckUpdateResult(null, await GetAsync(userId, deckId));
}
public async Task<FlashcardDeck?> GetByNameAsync(string userId, string normalizedName) =>
await _decks.Find(v => v.UserId == userId && v.NormalizedName == normalizedName).FirstOrDefaultAsync();
public async Task<FlashcardDeckUpdateResult> ArchiveAsync(string userId, string deckId, bool archived)
{
var deck = await GetAsync(userId, deckId);
if (deck is not null && deck.IsDefault) return new FlashcardDeckUpdateResult(null, deck);
var updated = await _decks.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == deckId && !v.IsDefault,
Builders<FlashcardDeck>.Update
.Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null)
.Set(v => v.UpdatedUtc, DateTime.UtcNow)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardDeck> { ReturnDocument = ReturnDocument.After });
return updated is not null
? new FlashcardDeckUpdateResult(updated, null)
: new FlashcardDeckUpdateResult(null, deck);
}
}
@@ -1,393 +0,0 @@
using System.Text;
using System.Text.Json;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed class FlashcardImportExportService
{
private const int MaxNotesPerImport = 10_000;
private const long MaxImportBytes = 25 * 1024 * 1024;
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly FlashcardDeckStore _decks;
private readonly FlashcardNoteStore _notes;
private readonly FlashcardScheduleStore _schedules;
private readonly FlashcardMediaStore _media;
private readonly FlashcardSettingsStore _settings;
private readonly IMongoCollection<FlashcardImportPreview> _previews;
public FlashcardImportExportService(
MongoDatabaseProvider mongo,
FlashcardDeckStore decks,
FlashcardNoteStore notes,
FlashcardScheduleStore schedules,
FlashcardMediaStore media,
FlashcardSettingsStore settings)
{
_decks = decks;
_notes = notes;
_schedules = schedules;
_media = media;
_settings = settings;
_previews = mongo.Database.GetCollection<FlashcardImportPreview>("FlashcardImportPreviews");
_previews.Indexes.CreateOne(new CreateIndexModel<FlashcardImportPreview>(
Builders<FlashcardImportPreview>.IndexKeys.Ascending(v => v.ExpiresUtc),
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "flashcard_import_preview_ttl" }));
}
public static void ValidateSize(long byteLength)
{
if (byteLength > MaxImportBytes)
throw new FlashcardValidationException("import file must be 25 MB or smaller");
}
public async Task<(string Csv, List<FlashcardExportOmission> Omissions)> ExportCsvAsync(string userId)
{
var deckList = await _decks.ListAsync(userId);
var deckNames = deckList.ToDictionary(v => v.Id!, v => v.Name, StringComparer.Ordinal);
var allNotes = await _notes.ListAllActiveAsync(userId);
var sb = new StringBuilder();
sb.Append(FlashcardCsv.WriteRow(FlashcardCsv.Headers)).Append('\n');
foreach (var note in allNotes.OrderBy(v => v.CreatedUtc))
{
var deckName = deckNames.GetValueOrDefault(note.DeckId, "Default");
sb.Append(FlashcardCsv.WriteRow([
note.StableId, deckName, note.Type, note.Prompt, note.Answer, note.Extra,
string.Join(';', note.Tags), note.SourceQuestionExternalId
])).Append('\n');
}
var omissions = new List<FlashcardExportOmission>
{
new() { Kind = "schedule", Reason = "CSV export omits FSRS scheduling state" },
new() { Kind = "history", Reason = "CSV export omits review history" }
};
if (allNotes.Any(v => v.Images.Count > 0))
omissions.Insert(0, new FlashcardExportOmission { Kind = "images", Reason = "CSV export omits attached images" });
return (sb.ToString(), omissions);
}
public async Task<FlashcardJsonExport> ExportJsonAsync(string userId)
{
var export = new FlashcardJsonExport
{
Settings = await _settings.GetAsync(userId),
Decks = await _decks.ListAsync(userId)
};
foreach (var note in await _notes.ListAllActiveAsync(userId))
{
var cards = await _schedules.GetByNoteIdAsync(userId, note.Id!);
var logs = new List<FlashcardReviewLog>();
foreach (var card in cards)
logs.AddRange(await _schedules.GetReviewLogsForCardAsync(userId, card.Id!));
var images = new List<FlashcardJsonExportImage>();
foreach (var image in note.Images)
{
var asset = await _media.GetAsync(userId, image.AssetId);
if (asset is null) continue;
images.Add(new FlashcardJsonExportImage
{
AssetId = asset.Id!,
FileName = asset.FileName,
ContentType = asset.ContentType,
Base64Bytes = Convert.ToBase64String(asset.Bytes)
});
}
export.Notes.Add(new FlashcardJsonExportNote { Note = note, Cards = cards, ReviewLogs = logs, Images = images });
}
return export;
}
public async Task<FlashcardImportPreviewResponse> PreviewCsvAsync(string userId, string csvText)
{
var rows = FlashcardCsv.ParseRows(csvText);
if (rows.Count == 0) throw new FlashcardValidationException("CSV file is empty");
if (!rows[0].Select(v => v.Trim()).SequenceEqual(FlashcardCsv.Headers, StringComparer.OrdinalIgnoreCase))
throw new FlashcardValidationException($"CSV header must be: {string.Join(",", FlashcardCsv.Headers)}");
if (rows.Count - 1 > MaxNotesPerImport)
throw new FlashcardValidationException($"import limited to {MaxNotesPerImport:N0} notes");
var existingNotes = await _notes.ListAllActiveAsync(userId);
var byStableId = existingNotes.Where(v => v.StableId.Length > 0)
.ToDictionary(v => v.StableId, StringComparer.Ordinal);
var fingerprints = existingNotes.Select(v => v.ContentFingerprint).ToHashSet(StringComparer.Ordinal);
var records = new List<FlashcardImportPendingRecord>();
for (var r = 1; r < rows.Count; r++)
{
var cols = rows[r];
var record = new FlashcardImportPendingRecord { RowNumber = r + 1 };
if (cols.Length != FlashcardCsv.Headers.Length)
{
record.Action = FlashcardImportRowActions.Error;
record.Error = $"expected {FlashcardCsv.Headers.Length} columns, found {cols.Length}";
records.Add(record);
continue;
}
var stableId = cols[0].Trim();
var deckName = cols[1].Trim();
var type = cols[2].Trim().ToLowerInvariant();
var prompt = cols[3];
var answer = cols[4];
var extra = cols[5];
var tags = cols[6].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
var sourceQuestionId = cols[7].Trim();
record.StableId = stableId;
record.DeckName = deckName.Length == 0 ? "Default" : deckName;
record.Type = type;
record.Prompt = prompt;
record.Answer = answer;
record.Extra = extra;
record.Tags = tags;
record.SourceQuestionExternalId = sourceQuestionId;
if (type != FlashcardTypes.Basic && type != FlashcardTypes.Cloze)
{
record.Action = FlashcardImportRowActions.Error;
record.Error = "type must be 'basic' or 'cloze'";
}
else if (prompt.Trim().Length == 0)
{
record.Action = FlashcardImportRowActions.Error;
record.Error = "prompt is required";
}
else if (type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(prompt))
{
record.Action = FlashcardImportRowActions.Error;
record.Error = "cloze prompt must contain at least one {{cN::answer}} marker";
}
else
{
var fingerprint = FlashcardNoteStore.Fingerprint(type, prompt, answer);
if (stableId.Length > 0 && byStableId.TryGetValue(stableId, out var existing))
{
record.Action = existing.ContentFingerprint == fingerprint
? FlashcardImportRowActions.Skip
: FlashcardImportRowActions.Update;
}
else if (fingerprints.Contains(fingerprint))
{
record.Action = FlashcardImportRowActions.Skip;
}
else
{
record.Action = FlashcardImportRowActions.Create;
if (stableId.Length == 0) record.StableId = Guid.NewGuid().ToString();
}
}
records.Add(record);
}
return await SavePreviewAsync(userId, FlashcardImportFormats.Csv, records);
}
public async Task<FlashcardImportPreviewResponse> PreviewJsonAsync(string userId, string jsonText)
{
FlashcardJsonExport parsed;
try
{
parsed = JsonSerializer.Deserialize<FlashcardJsonExport>(jsonText, JsonOptions)
?? throw new FlashcardValidationException("empty JSON document");
}
catch (JsonException exception)
{
throw new FlashcardValidationException($"invalid JSON: {exception.Message}");
}
if (parsed.Notes.Count > MaxNotesPerImport)
throw new FlashcardValidationException($"import limited to {MaxNotesPerImport:N0} notes");
var existingNotes = await _notes.ListAllActiveAsync(userId);
var byStableId = existingNotes.Where(v => v.StableId.Length > 0)
.ToDictionary(v => v.StableId, StringComparer.Ordinal);
var records = new List<FlashcardImportPendingRecord>();
var rowNumber = 0;
foreach (var item in parsed.Notes)
{
rowNumber++;
var note = item.Note;
var stableId = string.IsNullOrWhiteSpace(note.StableId) ? Guid.NewGuid().ToString() : note.StableId;
var type = FlashcardTypes.All.Contains(note.Type) ? note.Type : FlashcardTypes.Basic;
var record = new FlashcardImportPendingRecord
{
RowNumber = rowNumber,
StableId = stableId,
DeckName = parsed.Decks.FirstOrDefault(d => d.Id == note.DeckId)?.Name ?? "Default",
Type = type,
Prompt = note.Prompt,
Answer = note.Answer,
Extra = note.Extra,
Tags = note.Tags,
SourceQuestionExternalId = note.SourceQuestionExternalId,
Cards = item.Cards,
ReviewLogs = item.ReviewLogs
};
record.Action = type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(note.Prompt)
? FlashcardImportRowActions.Error
: byStableId.ContainsKey(stableId) ? FlashcardImportRowActions.Update : FlashcardImportRowActions.Create;
if (record.Action == FlashcardImportRowActions.Error)
record.Error = "cloze prompt must contain at least one {{cN::answer}} marker";
records.Add(record);
}
return await SavePreviewAsync(userId, FlashcardImportFormats.Json, records);
}
public async Task<FlashcardImportCommitResponse> CommitAsync(string userId, string importToken)
{
var preview = await _previews.Find(v => v.Id == importToken && v.UserId == userId).FirstOrDefaultAsync();
if (preview is null) throw new FlashcardValidationException("import preview not found or expired; re-run preview");
var deckCache = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var response = new FlashcardImportCommitResponse();
foreach (var record in preview.Records)
{
if (record.Action == FlashcardImportRowActions.Error)
{
response.Errored++;
response.Errors.Add(ToRow(record));
continue;
}
if (record.Action == FlashcardImportRowActions.Skip)
{
response.Skipped++;
continue;
}
var deckId = await ResolveDeckIdAsync(userId, record.DeckName, deckCache);
var existing = await _notes.GetByStableIdAsync(userId, record.StableId);
var request = new UpsertFlashcardNoteRequest
{
DeckId = deckId,
StableId = record.StableId,
Type = record.Type,
Prompt = record.Prompt,
Answer = record.Answer,
Extra = record.Extra,
Tags = record.Tags,
SourceQuestionExternalId = record.SourceQuestionExternalId,
ExpectedRevision = existing?.Revision
};
FlashcardNoteUpdateResult result;
try
{
result = await _notes.UpsertAsync(userId, request, existing?.Id);
}
catch (FlashcardValidationException exception)
{
response.Errored++;
response.Errors.Add(new FlashcardImportPreviewRow
{
RowNumber = record.RowNumber, Action = FlashcardImportRowActions.Error,
Error = exception.Message, StableId = record.StableId
});
continue;
}
if (!result.Succeeded)
{
response.Errored++;
response.Errors.Add(new FlashcardImportPreviewRow
{
RowNumber = record.RowNumber, Action = FlashcardImportRowActions.Error,
Error = "note changed since preview; re-run import", StableId = record.StableId
});
continue;
}
if (record.Action == FlashcardImportRowActions.Create) response.Created++; else response.Updated++;
foreach (var card in record.Cards)
await _schedules.ImportScheduleStateAsync(userId, result.Note!.Id!, card);
foreach (var log in record.ReviewLogs)
{
var targetCard = (await _schedules.GetByNoteIdAsync(userId, result.Note!.Id!))
.FirstOrDefault(v => v.Variant == record.Cards.FirstOrDefault(c => c.Id == log.CardId)?.Variant);
if (targetCard is not null)
await _schedules.ImportReviewLogAsync(userId, targetCard.Id!, log);
}
}
await _previews.DeleteOneAsync(v => v.Id == importToken);
return response;
}
private async Task<FlashcardImportPreviewResponse> SavePreviewAsync(
string userId, string format, List<FlashcardImportPendingRecord> records)
{
var preview = new FlashcardImportPreview
{
Id = Guid.NewGuid().ToString(),
UserId = userId,
Format = format,
Records = records
};
await _previews.InsertOneAsync(preview);
return new FlashcardImportPreviewResponse
{
ImportToken = preview.Id,
CreateCount = records.Count(v => v.Action == FlashcardImportRowActions.Create),
UpdateCount = records.Count(v => v.Action == FlashcardImportRowActions.Update),
SkipCount = records.Count(v => v.Action == FlashcardImportRowActions.Skip),
ErrorCount = records.Count(v => v.Action == FlashcardImportRowActions.Error),
Rows = records.Select(ToRow).ToList()
};
}
private static FlashcardImportPreviewRow ToRow(FlashcardImportPendingRecord record) => new()
{
RowNumber = record.RowNumber,
Action = record.Action,
Error = record.Error,
StableId = record.StableId,
Deck = record.DeckName,
Type = record.Type,
Prompt = Truncate(record.Prompt),
Answer = Truncate(record.Answer)
};
private static string Truncate(string value) => value.Length > 200 ? value[..200] + "…" : value;
private async Task<string> ResolveDeckIdAsync(string userId, string deckName, Dictionary<string, string> cache)
{
var key = deckName.Trim();
if (key.Length == 0) key = "Default";
if (cache.TryGetValue(key, out var cached)) return cached;
if (string.Equals(key, "Default", StringComparison.OrdinalIgnoreCase))
{
var defaultDeck = await _decks.GetOrCreateDefaultDeckAsync(userId);
cache[key] = defaultDeck.Id!;
return defaultDeck.Id!;
}
var normalized = FlashcardDeckStore.Normalize(key);
var existing = await _decks.GetByNameAsync(userId, normalized);
if (existing is not null)
{
cache[key] = existing.Id!;
return existing.Id!;
}
var created = await _decks.UpsertAsync(userId, new UpsertFlashcardDeckRequest { Name = key }, null);
var deckId = created.Deck?.Id ?? (await _decks.GetByNameAsync(userId, normalized))?.Id
?? throw new InvalidOperationException("deck resolution failed during import");
cache[key] = deckId;
return deckId;
}
}
@@ -1,41 +0,0 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed class FlashcardMediaStore
{
private readonly IMongoCollection<FlashcardMediaAsset> _assets;
public FlashcardMediaStore(MongoDatabaseProvider mongo)
{
_assets = mongo.Database.GetCollection<FlashcardMediaAsset>("FlashcardMediaAssets");
_assets.Indexes.CreateOne(new CreateIndexModel<FlashcardMediaAsset>(
Builders<FlashcardMediaAsset>.IndexKeys.Ascending(v => v.UserId),
new CreateIndexOptions { Name = "flashcard_media_user" }));
}
public async Task<FlashcardMediaAsset> CreateAsync(string userId, string fileName, string contentType, byte[] bytes)
{
var asset = new FlashcardMediaAsset
{
UserId = userId,
FileName = fileName,
ContentType = contentType,
Bytes = bytes,
CreatedUtc = DateTime.UtcNow
};
await _assets.InsertOneAsync(asset);
return asset;
}
public async Task<FlashcardMediaAsset?> GetAsync(string userId, string id)
{
if (!ObjectId.TryParse(id, out _)) return null;
return await _assets.Find(v => v.UserId == userId && v.Id == id).FirstOrDefaultAsync();
}
public Task<long> DeleteAsync(string userId, string id) =>
_assets.DeleteOneAsync(v => v.UserId == userId && v.Id == id).ContinueWith(t => t.Result.DeletedCount);
}
@@ -1,239 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed record FlashcardNoteUpdateResult(FlashcardNote? Note, FlashcardNote? Current)
{
public bool Succeeded => Note is not null;
}
public sealed class FlashcardNoteStore
{
private readonly IMongoCollection<FlashcardNote> _notes;
private readonly FlashcardScheduleStore _schedules;
public FlashcardNoteStore(MongoDatabaseProvider mongo, FlashcardScheduleStore schedules)
{
_notes = mongo.Database.GetCollection<FlashcardNote>("FlashcardNotes");
_schedules = schedules;
_notes.Indexes.CreateMany([
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId),
new CreateIndexOptions { Unique = true, Name = "flashcard_note_unique_user_stable" }),
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.DeckId),
new CreateIndexOptions { Name = "flashcard_note_user_deck" }),
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.SourceQuestionExternalId),
new CreateIndexOptions { Name = "flashcard_note_user_source_question" }),
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.ContentFingerprint),
new CreateIndexOptions { Name = "flashcard_note_user_fingerprint" })
]);
}
public static string Fingerprint(string type, string prompt, string answer) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{type}{prompt.Trim()}{answer.Trim()}")));
public async Task<FlashcardNote?> GetAsync(string userId, string noteId) =>
await _notes.Find(v => v.UserId == userId && v.Id == noteId).FirstOrDefaultAsync();
public async Task<FlashcardNote?> GetByStableIdAsync(string userId, string stableId) =>
await _notes.Find(v => v.UserId == userId && v.StableId == stableId).FirstOrDefaultAsync();
public Task<List<FlashcardNote>> ListByDeckAsync(string userId, string deckId) =>
_notes.Find(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync();
public Task<List<FlashcardNote>> ListAllActiveAsync(string userId) =>
_notes.Find(v => v.UserId == userId && v.ArchivedUtc == null).ToListAsync();
// Read-only: the caller's own notes plus every system (shared, admin-curated) note. Never
// used to back a write path - mutations must stay strictly own-user via the methods above.
public Task<List<FlashcardNote>> ListByDeckVisibleAsync(string userId, string deckId) =>
_notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System)
&& v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync();
public Task<List<FlashcardNote>> ListAllVisibleActiveAsync(string userId) =>
_notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null)
.ToListAsync();
public async Task<FlashcardNote?> GetVisibleAsync(string userId, string noteId) =>
await _notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.Id == noteId)
.FirstOrDefaultAsync();
// No owner filter at all - used only by the controller's mutation guard to check IsSystem
// regardless of who's asking.
public async Task<FlashcardNote?> GetAnyAsync(string noteId) =>
await _notes.Find(v => v.Id == noteId).FirstOrDefaultAsync();
public async Task<(List<FlashcardNote> Items, int Total)> SearchVisibleAsync(string userId, FlashcardLibraryQuery query)
{
var filter = Builders<FlashcardNote>.Filter.Or(
Builders<FlashcardNote>.Filter.Eq(v => v.UserId, userId),
Builders<FlashcardNote>.Filter.Eq(v => v.UserId, FlashcardOwners.System));
if (query.State != FlashcardArchiveFilter.IncludeArchived)
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.ArchivedUtc, null);
if (!string.IsNullOrWhiteSpace(query.DeckId))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.DeckId, query.DeckId);
if (!string.IsNullOrWhiteSpace(query.Type))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.Type, query.Type);
if (!string.IsNullOrWhiteSpace(query.Tag))
filter &= Builders<FlashcardNote>.Filter.AnyEq(v => v.Tags, query.Tag);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var pattern = new MongoDB.Bson.BsonRegularExpression(Regex.Escape(query.Search), "i");
filter &= Builders<FlashcardNote>.Filter.Or(
Builders<FlashcardNote>.Filter.Regex(v => v.Prompt, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Answer, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Extra, pattern));
}
var total = checked((int)await _notes.CountDocumentsAsync(filter));
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var items = await _notes.Find(filter)
.SortByDescending(v => v.UpdatedUtc)
.Skip((page - 1) * pageSize)
.Limit(pageSize)
.ToListAsync();
return (items, total);
}
public async Task<(List<FlashcardNote> Items, int Total)> SearchAsync(string userId, FlashcardLibraryQuery query)
{
var filter = Builders<FlashcardNote>.Filter.Eq(v => v.UserId, userId);
if (query.State != FlashcardArchiveFilter.IncludeArchived)
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.ArchivedUtc, null);
if (!string.IsNullOrWhiteSpace(query.DeckId))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.DeckId, query.DeckId);
if (!string.IsNullOrWhiteSpace(query.Type))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.Type, query.Type);
if (!string.IsNullOrWhiteSpace(query.Tag))
filter &= Builders<FlashcardNote>.Filter.AnyEq(v => v.Tags, query.Tag);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var pattern = new MongoDB.Bson.BsonRegularExpression(Regex.Escape(query.Search), "i");
filter &= Builders<FlashcardNote>.Filter.Or(
Builders<FlashcardNote>.Filter.Regex(v => v.Prompt, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Answer, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Extra, pattern));
}
var total = checked((int)await _notes.CountDocumentsAsync(filter));
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var items = await _notes.Find(filter)
.SortByDescending(v => v.UpdatedUtc)
.Skip((page - 1) * pageSize)
.Limit(pageSize)
.ToListAsync();
return (items, total);
}
public async Task<FlashcardNoteUpdateResult> UpsertAsync(string userId, UpsertFlashcardNoteRequest request, string? noteId)
{
var type = FlashcardTypes.All.Contains(request.Type) ? request.Type : FlashcardTypes.Basic;
if (type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(request.Prompt))
throw new FlashcardValidationException("cloze note must contain at least one {{cN::answer}} marker");
var now = DateTime.UtcNow;
var fingerprint = Fingerprint(type, request.Prompt, request.Answer);
FlashcardNote? saved;
if (string.IsNullOrWhiteSpace(noteId))
{
var created = new FlashcardNote
{
UserId = userId,
StableId = string.IsNullOrWhiteSpace(request.StableId) ? Guid.NewGuid().ToString() : request.StableId,
DeckId = request.DeckId,
Type = type,
Prompt = request.Prompt,
Answer = request.Answer,
Extra = request.Extra,
Tags = NormalizeTags(request.Tags),
Images = request.Images,
SourceQuestionExternalId = request.SourceQuestionExternalId,
ContentFingerprint = fingerprint,
CreatedUtc = now,
UpdatedUtc = now
};
try
{
await _notes.InsertOneAsync(created);
saved = created;
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return new FlashcardNoteUpdateResult(null, await GetByStableIdAsync(userId, created.StableId));
}
}
else
{
var expectedRevision = request.ExpectedRevision ?? 0;
var updated = await _notes.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == noteId && v.Revision == expectedRevision,
Builders<FlashcardNote>.Update
.Set(v => v.DeckId, request.DeckId)
.Set(v => v.Type, type)
.Set(v => v.Prompt, request.Prompt)
.Set(v => v.Answer, request.Answer)
.Set(v => v.Extra, request.Extra)
.Set(v => v.Tags, NormalizeTags(request.Tags))
.Set(v => v.Images, request.Images)
.Set(v => v.SourceQuestionExternalId, request.SourceQuestionExternalId)
.Set(v => v.ContentFingerprint, fingerprint)
.Set(v => v.UpdatedUtc, now)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardNote> { ReturnDocument = ReturnDocument.After });
if (updated is null)
return new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId));
saved = updated;
}
await _schedules.SyncVariantsAsync(saved);
return new FlashcardNoteUpdateResult(saved, null);
}
public async Task<FlashcardNoteUpdateResult> ArchiveAsync(string userId, string noteId, bool archived)
{
var updated = await _notes.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == noteId,
Builders<FlashcardNote>.Update
.Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null)
.Set(v => v.UpdatedUtc, DateTime.UtcNow)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardNote> { ReturnDocument = ReturnDocument.After });
return updated is not null
? new FlashcardNoteUpdateResult(updated, null)
: new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId));
}
public async Task<long> MoveAsync(string userId, IReadOnlyCollection<string> noteIds, string targetDeckId)
{
var result = await _notes.UpdateManyAsync(
v => v.UserId == userId && noteIds.Contains(v.Id!),
Builders<FlashcardNote>.Update.Set(v => v.DeckId, targetDeckId).Set(v => v.UpdatedUtc, DateTime.UtcNow).Inc(v => v.Revision, 1));
return result.ModifiedCount;
}
public Task<long> CountInDeckAsync(string userId, string deckId) =>
_notes.CountDocumentsAsync(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null);
private static List<string> NormalizeTags(List<string> tags) => tags
.Select(v => v.Trim())
.Where(v => v.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
public sealed class FlashcardValidationException(string message) : Exception(message);
public static class FlashcardArchiveFilter
{
public const string IncludeArchived = "archived";
}
@@ -1,8 +0,0 @@
namespace QuestionsApi.Services.Flashcards;
// Pure authorization decision for mutating a flashcard deck/note, kept dependency-free (like
// FlashcardScheduling) so it's unit testable without a controller or Mongo.
public static class FlashcardOwnershipGuard
{
public static bool CanMutate(bool isSuper, bool targetIsSystem) => isSuper || !targetIsSystem;
}
@@ -1,392 +0,0 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public enum FlashcardReviewOutcomeKind { Success, Duplicate, Conflict, NotFound }
public sealed class FlashcardReviewOutcome
{
public FlashcardReviewOutcomeKind Kind { get; init; }
public FlashcardSchedule? Schedule { get; init; }
public static FlashcardReviewOutcome NotFound() => new() { Kind = FlashcardReviewOutcomeKind.NotFound };
public static FlashcardReviewOutcome Conflict(FlashcardSchedule? current) => new() { Kind = FlashcardReviewOutcomeKind.Conflict, Schedule = current };
public static FlashcardReviewOutcome Duplicate(FlashcardSchedule? current) => new() { Kind = FlashcardReviewOutcomeKind.Duplicate, Schedule = current };
public static FlashcardReviewOutcome Success(FlashcardSchedule schedule) => new() { Kind = FlashcardReviewOutcomeKind.Success, Schedule = schedule };
}
public sealed class FlashcardScheduleStore
{
private readonly IMongoCollection<FlashcardSchedule> _schedules;
private readonly IMongoCollection<FlashcardReviewLog> _reviewLogs;
private readonly IFlashcardScheduler _scheduler;
public FlashcardScheduleStore(MongoDatabaseProvider mongo, IFlashcardScheduler scheduler)
{
_schedules = mongo.Database.GetCollection<FlashcardSchedule>("FlashcardSchedules");
_reviewLogs = mongo.Database.GetCollection<FlashcardReviewLog>("FlashcardReviewLogs");
_scheduler = scheduler;
_schedules.Indexes.CreateMany([
new CreateIndexModel<FlashcardSchedule>(
Builders<FlashcardSchedule>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NoteId).Ascending(v => v.Variant),
new CreateIndexOptions { Unique = true, Name = "flashcard_schedule_unique_user_note_variant" }),
new CreateIndexModel<FlashcardSchedule>(
Builders<FlashcardSchedule>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.State).Ascending(v => v.DueUtc),
new CreateIndexOptions { Name = "flashcard_schedule_user_state_due" })
]);
_reviewLogs.Indexes.CreateMany([
new CreateIndexModel<FlashcardReviewLog>(
Builders<FlashcardReviewLog>.IndexKeys.Ascending(v => v.UserId).Descending(v => v.ReviewedUtc),
new CreateIndexOptions { Name = "flashcard_review_log_user_reviewed" }),
new CreateIndexModel<FlashcardReviewLog>(
Builders<FlashcardReviewLog>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.CardId),
new CreateIndexOptions { Name = "flashcard_review_log_user_card" })
]);
}
// Basic notes always have exactly one card (variant 0). Cloze notes get one independently
// scheduled card per distinct {{cN::...}} number found in the prompt. Existing variants are
// never removed on re-sync (even if a cloze number disappears from edited text) so a note
// edit never discards review history.
public async Task SyncVariantsAsync(FlashcardNote note)
{
var desiredVariants = note.Type == FlashcardTypes.Cloze
? FlashcardClozeParser.DistinctClozeNumbers(note.Prompt)
: [0];
if (desiredVariants.Count == 0) return;
var existing = await GetByNoteIdAsync(note.UserId, note.Id!);
var existingVariants = existing.Select(v => v.Variant).ToHashSet();
var now = DateTime.UtcNow;
foreach (var variant in desiredVariants.Where(v => !existingVariants.Contains(v)))
{
var schedule = new FlashcardSchedule
{
UserId = note.UserId,
NoteId = note.Id!,
Variant = variant,
State = FlashcardScheduleStates.New,
DueUtc = now,
CreatedUtc = now
};
try
{
await _schedules.InsertOneAsync(schedule);
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Concurrent save already created this variant.
}
}
}
// Sibling of SyncVariantsAsync: materializes this note's variant schedule rows under a
// DIFFERENT user's ownership (the studying user, not the note's own owner). Used only for
// system notes, so a regular user studying shared content gets independent, per-user FSRS
// state. Deliberately duplicated rather than sharing a parameterized private helper with
// SyncVariantsAsync, which is called eagerly on every regular note save and must stay
// untouched/low-risk.
public async Task SyncVariantsForStudyingUserAsync(string studyingUserId, FlashcardNote note)
{
var desiredVariants = note.Type == FlashcardTypes.Cloze
? FlashcardClozeParser.DistinctClozeNumbers(note.Prompt)
: [0];
if (desiredVariants.Count == 0) return;
var existing = await GetByNoteIdAsync(studyingUserId, note.Id!);
var existingVariants = existing.Select(v => v.Variant).ToHashSet();
var now = DateTime.UtcNow;
foreach (var variant in desiredVariants.Where(v => !existingVariants.Contains(v)))
{
var schedule = new FlashcardSchedule
{
UserId = studyingUserId,
NoteId = note.Id!,
Variant = variant,
State = FlashcardScheduleStates.New,
DueUtc = now,
CreatedUtc = now
};
try
{
await _schedules.InsertOneAsync(schedule);
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Concurrent save already created this variant.
}
}
}
public async Task<FlashcardSchedule?> GetAsync(string userId, string cardId) =>
await _schedules.Find(v => v.UserId == userId && v.Id == cardId).FirstOrDefaultAsync();
public Task<List<FlashcardSchedule>> GetByNoteIdAsync(string userId, string noteId) =>
_schedules.Find(v => v.UserId == userId && v.NoteId == noteId).ToListAsync();
public async Task<List<FlashcardSchedule>> GetDueAsync(
string userId, IReadOnlyCollection<string> noteIds, DateTime nowUtc, DateTime studyDayStartUtc, int limit)
{
if (noteIds.Count == 0 || limit <= 0) return [];
return await _schedules.Find(v => v.UserId == userId
&& noteIds.Contains(v.NoteId)
&& v.State != FlashcardScheduleStates.New
&& !v.IsSuspended
&& (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc)
&& v.DueUtc <= nowUtc)
.SortBy(v => v.DueUtc)
.Limit(limit)
.ToListAsync();
}
public async Task<List<FlashcardSchedule>> GetNewAsync(
string userId, IReadOnlyCollection<string> noteIds, DateTime studyDayStartUtc, int limit)
{
if (noteIds.Count == 0 || limit <= 0) return [];
return await _schedules.Find(v => v.UserId == userId
&& noteIds.Contains(v.NoteId)
&& v.State == FlashcardScheduleStates.New
&& !v.IsSuspended
&& (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc))
.SortBy(v => v.CreatedUtc)
.Limit(limit)
.ToListAsync();
}
public Task<long> CountDueAsync(string userId, IReadOnlyCollection<string> noteIds, DateTime nowUtc, DateTime studyDayStartUtc) =>
noteIds.Count == 0
? Task.FromResult(0L)
: _schedules.CountDocumentsAsync(v => v.UserId == userId
&& noteIds.Contains(v.NoteId)
&& v.State != FlashcardScheduleStates.New
&& !v.IsSuspended
&& (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc)
&& v.DueUtc <= nowUtc);
public Task<long> CountNewAsync(string userId, IReadOnlyCollection<string> noteIds, DateTime studyDayStartUtc) =>
noteIds.Count == 0
? Task.FromResult(0L)
: _schedules.CountDocumentsAsync(v => v.UserId == userId
&& noteIds.Contains(v.NoteId)
&& v.State == FlashcardScheduleStates.New
&& !v.IsSuspended
&& (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc));
public Task<long> CountReviewsSinceAsync(string userId, DateTime sinceUtc) =>
_reviewLogs.CountDocumentsAsync(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null);
public Task<long> CountByStateAsync(string userId, IReadOnlyCollection<string> noteIds, string state) =>
noteIds.Count == 0
? Task.FromResult(0L)
: _schedules.CountDocumentsAsync(v => v.UserId == userId
&& noteIds.Contains(v.NoteId)
&& v.State == state
&& !v.IsSuspended);
// A card counts toward "mastered" once it has graduated to long-term review and reached a
// stability (FSRS's estimate of days until retention drops to ~90%) past this threshold -
// the same rough "won't need attention again soon" cutoff several SRS apps use for a mastery
// stat. Kept separate from the plain Review-state count, which includes recently-graduated
// cards still building stability.
public const double MasteryStabilityDaysThreshold = 21.0;
public Task<long> CountMasteredAsync(string userId, IReadOnlyCollection<string> noteIds) =>
noteIds.Count == 0
? Task.FromResult(0L)
: _schedules.CountDocumentsAsync(v => v.UserId == userId
&& noteIds.Contains(v.NoteId)
&& v.State == FlashcardScheduleStates.Review
&& !v.IsSuspended
&& v.Stability >= MasteryStabilityDaysThreshold);
// Bounded lookback (streaks longer than this are vanishingly rare and this keeps the query
// cheap); returns raw timestamps so the caller can compute consecutive-day streaks in the
// user's own calendar-day semantics.
public Task<List<DateTime>> GetReviewTimestampsSinceAsync(string userId, DateTime sinceUtc) =>
_reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null)
.Project(v => v.ReviewedUtc)
.ToListAsync();
public async Task<int> CountNewIntroducedSinceAsync(string userId, DateTime sinceUtc) =>
checked((int)await _reviewLogs.CountDocumentsAsync(v => v.UserId == userId
&& v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null && v.Before.State == FlashcardScheduleStates.New));
public Task<List<FlashcardReviewLog>> GetReviewsSinceAsync(string userId, DateTime sinceUtc) =>
_reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc).SortBy(v => v.ReviewedUtc).ToListAsync();
public Task<List<FlashcardReviewLog>> GetReviewLogsForCardAsync(string userId, string cardId) =>
_reviewLogs.Find(v => v.UserId == userId && v.CardId == cardId).SortBy(v => v.ReviewedUtc).ToListAsync();
// Used only by JSON import: restores a previously-exported card's FSRS state verbatim
// (not a normal review) so importing preserves history instead of re-deriving it.
public Task ImportScheduleStateAsync(string userId, string noteId, FlashcardSchedule imported) =>
_schedules.UpdateOneAsync(
v => v.UserId == userId && v.NoteId == noteId && v.Variant == imported.Variant,
Builders<FlashcardSchedule>.Update
.Set(v => v.State, imported.State)
.Set(v => v.Step, imported.Step)
.Set(v => v.Stability, imported.Stability)
.Set(v => v.Difficulty, imported.Difficulty)
.Set(v => v.DueUtc, imported.DueUtc)
.Set(v => v.LastReviewUtc, imported.LastReviewUtc)
.Set(v => v.IsSuspended, imported.IsSuspended)
.Set(v => v.BuriedUntilUtc, imported.BuriedUntilUtc)
.Inc(v => v.Revision, 1));
public async Task ImportReviewLogAsync(string userId, string cardId, FlashcardReviewLog imported)
{
var log = new FlashcardReviewLog
{
Id = string.IsNullOrWhiteSpace(imported.Id) ? Guid.NewGuid().ToString() : imported.Id,
UserId = userId,
CardId = cardId,
Rating = imported.Rating,
ReviewedUtc = imported.ReviewedUtc,
DurationSeconds = imported.DurationSeconds,
Before = imported.Before,
After = imported.After,
CreatedUtc = imported.CreatedUtc,
UndoneUtc = imported.UndoneUtc
};
try
{
await _reviewLogs.InsertOneAsync(log);
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Already imported (re-running the same import commit).
}
}
public List<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc) =>
_scheduler.PreviewRatingOptions(schedule, nowUtc);
public async Task<FlashcardReviewOutcome> SubmitReviewAsync(string userId, FlashcardReviewRequest request)
{
var schedule = await GetAsync(userId, request.CardId);
if (schedule is null) return FlashcardReviewOutcome.NotFound();
if (schedule.Revision != request.ExpectedRevision)
{
var priorLog = await _reviewLogs.Find(v => v.Id == request.ClientReviewId && v.UserId == userId).FirstOrDefaultAsync();
return priorLog is not null && priorLog.CardId == request.CardId
? FlashcardReviewOutcome.Duplicate(schedule)
: FlashcardReviewOutcome.Conflict(schedule);
}
var before = Snapshot(schedule);
var result = _scheduler.Review(schedule, request.Rating, request.ReviewedUtc, request.DurationSeconds);
var updated = await _schedules.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == request.CardId && v.Revision == request.ExpectedRevision,
Builders<FlashcardSchedule>.Update
.Set(v => v.State, result.State)
.Set(v => v.Step, result.Step)
.Set(v => v.Stability, result.Stability)
.Set(v => v.Difficulty, result.Difficulty)
.Set(v => v.DueUtc, result.DueUtc)
.Set(v => v.LastReviewUtc, result.LastReviewUtc)
.Set(v => v.AlgorithmVersion, FsrsFlashcardScheduler.AlgorithmVersion)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardSchedule> { ReturnDocument = ReturnDocument.After });
if (updated is null)
{
var raceLog = await _reviewLogs.Find(v => v.Id == request.ClientReviewId && v.UserId == userId).FirstOrDefaultAsync();
var current = await GetAsync(userId, request.CardId);
return raceLog is not null && raceLog.CardId == request.CardId
? FlashcardReviewOutcome.Duplicate(current)
: FlashcardReviewOutcome.Conflict(current);
}
var log = new FlashcardReviewLog
{
Id = request.ClientReviewId,
UserId = userId,
CardId = request.CardId,
Rating = request.Rating,
ReviewedUtc = request.ReviewedUtc,
DurationSeconds = Math.Clamp(request.DurationSeconds, 0, 86_400),
Before = before,
After = Snapshot(updated),
CreatedUtc = DateTime.UtcNow
};
try
{
await _reviewLogs.InsertOneAsync(log);
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// A retried duplicate request already recorded this review; the schedule update
// above only applies once because it matched the pre-review revision exactly.
}
// Bury cloze siblings (basic notes have exactly one variant, so this is a no-op for them).
var buriedUntil = FlashcardScheduling.NextStudyDayStart(request.StudyDayStartUtc);
await _schedules.UpdateManyAsync(
v => v.UserId == userId && v.NoteId == updated.NoteId && v.Id != updated.Id,
Builders<FlashcardSchedule>.Update.Set(v => v.BuriedUntilUtc, buriedUntil));
return FlashcardReviewOutcome.Success(updated);
}
public async Task<FlashcardReviewOutcome> UndoAsync(string userId, string reviewLogId)
{
var log = await _reviewLogs.Find(v => v.Id == reviewLogId && v.UserId == userId && v.UndoneUtc == null).FirstOrDefaultAsync();
if (log is null) return FlashcardReviewOutcome.NotFound();
var current = await GetAsync(userId, log.CardId);
if (current is null) return FlashcardReviewOutcome.NotFound();
if (current.Revision != log.After.Revision)
return FlashcardReviewOutcome.Conflict(current);
var restored = await _schedules.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == log.CardId && v.Revision == log.After.Revision,
Builders<FlashcardSchedule>.Update
.Set(v => v.State, log.Before.State)
.Set(v => v.Step, log.Before.Step)
.Set(v => v.Stability, log.Before.Stability)
.Set(v => v.Difficulty, log.Before.Difficulty)
.Set(v => v.DueUtc, log.Before.DueUtc)
.Set(v => v.LastReviewUtc, log.Before.LastReviewUtc)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardSchedule> { ReturnDocument = ReturnDocument.After });
if (restored is null) return FlashcardReviewOutcome.Conflict(await GetAsync(userId, log.CardId));
await _reviewLogs.UpdateOneAsync(
v => v.Id == reviewLogId,
Builders<FlashcardReviewLog>.Update.Set(v => v.UndoneUtc, DateTime.UtcNow));
return FlashcardReviewOutcome.Success(restored);
}
public async Task<long> SetSuspendedAsync(string userId, IReadOnlyCollection<string> cardIds, bool suspended)
{
if (cardIds.Count == 0) return 0;
var result = await _schedules.UpdateManyAsync(
v => v.UserId == userId && cardIds.Contains(v.Id!),
Builders<FlashcardSchedule>.Update.Set(v => v.IsSuspended, suspended).Inc(v => v.Revision, 1));
return result.ModifiedCount;
}
public async Task<long> BuryAsync(string userId, IReadOnlyCollection<string> cardIds, DateTime untilUtc)
{
if (cardIds.Count == 0) return 0;
var result = await _schedules.UpdateManyAsync(
v => v.UserId == userId && cardIds.Contains(v.Id!),
Builders<FlashcardSchedule>.Update.Set(v => v.BuriedUntilUtc, untilUtc).Inc(v => v.Revision, 1));
return result.ModifiedCount;
}
private static FlashcardScheduleSnapshot Snapshot(FlashcardSchedule schedule) => new()
{
State = schedule.State,
Step = schedule.Step,
Stability = schedule.Stability,
Difficulty = schedule.Difficulty,
DueUtc = schedule.DueUtc,
LastReviewUtc = schedule.LastReviewUtc,
Revision = schedule.Revision
};
}
@@ -1,116 +0,0 @@
using FSRS.Core.Enums;
using FSRS.Core.Models;
using FSRS.Core.Services;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed class FlashcardScheduleResult
{
public string State { get; init; } = FlashcardScheduleStates.Learning;
public int? Step { get; init; }
public double Stability { get; init; }
public double Difficulty { get; init; }
public DateTime DueUtc { get; init; }
public DateTime LastReviewUtc { get; init; }
}
public interface IFlashcardScheduler
{
FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds);
List<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc);
}
// Wraps FSRS.Core 1.0.7 behind an internal interface, pinned to the repo's fixed defaults
// (90% desired retention, 36,500 day max interval, no per-user parameter optimization).
// Fuzzing is disabled so scheduling is deterministic: reproducible for tests/support, and
// so a review's outcome depends only on stored state, never on wall-clock randomness.
public sealed class FsrsFlashcardScheduler : IFlashcardScheduler
{
public const string AlgorithmVersion = "fsrs-core-1.0.7";
public const double DesiredRetention = 0.90;
public const int MaximumIntervalDays = 36_500;
private readonly Scheduler _scheduler = new(
desiredRetention: DesiredRetention,
maximumInterval: MaximumIntervalDays,
enableFuzzing: false);
public FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds)
{
var card = ToCard(schedule);
var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), reviewedUtc, Math.Max(0, durationSeconds) * 1000);
return FromCard(updated);
}
public List<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc)
{
var options = new List<FlashcardRatingOption>();
foreach (var rating in FlashcardRatings.All)
{
var card = ToCard(schedule);
var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), nowUtc);
options.Add(new FlashcardRatingOption
{
Rating = rating,
DueUtc = updated.Due,
IntervalLabel = FormatInterval(updated.Due - nowUtc)
});
}
return options;
}
private static Card ToCard(FlashcardSchedule schedule) =>
schedule.LastReviewUtc is null
? new Card()
: new Card(
state: ParseState(schedule.State),
step: schedule.Step,
stability: schedule.Stability,
difficulty: schedule.Difficulty,
due: schedule.DueUtc,
lastReview: schedule.LastReviewUtc);
private static FlashcardScheduleResult FromCard(Card card) => new()
{
State = FormatState(card.State),
Step = card.Step,
Stability = card.Stability ?? 0,
Difficulty = card.Difficulty ?? 0,
DueUtc = card.Due,
LastReviewUtc = card.LastReview ?? DateTime.UtcNow
};
private static Rating ParseRating(string rating) => rating switch
{
FlashcardRatings.Again => Rating.Again,
FlashcardRatings.Hard => Rating.Hard,
FlashcardRatings.Good => Rating.Good,
FlashcardRatings.Easy => Rating.Easy,
_ => throw new ArgumentOutOfRangeException(nameof(rating), rating, "unknown flashcard rating")
};
private static State ParseState(string state) => state switch
{
FlashcardScheduleStates.Review => State.Review,
FlashcardScheduleStates.Relearning => State.Relearning,
_ => State.Learning
};
private static string FormatState(State state) => state switch
{
State.Review => FlashcardScheduleStates.Review,
State.Relearning => FlashcardScheduleStates.Relearning,
_ => FlashcardScheduleStates.Learning
};
public static string FormatInterval(TimeSpan span)
{
if (span.TotalMinutes < 1) return "<1m";
if (span.TotalHours < 1) return $"{(int)Math.Round(span.TotalMinutes)}m";
if (span.TotalDays < 1) return $"{(int)Math.Round(span.TotalHours)}h";
if (span.TotalDays < 30) return $"{(int)Math.Round(span.TotalDays)}d";
if (span.TotalDays < 365) return $"{Math.Round(span.TotalDays / 30, 1)}mo";
return $"{Math.Round(span.TotalDays / 365, 1)}y";
}
}
@@ -1,54 +0,0 @@
namespace QuestionsApi.Services.Flashcards;
// Small pure helpers pulled out of FlashcardStudyService/FlashcardScheduleStore so the
// daily-limit and sibling-bury rules can be unit tested without a live Mongo instance.
public static class FlashcardScheduling
{
// A card reviewed on a given local study day buries its siblings until the next local day.
public static DateTime NextStudyDayStart(DateTime studyDayStartUtc) => studyDayStartUtc.Date.AddDays(1);
// Effective per-day budget: deck override wins over the global default, minus what's
// already been consumed on the client's current local study day.
public static int ResolveDailyBudget(int globalLimit, int? deckOverride, int consumedToday) =>
Math.Max(0, (deckOverride ?? globalLimit) - consumedToday);
// A streak counts consecutive calendar days with at least one review, ending either today or
// yesterday (so the streak doesn't visibly reset to 0 the moment a new day starts before
// today's first review) - the same semantics as most habit-streak trackers.
public static int ComputeStreakDays(IReadOnlyCollection<DateTime> reviewTimestamps, DateTime todayUtc)
{
var reviewDays = reviewTimestamps.Select(v => v.Date).ToHashSet();
var cursor = todayUtc.Date;
if (!reviewDays.Contains(cursor))
{
cursor = cursor.AddDays(-1);
if (!reviewDays.Contains(cursor)) return 0;
}
var streak = 0;
while (reviewDays.Contains(cursor))
{
streak++;
cursor = cursor.AddDays(-1);
}
return streak;
}
// Picks which candidate in an already-sorted pool becomes the next card. Non-shuffle: the
// first not already excluded this session (today's deterministic oldest-first behavior).
// Shuffle: a random pick among the non-excluded candidates - `randomIndex(n)` must return a
// value in [0, n); production passes Random.Shared.Next, tests pass a fixed stub so the
// excluded-id-to-original-index mapping is verifiable without relying on real randomness.
public static int? PickIndex(IReadOnlyList<string> poolIds, ISet<string> exclude, bool shuffle, Func<int, int> randomIndex)
{
if (!shuffle)
{
for (var i = 0; i < poolIds.Count; i++)
if (!exclude.Contains(poolIds[i])) return i;
return null;
}
var candidates = new List<int>();
for (var i = 0; i < poolIds.Count; i++)
if (!exclude.Contains(poolIds[i])) candidates.Add(i);
return candidates.Count == 0 ? null : candidates[randomIndex(candidates.Count)];
}
}
@@ -1,36 +0,0 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed class FlashcardSettingsStore
{
private readonly IMongoCollection<FlashcardSettings> _settings;
public FlashcardSettingsStore(MongoDatabaseProvider mongo)
{
_settings = mongo.Database.GetCollection<FlashcardSettings>("FlashcardSettings");
_settings.Indexes.CreateOne(new CreateIndexModel<FlashcardSettings>(
Builders<FlashcardSettings>.IndexKeys.Ascending(v => v.UserId),
new CreateIndexOptions { Unique = true, Name = "flashcard_settings_unique_user" }));
}
public async Task<FlashcardSettings> GetAsync(string userId) =>
await _settings.Find(v => v.UserId == userId).FirstOrDefaultAsync()
?? new FlashcardSettings { UserId = userId };
public async Task<FlashcardSettings> UpdateAsync(string userId, UpdateFlashcardSettingsRequest request)
{
var now = DateTime.UtcNow;
return await _settings.FindOneAndUpdateAsync(
v => v.UserId == userId,
Builders<FlashcardSettings>.Update
.Set(v => v.NewCardsPerDay, Math.Clamp(request.NewCardsPerDay, 0, 2000))
.Set(v => v.ReviewsPerDay, Math.Clamp(request.ReviewsPerDay, 0, 5000))
.Set(v => v.DesiredRetention, FsrsFlashcardScheduler.DesiredRetention)
.Set(v => v.UpdatedUtc, now)
.Inc(v => v.Revision, 1)
.SetOnInsert(v => v.UserId, userId),
new FindOneAndUpdateOptions<FlashcardSettings> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
}
}
@@ -1,158 +0,0 @@
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
// Composes decks/notes/schedules/settings to answer "what's next" and daily-count questions.
// Kept separate from the individual stores to avoid a circular dependency (notes need to
// sync schedule variants; schedules must not need to know about notes/decks).
public sealed class FlashcardStudyService(
FlashcardDeckStore decks,
FlashcardNoteStore notes,
FlashcardScheduleStore schedules,
FlashcardSettingsStore settingsStore)
{
// Extra rows fetched beyond the budget when shuffling, so there's an actual pool to
// randomize over instead of just enough rows to skip past this session's exclusions.
private const int ShuffleCandidatePoolPadding = 40;
public async Task<FlashcardStudyCardResponse> GetNextCardAsync(string userId, FlashcardStudyRequest request)
{
var now = DateTime.UtcNow;
var studyDayStart = request.StudyDayStartUtc.Date;
var noteIds = await ResolveNoteIdsAsync(userId, request.DeckId);
var (newBudget, reviewBudget) = await ResolveBudgetsAsync(userId, request.DeckId, studyDayStart);
var exclude = request.ExcludeCardIds.Where(v => !string.IsNullOrWhiteSpace(v)).ToHashSet(StringComparer.Ordinal);
// Overdue/due cards first (oldest due first, or shuffled within the due pool), then new
// cards (oldest first, or shuffled within the new pool) - shuffle never lets a new card
// jump ahead of a due one, it only randomizes selection within each pool.
FlashcardSchedule? card = null;
if (reviewBudget > 0)
{
var dueLimit = (request.Shuffle ? Math.Max(reviewBudget, ShuffleCandidatePoolPadding) : reviewBudget) + exclude.Count + 1;
var due = await schedules.GetDueAsync(userId, noteIds, now, studyDayStart, dueLimit);
var pickedIndex = FlashcardScheduling.PickIndex(due.Select(v => v.Id!).ToList(), exclude, request.Shuffle, Random.Shared.Next);
if (pickedIndex is int dueIndex) card = due[dueIndex];
}
if (card is null && newBudget > 0)
{
var newLimit = (request.Shuffle ? Math.Max(newBudget, ShuffleCandidatePoolPadding) : newBudget) + exclude.Count + 1;
var fresh = await schedules.GetNewAsync(userId, noteIds, studyDayStart, newLimit);
var pickedIndex = FlashcardScheduling.PickIndex(fresh.Select(v => v.Id!).ToList(), exclude, request.Shuffle, Random.Shared.Next);
if (pickedIndex is int newIndex) card = fresh[newIndex];
}
var dueRemaining = checked((int)await schedules.CountDueAsync(userId, noteIds, now, studyDayStart));
var newRemaining = checked((int)await schedules.CountNewAsync(userId, noteIds, studyDayStart));
var response = new FlashcardStudyCardResponse
{
DueRemaining = Math.Min(dueRemaining, reviewBudget),
NewRemaining = Math.Min(newRemaining, newBudget)
};
if (card is null) return response;
var note = await notes.GetVisibleAsync(userId, card.NoteId);
if (note is null) return response;
response.Note = note;
response.Card = card;
response.RatingOptions = schedules.PreviewRatingOptions(card, now);
return response;
}
public async Task<FlashcardDashboardCounts> GetDashboardCountsAsync(string userId, DateTime nowUtc, DateTime weekStartUtc)
{
var studyDayStart = nowUtc.Date;
var noteIds = await ResolveNoteIdsAsync(userId, "");
var (newBudget, _) = await ResolveBudgetsAsync(userId, "", studyDayStart);
var due = checked((int)await schedules.CountDueAsync(userId, noteIds, nowUtc, studyDayStart));
var newAvailable = Math.Min(checked((int)await schedules.CountNewAsync(userId, noteIds, studyDayStart)), newBudget);
var reviewedThisWeek = checked((int)await schedules.CountReviewsSinceAsync(userId, weekStartUtc));
var learning = checked((int)await schedules.CountByStateAsync(userId, noteIds, FlashcardScheduleStates.Learning))
+ checked((int)await schedules.CountByStateAsync(userId, noteIds, FlashcardScheduleStates.Relearning));
var review = checked((int)await schedules.CountByStateAsync(userId, noteIds, FlashcardScheduleStates.Review));
var reviewsToday = checked((int)await schedules.CountReviewsSinceAsync(userId, studyDayStart));
var streakTimestamps = await schedules.GetReviewTimestampsSinceAsync(userId, nowUtc.AddDays(-400));
var settings = await settingsStore.GetAsync(userId);
return new FlashcardDashboardCounts
{
Due = due,
NewAvailable = newAvailable,
ReviewedThisWeek = reviewedThisWeek,
Learning = learning,
Review = review,
CurrentStreakDays = FlashcardScheduling.ComputeStreakDays(streakTimestamps, nowUtc.Date),
ReviewsToday = reviewsToday,
DailyGoal = settings.ReviewsPerDay
};
}
public async Task<List<FlashcardDeckSummary>> GetDeckSummariesAsync(string userId, DateTime nowUtc, DateTime studyDayStart)
{
var deckList = await decks.ListVisibleAsync(userId);
var result = new List<FlashcardDeckSummary>();
foreach (var deck in deckList)
{
var deckNotes = await notes.ListByDeckVisibleAsync(userId, deck.Id!);
await EnsureSystemScheduleRowsAsync(userId, deckNotes);
var deckNoteIds = deckNotes.Select(v => v.Id!).ToList();
var due = checked((int)await schedules.CountDueAsync(userId, deckNoteIds, nowUtc, studyDayStart));
var fresh = checked((int)await schedules.CountNewAsync(userId, deckNoteIds, studyDayStart));
var learning = checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Learning))
+ checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Relearning));
var review = checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Review));
var mastered = checked((int)await schedules.CountMasteredAsync(userId, deckNoteIds));
result.Add(new FlashcardDeckSummary
{
Deck = deck,
Total = deckNoteIds.Count,
Due = due,
New = fresh,
Learning = learning,
Review = review,
Mastered = mastered
});
}
return result;
}
private async Task<List<string>> ResolveNoteIdsAsync(string userId, string deckId)
{
var activeNotes = string.IsNullOrWhiteSpace(deckId)
? await notes.ListAllVisibleActiveAsync(userId)
: await notes.ListByDeckVisibleAsync(userId, deckId);
await EnsureSystemScheduleRowsAsync(userId, activeNotes);
return activeNotes.Select(v => v.Id!).ToList();
}
// Lazily materializes this user's own FlashcardSchedule rows for any note they don't own
// (i.e. system notes) before it's counted/served. A no-op once a user has touched a given
// system note (SyncVariantsForStudyingUserAsync is duplicate-key-tolerant and additive-only).
private async Task EnsureSystemScheduleRowsAsync(string userId, List<FlashcardNote> activeNotes)
{
foreach (var note in activeNotes.Where(v => v.IsSystem))
await schedules.SyncVariantsForStudyingUserAsync(userId, note);
}
private async Task<(int NewBudget, int ReviewBudget)> ResolveBudgetsAsync(string userId, string deckId, DateTime studyDayStart)
{
var settings = await settingsStore.GetAsync(userId);
var newLimit = settings.NewCardsPerDay;
var reviewLimit = settings.ReviewsPerDay;
if (!string.IsNullOrWhiteSpace(deckId))
{
var deck = await decks.GetVisibleAsync(userId, deckId);
if (deck is not null)
{
newLimit = deck.NewCardsPerDay ?? newLimit;
reviewLimit = deck.ReviewsPerDay ?? reviewLimit;
}
}
var reviewedToday = checked((int)await schedules.CountReviewsSinceAsync(userId, studyDayStart));
var newIntroducedToday = await schedules.CountNewIntroducedSinceAsync(userId, studyDayStart);
return (
FlashcardScheduling.ResolveDailyBudget(newLimit, null, newIntroducedToday),
FlashcardScheduling.ResolveDailyBudget(reviewLimit, null, reviewedToday));
}
}
@@ -1,86 +0,0 @@
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")
};
}
@@ -1,109 +0,0 @@
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 }
};
}
}
@@ -1,42 +0,0 @@
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;
}

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