165 lines
8.3 KiB
C#
165 lines
8.3 KiB
C#
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<string, string>(StringComparer.Ordinal);
|
|
var numbers = new Dictionary<string, double>(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<string, string> 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<string, double> 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);
|
|
}
|
|
}
|
|
}
|