diff --git a/PanTS-Demo/package-lock.json b/PanTS-Demo/package-lock.json
index c2b8c005..97f84b13 100644
--- a/PanTS-Demo/package-lock.json
+++ b/PanTS-Demo/package-lock.json
@@ -20,6 +20,7 @@
"@tabler/icons-react": "^3.34.1",
"@tailwindcss/vite": "^4.1.12",
"esbuild": "^0.25.9",
+ "framer-motion": "^12.42.2",
"jszip": "^3.10.1",
"pako": "^3.0.1",
"pdfjs-dist": "^6.2.108",
@@ -8169,6 +8170,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz",
+ "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.42.2",
+ "motion-utils": "^12.39.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -9437,6 +9465,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/motion-dom": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz",
+ "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.39.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.39.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
+ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10901,6 +10944,12 @@
"typescript": ">=4.8.4"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/tunnel-rat": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
diff --git a/PanTS-Demo/package.json b/PanTS-Demo/package.json
index d465e2bd..1b2b506d 100644
--- a/PanTS-Demo/package.json
+++ b/PanTS-Demo/package.json
@@ -25,6 +25,7 @@
"@tabler/icons-react": "^3.34.1",
"@tailwindcss/vite": "^4.1.12",
"esbuild": "^0.25.9",
+ "framer-motion": "^12.42.2",
"jszip": "^3.10.1",
"pako": "^3.0.1",
"pdfjs-dist": "^6.2.108",
diff --git a/PanTS-Demo/src/App.tsx b/PanTS-Demo/src/App.tsx
index 521db449..5ee401da 100644
--- a/PanTS-Demo/src/App.tsx
+++ b/PanTS-Demo/src/App.tsx
@@ -30,6 +30,7 @@ const AnalyticsSettings = lazy(() => import("./routes/Settings/AnalyticsSettings
const PeopleSettings = lazy(() => import("./routes/Settings/PeopleSettings"));
const SignupRedirect = lazy(() => import("./routes/SignupRedirect"));
const LegalPage = lazy(() => import("./routes/LegalPage"));
+const SharePatientCard = lazy(() => import("./routes/SharePatientCard"));
const RotatingHeartLoader = lazy(() => import("./components/Loading"));
const BASENAME = import.meta.env.VITE_BASENAME;
@@ -97,6 +98,7 @@ function App() {
/>
} />
} />
+ } />
}
@@ -152,4 +154,4 @@ function App() {
);
}
-export default App;
+export default App;
\ No newline at end of file
diff --git a/PanTS-Demo/src/components/ReportScreen/ReportScreen.tsx b/PanTS-Demo/src/components/ReportScreen/ReportScreen.tsx
index 530488cd..0c732f5a 100644
--- a/PanTS-Demo/src/components/ReportScreen/ReportScreen.tsx
+++ b/PanTS-Demo/src/components/ReportScreen/ReportScreen.tsx
@@ -34,7 +34,7 @@ interface ReportData {
type Lang = 'patient' | 'clinical';
type Step = number;
-const cache: { [k: string]: ReportData } = {};
+export const cache: { [key: string]: ReportData } = {};
// ─── Styles ───────────────────────────────────────────────────────────────────
@@ -80,10 +80,15 @@ function getDetail(organ: string, comments: string): string | null {
function organRoot(organ: string): string {
if (organ.startsWith('pancreas')) return 'pancreas';
if (organ.startsWith('kidney')) return 'kidney';
+ // BUG FIX: this used to end with .split(' ')[0], which truncated every
+ // multi-word organ down to its first word — "adrenal_gland_right" became
+ // just "adrenal", "common_bile_duct" became just "common". That broke
+ // section-heading lookup (the report heading wouldn't match a truncated
+ // root) and produced mangled patient sentences ("...in your adrenal.").
+ // Only the trailing location/anatomy-suffix word should ever be stripped.
return organ
.replace(/_(gland|body|tail|head|left|right)$/, '')
.replace(/_/g, ' ')
- .split(' ')[0]
.toLowerCase();
}
@@ -96,12 +101,18 @@ function getReportSection(organ: string, comments: string): string | null {
return cleaned === root || cleaned === `${root}s` || cleaned.startsWith(`${root}:`);
});
if (start === -1) return getDetail(organ, comments);
-
const collected: string[] = [];
+ let lesionsHeadingSeen = false;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
- if (i > start && /^[A-Za-z][A-Za-z\s_/-]*:\s*$/.test(trimmed)) break;
+ const cleanedHeading = trimmed.replace(/:$/, '').toLowerCase();
+ if (i > start && cleanedHeading === `${root} lesions`) {
+ lesionsHeadingSeen = true;
+ continue;
+ }
+ if (i > start && !lesionsHeadingSeen && /^[A-Za-z][A-Za-z\s_/-]*:\s*$/.test(trimmed)) break;
+ if (i > start && lesionsHeadingSeen && /^[A-Za-z][A-Za-z\s_/-]*:\s*$/.test(trimmed) && !cleanedHeading.startsWith(root)) break;
if (i > start && /^IMPRESSION:\s*$/i.test(trimmed)) break;
if (trimmed) collected.push(trimmed);
}
@@ -111,26 +122,108 @@ function getReportSection(organ: string, comments: string): string | null {
type ReportMeasurements = {
section: string | null;
volumeCc: number | null;
+ lesionVolumeCc: number | null;
+ organVolumeCc: number | null;
meanHu: number | null;
+ organMeanHu: number | null;
huSd: number | null;
sizeCm: string | null;
+ lesionCount: number;
};
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 sizeMatch = section?.match(/Size:\s*([^().]+?)\s*cm/i);
+ // Prefer the lesion's own numbers over the organ's baseline stats when a
+ // lesion block is present in this section, since those matter more clinically.
+ const lesionVolumeMatch = section?.match(/lesion[\s\S]*?volume:\s*([\d.]+)\s*cc/i);
+ const lesionHuMatch = section?.match(/hu\s*value\s*is\s*(-?[\d.]+)(?:\s*\+\/\-\s*([\d.]+))?/i);
+ const volumeMatch = lesionVolumeMatch ?? section?.match(/volume:\s*([\d.]+)\s*cc/i);
+ const huMatch = lesionHuMatch ?? section?.match(/Mean HU value:\s*([\d.]+)(?:\s*\+\/\-\s*([\d.]+))?/i);
+ // BUG FIX: the size capture excluded '.' from its own character class, so it
+ // could never match decimal sizes like "1.0 x 0.5 cm" — only whole numbers.
+ // That silently broke "Report size" for virtually every real lesion.
+ const sizeMatch = section?.match(/Size:\s*([^()]+?)\s*cm/i);
+
+ // Organ-level baseline stats (what the report states for the whole organ,
+ // e.g. "Pancreas: Normal size (volume: 9.0 cc). Mean HU value: 8.4 +/- 29.6.")
+ // — deliberately NOT lesion-preferred, since the metrics card needs these
+ // distinct from the lesion's own (and often much smaller, or relative-to-
+ // organ) numbers. "Mean HU value:" only ever appears for the organ baseline;
+ // the lesion's enhancement line reads "HU value is X", a different phrase,
+ // so this regex can't accidentally pick up the lesion's number.
+ const organVolumeMatch = section?.match(/volume:\s*([\d.]+)\s*cc/i);
+ const organHuMatch = section?.match(/Mean HU value:\s*([\d.]+)/i);
+
+ // Each distinct lesion in a report section carries its own "Size: ... cm" line,
+ // so counting those is a reasonable proxy for lesion count without the backend
+ // needing to add a dedicated field.
+ const sizeMatches = section?.match(/Size:\s*[^()]+?cm/gi) ?? [];
+ const lesionCount = sizeMatches.length || (lesionVolumeMatch ? 1 : 0);
return {
section,
volumeCc: volumeMatch ? Number(volumeMatch[1]) : null,
+ lesionVolumeCc: lesionVolumeMatch ? Number(lesionVolumeMatch[1]) : null,
+ organVolumeCc: organVolumeMatch ? Number(organVolumeMatch[1]) : null,
meanHu: huMatch ? Number(huMatch[1]) : null,
+ organMeanHu: organHuMatch ? Number(organHuMatch[1]) : null,
huSd: huMatch?.[2] ? Number(huMatch[2]) : null,
sizeCm: sizeMatch ? sizeMatch[1].trim() : null,
+ lesionCount,
};
}
+// Suffix on organ keys like "pancreas_tail" / "kidney_left" already encodes the
+// anatomical location the backend split out — reuse it instead of re-parsing
+// the report text for location. Paired organs (left/right) read better as
+// "your left kidney"; sub-regions of a single organ (head/body/tail) read
+// better as "in the tail of your pancreas" — so these are kept distinct
+// rather than forced through one phrasing template.
+function organLocation(organ: string): { type: 'lateral' | 'subregion'; word: string } | null {
+ const suffix = organ.split('_').pop() ?? '';
+ if (suffix === 'left' || suffix === 'right') return { type: 'lateral', word: suffix };
+ if (suffix === 'tail' || suffix === 'head' || suffix === 'body') return { type: 'subregion', word: suffix };
+ return null;
+}
+
+// Rough qualitative size bucket from whichever number we have — used only to
+// pick a plain-language adjective, not for any clinical claim.
+function sizeDescriptor(volumeCc: number | null, sizeCm: string | null): string {
+ let maxDim: number | null = null;
+ if (sizeCm) {
+ const nums = sizeCm.match(/[\d.]+/g)?.map(Number) ?? [];
+ if (nums.length) maxDim = Math.max(...nums);
+ }
+ if (maxDim !== null) {
+ if (maxDim < 1) return 'tiny';
+ if (maxDim < 2) return 'small';
+ if (maxDim < 5) return 'noticeable';
+ return 'sizable';
+ }
+ if (volumeCc !== null) {
+ if (volumeCc < 1) return 'tiny';
+ if (volumeCc < 5) return 'small';
+ if (volumeCc < 20) return 'noticeable';
+ return 'sizable';
+ }
+ return '';
+}
+
+// Returns null (deliberately) when the text doesn't actually describe a
+// lesion/mass — some organs get flagged purely on a numeric HU-range check
+// with no lesion mentioned anywhere in the report text, and it previously
+// defaulted to "spot" regardless, inventing a finding the text never stated.
+function findingNoun(detail: string): string | null {
+ const d = detail.toLowerCase();
+ if (d.includes('cyst')) return 'fluid-filled spot';
+ if (d.includes('nodule')) return 'small bump';
+ if (d.includes('mass') || d.includes('tumor')) return 'growth';
+ if (d.includes('enlarged')) return 'enlarged area';
+ if (d.includes('dilated') || d.includes('widened')) return 'widened area';
+ if (d.includes('lesion')) return 'spot';
+ return null;
+}
+
function getImpressionText(data: ReportData | null): string {
if (!data?.impression?.length) return '';
return data.impression
@@ -138,15 +231,43 @@ function getImpressionText(data: ReportData | null): string {
.join(' ');
}
-function patientFindingText(organ: string, detail: string | null): string {
- const name = labelize(organ).toLowerCase();
- const d = (detail || '').toLowerCase();
+function capFirst(s: string): string {
+ return s.length ? s[0].toUpperCase() + s.slice(1) : s;
+}
- if (d.includes('enlarged')) return `The report says the ${name} appears enlarged.`;
- if (d.includes('mass') || d.includes('lesion') || d.includes('tumor')) return `The report found a finding near the ${name}.`;
- if (d.includes('widened') || d.includes('dilated')) return `The report says the ${name} appears widened.`;
- if (d.includes('normal size')) return `The report mentions the ${name}.`;
- return `The report found something in the ${name} that should be reviewed.`;
+// Turns the parsed report measurements into a real plain-language sentence
+// instead of generic keyword-matched boilerplate — e.g. "The scan found a
+// small spot (1.0 x 0.5 cm) in the tail of your pancreas." Falls back to an
+// honest, still-specific sentence when the report text doesn't describe an
+// actual lesion (e.g. flagged purely on an HU-range anomaly) — it never
+// invents "a spot" or similar when none is described.
+function patientFindingText(organ: string, measurements: ReportMeasurements): string {
+ const organLabel = labelize(organRoot(organ)).toLowerCase();
+ const loc = organLocation(organ);
+ const subject =
+ loc?.type === 'lateral' ? `your ${loc.word} ${organLabel}`
+ : loc?.type === 'subregion' ? `the ${loc.word} of your ${organLabel}`
+ : `your ${organLabel}`;
+ const detail = measurements.section || '';
+
+ if (!detail) {
+ return `The scan flagged ${subject} for your doctor to review — the report text wasn't specific enough to describe here.`;
+ }
+
+ const noun = findingNoun(detail);
+ if (!noun) {
+ // Flagged, but the text doesn't describe an actual lesion/mass — don't
+ // invent one. Most common cause: flagged on an HU-range check, not a
+ // described finding.
+ return `${capFirst(subject)} was flagged for review, but the report doesn't describe a specific spot or growth — ask your doctor what stood out.`;
+ }
+
+ const sizeWord = sizeDescriptor(measurements.lesionVolumeCc ?? measurements.volumeCc, measurements.sizeCm);
+ const sizePart = measurements.sizeCm ? ` (${measurements.sizeCm} cm)` : '';
+ const article = sizeWord ? `a ${sizeWord} ${noun}` : `a ${noun}`;
+ const countPart = measurements.lesionCount > 1 ? `${measurements.lesionCount} spots` : article;
+
+ return `The scan found ${countPart}${sizePart} in ${subject}.`;
}
// ─── Small UI pieces ──────────────────────────────────────────────────────────
@@ -260,6 +381,66 @@ function MetricRow({ label, value, sub, tone = 'white' }: { label: string; value
);
}
+function Badge({ tone, children }: { tone: 'amber' | 'green'; children: React.ReactNode }) {
+ const color = tone === 'amber' ? '#fbbf24' : '#6ee7b7';
+ const bg = tone === 'amber' ? 'rgba(251,191,36,0.14)' : 'rgba(110,231,183,0.12)';
+ const border = tone === 'amber' ? 'rgba(251,191,36,0.32)' : 'rgba(110,231,183,0.28)';
+ return (
+
+ {children}
+
+ );
+}
+
+function MetricLine({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+// Structured doctor-view card: labeled metric rows + a review badge, in place
+// of dumping the raw report-comments string. Deliberately sources HU/volume
+// from the report's organ-baseline numbers (report.organMeanHu/organVolumeCc)
+// rather than curData — curData can be a small anatomical sub-label (e.g.
+// "pancreas_tail") whose own segmented mask is tiny, which previously showed
+// as a misleading "0 HU / 0 cc" even though the organ itself had real values.
+function OrganMetricsCard({
+ curData,
+ report,
+ needsReview,
+}: {
+ curData: OrganData;
+ report: ReportMeasurements;
+ needsReview: boolean;
+}) {
+ const meanHu = report.organMeanHu ?? curData.mean_hu;
+ const organVolume = report.organVolumeCc ?? curData.volume;
+ return (
+
- )}
+ {/* Full report-text paragraph deliberately omitted here — it's the exact
+ same string already shown in the left story panel (medLocal), so
+ showing it again just duplicated the same paragraph on screen. */}
{lang === 'patient' && (
-
- This panel shows the key measurement from the report. Your doctor can explain what it means for you.
-
+ <>
+ {reportVolume !== null && (
+
+ )}
+
+ This panel shows the key measurement from the report. Your doctor can explain what it means for you.
+
+ >
)}
);
@@ -403,6 +574,14 @@ export default function ReportScreen({ id, onClose, onViewChange, onOrganHighlig
const [modePromptOpen, setModePromptOpen] = useState(false);
const [plain2, setPlain2] = useState([]);
const [pLoad, setPLoad] = useState(false);
+ const [shareOpen, setShareOpen] = useState(false);
+ const [copied, setCopied] = useState(false);
+ // De-identified share link, minted on demand (see mintShareLink below) —
+ // this used to be a raw `${API_ORIGIN}/api/report/${id}` string built
+ // straight from the real case id. That exposed the real id in the URL and
+ // skipped the token system the rest of the app now uses for sharing.
+ const [shareUrl, setShareUrl] = useState(null);
+ const [shareLoading, setShareLoading] = useState(false);
const startRef = useRef(Date.now());
useEffect(() => {
@@ -419,6 +598,56 @@ export default function ReportScreen({ id, onClose, onViewChange, onOrganHighlig
.catch(() => setLoading(false));
}, [id]);
+ // Reset any previously-minted link when the case changes, so a stale
+ // token for a different case can never be shown/copied.
+ useEffect(() => {
+ setShareUrl(null);
+ }, [id]);
+
+ // Mints (or re-derives — the backend token is deterministic per case id)
+ // an opaque share token and builds the link to the new de-identified
+ // /share/:token card. Safe to call repeatedly; no-ops if already minted
+ // or in flight.
+ const mintShareLink = useCallback(async () => {
+ if (shareUrl || shareLoading) return;
+ setShareLoading(true);
+ try {
+ const r = await fetch(`${APP_CONSTANTS.API_ORIGIN}/api/share/${id}/token`, { method: 'POST' });
+ const j = await r.json();
+ const token = typeof j.url === 'string' ? j.url.split('/').pop() : null;
+ if (token) setShareUrl(`${window.location.origin}/share/${token}`);
+ } catch (e) {
+ console.error('Failed to create share link:', e);
+ } finally {
+ setShareLoading(false);
+ }
+ }, [id, shareUrl, shareLoading]);
+
+ useEffect(() => {
+ if (!shareOpen) return;
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setShareOpen(false); };
+ const onClick = () => setShareOpen(false);
+ document.addEventListener('keydown', onKey);
+ // Deferred so the same click that opened the popover doesn't immediately close it.
+ const t = setTimeout(() => document.addEventListener('click', onClick), 0);
+ return () => {
+ document.removeEventListener('keydown', onKey);
+ document.removeEventListener('click', onClick);
+ clearTimeout(t);
+ };
+ }, [shareOpen]);
+
+ const handleCopyShareLink = async () => {
+ if (!shareUrl) return;
+ try {
+ await navigator.clipboard.writeText(shareUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (e) {
+ console.error('Copy failed:', e);
+ }
+ };
+
const fetchPlain = useCallback(async () => {
if (plain2.length || !data) return;
setPLoad(true);
@@ -440,7 +669,7 @@ export default function ReportScreen({ id, onClose, onViewChange, onOrganHighlig
setStep(s);
}, [step]);
- const all = React.useMemo(() => data ? Object.entries(data.organ_volumes).filter(([_, v]) => v.volume > 5) : [], [data]);
+ const all = React.useMemo(() => data ? Object.entries(data.organ_volumes).filter(([_, v]) => v.volume > 5 || v.status === 'check') : [], [data]);
const flagged = React.useMemo(() => all.filter(([_, v]) => v.status === 'check'), [all]);
const normal = React.useMemo(() => all.filter(([_, v]) => v.status !== 'check'), [all]);
const totalSteps = 2 + flagged.length + 1;
@@ -464,8 +693,9 @@ export default function ReportScreen({ id, onClose, onViewChange, onOrganHighlig
if (!data) return null;
const curOrganLocal = step >= 2 && step < 2 + flagged.length ? flagged[step - 2]?.[0] : null;
const curDataLocal = step >= 2 && step < 2 + flagged.length ? flagged[step - 2]?.[1] : null;
- const medLocal = curOrganLocal ? getDetail(curOrganLocal, data.comments) : null;
- const patientLocal = curOrganLocal ? patientFindingText(curOrganLocal, medLocal) : '';
+ const medLocal = curOrganLocal ? getReportSection(curOrganLocal, data.comments) : null;
+ const measurementsLocal = curOrganLocal ? getReportMeasurements(curOrganLocal, data.comments) : null;
+ const patientLocal = curOrganLocal && measurementsLocal ? patientFindingText(curOrganLocal, measurementsLocal) : '';
const impressionText = getImpressionText(data);
if (step === 0) return (
@@ -638,6 +868,67 @@ export default function ReportScreen({ id, onClose, onViewChange, onOrganHighlig
)}
{!isLocal && (
- { track("report_open"); setShowReportScreen(true); }}
- aria-label="Open report"
- >
-
- Report
-
+ {
+ track("report_open");
+ setViewMode("3d");
+ setShowReportScreen(true);
+ }}
+ aria-label="Open report"
+ >
+
+ Report
+
)}
{/* HD and AI stay inline: HD is a live status indicator (streaming %),
diff --git a/flask-server/api/api_blueprint.py b/flask-server/api/api_blueprint.py
index 54c9e1a9..1741f863 100644
--- a/flask-server/api/api_blueprint.py
+++ b/flask-server/api/api_blueprint.py
@@ -1,6 +1,5 @@
-from flask import Blueprint, send_file, make_response, request, jsonify, Response, stream_with_context
+from flask import Blueprint, send_file, make_response, request, jsonify, Response, current_app, stream_with_context
from werkzeug.utils import secure_filename
-from services.nifti_processor import NiftiProcessor
from services.session_manager import SessionManager, generate_uuid
from services.auto_segmentor import run_auto_segmentation, cancel_session, cancel_all_inference
from services.mesh_generation import bake_case_meshes, generate_mesh_manifest, generate_organ_glb_bytes, LABELS as MESH_LABELS
@@ -68,6 +67,7 @@
LOWRES_ROOT = os.environ.get("PANTS_LOWRES_PATH", "/home/visitor/pants_lowres")
import hmac
+from itsdangerous import URLSafeSerializer, BadSignature
import threading
# Session/case ids come straight from client requests and are joined into
@@ -84,33 +84,53 @@ def _metadata_xlsx_path():
# metadata.xlsx ships either at the root of PANTS_PATH or under data/
# depending on the checkout/deployment; use whichever exists.
for candidate in (
- os.path.join(Constants.PANTS_PATH, "metadata.xlsx"),
os.path.join(Constants.PANTS_PATH, "data", "metadata.xlsx"),
+ os.path.join(Constants.PANTS_PATH, "metadata.xlsx"),
):
if os.path.exists(candidate):
return candidate
return None
+def _norm_colname(s):
+ import re
+ return re.sub(r'[^a-z0-9]', '', str(s).lower())
+
+
def _load_metadata_cache():
try:
xlsx_path = _metadata_xlsx_path()
if not xlsx_path:
return {}
df = pd.read_excel(xlsx_path, engine="openpyxl")
+ # Column-NAME lookup instead of position. This used to be
+ # row.iloc[0]/[4]/[5]/[13], which reads whatever happens to sit in
+ # those positions — silently wrong if the sheet's column order ever
+ # changes, same failure mode as OncoKit's hardcoded iloc indices.
+ # Matches by normalized name (case/whitespace/punctuation-insensitive)
+ # against the real header: "PanTS ID", "sex", "age", "tumor?".
+ col_map = {_norm_colname(c): c for c in df.columns}
+ pid_col = col_map.get(_norm_colname("PanTS ID")) or df.columns[0]
+ sex_col = col_map.get(_norm_colname("sex"))
+ age_col = col_map.get(_norm_colname("age"))
+ tumor_col = col_map.get(_norm_colname("tumor")) # matches "tumor?" too
+
cache = {}
for _, row in df.iterrows():
- pid = str(row.iloc[0])
+ pid = str(row[pid_col])
cache[pid] = {
- "sex": row.iloc[4] if pd.notna(row.iloc[4]) else "",
- "age": row.iloc[5] if pd.notna(row.iloc[5]) else "",
- "tumor": int(row.iloc[13]) if pd.notna(row.iloc[13]) else 0,
+ "sex": row[sex_col] if sex_col and pd.notna(row[sex_col]) else "",
+ "age": row[age_col] if age_col and pd.notna(row[age_col]) else "",
+ "tumor": int(row[tumor_col]) if tumor_col and pd.notna(row[tumor_col]) else 0,
}
return cache
except Exception:
return {}
_METADATA_CACHE = _load_metadata_cache()
+_REPORT_DATA_CACHE = {} # {case_id: report_data_dict} — avoids reloading/recomputing
+ # the full CT+mask volume on every report/PDF request
+progress_tracker = {} # {session_id: (start_time, expected_total_seconds)}
# Lazy cache of {PanTS id: (contrast, study_detail)} for /get-report-data.
# The old code re-loaded the entire ~10k-row workbook (non-read-only!) on
@@ -127,12 +147,28 @@ def _report_study_meta(pid):
if xlsx_path:
wb = load_workbook(xlsx_path, read_only=True, data_only=True)
sheet = wb["PanTS_metadata"] if "PanTS_metadata" in wb.sheetnames else wb.active
- for row in sheet.iter_rows(min_row=2, values_only=True):
+ rows_iter = sheet.iter_rows(min_row=1, values_only=True)
+ header = next(rows_iter, None) or ()
+ # Derived from the real header row ("ct phase", "study type")
+ # rather than hardcoded row[3]/row[8] — if columns get
+ # reordered upstream, this re-resolves correctly on next load
+ # instead of silently reading the wrong field.
+ header_map = {_norm_colname(h): i for i, h in enumerate(header) if h is not None}
+ ct_phase_idx = header_map.get(_norm_colname("ct phase"))
+ study_type_idx = header_map.get(_norm_colname("study type"))
+ for row in rows_iter:
if row and row[0]:
- cache[str(row[0])] = (
- row[3] if len(row) > 3 and row[3] is not None else "",
- row[8] if len(row) > 8 and row[8] is not None else "",
+ contrast = (
+ row[ct_phase_idx]
+ if ct_phase_idx is not None and ct_phase_idx < len(row) and row[ct_phase_idx] is not None
+ else ""
)
+ study_detail = (
+ row[study_type_idx]
+ if study_type_idx is not None and study_type_idx < len(row) and row[study_type_idx] is not None
+ else ""
+ )
+ cache[str(row[0])] = (contrast, study_detail)
wb.close()
except Exception as e:
print(f"[report meta] metadata load failed: {e}")
@@ -654,19 +690,22 @@ def define_term():
}), 200
-@api_blueprint.route('/get-report-data/', methods=['GET'])
-def get_report_data(id):
- # CancerVerse has no masks/RadGPT report yet — respond gracefully.
+def _build_report_data(id):
+ """Gathers everything the report needs (RadGPT text, organ volumes/status/
+ centroid/dimensions, lesions) into a plain dict. Shared by the JSON
+ endpoint and the PDF-generation endpoint so both show identical data."""
+ # CancerVerse has no masks/RadGPT report yet -- respond gracefully.
if get_dataset_from_case_id(secure_filename(str(id))) == "CancerVerse":
- return jsonify({"masks_available": False}), 200
+ return {"masks_available": False}
if id is None or not str(id).isdigit():
- return jsonify({"error": "Invalid id parameter"}), 400
+ return {"error": "Invalid id parameter"}
case_id = int(id)
+ id = str(case_id)
+
+ if id in _REPORT_DATA_CACHE:
+ return _REPORT_DATA_CACHE[id]
+
try:
- id = str(case_id)
- # ── Try RadGPT structured report from metadata.xlsx first ─────────────
- # This uses Zongwei Zhou's own RadGPT model output — more accurate
- # than Ollama-generated impressions. Falls back to Ollama if not found.
radgpt_comments = None
radgpt_impression = None
try:
@@ -684,9 +723,7 @@ def get_report_data(id):
row_id = str(row[id_col] or '').strip()
if row_id == pants_id:
raw = str(row[report_col] or '')
- # Clean Windows carriage return artifacts
raw = raw.replace('_x000D_', '\n').replace('\r\n', '\n').replace('\r', '\n')
- # Collapse multiple blank lines
import re as _re
raw = _re.sub(r'\n{3,}', '\n\n', raw)
findings_match = re.search(r'FINDINGS:(.*?)(?=IMPRESSION:|$)', raw, re.DOTALL)
@@ -695,7 +732,6 @@ def get_report_data(id):
radgpt_comments = findings_match.group(1).strip()
if impression_match:
imp_text = impression_match.group(1).strip()
- # Keep full impression, split into sentences
sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', imp_text) if s.strip()]
radgpt_impression = sentences if sentences else [imp_text]
print(f"[RadGPT] Found report for case {id}: {radgpt_impression}")
@@ -703,26 +739,23 @@ def get_report_data(id):
wb.close()
except Exception as e:
print(f"[RadGPT] metadata lookup failed: {e}")
- # ─────────────────────────────────────────────────────────────────────
+
subfolder = "ImageTr" if case_id < 9000 else "ImageTe"
label_subfolder = "LabelTr" if case_id < 9000 else "LabelTe"
- # Check image_only first (new structure), fall back to data/ImageTr
image_only_path = f"{Constants.PANTS_PATH}/image_only/{get_panTS_id(case_id)}/{Constants.MAIN_NIFTI_FILENAME}"
data_ct_path = f"{Constants.PANTS_PATH}/data/{subfolder}/{get_panTS_id(case_id)}/{Constants.MAIN_NIFTI_FILENAME}"
ct_path = image_only_path if os.path.exists(image_only_path) else data_ct_path
- # Check mask_only first (new structure), fall back to data/LabelTe
mask_only_path = f"{Constants.PANTS_PATH}/mask_only/{get_panTS_id(case_id)}/{Constants.COMBINED_LABELS_NIFTI_FILENAME}"
data_mask_path = f"{Constants.PANTS_PATH}/data/{label_subfolder}/{get_panTS_id(case_id)}/{Constants.COMBINED_LABELS_NIFTI_FILENAME}"
mask_path = mask_only_path if os.path.exists(mask_only_path) else data_mask_path
- seg_dir = f"{Constants.PANTS_PATH}/data/{label_subfolder}/{get_panTS_id(case_id)}/segmentations"
-
+ seg_dir = f"{Constants.PANTS_PATH}/mask_only/{get_panTS_id(case_id)}/segmentations"
pid = get_panTS_id(case_id)
meta = _METADATA_CACHE.get(pid, {})
age = meta.get("age", "N/A")
sex = meta.get("sex", "N/A")
-
+
contrast, study_detail = _report_study_meta(pid)
-
+
# If local files don't exist, download from HuggingFace
if not os.path.exists(ct_path) or not os.path.exists(mask_path):
import requests, tempfile
@@ -745,46 +778,27 @@ def get_report_data(id):
with open(mask_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
-
+
ct_nii = nib.load(ct_path)
spacing = ct_nii.header.get_zooms()
shape = ct_nii.shape
ct_array = ct_nii.get_fdata()
mask_nii = nib.load(mask_path)
mask_array = mask_nii.get_fdata().astype(np.uint8)
- # Crop both arrays to the minimum shape along each axis
- # to handle slight size mismatches between CT and mask
min_shape = tuple(min(c, m) for c, m in zip(ct_array.shape, mask_array.shape))
ct_array = ct_array[:min_shape[0], :min_shape[1], :min_shape[2]]
mask_array = mask_array[:min_shape[0], :min_shape[1], :min_shape[2]]
voxel_volume = np.prod(mask_nii.header.get_zooms()) / 1000
- # World-space affine - converts a voxel index (i, j, k) to real
- # millimeter coordinates. This is what makes the centroid below a
- # REAL position usable by moveCornerstoneCrosshairToMm, rather
- # than a placeholder.
affine = mask_nii.affine
-
+
LABELS = {v: k for k, v in Constants.PREDEFINED_LABELS.items()}
-
- # Soft physiological sanity ranges per organ type, used only to
- # flag "this needs review" vs "looks normal" internally. NOT
- # shown to the user as raw numbers - the frontend only ever sees
- # the `status` field. This is a stopgap for a known upstream
- # segmentation/data issue where some organs read in the air range;
- # rather than silently showing a wrong number as fact, or trying
- # to "correct" the data here, we flag it so the UI can say
- # "needs review" instead of presenting a confident wrong reading.
- SOLID_ORGAN_HU_RANGE = (-20, 150) # liver, spleen, kidney, pancreas, etc.
- GI_HOLLOW_ORGAN_HU_RANGE = (-300, 200) # colon, stomach, intestine, duodenum - tightened from
- # -1000 so a mean this close to pure air (e.g. -756 for
- # colon) correctly flags "check" instead of "normal" -
- # a real colon has enough wall/stool tissue that a mean
- # in the deep-air range usually signals a segmentation
- # issue, not a genuinely normal reading.
- LUNG_HU_RANGE = (-1000, -200) # lungs are genuinely air-filled - this range is correct as-is
+
+ SOLID_ORGAN_HU_RANGE = (-20, 150)
+ GI_HOLLOW_ORGAN_HU_RANGE = (-300, 200)
+ LUNG_HU_RANGE = (-1000, -200)
GI_HOLLOW_ORGANS = {"colon", "stomach", "intestine", "duodenum"}
LUNG_ORGANS = {"lung_left", "lung_right"}
-
+
organ_volumes = {}
NO_FLAG_ORGANS = {
"femur_left", "femur_right", "aorta", "postcava", "veins",
@@ -799,7 +813,7 @@ def get_report_data(id):
continue
volume = float(np.sum(mask) * voxel_volume)
mean_hu = float(np.mean(ct_array[mask]))
-
+
if organ in LUNG_ORGANS:
lo, hi = LUNG_HU_RANGE
elif organ in GI_HOLLOW_ORGANS:
@@ -807,85 +821,107 @@ def get_report_data(id):
else:
lo, hi = SOLID_ORGAN_HU_RANGE
status = "normal" if organ in NO_FLAG_ORGANS else ("check" if (mean_hu < lo or mean_hu > hi) else "normal")
-
- # Real centroid: voxel-space center of mass, converted to mm
- # via the affine. This is genuine anatomical position - not a
- # placeholder - and feeds the same crosshair-navigation
- # plumbing already used elsewhere (moveCornerstoneCrosshairToMm
- # / moveNiiVueCrosshairToMm) for click-to-jump.
+
voxel_coords = np.argwhere(mask)
- centroid_voxel = voxel_coords.mean(axis=0) # (i, j, k) in voxel space
+ centroid_voxel = voxel_coords.mean(axis=0)
centroid_world = nib.affines.apply_affine(affine, centroid_voxel)
-
- # Bounding box dimensions in cm (real physical size of the organ)
+
bbox_min = voxel_coords.min(axis=0)
bbox_max = voxel_coords.max(axis=0)
bbox_voxels = bbox_max - bbox_min + 1
- # Convert voxel counts to mm using spacing, then to cm
spacing_mm = np.abs([affine[0,0], affine[1,1], affine[2,2]])
dims_mm = bbox_voxels * spacing_mm
dims_cm = [round(float(d)/10, 1) for d in dims_mm]
-
+
organ_volumes[organ] = {
- "volume": round(volume, 2),
- "mean_hu": round(mean_hu, 1),
+ "volume": round(float(volume), 2),
+ "mean_hu": round(float(mean_hu), 1),
"status": status,
"centroid_mm": [round(float(c), 2) for c in centroid_world],
"dimensions": dims_cm,
}
-
+
lesions = {}
lesion_files = {
- "pancreas": "pancreatic_lesion.npz",
- "liver": "liver_lesion.npz",
- "kidney": "kidney_lesion.npz",
+ "pancreas": "pancreatic_lesion.nii.gz",
+ "liver": "liver_lesion.nii.gz",
+ "kidney": "kidney_lesion.nii.gz",
}
for organ, filename in lesion_files.items():
path = os.path.join(seg_dir, filename)
if os.path.exists(path):
- data = np.load(path)["data"]
- voxels = int(np.sum(data > 0))
+ lesion_data = nib.load(path).get_fdata()
+ voxels = int(np.sum(lesion_data > 0))
if voxels > 0:
lesion_volume = round(voxels * voxel_volume, 2)
- lesions[organ] = {"voxels": voxels, "volume": lesion_volume}
-
- organ_data_str = ""
- for organ, vals in organ_volumes.items():
- organ_data_str += f"{organ.replace('_', ' ')}: volume={vals['volume']}cc, mean HU={vals['mean_hu']}\n"
-
- # If we have a RadGPT report, it is the authoritative source for organ status.
- # Reset everything to normal first, then flag only what RadGPT calls abnormal.
+ lesions[organ] = {"voxels": voxels, "volume": round(float(lesion_volume), 2)}
+
+ print(f"[DEBUG] seg_dir={seg_dir}")
+ print(f"[DEBUG] lesions found: {lesions}")
+
if radgpt_comments:
for organ in list(organ_volumes.keys()):
organ_volumes[organ]['status'] = 'normal'
+ # NOTE: 'hypoattenuating'/'hyperattenuating' added — RadGPT's actual
+ # enhancement-description vocabulary ("Enhancement relative to
+ # pancreas: Hypoattenuating...") wasn't covered by the old list,
+ # which only had the unrelated words 'hypodense'/'hyperdense'.
abnormal_keywords = ['enlarged', 'mass', 'lesion', 'tumor', 'abnormal',
'dilated', 'obstruction', 'isoattenuating', 'hypodense',
- 'hyperdense', 'cyst', 'nodule', 'atrophy', 'bilateral']
- # Build stripped root for flexible matching
- organ_roots = {}
+ 'hyperdense', 'hypoattenuating', 'hyperattenuating',
+ 'cyst', 'nodule', 'atrophy', 'bilateral']
+ subtype_suffixes = ('_body', '_head', '_tail', '_left', '_right')
+
+ def _base_root(organ_name):
+ r = organ_name
+ for suf in ('_left', '_right', '_body', '_head', '_tail', '_gland', '_duct', '_lesion'):
+ r = r.replace(suf, '')
+ return r.replace('_', '').lower()
+
+ organs_by_root = {}
for organ in organ_volumes.keys():
- root = organ.replace('_left','').replace('_right','').replace('_body','') \
- .replace('_head','').replace('_tail','').replace('_gland','') \
- .replace('_duct','').replace('_lesion','').replace('_','')
- organ_roots[organ] = root.lower()
- for line in radgpt_comments.split('\n'):
- line_stripped = line.lower().replace(' ','').replace('_','')
- if any(kw in line.lower() for kw in abnormal_keywords):
- for organ, root in organ_roots.items():
- # Skip subtypes unless explicitly mentioned
- # e.g. "pancreas enlarged" shouldn't flag pancreas_body/head/tail
- if '_body' in organ or '_head' in organ or '_tail' in organ or '_duct' in organ:
- # Only flag subtype if the subtype word is in the line
- subtype = organ.split('_')[-1]
- if root in line_stripped and subtype in line.lower():
- organ_volumes[organ]['status'] = 'check'
- else:
- if root in line_stripped:
- organ_volumes[organ]['status'] = 'check'
+ organs_by_root.setdefault(_base_root(organ), []).append(organ)
+
+ # Find each organ-heading line (a line that IS, after stripping a
+ # trailing ':', one of the known organ roots or "s") — same
+ # rule the frontend uses to scope report text per organ — then
+ # scan the WHOLE block between consecutive headings for both the
+ # abnormal keyword and the subtype word, rather than requiring
+ # both on one line. A lesion's location ("Location: pancreas
+ # tail.") is routinely on a different line than the keyword that
+ # flags it ("Pancreas lesions:"), so the old per-line check could
+ # never flag a sub-label organ even when its own block clearly
+ # documented a finding there.
+ lines = radgpt_comments.split('\n')
+ heading_idx = []
+ for i, line in enumerate(lines):
+ cleaned = line.strip().rstrip(':').lower()
+ for root in organs_by_root:
+ if root and (cleaned == root or cleaned == f'{root}s'):
+ heading_idx.append((i, root))
+ break
+
+ for pos, (start, root) in enumerate(heading_idx):
+ end = heading_idx[pos + 1][0] if pos + 1 < len(heading_idx) else len(lines)
+ block_lower = '\n'.join(lines[start:end]).lower()
+ if not any(kw in block_lower for kw in abnormal_keywords):
+ continue
+ for organ in organs_by_root.get(root, []):
+ has_subtype = organ.endswith(subtype_suffixes)
+ if not has_subtype:
+ # Generic/base organ label (no left/right/body/head/tail
+ # subtype) — any abnormal keyword anywhere in its block
+ # flags it, same as before.
+ organ_volumes[organ]['status'] = 'check'
+ elif organ.rsplit('_', 1)[-1] in block_lower:
+ # Sub-label organ — only flag it if ITS specific
+ # subtype word (e.g. "tail") appears anywhere in the
+ # block, not just on the same line as the keyword.
+ organ_volumes[organ]['status'] = 'check'
+
comments = radgpt_comments or "Clinical comments unavailable."
impression_items = radgpt_impression or ["No impression available for this case."]
-
- return jsonify({
+ result = {
"case_id": id,
"patient": {"age": age, "sex": sex},
"imaging": {
@@ -898,14 +934,982 @@ def get_report_data(id):
"lesions": lesions,
"comments": comments,
"impression": impression_items,
- })
-
- except Exception as e:
- import traceback
- traceback.print_exc()
- return jsonify({"error": "An internal error occurred."}), 500
-
+ }
+ _REPORT_DATA_CACHE[id] = result
+ return result
+ except Exception:
+ return {"error": "Failed to build report data for the given id."}
+
+
+@api_blueprint.route('/get-report-data/', methods=['GET'])
+def get_report_data(id):
+ data = _build_report_data(id)
+ if "error" in data:
+ status = 400 if data["error"] == "Invalid id parameter" else 500
+ return jsonify(data), status
+ return jsonify(data)
+
+
+@api_blueprint.route('/report/', methods=['GET'])
+def report_html(id):
+ """Shareable Apple-Health-style HTML report for a case -- same underlying
+ data as get_report_data/generate_report_pdf (_build_report_data), just
+ rendered as an interactive patient/clinician-toggle page instead of JSON
+ or a static PDF. This is a live server-rendered page (not a static file
+ a client fetches data into), so the same auth/session that already
+ protects case access here protects this route too -- no new data-exposure
+ surface. The 'Download radiology report' button on the clinician side
+ links to the existing /generate-report-pdf/ route."""
+ resolved = _resolve_case_id_or_token(id)
+ if resolved is None:
+ return jsonify({"error": "Invalid or expired link"}), 404
+ data = _build_report_data(resolved)
+ if "error" in data:
+ status = 400 if data["error"] == "Invalid id parameter" else 500
+ return jsonify(data), status
+ html_out = _build_report_html(data)
+ return Response(html_out, mimetype="text/html")
+
+import html as _html_mod
+import json as _json_mod
+
+
+def _plain_organ_name(root):
+ return root
+
+
+_SUBREGION_PLAIN = {
+ ("pancreas", "tail"): "the tail of your pancreas (the end farthest from your stomach)",
+ ("pancreas", "body"): "the body of your pancreas (the middle section)",
+ ("pancreas", "head"): "the head of your pancreas (the end closest to your small intestine)",
+}
+
+_FINDING_WORD = [
+ ("cyst", "fluid-filled spot"),
+ ("nodule", "small bump"),
+ ("mass", "growth"),
+ ("tumor", "growth"),
+ ("enlarged", "enlarged area"),
+ ("dilated", "widened area"),
+ ("lesion", "spot"),
+]
+
+
+def _finding_word(detail_text):
+ d = (detail_text or "").lower()
+ for keyword, word in _FINDING_WORD:
+ if keyword in d:
+ return word
+ return None
+
+
+def _where_phrase(root, location_word):
+ name = _plain_organ_name(root)
+ if not location_word:
+ return f"your {name}"
+ if location_word in ("left", "right"):
+ return f"your {location_word} {name}"
+ if (root, location_word) in _SUBREGION_PLAIN:
+ return _SUBREGION_PLAIN[(root, location_word)]
+ # Unknown or compound locator (e.g. "segment 4", parsed straight from the
+ # report text) — forcing this into "the X of your Y" reads wrong for
+ # anything that isn't a short single anatomical word. This construction
+ # stays grammatically correct for any locator string.
+ return f"your {name} ({location_word})"
+
+
+def _organ_base_stats(root, organ_volumes):
+ """Looks up an organ's baseline volume/HU. Some organs (kidney, lung,
+ adrenal gland) have no bare aggregate key in organ_volumes — only
+ _left/_right — in which case this sums volume and averages
+ HU across both sides instead of returning nothing."""
+ if root in organ_volumes:
+ return organ_volumes[root]
+ parts = [organ_volumes[k] for k in (f"{root}_left", f"{root}_right") if k in organ_volumes]
+ if not parts:
+ return {}
+ vols = [p.get("volume") for p in parts if p.get("volume") is not None]
+ hus = [p.get("mean_hu") for p in parts if p.get("mean_hu") is not None]
+ return {
+ "volume": sum(vols) if vols else None,
+ "mean_hu": (sum(hus) / len(hus)) if hus else None,
+ }
+
+
+def _patient_lesion_sentence(root, lesion, location_word, detail_text):
+ where = _where_phrase(root, location_word)
+ size = lesion.get("size")
+ word = _finding_word(detail_text) or "spot"
+ size_part = f" measuring {size} cm" if size else ""
+ return f"Your scan found a {word}{size_part} in {where}."
+
+
+def _patient_no_lesion_sentence(root):
+ name = _plain_organ_name(root)
+ return (f"Your {name} was flagged for your doctor to review, but the report "
+ f"doesn't describe a specific spot or growth \u2014 your doctor can "
+ f"tell you exactly what stood out.")
+
+
+def _doctor_lesion_sentence(root, lesion, location_word):
+ loc = f" ({location_word})" if location_word else ""
+ return (f"{root.title()}{loc} lesion, {lesion.get('size', 'N/A')} cm, "
+ f"volume {lesion.get('volume', 0):.1f} cc. "
+ f"{lesion.get('enhancement', '')} relative to {root}, "
+ f"HU {lesion.get('hu', 0):.1f} \u00b1 {lesion.get('hu_sd', 0):.1f}.").strip()
+
+
+def _base_root(organ):
+ r = organ
+ for suf in ('_left', '_right', '_body', '_head', '_tail', '_gland', '_duct', '_lesion'):
+ r = r.replace(suf, '')
+ return r.replace('_', '').lower()
+
+
+def _e(s):
+ """HTML-escape any real data before it goes in the page — this is a
+ document assembled from case data, not a trusted template string."""
+ return _html_mod.escape(str(s), quote=True)
+
+
+def _build_report_html(report_data):
+ """Builds the full Apple-Health-inspired shareable HTML report from a
+ real report_data dict (same shape _draw_report_pdf and the JSON API use:
+ case_id, patient, imaging, organ_volumes, lesions, comments, impression).
+ No placeholder content — every value is pulled from report_data itself,
+ and the layout adapts to however many findings/organs actually exist.
+ """
+ case_id = report_data.get("case_id", "N/A")
+ patient = report_data.get("patient", {})
+ imaging = report_data.get("imaging", {})
+ organ_volumes = report_data.get("organ_volumes", {})
+ lesions = report_data.get("lesions", {})
+ comments = str(report_data.get("comments", ""))
+
+ roots_present = sorted(set(_base_root(o) for o in organ_volumes.keys()))
+ parsed_organs = _parse_findings(comments, roots_present)
+ organ_lookup = {o['root']: o for o in parsed_organs}
+
+ # ---- Build one "finding" entry per flagged organ (0, 1, or many) ----
+ flagged_roots = sorted(set(
+ _base_root(o) for o, v in organ_volumes.items() if v.get("status") == "check"
+ ))
+ findings = []
+ for root in flagged_roots:
+ entry = organ_lookup.get(root, {"baseline_lines": [], "lesions": []})
+ detail_text = " ".join(entry.get("baseline_lines", []))
+ organ_base = _organ_base_stats(root, organ_volumes)
+
+ if entry.get("lesions"):
+ for lesion in entry["lesions"]:
+ loc_word = lesion["location"].replace(root, "").strip() or None
+ findings.append({
+ "title": f"{root.title()}" + (f" \u2014 {loc_word.title()}" if loc_word else ""),
+ "patient_html": _e(_patient_lesion_sentence(root, lesion, loc_word, detail_text)),
+ "doctor_html": _e(_doctor_lesion_sentence(root, lesion, loc_word)),
+ "metrics": [
+ {"label": "Organ volume", "value": f"{organ_base.get('volume', 0):.1f} cc" if organ_base.get('volume') is not None else "N/A"},
+ {"label": "Lesion volume", "value": f"{lesion.get('volume', 0):.1f} cc"},
+ {"label": "Mean HU (organ)", "value": f"{organ_base.get('mean_hu', 0):.1f}" if organ_base.get('mean_hu') is not None else "N/A"},
+ {"label": "Lesion count", "value": str(len(entry["lesions"]))},
+ ],
+ })
+ else:
+ findings.append({
+ "title": root.title(),
+ "patient_html": _e(_patient_no_lesion_sentence(root)),
+ "doctor_html": _e(detail_text or f"{root.title()} flagged; no lesion described in report text."),
+ "metrics": [
+ {"label": "Organ volume", "value": f"{organ_base.get('volume', 0):.1f} cc" if organ_base.get('volume') is not None else "N/A"},
+ {"label": "Mean HU (organ)", "value": f"{organ_base.get('mean_hu', 0):.1f}" if organ_base.get('mean_hu') is not None else "N/A"},
+ ],
+ })
+
+ # ---- "Everything else looked normal" — organs RadGPT actually commented
+ # on that are NOT flagged (real prose-covered organs only, not every
+ # unrelated bone/vessel) ----
+ clean_cards = []
+ for organ in parsed_organs:
+ root = organ["root"]
+ if root in flagged_roots:
+ continue
+ base = _organ_base_stats(root, organ_volumes)
+ clean_cards.append({
+ "name": root.title(),
+ "volume": f"{base.get('volume', 0):.0f}" if base.get("volume") is not None else "\u2014",
+ "hu": f"Mean HU {base.get('mean_hu', 0):.1f}" if base.get("mean_hu") is not None else "",
+ })
+
+ # ---- Full organs-reviewed checklist (every organ in this case) ----
+ organ_rows = sorted(
+ (organ.replace("_", " ").title(), vals.get("status", "normal"))
+ for organ, vals in organ_volumes.items()
+ )
+ flagged_rows = [r for r in organ_rows if r[1] == "check"]
+ normal_rows = [r for r in organ_rows if r[1] != "check"]
+ visible_rows = flagged_rows + normal_rows[:max(0, 3 - len(flagged_rows))]
+ hidden_rows = normal_rows[len(visible_rows) - len(flagged_rows):]
+
+ def _row_html(name, status):
+ cls = "row flagged" if status == "check" else "row"
+ icon = '!' if status == "check" else '✓'
+ name_cls = ' class="name"' if status != "check" else ' class="name"'
+ return f'
{_e(name)}{icon}
'
+
+ visible_rows_html = "\n".join(_row_html(n, s) for n, s in visible_rows)
+ hidden_rows_html = "\n".join(_row_html(n, s) for n, s in hidden_rows)
+
+ clean_grid_html = "\n".join(
+ f'''
{_e(c['name'])}
+
{_e(c['volume'])} cc
+
{_e(c['hu'])}
'''
+ for c in clean_cards
+ ) or '
No additional measured organs for this case.
'
+
+ age = patient.get("age", "N/A")
+ sex = patient.get("sex", "N/A")
+ study = imaging.get("study_type", "N/A")
+ contrast = imaging.get("contrast", "N/A")
+
+ findings_json = _json_mod.dumps(findings)
+ pdf_url = f"/api/generate-report-pdf/{case_id}"
+
+ findings_count = len(findings)
+ flagged_summary = (
+ f"{findings_count} finding{'s' if findings_count != 1 else ''} need{'s' if findings_count == 1 else ''} review"
+ if findings_count else "No findings flagged for review"
+ )
+
+ return f"""
+
+
+
+
+CT Scan Report \u2014 Case {_e(case_id)}
+
+
+
+
+
+
+ For me / family
+ For clinicians
+
+
+
+
+
+
CT scan results
+
Case {_e(case_id)} · {_e(study)} · {_e(age)}y, {_e(sex)} · {_e(contrast)}