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));
}
}