diff --git a/api/QuestionsApi.Tests/ChoicePoolTests.cs b/api/QuestionsApi.Tests/ChoicePoolTests.cs index 8f80e14..15b447e 100644 --- a/api/QuestionsApi.Tests/ChoicePoolTests.cs +++ b/api/QuestionsApi.Tests/ChoicePoolTests.cs @@ -148,6 +148,45 @@ public sealed class ChoicePoolTests Assert.Null(QuestionValidator.Validate(request)); } + [Fact] + public void PublishedQuestionRequiresCompleteLabReferenceRanges() + { + var request = BuildRequest(5); + request.Labs = + [ + new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" } + ]; + + Assert.Contains("requires a reference range", QuestionValidator.Validate(request)); + + request.Labs[0].Reference = "136–145 mEq/L"; + Assert.Null(QuestionValidator.Validate(request)); + } + + [Fact] + public void DraftQuestionMaySaveLabWithoutReferenceRange() + { + var request = BuildRequest(5); + request.IsPublished = false; + request.Labs = + [ + new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" } + ]; + + Assert.Null(QuestionValidator.Validate(request)); + } + + [Fact] + public void LabEntriesAlwaysRequireNamesAndValues() + { + var request = BuildRequest(5); + request.Labs = [new QuestionLab { Name = "", Value = "128", Reference = "136–145 mEq/L" }]; + Assert.Contains("lab name and value", QuestionValidator.Validate(request)); + + request.Labs = [new QuestionLab { Name = "Sodium", Value = "", Reference = "136–145 mEq/L" }]; + Assert.Contains("lab name and value", QuestionValidator.Validate(request)); + } + [Theory] [InlineData("http://example.org/guideline", "HTTPS")] [InlineData("https://example.org/guideline", null)] diff --git a/api/QuestionsApi.Tests/CreditEconomyTests.cs b/api/QuestionsApi.Tests/CreditEconomyTests.cs new file mode 100644 index 0000000..c63772b --- /dev/null +++ b/api/QuestionsApi.Tests/CreditEconomyTests.cs @@ -0,0 +1,21 @@ +using QuestionsApi.Models; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class CreditEconomyTests +{ + [Fact] + public void EarningPolicy_UsesTheAgreedRollingLimit() + { + Assert.Equal(20, CreditEconomy.RollingEarningLimit); + Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow); + } + + [Fact] + public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers() + { + Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId))); + Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings))); + } +} diff --git a/api/QuestionsApi/Controllers/CreditsController.cs b/api/QuestionsApi/Controllers/CreditsController.cs new file mode 100644 index 0000000..198fe60 --- /dev/null +++ b/api/QuestionsApi/Controllers/CreditsController.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Services; +using System.Security.Claims; + +namespace QuestionsApi.Controllers; + +[ApiController] +[Route("api/credits")] +[Authorize(Roles = "USER,SUPER")] +public sealed class CreditsController(CreditStore credits) : ControllerBase +{ + [HttpGet] + public async Task Get() => Ok(await credits.GetStatusAsync(UserId())); + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? throw new UnauthorizedAccessException(); +} diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 221be3d..50b18d6 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -19,6 +19,7 @@ public class QuestionsController : ControllerBase private readonly ServiceMetrics _metrics; private readonly QuestionBookmarkStore _bookmarks; private readonly ReviewQueueStore _reviewQueue; + private readonly CreditStore _credits; public QuestionsController( QuestionStore questions, @@ -28,7 +29,8 @@ public class QuestionsController : ControllerBase AnswerStatisticsStore statistics, ServiceMetrics metrics, QuestionBookmarkStore bookmarks, - ReviewQueueStore reviewQueue) + ReviewQueueStore reviewQueue, + CreditStore credits) { _questions = questions; _attempts = attempts; @@ -38,6 +40,7 @@ public class QuestionsController : ControllerBase _metrics = metrics; _bookmarks = bookmarks; _reviewQueue = reviewQueue; + _credits = credits; } [HttpGet("systems")] @@ -172,6 +175,8 @@ public class QuestionsController : ControllerBase _metrics.AttemptDuplicate(); } await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt); + if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit) + await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? ""); var ratingOwner = Guid.NewGuid().ToString("N"); var shouldRate = await _attempts.TryAcquireRatingLeaseAsync( @@ -204,7 +209,8 @@ public class QuestionsController : ControllerBase question, await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)), QuestionElo = rating.QuestionElo, - Rating = rating + Rating = rating, + Credits = await _credits.GetStatusAsync(userId) }); } diff --git a/api/QuestionsApi/Models/CreditModels.cs b/api/QuestionsApi/Models/CreditModels.cs new file mode 100644 index 0000000..50f4aea --- /dev/null +++ b/api/QuestionsApi/Models/CreditModels.cs @@ -0,0 +1,35 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public static class CreditEconomy +{ + public const int RollingEarningLimit = 20; + public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24); +} + +public sealed class CreditEvent +{ + public string OperationId { get; set; } = ""; + public DateTime CreatedUtc { get; set; } +} + +public sealed class UserCreditAccount +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string UserId { get; set; } = ""; + public int Credits { get; set; } + public List RecentEarnings { get; set; } = []; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class CreditStatusResponse +{ + public int Credits { get; set; } + public int EarnedInRollingWindow { get; set; } + public int RemainingEarningCapacity { get; set; } + public int RollingWindowHours { get; set; } = 24; +} diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 7004cef..288c01e 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -49,4 +49,5 @@ public class FirstQuestionAttemptRequest public string ChoiceId { get; set; } = ""; public List PresentedChoiceIds { get; set; } = []; public int DurationSeconds { get; set; } + public bool MetTimeLimit { get; set; } = true; } diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index 0c636ba..97e70fb 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -141,6 +141,7 @@ public class FirstQuestionAttemptResponse public Dictionary ChoiceSelectionPercentages { get; set; } = []; public int QuestionElo { get; set; } = 1000; public RatingChangeResponse? Rating { get; set; } + public CreditStatusResponse? Credits { get; set; } } public class SkipQuestionResponse diff --git a/api/QuestionsApi/Models/RatingModels.cs b/api/QuestionsApi/Models/RatingModels.cs index fc6ff9d..7384330 100644 --- a/api/QuestionsApi/Models/RatingModels.cs +++ b/api/QuestionsApi/Models/RatingModels.cs @@ -47,4 +47,6 @@ public class RatingProfileResponse public int OverallElo { get; set; } public int RatedQuestionCount { get; set; } public Dictionary CategoryElos { get; set; } = []; + public string Tier { get; set; } = ""; + public int TierDivision { get; set; } } diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index c04924b..7daeda7 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -66,6 +66,8 @@ builder.Services.Configure(options => }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/api/QuestionsApi/Services/CreditStore.cs b/api/QuestionsApi/Services/CreditStore.cs new file mode 100644 index 0000000..5e6ee86 --- /dev/null +++ b/api/QuestionsApi/Services/CreditStore.cs @@ -0,0 +1,84 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class CreditStore +{ + private readonly IMongoCollection _accounts; + private readonly TimeProvider _clock; + + public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock) + { + _accounts = mongo.Database.GetCollection("UserCreditAccounts"); + _clock = clock; + _accounts.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId), + new CreateIndexOptions { Unique = true, Name = "uq_credit_user" })); + } + + public async Task AwardForFirstAnswerAsync(string userId, string attemptId) + { + if (string.IsNullOrWhiteSpace(attemptId)) return false; + var now = _clock.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + + var filters = Builders.Filter; + var belowCap = new BsonDocumentFilterDefinition( + new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray + { + new BsonDocument("$size", "$RecentEarnings"), + CreditEconomy.RollingEarningLimit + }))); + var filter = filters.Eq(value => value.UserId, userId) & + filters.Not(filters.ElemMatch(value => value.RecentEarnings, + earning => earning.OperationId == attemptId)) & + belowCap; + var update = Builders.Update + .Inc(value => value.Credits, 1) + .Push(value => value.RecentEarnings, new CreditEvent + { + OperationId = attemptId, + CreatedUtc = now + }) + .Set(value => value.UpdatedUtc, now); + return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1; + } + + public async Task GetStatusAsync(string userId) + { + var now = _clock.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + var account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); + return new CreditStatusResponse + { + Credits = account.Credits, + EarnedInRollingWindow = account.RecentEarnings.Count, + RemainingEarningCapacity = Math.Max( + 0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count) + }; + } + + private async Task EnsureAndCleanAsync(string userId, DateTime now) + { + await _accounts.UpdateOneAsync( + value => value.UserId == userId, + Builders.Update + .SetOnInsert(value => value.UserId, userId) + .SetOnInsert(value => value.Credits, 0) + .SetOnInsert(value => value.RecentEarnings, []) + .SetOnInsert(value => value.UpdatedUtc, now), + new UpdateOptions { IsUpsert = true }); + await _accounts.UpdateOneAsync( + Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false), + Builders.Update.Set(value => value.RecentEarnings, [])); + var cutoff = now - CreditEconomy.EarningWindow; + await _accounts.UpdateOneAsync( + value => value.UserId == userId, + Builders.Update.PullFilter( + value => value.RecentEarnings, + earning => earning.CreatedUtc <= cutoff)); + } +} diff --git a/api/QuestionsApi/Services/QuestionValidator.cs b/api/QuestionsApi/Services/QuestionValidator.cs index bf3e117..78feef6 100644 --- a/api/QuestionsApi/Services/QuestionValidator.cs +++ b/api/QuestionsApi/Services/QuestionValidator.cs @@ -68,11 +68,31 @@ public static class QuestionValidator if (string.IsNullOrWhiteSpace(req.System)) return "system required"; if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required"; if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5"; + var labValidation = ValidateLabs(req.Labs, req.IsPublished); + if (labValidation is not null) return labValidation; var referenceValidation = ValidateReferences(req.References, req.IsPublished); if (referenceValidation is not null) return referenceValidation; return null; } + public static string? ValidateLabs(IReadOnlyCollection? labs, bool requireComplete) + { + if (labs is null) + return "labs must be an array"; + if (labs.Count > 50) + return "questions support at most fifty lab results"; + + foreach (var lab in labs) + { + if (string.IsNullOrWhiteSpace(lab.Name) || string.IsNullOrWhiteSpace(lab.Value)) + return "lab name and value are required"; + if (requireComplete && string.IsNullOrWhiteSpace(lab.Reference)) + return $"published question lab '{lab.Name.Trim()}' requires a reference range"; + } + + return null; + } + public static string? ValidateReferences(IReadOnlyCollection? references, bool required) { if (references is null) diff --git a/api/QuestionsApi/Services/RatingStore.cs b/api/QuestionsApi/Services/RatingStore.cs index 0d3d790..521d336 100644 --- a/api/QuestionsApi/Services/RatingStore.cs +++ b/api/QuestionsApi/Services/RatingStore.cs @@ -27,14 +27,43 @@ public class RatingStore public async Task GetProfileAsync(string userId) { var rating = await GetOrCreateUserRatingAsync(userId); + var (tierName, tierDivision) = GetTier(rating.OverallElo); return new RatingProfileResponse { OverallElo = rating.OverallElo, RatedQuestionCount = rating.RatedQuestionCount, - CategoryElos = rating.CategoryElos + CategoryElos = rating.CategoryElos, + Tier = tierName, + TierDivision = tierDivision }; } + private static readonly (string Name, int FloorElo)[] TierBands = + [ + ("Bronze", MinElo), + ("Silver", 1000), + ("Gold", 1300), + ("Platinum", 1900), + ("Diamond", 2200) + ]; + + private static (string Name, int Division) GetTier(int elo) + { + var bandIndex = 0; + for (var i = 0; i < TierBands.Length; i++) + { + if (elo >= TierBands[i].FloorElo) + bandIndex = i; + } + + var band = TierBands[bandIndex]; + var nextFloor = bandIndex + 1 < TierBands.Length ? TierBands[bandIndex + 1].FloorElo : band.FloorElo + 300; + var bandWidth = nextFloor - band.FloorElo; + var progress = bandWidth > 0 ? (elo - band.FloorElo) / (double)bandWidth : 0; + var division = 3 - Math.Clamp((int)(progress * 3), 0, 2); + return (band.Name, division); + } + public async Task GetOrInitializeHistoryAsync(string userId, DateTime nowUtc) { var rating = await GetOrCreateUserRatingAsync(userId); diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index 2e3c086..50f9afe 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -19,7 +19,8 @@ public sealed class UserProfileStore "Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles", "ExternalIdentities", "ExternalAuthTransactions", "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", - "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews" + "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews", + "UserCreditAccounts" ]; public UserProfileStore(MongoDatabaseProvider mongo) diff --git a/assets/icons/lucide/shield.svg b/assets/icons/lucide/shield.svg new file mode 100644 index 0000000..779a29c --- /dev/null +++ b/assets/icons/lucide/shield.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/lucide/shield.svg.import b/assets/icons/lucide/shield.svg.import new file mode 100644 index 0000000..33c657a --- /dev/null +++ b/assets/icons/lucide/shield.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b6wxo6lr3s8ll" +path="res://.godot/imported/shield.svg-fbfc812301b797aa0266f7b4e83e6ef3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/shield.svg" +dest_files=["res://.godot/imported/shield.svg-fbfc812301b797aa0266f7b4e83e6ef3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/docs/QUESTION_COMPLETION_CREDITS.md b/docs/QUESTION_COMPLETION_CREDITS.md new file mode 100644 index 0000000..93f2dc3 --- /dev/null +++ b/docs/QUESTION_COMPLETION_CREDITS.md @@ -0,0 +1,19 @@ +# Question-completion credits + +This branch introduces credit earning only. It does not impose a question +limit, redeem credits, sell products, activate passes, consume exam tokens, or +reset QBank progress. + +- A successfully stored first answer earns one credit regardless of whether it + is correct. +- A repeated answer does not earn another credit. +- A skipped question does not earn credit. +- At most 20 credits can be earned in a rolling 24-hour window. +- Historical attempts are not backfilled. +- `GET /api/credits` returns the authenticated user's balance and current + rolling-window capacity. +- The first-attempt response also includes the updated credit status. + +The API is authoritative. Credit grants use the immutable attempt ID as their +idempotency key, so retrying the same completed question cannot grant another +credit. Credit accounts are included in account export and deletion. diff --git a/project.godot b/project.godot index 122d1a6..3fad8fc 100644 --- a/project.godot +++ b/project.godot @@ -13,14 +13,13 @@ config_version=5 config/name="RanaZe: Step 3 Trainer" config/version="pre-alpha" run/main_scene="res://scenes/main.tscn" -config/features=PackedStringArray("4.6", "GL Compatibility") -config/icon="res://assets/branding/ranaze-app-icon.png" config/use_custom_user_dir=true config/custom_user_dir_name="MedMind" +config/features=PackedStringArray("4.6", "GL Compatibility") boot_splash/bg_color=Color(0.019607844, 0.02745098, 0.05882353, 1) +boot_splash/stretch_mode=0 boot_splash/image="res://assets/branding/ranaze-mark.png" -boot_splash/fullsize=false -boot_splash/use_filter=true +config/icon="res://assets/branding/ranaze-app-icon.png" [autoload] @@ -40,6 +39,7 @@ 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" +CreditsService="*res://scripts/autoload/credits_service.gd" FlashcardService="*res://scripts/autoload/flashcard_service.gd" AccountProfileService="*res://scripts/autoload/account_profile_service.gd" DonationService="*res://scripts/autoload/donation_service.gd" @@ -57,9 +57,9 @@ window/stretch/aspect="expand" [rendering] +rendering_device/driver.windows="d3d12" renderer/rendering_method="gl_compatibility" renderer/rendering_method.mobile="gl_compatibility" -rendering_device/driver.windows="d3d12" textures/vram_compression/import_etc2_astc=true [step3] diff --git a/scripts/autoload/credits_service.gd b/scripts/autoload/credits_service.gd new file mode 100644 index 0000000..4f05a7a --- /dev/null +++ b/scripts/autoload/credits_service.gd @@ -0,0 +1,67 @@ +extends Node + +signal credits_changed +signal credits_failed(message: String) + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_URL := "https://us3q.ranaze.com/api/credits" + +var credits: int = 0 +var loaded := false +var loading := false +var request: HTTPRequest +var request_token := "" +var api_url := DEFAULT_URL + + +func _ready() -> void: + api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/credits_api_url", DEFAULT_URL)), + "/api/credits") + request = HTTPRequest.new() + request.timeout = 20.0 + request.request_completed.connect(_on_completed) + add_child(request) + AuthState.auth_changed.connect(_on_auth_changed) + + +func refresh() -> void: + if loading or not AuthState.can_send_authenticated_request(): + return + loading = true + request_token = AuthState.access_token + var headers := PackedStringArray(["Authorization: Bearer %s" % request_token]) + var error := request.request(api_url, headers, HTTPClient.METHOD_GET) + if error != OK: + loading = false + credits_failed.emit("Points balance could not be loaded. Check your connection and try again.") + + +func apply_status(parsed: Dictionary) -> void: + credits = int(parsed.get("credits", credits)) + loaded = true + credits_changed.emit() + + +func _on_completed(result: int, response_code: int, _headers: 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: + credits_failed.emit("Points balance 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: + credits_failed.emit("The credits service returned an invalid response.") + return + apply_status(parsed) + + +func _on_auth_changed() -> void: + if not AuthState.is_logged_in: + credits = 0 + loaded = false + loading = false + if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + request.cancel_request() diff --git a/scripts/autoload/credits_service.gd.uid b/scripts/autoload/credits_service.gd.uid new file mode 100644 index 0000000..c7a746f --- /dev/null +++ b/scripts/autoload/credits_service.gd.uid @@ -0,0 +1 @@ +uid://bi323du7o7dn2 diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 9e4d369..d1ec58e 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -38,6 +38,8 @@ var load_state := "idle" var retry_attempt := 0 var next_retry_seconds := 0 var overall_elo := 1000 +var rank_tier := "Bronze" +var rank_division := 3 var rated_question_count := 0 var category_elos := {} var last_rating_change := {} @@ -246,6 +248,8 @@ func clear_questions() -> void: retry_attempt = 0 next_retry_seconds = 0 overall_elo = 1000 + rank_tier = "Bronze" + rank_division = 3 rated_question_count = 0 category_elos = {} last_rating_change = {} @@ -257,10 +261,10 @@ func clear_questions() -> void: ratings_changed.emit() -func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0) -> void: +func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void: if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "": return - _enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds) + _enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds, met_time_limit) func skip_question(question_id: String) -> void: @@ -386,7 +390,7 @@ func _on_retry_timer_timeout() -> void: load_questions_from_api(false) -func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0) -> void: +func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void: var account := AuthState.username.strip_edges() if account == "": return @@ -400,6 +404,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres "choice_id": choice_id, "presented_choice_ids": presented_choice_ids.duplicate(), "duration_seconds": maxi(0, duration_seconds), + "met_time_limit": met_time_limit, "retry_count": 0 }) _save_attempt_queue() @@ -432,7 +437,8 @@ func _process_attempt_queue() -> void: "questionExternalId": question_id, "choiceId": str(queued.get("choice_id", "")), "presentedChoiceIds": queued.get("presented_choice_ids", []), - "durationSeconds": int(queued.get("duration_seconds", 0)) + "durationSeconds": int(queued.get("duration_seconds", 0)), + "metTimeLimit": bool(queued.get("met_time_limit", true)) }) if str(queued.get("kind", "answer")) == "skip": url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()] @@ -507,6 +513,9 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void: var rating: Dictionary = parsed.get("rating", {}) _update_question_elo(question_id, question_elo) _apply_rating_change(rating) + var credits_status: Variant = parsed.get("credits", null) + if typeof(credits_status) == TYPE_DICTIONARY: + CreditsService.apply_status(credits_status) choice_percentages_updated.emit(question_id, percentages) question_rating_updated.emit(question_id, question_elo, rating) @@ -612,6 +621,8 @@ func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringA return overall_elo = int(parsed.get("overallElo", parsed.get("overall_elo", 1000))) + rank_tier = str(parsed.get("tier", rank_tier)) + rank_division = int(parsed.get("tierDivision", parsed.get("tier_division", rank_division))) rated_question_count = int(parsed.get("ratedQuestionCount", parsed.get("rated_question_count", 0))) category_elos = parsed.get("categoryElos", parsed.get("category_elos", {})) ratings_changed.emit() diff --git a/scripts/ui/credits_badge.gd b/scripts/ui/credits_badge.gd new file mode 100644 index 0000000..583d3de --- /dev/null +++ b/scripts/ui/credits_badge.gd @@ -0,0 +1,165 @@ +extends PanelContainer + +const Brand := preload("res://scripts/ui/medmind_style.gd") +const SHIELD_ICON := preload("res://assets/icons/lucide/shield.svg") +const STAR_ICON := preload("res://assets/icons/lucide/star.svg") +const CHEVRON_ICON := preload("res://assets/icons/lucide/chevron-down.svg") + +var rank_icon_panel: PanelContainer +var rank_icon_texture: TextureRect +var rank_tier_label: Label +var rank_elo_label: Label +var divider: ColorRect + +var icon_panel: PanelContainer +var icon_texture: TextureRect +var value_label: Label +var caption_label: Label +var displayed_value := 0 +var _initialized := false + + +func _ready() -> void: + size_flags_vertical = Control.SIZE_SHRINK_CENTER + _build_ui() + refresh_theme_controls() + _on_credits_changed() + _on_ratings_changed() + CreditsService.credits_changed.connect(_on_credits_changed) + QuestionBankService.ratings_changed.connect(_on_ratings_changed) + resized.connect(func(): pivot_offset = size * 0.5) + + +func _build_ui() -> void: + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 7) + margin.add_theme_constant_override("margin_right", 7) + margin.add_theme_constant_override("margin_top", 5) + margin.add_theme_constant_override("margin_bottom", 5) + add_child(margin) + + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 5) + margin.add_child(row) + + rank_icon_panel = PanelContainer.new() + rank_icon_panel.custom_minimum_size = Vector2(22, 22) + rank_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + rank_icon_texture = TextureRect.new() + rank_icon_texture.texture = SHIELD_ICON + rank_icon_texture.custom_minimum_size = Vector2(13, 13) + rank_icon_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + rank_icon_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + rank_icon_panel.add_child(rank_icon_texture) + row.add_child(rank_icon_panel) + + var rank_copy := VBoxContainer.new() + rank_copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER + rank_copy.add_theme_constant_override("separation", 0) + rank_tier_label = Label.new() + rank_tier_label.add_theme_font_override("font", Brand.font) + rank_tier_label.add_theme_font_size_override("font_size", 13) + rank_copy.add_child(rank_tier_label) + rank_elo_label = Label.new() + rank_elo_label.add_theme_font_override("font", Brand.font) + rank_elo_label.add_theme_font_size_override("font_size", 9) + rank_copy.add_child(rank_elo_label) + row.add_child(rank_copy) + + divider = ColorRect.new() + divider.custom_minimum_size = Vector2(1, 22) + divider.size_flags_vertical = Control.SIZE_SHRINK_CENTER + row.add_child(divider) + + icon_panel = PanelContainer.new() + icon_panel.custom_minimum_size = Vector2(22, 22) + icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + icon_texture = TextureRect.new() + icon_texture.texture = STAR_ICON + icon_texture.custom_minimum_size = Vector2(13, 13) + icon_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon_panel.add_child(icon_texture) + row.add_child(icon_panel) + + var copy := VBoxContainer.new() + copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER + copy.add_theme_constant_override("separation", 0) + value_label = Label.new() + value_label.add_theme_font_override("font", Brand.font) + value_label.add_theme_font_size_override("font_size", 13) + copy.add_child(value_label) + caption_label = Label.new() + caption_label.text = "Rz Coins" + caption_label.add_theme_font_override("font", Brand.font) + caption_label.add_theme_font_size_override("font_size", 9) + copy.add_child(caption_label) + row.add_child(copy) + + var chevron := TextureRect.new() + chevron.texture = CHEVRON_ICON + chevron.custom_minimum_size = Vector2(12, 12) + chevron.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + chevron.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + chevron.mouse_filter = Control.MOUSE_FILTER_IGNORE + chevron.size_flags_vertical = Control.SIZE_SHRINK_CENTER + row.add_child(chevron) + + +func refresh_theme_controls() -> void: + var dark := UIThemeService.is_dark_mode() + add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE)) + rank_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 11)) + rank_icon_texture.modulate = Brand.ROYAL_BLUE + rank_tier_label.add_theme_color_override("font_color", Brand.text_color(dark)) + rank_elo_label.add_theme_color_override("font_color", Brand.ROYAL_BLUE) + divider.color = Color("2a365a") if dark else Brand.LIGHT_BORDER + icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 11)) + icon_texture.modulate = Brand.AMBER + value_label.add_theme_color_override("font_color", Brand.text_color(dark)) + caption_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + + +func bump() -> void: + var tween := create_tween() + tween.tween_property(self, "scale", Vector2(1.12, 1.12), 0.12).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + tween.tween_property(self, "scale", Vector2.ONE, 0.18).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) + + +func _on_ratings_changed() -> void: + var roman := {1: "I", 2: "II", 3: "III"} + var division_label: String = roman.get(QuestionBankService.rank_division, "") + rank_tier_label.text = "%s %s" % [QuestionBankService.rank_tier, division_label] + rank_elo_label.text = "%d ELO" % QuestionBankService.overall_elo + + +func _on_credits_changed() -> void: + if not CreditsService.loaded: + _initialized = false + displayed_value = 0 + value_label.text = "--" + return + if not _initialized: + _initialized = true + displayed_value = CreditsService.credits + value_label.text = _format_thousands(displayed_value) + return + var tween := create_tween() + tween.tween_method(_set_displayed_value, displayed_value, CreditsService.credits, 0.5).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + + +func _set_displayed_value(value: int) -> void: + displayed_value = value + value_label.text = _format_thousands(value) + + +func _format_thousands(value: int) -> String: + var digits := str(absi(value)) + var grouped := "" + var count := 0 + for index in range(digits.length() - 1, -1, -1): + grouped = digits[index] + grouped + count += 1 + if count % 3 == 0 and index != 0: + grouped = "," + grouped + return ("-" if value < 0 else "") + grouped diff --git a/scripts/ui/credits_badge.gd.uid b/scripts/ui/credits_badge.gd.uid new file mode 100644 index 0000000..384aa68 --- /dev/null +++ b/scripts/ui/credits_badge.gd.uid @@ -0,0 +1 @@ +uid://doisoxqnk1mf3 diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index dbbdc5f..dacb8dc 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -3,6 +3,7 @@ extends Control const Style := preload("res://scripts/ui/ui_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd") const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd") +const CreditsBadge := preload("res://scripts/ui/credits_badge.gd") var screens := {} var _account_profile_synced := false @@ -29,6 +30,7 @@ var drawer_open := false var menu_button: Button var account_button: Button var account_chevron_button: Button +var credits_badge: PanelContainer var account_menu: PanelContainer var nav_buttons: Dictionary = {} var current_nav_key := "home" @@ -204,6 +206,9 @@ func _build_ui() -> void: _style_account_chevron(account_chevron_button) header.add_child(account_chevron_button) + credits_badge = CreditsBadge.new() + header.add_child(credits_badge) + var screens_container := MarginContainer.new() screens_margin = screens_container screens_host = screens_container @@ -769,6 +774,7 @@ func _on_auth_changed() -> void: if not _account_profile_synced: _account_profile_synced = true AccountProfileService.refresh() + CreditsService.refresh() else: _account_profile_synced = false if AuthState.is_logged_in and AuthState.requires_legal_acceptance and current_screen_name not in ["login", "legal_consent"]: @@ -797,6 +803,8 @@ func _on_theme_changed(is_dark_mode: bool) -> void: _style_account_avatar(account_button) if account_chevron_button != null: _style_account_chevron(account_chevron_button) + if credits_badge != null: + credits_badge.refresh_theme_controls() if sidebar != null: sidebar.add_theme_stylebox_override("panel", _sidebar_box()) if header_panel != null: @@ -896,7 +904,7 @@ func _build_app_menu() -> void: menu_exit_button.text = "Exit Run" menu_exit_button.custom_minimum_size = Vector2(0, 48) UISoundService.set_button_sound(menu_exit_button, "back") - menu_exit_button.pressed.connect(_exit_run) + menu_exit_button.pressed.connect(exit_current_run) content.add_child(menu_exit_button) var logout := Button.new() @@ -943,7 +951,7 @@ func _navigate_from_pause(target_screen: String) -> void: BlockSessionService.pause_current_question_timer() -func _exit_run() -> void: +func exit_current_run() -> void: _close_app_menu() if current_screen_name == "ccs_player": var ccs_player = screens.get("ccs_player") diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index ec200db..e631b5b 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -4,6 +4,7 @@ const Style := preload("res://scripts/ui/ui_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd") const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd") const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd") +const CreditsBadge := preload("res://scripts/ui/credits_badge.gd") const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg") const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg") const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg") @@ -14,6 +15,7 @@ const FLAG_DARK_ICON := preload("res://assets/icons/medmind/flag-dark.svg") const FLAG_LIGHT_ICON := preload("res://assets/icons/medmind/flag-light.svg") const BADGE_CIRCLE_OUTLINE_ICON := preload("res://assets/icons/medmind/badge-circle-outline.svg") const BADGE_CIRCLE_FILLED_ICON := preload("res://assets/icons/medmind/badge-circle-filled.svg") +const COIN_ICON := preload("res://assets/icons/lucide/star.svg") const FEEDBACK_FADE_SECONDS := 0.45 const CORRECT_ADVANCE_DELAY_SECONDS := 1.25 const TEXT_SCALE_MIN := 0.85 @@ -90,6 +92,7 @@ var text_smaller_button: Button var text_larger_button: Button var labs_button: Button var calculator_button: Button +var exit_button: Button var clear_highlight_button: Button var flag_button: Button var report_button: Button @@ -99,6 +102,7 @@ var flashcard_prefill_dialog: Control var bookmark_button: Button var app_feedback_button: Button var theme_button: Button +var credits_badge: PanelContainer var question_toolbar: HFlowContainer var header_stack: VBoxContainer var header_row: HBoxContainer @@ -317,6 +321,18 @@ func _ready() -> void: UISoundService.set_button_sound(text_larger_button, "toggle") text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP)) + exit_button = Button.new() + exit_button.name = "ExitButton" + exit_button.tooltip_text = "Return to main area" + exit_button.icon = preload("res://assets/icons/lucide/house.svg") + exit_button.custom_minimum_size = Vector2(44, 44) + exit_button.add_theme_constant_override("icon_max_width", 22) + exit_button.alignment = HORIZONTAL_ALIGNMENT_CENTER + exit_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + exit_button.focus_mode = Control.FOCUS_NONE + UISoundService.set_button_sound(exit_button, "back") + exit_button.pressed.connect(func(): main.exit_current_run()) + calculator_button = Button.new() calculator_button.name = "CalculatorButton" calculator_button.tooltip_text = "Open calculator" @@ -404,10 +420,13 @@ func _ready() -> void: Style.style_chip(app_feedback_button, Style.CYAN) app_feedback_button.pressed.connect(func(): main.open_feedback_dialog()) + credits_badge = CreditsBadge.new() + # Every action is directly visible - no "More" overflow or popups hiding # controls behind an extra click. No text labels either (their varying widths # broke even spacing when a button hid itself) - each button's existing # tooltip_text carries the label as hover text instead. + question_toolbar.add_child(exit_button) question_toolbar.add_child(text_smaller_button) question_toolbar.add_child(text_larger_button) question_toolbar.add_child(calculator_button) @@ -420,6 +439,7 @@ func _ready() -> void: question_toolbar.add_child(notes_button) question_toolbar.add_child(theme_button) question_toolbar.add_child(app_feedback_button) + question_toolbar.add_child(credits_badge) header_toolbar_host = HBoxContainer.new() header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END @@ -788,9 +808,10 @@ func _refresh_responsive_layout() -> void: header_toolbar_host.reparent(target_parent) header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL - # 11 bare 44px icon buttons + the wider 92px Feedback button, with 8px gaps - # between all 12 - anything less than this wraps onto a second row. - header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 680.0 + # 12 bare 44px icon buttons + the wider 92px Feedback button + the ~194px + # rank/ELO + points badge, with 8px gaps between all 14 - anything less + # than this wraps onto a second row. + header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 960.0 if size.x < 560.0: nav_palette.columns = 5 else: @@ -1046,7 +1067,7 @@ func _apply_question_theme() -> void: var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label if navigator_heading != null: navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark)) - for control in [text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]: + for control in [exit_button, text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]: if control == null: continue var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", ""))) @@ -1062,6 +1083,8 @@ func _apply_question_theme() -> void: _style_theme_toggle_button(dark) if calculator_panel != null: calculator_panel.refresh_theme_controls() + if credits_badge != null: + credits_badge.refresh_theme_controls() if skip_button != null: var skip_link_color := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE skip_button.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) @@ -1543,12 +1566,15 @@ func _submit_or_next() -> void: return if BlockSessionService.session_kind in ["core", "bookmark", "review"]: var attempted_choice := selected_choice + var met_time_limit := true if BlockSessionService.session_kind == "core": + met_time_limit = BlockSessionService.get_current_elapsed_seconds() <= BlockSessionService.core_current_target_seconds QuestionBankService.record_first_attempt( question_id_for_attempt, attempted_choice, _presented_choice_ids(), - int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())) + int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())), + met_time_limit ) var result: Dictionary if BlockSessionService.session_kind == "core": @@ -1571,6 +1597,15 @@ func _submit_or_next() -> void: _update_question_elo_label(true) _show_answer_review() _trigger_correct_feedback() + var earned_coin := ( + BlockSessionService.session_kind == "core" + and bool(result.get("mastered", false)) + and met_time_limit + ) + if earned_coin: + var correct_choice_index := _choice_index_for_id(selected_choice) + if correct_choice_index >= 0 and correct_choice_index < choice_rows.size(): + _play_coin_burst(choice_rows[correct_choice_index].get_global_rect()) return _lock_wrong_choice(attempted_choice) _show_wrong_answer_followup(attempted_choice) @@ -2163,6 +2198,39 @@ func _trigger_correct_feedback() -> void: feedback_fog.color = Color(0.25, 1.0, 0.25, _feedback_overlay_alpha(true)) +func _play_coin_burst(origin_rect: Rect2) -> void: + if credits_badge == null: + return + var origin := origin_rect.get_center() + var target := credits_badge.get_global_rect().get_center() + var coin_count := 6 + var flight_time := 0.55 + var stagger := 0.035 + for index in range(coin_count): + var coin := TextureRect.new() + coin.texture = COIN_ICON + coin.modulate = Brand.AMBER + coin.custom_minimum_size = Vector2(16, 16) + coin.size = Vector2(16, 16) + coin.top_level = true + coin.mouse_filter = Control.MOUSE_FILTER_IGNORE + coin.pivot_offset = Vector2(8, 8) + var jitter := Vector2(randf_range(-24.0, 24.0), randf_range(-16.0, 16.0)) + coin.global_position = origin + jitter - coin.size * 0.5 + add_child(coin) + var tween := create_tween() + tween.tween_interval(index * stagger) + tween.set_parallel(true) + tween.tween_property(coin, "global_position", target - coin.size * 0.5, flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN) + tween.tween_property(coin, "scale", Vector2(0.6, 0.6), flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN) + tween.set_parallel(false) + tween.tween_callback(coin.queue_free) + var settle_time := (coin_count - 1) * stagger + flight_time + get_tree().create_timer(settle_time).timeout.connect(func(): + if credits_badge != null: + credits_badge.bump()) + + func _update_answer_feedback(delta: float) -> void: if shake_time > 0.0: shake_time = max(0.0, shake_time - delta) diff --git a/tools/backfill_lab_references.py b/tools/backfill_lab_references.py new file mode 100644 index 0000000..d531d9d --- /dev/null +++ b/tools/backfill_lab_references.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Audit or backfill missing question lab reference ranges. + +Ranges are based primarily on the NBME Laboratory Reference Values published +April 2026. The command is dry-run by default and writes a recoverable MongoDB +backup plus an audit record before applying changes. +""" + +import argparse +import copy +import uuid +from datetime import datetime, timezone + +from pymongo import MongoClient + + +NBME = { + "sodium": "136–146 mEq/L", "serum sodium": "136–146 mEq/L", + "potassium": "3.5–5.0 mEq/L", "serum potassium": "3.5–5.0 mEq/L", + "chloride": "95–105 mEq/L", "serum chloride": "95–105 mEq/L", + "bicarbonate": "22–28 mEq/L", "serum bicarbonate": "22–28 mEq/L", "hco3-": "22–28 mEq/L", + "blood urea nitrogen": "7–18 mg/dL", "serum urea nitrogen": "7–18 mg/dL", + "urea nitrogen": "7–18 mg/dL", "bun": "7–18 mg/dL", + "creatinine": "0.6–1.2 mg/dL", "serum creatinine": "0.6–1.2 mg/dL", + "baseline creatinine": "0.6–1.2 mg/dL", "postprocedure day 1 creatinine": "0.6–1.2 mg/dL", + "postprocedure day 2 creatinine": "0.6–1.2 mg/dL", + "glucose": "Fasting 70–100; random <140 mg/dL", "serum glucose": "Fasting 70–100; random <140 mg/dL", + "fasting glucose": "70–100 mg/dL", "fingerstick glucose": "70–100 mg/dL", + "point-of-care glucose": "70–100 mg/dL", "plasma glucose during episode": "70–100 mg/dL fasting", + "calcium": "8.4–10.2 mg/dL", "magnesium": "1.5–2.0 mg/dL", + "phosphorus": "3.0–4.5 mg/dL", "albumin": "3.5–5.5 g/dL", + "ast": "12–38 U/L", "aspartate aminotransferase": "12–38 U/L", + "alt": "10–40 U/L", "alanine aminotransferase": "10–40 U/L", + "alkaline phosphatase": "25–100 U/L", "amylase": "25–125 U/L", "lipase": "13–60 U/L", + "total bilirubin": "0.1–1.0 mg/dL", "serum bilirubin": "0.1–1.0 mg/dL", + "direct bilirubin": "0.0–0.3 mg/dL", "lactate dehydrogenase": "45–200 U/L", + "serum osmolality": "275–295 mOsm/kg H2O", "serum uric acid": "3.0–8.2 mg/dL", + "creatine kinase": "Male 25–90; female 10–70 U/L", + "total cholesterol": "Normal <200 mg/dL", "ldl cholesterol": "<160 mg/dL", + "ldl-cholesterol": "<160 mg/dL", "hdl cholesterol": "40–60 mg/dL", + "triglycerides": "Normal <150 mg/dL", "serum ferritin": "Male 20–250; female 10–120 ng/mL", + "tsh": "0.4–4.0 mIU/L", "thyroid-stimulating hormone": "0.4–4.0 mIU/L", + "free t4": "0.9–1.7 ng/dL", "total t3": "100–200 ng/dL", + "serum ige": "0–380 IU/mL", "ph": "7.35–7.45", "pco2": "33–45 mm Hg", + "paco2": "33–45 mm Hg", "arterial pco2": "33–45 mm Hg", + "pao2": "75–105 mm Hg", "po2": "75–105 mm Hg", "arterial po2": "75–105 mm Hg", + "arterial ph": "7.35–7.45", + "hemoglobin": "Male 13.5–17.5; female 12.0–16.0 g/dL", + "hematocrit": "Male 41%–53%; female 36%–46%", + "mean corpuscular volume": "80–100 fL", "mcv": "80–100 fL", + "leukocyte count": "4,500–11,000/mm3", "white blood cell count": "4,500–11,000/mm3", + "wbc count": "4,500–11,000/mm3", "wbc": "4,500–11,000/mm3", + "platelet count": "150,000–400,000/mm3", "platelets": "150,000–400,000/mm3", + "segmented neutrophils": "54%–62%", "band neutrophils": "3%–5%", + "lymphocytes": "25%–33%", "blood eosinophils": "1%–3%", + "pt": "11–15 seconds", "prothrombin time": "11–15 seconds", + "ptt": "25–40 seconds", "activated partial thromboplastin time": "25–40 seconds", + "inr": "0.8–1.2", "d-dimer": "≤250 ng/mL", "fibrinogen": "200–400 mg/dL", + "reticulocyte count": "0.5%–1.5%", "cd4 count": "≥500/mm3", + "hemoglobin a1c": "≤6%", "erythrocyte sedimentation rate": "Male 0–15; female 0–20 mm/h", + "urine osmolality": "50–1200 mOsm/kg H2O", "urine specific gravity": "1.005–1.030", + "urine albumin/creatinine ratio": "<30 mg/g", "urine albumin-to-creatinine ratio": "<30 mg/g", + "urine albumin-creatinine ratio": "<30 mg/g", "urine protein": "Negative; total <150 mg/24 h", + "urine wbc": "0–5/hpf", "urine wbcs": "0–5/hpf", "urine white blood cells": "0–5/hpf", + "urine leukocytes": "0–5/hpf", "urine rbc": "0–2/hpf", "urine rbcs": "0–2/hpf", + "urine erythrocytes": "0–2/hpf", "rbcs": "0–2/hpf", "wbcs": "0–5/hpf", + "bmi": "Adult 19–25 kg/m2", "cd4+ t-lymphocyte count": "≥500/mm3", + "c-reactive protein": "<1.0 mg/dL", "lactate": "0.5–2.2 mmol/L", + "vitamin b12": "200–900 pg/mL", "haptoglobin": "30–200 mg/dL", + "25-hydroxyvitamin d": "20–50 ng/mL", "aldolase": "1.0–7.5 U/L", + "bnp": "<100 pg/mL", "high-sensitivity troponin i": "Assay-specific; typically <99th percentile", + "oxygen saturation": "95%–100%", "room-air oxygen saturation": "95%–100%", + "temperature": "36.5–37.5°C", "pulse": "60–100/min", "respirations": "12–20/min", + "blood pressure": "Adult <120/80 mm Hg", "left ventricular ejection fraction": "55%–70%", + "qrs duration": "≤120 milliseconds", "pr interval": "120–200 milliseconds", + "estimated glomerular filtration rate": "≥60 mL/min/1.73 m2", "estimated gfr": "≥60 mL/min/1.73 m2", + "salicylate": "Therapeutic 10–30 mg/dL", "c3": "90–180 mg/dL", "c4": "10–40 mg/dL", + "c1q": "12–22 mg/dL", "synovial fluid leukocyte count": "<200/mm3", +} + +QUALITATIVE = { + "urinalysis", "peripheral blood smear", "serial cardiac troponins", "resting ecg between episodes", + "exercise treadmill test", "serum electrolytes", "urinalysis - leukocyte esterase", + "urinalysis - nitrites", "urinalysis - white blood cells", "compression ultrasonography", + "stool occult blood", "gastric biopsy", "urine toxicology screen", "repeat liver studies", + "synovial fluid gram stain", "synovial fluid bacterial culture", "synovial fluid crystal analysis", + "pulmonary function testing", "chest ct", "adrenal ct", "urine beta-hcg", "lithium level", + "serum beta-hydroxybutyrate", "antistreptolysin o titer", "fev1", "cbc", + "complete blood count", "comprehensive metabolic panel", "ecg", "echocardiogram", + "maternal hepatitis b surface antigen", "leukocyte esterase", "nitrite", "bacteria", + "urine blood", "urine leukocyte esterase", "urine nitrite", "urine casts", + "urine dipstick blood", "pregnancy test", "urine pregnancy test", + "potassium hydroxide preparation from scale scraping", "fetal karyotype", + "glucose and electrolytes", "cbc, ferritin, zinc, and venous lead", +} + +CUSTOM = { + "urine sodium": "40–220 mEq/24 h (diet dependent)", + "hiv rna": "Not detected", + "urine erythrocytes": "0–2/hpf", "urine leukocytes": "0–5/hpf", + "urine calcium": "100–300 mg/24 h", "urine blood": "Negative", + "urine nitrite": "Negative", "urine leukocyte esterase": "Negative", + "urine dipstick blood": "Negative", "pregnancy test": "Negative", + "urine pregnancy test": "Negative", "urine beta-hcg": "Negative", + "urine casts": "None to rare hyaline casts", + "urine albumin-creatinine ratio": "<30 mg/g", + "urine albumin-to-creatinine ratio": "<30 mg/g", + "urine albumin/creatinine ratio": "<30 mg/g", + "urine protein": "Negative; total <150 mg/24 h", + "ascitic fluid albumin": "Interpret with serum albumin (SAAG)", + "ascitic fluid total protein": "Clinical-context dependent", + "home glucose values": "Individualized target; commonly 80–130 mg/dL premeal", + "oxygen saturation": "95%–100%", "estimated pulmonary artery systolic pressure": "15–30 mm Hg", + "pulmonary-to-systemic flow ratio (qp:qs)": "Approximately 1:1", + "glucose infusion rate": "Age- and clinical-context dependent", + "tuberculin skin test": "Positive at ≥5, ≥10, or ≥15 mm depending on risk", + "urine output": "Adult target ≥0.5 mL/kg/h", +} + + +def embedded_test(value): + lowered = value.lower().strip() + aliases = [ + ("thyroid-stimulating hormone", "thyroid-stimulating hormone"), ("complete metabolic panel", None), + ("complete blood count", None), ("liver function tests", None), ("erythrocyte sedimentation rate", "erythrocyte sedimentation rate"), + ("measured serum osmolality", "serum osmolality"), ("activated partial thromboplastin time", "activated partial thromboplastin time"), + ("platelet count", "platelet count"), ("white blood cell count", "white blood cell count"), + ("blood urea nitrogen", "blood urea nitrogen"), ("oxygen saturation", "oxygen saturation"), + ("prothrombin time", "prothrombin time"), + ("segmented neutrophils", "segmented neutrophils"), ("creatine kinase", "creatine kinase"), + ("hematocrit", "hematocrit"), ("hemoglobin", "hemoglobin"), ("leukocyte count", "leukocyte count"), + ("arterial ph", "ph"), ("serum lipase", "lipase"), ("serum albumin", "albumin"), + ("total cholesterol", "total cholesterol"), ("urine output", "urine output"), + ("tuberculin skin test induration", "tuberculin skin test"), ("hiv rna", "hiv rna"), + ("cd4 count", "cd4 count"), ("pco2", "pco2"), ("po2", "po2"), ("sodium", "sodium"), + ("potassium", "potassium"), ("chloride", "chloride"), ("bicarbonate", "bicarbonate"), + ("hco3", "bicarbonate"), ("glucose", "glucose"), ("creatinine", "creatinine"), + ("calcium", "calcium"), ("magnesium", "magnesium"), ("phosphorus", "phosphorus"), + ("albumin", "albumin"), ("lipase", "lipase"), ("bands", "band neutrophils"), + ("lymphocytes", "lymphocytes"), ("inr", "inr"), ("na ", "sodium"), ("cl ", "chloride"), + ("k ", "potassium"), ("bun ", "bun"), + ] + for prefix, key in aliases: + if lowered.startswith(prefix) or lowered.startswith("admission " + prefix) or lowered.startswith("repeat " + prefix): + return key + return None + + +def reference_for(name, value): + key = name.strip().lower() + if key == "laboratory result": + key = embedded_test(value) + if key is None: + if " normal" in value.lower() or "within reference range" in value.lower(): + return "Normal/within the laboratory reference interval" + return None + if key in CUSTOM: + return CUSTOM[key] + if key in NBME: + return NBME[key] + if key in QUALITATIVE: + return "Expected: normal/negative unless otherwise specified" + return None + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--uri", required=True) + parser.add_argument("--database", required=True) + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + + client = MongoClient(args.uri, serverSelectionTimeoutMS=5000) + db = client[args.database] + questions = db["Questions"] + changes = [] + unresolved = [] + for question in questions.find({"Labs.0": {"$exists": True}}): + labs = question.get("Labs") or [] + original_labs = copy.deepcopy(labs) + updated = False + for lab in labs: + if (lab.get("Reference") or "").strip(): + continue + reference = reference_for(str(lab.get("Name") or ""), str(lab.get("Value") or "")) + if reference is None: + unresolved.append((question.get("ExternalId"), lab.get("Name"), lab.get("Value"))) + else: + lab["Reference"] = reference + updated = True + if updated: + changes.append((question, original_labs, labs)) + + print(f"database={args.database} questions_to_update={len(changes)} unresolved_labs={len(unresolved)}") + for item in unresolved: + print(f"UNRESOLVED externalId={item[0]!r} name={item[1]!r} value={item[2]!r}") + if unresolved: + raise SystemExit("Refusing to apply while lab entries remain unresolved") + if not args.apply: + return + + batch_id = f"lab-reference-{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}-{uuid.uuid4().hex[:8]}" + backups = db["QuestionLabReferenceBackups"] + audit = db["ContentMaintenance"] + now = datetime.now(timezone.utc) + for question, original_labs, labs in changes: + backups.insert_one({ + "BatchId": batch_id, "QuestionId": question["_id"], "ExternalId": question.get("ExternalId"), + "Labs": original_labs, "BackedUpUtc": now, + }) + questions.update_one({"_id": question["_id"]}, {"$set": {"Labs": labs, "UpdatedUtc": now}}) + remaining = questions.count_documents({ + "IsPublished": True, + "Labs": {"$elemMatch": {"$or": [ + {"Reference": {"$exists": False}}, {"Reference": ""}, {"Reference": None} + ]}} + }) + audit.insert_one({ + "BatchId": batch_id, "Operation": "BackfillQuestionLabReferences", + "Database": args.database, "QuestionsUpdated": len(changes), + "RemainingPublishedQuestionsWithMissingReferences": remaining, "CompletedUtc": now, + "Source": "NBME Laboratory Reference Values (April 2026)", + }) + print(f"applied batch={batch_id} questions_updated={len(changes)} remaining_published={remaining}") + if remaining: + raise SystemExit("Post-update verification failed") + + +if __name__ == "__main__": + main()