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; private readonly RatingStore _ratings; private readonly ServiceMetrics _metrics; public DynamicQuestionService( DynamicQuestionStore store, DynamicQuestionGenerator generator, DynamicQuestionTokenService tokens, RatingStore ratings, ServiceMetrics metrics) { _store = store; _generator = generator; _tokens = tokens; _ratings = ratings; _metrics = metrics; } 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 dueReviews = await _store.DueReviewsAsync(userId, DateTime.UtcNow); var dueByObjective = dueReviews.ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal); var candidates = unseen .Where(group => group.Key != run.PreviousObjectiveId || unseen.Count == 1) .ToList(); var roll = RandomNumberGenerator.GetInt32(100); IGrouping selectedObjective; var dueCandidates = candidates.Where(value => dueByObjective.ContainsKey(value.Key)).ToList(); if (roll < 50 && dueCandidates.Count == 0 && dueByObjective.Count > 0) dueCandidates = byObjective .Where(value => dueByObjective.ContainsKey(value.Key) && (value.Key != run.PreviousObjectiveId || dueByObjective.Count == 1)) .ToList(); if (roll < 50 && dueCandidates.Count > 0) { selectedObjective = dueCandidates .OrderBy(value => dueByObjective[value.Key].DueUtc) .ThenBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)) .First(); } else if (roll < 80) { var systemAttempts = byObjective .GroupBy(group => group.OrderByDescending(value => value.Version).First().System) .ToDictionary( group => group.Key, group => group.Sum(objective => mastery.TryGetValue(objective.Key, out var value) ? value.Attempts : 0), StringComparer.Ordinal); selectedObjective = candidates .OrderBy(group => { var latest = group.OrderByDescending(value => value.Version).First(); var weight = DynamicBlueprintCoveragePolicy.Weight(latest); return (systemAttempts.GetValueOrDefault(latest.System) + 1) / weight; }) .ThenBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)) .First(); } else { var weakestBand = candidates .OrderBy(group => mastery.TryGetValue(group.Key, out var value) ? value.Elo : 1000) .Take(Math.Min(4, candidates.Count)) .ToList(); selectedObjective = weakestBand[RandomNumberGenerator.GetInt32(weakestBand.Count)]; } var blueprint = selectedObjective .OrderByDescending(value => value.Version) .First(); var ratingProfile = await _ratings.GetProfileAsync(userId); mastery.TryGetValue(blueprint.ObjectiveId, out var objectiveMastery); var category = RatingCategory.Normalize( blueprint.PerformanceCategory, blueprint.System, blueprint.Discipline); var categoryElo = ratingProfile.CategoryElos.GetValueOrDefault(category, ratingProfile.OverallElo); var effectiveAbility = DynamicDifficultyPolicy.EffectiveAbility( ratingProfile.OverallElo, categoryElo, objectiveMastery); var targetElo = DynamicDifficultyPolicy.TargetFamilyElo(effectiveAbility); var familyRatings = await _store.FamilyRatingsAsync(blueprint); var blueprintFamilyIds = blueprint.CaseFamilies.Select(value => value.Id).ToHashSet(StringComparer.Ordinal); var recentForObjective = run.RecentCaseFamilyIds .Where(blueprintFamilyIds.Contains) .TakeLast(Math.Max(1, blueprint.CaseFamilies.Count / 4)) .ToHashSet(StringComparer.Ordinal); var selectedFamily = DynamicDifficultyPolicy.SelectFamily( blueprint.CaseFamilies, familyRatings, targetElo, recentForObjective, RandomNumberGenerator.GetInt32(int.MaxValue)); var selectedFamilyElo = familyRatings.TryGetValue(selectedFamily.Id, out var selectedRating) ? selectedRating.Elo : selectedFamily.NominalElo; _metrics.DynamicSelected(selectedFamily.DifficultyTier, selectedFamilyElo - targetElo); 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, CaseFamilyId = selectedFamily.Id, DifficultyTier = selectedFamily.DifficultyTier, EffectiveAbilityElo = effectiveAbility, FamilyElo = selectedFamilyElo, IsScheduledReview = dueByObjective.TryGetValue(blueprint.ObjectiveId, out var reviewItem), ReviewItemId = reviewItem?.Id ?? "", ReviewStage = reviewItem?.Stage ?? 0, ExpiresUtc = DateTime.UtcNow.AddDays(7) }; var generated = _generator.Generate( blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion, claims.CaseFamilyId); if (!run.SeenObjectiveIds.Contains(blueprint.ObjectiveId, StringComparer.Ordinal)) 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, questionElo: selectedFamilyElo), 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 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, claims.CaseFamilyId); 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) }; }