Tri-Netra-AI / src /llm_explain.py
anannyavyas1's picture
Upload folder using huggingface_hub
1cf3825 verified
Raw
History Blame Contribute Delete
113 kB
"""LLM-backed explanation of the segmentation + classification output.
Takes (image, mask, overlay, classifier results, Grad-CAM, feature dict from
src.tumor_explainability) and asks a multimodal LLM to produce a structured
human-language explanation grounded in the numeric features.
Backend selection (first available wins):
1. Ollama - default. Set OLLAMA_HOST / OLLAMA_MODEL env vars to override.
Defaults: http://localhost:11434, model qwen2.5vl:7b
2. Anthropic - if ANTHROPIC_API_KEY is set
3. OpenAI - if OPENAI_API_KEY is set
4. None - returns a deterministic feature-grounded narrative without
calling any LLM. Still useful, just less narrative.
The output schema is intentionally fixed (top-level keys below) so the UI can
render predictable sections regardless of which backend produced it.
Output:
{
"backend": "ollama" | "anthropic" | "openai" | "none",
"model": "<model name>",
"summary": "<plain-language overview, 2-3 sentences>",
"findings": {
"geometry": "<...>",
"localization": "<...>",
"intensity": "<...>",
"texture": "<...>",
"multimodal": "<...>", # only when modality channels are known
},
"differential_diagnosis_hints": ["<bullet>", ...],
"model_agreement_analysis": "<...>",
"confidence_assessment": "<...>",
"disclaimer": "<...>",
"raw_features": {...} # the entire feature dict from tumor_explainability
}
"""
from __future__ import annotations
import base64
import io
import json
import os
from pathlib import Path
from typing import Optional
import numpy as np
from PIL import Image
try:
import requests
except ImportError:
requests = None
DEFAULT_OLLAMA_HOST = os.environ.get('OLLAMA_HOST', 'http://localhost:11434')
# Two model slots:
# OLLAMA_MODEL_TEXT - small text-only LLM for Pattern A (polish) and
# Pattern B (differential expansion). These tasks
# don't need vision and benefit from a smaller, more
# compliant model with low system-RAM footprint.
# Default qwen2.5:1.5b needs ~2 GiB host RAM.
# OLLAMA_MODEL_VISION - vision-language model for Pattern C (visual
# co-observer). Loaded only if it actually fits; if
# it OOMs, Pattern C falls back to "skipped (RAM)"
# without poisoning the rest of the pipeline.
# Default qwen2.5vl:3b needs ~7 GiB host RAM.
# OLLAMA_MODEL - legacy override, mapped to MODEL_VISION for
# backwards-compat with earlier configs.
DEFAULT_OLLAMA_MODEL = os.environ.get('OLLAMA_MODEL', 'qwen2.5vl:3b')
DEFAULT_OLLAMA_MODEL_TEXT = os.environ.get('OLLAMA_MODEL_TEXT', 'qwen2.5:1.5b')
DEFAULT_OLLAMA_MODEL_VISION = os.environ.get('OLLAMA_MODEL_VISION', DEFAULT_OLLAMA_MODEL)
DEFAULT_ANTHROPIC_MODEL = os.environ.get('ANTHROPIC_MODEL', 'claude-sonnet-4-6')
DEFAULT_OPENAI_MODEL = os.environ.get('OPENAI_MODEL', 'gpt-4o-mini')
# HuggingFace Inference Providers - routes to Groq / Together / Fireworks /
# Replicate / etc. under the hood, all hosting OPEN-WEIGHT models. One token
# (HF_TOKEN) authenticates against all providers. Free tier + small HF credit
# allowance per month makes this the right call for a public Spaces demo
# while keeping the LLM open source (we only call open weights like Llama 3.3
# and Qwen2.5-VL, never closed-weight Claude/GPT/Gemini).
#
# Two model slots to mirror the local Ollama text+vision split:
# HF_MODEL_TEXT - text-only LLM for Patterns A (polish) and B
# (differential expansion). Llama 3.3 70B by default;
# strictly better at JSON-schema compliance than the
# local qwen2.5:1.5b we use offline.
# HF_MODEL_VISION - vision-language LLM for Pattern C (visual co-observer).
# Llama 3.2 90B Vision by default; Qwen2.5-VL 72B
# (Qwen/Qwen2.5-VL-72B-Instruct) is also a strong pick.
DEFAULT_HF_ROUTER_BASE = os.environ.get(
'HF_INFERENCE_BASE', 'https://router.huggingface.co/v1'
)
DEFAULT_HF_MODEL_TEXT = os.environ.get(
'HF_MODEL_TEXT', 'meta-llama/Llama-3.3-70B-Instruct'
)
DEFAULT_HF_MODEL_VISION = os.environ.get(
# Gemma 3 27B IT is Google's open-weight multimodal model and is enabled
# by default on the HF Inference Providers router for the standard free
# tier. Llama 3.2 Vision and Qwen2.5-VL are higher quality on paper but
# are gated behind specific provider plans (HuggingFace Pro, Together
# paid tier, etc.). Override with HF_MODEL_VISION when you have access
# to a stronger vision model.
'HF_MODEL_VISION', 'google/gemma-3-27b-it'
)
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def _advisory_summary(advisory: dict) -> dict:
"""Boil the v9b advisory payload down to a flat dict the deterministic
narrative can quote verbatim. Every value here is measured at request
time — nothing is invented by the LLM. Used as part of the
zero-hallucination substrate.
"""
signal_states = []
for label, fired_key, value_key in (
('Pattern Detector', 'v9c_fired', 'v9c_p95'),
('Reconstruction Detector', 'andi_fired', 'andi_max'),
('Tumor Outline Drawer', 'v8_fired', 'v8_area_px'),
('Asymmetry Detector', 'symmetry_fired', 'symmetry_p95'),
):
fired = advisory.get(fired_key)
value = advisory.get(value_key)
if fired is None and value is None:
continue
signal_states.append({
'signal': label,
'fired': bool(fired) if fired is not None else None,
'value': value,
})
measured = advisory.get('measured_performance') or {}
return {
'verdict': advisory.get('verdict', 'no_tumor'),
'confidence': advisory.get('confidence', 'low'),
'operating_point': advisory.get('operating_point', 'balanced'),
'rule': advisory.get('rule', 'ensemble'),
'signals_used': advisory.get('signals_used', ''),
'signal_states': signal_states,
'review_recommended': bool(advisory.get('review_recommended', False)),
'measured_recall': measured.get('ood_recall'),
'measured_fpr': measured.get('ood_fpr'),
'measured_f1': measured.get('ood_f1'),
'cohort': measured.get('cohort', '246-sample OOD bench'),
}
def explain(
image_rgb: np.ndarray,
mask_bin: np.ndarray,
overlay_rgb: Optional[np.ndarray],
classifier_results: Optional[dict],
gradcam_rgb: Optional[np.ndarray],
features: dict,
*,
modality_channels: Optional[tuple[str, str, str]] = None,
backend: Optional[str] = None,
advisory: Optional[dict] = None,
) -> dict:
"""Layered explanation pipeline (zero-hallucination by construction).
Architecture:
Step 0 Deterministic narrative from `features`. Source of truth.
Every number and label here is measured. Returned as a key
`deterministic_report` so the UI can always show the verified
version side by side with anything the LLM contributes.
Step A LLM polish pass. The LLM is given the deterministic narrative
as the ONLY source of facts and asked to rephrase it in clean
radiology-style prose. Post-check: any number or anatomical
term in the polished prose must appear in the source. If not,
we fall back to the deterministic prose.
Step B LLM differential-expansion pass. The LLM is given the structured
features (no images) and asked to propose differential diagnoses,
each with a citation to specific feature key(s). Post-check: any
bullet without a verifiable citation is dropped.
Step C LLM visual co-observer pass. The LLM is given the original MRI
+ overlay and asked for purely-qualitative observations (e.g.
"rim brighter than core in superior aspect"). Each observation
is checked against the features dict; observations that
CONTRADICT a measurement go into `disagreements` rather than
into the findings, so the user can see the model conflict.
Skip behaviour:
- backend='none' -> run only Step 0. Zero-hallucination guarantee.
- backend='ollama'/'anthropic'/'openai' -> run 0 + A + B + C.
- Any LLM step that fails individually does NOT poison the pipeline;
the deterministic substrate is preserved.
"""
if backend is None:
backend = _pick_backend()
# ---- Step 0 - deterministic substrate ---------------------------------
deterministic = _local_narrative(features, classifier_results)
# Inject the 4-signal advisory verdict + signal-level firing breakdown
# into the deterministic substrate. This becomes part of the source of
# truth that the LLM polish pass is constrained to, so the generated
# prose can reference the ensemble rule without inventing it.
if advisory:
deterministic['ensemble_advisory'] = _advisory_summary(advisory)
backend_used = 'none'
model_used = 'deterministic'
llm_passes: dict = {
'polish': {'status': 'skipped'},
'differential_expansion': {'status': 'skipped'},
'visual_observer': {'status': 'skipped'},
}
# If no LLM, return the deterministic report verbatim.
if backend == 'none':
payload = _coerce_to_schema(deterministic, features, classifier_results=classifier_results)
payload['backend'] = 'none'
payload['model'] = 'deterministic'
payload['raw_features'] = features
payload['deterministic_report'] = deterministic
payload['llm_passes'] = llm_passes
payload['hallucination_safety'] = 'guaranteed_zero (no LLM was called)'
if advisory:
payload['ensemble_advisory'] = deterministic['ensemble_advisory']
return payload
# Short-circuit: when the classifier consensus is firmly NO_TUMOR (all
# three models agree at >=moderate confidence), there is no clinical
# finding to enrich. Calling the LLM here only risks it inventing a
# tumor narrative around the false-positive U-Net mask - which it
# frequently does even with our citation guards, because the polish
# prompt asks it to rephrase the impression. Skip all three LLM passes,
# return the verdict-aware deterministic report.
cls_verdict, cls_mean_p, cls_band = _classifier_consensus(classifier_results)
if cls_verdict == 'no_tumor' and cls_band in ('high', 'moderate'):
skip_reason = (
f'classifier_consensus_no_tumor (mean p={cls_mean_p:.3f}, '
f'{cls_band} confidence) - no clinical finding to enrich.'
)
# Patterns A (polish) and B (differential expansion) stay short-
# circuited - there's no clinical finding to polish or differentiate.
# Pattern C (general visual observer) is replaced with Pattern D
# below: a vision-LLM call with a NEGATIVE-specific prompt that asks
# the model to articulate which visible features in the actual MRI
# support the no-tumor verdict. This gives the user real reasoning
# for negative cases instead of an empty short-circuit.
for k in ('polish', 'differential_expansion'):
llm_passes[k] = {'status': 'short_circuited', 'reason': skip_reason}
# Build the image set we'll feed to Pattern D.
if backend == 'ollama':
image_set = [('original_mri', image_rgb), ('overlay', overlay_rgb)]
else:
image_set = [
('original_mri', image_rgb),
('predicted_mask', _mask_to_rgb(mask_bin)),
('overlay', overlay_rgb),
('gradcam', gradcam_rgb),
]
images_b64, image_labels = [], []
for label, arr in image_set:
if arr is None:
continue
try:
images_b64.append(_to_base64_png(arr))
image_labels.append(label)
except Exception:
pass
vision_negative_reasoning = None
vision_model_used = None
if backend == 'ollama':
vision_model_used = DEFAULT_OLLAMA_MODEL_VISION
elif backend == 'hf_inference':
vision_model_used = DEFAULT_HF_MODEL_VISION
elif backend == 'anthropic':
vision_model_used = DEFAULT_ANTHROPIC_MODEL
elif backend == 'openai':
vision_model_used = DEFAULT_OPENAI_MODEL
if images_b64 and backend != 'none':
try:
neg_prompt = _build_negative_visual_prompt(
cls_mean_p or 0.0, cls_band or 'moderate', classifier_results,
)
raw_neg = _call_backend_with_model(
backend, neg_prompt, images_b64, image_labels,
model_override=vision_model_used,
)
vision_negative_reasoning, neg_warnings = _validate_negative_visual_reasoning(
raw_neg
)
llm_passes['visual_observer'] = {
'status': 'ok_negative_reasoning' if vision_negative_reasoning else 'rejected',
'pattern': 'D (vision negative reasoning)',
'model': vision_model_used,
'reason': 'classifier consensus negative; vision LLM asked why the MRI looks healthy',
'raw_chars': len(raw_neg or ''),
'warnings': neg_warnings,
}
except Exception as exc:
err = f'{type(exc).__name__}: {exc}'
is_oom = 'system memory' in err.lower() or 'memory' in err.lower()
is_quota = 'quota' in err.lower() or '429' in err
llm_passes['visual_observer'] = {
'status': ('skipped_insufficient_ram' if is_oom
else 'skipped_quota_exhausted' if is_quota
else 'error'),
'pattern': 'D (vision negative reasoning)',
'model': vision_model_used,
'error': err,
}
else:
llm_passes['visual_observer'] = {
'status': 'short_circuited',
'pattern': 'D (vision negative reasoning)',
'reason': 'no images available or backend=none',
}
payload = _coerce_to_schema(deterministic, features, classifier_results=classifier_results)
payload['backend'] = backend
payload['model'] = (
f'deterministic + vision negative reasoning ({vision_model_used})'
if vision_negative_reasoning else
'deterministic (short-circuit: no-tumor consensus)'
)
payload['raw_features'] = features
payload['deterministic_report'] = deterministic
payload['llm_passes'] = llm_passes
payload['vision_negative_reasoning'] = vision_negative_reasoning
payload['hallucination_safety'] = (
'Patterns A and B short-circuited (no clinical finding to enrich). '
+ ('Pattern D (vision negative reasoning) called - output validated '
'for negation-consistency (no tumor / lesion / abnormal claims).'
if vision_negative_reasoning else
'No LLM was called.')
)
return payload
model_used = (os.environ.get('OLLAMA_MODEL', DEFAULT_OLLAMA_MODEL) if backend == 'ollama'
else f'{DEFAULT_HF_MODEL_TEXT} + {DEFAULT_HF_MODEL_VISION}' if backend == 'hf_inference'
else DEFAULT_ANTHROPIC_MODEL if backend == 'anthropic'
else DEFAULT_OPENAI_MODEL if backend == 'openai'
else 'deterministic')
backend_used = backend
# Build the image set once. Ollama gets only 2 to fit VRAM. HF Inference
# routes to cloud GPUs so the 4-image set is fine and gives the visual
# observer richer context.
image_set = ([('original_mri', image_rgb), ('overlay', overlay_rgb)]
if backend == 'ollama' else
[('original_mri', image_rgb),
('predicted_mask', _mask_to_rgb(mask_bin)),
('overlay', overlay_rgb),
('gradcam', gradcam_rgb)])
images_b64, image_labels = [], []
for label, arr in image_set:
if arr is None:
continue
try:
images_b64.append(_to_base64_png(arr))
image_labels.append(label)
except Exception:
pass
# ---- Split-model LLM strategy -----------------------------------------
# Pattern A (polish) + Pattern B (differential expansion) don't need
# vision - they reason over text and structured features. We send them
# together to a SMALL text-only LLM (qwen2.5:1.5b ~2 GiB host RAM),
# which fits even when the dashboard PyTorch stack is co-resident.
# Pattern C (visual co-observer) requires actual vision; we attempt the
# VL model only if it loads. If it OOMs, Pattern C cleanly reports
# "skipped (insufficient_ram)" and the rest of the pipeline is unaffected.
polished = None
llm_differentials: list[dict] = []
visual_observations: list[dict] = []
visual_disagreements: list[dict] = []
# Per-pass model selection. Each backend has its own text vs vision split
# so we can use a small fast model for prose patterns and a stronger
# vision-capable model for image co-observation.
if backend == 'ollama':
text_model = DEFAULT_OLLAMA_MODEL_TEXT
vision_model = DEFAULT_OLLAMA_MODEL_VISION
elif backend == 'hf_inference':
text_model = DEFAULT_HF_MODEL_TEXT
vision_model = DEFAULT_HF_MODEL_VISION
else:
# Anthropic / OpenAI pick one strong model that handles both text +
# vision. We keep the model name as model_used for reporting.
text_model = model_used
vision_model = model_used
# ---- Patterns A + B: text-only combined call --------------------------
try:
ab_prompt = _build_text_combined_prompt(deterministic, features)
# 3072 ctx for the text pass: the prompt includes the full features
# subset + citable keys + already-proposed differentials, which run
# ~1200 tokens. 1.5b qwen2.5 is happy at 3072. Text-only KV growth is
# small enough not to OOM.
raw_ab = _call_backend_with_model(backend, ab_prompt, [], [],
model_override=text_model,
num_ctx_override=3072)
logger_msg = f'[llm_explain] text-pass model={text_model} raw_chars={len(raw_ab)}'
print(logger_msg, flush=True)
ab_parsed = _try_parse_json(raw_ab) or {}
# Polish
polish_raw = ab_parsed.get('polished_impression') or ''
polished_text, polish_warnings = _validate_polish(
polish_raw if isinstance(polish_raw, str) else '', deterministic, features
)
llm_passes['polish'] = {
'status': ('ok' if polished_text else ('rejected' if polish_warnings else 'empty')),
'model': text_model,
'raw_chars': len(polish_raw) if isinstance(polish_raw, str) else 0,
'warnings': polish_warnings,
}
if polished_text:
polished = polished_text
# Differential expansion
diff_items = ab_parsed.get('additional_differentials') or []
diff_wrapped = json.dumps({'differentials': diff_items})
accepted, rejected = _validate_differentials(diff_wrapped, features)
llm_differentials = accepted
llm_passes['differential_expansion'] = {
'status': 'ok',
'model': text_model,
'accepted_count': len(accepted),
'rejected_count': len(rejected),
'rejected_bullets': rejected,
'raw_chars': len(json.dumps(diff_items)),
}
except Exception as exc:
err = f'{type(exc).__name__}: {exc}'
llm_passes['polish'] = {'status': 'error', 'error': err, 'model': text_model}
llm_passes['differential_expansion'] = {'status': 'error', 'error': err,
'model': text_model}
# ---- Pattern C: vision call (best-effort, falls back on OOM/quota) ----
try:
if not images_b64:
llm_passes['visual_observer'] = {'status': 'skipped',
'reason': 'no_images_provided'}
else:
visual_prompt = _build_visual_prompt(features)
raw_vis = _call_backend_with_model(
backend, visual_prompt, images_b64, image_labels,
model_override=vision_model,
)
observations, disagreements = _validate_visual_observations(raw_vis, features)
visual_observations = observations
visual_disagreements = disagreements
llm_passes['visual_observer'] = {
'status': 'ok',
'model': vision_model,
'observation_count': len(observations),
'disagreement_count': len(disagreements),
'raw_chars': len(raw_vis or ''),
}
except Exception as exc:
err = f'{type(exc).__name__}: {exc}'
is_oom = 'system memory' in err.lower() or 'memory' in err.lower()
is_quota = 'quota' in err.lower() or '429' in err
if is_oom:
status, hint = 'skipped_insufficient_ram', (
'Close apps to free RAM for the local vision model, '
'or switch to HF Inference by setting HF_TOKEN.'
)
elif is_quota:
status, hint = 'skipped_quota_exhausted', (
'HF Inference free-tier quota reached. Add HF Pro or fall back '
'to local Ollama by unsetting HF_TOKEN.'
)
else:
status, hint = 'error', None
llm_passes['visual_observer'] = {
'status': status,
'error': err,
'model': vision_model,
'recovery_hint': hint,
}
# ---- Assemble final payload ------------------------------------------
final = dict(deterministic) # start from the source of truth
if polished:
# Use polished prose for the user-facing summary; keep raw measurement
# text alongside for verification.
final['impression'] = polished
final['summary'] = polished
# Merge LLM differentials AFTER the rule-based ones; tag origin.
for d in (final.get('differential_with_citations') or []):
d.setdefault('origin', 'rule-based')
for d in llm_differentials:
d['origin'] = 'llm-citation-checked'
final.setdefault('differential_with_citations', []).append(d)
final['differential_diagnosis_hints'] = [
d['statement'] for d in (final.get('differential_with_citations') or [])
]
final['visual_observations'] = visual_observations
final['visual_disagreements'] = visual_disagreements
payload = _coerce_to_schema(final, features, classifier_results=classifier_results)
payload['backend'] = backend_used
payload['model'] = model_used
payload['raw_features'] = features
payload['deterministic_report'] = deterministic
payload['llm_passes'] = llm_passes
payload['hallucination_safety'] = (
'deterministic substrate preserved; LLM-added content is citation-checked '
'and conflict-flagged. See llm_passes for per-pass results.'
)
if deterministic.get('ensemble_advisory'):
payload['ensemble_advisory'] = deterministic['ensemble_advisory']
return payload
def _call_backend_with_model(backend: str, prompt_text: str, images_b64: list,
image_labels: list, *, model_override: Optional[str] = None,
num_ctx_override: Optional[int] = None) -> str:
"""Dispatch helper that lets a per-pass model override take effect.
Ollama and HF Inference honor the override (text model vs vision model
split). Anthropic/OpenAI continue to use their single DEFAULT model since
those backends pick a strong general-purpose model regardless.
"""
if backend == 'ollama':
return _call_ollama(prompt_text, images_b64, image_labels,
model_override=model_override,
num_ctx_override=num_ctx_override)
if backend == 'hf_inference':
return _call_hf_inference(prompt_text, images_b64, image_labels,
model_override=model_override)
if backend == 'anthropic':
return _call_anthropic(prompt_text, images_b64, image_labels)
if backend == 'openai':
return _call_openai(prompt_text, images_b64, image_labels)
return ''
def _build_text_combined_prompt(deterministic: dict, features: dict) -> str:
"""Text-only prompt asking for Pattern A (polish) + Pattern B (differential
expansion) in a single JSON response. No images. Designed for a small
text-only LLM (e.g. qwen2.5:1.5b) that fits in ~2 GiB system RAM.
Output schema (strict JSON, no markdown):
{
"polished_impression": "<3-6 sentences rephrasing the impression>",
"additional_differentials": [
{"statement": "...", "supported_by": ["feature.key=value"], "confidence": "low|moderate|high"}
]
}
"""
impression = deterministic.get('impression') or deterministic.get('summary') or ''
findings = deterministic.get('findings') or {}
fields_text = '\n'.join(f'- {k}: {v}' for k, v in findings.items() if v)
rule_diffs = [d['statement'] for d in (deterministic.get('differential_with_citations') or [])]
rule_text = '\n'.join(f'- {b}' for b in rule_diffs) or '(none)'
feat_compact = _compact_features_for_diff(features)
citable = '\n'.join(sorted(_DIFFERENTIAL_CITABLE_KEYS))
# Include the ensemble advisory in the source-of-truth block so the
# polish pass can reference the verdict + which signals fired without
# inventing them. The strict rule already forbids adding facts not in
# the source, so quoting the advisory here is the only safe surface.
advisory_summary = deterministic.get('ensemble_advisory')
if advisory_summary:
advisory_block = (
"--- ENSEMBLE ADVISORY (measured at request time) ---\n"
f"{json.dumps(advisory_summary, indent=2, default=_safe_default)}\n"
)
else:
advisory_block = ""
return (
"You are a neuroradiology assistant. Produce one JSON object with two sections. "
"STRICT RULES:\n"
"1. Do NOT invent any number or finding not in the measured features.\n"
"2. Every numeric value you cite must match the features verbatim.\n"
"3. If a section cannot be filled in good faith, return an empty value.\n"
"4. Output a single JSON object - no markdown, no preamble.\n\n"
"SECTION 1 - 'polished_impression' (Pattern A):\n"
" - 3 to 6 sentences of radiology-style prose.\n"
" - Rephrase the SOURCE; do NOT add facts. Numbers must stay identical.\n"
" - If an ENSEMBLE ADVISORY block is present, your prose may quote\n"
" the verdict, confidence, operating_point, rule, and individual\n"
" signal fired-states verbatim — these are measurements, not opinions.\n\n"
"SECTION 2 - 'additional_differentials' (Pattern B):\n"
" - Up to 4 differentials NOT already in the already-proposed list.\n"
" - Each MUST cite a key from the citable whitelist as 'feature.key=value'.\n"
" - Schema per item: {statement, supported_by:[citations], "
"confidence: 'low'|'moderate'|'high'}.\n\n"
"--- SOURCE IMPRESSION ---\n"
f"{impression}\n"
"--- SOURCE FINDINGS ---\n"
f"{fields_text}\n"
f"{advisory_block}"
"--- ALREADY-PROPOSED DIFFERENTIALS (do not repeat) ---\n"
f"{rule_text}\n"
"--- CITABLE FEATURE KEYS (whitelist) ---\n"
f"{citable}\n"
"--- COMPACT MEASURED FEATURES ---\n"
f"{json.dumps(feat_compact, indent=2, default=_safe_default)}\n\n"
"Now return the single JSON object."
)
def _build_combined_prompt(deterministic: dict, features: dict) -> str:
"""Single prompt that asks for ALL three pattern outputs at once.
Output schema (strict JSON, no markdown):
{
"polished_impression": "<3-6 sentences rephrasing the impression>",
"additional_differentials": [
{"statement": "...", "supported_by": ["feature.key=value"], "confidence": "low|moderate|high"}
],
"visual_observations": [
{"region": "...", "observation": "...", "claimed_property": "intensity|uniformity|border|spatial"}
]
}
Each section is validated independently after we receive the response, so
a missing or malformed section degrades gracefully (e.g. polish empty ->
we just keep the deterministic impression; rejected differentials -> we
keep only the rule-based bullets).
"""
impression = deterministic.get('impression') or deterministic.get('summary') or ''
findings = deterministic.get('findings') or {}
fields_text = '\n'.join(f'- {k}: {v}' for k, v in findings.items() if v)
rule_diffs = [d['statement'] for d in (deterministic.get('differential_with_citations') or [])]
rule_text = '\n'.join(f'- {b}' for b in rule_diffs) or '(none)'
feat_compact = _compact_features_for_diff(features)
citable = '\n'.join(sorted(_DIFFERENTIAL_CITABLE_KEYS))
geom = features.get('geometry') or {}
loc = features.get('localization') or {}
return (
"You are a neuroradiology assistant. You will look at the brain MRI "
"image(s) provided and the measured features below, then produce a "
"single JSON object with three sections.\n\n"
"STRICT RULES (apply to every section):\n"
"1. Do NOT invent any number, location, or finding not present in the "
"measured features.\n"
"2. Every numeric value you cite must match the features verbatim.\n"
"3. If a section cannot be filled in good faith, return an empty value "
"for it. Do NOT make something up to satisfy the schema.\n"
"4. Output a single JSON object. No markdown, no preamble, no trailing "
"commentary.\n\n"
"SECTION 1 - 'polished_impression' (Pattern A: rephrase only):\n"
" - 3 to 6 sentences of clean radiology-style prose.\n"
" - Rephrase the SOURCE impression and findings below; do NOT add facts.\n"
" - Keep every numeric value identical to the source.\n\n"
"SECTION 2 - 'additional_differentials' (Pattern B: cite-or-drop):\n"
" - Up to 4 differentials NOT already in the already-proposed list.\n"
" - Each item MUST cite at least one key from the citable-keys list, "
"in the form 'feature.key=value'. Citations not in the list are rejected.\n"
" - Schema per item: {statement, supported_by:[citations], "
"confidence: 'low'|'moderate'|'high'}.\n\n"
"SECTION 3 - 'visual_observations' (Pattern C: image co-observer):\n"
" - Up to 4 short observations of what you VISUALLY see in the image.\n"
" - Restrict to: intensity (brighter/darker than surrounding brain), "
"uniformity (homogeneous/heterogeneous), border (sharp/diffuse), or "
"spatial relations.\n"
" - Schema per item: {region, observation, "
"claimed_property: 'intensity'|'uniformity'|'border'|'spatial'}.\n\n"
"--- SOURCE IMPRESSION ---\n"
f"{impression}\n"
"--- SOURCE FINDINGS ---\n"
f"{fields_text}\n"
"--- ALREADY-PROPOSED DIFFERENTIALS (do not repeat) ---\n"
f"{rule_text}\n"
"--- CITABLE FEATURE KEYS (whitelist) ---\n"
f"{citable}\n"
"--- COMPACT MEASURED FEATURES ---\n"
f"{json.dumps(feat_compact, indent=2, default=_safe_default)}\n"
"--- ANCHORS FOR ORIENTATION (don't restate as observations) ---\n"
f"hemisphere={loc.get('hemisphere', '?')}, "
f"lobe={loc.get('approximate_lobe_hint', '?')}, "
f"area_mm2={geom.get('area_mm2', 0):.0f}\n\n"
"Now return the single JSON object."
)
def _call_backend(backend: str, prompt_text: str, images_b64: list, image_labels: list) -> str:
"""Single dispatch point for the per-pass LLM calls."""
if backend == 'ollama':
return _call_ollama(prompt_text, images_b64, image_labels)
if backend == 'anthropic':
return _call_anthropic(prompt_text, images_b64, image_labels)
if backend == 'openai':
return _call_openai(prompt_text, images_b64, image_labels)
return ''
# ---------------------------------------------------------------------------
# Pattern A - Polish pass (rephrase only, no new facts)
# ---------------------------------------------------------------------------
def _build_polish_prompt(deterministic: dict) -> str:
impression = deterministic.get('impression') or deterministic.get('summary') or ''
findings = deterministic.get('findings') or {}
fields_text = '\n'.join(f'- {k}: {v}' for k, v in findings.items() if v)
return (
"You are a radiology editor. Your task is to REPHRASE the report below in clean, "
"concise, neutral radiology-style English suitable for a brain-MRI report. "
"STRICT RULES:\n"
"1. Do NOT introduce any new facts, numbers, anatomic locations, terms, or "
"differential diagnoses that are not already in the source.\n"
"2. Do NOT speculate. Do NOT 'sound more medical' by adding terminology not "
"warranted by the source.\n"
"3. Keep every numeric value verbatim (e.g. an area of '595 mm^2' must remain 595).\n"
"4. Output 3-6 sentences of plain prose. No bullet lists. No JSON. No preamble.\n\n"
"--- SOURCE REPORT ---\n"
f"Impression: {impression}\n"
f"Findings:\n{fields_text}\n"
"--- END SOURCE ---\n\n"
"Now write the polished prose."
)
def _validate_polish(raw: str, deterministic: dict, features: dict) -> tuple[str, list[str]]:
"""Return (polished_text, warnings).
Hallucination checks (rejection is a warning, returned as empty text):
1. Introduces a number absent from the source.
2. Names the contralateral hemisphere when source specified one.
3. Uses MRI modality vocabulary (T1, T2, T1c, FLAIR, DWI, ADC) when the
source has no multimodal section - common with single-channel RGB
inputs where the LLM 'sounds medical' by inventing sequence names.
4. Names a specific tumor type as a fact (e.g. 'glioma', 'meningioma',
'metastasis') in the polish - those belong in the differential,
not the impression rephrase.
5. Introduces a clinical action verb the source doesn't have
('biopsy is recommended', 'surgery indicated') - the recommendation
field is separately controlled by overall_confidence.
"""
if not raw or not isinstance(raw, str):
return '', ['empty_response']
text = raw.strip()
if not text:
return '', ['empty_response']
warnings: list[str] = []
src_text = ' '.join([
deterministic.get('impression') or '',
' '.join((deterministic.get('findings') or {}).values()),
deterministic.get('confidence_assessment') or '',
])
src_low = src_text.lower()
low = text.lower()
source_numbers = set(_extract_numbers(src_text))
response_numbers = set(_extract_numbers(text))
new_numbers = response_numbers - source_numbers
# Tolerate {0, 1} and very small ints that show up in counts.
new_numbers = {n for n in new_numbers if n not in (0.0, 1.0) and abs(n) > 0.0001}
if new_numbers:
warnings.append(f'introduced_new_numbers: {sorted(new_numbers)[:5]}')
# Hemisphere contradiction.
loc = (features.get('localization') or {})
hemi = (loc.get('hemisphere') or '').lower()
opp = 'left' if hemi == 'right' else ('right' if hemi == 'left' else '')
if hemi and opp and (f' {opp} hemisphere' in low or f' {opp}-sided' in low) and f' {hemi} hemisphere' not in low:
warnings.append(f'hemisphere_contradiction: source={hemi}, response_mentions={opp}')
# MRI modality terms invented when source has no multimodal data.
has_mm = (features.get('multimodal') is not None) and bool(features.get('multimodal'))
if not has_mm:
invented_mm: list[str] = []
# Patterns that strongly imply a specific MRI sequence we don't have.
modality_patterns = [
' t1 ', ' t1-', ' t2 ', ' t2-', ' t1c ', ' t1ce ',
' flair', ' dwi', ' adc', ' swi', ' mra ',
't1 sequence', 't2 sequence', 't1-weighted', 't2-weighted',
't1c-enhancing', 'gadolinium',
]
for p in modality_patterns:
if p in low and p not in src_low:
invented_mm.append(p.strip())
if invented_mm:
warnings.append(f'invented_modality_terms: {invented_mm}')
# Specific tumor types in the polish (belongs in differential, not impression).
typed_tumors = [
'glioblastoma', 'glioma', 'meningioma', 'metastasis', 'metastases',
'lymphoma', 'astrocytoma', 'oligodendroglioma', 'ependymoma',
'schwannoma', 'neurinoma', 'medulloblastoma', 'lipoma',
]
invented_dx = [t for t in typed_tumors if t in low and t not in src_low]
if invented_dx:
warnings.append(f'invented_diagnosis_terms_in_polish: {invented_dx}')
# Clinical action verbs that the source didn't authorise.
action_verbs = ['biopsy', 'surgery', 'surgical resection', 'radiation', 'chemotherapy']
invented_actions = [a for a in action_verbs if a in low and a not in src_low]
if invented_actions:
warnings.append(f'invented_clinical_actions: {invented_actions}')
if warnings:
return '', warnings
return text, []
def _extract_numbers(text: str) -> list[float]:
import re
return [float(m) for m in re.findall(r'-?\d+\.?\d*', text or '')]
# ---------------------------------------------------------------------------
# Pattern B - Differential expansion with citation checks
# ---------------------------------------------------------------------------
_DIFFERENTIAL_CITABLE_KEYS = {
'geometry.area_px', 'geometry.area_mm2', 'geometry.solidity', 'geometry.eccentricity',
'geometry.circularity', 'geometry.equivalent_diameter_px',
'localization.hemisphere', 'localization.approximate_lobe_hint', 'localization.depth_label',
'localization.midline_shift_suspected',
'components.n_components', 'components.multifocal',
'morphology.border_label', 'morphology.internal_intensity_zones',
'morphology.border_relative_to_brain', 'morphology.border_gradient_mean',
'internal_architecture.necrosis_like_fraction_single_channel',
'internal_architecture.rim_pattern_label',
'internal_architecture.rim_vs_core_intensity_ratio',
'internal_architecture.hyperdense_blob_count_inside_tumor',
'internal_architecture.hypodense_blob_count_inside_tumor',
'mass_effect.mass_effect_label', 'mass_effect.tumor_to_brain_area_ratio',
'mass_effect.brain_symmetry_iou',
'grade_evidence.score_0_to_1', 'grade_evidence.evidence_band',
'multimodal.t1c_enhancing_fraction', 'multimodal.t1c_predominantly_enhancing',
'multimodal.edema_likely', 'multimodal.edema_halo_ratio',
'multimodal.necrosis_likely', 'multimodal.t2_strongly_hyperintense',
'texture.heterogeneity_score', 'texture.shannon_entropy', 'texture.contrast',
}
def _build_differential_prompt(features: dict, deterministic: dict) -> str:
feat_compact = _compact_features_for_diff(features)
rule_diffs = [d['statement'] for d in (deterministic.get('differential_with_citations') or [])]
rule_text = '\n'.join(f'- {b}' for b in rule_diffs) or '(none)'
citable = '\n'.join(sorted(_DIFFERENTIAL_CITABLE_KEYS))
return (
"You are a neuroradiologist's assistant. Given the measured features of a "
"brain MRI lesion, propose UP TO 4 differential diagnoses we have not "
"already covered. STRICT RULES:\n"
"1. Each bullet MUST cite at least one feature key from the citable-keys "
"list below in the form 'feature_key=value' or 'feature_key (value)'.\n"
"2. Do NOT cite a key that is not in the list. Do NOT invent feature names.\n"
"3. Do NOT propose anything contradicted by the features.\n"
"4. If you cannot find a defensible additional differential, return an empty list.\n"
"5. Output JSON only, no Markdown, no preamble. Schema:\n"
' {"differentials": [{"statement": "...", "supported_by": ["feature.key=value", ...], "confidence": "low|moderate|high"}]}\n'
"\n--- ALREADY-PROPOSED DIFFERENTIALS (do not repeat) ---\n"
f"{rule_text}\n"
"\n--- CITABLE FEATURE KEYS ---\n"
f"{citable}\n"
"\n--- MEASURED FEATURES (subset) ---\n"
f"{json.dumps(feat_compact, indent=2, default=_safe_default)}\n"
"\nReturn JSON only."
)
def _compact_features_for_diff(features: dict) -> dict:
"""Just the subset of features used by the differential pass — keeps the
prompt small enough for the local model's context window."""
keep = {}
for top, sub in [
('geometry', ['area_mm2', 'area_px', 'solidity', 'eccentricity', 'circularity']),
('localization', ['hemisphere', 'approximate_lobe_hint', 'depth_label', 'midline_shift_suspected']),
('components', ['n_components', 'multifocal']),
('morphology', ['border_label', 'internal_intensity_zones', 'border_relative_to_brain']),
('internal_architecture', ['necrosis_like_fraction_single_channel', 'rim_pattern_label',
'rim_vs_core_intensity_ratio', 'hyperdense_blob_count_inside_tumor',
'hypodense_blob_count_inside_tumor']),
('mass_effect', ['mass_effect_label', 'tumor_to_brain_area_ratio', 'brain_symmetry_iou']),
('grade_evidence', ['score_0_to_1', 'evidence_band']),
('multimodal', ['t1c_enhancing_fraction', 'edema_likely', 'edema_halo_ratio',
'necrosis_likely', 't2_strongly_hyperintense', 't1c_predominantly_enhancing']),
('texture', ['heterogeneity_score', 'shannon_entropy', 'contrast']),
]:
if top in features and isinstance(features[top], dict):
sub_d = {k: features[top].get(k) for k in sub if k in features[top]}
if sub_d:
keep[top] = sub_d
return keep
def _validate_differentials(raw: str, features: dict) -> tuple[list[dict], list[dict]]:
"""Return (accepted, rejected_with_reasons).
Reject if: not valid JSON, missing supported_by, citation key not in
citable-set, or the cited fact contradicts the features.
"""
if not raw or not isinstance(raw, str):
return [], []
parsed = _try_parse_json(raw)
if not isinstance(parsed, dict):
return [], [{'reason': 'non_json_response', 'raw_preview': raw[:200]}]
items = parsed.get('differentials') or parsed.get('items') or []
if not isinstance(items, list):
return [], [{'reason': 'differentials_not_a_list', 'raw_preview': raw[:200]}]
accepted: list[dict] = []
rejected: list[dict] = []
for item in items:
if not isinstance(item, dict):
rejected.append({'reason': 'item_not_object', 'item': str(item)[:200]})
continue
stmt = str(item.get('statement') or '').strip()
cites = item.get('supported_by') or []
if not stmt:
rejected.append({'reason': 'empty_statement', 'item': item})
continue
if not isinstance(cites, list) or not cites:
rejected.append({'reason': 'no_supported_by', 'item': item})
continue
# Validate each citation against the citable-set + actual value.
bad_cites = []
for c in cites:
c_str = str(c)
# Pull the "feature.key" portion (left of '=' or '(').
key_part = c_str.split('=')[0].split('(')[0].strip()
if key_part not in _DIFFERENTIAL_CITABLE_KEYS:
bad_cites.append({'cite': c_str, 'why': 'key_not_in_citable_set'})
continue
# Verify the cited value matches features (when a value is given).
if not _citation_consistent_with_features(c_str, features):
bad_cites.append({'cite': c_str, 'why': 'value_inconsistent_with_features'})
if bad_cites:
rejected.append({'reason': 'citation_check_failed', 'bad_cites': bad_cites, 'item': item})
continue
accepted.append({
'statement': stmt,
'supported_by': [str(c) for c in cites],
'confidence': str(item.get('confidence', 'n/a')),
})
return accepted, rejected
def _citation_consistent_with_features(cite_str: str, features: dict) -> bool:
"""Check that a citation like 'morphology.border_label=sharp / well-circumscribed'
matches what's actually in features. If the citation gives a key only (no
'=value') we accept it as long as the key exists.
"""
if '=' not in cite_str:
key = cite_str.split('(')[0].strip()
top, _, sub = key.partition('.')
return isinstance(features.get(top), dict) and sub in features[top]
key, _, value_raw = cite_str.partition('=')
key = key.strip()
top, _, sub = key.partition('.')
if not isinstance(features.get(top), dict) or sub not in features[top]:
return False
actual = features[top][sub]
value_raw = value_raw.strip().strip('"').strip("'").rstrip(')')
# Numeric: tolerate 5% deviation to absorb formatting differences.
try:
actual_f = float(actual)
cited_f = float(value_raw)
return abs(actual_f - cited_f) <= max(0.05 * abs(actual_f), 0.05)
except (TypeError, ValueError):
pass
# Boolean.
if isinstance(actual, bool):
return value_raw.lower() in ('true', 'false') and (value_raw.lower() == 'true') == actual
# String compare (case-insensitive, allow prefix match).
actual_s = str(actual).lower()
cited_s = value_raw.lower()
return actual_s.startswith(cited_s) or cited_s.startswith(actual_s) or actual_s == cited_s
# ---------------------------------------------------------------------------
# Pattern C - Visual co-observer with conflict detection
# ---------------------------------------------------------------------------
def _build_visual_prompt(features: dict) -> str:
geom = features.get('geometry') or {}
loc = features.get('localization') or {}
return (
"You are a visual co-observer of a brain MRI. The lesion has been segmented "
"for you (look at the overlay image). DO NOT diagnose. ONLY describe what you "
"visually see inside or near the segmented region.\n\n"
"STRICT RULES:\n"
"1. Describe at most 4 observations. Each must be visually verifiable in "
"the overlay image you can see.\n"
"2. Each observation must be of the form: 'In the [region] of the lesion, "
"[signal observation]'. Stick to: signal intensity (bright/dark relative "
"to surrounding brain), uniformity (homogeneous/heterogeneous), border "
"(sharp/diffuse), and spatial relations.\n"
"3. If your observation appears to contradict a measurement, still report "
"it - we will flag it as a disagreement, not silently drop it.\n"
"4. Output JSON only. Schema:\n"
' {"observations": [{"region": "...", "observation": "...", "claimed_property": "intensity|uniformity|border|spatial"}]}\n'
"\n--- ALREADY-MEASURED ANCHORS (for orientation; do not restate) ---\n"
f"Hemisphere: {loc.get('hemisphere', 'unknown')}\n"
f"Approximate lobe: {loc.get('approximate_lobe_hint', 'unknown')}\n"
f"Area: {geom.get('area_mm2', 0):.0f} mm^2\n"
f"Border (measured): {(features.get('morphology') or {}).get('border_label', 'unknown')}\n"
f"Rim pattern (measured): {(features.get('internal_architecture') or {}).get('rim_pattern_label', 'unknown')}\n"
"\nReturn JSON only."
)
def _validate_visual_observations(raw: str, features: dict) -> tuple[list[dict], list[dict]]:
"""Cross-check each visual observation against features.
Returns (kept, disagreements). Kept observations are those compatible with
measurements. Disagreements are observations that contradict a measurement;
they're surfaced in their own UI section so the user sees the model conflict.
"""
if not raw or not isinstance(raw, str):
return [], []
parsed = _try_parse_json(raw)
if not isinstance(parsed, dict):
return [], [{'reason': 'non_json_response', 'raw_preview': raw[:200]}]
items = parsed.get('observations') or []
if not isinstance(items, list):
return [], [{'reason': 'observations_not_a_list', 'raw_preview': raw[:200]}]
kept: list[dict] = []
disagree: list[dict] = []
measured_border = (features.get('morphology') or {}).get('border_label', '').lower()
measured_rim = (features.get('internal_architecture') or {}).get('rim_pattern_label', '').lower()
measured_hemi = (features.get('localization') or {}).get('hemisphere', '').lower()
measured_uniform = (features.get('texture') or {}).get('heterogeneity_score', None)
for item in items:
if not isinstance(item, dict):
continue
obs = str(item.get('observation') or '').strip()
region = str(item.get('region') or '').strip()
claim = str(item.get('claimed_property') or '').strip()
if not obs:
continue
record = {'observation': obs, 'region': region, 'claimed_property': claim}
low = obs.lower()
conflicts = []
# Border conflict.
if 'sharp' in low and 'ill-defined' in measured_border:
conflicts.append(f'border (measured: "{measured_border}")')
if ('diffuse' in low or 'ill-defined' in low) and 'sharp' in measured_border:
conflicts.append(f'border (measured: "{measured_border}")')
# Rim/homogeneity conflict.
if 'homogeneous' in low and 'rim-enhancing' in measured_rim:
conflicts.append(f'rim_pattern (measured: "{measured_rim}")')
if 'rim' in low and 'homogeneous' in measured_rim:
conflicts.append(f'rim_pattern (measured: "{measured_rim}")')
# Heterogeneity conflict.
if isinstance(measured_uniform, (int, float)):
if 'homogeneous' in low and measured_uniform > 0.40:
conflicts.append(f'texture (measured heterogeneity_score={measured_uniform:.2f}, high)')
if 'heterogeneous' in low and measured_uniform < 0.15:
conflicts.append(f'texture (measured heterogeneity_score={measured_uniform:.2f}, low)')
# Hemisphere conflict.
if measured_hemi == 'right' and ('left hemisphere' in low or 'left side' in low):
conflicts.append(f'hemisphere (measured: "{measured_hemi}")')
if measured_hemi == 'left' and ('right hemisphere' in low or 'right side' in low):
conflicts.append(f'hemisphere (measured: "{measured_hemi}")')
if conflicts:
record['conflicts_with'] = conflicts
disagree.append(record)
else:
kept.append(record)
return kept, disagree
# ---------------------------------------------------------------------------
# Pattern D - Vision reasoning for NEGATIVE (no-tumor) cases
# ---------------------------------------------------------------------------
# Words whose appearance in an LLM "this is healthy" response would
# directly contradict the negative verdict and is therefore stripped /
# rejected. Includes tumour synonyms and pathological qualifiers.
_NEGATIVE_FORBIDDEN_TERMS = {
'tumor', 'tumour', 'lesion', 'mass', 'neoplasm', 'neoplastic',
'malignant', 'malignancy', 'metastasis', 'metastatic',
'glioma', 'glioblastoma', 'astrocytoma', 'meningioma',
'oligodendroglioma', 'ependymoma', 'schwannoma',
'cancerous', 'cancer', 'edema', 'oedema',
'necrosis', 'necrotic', 'enhancement', 'enhancing',
}
def _build_negative_visual_prompt(mean_p: float, band: str,
classifier_results: Optional[dict]) -> str:
"""Prompt the vision LLM to articulate why a healthy brain looks healthy.
Crucial framing: the verdict is *already established* (CNN, Transfer, ViT
all agree at very low tumor probability). We are NOT asking the LLM to
re-classify - we are asking it to look at the image and describe the
anatomical features of normalcy that are consistent with the verdict, AND
flag any normal-anatomy structures that could be confused for tumor by a
naive reader. This is the value-add for the user on negative cases.
"""
probs = []
for n in ('cnn', 'transfer', 'vit'):
r = (classifier_results or {}).get(n) or {}
p = r.get('probability')
if isinstance(p, (int, float)):
probs.append(f'{n}={p:.4f}')
per_model_line = ', '.join(probs) if probs else 'unknown'
return (
"Three independent classifiers (CNN, Transfer-Learning ResNet50, "
"Vision Transformer hybrid) have ALREADY ruled out tumor on this MRI "
f"at {band} confidence (per-model probabilities: {per_model_line}; "
f"mean p={mean_p:.4f}). The U-Net segmentation may still show a small "
"mask because it was trained on tumor-positive patients only and has "
"a positive bias - the mask is a known false positive.\n\n"
"Your task: looking at the actual MRI image(s) provided, describe "
"WHAT YOU SEE that is consistent with the no-tumor verdict.\n\n"
"STRICT RULES:\n"
"1. DO NOT re-diagnose. The verdict is final. Do not write the word "
"tumor, tumour, lesion, mass, neoplasm, malignant, metastasis, "
"glioma, edema, necrosis, or enhancement except inside the explicit "
"'potential_confounders' field (where you may use them only to say "
"'this is NOT a tumor, it is the normal X structure').\n"
"2. Describe ANATOMICAL NORMALCY: bilateral symmetry, normal ventricle "
"size and shape, intact grey-white differentiation, absence of mass "
"effect, no midline shift, no abnormal signal change.\n"
"3. Identify any normal anatomy that an untrained reader might "
"misinterpret as pathology (e.g. lateral ventricle CSF signal, "
"choroid plexus, normal cortical sulci, vasculature).\n"
"4. Output strict JSON only - no markdown, no preamble. Schema:\n"
' {"healthy_features": ["short observation 1", "short observation 2", ...],\n'
' "potential_confounders": ["normal structure X that could be mistaken for Y", ...],\n'
' "overall_assessment": "1-2 sentence plain-English summary"}\n'
"5. Keep each list item under 200 characters. Up to 5 items per list.\n\n"
"Return the JSON object."
)
def _validate_negative_visual_reasoning(raw: str) -> tuple[Optional[str], list]:
"""Validate the Pattern-D vision response on a negative case.
Returns (rendered_text, warnings) where:
- rendered_text is a multi-section plain-text block ready to drop into
the report, or None if the response was unusable.
- warnings is a list of strings describing dropped items / format
issues, surfaced in the llm_passes telemetry.
Sanitisation rules:
- Parse JSON; if not parseable, drop entire response.
- For healthy_features and overall_assessment, drop any item that
contains a forbidden term (tumor/lesion/mass/...). Those would
contradict the verdict.
- For potential_confounders, allow the forbidden terms because the
LLM has to name what the structure could be mistaken for in order
to clarify it's NOT that.
- If after sanitisation there are no healthy_features AND no
overall_assessment, return None.
"""
warnings: list = []
if not raw or not isinstance(raw, str):
return None, ['empty_response']
parsed = _try_parse_json(raw)
if not isinstance(parsed, dict):
return None, [f'non_json_response (first 200 chars: {raw[:200]})']
def _drop_forbidden(items, label):
kept = []
for it in items or []:
s = str(it).strip()
if not s:
continue
low = s.lower()
bad = [t for t in _NEGATIVE_FORBIDDEN_TERMS if t in low]
if bad:
warnings.append(f'{label}: dropped item using forbidden term(s) {bad}: {s[:80]}')
continue
kept.append(s)
return kept
healthy = _drop_forbidden(parsed.get('healthy_features'), 'healthy_features')
overall_raw = str(parsed.get('overall_assessment') or '').strip()
overall_low = overall_raw.lower()
if any(t in overall_low for t in _NEGATIVE_FORBIDDEN_TERMS):
warnings.append(f'overall_assessment: forbidden term, dropped')
overall = ''
else:
overall = overall_raw
# Confounders may legitimately reference forbidden terms when explaining
# "normal X may be mistaken for Y but is not Y". Lightly sanity-check
# each item is at least 8 chars to avoid empty noise.
confounders_in = parsed.get('potential_confounders') or []
confounders = [str(c).strip() for c in confounders_in if isinstance(c, (str,)) and len(str(c).strip()) >= 8]
if not healthy and not overall and not confounders:
return None, warnings + ['no_usable_content']
lines = []
if overall:
lines.append('OVERALL ASSESSMENT')
lines.append(overall)
lines.append('')
if healthy:
lines.append('FEATURES OF NORMALCY ON THIS MRI')
for h in healthy[:5]:
lines.append(f' - {h}')
lines.append('')
if confounders:
lines.append('NORMAL ANATOMY THAT COULD BE MISTAKEN FOR PATHOLOGY')
for c in confounders[:5]:
lines.append(f' - {c}')
lines.append('')
return '\n'.join(lines).strip(), warnings
# ---------------------------------------------------------------------------
# Backend selection
# ---------------------------------------------------------------------------
def _pick_backend() -> str:
"""Return the first usable backend, in this priority order:
1. HF Inference Providers (HF_TOKEN set) - open-weight models, no local
GPU needed, sub-second responses. The right default for the deployed
Spaces demo because we don't want to ship the Ollama daemon inside
the container (RAM-heavy) and we want to stay open source.
2. Local Ollama - the right default on a dev machine with a GPU.
3. Anthropic / OpenAI - last-resort closed-weight fallbacks the user
opted into by setting the corresponding API key.
4. 'none' - run the deterministic radiology report only (zero
hallucination, no external call).
"""
if os.environ.get('HF_TOKEN'):
return 'hf_inference'
if _ollama_reachable():
return 'ollama'
if os.environ.get('ANTHROPIC_API_KEY'):
return 'anthropic'
if os.environ.get('OPENAI_API_KEY'):
return 'openai'
return 'none'
def _ollama_reachable(timeout: float = 1.5) -> bool:
if requests is None:
return False
try:
r = requests.get(f'{DEFAULT_OLLAMA_HOST}/api/tags', timeout=timeout)
if r.status_code != 200:
return False
# Confirm the configured model is actually pulled, otherwise we'd 404 later.
tags = r.json().get('models', [])
names = {m.get('name', '') for m in tags}
return DEFAULT_OLLAMA_MODEL in names or any(n.startswith(DEFAULT_OLLAMA_MODEL.split(':')[0]) for n in names)
except Exception:
return False
# ---------------------------------------------------------------------------
# Prompt construction
# ---------------------------------------------------------------------------
SYSTEM_INSTRUCTIONS = """You are an AI assistant helping explain the output of an automated brain-MRI
tumor detection and segmentation system. The system has done all of the heavy
numeric work for you. Your job is to write a clear, grounded, plain-language
explanation that a clinically-literate but non-radiologist reader could
understand.
You receive:
- Up to 4 images: the original MRI, the predicted segmentation mask,
a coloured overlay, and a Grad-CAM heatmap.
- A structured feature dict full of measurements (geometry, intensity,
texture, anatomic localisation heuristics, and model behaviour).
- Per-model classifier probabilities.
Rules:
- Only state facts that are supported by the features or visibly true in the
images. Do NOT fabricate findings.
- When you cite a number, use the value from the features verbatim.
- When a heuristic is approximate (e.g. lobe quadrant), say so.
- Distinguish between deterministic measurements ('tumor area = 4,238 px',
'eccentricity = 0.74') and tentative inferences ('the high T1c-enhancing
fraction together with peritumoral FLAIR signal is consistent with...').
- Always include a disclaimer that the system is research-grade and not a
substitute for radiologist review.
Output a single JSON object with these top-level keys (no Markdown):
summary 2-3 sentence overview
findings.geometry one paragraph about size and shape
findings.localization one paragraph about location / hemisphere / lobe
findings.intensity one paragraph about how the tumor's intensity
compares to the surrounding brain
findings.texture one short paragraph about heterogeneity /
GLCM texture, or "Not enough information" if
the tumor was too small for those features
findings.multimodal one paragraph reading T1c / T2 / FLAIR signs
(enhancement, edema, necrosis). If the input
is not a known multimodal stack, write
"Single-channel input - multimodal cues
unavailable."
differential_diagnosis_hints array of 2-5 short bullets, each beginning
with the differential consideration and a
brief justification grounded in the features
(e.g. ["High-grade glioma - irregular shape
(solidity 0.71), edema halo, T1c necrotic
core"]). Mark uncertain ones with "(low
confidence)".
model_agreement_analysis one paragraph on how the CNN / Transfer / ViT
classifier outputs agreed, and how that
matches the segmentation
confidence_assessment one paragraph on overall trust in this output
(segmentation/classifier alignment, Grad-CAM
vs mask agreement, image quality)
disclaimer research-only disclaimer string
"""
def _build_prompt(features: dict, classifier_results: Optional[dict],
modality_channels: Optional[tuple[str, str, str]]) -> str:
feat_json = json.dumps(features, indent=2, default=_safe_default)
cls_json = json.dumps(classifier_results or {}, indent=2, default=_safe_default)
mod = f"\nMultimodal channels (R, G, B): {modality_channels}" if modality_channels else ''
return (
SYSTEM_INSTRUCTIONS
+ '\n\n--- FEATURE DICT ---\n'
+ feat_json
+ '\n\n--- CLASSIFIER RESULTS ---\n'
+ cls_json
+ mod
+ '\n\nReturn ONLY the JSON object, no preamble.'
)
def _safe_default(o):
if isinstance(o, (np.integer, np.int32, np.int64)):
return int(o)
if isinstance(o, (np.floating, np.float32, np.float64)):
return float(o)
if isinstance(o, np.ndarray):
return o.tolist()
return str(o)
# ---------------------------------------------------------------------------
# Backend implementations
# ---------------------------------------------------------------------------
def _call_ollama(prompt_text: str, images_b64: list[str], _image_labels: list[str],
*, model_override: Optional[str] = None,
num_ctx_override: Optional[int] = None) -> str:
if requests is None:
raise RuntimeError('requests not installed')
# num_ctx is held tight (2048) so qwen2.5vl:7b's KV cache fits next to our
# PyTorch segmentation/classifier models on an 8 GB card. Default 4096
# pushes VRAM need to ~8.6 GiB and gets rejected by Ollama. If the user
# exports OLLAMA_NUM_CTX we honor that override.
num_ctx = num_ctx_override or int(os.environ.get('OLLAMA_NUM_CTX', '1536'))
keep_alive = os.environ.get('OLLAMA_KEEP_ALIVE', '10m')
# num_gpu=999 forces Ollama to push EVERY layer of qwen2.5vl onto the GPU.
# Default Ollama splits layers across system RAM + GPU based on its memory
# estimate; on a 16 GB RAM / 8 GB VRAM laptop with PyTorch co-resident the
# split lands too RAM-heavy and the model can't fit (6.9 GiB needed, 6.9
# GiB free). Putting all layers on the GPU collapses system-RAM need to
# roughly the loading buffer (~1-2 GiB), which IS available, and uses the
# ~5 GiB of free VRAM we already have. Override with OLLAMA_NUM_GPU=N if
# you ever run on a card too small to hold all layers.
# num_gpu: only forward if user explicitly set the env var. Forcing a
# high value (e.g. 999) into the Ollama runtime for VL models breaks
# their memory layout planner ("memory layout cannot be allocated with
# num_gpu = 999"). Without the override Ollama picks a sensible split.
model_name = model_override or DEFAULT_OLLAMA_MODEL
options = {
'temperature': 0.2,
'num_predict': 1500,
'num_ctx': num_ctx,
}
if os.environ.get('OLLAMA_NUM_GPU'):
options['num_gpu'] = int(os.environ['OLLAMA_NUM_GPU'])
if os.environ.get('OLLAMA_KV_CACHE_TYPE'):
options['kv_cache_type'] = os.environ['OLLAMA_KV_CACHE_TYPE']
payload = {
'model': model_name,
'prompt': prompt_text,
'images': images_b64,
'stream': False,
'keep_alive': keep_alive,
'options': options,
}
r = requests.post(f'{DEFAULT_OLLAMA_HOST}/api/generate', json=payload, timeout=300)
if not r.ok:
# Surface Ollama's actual error JSON so the caller can see "model
# requires more system memory..." or similar capacity hints.
try:
err = r.json().get('error', r.text)
except Exception:
err = r.text
raise requests.HTTPError(f'Ollama /api/generate {r.status_code}: {err}', response=r)
return r.json().get('response', '').strip()
def _call_anthropic(prompt_text: str, images_b64: list[str], image_labels: list[str]) -> str:
if requests is None:
raise RuntimeError('requests not installed')
api_key = os.environ['ANTHROPIC_API_KEY']
content = []
for b64, label in zip(images_b64, image_labels):
content.append({
'type': 'image',
'source': {'type': 'base64', 'media_type': 'image/png', 'data': b64},
})
content.append({'type': 'text', 'text': f'(image labeled "{label}")'})
content.append({'type': 'text', 'text': prompt_text})
body = {
'model': DEFAULT_ANTHROPIC_MODEL,
'max_tokens': 2000,
'messages': [{'role': 'user', 'content': content}],
}
r = requests.post(
'https://api.anthropic.com/v1/messages',
json=body,
headers={
'x-api-key': api_key,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
timeout=120,
)
r.raise_for_status()
blocks = r.json().get('content', [])
return ''.join(b.get('text', '') for b in blocks if b.get('type') == 'text').strip()
def _call_openai(prompt_text: str, images_b64: list[str], image_labels: list[str]) -> str:
if requests is None:
raise RuntimeError('requests not installed')
api_key = os.environ['OPENAI_API_KEY']
content = [{'type': 'text', 'text': prompt_text}]
for b64, label in zip(images_b64, image_labels):
content.append({
'type': 'image_url',
'image_url': {'url': f'data:image/png;base64,{b64}', 'detail': 'high'},
})
content.append({'type': 'text', 'text': f'(image labeled "{label}")'})
body = {
'model': DEFAULT_OPENAI_MODEL,
'messages': [{'role': 'user', 'content': content}],
'max_tokens': 2000,
'temperature': 0.2,
}
r = requests.post(
'https://api.openai.com/v1/chat/completions',
json=body,
headers={'Authorization': f'Bearer {api_key}'},
timeout=120,
)
r.raise_for_status()
choices = r.json().get('choices', [])
if not choices:
return ''
return choices[0]['message']['content'].strip()
def _call_hf_inference(prompt_text: str, images_b64: list[str], image_labels: list[str],
*, model_override: Optional[str] = None) -> str:
"""Call HuggingFace Inference Providers (OpenAI-compatible router endpoint).
Auth: HF_TOKEN bearer. The router forwards to whichever underlying
provider currently hosts the model (Groq / Together / Fireworks / etc.),
giving us a single API key for many open-weight models.
Model selection:
- Caller passes `model_override` to choose text vs vision per pass.
- If no override, defaults to the text model (HF_MODEL_TEXT).
The router supports the OpenAI /chat/completions schema with image content
parts; we re-use the OpenAI message structure we already build for
_call_openai, with one tweak: images go in as data URLs.
"""
if requests is None:
raise RuntimeError('requests not installed')
token = os.environ.get('HF_TOKEN')
if not token:
raise RuntimeError('HF_TOKEN not set; cannot call HuggingFace Inference Providers.')
model = model_override or DEFAULT_HF_MODEL_TEXT
content: list[dict] = [{'type': 'text', 'text': prompt_text}]
for b64, label in zip(images_b64, image_labels):
content.append({
'type': 'image_url',
'image_url': {'url': f'data:image/png;base64,{b64}'},
})
content.append({'type': 'text', 'text': f'(image labeled "{label}")'})
body = {
'model': model,
'messages': [{'role': 'user', 'content': content}],
'max_tokens': int(os.environ.get('HF_MAX_TOKENS', '1500')),
'temperature': float(os.environ.get('HF_TEMPERATURE', '0.2')),
'stream': False,
}
r = requests.post(
f'{DEFAULT_HF_ROUTER_BASE}/chat/completions',
json=body,
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json',
},
timeout=120,
)
if not r.ok:
try:
err = r.json()
except Exception:
err = r.text
raise requests.HTTPError(
f'HF Inference {r.status_code} (model={model}): {str(err)[:400]}',
response=r,
)
payload = r.json()
choices = payload.get('choices', [])
if not choices:
return ''
return choices[0]['message']['content'].strip()
# ---------------------------------------------------------------------------
# Image helpers
# ---------------------------------------------------------------------------
_MAX_IMG_SIDE = int(os.environ.get('LLM_IMG_MAX_SIDE', '224'))
def _to_base64_png(arr: np.ndarray) -> str:
if arr.dtype != np.uint8:
a = np.asarray(arr)
if a.max() <= 1.0:
a = (a * 255.0)
arr = np.clip(a, 0, 255).astype(np.uint8)
if arr.ndim == 2:
arr = np.stack([arr, arr, arr], axis=-1)
# Cap longest side so each image consumes a bounded number of vision tokens
# in Qwen2.5-VL. Each extra 224px tile is ~32 patch tokens; capping at 224
# keeps a single image at one tile (~64 tokens) instead of 4-8 tiles.
h, w = arr.shape[:2]
max_side = max(h, w)
if max_side > _MAX_IMG_SIDE:
scale = _MAX_IMG_SIDE / max_side
new_w, new_h = max(1, int(w * scale)), max(1, int(h * scale))
arr = np.asarray(Image.fromarray(arr).resize((new_w, new_h), Image.BILINEAR))
buf = io.BytesIO()
Image.fromarray(arr).save(buf, format='PNG')
return base64.b64encode(buf.getvalue()).decode('utf-8')
def _mask_to_rgb(mask_bin: np.ndarray) -> np.ndarray:
m = np.asarray(mask_bin)
if m.ndim == 3:
m = m[..., 0]
if m.dtype != np.uint8:
m = (m > 0).astype(np.uint8) * 255
elif m.max() <= 1:
m = (m * 255).astype(np.uint8)
return np.stack([m, m, m], axis=-1)
# ---------------------------------------------------------------------------
# Output coercion + local fallback
# ---------------------------------------------------------------------------
_REQUIRED_KEYS = [
'summary', 'findings', 'differential_diagnosis_hints',
'model_agreement_analysis', 'confidence_assessment', 'disclaimer',
]
_FINDING_KEYS = ['geometry', 'localization', 'intensity', 'texture', 'multimodal']
_DEFAULT_DISCLAIMER = (
'This is an automated, research-grade output from a deep-learning system. '
'It is NOT a clinical diagnosis. All findings must be reviewed by a qualified '
'radiologist before any decision is made.'
)
def _coerce_to_schema(raw, features: dict, classifier_results: Optional[dict] = None) -> dict:
"""Normalize LLM / fallback output into the response schema.
`raw` may be:
- a dict (already-shaped local-narrative output), or
- a string (LLM response we need to JSON-parse).
Recovery ladder, weakest -> strongest:
1. String, no parseable JSON -> local narrative + raw text attached.
2. JSON parsed but missing our keys -> local narrative + raw text attached.
(Happens with small models like moondream that hallucinate a different
schema. We don't want to ship empty fields to the UI just because the
LLM ignored the instructions.)
3. JSON parsed with our keys present -> use it, fill missing keys with ''.
The recovery cases mark the response with `llm_followed_schema: False` so
callers can see the LLM was invoked but its output was not usable.
"""
raw_str_for_log = '' if isinstance(raw, dict) else str(raw)[:600]
if isinstance(raw, dict):
parsed = raw
else:
parsed = _try_parse_json(raw)
# Recovery: parse failed entirely.
if not isinstance(parsed, dict):
fallback = _local_narrative(features, classifier_results=classifier_results,
error_note=f'LLM did not return valid JSON. Raw: {raw_str_for_log[:300]}')
fallback['llm_followed_schema'] = False
fallback['raw_llm_text'] = raw_str_for_log
return fallback
# Did the LLM hit any of our schema keys?
has_schema_signal = (
bool(str(parsed.get('summary') or '').strip())
or isinstance(parsed.get('findings'), dict) and any(
str(v or '').strip() for v in parsed['findings'].values()
)
)
# Recovery: JSON came back but it's a different schema.
if not has_schema_signal and not isinstance(raw, dict):
fallback = _local_narrative(features, classifier_results=classifier_results,
error_note=f'LLM returned JSON in an unexpected shape. Raw: {raw_str_for_log[:300]}')
fallback['llm_followed_schema'] = False
fallback['raw_llm_text'] = raw_str_for_log
return fallback
out = dict(parsed)
if not isinstance(out.get('findings'), dict):
out['findings'] = {}
for k in _FINDING_KEYS:
out['findings'].setdefault(k, '')
for k in _REQUIRED_KEYS:
if k not in out:
out[k] = '' if k != 'differential_diagnosis_hints' else []
if not out['disclaimer']:
out['disclaimer'] = _DEFAULT_DISCLAIMER
if not isinstance(out['differential_diagnosis_hints'], list):
out['differential_diagnosis_hints'] = [str(out['differential_diagnosis_hints'])]
out['llm_followed_schema'] = True
return out
def _try_parse_json(raw: str):
s = raw.strip()
if s.startswith('```'):
s = s.strip('`')
# drop a leading 'json' tag if the model used a fenced block
if s.lower().startswith('json\n'):
s = s[5:]
elif s.lower().startswith('json '):
s = s[5:]
# Find the outermost JSON object.
start = s.find('{')
end = s.rfind('}')
if start < 0 or end <= start:
return None
candidate = s[start:end + 1]
try:
return json.loads(candidate)
except json.JSONDecodeError:
return None
def _build_classifier_negative_explanation(classifier_results: Optional[dict],
mean_p: Optional[float],
band: Optional[str],
geom: Optional[dict]) -> str:
"""Explain WHY the binary classifiers ruled out tumor on this scan.
Replaces what was previously a contradictory dump of tumor features
describing the U-Net's false-positive segmentation. The explanation
walks through the three architecture-diverse classifier outputs, why
inter-model agreement at this level is a strong negative signal, and
notes the U-Net positive bias that is responsible for the mask the
user still sees on the Visualization panel.
"""
if not classifier_results:
return ''
probs: dict = {}
for name in ('cnn', 'transfer', 'vit'):
r = classifier_results.get(name)
if isinstance(r, dict) and isinstance(r.get('probability'), (int, float)):
probs[name] = float(r['probability'])
if not probs:
return ''
arch_label = {
'cnn': 'CNN (custom 3-block convolutional)',
'transfer': 'Transfer Learning (ResNet50 pretrained on ImageNet, fine-tuned)',
'vit': 'Vision Transformer (ResNet50 + 4 transformer blocks, hybrid)',
}
per_model_lines = [
f'- {arch_label.get(n, n):<55} tumor probability {p:.4f} '
f'(equivalently {(1.0 - p) * 100:5.2f}% no-tumor confidence)'
for n, p in probs.items()
]
p_values = list(probs.values())
max_p = max(p_values) if p_values else 0.0
n_models = len(p_values)
band_phrase = (f'{band} confidence' if band else 'meaningful confidence')
summary = (
f'All {n_models} classifiers independently produced tumor probabilities below '
f'{max(0.5, max_p + 0.05):.2f} (mean p={mean_p:.4f}, {band_phrase}). '
f'These three models do not share an architecture, do not share a feature '
f'backbone, and were not trained with shared weights - they are three '
f'independent voters on the same question. Inter-model agreement at this '
f'level is a strong negative signal: a false negative would require all '
f'three architectures to fail in the same direction on the same input, '
f'which is empirically rare in our validation runs.'
)
bias_note = (
'The U-Net segmentation panel still shows a mask because the U-Net was '
'trained on BraTS + LGG (tumor-positive patients only). It has never seen '
'a healthy brain during training and has a positive bias: it always emits '
'a mask somewhere, picking up the strongest intensity edges in the input. '
'On a no-tumor scan that mask traces normal anatomy. The features measured '
'on that region (intensity, mass effect, grade evidence) are NOT '
'interpreted here because they are not measuring a tumor - they are '
'measuring a piece of normal anatomy that the U-Net mistook for one.'
)
if geom and geom.get('area_px'):
fp_note = (
f'For transparency, the false-positive region ({geom.get("area_px", 0):,} px) '
f'has been computed - those measurements are kept under '
f'"False-positive region analysis (debug)" below but are not '
f'interpreted as clinical findings.'
)
else:
fp_note = ''
return '\n\n'.join([
'WHY THE CLASSIFIERS RULED THIS OUT',
'Per-model tumor probability:',
'\n'.join(per_model_lines),
summary,
bias_note,
fp_note,
]).strip()
def _classifier_consensus(classifier_results: Optional[dict]) -> tuple:
"""Compute the binary-classifier majority verdict.
Returns (verdict, mean_probability, confidence_band):
verdict - 'tumor' | 'no_tumor' | 'mixed' | None
mean_probability - mean of per-model tumor probabilities, or None
confidence_band - 'high' | 'moderate' | 'low' | None
"""
if not classifier_results:
return None, None, None
probs = []
for _name, r in classifier_results.items():
if not isinstance(r, dict):
continue
p = r.get('probability')
if isinstance(p, (int, float)):
probs.append(float(p))
if not probs:
return None, None, None
mean_p = sum(probs) / len(probs)
all_above = all(p >= 0.5 for p in probs)
all_below = all(p <= 0.5 for p in probs)
if mean_p >= 0.7 and all_above:
band = 'high' if mean_p >= 0.9 else 'moderate'
return 'tumor', mean_p, band
if mean_p <= 0.3 and all_below:
band = 'high' if mean_p <= 0.1 else 'moderate'
return 'no_tumor', mean_p, band
return 'mixed', mean_p, 'low'
def _local_narrative(features: dict, classifier_results: Optional[dict],
error_note: Optional[str] = None) -> dict:
"""Structured radiology-style report grounded ONLY in measured features.
Zero invented facts: every sentence ties back to a key in `features`. When
no measurement supports a claim, the corresponding field is empty or marked
"not assessable". Used as: (a) the standalone deterministic output when the
LLM is skipped, and (b) the source-of-truth substrate the LLM is asked to
polish in the "interpretation" pass.
"""
geom = features.get('geometry') or {}
loc = features.get('localization') or {}
inten = features.get('intensity_per_channel') or {}
mm = features.get('multimodal') or {}
mb = features.get('model_behavior') or {}
morph = features.get('morphology') or {}
me = features.get('mass_effect') or {}
arch = features.get('internal_architecture') or {}
grade = features.get('grade_evidence') or {}
qual = features.get('quality') or {}
overall = features.get('overall_confidence') or {}
# ---- CLASSIFIER CONSENSUS (drives the impression framing) -------------
# The 3 binary classifiers vote on tumor / no-tumor. When they agree with
# high confidence, the segmentation mask should be interpreted IN LIGHT OF
# that vote - a small false-positive mask must not be narrated as if it
# were a real lesion, and an empty mask must not be narrated as if the
# classifiers found nothing either. Verdict thresholds:
# mean_p >= 0.7 with all 3 also >= 0.5: tumor (high if all >= 0.9)
# mean_p <= 0.3 with all 3 also <= 0.5: no_tumor (high if all <= 0.1)
# anything else: mixed/ambiguous
cls_verdict, cls_mean_p, cls_band = _classifier_consensus(classifier_results)
area_px = geom.get('area_px', 0) or 0
area_mm = geom.get('area_mm2', 0) or 0
# ---- IMPRESSION (one-line top of report, consensus-aware) -------------
if cls_verdict == 'no_tumor' and cls_mean_p is not None:
if area_px > 0:
impression = (
f'NO tumor detected. Per classifier consensus (mean tumor-probability '
f'{cls_mean_p:.3f}, {cls_band} confidence), this scan is interpreted as '
f'NEGATIVE. The U-Net segmentation produced a {area_px:,} px '
f'({area_mm:.0f} mm^2) region which, in the absence of any classifier '
f'support, is treated as a probable false positive and NOT a clinical '
f'finding.'
)
else:
impression = (
f'NO tumor detected. All three classifiers and the segmentation model '
f'agree (mean tumor-probability {cls_mean_p:.3f}, {cls_band} confidence).'
)
elif cls_verdict == 'tumor' and area_px == 0:
impression = (
f'CLASSIFIER-SEGMENTER DISAGREEMENT. Classifier consensus indicates tumor '
f'(mean probability {cls_mean_p:.3f}, {cls_band} confidence) but the '
f'segmentation model returned no mask. This is an ambiguous case; '
f'a radiologist review is required before any interpretation.'
)
elif area_px == 0:
impression = 'No segmentable tumor region was identified by the model on this image.'
else:
size_phrase = (f'a {area_mm:.0f} mm^2 lesion' if area_mm else f'a {area_px:,} px lesion')
loc_phrase = (f' in the {loc.get("hemisphere", "")} hemisphere'
+ (f', {loc.get("approximate_lobe_hint", "")}'
if loc.get('approximate_lobe_hint') else ''))
cls_phrase = (f' Classifier consensus: tumor ({cls_mean_p:.3f}, {cls_band} confidence).'
if cls_verdict == 'tumor'
else f' Classifier consensus: ambiguous (mean probability {cls_mean_p:.3f}).'
if cls_verdict == 'mixed'
else '')
confidence_phrase = (f' Overall model confidence {overall.get("band", "n/a")} '
f'({(overall.get("score_0_to_1") or 0):.2f}).')
impression = (f'Automated detection of {size_phrase}{loc_phrase}. '
f'Radiographic grade-evidence band: {grade.get("evidence_band", "n/a")}.'
f'{cls_phrase}{confidence_phrase}')
# ---- FINDINGS (structured per-domain) ---------------------------------
findings = {
'geometry': _narrate_geometry(geom),
'localization': _narrate_localization(loc),
'intensity': _narrate_intensity(inten),
'texture': _narrate_texture(features.get('texture') or {}),
'multimodal': _narrate_multimodal(mm),
'morphology_margins': _narrate_morphology(morph),
'internal_architecture': _narrate_architecture(arch),
'mass_effect': _narrate_mass_effect(me),
}
# ---- DIFFERENTIAL with explicit feature citations ---------------------
diff = _narrate_diff_with_citations(features)
# ---- GRADE EVIDENCE narrative (cite every component) ------------------
grade_narr = _narrate_grade(grade)
# ---- MODEL AGREEMENT + GRAD-CAM ---------------------------------------
model_agree = _narrate_model_agreement(mb)
# ---- QUALITY + CONFIDENCE ---------------------------------------------
confidence = _narrate_confidence_v2(overall, qual, mb, geom)
# Verdict-aware recommendation. The previous code always used
# overall_confidence.action_recommendation which is derived from the
# classifier mean / Grad-CAM alignment / quality - it ignored the binary
# verdict. When the classifiers say NO_TUMOR with high confidence, the
# recommendation must say so explicitly, not "findings are well supported".
if cls_verdict == 'no_tumor' and cls_band == 'high':
recommendation = (
f'No tumor reported. All classifiers agree (mean p={cls_mean_p:.3f}). '
f'No further action required from this output; correlate with clinical '
f'history if symptoms persist.'
)
elif cls_verdict == 'no_tumor':
recommendation = (
f'Classifier consensus is no-tumor (mean p={cls_mean_p:.3f}). Any small '
f'segmentation mask is treated as a probable false positive. Correlate '
f'with clinical history.'
)
elif cls_verdict == 'tumor' and area_px == 0:
recommendation = (
'Classifier-segmenter disagreement: classifiers report tumor but no mask '
'was generated. Radiologist review required before any clinical decision.'
)
else:
recommendation = overall.get('action_recommendation', '')
# When verdict is no_tumor, override the rule-based differential bullets
# entirely - the rule base infers from geometry only and would produce
# "infiltrative mass" bullets for a false-positive segmentation. The LLM
# passes downstream are also short-circuited (see explain()).
if cls_verdict == 'no_tumor' and cls_band in ('high', 'moderate'):
diff = [{
'statement': (
f'No tumor. Classifier consensus is negative '
f'(mean p={cls_mean_p:.3f}, {cls_band} confidence). '
f'No differential diagnosis applies; do not interpret the segmentation '
f'mask as a lesion.'
),
'supported_by': ['model_behavior.per_model_probabilities'],
'confidence': cls_band,
}]
# The original deterministic narrative treated every U-Net mask as a
# tumor and produced 8 structured-findings sections + grade-evidence
# describing it. When the classifier consensus is clearly no-tumor that
# reads as a contradictory report (Impression: "no tumor", then 800 words
# of "the tumor is hyperintense, mass effect substantial, grade-evidence
# 0.79..."). Split those sections into two buckets:
# - findings / grade_evidence_narrative: shown as the primary report.
# For no-tumor verdicts these are EMPTY because there is nothing
# clinical to describe.
# - fp_region_analysis / fp_grade_evidence: the raw feature breakdown of
# the false-positive segmentation, preserved verbatim under a
# "false-positive region debug" collapsible in the UI so the data
# isn't lost but is clearly labelled as not-clinical.
# - classifier_negative_explanation: NEW prose section explaining why
# the classifiers decided no-tumor (per-model probs + agreement
# reasoning + note on the U-Net positive bias). Replaces the
# contradictory tumor-feature dump with the actual rationale.
classifier_negative_explanation = None
fp_region_analysis = None
fp_grade_evidence = None
if cls_verdict == 'no_tumor' and cls_band in ('high', 'moderate'):
fp_region_analysis = dict(findings)
fp_grade_evidence = grade_narr
findings = {}
grade_narr = ''
classifier_negative_explanation = _build_classifier_negative_explanation(
classifier_results, cls_mean_p, cls_band, geom,
)
out = {
'summary': impression,
'impression': impression,
'findings': findings,
'differential_diagnosis_hints': [d['statement'] for d in diff],
'differential_with_citations': diff,
'grade_evidence_narrative': grade_narr,
'model_agreement_analysis': model_agree,
'confidence_assessment': confidence,
'recommendation': recommendation,
'classifier_consensus': {
'verdict': cls_verdict,
'mean_probability': cls_mean_p,
'confidence_band': cls_band,
},
'classifier_negative_explanation': classifier_negative_explanation,
'fp_region_analysis': fp_region_analysis,
'fp_grade_evidence': fp_grade_evidence,
'quality_warnings': qual.get('quality_warnings', []),
'disclaimer': _DEFAULT_DISCLAIMER,
}
if error_note:
out['_note'] = error_note
return out
def _narrate_morphology(morph: dict) -> str:
if not morph or 'note' in morph:
return morph.get('note', 'Morphology not assessable.')
parts = []
if 'border_label' in morph:
parts.append(
f'Border definition: {morph["border_label"]} '
f'(border gradient {morph.get("border_gradient_mean", 0):.1f} vs '
f'brain-average gradient {morph.get("brain_gradient_mean", 0):.1f}, '
f'relative {morph.get("border_relative_to_brain", 0):.2f}).'
)
zones = morph.get('internal_intensity_zones', 0)
if zones:
parts.append(
f'Internal intensity zones detected by k-means: {zones} '
f'(centers at {", ".join(f"{c:.0f}" for c in morph.get("internal_intensity_cluster_means", []))}).'
)
return ' '.join(parts) if parts else 'Morphology not assessable.'
def _narrate_architecture(arch: dict) -> str:
if not arch or 'note' in arch:
return arch.get('note', 'Internal architecture not assessable.')
parts = []
nec = arch.get('necrosis_like_fraction_single_channel')
if nec is not None:
parts.append(
f'Necrosis-like fraction (single-channel) = {nec:.2f} of pixels in the tumor are markedly '
f'darker than the tumor median. Values >0.20 are suggestive of a necrotic core.'
)
rim = arch.get('rim_vs_core_intensity_ratio')
if rim is not None:
parts.append(
f'Rim vs core intensity ratio = {rim:.2f}; pattern: {arch.get("rim_pattern_label", "n/a")}.'
)
hyper = arch.get('hyperdense_blob_count_inside_tumor')
if hyper:
parts.append(
f'{hyper} hyperdense focus(es) (>P95) inside the tumor; can suggest haemorrhage or coarse '
'calcification depending on modality.'
)
hypo = arch.get('hypodense_blob_count_inside_tumor')
if hypo:
parts.append(
f'{hypo} hypodense focus(es) (<P5) inside the tumor; can suggest cystic / necrotic foci.'
)
return ' '.join(parts) if parts else 'Internal architecture features not produced.'
def _narrate_mass_effect(me: dict) -> str:
if not me or 'note' in me:
return me.get('note', 'Mass effect not assessable.')
parts = [
f'Tumor-to-brain area ratio = {me.get("tumor_to_brain_area_ratio", 0):.3f}.',
f'Brain bilateral symmetry IoU (left vs horizontally-mirrored right) = '
f'{me.get("brain_symmetry_iou", 0):.2f} (1.0 = perfectly symmetric).',
f'Mass effect evidence: {me.get("mass_effect_label", "n/a")}.',
]
return ' '.join(parts)
def _narrate_grade(grade: dict) -> str:
if not grade:
return 'Grade-evidence score not computed.'
parts = [
f'Radiographic grade-evidence score = {grade.get("score_0_to_1", 0):.2f} of 1.00 '
f'({grade.get("evidence_band", "n/a")}).'
]
for c in (grade.get('components') or []):
parts.append(
f' - {c["name"]}: value={c["value_0_to_1"]:.2f} (weight {c["weight"]:.2f}). '
f'Driver: {c["detail"]}.'
)
parts.append(f'({grade.get("disclaimer", "")})')
return '\n'.join(parts)
def _narrate_diff_with_citations(features: dict) -> list[dict]:
"""Differential diagnosis hints, each bullet attached to feature citations.
Each entry: {statement, supported_by: [feature_key=value, ...], confidence}.
Rule-based only at this layer; the LLM Pattern-B pass adds more nuance.
"""
out: list[dict] = []
geom = features.get('geometry') or {}
mm = features.get('multimodal') or {}
loc = features.get('localization') or {}
morph = features.get('morphology') or {}
arch = features.get('internal_architecture') or {}
comps = features.get('components') or {}
grade = features.get('grade_evidence') or {}
if geom.get('area_px', 0) == 0:
out.append({
'statement': 'No measurable lesion. No differential can be offered.',
'supported_by': ['geometry.area_px=0'],
'confidence': 'high',
})
return out
score = grade.get('score_0_to_1', 0)
# HGG pattern: necrosis + edema + heterogeneity + irregularity.
if (mm.get('necrosis_likely') or (arch.get('necrosis_like_fraction_single_channel', 0) > 0.2)) \
and (mm.get('edema_likely') or score >= 0.5):
cites = []
if mm.get('necrosis_likely'):
cites.append('multimodal.necrosis_likely=True')
if arch.get('necrosis_like_fraction_single_channel'):
cites.append(f'internal_architecture.necrosis_like_fraction_single_channel={arch["necrosis_like_fraction_single_channel"]:.2f}')
if mm.get('edema_likely'):
cites.append(f'multimodal.edema_likely=True (halo_ratio={mm.get("edema_halo_ratio", 0):.2f})')
if score >= 0.5:
cites.append(f'grade_evidence.score_0_to_1={score:.2f}')
out.append({
'statement': 'High-grade glial neoplasm (e.g. glioblastoma) - necrotic/heterogeneous '
'appearance with peritumoral edema fits a classic HGG pattern.',
'supported_by': cites,
'confidence': 'moderate' if score < 0.7 else 'high',
})
# Meningioma: well-circumscribed, peripheral, homogeneously enhancing.
if morph.get('border_label', '').startswith('sharp') \
and loc.get('depth_label') == 'peripheral / cortical' \
and (mm.get('t1c_predominantly_enhancing') or not mm.get('edema_likely', False)):
out.append({
'statement': 'Extra-axial mass (e.g. meningioma) - sharply circumscribed, peripheral / cortical, '
'without prominent peritumoral edema. Look for a dural tail on full series '
'(not derivable here).',
'supported_by': [
f'morphology.border_label="{morph.get("border_label")}"',
f'localization.depth_label="{loc.get("depth_label")}"',
] + ([f'multimodal.t1c_predominantly_enhancing=True']
if mm.get('t1c_predominantly_enhancing') else []),
'confidence': 'low-to-moderate',
})
# Metastases: multifocal lesions, especially at grey-white junction.
if comps.get('multifocal'):
out.append({
'statement': 'Multifocal disease (e.g. metastatic disease, multifocal glioma, lymphoma) - '
f'{comps.get("n_components", 0)} discrete components on segmentation.',
'supported_by': [
f'components.n_components={comps.get("n_components", 0)}',
f'components.largest_component_area_fraction={comps.get("largest_component_area_fraction", 0):.2f}',
],
'confidence': 'moderate',
})
# LGG-like: well-circumscribed, homogeneous, low grade-evidence score.
if score < 0.35 and morph.get('border_label', '').startswith('sharp') \
and arch.get('rim_pattern_label', '').startswith('homogeneous'):
out.append({
'statement': 'Lower-grade glioma or benign-appearing lesion - homogeneous internal '
'architecture, sharp border, and low radiographic grade-evidence score.',
'supported_by': [
f'grade_evidence.score_0_to_1={score:.2f}',
f'morphology.border_label="{morph.get("border_label")}"',
f'internal_architecture.rim_pattern_label="{arch.get("rim_pattern_label")}"',
],
'confidence': 'low-to-moderate',
})
# Irregular / infiltrative shape strongly suggests glioma or mets.
if geom.get('solidity', 1.0) < 0.85 and geom.get('eccentricity', 0) > 0.6:
out.append({
'statement': 'Infiltrative mass (e.g. infiltrative glioma) - irregular shape with concavities '
'and elongated profile is more typical of an infiltrative lesion than a '
'well-circumscribed one.',
'supported_by': [
f'geometry.solidity={geom.get("solidity", 0):.2f}',
f'geometry.eccentricity={geom.get("eccentricity", 0):.2f}',
],
'confidence': 'moderate',
})
if not out:
out.append({
'statement': 'No distinctive radiographic pattern detected by the rule-base. '
'Recommend correlation with full multi-sequence series and clinical history.',
'supported_by': [f'grade_evidence.score_0_to_1={score:.2f}'],
'confidence': 'n/a',
})
return out
def _narrate_confidence_v2(overall: dict, qual: dict, mb: dict, geom: dict) -> str:
"""Confidence assessment combining overall_confidence, quality, and grad-cam."""
parts = [
f'Overall confidence band: {overall.get("band", "n/a")} '
f'(score = {overall.get("score_0_to_1", 0):.2f} of 1.00). '
f'Recommended action: {overall.get("action_recommendation", "")}.'
]
if mb.get('gradcam_segmentation_aligned'):
parts.append(
f'Grad-CAM peak aligns with the segmentation centroid '
f'(distance {mb.get("gradcam_to_segmentation_distance_px", 0):.0f} px, '
f'IoU {mb.get("gradcam_mask_iou", 0):.2f}); classifier and segmenter agree on the same region.'
)
elif 'gradcam_to_segmentation_distance_px' in mb:
parts.append(
f'Grad-CAM peak is far from the segmentation centroid '
f'(distance {mb.get("gradcam_to_segmentation_distance_px", 0):.0f} px, '
f'IoU {mb.get("gradcam_mask_iou", 0):.2f}); classifier and segmenter may be looking at '
'different regions.'
)
warnings = qual.get('quality_warnings') or []
if warnings:
parts.append('Quality warnings: ' + '; '.join(warnings) + '.')
if 0 < (geom.get('area_px') or 0) < 50:
parts.append('Predicted lesion is very small; uncertainty is elevated regardless of probability.')
return ' '.join(parts)
def _narrate_geometry(g: dict) -> str:
if not g or g.get('area_px', 0) == 0:
return 'No tumor region predicted.'
parts = [
f'Area = {g["area_px"]:,} pixels ({g.get("area_mm2", 0):.0f} mm^2).',
f'Equivalent diameter is {g.get("equivalent_diameter_px", 0):.1f} pixels.',
]
ecc = g.get('eccentricity', 0)
if ecc > 0.85:
parts.append(f'Shape is highly elongated (eccentricity {ecc:.2f}).')
elif ecc > 0.6:
parts.append(f'Shape is moderately elongated (eccentricity {ecc:.2f}).')
else:
parts.append(f'Shape is roughly round (eccentricity {ecc:.2f}).')
sol = g.get('solidity', 1)
if sol < 0.85:
parts.append(f'The boundary is irregular (solidity {sol:.2f}, < 0.85 indicates concavities).')
return ' '.join(parts)
def _narrate_localization(l: dict) -> str:
if not l or l.get('note') == 'no tumor predicted':
return 'No tumor predicted, so no localization possible.'
bits = []
if l.get('hemisphere'):
bits.append(f'Right- vs left-hemisphere centroid places this in the {l["hemisphere"]} hemisphere.')
if l.get('anterior_posterior'):
bits.append(f'Vertically, the centroid is in the {l["anterior_posterior"]} third of the brain.')
if l.get('approximate_lobe_hint'):
bits.append(f'Heuristic quadrant suggests {l["approximate_lobe_hint"]}.')
if l.get('depth_label'):
bits.append(f'The mass is {l["depth_label"]}.')
if l.get('midline_shift_suspected'):
bits.append('Marked left-right brain-area asymmetry suggests possible midline shift.')
return ' '.join(bits)
def _narrate_intensity(inten: dict) -> str:
if not inten:
return 'Intensity information unavailable.'
out = []
for name, d in inten.items():
if not isinstance(d, dict) or 'mean' not in d:
continue
cmp = 'hyperintense' if d.get('hyperintense_vs_brain') else 'hypointense' if d.get('hypointense_vs_brain') else 'iso-intense'
out.append(f'On channel {name}, the tumor is {cmp} relative to surrounding brain '
f'(mean {d["mean"]:.1f} vs background {d["mean_in_brain_outside_tumor"]:.1f}).')
return ' '.join(out) if out else 'No usable intensity statistics.'
def _narrate_texture(t: dict) -> str:
if not t or 'note' in t:
return t.get('note', 'No texture information.')
h = t.get('heterogeneity_score')
entropy = t.get('shannon_entropy')
contrast = t.get('contrast')
bits = []
if h is not None:
bits.append(f'Heterogeneity score = {h:.2f} ({"high" if h > 0.4 else "moderate" if h > 0.2 else "low"}).')
if entropy is not None:
bits.append(f'Shannon entropy = {entropy:.2f}.')
if contrast is not None:
bits.append(f'GLCM contrast = {contrast:.2f}.')
return ' '.join(bits) if bits else 'No texture information.'
def _narrate_multimodal(mm: dict) -> str:
if not mm:
return 'Single-channel input; multimodal cues unavailable.'
bits = []
if mm.get('t1c_enhancing_fraction') is not None:
bits.append(f'T1c enhancing fraction = {mm["t1c_enhancing_fraction"]:.2f}'
+ (' (predominantly enhancing).' if mm.get('t1c_predominantly_enhancing') else '.'))
if mm.get('t2_hyperintensity_ratio') is not None:
bits.append(f'T2 tumor-vs-brain ratio = {mm["t2_hyperintensity_ratio"]:.2f}'
+ (' (strongly hyperintense).' if mm.get('t2_strongly_hyperintense') else '.'))
if mm.get('edema_halo_ratio') is not None:
bits.append(f'FLAIR peritumoral / brain background ratio = {mm["edema_halo_ratio"]:.2f}'
+ (' (edema likely).' if mm.get('edema_likely') else '.'))
if mm.get('necrosis_likely'):
bits.append(f'Low T1c-intensity fraction = {mm.get("t1c_low_intensity_fraction", 0):.2f} '
f'inside an otherwise enhancing tumor suggests necrosis.')
return ' '.join(bits) if bits else 'No multimodal signals computed.'
def _narrate_model_agreement(mb: dict) -> str:
probs = mb.get('per_model_probabilities')
if not probs:
return 'No classifier probabilities available.'
items = ', '.join(f'{k}={v:.3f}' for k, v in probs.items())
line = f'Per-model tumor probability: {items}.'
line += f' Mean = {mb.get("mean_probability_tumor", 0):.3f}.'
line += f' Models agreement: {mb.get("models_agreement", "n/a")}.'
return line
def _narrate_confidence(mb: dict, geom: dict) -> str:
if mb.get('gradcam_segmentation_aligned'):
ga = (' Grad-CAM peak aligns well with the segmentation centroid '
f'(distance {mb.get("gradcam_to_segmentation_distance_px", 0):.0f} px, '
f'IoU {mb.get("gradcam_mask_iou", 0):.2f}), increasing confidence.')
elif 'gradcam_to_segmentation_distance_px' in mb:
ga = (' Grad-CAM peak is far from the segmentation centroid '
f'(distance {mb.get("gradcam_to_segmentation_distance_px", 0):.0f} px, '
f'IoU {mb.get("gradcam_mask_iou", 0):.2f}), which may indicate the classifier and '
'segmenter are looking at different regions; treat with caution.')
else:
ga = ' Grad-CAM unavailable.'
if geom.get('area_px', 0) < 50:
return 'Predicted tumor is very small or absent; confidence is low.' + ga
return f'Tumor of {geom.get("area_px", 0):,} pixels detected with classifier agreement {mb.get("models_agreement", "n/a")}.' + ga
def _narrate_diff(features: dict) -> list[str]:
bullets: list[str] = []
geom = features.get('geometry') or {}
mm = features.get('multimodal') or {}
loc = features.get('localization') or {}
if mm.get('necrosis_likely') and mm.get('edema_likely'):
bullets.append(
'High-grade glioma (e.g. glioblastoma) - the combination of T1c necrotic core, '
'peripheral enhancement, and FLAIR-bright peritumoral edema fits a typical HGG appearance.'
)
if mm.get('t1c_predominantly_enhancing') and not mm.get('edema_likely'):
bullets.append(
'Meningioma (extra-axial) - homogeneously enhancing on T1c with minimal edema can suggest '
'a meningioma if cortically based; check for dural tail. (low confidence without dural sign)'
)
if geom.get('eccentricity', 0) > 0.75 and geom.get('solidity', 1) < 0.85:
bullets.append(
'Infiltrative glioma vs metastasis - irregular, elongated, low-solidity shape with concavities '
'is more consistent with an infiltrative lesion than a well-circumscribed one.'
)
if loc.get('depth_label') == 'peripheral / cortical':
bullets.append(
'Metastasis - peripheral / cortical location can be seen with metastases, especially when '
'multifocal. (low confidence, also seen with cortical gliomas)'
)
if features.get('components', {}).get('multifocal'):
bullets.append(
'Multifocal disease - multiple connected components on segmentation; consider metastatic '
'disease, multifocal glioma, or lymphoma.'
)
if not bullets:
bullets.append(
'Cannot offer a confident differential from the features alone; recommend radiologist review.'
)
return bullets
__all__ = ['explain']