Implement persistent review queue
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class ReviewQueueTests
|
||||
{
|
||||
[Fact]
|
||||
public void SubmittedWrongFirstAttemptEntersQueue()
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
UserId = "user-1",
|
||||
QuestionExternalId = "question-1",
|
||||
ChoiceId = "B",
|
||||
IsCorrect = false,
|
||||
IsSkip = false
|
||||
};
|
||||
|
||||
Assert.True(ReviewQueueStore.ShouldQueue(attempt));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
public void CorrectAndSkippedFirstAttemptsDoNotEnterQueue(bool isCorrect, bool isSkip)
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
UserId = "user-1",
|
||||
QuestionExternalId = "question-1",
|
||||
IsCorrect = isCorrect,
|
||||
IsSkip = isSkip
|
||||
};
|
||||
|
||||
Assert.False(ReviewQueueStore.ShouldQueue(attempt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueueRequiresUserAndQuestionIdentity()
|
||||
{
|
||||
Assert.False(ReviewQueueStore.ShouldQueue(new QuestionAttempt { UserId = "", QuestionExternalId = "question-1" }));
|
||||
Assert.False(ReviewQueueStore.ShouldQueue(new QuestionAttempt { UserId = "user-1", QuestionExternalId = "" }));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public class QuestionsController : ControllerBase
|
||||
private readonly AnswerStatisticsStore _statistics;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
private readonly QuestionBookmarkStore _bookmarks;
|
||||
private readonly ReviewQueueStore _reviewQueue;
|
||||
|
||||
public QuestionsController(
|
||||
QuestionStore questions,
|
||||
@@ -26,7 +27,8 @@ public class QuestionsController : ControllerBase
|
||||
QuestionMediaStore media,
|
||||
AnswerStatisticsStore statistics,
|
||||
ServiceMetrics metrics,
|
||||
QuestionBookmarkStore bookmarks)
|
||||
QuestionBookmarkStore bookmarks,
|
||||
ReviewQueueStore reviewQueue)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
@@ -35,6 +37,7 @@ public class QuestionsController : ControllerBase
|
||||
_statistics = statistics;
|
||||
_metrics = metrics;
|
||||
_bookmarks = bookmarks;
|
||||
_reviewQueue = reviewQueue;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -168,6 +171,7 @@ public class QuestionsController : ControllerBase
|
||||
{
|
||||
_metrics.AttemptDuplicate();
|
||||
}
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
|
||||
var ratingOwner = Guid.NewGuid().ToString("N");
|
||||
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/review-queue")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class ReviewQueueController(
|
||||
ReviewQueueStore queue,
|
||||
QuestionStore questions,
|
||||
AnswerStatisticsStore statistics,
|
||||
RatingStore ratings,
|
||||
QuestionBookmarkStore bookmarks) : ControllerBase
|
||||
{
|
||||
[HttpPost("next")]
|
||||
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
|
||||
{
|
||||
var userId = UserId();
|
||||
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||
await queue.EnsureUserQueueAsync(userId);
|
||||
var item = await queue.GetRandomPendingAsync(userId, req.ExcludeExternalIds);
|
||||
if (item is null)
|
||||
return Ok(new ReviewQueueNextResponse { PendingCount = checked((int)await queue.CountPendingAsync(userId)) });
|
||||
|
||||
var question = await questions.GetByExternalIdAsync(item.QuestionExternalId);
|
||||
if (question is null || !question.IsPublished) return NotFound();
|
||||
var percentages = await statistics.GetPercentagesForQuestionAsync(question.ExternalId);
|
||||
var response = QuestionResponse.FromModel(
|
||||
question,
|
||||
percentages,
|
||||
await ratings.GetQuestionEloAsync(question.ExternalId),
|
||||
req.SupportsChoicePools);
|
||||
response.IsBookmarked = await bookmarks.IsBookmarkedAsync(userId, question.ExternalId);
|
||||
return Ok(new ReviewQueueNextResponse
|
||||
{
|
||||
Question = response,
|
||||
Cycle = item.Cycle,
|
||||
PendingCount = checked((int)await queue.CountPendingAsync(userId))
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("attempts")]
|
||||
public async Task<IActionResult> RecordAttempt([FromBody] ReviewQueueAttemptRequest req)
|
||||
{
|
||||
var userId = UserId();
|
||||
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||
if (!Guid.TryParse(req.ClientAttemptId, out _)) return BadRequest("clientAttemptId must be a UUID");
|
||||
if (string.IsNullOrWhiteSpace(req.QuestionExternalId)) return BadRequest("questionExternalId required");
|
||||
if (req.Cycle < 1) return BadRequest("cycle must be positive");
|
||||
if (string.IsNullOrWhiteSpace(req.ChoiceId)) return BadRequest("choiceId required");
|
||||
|
||||
var question = await questions.GetByExternalIdAsync(req.QuestionExternalId.Trim());
|
||||
if (question is null || !question.IsPublished) return NotFound();
|
||||
var choiceId = req.ChoiceId.Trim();
|
||||
if (!question.Choices.Any(choice => choice.Id == choiceId)) return BadRequest("choiceId must match a choice id");
|
||||
var presented = req.PresentedChoiceIds.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value.Trim()).ToList();
|
||||
if (presented.Count > 0)
|
||||
{
|
||||
var validIds = question.Choices.Select(choice => choice.Id).ToHashSet(StringComparer.Ordinal);
|
||||
if (presented.Count != 5 || presented.Distinct(StringComparer.Ordinal).Count() != 5)
|
||||
return BadRequest("presentedChoiceIds must contain exactly five unique choice ids");
|
||||
if (presented.Any(value => !validIds.Contains(value))
|
||||
|| !presented.Contains(choiceId, StringComparer.Ordinal)
|
||||
|| !presented.Contains(question.CorrectChoiceId, StringComparer.Ordinal))
|
||||
return BadRequest("presentedChoiceIds must include valid selected and correct choices");
|
||||
}
|
||||
|
||||
var result = await queue.RecordReviewAsync(
|
||||
userId,
|
||||
req.ClientAttemptId,
|
||||
question.ExternalId,
|
||||
req.Cycle,
|
||||
choiceId,
|
||||
presented,
|
||||
choiceId == question.CorrectChoiceId,
|
||||
req.DurationSeconds);
|
||||
if (result is null) return Conflict("review cycle is no longer active");
|
||||
return Ok(new ReviewQueueAttemptResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
IsCorrect = result.Attempt.IsCorrect,
|
||||
QueueState = result.Item?.State ?? ReviewQueueStates.Pending,
|
||||
PendingCount = checked((int)await queue.CountPendingAsync(userId))
|
||||
});
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? "";
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class ReviewQueueStates
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Mastered = "mastered";
|
||||
public const string Unavailable = "unavailable";
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueItem
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
|
||||
public string UserId { get; set; } = "";
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string SourceAttemptId { get; set; } = "";
|
||||
public string State { get; set; } = ReviewQueueStates.Pending;
|
||||
public int Cycle { get; set; } = 1;
|
||||
public int ReviewCount { get; set; }
|
||||
public int IncorrectReviewCount { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? LastReviewedUtc { get; set; }
|
||||
public DateTime? MasteredUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class QuestionReviewAttempt
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
public string UserId { get; set; } = "";
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public int Cycle { get; set; }
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public bool IsCorrect { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueBackfillState
|
||||
{
|
||||
[BsonId]
|
||||
public string UserId { get; set; } = "";
|
||||
public DateTime CompletedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueNextRequest
|
||||
{
|
||||
public List<string> ExcludeExternalIds { get; set; } = [];
|
||||
public bool SupportsChoicePools { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueNextResponse
|
||||
{
|
||||
public QuestionResponse? Question { get; set; }
|
||||
public int Cycle { get; set; }
|
||||
public int PendingCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueAttemptRequest
|
||||
{
|
||||
public string ClientAttemptId { get; set; } = "";
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public int Cycle { get; set; }
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public int DurationSeconds { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueAttemptResponse
|
||||
{
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public bool IsCorrect { get; set; }
|
||||
public string QueueState { get; set; } = ReviewQueueStates.Pending;
|
||||
public int PendingCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueRecordResult
|
||||
{
|
||||
public QuestionReviewAttempt Attempt { get; set; } = new();
|
||||
public ReviewQueueItem? Item { get; set; }
|
||||
}
|
||||
@@ -23,6 +23,7 @@ builder.Services.AddSingleton<CcsCaseReportStore>();
|
||||
builder.Services.AddSingleton<AppFeedbackStore>();
|
||||
builder.Services.AddSingleton<UserNotepadStore>();
|
||||
builder.Services.AddSingleton<QuestionBookmarkStore>();
|
||||
builder.Services.AddSingleton<ReviewQueueStore>();
|
||||
builder.Services.AddSingleton<DashboardStore>();
|
||||
builder.Services.AddSingleton<ServiceMetrics>();
|
||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||
|
||||
@@ -22,6 +22,8 @@ STEP-style question bank service for study games and clients.
|
||||
- `GET /api/app-feedback` List and filter app feedback, newest first (SUPER only).
|
||||
- `GET /api/app-feedback/{id}` Get one app feedback report (SUPER only).
|
||||
- `PATCH /api/app-feedback/{id}/status` Resolve or dismiss app feedback (SUPER only).
|
||||
- `POST /api/review-queue/next` Get one random pending review question, excluding IDs already shown in the current pass.
|
||||
- `POST /api/review-queue/attempts` Idempotently record the first answer for a review cycle.
|
||||
|
||||
Player reads require `USER` or `SUPER` JWT auth. Authoring endpoints require `SUPER`.
|
||||
|
||||
@@ -35,6 +37,12 @@ materialized counter buckets and cached for up to five minutes. These rates are
|
||||
independent and are not expected to sum to 100% when users see different
|
||||
distractor subsets.
|
||||
|
||||
Submitted incorrect first attempts are materialized in `ReviewQueueItems`.
|
||||
Skips are excluded. Review attempts are stored separately, do not affect Elo
|
||||
or first-attempt statistics, and clear an item only when the first answer in a
|
||||
review cycle is correct. Per-user incremental backfill checkpoints populate
|
||||
the queue from historical first attempts without requiring a manual migration.
|
||||
|
||||
A background service snapshots the original choice IDs, backfills historical
|
||||
attempts into v2 exposure counters, and reconciles recently active questions
|
||||
every five minutes. The metric
|
||||
|
||||
@@ -14,8 +14,9 @@ public sealed class DashboardStore
|
||||
private readonly IMongoCollection<QuestionBookmark> _bookmarks;
|
||||
private readonly IMongoCollection<UserNotepad> _notes;
|
||||
private readonly QuestionStore _questions;
|
||||
private readonly ReviewQueueStore _reviewQueue;
|
||||
|
||||
public DashboardStore(MongoDatabaseProvider mongo, QuestionStore questions)
|
||||
public DashboardStore(MongoDatabaseProvider mongo, QuestionStore questions, ReviewQueueStore reviewQueue)
|
||||
{
|
||||
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
|
||||
@@ -26,6 +27,7 @@ public sealed class DashboardStore
|
||||
_bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks");
|
||||
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
|
||||
_questions = questions;
|
||||
_reviewQueue = reviewQueue;
|
||||
}
|
||||
|
||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||
@@ -40,8 +42,9 @@ public sealed class DashboardStore
|
||||
var simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
|
||||
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
|
||||
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask,
|
||||
simulationsTask, bookmarkCountTask, notepadTask);
|
||||
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask);
|
||||
|
||||
var attempts = attemptsTask.Result;
|
||||
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
|
||||
@@ -50,6 +53,8 @@ public sealed class DashboardStore
|
||||
var ccsAttempts = ccsAttemptsTask.Result;
|
||||
var simulations = simulationsTask.Result;
|
||||
var completedCcs = ccsAttempts.Where(value => value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue).ToList();
|
||||
await _reviewQueue.EnsureUserQueueAsync(userId);
|
||||
var pendingReviewCount = await _reviewQueue.CountPendingAsync(userId);
|
||||
|
||||
var response = new DashboardResponse
|
||||
{
|
||||
@@ -62,7 +67,7 @@ public sealed class DashboardStore
|
||||
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
|
||||
BookmarkCount = checked((int)bookmarkCountTask.Result),
|
||||
NoteCount = CountNotes(notepadTask.Result?.Content),
|
||||
MissedQuestionCount = attempts.Where(value => !value.IsCorrect).Select(value => value.QuestionExternalId).Distinct().Count()
|
||||
MissedQuestionCount = checked((int)pendingReviewCount)
|
||||
};
|
||||
|
||||
response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset =>
|
||||
@@ -105,6 +110,7 @@ public sealed class DashboardStore
|
||||
StudyTimeSeconds = dailyQuestions.Sum(value => value.DurationSeconds)
|
||||
+ dailyCcs.Sum(CcsDurationSeconds)
|
||||
+ dailySimulations.Sum(value => value.Responses.Sum(item => Math.Max(0, item.ElapsedSeconds)))
|
||||
+ reviewAttemptsTask.Result.Where(value => value.CreatedUtc.Date == date).Sum(value => value.DurationSeconds)
|
||||
};
|
||||
}).ToList();
|
||||
response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds);
|
||||
|
||||
@@ -16,6 +16,7 @@ public class ExamSimulationStore
|
||||
private readonly AnswerStatisticsStore _statistics;
|
||||
private readonly RatingStore _ratings;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
private readonly ReviewQueueStore _reviewQueue;
|
||||
|
||||
public ExamSimulationStore(
|
||||
MongoDatabaseProvider mongo,
|
||||
@@ -23,7 +24,8 @@ public class ExamSimulationStore
|
||||
QuestionAttemptStore attempts,
|
||||
AnswerStatisticsStore statistics,
|
||||
RatingStore ratings,
|
||||
ServiceMetrics metrics)
|
||||
ServiceMetrics metrics,
|
||||
ReviewQueueStore reviewQueue)
|
||||
{
|
||||
_simulations = mongo.Database.GetCollection<ExamSimulation>("ExamSimulations");
|
||||
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
|
||||
@@ -32,6 +34,7 @@ public class ExamSimulationStore
|
||||
_statistics = statistics;
|
||||
_ratings = ratings;
|
||||
_metrics = metrics;
|
||||
_reviewQueue = reviewQueue;
|
||||
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
|
||||
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State)));
|
||||
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
|
||||
@@ -439,6 +442,7 @@ public class ExamSimulationStore
|
||||
responses.TryGetValue(item.QuestionExternalId, out var response);
|
||||
var choiceId = response?.ChoiceId ?? "";
|
||||
var record = await _attempts.RecordFirstAttemptAsync(item.QuestionExternalId, simulation.UserId, choiceId, choiceId == question.CorrectChoiceId, choiceId == "", item.PresentedChoiceIds);
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
if (!record.Inserted) continue;
|
||||
_metrics.AttemptCreated();
|
||||
try { await _statistics.IncrementAsync(record.Attempt); } catch { _metrics.CounterFailure(); }
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class ReviewQueueStore
|
||||
{
|
||||
private readonly IMongoCollection<ReviewQueueItem> _items;
|
||||
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
|
||||
private readonly IMongoCollection<QuestionAttempt> _firstAttempts;
|
||||
private readonly IMongoCollection<ReviewQueueBackfillState> _backfills;
|
||||
private readonly QuestionStore _questions;
|
||||
|
||||
public ReviewQueueStore(MongoDatabaseProvider mongo, QuestionStore questions)
|
||||
{
|
||||
_items = mongo.Database.GetCollection<ReviewQueueItem>("ReviewQueueItems");
|
||||
_reviews = mongo.Database.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
|
||||
_firstAttempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_backfills = mongo.Database.GetCollection<ReviewQueueBackfillState>("ReviewQueueBackfillStates");
|
||||
_questions = questions;
|
||||
|
||||
_items.Indexes.CreateMany([
|
||||
new CreateIndexModel<ReviewQueueItem>(
|
||||
Builders<ReviewQueueItem>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.QuestionExternalId),
|
||||
new CreateIndexOptions { Unique = true, Name = "review_queue_unique_user_question" }),
|
||||
new CreateIndexModel<ReviewQueueItem>(
|
||||
Builders<ReviewQueueItem>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State),
|
||||
new CreateIndexOptions { Name = "review_queue_user_state" })
|
||||
]);
|
||||
_reviews.Indexes.CreateMany([
|
||||
new CreateIndexModel<QuestionReviewAttempt>(
|
||||
Builders<QuestionReviewAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.QuestionExternalId).Ascending(value => value.Cycle),
|
||||
new CreateIndexOptions { Unique = true, Name = "review_attempt_unique_cycle" }),
|
||||
new CreateIndexModel<QuestionReviewAttempt>(
|
||||
Builders<QuestionReviewAttempt>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc),
|
||||
new CreateIndexOptions { Name = "review_attempt_user_created" })
|
||||
]);
|
||||
}
|
||||
|
||||
public async Task AddFromFirstAttemptAsync(QuestionAttempt attempt)
|
||||
{
|
||||
if (!ShouldQueue(attempt))
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
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);
|
||||
try
|
||||
{
|
||||
await _items.UpdateOneAsync(
|
||||
value => value.UserId == attempt.UserId && value.QuestionExternalId == attempt.QuestionExternalId,
|
||||
update,
|
||||
new UpdateOptions { IsUpsert = true });
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
// Another API replica created the same queue item concurrently.
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
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>.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))
|
||||
{ IsUpsert = true }).ToList();
|
||||
try
|
||||
{
|
||||
await _items.BulkWriteAsync(writes, new BulkWriteOptions { IsOrdered = false });
|
||||
}
|
||||
catch (MongoBulkWriteException<ReviewQueueItem> exception)
|
||||
when (exception.WriteErrors.All(error => error.Category == ServerErrorCategory.DuplicateKey))
|
||||
{
|
||||
// Concurrent lazy backfills are equivalent and protected by the unique index.
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
await _backfills.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<ReviewQueueBackfillState>.Update
|
||||
.SetOnInsert(value => value.UserId, userId)
|
||||
.Max(value => value.CompletedUtc, scanThroughUtc),
|
||||
new UpdateOptions { IsUpsert = true });
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
// Another replica completed the same per-user migration.
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var excluded = excludedIds.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToList();
|
||||
for (var pass = 0; pass < 5; pass++)
|
||||
{
|
||||
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 }) 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));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<ReviewQueueRecordResult?> RecordReviewAsync(
|
||||
string userId,
|
||||
string clientAttemptId,
|
||||
string questionExternalId,
|
||||
int cycle,
|
||||
string choiceId,
|
||||
IReadOnlyCollection<string> presentedChoiceIds,
|
||||
bool isCorrect,
|
||||
int durationSeconds)
|
||||
{
|
||||
var item = await _items.Find(value => value.UserId == userId
|
||||
&& value.QuestionExternalId == questionExternalId
|
||||
&& value.State == ReviewQueueStates.Pending
|
||||
&& value.Cycle == cycle).FirstOrDefaultAsync();
|
||||
if (item is null)
|
||||
{
|
||||
var prior = await _reviews.Find(value => value.UserId == userId
|
||||
&& value.QuestionExternalId == questionExternalId
|
||||
&& value.Cycle == cycle).FirstOrDefaultAsync();
|
||||
if (prior is null) return null;
|
||||
return new ReviewQueueRecordResult
|
||||
{
|
||||
Attempt = prior,
|
||||
Item = await FindItemAsync(userId, questionExternalId)
|
||||
};
|
||||
}
|
||||
|
||||
var attempt = new QuestionReviewAttempt
|
||||
{
|
||||
Id = clientAttemptId,
|
||||
UserId = userId,
|
||||
QuestionExternalId = questionExternalId,
|
||||
Cycle = cycle,
|
||||
ChoiceId = choiceId,
|
||||
PresentedChoiceIds = presentedChoiceIds.ToList(),
|
||||
IsCorrect = isCorrect,
|
||||
DurationSeconds = Math.Clamp(durationSeconds, 0, 86_400),
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
try
|
||||
{
|
||||
await _reviews.InsertOneAsync(attempt);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
var prior = await _reviews.Find(value => value.UserId == userId
|
||||
&& value.QuestionExternalId == questionExternalId
|
||||
&& value.Cycle == cycle).FirstOrDefaultAsync();
|
||||
if (prior is null) return null;
|
||||
attempt = prior;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var update = Builders<ReviewQueueItem>.Update
|
||||
.Inc(value => value.Cycle, 1)
|
||||
.Inc(value => value.ReviewCount, 1)
|
||||
.Set(value => value.LastReviewedUtc, attempt.CreatedUtc)
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
if (attempt.IsCorrect)
|
||||
{
|
||||
update = update
|
||||
.Set(value => value.State, ReviewQueueStates.Mastered)
|
||||
.Set(value => value.MasteredUtc, attempt.CreatedUtc);
|
||||
}
|
||||
else
|
||||
{
|
||||
update = update.Inc(value => value.IncorrectReviewCount, 1);
|
||||
}
|
||||
await _items.UpdateOneAsync(
|
||||
value => value.UserId == userId
|
||||
&& value.QuestionExternalId == questionExternalId
|
||||
&& value.State == ReviewQueueStates.Pending
|
||||
&& value.Cycle == cycle,
|
||||
update);
|
||||
return new ReviewQueueRecordResult { Attempt = attempt, Item = await FindItemAsync(userId, questionExternalId) };
|
||||
}
|
||||
|
||||
public Task<List<QuestionReviewAttempt>> GetReviewsSinceAsync(string userId, DateTime sinceUtc) =>
|
||||
_reviews.Find(value => value.UserId == userId && value.CreatedUtc >= sinceUtc).ToListAsync();
|
||||
|
||||
public static bool ShouldQueue(QuestionAttempt attempt) =>
|
||||
!attempt.IsCorrect
|
||||
&& !attempt.IsSkip
|
||||
&& !string.IsNullOrWhiteSpace(attempt.UserId)
|
||||
&& !string.IsNullOrWhiteSpace(attempt.QuestionExternalId);
|
||||
|
||||
private async Task<ReviewQueueItem?> FindItemAsync(string userId, string questionExternalId) =>
|
||||
await _items.Find(value => value.UserId == userId && value.QuestionExternalId == questionExternalId).FirstOrDefaultAsync();
|
||||
}
|
||||
Reference in New Issue
Block a user