Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da141670d1 | ||
|
|
edcc0def5d | ||
|
|
b9d4687b1b | ||
|
|
0ec486f132 | ||
|
|
32c455bba0 | ||
|
|
c91e70deb5 | ||
|
|
fae38d2ded | ||
|
|
5b5741de37 | ||
|
|
b1b83dae05 | ||
|
|
332da7c181 | ||
|
|
48f404518f | ||
|
|
4a4bed2ac5 | ||
|
|
88688d3eb0 | ||
|
|
92c5688b55 | ||
|
|
e174b5db15 | ||
|
|
403944b249 | ||
|
|
8390d795e5 | ||
|
|
6a6e703aa4 | ||
|
|
fcbc288a49 | ||
|
|
50f07a77a5 | ||
|
|
ba155fd38a | ||
|
|
8e216eac32 | ||
|
|
489b9c3d7c | ||
|
|
b3a8dec379 | ||
|
|
d69a1fd0e3 | ||
|
|
b88f38f29b | ||
|
|
c62eff8846 | ||
|
|
59ffaf4376 | ||
|
|
4db5b9d374 | ||
|
|
2a2ff29389 | ||
|
|
5ba7af9d3e | ||
|
|
b822fe5314 | ||
|
|
5f3289bb8e | ||
|
|
9d18704ad0 | ||
|
|
aef287ac26 | ||
|
|
6c388dfa5e | ||
|
|
b56d48d570 | ||
|
|
f17797becc | ||
|
|
9f62444a63 | ||
|
|
e8d615985b | ||
|
|
7f76ca0fdc | ||
|
|
3a01e9156c | ||
|
|
09b670d5d4 | ||
|
|
70aa425fde | ||
|
|
72fb6e4776 | ||
|
|
109581874d | ||
|
|
7e19366dcb | ||
|
|
b00b321d6f | ||
|
|
15e9f9779e | ||
|
|
ace04ab268 | ||
|
|
4dfbd811b9 | ||
|
|
48a8c8361d | ||
|
|
29b89dc404 | ||
|
|
c2d000cc48 |
@@ -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}" -t "${API_IMAGE}" api/QuestionsApi
|
||||
docker build --build-arg BUILD_REVISION="${BUILD_TAG}" -f api/QuestionsApi/Dockerfile -t "${API_IMAGE}" .
|
||||
docker build \
|
||||
--build-arg BUILD_REVISION="${BUILD_TAG}" \
|
||||
--build-arg DEPLOYMENT_ENVIRONMENT="${DEPLOYMENT_ENVIRONMENT}" \
|
||||
@@ -70,7 +70,48 @@ 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}."
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/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}"
|
||||
@@ -32,6 +32,7 @@ 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 }}
|
||||
@@ -40,3 +41,12 @@ 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
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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
|
||||
@@ -10,3 +10,8 @@
|
||||
# Local editor/user files
|
||||
*.user
|
||||
*.suo
|
||||
|
||||
# UAT smoke-test output
|
||||
/artifacts/uat-smoke/
|
||||
**/node_modules/
|
||||
/tests/smoke/local-results/
|
||||
|
||||
@@ -8,6 +8,41 @@ 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()
|
||||
{
|
||||
@@ -108,6 +143,25 @@ 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,3 +1,4 @@
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using Xunit;
|
||||
|
||||
@@ -27,4 +28,110 @@ 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, 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);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
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" }]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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,6 +51,16 @@ 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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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
|
||||
?? "";
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
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,6 +27,14 @@ 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 || (!question.IsPublished && !User.IsInRole("SUPER")))
|
||||
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
|
||||
return NotFound();
|
||||
|
||||
var userId = CurrentUserId();
|
||||
|
||||
@@ -94,7 +94,7 @@ public class QuestionsController : ControllerBase
|
||||
public async Task<IActionResult> Get(string id)
|
||||
{
|
||||
var question = await _questions.GetByIdAsync(id);
|
||||
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
|
||||
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
|
||||
return NotFound();
|
||||
|
||||
var percentages = await _statistics.GetPercentagesForQuestionAsync(question.ExternalId);
|
||||
@@ -119,7 +119,7 @@ public class QuestionsController : ControllerBase
|
||||
return BadRequest("choiceId required");
|
||||
|
||||
var question = await _questions.GetByExternalIdAsync(req.QuestionExternalId.Trim());
|
||||
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
|
||||
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
|
||||
return NotFound();
|
||||
if (!question.Choices.Any(c => c.Id == req.ChoiceId.Trim()))
|
||||
return BadRequest("choiceId must match a choice id");
|
||||
@@ -187,7 +187,7 @@ public class QuestionsController : ControllerBase
|
||||
record.Attempt.IsCorrect,
|
||||
shouldRate);
|
||||
if (shouldRate)
|
||||
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner);
|
||||
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -258,7 +258,7 @@ public class QuestionsController : ControllerBase
|
||||
return BadRequest("externalId required");
|
||||
|
||||
var question = await _questions.GetByExternalIdAsync(externalId.Trim());
|
||||
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
|
||||
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
|
||||
return NotFound();
|
||||
|
||||
var userId = CurrentUserId();
|
||||
@@ -286,7 +286,7 @@ public class QuestionsController : ControllerBase
|
||||
{
|
||||
rating = await _ratings.ApplyFirstResultAsync(userId, question, false, shouldRate);
|
||||
if (shouldRate)
|
||||
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner);
|
||||
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -404,6 +404,37 @@ 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)
|
||||
|
||||
@@ -4,16 +4,19 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
ARG BUILD_REVISION
|
||||
WORKDIR /src
|
||||
|
||||
COPY ["QuestionsApi.csproj", "./"]
|
||||
RUN dotnet restore "QuestionsApi.csproj"
|
||||
COPY ["api/QuestionsApi/QuestionsApi.csproj", "api/QuestionsApi/"]
|
||||
RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj"
|
||||
|
||||
COPY . .
|
||||
COPY api/QuestionsApi/ api/QuestionsApi/
|
||||
COPY content/dynamic_questions/ content/dynamic_questions/
|
||||
WORKDIR /src/api/QuestionsApi
|
||||
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 \
|
||||
|
||||
@@ -17,6 +17,26 @@ 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; } = "";
|
||||
@@ -25,6 +45,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -57,11 +80,19 @@ 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; } = "";
|
||||
@@ -75,6 +106,7 @@ 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; } = [];
|
||||
}
|
||||
|
||||
@@ -111,19 +143,22 @@ 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.Practice;
|
||||
public string Mode { get; set; } = CcsAttemptModes.Timed;
|
||||
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;
|
||||
@@ -144,6 +179,8 @@ 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; } = [];
|
||||
@@ -153,9 +190,30 @@ 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; }
|
||||
@@ -194,7 +252,7 @@ public class CcsImportResponse
|
||||
public class CreateCcsAttemptRequest
|
||||
{
|
||||
public string CaseExternalId { get; set; } = "";
|
||||
public string Mode { get; set; } = CcsAttemptModes.Practice;
|
||||
public string Mode { get; set; } = CcsAttemptModes.Timed;
|
||||
}
|
||||
|
||||
public class CcsCommandRequest
|
||||
@@ -206,6 +264,20 @@ 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; } = "";
|
||||
@@ -226,8 +298,11 @@ 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; }
|
||||
@@ -236,9 +311,12 @@ 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; } = [];
|
||||
|
||||
@@ -14,8 +14,12 @@ public sealed class DashboardResponse
|
||||
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; } = [];
|
||||
@@ -28,6 +32,7 @@ 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 sealed class DashboardSystemPerformance
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
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; } = "";
|
||||
}
|
||||
@@ -30,6 +30,9 @@ 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,6 +19,8 @@ public class QuestionAttempt
|
||||
public int DurationSeconds { get; set; }
|
||||
public int CounterBucket { get; set; }
|
||||
public bool? RatingApplied { get; set; }
|
||||
public int? UserOverallEloAfter { get; set; }
|
||||
public DateTime? RatingAppliedUtc { get; set; }
|
||||
public string RatingLeaseOwner { get; set; } = "";
|
||||
public DateTime RatingLeaseUntilUtc { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -45,6 +45,8 @@ 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,6 +38,11 @@ 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,6 +111,30 @@ 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; } = "";
|
||||
|
||||
@@ -13,6 +13,8 @@ public class UserRating
|
||||
public int OverallElo { get; set; } = 1000;
|
||||
public Dictionary<string, int> CategoryElos { get; set; } = [];
|
||||
public int RatedQuestionCount { get; set; }
|
||||
public int? HistoryBaselineElo { get; set; }
|
||||
public DateTime? HistoryStartedUtc { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ 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;
|
||||
}
|
||||
@@ -25,6 +27,12 @@ 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]
|
||||
@@ -48,3 +56,18 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -16,6 +17,38 @@ if (args.FirstOrDefault() == "promote-content")
|
||||
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 =>
|
||||
@@ -33,6 +66,10 @@ 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>();
|
||||
@@ -43,6 +80,14 @@ 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>();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<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" />
|
||||
|
||||
@@ -4,8 +4,7 @@ namespace QuestionsApi.Services;
|
||||
|
||||
public static class CcsCaseValidator
|
||||
{
|
||||
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"];
|
||||
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"];
|
||||
|
||||
public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
|
||||
{
|
||||
@@ -18,6 +17,8 @@ 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");
|
||||
@@ -50,7 +51,11 @@ 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();
|
||||
@@ -75,7 +80,8 @@ public static class CcsCaseValidator
|
||||
|
||||
public static string? ValidateCreate(CreateCcsAttemptRequest request)
|
||||
{
|
||||
if (!Modes.Contains(request.Mode)) return "mode must be practice or timed";
|
||||
if (!request.Mode.Equals(CcsAttemptModes.Timed, StringComparison.OrdinalIgnoreCase))
|
||||
return "new CCS attempts must be timed";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -88,4 +94,29 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,16 +115,22 @@ public sealed class CcsStore
|
||||
CaseExternalId = definition.ExternalId,
|
||||
CaseRevisionId = definition.Id ?? "",
|
||||
CaseVersion = definition.Version,
|
||||
Mode = request.Mode,
|
||||
Mode = CcsAttemptModes.Timed,
|
||||
OpeningCompleted = false,
|
||||
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 = request.Mode == CcsAttemptModes.Timed ? now.AddMinutes(definition.RealTimeMinutes) : null,
|
||||
RatingEligible = request.Mode == CcsAttemptModes.Timed,
|
||||
DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
|
||||
HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes),
|
||||
RatingEligible = true,
|
||||
Chart =
|
||||
[
|
||||
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.ReasonForEncounter },
|
||||
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.Appearance }
|
||||
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 }
|
||||
]
|
||||
};
|
||||
try { await _attempts.InsertOneAsync(attempt); }
|
||||
@@ -173,7 +179,12 @@ public sealed class CcsStore
|
||||
if (attempt.State == CcsAttemptStates.Active && attempt.DeadlineUtc <= DateTime.UtcNow)
|
||||
{
|
||||
attempt.State = CcsAttemptStates.FinalOrders;
|
||||
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.AddMinutes(2);
|
||||
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.Revision++;
|
||||
await SaveAsync(attempt);
|
||||
}
|
||||
@@ -206,9 +217,16 @@ 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 = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change." });
|
||||
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."
|
||||
});
|
||||
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;
|
||||
}
|
||||
@@ -220,6 +238,63 @@ 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);
|
||||
@@ -314,19 +389,49 @@ 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, Location = attempt.Location,
|
||||
DeadlineUtc = attempt.DeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
|
||||
SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc,
|
||||
OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location,
|
||||
DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc,
|
||||
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
|
||||
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
|
||||
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
|
||||
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,
|
||||
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult { OrderCode = value.OrderCode, DueMinute = value.DueMinute }).ToList(),
|
||||
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
|
||||
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
|
||||
{
|
||||
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title
|
||||
}).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,
|
||||
@@ -342,7 +447,7 @@ public sealed class CcsStore
|
||||
await SaveAsync(attempt);
|
||||
}
|
||||
|
||||
private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now)
|
||||
private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now, bool processImmediately = true)
|
||||
{
|
||||
code = code.Trim().ToLowerInvariant();
|
||||
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code.");
|
||||
@@ -352,17 +457,30 @@ 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, 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);
|
||||
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);
|
||||
}
|
||||
|
||||
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 = "exam", SimulatedMinute = attempt.SimulatedMinute, Text = $"{exam.Name}: {exam.Finding}" });
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name,
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding
|
||||
});
|
||||
Advance(attempt, definition, Math.Max(1, exam.DurationMinutes));
|
||||
}
|
||||
|
||||
@@ -371,7 +489,11 @@ 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 = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {code}" });
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Orders, Code = code, Title = Humanize(code),
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {Humanize(code)}"
|
||||
});
|
||||
}
|
||||
|
||||
private static void ChangeLocation(CcsAttempt attempt, CcsCaseRevision definition, string location, DateTime now)
|
||||
@@ -379,7 +501,11 @@ 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 = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {location}." });
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Progress, Code = "location", Title = "Location change",
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {Humanize(location)}."
|
||||
});
|
||||
}
|
||||
|
||||
private static void Advance(CcsAttempt attempt, CcsCaseRevision definition, int minutes)
|
||||
@@ -403,13 +529,20 @@ 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 = "results", SimulatedMinute = attempt.SimulatedMinute, Text = 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
|
||||
});
|
||||
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)
|
||||
@@ -419,13 +552,20 @@ 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;
|
||||
if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = item.Message });
|
||||
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 (item.EndCase) EnterFinalOrders(attempt);
|
||||
}
|
||||
if (vitalsChanged) SnapshotVitals(attempt);
|
||||
return events.Any(value => !string.IsNullOrWhiteSpace(value.Message) || value.EndCase);
|
||||
}
|
||||
|
||||
@@ -433,9 +573,21 @@ public sealed class CcsStore
|
||||
{
|
||||
if (attempt.State != CcsAttemptStates.Active) return;
|
||||
attempt.State = CcsAttemptStates.FinalOrders;
|
||||
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." });
|
||||
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."
|
||||
});
|
||||
}
|
||||
|
||||
private async Task CompleteAsync(CcsAttempt attempt)
|
||||
@@ -489,12 +641,44 @@ 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)
|
||||
{
|
||||
@@ -511,12 +695,25 @@ public sealed class CcsStore
|
||||
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
|
||||
Difficulty = value.Difficulty, Tags = value.Tags
|
||||
};
|
||||
private static int Rank(CcsOrderDefinition order, string query)
|
||||
internal 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()]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,11 +15,39 @@ 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;
|
||||
|
||||
public DashboardStore(MongoDatabaseProvider mongo, QuestionStore questions, ReviewQueueStore reviewQueue)
|
||||
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)
|
||||
{
|
||||
_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");
|
||||
@@ -28,6 +56,10 @@ public sealed class DashboardStore
|
||||
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
|
||||
_questions = questions;
|
||||
_reviewQueue = reviewQueue;
|
||||
_dynamicQuestions = dynamicQuestions;
|
||||
_flashcards = flashcards;
|
||||
_flashcardSchedules = flashcardSchedules;
|
||||
_ratingStore = ratingStore;
|
||||
}
|
||||
|
||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||
@@ -36,7 +68,7 @@ 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 = _ratings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||
var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId
|
||||
@@ -45,8 +77,12 @@ public sealed class DashboardStore
|
||||
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
|
||||
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
|
||||
var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart);
|
||||
var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart);
|
||||
var dynamicReviewsTask = _dynamicQuestions.DueReviewsAsync(userId, nowUtc);
|
||||
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask,
|
||||
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask);
|
||||
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask,
|
||||
flashcardReviewsTask, dynamicReviewsTask);
|
||||
|
||||
var attempts = attemptsTask.Result;
|
||||
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
|
||||
@@ -60,8 +96,8 @@ public sealed class DashboardStore
|
||||
|
||||
var response = new DashboardResponse
|
||||
{
|
||||
QuestionBankRating = ratingTask.Result?.OverallElo ?? 1000,
|
||||
RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0,
|
||||
QuestionBankRating = ratingTask.Result.OverallElo,
|
||||
RatedQuestionCount = ratingTask.Result.RatedQuestionCount,
|
||||
CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
|
||||
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
|
||||
QuestionsCompleted = answeredAttempts.Count,
|
||||
@@ -71,34 +107,15 @@ public sealed class DashboardStore
|
||||
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
|
||||
BookmarkCount = checked((int)bookmarkCountTask.Result),
|
||||
NoteCount = CountNotes(notepadTask.Result?.Content),
|
||||
MissedQuestionCount = checked((int)pendingReviewCount)
|
||||
MissedQuestionCount = checked((int)pendingReviewCount),
|
||||
DynamicReviewsDue = dynamicReviewsTask.Result.Count,
|
||||
FlashcardsDue = flashcardCountsTask.Result.Due,
|
||||
FlashcardsNewAvailable = flashcardCountsTask.Result.NewAvailable,
|
||||
FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek
|
||||
};
|
||||
|
||||
response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset =>
|
||||
{
|
||||
var date = trendStart.AddDays(offset);
|
||||
var daily = answeredAttempts.Where(value => value.CreatedUtc.Date == date).ToList();
|
||||
return new DashboardTrendPoint
|
||||
{
|
||||
DateUtc = date,
|
||||
Correct = daily.Count(value => value.IsCorrect),
|
||||
Total = daily.Count,
|
||||
Accuracy = Percentage(daily.Count(value => value.IsCorrect), daily.Count)
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
response.Systems = answeredAttempts
|
||||
.Where(value => questionMap.ContainsKey(value.QuestionExternalId))
|
||||
.GroupBy(value => NormalizeSystem(questionMap[value.QuestionExternalId].System))
|
||||
.Select(group => new DashboardSystemPerformance
|
||||
{
|
||||
System = group.Key,
|
||||
Correct = group.Count(value => value.IsCorrect),
|
||||
Total = group.Count(),
|
||||
Accuracy = Percentage(group.Count(value => value.IsCorrect), group.Count())
|
||||
})
|
||||
.OrderByDescending(value => value.Total).ThenByDescending(value => value.Accuracy)
|
||||
.ToList();
|
||||
response.PerformanceTrend = BuildPerformanceTrend(attempts, ratingTask.Result, today);
|
||||
response.Systems = BuildSystemPerformance(answeredAttempts, questionMap);
|
||||
|
||||
response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset =>
|
||||
{
|
||||
@@ -115,12 +132,14 @@ 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(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date))
|
||||
.Concat(flashcardReviewsTask.Result.Select(value => value.ReviewedUtc.Date));
|
||||
response.CurrentStreak = CalculateCurrentStreak(activeDates, today);
|
||||
response.RecentActivity = await BuildRecentActivityAsync(answeredAttempts, questionMap, completedCcs, simulations);
|
||||
return response;
|
||||
@@ -143,6 +162,133 @@ public sealed class DashboardStore
|
||||
return streak;
|
||||
}
|
||||
|
||||
public static List<DashboardTrendPoint> BuildPerformanceTrend(
|
||||
IEnumerable<QuestionAttempt> attempts,
|
||||
UserRating rating,
|
||||
DateTime todayUtc)
|
||||
{
|
||||
var attemptList = attempts.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));
|
||||
|
||||
int? currentRating = null;
|
||||
var eventIndex = 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++;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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.");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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));
|
||||
await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate);
|
||||
if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner);
|
||||
var rating = await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate);
|
||||
if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner, rating.UserOverallElo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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,11 +103,13 @@ public class QuestionAttemptStore
|
||||
return leased?.RatingLeaseOwner == owner;
|
||||
}
|
||||
|
||||
public Task MarkRatingAppliedAsync(string attemptId, string owner) =>
|
||||
public Task MarkRatingAppliedAsync(string attemptId, string owner, int userOverallElo) =>
|
||||
_col.UpdateOneAsync(
|
||||
attempt => attempt.Id == attemptId && attempt.RatingLeaseOwner == owner,
|
||||
Builders<QuestionAttempt>.Update
|
||||
.Set(attempt => attempt.RatingApplied, true)
|
||||
.Set(attempt => attempt.UserOverallEloAfter, userOverallElo)
|
||||
.Set(attempt => attempt.RatingAppliedUtc, DateTime.UtcNow)
|
||||
.Set(attempt => attempt.RatingLeaseUntilUtc, DateTime.MinValue));
|
||||
|
||||
public Task ReleaseRatingLeaseAsync(string attemptId, string owner) =>
|
||||
|
||||
@@ -105,7 +105,11 @@ public sealed class QuestionBookmarkStore
|
||||
}
|
||||
|
||||
var questionMatch = new BsonDocument();
|
||||
if (!includeUnpublished) questionMatch["Question.IsPublished"] = true;
|
||||
if (!includeUnpublished)
|
||||
{
|
||||
questionMatch["Question.IsPublished"] = true;
|
||||
questionMatch["Question.FlaggedForReviewUtc"] = BsonNull.Value;
|
||||
}
|
||||
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))
|
||||
@@ -172,7 +176,11 @@ public sealed class QuestionBookmarkStore
|
||||
new("$unwind", "$Question")
|
||||
};
|
||||
if (!includeUnpublished)
|
||||
pipeline.Add(new BsonDocument("$match", new BsonDocument("Question.IsPublished", true)));
|
||||
pipeline.Add(new BsonDocument("$match", new BsonDocument
|
||||
{
|
||||
{ "Question.IsPublished", true },
|
||||
{ "Question.FlaggedForReviewUtc", BsonNull.Value }
|
||||
}));
|
||||
pipeline.Add(new BsonDocument("$group", new BsonDocument
|
||||
{
|
||||
{ "_id", BsonNull.Value },
|
||||
|
||||
@@ -79,6 +79,39 @@ 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 _))
|
||||
|
||||
@@ -6,6 +6,9 @@ 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)
|
||||
@@ -26,7 +29,10 @@ 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))
|
||||
@@ -65,7 +71,7 @@ public class QuestionStore
|
||||
await _col.Find(Builders<Question>.Filter.Empty).ToListAsync();
|
||||
|
||||
public async Task<List<Question>> GetPublishedAsync() =>
|
||||
await _col.Find(question => question.IsPublished).ToListAsync();
|
||||
await _col.Find(question => question.IsPublished && question.FlaggedForReviewUtc == null).ToListAsync();
|
||||
|
||||
public async Task<Dictionary<string, Question>> GetByExternalIdsAsync(IEnumerable<string> externalIds)
|
||||
{
|
||||
@@ -102,6 +108,7 @@ public class QuestionStore
|
||||
var question = FromRequest(req);
|
||||
question.CreatedUtc = DateTime.UtcNow;
|
||||
question.UpdatedUtc = DateTime.UtcNow;
|
||||
ApplyMediaReviewGate(question);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -125,6 +132,10 @@ 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;
|
||||
}
|
||||
@@ -143,12 +154,16 @@ 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++;
|
||||
@@ -167,7 +182,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.IsPublished, true) & Builders<Question>.Filter.Eq(q => q.FlaggedForReviewUtc, null);
|
||||
var systems = await _col.Distinct(q => q.System, filter).ToListAsync();
|
||||
return systems.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToList();
|
||||
}
|
||||
@@ -181,6 +196,45 @@ 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;
|
||||
@@ -198,6 +252,18 @@ 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(),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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;
|
||||
}
|
||||
@@ -35,6 +35,12 @@ 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();
|
||||
@@ -71,6 +77,7 @@ public class RatingStore
|
||||
};
|
||||
}
|
||||
|
||||
userRating = await EnsureHistoryBaselineAsync(userRating, DateTime.UtcNow);
|
||||
var actual = isCorrect ? 1.0 : 0.0;
|
||||
var userK = userRating.RatedQuestionCount < 20 ? 40 : 24;
|
||||
var questionK = questionRating.AttemptCount < 20 ? 32 : 24;
|
||||
@@ -164,6 +171,26 @@ public class RatingStore
|
||||
return rating;
|
||||
}
|
||||
|
||||
private async Task<UserRating> EnsureHistoryBaselineAsync(UserRating rating, DateTime nowUtc)
|
||||
{
|
||||
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
|
||||
return rating;
|
||||
|
||||
var result = await _users.UpdateOneAsync(
|
||||
value => value.UserId == rating.UserId && value.HistoryStartedUtc == null,
|
||||
Builders<UserRating>.Update
|
||||
.Set(value => value.HistoryBaselineElo, rating.OverallElo)
|
||||
.Set(value => value.HistoryStartedUtc, nowUtc));
|
||||
if (result.ModifiedCount > 0)
|
||||
{
|
||||
rating.HistoryBaselineElo = rating.OverallElo;
|
||||
rating.HistoryStartedUtc = nowUtc;
|
||||
return rating;
|
||||
}
|
||||
|
||||
return await _users.Find(value => value.UserId == rating.UserId).FirstAsync();
|
||||
}
|
||||
|
||||
private async Task<QuestionRating> GetOrCreateQuestionRatingAsync(string questionExternalId)
|
||||
{
|
||||
var create = Builders<QuestionRating>.Update
|
||||
|
||||
@@ -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 }) return candidate;
|
||||
if (question is { IsPublished: true, FlaggedForReviewUtc: null }) return candidate;
|
||||
await _items.UpdateOneAsync(
|
||||
value => value.Id == candidate.Id && value.State == ReviewQueueStates.Pending,
|
||||
Builders<ReviewQueueItem>.Update
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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,6 +13,10 @@ 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);
|
||||
@@ -29,6 +33,16 @@ 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()
|
||||
{
|
||||
@@ -39,6 +53,16 @@ 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))
|
||||
@@ -62,4 +86,9 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,12 @@ 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"
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
@@ -48,6 +51,20 @@ 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();
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 972 KiB After Width: | Height: | Size: 894 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 554 KiB |
@@ -0,0 +1,6 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 903 B |
@@ -0,0 +1,43 @@
|
||||
[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
|
||||
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,43 @@
|
||||
[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
|
||||
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,43 @@
|
||||
[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
|
||||
@@ -0,0 +1,12 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 494 B |
@@ -0,0 +1,43 @@
|
||||
[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
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="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>
|
||||
|
After Width: | Height: | Size: 354 B |
@@ -0,0 +1,43 @@
|
||||
[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
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="15" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 118 B |
@@ -0,0 +1,43 @@
|
||||
[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
|
||||
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 199 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://2epi5x1l2k3k"
|
||||
path="res://.godot/imported/badge-circle-outline.svg-48e5795ac9a851c7826f85026138d065.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/medmind/badge-circle-outline.svg"
|
||||
dest_files=["res://.godot/imported/badge-circle-outline.svg-48e5795ac9a851c7826f85026138d065.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,5 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18.7 2.8 21.2 5.3 12.1 14.4 9.6 11.9 18.7 2.8Z" stroke="#CBD5E1" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M8.9 11.2 12.8 15.1C10.8 18.5 7.2 20.2 2.8 19.5 5.2 17.5 6.7 14.2 8.9 11.2Z" stroke="#CBD5E1" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M4.1 14.1H2.5M4.5 17H1.5M7.1 20.7H4.7" stroke="#8B5CF6" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<g transform="translate(13,12) rotate(45)">
|
||||
<rect x="-3.2" y="-8.4" width="6.4" height="16.8" rx="1.5" stroke="#CBD5E1" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M-3.2 2.6H3.2" stroke="#CBD5E1" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<circle cx="9" cy="3.2" r="0.65" fill="#8B5CF6"/>
|
||||
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" transform="translate(0.46,-0.04) scale(0.42)" stroke="#8B5CF6" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" transform="translate(16.2,13.6) scale(0.3)" stroke="#8B5CF6" stroke-width="2" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 463 B After Width: | Height: | Size: 1.1 KiB |
@@ -2,7 +2,7 @@
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bx3x2rqpurqqw"
|
||||
uid="uid://fl5g0hqtqegt"
|
||||
path="res://.godot/imported/clear-highlight-dark.svg-67d9eee7c448efe7f572e5fc819b8ea0.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18.7 2.8 21.2 5.3 12.1 14.4 9.6 11.9 18.7 2.8Z" stroke="#26344F" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M8.9 11.2 12.8 15.1C10.8 18.5 7.2 20.2 2.8 19.5 5.2 17.5 6.7 14.2 8.9 11.2Z" stroke="#26344F" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M4.1 14.1H2.5M4.5 17H1.5M7.1 20.7H4.7" stroke="#8B5CF6" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<g transform="translate(13,12) rotate(45)">
|
||||
<rect x="-3.2" y="-8.4" width="6.4" height="16.8" rx="1.5" stroke="#26344F" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M-3.2 2.6H3.2" stroke="#26344F" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<circle cx="9" cy="3.2" r="0.65" fill="#8B5CF6"/>
|
||||
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" transform="translate(0.46,-0.04) scale(0.42)" stroke="#8B5CF6" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" transform="translate(16.2,13.6) scale(0.3)" stroke="#8B5CF6" stroke-width="2" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 463 B After Width: | Height: | Size: 1.1 KiB |
@@ -2,7 +2,7 @@
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bgpsqicfixhew"
|
||||
uid="uid://4vrs2omb0iy6"
|
||||
path="res://.godot/imported/clear-highlight-light.svg-93f7a6cb5f22325bc90fadf2e89641fc.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="5" y="3.4" width="1.6" height="17.2" rx="0.8" fill="#F6F8FF"/>
|
||||
<path d="M6.6 4.8H17.3L14.6 8.4L17.3 12H6.6V4.8Z" fill="#FFC857"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://pffbgabfvqoi"
|
||||
path="res://.godot/imported/flag-dark.svg-991f8446e8d6e35dda3f6918b79c2529.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/medmind/flag-dark.svg"
|
||||
dest_files=["res://.godot/imported/flag-dark.svg-991f8446e8d6e35dda3f6918b79c2529.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="5" y="3.4" width="1.6" height="17.2" rx="0.8" fill="#0F172A"/>
|
||||
<path d="M6.6 4.8H17.3L14.6 8.4L17.3 12H6.6V4.8Z" fill="#986400"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 223 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://rrjvnmtbkc2c"
|
||||
path="res://.godot/imported/flag-light.svg-028b26500c0b5f2e10e3a85231e2e56d.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/medmind/flag-light.svg"
|
||||
dest_files=["res://.godot/imported/flag-light.svg-028b26500c0b5f2e10e3a85231e2e56d.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,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 3V21" stroke="#CBD5E1" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<path d="M5 5.2C9 2.8 13 7.1 19 4.5L16.5 9.8 19 15.2C13 17.8 9 13.5 5 15.9V5.2Z" stroke="#FF3344" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M12 7.8V11.5M12 14V14.1" stroke="#FF3344" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<rect x="3" y="13.4" width="3.6" height="5.6" rx="1.4" stroke="#CBD5E1" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M6.6 9.6 15 6.1V17.9L6.6 14.4V9.6Z" stroke="#CBD5E1" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M18.2 7.4 21 5.6M19.1 12H22M18.2 16.6 21 18.4" stroke="#FF3344" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 403 B After Width: | Height: | Size: 427 B |
@@ -2,7 +2,7 @@
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://d1rf1y5ym0abp"
|
||||
uid="uid://2h8hksovbjvl"
|
||||
path="res://.godot/imported/report-question-dark.svg-0c56358e403130829c20d7684d988f4f.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
|
||||