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
+16 -4
View File
@@ -7,7 +7,8 @@ public sealed class QuestionAttemptCounter
public string QuestionExternalId { get; set; } = "";
public int Bucket { get; set; }
public string ChoiceId { get; set; } = "";
public long Count { get; set; }
public long SelectionCount { get; set; }
public long PresentationCount { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -15,14 +16,16 @@ public sealed class QuestionAttemptCounterTotal
{
[BsonId]
public QuestionChoiceCounterKey Key { get; set; } = new();
public long Count { get; set; }
public long SelectionCount { get; set; }
public long PresentationCount { get; set; }
}
public sealed class QuestionAttemptCounterAggregate
{
[BsonId]
public QuestionChoiceBucketKey Key { get; set; } = new();
public long Count { get; set; }
public long SelectionCount { get; set; }
public long PresentationCount { get; set; }
}
public sealed class QuestionChoiceBucketKey
@@ -41,9 +44,18 @@ public sealed class QuestionChoiceCounterKey
public sealed class AnswerStatisticsState
{
[BsonId]
public string Id { get; set; } = "answer-statistics-v1";
public string Id { get; set; } = "answer-statistics-v2";
public bool Completed { get; set; }
public string LeaseOwner { get; set; } = "";
public DateTime LeaseUntilUtc { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
public DateTime CutoverUtc { get; set; }
}
public sealed class QuestionPresentationBaseline
{
[BsonId]
public string QuestionExternalId { get; set; } = "";
public List<string> ChoiceIds { get; set; } = [];
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,8 @@
namespace QuestionsApi.Models;
public sealed class QuestionAdminExportResponse
{
public List<QuestionResponse> Items { get; set; } = [];
public string? NextCursor { get; set; }
public bool HasMore { get; set; }
}
@@ -12,6 +12,8 @@ public class QuestionAttempt
public string QuestionExternalId { get; set; } = "";
public string UserId { get; set; } = "";
public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = [];
public bool TracksPresentation { get; set; }
public bool IsCorrect { get; set; }
public bool IsSkip { get; set; }
public int CounterBucket { get; set; }
@@ -8,6 +8,7 @@ public class QuestionSearchRequest
public int? DifficultyMin { get; set; }
public int? DifficultyMax { get; set; }
public int Limit { get; set; } = 40;
public bool SupportsChoicePools { get; set; }
}
public class UpsertQuestionRequest
@@ -35,4 +36,5 @@ public class FirstQuestionAttemptRequest
{
public string QuestionExternalId { get; set; } = "";
public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = [];
}
+50 -20
View File
@@ -12,6 +12,7 @@ public class QuestionResponse
public Dictionary<string, string> ChoiceExplanations { get; set; } = [];
public Dictionary<string, WrongAnswerFollowup> WrongAnswerFollowups { get; set; } = [];
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public string ChoiceSelectionRateBasis { get; set; } = "presented";
public string Discipline { get; set; } = "";
public string System { get; set; } = "";
public string PerformanceCategory { get; set; } = "";
@@ -24,27 +25,56 @@ public class QuestionResponse
public static QuestionResponse FromModel(
Question question,
Dictionary<string, int>? liveChoiceSelectionPercentages = null,
int questionElo = 1000) => new()
int questionElo = 1000,
bool includeChoicePool = true)
{
Id = question.Id ?? "",
ExternalId = question.ExternalId,
Stem = question.Stem,
LeadIn = question.LeadIn,
Choices = question.Choices,
CorrectChoiceId = question.CorrectChoiceId,
Explanation = question.Explanation,
ChoiceExplanations = question.ChoiceExplanations,
WrongAnswerFollowups = question.WrongAnswerFollowups,
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
};
var choices = includeChoicePool ? question.Choices : SelectLegacyChoices(question);
var choiceIds = choices.Select(choice => choice.Id).ToHashSet(StringComparer.Ordinal);
var percentages = BuildChoiceSelectionPercentages(question, liveChoiceSelectionPercentages);
return new QuestionResponse
{
Id = question.Id ?? "",
ExternalId = question.ExternalId,
Stem = question.Stem,
LeadIn = question.LeadIn,
Choices = choices,
CorrectChoiceId = question.CorrectChoiceId,
Explanation = question.Explanation,
ChoiceExplanations = question.ChoiceExplanations
.Where(entry => choiceIds.Contains(entry.Key))
.ToDictionary(),
WrongAnswerFollowups = question.WrongAnswerFollowups
.Where(entry => choiceIds.Contains(entry.Key))
.ToDictionary(),
ChoiceSelectionPercentages = percentages
.Where(entry => choiceIds.Contains(entry.Key))
.ToDictionary(),
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
};
}
private static List<QuestionChoice> SelectLegacyChoices(Question question)
{
if (question.Choices.Count <= 5)
return question.Choices;
var correct = question.Choices.First(choice => choice.Id == question.CorrectChoiceId);
var distractors = question.Choices
.Where(choice => choice.Id != question.CorrectChoiceId)
.OrderBy(_ => Random.Shared.Next())
.Take(4)
.Append(correct)
.OrderBy(_ => Random.Shared.Next())
.ToList();
return distractors;
}
public static Dictionary<string, int> BuildChoiceSelectionPercentages(Question question, Dictionary<string, int>? liveChoiceSelectionPercentages)
{