Files
step3/api/QuestionsApi/Services/DynamicQuestionService.cs
T

125 lines
5.7 KiB
C#

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, claims.GeneratorVersion);
var familyIds = blueprint.CaseFamilies.Select(value => value.Id).ToHashSet(StringComparer.Ordinal);
var recentForObjective = run.RecentCaseFamilyIds
.Where(familyIds.Contains)
.TakeLast(Math.Max(1, blueprint.CaseFamilies.Count - 1))
.ToHashSet(StringComparer.Ordinal);
for (var retry = 0; retry < 20 && recentForObjective.Contains(generated.CaseFamilyId); retry++)
{
claims.Seed = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
generated = _generator.Generate(blueprint, claims.Seed, claims.Nonce);
}
claims.CaseFamilyId = generated.CaseFamilyId;
run.SeenObjectiveIds.Add(blueprint.ObjectiveId);
run.PreviousObjectiveId = blueprint.ObjectiveId;
run.RecentCaseFamilyIds.Add(generated.CaseFamilyId);
run.RecentCaseFamilyIds = run.RecentCaseFamilyIds.TakeLast(30).ToList();
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 is < 1 or > 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.");
var generated = _generator.Generate(blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion);
if (!string.IsNullOrWhiteSpace(claims.CaseFamilyId)
&& claims.CaseFamilyId != generated.CaseFamilyId)
throw new InvalidOperationException("Generated case family does not match its signed descriptor.");
return (claims, generated);
}
private static DynamicRunClaims NewRun(string userId) => new()
{
UserId = userId,
ExpiresUtc = DateTime.UtcNow.AddDays(7)
};
}