Add server-side ELO ratings

This commit is contained in:
hz
2026-07-11 02:05:43 -05:00
parent bdd32c9dac
commit c716b16c53
21 changed files with 688 additions and 47 deletions
@@ -13,11 +13,13 @@ public class QuestionsController : ControllerBase
{
private readonly QuestionStore _questions;
private readonly QuestionAttemptStore _attempts;
private readonly RatingStore _ratings;
public QuestionsController(QuestionStore questions, QuestionAttemptStore attempts)
public QuestionsController(QuestionStore questions, QuestionAttemptStore attempts, RatingStore ratings)
{
_questions = questions;
_attempts = attempts;
_ratings = ratings;
}
[HttpGet("systems")]
@@ -34,10 +36,11 @@ public class QuestionsController : ControllerBase
{
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
var percentages = await _attempts.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId));
return Ok(questions.Select(q =>
{
percentages.TryGetValue(q.ExternalId, out var questionPercentages);
return QuestionResponse.FromModel(q, questionPercentages);
return QuestionResponse.FromModel(q, questionPercentages, questionElos.GetValueOrDefault(q.ExternalId, 1000));
}).ToList());
}
@@ -50,7 +53,8 @@ public class QuestionsController : ControllerBase
return NotFound();
var percentages = await _attempts.GetPercentagesForQuestionAsync(question.ExternalId);
return Ok(QuestionResponse.FromModel(question, percentages));
var questionElo = await _ratings.GetQuestionEloAsync(question.ExternalId);
return Ok(QuestionResponse.FromModel(question, percentages, questionElo));
}
[HttpPost("attempts/first")]
@@ -75,18 +79,60 @@ public class QuestionsController : ControllerBase
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
await _attempts.RecordFirstAttemptAsync(
var wasFirstAttempt = await _attempts.RecordFirstAttemptAsync(
question.ExternalId,
userId,
req.ChoiceId.Trim(),
req.ChoiceId.Trim() == question.CorrectChoiceId);
var rating = await _ratings.ApplyFirstResultAsync(
userId,
question,
req.ChoiceId.Trim() == question.CorrectChoiceId,
wasFirstAttempt);
return Ok(new FirstQuestionAttemptResponse
{
QuestionExternalId = question.ExternalId,
ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages(
question,
await _attempts.GetPercentagesForQuestionAsync(question.ExternalId))
await _attempts.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating
});
}
[HttpPost("{externalId}/skip")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Skip(string externalId)
{
if (string.IsNullOrWhiteSpace(externalId))
return BadRequest("externalId required");
var question = await _questions.GetByExternalIdAsync(externalId.Trim());
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
return NotFound();
var userId = CurrentUserId();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var wasFirstAttempt = await _attempts.RecordFirstAttemptAsync(
question.ExternalId,
userId,
"",
false,
true);
var rating = await _ratings.ApplyFirstResultAsync(userId, question, false, wasFirstAttempt);
return Ok(new SkipQuestionResponse
{
QuestionExternalId = question.ExternalId,
CorrectChoiceId = question.CorrectChoiceId,
ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages(
question,
await _attempts.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating
});
}
@@ -183,4 +229,10 @@ public class QuestionsController : ControllerBase
return null;
}
private string CurrentUserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class RatingsController : ControllerBase
{
private readonly RatingStore _ratings;
public RatingsController(RatingStore ratings)
{
_ratings = ratings;
}
[HttpGet("me")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Me()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
return Ok(await _ratings.GetProfileAsync(userId));
}
}
+1
View File
@@ -19,6 +19,7 @@ public class Question
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public string Discipline { get; set; } = "";
public string System { get; set; } = "";
public string PerformanceCategory { get; set; } = "";
public int Difficulty { get; set; } = 1;
public List<QuestionLab> Labs { get; set; } = [];
public List<string> Media { get; set; } = [];
@@ -13,5 +13,6 @@ public class QuestionAttempt
public string UserId { get; set; } = "";
public string ChoiceId { get; set; } = "";
public bool IsCorrect { get; set; }
public bool IsSkip { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -22,6 +22,7 @@ public class UpsertQuestionRequest
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public string Discipline { get; set; } = "";
public string System { get; set; } = "";
public string PerformanceCategory { get; set; } = "";
public int Difficulty { get; set; } = 1;
public List<QuestionLab> Labs { get; set; } = [];
public List<string> Media { get; set; } = [];
+63 -1
View File
@@ -13,12 +13,17 @@ public class QuestionResponse
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public string Discipline { get; set; } = "";
public string System { get; set; } = "";
public string PerformanceCategory { get; set; } = "";
public int Difficulty { get; set; }
public int QuestionElo { get; set; } = 1000;
public List<QuestionLab> Labs { get; set; } = [];
public List<string> Media { get; set; } = [];
public List<string> Tags { get; set; } = [];
public static QuestionResponse FromModel(Question question, Dictionary<string, int>? liveChoiceSelectionPercentages = null) => new()
public static QuestionResponse FromModel(
Question question,
Dictionary<string, int>? liveChoiceSelectionPercentages = null,
int questionElo = 1000) => new()
{
Id = question.Id ?? "",
ExternalId = question.ExternalId,
@@ -31,7 +36,9 @@ public class QuestionResponse
ChoiceSelectionPercentages = BuildChoiceSelectionPercentages(question, liveChoiceSelectionPercentages),
Discipline = question.Discipline,
System = question.System,
PerformanceCategory = RatingCategory.Normalize(question.PerformanceCategory, question.System, question.Discipline),
Difficulty = question.Difficulty,
QuestionElo = questionElo,
Labs = question.Labs,
Media = question.Media,
Tags = question.Tags
@@ -52,4 +59,59 @@ public class FirstQuestionAttemptResponse
{
public string QuestionExternalId { get; set; } = "";
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; }
}
public class SkipQuestionResponse
{
public string QuestionExternalId { get; set; } = "";
public string CorrectChoiceId { get; set; } = "";
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; }
}
public static class RatingCategory
{
public const string Uncategorized = "Uncategorized";
public static readonly string[] Categories =
[
"Renal/Urinary & Male/Female Systems & Pregnancy",
"Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety",
"Immune/Blood & Lymphoreticular/Endocrine/Multisystem",
"Biostatistics & Epidemiology/Population Health",
"Musculoskeletal/Skin & Subcutaneous Tissue",
"Cardiovascular System",
"Nervous System & Special Senses",
"Respiratory System",
"Gastrointestinal System",
Uncategorized
];
public static string Normalize(string? explicitCategory, string? system, string? discipline)
{
foreach (var value in new[] { explicitCategory, system, discipline })
{
if (string.IsNullOrWhiteSpace(value))
continue;
var normalized = Categories.FirstOrDefault(c => string.Equals(c, value.Trim(), StringComparison.OrdinalIgnoreCase));
if (normalized is not null)
return normalized;
var lower = value.ToLowerInvariant();
if (lower.Contains("cardio")) return "Cardiovascular System";
if (lower.Contains("resp")) return "Respiratory System";
if (lower.Contains("gastro") || lower.Contains("hepato")) return "Gastrointestinal System";
if (lower.Contains("neuro") || lower.Contains("special sense")) return "Nervous System & Special Senses";
if (lower.Contains("renal") || lower.Contains("urinary") || lower.Contains("pregnan") || lower.Contains("reproductive")) return "Renal/Urinary & Male/Female Systems & Pregnancy";
if (lower.Contains("behavior") || lower.Contains("ethic") || lower.Contains("communication") || lower.Contains("safety")) return "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety";
if (lower.Contains("immune") || lower.Contains("blood") || lower.Contains("endocrine") || lower.Contains("multisystem")) return "Immune/Blood & Lymphoreticular/Endocrine/Multisystem";
if (lower.Contains("biostat") || lower.Contains("epidemi") || lower.Contains("population")) return "Biostatistics & Epidemiology/Population Health";
if (lower.Contains("musculo") || lower.Contains("skin") || lower.Contains("derm")) return "Musculoskeletal/Skin & Subcutaneous Tissue";
}
return Uncategorized;
}
}
+48
View File
@@ -0,0 +1,48 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public class UserRating
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public int OverallElo { get; set; } = 1000;
public Dictionary<string, int> CategoryElos { get; set; } = [];
public int RatedQuestionCount { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public class QuestionRating
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string QuestionExternalId { get; set; } = "";
public int Elo { get; set; } = 1000;
public int AttemptCount { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public class RatingChangeResponse
{
public bool Rated { get; set; }
public string Category { get; set; } = "";
public int UserOverallElo { get; set; }
public int UserOverallDelta { get; set; }
public int UserCategoryElo { get; set; }
public int UserCategoryDelta { get; set; }
public int QuestionElo { get; set; }
public int QuestionEloDelta { get; set; }
}
public class RatingProfileResponse
{
public int OverallElo { get; set; }
public int RatedQuestionCount { get; set; }
public Dictionary<string, int> CategoryElos { get; set; } = [];
}
+1
View File
@@ -10,6 +10,7 @@ builder.Services.AddControllers();
builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<RatingStore>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
@@ -26,7 +26,7 @@ public class QuestionAttemptStore
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
}
public async Task RecordFirstAttemptAsync(string questionExternalId, string userId, string choiceId, bool isCorrect)
public async Task<bool> RecordFirstAttemptAsync(string questionExternalId, string userId, string choiceId, bool isCorrect, bool isSkip = false)
{
var attempt = new QuestionAttempt
{
@@ -34,15 +34,18 @@ public class QuestionAttemptStore
UserId = userId,
ChoiceId = choiceId,
IsCorrect = isCorrect,
IsSkip = isSkip,
CreatedUtc = DateTime.UtcNow
};
try
{
await _col.InsertOneAsync(attempt);
return true;
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
return false;
}
}
@@ -58,7 +61,7 @@ public class QuestionAttemptStore
if (ids.Count == 0)
return [];
var attempts = await _col.Find(a => ids.Contains(a.QuestionExternalId)).ToListAsync();
var attempts = await _col.Find(a => ids.Contains(a.QuestionExternalId) && !a.IsSkip).ToListAsync();
var output = new Dictionary<string, Dictionary<string, int>>();
foreach (var group in attempts.GroupBy(a => a.QuestionExternalId))
@@ -139,6 +139,7 @@ public class QuestionStore
ChoiceSelectionPercentages = req.ChoiceSelectionPercentages,
Discipline = req.Discipline.Trim(),
System = req.System.Trim(),
PerformanceCategory = RatingCategory.Normalize(req.PerformanceCategory, req.System, req.Discipline),
Difficulty = req.Difficulty,
Labs = req.Labs,
Media = req.Media,
+157
View File
@@ -0,0 +1,157 @@
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(IConfiguration cfg)
{
var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
var client = new MongoClient(cs);
var db = client.GetDatabase(dbName);
_users = db.GetCollection<UserRating>("UserRatings");
_questions = db.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<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
};
}
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));
userRating.OverallElo = Math.Max(MinElo, oldOverall + overallDelta);
userRating.CategoryElos[category] = Math.Max(MinElo, oldCategory + categoryDelta);
userRating.RatedQuestionCount++;
userRating.UpdatedUtc = DateTime.UtcNow;
questionRating.Elo = Math.Max(MinElo, oldQuestion + questionDelta);
questionRating.AttemptCount++;
questionRating.UpdatedUtc = DateTime.UtcNow;
await _users.ReplaceOneAsync(r => r.UserId == userRating.UserId, userRating, new ReplaceOptions { IsUpsert = true });
await _questions.ReplaceOneAsync(r => r.QuestionExternalId == questionRating.QuestionExternalId, questionRating, new ReplaceOptions { IsUpsert = true });
return new RatingChangeResponse
{
Rated = true,
Category = category,
UserOverallElo = userRating.OverallElo,
UserOverallDelta = userRating.OverallElo - oldOverall,
UserCategoryElo = userRating.CategoryElos[category],
UserCategoryDelta = userRating.CategoryElos[category] - oldCategory,
QuestionElo = questionRating.Elo,
QuestionEloDelta = questionRating.Elo - oldQuestion
};
}
private async Task<UserRating> GetOrCreateUserRatingAsync(string userId)
{
var rating = await _users.Find(r => r.UserId == userId).FirstOrDefaultAsync();
if (rating is null)
{
rating = new UserRating { UserId = userId, CategoryElos = DefaultCategoryElos() };
await _users.InsertOneAsync(rating);
return rating;
}
var changed = false;
foreach (var category in RatingCategory.Categories)
{
if (!rating.CategoryElos.ContainsKey(category))
{
rating.CategoryElos[category] = BaseElo;
changed = true;
}
}
if (changed)
await _users.ReplaceOneAsync(r => r.UserId == userId, rating);
return rating;
}
private async Task<QuestionRating> GetOrCreateQuestionRatingAsync(string questionExternalId)
{
var rating = await _questions.Find(r => r.QuestionExternalId == questionExternalId).FirstOrDefaultAsync();
if (rating is not null)
return rating;
rating = new QuestionRating { QuestionExternalId = questionExternalId };
await _questions.InsertOneAsync(rating);
return rating;
}
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));
}