197 lines
7.6 KiB
C#
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);
|