Add data-driven dynamic question mode
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using QuestionsApi.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class DynamicBlueprintCommand
|
||||
{
|
||||
public static async Task<int> RunAsync(string[] args, TextWriter output)
|
||||
{
|
||||
var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase);
|
||||
var sourceIndex = Array.FindIndex(args, value => value.Equals("--source", StringComparison.OrdinalIgnoreCase));
|
||||
var source = sourceIndex >= 0 && sourceIndex + 1 < args.Length
|
||||
? args[sourceIndex + 1]
|
||||
: Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "content", "dynamic_questions"));
|
||||
if (!Directory.Exists(source))
|
||||
{
|
||||
await output.WriteLineAsync($"Dynamic blueprint directory not found: {source}");
|
||||
return 2;
|
||||
}
|
||||
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true };
|
||||
var blueprints = new List<DynamicQuestionBlueprint>();
|
||||
foreach (var file in Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories))
|
||||
blueprints.AddRange(JsonSerializer.Deserialize<List<DynamicQuestionBlueprint>>(await File.ReadAllTextAsync(file), json) ?? []);
|
||||
|
||||
var generator = new DynamicQuestionGenerator();
|
||||
foreach (var blueprint in blueprints)
|
||||
{
|
||||
var error = DynamicQuestionGenerator.Validate(blueprint);
|
||||
if (error is not null)
|
||||
throw new InvalidDataException($"{blueprint.ExternalId} v{blueprint.Version}: {error}");
|
||||
for (var seed = 0; seed < 1000; seed++)
|
||||
generator.Generate(blueprint, seed.ToString(), seed.ToString());
|
||||
}
|
||||
await output.WriteLineAsync($"Validated {blueprints.Count} blueprints across 1,000 seeds each.");
|
||||
if (!apply)
|
||||
{
|
||||
await output.WriteLineAsync("Dry run only. Pass --apply to publish.");
|
||||
return 0;
|
||||
}
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
var store = new DynamicQuestionStore(new MongoDatabaseProvider(configuration));
|
||||
await store.UpsertBlueprintsAsync(blueprints);
|
||||
await output.WriteLineAsync($"Published {blueprints.Count} immutable blueprint versions.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using QuestionsApi.Models;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class DynamicQuestionService
|
||||
{
|
||||
private readonly DynamicQuestionStore _store;
|
||||
private readonly DynamicQuestionGenerator _generator;
|
||||
private readonly DynamicQuestionTokenService _tokens;
|
||||
|
||||
public DynamicQuestionService(
|
||||
DynamicQuestionStore store,
|
||||
DynamicQuestionGenerator generator,
|
||||
DynamicQuestionTokenService tokens)
|
||||
{
|
||||
_store = store;
|
||||
_generator = generator;
|
||||
_tokens = tokens;
|
||||
}
|
||||
|
||||
public Task<DynamicQuestionStatusResponse> StatusAsync(string userId) => _store.StatusAsync(userId);
|
||||
|
||||
public async Task<DynamicQuestionEnvelope> NextAsync(string userId, string runToken)
|
||||
{
|
||||
var blueprints = await _store.PublishedAsync();
|
||||
if (blueprints.Count == 0)
|
||||
throw new InvalidOperationException("No dynamic question blueprints are published.");
|
||||
|
||||
var run = string.IsNullOrWhiteSpace(runToken)
|
||||
? NewRun(userId)
|
||||
: _tokens.ReadRun(runToken, userId);
|
||||
var byObjective = blueprints.GroupBy(value => value.ObjectiveId).ToList();
|
||||
var objectiveIds = byObjective.Select(value => value.Key).ToHashSet(StringComparer.Ordinal);
|
||||
run.SeenObjectiveIds = run.SeenObjectiveIds.Where(objectiveIds.Contains).Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
var unseen = byObjective.Where(group => !run.SeenObjectiveIds.Contains(group.Key, StringComparer.Ordinal)).ToList();
|
||||
if (unseen.Count == 0)
|
||||
{
|
||||
run.Cycle++;
|
||||
run.SeenObjectiveIds.Clear();
|
||||
unseen = byObjective;
|
||||
}
|
||||
|
||||
var mastery = await _store.MasteryAsync(userId);
|
||||
var candidates = unseen
|
||||
.Where(group => group.Key != run.PreviousObjectiveId || unseen.Count == 1)
|
||||
.OrderBy(group => mastery.TryGetValue(group.Key, out var value) ? value.Elo : 1000)
|
||||
.ThenBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue))
|
||||
.ToList();
|
||||
var weakestBand = candidates.Take(Math.Min(4, candidates.Count)).ToList();
|
||||
var selectedObjective = weakestBand[RandomNumberGenerator.GetInt32(weakestBand.Count)];
|
||||
var blueprint = selectedObjective
|
||||
.OrderByDescending(value => value.Version)
|
||||
.First();
|
||||
|
||||
var claims = new DynamicGenerationClaims
|
||||
{
|
||||
UserId = userId,
|
||||
BlueprintExternalId = blueprint.ExternalId,
|
||||
BlueprintVersion = blueprint.Version,
|
||||
ObjectiveId = blueprint.ObjectiveId,
|
||||
Seed = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
|
||||
Nonce = Guid.NewGuid().ToString("N"),
|
||||
GeneratorVersion = DynamicQuestionGenerator.CurrentVersion,
|
||||
ExpiresUtc = DateTime.UtcNow.AddDays(7)
|
||||
};
|
||||
var generated = _generator.Generate(blueprint, claims.Seed, claims.Nonce);
|
||||
run.SeenObjectiveIds.Add(blueprint.ObjectiveId);
|
||||
run.PreviousObjectiveId = blueprint.ObjectiveId;
|
||||
run.ExpiresUtc = DateTime.UtcNow.AddDays(7);
|
||||
return new DynamicQuestionEnvelope
|
||||
{
|
||||
Question = QuestionResponse.FromModel(generated.Question),
|
||||
ObjectiveId = blueprint.ObjectiveId,
|
||||
ObjectiveTitle = blueprint.Title,
|
||||
GenerationToken = _tokens.SignGeneration(claims),
|
||||
RunToken = _tokens.SignRun(run),
|
||||
CycleSeen = run.SeenObjectiveIds.Count,
|
||||
TotalObjectives = byObjective.Count
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DynamicAttemptResponse> AttemptAsync(string userId, DynamicAttemptRequest request)
|
||||
{
|
||||
var (claims, generated) = await RegenerateAsync(userId, request.GenerationToken);
|
||||
return await _store.RecordAsync(claims, generated, request);
|
||||
}
|
||||
|
||||
public async Task<(DynamicGenerationClaims Claims, DynamicGeneratedQuestion Generated)> RegenerateAsync(
|
||||
string userId,
|
||||
string generationToken)
|
||||
{
|
||||
var claims = _tokens.ReadGeneration(generationToken, userId);
|
||||
if (claims.GeneratorVersion != DynamicQuestionGenerator.CurrentVersion)
|
||||
throw new InvalidOperationException("This generated question version is no longer supported.");
|
||||
var blueprint = await _store.GetAsync(claims.BlueprintExternalId, claims.BlueprintVersion)
|
||||
?? throw new KeyNotFoundException("The question blueprint is no longer available.");
|
||||
return (claims, _generator.Generate(blueprint, claims.Seed, claims.Nonce));
|
||||
}
|
||||
|
||||
private static DynamicRunClaims NewRun(string userId) => new()
|
||||
{
|
||||
UserId = userId,
|
||||
ExpiresUtc = DateTime.UtcNow.AddDays(7)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class DynamicQuestionStore
|
||||
{
|
||||
private readonly IMongoCollection<DynamicQuestionBlueprint> _blueprints;
|
||||
private readonly IMongoCollection<DynamicQuestionAttempt> _attempts;
|
||||
private readonly IMongoCollection<DynamicObjectiveMastery> _mastery;
|
||||
|
||||
public DynamicQuestionStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_blueprints = mongo.Database.GetCollection<DynamicQuestionBlueprint>("DynamicQuestionBlueprints");
|
||||
_attempts = mongo.Database.GetCollection<DynamicQuestionAttempt>("DynamicQuestionAttempts");
|
||||
_mastery = mongo.Database.GetCollection<DynamicObjectiveMastery>("DynamicObjectiveMastery");
|
||||
_blueprints.Indexes.CreateOne(new CreateIndexModel<DynamicQuestionBlueprint>(
|
||||
Builders<DynamicQuestionBlueprint>.IndexKeys.Ascending(value => value.ExternalId).Ascending(value => value.Version),
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
_attempts.Indexes.CreateOne(new CreateIndexModel<DynamicQuestionAttempt>(
|
||||
Builders<DynamicQuestionAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.Nonce),
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
_mastery.Indexes.CreateOne(new CreateIndexModel<DynamicObjectiveMastery>(
|
||||
Builders<DynamicObjectiveMastery>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ObjectiveId),
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
}
|
||||
|
||||
public async Task<List<DynamicQuestionBlueprint>> PublishedAsync() =>
|
||||
await _blueprints.Find(value => value.IsPublished).ToListAsync();
|
||||
|
||||
public async Task<DynamicQuestionBlueprint?> GetAsync(string externalId, int version) =>
|
||||
await _blueprints.Find(value => value.ExternalId == externalId && value.Version == version).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<Dictionary<string, DynamicObjectiveMastery>> MasteryAsync(string userId) =>
|
||||
(await _mastery.Find(value => value.UserId == userId).ToListAsync())
|
||||
.ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal);
|
||||
|
||||
public async Task<DynamicAttemptResponse> RecordAsync(
|
||||
DynamicGenerationClaims claims,
|
||||
DynamicGeneratedQuestion generated,
|
||||
DynamicAttemptRequest request)
|
||||
{
|
||||
var isCorrect = !request.IsSkip && request.ChoiceId == generated.Question.CorrectChoiceId;
|
||||
var attempt = new DynamicQuestionAttempt
|
||||
{
|
||||
UserId = claims.UserId,
|
||||
Nonce = claims.Nonce,
|
||||
BlueprintExternalId = claims.BlueprintExternalId,
|
||||
BlueprintVersion = claims.BlueprintVersion,
|
||||
ObjectiveId = claims.ObjectiveId,
|
||||
ChoiceId = request.ChoiceId,
|
||||
IsCorrect = isCorrect,
|
||||
IsSkip = request.IsSkip,
|
||||
DurationSeconds = Math.Clamp(request.DurationSeconds, 0, 86_400),
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
try { await _attempts.InsertOneAsync(attempt); }
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
var existingMastery = await _mastery.Find(value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstOrDefaultAsync();
|
||||
return new DynamicAttemptResponse
|
||||
{
|
||||
Inserted = false,
|
||||
IsCorrect = isCorrect,
|
||||
CorrectChoiceId = generated.Question.CorrectChoiceId,
|
||||
ObjectiveId = claims.ObjectiveId,
|
||||
ObjectiveElo = existingMastery?.Elo ?? 1000
|
||||
};
|
||||
}
|
||||
|
||||
var current = await _mastery.Find(value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstOrDefaultAsync();
|
||||
var oldElo = current?.Elo ?? 1000;
|
||||
var objectiveDifficulty = 800 + Math.Clamp(generated.Blueprint.Difficulty, 1, 5) * 100;
|
||||
var expected = 1.0 / (1.0 + Math.Pow(10, (objectiveDifficulty - oldElo) / 400.0));
|
||||
var k = (current?.Attempts ?? 0) < 5 ? 40 : 24;
|
||||
var delta = (int)Math.Round(k * ((isCorrect ? 1.0 : 0.0) - expected));
|
||||
var now = DateTime.UtcNow;
|
||||
var update = Builders<DynamicObjectiveMastery>.Update
|
||||
.SetOnInsert(value => value.UserId, claims.UserId)
|
||||
.SetOnInsert(value => value.ObjectiveId, claims.ObjectiveId)
|
||||
.SetOnInsert(value => value.Attempts, 0)
|
||||
.SetOnInsert(value => value.Correct, 0)
|
||||
.Set(value => value.Elo, oldElo + delta)
|
||||
.Inc(value => value.Attempts, 1)
|
||||
.Inc(value => value.Correct, isCorrect ? 1 : 0)
|
||||
.Set(value => value.LastSeenUtc, now);
|
||||
var updated = await _mastery.FindOneAndUpdateAsync(
|
||||
value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId,
|
||||
update,
|
||||
new FindOneAndUpdateOptions<DynamicObjectiveMastery> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
|
||||
return new DynamicAttemptResponse
|
||||
{
|
||||
Inserted = true,
|
||||
IsCorrect = isCorrect,
|
||||
CorrectChoiceId = generated.Question.CorrectChoiceId,
|
||||
ObjectiveId = claims.ObjectiveId,
|
||||
ObjectiveElo = updated.Elo,
|
||||
RatingChange = delta
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DynamicQuestionStatusResponse> StatusAsync(string userId)
|
||||
{
|
||||
var objectiveCount = (await PublishedAsync()).Select(value => value.ObjectiveId).Distinct().Count();
|
||||
var mastery = await _mastery.Find(value => value.UserId == userId).ToListAsync();
|
||||
return new DynamicQuestionStatusResponse
|
||||
{
|
||||
Available = objectiveCount > 0,
|
||||
PublishedObjectives = objectiveCount,
|
||||
AttemptedObjectives = mastery.Count,
|
||||
TotalAttempts = mastery.Sum(value => value.Attempts),
|
||||
AverageObjectiveElo = mastery.Count == 0 ? 1000 : (int)Math.Round(mastery.Average(value => value.Elo))
|
||||
};
|
||||
}
|
||||
|
||||
public async Task UpsertBlueprintsAsync(IEnumerable<DynamicQuestionBlueprint> blueprints)
|
||||
{
|
||||
foreach (var blueprint in blueprints)
|
||||
await _blueprints.ReplaceOneAsync(
|
||||
value => value.ExternalId == blueprint.ExternalId && value.Version == blueprint.Version,
|
||||
blueprint,
|
||||
new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using QuestionsApi.Models;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class DynamicQuestionTokenService
|
||||
{
|
||||
private readonly byte[] _key;
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public DynamicQuestionTokenService(IConfiguration configuration)
|
||||
{
|
||||
var key = configuration["DynamicQuestions:SigningKey"]
|
||||
?? configuration["Jwt:Key"]
|
||||
?? "development-dynamic-question-signing-key-change-me";
|
||||
_key = SHA256.HashData(Encoding.UTF8.GetBytes(key));
|
||||
}
|
||||
|
||||
public string SignGeneration(DynamicGenerationClaims claims) => Sign(claims);
|
||||
public string SignRun(DynamicRunClaims claims) => Sign(claims);
|
||||
public DynamicGenerationClaims ReadGeneration(string token, string userId) => Read<DynamicGenerationClaims>(token, userId);
|
||||
public DynamicRunClaims ReadRun(string token, string userId) => Read<DynamicRunClaims>(token, userId);
|
||||
|
||||
private string Sign<T>(T value)
|
||||
{
|
||||
var payload = Base64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, Json)));
|
||||
using var hmac = new HMACSHA256(_key);
|
||||
return $"{payload}.{Base64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)))}";
|
||||
}
|
||||
|
||||
private T Read<T>(string token, string userId) where T : class
|
||||
{
|
||||
var parts = (token ?? "").Split('.');
|
||||
if (parts.Length != 2) throw new ArgumentException("token is invalid");
|
||||
using var hmac = new HMACSHA256(_key);
|
||||
var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes(parts[0]));
|
||||
var supplied = FromBase64Url(parts[1]);
|
||||
if (!CryptographicOperations.FixedTimeEquals(expected, supplied)) throw new ArgumentException("token is invalid");
|
||||
var value = JsonSerializer.Deserialize<T>(FromBase64Url(parts[0]), Json)
|
||||
?? throw new ArgumentException("token is invalid");
|
||||
var tokenUser = value switch
|
||||
{
|
||||
DynamicGenerationClaims generation => generation.UserId,
|
||||
DynamicRunClaims run => run.UserId,
|
||||
_ => ""
|
||||
};
|
||||
var expires = value switch
|
||||
{
|
||||
DynamicGenerationClaims generation => generation.ExpiresUtc,
|
||||
DynamicRunClaims run => run.ExpiresUtc,
|
||||
_ => DateTime.MinValue
|
||||
};
|
||||
if (!string.Equals(tokenUser, userId, StringComparison.Ordinal) || expires < DateTime.UtcNow)
|
||||
throw new ArgumentException("token is expired or belongs to another user");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
private static byte[] FromBase64Url(string value)
|
||||
{
|
||||
value = value.Replace('-', '+').Replace('_', '/');
|
||||
value += new string('=', (4 - value.Length % 4) % 4);
|
||||
return Convert.FromBase64String(value);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,35 @@ public class QuestionReportStore
|
||||
return report;
|
||||
}
|
||||
|
||||
public async Task<QuestionReport> CreateDynamicAsync(
|
||||
DynamicGenerationClaims claims,
|
||||
string userId,
|
||||
string category,
|
||||
string details)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var report = new QuestionReport
|
||||
{
|
||||
QuestionExternalId = $"dynamic:{claims.BlueprintExternalId}:v{claims.BlueprintVersion}",
|
||||
UserId = userId,
|
||||
Category = category,
|
||||
Details = details,
|
||||
Status = QuestionReportStatus.Open,
|
||||
SourceType = "dynamic",
|
||||
DynamicGeneration = new DynamicGenerationDescriptor
|
||||
{
|
||||
BlueprintExternalId = claims.BlueprintExternalId,
|
||||
BlueprintVersion = claims.BlueprintVersion,
|
||||
GeneratorVersion = claims.GeneratorVersion,
|
||||
Seed = claims.Seed
|
||||
},
|
||||
CreatedUtc = now,
|
||||
UpdatedUtc = now
|
||||
};
|
||||
await _reports.InsertOneAsync(report);
|
||||
return report;
|
||||
}
|
||||
|
||||
public async Task<QuestionReport?> GetByIdAsync(string id)
|
||||
{
|
||||
if (!ObjectId.TryParse(id, out _))
|
||||
|
||||
Reference in New Issue
Block a user