{item.text}
+Preparing quiz pack…
{result.status === "provisional" ? "Objective score · AI grade pending" : "Final accuracy score"}
+{result.ai_grade.feedback || "AI grading is temporarily unavailable. Your objective result is preserved as provisional."}
+ {result.ai_grade.criteria &&{message.text}
)} + {sending && ( ++ +
+ )} + +{challenge.prompt}
+{item.text}
+Choose how you want to review this scan.
+{expired ? "Room data was deleted after its 24-hour lifetime." : message}
+ +Loading shared scan…
+{note.text}
+ +Temporary room data was deleted. Canonical dataset case remains unchanged.
+ Return to case +Pack {html.escape(quiz_summary['pack_id'])} " + f"version {int(quiz_summary['pack_version'])} · phase {html.escape(quiz_summary['phase'])}
" + "| Rank | Participant | Score | " + f"Consistency |
|---|
Case {html.escape(metadata['case_id'])} · {html.escape(metadata['resolution'])} resolution
+Created {html.escape(metadata['created_at'])} · Expires {html.escape(metadata['expires_at'])}
+| Tool | Label | Text |
|---|
For research and education use only. Not for diagnostic use.
""" + report_html.write_text(html_text, encoding="utf-8") + + pdf = canvas.Canvas(str(report_pdf), pagesize=letter) + width, height = letter + y = height - 54 + pdf.setTitle("BodyMaps Live Room report") + pdf.setFont("Helvetica-Bold", 18) + pdf.drawString(54, y, "BodyMaps Live Room report") + y -= 28 + pdf.setFont("Helvetica", 10) + for line in ( + f"Case {metadata['case_id']} | {metadata['resolution']} resolution", + f"Created {metadata['created_at']}", + f"Expires {metadata['expires_at']}", + f"Measurements: {len(measurements)} | Notes: {len(notes)} | Chat messages: {len(chat)}", + ): + pdf.drawString(54, y, line) + y -= 16 + if quiz_summary: + y -= 4 + pdf.setFont("Helvetica-Bold", 12) + pdf.drawString(54, y, "Live Quiz") + y -= 18 + pdf.setFont("Helvetica", 9) + pdf.drawString(62, y, f"Pack {quiz_summary['pack_id']} v{quiz_summary['pack_version']} | {quiz_summary['phase']}") + y -= 14 + for item in quiz_summary["leaderboard"][:20]: + pdf.drawString( + 62, + y, + f"#{item['rank']} {item['name']}: {item['score']}/{item['max_score']} | {item['consistency']['status']}", + ) + y -= 13 + y -= 8 + pdf.setFont("Helvetica-Bold", 12) + pdf.drawString(54, y, "Pinned notes") + y -= 18 + pdf.setFont("Helvetica", 9) + for item in notes[:40]: + text = f"{item['author']}: {item['text']}"[:110] + pdf.drawString(62, y, text) + y -= 14 + if y < 72: + pdf.showPage() + y = height - 54 + pdf.setFont("Helvetica", 9) + pdf.setFont("Helvetica-Oblique", 9) + pdf.drawString(54, 36, "For research and education use only. Not for diagnostic use.") + pdf.save() + return report_html, report_pdf + + def _quiz_export_summary_locked(self, room_dir: Path, metadata: dict[str, Any]) -> dict[str, Any] | None: + if metadata.get("mode", "review") != "quiz": + return None + quiz = self._read_json(room_dir / "quiz.json") + pack = self._pack_locked(room_dir, metadata) + return { + "pack_id": pack["pack_id"], + "pack_version": pack["version"], + "case_id": pack["case_id"], + "timer_seconds": metadata.get("quiz_timer_seconds"), + "phase": quiz.get("phase"), + "revealed_distributions": { + question_id: reveal.get("distribution", {}) + for question_id, reveal in (quiz.get("reveals") or {}).items() + }, + "leaderboard": quiz.get("leaderboard") or [], + "consistency_summary": quiz.get("consistency_summary") or {}, + "question_elapsed_ms": quiz.get("question_elapsed_ms") or {}, + "disclaimer": "For research and education use only. Not for diagnostic use.", + } + + def _require_quiz_export_ready_locked(self, room_dir: Path, metadata: dict[str, Any]) -> None: + if metadata.get("mode", "review") != "quiz": + return + quiz = self._read_json(room_dir / "quiz.json") + pack = self._pack_locked(room_dir, metadata) + final_revealed = ( + int(quiz.get("question_index", -1)) == len(pack["questions"]) - 1 + and quiz.get("phase") in {"question_revealed", "completed"} + ) + if not final_revealed: + error = LiveRoomError("Quiz exports are available only after the final reveal") + error.status_code = 403 + error.code = "quiz_export_locked" + raise error + + @staticmethod + def _chat_from_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + event["payload"]["message"] + for event in events + if event.get("type") == "chat.add" and (event.get("payload") or {}).get("message") + ] + + def build_export(self, room_id: str, room_key: str) -> Path: + with self._locked(room_id) as room_dir: + metadata = self._load_metadata(room_dir) + if not hmac.compare_digest(hash_room_key(room_key), metadata.get("key_hash", "")): + raise RoomUnauthorized("Invalid room key") + self._require_quiz_export_ready_locked(room_dir, metadata) + export_path = room_dir / "export.zip" + if export_path.exists(): + return export_path + state = self._read_json(room_dir / "state.json") + events = list(self._iter_events(room_dir)) + export_state = {**state, "chat": self._chat_from_events(events)} + if metadata.get("mode") == "quiz": + quiz = self._read_json(room_dir / "quiz.json") + pack = self._pack_locked(room_dir, metadata) + final_revealed = ( + int(quiz.get("question_index", -1)) == len(pack["questions"]) - 1 + and quiz.get("phase") in {"question_revealed", "completed"} + ) + mask_path = self._quiz_mask_locked(room_dir, metadata, revealed=final_revealed) + else: + mask_path = self._materialize_mask_locked(room_dir, metadata) + report_html, report_pdf = self._build_report(room_dir, metadata, export_state) + measurements = list(state["measurements"].values()) + measurements_json = json.dumps(measurements, indent=2, ensure_ascii=False) + csv_buffer = io.StringIO() + writer = csv.DictWriter( + csv_buffer, + fieldnames=["id", "tool", "label", "text", "revision", "frame_of_reference"], + extrasaction="ignore", + ) + writer.writeheader() + writer.writerows(measurements) + manifest = { + **self.public_metadata(metadata), + "exported_at": isoformat(self._now()), + "participant_nicknames": sorted({event["name"] for event in events}), + "artifacts": [ + "edited_labelmap.nii.gz", + "measurements.csv", + "measurements.json", + "notes.json", + "chat.json", + "events.json", + "report.html", + "report.pdf", + ], + "disclaimer": "For research and education use only. Not for diagnostic use.", + } + quiz_summary = self._quiz_export_summary_locked(room_dir, metadata) + if quiz_summary: + manifest["artifacts"].append("quiz-summary.json") + temp_path = room_dir / ".export.tmp.zip" + with zipfile.ZipFile(temp_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.write(mask_path, "edited_labelmap.nii.gz") + archive.writestr("measurements.csv", csv_buffer.getvalue()) + archive.writestr("measurements.json", measurements_json) + archive.writestr("notes.json", json.dumps(list(state["notes"].values()), indent=2, ensure_ascii=False)) + archive.writestr("chat.json", json.dumps(export_state["chat"], indent=2, ensure_ascii=False)) + archive.writestr("events.json", json.dumps(events, indent=2, ensure_ascii=False)) + archive.write(report_html, "report.html") + archive.write(report_pdf, "report.pdf") + if quiz_summary: + archive.writestr("quiz-summary.json", json.dumps(quiz_summary, indent=2, ensure_ascii=False)) + archive.writestr("manifest.json", json.dumps(manifest, indent=2, ensure_ascii=False)) + if temp_path.stat().st_size > MAX_EXPORT_BYTES: + temp_path.unlink(missing_ok=True) + raise RoomFull("Room export exceeds the 4 GiB artifact limit") + os.replace(temp_path, export_path) + return export_path + + def get_report(self, room_id: str, room_key: str) -> Path: + with self._locked(room_id) as room_dir: + metadata = self._load_metadata(room_dir) + if not hmac.compare_digest(hash_room_key(room_key), metadata.get("key_hash", "")): + raise RoomUnauthorized("Invalid room key") + self._require_quiz_export_ready_locked(room_dir, metadata) + state = self._read_json(room_dir / "state.json") + events = list(self._iter_events(room_dir)) + report_state = {**state, "chat": self._chat_from_events(events)} + _, report_pdf = self._build_report(room_dir, metadata, report_state) + return report_pdf + + def cleanup_expired(self) -> list[str]: + removed: list[str] = [] + for child in list(self.root.iterdir()): + if not child.is_dir(): + continue + try: + room_id = self.validate_room_id(child.name) + with self._locked(room_id) as room_dir: + metadata = self._load_metadata(room_dir, allow_expired=True) + if parse_time(metadata["expires_at"]) > self._now(): + continue + shutil.rmtree(child, ignore_errors=True) + removed.append(room_id) + except (LiveRoomError, OSError, ValueError, KeyError): + continue + return removed diff --git a/flask-server/services/mesh_generation.py b/flask-server/services/mesh_generation.py index 47dc3c9b..719a4351 100644 --- a/flask-server/services/mesh_generation.py +++ b/flask-server/services/mesh_generation.py @@ -3,6 +3,9 @@ import re import dotenv import os +import shutil +import threading +import uuid import nibabel as nib import numpy as np @@ -52,11 +55,69 @@ 35: {"key": "colon_lesion", "name": "Colon Lesion"}, } +_mesh_generation_lock = threading.Lock() + def safe_filename(s: str) -> str: return re.sub(r"[^a-zA-Z0-9_\\-]+", "_", s).lower() +def _manifest_is_complete(manifest_path: Path) -> bool: + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + organs = manifest["organs"] + bounds = manifest["bounds"] + if not isinstance(organs, list) or not isinstance(bounds, dict): + return False + return all( + isinstance(organ, dict) + and (manifest_path.parent / f"{safe_filename(str(organ.get('key', '')))}.glb").is_file() + for organ in organs + ) + except (FileNotFoundError, KeyError, TypeError, ValueError, json.JSONDecodeError): + return False + + +def ensure_case_meshes(case_id: str, label_nifti_path: str, output_root: str) -> Path: + """Create one complete, reusable mesh cache when precomputed assets are absent.""" + label_path = Path(label_nifti_path) + if not label_path.is_file(): + raise FileNotFoundError(label_path) + + root = Path(output_root) + case_dir = root / case_id + manifest_path = case_dir / "manifest.json" + if _manifest_is_complete(manifest_path): + return manifest_path + + # React Strict Mode can request the manifest twice. One process-wide lock keeps + # both requests from running marching cubes over the same case concurrently. + with _mesh_generation_lock: + if _manifest_is_complete(manifest_path): + return manifest_path + + from services.preprocess_meshes import preprocess_case + + root.mkdir(parents=True, exist_ok=True) + temporary_dir = root / f".{case_id}.{uuid.uuid4().hex}.tmp" + try: + preprocess_case(case_id, str(label_path), str(temporary_dir), verbose=False) + case_dir.mkdir(parents=True, exist_ok=True) + # Manifest is generated last, then moved last. Readers never observe a + # manifest that points at only a partial set of GLB files. + generated_manifest = temporary_dir / "manifest.json" + for asset in temporary_dir.iterdir(): + if asset != generated_manifest: + os.replace(asset, case_dir / asset.name) + os.replace(generated_manifest, manifest_path) + finally: + shutil.rmtree(temporary_dir, ignore_errors=True) + + if not _manifest_is_complete(manifest_path): + raise RuntimeError("Mesh preprocessing produced an incomplete cache") + return manifest_path + + def nifti_world_to_three(world_xyz: np.ndarray) -> np.ndarray: x = world_xyz[:, 0] y = world_xyz[:, 2] @@ -356,4 +417,3 @@ def generate_mesh_manifest( "bounds": bounds, } - diff --git a/flask-server/services/nifti_processor.py b/flask-server/services/nifti_processor.py index b51cbb30..c957f6a6 100644 --- a/flask-server/services/nifti_processor.py +++ b/flask-server/services/nifti_processor.py @@ -36,14 +36,14 @@ def __init__(self, main_nifti_path, clabel_path, organ_intensities=None): self._clabel_path = clabel_path self.number_max = 999999 self._organ_intensities = organ_intensities - + def set_organ_intensities(self, organ_intensities): self._organ_intensities = organ_intensities @classmethod def from_clabel_path(cls, clabel_path): return cls(None, clabel_path) - + def calculate_metrics(self): if ( self._organ_intensities is None @@ -322,7 +322,7 @@ def combine_labels(self, filenames: list[str], nifti_multi_dict: MultiDict, save if len(filenames) == 1: filename = filenames[0] segmentation = nifti_multi_dict[filename] - + img_data, affine, header = self.load_uploaded_nifti(segmentation) combined_labels_img_data = np.rint(img_data).astype(np.uint16) diff --git a/flask-server/services/ollama_client.py b/flask-server/services/ollama_client.py index c77b075a..23a1dd95 100644 --- a/flask-server/services/ollama_client.py +++ b/flask-server/services/ollama_client.py @@ -222,6 +222,7 @@ def chat_json( model: str | None, system_prompt: str, user_prompt: str, + response_schema: dict[str, Any] | None = None, timeout: float | None = None, temperature: float = 0.2, ) -> dict[str, Any]: @@ -268,7 +269,7 @@ def chat_json( "seed": 42, }, "think": OLLAMA_THINK, - "format": "json", + "format": response_schema or "json", } data = _post_chat(payload, request_timeout) @@ -305,7 +306,7 @@ def chat_json( "num_ctx": 4096, "seed": 42, }, - "format": "json", + "format": response_schema or "json", } repaired = _post_chat( diff --git a/flask-server/services/preprocess_meshes.py b/flask-server/services/preprocess_meshes.py index ba6f9706..1cb9c009 100644 --- a/flask-server/services/preprocess_meshes.py +++ b/flask-server/services/preprocess_meshes.py @@ -101,7 +101,7 @@ def compute_global_center(data: np.ndarray, affine: np.ndarray) -> np.ndarray: three = nifti_world_to_three(world) return (three.min(axis=0) + three.max(axis=0)) / 2.0 -def compute_volume_bounds_three(shape, affine: np.ndarray): +def compute_volume_bounds_three(shape, affine: np.ndarray, center: np.ndarray): nx, ny, nz = shape[:3] corners_ijk = np.array( @@ -121,8 +121,6 @@ def compute_volume_bounds_three(shape, affine: np.ndarray): world = nib.affines.apply_affine(affine, corners_ijk) three = nifti_world_to_three(world) - center = (three.min(axis=0) + three.max(axis=0)) / 2.0 - three_centered = three - center return { @@ -201,7 +199,7 @@ def get_folder_id(index): return get_cancerverse_id(index) if str(index).strip().upper().startswith("CV") else get_panTS_id(index) # display_id: PanTS_00000900 -def preprocess_case(display_id: str, label_nifti_path: str, output_root: str): +def preprocess_case(display_id: str, label_nifti_path: str, output_root: str, *, verbose: bool = True): label_nifti_path = Path(label_nifti_path) output_root = Path(output_root) @@ -219,7 +217,7 @@ def preprocess_case(display_id: str, label_nifti_path: str, output_root: str): data = data.astype(np.int32) global_center = compute_global_center(data, img.affine) - bounds = compute_volume_bounds_three(data.shape, img.affine) + bounds = compute_volume_bounds_three(data.shape, img.affine, global_center) manifest = { "caseId": display_id, @@ -256,12 +254,14 @@ def preprocess_case(display_id: str, label_nifti_path: str, output_root: str): } ) - print(f"Exported {meta['name']} -> {out_path}") + if verbose: + print(f"Exported {meta['name']} -> {out_path}") manifest_path = case_dir / "manifest.json" manifest_path.write_text(json.dumps(manifest, indent=2)) - print(f"Wrote manifest -> {manifest_path}") + if verbose: + print(f"Wrote manifest -> {manifest_path}") def preprocess_case_by_index(index: int, skip_existing: bool = False): pants_case = get_panTS_id(index) @@ -271,7 +271,7 @@ def preprocess_case_by_index(index: int, skip_existing: bool = False): f"{pants_case}/{Constants.COMBINED_LABELS_NIFTI_FILENAME}" ) - output_path = f"{Constants.PANTS_PATH}/render_only/{pants_case}/" + output_path = os.path.join(Constants.MESH_PATH, pants_case) manifest_path = Path(output_path) / "manifest.json" if skip_existing and manifest_path.exists(): @@ -336,4 +336,4 @@ def preprocess_case_by_index(index: int, skip_existing: bool = False): print(f"[ERROR] PanTS_{index:08d}: {e}") else: - raise SystemExit("Use either --case 900 or --start 1 --end 9901") \ No newline at end of file + raise SystemExit("Use either --case 900 or --start 1 --end 9901") diff --git a/flask-server/services/quiz_approval_ledger.v1.json b/flask-server/services/quiz_approval_ledger.v1.json new file mode 100644 index 00000000..06cc68e2 --- /dev/null +++ b/flask-server/services/quiz_approval_ledger.v1.json @@ -0,0 +1,19 @@ +{ + "ledger_version": 1, + "approvals": { + "radworld-case-35-v1": { + "status": "approved", + "ledger_id": "case35-reviewed-baseline", + "reviewed_at": "2026-08-06T00:00:00Z", + "reviewer": "BodyMaps education review", + "acknowledged_warnings": ["thick_slices"] + } + }, + "difficulty_overrides": { + "radworld-case-35-v1": "easy" + }, + "reviewed_template_versions": ["pancreas-imaging-chain/1.0.0"], + "reviewed_label_profiles": [], + "reviewed_cohorts": ["pancreas-imaging-chain/1.0.0:case35-reviewed-v1"], + "quarantined_cohorts": [] +} diff --git a/flask-server/services/quiz_catalog.v1.json b/flask-server/services/quiz_catalog.v1.json new file mode 100644 index 00000000..9727796a --- /dev/null +++ b/flask-server/services/quiz_catalog.v1.json @@ -0,0 +1,110 @@ +{ + "catalog_id": "bodymaps-vqa-v1", + "catalog_version": 1, + "generator_version": "bodymaps-vqa-generator/1.0.0", + "validator_version": "bodymaps-vqa-validator/1.0.0", + "packs": [ + { + "pack_id": "radworld-case-35-v1", + "version": 1, + "case_id": "35", + "title": "Case 35 · Pancreatic focus", + "difficulty": "easy", + "tags": ["pancreas", "tumor-positive", "localization", "measurement", "case-35"], + "provenance": { + "dataset_id": "PanTS_00000035", + "source": "PanTS combined CT and segmentation inventory", + "note": "Preserved reviewed Case 35 connected-question chain." + }, + "generator_version": "case35-curated/1.0.0", + "validator_version": "bodymaps-vqa-validator/1.0.0", + "approval": { + "status": "approved", + "ledger_id": "case35-reviewed-baseline", + "reviewed_at": "2026-08-06T00:00:00Z", + "reviewer": "BodyMaps education review" + }, + "ground_truth": { + "label_profile_id": "case35-reviewed-v1", + "label_mapping": { + "pancreas": [17, 18, 19, 20], + "lesion": [28] + }, + "lesion_present": true, + "lesion_labels": [28], + "lesion_centroid_lps": [260.25, 224.625, -290.0], + "crosshair_lps": [260.25, 224.625, -290.0], + "pancreatic_region": "tail", + "max_axial_diameter_mm": 23.3, + "reference_measurement_lps": [[261.0, 236.25, -290.0], [259.5, 213.0, -290.0]], + "reveal_mask": { + "kind": "labels", + "source_labels": [28], + "output_label": 1 + } + }, + "questions": [ + { + "id": "organ", + "prompt": "Which organ is under the synchronized crosshair?", + "choices": [ + {"id": "pancreas", "label": "Pancreas", "claims": {}}, + {"id": "liver", "label": "Liver", "claims": {}}, + {"id": "spleen", "label": "Spleen", "claims": {}}, + {"id": "stomach", "label": "Stomach", "claims": {}} + ], + "correct_choice_id": "pancreas", + "explanation": "The crosshair is positioned within the pancreas.", + "viewer_cue": {"clear_overlays": true, "crosshair_lps": [260.25, 224.625, -290.0]} + }, + { + "id": "presence", + "prompt": "Is a focal pancreatic abnormality present?", + "choices": [ + {"id": "yes", "label": "Yes, a focal abnormality is present", "claims": {"lesion_present": true}}, + {"id": "no", "label": "No focal abnormality is present", "claims": {"lesion_present": false}}, + {"id": "diffuse", "label": "Only diffuse pancreatic change is present", "claims": {"lesion_present": false}}, + {"id": "outside", "label": "The finding is centered outside the pancreas", "claims": {"lesion_present": false}} + ], + "correct_choice_id": "yes", + "explanation": "There is a discrete focal abnormality within the pancreas.", + "viewer_cue": {"clear_overlays": true, "crosshair_lps": [260.25, 224.625, -290.0]} + }, + { + "id": "location", + "prompt": "Where is the focal abnormality located within the pancreas?", + "choices": [ + {"id": "tail", "label": "Pancreatic tail", "claims": {"lesion_present": true, "pancreatic_region": "tail"}}, + {"id": "body", "label": "Pancreatic body", "claims": {"lesion_present": true, "pancreatic_region": "body"}}, + {"id": "head", "label": "Pancreatic head", "claims": {"lesion_present": true, "pancreatic_region": "head"}}, + {"id": "not_applicable", "label": "No focal abnormality to localize", "claims": {"lesion_present": false}} + ], + "correct_choice_id": "tail", + "explanation": "The focal abnormality is centered in the pancreatic tail.", + "viewer_cue": {"clear_overlays": true, "crosshair_lps": [260.25, 224.625, -290.0]} + }, + { + "id": "conclusion", + "prompt": "Which conclusion best summarizes the case?", + "choices": [ + {"id": "focal_tail_23mm", "label": "Focal pancreatic-tail abnormality, approximately 23 mm at its widest axial extent", "claims": {"lesion_present": true, "pancreatic_region": "tail"}}, + {"id": "focal_head_23mm", "label": "Focal pancreatic-head abnormality, approximately 23 mm at its widest axial extent", "claims": {"lesion_present": true, "pancreatic_region": "head"}}, + {"id": "diffuse_change", "label": "Diffuse pancreatic abnormality without a focal lesion", "claims": {"lesion_present": false}}, + {"id": "no_abnormality", "label": "No focal pancreatic abnormality", "claims": {"lesion_present": false}} + ], + "correct_choice_id": "focal_tail_23mm", + "explanation": "The best conclusion is a focal pancreatic-tail abnormality measuring about 23 mm on its widest axial slice.", + "viewer_cue": {"clear_overlays": true, "crosshair_lps": [260.25, 224.625, -290.0]}, + "reveal_viewer_cue": { + "show_lesion_overlay": true, + "crosshair_lps": [260.25, 224.625, -290.0], + "reference_measurement_lps": [[261.0, 236.25, -290.0], [259.5, 213.0, -290.0]], + "reference_diameter_mm": 23.3, + "lesion_label": 1, + "mesh_organ_id": 22 + } + } + ] + } + ] +} diff --git a/flask-server/services/quiz_generation.py b/flask-server/services/quiz_generation.py new file mode 100644 index 00000000..94efa400 --- /dev/null +++ b/flask-server/services/quiz_generation.py @@ -0,0 +1,1326 @@ +"""Offline deterministic VQA pack generation and release-gate validation.""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import re +import tempfile +import warnings +from collections import Counter +from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +import nibabel as nib +import numpy as np +import openpyxl +from PIL import Image, ImageDraw +from scipy import ndimage +from scipy.spatial import ConvexHull, cKDTree, distance + +from services.live_quiz import CATALOG_VERSION, V2_QUESTION_ORDER, QuizPackError, validate_pack + + +GENERATOR_VERSION = "bodymaps-vqa-generator/2.0.0" +VALIDATOR_VERSION = "bodymaps-vqa-validator/2.0.0" +TEMPLATE_VERSION = "pancreas-imaging-chain/2.0.0" +REPORT_PARSER_VERSION = "radgpt-structured-report/1.0.0" +TARGETS = {50: (35, 15), 100: (70, 30), 500: (350, 150)} +DEFAULT_PROFILE_PATH = Path(__file__).with_name("quiz_label_profiles.v1.json") +DEFAULT_LEDGER_PATH = Path(__file__).with_name("quiz_approval_ledger.v1.json") + + +class QuizGenerationError(RuntimeError): + """Candidate-specific deterministic generation failure.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True) +class Candidate: + case_id: str + dataset_id: str + tumor_positive: bool + spacing: tuple[float, float, float] | None + metadata: dict[str, Any] + + +@dataclass +class GeneratedCandidate: + pack: dict[str, Any] + warnings: list[dict[str, str]] + qa_image: str | None = None + + +@dataclass(frozen=True) +class ParsedStructuredReport: + report_sha256: str + normalized_text: str + findings: str + impression: str + organ_sizes: dict[str, dict[str, str]] + pancreatic_lesions: tuple[dict[str, Any], ...] + pancreatic_mean_hu: float | None + valid: bool + validation_outcome: str + + +def normalize_structured_report(raw_report: str) -> str: + """Canonicalize RadGPT workbook text without changing clinical meaning.""" + text = str(raw_report).replace("_x000D_", "\n").replace("\r\n", "\n").replace("\r", "\n") + text = ( + text.replace("\u00a0", " ") + .replace("\u00d7", "x") + .replace("\u2212", "-") + .replace("\u00b1", "+/-") + .replace("cm\u00b3", "cm^3") + .replace("mm\u00b3", "mm^3") + ) + text = re.sub(r"\bcentimet(?:er|re)s?\b", "cm", text, flags=re.IGNORECASE) + text = re.sub(r"\bmillimet(?:er|re)s?\b", "mm", text, flags=re.IGNORECASE) + normalized_lines: list[str] = [] + for raw_line in text.split("\n"): + line = re.sub(r"[ \t]+", " ", raw_line).strip() + heading = re.match(r"^(FINDINGS?|IMPRESSION)\s*:?\s*(.*)$", line, flags=re.IGNORECASE) + if heading: + normalized_lines.append("FINDINGS:" if heading.group(1).lower().startswith("finding") else "IMPRESSION:") + if heading.group(2): + normalized_lines.append(heading.group(2).strip()) + elif line: + normalized_lines.append(line) + elif normalized_lines and normalized_lines[-1] != "": + normalized_lines.append("") + while normalized_lines and normalized_lines[-1] == "": + normalized_lines.pop() + return "\n".join(normalized_lines) + + +def _report_text_from_metadata(metadata: dict[str, Any]) -> str | None: + preferred: list[tuple[int, str, Any]] = [] + for key, value in metadata.items(): + normalized = re.sub(r"[^a-z0-9]+", " ", str(key).lower()).strip() + if "report" not in normalized.split(): + continue + priority = ( + 0 if normalized == "structured report" + else 1 if "radgpt" in normalized.split() and "structured" in normalized.split() + else 2 if "structured" in normalized.split() + else 3 if normalized == "report" + else 4 + ) + preferred.append((priority, str(key), value)) + for _, _, value in sorted(preferred, key=lambda item: (item[0], item[1].lower())): + if value is not None and not (isinstance(value, float) and math.isnan(value)) and str(value).strip(): + return str(value) + return None + + +def _organ_sections(findings: str) -> dict[str, str]: + pattern = re.compile(r"(?im)^\s*(Spleen|Liver|Pancreas|Kidneys?)\s*:\s*$") + matches = list(pattern.finditer(findings)) + sections: dict[str, str] = {} + for index, match in enumerate(matches): + name = match.group(1).lower() + key = "kidneys" if name.startswith("kidney") else name + end = matches[index + 1].start() if index + 1 < len(matches) else len(findings) + sections[key] = findings[match.end():end].strip() + return sections + + +def _explicit_size_state(section: str) -> tuple[str, str] | None: + lowered = section.lower() + states: set[str] = set() + if "massively enlarged" in lowered: + states.add("massively_enlarged") + without_massive = lowered.replace("massively enlarged", "") + if re.search(r"\benlarged\b", without_massive): + states.add("enlarged") + if re.search(r"\bnormal size\b|\bsize (?:is|are) normal\b", lowered): + states.add("normal") + if len(states) != 1: + return None + state = next(iter(states)) + evidence = next( + (line for line in section.splitlines() if re.search(r"normal size|enlarged|size (?:is|are) normal", line, re.IGNORECASE)), + section.splitlines()[0] if section.splitlines() else "", + ) + return state, re.sub(r"\s+", " ", evidence).strip() + + +def _parse_dimensions_mm(block: str) -> tuple[float, ...]: + match = re.search( + r"\bSize\s*:\s*(?PBodyMaps implementation report for engineering handoff
BodyMaps now has a working 500-case development quiz bank instead of one usable case. The bank contains 350 lesion-label-positive and 150 lesion-label-absent cases, works in solo practice and Live Quiz, and includes local CT, masks, QA images, and fast preview volumes. Automated checks passed. Human content review remains intentionally deferred: all 500 regenerated v1.1 packs are pending.
+ +Quiz cases
500500 unique cases and pack IDsCase mix
350 / 150Tumor-positive / normalFast assets
524Aligned low-resolution CT/mask pairsAutomated tests
391222 backend + 169 frontendCurrent state
Implementation
Files and data
flask-server/scripts/stage_quiz_dataset.pyStages official masks and deterministic CT/mask cohort.
flask-server/services/quiz_generation.pyBuilds and validates versioned quiz packs.
flask-server/services/live_quiz.pyLoads catalogs, exposes playlists, and scores answers.
flask-server/scripts/make_lowres.pyCreates aligned preview CT/mask pairs with lower memory use.
PanTS-Demo/src/education/QuizPracticePage.tsxRuns solo four-question practice attempts.
PanTS-Demo/src/liveRooms/LiveQuizDock.tsxAdds quiz controls and reveal state to Live Rooms.
PanTS-Demo/src/routes/VisualizationPage.tsxUses quiz viewer cues and keeps quiz sessions on fast preview volumes.
quiz-data/staging/quiz/quiz_catalog.dev.v1.jsonGenerated 500-pack development catalog; local and gitignored.
| Local data | Size | Purpose |
|---|---|---|
| Official source archive | 14 GB | Original PanTS label source |
| Staging dataset | 24 GB | 524 CTs plus 9,901 combined masks and generated quiz artifacts |
| Low-resolution assets | 2.1 GB | 524 fast CT/mask pairs |
Evidence
../.venv/bin/python -m pytest -qPassed222 passed, 6 skipped
npm test -- --runPassed169 tests across 28 files
npm run buildPassedProduction bundle completed; existing large-chunk warnings remained
Selected low-resolution asset auditPassed500/500 present; 0 missing and 0 geometrically misaligned
Live playlist and service checksPassedMixed playlist reports 500 cases at 350/150; Flask and WebSocket health endpoints respond
Controlled browser walkthroughNot completedBrowser extension timed out before a controlled tab could be created; this does not prove or disprove rendered UI behavior
Not claimed
Recommended
| Owner | Action | Expected result |
|---|---|---|
| Content reviewer | Review QA images and 175 flagged packs first; approve, edit, or quarantine each pack. | Trusted reviewed catalog replaces development-only access. |
| Engineer | Run one solo and one Live Quiz browser walkthrough on a non-Case-35 pack when browser control is available. | Rendered viewer, choices, scoring, and reveal behavior gain visual proof. |
| Maintainer | Separate intended quiz changes from older dirty-worktree changes, then commit scoped files and deployment configuration. | Reviewable change set without overwriting existing work. |
| Deployment owner | Set catalog, unreviewed-development, dataset, and low-resolution paths explicitly per environment. | Local testing can include pending packs while production remains approved-only. |
Data origin
Dataset and publication source.
Combined-label source staged for quiz generation.