Add data-driven dynamic question mode
This commit is contained in:
@@ -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 Details { get; set; } = "";
|
||||||
public string Status { get; set; } = QuestionReportStatus.Open;
|
public string Status { get; set; } = QuestionReportStatus.Open;
|
||||||
public QuestionReportSnapshot QuestionSnapshot { get; set; } = new();
|
public QuestionReportSnapshot QuestionSnapshot { get; set; } = new();
|
||||||
|
public string SourceType { get; set; } = "authored";
|
||||||
|
public DynamicGenerationDescriptor? DynamicGeneration { get; set; }
|
||||||
public string ReviewedByUserId { get; set; } = "";
|
public string ReviewedByUserId { get; set; } = "";
|
||||||
public string ReviewerNote { get; set; } = "";
|
public string ReviewerNote { get; set; } = "";
|
||||||
public DateTime? ReviewedUtc { get; set; }
|
public DateTime? ReviewedUtc { get; set; }
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ if (args.FirstOrDefault() == "promote-content")
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.FirstOrDefault() == "promote-dynamic-questions")
|
||||||
|
{
|
||||||
|
Environment.ExitCode = await DynamicBlueprintCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (args.FirstOrDefault() == "seed-flashcards")
|
if (args.FirstOrDefault() == "seed-flashcards")
|
||||||
{
|
{
|
||||||
var seedConfiguration = new ConfigurationBuilder()
|
var seedConfiguration = new ConfigurationBuilder()
|
||||||
@@ -54,6 +60,10 @@ builder.Services.AddSingleton<AnswerStatisticsStore>();
|
|||||||
builder.Services.AddSingleton<RatingStore>();
|
builder.Services.AddSingleton<RatingStore>();
|
||||||
builder.Services.AddSingleton<QuestionMediaStore>();
|
builder.Services.AddSingleton<QuestionMediaStore>();
|
||||||
builder.Services.AddSingleton<QuestionReportStore>();
|
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<ExamSimulationStore>();
|
||||||
builder.Services.AddSingleton<CcsStore>();
|
builder.Services.AddSingleton<CcsStore>();
|
||||||
builder.Services.AddSingleton<CcsRatingStore>();
|
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;
|
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)
|
public async Task<QuestionReport?> GetByIdAsync(string id)
|
||||||
{
|
{
|
||||||
if (!ObjectId.TryParse(id, out _))
|
if (!ObjectId.TryParse(id, out _))
|
||||||
|
|||||||
@@ -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"}]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -25,6 +25,7 @@ LegalService="*res://scripts/autoload/legal_service.gd"
|
|||||||
UIThemeService="*res://scripts/autoload/ui_theme_service.gd"
|
UIThemeService="*res://scripts/autoload/ui_theme_service.gd"
|
||||||
UISoundService="*res://scripts/autoload/ui_sound_service.gd"
|
UISoundService="*res://scripts/autoload/ui_sound_service.gd"
|
||||||
QuestionBankService="*res://scripts/autoload/question_bank_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"
|
ProgressionService="*res://scripts/autoload/progression_service.gd"
|
||||||
ProfileService="*res://scripts/autoload/profile_service.gd"
|
ProfileService="*res://scripts/autoload/profile_service.gd"
|
||||||
BlockSessionService="*res://scripts/autoload/block_session_service.gd"
|
BlockSessionService="*res://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_MAX_TARGET_SECONDS := 150.0
|
||||||
const CORE_RETRY_TARGET_SECONDS := 30.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:
|
func start_block(selected_system: String, block_size: int, selected_mode: String) -> void:
|
||||||
system_name = selected_system
|
system_name = selected_system
|
||||||
mode = selected_mode
|
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
|
||||||
@@ -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
|
||||||
@@ -248,6 +248,12 @@ func _build_ui() -> void:
|
|||||||
screens_container.add_child(selector)
|
screens_container.add_child(selector)
|
||||||
screens["selection"] = 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()
|
var map := preload("res://scripts/ui/study_map.gd").new()
|
||||||
map.name = "StudyMap"
|
map.name = "StudyMap"
|
||||||
map.main = self
|
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, "home", "Dashboard", "chart-no-axes-column-increasing", true)
|
||||||
_add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", 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, "ccs", "CCS Cases", "stethoscope", true)
|
||||||
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true)
|
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true)
|
||||||
_add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true)
|
_add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true)
|
||||||
@@ -454,6 +461,7 @@ func _on_navigation_pressed(key: String) -> void:
|
|||||||
match key:
|
match key:
|
||||||
"home": show_screen("dashboard")
|
"home": show_screen("dashboard")
|
||||||
"question_bank": show_screen("selection", "question_bank")
|
"question_bank": show_screen("selection", "question_bank")
|
||||||
|
"dynamic_questions": show_screen("dynamic_questions")
|
||||||
"exams": show_screen("simulation_history")
|
"exams": show_screen("simulation_history")
|
||||||
"ccs": show_screen("ccs_hub")
|
"ccs": show_screen("ccs_hub")
|
||||||
"flashcards": show_screen("flashcards_study")
|
"flashcards": show_screen("flashcards_study")
|
||||||
@@ -472,6 +480,7 @@ func _update_header_title(screen_name: String) -> void:
|
|||||||
var titles := {
|
var titles := {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"selection": "Question Bank",
|
"selection": "Question Bank",
|
||||||
|
"dynamic_questions": "Dynamic Questions",
|
||||||
"simulation_history": "Exam Simulations",
|
"simulation_history": "Exam Simulations",
|
||||||
"ccs_hub": "CCS Cases",
|
"ccs_hub": "CCS Cases",
|
||||||
"flashcards_hub": "Flashcards",
|
"flashcards_hub": "Flashcards",
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
|||||||
elif screen_name in ["map", "setup", "review"]:
|
elif screen_name in ["map", "setup", "review"]:
|
||||||
key = "study_plan" if screen_name == "map" else "question_bank"
|
key = "study_plan" if screen_name == "map" else "question_bank"
|
||||||
elif screen_name == "player":
|
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_"):
|
elif screen_name.begins_with("simulation_"):
|
||||||
key = "exams"
|
key = "exams"
|
||||||
elif screen_name.begins_with("ccs_"):
|
elif screen_name.begins_with("ccs_"):
|
||||||
|
|||||||
@@ -164,6 +164,14 @@ func _ready() -> void:
|
|||||||
QuestionBankService.question_report_submitted.connect(_on_question_report_submitted)
|
QuestionBankService.question_report_submitted.connect(_on_question_report_submitted)
|
||||||
if not QuestionBankService.question_report_failed.is_connected(_on_question_report_failed):
|
if not QuestionBankService.question_report_failed.is_connected(_on_question_report_failed):
|
||||||
QuestionBankService.question_report_failed.connect(_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):
|
if not QuestionBankService.questions_changed.is_connected(_on_random_question_changed):
|
||||||
QuestionBankService.questions_changed.connect(_on_random_question_changed)
|
QuestionBankService.questions_changed.connect(_on_random_question_changed)
|
||||||
if not QuestionBankService.questions_load_failed.is_connected(_on_random_question_failed):
|
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.text = "Submit"
|
||||||
next_button.disabled = true
|
next_button.disabled = true
|
||||||
skip_button.disabled = false
|
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"
|
skip_button.text = "Skip"
|
||||||
report_button.visible = BlockSessionService.session_kind != "simulation"
|
report_button.visible = BlockSessionService.session_kind != "simulation"
|
||||||
|
|
||||||
@@ -1123,13 +1131,16 @@ func _load_question() -> void:
|
|||||||
if question.is_empty():
|
if question.is_empty():
|
||||||
if BlockSessionService.session_kind == "review":
|
if BlockSessionService.session_kind == "review":
|
||||||
main.show_screen("dashboard")
|
main.show_screen("dashboard")
|
||||||
|
elif BlockSessionService.session_kind == "dynamic":
|
||||||
|
main.show_screen("dynamic_questions")
|
||||||
else:
|
else:
|
||||||
main.show_screen("bookmarks" if BlockSessionService.session_kind == "bookmark" else "selection", "exam" if BlockSessionService.session_kind == "core" else "step3")
|
main.show_screen("bookmarks" if BlockSessionService.session_kind == "bookmark" else "selection", "exam" if BlockSessionService.session_kind == "core" else "step3")
|
||||||
return
|
return
|
||||||
BookmarkService.observe_question(question)
|
if BlockSessionService.session_kind != "dynamic":
|
||||||
|
BookmarkService.observe_question(question)
|
||||||
progress_button.text = _question_progress_text()
|
progress_button.text = _question_progress_text()
|
||||||
progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review"]
|
progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"]
|
||||||
progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review"] else "Open question navigator"
|
progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"] else "Open question navigator"
|
||||||
var show_timer := _is_session_timer_visible()
|
var show_timer := _is_session_timer_visible()
|
||||||
timer_label.visible = show_timer
|
timer_label.visible = show_timer
|
||||||
timer_bar.visible = show_timer
|
timer_bar.visible = show_timer
|
||||||
@@ -1373,6 +1384,12 @@ func _submit_or_next() -> void:
|
|||||||
if selected_choice == "":
|
if selected_choice == "":
|
||||||
return
|
return
|
||||||
var question_id_for_attempt := str(BlockSessionService.get_current_question().get("id", ""))
|
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"]:
|
if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
|
||||||
var attempted_choice := selected_choice
|
var attempted_choice := selected_choice
|
||||||
if BlockSessionService.session_kind == "core":
|
if BlockSessionService.session_kind == "core":
|
||||||
@@ -1422,7 +1439,12 @@ func _submit_or_next() -> void:
|
|||||||
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
|
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
|
||||||
_show_answer_review()
|
_show_answer_review()
|
||||||
else:
|
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()
|
_request_next_core_question()
|
||||||
elif BlockSessionService.session_kind == "bookmark":
|
elif BlockSessionService.session_kind == "bookmark":
|
||||||
if BookmarkService.has_next_practice():
|
if BookmarkService.has_next_practice():
|
||||||
@@ -1441,6 +1463,26 @@ func _submit_or_next() -> void:
|
|||||||
_advance_or_finish()
|
_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:
|
func _request_next_core_question() -> void:
|
||||||
awaiting_next_question = true
|
awaiting_next_question = true
|
||||||
next_button.text = "Loading..."
|
next_button.text = "Loading..."
|
||||||
@@ -2053,12 +2095,12 @@ func _refresh_toolbar_state() -> void:
|
|||||||
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
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"
|
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
|
||||||
if notes_button != null:
|
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:
|
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()
|
flashcard_button.disabled = current_answer.is_empty()
|
||||||
if bookmark_button != null:
|
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_question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
||||||
var bookmark_value := BookmarkService.is_bookmarked(bookmark_question_id)
|
var bookmark_value := BookmarkService.is_bookmarked(bookmark_question_id)
|
||||||
var bookmark_pending := BookmarkService.is_pending(bookmark_question_id)
|
var bookmark_pending := BookmarkService.is_pending(bookmark_question_id)
|
||||||
@@ -2797,7 +2839,10 @@ func _submit_question_report() -> void:
|
|||||||
return
|
return
|
||||||
report_error_label.text = ""
|
report_error_label.text = ""
|
||||||
_set_report_submitting(true)
|
_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:
|
func _on_question_report_submitted(question_id: String, _report_id: String) -> void:
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ func _build_mode_view() -> void:
|
|||||||
mode_view.add_child(title)
|
mode_view.add_child(title)
|
||||||
|
|
||||||
var subtitle := Label.new()
|
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.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
subtitle.add_theme_font_override("font", Brand.font)
|
subtitle.add_theme_font_override("font", Brand.font)
|
||||||
subtitle.add_theme_font_size_override("font_size", 17)
|
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 = question_card.retry
|
||||||
retry_button.pressed.connect(_retry_question_bank)
|
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(
|
var ccs_card := _add_mode_card(
|
||||||
"CCS Cases",
|
"CCS Cases",
|
||||||
"Interactive case simulations",
|
"Interactive case simulations",
|
||||||
|
|||||||
Reference in New Issue
Block a user