Add data-driven dynamic question mode
This commit is contained in:
@@ -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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user