Add variable question distractor pools

This commit is contained in:
hz
2026-07-13 10:15:05 -05:00
parent f2c59388eb
commit 93154d5925
24 changed files with 1040 additions and 247 deletions
@@ -52,10 +52,34 @@ public class QuestionsController : ControllerBase
return Ok(questions.Select(q =>
{
percentages.TryGetValue(q.ExternalId, out var questionPercentages);
return QuestionResponse.FromModel(q, questionPercentages, questionElos.GetValueOrDefault(q.ExternalId, 1000));
return QuestionResponse.FromModel(
q,
questionPercentages,
questionElos.GetValueOrDefault(q.ExternalId, 1000),
req.SupportsChoicePools);
}).ToList());
}
[HttpGet("admin/export")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> AdminExport([FromQuery] string? after = null, [FromQuery] int limit = 100)
{
try
{
var page = await _questions.ExportPageAsync(after, limit);
return Ok(new QuestionAdminExportResponse
{
Items = page.Items.Select(question => QuestionResponse.FromModel(question)).ToList(),
NextCursor = page.NextCursor,
HasMore = page.HasMore
});
}
catch (ArgumentException exception)
{
return BadRequest(exception.Message);
}
}
[HttpGet("{id}")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Get(string id)
@@ -84,6 +108,22 @@ public class QuestionsController : ControllerBase
if (!question.Choices.Any(c => c.Id == req.ChoiceId.Trim()))
return BadRequest("choiceId must match a choice id");
var presentedChoiceIds = req.PresentedChoiceIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.ToList();
if (presentedChoiceIds.Count > 0)
{
var validChoiceIds = question.Choices.Select(choice => choice.Id).ToHashSet(StringComparer.Ordinal);
if (presentedChoiceIds.Count != 5 || presentedChoiceIds.Distinct(StringComparer.Ordinal).Count() != 5)
return BadRequest("presentedChoiceIds must contain exactly five unique choice ids");
if (presentedChoiceIds.Any(id => !validChoiceIds.Contains(id)))
return BadRequest("presentedChoiceIds must match question choice ids");
if (!presentedChoiceIds.Contains(req.ChoiceId.Trim(), StringComparer.Ordinal)
|| !presentedChoiceIds.Contains(question.CorrectChoiceId, StringComparer.Ordinal))
return BadRequest("presentedChoiceIds must include the selected and correct choices");
}
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
@@ -95,7 +135,9 @@ public class QuestionsController : ControllerBase
question.ExternalId,
userId,
req.ChoiceId.Trim(),
req.ChoiceId.Trim() == question.CorrectChoiceId);
req.ChoiceId.Trim() == question.CorrectChoiceId,
false,
presentedChoiceIds);
if (record.Inserted)
{
_metrics.AttemptCreated();
@@ -252,7 +294,7 @@ public class QuestionsController : ControllerBase
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Create([FromBody] UpsertQuestionRequest req)
{
var validation = ValidateQuestion(req);
var validation = QuestionValidator.Validate(req);
if (validation is not null)
return BadRequest(validation);
@@ -272,7 +314,7 @@ public class QuestionsController : ControllerBase
foreach (var question in req.Questions)
{
var validation = ValidateQuestion(question);
var validation = QuestionValidator.Validate(question);
if (validation is not null)
return BadRequest($"{question.ExternalId}: {validation}");
}
@@ -285,7 +327,7 @@ public class QuestionsController : ControllerBase
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Update(string id, [FromBody] UpsertQuestionRequest req)
{
var validation = ValidateQuestion(req);
var validation = QuestionValidator.Validate(req);
if (validation is not null)
return BadRequest(validation);
@@ -314,59 +356,6 @@ public class QuestionsController : ControllerBase
return Ok("Deleted");
}
private static string? ValidateQuestion(UpsertQuestionRequest req)
{
if (string.IsNullOrWhiteSpace(req.ExternalId))
return "externalId required";
if (string.IsNullOrWhiteSpace(req.Stem))
return "stem required";
if (string.IsNullOrWhiteSpace(req.LeadIn))
return "leadIn required";
if (req.Choices.Count < 2)
return "at least two choices required";
if (string.IsNullOrWhiteSpace(req.CorrectChoiceId))
return "correctChoiceId required";
if (!req.Choices.Any(c => c.Id == req.CorrectChoiceId))
return "correctChoiceId must match a choice id";
if (string.IsNullOrWhiteSpace(req.Explanation))
return "explanation required";
if (req.ChoiceSelectionPercentages.Any(entry => entry.Value is < 0 or > 100))
return "choiceSelectionPercentages values must be between 0 and 100";
var followupValidation = ValidateWrongAnswerFollowups(req);
if (followupValidation is not null)
return followupValidation;
if (string.IsNullOrWhiteSpace(req.System))
return "system required";
if (string.IsNullOrWhiteSpace(req.Discipline))
return "discipline required";
if (req.Difficulty is < 1 or > 5)
return "difficulty must be between 1 and 5";
return null;
}
private static string? ValidateWrongAnswerFollowups(UpsertQuestionRequest req)
{
var choiceIds = req.Choices.Select(c => c.Id).Where(id => !string.IsNullOrWhiteSpace(id)).ToHashSet();
foreach (var entry in req.WrongAnswerFollowups)
{
if (!choiceIds.Contains(entry.Key))
return "wrongAnswerFollowups keys must match a choice id";
if (entry.Key == req.CorrectChoiceId)
return "wrongAnswerFollowups should only target incorrect choices";
if (string.IsNullOrWhiteSpace(entry.Value.Prompt))
return "wrongAnswerFollowups prompt required";
if (string.IsNullOrWhiteSpace(entry.Value.TeachingPoint))
return "wrongAnswerFollowups teachingPoint required";
if (entry.Value.Choices.Count == 0)
return "wrongAnswerFollowups choices required";
if (!entry.Value.Choices.Any(c => c.Id == entry.Value.CorrectChoiceId))
return "wrongAnswerFollowups correctChoiceId must match a follow-up choice id";
}
return null;
}
private string CurrentUserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)