224 lines
9.5 KiB
C#
224 lines
9.5 KiB
C#
using MongoDB.Driver;
|
|
using QuestionsApi.Models;
|
|
|
|
namespace QuestionsApi.Services;
|
|
|
|
public class RatingStore
|
|
{
|
|
private const int BaseElo = 1000;
|
|
private const int MinElo = 100;
|
|
|
|
private readonly IMongoCollection<UserRating> _users;
|
|
private readonly IMongoCollection<QuestionRating> _questions;
|
|
|
|
public RatingStore(MongoDatabaseProvider mongo)
|
|
{
|
|
_users = mongo.Database.GetCollection<UserRating>("UserRatings");
|
|
_questions = mongo.Database.GetCollection<QuestionRating>("QuestionRatings");
|
|
|
|
_users.Indexes.CreateOne(new CreateIndexModel<UserRating>(
|
|
Builders<UserRating>.IndexKeys.Ascending(r => r.UserId),
|
|
new CreateIndexOptions { Unique = true }));
|
|
_questions.Indexes.CreateOne(new CreateIndexModel<QuestionRating>(
|
|
Builders<QuestionRating>.IndexKeys.Ascending(r => r.QuestionExternalId),
|
|
new CreateIndexOptions { Unique = true }));
|
|
}
|
|
|
|
public async Task<RatingProfileResponse> GetProfileAsync(string userId)
|
|
{
|
|
var rating = await GetOrCreateUserRatingAsync(userId);
|
|
return new RatingProfileResponse
|
|
{
|
|
OverallElo = rating.OverallElo,
|
|
RatedQuestionCount = rating.RatedQuestionCount,
|
|
CategoryElos = rating.CategoryElos
|
|
};
|
|
}
|
|
|
|
public async Task<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
|
|
{
|
|
var rating = await GetOrCreateUserRatingAsync(userId);
|
|
return await EnsureHistoryBaselineAsync(rating, nowUtc);
|
|
}
|
|
|
|
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
|
|
{
|
|
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
|
if (ids.Count == 0)
|
|
return [];
|
|
|
|
var ratings = await _questions.Find(r => ids.Contains(r.QuestionExternalId)).ToListAsync();
|
|
return ids.ToDictionary(
|
|
id => id,
|
|
id => ratings.FirstOrDefault(r => r.QuestionExternalId == id)?.Elo ?? BaseElo);
|
|
}
|
|
|
|
public async Task<int> GetQuestionEloAsync(string questionExternalId)
|
|
{
|
|
var rating = await GetOrCreateQuestionRatingAsync(questionExternalId);
|
|
return rating.Elo;
|
|
}
|
|
|
|
public async Task<RatingChangeResponse> ApplyFirstResultAsync(string userId, Question question, bool isCorrect, bool shouldRate)
|
|
{
|
|
var userRating = await GetOrCreateUserRatingAsync(userId);
|
|
var questionRating = await GetOrCreateQuestionRatingAsync(question.ExternalId);
|
|
var category = RatingCategory.Normalize(question.PerformanceCategory, question.System, question.Discipline);
|
|
|
|
if (!shouldRate)
|
|
{
|
|
return new RatingChangeResponse
|
|
{
|
|
Rated = false,
|
|
Category = category,
|
|
UserOverallElo = userRating.OverallElo,
|
|
UserCategoryElo = userRating.CategoryElos.GetValueOrDefault(category, BaseElo),
|
|
QuestionElo = questionRating.Elo
|
|
};
|
|
}
|
|
|
|
userRating = await EnsureHistoryBaselineAsync(userRating, DateTime.UtcNow);
|
|
var actual = isCorrect ? 1.0 : 0.0;
|
|
var userK = userRating.RatedQuestionCount < 20 ? 40 : 24;
|
|
var questionK = questionRating.AttemptCount < 20 ? 32 : 24;
|
|
|
|
var oldOverall = userRating.OverallElo;
|
|
var oldCategory = userRating.CategoryElos.GetValueOrDefault(category, BaseElo);
|
|
var oldQuestion = questionRating.Elo;
|
|
|
|
var overallExpected = ExpectedScore(oldOverall, oldQuestion);
|
|
var categoryExpected = ExpectedScore(oldCategory, oldQuestion);
|
|
var overallDelta = (int)Math.Round(userK * (actual - overallExpected));
|
|
var categoryDelta = (int)Math.Round(userK * (actual - categoryExpected));
|
|
var questionDelta = (int)Math.Round(questionK * (overallExpected - actual));
|
|
|
|
var now = DateTime.UtcNow;
|
|
var categoryPath = $"CategoryElos.{category}";
|
|
var userUpdate = Builders<UserRating>.Update
|
|
.Inc(rating => rating.OverallElo, overallDelta)
|
|
.Inc(categoryPath, categoryDelta)
|
|
.Inc(rating => rating.RatedQuestionCount, 1)
|
|
.Set(rating => rating.UpdatedUtc, now);
|
|
await _users.UpdateOneAsync(rating => rating.UserId == userId, userUpdate);
|
|
await _users.UpdateOneAsync(
|
|
rating => rating.UserId == userId,
|
|
Builders<UserRating>.Update
|
|
.Max(rating => rating.OverallElo, MinElo)
|
|
.Max(categoryPath, MinElo));
|
|
|
|
var questionUpdate = Builders<QuestionRating>.Update
|
|
.Inc(rating => rating.Elo, questionDelta)
|
|
.Inc(rating => rating.AttemptCount, 1)
|
|
.Set(rating => rating.UpdatedUtc, now);
|
|
await _questions.UpdateOneAsync(rating => rating.QuestionExternalId == question.ExternalId, questionUpdate);
|
|
await _questions.UpdateOneAsync(
|
|
rating => rating.QuestionExternalId == question.ExternalId,
|
|
Builders<QuestionRating>.Update.Max(rating => rating.Elo, MinElo));
|
|
|
|
var updatedUser = await _users.Find(rating => rating.UserId == userId).FirstAsync();
|
|
var updatedQuestion = await _questions.Find(rating => rating.QuestionExternalId == question.ExternalId).FirstAsync();
|
|
|
|
return new RatingChangeResponse
|
|
{
|
|
Rated = true,
|
|
Category = category,
|
|
UserOverallElo = updatedUser.OverallElo,
|
|
UserOverallDelta = overallDelta,
|
|
UserCategoryElo = updatedUser.CategoryElos.GetValueOrDefault(category, BaseElo),
|
|
UserCategoryDelta = categoryDelta,
|
|
QuestionElo = updatedQuestion.Elo,
|
|
QuestionEloDelta = questionDelta
|
|
};
|
|
}
|
|
|
|
private async Task<UserRating> GetOrCreateUserRatingAsync(string userId)
|
|
{
|
|
var create = Builders<UserRating>.Update
|
|
.SetOnInsert(rating => rating.UserId, userId)
|
|
.SetOnInsert(rating => rating.OverallElo, BaseElo)
|
|
.SetOnInsert(rating => rating.RatedQuestionCount, 0)
|
|
.SetOnInsert(rating => rating.CategoryElos, DefaultCategoryElos())
|
|
.SetOnInsert(rating => rating.UpdatedUtc, DateTime.UtcNow);
|
|
UserRating rating;
|
|
try
|
|
{
|
|
rating = await _users.FindOneAndUpdateAsync(
|
|
item => item.UserId == userId,
|
|
create,
|
|
new FindOneAndUpdateOptions<UserRating>
|
|
{
|
|
IsUpsert = true,
|
|
ReturnDocument = ReturnDocument.After
|
|
});
|
|
}
|
|
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
|
{
|
|
rating = await _users.Find(item => item.UserId == userId).FirstAsync();
|
|
}
|
|
|
|
var missingCategories = RatingCategory.Categories
|
|
.Where(category => !rating.CategoryElos.ContainsKey(category))
|
|
.ToList();
|
|
if (missingCategories.Count > 0)
|
|
{
|
|
var updates = missingCategories
|
|
.Select(category => Builders<UserRating>.Update.Set($"CategoryElos.{category}", BaseElo))
|
|
.ToList();
|
|
if (updates.Count > 0)
|
|
await _users.UpdateOneAsync(item => item.UserId == userId, Builders<UserRating>.Update.Combine(updates));
|
|
rating = await _users.Find(item => item.UserId == userId).FirstAsync();
|
|
}
|
|
return rating;
|
|
}
|
|
|
|
private async Task<UserRating> EnsureHistoryBaselineAsync(UserRating rating, DateTime nowUtc)
|
|
{
|
|
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
|
|
return rating;
|
|
|
|
var result = await _users.UpdateOneAsync(
|
|
value => value.UserId == rating.UserId && value.HistoryStartedUtc == null,
|
|
Builders<UserRating>.Update
|
|
.Set(value => value.HistoryBaselineElo, rating.OverallElo)
|
|
.Set(value => value.HistoryStartedUtc, nowUtc));
|
|
if (result.ModifiedCount > 0)
|
|
{
|
|
rating.HistoryBaselineElo = rating.OverallElo;
|
|
rating.HistoryStartedUtc = nowUtc;
|
|
return rating;
|
|
}
|
|
|
|
return await _users.Find(value => value.UserId == rating.UserId).FirstAsync();
|
|
}
|
|
|
|
private async Task<QuestionRating> GetOrCreateQuestionRatingAsync(string questionExternalId)
|
|
{
|
|
var create = Builders<QuestionRating>.Update
|
|
.SetOnInsert(rating => rating.QuestionExternalId, questionExternalId)
|
|
.SetOnInsert(rating => rating.Elo, BaseElo)
|
|
.SetOnInsert(rating => rating.AttemptCount, 0)
|
|
.SetOnInsert(rating => rating.UpdatedUtc, DateTime.UtcNow);
|
|
try
|
|
{
|
|
return await _questions.FindOneAndUpdateAsync(
|
|
rating => rating.QuestionExternalId == questionExternalId,
|
|
create,
|
|
new FindOneAndUpdateOptions<QuestionRating>
|
|
{
|
|
IsUpsert = true,
|
|
ReturnDocument = ReturnDocument.After
|
|
});
|
|
}
|
|
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
|
{
|
|
return await _questions.Find(rating => rating.QuestionExternalId == questionExternalId).FirstAsync();
|
|
}
|
|
}
|
|
|
|
private static Dictionary<string, int> DefaultCategoryElos() =>
|
|
RatingCategory.Categories.ToDictionary(category => category, _ => BaseElo);
|
|
|
|
private static double ExpectedScore(int playerElo, int questionElo) =>
|
|
1.0 / (1.0 + Math.Pow(10.0, (questionElo - playerElo) / 400.0));
|
|
}
|