using QuestionsApi.Services; namespace QuestionsApi.Models; public class QuestionResponse { public string Id { get; set; } = ""; public string ExternalId { get; set; } = ""; public string Stem { get; set; } = ""; public string LeadIn { get; set; } = ""; public List Choices { get; set; } = []; public string CorrectChoiceId { get; set; } = ""; public string Explanation { get; set; } = ""; public string KeyTakeaway { get; set; } = ""; public string UsmlePearl { get; set; } = ""; public List ReviewFollowups { get; set; } = []; public Dictionary ChoiceExplanations { get; set; } = []; public Dictionary WrongAnswerFollowups { get; set; } = []; public Dictionary ChoiceSelectionPercentages { get; set; } = []; public string ChoiceSelectionRateBasis { get; set; } = "presented"; public string Discipline { get; set; } = ""; public string System { get; set; } = ""; public string Step3System { get; set; } = ""; public string PerformanceCategory { get; set; } = ""; public int Difficulty { get; set; } public int QuestionElo { get; set; } = 1000; public List Labs { get; set; } = []; public List Media { get; set; } = []; public List Tags { get; set; } = []; public List References { get; set; } = []; public bool IsBookmarked { get; set; } public static QuestionResponse FromModel( Question question, Dictionary? liveChoiceSelectionPercentages = null, int questionElo = 1000, bool includeChoicePool = true) { var choices = includeChoicePool ? question.Choices : SelectLegacyChoices(question, liveChoiceSelectionPercentages); var choiceIds = choices.Select(choice => choice.Id).ToHashSet(StringComparer.Ordinal); var percentages = BuildChoiceSelectionPercentages(question, liveChoiceSelectionPercentages); return new QuestionResponse { Id = question.Id ?? "", ExternalId = question.ExternalId, Stem = question.Stem, LeadIn = question.LeadIn, Choices = choices, CorrectChoiceId = question.CorrectChoiceId, Explanation = question.Explanation, KeyTakeaway = question.KeyTakeaway, UsmlePearl = question.UsmlePearl, ReviewFollowups = question.ReviewFollowups, ChoiceExplanations = question.ChoiceExplanations .Where(entry => choiceIds.Contains(entry.Key)) .ToDictionary(), WrongAnswerFollowups = question.WrongAnswerFollowups .Where(entry => choiceIds.Contains(entry.Key)) .ToDictionary(), ChoiceSelectionPercentages = percentages .Where(entry => choiceIds.Contains(entry.Key)) .ToDictionary(), Discipline = question.Discipline, System = question.System, Step3System = Step3SystemCatalog.Normalize(question) ?? "", PerformanceCategory = RatingCategory.Normalize(question.PerformanceCategory, question.System, question.Discipline), Difficulty = question.Difficulty, QuestionElo = questionElo, Labs = question.Labs, Media = question.Media, Tags = question.Tags, References = question.References }; } public static QuestionResponse FromModelWithChoices( Question question, IReadOnlyCollection presentedChoiceIds, int questionElo = 1000) { var response = FromModel(question, questionElo: questionElo, includeChoicePool: true); var ids = presentedChoiceIds.ToHashSet(StringComparer.Ordinal); response.Choices = presentedChoiceIds .Select(id => question.Choices.FirstOrDefault(choice => choice.Id == id)) .Where(choice => choice is not null) .Cast() .ToList(); response.ChoiceExplanations = response.ChoiceExplanations.Where(entry => ids.Contains(entry.Key)).ToDictionary(); response.WrongAnswerFollowups = response.WrongAnswerFollowups.Where(entry => ids.Contains(entry.Key)).ToDictionary(); response.ChoiceSelectionPercentages = response.ChoiceSelectionPercentages.Where(entry => ids.Contains(entry.Key)).ToDictionary(); return response; } private static List SelectLegacyChoices( Question question, IReadOnlyDictionary? liveChoiceSelectionPercentages) { if (question.Choices.Count <= 5) return question.Choices; var selectedIds = ChoicePoolSelector.SelectChoiceIds(question, liveChoiceSelectionPercentages); return selectedIds .Select(id => question.Choices.First(choice => choice.Id == id)) .ToList(); } public static Dictionary BuildChoiceSelectionPercentages(Question question, Dictionary? liveChoiceSelectionPercentages) { if (liveChoiceSelectionPercentages is not { Count: > 0 }) return question.ChoiceSelectionPercentages; return question.Choices.ToDictionary( choice => choice.Id, choice => liveChoiceSelectionPercentages.GetValueOrDefault(choice.Id, 0)); } } // Admin-only shape for the flag/unflag/list-flagged endpoints - deliberately separate from // QuestionResponse (which is student-facing and never exposes IsPublished or the flag fields). public class QuestionFlagResponse { public string Id { get; set; } = ""; public string ExternalId { get; set; } = ""; public string Stem { get; set; } = ""; public bool IsPublished { get; set; } public DateTime? FlaggedForReviewUtc { get; set; } public string FlagReason { get; set; } = ""; public string FlaggedByUserId { get; set; } = ""; public static QuestionFlagResponse FromModel(Question question) => new() { Id = question.Id ?? "", ExternalId = question.ExternalId, Stem = question.Stem, IsPublished = question.IsPublished, FlaggedForReviewUtc = question.FlaggedForReviewUtc, FlagReason = question.FlagReason, FlaggedByUserId = question.FlaggedByUserId }; } public class FirstQuestionAttemptResponse { public string QuestionExternalId { get; set; } = ""; public Dictionary ChoiceSelectionPercentages { get; set; } = []; public int QuestionElo { get; set; } = 1000; public RatingChangeResponse? Rating { get; set; } public CreditStatusResponse? Credits { get; set; } public List CreditAwards { get; set; } = []; public StudySessionProgress? StudySessionProgress { get; set; } public StudySessionSummary? SessionSummary { get; set; } public DailyMissionSetResponse? DailyMissions { get; set; } public ProgressionResponse? Progression { get; set; } } public class SkipQuestionResponse { public string QuestionExternalId { get; set; } = ""; public string CorrectChoiceId { get; set; } = ""; public Dictionary ChoiceSelectionPercentages { get; set; } = []; public int QuestionElo { get; set; } = 1000; public RatingChangeResponse? Rating { get; set; } public ProgressionResponse? Progression { get; set; } } public static class RatingCategory { public const string Uncategorized = "Uncategorized"; public static readonly string[] Categories = [ "Renal/Urinary & Male/Female Systems & Pregnancy", "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety", "Immune/Blood & Lymphoreticular/Endocrine/Multisystem", "Biostatistics & Epidemiology/Population Health", "Musculoskeletal/Skin & Subcutaneous Tissue", "Cardiovascular System", "Nervous System & Special Senses", "Respiratory System", "Gastrointestinal System", Uncategorized ]; public static string Normalize(string? explicitCategory, string? system, string? discipline) { foreach (var value in new[] { explicitCategory, system, discipline }) { if (string.IsNullOrWhiteSpace(value)) continue; var normalized = Categories.FirstOrDefault(c => string.Equals(c, value.Trim(), StringComparison.OrdinalIgnoreCase)); if (normalized is not null) return normalized; var lower = value.ToLowerInvariant(); if (lower.Contains("cardio")) return "Cardiovascular System"; if (lower.Contains("resp")) return "Respiratory System"; if (lower.Contains("gastro") || lower.Contains("hepato")) return "Gastrointestinal System"; if (lower.Contains("neuro") || lower.Contains("special sense")) return "Nervous System & Special Senses"; if (lower.Contains("renal") || lower.Contains("urinary") || lower.Contains("pregnan") || lower.Contains("reproductive")) return "Renal/Urinary & Male/Female Systems & Pregnancy"; if (lower.Contains("behavior") || lower.Contains("ethic") || lower.Contains("communication") || lower.Contains("safety")) return "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety"; if (lower.Contains("immune") || lower.Contains("blood") || lower.Contains("endocrine") || lower.Contains("multisystem")) return "Immune/Blood & Lymphoreticular/Endocrine/Multisystem"; if (lower.Contains("biostat") || lower.Contains("epidemi") || lower.Contains("population")) return "Biostatistics & Epidemiology/Population Health"; if (lower.Contains("musculo") || lower.Contains("skin") || lower.Contains("derm")) return "Musculoskeletal/Skin & Subcutaneous Tissue"; } return Uncategorized; } }