Add question bank mastery paths and gap questions

This commit is contained in:
hz
2026-07-31 23:46:46 -05:00
parent 012d0cf196
commit 8c439c6294
28 changed files with 1409 additions and 121 deletions
+66 -28
View File
@@ -46,15 +46,20 @@ public sealed class ReviewQueueStore
var update = Builders<ReviewQueueItem>.Update
.SetOnInsert(value => value.UserId, attempt.UserId)
.SetOnInsert(value => value.QuestionExternalId, attempt.QuestionExternalId)
.SetOnInsert(value => value.SourceAttemptId, attempt.Id ?? "")
.SetOnInsert(value => value.State, ReviewQueueStates.Pending)
.SetOnInsert(value => value.Cycle, 1)
.SetOnInsert(value => value.CreatedUtc, attempt.CreatedUtc)
.SetOnInsert(value => value.UpdatedUtc, now);
.Set(value => value.SourceAttemptId, attempt.Id ?? "")
.Set(value => value.State, ReviewQueueStates.Pending)
.Set(value => value.MasteredUtc, null)
.Set(value => value.UpdatedUtc, now);
var filter = Builders<ReviewQueueItem>.Filter.Eq(value => value.UserId, attempt.UserId)
& Builders<ReviewQueueItem>.Filter.Eq(value => value.QuestionExternalId, attempt.QuestionExternalId)
& (Builders<ReviewQueueItem>.Filter.Ne(value => value.State, ReviewQueueStates.Mastered)
| Builders<ReviewQueueItem>.Filter.Ne(value => value.SourceAttemptId, attempt.Id ?? ""));
try
{
await _items.UpdateOneAsync(
value => value.UserId == attempt.UserId && value.QuestionExternalId == attempt.QuestionExternalId,
filter,
update,
new UpdateOptions { IsUpsert = true });
}
@@ -66,26 +71,36 @@ public sealed class ReviewQueueStore
public async Task EnsureUserQueueAsync(string userId)
{
var checkpoint = await _backfills.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var scanThroughUtc = DateTime.UtcNow;
var filter = Builders<QuestionAttempt>.Filter.Where(value => value.UserId == userId && !value.IsCorrect && !value.IsSkip);
if (checkpoint is not null)
filter &= Builders<QuestionAttempt>.Filter.Gte(value => value.CreatedUtc, checkpoint.CompletedUtc);
filter &= Builders<QuestionAttempt>.Filter.Lte(value => value.CreatedUtc, scanThroughUtc);
var misses = await _firstAttempts.Find(filter).ToListAsync();
var allMisses = await _firstAttempts.Find(filter).ToListAsync();
var existingItems = (await _items.Find(value => value.UserId == userId).ToListAsync())
.ToDictionary(value => value.QuestionExternalId, StringComparer.Ordinal);
var misses = allMisses.GroupBy(value => value.QuestionExternalId, StringComparer.Ordinal)
.Select(group => group.OrderByDescending(value => value.CreatedUtc).First())
.Where(attempt => !existingItems.TryGetValue(attempt.QuestionExternalId, out var item)
|| (item.State == ReviewQueueStates.Mastered
&& item.SourceAttemptId != (attempt.Id ?? "")
&& (!item.MasteredUtc.HasValue || attempt.CreatedUtc > item.MasteredUtc.Value)))
.ToList();
if (misses.Count > 0)
{
var now = DateTime.UtcNow;
var writes = misses.Select(attempt => new UpdateOneModel<ReviewQueueItem>(
Builders<ReviewQueueItem>.Filter.Where(value => value.UserId == userId && value.QuestionExternalId == attempt.QuestionExternalId),
Builders<ReviewQueueItem>.Filter.Eq(value => value.UserId, userId)
& Builders<ReviewQueueItem>.Filter.Eq(value => value.QuestionExternalId, attempt.QuestionExternalId)
& (Builders<ReviewQueueItem>.Filter.Ne(value => value.State, ReviewQueueStates.Mastered)
| Builders<ReviewQueueItem>.Filter.Ne(value => value.SourceAttemptId, attempt.Id ?? "")),
Builders<ReviewQueueItem>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.QuestionExternalId, attempt.QuestionExternalId)
.SetOnInsert(value => value.SourceAttemptId, attempt.Id ?? "")
.SetOnInsert(value => value.State, ReviewQueueStates.Pending)
.SetOnInsert(value => value.Cycle, 1)
.SetOnInsert(value => value.CreatedUtc, attempt.CreatedUtc)
.SetOnInsert(value => value.UpdatedUtc, now))
.Set(value => value.SourceAttemptId, attempt.Id ?? "")
.Set(value => value.State, ReviewQueueStates.Pending)
.Set(value => value.MasteredUtc, null)
.Set(value => value.UpdatedUtc, now))
{ IsUpsert = true }).ToList();
try
{
@@ -115,27 +130,50 @@ public sealed class ReviewQueueStore
public Task<long> CountPendingAsync(string userId) =>
_items.CountDocumentsAsync(value => value.UserId == userId && value.State == ReviewQueueStates.Pending);
public async Task<ReviewQueueItem?> GetRandomPendingAsync(string userId, IEnumerable<string> excludedIds)
public async Task<long> CountPendingAsync(string userId, string? step3System)
{
if (string.IsNullOrWhiteSpace(step3System)) return await CountPendingAsync(userId);
var requested = Step3SystemCatalog.Normalize(step3System)
?? throw new ArgumentException("step3System must be an official Step 3 system");
var pending = await _items.Find(value => value.UserId == userId && value.State == ReviewQueueStates.Pending).ToListAsync();
var count = 0L;
foreach (var item in pending)
{
var question = await _questions.GetByExternalIdAsync(item.QuestionExternalId);
if (question is not null && Step3SystemCatalog.Normalize(question) == requested) count++;
}
return count;
}
public Task<List<ReviewQueueItem>> GetUserItemsAsync(string userId) =>
_items.Find(value => value.UserId == userId).ToListAsync();
public Task<List<QuestionReviewAttempt>> GetUserReviewsAsync(string userId) =>
_reviews.Find(value => value.UserId == userId).ToListAsync();
public async Task<ReviewQueueItem?> GetRandomPendingAsync(
string userId, IEnumerable<string> excludedIds, string? step3System = null)
{
var excluded = excludedIds.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToList();
for (var pass = 0; pass < 5; pass++)
var requestedSystem = string.IsNullOrWhiteSpace(step3System) ? null : Step3SystemCatalog.Normalize(step3System);
if (!string.IsNullOrWhiteSpace(step3System) && requestedSystem is null)
throw new ArgumentException("step3System must be an official Step 3 system");
var filter = Builders<ReviewQueueItem>.Filter.Eq(value => value.UserId, userId)
& Builders<ReviewQueueItem>.Filter.Eq(value => value.State, ReviewQueueStates.Pending);
if (excluded.Count > 0) filter &= Builders<ReviewQueueItem>.Filter.Nin(value => value.QuestionExternalId, excluded);
var candidates = await _items.Find(filter).ToListAsync();
foreach (var candidate in candidates.OrderBy(_ => Random.Shared.Next()))
{
var filter = Builders<ReviewQueueItem>.Filter.Eq(value => value.UserId, userId)
& Builders<ReviewQueueItem>.Filter.Eq(value => value.State, ReviewQueueStates.Pending);
if (excluded.Count > 0)
filter &= Builders<ReviewQueueItem>.Filter.Nin(value => value.QuestionExternalId, excluded);
var candidates = await _items.Aggregate().Match(filter).Sample(20).ToListAsync();
if (candidates.Count == 0) return null;
foreach (var candidate in candidates)
var question = await _questions.GetByExternalIdAsync(candidate.QuestionExternalId);
if (question is { IsPublished: true, FlaggedForReviewUtc: null })
{
var question = await _questions.GetByExternalIdAsync(candidate.QuestionExternalId);
if (question is { IsPublished: true, FlaggedForReviewUtc: null }) return candidate;
await _items.UpdateOneAsync(
value => value.Id == candidate.Id && value.State == ReviewQueueStates.Pending,
Builders<ReviewQueueItem>.Update
.Set(value => value.State, ReviewQueueStates.Unavailable)
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
if (requestedSystem is null || Step3SystemCatalog.Normalize(question) == requestedSystem) return candidate;
continue;
}
await _items.UpdateOneAsync(
value => value.Id == candidate.Id && value.State == ReviewQueueStates.Pending,
Builders<ReviewQueueItem>.Update.Set(value => value.State, ReviewQueueStates.Unavailable)
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
}
return null;
}