diff --git a/api/QuestionsApi.Tests/ChoicePoolTests.cs b/api/QuestionsApi.Tests/ChoicePoolTests.cs index 8c0b60c..d0cc440 100644 --- a/api/QuestionsApi.Tests/ChoicePoolTests.cs +++ b/api/QuestionsApi.Tests/ChoicePoolTests.cs @@ -28,6 +28,8 @@ public sealed class ChoicePoolTests var question = BuildQuestion(9); var response = QuestionResponse.FromModel(question, includeChoicePool: true); Assert.Equal(9, response.Choices.Count); + Assert.Single(response.References); + Assert.Equal("https://example.org/guideline", response.References[0].Url); } [Fact] @@ -51,6 +53,51 @@ public sealed class ChoicePoolTests Assert.Contains("unique", QuestionValidator.Validate(request)); } + [Fact] + public void PublishedQuestionRequiresVerificationReferencesButDraftDoesNot() + { + var request = BuildRequest(5); + request.References.Clear(); + Assert.Contains("verification reference", QuestionValidator.Validate(request)); + + request.IsPublished = false; + Assert.Null(QuestionValidator.Validate(request)); + } + + [Theory] + [InlineData("http://example.org/guideline", "HTTPS")] + [InlineData("https://example.org/guideline", null)] + public void VerificationReferencesValidateUrls(string url, string? expectedError) + { + var request = BuildRequest(5); + request.References[0].Url = url; + var validation = QuestionValidator.Validate(request); + if (expectedError is null) + Assert.Null(validation); + else + Assert.Contains(expectedError, validation); + } + + [Fact] + public void VerificationReferencesRequireIsoAccessDateAndUniqueUrls() + { + var request = BuildRequest(5); + request.References[0].AccessedOn = "July 13, 2026"; + Assert.Contains("YYYY-MM-DD", QuestionValidator.Validate(request)); + + request = BuildRequest(5); + request.References.Add(new QuestionReference + { + Title = "Second source", + Publisher = "Publisher", + Url = request.References[0].Url.ToUpperInvariant(), + Supports = "Another claim", + AccessedOn = "2026-07-13", + Reviewer = "Reviewer" + }); + Assert.Contains("unique", QuestionValidator.Validate(request)); + } + private static Question BuildQuestion(int count) { var request = BuildRequest(count); @@ -64,7 +111,8 @@ public sealed class ChoicePoolTests Explanation = request.Explanation, ChoiceExplanations = request.ChoiceExplanations, WrongAnswerFollowups = request.WrongAnswerFollowups, - ChoiceSelectionPercentages = request.ChoiceSelectionPercentages + ChoiceSelectionPercentages = request.ChoiceSelectionPercentages, + References = request.References }; } @@ -98,6 +146,18 @@ public sealed class ChoicePoolTests Discipline = "Medicine", System = "Cardiovascular System", Difficulty = 2, + References = + [ + new QuestionReference + { + Title = "Clinical guideline", + Publisher = "Authoritative publisher", + Url = "https://example.org/guideline", + Supports = "The keyed answer and explanation.", + AccessedOn = "2026-07-13", + Reviewer = "Reviewer" + } + ], IsPublished = true }; } diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 8310e62..e2f82cc 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -323,6 +323,36 @@ public class QuestionsController : ControllerBase return Ok(new { upserted }); } + [HttpPost("admin/references/bulk")] + [Authorize(Roles = "SUPER")] + public async Task BulkUpdateReferences([FromBody] BulkQuestionReferencesRequest req) + { + if (req.Questions.Count == 0) + return BadRequest("questions required"); + + var externalIds = new HashSet(StringComparer.Ordinal); + foreach (var update in req.Questions) + { + if (string.IsNullOrWhiteSpace(update.ExternalId)) + return BadRequest("externalId required"); + var externalId = update.ExternalId.Trim(); + if (!externalIds.Add(externalId)) + return BadRequest($"duplicate externalId: {externalId}"); + var validation = QuestionValidator.ValidateReferences(update.References, required: true); + if (validation is not null) + return BadRequest($"{externalId}: {validation}"); + } + + var missing = await _questions.GetMissingExternalIdsAsync(externalIds); + if (missing.Count > 0) + return BadRequest($"unknown externalIds: {string.Join(", ", missing)}"); + + var updated = await _questions.UpdateReferencesByExternalIdAsync(req.Questions); + if (updated != req.Questions.Count) + return Conflict("one or more questions changed before references could be updated"); + return Ok(new { updated }); + } + [HttpPut("{id}")] [Authorize(Roles = "SUPER")] public async Task Update(string id, [FromBody] UpsertQuestionRequest req) diff --git a/api/QuestionsApi/Models/BulkQuestionReferencesRequest.cs b/api/QuestionsApi/Models/BulkQuestionReferencesRequest.cs new file mode 100644 index 0000000..87766c7 --- /dev/null +++ b/api/QuestionsApi/Models/BulkQuestionReferencesRequest.cs @@ -0,0 +1,12 @@ +namespace QuestionsApi.Models; + +public class BulkQuestionReferencesRequest +{ + public List Questions { get; set; } = []; +} + +public class QuestionReferencesUpdate +{ + public string ExternalId { get; set; } = ""; + public List References { get; set; } = []; +} diff --git a/api/QuestionsApi/Models/Question.cs b/api/QuestionsApi/Models/Question.cs index 9569493..32ce8bd 100644 --- a/api/QuestionsApi/Models/Question.cs +++ b/api/QuestionsApi/Models/Question.cs @@ -25,6 +25,7 @@ public class Question public List Labs { get; set; } = []; public List Media { get; set; } = []; public List Tags { get; set; } = []; + public List References { get; set; } = []; public bool IsPublished { get; set; } = true; public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; @@ -51,3 +52,13 @@ public class WrongAnswerFollowup public string CorrectChoiceId { get; set; } = ""; public string TeachingPoint { get; set; } = ""; } + +public class QuestionReference +{ + public string Title { get; set; } = ""; + public string Publisher { get; set; } = ""; + public string Url { get; set; } = ""; + public string Supports { get; set; } = ""; + public string AccessedOn { get; set; } = ""; + public string Reviewer { get; set; } = ""; +} diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 2c9fb0b..6ea90b4 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -29,6 +29,7 @@ public class UpsertQuestionRequest public List Labs { get; set; } = []; public List Media { get; set; } = []; public List Tags { get; set; } = []; + public List References { get; set; } = []; public bool IsPublished { get; set; } = true; } diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index ccf009e..a13b33e 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -21,6 +21,7 @@ public class QuestionResponse public List Labs { get; set; } = []; public List Media { get; set; } = []; public List Tags { get; set; } = []; + public List References { get; set; } = []; public static QuestionResponse FromModel( Question question, @@ -56,7 +57,8 @@ public class QuestionResponse QuestionElo = questionElo, Labs = question.Labs, Media = question.Media, - Tags = question.Tags + Tags = question.Tags, + References = question.References }; } diff --git a/api/QuestionsApi/README.md b/api/QuestionsApi/README.md index e72a4a8..94762eb 100644 --- a/api/QuestionsApi/README.md +++ b/api/QuestionsApi/README.md @@ -11,6 +11,7 @@ STEP-style question bank service for study games and clients. - `GET /api/questions/{id}` Get one question. - `POST /api/questions` Create question (SUPER only). - `POST /api/questions/bulk` Upsert questions by `externalId` (SUPER only). +- `POST /api/questions/admin/references/bulk` Update only embedded verification references by `externalId` (SUPER only). - `PUT /api/questions/{id}` Update question (SUPER only). - `DELETE /api/questions/{id}` Delete question (SUPER only). - `POST /api/question-reports` Submit a categorized question report (USER or SUPER). @@ -39,7 +40,10 @@ extra distractors to existing questions. Search clients that set `supportsChoicePools: true` receive each complete choice pool. Older clients receive five server-selected choices that always include the correct answer. Published questions require 5-20 unique choices, -an explanation for every choice, and a follow-up for every incorrect choice. +an explanation for every choice, a follow-up for every incorrect choice, and at +least one embedded authoritative verification reference. Legacy published +questions without references remain readable but must add references before a +future authoring update. - `GET /readyz` verifies MongoDB connectivity for readiness checks. - `GET /metrics` exposes Prometheus-format service and reconciliation metrics. diff --git a/api/QuestionsApi/Services/QuestionStore.cs b/api/QuestionsApi/Services/QuestionStore.cs index aeedec6..7282b0b 100644 --- a/api/QuestionsApi/Services/QuestionStore.cs +++ b/api/QuestionsApi/Services/QuestionStore.cs @@ -151,6 +151,32 @@ public class QuestionStore return systems.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToList(); } + public async Task> GetMissingExternalIdsAsync(IEnumerable externalIds) + { + var requested = externalIds.Distinct(StringComparer.Ordinal).ToList(); + var existing = await _col.Find(Builders.Filter.In(q => q.ExternalId, requested)) + .Project(q => q.ExternalId) + .ToListAsync(); + return requested.Except(existing, StringComparer.Ordinal).OrderBy(id => id).ToList(); + } + + public async Task UpdateReferencesByExternalIdAsync(IEnumerable updates) + { + var now = DateTime.UtcNow; + var writes = updates.Select(update => + new UpdateOneModel( + Builders.Filter.Eq(question => question.ExternalId, update.ExternalId.Trim()), + Builders.Update + .Set(question => question.References, NormalizeReferences(update.References)) + .Set(question => question.UpdatedUtc, now))) + .ToList(); + if (writes.Count == 0) + return 0; + + var result = await _col.BulkWriteAsync(writes, new BulkWriteOptions { IsOrdered = true }); + return (int)result.MatchedCount; + } + private static Question FromRequest(UpsertQuestionRequest req) => new() { ExternalId = req.ExternalId.Trim(), @@ -169,6 +195,18 @@ public class QuestionStore Labs = req.Labs, Media = req.Media, Tags = req.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList(), + References = NormalizeReferences(req.References), IsPublished = req.IsPublished }; + + private static List NormalizeReferences(IEnumerable? references) => + (references ?? []).Select(reference => new QuestionReference + { + Title = reference.Title.Trim(), + Publisher = reference.Publisher.Trim(), + Url = reference.Url.Trim(), + Supports = reference.Supports.Trim(), + AccessedOn = reference.AccessedOn.Trim(), + Reviewer = reference.Reviewer.Trim() + }).ToList(); } diff --git a/api/QuestionsApi/Services/QuestionValidator.cs b/api/QuestionsApi/Services/QuestionValidator.cs index 4275320..aef801e 100644 --- a/api/QuestionsApi/Services/QuestionValidator.cs +++ b/api/QuestionsApi/Services/QuestionValidator.cs @@ -1,4 +1,5 @@ using QuestionsApi.Models; +using System.Globalization; namespace QuestionsApi.Services; @@ -52,6 +53,45 @@ 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 referenceValidation = ValidateReferences(req.References, req.IsPublished); + if (referenceValidation is not null) return referenceValidation; + return null; + } + + public static string? ValidateReferences(IReadOnlyCollection? references, bool required) + { + if (references is null) + return "verification references must be an array"; + if (required && references.Count == 0) + return "published questions require at least one verification reference"; + if (references.Count > 20) + return "questions support at most twenty verification references"; + + var urls = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var reference in references) + { + if (string.IsNullOrWhiteSpace(reference.Title) + || string.IsNullOrWhiteSpace(reference.Publisher) + || string.IsNullOrWhiteSpace(reference.Url) + || string.IsNullOrWhiteSpace(reference.Supports) + || string.IsNullOrWhiteSpace(reference.AccessedOn) + || string.IsNullOrWhiteSpace(reference.Reviewer)) + return "verification reference fields are required"; + + var url = reference.Url.Trim(); + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) + return "verification reference urls must be absolute HTTPS urls"; + if (!urls.Add(url)) + return "verification reference urls must be unique within a question"; + if (!DateOnly.TryParseExact( + reference.AccessedOn.Trim(), + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _)) + return "verification reference accessedOn must use YYYY-MM-DD"; + } + return null; } } diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 75e563e..059fe94 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -580,6 +580,7 @@ func _normalize_api_question(item: Variant) -> Dictionary: "question_elo": int(item.get("questionElo", item.get("question_elo", 1000))), "labs": item.get("labs", []), "media": item.get("media", []), - "tags": item.get("tags", []) + "tags": item.get("tags", []), + "references": item.get("references", []) } return normalized