238 lines
12 KiB
C#
238 lines
12 KiB
C#
using MongoDB.Driver;
|
|
using MongoDB.Bson;
|
|
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;
|
|
private readonly IMongoCollection<DynamicFamilyRating> _familyRatings;
|
|
private readonly ServiceMetrics? _metrics;
|
|
|
|
public DynamicQuestionStore(MongoDatabaseProvider mongo, ServiceMetrics? metrics = null)
|
|
{
|
|
_metrics = metrics;
|
|
_blueprints = mongo.Database.GetCollection<DynamicQuestionBlueprint>("DynamicQuestionBlueprints");
|
|
_attempts = mongo.Database.GetCollection<DynamicQuestionAttempt>("DynamicQuestionAttempts");
|
|
_mastery = mongo.Database.GetCollection<DynamicObjectiveMastery>("DynamicObjectiveMastery");
|
|
_familyRatings = mongo.Database.GetCollection<DynamicFamilyRating>("DynamicFamilyRatings");
|
|
_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 }));
|
|
_familyRatings.Indexes.CreateOne(new CreateIndexModel<DynamicFamilyRating>(
|
|
Builders<DynamicFamilyRating>.IndexKeys
|
|
.Ascending(value => value.BlueprintExternalId)
|
|
.Ascending(value => value.BlueprintVersion)
|
|
.Ascending(value => value.CaseFamilyId),
|
|
new CreateIndexOptions { Unique = true }));
|
|
}
|
|
|
|
public async Task<List<DynamicQuestionBlueprint>> PublishedAsync() =>
|
|
await _blueprints.Find(value => value.IsPublished && value.QualityStandardVersion >= 2).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<Dictionary<string, DynamicFamilyRating>> FamilyRatingsAsync(DynamicQuestionBlueprint blueprint)
|
|
{
|
|
var ratings = await _familyRatings.Find(value =>
|
|
value.BlueprintExternalId == blueprint.ExternalId
|
|
&& value.BlueprintVersion == blueprint.Version).ToListAsync();
|
|
return ratings.ToDictionary(value => value.CaseFamilyId, 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,
|
|
CaseFamilyId = claims.CaseFamilyId,
|
|
DifficultyTier = claims.DifficultyTier,
|
|
EffectiveAbilityElo = claims.EffectiveAbilityElo,
|
|
FamilyElo = claims.FamilyElo,
|
|
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
|
|
};
|
|
}
|
|
_metrics?.DynamicAttempt(claims.DifficultyTier, isCorrect);
|
|
|
|
await EnsureMasteryAsync(claims.UserId, claims.ObjectiveId);
|
|
var current = await _mastery.Find(value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstAsync()
|
|
?? throw new InvalidOperationException("Dynamic objective mastery could not be initialized.");
|
|
var oldElo = current.Elo;
|
|
var familyElo = claims.FamilyElo > 0
|
|
? claims.FamilyElo
|
|
: 800 + Math.Clamp(generated.Blueprint.Difficulty, 1, 5) * 100;
|
|
var delta = DynamicDifficultyPolicy.LearnerDelta(oldElo, familyElo, isCorrect, current.Attempts);
|
|
var now = DateTime.UtcNow;
|
|
var update = Builders<DynamicObjectiveMastery>.Update
|
|
.Inc(value => value.Elo, 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> { ReturnDocument = ReturnDocument.After });
|
|
await _mastery.UpdateOneAsync(
|
|
value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId,
|
|
Builders<DynamicObjectiveMastery>.Update
|
|
.Max(value => value.Elo, DynamicDifficultyPolicy.MinimumElo)
|
|
.Min(value => value.Elo, DynamicDifficultyPolicy.MaximumElo));
|
|
if (!string.IsNullOrWhiteSpace(claims.CaseFamilyId))
|
|
await UpdateFamilyRatingAsync(claims, generated, isCorrect);
|
|
return new DynamicAttemptResponse
|
|
{
|
|
Inserted = true,
|
|
IsCorrect = isCorrect,
|
|
CorrectChoiceId = generated.Question.CorrectChoiceId,
|
|
ObjectiveId = claims.ObjectiveId,
|
|
ObjectiveElo = updated.Elo,
|
|
RatingChange = delta
|
|
};
|
|
}
|
|
|
|
private async Task EnsureMasteryAsync(string userId, string objectiveId)
|
|
{
|
|
try
|
|
{
|
|
await _mastery.UpdateOneAsync(
|
|
value => value.UserId == userId && value.ObjectiveId == objectiveId,
|
|
Builders<DynamicObjectiveMastery>.Update
|
|
.SetOnInsert(value => value.UserId, userId)
|
|
.SetOnInsert(value => value.ObjectiveId, objectiveId)
|
|
.SetOnInsert(value => value.Elo, 1000)
|
|
.SetOnInsert(value => value.Attempts, 0)
|
|
.SetOnInsert(value => value.Correct, 0),
|
|
new UpdateOptions { IsUpsert = true });
|
|
}
|
|
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { }
|
|
}
|
|
|
|
private async Task UpdateFamilyRatingAsync(
|
|
DynamicGenerationClaims claims,
|
|
DynamicGeneratedQuestion generated,
|
|
bool isCorrect)
|
|
{
|
|
var family = generated.Blueprint.CaseFamilies.First(value => value.Id == claims.CaseFamilyId);
|
|
try
|
|
{
|
|
await _familyRatings.UpdateOneAsync(
|
|
value => value.BlueprintExternalId == claims.BlueprintExternalId
|
|
&& value.BlueprintVersion == claims.BlueprintVersion
|
|
&& value.CaseFamilyId == claims.CaseFamilyId,
|
|
Builders<DynamicFamilyRating>.Update
|
|
.SetOnInsert(value => value.BlueprintExternalId, claims.BlueprintExternalId)
|
|
.SetOnInsert(value => value.BlueprintVersion, claims.BlueprintVersion)
|
|
.SetOnInsert(value => value.CaseFamilyId, claims.CaseFamilyId)
|
|
.SetOnInsert(value => value.DifficultyTier, family.DifficultyTier)
|
|
.SetOnInsert(value => value.NominalElo, family.NominalElo)
|
|
.SetOnInsert(value => value.Elo, family.NominalElo)
|
|
.SetOnInsert(value => value.Attempts, 0)
|
|
.SetOnInsert(value => value.Correct, 0),
|
|
new UpdateOptions { IsUpsert = true });
|
|
}
|
|
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { }
|
|
var current = await _familyRatings.Find(value =>
|
|
value.BlueprintExternalId == claims.BlueprintExternalId
|
|
&& value.BlueprintVersion == claims.BlueprintVersion
|
|
&& value.CaseFamilyId == claims.CaseFamilyId).FirstAsync();
|
|
var delta = DynamicDifficultyPolicy.FamilyDelta(
|
|
claims.EffectiveAbilityElo > 0 ? claims.EffectiveAbilityElo : 1000,
|
|
claims.FamilyElo > 0 ? claims.FamilyElo : current.Elo,
|
|
isCorrect,
|
|
current.Attempts);
|
|
await _familyRatings.UpdateOneAsync(
|
|
value => value.Id == current.Id,
|
|
Builders<DynamicFamilyRating>.Update
|
|
.Inc(value => value.Elo, delta)
|
|
.Inc(value => value.Attempts, 1)
|
|
.Inc(value => value.Correct, isCorrect ? 1 : 0)
|
|
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
|
await _familyRatings.UpdateOneAsync(
|
|
value => value.Id == current.Id,
|
|
Builders<DynamicFamilyRating>.Update
|
|
.Max(value => value.Elo, DynamicDifficultyPolicy.MinimumElo)
|
|
.Min(value => value.Elo, DynamicDifficultyPolicy.MaximumElo));
|
|
}
|
|
|
|
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<(int Inserted, int Unchanged)> UpsertBlueprintsAsync(IEnumerable<DynamicQuestionBlueprint> blueprints)
|
|
{
|
|
var inserted = 0;
|
|
var unchanged = 0;
|
|
foreach (var blueprint in blueprints)
|
|
{
|
|
var existing = await _blueprints.Find(
|
|
value => value.ExternalId == blueprint.ExternalId && value.Version == blueprint.Version)
|
|
.FirstOrDefaultAsync();
|
|
if (existing is not null)
|
|
{
|
|
blueprint.Id = existing.Id;
|
|
var incomingDocument = blueprint.ToBsonDocument();
|
|
var existingDocument = existing.ToBsonDocument();
|
|
incomingDocument.Remove("_id");
|
|
existingDocument.Remove("_id");
|
|
if (incomingDocument != existingDocument)
|
|
throw new InvalidDataException(
|
|
$"{blueprint.ExternalId} v{blueprint.Version} is immutable; publish changes with a new version");
|
|
unchanged++;
|
|
continue;
|
|
}
|
|
blueprint.Id = ObjectId.GenerateNewId().ToString();
|
|
await _blueprints.InsertOneAsync(blueprint);
|
|
inserted++;
|
|
}
|
|
return (inserted, unchanged);
|
|
}
|
|
}
|