using QuestionsApi.Models; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; namespace QuestionsApi.Services; public sealed class DynamicQuestionGenerator { public const int CurrentVersion = 1; private static readonly Regex Placeholder = new(@"\{\{([a-zA-Z0-9_]+)\}\}", RegexOptions.Compiled); public DynamicGeneratedQuestion Generate(DynamicQuestionBlueprint blueprint, string seed, string nonce) { var draw = new DeterministicDraw($"{blueprint.ExternalId}|{blueprint.Version}|{CurrentVersion}|{seed}"); var values = new Dictionary(StringComparer.Ordinal); var numbers = new Dictionary(StringComparer.Ordinal); foreach (var item in blueprint.Variables.OrderBy(value => value.Key, StringComparer.Ordinal)) { var definition = item.Value; if (definition.Type == "enum") { if (definition.Values.Count == 0) throw new InvalidOperationException($"{item.Key} requires values"); values[item.Key] = definition.Values[draw.Next(definition.Values.Count)]; continue; } var step = definition.Step <= 0 ? 1 : definition.Step; var count = (int)Math.Floor((definition.Maximum - definition.Minimum) / step) + 1; if (count < 1) throw new InvalidOperationException($"{item.Key} range is invalid"); var number = definition.Minimum + draw.Next(count) * step; number = Math.Round(number, Math.Clamp(definition.Precision, 0, 8)); numbers[item.Key] = number; values[item.Key] = Format(number, definition.Precision); } foreach (var item in blueprint.Derived) { var definition = item.Value; var left = ResolveNumber(definition.Left, numbers); var right = ResolveNumber(definition.Right, numbers); var number = definition.Operation switch { "add" => left + right, "subtract" => left - right, "multiply" => left * right, "divide" when Math.Abs(right) > double.Epsilon => left / right, _ => throw new InvalidOperationException($"{item.Key} operation is invalid") }; number = Math.Round(number, Math.Clamp(definition.Precision, 0, 8)); numbers[item.Key] = number; values[item.Key] = Format(number, definition.Precision); } foreach (var constraint in blueprint.Constraints) { if (!numbers.TryGetValue(constraint.Field, out var actual)) throw new InvalidOperationException($"constraint field {constraint.Field} is not numeric"); var valid = constraint.Operator switch { "lt" => actual < constraint.Value, "lte" => actual <= constraint.Value, "gt" => actual > constraint.Value, "gte" => actual >= constraint.Value, "eq" => Math.Abs(actual - constraint.Value) < 0.0000001, "neq" => Math.Abs(actual - constraint.Value) >= 0.0000001, _ => false }; if (!valid) throw new InvalidOperationException($"generated value violates {constraint.Field} {constraint.Operator}"); } var distractors = blueprint.Distractors.OrderBy(_ => draw.Next(int.MaxValue)).Take(4).ToList(); if (distractors.Count != 4) throw new InvalidOperationException("at least four distractors are required"); var answerTemplates = distractors.Append(blueprint.CorrectAnswer) .OrderBy(_ => draw.Next(int.MaxValue)).ToList(); var choices = answerTemplates.Select(answer => new QuestionChoice { Id = answer.Id, Text = Render(answer.TextTemplate, values) }).ToList(); if (choices.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != 5 || choices.Select(value => value.Text).Distinct(StringComparer.OrdinalIgnoreCase).Count() != 5) throw new InvalidOperationException("generated choices must be unique"); var question = new Question { Id = $"dynamic-{nonce}", ExternalId = $"dynamic-{nonce}", Stem = Render(blueprint.StemTemplate, values), LeadIn = Render(blueprint.LeadIn, values), Choices = choices, CorrectChoiceId = blueprint.CorrectAnswer.Id, Explanation = Render(blueprint.ExplanationTemplate, values), KeyTakeaway = Render(blueprint.KeyTakeaway, values), UsmlePearl = Render(blueprint.UsmlePearl, values), ChoiceExplanations = answerTemplates.ToDictionary( value => value.Id, value => Render(value.ExplanationTemplate, values)), Discipline = blueprint.Discipline, System = blueprint.System, PerformanceCategory = blueprint.PerformanceCategory, Difficulty = blueprint.Difficulty, Labs = blueprint.Labs.Select(lab => new QuestionLab { Name = lab.Name, Value = Render(lab.ValueTemplate, values), Unit = lab.Unit, Reference = lab.Reference }).ToList(), Tags = [.. blueprint.Tags, "dynamic", $"objective:{blueprint.ObjectiveId}"], References = blueprint.References, IsPublished = true }; return new DynamicGeneratedQuestion { Question = question, Blueprint = blueprint }; } public static string? Validate(DynamicQuestionBlueprint blueprint) { if (string.IsNullOrWhiteSpace(blueprint.ExternalId)) return "externalId required"; if (string.IsNullOrWhiteSpace(blueprint.ObjectiveId)) return "objectiveId required"; if (blueprint.Version < 1) return "version must be positive"; if (string.IsNullOrWhiteSpace(blueprint.StemTemplate) || string.IsNullOrWhiteSpace(blueprint.LeadIn)) return "stemTemplate and leadIn required"; if (string.IsNullOrWhiteSpace(blueprint.CorrectAnswer.Id) || string.IsNullOrWhiteSpace(blueprint.CorrectAnswer.TextTemplate)) return "correctAnswer required"; if (blueprint.Distractors.Count < 4) return "at least four distractors required"; var ids = blueprint.Distractors.Select(value => value.Id).Append(blueprint.CorrectAnswer.Id).ToList(); if (ids.Any(string.IsNullOrWhiteSpace) || ids.Distinct(StringComparer.Ordinal).Count() != ids.Count) return "answer ids must be unique"; if (blueprint.References.Count == 0) return "at least one reference required"; try { var generator = new DynamicQuestionGenerator(); for (var index = 0; index < 100; index++) generator.Generate(blueprint, $"validation-{index}", $"validation-{index}"); } catch (Exception exception) { return exception.Message; } return null; } private static string Render(string template, IReadOnlyDictionary values) { var rendered = Placeholder.Replace(template ?? "", match => values.TryGetValue(match.Groups[1].Value, out var value) ? value : match.Value); if (Placeholder.IsMatch(rendered)) throw new InvalidOperationException($"unresolved template in {rendered}"); return rendered; } private static double ResolveNumber(string operand, IReadOnlyDictionary numbers) => numbers.TryGetValue(operand, out var value) ? value : double.TryParse(operand, NumberStyles.Float, CultureInfo.InvariantCulture, out value) ? value : throw new InvalidOperationException($"unknown numeric operand {operand}"); private static string Format(double value, int precision) => value.ToString(precision > 0 ? $"F{precision}" : "0", CultureInfo.InvariantCulture); private sealed class DeterministicDraw(string source) { private int _counter; public int Next(int maximum) { if (maximum <= 0) throw new ArgumentOutOfRangeException(nameof(maximum)); var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{source}|{_counter++}")); return (int)(BitConverter.ToUInt32(bytes, 0) % (uint)maximum); } } }