Implement persistent review queue
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
using QuestionsApi.Models;
|
||||||
|
using QuestionsApi.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Tests;
|
||||||
|
|
||||||
|
public sealed class ReviewQueueTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SubmittedWrongFirstAttemptEntersQueue()
|
||||||
|
{
|
||||||
|
var attempt = new QuestionAttempt
|
||||||
|
{
|
||||||
|
UserId = "user-1",
|
||||||
|
QuestionExternalId = "question-1",
|
||||||
|
ChoiceId = "B",
|
||||||
|
IsCorrect = false,
|
||||||
|
IsSkip = false
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(ReviewQueueStore.ShouldQueue(attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true, false)]
|
||||||
|
[InlineData(false, true)]
|
||||||
|
public void CorrectAndSkippedFirstAttemptsDoNotEnterQueue(bool isCorrect, bool isSkip)
|
||||||
|
{
|
||||||
|
var attempt = new QuestionAttempt
|
||||||
|
{
|
||||||
|
UserId = "user-1",
|
||||||
|
QuestionExternalId = "question-1",
|
||||||
|
IsCorrect = isCorrect,
|
||||||
|
IsSkip = isSkip
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.False(ReviewQueueStore.ShouldQueue(attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void QueueRequiresUserAndQuestionIdentity()
|
||||||
|
{
|
||||||
|
Assert.False(ReviewQueueStore.ShouldQueue(new QuestionAttempt { UserId = "", QuestionExternalId = "question-1" }));
|
||||||
|
Assert.False(ReviewQueueStore.ShouldQueue(new QuestionAttempt { UserId = "user-1", QuestionExternalId = "" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public class QuestionsController : ControllerBase
|
|||||||
private readonly AnswerStatisticsStore _statistics;
|
private readonly 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; }
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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()):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c5ac2bohtsllu
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cx3j2ghktaf8s
|
||||||
Reference in New Issue
Block a user