using System.Security.Cryptography; using System.Text; using System.Text.Json; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; using QuestionsApi.Models; const string defaultConnection = "mongodb://192.168.86.50:27017"; var options = args.Select((value, index) => (value, index)).Where(x => x.value.StartsWith("--")) .ToDictionary(x => x.value, x => x.index + 1 < args.Length && !args[x.index + 1].StartsWith("--") ? args[x.index + 1] : "true"); var mode = options.GetValueOrDefault("--mode", "audit").ToLowerInvariant(); var connection = options.GetValueOrDefault("--connection", Environment.GetEnvironmentVariable("STEP3_MONGODB_CONNECTION_STRING") ?? defaultConnection); var databases = options.GetValueOrDefault("--databases", "ranaze_step3_uat,ranaze_step3_prd").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var manifestPath = options.GetValueOrDefault("--manifest", "content/repairs/wrong-answer-followups-20260801.json"); var batchId = options.GetValueOrDefault("--batch", "wrong-answer-followups-20260801"); var client = new MongoClient(connection); var json = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; if (mode is "audit" or "prepare") { var inventory = new Dictionary>(); foreach (var database in databases) { var questions = await client.GetDatabase(database).GetCollection("Questions") .Find(x => x.IsPublished).SortBy(x => x.ExternalId).ToListAsync(); inventory[database] = questions; var broken = questions.Select(q => (Question: q, Issues: Issues(q))).Where(x => x.Issues.Count > 0).ToList(); Console.WriteLine($"{database}: published={questions.Count}, compliant={questions.Count - broken.Count}, repair={broken.Count}"); foreach (var item in broken) Console.WriteLine($" {item.Question.ExternalId}: {string.Join("; ", item.Issues)}"); } if (mode == "audit") return; var entries = inventory.SelectMany(db => db.Value.Select(question => (Database: db.Key, Question: question))) .Where(x => Issues(x.Question).Count > 0).GroupBy(x => x.Question.ExternalId, StringComparer.Ordinal).Select(group => { var source = group.OrderBy(x => Array.IndexOf(databases, x.Database)).First().Question; var replacement = Build(source); var original = source.WrongAnswerFollowups; source.WrongAnswerFollowups = replacement; var replacementIssues = Issues(source); source.WrongAnswerFollowups = original; if (replacementIssues.Count > 0) throw new InvalidOperationException($"generated repair for {source.ExternalId} is invalid: {string.Join("; ", replacementIssues)}"); return new RepairEntry { ExternalId = source.ExternalId, ExpectedHashes = group.ToDictionary(x => x.Database, x => Hash(x.Question.WrongAnswerFollowups)), Replacement = replacement }; }).OrderBy(x => x.ExternalId).ToList(); var manifest = new RepairManifest { BatchId = batchId, CreatedUtc = DateTime.UtcNow, Entries = entries }; Directory.CreateDirectory(Path.GetDirectoryName(manifestPath) ?? "."); await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest, json)); Console.WriteLine($"Prepared {entries.Count} repair entries at {manifestPath}."); return; } var loaded = JsonSerializer.Deserialize(await File.ReadAllTextAsync(manifestPath), json) ?? throw new InvalidOperationException("repair manifest is invalid"); if (loaded.BatchId != batchId) throw new InvalidOperationException("manifest batch does not match --batch"); foreach (var database in databases) { var db = client.GetDatabase(database); var questions = db.GetCollection("Questions"); var backups = db.GetCollection("WrongAnswerFollowupRepairBackups"); await backups.Indexes.CreateOneAsync(new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.BatchId).Ascending(x => x.ExternalId), new CreateIndexOptions { Unique = true })); var changed = 0; foreach (var entry in loaded.Entries) { var question = await questions.Find(x => x.ExternalId == entry.ExternalId).FirstOrDefaultAsync(); if (question is null || !question.IsPublished || !entry.ExpectedHashes.TryGetValue(database, out var expected)) continue; if (mode == "rollback") { var backup = await backups.Find(x => x.BatchId == batchId && x.ExternalId == entry.ExternalId).FirstOrDefaultAsync(); if (backup is null) continue; await questions.UpdateOneAsync(x => x.Id == question.Id, Builders.Update .Set(x => x.WrongAnswerFollowups, backup.Original).Set(x => x.UpdatedUtc, DateTime.UtcNow)); changed++; continue; } if (mode != "apply") throw new ArgumentException("--mode must be audit, prepare, apply, or rollback"); var actual = Hash(question.WrongAnswerFollowups); var replacementHash = Hash(entry.Replacement); if (actual == replacementHash) continue; if (actual != expected) throw new InvalidOperationException($"{database}/{entry.ExternalId} changed after audit; expected {expected}, found {actual}"); try { await backups.InsertOneAsync(new RepairBackup { Id = ObjectId.GenerateNewId().ToString(), BatchId = batchId, ExternalId = entry.ExternalId, OriginalHash = actual, ReplacementHash = replacementHash, Original = question.WrongAnswerFollowups, CreatedUtc = DateTime.UtcNow }); } catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { } var update = await questions.UpdateOneAsync(x => x.Id == question.Id && x.UpdatedUtc == question.UpdatedUtc, Builders.Update.Set(x => x.WrongAnswerFollowups, entry.Replacement).Set(x => x.UpdatedUtc, DateTime.UtcNow)); if (update.ModifiedCount != 1) throw new InvalidOperationException($"{database}/{entry.ExternalId} changed during repair"); changed++; } Console.WriteLine($"{database}: {mode} changed {changed} question(s)."); } static List Issues(Question question) { var ids = question.Choices.Select(x => x.Id).Where(x => x != question.CorrectChoiceId).ToHashSet(StringComparer.Ordinal); var issues = new List(); if (!ids.SetEquals(question.WrongAnswerFollowups.Keys)) issues.Add("follow-up keys do not match distractors"); var values = question.WrongAnswerFollowups.Where(x => ids.Contains(x.Key)).Select(x => x.Value).ToList(); if (values.Any(x => x.Choices.Count != 3)) issues.Add("follow-up does not have exactly three choices"); Duplicate(values.Select(x => x.Prompt), "duplicate prompts", issues); Duplicate(values.Select(x => x.TeachingPoint), "duplicate teaching points", issues); Duplicate(values.Select(x => x.Choices.FirstOrDefault(c => c.Id == x.CorrectChoiceId)?.Text ?? ""), "duplicate correct follow-up answers", issues); Duplicate(values.Select(x => string.Join("|", x.Choices.Select(c => Normalize(c.Text)).Order())), "duplicate choice sets", issues); if (values.Any(x => x.Choices.Count == 0 || x.Choices.All(c => c.Id != x.CorrectChoiceId))) issues.Add("invalid correct follow-up choice"); return issues.Distinct().ToList(); } static void Duplicate(IEnumerable values, string issue, List issues) { var normalized = values.Select(Normalize).ToList(); if (normalized.Any(string.IsNullOrWhiteSpace) || normalized.Distinct(StringComparer.Ordinal).Count() != normalized.Count) issues.Add(issue); } static Dictionary Build(Question question) { var distractors = question.Choices.Where(x => x.Id != question.CorrectChoiceId).ToList(); var rationales = distractors.ToDictionary(x => x.Id, x => Rationale(question, x.Id)); return distractors.ToDictionary(choice => choice.Id, choice => { var alternatives = distractors.Where(x => x.Id != choice.Id).Take(2).ToArray(); return new WrongAnswerFollowup { Prompt = $"Which feature of option {choice.Id} should have ruled it out in this case?", Choices = [ new() { Id = "A", Text = rationales[choice.Id] }, new() { Id = "B", Text = Unique(rationales[alternatives[0].Id], alternatives[0].Id) }, new() { Id = "C", Text = Unique(rationales[alternatives[1].Id], alternatives[1].Id) } ], CorrectChoiceId = "A", TeachingPoint = rationales[choice.Id] }; }); } static string Rationale(Question question, string id) { var value = question.ChoiceExplanations.GetValueOrDefault(id, question.WrongAnswerFollowups.GetValueOrDefault(id)?.TeachingPoint ?? "").Trim(); foreach (var prefix in new[] { "Incorrect.", "Incorrect:", "Wrong.", "Wrong:" }) if (value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) value = value[prefix.Length..].Trim(); var choice = question.Choices.FirstOrDefault(x => x.Id == id)?.Text.Trim() ?? $"Option {id}"; if (string.IsNullOrWhiteSpace(value)) return $"{choice} does not fit the decisive clinical finding."; return value.Contains(choice, StringComparison.OrdinalIgnoreCase) ? value : $"{choice} is inappropriate because {char.ToLowerInvariant(value[0])}{value[1..]}"; } static string Unique(string value, string id) => $"Option {id}: {value}"; static string Normalize(string value) => string.Join(' ', (value ?? "").Trim().ToLowerInvariant().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); static string Hash(Dictionary value) { var canonical = value.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(canonical)))).ToLowerInvariant(); } sealed class RepairManifest { public string BatchId { get; set; } = ""; public DateTime CreatedUtc { get; set; } public List Entries { get; set; } = []; } sealed class RepairEntry { public string ExternalId { get; set; } = ""; public Dictionary ExpectedHashes { get; set; } = []; public Dictionary Replacement { get; set; } = []; } sealed class RepairBackup { [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } public string BatchId { get; set; } = ""; public string ExternalId { get; set; } = ""; public string OriginalHash { get; set; } = ""; public string ReplacementHash { get; set; } = ""; public Dictionary Original { get; set; } = []; public DateTime CreatedUtc { get; set; } }