209 changed files with 1379 additions and 15884 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/
-54
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()
{
@@ -143,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()
{
@@ -1,21 +0,0 @@
using QuestionsApi.Models;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class CreditEconomyTests
{
[Fact]
public void EarningPolicy_UsesTheAgreedRollingLimit()
{
Assert.Equal(20, CreditEconomy.RollingEarningLimit);
Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow);
}
[Fact]
public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers()
{
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId)));
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings)));
}
}
-203
View File
@@ -1,4 +1,3 @@
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
@@ -28,206 +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")]
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" }]
};
}
}
@@ -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,49 +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);
}
}
@@ -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);
}
}
@@ -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,20 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/credits")]
[Authorize(Roles = "USER,SUPER")]
public sealed class CreditsController(CreditStore credits) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
}
@@ -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
?? "";
}
@@ -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
@@ -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,7 +19,6 @@ public class QuestionsController : ControllerBase
private readonly ServiceMetrics _metrics;
private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue;
private readonly CreditStore _credits;
public QuestionsController(
QuestionStore questions,
@@ -29,8 +28,7 @@ public class QuestionsController : ControllerBase
AnswerStatisticsStore statistics,
ServiceMetrics metrics,
QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue,
CreditStore credits)
ReviewQueueStore reviewQueue)
{
_questions = questions;
_attempts = attempts;
@@ -40,7 +38,6 @@ public class QuestionsController : ControllerBase
_metrics = metrics;
_bookmarks = bookmarks;
_reviewQueue = reviewQueue;
_credits = credits;
}
[HttpGet("systems")]
@@ -97,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);
@@ -122,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");
@@ -175,8 +172,6 @@ public class QuestionsController : ControllerBase
_metrics.AttemptDuplicate();
}
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (record.Inserted && !User.IsInRole("SUPER"))
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
var ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
@@ -192,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
{
@@ -209,8 +204,7 @@ public class QuestionsController : ControllerBase
question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating,
Credits = await _credits.GetStatusAsync(userId)
Rating = rating
});
}
@@ -264,7 +258,7 @@ 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();
@@ -292,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
{
@@ -410,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)
+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 -82
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;
}
@@ -80,19 +57,11 @@ public class CcsInitialChart
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; } = "";
@@ -106,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; } = [];
}
@@ -143,22 +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 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;
@@ -179,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; } = [];
@@ -190,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; }
@@ -252,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
@@ -264,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; } = "";
@@ -298,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; }
@@ -311,12 +236,9 @@ 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; } = [];
@@ -349,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;
}
-35
View File
@@ -1,35 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class CreditEconomy
{
public const int RollingEarningLimit = 20;
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
}
public sealed class CreditEvent
{
public string OperationId { 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 List<CreditEvent> RecentEarnings { 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;
}
@@ -10,23 +10,12 @@ 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; } = [];
@@ -39,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
@@ -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; }
}
-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; } = "";
}
-3
View File
@@ -30,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;
}
@@ -19,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; }
@@ -38,11 +38,6 @@ 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; } = "";
@@ -111,37 +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 class SkipQuestionResponse
-2
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;
}
@@ -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;
}
}
-47
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,38 +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;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
@@ -66,18 +33,12 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
});
builder.Services.AddSingleton<MongoDatabaseProvider>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CreditStore>();
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>();
@@ -88,14 +49,6 @@ builder.Services.AddSingleton<QuestionBookmarkStore>();
builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>();
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>();
-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" />
+3 -34
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", "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");
@@ -51,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();
@@ -80,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;
}
@@ -94,29 +88,4 @@ public static class CcsCaseValidator
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;
}
}
@@ -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)
+23 -220
View File
@@ -115,22 +115,16 @@ public sealed class CcsStore
CaseExternalId = definition.ExternalId,
CaseRevisionId = definition.Id ?? "",
CaseVersion = definition.Version,
Mode = CcsAttemptModes.Timed,
OpeningCompleted = false,
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); }
@@ -179,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);
}
@@ -217,16 +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 "end_case": EnterFinalOrders(attempt); break;
case "exit_case": await CompleteAsync(attempt); break;
}
@@ -238,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);
@@ -389,49 +314,19 @@ 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) }];
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 : [],
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
@@ -447,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.");
@@ -457,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));
}
@@ -489,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)
@@ -501,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)
@@ -529,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)
@@ -552,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);
}
@@ -573,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)
@@ -641,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)
{
@@ -695,25 +511,12 @@ public sealed class CcsStore
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
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()]
};
}
-78
View File
@@ -1,78 +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 TimeProvider _clock;
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
{
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_clock = clock;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
}
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
{
if (string.IsNullOrWhiteSpace(attemptId)) return false;
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserCreditAccount>.Filter;
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
{
new BsonDocument("$size", "$RecentEarnings"),
CreditEconomy.RollingEarningLimit
})));
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.ElemMatch(value => value.RecentEarnings,
earning => earning.OperationId == attemptId)) &
belowCap;
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, 1)
.Push(value => value.RecentEarnings, new CreditEvent
{
OperationId = attemptId,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
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 new CreditStatusResponse
{
Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Count,
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count)
};
}
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.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true });
var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff));
}
}
+41 -255
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,56 +15,19 @@ 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 =
[
"Human Development",
"Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders",
"Behavioral Health",
"Nervous System & Special Senses",
"Skin & Subcutaneous Tissue",
"Musculoskeletal System",
"Cardiovascular System",
"Respiratory System",
"Gastrointestinal System",
"Renal/Urinary & Male Reproductive Systems",
"Pregnancy/Childbirth & Female Reproductive System & Breast",
"Endocrine System",
"Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature",
"Social Sciences: Communication Skills/Ethics/Patient Safety"
];
public DashboardStore(
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)
@@ -73,30 +36,21 @@ public sealed class DashboardStore
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 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);
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));
var ccsAttempts = ccsAttemptsTask.Result;
var simulations = simulationsTask.Result;
@@ -104,51 +58,47 @@ public sealed class DashboardStore
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 = BuildSystemPerformance(answeredAttempts, questionMap);
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 =>
{
@@ -165,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;
@@ -181,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();
@@ -211,152 +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)
{
foreach (var candidate in new[] { question.System, question.PerformanceCategory, question.Discipline })
{
var normalized = NormalizeStep3System(candidate);
if (normalized is not null)
return normalized;
}
return null;
}
public static string? NormalizeStep3System(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var exact = Step3Systems.FirstOrDefault(
system => string.Equals(system, value.Trim(), StringComparison.OrdinalIgnoreCase));
if (exact is not null)
return exact;
var lower = value.Trim().ToLowerInvariant();
if (ContainsAny(lower, "human development", "well patient", "preventive", "health maintenance", "geriatr", "age-related"))
return Step3Systems[0];
if (ContainsAny(lower, "social science", "communication", "ethic", "patient safety", "professionalism", "systems-based"))
return Step3Systems[13];
if (ContainsAny(lower, "pregnan", "obstetric", "gynec", "female reproductive", "breast", "puerper"))
return Step3Systems[10];
if (ContainsAny(lower, "renal", "urinary", "male reproductive", "urolog", "nephro"))
return Step3Systems[9];
if (ContainsAny(lower, "biostat", "epidemi", "population health", "medical literature"))
return Step3Systems[12];
if (ContainsAny(lower, "immune", "immun", "blood", "hemat", "lymph", "multisystem", "infectious"))
return Step3Systems[1];
if (ContainsAny(lower, "behavior", "psychi", "mental health"))
return Step3Systems[2];
if (ContainsAny(lower, "nervous", "neuro", "special sense", "ophthalm", "otolaryng", " ent"))
return Step3Systems[3];
if (ContainsAny(lower, "skin", "derm", "subcutaneous"))
return Step3Systems[4];
if (ContainsAny(lower, "musculo", "orthop", "rheumat"))
return Step3Systems[5];
if (ContainsAny(lower, "cardio", "circulatory"))
return Step3Systems[6];
if (ContainsAny(lower, "resp", "pulmon"))
return Step3Systems[7];
if (ContainsAny(lower, "gastro", "digest", "hepato"))
return Step3Systems[8];
if (ContainsAny(lower, "endocr", "metabolic"))
return Step3Systems[11];
return null;
}
private static bool ContainsAny(string value, params string[] fragments) =>
fragments.Any(value.Contains);
private async Task<List<DashboardRecentActivity>> BuildRecentActivityAsync(
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,448 +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>();
foreach (var distractor in distractors)
{
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;
}
result[distractor.Id] = new WrongAnswerFollowup
{
Prompt = "Which option best addresses the decisive finding in this case?",
Choices =
[
new QuestionChoice { Id = correct.Id, Text = Render(correct.TextTemplate, values) },
new QuestionChoice { Id = distractor.Id, Text = Render(distractor.TextTemplate, values) }
],
CorrectChoiceId = correct.Id,
TeachingPoint = $"{Render(distractor.ExplanationTemplate, values)} {Render(correct.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);
}
}
@@ -454,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
{
@@ -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));
}
}
@@ -103,13 +103,11 @@ public class QuestionAttemptStore
return leased?.RatingLeaseOwner == owner;
}
public Task MarkRatingAppliedAsync(string attemptId, string owner, int userOverallElo) =>
public Task MarkRatingAppliedAsync(string attemptId, string owner) =>
_col.UpdateOneAsync(
attempt => attempt.Id == attemptId && attempt.RatingLeaseOwner == owner,
Builders<QuestionAttempt>.Update
.Set(attempt => attempt.RatingApplied, true)
.Set(attempt => attempt.UserOverallEloAfter, userOverallElo)
.Set(attempt => attempt.RatingAppliedUtc, DateTime.UtcNow)
.Set(attempt => attempt.RatingLeaseUntilUtc, DateTime.MinValue));
public Task ReleaseRatingLeaseAsync(string attemptId, string owner) =>
@@ -105,11 +105,7 @@ public sealed class QuestionBookmarkStore
}
var questionMatch = new BsonDocument();
if (!includeUnpublished)
{
questionMatch["Question.IsPublished"] = true;
questionMatch["Question.FlaggedForReviewUtc"] = BsonNull.Value;
}
if (!includeUnpublished) questionMatch["Question.IsPublished"] = true;
if (!string.IsNullOrWhiteSpace(system) && system != "All Systems") questionMatch["Question.System"] = system.Trim();
if (!string.IsNullOrWhiteSpace(discipline)) questionMatch["Question.Discipline"] = discipline.Trim();
if (!string.IsNullOrWhiteSpace(query))
@@ -176,11 +172,7 @@ public sealed class QuestionBookmarkStore
new("$unwind", "$Question")
};
if (!includeUnpublished)
pipeline.Add(new BsonDocument("$match", new BsonDocument
{
{ "Question.IsPublished", true },
{ "Question.FlaggedForReviewUtc", BsonNull.Value }
}));
pipeline.Add(new BsonDocument("$match", new BsonDocument("Question.IsPublished", true)));
pipeline.Add(new BsonDocument("$group", new BsonDocument
{
{ "_id", BsonNull.Value },
@@ -79,39 +79,6 @@ public class QuestionReportStore
return report;
}
public async Task<QuestionReport> CreateDynamicAsync(
DynamicGenerationClaims claims,
string userId,
string category,
string details)
{
var now = DateTime.UtcNow;
var report = new QuestionReport
{
QuestionExternalId = $"dynamic:{claims.BlueprintExternalId}:v{claims.BlueprintVersion}",
UserId = userId,
Category = category,
Details = details,
Status = QuestionReportStatus.Open,
SourceType = "dynamic",
DynamicGeneration = new DynamicGenerationDescriptor
{
BlueprintExternalId = claims.BlueprintExternalId,
BlueprintVersion = claims.BlueprintVersion,
GeneratorVersion = claims.GeneratorVersion,
Seed = claims.Seed,
CaseFamilyId = claims.CaseFamilyId,
DifficultyTier = claims.DifficultyTier,
FamilyElo = claims.FamilyElo,
EffectiveAbilityElo = claims.EffectiveAbilityElo
},
CreatedUtc = now,
UpdatedUtc = now
};
await _reports.InsertOneAsync(report);
return report;
}
public async Task<QuestionReport?> GetByIdAsync(string id)
{
if (!ObjectId.TryParse(id, out _))
+2 -68
View File
@@ -6,9 +6,6 @@ namespace QuestionsApi.Services;
public class QuestionStore
{
private const string MediaReviewReason = "Contains media - pending review";
private const string MediaReviewFlaggedBy = "system";
private readonly IMongoCollection<Question> _col;
public QuestionStore(MongoDatabaseProvider mongo)
@@ -29,10 +26,7 @@ public class QuestionStore
var filters = new List<FilterDefinition<Question>>();
if (!includeUnpublished)
{
filters.Add(builder.Eq(q => q.IsPublished, true));
filters.Add(builder.Eq(q => q.FlaggedForReviewUtc, null));
}
if (!string.IsNullOrWhiteSpace(req.System) && req.System != "All Systems")
filters.Add(builder.Eq(q => q.System, req.System.Trim()));
if (!string.IsNullOrWhiteSpace(req.Discipline))
@@ -71,7 +65,7 @@ public class QuestionStore
await _col.Find(Builders<Question>.Filter.Empty).ToListAsync();
public async Task<List<Question>> GetPublishedAsync() =>
await _col.Find(question => question.IsPublished && question.FlaggedForReviewUtc == null).ToListAsync();
await _col.Find(question => question.IsPublished).ToListAsync();
public async Task<Dictionary<string, Question>> GetByExternalIdsAsync(IEnumerable<string> externalIds)
{
@@ -108,7 +102,6 @@ public class QuestionStore
var question = FromRequest(req);
question.CreatedUtc = DateTime.UtcNow;
question.UpdatedUtc = DateTime.UtcNow;
ApplyMediaReviewGate(question);
try
{
@@ -132,10 +125,6 @@ public class QuestionStore
return null;
question.CreatedUtc = existing.CreatedUtc;
question.FlaggedForReviewUtc = existing.FlaggedForReviewUtc;
question.FlagReason = existing.FlagReason;
question.FlaggedByUserId = existing.FlaggedByUserId;
ApplyMediaReviewGate(question);
var result = await _col.ReplaceOneAsync(q => q.Id == id, question);
return result.MatchedCount == 0 ? null : question;
}
@@ -154,16 +143,12 @@ public class QuestionStore
{
question.Id = existing.Id;
question.CreatedUtc = existing.CreatedUtc;
question.FlaggedForReviewUtc = existing.FlaggedForReviewUtc;
question.FlagReason = existing.FlagReason;
question.FlaggedByUserId = existing.FlaggedByUserId;
}
else
{
question.Id = ObjectId.GenerateNewId().ToString();
question.CreatedUtc = DateTime.UtcNow;
}
ApplyMediaReviewGate(question);
await _col.ReplaceOneAsync(filter, question, new ReplaceOptions { IsUpsert = true });
count++;
@@ -182,7 +167,7 @@ public class QuestionStore
{
var filter = includeUnpublished
? Builders<Question>.Filter.Empty
: Builders<Question>.Filter.Eq(q => q.IsPublished, true) & Builders<Question>.Filter.Eq(q => q.FlaggedForReviewUtc, null);
: Builders<Question>.Filter.Eq(q => q.IsPublished, true);
var systems = await _col.Distinct(q => q.System, filter).ToListAsync();
return systems.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToList();
}
@@ -196,45 +181,6 @@ public class QuestionStore
return requested.Except(existing, StringComparer.Ordinal).OrderBy(id => id).ToList();
}
// One-time bulk sweep: flags every question that has at least one image and isn't already
// flagged, in a single update rather than fetching everything into memory.
public async Task<long> FlagAllWithMediaAsync(string flaggedByUserId, string reason)
{
var filter = Builders<Question>.Filter.Exists("Media.0", true)
& Builders<Question>.Filter.Eq(q => q.FlaggedForReviewUtc, null);
var update = Builders<Question>.Update
.Set(q => q.FlaggedForReviewUtc, DateTime.UtcNow)
.Set(q => q.FlagReason, reason)
.Set(q => q.FlaggedByUserId, flaggedByUserId);
var result = await _col.UpdateManyAsync(filter, update);
return result.ModifiedCount;
}
public async Task<Question?> FlagAsync(string id, string reason, string flaggedByUserId)
{
var update = Builders<Question>.Update
.Set(q => q.FlaggedForReviewUtc, DateTime.UtcNow)
.Set(q => q.FlagReason, reason)
.Set(q => q.FlaggedByUserId, flaggedByUserId);
return await _col.FindOneAndUpdateAsync(q => q.Id == id, update,
new FindOneAndUpdateOptions<Question> { ReturnDocument = ReturnDocument.After });
}
public async Task<Question?> UnflagAsync(string id)
{
var update = Builders<Question>.Update
.Set(q => q.FlaggedForReviewUtc, null)
.Set(q => q.FlagReason, "")
.Set(q => q.FlaggedByUserId, "");
return await _col.FindOneAndUpdateAsync(q => q.Id == id, update,
new FindOneAndUpdateOptions<Question> { ReturnDocument = ReturnDocument.After });
}
public async Task<List<Question>> GetFlaggedAsync() =>
await _col.Find(q => q.FlaggedForReviewUtc != null)
.SortByDescending(q => q.FlaggedForReviewUtc)
.ToListAsync();
public async Task<int> UpdateReferencesByExternalIdAsync(IEnumerable<QuestionReferencesUpdate> updates)
{
var now = DateTime.UtcNow;
@@ -252,18 +198,6 @@ public class QuestionStore
return (int)result.MatchedCount;
}
// Questions with media must be reviewed before reaching students - gate any question that
// gains media and isn't already flagged, so newly created/imported/edited questions can't
// slip past the media-review requirement the way a one-time sweep alone would miss.
internal static void ApplyMediaReviewGate(Question question)
{
if (question.Media.Count == 0 || question.FlaggedForReviewUtc is not null)
return;
question.FlaggedForReviewUtc = DateTime.UtcNow;
question.FlagReason = MediaReviewReason;
question.FlaggedByUserId = MediaReviewFlaggedBy;
}
private static Question FromRequest(UpsertQuestionRequest req) => new()
{
ExternalId = req.ExternalId.Trim(),
@@ -1,9 +0,0 @@
namespace QuestionsApi.Services;
// Pure student-visibility decision for a question, kept dependency-free (like FlashcardScheduling)
// so it's unit testable without a controller or Mongo. Consolidates what used to be duplicated
// inline across QuestionsController/QuestionReportsController.
public static class QuestionVisibility
{
public static bool IsVisibleToStudent(bool isPublished, bool isFlagged) => isPublished && !isFlagged;
}
-27
View File
@@ -35,12 +35,6 @@ public class RatingStore
};
}
public async Task<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
{
var rating = await GetOrCreateUserRatingAsync(userId);
return await EnsureHistoryBaselineAsync(rating, nowUtc);
}
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
{
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
@@ -77,7 +71,6 @@ public class RatingStore
};
}
userRating = await EnsureHistoryBaselineAsync(userRating, DateTime.UtcNow);
var actual = isCorrect ? 1.0 : 0.0;
var userK = userRating.RatedQuestionCount < 20 ? 40 : 24;
var questionK = questionRating.AttemptCount < 20 ? 32 : 24;
@@ -171,26 +164,6 @@ public class RatingStore
return rating;
}
private async Task<UserRating> EnsureHistoryBaselineAsync(UserRating rating, DateTime nowUtc)
{
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
return rating;
var result = await _users.UpdateOneAsync(
value => value.UserId == rating.UserId && value.HistoryStartedUtc == null,
Builders<UserRating>.Update
.Set(value => value.HistoryBaselineElo, rating.OverallElo)
.Set(value => value.HistoryStartedUtc, nowUtc));
if (result.ModifiedCount > 0)
{
rating.HistoryBaselineElo = rating.OverallElo;
rating.HistoryStartedUtc = nowUtc;
return rating;
}
return await _users.Find(value => value.UserId == rating.UserId).FirstAsync();
}
private async Task<QuestionRating> GetOrCreateQuestionRatingAsync(string questionExternalId)
{
var create = Builders<QuestionRating>.Update
@@ -129,7 +129,7 @@ public sealed class ReviewQueueStore
foreach (var candidate in candidates)
{
var question = await _questions.GetByExternalIdAsync(candidate.QuestionExternalId);
if (question is { IsPublished: true, FlaggedForReviewUtc: null }) return candidate;
if (question is { IsPublished: true }) return candidate;
await _items.UpdateOneAsync(
value => value.Id == candidate.Id && value.State == ReviewQueueStates.Pending,
Builders<ReviewQueueItem>.Update
@@ -1,62 +0,0 @@
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
namespace QuestionsApi.Services;
// Idempotent, single-target seed for testing "system" (shared, admin-curated) flashcards.
// Unlike ContentPromotionCommand (cross-environment copy, source+target connections), this only
// ever writes to the one database the app is already configured against, using the exact same
// store methods a real user's create/edit path uses - just under the reserved FlashcardOwners.System
// identity - so seeded content is created/updated through the same validation and revision
// handling as any other deck/note.
public static class SeedFlashcardsCommand
{
public static async Task<int> RunAsync(IConfiguration configuration, TextWriter output)
{
var mongo = new MongoDatabaseProvider(configuration);
var scheduleStore = new FlashcardScheduleStore(mongo, new FsrsFlashcardScheduler());
var deckStore = new FlashcardDeckStore(mongo);
var noteStore = new FlashcardNoteStore(mongo, scheduleStore);
var deck = await UpsertSystemDeckAsync(deckStore, "Step 3 Essentials", "Official curated review deck");
await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Basic,
"A 62-year-old woman presents with sudden, painless, monocular vision loss. What is the diagnosis?",
"Central retinal artery occlusion", "\"Cherry-red spot\" on fundoscopy is the classic finding.");
await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Basic,
"Thyroid storm: correct order of pharmacologic treatment?",
"Beta-blocker (propranolol) first -> thionamide (PTU/methimazole) -> iodine solution (started >=1hr after thionamide) -> glucocorticoids",
"Giving iodine before the thionamide can fuel new hormone synthesis (Wolff-Chaikoff escape risk).");
await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Cloze,
"First-line treatment for uncomplicated hypertension in a Black patient is {{c1::a thiazide diuretic or calcium channel blocker}}.",
"", "");
await output.WriteLineAsync($"Seeded system flashcards into deck '{deck.Name}' ({deck.Id}).");
return 0;
}
private static async Task<FlashcardDeck> UpsertSystemDeckAsync(FlashcardDeckStore deckStore, string name, string description)
{
var existing = await deckStore.GetByNameAsync(FlashcardOwners.System, FlashcardDeckStore.Normalize(name));
var request = new UpsertFlashcardDeckRequest { Name = name, Description = description };
var result = await deckStore.UpsertAsync(FlashcardOwners.System, request, existing?.Id);
return result.Deck ?? existing ?? throw new InvalidOperationException($"failed to seed deck '{name}'");
}
private static async Task UpsertSystemNoteAsync(
FlashcardNoteStore noteStore, string deckId, string type, string prompt, string answer, string extra)
{
var fingerprint = FlashcardNoteStore.Fingerprint(type, prompt, answer);
var existingNotes = await noteStore.ListByDeckAsync(FlashcardOwners.System, deckId);
var existing = existingNotes.FirstOrDefault(v => v.ContentFingerprint == fingerprint);
var request = new UpsertFlashcardNoteRequest
{
DeckId = deckId,
Type = type,
Prompt = prompt,
Answer = answer,
Extra = extra,
ExpectedRevision = existing?.Revision
};
await noteStore.UpsertAsync(FlashcardOwners.System, request, existing?.Id);
}
}
@@ -13,10 +13,6 @@ public sealed class ServiceMetrics
private long _httpRequests;
private long _httpDurationMicroseconds;
private long _answerStatisticsBackfillComplete;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicSelections = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicAttempts = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicCorrect = new();
private long _dynamicTargetGapTotal;
public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
@@ -33,16 +29,6 @@ public sealed class ServiceMetrics
Interlocked.Increment(ref _httpRequests);
Interlocked.Add(ref _httpDurationMicroseconds, (long)(duration.TotalMilliseconds * 1000));
}
public void DynamicSelected(string tier, int targetGap)
{
_dynamicSelections.AddOrUpdate(tier, 1, (_, value) => value + 1);
Interlocked.Add(ref _dynamicTargetGapTotal, Math.Abs(targetGap));
}
public void DynamicAttempt(string tier, bool correct)
{
_dynamicAttempts.AddOrUpdate(tier, 1, (_, value) => value + 1);
if (correct) _dynamicCorrect.AddOrUpdate(tier, 1, (_, value) => value + 1);
}
public string RenderPrometheus()
{
@@ -53,16 +39,6 @@ public sealed class ServiceMetrics
AppendCounter(output, "step3_rating_failures_total", Interlocked.Read(ref _ratingFailures));
AppendCounter(output, "step3_answer_reconciliations_total", Interlocked.Read(ref _reconciliations));
AppendCounter(output, "step3_http_requests_total", Interlocked.Read(ref _httpRequests));
output.AppendLine("# TYPE step3_dynamic_selections_total counter");
output.AppendLine("# TYPE step3_dynamic_attempts_total counter");
output.AppendLine("# TYPE step3_dynamic_correct_total counter");
foreach (var tier in _dynamicSelections.Keys.Order())
{
AppendLabeledSample(output, "step3_dynamic_selections_total", tier, _dynamicSelections.GetValueOrDefault(tier));
AppendLabeledSample(output, "step3_dynamic_attempts_total", tier, _dynamicAttempts.GetValueOrDefault(tier));
AppendLabeledSample(output, "step3_dynamic_correct_total", tier, _dynamicCorrect.GetValueOrDefault(tier));
}
AppendCounter(output, "step3_dynamic_target_gap_points_total", Interlocked.Read(ref _dynamicTargetGapTotal));
output.AppendLine("# TYPE step3_http_request_duration_seconds summary");
output.Append("step3_http_request_duration_seconds_count ")
.Append(Interlocked.Read(ref _httpRequests))
@@ -86,9 +62,4 @@ public sealed class ServiceMetrics
output.Append("# TYPE ").Append(name).AppendLine(" counter");
output.Append(name).Append(' ').Append(value).AppendLine();
}
private static void AppendLabeledSample(StringBuilder output, string name, string tier, long value)
{
output.Append(name).Append("{tier=\"").Append(tier.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
}
}
+1 -19
View File
@@ -14,13 +14,9 @@ public sealed class UserProfileStore
[
"QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads",
"ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates",
"DynamicQuestionAttempts", "DynamicObjectiveMastery", "DynamicReviewItems",
"CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations",
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
"ExternalIdentities", "ExternalAuthTransactions",
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
"UserCreditAccounts"
"ExternalIdentities", "ExternalAuthTransactions"
];
public UserProfileStore(MongoDatabaseProvider mongo)
@@ -52,20 +48,6 @@ public sealed class UserProfileStore
new FindOneAndUpdateOptions<UserStudyProfile> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
}
public async Task<UserStudyProfile> UpdateTextScaleAsync(string userId, UpdateTextScaleRequest request)
{
var update = Builders<UserStudyProfile>.Update.Set(value => value.UpdatedUtc, DateTime.UtcNow);
if (request.QuestionTextScale is { } questionScale)
update = update.Set(value => value.QuestionTextScale, questionScale);
if (request.CcsTextScale is { } ccsScale)
update = update.Set(value => value.CcsTextScale, ccsScale);
update = update.SetOnInsert(value => value.UserId, userId);
return await _profiles.FindOneAndUpdateAsync(
value => value.UserId == userId,
update,
new FindOneAndUpdateOptions<UserStudyProfile> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
}
public Task<bool> IsDeletedAsync(string userId) =>
_deletedUsers.Find(value => value.UserId == userId).AnyAsync();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 KiB

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

After

Width:  |  Height:  |  Size: 2.0 MiB

-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<path fill="#FFC107" d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20c11.045 0 20-8.955 20-20 0-1.341-.138-2.65-.389-3.917z"/>
<path fill="#FF3D00" d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z"/>
<path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238C29.211 35.091 26.715 36 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"/>
<path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303c-.792 2.237-2.231 4.166-4.087 5.571l6.19 5.238C39.971 34.205 44 29 44 24c0-1.341-.138-2.65-.389-3.917z"/>
</svg>

Before

Width:  |  Height:  |  Size: 903 B

-43
View File
@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bc3ttuqtwb8e8"
path="res://.godot/imported/google.svg-96c1802a2f0dcbabfd95c7970a307f34.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/brands/google.svg"
dest_files=["res://.godot/imported/google.svg-96c1802a2f0dcbabfd95c7970a307f34.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff">
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.456-.397.957-1.497 1.41-2.454 1.012L7.54 18.21zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-43
View File
@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b5f60sr0rt5tw"
path="res://.godot/imported/steam-dark.svg-0b8589df363e0bd197044b86e8158d1c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/brands/steam-dark.svg"
dest_files=["res://.godot/imported/steam-dark.svg-0b8589df363e0bd197044b86e8158d1c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#0F172A">
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.456-.397.957-1.497 1.41-2.454 1.012L7.54 18.21zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b8sp3ihkkfidy"
path="res://.godot/imported/steam-light.svg-43e1d1f7b2cea83339ab43c99a461f9e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/brands/steam-light.svg"
dest_files=["res://.godot/imported/steam-light.svg-43e1d1f7b2cea83339ab43c99a461f9e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
-14
View File
@@ -1,14 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>

Before

Width:  |  Height:  |  Size: 373 B

-43
View File
@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bgv42gr8xm8rf"
path="res://.godot/imported/book-open.svg-683be4c8f98553ef842aedfa9c35759c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/book-open.svg"
dest_files=["res://.godot/imported/book-open.svg-683be4c8f98553ef842aedfa9c35759c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="16" height="20" x="4" y="2" rx="2"/>
<line x1="8" x2="16" y1="6" y2="6"/>
<line x1="16" x2="16" y1="14" y2="18"/>
<path d="M16 10h.01"/>
<path d="M12 10h.01"/>
<path d="M8 10h.01"/>
<path d="M12 14h.01"/>
<path d="M8 14h.01"/>
<path d="M12 18h.01"/>
<path d="M8 18h.01"/>
</svg>

Before

Width:  |  Height:  |  Size: 494 B

-43
View File
@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dy8lr6snglfpm"
path="res://.godot/imported/calculator.svg-0f64f216f45259cc59d80d23ea9a79a8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/calculator.svg"
dest_files=["res://.godot/imported/calculator.svg-0f64f216f45259cc59d80d23ea9a79a8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
-14
View File
@@ -1,14 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>

Before

Width:  |  Height:  |  Size: 354 B

-43
View File
@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bndvpquam5yd7"
path="res://.godot/imported/link.svg-0a6581aa8302c8c4308f0bb54259bf1e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/link.svg"
dest_files=["res://.godot/imported/link.svg-0a6581aa8302c8c4308f0bb54259bf1e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
-13
View File
@@ -1,13 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
</svg>

Before

Width:  |  Height:  |  Size: 583 B

-43
View File
@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dty3x2w0mjrv3"
path="res://.godot/imported/star.svg-5cf15f039032ad2da3c91090b75025e6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/star.svg"
dest_files=["res://.godot/imported/star.svg-5cf15f039032ad2da3c91090b75025e6.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="15" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 118 B

@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://neyyjsv1yi2y"
path="res://.godot/imported/badge-circle-filled.svg-c615dfa6e3ba91563385092d88757ba4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/medmind/badge-circle-filled.svg"
dest_files=["res://.godot/imported/badge-circle-filled.svg-c615dfa6e3ba91563385092d88757ba4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14.25" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.55" stroke-width="1.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 199 B

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