using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services; public sealed class DynamicQuestionStore { private readonly IMongoCollection _blueprints; private readonly IMongoCollection _attempts; private readonly IMongoCollection _mastery; public DynamicQuestionStore(MongoDatabaseProvider mongo) { _blueprints = mongo.Database.GetCollection("DynamicQuestionBlueprints"); _attempts = mongo.Database.GetCollection("DynamicQuestionAttempts"); _mastery = mongo.Database.GetCollection("DynamicObjectiveMastery"); _blueprints.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.ExternalId).Ascending(value => value.Version), new CreateIndexOptions { Unique = true })); _attempts.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.Nonce), new CreateIndexOptions { Unique = true })); _mastery.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ObjectiveId), new CreateIndexOptions { Unique = true })); } public async Task> PublishedAsync() => await _blueprints.Find(value => value.IsPublished).ToListAsync(); public async Task GetAsync(string externalId, int version) => await _blueprints.Find(value => value.ExternalId == externalId && value.Version == version).FirstOrDefaultAsync(); public async Task> MasteryAsync(string userId) => (await _mastery.Find(value => value.UserId == userId).ToListAsync()) .ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal); public async Task RecordAsync( DynamicGenerationClaims claims, DynamicGeneratedQuestion generated, DynamicAttemptRequest request) { var isCorrect = !request.IsSkip && request.ChoiceId == generated.Question.CorrectChoiceId; var attempt = new DynamicQuestionAttempt { UserId = claims.UserId, Nonce = claims.Nonce, BlueprintExternalId = claims.BlueprintExternalId, BlueprintVersion = claims.BlueprintVersion, ObjectiveId = claims.ObjectiveId, ChoiceId = request.ChoiceId, IsCorrect = isCorrect, IsSkip = request.IsSkip, DurationSeconds = Math.Clamp(request.DurationSeconds, 0, 86_400), CreatedUtc = DateTime.UtcNow }; try { await _attempts.InsertOneAsync(attempt); } catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { var existingMastery = await _mastery.Find(value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstOrDefaultAsync(); return new DynamicAttemptResponse { Inserted = false, IsCorrect = isCorrect, CorrectChoiceId = generated.Question.CorrectChoiceId, ObjectiveId = claims.ObjectiveId, ObjectiveElo = existingMastery?.Elo ?? 1000 }; } var current = await _mastery.Find(value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstOrDefaultAsync(); var oldElo = current?.Elo ?? 1000; var objectiveDifficulty = 800 + Math.Clamp(generated.Blueprint.Difficulty, 1, 5) * 100; var expected = 1.0 / (1.0 + Math.Pow(10, (objectiveDifficulty - oldElo) / 400.0)); var k = (current?.Attempts ?? 0) < 5 ? 40 : 24; var delta = (int)Math.Round(k * ((isCorrect ? 1.0 : 0.0) - expected)); var now = DateTime.UtcNow; var update = Builders.Update .SetOnInsert(value => value.UserId, claims.UserId) .SetOnInsert(value => value.ObjectiveId, claims.ObjectiveId) .SetOnInsert(value => value.Attempts, 0) .SetOnInsert(value => value.Correct, 0) .Set(value => value.Elo, oldElo + delta) .Inc(value => value.Attempts, 1) .Inc(value => value.Correct, isCorrect ? 1 : 0) .Set(value => value.LastSeenUtc, now); var updated = await _mastery.FindOneAndUpdateAsync( value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId, update, new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); return new DynamicAttemptResponse { Inserted = true, IsCorrect = isCorrect, CorrectChoiceId = generated.Question.CorrectChoiceId, ObjectiveId = claims.ObjectiveId, ObjectiveElo = updated.Elo, RatingChange = delta }; } public async Task StatusAsync(string userId) { var objectiveCount = (await PublishedAsync()).Select(value => value.ObjectiveId).Distinct().Count(); var mastery = await _mastery.Find(value => value.UserId == userId).ToListAsync(); return new DynamicQuestionStatusResponse { Available = objectiveCount > 0, PublishedObjectives = objectiveCount, AttemptedObjectives = mastery.Count, TotalAttempts = mastery.Sum(value => value.Attempts), AverageObjectiveElo = mastery.Count == 0 ? 1000 : (int)Math.Round(mastery.Average(value => value.Elo)) }; } public async Task UpsertBlueprintsAsync(IEnumerable blueprints) { foreach (var blueprint in blueprints) await _blueprints.ReplaceOneAsync( value => value.ExternalId == blueprint.ExternalId && value.Version == blueprint.Version, blueprint, new ReplaceOptions { IsUpsert = true }); } }