diff --git a/api/QuestionsApi.Tests/ReviewQueueTests.cs b/api/QuestionsApi.Tests/ReviewQueueTests.cs new file mode 100644 index 0000000..a5c5d7f --- /dev/null +++ b/api/QuestionsApi.Tests/ReviewQueueTests.cs @@ -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 = "" })); + } +} diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index e2850f3..0116d2b 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -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( diff --git a/api/QuestionsApi/Controllers/ReviewQueueController.cs b/api/QuestionsApi/Controllers/ReviewQueueController.cs new file mode 100644 index 0000000..b4dbbc8 --- /dev/null +++ b/api/QuestionsApi/Controllers/ReviewQueueController.cs @@ -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 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 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 + ?? ""; +} diff --git a/api/QuestionsApi/Models/ReviewQueueModels.cs b/api/QuestionsApi/Models/ReviewQueueModels.cs new file mode 100644 index 0000000..97e4cfb --- /dev/null +++ b/api/QuestionsApi/Models/ReviewQueueModels.cs @@ -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 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 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 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; } +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 6f8de5e..1f19b7f 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -23,6 +23,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/api/QuestionsApi/README.md b/api/QuestionsApi/README.md index c222f96..14d0cf9 100644 --- a/api/QuestionsApi/README.md +++ b/api/QuestionsApi/README.md @@ -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 diff --git a/api/QuestionsApi/Services/DashboardStore.cs b/api/QuestionsApi/Services/DashboardStore.cs index 2696e0d..799e3f5 100644 --- a/api/QuestionsApi/Services/DashboardStore.cs +++ b/api/QuestionsApi/Services/DashboardStore.cs @@ -14,8 +14,9 @@ public sealed class DashboardStore private readonly IMongoCollection _bookmarks; private readonly IMongoCollection _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("QuestionAttempts"); _ratings = mongo.Database.GetCollection("UserRatings"); @@ -26,6 +27,7 @@ public sealed class DashboardStore _bookmarks = mongo.Database.GetCollection("QuestionBookmarks"); _notes = mongo.Database.GetCollection("UserNotepads"); _questions = questions; + _reviewQueue = reviewQueue; } public async Task 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); diff --git a/api/QuestionsApi/Services/ExamSimulationStore.cs b/api/QuestionsApi/Services/ExamSimulationStore.cs index 357e1b7..014da74 100644 --- a/api/QuestionsApi/Services/ExamSimulationStore.cs +++ b/api/QuestionsApi/Services/ExamSimulationStore.cs @@ -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("ExamSimulations"); _outcomes = mongo.Database.GetCollection("Step3Outcomes"); @@ -32,6 +34,7 @@ public class ExamSimulationStore _statistics = statistics; _ratings = ratings; _metrics = metrics; + _reviewQueue = reviewQueue; _simulations.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State))); _simulations.Indexes.CreateOne(new CreateIndexModel( @@ -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(); } diff --git a/api/QuestionsApi/Services/ReviewQueueStore.cs b/api/QuestionsApi/Services/ReviewQueueStore.cs new file mode 100644 index 0000000..bf63817 --- /dev/null +++ b/api/QuestionsApi/Services/ReviewQueueStore.cs @@ -0,0 +1,231 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class ReviewQueueStore +{ + private readonly IMongoCollection _items; + private readonly IMongoCollection _reviews; + private readonly IMongoCollection _firstAttempts; + private readonly IMongoCollection _backfills; + private readonly QuestionStore _questions; + + public ReviewQueueStore(MongoDatabaseProvider mongo, QuestionStore questions) + { + _items = mongo.Database.GetCollection("ReviewQueueItems"); + _reviews = mongo.Database.GetCollection("QuestionReviewAttempts"); + _firstAttempts = mongo.Database.GetCollection("QuestionAttempts"); + _backfills = mongo.Database.GetCollection("ReviewQueueBackfillStates"); + _questions = questions; + + _items.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.QuestionExternalId), + new CreateIndexOptions { Unique = true, Name = "review_queue_unique_user_question" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State), + new CreateIndexOptions { Name = "review_queue_user_state" }) + ]); + _reviews.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.QuestionExternalId).Ascending(value => value.Cycle), + new CreateIndexOptions { Unique = true, Name = "review_attempt_unique_cycle" }), + new CreateIndexModel( + Builders.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.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.Filter.Where(value => value.UserId == userId && !value.IsCorrect && !value.IsSkip); + if (checkpoint is not null) + filter &= Builders.Filter.Gte(value => value.CreatedUtc, checkpoint.CompletedUtc); + filter &= Builders.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( + Builders.Filter.Where(value => value.UserId == userId && value.QuestionExternalId == attempt.QuestionExternalId), + Builders.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 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.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 CountPendingAsync(string userId) => + _items.CountDocumentsAsync(value => value.UserId == userId && value.State == ReviewQueueStates.Pending); + + public async Task GetRandomPendingAsync(string userId, IEnumerable excludedIds) + { + var excluded = excludedIds.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToList(); + for (var pass = 0; pass < 5; pass++) + { + var filter = Builders.Filter.Eq(value => value.UserId, userId) + & Builders.Filter.Eq(value => value.State, ReviewQueueStates.Pending); + if (excluded.Count > 0) + filter &= Builders.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.Update + .Set(value => value.State, ReviewQueueStates.Unavailable) + .Set(value => value.UpdatedUtc, DateTime.UtcNow)); + } + } + return null; + } + + public async Task RecordReviewAsync( + string userId, + string clientAttemptId, + string questionExternalId, + int cycle, + string choiceId, + IReadOnlyCollection 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.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> 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 FindItemAsync(string userId, string questionExternalId) => + await _items.Find(value => value.UserId == userId && value.QuestionExternalId == questionExternalId).FirstOrDefaultAsync(); +} diff --git a/project.godot b/project.godot index d77ee81..370b875 100644 --- a/project.godot +++ b/project.godot @@ -30,6 +30,7 @@ CCSService="*res://scripts/autoload/ccs_service.gd" FeedbackService="*res://scripts/autoload/feedback_service.gd" NotesService="*res://scripts/autoload/notes_service.gd" BookmarkService="*res://scripts/autoload/bookmark_service.gd" +ReviewQueueService="*res://scripts/autoload/review_queue_service.gd" DashboardService="*res://scripts/autoload/dashboard_service.gd" [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" notes_api_url="https://s3q.ranaze.com/api/notes" dashboard_api_url="https://s3q.ranaze.com/api/dashboard" +review_queue_api_url="https://s3q.ranaze.com/api/review-queue" build_revision="development" diff --git a/scripts/autoload/block_session_service.gd b/scripts/autoload/block_session_service.gd index 1d4da39..5fcf109 100644 --- a/scripts/autoload/block_session_service.gd +++ b/scripts/autoload/block_session_service.gd @@ -27,6 +27,7 @@ var core_status_message := "" var simulation_block_index := 0 var simulation_total_blocks := 12 var simulation_deadline_unix := 0.0 +var review_first_submission_recorded := false const CORE_MIN_TARGET_SECONDS := 45.0 const CORE_MAX_TARGET_SECONDS := 150.0 @@ -107,6 +108,38 @@ func replace_bookmark_question(question: Dictionary) -> bool: 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: system_name = "All Systems" 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)} +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: var question := get_current_question() if session_kind != "bookmark" or question.is_empty(): @@ -316,7 +375,7 @@ func get_progress_text() -> String: func finish_block() -> Dictionary: - if session_kind in ["core", "bookmark"]: + if session_kind in ["core", "bookmark", "review"]: block_started = false session_changed.emit() return {} @@ -435,19 +494,20 @@ func get_session_timer_ratio() -> float: 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 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: - 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 paused_duration := now - current_question_pause_started_at current_question_started_at += paused_duration 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 diff --git a/scripts/autoload/review_queue_service.gd b/scripts/autoload/review_queue_service.gd new file mode 100644 index 0000000..e3261f7 --- /dev/null +++ b/scripts/autoload/review_queue_service.gd @@ -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)] diff --git a/scripts/autoload/review_queue_service.gd.uid b/scripts/autoload/review_queue_service.gd.uid new file mode 100644 index 0000000..88d1370 --- /dev/null +++ b/scripts/autoload/review_queue_service.gd.uid @@ -0,0 +1 @@ +uid://coquauokw5nup diff --git a/scripts/ui/dashboard_screen.gd b/scripts/ui/dashboard_screen.gd index 845ec99..d1c1f1f 100644 --- a/scripts/ui/dashboard_screen.gd +++ b/scripts/ui/dashboard_screen.gd @@ -20,12 +20,16 @@ var recent_list: VBoxContainer var weekly_summary: VBoxContainer var metric_values: Dictionary = {} var metric_details: Dictionary = {} +var pending_review_launch := false func _ready() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) DashboardService.dashboard_changed.connect(_render) 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) resized.connect(_update_responsive) _build() @@ -229,7 +233,15 @@ func _render_focus(systems: Array) -> void: func _render_review(data: Dictionary) -> void: _clear(review_list) 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 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) button.alignment = HORIZONTAL_ALIGNMENT_LEFT 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 +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: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 10) diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 01eae39..b5cdadc 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -253,6 +253,12 @@ func _build_ui() -> void: screens_container.add_child(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() simulation_break.name = "SimulationBreak" 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() return 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": BlockSessionService.finish_block() elif BlockSessionService.session_kind == "simulation": 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") diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 4bcc0d1..721c7e9 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -168,6 +168,12 @@ func _ready() -> void: BookmarkService.question_loaded.connect(_on_bookmark_question_loaded) if not BookmarkService.question_failed.is_connected(_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.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -1036,18 +1042,21 @@ func _load_question() -> void: next_button.text = "Submit" next_button.disabled = true skip_button.disabled = false - skip_button.visible = true + skip_button.visible = BlockSessionService.session_kind != "review" skip_button.text = "Skip" report_button.visible = BlockSessionService.session_kind != "simulation" var question := BlockSessionService.get_current_question() 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 BookmarkService.observe_question(question) progress_button.text = _question_progress_text() - progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark"] - progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark"] else "Open question navigator" + progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review"] + progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review"] else "Open question navigator" var show_timer := _is_session_timer_visible() timer_label.visible = show_timer timer_bar.visible = show_timer @@ -1291,7 +1300,7 @@ func _submit_or_next() -> void: if selected_choice == "": return 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() var attempted_choice := selected_choice if BlockSessionService.session_kind == "core": @@ -1301,10 +1310,22 @@ func _submit_or_next() -> void: _presented_choice_ids(), 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(): return 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)): current_answer = answer _update_question_elo_label(true) @@ -1339,6 +1360,11 @@ func _submit_or_next() -> void: BookmarkService.next_practice() else: _finish() + elif BlockSessionService.session_kind == "review": + awaiting_next_question = true + next_button.text = "Loading..." + next_button.disabled = true + ReviewQueueService.load_next() else: _advance_or_finish() @@ -1708,6 +1734,10 @@ func _finish() -> void: BlockSessionService.finish_block() main.show_screen("selection", "exam") return + if BlockSessionService.session_kind == "review": + BlockSessionService.finish_block() + main.show_screen("review_queue_results", ReviewQueueService.get_summary()) + return var results := BlockSessionService.finish_block() main.show_screen("review", results) @@ -1717,6 +1747,11 @@ func _exit_run() -> void: BlockSessionService.finish_block() main.show_screen("bookmarks") return + if BlockSessionService.session_kind == "review": + BlockSessionService.finish_block() + DashboardService.refresh() + main.show_screen("dashboard") + return var was_core_run := BlockSessionService.session_kind == "core" BlockSessionService.resume_current_question_timer() BlockSessionService.finish_block() @@ -1973,6 +2008,32 @@ func _on_bookmark_question_failed(message: String) -> void: 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: if media_viewer_background != null: media_viewer_background.color = Style.BACKGROUND @@ -3458,7 +3519,7 @@ func _refresh_nav_palette() -> void: for child in nav_palette.get_children(): child.queue_free() - if BlockSessionService.session_kind == "core": + if BlockSessionService.session_kind in ["core", "review"]: return for index in range(BlockSessionService.active_questions.size()): diff --git a/scripts/ui/review_queue_results.gd b/scripts/ui/review_queue_results.gd new file mode 100644 index 0000000..b68934a --- /dev/null +++ b/scripts/ui/review_queue_results.gd @@ -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 diff --git a/scripts/ui/review_queue_results.gd.uid b/scripts/ui/review_queue_results.gd.uid new file mode 100644 index 0000000..9d7b3eb --- /dev/null +++ b/scripts/ui/review_queue_results.gd.uid @@ -0,0 +1 @@ +uid://c5ac2bohtsllu diff --git a/tests/review_queue_session_test.gd b/tests/review_queue_session_test.gd new file mode 100644 index 0000000..82cb938 --- /dev/null +++ b/tests/review_queue_session_test.gd @@ -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) diff --git a/tests/review_queue_session_test.gd.uid b/tests/review_queue_session_test.gd.uid new file mode 100644 index 0000000..993327c --- /dev/null +++ b/tests/review_queue_session_test.gd.uid @@ -0,0 +1 @@ +uid://cx3j2ghktaf8s