Add question reporting and reference reviews

This commit is contained in:
hz
2026-07-13 01:30:01 -05:00
parent 6eae6c9690
commit 700da4e415
32 changed files with 2684 additions and 6 deletions
File diff suppressed because it is too large Load Diff
+88
View File
@@ -32,6 +32,94 @@ The Godot client reads this from `project.godot`:
step3/questions_api_url
```
## Question Image Copyright Review
All questions containing images must receive a copyright and licensing review
before publication or distribution. This applies both to newly submitted images
and to every image already stored in the question bank.
For each image-bearing question, document:
- the original source and creator, when known;
- the applicable license or written permission;
- whether attribution is required and where it is displayed;
- whether the image was modified or generated;
- the reviewer and review date.
Do not publish an image when its source or reuse rights cannot be established.
Remove or replace images that fail review. Images added for development or
manual testing are not exempt from this requirement.
Outstanding action: audit every existing question with a non-empty `media`
field before treating the current question bank as cleared for release.
## Question Text Originality Review
Every question must also receive an originality and copyright review before
publication or distribution. Review the stem, lead-in, answer choices,
explanations, follow-up questions, teaching points, and distinctive combinations
of patient details for substantial overlap with copyrighted question banks,
books, articles, screenshots, or other source material.
Changing only names, ages, individual values, or a few words is not sufficient
when the structure, sequence, and expression remain recognizably derived from a
copyrighted source. Clinical facts and concepts may be used, but each question
must express them independently with original organization and wording.
For each reviewed question, document the reviewer, review date, any reference
materials consulted, and whether revisions were required. Do not publish a
question when substantial textual or structural overlap remains unresolved.
Outstanding action: perform this review for every existing question in the
question bank, not only newly submitted questions, before considering the bank
cleared for release.
## Question Verification References
Every question must provide direct reference links to authoritative sources
that allow reviewers to verify the clinical, scientific, statistical, or
ethical information used in the stem, answer key, and explanation. References
are required before publication, even when the tested fact is considered
standard knowledge.
Prefer current primary or authoritative sources, including professional society
guidelines, government health agencies, original research, and official drug or
product labeling. Use secondary sources only when a suitable primary or
authoritative source is unavailable. Link to the specific guideline, article,
or document rather than a search-results page or an organization's home page.
For each question, document:
- at least one direct reference URL;
- the title and publishing organization for each reference;
- which key claim or teaching point the reference supports;
- the date the source was accessed and the reviewer who verified it.
References must support the keyed answer and any management recommendations,
diagnostic thresholds, formulas, or numerical claims. A citation does not grant
permission to copy wording or media; the separate text-originality and image-
copyright requirements still apply. If the question schema does not expose a
reference field, retain this information in the associated authoring or review
record until it can be stored with the question.
## Duplicate Question Review
Every question must be checked for duplicates before publication. This review
must detect both exact duplicates and near-duplicates that test the same concept
with substantially equivalent stems, decisive findings, lead-ins, answer sets,
or teaching objectives despite different wording or external IDs.
Repeated coverage of a learning objective is acceptable when the presentation,
reasoning path, or learner task is meaningfully different. Reject exact copies
and near-duplicates that merely change a patient age, reorder answers, or make
minor value and wording changes. When substantial duplication remains, retain
the stronger question, merge any useful unique teaching material, and remove or
substantially differentiate the other version.
Document the reviewer, review date, compared question IDs, and disposition of
each potential duplicate. Outstanding action: run this review across the entire
existing question bank before considering it cleared for release.
## Push Build
`.github/workflows/questions-api.yml` builds and publishes the Questions API Docker image to GitHub Container Registry on pushes to `main`.
@@ -0,0 +1,125 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/question-reports")]
public class QuestionReportsController : ControllerBase
{
private const int MinDetailsLength = 10;
private const int MaxDetailsLength = 2000;
private const int MaxReviewerNoteLength = 2000;
private readonly QuestionStore _questions;
private readonly QuestionReportStore _reports;
public QuestionReportsController(QuestionStore questions, QuestionReportStore reports)
{
_questions = questions;
_reports = reports;
}
[HttpPost]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Create([FromBody] CreateQuestionReportRequest request)
{
var questionExternalId = (request.QuestionExternalId ?? "").Trim();
var category = (request.Category ?? "").Trim().ToLowerInvariant();
var details = (request.Details ?? "").Trim();
if (string.IsNullOrWhiteSpace(questionExternalId))
return BadRequest("questionExternalId required");
if (!QuestionReportCategory.All.Contains(category))
return BadRequest("category is invalid");
if (details.Length < MinDetailsLength || details.Length > MaxDetailsLength)
return BadRequest($"details must be between {MinDetailsLength} and {MaxDetailsLength} characters");
var question = await _questions.GetByExternalIdAsync(questionExternalId);
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
return NotFound();
var userId = CurrentUserId();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var report = await _reports.CreateAsync(question, userId, category, details);
return StatusCode(StatusCodes.Status201Created, new CreateQuestionReportResponse
{
Id = report.Id ?? "",
QuestionExternalId = report.QuestionExternalId,
Category = report.Category,
Status = report.Status,
CreatedUtc = report.CreatedUtc
});
}
[HttpGet]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> List(
[FromQuery] string status = QuestionReportStatus.Open,
[FromQuery] string category = "",
[FromQuery] string questionExternalId = "",
[FromQuery] string userId = "",
[FromQuery] int skip = 0,
[FromQuery] int limit = 50)
{
status = status.Trim().ToLowerInvariant();
category = category.Trim().ToLowerInvariant();
questionExternalId = questionExternalId.Trim();
userId = userId.Trim();
if (status != "all" && status != QuestionReportStatus.Open && !QuestionReportStatus.Terminal.Contains(status))
return BadRequest("status is invalid");
if (!string.IsNullOrWhiteSpace(category) && !QuestionReportCategory.All.Contains(category))
return BadRequest("category is invalid");
if (skip < 0)
return BadRequest("skip must be zero or greater");
if (limit is < 1 or > 100)
return BadRequest("limit must be between 1 and 100");
return Ok(await _reports.SearchAsync(status, category, questionExternalId, userId, skip, limit));
}
[HttpGet("{id}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Get(string id)
{
var report = await _reports.GetByIdAsync(id.Trim());
return report is null ? NotFound() : Ok(report);
}
[HttpPatch("{id}/status")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> UpdateStatus(string id, [FromBody] UpdateQuestionReportStatusRequest request)
{
var status = (request.Status ?? "").Trim().ToLowerInvariant();
var reviewerNote = (request.ReviewerNote ?? "").Trim();
if (!QuestionReportStatus.Terminal.Contains(status))
return BadRequest("status must be resolved or dismissed");
if (reviewerNote.Length > MaxReviewerNoteLength)
return BadRequest($"reviewerNote must be {MaxReviewerNoteLength} characters or fewer");
var existing = await _reports.GetByIdAsync(id.Trim());
if (existing is null)
return NotFound();
if (existing.Status != QuestionReportStatus.Open)
return Conflict("report is already closed");
var reviewerUserId = CurrentUserId();
if (string.IsNullOrWhiteSpace(reviewerUserId))
return Unauthorized();
var updated = await _reports.CloseAsync(id.Trim(), status, reviewerUserId, reviewerNote);
return updated is null ? Conflict("report is already closed") : Ok(updated);
}
private string CurrentUserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
}
+96
View File
@@ -0,0 +1,96 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class QuestionReportCategory
{
public const string Clarity = "clarity";
public const string MedicalAccuracy = "medical_accuracy";
public const string AnswerExplanation = "answer_explanation";
public const string FormattingMedia = "formatting_media";
public const string TechnicalIssue = "technical_issue";
public const string Other = "other";
public static readonly HashSet<string> All =
[
Clarity,
MedicalAccuracy,
AnswerExplanation,
FormattingMedia,
TechnicalIssue,
Other
];
}
public static class QuestionReportStatus
{
public const string Open = "open";
public const string Resolved = "resolved";
public const string Dismissed = "dismissed";
public static readonly HashSet<string> Terminal = [Resolved, Dismissed];
}
public class QuestionReport
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string QuestionExternalId { get; set; } = "";
public string QuestionMongoId { get; set; } = "";
public string UserId { get; set; } = "";
public string Category { get; set; } = "";
public string Details { get; set; } = "";
public string Status { get; set; } = QuestionReportStatus.Open;
public QuestionReportSnapshot QuestionSnapshot { get; set; } = new();
public string ReviewedByUserId { get; set; } = "";
public string ReviewerNote { get; set; } = "";
public DateTime? ReviewedUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public class QuestionReportSnapshot
{
public string Stem { get; set; } = "";
public string LeadIn { get; set; } = "";
public List<QuestionChoice> Choices { get; set; } = [];
public string CorrectChoiceId { get; set; } = "";
public string Explanation { get; set; } = "";
public Dictionary<string, string> ChoiceExplanations { get; set; } = [];
public Dictionary<string, WrongAnswerFollowup> WrongAnswerFollowups { get; set; } = [];
public List<QuestionLab> Labs { get; set; } = [];
public List<string> Media { get; set; } = [];
}
public class CreateQuestionReportRequest
{
public string QuestionExternalId { get; set; } = "";
public string Category { get; set; } = "";
public string Details { get; set; } = "";
}
public class CreateQuestionReportResponse
{
public string Id { get; set; } = "";
public string QuestionExternalId { get; set; } = "";
public string Category { get; set; } = "";
public string Status { get; set; } = QuestionReportStatus.Open;
public DateTime CreatedUtc { get; set; }
}
public class UpdateQuestionReportStatusRequest
{
public string Status { get; set; } = "";
public string ReviewerNote { get; set; } = "";
}
public class QuestionReportListResponse
{
public List<QuestionReport> Items { get; set; } = [];
public long Total { get; set; }
public int Skip { get; set; }
public int Limit { get; set; }
}
+1
View File
@@ -12,6 +12,7 @@ builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<RatingStore>();
builder.Services.AddSingleton<QuestionMediaStore>();
builder.Services.AddSingleton<QuestionReportStore>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
+10
View File
@@ -12,5 +12,15 @@ STEP-style question bank service for study games and clients.
- `POST /api/questions/bulk` Upsert questions 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).
- `GET /api/question-reports` List and filter reports, newest first (SUPER only).
- `GET /api/question-reports/{id}` Get a report and its question snapshot (SUPER only).
- `PATCH /api/question-reports/{id}/status` Resolve or dismiss an open report (SUPER only).
Player reads require `USER` or `SUPER` JWT auth. Authoring endpoints require `SUPER`.
Questions submitted through authoring endpoints must have authoritative direct
reference links recorded as described in the repository's **Question
Verification References** policy. The references must verify the keyed answer
and the material claims in the explanation; search-result links are not
acceptable.
@@ -0,0 +1,146 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public class QuestionReportStore
{
private readonly IMongoCollection<QuestionReport> _reports;
public QuestionReportStore(IConfiguration cfg)
{
var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
var databaseName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
var client = new MongoClient(connectionString);
var database = client.GetDatabase(databaseName);
_reports = database.GetCollection<QuestionReport>("QuestionReports");
_reports.Indexes.CreateMany(
[
new CreateIndexModel<QuestionReport>(
Builders<QuestionReport>.IndexKeys
.Ascending(r => r.Status)
.Descending(r => r.CreatedUtc)),
new CreateIndexModel<QuestionReport>(
Builders<QuestionReport>.IndexKeys.Ascending(r => r.QuestionExternalId)),
new CreateIndexModel<QuestionReport>(
Builders<QuestionReport>.IndexKeys.Ascending(r => r.UserId))
]);
}
public async Task<QuestionReport> CreateAsync(Question question, string userId, string category, string details)
{
var now = DateTime.UtcNow;
var report = new QuestionReport
{
QuestionExternalId = question.ExternalId,
QuestionMongoId = question.Id ?? "",
UserId = userId,
Category = category,
Details = details,
Status = QuestionReportStatus.Open,
QuestionSnapshot = new QuestionReportSnapshot
{
Stem = question.Stem,
LeadIn = question.LeadIn,
Choices = question.Choices
.Select(choice => new QuestionChoice { Id = choice.Id, Text = choice.Text })
.ToList(),
CorrectChoiceId = question.CorrectChoiceId,
Explanation = question.Explanation,
ChoiceExplanations = new Dictionary<string, string>(question.ChoiceExplanations),
WrongAnswerFollowups = question.WrongAnswerFollowups.ToDictionary(
item => item.Key,
item => new WrongAnswerFollowup
{
Prompt = item.Value.Prompt,
Choices = item.Value.Choices
.Select(choice => new QuestionChoice { Id = choice.Id, Text = choice.Text })
.ToList(),
CorrectChoiceId = item.Value.CorrectChoiceId,
TeachingPoint = item.Value.TeachingPoint
}),
Labs = question.Labs
.Select(lab => new QuestionLab
{
Name = lab.Name,
Value = lab.Value,
Unit = lab.Unit,
Reference = lab.Reference
})
.ToList(),
Media = [.. question.Media]
},
CreatedUtc = now,
UpdatedUtc = now
};
await _reports.InsertOneAsync(report);
return report;
}
public async Task<QuestionReport?> GetByIdAsync(string id)
{
if (!ObjectId.TryParse(id, out _))
return null;
return await _reports.Find(r => r.Id == id).FirstOrDefaultAsync();
}
public async Task<QuestionReportListResponse> SearchAsync(
string status,
string category,
string questionExternalId,
string userId,
int skip,
int limit)
{
var filter = Builders<QuestionReport>.Filter.Empty;
if (!string.Equals(status, "all", StringComparison.OrdinalIgnoreCase))
filter &= Builders<QuestionReport>.Filter.Eq(r => r.Status, status);
if (!string.IsNullOrWhiteSpace(category))
filter &= Builders<QuestionReport>.Filter.Eq(r => r.Category, category);
if (!string.IsNullOrWhiteSpace(questionExternalId))
filter &= Builders<QuestionReport>.Filter.Eq(r => r.QuestionExternalId, questionExternalId);
if (!string.IsNullOrWhiteSpace(userId))
filter &= Builders<QuestionReport>.Filter.Eq(r => r.UserId, userId);
var total = await _reports.CountDocumentsAsync(filter);
var items = await _reports
.Find(filter)
.SortByDescending(r => r.CreatedUtc)
.Skip(skip)
.Limit(limit)
.ToListAsync();
return new QuestionReportListResponse
{
Items = items,
Total = total,
Skip = skip,
Limit = limit
};
}
public async Task<QuestionReport?> CloseAsync(string id, string status, string reviewerUserId, string reviewerNote)
{
if (!ObjectId.TryParse(id, out _))
return null;
var now = DateTime.UtcNow;
var filter = Builders<QuestionReport>.Filter.And(
Builders<QuestionReport>.Filter.Eq(r => r.Id, id),
Builders<QuestionReport>.Filter.Eq(r => r.Status, QuestionReportStatus.Open));
var update = Builders<QuestionReport>.Update
.Set(r => r.Status, status)
.Set(r => r.ReviewerNote, reviewerNote)
.Set(r => r.ReviewedByUserId, reviewerUserId)
.Set(r => r.ReviewedUtc, now)
.Set(r => r.UpdatedUtc, now);
return await _reports.FindOneAndUpdateAsync(
filter,
update,
new FindOneAndUpdateOptions<QuestionReport> { ReturnDocument = ReturnDocument.After });
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b1ul1abd3ancy"
path="res://.godot/imported/alopecia-areata-occipital-001.png-cac14e23748bed69672554273e867cf9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/question_images/alopecia-areata-occipital-001.png"
dest_files=["res://.godot/imported/alopecia-areata-occipital-001.png-cac14e23748bed69672554273e867cf9.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cf3a0cl2atyw8"
path="res://.godot/imported/asbestos-ferruginous-body-528.png-e5de794a289107157349231884cf169c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/question_images/asbestos-ferruginous-body-528.png"
dest_files=["res://.godot/imported/asbestos-ferruginous-body-528.png-e5de794a289107157349231884cf169c.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://2phukqqwkr0j"
path="res://.godot/imported/atrial-fibrillation-controlled-response-5172.png-9fe67e541e5dbd55542ed0f497a51d66.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/question_images/atrial-fibrillation-controlled-response-5172.png"
dest_files=["res://.godot/imported/atrial-fibrillation-controlled-response-5172.png-9fe67e541e5dbd55542ed0f497a51d66.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cgaj6mqqk8txf"
path="res://.godot/imported/hsv-grouped-vesicles-mucocutaneous-3041.png-4f4d86d3e278d5b18e7cf944720c4403.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/question_images/hsv-grouped-vesicles-mucocutaneous-3041.png"
dest_files=["res://.godot/imported/hsv-grouped-vesicles-mucocutaneous-3041.png-4f4d86d3e278d5b18e7cf944720c4403.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c12lrclgs8xtp"
path="res://.godot/imported/huntington-pedigree-3528.png-ef596230c5e63c2dbbdf5e9df3061b8f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/question_images/huntington-pedigree-3528.png"
dest_files=["res://.godot/imported/huntington-pedigree-3528.png-ef596230c5e63c2dbbdf5e9df3061b8f.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
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720">
<rect width="1200" height="720" fill="#f8fafc"/>
<g stroke="#172033" stroke-width="8" fill="none" stroke-linecap="square">
<line x1="420" y1="135" x2="780" y2="135"/>
<line x1="600" y1="135" x2="600" y2="245"/>
<line x1="375" y1="245" x2="825" y2="245"/>
<line x1="375" y1="245" x2="375" y2="330"/>
<line x1="825" y1="245" x2="825" y2="330"/>
<line x1="425" y1="385" x2="560" y2="385"/>
<line x1="492" y1="385" x2="492" y2="500"/>
<line x1="467" y1="500" x2="720" y2="500"/>
</g>
<g stroke="#172033" stroke-width="8">
<rect x="370" y="85" width="100" height="100" rx="2" fill="#172033"/>
<circle cx="830" cy="135" r="50" fill="#f8fafc"/>
<circle cx="375" cy="385" r="50" fill="#172033"/>
<rect x="560" y="335" width="100" height="100" rx="2" fill="#f8fafc"/>
<rect x="775" y="335" width="100" height="100" rx="2" fill="#172033"/>
<rect x="417" y="500" width="100" height="100" rx="2" fill="#f8fafc"/>
<circle cx="770" cy="550" r="50" fill="#f8fafc"/>
</g>
<g font-family="Arial, sans-serif" font-size="30" fill="#4b5563">
<text x="70" y="145">I</text>
<text x="70" y="395">II</text>
<text x="70" y="565">III</text>
<text x="370" y="660">At-risk husband</text>
<text x="700" y="660">Wife</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dvprnn2mwq7h0"
path="res://.godot/imported/huntington-pedigree-3528.svg-b6915e7f1c86a790ca2b1d9134425a60.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/question_images/huntington-pedigree-3528.svg"
dest_files=["res://.godot/imported/huntington-pedigree-3528.svg-b6915e7f1c86a790ca2b1d9134425a60.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cg0f4dp5l5puy"
path="res://.godot/imported/papilledema-fundus-original.png-e6cfd47dd210cfce0b1895feccd9bdf7.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/question_images/papilledema-fundus-original.png"
dest_files=["res://.godot/imported/papilledema-fundus-original.png-e6cfd47dd210cfce0b1895feccd9bdf7.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dq7r8yq65kutq"
path="res://.godot/imported/pcp-diffuse-bilateral-interstitial-cxr-2801.png-9b10bd4d25c9c74ad7b984ef2a458d32.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/question_images/pcp-diffuse-bilateral-interstitial-cxr-2801.png"
dest_files=["res://.godot/imported/pcp-diffuse-bilateral-interstitial-cxr-2801.png-9b10bd4d25c9c74ad7b984ef2a458d32.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://blladq71aek48"
path="res://.godot/imported/pediatric-left-lower-lobe-postobstructive-pneumonia.png-65c5f5042ce198b18c2a8356a93ca274.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/question_images/pediatric-left-lower-lobe-postobstructive-pneumonia.png"
dest_files=["res://.godot/imported/pediatric-left-lower-lobe-postobstructive-pneumonia.png-65c5f5042ce198b18c2a8356a93ca274.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dpbme1e646oto"
path="res://.godot/imported/step3-cardiology-preop-ecg-incidental-552.png-98ee131255d583fc3b714377bb7f05e2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/question_images/step3-cardiology-preop-ecg-incidental-552.png"
dest_files=["res://.godot/imported/step3-cardiology-preop-ecg-incidental-552.png-98ee131255d583fc3b714377bb7f05e2.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://daws5c8gngey"
path="res://.godot/imported/step3-infectious-bacterial-meningitis-3001.png-d415a86d7d931dce1df2789095c995e1.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/question_images/step3-infectious-bacterial-meningitis-3001.png"
dest_files=["res://.godot/imported/step3-infectious-bacterial-meningitis-3001.png-d415a86d7d931dce1df2789095c995e1.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
+1
View File
@@ -40,3 +40,4 @@ rendering_device/driver.windows="d3d12"
[step3]
questions_api_url="https://s3q.ranaze.com/api/questions/search"
question_reports_api_url="https://s3q.ranaze.com/api/question-reports"
+74
View File
@@ -4,6 +4,7 @@ const DEFAULT_QUESTIONS_API_URL := "https://s3q.ranaze.com/api/questions/search"
const DEFAULT_QUESTION_ATTEMPT_API_URL := "https://s3q.ranaze.com/api/questions/attempts/first"
const DEFAULT_QUESTION_API_BASE_URL := "https://s3q.ranaze.com/api/questions"
const DEFAULT_RATINGS_API_URL := "https://s3q.ranaze.com/api/ratings/me"
const DEFAULT_QUESTION_REPORTS_API_URL := "https://s3q.ranaze.com/api/question-reports"
const RETRY_DELAYS := [2, 5, 10, 20, 30]
signal questions_changed
@@ -12,17 +13,21 @@ signal choice_percentages_updated(question_id: String, percentages: Dictionary)
signal ratings_changed
signal question_rating_updated(question_id: String, question_elo: int, rating: Dictionary)
signal question_skipped(question_id: String, correct_choice_id: String, question_elo: int, rating: Dictionary)
signal question_report_submitted(question_id: String, report_id: String)
signal question_report_failed(question_id: String, message: String)
var questions: Array = []
var request: HTTPRequest
var attempt_request: HTTPRequest
var rating_request: HTTPRequest
var skip_request: HTTPRequest
var report_request: HTTPRequest
var retry_timer: Timer
var questions_api_url := DEFAULT_QUESTIONS_API_URL
var question_attempt_api_url := DEFAULT_QUESTION_ATTEMPT_API_URL
var question_api_base_url := DEFAULT_QUESTION_API_BASE_URL
var ratings_api_url := DEFAULT_RATINGS_API_URL
var question_reports_api_url := DEFAULT_QUESTION_REPORTS_API_URL
var is_loading := false
var last_error := ""
var load_state := "idle"
@@ -32,12 +37,14 @@ var overall_elo := 1000
var rated_question_count := 0
var category_elos := {}
var last_rating_change := {}
var pending_report_question_id := ""
func _ready() -> void:
questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL))
question_attempt_api_url = str(ProjectSettings.get_setting("step3/question_attempt_api_url", DEFAULT_QUESTION_ATTEMPT_API_URL))
question_api_base_url = str(ProjectSettings.get_setting("step3/question_api_base_url", DEFAULT_QUESTION_API_BASE_URL))
ratings_api_url = str(ProjectSettings.get_setting("step3/ratings_api_url", DEFAULT_RATINGS_API_URL))
question_reports_api_url = str(ProjectSettings.get_setting("step3/question_reports_api_url", DEFAULT_QUESTION_REPORTS_API_URL))
request = HTTPRequest.new()
request.request_completed.connect(_on_api_questions_loaded)
add_child(request)
@@ -54,6 +61,10 @@ func _ready() -> void:
skip_request.request_completed.connect(_on_question_skipped)
add_child(skip_request)
report_request = HTTPRequest.new()
report_request.request_completed.connect(_on_question_report_submitted)
add_child(report_request)
retry_timer = Timer.new()
retry_timer.one_shot = true
retry_timer.timeout.connect(_on_retry_timer_timeout)
@@ -135,6 +146,8 @@ func clear_questions() -> void:
rating_request.cancel_request()
if skip_request != null:
skip_request.cancel_request()
if report_request != null:
report_request.cancel_request()
if retry_timer != null:
retry_timer.stop()
questions.clear()
@@ -147,6 +160,7 @@ func clear_questions() -> void:
rated_question_count = 0
category_elos = {}
last_rating_change = {}
pending_report_question_id = ""
questions_changed.emit()
ratings_changed.emit()
@@ -182,6 +196,37 @@ func skip_question(question_id: String) -> void:
push_warning("Failed to record skipped question.")
func submit_question_report(question_id: String, category: String, details: String) -> bool:
if AuthState.access_token.is_empty() or question_id == "":
question_report_failed.emit(question_id, "Login is required to submit a report.")
return false
if report_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
question_report_failed.emit(question_id, "Another report is still being submitted.")
return false
var headers := PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % AuthState.access_token
])
var payload := {
"questionExternalId": question_id,
"category": category,
"details": details
}
pending_report_question_id = question_id
var err := report_request.request(
question_reports_api_url,
headers,
HTTPClient.METHOD_POST,
JSON.stringify(payload)
)
if err != OK:
pending_report_question_id = ""
question_report_failed.emit(question_id, "The report could not be submitted. Please try again.")
return false
return true
func load_ratings_from_api() -> void:
if AuthState.access_token.is_empty():
return
@@ -305,6 +350,35 @@ func _on_question_skipped(result: int, response_code: int, _headers: PackedStrin
question_skipped.emit(question_id, correct_choice_id, question_elo, rating)
func _on_question_report_submitted(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
var question_id := ""
if typeof(parsed) == TYPE_DICTIONARY:
question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
if question_id == "":
question_id = pending_report_question_id
pending_report_question_id = ""
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
var message := "The report could not be submitted. Please try again."
if typeof(parsed) == TYPE_DICTIONARY:
message = str(parsed.get("detail", parsed.get("title", message)))
elif typeof(parsed) == TYPE_STRING and not str(parsed).strip_edges().is_empty():
message = str(parsed).strip_edges()
question_report_failed.emit(question_id, message)
return
if typeof(parsed) != TYPE_DICTIONARY:
question_report_failed.emit(question_id, "The report API returned an invalid response.")
return
var report_id := str(parsed.get("id", ""))
if question_id == "" or report_id == "":
question_report_failed.emit(question_id, "The report API returned an incomplete response.")
return
question_report_submitted.emit(question_id, report_id)
func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
push_warning("Ratings API request failed with status %d." % response_code)
+318 -6
View File
@@ -6,6 +6,16 @@ const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
const TEXT_SCALE_MIN := 0.85
const TEXT_SCALE_MAX := 1.35
const TEXT_SCALE_STEP := 0.1
const REPORT_DETAILS_MIN := 10
const REPORT_DETAILS_MAX := 2000
const REPORT_CATEGORIES := [
{"id": "clarity", "label": "Clarity"},
{"id": "medical_accuracy", "label": "Medical accuracy"},
{"id": "answer_explanation", "label": "Answer / explanation"},
{"id": "formatting_media", "label": "Formatting / media"},
{"id": "technical_issue", "label": "Technical issue"},
{"id": "other", "label": "Other"}
]
var main: Control
var choice_buttons: Array[Button] = []
@@ -54,6 +64,20 @@ var feedback_fog: ColorRect
var labs_overlay: Control
var labs_panel: PanelContainer
var labs_content: VBoxContainer
var report_overlay: Control
var report_panel: PanelContainer
var report_form_content: VBoxContainer
var report_success_content: VBoxContainer
var report_category_group: ButtonGroup
var report_category_buttons: Array[CheckBox] = []
var report_details: TextEdit
var report_char_count: Label
var report_error_label: Label
var report_submit_button: Button
var report_cancel_button: Button
var report_success_label: Label
var report_is_submitting := false
var report_question_id := ""
var media_requests: Array[HTTPRequest] = []
var red_fog_time := 0.0
var green_fog_time := 0.0
@@ -76,6 +100,10 @@ func _ready() -> void:
QuestionBankService.ratings_changed.connect(_refresh_player_stats)
if not AuthState.auth_changed.is_connected(_refresh_player_stats):
AuthState.auth_changed.connect(_refresh_player_stats)
if not QuestionBankService.question_report_submitted.is_connected(_on_question_report_submitted):
QuestionBankService.question_report_submitted.connect(_on_question_report_submitted)
if not QuestionBankService.question_report_failed.is_connected(_on_question_report_failed):
QuestionBankService.question_report_failed.connect(_on_question_report_failed)
var top := HBoxContainer.new()
top.add_theme_constant_override("separation", 12)
@@ -156,6 +184,8 @@ func _ready() -> void:
report_button.tooltip_text = "Report question"
report_button.icon = _make_report_icon()
report_button.custom_minimum_size = Vector2(44, 44)
Style.style_chip(report_button, Style.CYAN)
UISoundService.set_button_sound(report_button, "open")
report_button.pressed.connect(_toggle_report_current_question)
question_toolbar.add_child(report_button)
@@ -326,17 +356,23 @@ func _ready() -> void:
add_child(feedback_fog)
_build_labs_modal()
_build_report_modal()
func on_show(_payload: Variant = null) -> void:
_load_question()
func on_hide() -> void:
_hide_report_modal(true)
_hide_labs_modal()
func _process(delta: float) -> void:
_update_answer_feedback(delta)
if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core":
_update_timer_bar()
if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed":
if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed" and (report_overlay == null or not report_overlay.visible):
BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta)
var shown_seconds := int(ceil(BlockSessionService.seconds_remaining))
var minutes := int(floor(float(shown_seconds) / 60.0))
@@ -346,6 +382,10 @@ func _process(delta: float) -> void:
func _input(event: InputEvent) -> void:
if report_overlay != null and report_overlay.visible and event.is_action_pressed("ui_cancel"):
_hide_report_modal()
get_viewport().set_input_as_handled()
return
if labs_overlay != null and labs_overlay.visible and event.is_action_pressed("ui_cancel"):
_hide_labs_modal()
get_viewport().set_input_as_handled()
@@ -367,6 +407,7 @@ func _input(event: InputEvent) -> void:
func _load_question() -> void:
_hide_report_modal(true)
_reset_wrong_feedback()
_hide_labs_modal()
eliminated.clear()
@@ -785,11 +826,7 @@ func _toggle_report_current_question() -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
if question_id == "":
return
if reported_question_ids.has(question_id):
reported_question_ids.erase(question_id)
else:
reported_question_ids.append(question_id)
_refresh_toolbar_state()
_show_report_modal(question_id)
func _refresh_toolbar_state() -> void:
@@ -799,6 +836,7 @@ func _refresh_toolbar_state() -> void:
if report_button != null:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
Style.style_chip(report_button, Style.RED if reported_question_ids.has(question_id) else Style.CYAN)
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
func _refresh_player_stats() -> void:
@@ -976,6 +1014,280 @@ func _on_media_loaded(
status.visible = false
func _build_report_modal() -> void:
report_overlay = Control.new()
report_overlay.visible = false
report_overlay.top_level = true
report_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
report_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
report_overlay.gui_input.connect(_report_overlay_gui_input)
add_child(report_overlay)
var dim := ColorRect.new()
dim.color = Color(0.02, 0.03, 0.05, 0.78)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
report_overlay.add_child(dim)
var center := CenterContainer.new()
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
report_overlay.add_child(center)
report_panel = PanelContainer.new()
report_panel.custom_minimum_size = Vector2(700, 600)
report_panel.mouse_filter = Control.MOUSE_FILTER_STOP
report_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.RED))
center.add_child(report_panel)
var modal_body := VBoxContainer.new()
modal_body.add_theme_constant_override("separation", 14)
report_panel.add_child(modal_body)
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 10)
modal_body.add_child(header)
var title := Label.new()
title.text = "Report question"
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(title, 26, Style.TEXT)
header.add_child(title)
var close_button := Button.new()
close_button.text = "X"
close_button.tooltip_text = "Close report form"
close_button.custom_minimum_size = Vector2(42, 38)
Style.style_chip(close_button, Style.RED)
UISoundService.set_button_sound(close_button, "close")
close_button.pressed.connect(_hide_report_modal)
header.add_child(close_button)
report_form_content = VBoxContainer.new()
report_form_content.add_theme_constant_override("separation", 12)
modal_body.add_child(report_form_content)
var category_label := Label.new()
category_label.text = "What is the problem?"
_set_scaled_label(category_label, 18, Style.CYAN)
report_form_content.add_child(category_label)
var category_grid := GridContainer.new()
category_grid.columns = 2
category_grid.add_theme_constant_override("h_separation", 18)
category_grid.add_theme_constant_override("v_separation", 8)
report_form_content.add_child(category_grid)
report_category_group = ButtonGroup.new()
report_category_group.allow_unpress = false
for category in REPORT_CATEGORIES:
var category_button := CheckBox.new()
category_button.text = str(category["label"])
category_button.set_meta("report_category_id", str(category["id"]))
category_button.button_group = report_category_group
category_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
category_button.custom_minimum_size = Vector2(0, 38)
_set_scaled_button_font(category_button, 16)
category_button.toggled.connect(_on_report_category_toggled)
category_grid.add_child(category_button)
report_category_buttons.append(category_button)
var details_header := HBoxContainer.new()
report_form_content.add_child(details_header)
var details_label := Label.new()
details_label.text = "Details"
details_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_set_scaled_label(details_label, 18, Style.CYAN)
details_header.add_child(details_label)
report_char_count = Label.new()
report_char_count.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_set_scaled_label(report_char_count, 14, Style.MUTED)
details_header.add_child(report_char_count)
report_details = TextEdit.new()
report_details.placeholder_text = "Describe what is wrong and what should be reviewed."
report_details.custom_minimum_size = Vector2(0, 190)
report_details.size_flags_vertical = Control.SIZE_EXPAND_FILL
report_details.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
report_details.add_theme_font_size_override("font_size", 16)
report_details.add_theme_color_override("font_color", Style.TEXT)
report_details.add_theme_color_override("font_placeholder_color", Style.MUTED)
report_details.add_theme_stylebox_override("normal", Style.panel_box(Style.SURFACE_DARK, Color("3b4964")))
report_details.text_changed.connect(_on_report_details_changed)
report_form_content.add_child(report_details)
report_error_label = Label.new()
report_error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
report_error_label.custom_minimum_size = Vector2(0, 28)
_set_scaled_label(report_error_label, 15, Style.RED)
report_form_content.add_child(report_error_label)
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 10)
report_form_content.add_child(actions)
report_cancel_button = Button.new()
report_cancel_button.text = "Cancel"
report_cancel_button.custom_minimum_size = Vector2(130, 46)
Style.style_chip(report_cancel_button, Style.MUTED)
UISoundService.set_button_sound(report_cancel_button, "close")
report_cancel_button.pressed.connect(_hide_report_modal)
actions.add_child(report_cancel_button)
report_submit_button = Button.new()
report_submit_button.text = "Submit report"
report_submit_button.custom_minimum_size = Vector2(170, 46)
report_submit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
Style.style_primary_button(report_submit_button)
UISoundService.set_button_sound(report_submit_button, "confirmation")
report_submit_button.pressed.connect(_submit_question_report)
actions.add_child(report_submit_button)
report_success_content = VBoxContainer.new()
report_success_content.visible = false
report_success_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
report_success_content.alignment = BoxContainer.ALIGNMENT_CENTER
report_success_content.add_theme_constant_override("separation", 18)
modal_body.add_child(report_success_content)
report_success_label = Label.new()
report_success_label.text = "Report submitted"
report_success_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
report_success_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_set_scaled_label(report_success_label, 24, Style.LIME)
report_success_content.add_child(report_success_label)
var success_close_button := Button.new()
success_close_button.text = "Close"
success_close_button.custom_minimum_size = Vector2(180, 48)
Style.style_primary_button(success_close_button)
UISoundService.set_button_sound(success_close_button, "close")
success_close_button.pressed.connect(_hide_report_modal)
report_success_content.add_child(success_close_button)
_update_report_form_state()
func _report_overlay_gui_input(event: InputEvent) -> void:
var mouse_event := event as InputEventMouseButton
if mouse_event == null or not mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT:
return
if report_panel != null and not report_panel.get_global_rect().has_point(mouse_event.global_position):
_hide_report_modal()
get_viewport().set_input_as_handled()
func _show_report_modal(question_id: String) -> void:
if report_overlay == null or report_is_submitting:
return
_hide_labs_modal()
report_question_id = question_id
report_form_content.visible = true
report_success_content.visible = false
report_details.text = ""
report_error_label.text = ""
for category_button in report_category_buttons:
category_button.button_pressed = false
_set_report_submitting(false)
_update_report_form_state()
report_overlay.visible = true
report_overlay.move_to_front()
BlockSessionService.pause_current_question_timer()
func _hide_report_modal(force: bool = false) -> void:
if report_overlay == null or (report_is_submitting and not force):
return
if report_overlay.visible:
report_overlay.visible = false
BlockSessionService.resume_current_question_timer()
if not report_is_submitting:
report_question_id = ""
func _on_report_category_toggled(pressed: bool) -> void:
if pressed:
_update_report_form_state()
func _on_report_details_changed() -> void:
_update_report_form_state()
func _selected_report_category() -> String:
if report_category_group == null:
return ""
var selected := report_category_group.get_pressed_button()
if selected == null:
return ""
return str(selected.get_meta("report_category_id", ""))
func _update_report_form_state() -> void:
if report_details == null or report_submit_button == null:
return
var details_length := report_details.text.strip_edges().length()
report_char_count.text = "%d / %d" % [details_length, REPORT_DETAILS_MAX]
report_char_count.add_theme_color_override("font_color", Style.RED if details_length > REPORT_DETAILS_MAX else Style.MUTED)
if not report_is_submitting:
report_error_label.text = ""
report_submit_button.disabled = (
report_is_submitting
or _selected_report_category() == ""
or details_length < REPORT_DETAILS_MIN
or details_length > REPORT_DETAILS_MAX
)
func _set_report_submitting(value: bool) -> void:
report_is_submitting = value
if report_details != null:
report_details.editable = not value
for category_button in report_category_buttons:
category_button.disabled = value
if report_cancel_button != null:
report_cancel_button.disabled = value
if report_submit_button != null:
report_submit_button.text = "Submitting..." if value else "Submit report"
_update_report_form_state()
func _submit_question_report() -> void:
if report_is_submitting or report_question_id == "":
return
var category := _selected_report_category()
var details := report_details.text.strip_edges()
if category == "" or details.length() < REPORT_DETAILS_MIN or details.length() > REPORT_DETAILS_MAX:
_update_report_form_state()
return
report_error_label.text = ""
_set_report_submitting(true)
QuestionBankService.submit_question_report(report_question_id, category, details)
func _on_question_report_submitted(question_id: String, _report_id: String) -> void:
if question_id != report_question_id:
return
_set_report_submitting(false)
if not reported_question_ids.has(question_id):
reported_question_ids.append(question_id)
_refresh_toolbar_state()
report_form_content.visible = false
report_success_content.visible = true
report_success_label.text = "Report submitted\nThank you for helping improve this question."
UISoundService.play_sound("confirmation")
func _on_question_report_failed(question_id: String, message: String) -> void:
if question_id != report_question_id:
return
_set_report_submitting(false)
report_error_label.text = message
UISoundService.play_sound("error")
func _build_labs_modal() -> void:
labs_overlay = Control.new()
labs_overlay.visible = false