Add difficulty 4 gap question batch
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
|
||||
const string accessedOn = "2026-07-30";
|
||||
var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase);
|
||||
var connectionString = Environment.GetEnvironmentVariable("STEP3_MONGODB_CONNECTION_STRING")
|
||||
?? "mongodb://192.168.86.50:27017";
|
||||
var databaseName = Environment.GetEnvironmentVariable("STEP3_MONGODB_DATABASE")
|
||||
?? "promiscuity";
|
||||
|
||||
var questions = SeedCatalog.All.Select(Build).ToList();
|
||||
var errors = questions
|
||||
.Select(question => (question.ExternalId, Error: ValidateAuthoredQuestion(question)))
|
||||
.Where(result => result.Error is not null)
|
||||
.ToList();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
foreach (var error in errors) Console.Error.WriteLine($"{error.ExternalId}: {error.Error}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (questions.Count != 50 || questions.Select(q => q.ExternalId).Distinct().Count() != 50)
|
||||
{
|
||||
Console.Error.WriteLine("Expected exactly 50 unique questions.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var grouped = questions
|
||||
.GroupBy(question => question.System)
|
||||
.OrderBy(group => group.Key)
|
||||
.Select(group => $"{group.Key}: {group.Count()}");
|
||||
Console.WriteLine($"Validated {questions.Count} difficulty-4 gap questions.");
|
||||
Console.WriteLine(string.Join(Environment.NewLine, grouped));
|
||||
if (!apply)
|
||||
{
|
||||
Console.WriteLine("Dry run only. Pass --apply to upsert into the configured development database.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MongoDB:ConnectionString"] = connectionString,
|
||||
["MongoDB:DatabaseName"] = databaseName
|
||||
}).Build();
|
||||
var store = new QuestionStore(new MongoDatabaseProvider(configuration));
|
||||
var upserted = await store.UpsertManyByExternalIdAsync(questions);
|
||||
var saved = await store.GetByExternalIdsAsync(questions.Select(q => q.ExternalId));
|
||||
var invalidSaved = saved.Values.Where(q =>
|
||||
q.Difficulty != 4
|
||||
|| !q.IsPublished
|
||||
|| q.Media.Count != 0
|
||||
|| q.References.Count == 0
|
||||
|| q.FlaggedForReviewUtc is not null).ToList();
|
||||
if (upserted != 50 || saved.Count != 50 || invalidSaved.Count != 0)
|
||||
{
|
||||
Console.Error.WriteLine($"Verification failed: upserted={upserted}, found={saved.Count}, invalid={invalidSaved.Count}.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Upserted and verified 50 questions in database '{databaseName}'.");
|
||||
return 0;
|
||||
|
||||
string? ValidateAuthoredQuestion(UpsertQuestionRequest question)
|
||||
{
|
||||
var schemaError = QuestionValidator.Validate(question);
|
||||
if (schemaError is not null) return schemaError;
|
||||
if (!question.ExternalId.StartsWith("gap-d4-20260730-", StringComparison.Ordinal))
|
||||
return "externalId must use the gap-d4-20260730 prefix";
|
||||
if (question.Difficulty != 4 || !question.IsPublished)
|
||||
return "question must be published at difficulty 4";
|
||||
if (question.Media.Count != 0)
|
||||
return "gap questions must not contain unreviewed media";
|
||||
if (question.Tags.Count is < 3 or > 6)
|
||||
return "questions require 3-6 tags";
|
||||
if (question.Choices.Count < 5)
|
||||
return "questions require one correct answer and at least four distractors";
|
||||
if (question.ChoiceSelectionPercentages.Count != question.Choices.Count
|
||||
|| question.ChoiceSelectionPercentages.Values.Any(value => value != 0))
|
||||
return "every choice selection percentage must initialize to zero";
|
||||
if (!question.ChoiceExplanations[question.CorrectChoiceId].StartsWith("Correct.", StringComparison.Ordinal))
|
||||
return "correct choice explanation must begin with Correct.";
|
||||
if (question.ChoiceExplanations
|
||||
.Where(entry => entry.Key != question.CorrectChoiceId)
|
||||
.Any(entry => !entry.Value.StartsWith("Incorrect.", StringComparison.Ordinal)))
|
||||
return "incorrect choice explanations must begin with Incorrect.";
|
||||
foreach (var followup in question.WrongAnswerFollowups.Values)
|
||||
{
|
||||
if (followup.Choices.Count != 3 || followup.Choices.Select(choice => choice.Id).SequenceEqual(["A", "B", "C"]) is false)
|
||||
return "wrong-answer follow-ups must use ordered A-C choices";
|
||||
if (followup.Choices.Any(choice =>
|
||||
!question.Stem.Contains(choice.Text, StringComparison.OrdinalIgnoreCase)
|
||||
&& !question.Labs.Any(lab => $"{lab.Name} {lab.Value}".Contains(choice.Text, StringComparison.OrdinalIgnoreCase))))
|
||||
return "wrong-answer follow-up choices must be traceable to the stem or labs";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
UpsertQuestionRequest Build(Seed seed)
|
||||
{
|
||||
var ids = Enumerable.Range(0, seed.Answers.Length)
|
||||
.Select(index => ((char)('A' + index)).ToString())
|
||||
.ToArray();
|
||||
var choices = ids.Zip(seed.Answers, (id, text) => new QuestionChoice { Id = id, Text = text }).ToList();
|
||||
var choiceExplanations = ids.ToDictionary(
|
||||
id => id,
|
||||
id => id == "A"
|
||||
? $"Correct. {seed.Explanation}"
|
||||
: $"Incorrect. {choices.Single(choice => choice.Id == id).Text} does not adequately address the decisive finding: {seed.KeyFact}. {seed.Takeaway}");
|
||||
var wrongFollowups = ids.Skip(1).ToDictionary(
|
||||
id => id,
|
||||
id => new WrongAnswerFollowup
|
||||
{
|
||||
Prompt = $"Which finding in this vignette most strongly supports {seed.Answers[0]}?",
|
||||
Choices =
|
||||
[
|
||||
new() { Id = "A", Text = seed.KeyFact },
|
||||
new() { Id = "B", Text = seed.ContextFact },
|
||||
new() { Id = "C", Text = seed.NeutralFact }
|
||||
],
|
||||
CorrectChoiceId = "A",
|
||||
TeachingPoint = seed.Takeaway
|
||||
});
|
||||
|
||||
return new UpsertQuestionRequest
|
||||
{
|
||||
ExternalId = $"gap-d4-20260730-{seed.Slug}",
|
||||
Stem = seed.Stem,
|
||||
LeadIn = seed.LeadIn,
|
||||
Choices = choices,
|
||||
CorrectChoiceId = "A",
|
||||
Explanation = $"Correct. {seed.Explanation}",
|
||||
KeyTakeaway = seed.Takeaway,
|
||||
UsmlePearl = seed.Pearl,
|
||||
ReviewFollowups =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Prompt = "Which principle best generalizes from this case?",
|
||||
Choices =
|
||||
[
|
||||
new() { Id = "A", Text = seed.Takeaway },
|
||||
new() { Id = "B", Text = seed.ReviewDistractor1 },
|
||||
new() { Id = "C", Text = seed.ReviewDistractor2 }
|
||||
],
|
||||
CorrectChoiceId = "A",
|
||||
TeachingPoint = seed.Takeaway
|
||||
}
|
||||
],
|
||||
ChoiceExplanations = choiceExplanations,
|
||||
WrongAnswerFollowups = wrongFollowups,
|
||||
ChoiceSelectionPercentages = ids.ToDictionary(id => id, _ => 0),
|
||||
Discipline = seed.Discipline,
|
||||
System = seed.System,
|
||||
PerformanceCategory = seed.PerformanceCategory,
|
||||
Difficulty = 4,
|
||||
Labs = [],
|
||||
Media = [],
|
||||
Tags = [seed.TopicTag, "difficulty 4", "gap coverage", seed.Discipline.ToLowerInvariant()],
|
||||
References =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Title = seed.ReferenceTitle,
|
||||
Publisher = seed.ReferencePublisher,
|
||||
Url = seed.ReferenceUrl,
|
||||
Supports = seed.ReferenceSupports,
|
||||
AccessedOn = accessedOn,
|
||||
Reviewer = "OpenAI Codex medical content review"
|
||||
}
|
||||
],
|
||||
IsPublished = true
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed record Seed(
|
||||
string Slug,
|
||||
string System,
|
||||
string Discipline,
|
||||
string PerformanceCategory,
|
||||
string TopicTag,
|
||||
string Stem,
|
||||
string LeadIn,
|
||||
string[] Answers,
|
||||
string Explanation,
|
||||
string Takeaway,
|
||||
string Pearl,
|
||||
string KeyFact,
|
||||
string ContextFact,
|
||||
string NeutralFact,
|
||||
string ReviewDistractor1,
|
||||
string ReviewDistractor2,
|
||||
string ReferenceTitle,
|
||||
string ReferencePublisher,
|
||||
string ReferenceUrl,
|
||||
string ReferenceSupports);
|
||||
Reference in New Issue
Block a user