Files
step3/tools/Difficulty4GapSeeder20260730/Program.cs
T

197 lines
7.6 KiB
C#

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);