Merge pull request 'Main' (#13) from main into production

Reviewed-on: admin/step3#13
This commit is contained in:
hz
2026-07-29 01:35:56 -05:00
25 changed files with 907 additions and 20 deletions
+39
View File
@@ -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)]
@@ -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)));
}
}
@@ -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<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
}
@@ -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)
});
}
+35
View File
@@ -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<CreditEvent> 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;
}
@@ -49,4 +49,5 @@ public class FirstQuestionAttemptRequest
public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = [];
public int DurationSeconds { get; set; }
public bool MetTimeLimit { get; set; } = true;
}
@@ -141,6 +141,7 @@ public class FirstQuestionAttemptResponse
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; }
public CreditStatusResponse? Credits { get; set; }
}
public class SkipQuestionResponse
+2
View File
@@ -47,4 +47,6 @@ public class RatingProfileResponse
public int OverallElo { get; set; }
public int RatedQuestionCount { get; set; }
public Dictionary<string, int> CategoryElos { get; set; } = [];
public string Tier { get; set; } = "";
public int TierDivision { get; set; }
}
+2
View File
@@ -66,6 +66,8 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
});
builder.Services.AddSingleton<MongoDatabaseProvider>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CreditStore>();
builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<AnswerStatisticsStore>();
+84
View File
@@ -0,0 +1,84 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class CreditStore
{
private readonly IMongoCollection<UserCreditAccount> _accounts;
private readonly TimeProvider _clock;
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
{
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_clock = clock;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
}
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
{
if (string.IsNullOrWhiteSpace(attemptId)) return false;
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserCreditAccount>.Filter;
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
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<UserCreditAccount>.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<CreditStatusResponse> 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<UserCreditAccount>.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<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff));
}
}
@@ -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<QuestionLab>? 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<QuestionReference>? references, bool required)
{
if (references is null)
+30 -1
View File
@@ -27,14 +27,43 @@ public class RatingStore
public async Task<RatingProfileResponse> 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<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
{
var rating = await GetOrCreateUserRatingAsync(userId);
@@ -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)
+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.79 17 5 19 5a1 1 0 0 1 1 1z" />
</svg>

After

Width:  |  Height:  |  Size: 381 B

+43
View File
@@ -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
+19
View File
@@ -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.
+5 -5
View File
@@ -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]
+67
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://bi323du7o7dn2
+15 -4
View File
@@ -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()
+165
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://doisoxqnk1mf3
+10 -2
View File
@@ -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")
+73 -5
View File
@@ -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)
+230
View File
@@ -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()