Files
step3/tools/WrongAnswerFollowupRepair/Program.cs
T

173 lines
11 KiB
C#

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<string, List<Question>>();
foreach (var database in databases)
{
var questions = await client.GetDatabase(database).GetCollection<Question>("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<RepairManifest>(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<Question>("Questions");
var backups = db.GetCollection<RepairBackup>("WrongAnswerFollowupRepairBackups");
await backups.Indexes.CreateOneAsync(new CreateIndexModel<RepairBackup>(
Builders<RepairBackup>.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<Question>.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<Question>.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<string> Issues(Question question)
{
var ids = question.Choices.Select(x => x.Id).Where(x => x != question.CorrectChoiceId).ToHashSet(StringComparer.Ordinal);
var issues = new List<string>();
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<string> values, string issue, List<string> issues)
{
var normalized = values.Select(Normalize).ToList();
if (normalized.Any(string.IsNullOrWhiteSpace) || normalized.Distinct(StringComparer.Ordinal).Count() != normalized.Count) issues.Add(issue);
}
static Dictionary<string, WrongAnswerFollowup> 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<string, WrongAnswerFollowup> 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<RepairEntry> Entries { get; set; } = []; }
sealed class RepairEntry { public string ExternalId { get; set; } = ""; public Dictionary<string, string> ExpectedHashes { get; set; } = []; public Dictionary<string, WrongAnswerFollowup> 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<string, WrongAnswerFollowup> Original { get; set; } = []; public DateTime CreatedUtc { get; set; }
}