193 lines
9.2 KiB
C#
193 lines
9.2 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;
|
|
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<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 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<string, DynamicQuestionBlueprint> 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<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, 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)
|
|
};
|
|
}
|