anannyavyas1's picture
Upload folder using huggingface_hub (part 2)
9073588 verified
Raw
History Blame Contribute Delete
126 kB
/**
* Tri-Netra — Professional Dashboard Application
*/
class TriNetraApp {
constructor() {
this.currentFile = null;
this.currentResults = null;
this.currentSegmentation = null;
this.startTime = null;
this.imageDataUrl = null;
this.init();
}
init() {
this.bindEvents();
this.loadMetrics();
this.loadStatus();
// Refresh the sidebar status every 30 s. Cheap call (<5 KB JSON);
// gives the user live feedback that the backend is alive.
if (!this._statusTimer) {
this._statusTimer = setInterval(() => this.loadStatus(), 30_000);
}
this.setupNavigation();
}
bindEvents() {
// Theme Toggle Logic
const themeCheckbox = document.getElementById('themeCheckbox');
if (themeCheckbox) {
// Check local storage for saved theme
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark-mode');
themeCheckbox.checked = true;
}
themeCheckbox.addEventListener('change', (e) => {
if (e.target.checked) {
document.body.classList.add('magic-pink-mode');
localStorage.setItem('theme', 'pink');
} else {
document.body.classList.remove('magic-pink-mode');
localStorage.setItem('theme', 'light');
}
});
}
// File upload
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
const analyzeBtn = document.getElementById('analyzeBtn');
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
this.handleFile(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
this.handleFile(e.target.files[0]);
}
});
analyzeBtn.addEventListener('click', () => this.runAnalysis());
// Navigation
document.getElementById('newAnalysisBtn').addEventListener('click', () => {
this.showSection('upload');
});
document.getElementById('exportBtn').addEventListener('click', () => {
this.exportReport();
});
const printBtn = document.getElementById('printBtn');
if (printBtn) printBtn.addEventListener('click', () => this.printReport());
// Threshold slider
const thresholdSlider = document.getElementById('thresholdSlider');
const thresholdValue = document.getElementById('thresholdValue');
thresholdSlider.addEventListener('input', (e) => {
thresholdValue.textContent = (e.target.value / 100).toFixed(2);
});
thresholdSlider.addEventListener('change', () => {
// Re-run segmentation on the cached file with the new threshold.
if (this.currentFile) {
this.runSegmentation();
}
});
// Segmentation button
document.getElementById('runSegmentationBtn').addEventListener('click', () => {
this.runSegmentation();
});
// AI Explanation button on the Segmentation page.
const explainBtn = document.getElementById('runExplainBtn');
if (explainBtn) {
explainBtn.addEventListener('click', () => this.runExplanation());
}
// AI Radiology Report button on the Results page - top-level surface
// so the LLM explanation is one click away from the analysis the
// user just ran.
const generateBtn = document.getElementById('generateReportBtn');
if (generateBtn) {
generateBtn.addEventListener('click', () => this.generateReport());
}
// Batch upload: open multi-file picker -> sequential analysis.
const batchBtn = document.getElementById('batchUploadBtn');
const batchInput = document.getElementById('batchFileInput');
if (batchBtn && batchInput) {
batchBtn.addEventListener('click', () => batchInput.click());
batchInput.addEventListener('change', (e) => {
if (e.target.files && e.target.files.length) {
this.runBatchAnalysis(Array.from(e.target.files));
}
// reset so the same file can be re-selected
e.target.value = '';
});
}
const batchClearBtn = document.getElementById('batchClearBtn');
if (batchClearBtn) batchClearBtn.addEventListener('click', () => this.clearBatch());
const batchExportCsvBtn = document.getElementById('batchExportCsvBtn');
if (batchExportCsvBtn) batchExportCsvBtn.addEventListener('click', () => this.exportBatchCsv());
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => this.handleTabClick(e));
});
// Sidebar navigation
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.dataset.tab;
this.showSection(tab);
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
item.classList.add('active');
});
});
// XAI Slider logic
const xaiSlider = document.getElementById('xaiSlider');
if (xaiSlider) {
xaiSlider.addEventListener('input', (e) => {
const agreeImg = document.getElementById('agreementMapImage');
if (agreeImg) {
agreeImg.style.opacity = e.target.value / 100;
}
});
}
// --- Copilot Logic ---
const copilotToggleBtn = document.getElementById('copilotToggleBtn');
const copilotWindow = document.getElementById('copilotWindow');
const copilotCloseBtn = document.getElementById('copilotCloseBtn');
if (copilotToggleBtn) {
copilotToggleBtn.addEventListener('click', () => {
copilotWindow.style.display = copilotWindow.style.display === 'none' ? 'flex' : 'none';
});
}
if (copilotCloseBtn) {
copilotCloseBtn.addEventListener('click', () => {
copilotWindow.style.display = 'none';
});
}
document.querySelectorAll('.copilot-prompt-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const promptType = e.target.dataset.prompt;
this.handleCopilotPrompt(promptType);
});
});
// --- Email Share Modal Logic ---
const openEmailBtn = document.getElementById('openEmailModalBtn');
const emailModal = document.getElementById('emailModal');
const cancelEmailBtn = document.getElementById('emailCancelBtn');
const sendEmailBtn = document.getElementById('emailSendBtn');
if (openEmailBtn) {
openEmailBtn.addEventListener('click', () => {
emailModal.style.display = 'flex';
document.getElementById('emailStatus').style.display = 'none';
document.getElementById('emailInput').value = '';
});
}
if (cancelEmailBtn) {
cancelEmailBtn.addEventListener('click', () => {
emailModal.style.display = 'none';
});
}
if (sendEmailBtn) {
sendEmailBtn.addEventListener('click', async () => {
const status = document.getElementById('emailStatus');
const email = document.getElementById('emailInput').value;
if (!email) {
alert('Please enter an email address.');
return;
}
status.style.display = 'block';
status.style.color = '#64748b';
status.textContent = 'Encrypting and transmitting report to ' + email + '...';
sendEmailBtn.disabled = true;
try {
// Simulate API Call delay
await new Promise(r => setTimeout(r, 1500));
// Generate an actual EML file for the user to download as proof
const segData = this.currentSegmentation || {};
const resData = this.currentResults || {};
const emlContent = `To: ${email}\r\nFrom: noreply@tri-netra-ai.org\r\nSubject: Tri-Netra AI - Patient MRI Analysis Report\r\n\r\nTri-Netra AI Analysis Report\r\n=============================\r\nVerdict: ${segData.verdict || resData.diagnosis || 'Unknown'}\r\nConfidence: ${typeof segData.unified_confidence === 'number' ? segData.unified_confidence + '%' : (segData.confidence || 'N/A')}\r\nRisk Level: ${segData.risk_level || 'N/A'}\r\nRisk Score: ${segData.risk_score || 'N/A'}\r\nVolume: ${segData.volume_cm3 || 'N/A'} cm³\r\n\r\nRecommended Next Steps:\r\n${segData.follow_up || 'Consult your doctor for a full review of these results.'}\r\n\r\nDisclaimer: This is a research-grade demonstration. Not a clinical diagnosis.`;
const blob = new Blob([emlContent], { type: 'message/rfc822' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Tri-Netra_Report_${email}.eml`;
document.body.appendChild(a);
a.click();
a.remove();
status.style.color = '#10b981';
status.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg> Email Sent (Saved locally as .eml file)!';
setTimeout(() => {
emailModal.style.display = 'none';
sendEmailBtn.disabled = false;
}, 2500);
} catch (e) {
status.style.color = '#ef4444';
status.textContent = 'Failed to send email. Server error.';
sendEmailBtn.disabled = false;
}
});
}
}
handleCopilotPrompt(type) {
const copilotBody = document.getElementById('copilotBody');
const userMsg = document.createElement('div');
userMsg.className = 'copilot-msg user';
userMsg.textContent = type === 'summarize' ? 'Please summarize this scan for me.' : 'Give me a detailed volume and growth analysis.';
copilotBody.appendChild(userMsg);
copilotBody.scrollTop = copilotBody.scrollHeight;
// Disable buttons
document.querySelectorAll('.copilot-prompt-btn').forEach(b => b.disabled = true);
// Add loading bot message
const botMsg = document.createElement('div');
botMsg.className = 'copilot-msg bot';
botMsg.innerHTML = '<span style="opacity:0.6;">Analyzing clinical data...</span>';
copilotBody.appendChild(botMsg);
copilotBody.scrollTop = copilotBody.scrollHeight;
setTimeout(() => {
let reply = '';
const volume = document.getElementById('volumeValue') ? document.getElementById('volumeValue').textContent : 'Unknown';
const conf = document.getElementById('confidenceValue') ? document.getElementById('confidenceValue').textContent : 'Unknown';
if (type === 'summarize') {
reply = `Based on the ensemble analysis, the model detected anomalous regions with <strong>${conf}</strong> confidence. The Grad-CAM heatmap primarily highlights these areas. I recommend clinical review of the AI Agreement map.`;
} else {
const growth = document.getElementById('growthVelocityLabel') ? document.getElementById('growthVelocityLabel').textContent : '0 cm³';
reply = `The extracted 3D tumor volume is estimated at <strong>${volume}</strong>. Compared to the historical baseline (-3 months), this represents a growth velocity of <strong>${growth}</strong>.`;
}
botMsg.innerHTML = reply;
copilotBody.scrollTop = copilotBody.scrollHeight;
document.querySelectorAll('.copilot-prompt-btn').forEach(b => b.disabled = false);
}, 1200);
}
handleFile(file) {
this.currentFile = file;
// Update file info
document.getElementById('fileName').textContent = file.name;
document.getElementById('fileSize').textContent = this.formatFileSize(file.size);
// Show preview
const reader = new FileReader();
reader.onload = (e) => {
this.imageDataUrl = e.target.result;
const img = document.getElementById('previewImage');
img.src = e.target.result;
img.style.display = 'block';
document.querySelector('.preview-placeholder').style.display = 'none';
// Get image dimensions
const tempImg = new Image();
tempImg.onload = () => {
document.getElementById('dimensions').textContent = `${tempImg.width} × ${tempImg.height}`;
};
tempImg.src = e.target.result;
};
reader.readAsDataURL(file);
// Enable analyze button
document.getElementById('analyzeBtn').disabled = false;
}
async runAnalysis() {
if (!this.currentFile) return;
const patientId = document.getElementById('patientId').value || `SCAN-${Date.now()}`;
this.showLoading();
this.startTime = Date.now();
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
let progress = 0;
const progressInterval = setInterval(() => {
progress = Math.min(95, progress + Math.random() * 10 + 3);
progressFill.style.width = `${progress}%`;
progressText.textContent = `Processing: ${Math.round(progress)}%`;
}, 300);
try {
const thresholdInput = document.getElementById('thresholdSlider');
const threshold = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5;
const segModalitySel = document.getElementById('segModelSelect');
const segModality = segModalitySel ? segModalitySel.value : '';
// Only call /segment (which returns segmentation + 4-signal advisory)
const segmentation = await this.callSegment(this.currentFile, threshold, segModality);
clearInterval(progressInterval);
progressFill.style.width = '100%';
progressText.textContent = 'Processing: 100%';
this.currentSegmentation = segmentation;
this.currentResults = this.buildResultsFromBackend(patientId, segmentation);
if (segmentation.global_stats) {
document.querySelectorAll('#stat-total-scans, .stat-total-scans-dup').forEach(el => el.innerText = segmentation.global_stats.total_scans);
document.querySelectorAll('#stat-tumor-positive, .stat-tumor-positive-dup').forEach(el => el.innerText = segmentation.global_stats.tumor_positive);
document.querySelectorAll('#stat-normal, .stat-normal-dup').forEach(el => el.innerText = segmentation.global_stats.normal);
document.querySelectorAll('#stat-avg-confidence, .stat-avg-confidence-dup').forEach(el => el.innerText = segmentation.global_stats.avg_confidence + '%');
}
// Push to session-scoped Recent Scans sidebar.
this.addRecentScan({
id: patientId,
isPositive: this.currentResults.isPositive,
confidence: this.currentResults.confidence,
timestamp: Date.now(),
});
setTimeout(() => {
this.hideLoading();
this.displayResults();
// Eagerly populate the segmentation tab so the user sees the
// mask immediately when they click it (no extra round trip).
this.renderSegmentationFromCache();
}, 300);
} catch (err) {
clearInterval(progressInterval);
this.hideLoading();
alert('Analysis failed: ' + (err.message || err));
console.error(err);
}
}
async callSegment(file, threshold = 0.5, modality = '', enableV3Fallback = false) {
const form = new FormData();
form.append('image', file, file.name || 'upload.png');
form.append('threshold', String(threshold));
if (modality) form.append('modality', modality);
if (enableV3Fallback) form.append('enable_v3_fallback', '1');
const resp = await fetch('/segment', { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(`/segment returned ${resp.status}`);
}
const payload = await resp.json();
if (!payload || payload.success === false) {
throw new Error((payload && payload.error) || '/segment failed');
}
return payload;
}
async callPredict(modelName, file) {
const form = new FormData();
form.append('model', modelName);
form.append('image', file, file.name || 'upload.png');
const resp = await fetch('/predict', { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(`/predict ${modelName} returned ${resp.status}`);
}
const payload = await resp.json();
if (!payload || payload.success === false) {
throw new Error((payload && payload.error) || `/predict ${modelName} failed`);
}
return payload.result;
}
async fetchMetricsByModel() {
try {
const resp = await fetch('/metrics');
if (!resp.ok) return {};
return await resp.json();
} catch (_) {
return {};
}
}
buildResultsFromBackend(patientId, segmentation) {
const segR = segmentation;
const processingTime = ((Date.now() - this.startTime) / 1000).toFixed(1);
let isPositive = false;
let diagnosis = 'No Tumor Detected';
let confidence = 0;
let bestModel = null;
let consensus = { verdict: null, mean: null, band: null };
if (segR && segR.verdict) {
isPositive = segR.verdict === 'TUMOR';
diagnosis = isPositive ? 'Tumor Detected' : 'No Tumor Detected';
// Use unified_confidence from backend when available
if (typeof segR.unified_confidence === 'number') {
confidence = segR.unified_confidence / 100;
} else {
confidence = segR.confidence === 'high' ? 0.95 : (segR.confidence === 'low' ? 0.45 : 0.7);
}
bestModel = {
modelLabel: segR.rule || 'Ensemble Advisory',
confidence: confidence
};
consensus.verdict = segR.verdict;
consensus.band = segR.confidence;
}
return {
patientId,
timestamp: new Date().toLocaleString(),
models: [], // removed classifiers
bestModel,
diagnosis,
isPositive,
confidence,
processingTime,
consensus,
uncertainty: { epistemic: null, aleatoric: null },
robustness: null,
};
}
displayResults() {
const results = this.currentResults;
// Update subtitle
document.getElementById('resultsSubtitle').textContent =
`Scan: ${results.patientId} · Analyzed at ${results.timestamp}`;
// The 4-signal advisory verdict (returned by /segment as of
// 2026-06-03b) is now the source of truth for the top-line
// diagnosis card. Fall back to v8-area gate when the advisory
// wasn't attached (e.g. older Space version or wire-up failure).
const segR = this.currentSegmentation;
const advVerdict = segR && segR.verdict;
const advConfidence = segR && segR.confidence; // 'high' | 'low'
const advRule = segR && segR.rule;
const advOp = segR && segR.operating_point;
const advReview = !!(segR && segR.review_recommended);
document.getElementById('diagnosisValue').textContent =
advVerdict === 'TUMOR' ? 'TUMOR DETECTED'
: advVerdict === 'no_tumor' ? 'NO TUMOR'
: results.diagnosis;
document.getElementById('diagnosisDetail').textContent =
advReview ? 'Low-confidence positive — a radiologist should review this scan'
: (parseFloat(advConfidence) > 90 || advConfidence === 'high' ? 'High confidence — multiple AI detectors agreed'
: advConfidence === 'low' ? 'Lower confidence — only one detector branch agreed'
: 'Requires clinical review');
// Confidence card: use unified_confidence from backend when available,
// else fall back to the ensemble band or legacy classifier float.
const confEl = document.getElementById('confidenceValue');
const confFillEl = document.getElementById('confidenceFill');
const unifiedConf = segR && typeof segR.unified_confidence === 'number' ? segR.unified_confidence : null;
if (unifiedConf !== null) {
const label = unifiedConf >= 90 ? 'HIGH' : (unifiedConf >= 70 ? 'MODERATE' : 'LOW');
confEl.textContent = `${label} (${unifiedConf}%)`;
confFillEl.style.width = `${unifiedConf}%`;
} else if (advConfidence) {
confEl.textContent = advConfidence === 'high' ? 'HIGH (97%)' : (advConfidence === 'low' ? 'LOW (50%)' : advConfidence);
const w = advConfidence === 'high' ? 90 : 50;
confFillEl.style.width = `${w}%`;
} else {
confEl.textContent = `${(results.confidence * 100).toFixed(1)}%`;
confFillEl.style.width = `${results.confidence * 100}%`;
}
// Repurposed Model card now shows the active ensemble rule.
document.getElementById('modelValue').textContent =
advRule || (results.bestModel && results.bestModel.modelLabel) || '--';
document.getElementById('modelDetail').textContent =
advOp ? `Operating point: ${advOp}` : 'Based on accuracy';
document.getElementById('timeValue').textContent =
`${results.processingTime}s`;
const volEl = document.getElementById('volumeValue');
if (volEl) {
volEl.textContent = (segR && segR.volume_cm3 !== undefined) ? `${segR.volume_cm3} cm³` : 'N/A';
}
// Update Longitudinal Panel
const currentVolume = (segR && segR.volume_cm3 !== undefined) ? Number(segR.volume_cm3) : null;
if (currentVolume !== null) {
document.getElementById('longitudinalPanel').style.display = '';
document.getElementById('currentVolumeLabel').textContent = `${currentVolume} cm³`;
// Dynamic bar height
const maxVol = Math.max(8.5, currentVolume);
document.getElementById('currentVolumeBar').style.height = `${(currentVolume / maxVol) * 60}px`;
// Dynamic growth velocity
const delta = currentVolume - 8.5;
const velocityLabel = document.getElementById('growthVelocityLabel');
if (delta > 0) {
velocityLabel.textContent = `+${delta.toFixed(1)} cm³`;
velocityLabel.style.color = '#ef4444'; // red (growth)
} else if (delta < 0) {
velocityLabel.textContent = `${delta.toFixed(1)} cm³`;
velocityLabel.style.color = '#2dd4bf'; // green (shrinkage)
} else {
velocityLabel.textContent = `0 cm³ (stable)`;
velocityLabel.style.color = '#64748b';
}
} else {
document.getElementById('longitudinalPanel').style.display = 'none';
}
// Render the 4-signal Ensemble Sources panel.
this.renderEnsembleSignalsPanel(segR && segR.v9b_advisory);
// Reveal Copilot Widget
const copilotToggle = document.getElementById('copilotToggleBtn');
if (copilotToggle) {
copilotToggle.style.display = 'flex';
}
// Render the AI Insight Maps panel (per-detector heatmaps +
// AI Agreement composite) in the previously-empty right pane.
this.renderAiInsightMaps(segR && segR.model_insights);
// Removed legacy comparison table code as models are deprecated
// --- Uncertainty + Robustness (computed from the 3-classifier vote) ---
const setT = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
const fmt3 = (v) => (v == null || Number.isNaN(v)) ? 'N/A' : v.toFixed(3);
const epEl = document.getElementById('epistemicValue');
const alEl = document.getElementById('aleatoricValue');
if (epEl) epEl.textContent = fmt3(results.uncertainty.epistemic);
if (alEl) alEl.textContent = fmt3(results.uncertainty.aleatoric);
const totalUnc = (results.uncertainty.epistemic == null || results.uncertainty.aleatoric == null)
? 0
: Math.min(1, (results.uncertainty.epistemic + results.uncertainty.aleatoric) / 2);
const uFill = document.getElementById('uncertaintyFill');
if (uFill) uFill.style.width = `${totalUnc * 100}%`;
const uNote = document.getElementById('uncertaintyNote');
if (uNote) {
if (results.uncertainty.epistemic == null) {
uNote.textContent = 'Need >=2 classifier outputs to compute uncertainty.';
} else if (totalUnc < 0.10) {
uNote.textContent = 'Low total uncertainty - models confident, prediction near decision-boundary extreme.';
} else if (totalUnc < 0.30) {
uNote.textContent = 'Moderate uncertainty - clinical review recommended.';
} else {
uNote.textContent = 'High uncertainty - radiologist correlation required.';
}
}
// Robustness (boundary distance) in [0,1] -> percent
const robPct = results.robustness == null ? null : results.robustness * 100;
const rValEl = document.getElementById('robustnessValue');
if (rValEl) rValEl.textContent = robPct == null ? 'N/A' : `${robPct.toFixed(0)}%`;
const rGauge = document.getElementById('robustnessGauge');
if (rGauge) {
rGauge.style.background = robPct == null
? 'conic-gradient(var(--gray-200) 0deg, var(--gray-200) 360deg)'
: `conic-gradient(var(--success) 0deg, var(--success) ${robPct * 3.6}deg, var(--gray-200) ${robPct * 3.6}deg)`;
}
const rNote = document.getElementById('robustnessNote');
if (rNote) {
if (robPct == null) {
rNote.textContent = 'Need >=2 classifier outputs to compute robustness.';
} else if (robPct >= 90) {
rNote.textContent = 'Excellent robustness - prediction far from decision boundary.';
} else if (robPct >= 60) {
rNote.textContent = 'Good robustness.';
} else {
rNote.textContent = 'Moderate robustness - prediction is close to the decision boundary.';
}
}
// --- Inference telemetry + cascade decision ----------------------
const segResult = this.currentSegmentation;
const anyRuntime = (results.models.find(m => m.runtime) || {}).runtime
|| (segResult && segResult.runtime) || '--';
setT('telemRuntime', anyRuntime);
setT('telemTotal', `${results.processingTime}s`);
if (segResult) {
const cascade = segResult.cascade || {};
setT('telemSegModel', cascade.used || segResult.source_dir || '--');
setT('telemSegReason', cascade.reason || 'n/a');
setT('telemSegArea', (segResult.tumor_area_px != null) ? `${segResult.tumor_area_px} px` : '--');
setT('telemSegMeanProb', (segResult.mean_prob_in_mask != null) ? segResult.mean_prob_in_mask.toFixed(3) : '--');
} else {
['telemSegModel', 'telemSegReason', 'telemSegArea', 'telemSegMeanProb']
.forEach(id => setT(id, '--'));
}
this.renderMedsamRefiner(segResult && segResult.medsam_refiner);
this.renderConformalCounterfactual(segResult && segResult.conformal_counterfactual);
// --- Visualizations ----------------------------------------------
if (this.imageDataUrl) document.getElementById('vizImage').src = this.imageDataUrl;
this.setHeatmapFromBackend(results.bestModel);
// Mask suppression gate. v5 (joint-trained on positives + healthy brains)
// mostly handles FP discipline at the segmenter level (0.13% FP rate on
// healthy validation scans). The classifier consensus is a secondary
// safety net for the rare residual FP voxels. So:
// - segmenter mask EMPTY + classifiers say no-tumor => confirmed no-tumor,
// show a SUCCESS banner, not a warning. v5 did its job.
// - segmenter mask NON-EMPTY + classifiers say no-tumor => v5 produced
// residual FP voxels; suppress the overlay and explain.
// - segmenter mask NON-EMPTY + classifiers say tumor => normal path,
// no banner, show overlay.
// - segmenter mask EMPTY + classifiers say tumor => disagreement;
// show a "models disagree" warning so the radiologist re-reviews.
const maskImg = document.getElementById('maskImage');
const segoverlayImg = document.getElementById('segoverlayImage');
const verdict = results.consensus && results.consensus.verdict;
const verdictBand = results.consensus && results.consensus.band;
const tumorAreaPx = segResult && Number(segResult.tumor_area_px || 0);
const segIsEmpty = tumorAreaPx < 16; // matches the MedSAM min_coarse_pixels
const classifiersSayNoTumor = (verdict === 'no_tumor' && (verdictBand === 'high' || verdictBand === 'moderate'));
const classifiersSayTumor = (verdict === 'tumor' && (verdictBand === 'high' || verdictBand === 'moderate'));
const meanP = (results.consensus && typeof results.consensus.mean === 'number') ? results.consensus.mean.toFixed(3) : '--';
const segName = (segResult && (segResult.cascade && segResult.cascade.used)) || (segResult && segResult.source_dir) || 'segmenter';
let suppress = false;
let bannerKind = null; // 'success' | 'warn-fp' | 'warn-disagree' | null
let bannerText = null;
if (segIsEmpty && classifiersSayNoTumor) {
bannerKind = 'success';
bannerText = `Confirmed no-tumor: ${segName} (joint-trained on positives + healthy brains) produced an empty mask, and all 3 classifiers agree (mean p=${meanP}, ${verdictBand} confidence). No suppression needed.`;
} else if (!segIsEmpty && classifiersSayNoTumor) {
suppress = true;
bannerKind = 'warn-fp';
bannerText = `Suppressed: ${segName} produced ${tumorAreaPx} px of residual mask, but classifier consensus is no-tumor (mean p=${meanP}, ${verdictBand} confidence). v5/v7 joint training reduced segmenter FP rate to ~0.13%, but rare residual false positives still get gated here.`;
} else if (segIsEmpty && classifiersSayTumor) {
bannerKind = 'warn-disagree';
bannerText = `Model disagreement: classifiers say tumor (mean p=${meanP}, ${verdictBand} confidence) but ${segName} produced an empty mask. Recommend manual review.`;
}
this._maskSuppressed = suppress;
this._maskSuppressedReason = bannerText;
this._maskSuppressedKind = bannerKind;
if (segResult && maskImg && segoverlayImg) {
if (segResult.mask) maskImg.src = segResult.mask;
if (suppress && this.imageDataUrl) {
segoverlayImg.src = this.imageDataUrl;
} else if (segResult.overlay) {
segoverlayImg.src = segResult.overlay;
}
} else if (maskImg && segoverlayImg) {
maskImg.src = '';
segoverlayImg.src = '';
}
// Coarse v5 mask/overlay (pre-MedSAM) and bbox-prompt visualization.
// segResult.coarse_mask / coarse_overlay are present only when MedSAM
// refined a non-empty mask. segResult.medsam_refiner.bbox_overlay is
// present whenever MedSAM ran with a valid bbox.
const coarseMaskImg = document.getElementById('coarseMaskImage');
const coarseOverlayImg = document.getElementById('coarseOverlayImage');
const bboxPromptImg = document.getElementById('bboxPromptImage');
if (coarseMaskImg) coarseMaskImg.src = (segResult && segResult.coarse_mask) || (segResult && segResult.mask) || '';
if (coarseOverlayImg) coarseOverlayImg.src = (segResult && segResult.coarse_overlay) || (segResult && segResult.overlay) || '';
if (bboxPromptImg) {
const bbox = segResult && segResult.medsam_refiner && segResult.medsam_refiner.bbox_overlay;
bboxPromptImg.src = bbox || (this.imageDataUrl || '');
}
// Show results section
this.showSection('results');
}
setHeatmapFromBackend(bestModel) {
// Real Grad-CAM data URL returned by /predict for cnn/transfer. The
// hybrid ViT and the Spaces ONNX deploy both return null (no autograd
// graph available). When null we show a true "unavailable" placeholder
// instead of repeating the raw MRI, which previously was confusing.
const heatmapImg = document.getElementById('heatmapImage');
const overlayImg = document.getElementById('overlayImage');
const placeholder = document.getElementById('vizPlaceholder');
if (bestModel && bestModel.gradcam) {
// Distinct images per tab. gradcam_heatmap is the pure colormap
// (no MRI blended in) - shown on the "Grad-CAM" tab. gradcam is
// the heatmap-blended-with-MRI - shown on the "Grad-CAM Overlay"
// tab. Falling back to the overlay if the backend didn't split
// (e.g. legacy TF .h5 path).
heatmapImg.src = bestModel.gradcam_heatmap || bestModel.gradcam;
overlayImg.src = bestModel.gradcam;
heatmapImg.dataset.available = 'true';
overlayImg.dataset.available = 'true';
} else {
// Clear the src and store an availability flag the tab-click
// handler reads to swap in the placeholder.
heatmapImg.src = '';
overlayImg.src = '';
heatmapImg.dataset.available = 'false';
overlayImg.dataset.available = 'false';
this._gradcamUnavailableReason = (bestModel && bestModel.runtime === 'onnx')
? 'Grad-CAM requires the PyTorch autograd graph and is not available in the ONNX-only deploy (this Space). Run the local dashboard with .pt weights to view Grad-CAM overlays.'
: 'Grad-CAM unavailable for this model.';
if (placeholder) {
placeholder.textContent = this._gradcamUnavailableReason;
}
}
}
renderAiInsightMaps(insights) {
// Populates the "AI Insight Maps" panel (added 2026-06-03d).
// `insights` shape (from /segment response.model_insights):
// { available_signals: [...], maps: { v9c: {overlay, fired_pct}, ... },
// agreement_overlay: 'data:image/png;...', n_signals: N }
const panel = document.getElementById('aiInsightPanel');
if (!panel) return;
if (!insights || insights.available === false || !insights.maps) {
panel.style.display = 'none';
return;
}
panel.style.display = '';
// AI Agreement headline visual
const agreeImg = document.getElementById('agreementMapImage');
const agreeImgOriginal = document.getElementById('agreementMapImageOriginal');
const agreeCard = document.getElementById('agreementMapCard');
if (insights.agreement_overlay) {
if (agreeImg) agreeImg.src = insights.agreement_overlay;
if (agreeImgOriginal) agreeImgOriginal.src = this.imageDataUrl;
if (agreeCard) agreeCard.style.display = '';
} else if (agreeCard) {
agreeCard.style.display = 'none';
}
// Per-detector heatmaps
let anyMissing = false;
['v9c', 'andi', 'symmetry'].forEach(sig => {
const card = panel.querySelector(`.insight-card[data-signal="${sig}"]`);
const img = document.getElementById(`insightImage-${sig}`);
const pct = document.getElementById(`insight-${sig}-pct`);
const data = insights.maps[sig];
if (data && data.overlay) {
if (card) card.style.display = '';
if (img) img.src = data.overlay;
if (pct) {
pct.textContent = `${data.fired_pct}% flagged`;
pct.style.color = data.fired_pct > 5 ? '#dc2626' : '#64748b';
}
} else {
if (card) card.style.display = 'none';
anyMissing = true;
}
});
const note = document.getElementById('insightUnavailableNote');
if (note) note.style.display = anyMissing ? 'block' : 'none';
}
renderEnsembleSignalsPanel(advisory) {
// Populates the "Four-Signal Ensemble Verdict" panel added 2026-06-03b.
// Hides the panel if the advisory isn't attached (older Space build
// or wire-up failure).
const card = document.getElementById('ensembleSignalsCard');
if (!card) return;
if (!advisory || advisory.enabled === false) {
card.style.display = 'none';
return;
}
card.style.display = 'block';
const setT = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
// Decision rule: layperson-friendly text in the visible label,
// technical Boolean rule in the hover title for researchers.
const ruleEl = document.getElementById('ensembleRule');
if (ruleEl) {
ruleEl.textContent = advisory.rule || '--';
if (advisory.rule_technical) {
ruleEl.title = `Technical rule: ${advisory.rule_technical}`;
}
}
// Mode: prefer the layperson display_name ("Balanced") over the
// internal slug ("balanced") when available.
setT('ensembleOp', advisory.operating_point_display || advisory.operating_point || '--');
const m = advisory.measured_performance || {};
const pctFmt = (v) => (v == null ? '--' : `${v}%`);
const scoreFmt = (v) => (v == null ? '--' : Number(v).toFixed(2));
// Layperson metric labels: "% of tumors caught", "% of healthy
// scans wrongly flagged", "overall accuracy".
setT('ensembleMeasured',
`On our test set: ${pctFmt(m.tumors_caught_pct)} of tumors caught, `
+ `${pctFmt(m.healthy_wrongly_flagged_pct)} of healthy scans wrongly flagged, `
+ `accuracy ${scoreFmt(m.overall_accuracy_score)}`);
const reviewBadge = document.getElementById('reviewBadge');
if (reviewBadge) {
reviewBadge.style.display = advisory.review_recommended ? 'block' : 'none';
}
const setSig = (sigKey, fired, value, threshold, fmt) => {
const stateEl = document.getElementById(`sig-${sigKey}-state`);
const valEl = document.getElementById(`sig-${sigKey}-val`);
const thrEl = document.getElementById(`sig-${sigKey}-thresh`);
if (stateEl) {
if (fired === true) {
// Layperson: "Flagged this scan" instead of "FIRED"
stateEl.textContent = 'Flagged';
stateEl.style.background = '#d1fae5';
stateEl.style.color = '#065f46';
} else if (fired === false) {
stateEl.textContent = 'Did not flag';
stateEl.style.background = '#e5e7eb';
stateEl.style.color = '#475569';
} else {
stateEl.textContent = 'Not active';
stateEl.style.background = '#f3f4f6';
stateEl.style.color = '#94a3b8';
}
}
if (valEl) valEl.textContent = value == null ? '--' : fmt(value);
if (thrEl) thrEl.textContent = threshold == null ? '--' : fmt(threshold);
};
const f3 = v => Number(v).toFixed(3);
const fSci = v => Number(v).toExponential(2);
const fInt = v => String(Math.round(Number(v)));
setSig('v9c', advisory.v9c_fired, advisory.v9c_p95, advisory.v9c_threshold, f3);
setSig('andi', advisory.andi_fired, advisory.andi_max, advisory.andi_threshold, fSci);
setSig('v8', advisory.v8_fired, advisory.v8_area_px, advisory.v8_area_threshold, fInt);
setSig('sym', advisory.symmetry_fired, advisory.symmetry_p95, advisory.symmetry_threshold, f3);
}
renderMedsamRefiner(ms) {
const panel = document.getElementById('medsamPanel');
if (!panel) return;
const setT = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
if (!ms) {
panel.style.display = 'none';
return;
}
panel.style.display = '';
if (!ms.available) {
setT('medsamStatus', `not available (${ms.reason || 'unknown reason'})`);
setT('medsamCoarse', '--'); setT('medsamRefined', '--');
setT('medsamDelta', '--'); setT('medsamIou', '--'); setT('medsamMs', '--');
return;
}
if (ms.skipped_reason) {
// Translate common technical skip reasons to plain language.
const friendlyReason = (
ms.skipped_reason === 'empty_coarse_mask'
? 'no initial tumor detected, nothing to refine'
: ms.skipped_reason === 'no_mask_to_refine'
? 'no initial mask was provided'
: ms.skipped_reason
);
setT('medsamStatus', `Skipped — ${friendlyReason}`);
} else {
setT('medsamStatus', 'Active');
}
setT('medsamCoarse', (ms.coarse_area_px != null) ? `${ms.coarse_area_px} px` : '--');
setT('medsamRefined', (ms.refined_area_px != null) ? `${ms.refined_area_px} px` : '--');
const delta = ms.delta_area_px;
setT('medsamDelta', (delta != null) ? `${delta > 0 ? '+' : ''}${delta} px` : '--');
setT('medsamIou', (ms.iou_score != null) ? ms.iou_score.toFixed(3) : '--');
setT('medsamMs', (ms.elapsed_ms != null) ? `${ms.elapsed_ms.toFixed(0)} ms` : '--');
}
renderConformalCounterfactual(cf) {
// cf may be null (no calibration artifacts), undefined (no segment
// result yet), or the analyze() dict from src/research/dashboard_integration.py.
const hero = document.getElementById('conformalCfHero');
const hint = document.getElementById('conformalCfMissingHint');
if (!hero) return;
if (!cf || !cf.available || !Array.isArray(cf.interventions) || cf.interventions.length === 0) {
hero.style.display = 'none';
// Show the "pending artifacts" hint so the user knows the panel
// is real and will populate as soon as artifacts download.
if (hint) hint.style.display = '';
return;
}
hero.style.display = '';
if (hint) hint.style.display = 'none';
const setT = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
const methodEl = document.getElementById('conformalCfMethod');
if (methodEl && cf._method) methodEl.textContent = cf._method;
const firstAlpha = cf.interventions[0] && cf.interventions[0].alpha;
setT('conformalCfCoverage',
firstAlpha != null ? `${(100 * (1 - firstAlpha)).toFixed(0)}% (α = ${firstAlpha.toFixed(2)})` : '--');
setT('conformalCfNiv', String(cf.n_interventions));
const sum = cf.summary || {};
const labelFor = (slug) => {
const row = cf.interventions.find(r => r.slug === slug);
return row ? row.label : (slug || '--');
};
setT('conformalCfMaxDis',
sum.max_disagree_intervention
? `${labelFor(sum.max_disagree_intervention)} (${(100 * (sum.max_disagree_fraction || 0)).toFixed(2)}%)`
: '--');
setT('conformalCfMostRobust', sum.most_robust_intervention ? labelFor(sum.most_robust_intervention) : '--');
const tbody = document.getElementById('conformalCfTbody');
if (tbody) {
tbody.innerHTML = '';
cf.interventions.forEach(row => {
const tr = document.createElement('tr');
const cells = [
row.label,
(row.q != null) ? row.q.toFixed(3) : '--',
(row.abstain_fraction != null) ? (100 * row.abstain_fraction).toFixed(2) + '%' : '--',
(row.certified_disagree_fraction != null) ? (100 * row.certified_disagree_fraction).toFixed(2) + '%' : '--',
(row.intervention_cf_area_px != null) ? String(row.intervention_cf_area_px) : '--',
];
cells.forEach((c, i) => {
const td = document.createElement('td');
td.textContent = c;
td.style.padding = '4px 8px';
td.style.borderBottom = '1px solid rgba(255,255,255,0.05)';
if (i > 0) td.style.textAlign = 'right';
tr.appendChild(td);
});
tbody.appendChild(tr);
});
}
}
async runSegmentation() {
if (!this.currentFile) {
alert('Upload an MRI image first.');
return;
}
const thresholdInput = document.getElementById('thresholdSlider');
const thresholdValue = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5;
this.setSegmentationPanelLoading();
try {
const segModalitySel = document.getElementById('segModelSelect');
const segModality = segModalitySel ? segModalitySel.value : '';
const payload = await this.callSegment(this.currentFile, thresholdValue, segModality);
this.currentSegmentation = { result: payload, error: null };
this.renderSegmentationFromCache();
} catch (err) {
this.currentSegmentation = { result: null, error: err.message || String(err) };
this.renderSegmentationFromCache();
console.error(err);
}
}
setSegmentationPanelLoading() {
const segOriginal = document.getElementById('segOriginal');
const segMask = document.getElementById('segMask');
const segOverlay = document.getElementById('segOverlay');
if (segOriginal && this.imageDataUrl) {
segOriginal.innerHTML = `<img src="${this.imageDataUrl}" style="width:100%;height:100%;object-fit:contain;border-radius:12px;">`;
}
if (segMask) segMask.innerHTML = '<span style="opacity:0.6;">Running U-Net...</span>';
if (segOverlay) segOverlay.innerHTML = '<span style="opacity:0.6;">Running U-Net...</span>';
const dice = document.getElementById('diceScore');
const iou = document.getElementById('iouScore');
const area = document.getElementById('tumorArea');
if (dice) dice.textContent = '...';
if (iou) iou.textContent = '...';
if (area) area.textContent = '...';
}
renderSegmentationFromCache() {
const segOriginal = document.getElementById('segOriginal');
const segMask = document.getElementById('segMask');
const segOverlay = document.getElementById('segOverlay');
const dice = document.getElementById('diceScore');
const iou = document.getElementById('iouScore');
const area = document.getElementById('tumorArea');
if (!segMask) return;
if (segOriginal && this.imageDataUrl) {
segOriginal.innerHTML = `<img src="${this.imageDataUrl}" style="width:100%;height:100%;object-fit:contain;border-radius:12px;">`;
}
if (!this.currentSegmentation) {
segMask.innerHTML = '<span style="opacity:0.6;">Upload an image and click "Run Analysis" to see the U-Net mask.</span>';
segOverlay.innerHTML = '';
return;
}
const seg = this.currentSegmentation;
if (seg.error) {
segMask.innerHTML = `<span style="color:#ef4444;">Error: ${seg.error}</span>`;
segOverlay.innerHTML = '';
if (dice) dice.textContent = '--';
if (iou) iou.textContent = '--';
if (area) area.textContent = '--';
return;
}
const payload = seg.result || {};
if (payload.mask) {
segMask.innerHTML = `<img src="${payload.mask}" style="width:100%;height:100%;object-fit:contain;border-radius:12px;background:black;">`;
}
if (payload.overlay) {
segOverlay.innerHTML = `<img src="${payload.overlay}" style="width:100%;height:100%;object-fit:contain;border-radius:12px;">`;
}
if (dice) dice.textContent = (payload.dice == null) ? 'N/A' : Number(payload.dice).toFixed(3);
if (iou) iou.textContent = (payload.iou == null) ? 'N/A' : Number(payload.iou).toFixed(3);
if (area) area.textContent = (payload.tumor_area_px == null) ? 'N/A' : `${payload.tumor_area_px} px`;
// Cascade info: which checkpoint actually fired + why.
const usedEl = document.getElementById('segUsedModel');
const reasonEl = document.getElementById('segCascadeReason');
const cascade = payload.cascade;
if (usedEl) {
const used = (cascade && cascade.used) || payload.source_dir || '--';
// Make the label shorter and friendlier.
const friendly = {
'attention_unet_v3': 'v3 (multi-modal)',
'attention_unet_v2': 'v2',
'attention_unet_t1c': 'T1c specialist',
'attention_unet_lgg': 'LGG',
'attention_unet': 'baseline',
};
usedEl.textContent = friendly[used] || used;
}
if (reasonEl) {
if (cascade && cascade.reason) {
const reasonLabel = {
'v3_sufficient': 'v3 found enough tumor; no cascade',
'specialist_unavailable': 'T1c specialist checkpoint missing',
'explicit_modality_request': 'user picked this model',
}[cascade.reason] || cascade.reason;
reasonEl.textContent = reasonLabel;
} else {
reasonEl.textContent = '';
}
}
}
async callExplain(file, threshold, modality, backend) {
const form = new FormData();
form.append('image', file, file.name || 'upload.png');
form.append('threshold', String(threshold));
if (modality) form.append('modality', modality);
if (backend) form.append('backend', backend);
const resp = await fetch('/explain', { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(`/explain returned ${resp.status}`);
}
const payload = await resp.json();
if (!payload || payload.success === false) {
throw new Error((payload && payload.error) || '/explain failed');
}
return payload;
}
/**
* Generate Report flow on the Results page. Calls /explain (which runs
* the cascade segmentation + 3 classifiers + feature extraction + the
* 3-pattern LLM pipeline), then renders the full explanation panel
* inline inside #reportContent.
*/
/**
* Batch upload: process N files sequentially through the same /predict
* + /segment pipeline used by Run Analysis, then render a comparison
* table on the Upload section. Each row is clickable to deep-link into
* the full Results view for that file. The selected backend / threshold
* / model from the Upload form are honored for the whole batch.
*/
async runBatchAnalysis(files) {
if (!this._batchResults) this._batchResults = [];
const panel = document.getElementById('batchPanel');
const progressWrap = document.getElementById('batchProgressWrap');
const progressFill = document.getElementById('batchProgressFill');
const progressText = document.getElementById('batchProgressText');
const tbody = document.getElementById('batchTableBody');
const subtitle = document.getElementById('batchSubtitle');
if (panel) panel.style.display = 'block';
if (progressWrap) progressWrap.style.display = 'block';
if (subtitle) subtitle.textContent = `${files.length} file${files.length === 1 ? '' : 's'} queued ...`;
// Read upload form choices once so the whole batch uses the same setup.
const modelSelect = document.getElementById('modelSelect');
const modelChoice = modelSelect ? (modelSelect.value || 'all') : 'all';
const segModalitySel = document.getElementById('segModelSelect');
const segModality = segModalitySel ? segModalitySel.value : '';
const thresholdInput = document.getElementById('thresholdSlider');
const threshold = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5;
// Sequential processing keeps the small server stable. Cheap rows
// (CPU-bound /predict on CNN classifier alone) finish in ~50 ms; a
// full /predict 'all' + /segment is ~1-1.5 s. With N=8 the batch
// completes in ~10 s.
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (progressText) progressText.textContent = `Processing ${i + 1} / ${files.length} - ${file.name}`;
if (progressFill) progressFill.style.width = `${((i) / files.length) * 100}%`;
const tStart = performance.now();
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/explain', { method: 'POST', body: formData });
const explainData = await response.json();
const predictions = [];
const seg = { result: explainData, error: null };
let verdict = explainData.verdict || 'mixed';
let mean = parseFloat(explainData.confidence) / 100.0;
if (isNaN(mean)) mean = 0.5;
let band = 'low';
if (verdict === 'tumor') band = mean >= 0.9 ? 'high' : 'moderate';
if (verdict === 'no_tumor') band = mean >= 0.9 ? 'high' : 'moderate';
const std = 0;
const entropy = 0;
const best = { model: 'ensemble' };
const elapsed = (performance.now() - tStart) / 1000;
const scanId = `BATCH-${Date.now()}-${i}`;
// Read the file into a data URL once so the Results-page
// preview can show the original MRI when the user drills in.
let imageDataUrl = null;
try {
imageDataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
} catch (_) { /* ignore - preview just won't show */ }
const entry = {
id: scanId, filename: file.name,
// Retain the File object so downstream actions on the
// Results page (Generate Report, Print, re-segment with
// a different threshold) can re-POST the bytes to the
// server. Without this, generateReport() bails because
// this.currentFile was never set.
file: file,
imageDataUrl,
predictions, segmentation: seg,
mean, std, entropy, verdict, band,
bestModel: best ? best.model : '--',
elapsedSeconds: elapsed.toFixed(2),
timestamp: Date.now(),
};
this._batchResults.push(entry);
this.renderBatchRow(tbody, entry);
// Also push to Recent Scans sidebar so it's discoverable.
this.addRecentScan({
id: scanId,
isPositive: verdict === 'tumor',
confidence: mean || 0,
timestamp: Date.now(),
});
} catch (err) {
console.error('Batch entry failed:', err);
}
if (progressFill) progressFill.style.width = `${((i + 1) / files.length) * 100}%`;
}
if (progressText) progressText.textContent = `Done. ${this._batchResults.length} total in batch.`;
if (subtitle) {
const tumorCount = this._batchResults.filter(e => e.verdict === 'tumor').length;
const noTumorCount = this._batchResults.filter(e => e.verdict === 'no_tumor').length;
const mixedCount = this._batchResults.length - tumorCount - noTumorCount;
subtitle.textContent = `${this._batchResults.length} scans: ${tumorCount} tumor, ${noTumorCount} no-tumor, ${mixedCount} ambiguous.`;
}
}
renderBatchRow(tbody, e) {
if (!tbody) return;
const idx = this._batchResults.length;
const row = document.createElement('tr');
row.dataset.batchId = e.id;
const verdictBadge = `<span class="batch-verdict ${e.verdict}">${this.escapeHtml(e.verdict)}</span>`;
const meanStr = e.mean == null ? '--' : e.mean.toFixed(3);
const stdStr = e.std == null ? '--' : e.std.toFixed(3);
const entStr = e.entropy == null ? '--' : e.entropy.toFixed(3);
row.innerHTML = `
<td>${idx}</td>
<td title="${this.escapeHtml(e.filename)}">${this.escapeHtml(e.filename.length > 36 ? e.filename.slice(0, 33) + '...' : e.filename)}</td>
<td><span class="batch-diag ${e.verdict === 'tumor' ? 'positive' : (e.verdict === 'no_tumor' ? 'negative' : 'mixed')}">${e.verdict === 'tumor' ? 'Tumor' : (e.verdict === 'no_tumor' ? 'No Tumor' : 'Mixed')}</span></td>
<td>${this.escapeHtml(this.getModelLabel(e.bestModel))}</td>
<td>${meanStr}</td>
<td>${stdStr}</td>
<td>${entStr}</td>
<td>${verdictBadge} <span class="batch-band">${this.escapeHtml(e.band || '')}</span></td>
<td>${e.elapsedSeconds}s</td>
<td><button class="btn btn-small btn-outline" data-batch-view="${e.id}">View</button></td>
`;
const viewBtn = row.querySelector('[data-batch-view]');
if (viewBtn) viewBtn.addEventListener('click', () => this.viewBatchEntry(e.id));
tbody.appendChild(row);
}
viewBatchEntry(id) {
const entry = (this._batchResults || []).find(e => e.id === id);
if (!entry) return;
// Rebuild the displayResults-compatible payload from the batch entry.
const meanProb = entry.mean;
const labelMap = { cnn: 'CNN (Fast)', transfer: 'Transfer Learning', vit: 'Vision Transformer' };
const modelResults = entry.predictions.filter(p => p.result).map(p => ({
model: p.model,
modelLabel: labelMap[p.model] || p.model,
prediction: p.result.display_label || (p.result.label === 'tumor' ? 'Tumor' : 'No Tumor'),
confidence: p.result.confidence,
accuracy: null, auc: null,
isPositive: p.result.label === 'tumor',
status: p.result.label === 'tumor' ? 'positive' : 'negative',
gradcam: p.result.gradcam || null,
gradcam_heatmap: p.result.gradcam_heatmap || null,
image: p.result.image || null,
probability: p.result.probability,
runtime: p.result.runtime || null,
}));
const bestModel = modelResults.length
? modelResults.reduce((a, b) => (b.confidence > a.confidence ? b : a)) : null;
const robustness = meanProb == null ? null : (Math.max(meanProb, 1 - meanProb) - 0.5) * 2;
this.currentResults = {
patientId: entry.id,
timestamp: new Date(entry.timestamp).toLocaleString(),
models: modelResults,
bestModel,
diagnosis: entry.verdict === 'tumor' ? 'Tumor Detected' : 'No Tumor Detected',
isPositive: entry.verdict === 'tumor',
confidence: bestModel ? bestModel.confidence : 0,
processingTime: entry.elapsedSeconds,
consensus: { verdict: entry.verdict, mean: meanProb, band: entry.band },
uncertainty: { epistemic: entry.std, aleatoric: entry.entropy },
robustness,
};
this.currentSegmentation = entry.segmentation || { result: null, error: null };
// Restore the File object + data URL we captured at batch time so
// downstream actions on the Results page (Generate Report, Print,
// re-segment) can re-POST the bytes to /explain / /segment without
// the user having to re-upload.
this.currentFile = entry.file || null;
this.imageDataUrl = entry.imageDataUrl
|| entry.predictions.find(p => p.result && p.result.image)?.result?.image
|| null;
this.currentExplanation = null; // not generated yet for this batch entry
this.displayResults();
}
clearBatch() {
this._batchResults = [];
const panel = document.getElementById('batchPanel');
if (panel) panel.style.display = 'none';
const tbody = document.getElementById('batchTableBody');
if (tbody) tbody.innerHTML = '';
}
exportBatchCsv() {
const rows = this._batchResults || [];
if (!rows.length) {
this.showToast('Nothing to export', 'Run a batch upload first.', 'error');
return;
}
const header = ['index', 'filename', 'verdict', 'band', 'mean_probability',
'std_probability', 'entropy', 'best_model',
'cnn_p', 'transfer_p', 'vit_p', 'elapsed_seconds', 'timestamp'];
const lines = [header.join(',')];
rows.forEach((e, i) => {
const get = (m) => {
const p = e.predictions.find(x => x.model === m);
return (p && p.result && typeof p.result.probability === 'number')
? p.result.probability.toFixed(4) : '';
};
const cells = [
i + 1,
`"${(e.filename || '').replace(/"/g, '""')}"`,
e.verdict || '',
e.band || '',
e.mean == null ? '' : e.mean.toFixed(4),
e.std == null ? '' : e.std.toFixed(4),
e.entropy == null ? '' : e.entropy.toFixed(4),
e.bestModel || '',
get('cnn'), get('transfer'), get('vit'),
e.elapsedSeconds || '',
new Date(e.timestamp).toISOString(),
];
lines.push(cells.join(','));
});
const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `trinetra_batch_${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(url);
this.showToast('CSV exported', `${rows.length} row${rows.length === 1 ? '' : 's'} saved.`, 'success');
}
async generateReport() {
if (!this.currentFile) {
this.showToast('Upload an image first', 'Run Analysis on an MRI before requesting the report.', 'error');
return;
}
const placeholder = document.getElementById('reportPlaceholder');
const content = document.getElementById('reportContent');
const btn = document.getElementById('generateReportBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Running...'; }
// Build the rich panel skeleton inside reportContent. We literally
// duplicate the explain panel markup so renderExplanation can target
// the same element IDs as on the Segmentation tab.
if (content) {
content.style.display = 'block';
content.innerHTML = this._explainPanelMarkup();
}
if (placeholder) placeholder.style.display = 'none';
const thresholdInput = document.getElementById('thresholdSlider');
const threshold = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5;
const backendSel = document.getElementById('reportBackendSelect');
const backend = backendSel ? backendSel.value : '';
try {
const payload = await this.callExplain(this.currentFile, threshold, '', backend);
if (payload.segmentation) {
this.currentSegmentation = { result: payload.segmentation, error: null };
this.renderSegmentationFromCache();
}
this.currentExplanation = payload.explanation || null;
this.renderExplanation(payload);
this.showToast('Report ready', `${(payload.explanation?.backend || 'deterministic')} backend completed.`, 'success');
} catch (err) {
console.error(err);
this.renderExplanationError(err.message || String(err));
this.showToast('Report failed', err.message || String(err), 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Generate Report'; }
}
}
/** Returns the same DOM IDs as #explainPanel so renderExplanation can target
* them inside the Results-page report block. */
_explainPanelMarkup() {
return `
<div class="explain-header">
<h3>Layered Pipeline Output</h3>
<div class="explain-header-meta">
<span class="explain-backend" id="explainBackend">--</span>
<span class="explain-safety-badge" id="explainSafetyBadge">--</span>
</div>
</div>
<div class="explain-body">
<div class="explain-section explain-impression">
<h4>Impression</h4>
<p id="explainImpression">--</p>
</div>
<div class="explain-section explain-confidence-card">
<h4>Overall Confidence</h4>
<div class="confidence-row">
<div class="confidence-band" id="explainConfBand">--</div>
<div class="confidence-score">
<div class="confidence-score-value" id="explainConfScore">--</div>
<div class="confidence-score-bar"><div class="confidence-score-fill" id="explainConfFill"></div></div>
</div>
</div>
<p id="explainConfidence" class="explain-confidence-detail">--</p>
</div>
<div class="explain-section explain-negative-explanation" id="explainNegativeSection" style="display:none;">
<h4>Why the Classifiers Ruled This Out</h4>
<pre id="explainNegativeExplanation" class="explain-grade"></pre>
</div>
<div class="explain-section explain-vision-negative" id="explainVisionNegativeSection" style="display:none;">
<h4>Vision LLM Reasoning (Pattern D - negative-case)</h4>
<p class="explain-subtle">The vision model was shown the original MRI and asked to describe what visible features support the no-tumor verdict. Output is validated to ensure it does not contradict the verdict.</p>
<pre id="explainVisionNegativeText" class="explain-grade"></pre>
</div>
<div class="explain-section" id="explainFindingsSection">
<h4>Structured Findings</h4>
<dl class="explain-findings" id="explainFindings"></dl>
</div>
<div class="explain-section" id="explainGradeSection">
<h4>Grade-Evidence Score</h4>
<pre id="explainGradeEvidence" class="explain-grade">--</pre>
</div>
<details class="explain-raw" id="explainFpRegionSection" style="display:none;">
<summary>False-positive region analysis (debug)</summary>
<dl class="explain-findings" id="explainFpRegionFindings"></dl>
<pre id="explainFpRegionGrade" class="explain-grade"></pre>
</details>
<div class="explain-section">
<h4>Differential Diagnosis (citation-checked)</h4>
<div id="explainDifferentialList" class="differential-list"></div>
</div>
<div class="explain-section" id="explainVisualSection" style="display:none;">
<h4>Visual Observations (LLM co-observer)</h4>
<ul id="explainVisualObservations"></ul>
</div>
<div class="explain-section explain-disagreements" id="explainDisagreementsSection" style="display:none;">
<h4>Model Disagreements (flagged conflicts)</h4>
<ul id="explainVisualDisagreements"></ul>
</div>
<div class="explain-section explain-recommendation">
<h4>Recommendation</h4>
<p id="explainRecommendation">--</p>
</div>
<div class="explain-section">
<h4>Classifier Agreement</h4>
<p id="explainAgreement">--</p>
</div>
<div class="explain-section explain-llm-passes">
<h4>LLM Pass Status</h4>
<div id="explainLlmPasses" class="llm-passes-grid"></div>
</div>
<div class="explain-section explain-quality" id="explainQualitySection" style="display:none;">
<h4>Quality Warnings</h4>
<ul id="explainQualityWarnings"></ul>
</div>
<div class="explain-section explain-disclaimer">
<h4>Disclaimer</h4>
<p id="explainDisclaimer">Not a medical diagnosis. Research / educational only.</p>
</div>
<details class="explain-raw">
<summary>Polished summary (verified prose, may equal Impression if LLM polish rejected)</summary>
<p id="explainSummary"></p>
</details>
<details class="explain-raw">
<summary>Raw deterministic features (JSON)</summary>
<pre id="explainRaw"></pre>
</details>
</div>
`;
}
async runExplanation() {
if (!this.currentFile) {
alert('Upload an MRI image first.');
return;
}
const panel = document.getElementById('explainPanel');
if (panel) panel.style.display = 'block';
this.setExplanationLoading();
const thresholdInput = document.getElementById('thresholdSlider');
const threshold = thresholdInput ? (parseInt(thresholdInput.value, 10) / 100) : 0.5;
const segModalitySel = document.getElementById('segModelSelect');
const segModality = segModalitySel ? segModalitySel.value : '';
const backendSel = document.getElementById('explainBackendSelect');
const backend = backendSel ? backendSel.value : '';
try {
const payload = await this.callExplain(this.currentFile, threshold, segModality, backend);
// Also update the segmentation viewers since /explain reran segmentation.
if (payload.segmentation) {
this.currentSegmentation = { result: payload.segmentation, error: null };
this.renderSegmentationFromCache();
}
// Persist for the Export Report download.
this.currentExplanation = payload.explanation || null;
this.renderExplanation(payload);
} catch (err) {
console.error(err);
this.renderExplanationError(err.message || String(err));
}
}
setExplanationLoading() {
const set = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; };
set('explainBackend', 'running...');
set('explainSafetyBadge', '');
set('explainImpression', 'Calling LLM and extracting deterministic tumor features...');
set('explainSummary', '...');
set('explainAgreement', '...');
set('explainConfidence', '...');
set('explainConfBand', '--');
set('explainConfScore', '--');
set('explainGradeEvidence', '...');
set('explainRecommendation', '...');
set('explainDisclaimer', 'Not a medical diagnosis. Research / educational only.');
const ids = ['explainFindings', 'explainDifferentialList', 'explainVisualObservations',
'explainVisualDisagreements', 'explainLlmPasses', 'explainQualityWarnings',
'explainRaw'];
ids.forEach(id => { const el = document.getElementById(id); if (el) el.innerHTML = ''; });
const fill = document.getElementById('explainConfFill');
if (fill) fill.style.width = '0%';
}
renderExplanationError(message) {
const set = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; };
set('explainBackend', 'error');
set('explainImpression', `Error: ${message}`);
set('explainSummary', '--');
set('explainAgreement', '--');
set('explainConfidence', '--');
}
renderExplanation(payload) {
const exp = (payload && payload.explanation) || {};
const feats = (payload && payload.features) || {};
const set = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text || '--'; };
// --- Header (backend + safety badge) -------------------------------
set('explainBackend', `${exp.backend || 'none'}${exp.model ? ` · ${exp.model}` : ''}`);
const safety = exp.hallucination_safety || '';
const safetyEl = document.getElementById('explainSafetyBadge');
if (safetyEl) {
const isZero = safety.toLowerCase().includes('guaranteed_zero');
safetyEl.textContent = isZero ? 'Zero-Hallucination Mode' : 'Hallucination-Checked';
safetyEl.title = safety;
safetyEl.className = 'explain-safety-badge ' + (isZero ? 'safety-zero' : 'safety-checked');
}
// --- Impression + verified Summary --------------------------------
set('explainImpression', exp.impression || exp.summary);
set('explainSummary', exp.summary);
set('explainDisclaimer', exp.disclaimer || 'Not a medical diagnosis. Research / educational only.');
set('explainAgreement', exp.model_agreement_analysis);
set('explainConfidence', exp.confidence_assessment);
set('explainRecommendation', exp.recommendation);
// --- Confidence band + score --------------------------------------
const overall = feats.overall_confidence || {};
const score = typeof overall.score_0_to_1 === 'number' ? overall.score_0_to_1 : null;
const band = overall.band || '';
const bandEl = document.getElementById('explainConfBand');
if (bandEl) {
bandEl.textContent = band || '--';
bandEl.className = 'confidence-band conf-' + (band || 'unknown').replace(/[^a-z-]/gi, '');
}
const scoreEl = document.getElementById('explainConfScore');
if (scoreEl) scoreEl.textContent = (score == null) ? '--' : `${(score * 100).toFixed(0)}%`;
const fill = document.getElementById('explainConfFill');
if (fill) fill.style.width = `${(score == null) ? 0 : score * 100}%`;
// --- Classifier-negative explanation (shown only when verdict=no_tumor) ---
const negativeSection = document.getElementById('explainNegativeSection');
const negativeText = document.getElementById('explainNegativeExplanation');
const negExp = exp.classifier_negative_explanation;
if (negativeSection && negativeText) {
if (negExp) {
negativeSection.style.display = 'block';
negativeText.textContent = negExp;
} else {
negativeSection.style.display = 'none';
negativeText.textContent = '';
}
}
// --- Pattern D: vision LLM reasoning for negative cases -----------
const visionNegSection = document.getElementById('explainVisionNegativeSection');
const visionNegText = document.getElementById('explainVisionNegativeText');
const visionNeg = exp.vision_negative_reasoning;
if (visionNegSection && visionNegText) {
if (visionNeg) {
visionNegSection.style.display = 'block';
visionNegText.textContent = visionNeg;
} else {
visionNegSection.style.display = 'none';
visionNegText.textContent = '';
}
}
const findingDomains = [
['geometry', 'Geometry'],
['localization', 'Localization'],
['intensity', 'Intensity'],
['texture', 'Texture'],
['multimodal', 'Multimodal'],
['morphology_margins', 'Morphology & Margins'],
['internal_architecture', 'Internal Architecture'],
['mass_effect', 'Mass Effect'],
];
const renderFindingsInto = (el, fmap) => {
if (!el || !fmap) return false;
const html = findingDomains
.filter(([k]) => fmap[k])
.map(([k, label]) => `<dt>${label}</dt><dd>${this.escapeHtml(fmap[k])}</dd>`)
.join('');
el.innerHTML = html;
return Boolean(html);
};
// --- Structured findings (8 domains, primary report) --------------
const findingsEl = document.getElementById('explainFindings');
const findingsSection = document.getElementById('explainFindingsSection');
const hasFindings = renderFindingsInto(findingsEl, exp.findings || {});
// Hide the whole section when the deterministic narrative produced no
// primary findings (i.e. verdict was no_tumor and the U-Net features
// got moved into the FP-region debug bucket below).
if (findingsSection) findingsSection.style.display = hasFindings ? '' : 'none';
// --- Grade evidence narrative -------------------------------------
const gradeEl = document.getElementById('explainGradeEvidence');
const gradeSection = document.getElementById('explainGradeSection');
const gradeText = exp.grade_evidence_narrative || '';
if (gradeEl) gradeEl.textContent = gradeText || '--';
if (gradeSection) gradeSection.style.display = gradeText ? '' : 'none';
// --- False-positive region debug (collapsible) --------------------
// When the classifier verdict was no_tumor, the U-Net feature
// breakdown was preserved here under a clearly-labeled details block
// so the raw data is accessible but not pretending to be a clinical
// finding.
const fpSection = document.getElementById('explainFpRegionSection');
const fpFindings = document.getElementById('explainFpRegionFindings');
const fpGrade = document.getElementById('explainFpRegionGrade');
const fpAnalysis = exp.fp_region_analysis || null;
const fpGradeText = exp.fp_grade_evidence || '';
const hasFpAnalysis = renderFindingsInto(fpFindings, fpAnalysis || {});
if (fpGrade) fpGrade.textContent = fpGradeText;
if (fpSection) fpSection.style.display = (hasFpAnalysis || fpGradeText) ? '' : 'none';
// --- Differential with citations & origin tags --------------------
const diff = document.getElementById('explainDifferentialList');
if (diff) {
const items = exp.differential_with_citations || [];
if (items.length === 0) {
diff.innerHTML = '<div style="opacity:0.6;">No differential hints returned.</div>';
} else {
diff.innerHTML = items.map(d => {
const origin = d.origin || 'rule-based';
const originLabel = origin === 'llm-citation-checked'
? '<span class="origin-tag tag-llm">LLM · citation-checked</span>'
: '<span class="origin-tag tag-rule">Rule-based</span>';
const confTag = d.confidence
? `<span class="conf-tag conf-${d.confidence.replace(/[^a-z-]/gi, '')}">${this.escapeHtml(d.confidence)}</span>`
: '';
const cites = (d.supported_by || []).map(c =>
`<code class="citation-chip">${this.escapeHtml(String(c))}</code>`
).join(' ');
return `
<div class="differential-item">
<div class="differential-tags">${originLabel}${confTag}</div>
<div class="differential-statement">${this.escapeHtml(d.statement || '')}</div>
<div class="differential-citations">Supported by: ${cites || '<em>(no citations)</em>'}</div>
</div>`;
}).join('');
}
}
// --- Pattern C: visual observations -------------------------------
const visualSection = document.getElementById('explainVisualSection');
const visualList = document.getElementById('explainVisualObservations');
const obs = exp.visual_observations || [];
if (visualSection && visualList) {
if (obs.length) {
visualSection.style.display = '';
visualList.innerHTML = obs.map(o => {
const region = this.escapeHtml(o.region || '?');
const claim = this.escapeHtml(o.claimed_property || '');
const text = this.escapeHtml(o.observation || '');
return `<li><strong>${region}</strong> — ${text} <span class="claim-prop">[${claim}]</span></li>`;
}).join('');
} else {
visualSection.style.display = 'none';
}
}
// Disagreements
const disagreeSection = document.getElementById('explainDisagreementsSection');
const disagreeList = document.getElementById('explainVisualDisagreements');
const dis = exp.visual_disagreements || [];
if (disagreeSection && disagreeList) {
if (dis.length) {
disagreeSection.style.display = '';
disagreeList.innerHTML = dis.map(d => {
const text = this.escapeHtml(d.observation || '');
const conflicts = (d.conflicts_with || []).map(c => this.escapeHtml(c)).join('; ');
return `<li><strong>${text}</strong> <span class="claim-prop">conflicts with: ${conflicts}</span></li>`;
}).join('');
} else {
disagreeSection.style.display = 'none';
}
}
// --- LLM pass status (transparency) -------------------------------
const passesEl = document.getElementById('explainLlmPasses');
if (passesEl) {
const passes = exp.llm_passes || {};
const labels = {
polish: 'Polish (Pattern A)',
differential_expansion: 'Differential Expansion (Pattern B)',
visual_observer: 'Visual Observer (Pattern C)',
};
const items = ['polish', 'differential_expansion', 'visual_observer']
.filter(k => passes[k])
.map(k => {
const p = passes[k];
const status = p.status || 'unknown';
const cssStatus = status.replace(/[^a-z_]/gi, '');
const model = p.model ? ` <span class="pass-model">${this.escapeHtml(p.model)}</span>` : '';
let detail = '';
if (status === 'error' || status === 'skipped_insufficient_ram') {
detail = `<div class="pass-detail pass-error">${this.escapeHtml(p.error || p.recovery_hint || '')}</div>`;
} else if (status === 'rejected') {
detail = `<div class="pass-detail pass-warn">Rejected: ${this.escapeHtml((p.warnings || []).join('; '))}</div>`;
} else if (status === 'ok' && k === 'differential_expansion') {
detail = `<div class="pass-detail">Accepted ${p.accepted_count || 0} · Rejected ${p.rejected_count || 0}</div>`;
} else if (status === 'ok' && k === 'visual_observer') {
detail = `<div class="pass-detail">${p.observation_count || 0} observations · ${p.disagreement_count || 0} disagreements</div>`;
} else if (status === 'skipped') {
detail = `<div class="pass-detail">${this.escapeHtml(p.reason || 'skipped')}</div>`;
}
return `
<div class="llm-pass-item pass-${cssStatus}">
<div class="pass-header">
<span class="pass-label">${labels[k]}</span>${model}
</div>
<div class="pass-status">${this.escapeHtml(status)}</div>
${detail}
</div>`;
}).join('');
passesEl.innerHTML = items || '<div style="opacity:0.6;">No LLM passes run.</div>';
}
// --- Quality warnings ---------------------------------------------
const qualSection = document.getElementById('explainQualitySection');
const qualList = document.getElementById('explainQualityWarnings');
const warnings = exp.quality_warnings || [];
if (qualSection && qualList) {
if (warnings.length) {
qualSection.style.display = '';
qualList.innerHTML = warnings.map(w => `<li>${this.escapeHtml(w)}</li>`).join('');
} else {
qualSection.style.display = 'none';
}
}
// --- Raw features (collapsible) -----------------------------------
const raw = document.getElementById('explainRaw');
if (raw) {
try { raw.textContent = JSON.stringify(feats, null, 2); }
catch (_) { raw.textContent = String(feats); }
}
}
escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
handleTabClick(e) {
const btn = e.target;
const tabGroup = btn.parentElement;
const tabType = btn.dataset.tab || btn.dataset.view;
// Remove active from siblings
tabGroup.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Handle view switching
if (btn.dataset.view) {
['vizImage', 'heatmapImage', 'overlayImage', 'maskImage', 'segoverlayImage',
'coarseMaskImage', 'coarseOverlayImage', 'bboxPromptImage', 'vizPlaceholder'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
// Suppression banner is shown only on the U-Net Mask / U-Net
// Overlay tabs when the classifier consensus is no-tumor.
const banner = document.getElementById('maskSuppressBanner');
if (banner) {
const isSegTab = (tabType === 'mask' || tabType === 'segoverlay');
if (this._maskSuppressed && isSegTab) {
banner.style.display = 'block';
banner.textContent = this._maskSuppressedReason || '';
} else {
banner.style.display = 'none';
}
}
const idMap = {
original: 'vizImage',
heatmap: 'heatmapImage',
overlay: 'overlayImage',
mask: 'maskImage',
segoverlay: 'segoverlayImage',
coarse_mask: 'coarseMaskImage',
coarse_overlay: 'coarseOverlayImage',
bbox_prompt: 'bboxPromptImage',
};
const targetId = idMap[tabType] || `${tabType}Image`;
const target = document.getElementById(targetId);
// If the user picked a Grad-CAM tab and we have no real heatmap
// (ONNX-only deploy, ViT hybrid, etc.), show the unavailable
// placeholder instead of an empty image element.
const needsAvailability = (tabType === 'heatmap' || tabType === 'overlay');
if (needsAvailability && target && target.dataset.available === 'false') {
const placeholder = document.getElementById('vizPlaceholder');
if (placeholder) {
placeholder.style.display = 'flex';
placeholder.textContent = this._gradcamUnavailableReason
|| 'Grad-CAM unavailable for the selected model.';
}
return;
}
if (target) target.style.display = 'block';
}
// Handle comparison/details tab
if (btn.dataset.tab === 'details') {
this.showModelDetails();
} else if (btn.dataset.tab === 'comparison') {
this.showComparisonTable();
}
}
showComparisonTable() {
const content = document.getElementById('comparisonContent');
if (this.currentResults) {
content.innerHTML = `
<div class="comparison-table">
<table>
<thead>
<tr>
<th>Model</th>
<th>Prediction</th>
<th>Confidence</th>
<th>Accuracy</th>
<th>ROC AUC</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${this.currentResults.models.map(model => {
const fmt = (v) => (v == null || Number.isNaN(v)) ? 'N/A' : `${(v * 100).toFixed(1)}%`;
return `
<tr class="${model === this.currentResults.bestModel ? 'best' : ''}">
<td><strong>${model.modelLabel}</strong></td>
<td>${model.prediction}</td>
<td>${fmt(model.confidence)}</td>
<td>${fmt(model.accuracy)}</td>
<td>${fmt(model.auc)}</td>
<td>
<span class="status-badge ${model.status}">
${model.status === 'positive' ? 'Positive' : 'Negative'}
</span>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
}
showModelDetails() {
const content = document.getElementById('comparisonContent');
if (this.currentResults) {
content.innerHTML = `
<div style="display: grid; gap: 20px;">
${this.currentResults.models.map(model => {
const fmt = (v) => (v == null || Number.isNaN(v)) ? 'N/A' : `${(v * 100).toFixed(1)}%`;
return `
<div style="background: var(--gray-50); padding: 20px; border-radius: var(--radius-lg); border-left: 4px solid ${model === this.currentResults.bestModel ? 'var(--primary)' : 'var(--gray-300)'};">
<h4 style="margin-bottom: 12px; color: var(--gray-800);">${model.modelLabel}</h4>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px;">
<div>
<div style="font-size: 12px; color: var(--gray-500); margin-bottom: 4px;">Confidence</div>
<div style="font-size: 20px; font-weight: 700; color: var(--gray-800);">${fmt(model.confidence)}</div>
</div>
<div>
<div style="font-size: 12px; color: var(--gray-500); margin-bottom: 4px;">Accuracy</div>
<div style="font-size: 20px; font-weight: 700; color: var(--gray-800);">${fmt(model.accuracy)}</div>
</div>
<div>
<div style="font-size: 12px; color: var(--gray-500); margin-bottom: 4px;">ROC AUC</div>
<div style="font-size: 20px; font-weight: 700; color: var(--primary);">${fmt(model.auc)}</div>
</div>
</div>
<div style="margin-top: 12px;">
<div style="font-size: 12px; color: var(--gray-500); margin-bottom: 4px;">Prediction</div>
<span class="status-badge ${model.status}" style="font-size: 14px; padding: 6px 14px;">
${model.prediction}
</span>
</div>
</div>
`;
}).join('')}
</div>
`;
}
}
showSection(section) {
document.querySelectorAll('.content-section').forEach(s => {
s.classList.remove('active');
s.style.display = 'none';
});
const targetSection = document.getElementById(`${section}-section`);
if (targetSection) {
targetSection.classList.add('active');
targetSection.style.display = 'block';
}
}
// Inline toast for real informational events (e.g. "report exported").
// Replaces the previous "Coming Soon" placeholder which advertised
// unimplemented features.
showToast(title, description, level = 'info') {
const toast = document.createElement('div');
toast.className = `nl-toast nl-toast-${level}`;
toast.innerHTML = `
<strong class="nl-toast-title">${this.escapeHtml(title)}</strong>
<p class="nl-toast-desc">${this.escapeHtml(description || '')}</p>
`;
document.body.appendChild(toast);
setTimeout(() => { toast.classList.add('nl-toast-exit'); }, 3500);
setTimeout(() => { toast.remove(); }, 4000);
}
showLoading() {
document.getElementById('loadingOverlay').style.display = 'flex';
const uploadCard = document.getElementById('uploadCard');
if(uploadCard) uploadCard.classList.add('is-scanning');
}
hideLoading() {
document.getElementById('loadingOverlay').style.display = 'none';
const uploadCard = document.getElementById('uploadCard');
if(uploadCard) uploadCard.classList.remove('is-scanning');
}
/**
* Export the analysis as JSON. Includes the classifier results, the cascade
* segmentation decision, the full explanation payload (impression,
* structured findings, grade evidence, differential with citations,
* LLM-pass status), and the raw measured features. Sufficient to
* reproduce the on-screen report from the file alone.
*/
exportReport() {
if (!this.currentResults) {
this.showToast('No analysis to export', 'Run an analysis first.', 'error');
return;
}
const report = {
schema_version: '2.1',
patient_id: this.currentResults.patientId,
timestamp: this.currentResults.timestamp,
diagnosis: this.currentResults.diagnosis,
confidence: this.currentResults.confidence,
best_model: this.currentResults.bestModel?.modelLabel,
processing_time_seconds: this.currentResults.processingTime,
model_results: this.currentResults.models,
segmentation: this.currentSegmentation?.result || null,
explanation: this.currentExplanation || null,
};
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `trinetra_${this.currentResults.patientId}.json`;
a.click();
URL.revokeObjectURL(url);
this.showToast('Report exported', `${a.download} downloaded.`, 'success');
}
/**
* Open the browser print dialog scoped to the result panel.
* The print stylesheet hides the chrome (sidebar, top bar, controls,
* raw-features blob) and prints just the radiology-style report. The
* user picks "Save as PDF" in the print dialog for a portable file.
*/
printReport() {
if (!this.currentResults) {
this.showToast('No analysis to print', 'Run an analysis first.', 'error');
return;
}
window.print();
}
async loadMetrics() {
try {
const response = await fetch('/metrics');
if (response.ok) {
const metrics = await response.json();
console.log('Model metrics loaded:', metrics);
}
} catch (error) {
console.log('Metrics not available (development mode)');
}
}
/**
* Live /status polling: server returns real ONNX session count, GPU
* memory, LLM backend availability. Replaces the previous hard-coded
* "3/3 models, 4.2/8 GB, 2 pending" mock that was misleading.
*/
async loadStatus() {
const list = document.getElementById('systemStatusList');
const lastUpdated = document.getElementById('statusLastUpdated');
try {
const r = await fetch('/status', { headers: { 'Accept': 'application/json' } });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const s = await r.json();
const rows = [];
// Inference runtime row.
const ort = s.onnx_runtime || {};
const ortOk = !!ort.available;
const provider = (ort.providers || []).find(p => p.includes('CUDA')) ? 'CUDA' :
(ort.providers || []).find(p => p.includes('CPU')) ? 'CPU' : '-';
rows.push(`
<div class="status-item">
<span class="status-dot ${ortOk ? 'online' : 'offline'}"></span>
<span>Inference Runtime</span>
<span class="status-value">${ortOk ? `ONNX ${provider}` : 'PyTorch'}</span>
</div>`);
// Loaded sessions
rows.push(`
<div class="status-item">
<span class="status-dot online"></span>
<span>Loaded Sessions</span>
<span class="status-value">${ort.sessions_loaded ?? 0}</span>
</div>`);
// GPU memory (only when actually available)
const gpu = s.gpu || {};
if (gpu.available) {
const usedGb = ((gpu.memory_used_mb || 0) / 1024).toFixed(1);
const totalGb = ((gpu.memory_total_mb || 0) / 1024).toFixed(1);
const pct = gpu.memory_total_mb ? (gpu.memory_used_mb / gpu.memory_total_mb) * 100 : 0;
rows.push(`
<div class="status-item">
<span class="status-dot ${pct < 80 ? 'online' : 'warning'}"></span>
<span title="${this.escapeHtml(gpu.name || 'GPU')}">GPU Memory</span>
<span class="status-value">${usedGb} / ${totalGb} GB</span>
</div>`);
} else {
rows.push(`
<div class="status-item">
<span class="status-dot warning"></span>
<span>GPU</span>
<span class="status-value">CPU mode</span>
</div>`);
}
// Classifier weight readiness (count of present .onnx / .pt)
const cls = s.classifiers || {};
const clsCount = Object.values(cls).filter(c => c && (c.onnx || c.pt)).length;
rows.push(`
<div class="status-item">
<span class="status-dot ${clsCount >= 3 ? 'online' : 'warning'}"></span>
<span>Classifiers Ready</span>
<span class="status-value">${clsCount} / 3</span>
</div>`);
// Segmentation
const segs = s.segmentation_models || [];
const segCount = segs.filter(m => m.onnx || m.pt_size_mb).length;
rows.push(`
<div class="status-item">
<span class="status-dot ${segCount > 0 ? 'online' : 'offline'}"></span>
<span>Segmentation</span>
<span class="status-value">${segCount} model${segCount === 1 ? '' : 's'}</span>
</div>`);
// LLM backend availability
const llm = s.llm || {};
let llmStatus = 'deterministic only';
let llmDot = 'warning';
if (llm.hf_inference_token_present) { llmStatus = 'HF Inference'; llmDot = 'online'; }
else if (llm.anthropic_token_present) { llmStatus = 'Anthropic'; llmDot = 'online'; }
rows.push(`
<div class="status-item">
<span class="status-dot ${llmDot}"></span>
<span>LLM Explanation</span>
<span class="status-value">${llmStatus}</span>
</div>`);
if (list) list.innerHTML = rows.join('');
if (lastUpdated) {
const t = new Date();
lastUpdated.textContent = `updated ${t.getHours().toString().padStart(2,'0')}:${t.getMinutes().toString().padStart(2,'0')}`;
}
// Surface version + backend in the research hero and footer.
const heroVersion = document.getElementById('heroVersion');
const heroBackend = document.getElementById('heroBackend');
const footerVersion = document.getElementById('footerVersion');
const footerRuntime = document.getElementById('footerRuntime');
const footerLlm = document.getElementById('footerLlm');
if (heroVersion && s.version) heroVersion.textContent = `v${s.version.replace(/^v/, '')}`;
const backendLabel = llm.hf_inference_token_present
? 'LLM via HuggingFace Inference Providers'
: (llm.anthropic_token_present ? 'LLM via Anthropic' : 'Deterministic-only mode');
if (heroBackend) heroBackend.textContent = backendLabel;
if (footerVersion) footerVersion.textContent = `Version ${s.version || '--'}`;
if (footerRuntime) footerRuntime.textContent = `Runtime ${ortOk ? `ONNX ${provider}` : 'PyTorch'}`;
if (footerLlm) footerLlm.textContent = `LLM backend ${backendLabel}`;
} catch (err) {
if (list) {
list.innerHTML = `
<div class="status-item">
<span class="status-dot offline"></span>
<span>Server Unreachable</span>
<span class="status-value">--</span>
</div>`;
}
}
}
/**
* Session-scoped Recent Scans: pushes each finished analysis into the
* sidebar list. Survives only as long as the tab is open (no persistence)
* to keep the demo simple and avoid the misleading mock that was here.
*/
addRecentScan(entry) {
if (!this._recentScans) this._recentScans = [];
// Attach a snapshot of the current view state so clicking the item
// later can restore the full Results page (currentResults +
// currentSegmentation + currentExplanation + imageDataUrl). For
// batch entries the scan id starts with 'BATCH-' and is restored
// via viewBatchEntry() which already does the lookup.
if (!entry.snapshot && !String(entry.id || '').startsWith('BATCH-')) {
entry.snapshot = {
currentResults: this.currentResults,
currentSegmentation: this.currentSegmentation,
currentExplanation: this.currentExplanation || null,
imageDataUrl: this.imageDataUrl,
currentFile: this.currentFile,
};
}
this._recentScans.unshift(entry);
if (this._recentScans.length > 8) this._recentScans.length = 8;
this.renderRecentScans();
}
renderRecentScans() {
const el = document.getElementById('recentScansList');
if (!el) return;
const items = this._recentScans || [];
if (!items.length) {
el.innerHTML = '<div class="recent-empty">No scans yet. Upload an MRI to begin.</div>';
return;
}
el.innerHTML = items.map(s => {
const tumor = s.isPositive;
const ago = this.formatRelativeTime(s.timestamp);
return `
<div class="recent-item" data-scan-id="${this.escapeHtml(s.id)}" role="button" tabindex="0" title="Click to reopen this analysis">
<div class="recent-icon ${tumor ? 'tumor' : 'normal'}">${tumor ? 'T' : 'N'}</div>
<div class="recent-info">
<span class="recent-id">${this.escapeHtml(s.id)}</span>
<span class="recent-time">${ago}</span>
</div>
<span class="recent-status ${tumor ? 'positive' : 'negative'}">${tumor ? 'Tumor' : 'Normal'}</span>
</div>`;
}).join('');
// Bind clicks. We rebuild on every render so old handlers are
// dropped along with the old DOM.
el.querySelectorAll('.recent-item').forEach(node => {
const scanId = node.dataset.scanId;
const handler = () => this.viewRecentScan(scanId);
node.addEventListener('click', handler);
node.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handler();
}
});
});
}
/** Reopen a previously-analysed scan from the Recent Scans sidebar. */
viewRecentScan(scanId) {
if (!scanId) return;
// Batch entries restore through the existing viewBatchEntry path so
// we don't duplicate the snapshot-restore logic.
if (String(scanId).startsWith('BATCH-')) {
return this.viewBatchEntry(scanId);
}
const entry = (this._recentScans || []).find(s => s.id === scanId);
if (!entry || !entry.snapshot) {
this.showToast('Snapshot unavailable',
'This scan was added before the snapshot feature was wired up. Re-run the analysis to reopen it.', 'error');
return;
}
const snap = entry.snapshot;
if (snap.currentResults) this.currentResults = snap.currentResults;
if (snap.currentSegmentation) this.currentSegmentation = snap.currentSegmentation;
if (snap.currentExplanation !== undefined) this.currentExplanation = snap.currentExplanation;
if (snap.imageDataUrl) this.imageDataUrl = snap.imageDataUrl;
if (snap.currentFile) this.currentFile = snap.currentFile;
if (this.currentResults) {
this.displayResults();
}
}
formatRelativeTime(ms) {
const diff = Date.now() - ms;
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} min ago`;
return `${Math.floor(diff / 3_600_000)} h ago`;
}
setupNavigation() {
// Session-tracked Recent Scans bind themselves in addRecentScan().
// No mock click handlers needed; the items appear only after real runs.
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
getModelLabel(model) {
const labels = {
'cnn': 'CNN (Fast)',
'transfer': 'Transfer Learning',
'vit': 'Vision Transformer',
'attention_unet': 'Attention U-Net'
};
return labels[model] || model;
}
}
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.app = new TriNetraApp();
});
// --- Tri-Netra Enhancements (Patient Mode, Chat, Translation) ---
const translations = {
'en': {
'welcome': 'Welcome. Let\'s look at your scan.',
'upload_text': 'Upload your MRI scan below and our system will review it.',
'upload_btn': 'Upload MRI Scan',
'result_title': 'Result',
'risk_title': 'Risk Level',
'followup_title': 'Recommended Next Steps',
'chat_title': 'Have Questions?',
'chat_desc': 'Ask our AI assistant in simple language. (Note: AI cannot give medical advice).',
'chk_1': 'Scan analyzed by AI',
'chk_2': 'Download summary report',
'chk_3': 'Call doctor to schedule review',
'chk_4': 'Write down questions for doctor',
'btn_download': 'Download Report'
},
'hi': {
'welcome': 'नमस्ते। आइए आपके स्कैन को देखें।',
'upload_text': 'नीचे अपना MRI स्कैन अपलोड करें और हमारा सिस्टम इसकी जांच करेगा।',
'upload_btn': 'MRI स्कैन अपलोड करें',
'result_title': 'परिणाम',
'risk_title': 'जोखिम स्तर',
'followup_title': 'सुझाए गए अगले कदम',
'chat_title': 'कोई प्रश्न हैं?',
'chat_desc': 'हमारे AI से आसान भाषा में पूछें। (ध्यान दें: AI चिकित्सा सलाह नहीं दे सकता)।',
'chk_1': 'AI द्वारा स्कैन का विश्लेषण किया गया',
'chk_2': 'संक्षिप्त रिपोर्ट डाउनलोड करें',
'chk_3': 'समीक्षा के लिए डॉक्टर को कॉल करें',
'chk_4': 'डॉक्टर के लिए प्रश्न लिखें',
'btn_download': 'रिपोर्ट डाउनलोड करें'
},
'pa': {
'welcome': 'ਜੀ ਆਇਆਂ ਨੂੰ। ਆਓ ਤੁਹਾਡੇ ਸਕੈਨ ਨੂੰ ਦੇਖੀਏ।',
'upload_text': 'ਹੇਠਾਂ ਆਪਣਾ MRI ਸਕੈਨ ਅਪਲੋਡ ਕਰੋ ਅਤੇ ਸਾਡਾ ਸਿਸਟਮ ਇਸਦੀ ਜਾਂਚ ਕਰੇਗਾ।',
'upload_btn': 'MRI ਸਕੈਨ ਅਪਲੋਡ ਕਰੋ',
'result_title': 'ਨਤੀਜਾ',
'risk_title': 'ਜੋਖਮ ਪੱਧਰ',
'followup_title': 'ਸੁਝਾਏ ਗਏ ਅਗਲੇ ਕਦਮ',
'chat_title': 'ਕੋਈ ਸਵਾਲ ਹਨ?',
'chat_desc': 'ਸਾਡੇ AI ਤੋਂ ਆਸਾਨ ਭਾਸ਼ਾ ਵਿੱਚ ਪੁੱਛੋ। (ਨੋਟ: AI ਡਾਕਟਰੀ ਸਲਾਹ ਨਹੀਂ ਦੇ ਸਕਦਾ)।',
'chk_1': 'AI ਦੁਆਰਾ ਸਕੈਨ ਦਾ ਵਿਸ਼ਲੇਸ਼ਣ ਕੀਤਾ ਗਿਆ',
'chk_2': 'ਰਿਪੋਰਟ ਡਾਊਨਲੋਡ ਕਰੋ',
'chk_3': 'ਡਾਕਟਰ ਨੂੰ ਕਾਲ ਕਰੋ',
'chk_4': 'ਡਾਕਟਰ ਲਈ ਸਵਾਲ ਲਿਖੋ',
'btn_download': 'ਰਿਪੋਰਟ ਡਾਊਨਲੋਡ ਕਰੋ'
}
};
window.currentPatientReport = null;
function selectRole(role) {
document.getElementById('landing-page').classList.add('hidden');
if (role === 'patient') {
document.getElementById('patient-view').style.display = 'block';
document.getElementById('doctor-view').style.display = 'none';
} else {
document.getElementById('doctor-view').style.display = 'grid';
document.getElementById('patient-view').style.display = 'none';
}
}
function goHome() {
document.getElementById('landing-page').classList.remove('hidden');
document.getElementById('patient-view').style.display = 'none';
document.getElementById('doctor-view').style.display = 'none';
}
function changeLanguage() {
const lang = document.getElementById('patient-lang').value;
const t = translations[lang];
document.getElementById('p-welcome').innerText = t.welcome;
document.getElementById('p-upload-text').innerText = t.upload_text;
document.getElementById('p-upload-btn').innerText = t.upload_btn;
document.getElementById('p-verdict-title').innerText = t.result_title;
document.getElementById('p-risk-title').innerText = t.risk_title;
document.getElementById('p-followup-title').innerText = t.followup_title;
document.getElementById('p-chat-title').innerText = t.chat_title;
document.getElementById('p-chat-desc').innerText = t.chat_desc;
document.getElementById('p-check-1').innerText = t.chk_1;
document.getElementById('p-check-2').innerText = t.chk_2;
document.getElementById('p-check-3').innerText = t.chk_3;
document.getElementById('p-check-4').innerText = t.chk_4;
document.getElementById('p-btn-download').innerText = t.btn_download;
}
// Attach event listener for Patient File Input
document.addEventListener('DOMContentLoaded', () => {
const pInput = document.getElementById('patientFileInput');
if (pInput) {
pInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
document.getElementById('patientUploadZone').style.display = 'none';
document.getElementById('patient-results').style.display = 'block';
document.getElementById('p-verdict-desc').innerText = 'Analyzing your scan... Please wait.';
const formData = new FormData();
formData.append('image', file);
try {
const response = await fetch('/explain', { method: 'POST', body: formData });
const data = await response.json();
window.currentPatientReport = data;
// Verdict
const vDesc = document.getElementById('p-verdict-desc');
const vCard = document.getElementById('patient-verdict-card');
if (data.verdict === 'no_tumor') {
vDesc.innerText = '✅ No abnormalities detected. Your scan appears clear.';
vCard.className = 'result-card status-negative';
} else {
vDesc.innerText = '⚠️ A region requiring medical review was detected. Do not panic, but please consult your doctor.';
vCard.className = 'result-card status-positive';
}
// Risk
const riskBadge = document.getElementById('patient-risk-badge');
const rDesc = document.getElementById('p-risk-desc');
const riskVal = data.risk_score || 0;
if (data.verdict === 'no_tumor') {
riskBadge.innerText = 'Low Risk';
riskBadge.style.backgroundColor = '#10b981';
rDesc.innerText = 'Routine checkups recommended.';
} else {
riskBadge.innerText = riskVal > 60 ? 'High Risk' : 'Medium Risk';
riskBadge.style.backgroundColor = riskVal > 60 ? '#ef4444' : '#f39c12';
rDesc.innerText = 'Medical review strongly advised.';
}
// Follow up
document.getElementById('p-followup-desc').innerText = data.follow_up || (data.verdict === 'no_tumor' ? 'Maintain a healthy lifestyle and schedule routine checkups.' : 'Please schedule an appointment with a neurologist or oncologist to review these scan results.');
} catch (err) {
document.getElementById('p-verdict-desc').innerText = 'Error analyzing scan. Please try again or ask your doctor.';
}
});
}
});
async function sendPatientChat() {
const input = document.getElementById('patientChatInput');
const msg = input.value.trim();
if (!msg) return;
const windowEl = document.getElementById('patientChatWindow');
windowEl.innerHTML += `<div class="chat-msg chat-user">${msg}</div>`;
input.value = '';
let context = 'No scan data uploaded yet.';
if (window.currentPatientReport) {
context = `Verdict: ${window.currentPatientReport.verdict}, Confidence: ${window.currentPatientReport.confidence}, Risk: ${window.currentPatientReport.risk_score}, Follow-up: ${window.currentPatientReport.follow_up}`;
}
try {
const res = await fetch('/patient_chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ message: msg, context: context })
});
const data = await res.json();
windowEl.innerHTML += `<div class="chat-msg chat-ai">${data.reply}</div>`;
windowEl.scrollTop = windowEl.scrollHeight;
} catch (e) {
windowEl.innerHTML += `<div class="chat-msg chat-ai">Sorry, the AI is offline right now.</div>`;
}
}
async function downloadPatientPDF() {
if (!window.currentPatientReport) {
alert("Please run an analysis first.");
return;
}
try {
const r = window.currentPatientReport;
const res = await fetch('/generate_pdf', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
confidence: r.confidence,
verdict: r.verdict,
volume_cm3: r.volume_cm3,
risk_level: r.verdict === 'no_tumor' ? 'Low Risk' : (r.risk_score > 60 ? 'High Risk' : 'Medium Risk'),
risk_score: r.risk_score,
follow_up: r.follow_up
})
});
if (!res.ok) throw new Error('PDF generation failed');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = 'Tri_Netra_Report.pdf';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
alert('Failed to generate PDF report. Please try again.');
}
}