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 StatusAsync(string userId) => _store.StatusAsync(userId); public async Task 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 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) }; }