Add data-driven dynamic question mode

This commit is contained in:
hz
2026-07-23 12:33:23 -05:00
parent 50f07a77a5
commit fcbc288a49
22 changed files with 1247 additions and 21 deletions
@@ -0,0 +1,71 @@
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"));
}
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" }]
};
}
@@ -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,190 @@
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 int Difficulty { get; set; } = 3;
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 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 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 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 DateTime ExpiresUtc { get; set; }
}
public sealed class DynamicGeneratedQuestion
{
public Question Question { get; set; } = new();
public DynamicQuestionBlueprint Blueprint { get; set; } = new();
}
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 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 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 ChoiceId { get; set; } = "";
public bool IsCorrect { get; set; }
public bool IsSkip { get; set; }
public int DurationSeconds { get; set; }
public DateTime CreatedUtc { 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 DynamicGenerationDescriptor
{
public string BlueprintExternalId { get; set; } = "";
public int BlueprintVersion { get; set; }
public int GeneratorVersion { get; set; }
public string Seed { get; set; } = "";
}
@@ -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; }
+10
View File
@@ -17,6 +17,12 @@ 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() == "seed-flashcards")
{
var seedConfiguration = new ConfigurationBuilder()
@@ -54,6 +60,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>();
@@ -0,0 +1,50 @@
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.GetFullPath(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) ?? []);
var generator = new DynamicQuestionGenerator();
foreach (var blueprint in blueprints)
{
var error = DynamicQuestionGenerator.Validate(blueprint);
if (error is not null)
throw new InvalidDataException($"{blueprint.ExternalId} v{blueprint.Version}: {error}");
for (var seed = 0; seed < 1000; seed++)
generator.Generate(blueprint, seed.ToString(), seed.ToString());
}
await output.WriteLineAsync($"Validated {blueprints.Count} blueprints across 1,000 seeds each.");
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));
await store.UpsertBlueprintsAsync(blueprints);
await output.WriteLineAsync($"Published {blueprints.Count} immutable blueprint versions.");
return 0;
}
}
@@ -0,0 +1,164 @@
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 = 1;
private static readonly Regex Placeholder = new(@"\{\{([a-zA-Z0-9_]+)\}\}", RegexOptions.Compiled);
public DynamicGeneratedQuestion Generate(DynamicQuestionBlueprint blueprint, string seed, string nonce)
{
var draw = new DeterministicDraw($"{blueprint.ExternalId}|{blueprint.Version}|{CurrentVersion}|{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 };
}
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 (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 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,107 @@
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;
public DynamicQuestionService(
DynamicQuestionStore store,
DynamicQuestionGenerator generator,
DynamicQuestionTokenService tokens)
{
_store = store;
_generator = generator;
_tokens = tokens;
}
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 candidates = unseen
.Where(group => group.Key != run.PreviousObjectiveId || unseen.Count == 1)
.OrderBy(group => mastery.TryGetValue(group.Key, out var value) ? value.Elo : 1000)
.ThenBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue))
.ToList();
var weakestBand = candidates.Take(Math.Min(4, candidates.Count)).ToList();
var selectedObjective = weakestBand[RandomNumberGenerator.GetInt32(weakestBand.Count)];
var blueprint = selectedObjective
.OrderByDescending(value => value.Version)
.First();
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,
ExpiresUtc = DateTime.UtcNow.AddDays(7)
};
var generated = _generator.Generate(blueprint, claims.Seed, claims.Nonce);
run.SeenObjectiveIds.Add(blueprint.ObjectiveId);
run.PreviousObjectiveId = blueprint.ObjectiveId;
run.ExpiresUtc = DateTime.UtcNow.AddDays(7);
return new DynamicQuestionEnvelope
{
Question = QuestionResponse.FromModel(generated.Question),
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 != 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.");
return (claims, _generator.Generate(blueprint, claims.Seed, claims.Nonce));
}
private static DynamicRunClaims NewRun(string userId) => new()
{
UserId = userId,
ExpiresUtc = DateTime.UtcNow.AddDays(7)
};
}
@@ -0,0 +1,124 @@
using MongoDB.Driver;
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;
public DynamicQuestionStore(MongoDatabaseProvider mongo)
{
_blueprints = mongo.Database.GetCollection<DynamicQuestionBlueprint>("DynamicQuestionBlueprints");
_attempts = mongo.Database.GetCollection<DynamicQuestionAttempt>("DynamicQuestionAttempts");
_mastery = mongo.Database.GetCollection<DynamicObjectiveMastery>("DynamicObjectiveMastery");
_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 }));
}
public async Task<List<DynamicQuestionBlueprint>> PublishedAsync() =>
await _blueprints.Find(value => value.IsPublished).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<DynamicAttemptResponse> RecordAsync(
DynamicGenerationClaims claims,
DynamicGeneratedQuestion generated,
DynamicAttemptRequest request)
{
var isCorrect = !request.IsSkip && request.ChoiceId == generated.Question.CorrectChoiceId;
var attempt = new DynamicQuestionAttempt
{
UserId = claims.UserId,
Nonce = claims.Nonce,
BlueprintExternalId = claims.BlueprintExternalId,
BlueprintVersion = claims.BlueprintVersion,
ObjectiveId = claims.ObjectiveId,
ChoiceId = request.ChoiceId,
IsCorrect = isCorrect,
IsSkip = request.IsSkip,
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
};
}
var current = await _mastery.Find(value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstOrDefaultAsync();
var oldElo = current?.Elo ?? 1000;
var objectiveDifficulty = 800 + Math.Clamp(generated.Blueprint.Difficulty, 1, 5) * 100;
var expected = 1.0 / (1.0 + Math.Pow(10, (objectiveDifficulty - oldElo) / 400.0));
var k = (current?.Attempts ?? 0) < 5 ? 40 : 24;
var delta = (int)Math.Round(k * ((isCorrect ? 1.0 : 0.0) - expected));
var now = DateTime.UtcNow;
var update = Builders<DynamicObjectiveMastery>.Update
.SetOnInsert(value => value.UserId, claims.UserId)
.SetOnInsert(value => value.ObjectiveId, claims.ObjectiveId)
.SetOnInsert(value => value.Attempts, 0)
.SetOnInsert(value => value.Correct, 0)
.Set(value => value.Elo, oldElo + 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> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
return new DynamicAttemptResponse
{
Inserted = true,
IsCorrect = isCorrect,
CorrectChoiceId = generated.Question.CorrectChoiceId,
ObjectiveId = claims.ObjectiveId,
ObjectiveElo = updated.Elo,
RatingChange = delta
};
}
public async Task<DynamicQuestionStatusResponse> StatusAsync(string userId)
{
var objectiveCount = (await PublishedAsync()).Select(value => value.ObjectiveId).Distinct().Count();
var mastery = await _mastery.Find(value => value.UserId == userId).ToListAsync();
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))
};
}
public async Task UpsertBlueprintsAsync(IEnumerable<DynamicQuestionBlueprint> blueprints)
{
foreach (var blueprint in blueprints)
await _blueprints.ReplaceOneAsync(
value => value.ExternalId == blueprint.ExternalId && value.Version == blueprint.Version,
blueprint,
new ReplaceOptions { IsUpsert = true });
}
}
@@ -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);
}
}
@@ -79,6 +79,35 @@ 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
},
CreatedUtc = now,
UpdatedUtc = now
};
await _reports.InsertOneAsync(report);
return report;
}
public async Task<QuestionReport?> GetByIdAsync(string id)
{
if (!ObjectId.TryParse(id, out _))
+78
View File
@@ -0,0 +1,78 @@
[
{
"externalId":"dyn-biostats-arr","version":1,"objectiveId":"biostats.absolute-risk-reduction","title":"Absolute risk reduction","system":"Biostatistics","discipline":"Biostatistics","performanceCategory":"Applying Foundational Science Concepts","physicianTask":"Interpretation of Medical Literature","difficulty":2,
"stemTemplate":"A randomized trial follows {{size}} patients. The event rate is {{control}}% with placebo and {{treatment}}% with treatment.","leadIn":"What is the absolute risk reduction?","variables":{"size":{"type":"integer","minimum":400,"maximum":1200,"step":100},"control":{"type":"integer","minimum":18,"maximum":24},"treatment":{"type":"integer","minimum":8,"maximum":12}},"derived":{"arr":{"operation":"subtract","left":"control","right":"treatment","precision":0}},"constraints":[{"field":"arr","operator":"gt","value":0}],
"correctAnswer":{"id":"correct","textTemplate":"{{arr}} percentage points","explanationTemplate":"ARR is control event rate minus treatment event rate."},"distractors":[{"id":"a","textTemplate":"1 percentage point"},{"id":"b","textTemplate":"30 percentage points"},{"id":"c","textTemplate":"50 percentage points"},{"id":"d","textTemplate":"100 percentage points"}],
"explanationTemplate":"Absolute risk reduction is {{control}}% − {{treatment}}% = {{arr}} percentage points.","keyTakeaway":"ARR is the arithmetic difference between control and treatment event rates.","usmlePearl":"Keep percentages in percentage points when subtracting.","references":[{"title":"Principles of Epidemiology","publisher":"CDC","url":"https://www.cdc.gov/field-epi-manual/php/chapters/analyzing-interpreting-data.html"}],"tags":["biostatistics"]
},
{
"externalId":"dyn-biostats-nnt","version":1,"objectiveId":"biostats.number-needed-to-treat","title":"Number needed to treat","system":"Biostatistics","discipline":"Biostatistics","performanceCategory":"Applying Foundational Science Concepts","physicianTask":"Interpretation of Medical Literature","difficulty":3,
"stemTemplate":"A therapy lowers the {{outcome}} rate from {{control}}% to {{treatment}}%.","leadIn":"What is the approximate number needed to treat?","variables":{"outcome":{"type":"enum","values":["stroke","hospitalization","recurrent infection"]},"control":{"type":"integer","minimum":20,"maximum":20},"treatment":{"type":"integer","minimum":10,"maximum":10}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"10","explanationTemplate":"NNT = 1/0.10 = 10."},"distractors":[{"id":"a","textTemplate":"2"},{"id":"b","textTemplate":"5"},{"id":"c","textTemplate":"20"},{"id":"d","textTemplate":"100"}],"explanationTemplate":"The ARR is 10 percentage points, or 0.10. NNT = 1/0.10 = 10.","keyTakeaway":"NNT is the reciprocal of ARR expressed as a proportion.","usmlePearl":"Convert percentage points to a decimal before taking the reciprocal.","references":[{"title":"Understanding absolute and relative risk","publisher":"NLM","url":"https://www.ncbi.nlm.nih.gov/books/NBK63647/"}]
},
{
"externalId":"dyn-biostats-test","version":1,"objectiveId":"biostats.diagnostic-test","title":"Diagnostic test characteristics","system":"Biostatistics","discipline":"Biostatistics","performanceCategory":"Applying Foundational Science Concepts","physicianTask":"Interpretation of Medical Literature","difficulty":2,
"stemTemplate":"A screening test has very high {{metric}} in a population of {{size}} people.","leadIn":"Which statement best describes this test?","variables":{"metric":{"type":"enum","values":["sensitivity"]},"size":{"type":"integer","minimum":500,"maximum":2000,"step":100}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"A negative result helps rule out disease","explanationTemplate":"A highly sensitive test has few false negatives."},"distractors":[{"id":"a","textTemplate":"A positive result definitively rules in disease"},{"id":"b","textTemplate":"Disease prevalence must be high"},{"id":"c","textTemplate":"Positive predictive value is always 100%"},{"id":"d","textTemplate":"Specificity must equal sensitivity"}],"explanationTemplate":"High sensitivity means few affected patients test negative, so a negative result is useful for ruling out disease.","keyTakeaway":"Sensitive tests are useful to rule out when negative.","usmlePearl":"SnNout is a memory aid, not a substitute for the 2×2 table.","references":[{"title":"Evidence-Based Medicine Toolkit","publisher":"University of Alberta","url":"https://www.ualberta.ca/en/medicine/programs/lifelong-learning/evidence-based-medicine/resources/ebm-toolkit.html"}]
},
{
"externalId":"dyn-cardio-af","version":1,"objectiveId":"cardio.unstable-atrial-fibrillation","title":"Unstable atrial fibrillation","system":"Cardiovascular","discipline":"Medicine","performanceCategory":"Patient Care: Management","physicianTask":"Emergency Management","difficulty":2,
"stemTemplate":"A {{age}}-year-old patient has atrial fibrillation with a ventricular rate of {{rate}}/min, hypotension, diaphoresis, and altered mental status.","leadIn":"What is the next best step?","variables":{"age":{"type":"integer","minimum":48,"maximum":82},"rate":{"type":"integer","minimum":150,"maximum":190}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"Immediate synchronized cardioversion","explanationTemplate":"Hemodynamic instability requires synchronized cardioversion."},"distractors":[{"id":"a","textTemplate":"Oral metoprolol"},{"id":"b","textTemplate":"IV adenosine"},{"id":"c","textTemplate":"Observe for spontaneous conversion"},{"id":"d","textTemplate":"Begin warfarin and reassess tomorrow"}],"explanationTemplate":"Hypotension and altered mental status indicate instability; perform immediate synchronized cardioversion.","keyTakeaway":"Unstable tachyarrhythmia requires synchronized cardioversion.","usmlePearl":"Do not delay electrical therapy for anticoagulation in an unstable patient.","references":[{"title":"Adult Tachycardia With a Pulse Algorithm","publisher":"American Heart Association","url":"https://cpr.heart.org/en/resuscitation-science/cpr-and-ecc-guidelines/algorithms"}]
},
{
"externalId":"dyn-renal-hyperk","version":1,"objectiveId":"renal.hyperkalemia-ecg","title":"Hyperkalemia with ECG changes","system":"Renal","discipline":"Medicine","performanceCategory":"Patient Care: Management","physicianTask":"Emergency Management","difficulty":2,
"stemTemplate":"A patient with kidney failure has potassium {{potassium}} mEq/L and {{ecg}}.","leadIn":"Which treatment should be given first?","variables":{"potassium":{"type":"decimal","minimum":6.6,"maximum":7.4,"step":0.1,"precision":1},"ecg":{"type":"enum","values":["peaked T waves","QRS widening"]}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"IV calcium gluconate","explanationTemplate":"Calcium stabilizes the cardiac membrane."},"distractors":[{"id":"a","textTemplate":"Oral sodium polystyrene sulfonate only"},{"id":"b","textTemplate":"Fluid restriction"},{"id":"c","textTemplate":"IV furosemide only"},{"id":"d","textTemplate":"Repeat potassium in 6 hours"}],"explanationTemplate":"ECG toxicity from hyperkalemia requires immediate IV calcium to stabilize the myocardium, followed by potassium-shifting and removal therapies.","keyTakeaway":"Give IV calcium first when hyperkalemia causes ECG changes.","usmlePearl":"Calcium protects the heart but does not lower serum potassium.","references":[{"title":"Acute Hyperkalemia in Adults","publisher":"KDIGO","url":"https://kdigo.org/conferences/acute-hyperkalemia/"}]
},
{
"externalId":"dyn-endo-dka","version":1,"objectiveId":"endocrine.dka-initial-management","title":"Initial DKA management","system":"Endocrine","discipline":"Medicine","performanceCategory":"Patient Care: Management","physicianTask":"Emergency Management","difficulty":2,
"stemTemplate":"A {{age}}-year-old with polyuria, abdominal pain, glucose {{glucose}} mg/dL, anion-gap acidosis, and dehydration is diagnosed with DKA.","leadIn":"What is the initial treatment?","variables":{"age":{"type":"integer","minimum":19,"maximum":54},"glucose":{"type":"integer","minimum":420,"maximum":680,"step":10}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"IV isotonic crystalloid","explanationTemplate":"Volume resuscitation is the first treatment."},"distractors":[{"id":"a","textTemplate":"IV bicarbonate in every patient"},{"id":"b","textTemplate":"Subcutaneous long-acting insulin only"},{"id":"c","textTemplate":"Fluid restriction"},{"id":"d","textTemplate":"Immediate hemodialysis"}],"explanationTemplate":"Begin DKA treatment with isotonic IV fluid resuscitation, then address potassium and insulin based on laboratory results.","keyTakeaway":"Restore intravascular volume first in DKA.","usmlePearl":"Check potassium before or alongside starting insulin.","references":[{"title":"Hyperglycemic Crises in Adults","publisher":"American Diabetes Association","url":"https://diabetesjournals.org/care/article/47/8/1257/156806/Hyperglycemic-Crises-in-Adults-With-Diabetes-A"}]
},
{
"externalId":"dyn-pulm-asthma","version":1,"objectiveId":"pulmonary.severe-asthma-exacerbation","title":"Severe asthma exacerbation","system":"Pulmonary","discipline":"Medicine","performanceCategory":"Patient Care: Management","physicianTask":"Emergency Management","difficulty":2,
"stemTemplate":"A {{age}}-year-old with asthma has severe dyspnea, diffuse wheezing, and oxygen saturation {{sat}}%.","leadIn":"Which inhaled treatment is most appropriate immediately?","variables":{"age":{"type":"integer","minimum":18,"maximum":60},"sat":{"type":"integer","minimum":84,"maximum":91}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"Albuterol plus ipratropium","explanationTemplate":"Severe exacerbations benefit from repeated SABA plus inhaled anticholinergic."},"distractors":[{"id":"a","textTemplate":"Salmeterol alone"},{"id":"b","textTemplate":"Inhaled corticosteroid alone"},{"id":"c","textTemplate":"Montelukast alone"},{"id":"d","textTemplate":"No bronchodilator until spirometry"}],"explanationTemplate":"Give rapid inhaled bronchodilation with albuterol and ipratropium, oxygen, and systemic glucocorticoids.","keyTakeaway":"Use SABA plus ipratropium for a severe acute asthma exacerbation.","usmlePearl":"Long-acting bronchodilators do not replace rescue therapy.","references":[{"title":"Global Strategy for Asthma Management and Prevention","publisher":"GINA","url":"https://ginasthma.org/reports/"}]
},
{
"externalId":"dyn-ob-pyelo","version":1,"objectiveId":"ob.pregnancy-pyelonephritis","title":"Pyelonephritis in pregnancy","system":"Pregnancy","discipline":"Obstetrics","performanceCategory":"Patient Care: Management","physicianTask":"Pharmacotherapy","difficulty":3,
"stemTemplate":"A patient at {{weeks}} weeks' gestation has fever, flank pain, costovertebral-angle tenderness, and pyuria.","leadIn":"What is the most appropriate management?","variables":{"weeks":{"type":"integer","minimum":14,"maximum":32}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"Hospitalize and give IV ceftriaxone","explanationTemplate":"Pyelonephritis in pregnancy is initially managed inpatient with parenteral antibiotics."},"distractors":[{"id":"a","textTemplate":"Oral nitrofurantoin as outpatient"},{"id":"b","textTemplate":"No antibiotics until culture returns"},{"id":"c","textTemplate":"Oral phenazopyridine only"},{"id":"d","textTemplate":"Immediate delivery"}],"explanationTemplate":"Pregnant patients with pyelonephritis should receive initial inpatient parenteral therapy such as ceftriaxone, adjusted to culture results.","keyTakeaway":"Treat pyelonephritis in pregnancy initially with inpatient IV antibiotics.","usmlePearl":"Nitrofurantoin does not achieve adequate renal tissue levels for pyelonephritis.","references":[{"title":"Urinary Tract Infections in Pregnant Individuals","publisher":"ACOG","url":"https://www.acog.org/clinical/clinical-guidance/clinical-consensus/articles/2023/08/urinary-tract-infections-in-pregnant-individuals"}]
},
{
"externalId":"dyn-endo-aldo","version":1,"objectiveId":"endocrine.primary-aldosteronism-screen","title":"Primary aldosteronism screening","system":"Endocrine","discipline":"Medicine","performanceCategory":"Patient Care: Diagnosis","physicianTask":"Diagnostic Studies","difficulty":3,
"stemTemplate":"A {{age}}-year-old has resistant hypertension and spontaneous hypokalemia with potassium {{potassium}} mEq/L.","leadIn":"What is the best initial screening test?","variables":{"age":{"type":"integer","minimum":32,"maximum":65},"potassium":{"type":"decimal","minimum":2.7,"maximum":3.3,"step":0.1,"precision":1}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"Plasma aldosterone-to-renin ratio","explanationTemplate":"The aldosterone-to-renin ratio is the recommended screening test."},"distractors":[{"id":"a","textTemplate":"24-hour urine metanephrines"},{"id":"b","textTemplate":"Dexamethasone suppression test"},{"id":"c","textTemplate":"TSH concentration"},{"id":"d","textTemplate":"Renal biopsy"}],"explanationTemplate":"Resistant hypertension plus hypokalemia suggests primary aldosteronism; screen with a plasma aldosterone-to-renin ratio.","keyTakeaway":"Use the aldosterone-to-renin ratio to screen for primary aldosteronism.","usmlePearl":"Correct hypokalemia and consider medication interference when interpreting the ratio.","references":[{"title":"Primary Aldosteronism Guideline","publisher":"Endocrine Society","url":"https://www.endocrine.org/clinical-practice-guidelines/primary-aldosteronism"}]
},
{
"externalId":"dyn-neuro-sah","version":1,"objectiveId":"neuro.subarachnoid-hemorrhage-initial-test","title":"Suspected subarachnoid hemorrhage","system":"Neurologic","discipline":"Medicine","performanceCategory":"Patient Care: Diagnosis","physicianTask":"Diagnostic Studies","difficulty":2,
"stemTemplate":"A {{age}}-year-old develops a sudden maximal-at-onset headache during {{activity}}, with vomiting and neck stiffness.","leadIn":"What is the best initial diagnostic study?","variables":{"age":{"type":"integer","minimum":38,"maximum":72},"activity":{"type":"enum","values":["exercise","sexual activity","lifting a heavy object"]}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"Noncontrast CT of the head","explanationTemplate":"Noncontrast head CT is the initial test for suspected SAH."},"distractors":[{"id":"a","textTemplate":"EEG"},{"id":"b","textTemplate":"Carotid ultrasonography"},{"id":"c","textTemplate":"Cervical spine radiograph"},{"id":"d","textTemplate":"Electromyography"}],"explanationTemplate":"A thunderclap headache suggests subarachnoid hemorrhage; obtain an urgent noncontrast head CT first.","keyTakeaway":"Start evaluation of suspected SAH with noncontrast head CT.","usmlePearl":"If suspicion remains after a negative CT, additional evaluation may be required based on timing and local protocols.","references":[{"title":"Guideline for Aneurysmal Subarachnoid Hemorrhage","publisher":"American Heart Association","url":"https://www.ahajournals.org/doi/10.1161/STR.0000000000000436"}]
},
{
"externalId":"dyn-pulm-pe","version":1,"objectiveId":"pulmonary.stable-pe-imaging","title":"Stable pulmonary embolism imaging","system":"Pulmonary","discipline":"Medicine","performanceCategory":"Patient Care: Diagnosis","physicianTask":"Diagnostic Studies","difficulty":3,
"stemTemplate":"A hemodynamically stable {{age}}-year-old has acute pleuritic chest pain, tachycardia, hypoxemia, and high clinical probability of pulmonary embolism. Kidney function is normal.","leadIn":"What is the best diagnostic test?","variables":{"age":{"type":"integer","minimum":35,"maximum":74}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"CT pulmonary angiography","explanationTemplate":"CT pulmonary angiography is first-line imaging in a stable patient without contrast contraindication."},"distractors":[{"id":"a","textTemplate":"Exercise stress test"},{"id":"b","textTemplate":"Bronchoscopy"},{"id":"c","textTemplate":"Noncontrast chest CT"},{"id":"d","textTemplate":"Coronary angiography"}],"explanationTemplate":"In a stable patient with high PE probability and no contrast contraindication, proceed to CT pulmonary angiography.","keyTakeaway":"Use CT pulmonary angiography to diagnose PE when probability is high and contrast is acceptable.","usmlePearl":"A D-dimer is most useful when pretest probability is low or intermediate.","references":[{"title":"Acute Pulmonary Embolism Guideline","publisher":"European Society of Cardiology","url":"https://www.escardio.org/Guidelines/Clinical-Practice-Guidelines/Acute-Pulmonary-Embolism-Diagnosis-and-Management-of"}]
},
{
"externalId":"dyn-id-meningitis","version":1,"objectiveId":"infectious.adult-bacterial-meningitis","title":"Empiric adult bacterial meningitis therapy","system":"Infectious Disease","discipline":"Medicine","performanceCategory":"Patient Care: Management","physicianTask":"Pharmacotherapy","difficulty":3,
"stemTemplate":"A {{age}}-year-old has fever, severe headache, neck stiffness, and altered mental status. Acute bacterial meningitis is suspected.","leadIn":"Which empiric antibiotic regimen is most appropriate?","variables":{"age":{"type":"integer","minimum":19,"maximum":45}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"Vancomycin plus ceftriaxone","explanationTemplate":"This combination covers resistant pneumococcus and common adult pathogens."},"distractors":[{"id":"a","textTemplate":"Azithromycin alone"},{"id":"b","textTemplate":"Metronidazole alone"},{"id":"c","textTemplate":"Nitrofurantoin alone"},{"id":"d","textTemplate":"Wait for CSF culture before antibiotics"}],"explanationTemplate":"For a nonelderly adult with suspected community-acquired bacterial meningitis, promptly give vancomycin plus a third-generation cephalosporin; add other agents when indicated by age or immune status.","keyTakeaway":"Do not delay empiric antibiotics in suspected bacterial meningitis.","usmlePearl":"Add ampicillin when Listeria coverage is indicated.","references":[{"title":"Practice Guidelines for Bacterial Meningitis","publisher":"IDSA","url":"https://academic.oup.com/cid/article/39/9/1267/402080"}]
},
{
"externalId":"dyn-psych-suicide","version":1,"objectiveId":"psychiatry.imminent-suicide-risk","title":"Imminent suicide risk","system":"Behavioral Health","discipline":"Psychiatry","performanceCategory":"Patient Care: Management","physicianTask":"Disposition","difficulty":2,
"stemTemplate":"A {{age}}-year-old with major depression reports a plan to die by suicide tonight, has access to the means, and cannot agree to a safety plan.","leadIn":"What is the most appropriate next step?","variables":{"age":{"type":"integer","minimum":18,"maximum":64}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"Emergency psychiatric hospitalization","explanationTemplate":"Specific plan, access, and imminent intent require a protected setting."},"distractors":[{"id":"a","textTemplate":"Routine follow-up in one month"},{"id":"b","textTemplate":"Ask the patient to promise not to self-harm and discharge"},{"id":"c","textTemplate":"Start psychotherapy next week without further action"},{"id":"d","textTemplate":"Leave the patient alone while arranging transport"}],"explanationTemplate":"Imminent suicide risk requires continuous safety measures and emergency psychiatric evaluation with hospitalization, involuntarily if necessary under applicable law.","keyTakeaway":"Hospitalize patients with imminent suicide risk.","usmlePearl":"A no-suicide contract does not replace risk assessment and safety planning.","references":[{"title":"Suicide Prevention Resource Center","publisher":"SPRC","url":"https://sprc.org/effective-prevention/a-comprehensive-approach-to-suicide-prevention/"}]
},
{
"externalId":"dyn-prevent-crc","version":1,"objectiveId":"preventive.colorectal-screening","title":"Colorectal cancer screening","system":"Preventive Medicine","discipline":"Family Medicine","performanceCategory":"Health Maintenance","physicianTask":"Preventive Care","difficulty":2,
"stemTemplate":"An asymptomatic {{age}}-year-old at average colorectal cancer risk has never been screened.","leadIn":"What is the best recommendation?","variables":{"age":{"type":"integer","minimum":45,"maximum":49}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"Begin colorectal cancer screening now","explanationTemplate":"Average-risk screening begins at age 45."},"distractors":[{"id":"a","textTemplate":"Wait until age 60"},{"id":"b","textTemplate":"Screen only if symptoms develop"},{"id":"c","textTemplate":"Order annual abdominal CT"},{"id":"d","textTemplate":"No screening is recommended at any age"}],"explanationTemplate":"Average-risk adults should begin colorectal cancer screening at age 45 using an accepted stool-based or direct-visualization strategy.","keyTakeaway":"Begin average-risk colorectal cancer screening at age 45.","usmlePearl":"Test choice and interval depend on the selected screening modality.","references":[{"title":"Colorectal Cancer Screening Recommendation","publisher":"USPSTF","url":"https://www.uspreventiveservicestaskforce.org/uspstf/recommendation/colorectal-cancer-screening"}]
},
{
"externalId":"dyn-ethics-surrogate","version":1,"objectiveId":"ethics.surrogate-decision-making","title":"Surrogate decision-making","system":"Social Sciences","discipline":"Ethics","performanceCategory":"Communication and Interpersonal Skills","physicianTask":"Legal/Ethical Principles","difficulty":3,
"stemTemplate":"An incapacitated {{age}}-year-old has no advance directive. The legally authorized surrogate is asked to choose among treatments.","leadIn":"Which standard should primarily guide the surrogate?","variables":{"age":{"type":"integer","minimum":46,"maximum":88}},"derived":{},"constraints":[],
"correctAnswer":{"id":"correct","textTemplate":"The patient's known values and previously expressed preferences","explanationTemplate":"Use substituted judgment when the patient's preferences are known."},"distractors":[{"id":"a","textTemplate":"The surrogate's personal treatment preferences"},{"id":"b","textTemplate":"The least expensive option regardless of benefit"},{"id":"c","textTemplate":"Whatever treatment the physician would choose personally"},{"id":"d","textTemplate":"Always provide every available intervention"}],"explanationTemplate":"A surrogate should use substituted judgment based on the patient's known values and prior wishes; if unknown, use the patient's best interests.","keyTakeaway":"Surrogates represent the patient's preferences, not their own.","usmlePearl":"Use best-interest reasoning only when the patient's preferences cannot be inferred.","references":[{"title":"Decisions for Adult Patients Who Lack Capacity","publisher":"American Medical Association","url":"https://code-medical-ethics.ama-assn.org/ethics-opinions/decisions-adult-patients-who-lack-capacity"}]
}
]
+1
View File
@@ -25,6 +25,7 @@ LegalService="*res://scripts/autoload/legal_service.gd"
UIThemeService="*res://scripts/autoload/ui_theme_service.gd"
UISoundService="*res://scripts/autoload/ui_sound_service.gd"
QuestionBankService="*res://scripts/autoload/question_bank_service.gd"
DynamicQuestionService="*res://scripts/autoload/dynamic_question_service.gd"
ProgressionService="*res://scripts/autoload/progression_service.gd"
ProfileService="*res://scripts/autoload/profile_service.gd"
BlockSessionService="*res://scripts/autoload/block_session_service.gd"
+30
View File
@@ -33,6 +33,36 @@ const CORE_MIN_TARGET_SECONDS := 45.0
const CORE_MAX_TARGET_SECONDS := 150.0
const CORE_RETRY_TARGET_SECONDS := 30.0
func start_dynamic_question(question: Dictionary) -> bool:
if question.is_empty():
return false
system_name = str(question.get("system", "All Systems"))
mode = "Tutor"
session_kind = "dynamic"
active_questions = [_prepare_question_choices(question)]
answers = [null]
current_index = 0
seconds_remaining = 0.0
session_initial_seconds = 90.0
current_question_started_at = Time.get_ticks_msec() / 1000.0
current_question_pause_started_at = 0.0
block_started = true
session_changed.emit()
return true
func replace_dynamic_question(question: Dictionary) -> bool:
if session_kind != "dynamic" or question.is_empty():
return false
active_questions = [_prepare_question_choices(question)]
answers = [null]
current_index = 0
current_question_started_at = Time.get_ticks_msec() / 1000.0
current_question_pause_started_at = 0.0
session_changed.emit()
return true
func start_block(selected_system: String, block_size: int, selected_mode: String) -> void:
system_name = selected_system
mode = selected_mode
@@ -0,0 +1,108 @@
extends Node
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_API_URL := "https://us3q.ranaze.com/api/dynamic-questions"
signal status_loaded(status: Dictionary)
signal question_loaded(question: Dictionary)
signal attempt_completed(result: Dictionary)
signal report_submitted(question_id: String, report_id: String)
signal report_failed(question_id: String, message: String)
signal request_failed(message: String)
var api_url := DEFAULT_API_URL
var run_token := ""
var generation_token := ""
var current_envelope := {}
var _request: HTTPRequest
var _operation := ""
var _report_question_id := ""
func _ready() -> void:
api_url = EndpointConfig.resolve(
str(ProjectSettings.get_setting("step3/dynamic_questions_api_url", DEFAULT_API_URL)),
"/api/dynamic-questions")
_request = HTTPRequest.new()
_request.timeout = 30.0
_request.request_completed.connect(_on_completed)
add_child(_request)
func load_status() -> void:
_send("status", HTTPClient.METHOD_GET, "/status", {})
func load_next() -> void:
_send("next", HTTPClient.METHOD_POST, "/next", {"runToken": run_token})
func submit_attempt(choice_id: String, duration_seconds: int, is_skip := false) -> void:
_send("attempt", HTTPClient.METHOD_POST, "/attempt", {
"generationToken": generation_token,
"choiceId": choice_id,
"isSkip": is_skip,
"durationSeconds": duration_seconds
})
func submit_report(question_id: String, category: String, details: String) -> void:
_report_question_id = question_id
_send("report", HTTPClient.METHOD_POST, "/report", {
"generationToken": generation_token,
"category": category,
"details": details
})
func reset_run() -> void:
run_token = ""
generation_token = ""
current_envelope = {}
func _send(operation: String, method: int, suffix: String, payload: Dictionary) -> void:
if not AuthState.can_send_authenticated_request():
request_failed.emit("Log in to use Dynamic Questions.")
return
if _request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return
_operation = operation
var headers := PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % AuthState.access_token
])
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var error := _request.request(api_url + suffix, headers, method, body)
if error != OK:
_operation = ""
request_failed.emit("Could not connect to Dynamic Questions.")
func _on_completed(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var operation := _operation
_operation = ""
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if response_code < 200 or response_code >= 300 or typeof(parsed) != TYPE_DICTIONARY:
var message := str(parsed) if parsed != null else "Dynamic Questions request failed."
if operation == "report":
report_failed.emit(_report_question_id, message)
else:
request_failed.emit(message)
return
var data: Dictionary = parsed
match operation:
"status":
status_loaded.emit(data)
"next":
run_token = str(data.get("runToken", ""))
generation_token = str(data.get("generationToken", ""))
current_envelope = data
var question := QuestionBankService.normalize_api_question(data.get("question", {}))
question["_dynamic_objective_id"] = str(data.get("objectiveId", ""))
question["_dynamic_objective_title"] = str(data.get("objectiveTitle", ""))
question_loaded.emit(question)
"attempt":
attempt_completed.emit(data)
"report":
report_submitted.emit(_report_question_id, str(data.get("id", "")))
@@ -0,0 +1 @@
uid://b8u2b8km7k6t
+79
View File
@@ -0,0 +1,79 @@
extends Control
const Brand := preload("res://scripts/ui/medmind_style.gd")
var main
var status_label: Label
var start_button: Button
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center)
var card := VBoxContainer.new()
card.custom_minimum_size = Vector2(620, 0)
card.add_theme_constant_override("separation", 18)
center.add_child(card)
var title := Label.new()
title.text = "Dynamic Questions"
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 34)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
card.add_child(title)
var description := Label.new()
description.text = "Endless, data-driven Step 3 practice. Clinical values and distractors vary while each tested objective stays medically invariant."
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
description.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
card.add_child(description)
status_label = Label.new()
status_label.text = "Checking available objectives…"
status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
card.add_child(status_label)
start_button = Button.new()
start_button.text = "Start Dynamic Questions"
start_button.custom_minimum_size = Vector2(0, 52)
start_button.disabled = true
start_button.pressed.connect(_start)
card.add_child(start_button)
DynamicQuestionService.status_loaded.connect(_on_status)
DynamicQuestionService.question_loaded.connect(_on_question)
DynamicQuestionService.request_failed.connect(_on_failed)
func on_show(_payload: Variant = null) -> void:
start_button.disabled = true
status_label.text = "Checking available objectives…"
DynamicQuestionService.load_status()
func _on_status(status: Dictionary) -> void:
var available := bool(status.get("available", false))
var count := int(status.get("publishedObjectives", 0))
var attempts := int(status.get("totalAttempts", 0))
status_label.text = "%d objectives • %d attempts • mastery %d" % [
count, attempts, int(status.get("averageObjectiveElo", 1000))]
start_button.disabled = not available
if not available:
status_label.text = "Dynamic question content has not been published yet."
func _start() -> void:
start_button.disabled = true
start_button.text = "Generating…"
DynamicQuestionService.reset_run()
DynamicQuestionService.load_next()
func _on_question(question: Dictionary) -> void:
start_button.text = "Start Dynamic Questions"
start_button.disabled = false
if BlockSessionService.start_dynamic_question(question):
main.show_screen("player")
func _on_failed(message: String) -> void:
start_button.text = "Try Again"
start_button.disabled = false
status_label.text = message
@@ -0,0 +1 @@
uid://53fdgx3f3am1
+9
View File
@@ -248,6 +248,12 @@ func _build_ui() -> void:
screens_container.add_child(selector)
screens["selection"] = selector
var dynamic_questions := preload("res://scripts/ui/dynamic_questions_screen.gd").new()
dynamic_questions.name = "DynamicQuestionsScreen"
dynamic_questions.main = self
screens_container.add_child(dynamic_questions)
screens["dynamic_questions"] = dynamic_questions
var map := preload("res://scripts/ui/study_map.gd").new()
map.name = "StudyMap"
map.main = self
@@ -410,6 +416,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
_add_nav_button(container, "home", "Dashboard", "chart-no-axes-column-increasing", true)
_add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", true)
_add_nav_button(container, "dynamic_questions", "Dynamic Questions", "shuffle", true)
_add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true)
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true)
_add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true)
@@ -454,6 +461,7 @@ func _on_navigation_pressed(key: String) -> void:
match key:
"home": show_screen("dashboard")
"question_bank": show_screen("selection", "question_bank")
"dynamic_questions": show_screen("dynamic_questions")
"exams": show_screen("simulation_history")
"ccs": show_screen("ccs_hub")
"flashcards": show_screen("flashcards_study")
@@ -472,6 +480,7 @@ func _update_header_title(screen_name: String) -> void:
var titles := {
"dashboard": "Dashboard",
"selection": "Question Bank",
"dynamic_questions": "Dynamic Questions",
"simulation_history": "Exam Simulations",
"ccs_hub": "CCS Cases",
"flashcards_hub": "Flashcards",
+3 -1
View File
@@ -10,7 +10,9 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
elif screen_name in ["map", "setup", "review"]:
key = "study_plan" if screen_name == "map" else "question_bank"
elif screen_name == "player":
key = "question_bank"
key = "dynamic_questions" if BlockSessionService.session_kind == "dynamic" else "question_bank"
elif screen_name == "dynamic_questions":
key = "dynamic_questions"
elif screen_name.begins_with("simulation_"):
key = "exams"
elif screen_name.begins_with("ccs_"):
+54 -9
View File
@@ -164,6 +164,14 @@ func _ready() -> void:
QuestionBankService.question_report_submitted.connect(_on_question_report_submitted)
if not QuestionBankService.question_report_failed.is_connected(_on_question_report_failed):
QuestionBankService.question_report_failed.connect(_on_question_report_failed)
if not DynamicQuestionService.question_loaded.is_connected(_on_dynamic_question_loaded):
DynamicQuestionService.question_loaded.connect(_on_dynamic_question_loaded)
if not DynamicQuestionService.request_failed.is_connected(_on_dynamic_question_failed):
DynamicQuestionService.request_failed.connect(_on_dynamic_question_failed)
if not DynamicQuestionService.report_submitted.is_connected(_on_question_report_submitted):
DynamicQuestionService.report_submitted.connect(_on_question_report_submitted)
if not DynamicQuestionService.report_failed.is_connected(_on_question_report_failed):
DynamicQuestionService.report_failed.connect(_on_question_report_failed)
if not QuestionBankService.questions_changed.is_connected(_on_random_question_changed):
QuestionBankService.questions_changed.connect(_on_random_question_changed)
if not QuestionBankService.questions_load_failed.is_connected(_on_random_question_failed):
@@ -1115,7 +1123,7 @@ func _load_question() -> void:
next_button.text = "Submit"
next_button.disabled = true
skip_button.disabled = false
skip_button.visible = BlockSessionService.session_kind != "review"
skip_button.visible = BlockSessionService.session_kind not in ["review", "dynamic"]
skip_button.text = "Skip"
report_button.visible = BlockSessionService.session_kind != "simulation"
@@ -1123,13 +1131,16 @@ func _load_question() -> void:
if question.is_empty():
if BlockSessionService.session_kind == "review":
main.show_screen("dashboard")
elif BlockSessionService.session_kind == "dynamic":
main.show_screen("dynamic_questions")
else:
main.show_screen("bookmarks" if BlockSessionService.session_kind == "bookmark" else "selection", "exam" if BlockSessionService.session_kind == "core" else "step3")
return
BookmarkService.observe_question(question)
if BlockSessionService.session_kind != "dynamic":
BookmarkService.observe_question(question)
progress_button.text = _question_progress_text()
progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review"]
progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review"] else "Open question navigator"
progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"]
progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"] else "Open question navigator"
var show_timer := _is_session_timer_visible()
timer_label.visible = show_timer
timer_bar.visible = show_timer
@@ -1373,6 +1384,12 @@ func _submit_or_next() -> void:
if selected_choice == "":
return
var question_id_for_attempt := str(BlockSessionService.get_current_question().get("id", ""))
if BlockSessionService.session_kind == "dynamic":
var elapsed := int(floor(BlockSessionService.get_current_elapsed_seconds()))
DynamicQuestionService.submit_attempt(selected_choice, elapsed)
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, false)
_show_answer_review()
return
if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
var attempted_choice := selected_choice
if BlockSessionService.session_kind == "core":
@@ -1422,7 +1439,12 @@ func _submit_or_next() -> void:
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
_show_answer_review()
else:
if BlockSessionService.session_kind == "core":
if BlockSessionService.session_kind == "dynamic":
awaiting_next_question = true
next_button.text = "Generating…"
next_button.disabled = true
DynamicQuestionService.load_next()
elif BlockSessionService.session_kind == "core":
_request_next_core_question()
elif BlockSessionService.session_kind == "bookmark":
if BookmarkService.has_next_practice():
@@ -1441,6 +1463,26 @@ func _submit_or_next() -> void:
_advance_or_finish()
func _on_dynamic_question_loaded(question: Dictionary) -> void:
if not awaiting_next_question or BlockSessionService.session_kind != "dynamic":
return
if not BlockSessionService.replace_dynamic_question(question):
_on_dynamic_question_failed("The next dynamic question could not be prepared.")
return
awaiting_next_question = false
_load_question()
UISoundService.play_sound("question")
func _on_dynamic_question_failed(message: String) -> void:
if not awaiting_next_question or BlockSessionService.session_kind != "dynamic":
return
awaiting_next_question = false
next_button.text = "Retry"
next_button.disabled = false
next_button.tooltip_text = message
func _request_next_core_question() -> void:
awaiting_next_question = true
next_button.text = "Loading..."
@@ -2053,12 +2095,12 @@ func _refresh_toolbar_state() -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
if notes_button != null:
notes_button.visible = BlockSessionService.session_kind != "simulation"
notes_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
if flashcard_button != null:
flashcard_button.visible = BlockSessionService.session_kind != "simulation"
flashcard_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
flashcard_button.disabled = current_answer.is_empty()
if bookmark_button != null:
bookmark_button.visible = BlockSessionService.session_kind != "simulation"
bookmark_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
var bookmark_question_id := str(BlockSessionService.get_current_question().get("id", ""))
var bookmark_value := BookmarkService.is_bookmarked(bookmark_question_id)
var bookmark_pending := BookmarkService.is_pending(bookmark_question_id)
@@ -2797,7 +2839,10 @@ func _submit_question_report() -> void:
return
report_error_label.text = ""
_set_report_submitting(true)
QuestionBankService.submit_question_report(report_question_id, category, details)
if BlockSessionService.session_kind == "dynamic":
DynamicQuestionService.submit_report(report_question_id, category, details)
else:
QuestionBankService.submit_question_report(report_question_id, category, details)
func _on_question_report_submitted(question_id: String, _report_id: String) -> void:
+1 -11
View File
@@ -155,7 +155,7 @@ func _build_mode_view() -> void:
mode_view.add_child(title)
var subtitle := Label.new()
subtitle.text = "Build clinical judgment through focused questions. Additional modes are in development."
subtitle.text = "Build clinical judgment through focused authored questions."
subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
subtitle.add_theme_font_override("font", Brand.font)
subtitle.add_theme_font_size_override("font_size", 17)
@@ -187,16 +187,6 @@ func _build_mode_view() -> void:
retry_button = question_card.retry
retry_button.pressed.connect(_retry_question_bank)
_add_mode_card(
"Dynamic Questions",
"AI-generated practice",
"Coming Soon",
"Coming Soon",
"Endless practice with AI-generated questions\ntailored to your performance.",
"activity",
false,
Brand.PURPLE)
var ccs_card := _add_mode_card(
"CCS Cases",
"Interactive case simulations",