diff --git a/.gitignore b/.gitignore index c6419429..32a2a7ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,17 @@ .env node_modules/ +.venv/ certs/ dist/ tmp/* /PanTS/ downloaded.nii.gz +PanTS-Demo/public/image_only/PanTS_*/ +PanTS-Demo/public/mask_only/PanTS_*/ +PanTS-Demo/public/quiz/ +/quiz-data/ +PanTS-Demo/public/metadata.official.xlsx PanTS/data/pdf/ .DS_Store @@ -19,3 +25,6 @@ flask-server/pants-dev.db-shm # Local Claude Code scratch config (launch profiles etc.) .claude/ + +# Local Playwright MCP artifacts. +.playwright-mcp/ diff --git a/PanTS-Demo/eslint.config.js b/PanTS-Demo/eslint.config.js index 8f7b2418..9d8035b0 100644 --- a/PanTS-Demo/eslint.config.js +++ b/PanTS-Demo/eslint.config.js @@ -28,7 +28,8 @@ export default tseslint.config([ caughtErrorsIgnorePattern: "^_" } ], - "@typescript-eslint/no-explicity-any": "off" + "@typescript-eslint/no-explicit-any": "off", + "react-refresh/only-export-components": "off" } }, ]) diff --git a/PanTS-Demo/public/favicon/favicon.svg b/PanTS-Demo/public/favicon/favicon.svg new file mode 100644 index 00000000..124d62e5 --- /dev/null +++ b/PanTS-Demo/public/favicon/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/PanTS-Demo/src/App.tsx b/PanTS-Demo/src/App.tsx index 98b9c31f..5b6cee7f 100644 --- a/PanTS-Demo/src/App.tsx +++ b/PanTS-Demo/src/App.tsx @@ -19,6 +19,9 @@ import ScrollToTopButton from "./components/ScrollToTopButton"; const VisualizationPage = lazy(() => import("./routes/VisualizationPage")); const CompareViewerPage = lazy(() => import("./routes/CompareViewerPage")); const UploadPage = lazy(() => import("./routes/UploadPage")); +const LiveRoomPage = lazy(() => import("./liveRooms/LiveRoomPage")); +const SoloChallengePage = lazy(() => import("./education/SoloChallengePage")); +const QuizPracticePage = lazy(() => import("./education/QuizPracticePage")); const SettingsPage = lazy(() => import("./routes/Settings")); const ProfileSettings = lazy(() => import("./routes/Settings/ProfileSettings")); const PlanSettings = lazy(() => import("./routes/Settings/PlanSettings")); @@ -97,6 +100,9 @@ function App() { /> } /> } /> + } /> + } /> + } /> } diff --git a/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx b/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx index bc15be66..e7062353 100644 --- a/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx +++ b/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useAuth } from "../../contexts/authContext"; +import { track } from "../../helpers/analytics"; import { API_BASE } from "../../helpers/constants"; import type { AIAction, @@ -853,6 +854,7 @@ export default function AISidebar({ const text = (overrideText ?? input).trim(); const outgoingAttachments = attachments; if ((!text && outgoingAttachments.length === 0) || loading) return; + track("assistant_send_message"); const conversation = messages .filter((message) => message.role === "user" || message.role === "assistant") diff --git a/PanTS-Demo/src/components/MeshViewer.tsx b/PanTS-Demo/src/components/MeshViewer.tsx deleted file mode 100644 index 21a72c6d..00000000 --- a/PanTS-Demo/src/components/MeshViewer.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { Bounds, OrbitControls } from "@react-three/drei"; -import { Canvas } from "@react-three/fiber"; -import { Suspense, useEffect, useMemo, useState } from "react"; -import { APP_CONSTANTS } from "../helpers/constants"; -import { cornerstoneLpsMmToThree, type Vec3 } from "../helpers/utils"; -import type { MeshManifest } from "../types"; -import { OrganMesh } from "./viewer/OrganMesh"; -import { SceneCrosshair3D } from "./SceneCrosshair3D"; -import type { Color } from "@cornerstonejs/core/types"; -import { LiveSegmentMesh } from "./viewer/LiveSegmentMesh"; -import type { CheckBoxData } from "../types"; -import { getEditedSegments, subscribeToSegmentationEdits } from "../helpers/CornerstoneNifti2"; - -type SegmentationMeshViewerProps = { - caseId: string; - loading: boolean - checkState: boolean[]; - opacity: number; - crosshairMm: Vec3 | null - customOrgans?: CheckBoxData[]; - labelColorMap?: { [key: number]: Color }; -}; - -export async function fetchMeshManifest(caseId: string): Promise { - const res = await fetch(`${APP_CONSTANTS.API_ORIGIN}/api/cases/${caseId}/mesh-manifest`); - if (!res.ok) throw new Error(`Failed to fetch mesh manifest: ${res.status}`); - return res.json(); -} - -export function SegmentationMeshViewer({ caseId, checkState, loading, opacity, crosshairMm, customOrgans = [], labelColorMap = {}}: SegmentationMeshViewerProps) { - const [manifest, setManifest] = useState(null); - const [loaded, setLoaded] = useState>({}); - // Bumped on every mask edit so editedSegments below is recomputed — the 3D pane - // needs to know the instant a static organ's mask changes, not just at mount. - const [editVersion, setEditVersion] = useState(0); - - useEffect(() => { - const unsubscribe = subscribeToSegmentationEdits(() => setEditVersion((v) => v + 1)); - return unsubscribe; - }, []); - - // Segment indices touched since the case loaded — includes edits to the STATIC - // 32-organ catalog, not just brand-new custom classes. - const editedSegments = useMemo(() => getEditedSegments(), [editVersion]); - - const crosshairPosition = useMemo(() => { - if (!manifest || !crosshairMm) return null; - return cornerstoneLpsMmToThree(crosshairMm, manifest.center); - }, [manifest, crosshairMm]); - - useEffect(() => { - let alive = true; - fetchMeshManifest(caseId) - .then((data) => { - if (!alive) return; - setManifest(data); - const initialLoaded: Record = {}; - for (const organ of data.organs) initialLoaded[organ.id] = true; - setLoaded(initialLoaded); - }) - .catch((err) => console.error(err)); - return () => { alive = false; }; - }, [caseId]); - - const organs = useMemo(() => manifest?.organs ?? [], [manifest]); - - if (!manifest || loading || !checkState || checkState.length === 0) { - return
Loading 3D segmentation...
; - } - return ( -
-
- - - - - - - - {organs.map((organ) => { - if (!loaded[organ.id]) return null; - // Edited static organ: the server-baked GLB is stale — extract a - // fresh live mesh from the in-memory labelmap instead, same path - // custom classes already use. - if (editedSegments.has(organ.id)) { - return ( - - ); - } - return ( - - ); - })} - {customOrgans.map((organ) => ( - - ))} - - - {crosshairPosition && manifest.bounds && ( - - )} - - - -
-
- ); -} \ No newline at end of file diff --git a/PanTS-Demo/src/components/OrganCheckbox.tsx b/PanTS-Demo/src/components/OrganCheckbox.tsx index 761c716e..f9e21fa5 100644 --- a/PanTS-Demo/src/components/OrganCheckbox.tsx +++ b/PanTS-Demo/src/components/OrganCheckbox.tsx @@ -62,7 +62,6 @@ function Checked({ OrganSystem[system].forEach((sub) => { if (typeof sub === "string") { newCheckState[getOrganIdx(sub) + 1] = toggled; - console.log(toggled); return; } const key: SubSystems = Object.keys(sub)[0] as SubSystems; @@ -214,18 +213,16 @@ function Checked({ ) { const organKey: AllSystems = Object.keys(organ)[0] as AllSystems; return ( - <> - - + ); } })} diff --git a/PanTS-Demo/src/components/ReportScreen/ReportScreen.tsx b/PanTS-Demo/src/components/ReportScreen/ReportScreen.tsx index 530488cd..9eb36731 100644 --- a/PanTS-Demo/src/components/ReportScreen/ReportScreen.tsx +++ b/PanTS-Demo/src/components/ReportScreen/ReportScreen.tsx @@ -119,7 +119,7 @@ type ReportMeasurements = { function getReportMeasurements(organ: string, comments: string): ReportMeasurements { const section = getReportSection(organ, comments); const volumeMatch = section?.match(/volume:\s*([\d.]+)\s*cc/i); - const huMatch = section?.match(/Mean HU value:\s*([\d.]+)(?:\s*\+\/\-\s*([\d.]+))?/i); + const huMatch = section?.match(/Mean HU value:\s*([\d.]+)(?:\s*\+\/-\s*([\d.]+))?/i); const sizeMatch = section?.match(/Size:\s*([^().]+?)\s*cm/i); return { @@ -430,7 +430,9 @@ export default function ReportScreen({ id, onClose, onViewChange, onOrganHighlig }); const j = await r.json(); setPlain2(j.plain_language || []); - } catch {} finally { setPLoad(false); } + } catch { + // Plain-language text is optional; retain the original report on failure. + } finally { setPLoad(false); } }, [data, plain2]); useEffect(() => { if (data) fetchPlain(); }, [data]); diff --git a/PanTS-Demo/src/components/ScrollToTopButton/ScrollToTopButton.module.css b/PanTS-Demo/src/components/ScrollToTopButton/ScrollToTopButton.module.css index 5139a0c2..6cb3a74b 100644 --- a/PanTS-Demo/src/components/ScrollToTopButton/ScrollToTopButton.module.css +++ b/PanTS-Demo/src/components/ScrollToTopButton/ScrollToTopButton.module.css @@ -43,4 +43,3 @@ height: 20px; flex-shrink: 0; } - diff --git a/PanTS-Demo/src/components/viewer/MeshViewer.tsx b/PanTS-Demo/src/components/viewer/MeshViewer.tsx index a53354ff..30a393ae 100644 --- a/PanTS-Demo/src/components/viewer/MeshViewer.tsx +++ b/PanTS-Demo/src/components/viewer/MeshViewer.tsx @@ -30,11 +30,16 @@ export async function fetchMeshManifest(caseId: string, isSession = false): Prom : `${APP_CONSTANTS.API_ORIGIN}/api/cases/${caseId}/mesh-manifest`; const res = await fetch(base); if (!res.ok) throw new Error(`Failed to fetch mesh manifest: ${res.status}`); - return res.json(); + const data = await res.json() as Partial; + if (!Array.isArray(data.organs) || !Array.isArray(data.center)) { + throw new Error("Mesh manifest response is invalid"); + } + return data as MeshManifest; } export function SegmentationMeshViewer({ caseId, checkState, loading, opacity, crosshairMm, customOrgans = [], labelColorMap = {}, isSession = false}: SegmentationMeshViewerProps) { const [manifest, setManifest] = useState(null); + const [manifestError, setManifestError] = useState(false); const [loaded, setLoaded] = useState>({}); // Bumped on every mask edit so editedSegments below is recomputed — the 3D pane // needs to know the instant a static organ's mask changes, not just at mount. @@ -56,6 +61,8 @@ export function SegmentationMeshViewer({ caseId, checkState, loading, opacity, c useEffect(() => { let alive = true; + setManifest(null); + setManifestError(false); fetchMeshManifest(caseId, isSession) .then((data) => { if (!alive) return; @@ -64,12 +71,13 @@ export function SegmentationMeshViewer({ caseId, checkState, loading, opacity, c for (const organ of data.organs) initialLoaded[organ.id] = true; setLoaded(initialLoaded); }) - .catch((err) => console.error(err)); + .catch(() => { if (alive) setManifestError(true); }); return () => { alive = false; }; }, [caseId, isSession]); const organs = useMemo(() => manifest?.organs ?? [], [manifest]); + if (manifestError) return
3D segmentation unavailable.
; if (!manifest || loading || !checkState || checkState.length === 0) { return
Loading 3D segmentation...
; } @@ -106,6 +114,7 @@ export function SegmentationMeshViewer({ caseId, checkState, loading, opacity, c organ={organ} visible={!!checkState[organ.id]} opacity={opacity/100} + color={labelColorMap[organ.id]} /> ); })} @@ -130,4 +139,4 @@ export function SegmentationMeshViewer({ caseId, checkState, loading, opacity, c ); -} \ No newline at end of file +} diff --git a/PanTS-Demo/src/components/viewer/OrganMesh.tsx b/PanTS-Demo/src/components/viewer/OrganMesh.tsx index da812730..fc1b6097 100644 --- a/PanTS-Demo/src/components/viewer/OrganMesh.tsx +++ b/PanTS-Demo/src/components/viewer/OrganMesh.tsx @@ -1,4 +1,5 @@ import { useGLTF } from "@react-three/drei"; +import type { Color } from "@cornerstonejs/core/types"; import { useEffect, useMemo } from "react"; import * as THREE from "three"; import { segmentation_category_colors } from '../../helpers/constants'; @@ -7,33 +8,40 @@ type OrganMeshProps = { organ: OrganMeshInfo; visible: boolean; opacity?: number; + color?: Color; }; export const rgbToHex = (r: number, g: number, b: number, _a: number) => '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join(''); -export function OrganMesh({ organ, visible, opacity = 1 }: OrganMeshProps) { +export function OrganMesh({ organ, visible, opacity = 1, color }: OrganMeshProps) { const gltf = useGLTF(organ.url); const object = useMemo(() => { return gltf.scene.clone(true); }, [gltf.scene]); useEffect(() => { + const createdMaterials: THREE.Material[] = []; object.traverse((child) => { if (!(child instanceof THREE.Mesh)) return; - child.material = new THREE.MeshStandardMaterial({ - color: new THREE.Color(rgbToHex(...segmentation_category_colors[organ.id])), + const material = new THREE.MeshStandardMaterial({ + color: new THREE.Color(rgbToHex(...(color ?? segmentation_category_colors[organ.id]))), roughness: 0.75, metalness: 0.0, transparent: opacity < 1, opacity, side: THREE.DoubleSide, }); + child.material = material; + createdMaterials.push(material); child.frustumCulled = true; }); - }, [object, organ.id, opacity]); + return () => { + for (const material of createdMaterials) material.dispose(); + }; + }, [object, organ.id, opacity, color]); return ; -} \ No newline at end of file +} diff --git a/PanTS-Demo/src/education/QuizPracticeChrome.test.tsx b/PanTS-Demo/src/education/QuizPracticeChrome.test.tsx new file mode 100644 index 00000000..78ff13df --- /dev/null +++ b/PanTS-Demo/src/education/QuizPracticeChrome.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { QuizPracticeDock, QuizPracticeHeader } from "./QuizPracticeChrome"; +import type { QuizPracticeController } from "./types"; + +const pack = { + pack_id: "test-pack-v1", + version: 1, + case_id: "88", + title: "Case 88 · Pancreas imaging quiz", + difficulty: "medium" as const, + provenance: {}, + generator_version: "generator/1", + validator_version: "validator/1", + questions: [ + { + id: "organ", + prompt: "Which organ?", + choices: [{ id: "pancreas", label: "Pancreas" }, { id: "liver", label: "Liver" }], + viewer_cue: { crosshair_lps: [1, 2, 3] as [number, number, number] }, + }, + ], +}; + +function controller(overrides: Partial = {}): QuizPracticeController { + return { + pack, + questionIndex: 0, + answers: {}, + result: null, + maskUrl: null, + submitting: false, + error: null, + dockOpen: true, + setDockOpen: vi.fn(), + selectAnswer: vi.fn(), + previous: vi.fn(), + next: vi.fn(), + reportContent: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe("QuizPracticeChrome", () => { + it("shows one server-authored question and forwards private selection", () => { + const value = controller(); + render(<>); + expect(screen.getByText("Case 88 · medium")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Pancreas/i })); + expect(value.selectAnswer).toHaveBeenCalledWith("pancreas"); + expect(screen.getByRole("button", { name: /Submit/i })).toBeDisabled(); + expect(screen.getByText("1 linked question")).toBeInTheDocument(); + }); + + it("reveals answer and explanation only after completed result", () => { + const value = controller({ + answers: { organ: "liver" }, + result: { + attempt_id: "attempt", + pack_id: pack.pack_id, + pack_version: 1, + case_id: pack.case_id, + status: "completed", + completed_at: "now", + score: 0, + max_score: 1, + answers: { organ: "liver" }, + consistency: { status: "incomplete", reasons: [] }, + reveals: [{ + question_id: "organ", + correct_choice_id: "pancreas", + explanation: "Crosshair is in pancreas.", + source_label: "Structured report finding", + distribution: { liver: 1, pancreas: 0 }, + }], + }, + }); + render(); + expect(screen.getByText("Incorrect")).toBeInTheDocument(); + expect(screen.getByText("Crosshair is in pancreas.")).toBeInTheDocument(); + expect(screen.getByText("Structured report finding")).toBeInTheDocument(); + expect(screen.getByText("0/1 correct")).toBeInTheDocument(); + }); +}); diff --git a/PanTS-Demo/src/education/QuizPracticeChrome.tsx b/PanTS-Demo/src/education/QuizPracticeChrome.tsx new file mode 100644 index 00000000..4d09c307 --- /dev/null +++ b/PanTS-Demo/src/education/QuizPracticeChrome.tsx @@ -0,0 +1,75 @@ +import { IconAlertTriangle, IconArrowLeft, IconArrowRight, IconCheck, IconFlag, IconLayoutSidebarRight, IconTrophy, IconX } from "@tabler/icons-react"; +import { useState } from "react"; +import type { QuizPracticeController } from "./types"; + +export function QuizPracticeHeader({ controller }: { controller: QuizPracticeController }) { + return ( +
+
+ +
Solo VQA PracticeCase {controller.pack.case_id} · {controller.pack.difficulty}
+
+
Untimed · answer key stays server-side until submission
+
+ {controller.result ? `${controller.result.score}/${controller.result.max_score}` : `${controller.questionIndex + 1}/${controller.pack.questions.length}`} + +
+
+ ); +} + +export function QuizPracticeDock({ controller }: { controller: QuizPracticeController }) { + const [reported, setReported] = useState(false); + const question = controller.pack.questions[controller.questionIndex]; + const reveal = controller.result?.reveals.find((item) => item.question_id === question.id); + const selected = controller.answers[question.id]; + return ( + + ); +} diff --git a/PanTS-Demo/src/education/QuizPracticePage.test.tsx b/PanTS-Demo/src/education/QuizPracticePage.test.tsx new file mode 100644 index 00000000..19f39c02 --- /dev/null +++ b/PanTS-Demo/src/education/QuizPracticePage.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { gzip } from "pako"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { QuizPracticeController } from "./types"; + +vi.mock("../routes/VisualizationPage", () => ({ + default: ({ quizPractice }: { quizPractice: QuizPracticeController }) => ( +
+ {quizPractice.maskUrl ?? "no-mask"} + + +
+ ), +})); + +import QuizPracticePage from "./QuizPracticePage"; + +function readBlob(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); +} + +const pack = { + pack_id: "radworld-case-35-v1", + version: 1, + case_id: "35", + title: "Case 35", + difficulty: "easy" as const, + provenance: {}, + generator_version: "test", + validator_version: "test", + questions: [{ + id: "organ", + prompt: "Which organ?", + choices: [{ id: "pancreas", label: "Pancreas" }], + }], +}; + +const result = { + attempt_id: "attempt-1", + pack_id: pack.pack_id, + pack_version: 1, + case_id: "35", + status: "completed", + completed_at: "2026-08-13T00:00:00Z", + score: 1, + max_score: 1, + answers: { organ: "pancreas" }, + consistency: { status: "consistent", reasons: [] }, + reveals: [], +}; + +describe("QuizPracticePage reveal mask", () => { + let createdBlob: Blob | null; + const rawNifti = new Uint8Array([92, 1, 0, 0, 110, 43, 49, 0]); + + beforeEach(() => { + createdBlob = null; + Object.assign(URL, { + createObjectURL: vi.fn((blob: Blob) => { + createdBlob = blob; + return "blob:quiz-mask"; + }), + revokeObjectURL: vi.fn(), + }); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/attempts")) { + return new Response(JSON.stringify({ + attempt_id: "attempt-1", + attempt_key: "secret", + pack, + }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + if (url.endsWith("/submit")) { + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.endsWith("/reveal-segmentation.nii.gz")) { + return new Response(gzip(rawNifti), { status: 200 }); + } + throw new Error(`Unexpected request: ${url}`); + })); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it("decompresses gzip before creating suffix-less reveal blob URL", async () => { + render( + + + } /> + + , + ); + + expect(await screen.findByText("no-mask")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Select answer" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + expect(await screen.findByText("blob:quiz-mask")).toBeInTheDocument(); + await waitFor(() => expect(createdBlob).not.toBeNull()); + expect(new Uint8Array(await readBlob(createdBlob!))).toEqual(rawNifti); + expect(createdBlob!.type).toBe("application/octet-stream"); + }); +}); diff --git a/PanTS-Demo/src/education/QuizPracticePage.tsx b/PanTS-Demo/src/education/QuizPracticePage.tsx new file mode 100644 index 00000000..9a8b7261 --- /dev/null +++ b/PanTS-Demo/src/education/QuizPracticePage.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ungzip } from "pako"; +import { useParams } from "react-router-dom"; +import { API_BASE } from "../helpers/constants"; +import VisualizationPage from "../routes/VisualizationPage"; +import type { + QuizPracticeController, + QuizPracticePack, + QuizPracticeResult, +} from "./types"; +import "../liveRooms/liveRooms.css"; +import "./quizPractice.css"; + +type Attempt = { + attempt_id: string; + attempt_key: string; + pack: QuizPracticePack; +}; + +async function responseJson(response: Response): Promise { + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error || `Request failed (${response.status})`); + return body as T; +} + +export default function QuizPracticePage() { + const { packId = "" } = useParams<{ packId: string }>(); + const [attempt, setAttempt] = useState(null); + const [questionIndex, setQuestionIndex] = useState(0); + const [answers, setAnswers] = useState>({}); + const [result, setResult] = useState(null); + const [maskUrl, setMaskUrl] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [dockOpen, setDockOpen] = useState(true); + + useEffect(() => { + let active = true; + setAttempt(null); + setQuestionIndex(0); + setAnswers({}); + setResult(null); + setMaskUrl(null); + setError(null); + fetch(`${API_BASE}/api/education/quiz-packs/${encodeURIComponent(packId)}/attempts`, { + method: "POST", + }).then((response) => responseJson(response)).then((value) => { + if (active) setAttempt(value); + }).catch((caught) => { + if (active) setError(caught instanceof Error ? caught.message : "Quiz practice unavailable"); + }); + return () => { active = false; }; + }, [packId]); + + useEffect(() => () => { + if (maskUrl) URL.revokeObjectURL(maskUrl); + }, [maskUrl]); + + const submit = useCallback(async () => { + if (!attempt || submitting || result) return; + setSubmitting(true); + setError(null); + try { + const response = await fetch(`${API_BASE}/api/education/quiz-attempts/${attempt.attempt_id}/submit`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Quiz-Attempt-Key": attempt.attempt_key, + }, + body: JSON.stringify({ answers }), + }); + const completed = await responseJson(response); + setResult(completed); + const revealResponse = await fetch( + `${API_BASE}/api/education/quiz-attempts/${attempt.attempt_id}/reveal-segmentation.nii.gz`, + { headers: { "X-Quiz-Attempt-Key": attempt.attempt_key } }, + ); + if (!revealResponse.ok) throw new Error("Quiz reveal could not be loaded"); + // Blob URLs have no `.gz` suffix, so Cornerstone cannot infer gzip handling. + // Expose raw NIfTI bytes, matching live-room reveal-mask handling. + const compressedMask = new Uint8Array(await revealResponse.arrayBuffer()); + setMaskUrl(URL.createObjectURL(new Blob([ + new Uint8Array(ungzip(compressedMask)), + ], { type: "application/octet-stream" }))); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Quiz could not be submitted"); + } finally { + setSubmitting(false); + } + }, [answers, attempt, result, submitting]); + + const next = useCallback(() => { + if (!attempt) return; + if (result) { + setQuestionIndex((current) => Math.min(attempt.pack.questions.length - 1, current + 1)); + return; + } + const question = attempt.pack.questions[questionIndex]; + if (!answers[question.id]) return; + if (questionIndex === attempt.pack.questions.length - 1) { + void submit(); + } else { + setQuestionIndex((current) => current + 1); + } + }, [answers, attempt, questionIndex, result, submit]); + + const reportContent = useCallback(async (category: string) => { + if (!attempt) return; + try { + const response = await fetch(`${API_BASE}/api/education/quiz-packs/${encodeURIComponent(attempt.pack.pack_id)}/reports`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ category, mode: "solo" }), + }); + await responseJson(response); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Content report could not be recorded"); + throw caught; + } + }, [attempt]); + + const controller = useMemo(() => attempt ? ({ + pack: attempt.pack, + questionIndex, + answers, + result, + maskUrl, + submitting, + error, + dockOpen, + setDockOpen, + selectAnswer: (choiceId) => { + if (result) return; + const question = attempt.pack.questions[questionIndex]; + setAnswers((current) => ({ ...current, [question.id]: choiceId })); + }, + previous: () => setQuestionIndex((current) => Math.max(0, current - 1)), + next, + reportContent, + }) : null, [answers, attempt, dockOpen, error, maskUrl, next, questionIndex, reportContent, result, submitting]); + + if (error && !attempt) return ( +

Quiz practice unavailable

{error}

Return to dashboard
+ ); + if (!controller) return

Preparing quiz pack…

; + return ; +} diff --git a/PanTS-Demo/src/education/SoloChallengeChrome.test.tsx b/PanTS-Demo/src/education/SoloChallengeChrome.test.tsx new file mode 100644 index 00000000..65201bed --- /dev/null +++ b/PanTS-Demo/src/education/SoloChallengeChrome.test.tsx @@ -0,0 +1,143 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SoloChallengeDock } from "./SoloChallengeChrome"; +import type { EducationChallenge, EducationResult, SoloChallengeController } from "./types"; + +const challenge: EducationChallenge = { + challenge_id: "pancreas-case-35", + case_id: "35", + title: "Find the abnormal area in the pancreas", + eyebrow: "BodyMaps Solo Challenge 01", + prompt: "Review the scan.", + time_limit_seconds: 300, + finding_choices: [ + { id: "no_focal_lesion", label: "No focal pancreatic lesion" }, + { id: "focal_pancreatic_lesion", label: "Focal pancreatic lesion" }, + ], + requirements: [], + scoring: { localization: 35, measurement: 15, finding: 10, impression: 40, time: "tie_break" }, +}; + +function controller(overrides: Partial = {}): SoloChallengeController { + return { + challenge, + attempt: { + attempt_id: "attempt-1", attempt_key: "key", challenge_id: challenge.challenge_id, + started_at: "2026-07-31T12:00:00Z", deadline_at: "2026-07-31T12:05:00Z", + delete_at: "2026-08-01T12:00:00Z", status: "active", + }, + remainingSeconds: 240, + findingChoice: "focal_pancreatic_lesion", + setFindingChoice: vi.fn(), + impression: "Focal pancreatic lesion with a measurable axial diameter.", + setImpression: vi.fn(), + marker: [-5, -5, 2], + setMarker: vi.fn(), + measurement: null, + setMeasurement: vi.fn(), + result: null, + submitting: false, + retryingGrade: false, + error: null, + submit: vi.fn(), + retryGrade: vi.fn(), + taskDockOpen: true, + setTaskDockOpen: vi.fn(), + clearSession: vi.fn(), + ...overrides, + }; +} + +const measurement = { uid: "m1", tool: "Length", label: "", value: "31.0 mm", center: [-5, -5, 2] as [number, number, number] }; +const serialized = { id: "m1", tool: "Length", points: [[-4, -4, 2], [-6, -6, 2]], polyline: [], text: "", label: "", frame_of_reference: "", metadata: {} }; + +describe("Solo Challenge chrome", () => { + it("requires a complete marked and measured interpretation before submission", () => { + const onSubmit = vi.fn(); + render(); + expect(screen.getByText("Measure the abnormal area")).toBeInTheDocument(); + expect(screen.getByText(/axial \(top-down\) CT view/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Start measuring" })).toBeEnabled(); + const button = screen.getByRole("button", { name: /Submit interpretation/i }); + expect(button).toBeEnabled(); + fireEvent.click(button); + expect(onSubmit).toHaveBeenCalledOnce(); + }); + + it("offers an AI grading retry while preserving a provisional result", () => { + const retryGrade = vi.fn(); + const result: EducationResult = { + attempt_id: "attempt-1", challenge_id: challenge.challenge_id, status: "provisional", + submitted_at: "2026-07-31T12:03:00Z", elapsed_seconds: 180, + objective_points: 60, total_points: null, max_points: 100, + scores: { + localization: { points: 35, max_points: 35, distance_mm: 0, inside_lesion: true }, + measurement: { points: 15, max_points: 15, measured_mm: 31, reference_mm: 30, error_percent: 3.3 }, + finding: { points: 10, max_points: 10, selected: "focal_pancreatic_lesion", correct: "focal_pancreatic_lesion" }, + }, + ai_grade: { status: "provisional", model: "llama", rubric_version: 1, criteria: null, points: null, max_points: 40, feedback: null }, + ground_truth: { correct_finding: "focal_pancreatic_lesion", correct_finding_label: "Focal pancreatic lesion", segmentation_label: 1, mesh_organ_id: 28, location: "pancreatic head", reference_diameter_mm: 30, reference_measurement_lps: [], teaching_points: [] }, + }; + render(); + expect(screen.getByPlaceholderText("AI tutor becomes available after the impression grade is complete.")).toBeDisabled(); + expect(screen.getByRole("button", { name: "Send question to AI tutor" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: /Retry AI grade/i })); + expect(retryGrade).toHaveBeenCalledOnce(); + }); + + it("keeps submission disabled when an abnormal finding has no marker", () => { + render(); + expect(screen.getByRole("button", { name: /Submit interpretation/i })).toBeDisabled(); + }); + + it("reveals the score and teaching feedback after submission", () => { + const result: EducationResult = { + attempt_id: "attempt-1", challenge_id: challenge.challenge_id, status: "graded", + submitted_at: "2026-07-31T12:03:00Z", elapsed_seconds: 180, + objective_points: 60, total_points: 96, max_points: 100, + scores: { + localization: { points: 35, max_points: 35, distance_mm: 0, inside_lesion: true }, + measurement: { points: 15, max_points: 15, measured_mm: 31, reference_mm: 30, error_percent: 3.3 }, + finding: { points: 10, max_points: 10, selected: "focal_pancreatic_lesion", correct: "focal_pancreatic_lesion" }, + }, + ai_grade: { status: "graded", model: "llama", rubric_version: 1, criteria: { finding: 10, location: 9, evidence: 8, impression: 9 }, points: 36, max_points: 40, feedback: "Strong calibrated impression." }, + ground_truth: { correct_finding: "focal_pancreatic_lesion", correct_finding_label: "Abnormal area in the pancreas", segmentation_label: 1, mesh_organ_id: 28, location: "pancreatic head", reference_diameter_mm: 30, reference_measurement_lps: [], teaching_points: ["Use the top-down CT view."] }, + }; + render(); + expect(screen.getByText("96")).toBeInTheDocument(); + expect(screen.getByText("Correct answer")).toBeInTheDocument(); + expect(screen.getByText("Abnormal area in the pancreas")).toBeInTheDocument(); + expect(screen.getByText("Widest size")).toBeInTheDocument(); + expect(screen.getByText("30 mm")).toBeInTheDocument(); + expect(screen.getByText("Strong calibrated impression.")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Ask why the measurement or impression was scored this way…")).toBeEnabled(); + expect(screen.getByRole("button", { name: "Send question to AI tutor" })).toBeDisabled(); + }); + + it("shows an accessible typing indicator while the AI tutor is responding", () => { + const pendingResponse = new Promise(() => undefined); + const fetchMock = vi.fn(() => pendingResponse); + vi.stubGlobal("fetch", fetchMock); + const result: EducationResult = { + attempt_id: "attempt-1", challenge_id: challenge.challenge_id, status: "graded", + submitted_at: "2026-07-31T12:03:00Z", elapsed_seconds: 180, + objective_points: 60, total_points: 96, max_points: 100, + scores: { + localization: { points: 35, max_points: 35, distance_mm: 0, inside_lesion: true }, + measurement: { points: 15, max_points: 15, measured_mm: 31, reference_mm: 30, error_percent: 3.3 }, + finding: { points: 10, max_points: 10, selected: "focal_pancreatic_lesion", correct: "focal_pancreatic_lesion" }, + }, + ai_grade: { status: "graded", model: "qwen", rubric_version: 1, criteria: { finding: 10, location: 9, evidence: 8, impression: 9 }, points: 36, max_points: 40, feedback: "Strong calibrated impression." }, + ground_truth: { correct_finding: "focal_pancreatic_lesion", correct_finding_label: "Abnormal area in the pancreas", segmentation_label: 1, mesh_organ_id: 28, location: "pancreatic head", reference_diameter_mm: 30, reference_measurement_lps: [], teaching_points: [] }, + }; + render(); + fireEvent.change(screen.getByPlaceholderText("Ask why the measurement or impression was scored this way…"), { target: { value: "What can I improve?" } }); + fireEvent.click(screen.getByRole("button", { name: "Send question to AI tutor" })); + expect(screen.getByRole("status", { name: "AI tutor is thinking" })).toBeInTheDocument(); + expect(screen.getByText("What can I improve?")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledOnce(); + const request = fetchMock.mock.calls[0][1] as RequestInit; + expect(JSON.parse(String(request.body))).toEqual({ message: "What can I improve?", history: [] }); + vi.unstubAllGlobals(); + }); +}); diff --git a/PanTS-Demo/src/education/SoloChallengeChrome.tsx b/PanTS-Demo/src/education/SoloChallengeChrome.tsx new file mode 100644 index 00000000..d4acc4a0 --- /dev/null +++ b/PanTS-Demo/src/education/SoloChallengeChrome.tsx @@ -0,0 +1,216 @@ +import { + IconCheck, + IconClock, + IconCrosshair, + IconMessageCircle, + IconRefresh, + IconRulerMeasure, + IconSend, + IconSparkles, + IconTargetArrow, + IconX, +} from "@tabler/icons-react"; +import { useMemo, useState } from "react"; +import { API_BASE } from "../helpers/constants"; +import type { MeasurementSummary, SharedMeasurement } from "../helpers/CornerstoneNifti2"; +import type { SoloChallengeController } from "./types"; + +function clock(seconds: number): string { + const minutes = Math.floor(seconds / 60); + return `${String(minutes).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; +} + +export function SoloChallengeHeader({ controller }: { controller: SoloChallengeController }) { + const urgent = controller.remainingSeconds <= 60 && !controller.result; + return ( +
+
+ 01 +
Solo ChallengeCase {controller.challenge.case_id} · Pancreas CT
+
+
Find · measure · interpret
+
+
+ + {controller.result ? clock(controller.result.elapsed_seconds) : clock(controller.remainingSeconds)} + {controller.result ? "elapsed" : "remaining"} +
+ +
+
+ ); +} + +export function SoloChallengeDock({ + controller, + crosshair, + measurement, + serializedMeasurement, + onSetMarker, + onActivateMeasure, + onSubmit, +}: { + controller: SoloChallengeController; + crosshair: [number, number, number] | null; + measurement: MeasurementSummary | null; + serializedMeasurement: SharedMeasurement | null; + onSetMarker: () => void; + onActivateMeasure: () => void; + onSubmit: () => void; +}) { + const abnormalChoice = controller.findingChoice && controller.findingChoice !== "no_focal_lesion"; + const ready = Boolean( + controller.findingChoice + && controller.impression.trim() + && (!abnormalChoice || controller.marker && serializedMeasurement), + ); + return ( +