Upgrade dynamic questions to authored parity

This commit is contained in:
hz
2026-07-23 14:11:18 -05:00
parent e174b5db15
commit 92c5688b55
12 changed files with 690 additions and 18 deletions
@@ -48,6 +48,38 @@ public sealed class DynamicQuestionTests
Assert.ThrowsAny<Exception>(() => service.ReadGeneration(token + "x", "user-one"));
}
[Fact]
public void GoldGenerator_UsesCaseFamilies_AndProducesAuthoredTeachingShape()
{
var blueprint = GoldBlueprint();
var generator = new DynamicQuestionGenerator();
var generated = Enumerable.Range(0, 100)
.Select(seed => generator.Generate(blueprint, seed.ToString(), seed.ToString()))
.ToList();
Assert.Equal(3, generated.Select(value => value.CaseFamilyId).Distinct().Count());
Assert.All(generated, value =>
{
Assert.True(value.Question.Stem.Length >= 220);
Assert.Equal(5, value.Question.Choices.Count);
Assert.Equal(5, value.Question.ChoiceExplanations.Count);
Assert.Single(value.Question.ReviewFollowups);
Assert.Equal(4, value.Question.WrongAnswerFollowups.Count);
Assert.Contains("quality-standard:2", value.Question.Tags);
});
Assert.Null(DynamicQuestionGenerator.Validate(blueprint));
}
[Fact]
public void LegacyGeneratorVersion_RemainsDeterministic()
{
var generator = new DynamicQuestionGenerator();
var first = generator.Generate(Blueprint(), "legacy-seed", "one", 1).Question;
var regenerated = generator.Generate(Blueprint(), "legacy-seed", "two", 1).Question;
Assert.Equal(first.Stem, regenerated.Stem);
Assert.Equal(first.Choices.Select(value => value.Id), regenerated.Choices.Select(value => value.Id));
}
private static DynamicQuestionBlueprint Blueprint() => new()
{
ExternalId = "test",
@@ -68,4 +100,50 @@ public sealed class DynamicQuestionTests
],
References = [new QuestionReference { Title = "Reference", Url = "https://example.test" }]
};
private static DynamicQuestionBlueprint GoldBlueprint()
{
var stem = string.Join(" ", Enumerable.Repeat(
"A patient presents with a carefully correlated history, examination, vital signs, and diagnostic findings that establish the clinical context without naming the diagnosis.", 3));
return new DynamicQuestionBlueprint
{
ExternalId = "gold-test",
Version = 2,
ObjectiveId = "gold.objective",
QualityStandardVersion = 2,
CorrectConceptId = "gold.correct",
CorrectAnswer = new DynamicAnswerTemplate
{ Id = "correct", TextTemplate = "Correct action", ExplanationTemplate = "This addresses the decisive finding." },
Distractors = Enumerable.Range(1, 6).Select(index => new DynamicAnswerTemplate
{
Id = $"d{index}",
TextTemplate = $"Distractor {index}",
ExplanationTemplate = $"Distractor {index} does not address the decisive finding."
}).ToList(),
ExplanationTemplate = "A complete explanation connects the presentation to the action.",
KeyTakeaway = "Use the decisive finding.",
UsmlePearl = "Sequence urgent care correctly.",
ReviewFollowups =
[
new DynamicReviewFollowupTemplate
{
PromptTemplate = "What is decisive?",
Choices =
[
new() { Id = "yes", TextTemplate = "The unstable finding" },
new() { Id = "no", TextTemplate = "An incidental finding" }
],
CorrectChoiceId = "yes",
TeachingPointTemplate = "The unstable finding determines management."
}
],
CaseFamilies =
[
new() { Id = "family-a", StemTemplate = stem, LeadIn = "What is next?" },
new() { Id = "family-b", StemTemplate = stem + " Family B.", LeadIn = "What is next?" },
new() { Id = "family-c", StemTemplate = stem + " Family C.", LeadIn = "What is next?" }
],
References = [new QuestionReference { Title = "Reference", Url = "https://example.test" }]
};
}
}
@@ -17,6 +17,8 @@ public sealed class DynamicQuestionBlueprint
public string PerformanceCategory { get; set; } = "";
public string PhysicianTask { get; set; } = "";
public int Difficulty { get; set; } = 3;
public int QualityStandardVersion { get; set; } = 1;
public string CorrectConceptId { get; set; } = "";
public bool IsPublished { get; set; } = true;
public string StemTemplate { get; set; } = "";
public string LeadIn { get; set; } = "";
@@ -31,6 +33,44 @@ public sealed class DynamicQuestionBlueprint
public string UsmlePearl { get; set; } = "";
public List<QuestionReference> References { get; set; } = [];
public List<string> Tags { get; set; } = [];
public List<DynamicReviewFollowupTemplate> ReviewFollowups { get; set; } = [];
public Dictionary<string, DynamicWrongAnswerFollowupTemplate> WrongAnswerFollowups { get; set; } = [];
public List<DynamicCaseFamily> CaseFamilies { get; set; } = [];
}
public sealed class DynamicCaseFamily
{
public string Id { get; set; } = "";
public string StemTemplate { get; set; } = "";
public string LeadIn { get; set; } = "";
public Dictionary<string, DynamicVariableDefinition> Variables { get; set; } = [];
public List<Dictionary<string, string>> ValueBundles { get; set; } = [];
public Dictionary<string, DynamicDerivedDefinition> Derived { get; set; } = [];
public List<DynamicConstraint> Constraints { get; set; } = [];
public List<DynamicLabTemplate> Labs { get; set; } = [];
public DynamicAnswerTemplate CorrectAnswer { get; set; } = new();
public List<DynamicAnswerTemplate> Distractors { get; set; } = [];
public string ExplanationTemplate { get; set; } = "";
public string KeyTakeaway { get; set; } = "";
public string UsmlePearl { get; set; } = "";
public List<DynamicReviewFollowupTemplate> ReviewFollowups { get; set; } = [];
public Dictionary<string, DynamicWrongAnswerFollowupTemplate> WrongAnswerFollowups { get; set; } = [];
}
public sealed class DynamicReviewFollowupTemplate
{
public string PromptTemplate { get; set; } = "";
public List<DynamicAnswerTemplate> Choices { get; set; } = [];
public string CorrectChoiceId { get; set; } = "";
public string TeachingPointTemplate { get; set; } = "";
}
public sealed class DynamicWrongAnswerFollowupTemplate
{
public string PromptTemplate { get; set; } = "";
public List<DynamicAnswerTemplate> Choices { get; set; } = [];
public string CorrectChoiceId { get; set; } = "";
public string TeachingPointTemplate { get; set; } = "";
}
public sealed class DynamicVariableDefinition
@@ -82,6 +122,7 @@ public sealed class DynamicGenerationClaims
public string Seed { get; set; } = "";
public string Nonce { get; set; } = "";
public int GeneratorVersion { get; set; } = 1;
public string CaseFamilyId { get; set; } = "";
public DateTime ExpiresUtc { get; set; }
}
@@ -91,6 +132,7 @@ public sealed class DynamicRunClaims
public List<string> SeenObjectiveIds { get; set; } = [];
public string PreviousObjectiveId { get; set; } = "";
public int Cycle { get; set; }
public List<string> RecentCaseFamilyIds { get; set; } = [];
public DateTime ExpiresUtc { get; set; }
}
@@ -98,6 +140,7 @@ public sealed class DynamicGeneratedQuestion
{
public Question Question { get; set; } = new();
public DynamicQuestionBlueprint Blueprint { get; set; } = new();
public string CaseFamilyId { get; set; } = "";
}
public sealed class DynamicQuestionEnvelope
@@ -187,4 +230,5 @@ public sealed class DynamicGenerationDescriptor
public int BlueprintVersion { get; set; }
public int GeneratorVersion { get; set; }
public string Seed { get; set; } = "";
public string CaseFamilyId { get; set; } = "";
}
+6
View File
@@ -23,6 +23,12 @@ if (args.FirstOrDefault() == "promote-dynamic-questions")
return;
}
if (args.FirstOrDefault() == "export-dynamic-review")
{
Environment.ExitCode = await DynamicReviewExportCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
if (args.FirstOrDefault() == "seed-flashcards")
{
var seedConfiguration = new ConfigurationBuilder()
@@ -24,15 +24,18 @@ public static class DynamicBlueprintCommand
blueprints.AddRange(JsonSerializer.Deserialize<List<DynamicQuestionBlueprint>>(await File.ReadAllTextAsync(file), json) ?? []);
var generator = new DynamicQuestionGenerator();
var generatedVariants = 0;
foreach (var blueprint in blueprints)
{
var error = DynamicQuestionGenerator.Validate(blueprint);
if (error is not null)
throw new InvalidDataException($"{blueprint.ExternalId} v{blueprint.Version}: {error}");
for (var seed = 0; seed < 1000; seed++)
var validationSeeds = blueprint.QualityStandardVersion >= 2 ? 10_000 : 1_000;
for (var seed = 0; seed < validationSeeds; seed++)
generator.Generate(blueprint, seed.ToString(), seed.ToString());
generatedVariants += validationSeeds;
}
await output.WriteLineAsync($"Validated {blueprints.Count} blueprints across 1,000 seeds each.");
await output.WriteLineAsync($"Validated {blueprints.Count} blueprints across {generatedVariants:N0} generated variants.");
if (!apply)
{
await output.WriteLineAsync("Dry run only. Pass --apply to publish.");
@@ -43,8 +46,8 @@ public static class DynamicBlueprintCommand
.AddEnvironmentVariables()
.Build();
var store = new DynamicQuestionStore(new MongoDatabaseProvider(configuration));
await store.UpsertBlueprintsAsync(blueprints);
await output.WriteLineAsync($"Published {blueprints.Count} immutable blueprint versions.");
var result = await store.UpsertBlueprintsAsync(blueprints);
await output.WriteLineAsync($"Promotion complete: {result.Inserted} inserted, {result.Unchanged} unchanged.");
return 0;
}
}
@@ -8,12 +8,17 @@ namespace QuestionsApi.Services;
public sealed class DynamicQuestionGenerator
{
public const int CurrentVersion = 1;
public const int CurrentVersion = 2;
private static readonly Regex Placeholder = new(@"\{\{([a-zA-Z0-9_]+)\}\}", RegexOptions.Compiled);
public DynamicGeneratedQuestion Generate(DynamicQuestionBlueprint blueprint, string seed, string nonce)
public DynamicGeneratedQuestion Generate(DynamicQuestionBlueprint blueprint, string seed, string nonce, int generatorVersion = CurrentVersion)
=> blueprint.QualityStandardVersion >= 2
? GenerateV2(blueprint, seed, nonce)
: GenerateLegacy(blueprint, seed, nonce, generatorVersion);
private DynamicGeneratedQuestion GenerateLegacy(DynamicQuestionBlueprint blueprint, string seed, string nonce, int generatorVersion)
{
var draw = new DeterministicDraw($"{blueprint.ExternalId}|{blueprint.Version}|{CurrentVersion}|{seed}");
var draw = new DeterministicDraw($"{blueprint.ExternalId}|{blueprint.Version}|{generatorVersion}|{seed}");
var values = new Dictionary<string, string>(StringComparer.Ordinal);
var numbers = new Dictionary<string, double>(StringComparer.Ordinal);
foreach (var item in blueprint.Variables.OrderBy(value => value.Key, StringComparer.Ordinal))
@@ -112,11 +117,193 @@ public sealed class DynamicQuestionGenerator
return new DynamicGeneratedQuestion { Question = question, Blueprint = blueprint };
}
private DynamicGeneratedQuestion GenerateV2(DynamicQuestionBlueprint blueprint, string seed, string nonce)
{
if (blueprint.CaseFamilies.Count == 0)
throw new InvalidOperationException("quality-standard-2 blueprints require case families");
var draw = new DeterministicDraw($"{blueprint.ExternalId}|{blueprint.Version}|{CurrentVersion}|{seed}");
var family = blueprint.CaseFamilies.OrderBy(value => value.Id, StringComparer.Ordinal)
.ElementAt(draw.Next(blueprint.CaseFamilies.Count));
var values = new Dictionary<string, string>(StringComparer.Ordinal);
var numbers = new Dictionary<string, double>(StringComparer.Ordinal);
if (family.ValueBundles.Count > 0)
foreach (var item in family.ValueBundles[draw.Next(family.ValueBundles.Count)])
values[item.Key] = item.Value;
PopulateVariables(family.Variables, values, numbers, draw);
PopulateDerived(family.Derived, values, numbers);
ValidateConstraints(family.Constraints, numbers);
var correctAnswer = string.IsNullOrWhiteSpace(family.CorrectAnswer.Id) ? blueprint.CorrectAnswer : family.CorrectAnswer;
var distractorPool = family.Distractors.Count == 0 ? blueprint.Distractors : family.Distractors;
var reviewTemplates = family.ReviewFollowups.Count == 0 ? blueprint.ReviewFollowups : family.ReviewFollowups;
var wrongTemplates = family.WrongAnswerFollowups.Count == 0 ? blueprint.WrongAnswerFollowups : family.WrongAnswerFollowups;
var explanationTemplate = string.IsNullOrWhiteSpace(family.ExplanationTemplate) ? blueprint.ExplanationTemplate : family.ExplanationTemplate;
var keyTakeaway = string.IsNullOrWhiteSpace(family.KeyTakeaway) ? blueprint.KeyTakeaway : family.KeyTakeaway;
var usmlePearl = string.IsNullOrWhiteSpace(family.UsmlePearl) ? blueprint.UsmlePearl : family.UsmlePearl;
var distractors = distractorPool.OrderBy(_ => draw.Next(int.MaxValue)).Take(4).ToList();
if (distractors.Count != 4) throw new InvalidOperationException($"{family.Id} requires at least four distractors");
var answerTemplates = distractors.Append(correctAnswer).OrderBy(_ => draw.Next(int.MaxValue)).ToList();
var choices = answerTemplates.Select(answer => new QuestionChoice
{
Id = answer.Id,
Text = Render(answer.TextTemplate, values)
}).ToList();
if (choices.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != 5
|| choices.Select(value => value.Text).Distinct(StringComparer.OrdinalIgnoreCase).Count() != 5)
throw new InvalidOperationException($"{family.Id} generated choices must be unique");
var question = new Question
{
Id = $"dynamic-{nonce}",
ExternalId = $"dynamic-{nonce}",
Stem = Render(family.StemTemplate, values),
LeadIn = Render(family.LeadIn, values),
Choices = choices,
CorrectChoiceId = correctAnswer.Id,
Explanation = Render(explanationTemplate, values),
KeyTakeaway = Render(keyTakeaway, values),
UsmlePearl = Render(usmlePearl, values),
ChoiceExplanations = answerTemplates.ToDictionary(
value => value.Id, value => Render(value.ExplanationTemplate, values)),
ReviewFollowups = reviewTemplates.Select(followup => new QuestionReviewFollowup
{
Prompt = Render(followup.PromptTemplate, values),
Choices = followup.Choices.Select(choice => new QuestionChoice
{ Id = choice.Id, Text = Render(choice.TextTemplate, values) }).ToList(),
CorrectChoiceId = followup.CorrectChoiceId,
TeachingPoint = Render(followup.TeachingPointTemplate, values)
}).ToList(),
WrongAnswerFollowups = BuildWrongAnswerFollowups(
distractors, correctAnswer, wrongTemplates, values),
Discipline = blueprint.Discipline,
System = blueprint.System,
PerformanceCategory = blueprint.PerformanceCategory,
Difficulty = blueprint.Difficulty,
Labs = family.Labs.Select(lab => new QuestionLab
{
Name = lab.Name,
Value = Render(lab.ValueTemplate, values),
Unit = lab.Unit,
Reference = lab.Reference
}).ToList(),
Tags = [.. blueprint.Tags, "dynamic", "quality-standard:2", $"objective:{blueprint.ObjectiveId}", $"family:{family.Id}"],
References = blueprint.References,
IsPublished = true
};
return new DynamicGeneratedQuestion { Question = question, Blueprint = blueprint, CaseFamilyId = family.Id };
}
private static void PopulateVariables(
IReadOnlyDictionary<string, DynamicVariableDefinition> definitions,
IDictionary<string, string> values,
IDictionary<string, double> numbers,
DeterministicDraw draw)
{
foreach (var item in definitions.OrderBy(value => value.Key, StringComparer.Ordinal))
{
var definition = item.Value;
if (definition.Type == "enum")
{
if (definition.Values.Count == 0) throw new InvalidOperationException($"{item.Key} requires values");
values[item.Key] = definition.Values[draw.Next(definition.Values.Count)];
continue;
}
var step = definition.Step <= 0 ? 1 : definition.Step;
var count = (int)Math.Floor((definition.Maximum - definition.Minimum) / step) + 1;
if (count < 1) throw new InvalidOperationException($"{item.Key} range is invalid");
var number = Math.Round(definition.Minimum + draw.Next(count) * step, Math.Clamp(definition.Precision, 0, 8));
numbers[item.Key] = number;
values[item.Key] = Format(number, definition.Precision);
}
}
private static void PopulateDerived(
IReadOnlyDictionary<string, DynamicDerivedDefinition> definitions,
IDictionary<string, string> values,
Dictionary<string, double> numbers)
{
foreach (var item in definitions)
{
var definition = item.Value;
var left = ResolveNumber(definition.Left, numbers);
var right = ResolveNumber(definition.Right, numbers);
var number = definition.Operation switch
{
"add" => left + right,
"subtract" => left - right,
"multiply" => left * right,
"divide" when Math.Abs(right) > double.Epsilon => left / right,
_ => throw new InvalidOperationException($"{item.Key} operation is invalid")
};
number = Math.Round(number, Math.Clamp(definition.Precision, 0, 8));
numbers[item.Key] = number;
values[item.Key] = Format(number, definition.Precision);
}
}
private static void ValidateConstraints(IEnumerable<DynamicConstraint> constraints, IReadOnlyDictionary<string, double> numbers)
{
foreach (var constraint in constraints)
{
if (!numbers.TryGetValue(constraint.Field, out var actual))
throw new InvalidOperationException($"constraint field {constraint.Field} is not numeric");
var valid = constraint.Operator switch
{
"lt" => actual < constraint.Value,
"lte" => actual <= constraint.Value,
"gt" => actual > constraint.Value,
"gte" => actual >= constraint.Value,
"eq" => Math.Abs(actual - constraint.Value) < 0.0000001,
"neq" => Math.Abs(actual - constraint.Value) >= 0.0000001,
_ => false
};
if (!valid) throw new InvalidOperationException($"generated value violates {constraint.Field} {constraint.Operator}");
}
}
private static Dictionary<string, WrongAnswerFollowup> BuildWrongAnswerFollowups(
IEnumerable<DynamicAnswerTemplate> distractors,
DynamicAnswerTemplate correct,
IReadOnlyDictionary<string, DynamicWrongAnswerFollowupTemplate> templates,
IReadOnlyDictionary<string, string> values)
{
var result = new Dictionary<string, WrongAnswerFollowup>();
foreach (var distractor in distractors)
{
if (templates.TryGetValue(distractor.Id, out var template))
{
result[distractor.Id] = new WrongAnswerFollowup
{
Prompt = Render(template.PromptTemplate, values),
Choices = template.Choices.Select(choice => new QuestionChoice
{ Id = choice.Id, Text = Render(choice.TextTemplate, values) }).ToList(),
CorrectChoiceId = template.CorrectChoiceId,
TeachingPoint = Render(template.TeachingPointTemplate, values)
};
continue;
}
result[distractor.Id] = new WrongAnswerFollowup
{
Prompt = "Which option best addresses the decisive finding in this case?",
Choices =
[
new QuestionChoice { Id = correct.Id, Text = Render(correct.TextTemplate, values) },
new QuestionChoice { Id = distractor.Id, Text = Render(distractor.TextTemplate, values) }
],
CorrectChoiceId = correct.Id,
TeachingPoint = $"{Render(distractor.ExplanationTemplate, values)} {Render(correct.ExplanationTemplate, values)}"
};
}
return result;
}
public static string? Validate(DynamicQuestionBlueprint blueprint)
{
if (string.IsNullOrWhiteSpace(blueprint.ExternalId)) return "externalId required";
if (string.IsNullOrWhiteSpace(blueprint.ObjectiveId)) return "objectiveId required";
if (blueprint.Version < 1) return "version must be positive";
if (blueprint.QualityStandardVersion >= 2)
return ValidateV2(blueprint);
if (string.IsNullOrWhiteSpace(blueprint.StemTemplate) || string.IsNullOrWhiteSpace(blueprint.LeadIn)) return "stemTemplate and leadIn required";
if (string.IsNullOrWhiteSpace(blueprint.CorrectAnswer.Id) || string.IsNullOrWhiteSpace(blueprint.CorrectAnswer.TextTemplate)) return "correctAnswer required";
if (blueprint.Distractors.Count < 4) return "at least four distractors required";
@@ -133,6 +320,51 @@ public sealed class DynamicQuestionGenerator
return null;
}
private static string? ValidateV2(DynamicQuestionBlueprint blueprint)
{
if (string.IsNullOrWhiteSpace(blueprint.CorrectConceptId)) return "correctConceptId required";
if (blueprint.CaseFamilies.Count < 3) return "at least three case families required";
if (blueprint.References.Count == 0) return "at least one reference required";
if (blueprint.CaseFamilies.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != blueprint.CaseFamilies.Count)
return "case family ids must be unique";
foreach (var family in blueprint.CaseFamilies)
{
if (string.IsNullOrWhiteSpace(family.Id)) return "case family id required";
if (family.StemTemplate.Length < 220) return $"{family.Id} stem is too short for quality standard 2";
if (string.IsNullOrWhiteSpace(family.LeadIn)) return $"{family.Id} leadIn required";
if (!string.IsNullOrWhiteSpace(family.CorrectAnswer.Id))
return $"{family.Id} must use the blueprint-level invariant correct answer";
var correctAnswer = string.IsNullOrWhiteSpace(family.CorrectAnswer.Id) ? blueprint.CorrectAnswer : family.CorrectAnswer;
var distractors = family.Distractors.Count == 0 ? blueprint.Distractors : family.Distractors;
var reviews = family.ReviewFollowups.Count == 0 ? blueprint.ReviewFollowups : family.ReviewFollowups;
var wrong = family.WrongAnswerFollowups.Count == 0 ? blueprint.WrongAnswerFollowups : family.WrongAnswerFollowups;
if (distractors.Count < 6) return $"{family.Id} requires at least six distractors";
var answers = distractors.Append(correctAnswer).ToList();
if (answers.Any(value => string.IsNullOrWhiteSpace(value.Id)
|| string.IsNullOrWhiteSpace(value.TextTemplate)
|| string.IsNullOrWhiteSpace(value.ExplanationTemplate)))
return $"{family.Id} every answer requires id, text, and explanation";
if (answers.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != answers.Count)
return $"{family.Id} answer ids must be unique";
if (reviews.Count == 0) return $"{family.Id} review followup required";
if (string.IsNullOrWhiteSpace(family.ExplanationTemplate) && string.IsNullOrWhiteSpace(blueprint.ExplanationTemplate)
|| string.IsNullOrWhiteSpace(family.KeyTakeaway) && string.IsNullOrWhiteSpace(blueprint.KeyTakeaway)
|| string.IsNullOrWhiteSpace(family.UsmlePearl) && string.IsNullOrWhiteSpace(blueprint.UsmlePearl))
return $"{family.Id} complete teaching content required";
}
try
{
var generator = new DynamicQuestionGenerator();
for (var index = 0; index < 300; index++)
{
var generated = generator.Generate(blueprint, $"validation-{index}", $"validation-{index}");
if (generated.Question.ChoiceExplanations.Count != 5) return "every displayed choice requires an explanation";
}
}
catch (Exception exception) { return exception.Message; }
return null;
}
private static string Render(string template, IReadOnlyDictionary<string, string> values)
{
var rendered = Placeholder.Replace(template ?? "", match =>
@@ -65,9 +65,22 @@ public sealed class DynamicQuestionService
GeneratorVersion = DynamicQuestionGenerator.CurrentVersion,
ExpiresUtc = DateTime.UtcNow.AddDays(7)
};
var generated = _generator.Generate(blueprint, claims.Seed, claims.Nonce);
var generated = _generator.Generate(blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion);
var familyIds = blueprint.CaseFamilies.Select(value => value.Id).ToHashSet(StringComparer.Ordinal);
var recentForObjective = run.RecentCaseFamilyIds
.Where(familyIds.Contains)
.TakeLast(Math.Max(1, blueprint.CaseFamilies.Count - 1))
.ToHashSet(StringComparer.Ordinal);
for (var retry = 0; retry < 20 && recentForObjective.Contains(generated.CaseFamilyId); retry++)
{
claims.Seed = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
generated = _generator.Generate(blueprint, claims.Seed, claims.Nonce);
}
claims.CaseFamilyId = generated.CaseFamilyId;
run.SeenObjectiveIds.Add(blueprint.ObjectiveId);
run.PreviousObjectiveId = blueprint.ObjectiveId;
run.RecentCaseFamilyIds.Add(generated.CaseFamilyId);
run.RecentCaseFamilyIds = run.RecentCaseFamilyIds.TakeLast(30).ToList();
run.ExpiresUtc = DateTime.UtcNow.AddDays(7);
return new DynamicQuestionEnvelope
{
@@ -92,11 +105,15 @@ public sealed class DynamicQuestionService
string generationToken)
{
var claims = _tokens.ReadGeneration(generationToken, userId);
if (claims.GeneratorVersion != DynamicQuestionGenerator.CurrentVersion)
if (claims.GeneratorVersion is < 1 or > DynamicQuestionGenerator.CurrentVersion)
throw new InvalidOperationException("This generated question version is no longer supported.");
var blueprint = await _store.GetAsync(claims.BlueprintExternalId, claims.BlueprintVersion)
?? throw new KeyNotFoundException("The question blueprint is no longer available.");
return (claims, _generator.Generate(blueprint, claims.Seed, claims.Nonce));
var generated = _generator.Generate(blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion);
if (!string.IsNullOrWhiteSpace(claims.CaseFamilyId)
&& claims.CaseFamilyId != generated.CaseFamilyId)
throw new InvalidOperationException("Generated case family does not match its signed descriptor.");
return (claims, generated);
}
private static DynamicRunClaims NewRun(string userId) => new()
@@ -27,7 +27,7 @@ public sealed class DynamicQuestionStore
}
public async Task<List<DynamicQuestionBlueprint>> PublishedAsync() =>
await _blueprints.Find(value => value.IsPublished).ToListAsync();
await _blueprints.Find(value => value.IsPublished && value.QualityStandardVersion >= 2).ToListAsync();
public async Task<DynamicQuestionBlueprint?> GetAsync(string externalId, int version) =>
await _blueprints.Find(value => value.ExternalId == externalId && value.Version == version).FirstOrDefaultAsync();
@@ -114,18 +114,32 @@ public sealed class DynamicQuestionStore
};
}
public async Task UpsertBlueprintsAsync(IEnumerable<DynamicQuestionBlueprint> blueprints)
public async Task<(int Inserted, int Unchanged)> UpsertBlueprintsAsync(IEnumerable<DynamicQuestionBlueprint> blueprints)
{
var inserted = 0;
var unchanged = 0;
foreach (var blueprint in blueprints)
{
var existing = await _blueprints.Find(
value => value.ExternalId == blueprint.ExternalId && value.Version == blueprint.Version)
.FirstOrDefaultAsync();
blueprint.Id = existing is null ? ObjectId.GenerateNewId().ToString() : existing.Id;
await _blueprints.ReplaceOneAsync(
value => value.ExternalId == blueprint.ExternalId && value.Version == blueprint.Version,
blueprint,
new ReplaceOptions { IsUpsert = true });
if (existing is not null)
{
blueprint.Id = existing.Id;
var incomingDocument = blueprint.ToBsonDocument();
var existingDocument = existing.ToBsonDocument();
incomingDocument.Remove("_id");
existingDocument.Remove("_id");
if (incomingDocument != existingDocument)
throw new InvalidDataException(
$"{blueprint.ExternalId} v{blueprint.Version} is immutable; publish changes with a new version");
unchanged++;
continue;
}
blueprint.Id = ObjectId.GenerateNewId().ToString();
await _blueprints.InsertOneAsync(blueprint);
inserted++;
}
return (inserted, unchanged);
}
}
@@ -0,0 +1,74 @@
using Microsoft.Extensions.Configuration;
using QuestionsApi.Models;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class DynamicReviewExportCommand
{
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
static string Argument(string[] values, string name, string fallback = "")
{
var index = Array.FindIndex(values, value => value.Equals(name, StringComparison.OrdinalIgnoreCase));
return index >= 0 && index + 1 < values.Length ? values[index + 1] : fallback;
}
var source = Argument(args, "--source", Path.Combine(AppContext.BaseDirectory, "content", "dynamic_questions"));
var destination = Argument(args, "--output");
if (!Directory.Exists(source) || string.IsNullOrWhiteSpace(destination))
{
await output.WriteLineAsync("Usage: export-dynamic-review --source <blueprints> --output <directory>");
return 2;
}
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{ PropertyNameCaseInsensitive = true, WriteIndented = true };
var blueprints = new List<DynamicQuestionBlueprint>();
foreach (var file in Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories))
blueprints.AddRange(JsonSerializer.Deserialize<List<DynamicQuestionBlueprint>>(
await File.ReadAllTextAsync(file), jsonOptions) ?? []);
blueprints = blueprints.Where(value => value.IsPublished && value.QualityStandardVersion >= 2).ToList();
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
var authored = (await new QuestionStore(new MongoDatabaseProvider(configuration)).GetPublishedAsync())
.OrderBy(value => value.ExternalId, StringComparer.Ordinal)
.Take(blueprints.Count * 3)
.ToList();
if (authored.Count < blueprints.Count * 3)
throw new InvalidOperationException("Not enough published authored questions for a balanced review packet.");
var generator = new DynamicQuestionGenerator();
var entries = new List<(Question Question, string Source, string Detail)>();
foreach (var blueprint in blueprints)
for (var seed = 0; seed < 3; seed++)
{
var generated = generator.Generate(blueprint, $"review-{seed}", $"review-{blueprint.ExternalId}-{seed}");
entries.Add((generated.Question, "dynamic", $"{blueprint.ExternalId}:{generated.CaseFamilyId}"));
}
entries.AddRange(authored.Select(value => (value, "authored", value.ExternalId)));
var ordered = entries.OrderBy(value => Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value.Detail)))).ToList();
Directory.CreateDirectory(destination);
var packet = ordered.Select((entry, index) => new
{
reviewId = $"Q{index + 1:D2}",
entry.Question.Stem,
entry.Question.LeadIn,
entry.Question.Choices,
entry.Question.Labs,
entry.Question.Explanation,
entry.Question.ChoiceExplanations,
entry.Question.ReviewFollowups,
entry.Question.WrongAnswerFollowups,
scoring = new { realism = 0, distractors = 0, explanation = 0, step3Relevance = 0, criticalIssue = "", predictedSource = "" }
});
var key = ordered.Select((entry, index) => new { reviewId = $"Q{index + 1:D2}", entry.Source, entry.Detail });
await File.WriteAllTextAsync(Path.Combine(destination, "review-packet.json"), JsonSerializer.Serialize(packet, jsonOptions));
await File.WriteAllTextAsync(Path.Combine(destination, "answer-key.json"), JsonSerializer.Serialize(key, jsonOptions));
await output.WriteLineAsync($"Exported {ordered.Count} blinded questions to {destination}.");
return 0;
}
}
@@ -99,7 +99,8 @@ public class QuestionReportStore
BlueprintExternalId = claims.BlueprintExternalId,
BlueprintVersion = claims.BlueprintVersion,
GeneratorVersion = claims.GeneratorVersion,
Seed = claims.Seed
Seed = claims.Seed,
CaseFamilyId = claims.CaseFamilyId
},
CreatedUtc = now,
UpdatedUtc = now