125 lines
6.3 KiB
C#
125 lines
6.3 KiB
C#
using MongoDB.Driver;
|
|
using QuestionsApi.Models;
|
|
|
|
namespace QuestionsApi.Services;
|
|
|
|
public sealed class DynamicQuestionStore
|
|
{
|
|
private readonly IMongoCollection<DynamicQuestionBlueprint> _blueprints;
|
|
private readonly IMongoCollection<DynamicQuestionAttempt> _attempts;
|
|
private readonly IMongoCollection<DynamicObjectiveMastery> _mastery;
|
|
|
|
public DynamicQuestionStore(MongoDatabaseProvider mongo)
|
|
{
|
|
_blueprints = mongo.Database.GetCollection<DynamicQuestionBlueprint>("DynamicQuestionBlueprints");
|
|
_attempts = mongo.Database.GetCollection<DynamicQuestionAttempt>("DynamicQuestionAttempts");
|
|
_mastery = mongo.Database.GetCollection<DynamicObjectiveMastery>("DynamicObjectiveMastery");
|
|
_blueprints.Indexes.CreateOne(new CreateIndexModel<DynamicQuestionBlueprint>(
|
|
Builders<DynamicQuestionBlueprint>.IndexKeys.Ascending(value => value.ExternalId).Ascending(value => value.Version),
|
|
new CreateIndexOptions { Unique = true }));
|
|
_attempts.Indexes.CreateOne(new CreateIndexModel<DynamicQuestionAttempt>(
|
|
Builders<DynamicQuestionAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.Nonce),
|
|
new CreateIndexOptions { Unique = true }));
|
|
_mastery.Indexes.CreateOne(new CreateIndexModel<DynamicObjectiveMastery>(
|
|
Builders<DynamicObjectiveMastery>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ObjectiveId),
|
|
new CreateIndexOptions { Unique = true }));
|
|
}
|
|
|
|
public async Task<List<DynamicQuestionBlueprint>> PublishedAsync() =>
|
|
await _blueprints.Find(value => value.IsPublished).ToListAsync();
|
|
|
|
public async Task<DynamicQuestionBlueprint?> GetAsync(string externalId, int version) =>
|
|
await _blueprints.Find(value => value.ExternalId == externalId && value.Version == version).FirstOrDefaultAsync();
|
|
|
|
public async Task<Dictionary<string, DynamicObjectiveMastery>> MasteryAsync(string userId) =>
|
|
(await _mastery.Find(value => value.UserId == userId).ToListAsync())
|
|
.ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal);
|
|
|
|
public async Task<DynamicAttemptResponse> 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<DynamicObjectiveMastery>.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<DynamicObjectiveMastery> { 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<DynamicQuestionStatusResponse> 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<DynamicQuestionBlueprint> blueprints)
|
|
{
|
|
foreach (var blueprint in blueprints)
|
|
await _blueprints.ReplaceOneAsync(
|
|
value => value.ExternalId == blueprint.ExternalId && value.Version == blueprint.Version,
|
|
blueprint,
|
|
new ReplaceOptions { IsUpsert = true });
|
|
}
|
|
}
|