Embed question verification references
This commit is contained in:
@@ -28,6 +28,8 @@ public sealed class ChoicePoolTests
|
|||||||
var question = BuildQuestion(9);
|
var question = BuildQuestion(9);
|
||||||
var response = QuestionResponse.FromModel(question, includeChoicePool: true);
|
var response = QuestionResponse.FromModel(question, includeChoicePool: true);
|
||||||
Assert.Equal(9, response.Choices.Count);
|
Assert.Equal(9, response.Choices.Count);
|
||||||
|
Assert.Single(response.References);
|
||||||
|
Assert.Equal("https://example.org/guideline", response.References[0].Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -51,6 +53,51 @@ public sealed class ChoicePoolTests
|
|||||||
Assert.Contains("unique", QuestionValidator.Validate(request));
|
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)
|
private static Question BuildQuestion(int count)
|
||||||
{
|
{
|
||||||
var request = BuildRequest(count);
|
var request = BuildRequest(count);
|
||||||
@@ -64,7 +111,8 @@ public sealed class ChoicePoolTests
|
|||||||
Explanation = request.Explanation,
|
Explanation = request.Explanation,
|
||||||
ChoiceExplanations = request.ChoiceExplanations,
|
ChoiceExplanations = request.ChoiceExplanations,
|
||||||
WrongAnswerFollowups = request.WrongAnswerFollowups,
|
WrongAnswerFollowups = request.WrongAnswerFollowups,
|
||||||
ChoiceSelectionPercentages = request.ChoiceSelectionPercentages
|
ChoiceSelectionPercentages = request.ChoiceSelectionPercentages,
|
||||||
|
References = request.References
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +146,18 @@ public sealed class ChoicePoolTests
|
|||||||
Discipline = "Medicine",
|
Discipline = "Medicine",
|
||||||
System = "Cardiovascular System",
|
System = "Cardiovascular System",
|
||||||
Difficulty = 2,
|
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
|
IsPublished = true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,6 +323,36 @@ public class QuestionsController : ControllerBase
|
|||||||
return Ok(new { upserted });
|
return Ok(new { upserted });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("admin/references/bulk")]
|
||||||
|
[Authorize(Roles = "SUPER")]
|
||||||
|
public async Task<IActionResult> BulkUpdateReferences([FromBody] BulkQuestionReferencesRequest req)
|
||||||
|
{
|
||||||
|
if (req.Questions.Count == 0)
|
||||||
|
return BadRequest("questions required");
|
||||||
|
|
||||||
|
var externalIds = new HashSet<string>(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}")]
|
[HttpPut("{id}")]
|
||||||
[Authorize(Roles = "SUPER")]
|
[Authorize(Roles = "SUPER")]
|
||||||
public async Task<IActionResult> Update(string id, [FromBody] UpsertQuestionRequest req)
|
public async Task<IActionResult> Update(string id, [FromBody] UpsertQuestionRequest req)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace QuestionsApi.Models;
|
||||||
|
|
||||||
|
public class BulkQuestionReferencesRequest
|
||||||
|
{
|
||||||
|
public List<QuestionReferencesUpdate> Questions { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class QuestionReferencesUpdate
|
||||||
|
{
|
||||||
|
public string ExternalId { get; set; } = "";
|
||||||
|
public List<QuestionReference> References { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ public class Question
|
|||||||
public List<QuestionLab> Labs { get; set; } = [];
|
public List<QuestionLab> Labs { get; set; } = [];
|
||||||
public List<string> Media { get; set; } = [];
|
public List<string> Media { get; set; } = [];
|
||||||
public List<string> Tags { get; set; } = [];
|
public List<string> Tags { get; set; } = [];
|
||||||
|
public List<QuestionReference> References { get; set; } = [];
|
||||||
public bool IsPublished { get; set; } = true;
|
public bool IsPublished { get; set; } = true;
|
||||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedUtc { 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 CorrectChoiceId { get; set; } = "";
|
||||||
public string TeachingPoint { 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; } = "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class UpsertQuestionRequest
|
|||||||
public List<QuestionLab> Labs { get; set; } = [];
|
public List<QuestionLab> Labs { get; set; } = [];
|
||||||
public List<string> Media { get; set; } = [];
|
public List<string> Media { get; set; } = [];
|
||||||
public List<string> Tags { get; set; } = [];
|
public List<string> Tags { get; set; } = [];
|
||||||
|
public List<QuestionReference> References { get; set; } = [];
|
||||||
public bool IsPublished { get; set; } = true;
|
public bool IsPublished { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class QuestionResponse
|
|||||||
public List<QuestionLab> Labs { get; set; } = [];
|
public List<QuestionLab> Labs { get; set; } = [];
|
||||||
public List<string> Media { get; set; } = [];
|
public List<string> Media { get; set; } = [];
|
||||||
public List<string> Tags { get; set; } = [];
|
public List<string> Tags { get; set; } = [];
|
||||||
|
public List<QuestionReference> References { get; set; } = [];
|
||||||
|
|
||||||
public static QuestionResponse FromModel(
|
public static QuestionResponse FromModel(
|
||||||
Question question,
|
Question question,
|
||||||
@@ -56,7 +57,8 @@ public class QuestionResponse
|
|||||||
QuestionElo = questionElo,
|
QuestionElo = questionElo,
|
||||||
Labs = question.Labs,
|
Labs = question.Labs,
|
||||||
Media = question.Media,
|
Media = question.Media,
|
||||||
Tags = question.Tags
|
Tags = question.Tags,
|
||||||
|
References = question.References
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ STEP-style question bank service for study games and clients.
|
|||||||
- `GET /api/questions/{id}` Get one question.
|
- `GET /api/questions/{id}` Get one question.
|
||||||
- `POST /api/questions` Create question (SUPER only).
|
- `POST /api/questions` Create question (SUPER only).
|
||||||
- `POST /api/questions/bulk` Upsert questions by `externalId` (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).
|
- `PUT /api/questions/{id}` Update question (SUPER only).
|
||||||
- `DELETE /api/questions/{id}` Delete question (SUPER only).
|
- `DELETE /api/questions/{id}` Delete question (SUPER only).
|
||||||
- `POST /api/question-reports` Submit a categorized question report (USER or SUPER).
|
- `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
|
Search clients that set `supportsChoicePools: true` receive each complete
|
||||||
choice pool. Older clients receive five server-selected choices that always
|
choice pool. Older clients receive five server-selected choices that always
|
||||||
include the correct answer. Published questions require 5-20 unique choices,
|
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 /readyz` verifies MongoDB connectivity for readiness checks.
|
||||||
- `GET /metrics` exposes Prometheus-format service and reconciliation metrics.
|
- `GET /metrics` exposes Prometheus-format service and reconciliation metrics.
|
||||||
|
|||||||
@@ -151,6 +151,32 @@ public class QuestionStore
|
|||||||
return systems.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToList();
|
return systems.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> GetMissingExternalIdsAsync(IEnumerable<string> externalIds)
|
||||||
|
{
|
||||||
|
var requested = externalIds.Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
var existing = await _col.Find(Builders<Question>.Filter.In(q => q.ExternalId, requested))
|
||||||
|
.Project(q => q.ExternalId)
|
||||||
|
.ToListAsync();
|
||||||
|
return requested.Except(existing, StringComparer.Ordinal).OrderBy(id => id).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> UpdateReferencesByExternalIdAsync(IEnumerable<QuestionReferencesUpdate> updates)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var writes = updates.Select(update =>
|
||||||
|
new UpdateOneModel<Question>(
|
||||||
|
Builders<Question>.Filter.Eq(question => question.ExternalId, update.ExternalId.Trim()),
|
||||||
|
Builders<Question>.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()
|
private static Question FromRequest(UpsertQuestionRequest req) => new()
|
||||||
{
|
{
|
||||||
ExternalId = req.ExternalId.Trim(),
|
ExternalId = req.ExternalId.Trim(),
|
||||||
@@ -169,6 +195,18 @@ public class QuestionStore
|
|||||||
Labs = req.Labs,
|
Labs = req.Labs,
|
||||||
Media = req.Media,
|
Media = req.Media,
|
||||||
Tags = req.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
Tags = req.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
||||||
|
References = NormalizeReferences(req.References),
|
||||||
IsPublished = req.IsPublished
|
IsPublished = req.IsPublished
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static List<QuestionReference> NormalizeReferences(IEnumerable<QuestionReference>? 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using QuestionsApi.Models;
|
using QuestionsApi.Models;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace QuestionsApi.Services;
|
namespace QuestionsApi.Services;
|
||||||
|
|
||||||
@@ -52,6 +53,45 @@ public static class QuestionValidator
|
|||||||
if (string.IsNullOrWhiteSpace(req.System)) return "system required";
|
if (string.IsNullOrWhiteSpace(req.System)) return "system required";
|
||||||
if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required";
|
if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required";
|
||||||
if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5";
|
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<QuestionReference>? 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<string>(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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -580,6 +580,7 @@ func _normalize_api_question(item: Variant) -> Dictionary:
|
|||||||
"question_elo": int(item.get("questionElo", item.get("question_elo", 1000))),
|
"question_elo": int(item.get("questionElo", item.get("question_elo", 1000))),
|
||||||
"labs": item.get("labs", []),
|
"labs": item.get("labs", []),
|
||||||
"media": item.get("media", []),
|
"media": item.get("media", []),
|
||||||
"tags": item.get("tags", [])
|
"tags": item.get("tags", []),
|
||||||
|
"references": item.get("references", [])
|
||||||
}
|
}
|
||||||
return normalized
|
return normalized
|
||||||
|
|||||||
Reference in New Issue
Block a user