Implement persistent review queue

This commit is contained in:
hz
2026-07-19 12:51:24 -05:00
parent 1bfb3f5347
commit 7508d4fe1d
20 changed files with 1155 additions and 20 deletions
@@ -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 AnswerStatisticsStore _statistics;
private readonly ServiceMetrics _metrics; private readonly ServiceMetrics _metrics;
private readonly QuestionBookmarkStore _bookmarks; private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue;
public QuestionsController( public QuestionsController(
QuestionStore questions, QuestionStore questions,
@@ -26,7 +27,8 @@ public class QuestionsController : ControllerBase
QuestionMediaStore media, QuestionMediaStore media,
AnswerStatisticsStore statistics, AnswerStatisticsStore statistics,
ServiceMetrics metrics, ServiceMetrics metrics,
QuestionBookmarkStore bookmarks) QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue)
{ {
_questions = questions; _questions = questions;
_attempts = attempts; _attempts = attempts;
@@ -35,6 +37,7 @@ public class QuestionsController : ControllerBase
_statistics = statistics; _statistics = statistics;
_metrics = metrics; _metrics = metrics;
_bookmarks = bookmarks; _bookmarks = bookmarks;
_reviewQueue = reviewQueue;
} }
[HttpGet("systems")] [HttpGet("systems")]
@@ -168,6 +171,7 @@ public class QuestionsController : ControllerBase
{ {
_metrics.AttemptDuplicate(); _metrics.AttemptDuplicate();
} }
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
var ratingOwner = Guid.NewGuid().ToString("N"); var ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync( 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; }
}
+1
View File
@@ -23,6 +23,7 @@ builder.Services.AddSingleton<CcsCaseReportStore>();
builder.Services.AddSingleton<AppFeedbackStore>(); builder.Services.AddSingleton<AppFeedbackStore>();
builder.Services.AddSingleton<UserNotepadStore>(); builder.Services.AddSingleton<UserNotepadStore>();
builder.Services.AddSingleton<QuestionBookmarkStore>(); builder.Services.AddSingleton<QuestionBookmarkStore>();
builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>(); builder.Services.AddSingleton<DashboardStore>();
builder.Services.AddSingleton<ServiceMetrics>(); builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>(); builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
+8
View File
@@ -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` List and filter app feedback, newest first (SUPER only).
- `GET /api/app-feedback/{id}` Get one app feedback report (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). - `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`. 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 independent and are not expected to sum to 100% when users see different
distractor subsets. 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 A background service snapshots the original choice IDs, backfills historical
attempts into v2 exposure counters, and reconciles recently active questions attempts into v2 exposure counters, and reconciles recently active questions
every five minutes. The metric every five minutes. The metric
+9 -3
View File
@@ -14,8 +14,9 @@ public sealed class DashboardStore
private readonly IMongoCollection<QuestionBookmark> _bookmarks; private readonly IMongoCollection<QuestionBookmark> _bookmarks;
private readonly IMongoCollection<UserNotepad> _notes; private readonly IMongoCollection<UserNotepad> _notes;
private readonly QuestionStore _questions; 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"); _attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings"); _ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
@@ -26,6 +27,7 @@ public sealed class DashboardStore
_bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks"); _bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks");
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads"); _notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
_questions = questions; _questions = questions;
_reviewQueue = reviewQueue;
} }
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc) 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 simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId); var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask,
simulationsTask, bookmarkCountTask, notepadTask); simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask);
var attempts = attemptsTask.Result; var attempts = attemptsTask.Result;
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList(); var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
@@ -50,6 +53,8 @@ public sealed class DashboardStore
var ccsAttempts = ccsAttemptsTask.Result; var ccsAttempts = ccsAttemptsTask.Result;
var simulations = simulationsTask.Result; var simulations = simulationsTask.Result;
var completedCcs = ccsAttempts.Where(value => value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue).ToList(); 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 var response = new DashboardResponse
{ {
@@ -62,7 +67,7 @@ public sealed class DashboardStore
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart), QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
BookmarkCount = checked((int)bookmarkCountTask.Result), BookmarkCount = checked((int)bookmarkCountTask.Result),
NoteCount = CountNotes(notepadTask.Result?.Content), 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 => response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset =>
@@ -105,6 +110,7 @@ public sealed class DashboardStore
StudyTimeSeconds = dailyQuestions.Sum(value => value.DurationSeconds) StudyTimeSeconds = dailyQuestions.Sum(value => value.DurationSeconds)
+ dailyCcs.Sum(CcsDurationSeconds) + dailyCcs.Sum(CcsDurationSeconds)
+ dailySimulations.Sum(value => value.Responses.Sum(item => Math.Max(0, item.ElapsedSeconds))) + 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(); }).ToList();
response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds); response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds);
@@ -16,6 +16,7 @@ public class ExamSimulationStore
private readonly AnswerStatisticsStore _statistics; private readonly AnswerStatisticsStore _statistics;
private readonly RatingStore _ratings; private readonly RatingStore _ratings;
private readonly ServiceMetrics _metrics; private readonly ServiceMetrics _metrics;
private readonly ReviewQueueStore _reviewQueue;
public ExamSimulationStore( public ExamSimulationStore(
MongoDatabaseProvider mongo, MongoDatabaseProvider mongo,
@@ -23,7 +24,8 @@ public class ExamSimulationStore
QuestionAttemptStore attempts, QuestionAttemptStore attempts,
AnswerStatisticsStore statistics, AnswerStatisticsStore statistics,
RatingStore ratings, RatingStore ratings,
ServiceMetrics metrics) ServiceMetrics metrics,
ReviewQueueStore reviewQueue)
{ {
_simulations = mongo.Database.GetCollection<ExamSimulation>("ExamSimulations"); _simulations = mongo.Database.GetCollection<ExamSimulation>("ExamSimulations");
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes"); _outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
@@ -32,6 +34,7 @@ public class ExamSimulationStore
_statistics = statistics; _statistics = statistics;
_ratings = ratings; _ratings = ratings;
_metrics = metrics; _metrics = metrics;
_reviewQueue = reviewQueue;
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>( _simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State))); Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State)));
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>( _simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
@@ -439,6 +442,7 @@ public class ExamSimulationStore
responses.TryGetValue(item.QuestionExternalId, out var response); responses.TryGetValue(item.QuestionExternalId, out var response);
var choiceId = response?.ChoiceId ?? ""; var choiceId = response?.ChoiceId ?? "";
var record = await _attempts.RecordFirstAttemptAsync(item.QuestionExternalId, simulation.UserId, choiceId, choiceId == question.CorrectChoiceId, choiceId == "", item.PresentedChoiceIds); 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; if (!record.Inserted) continue;
_metrics.AttemptCreated(); _metrics.AttemptCreated();
try { await _statistics.IncrementAsync(record.Attempt); } catch { _metrics.CounterFailure(); } 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();
}
+2
View File
@@ -30,6 +30,7 @@ CCSService="*res://scripts/autoload/ccs_service.gd"
FeedbackService="*res://scripts/autoload/feedback_service.gd" FeedbackService="*res://scripts/autoload/feedback_service.gd"
NotesService="*res://scripts/autoload/notes_service.gd" NotesService="*res://scripts/autoload/notes_service.gd"
BookmarkService="*res://scripts/autoload/bookmark_service.gd" BookmarkService="*res://scripts/autoload/bookmark_service.gd"
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
DashboardService="*res://scripts/autoload/dashboard_service.gd" DashboardService="*res://scripts/autoload/dashboard_service.gd"
[display] [display]
@@ -57,4 +58,5 @@ question_reports_api_url="https://s3q.ranaze.com/api/question-reports"
app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback" app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback"
notes_api_url="https://s3q.ranaze.com/api/notes" notes_api_url="https://s3q.ranaze.com/api/notes"
dashboard_api_url="https://s3q.ranaze.com/api/dashboard" dashboard_api_url="https://s3q.ranaze.com/api/dashboard"
review_queue_api_url="https://s3q.ranaze.com/api/review-queue"
build_revision="development" build_revision="development"
+65 -5
View File
@@ -27,6 +27,7 @@ var core_status_message := ""
var simulation_block_index := 0 var simulation_block_index := 0
var simulation_total_blocks := 12 var simulation_total_blocks := 12
var simulation_deadline_unix := 0.0 var simulation_deadline_unix := 0.0
var review_first_submission_recorded := false
const CORE_MIN_TARGET_SECONDS := 45.0 const CORE_MIN_TARGET_SECONDS := 45.0
const CORE_MAX_TARGET_SECONDS := 150.0 const CORE_MAX_TARGET_SECONDS := 150.0
@@ -107,6 +108,38 @@ func replace_bookmark_question(question: Dictionary) -> bool:
return true return true
func start_review_question(question: Dictionary) -> bool:
if question.is_empty():
return false
system_name = str(question.get("system", "All Systems"))
mode = "Tutor"
session_kind = "review"
active_questions = [_prepare_question_choices(question)]
answers = [null]
current_index = 0
seconds_remaining = 0.0
session_initial_seconds = 90.0
current_question_started_at = Time.get_ticks_msec() / 1000.0
current_question_pause_started_at = 0.0
review_first_submission_recorded = false
block_started = true
session_changed.emit()
return true
func replace_review_question(question: Dictionary) -> bool:
if session_kind != "review" or question.is_empty():
return false
active_questions = [_prepare_question_choices(question)]
answers = [null]
current_index = 0
current_question_started_at = Time.get_ticks_msec() / 1000.0
current_question_pause_started_at = 0.0
review_first_submission_recorded = false
session_changed.emit()
return true
func start_simulation_block(questions: Array, block_index: int, total_blocks: int, deadline_unix: float) -> void: func start_simulation_block(questions: Array, block_index: int, total_blocks: int, deadline_unix: float) -> void:
system_name = "All Systems" system_name = "All Systems"
mode = "Simulation" mode = "Simulation"
@@ -239,6 +272,32 @@ func submit_bookmark_attempt(choice_id: String, eliminated: Array, flagged: bool
return {"answer": answer, "is_correct": bool(answer.is_correct), "final": bool(answer.is_correct)} return {"answer": answer, "is_correct": bool(answer.is_correct), "final": bool(answer.is_correct)}
func submit_review_attempt(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary:
var question := get_current_question()
if session_kind != "review" or question.is_empty():
return {}
var first_submission := not review_first_submission_recorded
var answer := {
"question": question.duplicate(true),
"selected_choice_id": choice_id,
"is_correct": choice_id == str(question.get("correct_choice_id", "")),
"eliminated_choice_ids": eliminated.duplicate(),
"flagged": flagged,
"elapsed_seconds": get_current_elapsed_seconds(),
"target_seconds": 0.0
}
review_first_submission_recorded = true
if bool(answer.is_correct):
answers[current_index] = answer
session_changed.emit()
return {
"answer": answer,
"is_correct": bool(answer.is_correct),
"final": bool(answer.is_correct),
"first_submission": first_submission
}
func skip_bookmark_question(eliminated: Array, flagged: bool) -> Dictionary: func skip_bookmark_question(eliminated: Array, flagged: bool) -> Dictionary:
var question := get_current_question() var question := get_current_question()
if session_kind != "bookmark" or question.is_empty(): if session_kind != "bookmark" or question.is_empty():
@@ -316,7 +375,7 @@ func get_progress_text() -> String:
func finish_block() -> Dictionary: func finish_block() -> Dictionary:
if session_kind in ["core", "bookmark"]: if session_kind in ["core", "bookmark", "review"]:
block_started = false block_started = false
session_changed.emit() session_changed.emit()
return {} return {}
@@ -435,19 +494,20 @@ func get_session_timer_ratio() -> float:
func pause_current_question_timer() -> void: func pause_current_question_timer() -> void:
if session_kind == "core" and current_question_pause_started_at <= 0.0: if session_kind in ["core", "review"] and current_question_pause_started_at <= 0.0:
var now := Time.get_ticks_msec() / 1000.0 var now := Time.get_ticks_msec() / 1000.0
current_question_pause_started_at = now current_question_pause_started_at = now
_pause_core_question_total_timer(now) if session_kind == "core":
_pause_core_question_total_timer(now)
func resume_current_question_timer() -> void: func resume_current_question_timer() -> void:
if session_kind == "core" and current_question_pause_started_at > 0.0: if session_kind in ["core", "review"] and current_question_pause_started_at > 0.0:
var now := Time.get_ticks_msec() / 1000.0 var now := Time.get_ticks_msec() / 1000.0
var paused_duration := now - current_question_pause_started_at var paused_duration := now - current_question_pause_started_at
current_question_started_at += paused_duration current_question_started_at += paused_duration
current_question_pause_started_at = 0.0 current_question_pause_started_at = 0.0
if not core_question_total_frozen: if session_kind == "core" and not core_question_total_frozen:
core_question_total_active_since = now core_question_total_active_since = now
+268
View File
@@ -0,0 +1,268 @@
extends Node
signal question_loaded(question: Dictionary)
signal question_failed(message: String)
signal pass_completed(summary: Dictionary)
signal attempt_synced(question_id: String, is_correct: bool, pending_count: int)
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_API_URL := "https://s3q.ranaze.com/api/review-queue"
const OUTBOX_PATH := "user://pending_review_attempts.json"
var api_url := DEFAULT_API_URL
var next_request: HTTPRequest
var attempt_request: HTTPRequest
var retry_timer: Timer
var loading := false
var submitting := false
var request_token := ""
var excluded_question_ids: Array = []
var current_cycle := 0
var pending_count := 0
var reviewed_count := 0
var cleared_count := 0
var retained_count := 0
var outbox: Array = []
var active_account_key := ""
func _ready() -> void:
api_url = EndpointConfig.resolve(
str(ProjectSettings.get_setting("step3/review_queue_api_url", DEFAULT_API_URL)),
"/api/review-queue")
next_request = HTTPRequest.new()
next_request.timeout = 25.0
next_request.request_completed.connect(_on_next_completed)
add_child(next_request)
attempt_request = HTTPRequest.new()
attempt_request.timeout = 25.0
attempt_request.request_completed.connect(_on_attempt_completed)
add_child(attempt_request)
retry_timer = Timer.new()
retry_timer.one_shot = true
retry_timer.wait_time = 5.0
retry_timer.timeout.connect(_process_outbox)
add_child(retry_timer)
_load_outbox()
AuthState.auth_changed.connect(_on_auth_changed)
_on_auth_changed()
func start_pass() -> void:
excluded_question_ids.clear()
reviewed_count = 0
cleared_count = 0
retained_count = 0
load_next()
func load_next() -> void:
if loading:
return
if not AuthState.can_send_authenticated_request():
question_failed.emit("Login is required to load the review queue.")
return
loading = true
request_token = AuthState.access_token
var payload := {
"excludeExternalIds": excluded_question_ids,
"supportsChoicePools": true
}
var error := next_request.request(
"%s/next" % api_url,
_headers(),
HTTPClient.METHOD_POST,
JSON.stringify(payload))
if error != OK:
loading = false
question_failed.emit("The review queue could not be loaded. Check your connection and try again.")
func record_first_submission(question: Dictionary, choice_id: String, presented_choice_ids: Array, duration_seconds: int) -> void:
var question_id := str(question.get("id", ""))
if question_id == "" or choice_id == "" or current_cycle < 1:
return
var is_correct := choice_id == str(question.get("correct_choice_id", ""))
reviewed_count += 1
if is_correct:
cleared_count += 1
pending_count = maxi(0, pending_count - 1)
else:
retained_count += 1
var payload := {
"clientAttemptId": _new_uuid(),
"questionExternalId": question_id,
"cycle": current_cycle,
"choiceId": choice_id,
"presentedChoiceIds": presented_choice_ids.duplicate(),
"durationSeconds": clampi(duration_seconds, 0, 86400)
}
outbox.append({"account": _account_key(), "payload": payload, "retry_count": 0})
_save_outbox()
_process_outbox()
func get_summary() -> Dictionary:
return {
"reviewed": reviewed_count,
"cleared": cleared_count,
"remaining": maxi(0, pending_count),
"needs_review": retained_count
}
func has_pending_submissions() -> bool:
return _next_account_item_index() >= 0
func _on_next_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void:
loading = false
if response_code == 401:
AuthState.handle_unauthorized(request_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
question_failed.emit("The review queue could not be loaded. Check your connection and try again.")
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
question_failed.emit("The review service returned an invalid response.")
return
pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count)))
var raw_question: Variant = parsed.get("question", {})
if typeof(raw_question) != TYPE_DICTIONARY or raw_question.is_empty():
pass_completed.emit(get_summary())
return
var question := QuestionBankService.normalize_api_question(raw_question)
if question.is_empty() or str(question.get("id", "")) == "":
question_failed.emit("The review service returned an invalid question.")
return
current_cycle = int(parsed.get("cycle", 0))
question["review_cycle"] = current_cycle
var question_id := str(question.get("id", ""))
if not excluded_question_ids.has(question_id):
excluded_question_ids.append(question_id)
question_loaded.emit(question)
func _process_outbox() -> void:
if submitting or outbox.is_empty() or not AuthState.can_send_authenticated_request():
return
var index := _next_account_item_index()
if index < 0:
return
var item: Dictionary = outbox[index]
item["active"] = true
outbox[index] = item
submitting = true
request_token = AuthState.access_token
var error := attempt_request.request(
"%s/attempts" % api_url,
_headers(),
HTTPClient.METHOD_POST,
JSON.stringify(item.get("payload", {})))
if error != OK:
submitting = false
item.erase("active")
item["retry_count"] = int(item.get("retry_count", 0)) + 1
outbox[index] = item
_save_outbox()
retry_timer.start()
func _on_attempt_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void:
submitting = false
var index := _active_item_index()
if index < 0:
return
var item: Dictionary = outbox[index]
item.erase("active")
if response_code == 401:
outbox[index] = item
_save_outbox()
AuthState.handle_unauthorized(request_token)
return
if result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300:
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
var synced_question_id := ""
var synced_is_correct := false
if typeof(parsed) == TYPE_DICTIONARY:
pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count)))
synced_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
synced_is_correct = bool(parsed.get("isCorrect", parsed.get("is_correct", false)))
outbox.remove_at(index)
_save_outbox()
attempt_synced.emit(synced_question_id, synced_is_correct, pending_count)
_process_outbox()
return
if response_code >= 400 and response_code < 500 and response_code not in [408, 429]:
outbox.remove_at(index)
_save_outbox()
_process_outbox()
return
item["retry_count"] = int(item.get("retry_count", 0)) + 1
outbox[index] = item
_save_outbox()
retry_timer.start()
func _headers() -> PackedStringArray:
return PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % AuthState.access_token
])
func _on_auth_changed() -> void:
active_account_key = _account_key()
if AuthState.is_logged_in:
_process_outbox()
elif retry_timer != null:
retry_timer.stop()
func _account_key() -> String:
return AuthState.username.strip_edges().to_lower().sha256_text() if not AuthState.username.strip_edges().is_empty() else ""
func _next_account_item_index() -> int:
for index in range(outbox.size()):
if str(outbox[index].get("account", "")) == active_account_key:
return index
return -1
func _active_item_index() -> int:
for index in range(outbox.size()):
if bool(outbox[index].get("active", false)):
return index
return -1
func _load_outbox() -> void:
outbox = []
if not FileAccess.file_exists(OUTBOX_PATH):
return
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(OUTBOX_PATH))
if typeof(parsed) != TYPE_ARRAY:
return
for item in parsed:
if typeof(item) == TYPE_DICTIONARY and typeof(item.get("payload", {})) == TYPE_DICTIONARY:
item.erase("active")
outbox.append(item)
func _save_outbox() -> void:
var stored := outbox.duplicate(true)
for item in stored:
item.erase("active")
var file := FileAccess.open(OUTBOX_PATH, FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(stored))
func _new_uuid() -> String:
var bytes := Crypto.new().generate_random_bytes(16)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
var value := bytes.hex_encode()
return "%s-%s-%s-%s-%s" % [value.substr(0, 8), value.substr(8, 4), value.substr(12, 4), value.substr(16, 4), value.substr(20, 12)]
@@ -0,0 +1 @@
uid://coquauokw5nup
+56 -2
View File
@@ -20,12 +20,16 @@ var recent_list: VBoxContainer
var weekly_summary: VBoxContainer var weekly_summary: VBoxContainer
var metric_values: Dictionary = {} var metric_values: Dictionary = {}
var metric_details: Dictionary = {} var metric_details: Dictionary = {}
var pending_review_launch := false
func _ready() -> void: func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
DashboardService.dashboard_changed.connect(_render) DashboardService.dashboard_changed.connect(_render)
DashboardService.dashboard_failed.connect(_show_error) DashboardService.dashboard_failed.connect(_show_error)
ReviewQueueService.question_loaded.connect(_on_review_question_loaded)
ReviewQueueService.question_failed.connect(_on_review_question_failed)
ReviewQueueService.pass_completed.connect(_on_review_pass_completed)
UIThemeService.theme_changed.connect(_rebuild) UIThemeService.theme_changed.connect(_rebuild)
resized.connect(_update_responsive) resized.connect(_update_responsive)
_build() _build()
@@ -229,7 +233,15 @@ func _render_focus(systems: Array) -> void:
func _render_review(data: Dictionary) -> void: func _render_review(data: Dictionary) -> void:
_clear(review_list) _clear(review_list)
review_list.add_child(_section_title("Review queue")) review_list.add_child(_section_title("Review queue"))
review_list.add_child(_review_action("%d missed questions" % int(data.get("missedQuestionCount", 0)), "x", Brand.MAGENTA, "profile")) var missed_count := int(data.get("missedQuestionCount", 0))
var missed_action := _review_action(
"%d missed questions" % missed_count if missed_count > 0 else "Review queue clear",
"x",
Brand.MAGENTA,
"")
missed_action.disabled = missed_count <= 0 or pending_review_launch
missed_action.pressed.connect(_start_review_queue)
review_list.add_child(missed_action)
review_list.add_child(_review_action("%d bookmarks" % int(data.get("bookmarkCount", 0)), "bookmark", Brand.ROYAL_BLUE, "bookmarks")) review_list.add_child(_review_action("%d bookmarks" % int(data.get("bookmarkCount", 0)), "bookmark", Brand.ROYAL_BLUE, "bookmarks"))
review_list.add_child(_review_action("%d notes" % int(data.get("noteCount", 0)), "notebook-pen", Brand.AMBER, "notes")) review_list.add_child(_review_action("%d notes" % int(data.get("noteCount", 0)), "notebook-pen", Brand.AMBER, "notes"))
@@ -311,10 +323,52 @@ func _review_action(text: String, icon_name: String, accent: Color, route: Strin
var button := _button(text, icon_name, false) var button := _button(text, icon_name, false)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.add_theme_color_override("icon_normal_color", accent) button.add_theme_color_override("icon_normal_color", accent)
button.pressed.connect(func(): main._on_navigation_pressed(route)) if not route.is_empty():
button.pressed.connect(func(): main._on_navigation_pressed(route))
return button return button
func _start_review_queue() -> void:
if pending_review_launch:
return
pending_review_launch = true
_show_loading_message("Preparing your review queue...")
ReviewQueueService.start_pass()
_render_review(DashboardService.data)
func _on_review_question_loaded(question: Dictionary) -> void:
if not pending_review_launch or not visible:
return
pending_review_launch = false
if BlockSessionService.start_review_question(question):
main.show_screen("player")
else:
_show_error("The review question could not be prepared.")
func _on_review_question_failed(message: String) -> void:
if not pending_review_launch or not visible:
return
pending_review_launch = false
_show_error(message)
_render_review(DashboardService.data)
func _on_review_pass_completed(_summary: Dictionary) -> void:
if not pending_review_launch or not visible:
return
pending_review_launch = false
DashboardService.refresh()
_show_loading_message("Your review queue is clear.")
func _show_loading_message(message: String) -> void:
status_label.text = message
status_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
status_label.visible = true
func _summary_stat(value: String, caption: String, icon_name: String, accent: Color) -> HBoxContainer: func _summary_stat(value: String, caption: String, icon_name: String, accent: Color) -> HBoxContainer:
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10) row.add_theme_constant_override("separation", 10)
+11 -1
View File
@@ -253,6 +253,12 @@ func _build_ui() -> void:
screens_container.add_child(review) screens_container.add_child(review)
screens["review"] = review screens["review"] = review
var review_queue_results := preload("res://scripts/ui/review_queue_results.gd").new()
review_queue_results.name = "ReviewQueueResults"
review_queue_results.main = self
screens_container.add_child(review_queue_results)
screens["review_queue_results"] = review_queue_results
var simulation_break := preload("res://scripts/ui/simulation_break.gd").new() var simulation_break := preload("res://scripts/ui/simulation_break.gd").new()
simulation_break.name = "SimulationBreak" simulation_break.name = "SimulationBreak"
simulation_break.main = self simulation_break.main = self
@@ -846,8 +852,12 @@ func _exit_run() -> void:
if ccs_player != null and ccs_player.has_method("leave_case"): ccs_player.leave_case() if ccs_player != null and ccs_player.has_method("leave_case"): ccs_player.leave_case()
return return
var was_core_run := BlockSessionService.session_kind == "core" var was_core_run := BlockSessionService.session_kind == "core"
var was_review_run := BlockSessionService.session_kind == "review"
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation": if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
BlockSessionService.finish_block() BlockSessionService.finish_block()
elif BlockSessionService.session_kind == "simulation": elif BlockSessionService.session_kind == "simulation":
BlockSessionService.block_started = false BlockSessionService.block_started = false
show_screen("selection", "exam" if was_core_run else "step3") if was_review_run:
show_screen("dashboard")
else:
show_screen("selection", "exam" if was_core_run else "step3")
+68 -7
View File
@@ -168,6 +168,12 @@ func _ready() -> void:
BookmarkService.question_loaded.connect(_on_bookmark_question_loaded) BookmarkService.question_loaded.connect(_on_bookmark_question_loaded)
if not BookmarkService.question_failed.is_connected(_on_bookmark_question_failed): if not BookmarkService.question_failed.is_connected(_on_bookmark_question_failed):
BookmarkService.question_failed.connect(_on_bookmark_question_failed) BookmarkService.question_failed.connect(_on_bookmark_question_failed)
if not ReviewQueueService.question_loaded.is_connected(_on_review_question_loaded):
ReviewQueueService.question_loaded.connect(_on_review_question_loaded)
if not ReviewQueueService.question_failed.is_connected(_on_review_question_failed):
ReviewQueueService.question_failed.connect(_on_review_question_failed)
if not ReviewQueueService.pass_completed.is_connected(_on_review_pass_completed):
ReviewQueueService.pass_completed.connect(_on_review_pass_completed)
question_card = PanelContainer.new() question_card = PanelContainer.new()
question_card.size_flags_horizontal = Control.SIZE_EXPAND_FILL question_card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
@@ -1036,18 +1042,21 @@ func _load_question() -> void:
next_button.text = "Submit" next_button.text = "Submit"
next_button.disabled = true next_button.disabled = true
skip_button.disabled = false skip_button.disabled = false
skip_button.visible = true skip_button.visible = BlockSessionService.session_kind != "review"
skip_button.text = "Skip" skip_button.text = "Skip"
report_button.visible = BlockSessionService.session_kind != "simulation" report_button.visible = BlockSessionService.session_kind != "simulation"
var question := BlockSessionService.get_current_question() var question := BlockSessionService.get_current_question()
if question.is_empty(): if question.is_empty():
main.show_screen("bookmarks" if BlockSessionService.session_kind == "bookmark" else "selection", "exam" if BlockSessionService.session_kind == "core" else "step3") if BlockSessionService.session_kind == "review":
main.show_screen("dashboard")
else:
main.show_screen("bookmarks" if BlockSessionService.session_kind == "bookmark" else "selection", "exam" if BlockSessionService.session_kind == "core" else "step3")
return return
BookmarkService.observe_question(question) BookmarkService.observe_question(question)
progress_button.text = _question_progress_text() progress_button.text = _question_progress_text()
progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark"] progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review"]
progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark"] else "Open question navigator" progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review"] else "Open question navigator"
var show_timer := _is_session_timer_visible() var show_timer := _is_session_timer_visible()
timer_label.visible = show_timer timer_label.visible = show_timer
timer_bar.visible = show_timer timer_bar.visible = show_timer
@@ -1291,7 +1300,7 @@ func _submit_or_next() -> void:
if selected_choice == "": if selected_choice == "":
return return
var question_id_for_attempt := str(BlockSessionService.get_current_question().get("id", "")) var question_id_for_attempt := str(BlockSessionService.get_current_question().get("id", ""))
if BlockSessionService.session_kind in ["core", "bookmark"]: if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
_collapse_resolved_followups() _collapse_resolved_followups()
var attempted_choice := selected_choice var attempted_choice := selected_choice
if BlockSessionService.session_kind == "core": if BlockSessionService.session_kind == "core":
@@ -1301,10 +1310,22 @@ func _submit_or_next() -> void:
_presented_choice_ids(), _presented_choice_ids(),
int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())) int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))
) )
var result := BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged) if BlockSessionService.session_kind == "core" else BlockSessionService.submit_bookmark_attempt(selected_choice, eliminated, flagged) var result: Dictionary
if BlockSessionService.session_kind == "core":
result = BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged)
elif BlockSessionService.session_kind == "bookmark":
result = BlockSessionService.submit_bookmark_attempt(selected_choice, eliminated, flagged)
else:
result = BlockSessionService.submit_review_attempt(selected_choice, eliminated, flagged)
if result.is_empty(): if result.is_empty():
return return
var answer: Dictionary = result.get("answer", {}) var answer: Dictionary = result.get("answer", {})
if BlockSessionService.session_kind == "review" and bool(result.get("first_submission", false)):
ReviewQueueService.record_first_submission(
BlockSessionService.get_current_question(),
attempted_choice,
_presented_choice_ids(),
int(floor(float(answer.get("elapsed_seconds", 0.0)))))
if bool(result.get("is_correct", false)): if bool(result.get("is_correct", false)):
current_answer = answer current_answer = answer
_update_question_elo_label(true) _update_question_elo_label(true)
@@ -1339,6 +1360,11 @@ func _submit_or_next() -> void:
BookmarkService.next_practice() BookmarkService.next_practice()
else: else:
_finish() _finish()
elif BlockSessionService.session_kind == "review":
awaiting_next_question = true
next_button.text = "Loading..."
next_button.disabled = true
ReviewQueueService.load_next()
else: else:
_advance_or_finish() _advance_or_finish()
@@ -1708,6 +1734,10 @@ func _finish() -> void:
BlockSessionService.finish_block() BlockSessionService.finish_block()
main.show_screen("selection", "exam") main.show_screen("selection", "exam")
return return
if BlockSessionService.session_kind == "review":
BlockSessionService.finish_block()
main.show_screen("review_queue_results", ReviewQueueService.get_summary())
return
var results := BlockSessionService.finish_block() var results := BlockSessionService.finish_block()
main.show_screen("review", results) main.show_screen("review", results)
@@ -1717,6 +1747,11 @@ func _exit_run() -> void:
BlockSessionService.finish_block() BlockSessionService.finish_block()
main.show_screen("bookmarks") main.show_screen("bookmarks")
return return
if BlockSessionService.session_kind == "review":
BlockSessionService.finish_block()
DashboardService.refresh()
main.show_screen("dashboard")
return
var was_core_run := BlockSessionService.session_kind == "core" var was_core_run := BlockSessionService.session_kind == "core"
BlockSessionService.resume_current_question_timer() BlockSessionService.resume_current_question_timer()
BlockSessionService.finish_block() BlockSessionService.finish_block()
@@ -1973,6 +2008,32 @@ func _on_bookmark_question_failed(message: String) -> void:
next_button.tooltip_text = message next_button.tooltip_text = message
func _on_review_question_loaded(question: Dictionary) -> void:
if not visible or BlockSessionService.session_kind != "review" or not awaiting_next_question:
return
awaiting_next_question = false
if BlockSessionService.replace_review_question(question):
_load_question()
UISoundService.play_sound("question")
func _on_review_question_failed(message: String) -> void:
if not visible or BlockSessionService.session_kind != "review":
return
awaiting_next_question = false
next_button.text = "Retry"
next_button.disabled = false
next_button.tooltip_text = message
func _on_review_pass_completed(summary: Dictionary) -> void:
if not visible or BlockSessionService.session_kind != "review":
return
awaiting_next_question = false
BlockSessionService.finish_block()
main.show_screen("review_queue_results", summary)
func refresh_theme_controls() -> void: func refresh_theme_controls() -> void:
if media_viewer_background != null: if media_viewer_background != null:
media_viewer_background.color = Style.BACKGROUND media_viewer_background.color = Style.BACKGROUND
@@ -3458,7 +3519,7 @@ func _refresh_nav_palette() -> void:
for child in nav_palette.get_children(): for child in nav_palette.get_children():
child.queue_free() child.queue_free()
if BlockSessionService.session_kind == "core": if BlockSessionService.session_kind in ["core", "review"]:
return return
for index in range(BlockSessionService.active_questions.size()): for index in range(BlockSessionService.active_questions.size()):
+151
View File
@@ -0,0 +1,151 @@
extends VBoxContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
var main: Control
var title_label: Label
var summary_label: Label
var status_label: Label
var another_button: Button
var results_panel: PanelContainer
var dashboard_button: Button
var launch_pending := false
var latest_remaining := 0
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
alignment = BoxContainer.ALIGNMENT_CENTER
add_theme_constant_override("separation", 18)
UIThemeService.theme_changed.connect(_apply_theme)
ReviewQueueService.question_loaded.connect(_on_question_loaded)
ReviewQueueService.question_failed.connect(_on_question_failed)
ReviewQueueService.pass_completed.connect(_on_pass_completed)
ReviewQueueService.attempt_synced.connect(_on_attempt_synced)
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(spacer)
results_panel = PanelContainer.new()
results_panel.custom_minimum_size = Vector2(520, 0)
results_panel.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
add_child(results_panel)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 16)
results_panel.add_child(content)
title_label = _label("Review pass complete", 28, true)
content.add_child(title_label)
summary_label = _label("", 17)
summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content.add_child(summary_label)
status_label = _label("", 14)
status_label.visible = false
content.add_child(status_label)
another_button = Button.new()
another_button.text = "Start Another Review"
another_button.custom_minimum_size.y = 46
another_button.pressed.connect(_start_another)
content.add_child(another_button)
dashboard_button = Button.new()
dashboard_button.text = "Back to Dashboard"
dashboard_button.custom_minimum_size.y = 46
dashboard_button.pressed.connect(_back_to_dashboard)
content.add_child(dashboard_button)
var bottom_spacer := Control.new()
bottom_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(bottom_spacer)
_apply_theme()
func on_show(payload: Variant = null) -> void:
var summary: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else ReviewQueueService.get_summary()
var reviewed := int(summary.get("reviewed", 0))
var cleared := int(summary.get("cleared", 0))
var remaining := int(summary.get("remaining", 0))
latest_remaining = remaining
summary_label.text = "Reviewed %d question%s\nCleared %d from the queue\n%d still need%s review" % [
reviewed,
"" if reviewed == 1 else "s",
cleared,
remaining,
"s" if remaining == 1 else ""
]
another_button.disabled = remaining <= 0 or ReviewQueueService.has_pending_submissions()
status_label.text = "Saving your review result..." if ReviewQueueService.has_pending_submissions() else ""
status_label.visible = not status_label.text.is_empty()
launch_pending = false
DashboardService.refresh()
func _start_another() -> void:
if launch_pending:
return
launch_pending = true
another_button.disabled = true
status_label.text = "Preparing another review pass..."
status_label.visible = true
ReviewQueueService.start_pass()
func _on_question_loaded(question: Dictionary) -> void:
if not launch_pending or not visible:
return
launch_pending = false
if BlockSessionService.start_review_question(question):
main.show_screen("player")
else:
_on_question_failed("The review question could not be prepared.")
func _on_question_failed(message: String) -> void:
if not launch_pending or not visible:
return
launch_pending = false
another_button.disabled = false
status_label.text = message
status_label.visible = true
func _on_pass_completed(summary: Dictionary) -> void:
if not launch_pending or not visible:
return
launch_pending = false
on_show(summary)
func _on_attempt_synced(_question_id: String, _is_correct: bool, server_pending_count: int) -> void:
if not visible:
return
var summary := ReviewQueueService.get_summary()
summary["remaining"] = server_pending_count
on_show(summary)
func _back_to_dashboard() -> void:
main.show_screen("dashboard")
func _apply_theme(_dark: bool = UIThemeService.is_dark_mode()) -> void:
var dark := UIThemeService.is_dark_mode()
if results_panel != null:
results_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark))
for label in [title_label, summary_label]:
if label != null:
label.add_theme_color_override("font_color", Brand.text_color(dark))
if status_label != null:
status_label.add_theme_color_override("font_color", Brand.muted_color(dark))
if another_button != null:
another_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
if dashboard_button != null:
dashboard_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
func _label(text: String, font_size: int, bold: bool = false) -> Label:
var label := Label.new()
label.text = text
label.add_theme_font_override("font", Brand.font)
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", Brand.text_color(UIThemeService.is_dark_mode()))
if bold:
label.add_theme_constant_override("outline_size", 0)
return label
+1
View File
@@ -0,0 +1 @@
uid://c5ac2bohtsllu
+42
View File
@@ -0,0 +1,42 @@
extends SceneTree
var failures := 0
func _initialize() -> void:
var service = load("res://scripts/autoload/block_session_service.gd").new()
var question := {
"id": "review-test-question",
"system": "Test System",
"choices": [
{"id": "A", "text": "Correct"},
{"id": "B", "text": "Wrong"},
{"id": "C", "text": "Wrong"},
{"id": "D", "text": "Wrong"},
{"id": "E", "text": "Wrong"}
],
"correct_choice_id": "A"
}
_assert(service.start_review_question(question), "review session should start")
_assert(service.session_kind == "review", "review session should have its own kind")
var wrong: Dictionary = service.submit_review_attempt("B", [], false)
_assert(bool(wrong.get("first_submission", false)), "the first review answer should be marked as the queue outcome")
_assert(not bool(wrong.get("is_correct", true)), "a wrong review should remain retryable")
_assert(service.get_answer_for_index(0) == null, "a wrong review should not finalize the question")
var correct: Dictionary = service.submit_review_attempt("A", [], false)
_assert(not bool(correct.get("first_submission", true)), "a retry must not replace the first review outcome")
_assert(bool(correct.get("is_correct", false)), "the correct retry should resolve the question UI")
_assert(service.get_answer_for_index(0) != null, "the resolved review should remain available for explanation review")
_assert(service.replace_review_question(question), "the next review question should replace the current one")
var next_correct: Dictionary = service.submit_review_attempt("A", [], false)
_assert(bool(next_correct.get("first_submission", false)), "a new review exposure should accept a new first outcome")
_assert(service.finish_block().is_empty(), "review sessions should not calculate progression rewards")
service.free()
quit(1 if failures > 0 else 0)
func _assert(condition: bool, message: String) -> void:
if condition:
return
failures += 1
push_error(message)
+1
View File
@@ -0,0 +1 @@
uid://cx3j2ghktaf8s