diff --git a/api/QuestionsApi.Tests/DynamicQuestionTests.cs b/api/QuestionsApi.Tests/DynamicQuestionTests.cs new file mode 100644 index 0000000..0565aa9 --- /dev/null +++ b/api/QuestionsApi.Tests/DynamicQuestionTests.cs @@ -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 { ["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(() => service.ReadGeneration(token, "user-two")); + Assert.ThrowsAny(() => 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" }] + }; +} diff --git a/api/QuestionsApi/Controllers/DynamicQuestionsController.cs b/api/QuestionsApi/Controllers/DynamicQuestionsController.cs new file mode 100644 index 0000000..fd577b9 --- /dev/null +++ b/api/QuestionsApi/Controllers/DynamicQuestionsController.cs @@ -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> Status() => + Ok(await _questions.StatusAsync(UserId())); + + [HttpPost("next")] + public async Task> 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> 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 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 + ?? ""; +} diff --git a/api/QuestionsApi/Models/DynamicQuestionModels.cs b/api/QuestionsApi/Models/DynamicQuestionModels.cs new file mode 100644 index 0000000..c018a20 --- /dev/null +++ b/api/QuestionsApi/Models/DynamicQuestionModels.cs @@ -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 Variables { get; set; } = []; + public Dictionary Derived { get; set; } = []; + public List Constraints { get; set; } = []; + public List Labs { get; set; } = []; + public DynamicAnswerTemplate CorrectAnswer { get; set; } = new(); + public List Distractors { get; set; } = []; + public string ExplanationTemplate { get; set; } = ""; + public string KeyTakeaway { get; set; } = ""; + public string UsmlePearl { get; set; } = ""; + public List References { get; set; } = []; + public List 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 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 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; } = ""; +} diff --git a/api/QuestionsApi/Models/QuestionReport.cs b/api/QuestionsApi/Models/QuestionReport.cs index d8c910d..223312c 100644 --- a/api/QuestionsApi/Models/QuestionReport.cs +++ b/api/QuestionsApi/Models/QuestionReport.cs @@ -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; } diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index b1e094b..754fdc7 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/api/QuestionsApi/Services/DynamicBlueprintCommand.cs b/api/QuestionsApi/Services/DynamicBlueprintCommand.cs new file mode 100644 index 0000000..8d781f4 --- /dev/null +++ b/api/QuestionsApi/Services/DynamicBlueprintCommand.cs @@ -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 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(); + foreach (var file in Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories)) + blueprints.AddRange(JsonSerializer.Deserialize>(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; + } +} diff --git a/api/QuestionsApi/Services/DynamicQuestionGenerator.cs b/api/QuestionsApi/Services/DynamicQuestionGenerator.cs new file mode 100644 index 0000000..ed3b6aa --- /dev/null +++ b/api/QuestionsApi/Services/DynamicQuestionGenerator.cs @@ -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(StringComparer.Ordinal); + var numbers = new Dictionary(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 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 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); + } + } +} diff --git a/api/QuestionsApi/Services/DynamicQuestionService.cs b/api/QuestionsApi/Services/DynamicQuestionService.cs new file mode 100644 index 0000000..ce6b39c --- /dev/null +++ b/api/QuestionsApi/Services/DynamicQuestionService.cs @@ -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 StatusAsync(string userId) => _store.StatusAsync(userId); + + public async Task 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 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) + }; +} diff --git a/api/QuestionsApi/Services/DynamicQuestionStore.cs b/api/QuestionsApi/Services/DynamicQuestionStore.cs new file mode 100644 index 0000000..3c591c7 --- /dev/null +++ b/api/QuestionsApi/Services/DynamicQuestionStore.cs @@ -0,0 +1,124 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class DynamicQuestionStore +{ + private readonly IMongoCollection _blueprints; + private readonly IMongoCollection _attempts; + private readonly IMongoCollection _mastery; + + public DynamicQuestionStore(MongoDatabaseProvider mongo) + { + _blueprints = mongo.Database.GetCollection("DynamicQuestionBlueprints"); + _attempts = mongo.Database.GetCollection("DynamicQuestionAttempts"); + _mastery = mongo.Database.GetCollection("DynamicObjectiveMastery"); + _blueprints.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.ExternalId).Ascending(value => value.Version), + new CreateIndexOptions { Unique = true })); + _attempts.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.Nonce), + new CreateIndexOptions { Unique = true })); + _mastery.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ObjectiveId), + new CreateIndexOptions { Unique = true })); + } + + public async Task> PublishedAsync() => + await _blueprints.Find(value => value.IsPublished).ToListAsync(); + + public async Task GetAsync(string externalId, int version) => + await _blueprints.Find(value => value.ExternalId == externalId && value.Version == version).FirstOrDefaultAsync(); + + public async Task> MasteryAsync(string userId) => + (await _mastery.Find(value => value.UserId == userId).ToListAsync()) + .ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal); + + public async Task 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.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 { 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 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 blueprints) + { + foreach (var blueprint in blueprints) + await _blueprints.ReplaceOneAsync( + value => value.ExternalId == blueprint.ExternalId && value.Version == blueprint.Version, + blueprint, + new ReplaceOptions { IsUpsert = true }); + } +} diff --git a/api/QuestionsApi/Services/DynamicQuestionTokenService.cs b/api/QuestionsApi/Services/DynamicQuestionTokenService.cs new file mode 100644 index 0000000..e1a396e --- /dev/null +++ b/api/QuestionsApi/Services/DynamicQuestionTokenService.cs @@ -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(token, userId); + public DynamicRunClaims ReadRun(string token, string userId) => Read(token, userId); + + private string Sign(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(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(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); + } +} diff --git a/api/QuestionsApi/Services/QuestionReportStore.cs b/api/QuestionsApi/Services/QuestionReportStore.cs index a082097..5027cd5 100644 --- a/api/QuestionsApi/Services/QuestionReportStore.cs +++ b/api/QuestionsApi/Services/QuestionReportStore.cs @@ -79,6 +79,35 @@ public class QuestionReportStore return report; } + public async Task 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 GetByIdAsync(string id) { if (!ObjectId.TryParse(id, out _)) diff --git a/content/dynamic_questions/pilot.json b/content/dynamic_questions/pilot.json new file mode 100644 index 0000000..5d19f16 --- /dev/null +++ b/content/dynamic_questions/pilot.json @@ -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"}] + } +] diff --git a/project.godot b/project.godot index 2630382..49fd38f 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/scripts/autoload/block_session_service.gd b/scripts/autoload/block_session_service.gd index 70df3b9..a2a2fd8 100644 --- a/scripts/autoload/block_session_service.gd +++ b/scripts/autoload/block_session_service.gd @@ -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 diff --git a/scripts/autoload/dynamic_question_service.gd b/scripts/autoload/dynamic_question_service.gd new file mode 100644 index 0000000..ccaa126 --- /dev/null +++ b/scripts/autoload/dynamic_question_service.gd @@ -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", ""))) diff --git a/scripts/autoload/dynamic_question_service.gd.uid b/scripts/autoload/dynamic_question_service.gd.uid new file mode 100644 index 0000000..93be453 --- /dev/null +++ b/scripts/autoload/dynamic_question_service.gd.uid @@ -0,0 +1 @@ +uid://b8u2b8km7k6t diff --git a/scripts/ui/dynamic_questions_screen.gd b/scripts/ui/dynamic_questions_screen.gd new file mode 100644 index 0000000..81e25c8 --- /dev/null +++ b/scripts/ui/dynamic_questions_screen.gd @@ -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 diff --git a/scripts/ui/dynamic_questions_screen.gd.uid b/scripts/ui/dynamic_questions_screen.gd.uid new file mode 100644 index 0000000..3fd0abf --- /dev/null +++ b/scripts/ui/dynamic_questions_screen.gd.uid @@ -0,0 +1 @@ +uid://53fdgx3f3am1 diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 3a64801..9f3fc38 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -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", diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index 23357c4..8070cec 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -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_"): diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index faa274c..13db4dd 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -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: diff --git a/scripts/ui/study_selector.gd b/scripts/ui/study_selector.gd index de61422..2950c23 100644 --- a/scripts/ui/study_selector.gd +++ b/scripts/ui/study_selector.gd @@ -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",