Embed question verification references

This commit is contained in:
hz
2026-07-13 22:29:20 -05:00
parent 0fe47de423
commit 292f8ff9b4
10 changed files with 203 additions and 4 deletions
@@ -323,6 +323,36 @@ public class QuestionsController : ControllerBase
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}")]
[Authorize(Roles = "SUPER")]
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; } = [];
}
+11
View File
@@ -25,6 +25,7 @@ public class Question
public List<QuestionLab> Labs { get; set; } = [];
public List<string> Media { get; set; } = [];
public List<string> Tags { get; set; } = [];
public List<QuestionReference> 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; } = "";
}
@@ -29,6 +29,7 @@ public class UpsertQuestionRequest
public List<QuestionLab> Labs { get; set; } = [];
public List<string> Media { get; set; } = [];
public List<string> Tags { get; set; } = [];
public List<QuestionReference> References { get; set; } = [];
public bool IsPublished { get; set; } = true;
}
+3 -1
View File
@@ -21,6 +21,7 @@ public class QuestionResponse
public List<QuestionLab> Labs { get; set; } = [];
public List<string> Media { get; set; } = [];
public List<string> Tags { get; set; } = [];
public List<QuestionReference> 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
};
}
+5 -1
View File
@@ -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.
@@ -151,6 +151,32 @@ public class QuestionStore
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()
{
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<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 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<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;
}
}