Gopal05 commited on
Commit
2306d9c
·
verified ·
1 Parent(s): 2bbf49a

Upload 15 files

Browse files
Files changed (15) hide show
  1. README.md +112 -9
  2. __init__.py +0 -0
  3. database.py +279 -0
  4. main.py +499 -0
  5. medical_coding.py +531 -0
  6. model_loader.py +212 -0
  7. preprocessing.py +263 -0
  8. report_generator.py +440 -0
  9. requirements.txt +43 -0
  10. run_backend.bat +7 -0
  11. run_frontend.bat +4 -0
  12. run_training.bat +7 -0
  13. setup.bat +32 -0
  14. ui.py +707 -0
  15. validation_module.py +375 -0
README.md CHANGED
@@ -1,12 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Report Generatio System Radiology
3
- emoji: 📊
4
- colorFrom: indigo
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- short_description: Analyse and generate radiology report
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
1
+ # Radiology AI Report Generation System v2.0
2
+
3
+ AI-powered radiology report generation using BLIP-2 vision + Phi-2 LLM fine-tuned on real radiology reports.
4
+
5
+ ## What it does
6
+ - Upload any radiology image (X-ray, CT, MRI, Ultrasound, PET, Mammography, etc.)
7
+ - BLIP-2 reads the actual image and describes what it sees
8
+ - Phi-2 (fine-tuned on 227,835 real radiology reports) generates a structured report
9
+ - Outputs: FINDINGS, IMPRESSION, RECOMMENDATIONS + UMLS/ICD-10/SNOMED CT codes
10
+
11
+ ## Requirements
12
+ - Windows 10/11
13
+ - Python 3.10
14
+ - NVIDIA GPU with 8GB+ VRAM (RTX 4060 or better)
15
+ - CUDA 12.8
16
+ - 20GB free disk space (for models)
17
+
18
+ ---
19
+
20
+ ## Quick Start
21
+
22
+ ### Step 1 — Setup (run once)
23
+ ```
24
+ Double-click: setup.bat
25
+ ```
26
+ This creates venv, installs all dependencies, verifies GPU.
27
+
28
+ ### Step 2 — Fine-tune (run once, ~35 minutes)
29
+ ```
30
+ Double-click: run_training.bat
31
+ ```
32
+ Downloads unsloth/Radiology_mini (227,835 real reports) and fine-tunes Phi-2.
33
+
34
+ ### Step 3 — Start the system
35
+ Terminal 1:
36
+ ```
37
+ run_backend.bat
38
+ ```
39
+ Terminal 2:
40
+ ```
41
+ run_frontend.bat
42
+ ```
43
+
44
+ ### Step 4 — Use the system
45
+ Open browser: http://localhost:7860
46
+
47
  ---
48
+
49
+ ## Manual commands (if .bat files don't work)
50
+
51
+ ```bash
52
+ cd D:\radiology_ai_system
53
+ venv\Scripts\activate
54
+
55
+ # Backend
56
+ set USE_4BIT=true
57
+ set ENABLE_VISION_CAPTION=true
58
+ uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
59
+
60
+ # Frontend (new terminal)
61
+ venv\Scripts\activate
62
+ python frontend/ui.py
63
+
64
+ # Fine-tuning (Git Bash)
65
+ python training/train_unsloth.py --max_steps 300
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Project Structure
71
+ ```
72
+ radiology_ai_system/
73
+ ├── backend/
74
+ │ ├── main.py # FastAPI endpoints
75
+ │ ├── model_loader.py # BLIP-2 + Phi-2 loading
76
+ │ ├── preprocessing.py # Image preprocessing (9 modalities)
77
+ │ ├── report_generator.py # Report generation + parsing
78
+ │ ├── validation_module.py # Report quality scoring
79
+ │ └── medical_coding.py # UMLS/ICD-10/SNOMED CT
80
+ ├── frontend/
81
+ │ └── ui.py # Gradio UI
82
+ ├── training/
83
+ │ ├── train_unsloth.py # Fine-tuning on Radiology_mini
84
+ │ ├── prepare_dataset.py # Dataset preparation
85
+ │ └── evaluate_model.py # Model evaluation
86
+ ├── models/
87
+ │ ├── slm_model/ # Phi-2 cached here
88
+ │ │ └── lora_adapter/ # Fine-tuned LoRA adapter saved here
89
+ │ └── vision_encoder/ # BLIP-2 cached here
90
+ ├── setup.bat # One-time setup
91
+ ├── run_backend.bat # Start backend
92
+ ├── run_frontend.bat # Start frontend
93
+ ├── run_training.bat # Fine-tune model
94
+ └── requirements.txt
95
+ ```
96
+
97
+ ---
98
+
99
+ ## VRAM Usage
100
+ | Component | VRAM |
101
+ |---------------|--------|
102
+ | Phi-2 (4-bit) | ~2.0GB |
103
+ | BLIP-2 | ~3.5GB |
104
+ | Total | ~5.5GB |
105
+ | Available | 8.6GB ✅ |
106
+
107
  ---
108
 
109
+ ## API Endpoints
110
+ - POST /api/generate-report — Generate report from image
111
+ - GET /api/report/{id} — Get stored report
112
+ - GET /api/export/pdf/{id} — Download PDF
113
+ - GET /api/export/json/{id} — Download JSON
114
+ - GET /api/health — System health
115
+ - GET /docs — Swagger UI (http://localhost:8000/docs)
__init__.py ADDED
File without changes
database.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ database.py
3
+ -----------
4
+ SQLite database for Radiology AI Report System.
5
+ Stores all generated reports with patient info, findings, and metadata.
6
+
7
+ Tables:
8
+ patients — patient demographics
9
+ reports — generated radiology reports
10
+ images — uploaded image metadata
11
+ """
12
+
13
+ import json
14
+ import logging
15
+ import sqlite3
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ DB_PATH = Path("data/radiology.db")
23
+
24
+
25
+ def get_connection() -> sqlite3.Connection:
26
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
27
+ conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
28
+ conn.row_factory = sqlite3.Row
29
+ conn.execute("PRAGMA journal_mode=WAL")
30
+ conn.execute("PRAGMA foreign_keys=ON")
31
+ return conn
32
+
33
+
34
+ def init_db():
35
+ """Create all tables if they don't exist."""
36
+ conn = get_connection()
37
+ try:
38
+ conn.executescript("""
39
+ CREATE TABLE IF NOT EXISTS patients (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ patient_name TEXT NOT NULL,
42
+ age INTEGER NOT NULL,
43
+ gender TEXT NOT NULL,
44
+ created_at TEXT DEFAULT (datetime('now')),
45
+ updated_at TEXT DEFAULT (datetime('now'))
46
+ );
47
+
48
+ CREATE TABLE IF NOT EXISTS reports (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ report_uuid TEXT NOT NULL UNIQUE,
51
+ patient_id INTEGER NOT NULL REFERENCES patients(id),
52
+ patient_name TEXT NOT NULL,
53
+ age INTEGER NOT NULL,
54
+ gender TEXT NOT NULL,
55
+ symptoms TEXT,
56
+ clinical_history TEXT,
57
+ modality TEXT,
58
+ body_region TEXT,
59
+ view TEXT,
60
+ examination_details TEXT,
61
+ clinical_information TEXT,
62
+ findings TEXT,
63
+ impression TEXT,
64
+ recommendations TEXT,
65
+ confidence_score REAL,
66
+ generation_time_sec REAL,
67
+ image_caption TEXT,
68
+ validation_json TEXT,
69
+ medical_coding_json TEXT,
70
+ created_at TEXT DEFAULT (datetime('now'))
71
+ );
72
+
73
+ CREATE INDEX IF NOT EXISTS idx_reports_uuid
74
+ ON reports(report_uuid);
75
+ CREATE INDEX IF NOT EXISTS idx_reports_patient
76
+ ON reports(patient_id);
77
+ CREATE INDEX IF NOT EXISTS idx_reports_created
78
+ ON reports(created_at);
79
+ CREATE INDEX IF NOT EXISTS idx_patients_name
80
+ ON patients(patient_name);
81
+ """)
82
+ conn.commit()
83
+ logger.info(f"Database initialised at {DB_PATH}")
84
+ finally:
85
+ conn.close()
86
+
87
+
88
+ # ═══════════════════════════════════════════════════════════════════════════
89
+ # Patient operations
90
+ # ═══════════════════════════════════════════════════════════════════════════
91
+
92
+ def create_or_get_patient(name: str, age: int, gender: str) -> int:
93
+ """
94
+ Creates a new patient record or returns existing ID
95
+ if same name+age+gender exists.
96
+ """
97
+ conn = get_connection()
98
+ try:
99
+ row = conn.execute(
100
+ "SELECT id FROM patients WHERE patient_name=? AND age=? AND gender=?",
101
+ (name.strip(), age, gender)
102
+ ).fetchone()
103
+ if row:
104
+ return row["id"]
105
+ cur = conn.execute(
106
+ "INSERT INTO patients (patient_name, age, gender) VALUES (?,?,?)",
107
+ (name.strip(), age, gender)
108
+ )
109
+ conn.commit()
110
+ return cur.lastrowid
111
+ finally:
112
+ conn.close()
113
+
114
+
115
+ def search_patients(query: str) -> list:
116
+ """Search patients by name."""
117
+ conn = get_connection()
118
+ try:
119
+ rows = conn.execute(
120
+ "SELECT DISTINCT patient_name, age, gender, COUNT(*) as report_count "
121
+ "FROM reports WHERE patient_name LIKE ? "
122
+ "GROUP BY patient_name, age, gender "
123
+ "ORDER BY created_at DESC LIMIT 20",
124
+ (f"%{query}%",)
125
+ ).fetchall()
126
+ return [dict(r) for r in rows]
127
+ finally:
128
+ conn.close()
129
+
130
+
131
+ # ═══════════════════════════════════════════════════════════════════════════
132
+ # Report operations
133
+ # ═══════════════════════════════════════════════════════════════════════════
134
+
135
+ def save_report(payload: dict, patient_name: str) -> int:
136
+ """Save a complete report payload to the database. Returns row ID."""
137
+ patient = payload.get("patient", {})
138
+ imaging = payload.get("imaging", {})
139
+ report = payload.get("report", {})
140
+ valid = payload.get("validation", {})
141
+ coding = payload.get("medical_coding", {})
142
+
143
+ age = patient.get("age", 0)
144
+ gender = patient.get("gender", "Unknown")
145
+
146
+ patient_id = create_or_get_patient(patient_name, age, gender)
147
+
148
+ conn = get_connection()
149
+ try:
150
+ cur = conn.execute("""
151
+ INSERT INTO reports (
152
+ report_uuid, patient_id, patient_name, age, gender,
153
+ symptoms, clinical_history,
154
+ modality, body_region, view,
155
+ examination_details, clinical_information,
156
+ findings, impression, recommendations,
157
+ confidence_score, generation_time_sec, image_caption,
158
+ validation_json, medical_coding_json
159
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
160
+ """, (
161
+ payload.get("report_id", ""),
162
+ patient_id,
163
+ patient_name.strip(),
164
+ age,
165
+ gender,
166
+ patient.get("symptoms", ""),
167
+ patient.get("clinical_history", ""),
168
+ imaging.get("modality", ""),
169
+ imaging.get("body_region", ""),
170
+ imaging.get("view", ""),
171
+ report.get("examination_details", ""),
172
+ report.get("clinical_information", ""),
173
+ report.get("findings", ""),
174
+ report.get("impression", ""),
175
+ report.get("recommendations", ""),
176
+ report.get("confidence_score", 0.0),
177
+ report.get("generation_time_seconds", 0.0),
178
+ payload.get("image_caption", ""),
179
+ json.dumps(valid),
180
+ json.dumps(coding),
181
+ ))
182
+ conn.commit()
183
+ logger.info(f"Report saved — DB row: {cur.lastrowid} | patient: {patient_name}")
184
+ return cur.lastrowid
185
+ finally:
186
+ conn.close()
187
+
188
+
189
+ def get_all_reports(limit: int = 50, offset: int = 0) -> list:
190
+ """Get all reports ordered by newest first."""
191
+ conn = get_connection()
192
+ try:
193
+ rows = conn.execute("""
194
+ SELECT report_uuid, patient_name, age, gender,
195
+ modality, body_region, confidence_score,
196
+ created_at
197
+ FROM reports
198
+ ORDER BY created_at DESC
199
+ LIMIT ? OFFSET ?
200
+ """, (limit, offset)).fetchall()
201
+ return [dict(r) for r in rows]
202
+ finally:
203
+ conn.close()
204
+
205
+
206
+ def get_report_by_uuid(uuid: str) -> Optional[dict]:
207
+ """Get full report by UUID."""
208
+ conn = get_connection()
209
+ try:
210
+ row = conn.execute(
211
+ "SELECT * FROM reports WHERE report_uuid=?", (uuid,)
212
+ ).fetchone()
213
+ if not row:
214
+ return None
215
+ result = dict(row)
216
+ # Parse JSON fields
217
+ for field in ("validation_json", "medical_coding_json"):
218
+ try:
219
+ result[field] = json.loads(result[field] or "{}")
220
+ except Exception:
221
+ result[field] = {}
222
+ return result
223
+ finally:
224
+ conn.close()
225
+
226
+
227
+ def get_reports_by_patient(patient_name: str) -> list:
228
+ """Get all reports for a specific patient."""
229
+ conn = get_connection()
230
+ try:
231
+ rows = conn.execute("""
232
+ SELECT report_uuid, patient_name, age, gender,
233
+ modality, body_region, confidence_score,
234
+ findings, impression, created_at
235
+ FROM reports
236
+ WHERE patient_name LIKE ?
237
+ ORDER BY created_at DESC
238
+ """, (f"%{patient_name}%",)).fetchall()
239
+ return [dict(r) for r in rows]
240
+ finally:
241
+ conn.close()
242
+
243
+
244
+ def get_db_stats() -> dict:
245
+ """Get database statistics."""
246
+ conn = get_connection()
247
+ try:
248
+ total_reports = conn.execute("SELECT COUNT(*) FROM reports").fetchone()[0]
249
+ total_patients = conn.execute("SELECT COUNT(DISTINCT patient_name) FROM reports").fetchone()[0]
250
+ avg_confidence = conn.execute("SELECT AVG(confidence_score) FROM reports").fetchone()[0]
251
+ modality_counts = conn.execute(
252
+ "SELECT modality, COUNT(*) as cnt FROM reports GROUP BY modality ORDER BY cnt DESC"
253
+ ).fetchall()
254
+ return {
255
+ "total_reports": total_reports,
256
+ "total_patients": total_patients,
257
+ "avg_confidence": round(avg_confidence or 0, 2),
258
+ "modality_counts": {r["modality"]: r["cnt"] for r in modality_counts},
259
+ }
260
+ finally:
261
+ conn.close()
262
+
263
+
264
+ def delete_report(uuid: str) -> bool:
265
+ """Delete a report by UUID."""
266
+ conn = get_connection()
267
+ try:
268
+ conn.execute("DELETE FROM reports WHERE report_uuid=?", (uuid,))
269
+ conn.commit()
270
+ return True
271
+ except Exception as e:
272
+ logger.error(f"Delete failed: {e}")
273
+ return False
274
+ finally:
275
+ conn.close()
276
+
277
+
278
+ # Auto-initialise on import
279
+ init_db()
main.py ADDED
@@ -0,0 +1,499 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ main.py — FastAPI backend for Radiology AI Report Generation
3
+ Vision is ENABLED by default (BLIP-2 reads the actual image).
4
+ """
5
+
6
+ import io
7
+ import json
8
+ import logging
9
+ import os
10
+ import sys
11
+ import uuid
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ ROOT = Path(__file__).resolve().parent.parent
17
+ if str(ROOT) not in sys.path:
18
+ sys.path.insert(0, str(ROOT))
19
+
20
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from fastapi.responses import JSONResponse, Response
23
+
24
+ # Database
25
+ import importlib.util as _ilu
26
+ _db_spec = _ilu.spec_from_file_location("backend.database",
27
+ __file__.replace("main.py","database.py"))
28
+ _db_mod = _ilu.module_from_spec(_db_spec)
29
+ _db_spec.loader.exec_module(_db_mod)
30
+ db = _db_mod
31
+
32
+ logging.basicConfig(
33
+ level=logging.INFO,
34
+ format="%(asctime)s | %(levelname)-8s | %(name)s — %(message)s",
35
+ )
36
+ logger = logging.getLogger(__name__)
37
+
38
+ app = FastAPI(
39
+ title="Radiology AI Report Generation System",
40
+ version="2.0.0",
41
+ description="AI-powered radiology report generation with BLIP-2 vision + Phi-2 LLM",
42
+ )
43
+
44
+ app.add_middleware(
45
+ CORSMiddleware,
46
+ allow_origins=["*"],
47
+ allow_credentials=True,
48
+ allow_methods=["*"],
49
+ allow_headers=["*"],
50
+ )
51
+
52
+ _report_store: dict = {}
53
+ _models_loaded = False
54
+ _slm_model = None
55
+ _slm_tokenizer = None
56
+ _vision_model = None
57
+ _vision_processor = None
58
+
59
+ # Vision is ON by default — set ENABLE_VISION_CAPTION=false to disable
60
+ ENABLE_VISION_CAPTION = os.getenv("ENABLE_VISION_CAPTION", "true").lower() == "true"
61
+
62
+
63
+ # ═══════════════════════════════════════════════════════════════════════════
64
+ # Module loader helper (avoids import path issues on Windows)
65
+ # ═══════════════════════════════════════════════════════════════════════════
66
+
67
+ def _load_module(name, filepath):
68
+ import importlib.util
69
+ if name in sys.modules:
70
+ return sys.modules[name]
71
+ spec = importlib.util.spec_from_file_location(name, filepath)
72
+ mod = importlib.util.module_from_spec(spec)
73
+ sys.modules[name] = mod
74
+ spec.loader.exec_module(mod)
75
+ return mod
76
+
77
+
78
+ _BACKEND = Path(__file__).resolve().parent
79
+
80
+
81
+ def _ml():
82
+ return _load_module("backend.model_loader", _BACKEND / "model_loader.py")
83
+ def _pp():
84
+ return _load_module("backend.preprocessing", _BACKEND / "preprocessing.py")
85
+ def _rg():
86
+ return _load_module("backend.report_generator",_BACKEND / "report_generator.py")
87
+ def _vm():
88
+ return _load_module("backend.validation_module",_BACKEND / "validation_module.py")
89
+ def _mc():
90
+ return _load_module("backend.medical_coding", _BACKEND / "medical_coding.py")
91
+
92
+
93
+ # ═══════════════════════════════════════════════════════════════════════════
94
+ # Model loading
95
+ # ═══════════════════════════════════════════════════════════════════════════
96
+
97
+ def _ensure_models_loaded():
98
+ global _models_loaded, _slm_model, _slm_tokenizer, _vision_model, _vision_processor
99
+ if _models_loaded:
100
+ return
101
+
102
+ logger.info("Loading AI models...")
103
+ ml = _ml()
104
+
105
+ # Always load language model
106
+ _slm_model, _slm_tokenizer = ml.load_slm()
107
+
108
+ # Load vision model (BLIP-2) if enabled
109
+ if ENABLE_VISION_CAPTION:
110
+ logger.info("Loading BLIP-2 vision model (this takes ~30s on first run)...")
111
+ try:
112
+ _vision_model, _vision_processor = ml.load_vision_encoder()
113
+ logger.info("BLIP-2 vision model loaded — image content will be analysed.")
114
+ except Exception as exc:
115
+ logger.warning(f"BLIP-2 failed to load: {exc}. Reports will use form-based captions only.")
116
+
117
+ _models_loaded = True
118
+ logger.info("All models loaded.")
119
+
120
+
121
+ @app.on_event("startup")
122
+ async def startup_event():
123
+ try:
124
+ _ensure_models_loaded()
125
+ except Exception as exc:
126
+ logger.error(f"Startup model loading failed: {exc}")
127
+
128
+
129
+ # ═══════════════════════════════════════════════════════════════════════════
130
+ # Health & Info
131
+ # ═══════════════════════════════════════════════════════════════════════════
132
+
133
+ @app.get("/api/health", tags=["System"])
134
+ def health_check():
135
+ import torch
136
+ return {
137
+ "status": "healthy",
138
+ "models_loaded": _models_loaded,
139
+ "vision_enabled": ENABLE_VISION_CAPTION,
140
+ "vision_model_loaded": _vision_model is not None,
141
+ "device": "cuda" if torch.cuda.is_available() else "cpu",
142
+ "timestamp": datetime.utcnow().isoformat(),
143
+ }
144
+
145
+
146
+ @app.get("/api/models", tags=["System"])
147
+ def list_models():
148
+ import torch
149
+ return {
150
+ "slm_model": os.getenv("SLM_MODEL_ID", "microsoft/phi-2"),
151
+ "vision_model": os.getenv("VISION_MODEL_ID", "Salesforce/blip2-opt-2.7b"),
152
+ "vision_enabled": ENABLE_VISION_CAPTION,
153
+ "vision_loaded": _vision_model is not None,
154
+ "device": "cuda" if torch.cuda.is_available() else "cpu",
155
+ "models_loaded": _models_loaded,
156
+ }
157
+
158
+
159
+ # ═══════════════════════════════════════════════════════════════════════════
160
+ # Main report generation endpoint
161
+ # ═══════════════════════════════════════════════════════════════════════════
162
+
163
+ @app.post("/api/generate-report", tags=["Report"])
164
+ async def generate_report(
165
+ image: UploadFile = File(...),
166
+ patient_name: str = Form(""),
167
+ patient_age: int = Form(...),
168
+ patient_gender: str = Form(...),
169
+ symptoms: str = Form(...),
170
+ clinical_history: str = Form(...),
171
+ modality: str = Form(...),
172
+ body_region: str = Form(...),
173
+ view: Optional[str] = Form(""),
174
+ ):
175
+ # 1. Read image bytes
176
+ file_bytes = await image.read()
177
+ filename = image.filename or "upload.jpg"
178
+ logger.info(f"Image received: {filename} ({len(file_bytes)/1024:.1f} KB)")
179
+
180
+ # 2. Preprocess image
181
+ try:
182
+ pp = _pp()
183
+ preprocessor = pp.RadiologyPreprocessor(
184
+ modality=modality.lower(),
185
+ body_region=body_region.lower(),
186
+ )
187
+ pil_image = preprocessor.preprocess_pil(file_bytes, filename)
188
+ except Exception as exc:
189
+ logger.error(f"Preprocessing error: {exc}")
190
+ raise HTTPException(status_code=422, detail=f"Image preprocessing failed: {exc}")
191
+
192
+ # 3. Load models
193
+ try:
194
+ _ensure_models_loaded()
195
+ except Exception as exc:
196
+ raise HTTPException(status_code=503, detail=f"Model loading failed: {exc}")
197
+
198
+ # 4. Generate image caption using BLIP-2 (reads actual image pixels)
199
+ image_caption = (
200
+ f"{modality} image of the {body_region}. "
201
+ f"Patient presents with {symptoms}."
202
+ )
203
+ if ENABLE_VISION_CAPTION and _vision_model is not None:
204
+ try:
205
+ ml = _ml()
206
+ _, blip_caption = ml.extract_visual_features(
207
+ pil_image, _vision_model, _vision_processor
208
+ )
209
+ if blip_caption and len(blip_caption) > 20:
210
+ image_caption = blip_caption
211
+ logger.info(f"BLIP-2 caption used: {image_caption[:120]}")
212
+ except Exception as exc:
213
+ logger.warning(f"Vision captioning failed: {exc} — using fallback caption")
214
+
215
+ # 5. Generate report using Phi-2
216
+ try:
217
+ rg = _rg()
218
+ patient = rg.PatientInfo(
219
+ age=patient_age, gender=patient_gender,
220
+ symptoms=symptoms, clinical_history=clinical_history,
221
+ )
222
+ imaging = rg.ImagingInfo(
223
+ modality=modality, body_region=body_region, view=view or ""
224
+ )
225
+ generator = rg.ReportGenerator(
226
+ slm_model=_slm_model, slm_tokenizer=_slm_tokenizer
227
+ )
228
+ report = generator.generate(
229
+ patient=patient, imaging=imaging, image_caption=image_caption
230
+ )
231
+ logger.info(f"Report generated in {report.generation_time_seconds:.1f}s")
232
+ except Exception as exc:
233
+ logger.error(f"Report generation error: {exc}")
234
+ raise HTTPException(status_code=500, detail=f"Report generation failed: {exc}")
235
+
236
+ # 6. Validate
237
+ vm = _vm()
238
+ validation = vm.validate_report(report)
239
+ report.confidence_score = validation.confidence_score
240
+
241
+ # 7. Medical coding (UMLS / ICD-10 / SNOMED CT)
242
+ mc = _mc()
243
+ coding_result = mc.extract_medical_codes(
244
+ report_text=report.raw_text,
245
+ sections={
246
+ "findings": report.findings,
247
+ "impression": report.impression,
248
+ "recommendations": report.recommendations,
249
+ },
250
+ )
251
+
252
+ # 8. Build and store response
253
+ report_id = str(uuid.uuid4())
254
+ # Clean patient name
255
+ patient_name_clean = patient_name.strip() if patient_name.strip() else "Unknown Patient"
256
+
257
+ payload = {
258
+ "report_id": report_id,
259
+ "timestamp": datetime.utcnow().isoformat(),
260
+ "image_caption": image_caption,
261
+ "patient_name": patient_name_clean,
262
+ "patient": {
263
+ "name": patient_name_clean,
264
+ "age": patient_age,
265
+ "gender": patient_gender,
266
+ "symptoms": symptoms,
267
+ "clinical_history": clinical_history,
268
+ },
269
+ "imaging": {
270
+ "modality": modality,
271
+ "body_region": body_region,
272
+ "view": view,
273
+ },
274
+ "report": report.to_dict(),
275
+ "report_text": report.to_text(),
276
+ "validation": validation.to_dict(),
277
+ "medical_coding": coding_result.to_dict(),
278
+ }
279
+ _report_store[report_id] = payload
280
+
281
+ # Save to SQLite database
282
+ try:
283
+ db.save_report(payload, patient_name_clean)
284
+ except Exception as db_exc:
285
+ logger.warning(f"DB save failed (non-critical): {db_exc}")
286
+
287
+ logger.info(
288
+ f"Report stored: {report_id[:8]} | "
289
+ f"confidence: {report.confidence_score:.2f} | "
290
+ f"codes: {len(coding_result.codes)} | "
291
+ f"diagnosis: {coding_result.primary_diagnosis_label}"
292
+ )
293
+ return JSONResponse(content=payload)
294
+
295
+
296
+ # ═══════════════════════════════════════════════════════════════════════════
297
+ # Report retrieval & export
298
+ # ═══════════════════════════════════════════════════════════════════════════
299
+
300
+ @app.get("/api/report/{report_id}", tags=["Report"])
301
+ def get_report(report_id: str):
302
+ if report_id not in _report_store:
303
+ raise HTTPException(status_code=404, detail="Report not found.")
304
+ return JSONResponse(content=_report_store[report_id])
305
+
306
+
307
+ @app.get("/api/export/json/{report_id}", tags=["Export"])
308
+ def export_json(report_id: str):
309
+ if report_id not in _report_store:
310
+ raise HTTPException(status_code=404, detail="Report not found.")
311
+ return Response(
312
+ content=json.dumps(_report_store[report_id], indent=2).encode(),
313
+ media_type="application/json",
314
+ headers={"Content-Disposition": f'attachment; filename="report_{report_id[:8]}.json"'},
315
+ )
316
+
317
+
318
+ @app.get("/api/export/pdf/{report_id}", tags=["Export"])
319
+ def export_pdf(report_id: str):
320
+ if report_id not in _report_store:
321
+ raise HTTPException(status_code=404, detail="Report not found.")
322
+ try:
323
+ pdf_bytes = _generate_pdf(_report_store[report_id])
324
+ except Exception as exc:
325
+ raise HTTPException(status_code=500, detail=f"PDF generation failed: {exc}")
326
+ return Response(
327
+ content=pdf_bytes,
328
+ media_type="application/pdf",
329
+ headers={"Content-Disposition": f'attachment; filename="report_{report_id[:8]}.pdf"'},
330
+ )
331
+
332
+
333
+ # ═══════════════════════════════════════════════════════════════════════════
334
+ # PDF generator
335
+ # ═══════════════════════════════════════════════════════════════════════════
336
+
337
+
338
+
339
+ # ═══════════════════════════════════════════════════════════════════════════
340
+ # Database endpoints
341
+ # ═══════════════════════════════════════════════════════════════════════════
342
+
343
+ @app.get("/api/reports", tags=["Database"])
344
+ def list_all_reports(limit: int = 50, offset: int = 0):
345
+ """List all stored reports from database."""
346
+ try:
347
+ reports = db.get_all_reports(limit=limit, offset=offset)
348
+ return JSONResponse(content={"reports": reports, "count": len(reports)})
349
+ except Exception as e:
350
+ raise HTTPException(status_code=500, detail=str(e))
351
+
352
+
353
+ @app.get("/api/reports/patient/{patient_name}", tags=["Database"])
354
+ def get_patient_reports(patient_name: str):
355
+ """Get all reports for a specific patient."""
356
+ try:
357
+ reports = db.get_reports_by_patient(patient_name)
358
+ return JSONResponse(content={"patient": patient_name,
359
+ "reports": reports,
360
+ "count": len(reports)})
361
+ except Exception as e:
362
+ raise HTTPException(status_code=500, detail=str(e))
363
+
364
+
365
+ @app.get("/api/reports/search", tags=["Database"])
366
+ def search_patients(q: str = ""):
367
+ """Search patients by name."""
368
+ try:
369
+ results = db.search_patients(q)
370
+ return JSONResponse(content={"results": results})
371
+ except Exception as e:
372
+ raise HTTPException(status_code=500, detail=str(e))
373
+
374
+
375
+ @app.get("/api/stats", tags=["Database"])
376
+ def get_stats():
377
+ """Get database statistics."""
378
+ try:
379
+ return JSONResponse(content=db.get_db_stats())
380
+ except Exception as e:
381
+ raise HTTPException(status_code=500, detail=str(e))
382
+
383
+
384
+ @app.delete("/api/reports/{report_id}", tags=["Database"])
385
+ def delete_report(report_id: str):
386
+ """Delete a report by ID."""
387
+ try:
388
+ success = db.delete_report(report_id)
389
+ if not success:
390
+ raise HTTPException(status_code=404, detail="Report not found.")
391
+ return JSONResponse(content={"deleted": report_id})
392
+ except HTTPException:
393
+ raise
394
+ except Exception as e:
395
+ raise HTTPException(status_code=500, detail=str(e))
396
+
397
+ def _generate_pdf(payload: dict) -> bytes:
398
+ from reportlab.lib import colors
399
+ from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
400
+ from reportlab.lib.pagesizes import A4
401
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
402
+ from reportlab.lib.units import cm
403
+ from reportlab.platypus import (HRFlowable, Paragraph, SimpleDocTemplate,
404
+ Spacer, Table, TableStyle)
405
+
406
+ buf = io.BytesIO()
407
+ doc = SimpleDocTemplate(buf, pagesize=A4,
408
+ rightMargin=2*cm, leftMargin=2*cm,
409
+ topMargin=2.5*cm, bottomMargin=2*cm)
410
+ styles = getSampleStyleSheet()
411
+ BLUE = colors.HexColor("#1a4a8a")
412
+ LIGHT = colors.HexColor("#e8f0fb")
413
+
414
+ story = []
415
+ story.append(Paragraph("RADIOLOGY REPORT",
416
+ ParagraphStyle("T", parent=styles["Title"], textColor=BLUE,
417
+ fontSize=20, spaceAfter=4, fontName="Helvetica-Bold")))
418
+
419
+ report = payload.get("report", {})
420
+ patient = payload.get("patient", {})
421
+ imaging = payload.get("imaging", {})
422
+ validation = payload.get("validation", {})
423
+ report_id = payload.get("report_id", "N/A")
424
+ timestamp = payload.get("timestamp", "")
425
+
426
+ story.append(Paragraph(
427
+ f"AI-Generated Report | {timestamp[:10]} | ID: {report_id[:8]}",
428
+ ParagraphStyle("S", parent=styles["Normal"], textColor=colors.HexColor("#555"),
429
+ fontSize=10, spaceAfter=12, alignment=TA_CENTER)))
430
+ story.append(HRFlowable(width="100%", thickness=2, color=BLUE))
431
+ story.append(Spacer(1, 0.4*cm))
432
+
433
+ cell_style = ParagraphStyle("cell", parent=styles["Normal"], fontSize=9,
434
+ leading=12)
435
+ lbl_style = ParagraphStyle("lbl", parent=styles["Normal"], fontSize=9,
436
+ leading=12, fontName="Helvetica-Bold")
437
+ symptoms_text = str(patient.get("symptoms", "—"))
438
+
439
+ tbl = Table([
440
+ [Paragraph("Patient Name", lbl_style), Paragraph(str(payload.get("patient_name","—")), cell_style),
441
+ Paragraph("Report ID", lbl_style), Paragraph(str(report_id[:8]), cell_style)],
442
+ [Paragraph("Patient Age", lbl_style), Paragraph(str(patient.get("age","—")), cell_style),
443
+ Paragraph("Modality", lbl_style), Paragraph(str(imaging.get("modality","—")), cell_style)],
444
+ [Paragraph("Gender", lbl_style), Paragraph(str(patient.get("gender","—")), cell_style),
445
+ Paragraph("Body Region", lbl_style), Paragraph(str(imaging.get("body_region","—")), cell_style)],
446
+ [Paragraph("View", lbl_style), Paragraph(str(imaging.get("view","—") or "—"), cell_style),
447
+ Paragraph("", cell_style), Paragraph("", cell_style)],
448
+ [Paragraph("Symptoms", lbl_style), Paragraph(symptoms_text, cell_style),
449
+ Paragraph("", cell_style), Paragraph("", cell_style)],
450
+ ], colWidths=[3*cm, 7*cm, 3*cm, 4*cm])
451
+ tbl.setStyle(TableStyle([
452
+ ("FONTSIZE", (0,0),(-1,-1), 9),
453
+ ("BACKGROUND", (0,0),(-1,0), LIGHT),
454
+ ("BACKGROUND", (0,2),(-1,2), LIGHT),
455
+ ("GRID", (0,0),(-1,-1), 0.5, colors.HexColor("#ccc")),
456
+ ("VALIGN", (0,0),(-1,-1), "TOP"),
457
+ ("TOPPADDING", (0,0),(-1,-1), 5),
458
+ ("BOTTOMPADDING", (0,0),(-1,-1), 5),
459
+ ("SPAN", (1,2),(3,2)),
460
+ ("SPAN", (1,3),(3,3)),
461
+ ]))
462
+ story.append(tbl)
463
+ story.append(Spacer(1, 0.5*cm))
464
+ story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#aaa")))
465
+
466
+ hdr = ParagraphStyle("H", parent=styles["Heading2"], textColor=BLUE,
467
+ fontSize=11, fontName="Helvetica-Bold", spaceBefore=12, spaceAfter=4)
468
+ body = ParagraphStyle("B", parent=styles["Normal"], fontSize=10,
469
+ leading=15, spaceAfter=6, alignment=TA_JUSTIFY)
470
+
471
+ for header, key in [
472
+ ("Examination Details", "examination_details"),
473
+ ("Clinical Information", "clinical_information"),
474
+ ("Findings", "findings"),
475
+ ("Impression", "impression"),
476
+ ("Recommendations", "recommendations"),
477
+ ]:
478
+ story.append(Paragraph(header.upper(), hdr))
479
+ story.append(Paragraph(str(report.get(key,"—") or "—"), body))
480
+
481
+ story.append(Spacer(1, 0.5*cm))
482
+ story.append(HRFlowable(width="100%", thickness=1, color=BLUE))
483
+ confidence = validation.get("confidence_score", 0)
484
+ story.append(Paragraph(
485
+ f"CONFIDENCE SCORE: {confidence:.2f} / 1.00",
486
+ ParagraphStyle("C", parent=styles["Normal"], fontSize=12,
487
+ fontName="Helvetica-Bold", textColor=BLUE,
488
+ alignment=TA_CENTER, spaceBefore=10)))
489
+
490
+ story.append(Spacer(1, 1*cm))
491
+ story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#aaa")))
492
+ story.append(Paragraph(
493
+ "This report was generated by an AI system. "
494
+ "Clinical correlation by a licensed radiologist is mandatory.",
495
+ ParagraphStyle("D", parent=styles["Normal"], fontSize=8,
496
+ textColor=colors.HexColor("#888"),
497
+ alignment=TA_CENTER, spaceBefore=6)))
498
+ doc.build(story)
499
+ return buf.getvalue()
medical_coding.py ADDED
@@ -0,0 +1,531 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ medical_coding.py
3
+ -----------------
4
+ UMLS / ICD-10 / SNOMED medical concept extraction and coding.
5
+
6
+ Extracts medical entities from radiology report text and maps them to:
7
+ - UMLS CUIs (e.g. C1306645 → Pneumonia)
8
+ - ICD-10 codes (e.g. J18.9 → Pneumonia, unspecified)
9
+ - SNOMED CT codes
10
+ - Severity indicators
11
+ - Anatomical locations
12
+
13
+ Works WITHOUT scispacy (uses built-in dictionary for reliability).
14
+ Optionally uses scispacy if installed for deeper NLP extraction.
15
+ """
16
+
17
+ import logging
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from typing import Dict, List, Optional, Tuple
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # UMLS + ICD-10 Medical Concept Dictionary
27
+ # Covers the most common radiology findings
28
+ # ---------------------------------------------------------------------------
29
+
30
+ MEDICAL_CONCEPT_DB: Dict[str, dict] = {
31
+
32
+ # ── Pulmonary ──────────────────────────────────────────────────────────
33
+ "pneumonia": {
34
+ "cui": "C0032285", "icd10": "J18.9", "snomed": "233604007",
35
+ "label": "Pneumonia", "category": "Pulmonary",
36
+ "severity_weight": 0.7
37
+ },
38
+ "consolidation": {
39
+ "cui": "C0521530", "icd10": "J98.19", "snomed": "44416002",
40
+ "label": "Pulmonary Consolidation", "category": "Pulmonary",
41
+ "severity_weight": 0.6
42
+ },
43
+ "pneumothorax": {
44
+ "cui": "C0032326", "icd10": "J93.9", "snomed": "36118008",
45
+ "label": "Pneumothorax", "category": "Pulmonary",
46
+ "severity_weight": 0.9
47
+ },
48
+ "pleural effusion": {
49
+ "cui": "C0032227", "icd10": "J90", "snomed": "60046008",
50
+ "label": "Pleural Effusion", "category": "Pulmonary",
51
+ "severity_weight": 0.7
52
+ },
53
+ "atelectasis": {
54
+ "cui": "C0004144", "icd10": "J98.11", "snomed": "46621007",
55
+ "label": "Atelectasis", "category": "Pulmonary",
56
+ "severity_weight": 0.6
57
+ },
58
+ "pulmonary edema": {
59
+ "cui": "C0034063", "icd10": "J81.0", "snomed": "19242006",
60
+ "label": "Pulmonary Edema", "category": "Pulmonary",
61
+ "severity_weight": 0.8
62
+ },
63
+ "emphysema": {
64
+ "cui": "C0034067", "icd10": "J43.9", "snomed": "87433001",
65
+ "label": "Pulmonary Emphysema", "category": "Pulmonary",
66
+ "severity_weight": 0.6
67
+ },
68
+ "opacity": {
69
+ "cui": "C0333053", "icd10": "R91.8", "snomed": "408721000",
70
+ "label": "Pulmonary Opacity", "category": "Pulmonary",
71
+ "severity_weight": 0.5
72
+ },
73
+ "nodule": {
74
+ "cui": "C0034079", "icd10": "R91.1", "snomed": "27925004",
75
+ "label": "Pulmonary Nodule", "category": "Pulmonary",
76
+ "severity_weight": 0.6
77
+ },
78
+ "mass": {
79
+ "cui": "C0577559", "icd10": "R91.8", "snomed": "300848003",
80
+ "label": "Pulmonary Mass", "category": "Pulmonary",
81
+ "severity_weight": 0.8
82
+ },
83
+ "fibrosis": {
84
+ "cui": "C1800706", "icd10": "J84.10", "snomed": "40122008",
85
+ "label": "Pulmonary Fibrosis", "category": "Pulmonary",
86
+ "severity_weight": 0.7
87
+ },
88
+
89
+ # ── Cardiac ───────────────────────────────────────────────────────────
90
+ "cardiomegaly": {
91
+ "cui": "C0018800", "icd10": "I51.7", "snomed": "8186001",
92
+ "label": "Cardiomegaly", "category": "Cardiac",
93
+ "severity_weight": 0.7
94
+ },
95
+ "pericardial effusion": {
96
+ "cui": "C0031039", "icd10": "I30.9", "snomed": "373945007",
97
+ "label": "Pericardial Effusion", "category": "Cardiac",
98
+ "severity_weight": 0.8
99
+ },
100
+
101
+ # ── Neurological ──────────────────────────────────────────────────────
102
+ "hemorrhage": {
103
+ "cui": "C0019080", "icd10": "I62.9", "snomed": "50960005",
104
+ "label": "Intracranial Hemorrhage", "category": "Neurological",
105
+ "severity_weight": 0.95
106
+ },
107
+ "infarct": {
108
+ "cui": "C0007785", "icd10": "I63.9", "snomed": "432504007",
109
+ "label": "Cerebral Infarction", "category": "Neurological",
110
+ "severity_weight": 0.9
111
+ },
112
+ "hydrocephalus": {
113
+ "cui": "C0020255", "icd10": "G91.9", "snomed": "230745008",
114
+ "label": "Hydrocephalus", "category": "Neurological",
115
+ "severity_weight": 0.85
116
+ },
117
+ "midline shift": {
118
+ "cui": "C0235050", "icd10": "G93.5", "snomed": "44695005",
119
+ "label": "Midline Shift", "category": "Neurological",
120
+ "severity_weight": 0.9
121
+ },
122
+ "edema": {
123
+ "cui": "C0006960", "icd10": "G93.6", "snomed": "2776000",
124
+ "label": "Cerebral Edema", "category": "Neurological",
125
+ "severity_weight": 0.85
126
+ },
127
+
128
+ # ── Abdominal ────────────────��────────────────────────────────────────
129
+ "hepatomegaly": {
130
+ "cui": "C0019209", "icd10": "R16.0", "snomed": "80515008",
131
+ "label": "Hepatomegaly", "category": "Abdominal",
132
+ "severity_weight": 0.6
133
+ },
134
+ "splenomegaly": {
135
+ "cui": "C0038002", "icd10": "R16.1", "snomed": "16294009",
136
+ "label": "Splenomegaly", "category": "Abdominal",
137
+ "severity_weight": 0.6
138
+ },
139
+ "ascites": {
140
+ "cui": "C0003962", "icd10": "R18.0", "snomed": "389026000",
141
+ "label": "Ascites", "category": "Abdominal",
142
+ "severity_weight": 0.7
143
+ },
144
+ "obstruction": {
145
+ "cui": "C0029895", "icd10": "K56.60", "snomed": "81060008",
146
+ "label": "Bowel Obstruction", "category": "Abdominal",
147
+ "severity_weight": 0.8
148
+ },
149
+ "calculi": {
150
+ "cui": "C0022650", "icd10": "N20.0", "snomed": "95570007",
151
+ "label": "Urolithiasis/Calculi", "category": "Abdominal",
152
+ "severity_weight": 0.5
153
+ },
154
+ "gallstone": {
155
+ "cui": "C0008350", "icd10": "K80.20", "snomed": "266474003",
156
+ "label": "Cholelithiasis", "category": "Abdominal",
157
+ "severity_weight": 0.5
158
+ },
159
+
160
+ # ── Musculoskeletal ───────────────────────────────────────────────────
161
+ "fracture": {
162
+ "cui": "C0016658", "icd10": "M84.40", "snomed": "125605004",
163
+ "label": "Bone Fracture", "category": "Musculoskeletal",
164
+ "severity_weight": 0.75
165
+ },
166
+ "osteoporosis": {
167
+ "cui": "C0029456", "icd10": "M81.0", "snomed": "64859006",
168
+ "label": "Osteoporosis", "category": "Musculoskeletal",
169
+ "severity_weight": 0.6
170
+ },
171
+ "osteopenia": {
172
+ "cui": "C0029453", "icd10": "M85.80", "snomed": "312894000",
173
+ "label": "Osteopenia", "category": "Musculoskeletal",
174
+ "severity_weight": 0.4
175
+ },
176
+ "dislocation": {
177
+ "cui": "C0012691", "icd10": "M24.30", "snomed": "57637002",
178
+ "label": "Joint Dislocation", "category": "Musculoskeletal",
179
+ "severity_weight": 0.7
180
+ },
181
+ "arthritis": {
182
+ "cui": "C0003864", "icd10": "M19.90", "snomed": "57676002",
183
+ "label": "Arthritis", "category": "Musculoskeletal",
184
+ "severity_weight": 0.5
185
+ },
186
+ "arthroplasty": {
187
+ "cui": "C0003893", "icd10": "Z96.641", "snomed": "179294005",
188
+ "label": "Hip Arthroplasty/Implant", "category": "Musculoskeletal",
189
+ "severity_weight": 0.2
190
+ },
191
+ "implant": {
192
+ "cui": "C0021102", "icd10": "Z96.9", "snomed": "40388003",
193
+ "label": "Orthopedic Implant", "category": "Musculoskeletal",
194
+ "severity_weight": 0.2
195
+ },
196
+ "lytic": {
197
+ "cui": "C0262587", "icd10": "M89.50", "snomed": "203522001",
198
+ "label": "Lytic Bone Lesion", "category": "Musculoskeletal",
199
+ "severity_weight": 0.8
200
+ },
201
+
202
+ # ── Normal findings ───────────────────────────────────────────────────
203
+ "no acute": {
204
+ "cui": "C0442737", "icd10": "Z00.00", "snomed": "17621005",
205
+ "label": "No Acute Finding", "category": "Normal",
206
+ "severity_weight": 0.0
207
+ },
208
+ "normal": {
209
+ "cui": "C0442737", "icd10": "Z00.00", "snomed": "17621005",
210
+ "label": "Normal Study", "category": "Normal",
211
+ "severity_weight": 0.0
212
+ },
213
+ "unremarkable": {
214
+ "cui": "C0442737", "icd10": "Z00.00", "snomed": "17621005",
215
+ "label": "Unremarkable", "category": "Normal",
216
+ "severity_weight": 0.0
217
+ },
218
+ # ── Breast / Mammography ──────────────────────────────────────────────
219
+ "microcalcification": {
220
+ "cui": "C0024496", "icd10": "N64.89", "snomed": "129769009",
221
+ "label": "Breast Microcalcification", "category": "Breast", "severity_weight": 0.75
222
+ },
223
+ "breast mass": {
224
+ "cui": "C0024103", "icd10": "N63.0", "snomed": "59614007",
225
+ "label": "Breast Mass", "category": "Breast", "severity_weight": 0.8
226
+ },
227
+ "ductal": {
228
+ "cui": "C0007103", "icd10": "C50.919", "snomed": "413448000",
229
+ "label": "Ductal Carcinoma", "category": "Breast", "severity_weight": 0.95
230
+ },
231
+
232
+ # ── Thyroid / Neck ────────────────────────────────────────────────────
233
+ "goiter": {
234
+ "cui": "C0018021", "icd10": "E04.9", "snomed": "3716002",
235
+ "label": "Goiter", "category": "Thyroid", "severity_weight": 0.5
236
+ },
237
+ "thyroid nodule": {
238
+ "cui": "C0040136", "icd10": "E04.1", "snomed": "237495005",
239
+ "label": "Thyroid Nodule", "category": "Thyroid", "severity_weight": 0.6
240
+ },
241
+ "lymphadenopathy": {
242
+ "cui": "C0497156", "icd10": "R59.9", "snomed": "111590001",
243
+ "label": "Lymphadenopathy", "category": "Lymphatic", "severity_weight": 0.65
244
+ },
245
+
246
+ # ── Vascular ──────────────────────────────────────────────────────────
247
+ "aneurysm": {
248
+ "cui": "C0002940", "icd10": "I72.9", "snomed": "432119003",
249
+ "label": "Aneurysm", "category": "Vascular", "severity_weight": 0.9
250
+ },
251
+ "stenosis": {
252
+ "cui": "C1261287", "icd10": "I77.1", "snomed": "415068001",
253
+ "label": "Arterial Stenosis", "category": "Vascular", "severity_weight": 0.8
254
+ },
255
+ "thrombosis": {
256
+ "cui": "C0087086", "icd10": "I82.90", "snomed": "396347007",
257
+ "label": "Thrombosis", "category": "Vascular", "severity_weight": 0.85
258
+ },
259
+ "embolism": {
260
+ "cui": "C0014079", "icd10": "I74.9", "snomed": "59282003",
261
+ "label": "Pulmonary Embolism", "category": "Vascular", "severity_weight": 0.9
262
+ },
263
+
264
+ # ── Liver / Hepatobiliary ─────────────────────────────────────────────
265
+ "cirrhosis": {
266
+ "cui": "C0023895", "icd10": "K74.60", "snomed": "19943007",
267
+ "label": "Liver Cirrhosis", "category": "Hepatobiliary", "severity_weight": 0.8
268
+ },
269
+ "steatosis": {
270
+ "cui": "C0015695", "icd10": "K76.0", "snomed": "197315008",
271
+ "label": "Hepatic Steatosis", "category": "Hepatobiliary", "severity_weight": 0.55
272
+ },
273
+ "cholecystitis": {
274
+ "cui": "C0008325", "icd10": "K81.9", "snomed": "76581006",
275
+ "label": "Cholecystitis", "category": "Hepatobiliary", "severity_weight": 0.7
276
+ },
277
+
278
+ # ── Renal ─────────────────────────────────────────────────────────────
279
+ "hydronephrosis": {
280
+ "cui": "C0020295", "icd10": "N13.30", "snomed": "232895005",
281
+ "label": "Hydronephrosis", "category": "Renal", "severity_weight": 0.7
282
+ },
283
+ "renal cyst": {
284
+ "cui": "C0085413", "icd10": "N28.1", "snomed": "79411009",
285
+ "label": "Renal Cyst", "category": "Renal", "severity_weight": 0.3
286
+ },
287
+
288
+ # ── Spinal ────────────────────────────────────────────────────────────
289
+ "disc herniation": {
290
+ "cui": "C0242362", "icd10": "M51.16", "snomed": "73589001",
291
+ "label": "Disc Herniation", "category": "Spinal", "severity_weight": 0.65
292
+ },
293
+ "spondylosis": {
294
+ "cui": "C0038614", "icd10": "M47.816", "snomed": "41114007",
295
+ "label": "Spondylosis", "category": "Spinal", "severity_weight": 0.5
296
+ },
297
+ "spinal stenosis": {
298
+ "cui": "C0158288", "icd10": "M48.06", "snomed": "18946005",
299
+ "label": "Spinal Stenosis", "category": "Spinal", "severity_weight": 0.65
300
+ },
301
+ "compression fracture": {
302
+ "cui": "C0423697", "icd10": "M48.50", "snomed": "263057000",
303
+ "label": "Vertebral Compression Fracture", "category": "Spinal", "severity_weight": 0.75
304
+ },
305
+
306
+ # ── Paediatric / Obstetric ────────────────────────────────────────────
307
+ "placenta previa": {
308
+ "cui": "C0032046", "icd10": "O44.00", "snomed": "60758002",
309
+ "label": "Placenta Previa", "category": "Obstetric", "severity_weight": 0.85
310
+ },
311
+ "oligohydramnios": {
312
+ "cui": "C0030289", "icd10": "O41.00", "snomed": "59566000",
313
+ "label": "Oligohydramnios", "category": "Obstetric", "severity_weight": 0.8
314
+ },
315
+ }
316
+
317
+ # Severity level mapping
318
+ SEVERITY_LEVELS = {
319
+ (0.0, 0.0): "Normal",
320
+ (0.01, 0.39): "Minimal",
321
+ (0.4, 0.59): "Mild",
322
+ (0.6, 0.74): "Moderate",
323
+ (0.75, 0.89): "Significant",
324
+ (0.9, 1.0): "Critical",
325
+ }
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Data Models
330
+ # ---------------------------------------------------------------------------
331
+
332
+ @dataclass
333
+ class MedicalCode:
334
+ term: str
335
+ cui: str
336
+ icd10: str
337
+ snomed: str
338
+ label: str
339
+ category: str
340
+ severity_weight: float
341
+ context: str = "" # surrounding sentence
342
+
343
+ def to_dict(self) -> dict:
344
+ return {
345
+ "term_found": self.term,
346
+ "cui": self.cui,
347
+ "icd10": self.icd10,
348
+ "snomed": self.snomed,
349
+ "label": self.label,
350
+ "category": self.category,
351
+ "severity": self._severity_label(),
352
+ "context": self.context,
353
+ }
354
+
355
+ def _severity_label(self) -> str:
356
+ for (low, high), label in SEVERITY_LEVELS.items():
357
+ if low <= self.severity_weight <= high:
358
+ return label
359
+ return "Unknown"
360
+
361
+
362
+ @dataclass
363
+ class MedicalCodingResult:
364
+ codes: List[MedicalCode] = field(default_factory=list)
365
+ primary_diagnosis_cui: str = ""
366
+ primary_diagnosis_label: str = ""
367
+ icd10_codes: List[str] = field(default_factory=list)
368
+ snomed_codes: List[str] = field(default_factory=list)
369
+ categories_found: List[str] = field(default_factory=list)
370
+ overall_severity: str = "Normal"
371
+ coding_confidence: float = 0.0
372
+ raw_cuis: List[str] = field(default_factory=list)
373
+
374
+ def to_dict(self) -> dict:
375
+ return {
376
+ "medical_codes": [c.to_dict() for c in self.codes],
377
+ "primary_diagnosis": {
378
+ "cui": self.primary_diagnosis_cui,
379
+ "label": self.primary_diagnosis_label,
380
+ },
381
+ "icd10_codes": self.icd10_codes,
382
+ "snomed_codes": self.snomed_codes,
383
+ "categories_found": self.categories_found,
384
+ "overall_severity": self.overall_severity,
385
+ "coding_confidence": round(self.coding_confidence, 3),
386
+ "raw_cuis": self.raw_cuis,
387
+ "total_findings": len(self.codes),
388
+ }
389
+
390
+ def format_summary(self) -> str:
391
+ if not self.codes:
392
+ return "No significant medical concepts identified."
393
+
394
+ lines = [
395
+ f"**Primary Diagnosis:** {self.primary_diagnosis_label} "
396
+ f"[CUI: {self.primary_diagnosis_cui}]",
397
+ f"**Overall Severity:** {self.overall_severity}",
398
+ f"**Coding Confidence:** {self.coding_confidence:.0%}",
399
+ "",
400
+ "**Identified Medical Concepts:**",
401
+ ]
402
+ for code in self.codes:
403
+ lines.append(
404
+ f" • {code.label} | "
405
+ f"CUI: `{code.cui}` | "
406
+ f"ICD-10: `{code.icd10}` | "
407
+ f"Severity: {code._severity_label()}"
408
+ )
409
+
410
+ if self.icd10_codes:
411
+ lines.append(f"\n**ICD-10 Codes:** {', '.join(self.icd10_codes)}")
412
+ if self.snomed_codes:
413
+ lines.append(f"**SNOMED CT:** {', '.join(self.snomed_codes)}")
414
+
415
+ return "\n".join(lines)
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # Medical Coding Engine
420
+ # ---------------------------------------------------------------------------
421
+
422
+ class MedicalCodingEngine:
423
+ """
424
+ Extracts medical concepts from report text and maps to
425
+ UMLS CUIs, ICD-10, and SNOMED CT codes.
426
+ """
427
+
428
+ def extract_codes(self, report_text: str, sections: dict = None) -> MedicalCodingResult:
429
+ """
430
+ Main entry point.
431
+
432
+ Args:
433
+ report_text : Full report text or findings/impression combined
434
+ sections : Optional dict with 'findings', 'impression' keys
435
+
436
+ Returns:
437
+ MedicalCodingResult
438
+ """
439
+ # Combine findings + impression for analysis (most clinically relevant)
440
+ if sections:
441
+ analysis_text = (
442
+ sections.get("findings", "") + " " +
443
+ sections.get("impression", "") + " " +
444
+ sections.get("recommendations", "")
445
+ )
446
+ else:
447
+ analysis_text = report_text
448
+
449
+ analysis_text = analysis_text.lower()
450
+
451
+ # Extract matching concepts
452
+ found_codes: List[MedicalCode] = []
453
+ seen_cuis = set()
454
+
455
+ for term, concept in MEDICAL_CONCEPT_DB.items():
456
+ if term in analysis_text:
457
+ if concept["cui"] not in seen_cuis:
458
+ # Extract surrounding context
459
+ context = self._extract_context(analysis_text, term)
460
+ code = MedicalCode(
461
+ term=term,
462
+ cui=concept["cui"],
463
+ icd10=concept["icd10"],
464
+ snomed=concept["snomed"],
465
+ label=concept["label"],
466
+ category=concept["category"],
467
+ severity_weight=concept["severity_weight"],
468
+ context=context,
469
+ )
470
+ found_codes.append(code)
471
+ seen_cuis.add(concept["cui"])
472
+
473
+ # Sort by severity (most severe first)
474
+ found_codes.sort(key=lambda x: x.severity_weight, reverse=True)
475
+
476
+ # Build result
477
+ result = MedicalCodingResult()
478
+ result.codes = found_codes
479
+ result.raw_cuis = [c.cui for c in found_codes]
480
+ result.icd10_codes = list(dict.fromkeys(c.icd10 for c in found_codes))
481
+ result.snomed_codes = list(dict.fromkeys(c.snomed for c in found_codes))
482
+ result.categories_found = list(dict.fromkeys(c.category for c in found_codes))
483
+
484
+ # Primary diagnosis = highest severity non-normal finding
485
+ non_normal = [c for c in found_codes if c.category != "Normal"]
486
+ if non_normal:
487
+ primary = non_normal[0]
488
+ result.primary_diagnosis_cui = primary.cui
489
+ result.primary_diagnosis_label = primary.label
490
+ elif found_codes:
491
+ result.primary_diagnosis_cui = found_codes[0].cui
492
+ result.primary_diagnosis_label = found_codes[0].label
493
+
494
+ # Overall severity
495
+ if non_normal:
496
+ max_severity = max(c.severity_weight for c in non_normal)
497
+ for (low, high), label in SEVERITY_LEVELS.items():
498
+ if low <= max_severity <= high:
499
+ result.overall_severity = label
500
+ break
501
+ else:
502
+ result.overall_severity = "Normal"
503
+
504
+ # Coding confidence: based on number of matched terms
505
+ result.coding_confidence = min(len(found_codes) / 5.0, 1.0)
506
+
507
+ logger.info(
508
+ f"Medical coding: {len(found_codes)} concepts found | "
509
+ f"Primary: {result.primary_diagnosis_label} | "
510
+ f"Severity: {result.overall_severity}"
511
+ )
512
+ return result
513
+
514
+ def _extract_context(self, text: str, term: str, window: int = 80) -> str:
515
+ """Extract surrounding sentence for context."""
516
+ idx = text.find(term)
517
+ if idx == -1:
518
+ return ""
519
+ start = max(0, idx - window)
520
+ end = min(len(text), idx + len(term) + window)
521
+ context = text[start:end].strip()
522
+ return f"...{context}..."
523
+
524
+
525
+ # ---------------------------------------------------------------------------
526
+ # Convenience function
527
+ # ---------------------------------------------------------------------------
528
+
529
+ def extract_medical_codes(report_text: str, sections: dict = None) -> MedicalCodingResult:
530
+ engine = MedicalCodingEngine()
531
+ return engine.extract_codes(report_text, sections)
model_loader.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ model_loader.py
3
+ ---------------
4
+ Loads all AI models:
5
+ - LLM: epfl-llm/meditron-7b (4-bit NF4, ~5GB VRAM)
6
+ - Vision: Salesforce/blip2-opt-2.7b (float16, ~3.5GB VRAM)
7
+
8
+ VRAM budget on RTX 4060 8GB:
9
+ Meditron-7B 4-bit : ~5.0 GB
10
+ BLIP-2 : ~3.5 GB
11
+ NOTE: Run them sequentially (caption first, then LLM)
12
+ so both never sit in VRAM simultaneously.
13
+ Or use 8GB by loading BLIP-2, captioning, unloading,
14
+ then loading Meditron. This is handled automatically.
15
+ """
16
+
17
+ import gc
18
+ import logging
19
+ import os
20
+ from pathlib import Path
21
+
22
+ import torch
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ SLM_MODEL_ID = os.getenv("SLM_MODEL_ID", "BioMistral/BioMistral-7B")
27
+ VISION_MODEL_ID = os.getenv("VISION_MODEL_ID", "Salesforce/blip2-opt-2.7b")
28
+ LORA_ADAPTER_PATH = os.getenv("LORA_ADAPTER_PATH", "models/slm_model/lora_adapter")
29
+ USE_4BIT = os.getenv("USE_4BIT", "true").lower() == "true"
30
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
31
+
32
+ logger.info(f"Device: {DEVICE} | 4-bit: {USE_4BIT}")
33
+ if DEVICE == "cuda":
34
+ logger.info(
35
+ f"GPU: {torch.cuda.get_device_name(0)} | "
36
+ f"VRAM: {torch.cuda.get_device_properties(0).total_memory/1e9:.1f}GB"
37
+ )
38
+
39
+ _vision_model = None
40
+ _vision_processor = None
41
+ _slm_model = None
42
+ _slm_tokenizer = None
43
+
44
+
45
+ # ═══════════════════════════════════════════════════════════════════════════
46
+ # VISION — BLIP-2 (generates real image captions)
47
+ # ═══════════════════════════════════════════════════════════════════════════
48
+
49
+ def load_vision_encoder():
50
+ global _vision_model, _vision_processor
51
+ if _vision_model is not None:
52
+ return _vision_model, _vision_processor
53
+
54
+ logger.info(f"Loading BLIP-2: {VISION_MODEL_ID}")
55
+ from transformers import Blip2ForConditionalGeneration, Blip2Processor
56
+
57
+ _vision_processor = Blip2Processor.from_pretrained(
58
+ VISION_MODEL_ID, cache_dir="models/vision_encoder"
59
+ )
60
+ if DEVICE == "cuda":
61
+ _vision_model = Blip2ForConditionalGeneration.from_pretrained(
62
+ VISION_MODEL_ID,
63
+ torch_dtype=torch.float16,
64
+ device_map="auto",
65
+ cache_dir="models/vision_encoder",
66
+ )
67
+ else:
68
+ _vision_model = Blip2ForConditionalGeneration.from_pretrained(
69
+ VISION_MODEL_ID,
70
+ torch_dtype=torch.float32,
71
+ cache_dir="models/vision_encoder",
72
+ )
73
+ _vision_model.eval()
74
+ logger.info("BLIP-2 loaded.")
75
+ return _vision_model, _vision_processor
76
+
77
+
78
+ def unload_vision_encoder():
79
+ """Free VRAM after captioning so Meditron-7B can load."""
80
+ global _vision_model, _vision_processor
81
+ if _vision_model is not None:
82
+ del _vision_model
83
+ _vision_model = None
84
+ _vision_processor = None
85
+ if DEVICE == "cuda":
86
+ torch.cuda.empty_cache()
87
+ gc.collect()
88
+ logger.info("BLIP-2 unloaded — VRAM freed for Meditron.")
89
+
90
+
91
+ # ═══════════════════════════════════════════════════════════════════════════
92
+ # LLM — Meditron-7B (medical language model)
93
+ # ═══════════════════════════════════════════════════════════════════════════
94
+
95
+ def load_slm():
96
+ global _slm_model, _slm_tokenizer
97
+ if _slm_model is not None:
98
+ return _slm_model, _slm_tokenizer
99
+
100
+ logger.info(f"Loading LLM: {SLM_MODEL_ID} | 4-bit: {USE_4BIT}")
101
+ from transformers import AutoModelForCausalLM, AutoTokenizer
102
+
103
+ _slm_tokenizer = AutoTokenizer.from_pretrained(
104
+ SLM_MODEL_ID,
105
+ trust_remote_code=True,
106
+ padding_side="left",
107
+ cache_dir="models/slm_model",
108
+ )
109
+ if _slm_tokenizer.pad_token is None:
110
+ _slm_tokenizer.pad_token = _slm_tokenizer.eos_token
111
+
112
+ if USE_4BIT and DEVICE == "cuda":
113
+ from transformers import BitsAndBytesConfig
114
+ bnb = BitsAndBytesConfig(
115
+ load_in_4bit=True,
116
+ bnb_4bit_use_double_quant=True,
117
+ bnb_4bit_quant_type="nf4",
118
+ bnb_4bit_compute_dtype=torch.bfloat16,
119
+ )
120
+ _slm_model = AutoModelForCausalLM.from_pretrained(
121
+ SLM_MODEL_ID,
122
+ quantization_config=bnb,
123
+ device_map="auto",
124
+ trust_remote_code=True,
125
+ cache_dir="models/slm_model",
126
+ )
127
+ logger.info("Meditron-7B loaded 4-bit NF4 (~5GB VRAM)")
128
+ elif DEVICE == "cuda":
129
+ _slm_model = AutoModelForCausalLM.from_pretrained(
130
+ SLM_MODEL_ID,
131
+ torch_dtype=torch.float16,
132
+ device_map="auto",
133
+ trust_remote_code=True,
134
+ cache_dir="models/slm_model",
135
+ )
136
+ logger.info("Meditron-7B loaded float16 (~14GB VRAM)")
137
+ else:
138
+ logger.warning("No GPU — CPU inference will be very slow")
139
+ _slm_model = AutoModelForCausalLM.from_pretrained(
140
+ SLM_MODEL_ID,
141
+ torch_dtype=torch.float32,
142
+ trust_remote_code=True,
143
+ low_cpu_mem_usage=True,
144
+ cache_dir="models/slm_model",
145
+ )
146
+
147
+ _slm_model = _apply_lora_adapter(_slm_model)
148
+ _slm_model.eval()
149
+ logger.info("Meditron-7B ready.")
150
+ return _slm_model, _slm_tokenizer
151
+
152
+
153
+ def _apply_lora_adapter(model):
154
+ adapter_path = Path(LORA_ADAPTER_PATH)
155
+ if not (adapter_path / "adapter_config.json").exists():
156
+ logger.info("No LoRA adapter — using base Meditron-7B.")
157
+ return model
158
+ try:
159
+ from peft import PeftModel
160
+ model = PeftModel.from_pretrained(model, str(adapter_path))
161
+ model = model.merge_and_unload()
162
+ logger.info(f"LoRA adapter loaded from {adapter_path}")
163
+ except Exception as e:
164
+ logger.warning(f"LoRA load failed: {e} — using base model.")
165
+ return model
166
+
167
+
168
+ # ═══════════════════════════════════════════════════════════════════════════
169
+ # Image captioning — BLIP-2
170
+ # ═══════════════════════════════════════════════════════════════════════════
171
+
172
+ def generate_image_caption(pil_image, vision_model=None,
173
+ vision_processor=None) -> str:
174
+ if vision_model is None or vision_processor is None:
175
+ vision_model, vision_processor = load_vision_encoder()
176
+
177
+ prompt = (
178
+ "Question: Describe this medical radiology image in detail. "
179
+ "Include all visible anatomy, any hardware or implants, "
180
+ "any pathology or abnormalities, and the image quality. Answer:"
181
+ )
182
+ try:
183
+ inputs = vision_processor(
184
+ images=pil_image, text=prompt, return_tensors="pt"
185
+ )
186
+ if DEVICE == "cuda":
187
+ inputs = {
188
+ k: v.to(DEVICE).to(torch.float16)
189
+ if v.dtype == torch.float32 else v.to(DEVICE)
190
+ for k, v in inputs.items()
191
+ }
192
+ with torch.no_grad():
193
+ ids = vision_model.generate(
194
+ **inputs,
195
+ max_new_tokens=150,
196
+ num_beams=4,
197
+ repetition_penalty=1.3,
198
+ )
199
+ caption = vision_processor.decode(ids[0], skip_special_tokens=True)
200
+ if "Answer:" in caption:
201
+ caption = caption.split("Answer:")[-1].strip()
202
+ logger.info(f"BLIP-2 caption: {caption[:150]}")
203
+ return caption
204
+ except Exception as e:
205
+ logger.warning(f"BLIP-2 failed: {e}")
206
+ return ""
207
+
208
+
209
+ def extract_visual_features(pil_image, vision_model=None,
210
+ vision_processor=None):
211
+ caption = generate_image_caption(pil_image, vision_model, vision_processor)
212
+ return None, caption
preprocessing.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ preprocessing.py
3
+ ----------------
4
+ Full radiology image preprocessing pipeline.
5
+ Covers ALL modalities: X-ray, CT, MRI, Ultrasound, PET, Mammography,
6
+ Fluoroscopy, Nuclear Medicine, DEXA, Angiography.
7
+ Supports DICOM (.dcm), PNG, JPG.
8
+ """
9
+
10
+ import io
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import cv2
16
+ import numpy as np
17
+ from PIL import Image
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class RadiologyPreprocessor:
23
+ TARGET_SIZE = (224, 224)
24
+
25
+ # ── Window presets for every modality + region combination ──────────
26
+ WINDOW_PRESETS = {
27
+
28
+ # X-RAY
29
+ "xray": {"wc": 40, "ww": 400},
30
+ "xray_chest": {"wc": -600, "ww": 1500},
31
+ "xray_abdomen": {"wc": 60, "ww": 400},
32
+ "xray_pelvis": {"wc": 40, "ww": 400},
33
+ "xray_spine": {"wc": 40, "ww": 400},
34
+ "xray_knee": {"wc": 40, "ww": 400},
35
+ "xray_shoulder": {"wc": 40, "ww": 400},
36
+ "xray_hand": {"wc": 40, "ww": 350},
37
+ "xray_foot": {"wc": 40, "ww": 350},
38
+ "xray_wrist": {"wc": 40, "ww": 350},
39
+ "xray_ankle": {"wc": 40, "ww": 350},
40
+ "xray_hip": {"wc": 40, "ww": 400},
41
+ "xray_skull": {"wc": 35, "ww": 80},
42
+ "xray_neck": {"wc": 40, "ww": 300},
43
+
44
+ # CT
45
+ "ct": {"wc": 40, "ww": 400},
46
+ "ct_chest": {"wc": -600, "ww": 1500},
47
+ "ct_abdomen": {"wc": 50, "ww": 350},
48
+ "ct_pelvis": {"wc": 50, "ww": 350},
49
+ "ct_brain": {"wc": 35, "ww": 80},
50
+ "ct_brain_bone": {"wc": 400, "ww": 2000},
51
+ "ct_brain_subdural": {"wc": 75, "ww": 215},
52
+ "ct_spine": {"wc": 400, "ww": 1800},
53
+ "ct_spine_soft": {"wc": 50, "ww": 350},
54
+ "ct_liver": {"wc": 60, "ww": 160},
55
+ "ct_lung": {"wc": -600, "ww": 1500},
56
+ "ct_mediastinum": {"wc": 50, "ww": 350},
57
+ "ct_bone": {"wc": 400, "ww": 1800},
58
+ "ct_angiography": {"wc": 300, "ww": 600},
59
+ "ct_cardiac": {"wc": 50, "ww": 350},
60
+ "ct_neck": {"wc": 50, "ww": 350},
61
+ "ct_kidney": {"wc": 50, "ww": 350},
62
+ "ct_pancreas": {"wc": 50, "ww": 250},
63
+ "ct_adrenal": {"wc": 35, "ww": 150},
64
+
65
+ # MRI
66
+ "mri": {"wc": 128, "ww": 256},
67
+ "mri_brain": {"wc": 128, "ww": 256},
68
+ "mri_spine": {"wc": 128, "ww": 256},
69
+ "mri_knee": {"wc": 128, "ww": 256},
70
+ "mri_shoulder": {"wc": 128, "ww": 256},
71
+ "mri_abdomen": {"wc": 128, "ww": 256},
72
+ "mri_pelvis": {"wc": 128, "ww": 256},
73
+ "mri_cardiac": {"wc": 128, "ww": 256},
74
+ "mri_breast": {"wc": 128, "ww": 256},
75
+ "mri_liver": {"wc": 128, "ww": 256},
76
+ "mri_prostate": {"wc": 128, "ww": 256},
77
+ "mri_neck": {"wc": 128, "ww": 256},
78
+ "mri_hip": {"wc": 128, "ww": 256},
79
+ "mri_ankle": {"wc": 128, "ww": 256},
80
+ "mri_wrist": {"wc": 128, "ww": 256},
81
+ "mri_fetal": {"wc": 128, "ww": 256},
82
+
83
+ # ULTRASOUND
84
+ "ultrasound": {"wc": 128, "ww": 256},
85
+ "ultrasound_abdomen": {"wc": 128, "ww": 256},
86
+ "ultrasound_pelvis": {"wc": 128, "ww": 256},
87
+ "ultrasound_thyroid": {"wc": 128, "ww": 256},
88
+ "ultrasound_breast": {"wc": 128, "ww": 256},
89
+ "ultrasound_cardiac": {"wc": 128, "ww": 256},
90
+ "ultrasound_obstetric": {"wc": 128, "ww": 256},
91
+ "ultrasound_vascular": {"wc": 128, "ww": 256},
92
+ "ultrasound_neck": {"wc": 128, "ww": 256},
93
+ "ultrasound_liver": {"wc": 128, "ww": 256},
94
+ "ultrasound_kidney": {"wc": 128, "ww": 256},
95
+ "ultrasound_testicular": {"wc": 128, "ww": 256},
96
+ "ultrasound_neonatal": {"wc": 128, "ww": 256},
97
+
98
+ # PET / NUCLEAR MEDICINE
99
+ "pet": {"wc": 128, "ww": 256},
100
+ "pet_brain": {"wc": 128, "ww": 256},
101
+ "pet_body": {"wc": 128, "ww": 256},
102
+ "nuclear medicine": {"wc": 128, "ww": 256},
103
+ "spect": {"wc": 128, "ww": 256},
104
+
105
+ # MAMMOGRAPHY
106
+ "mammography": {"wc": 1500, "ww": 6000},
107
+ "mammography_breast": {"wc": 1500, "ww": 6000},
108
+
109
+ # FLUOROSCOPY
110
+ "fluoroscopy": {"wc": 40, "ww": 400},
111
+ "fluoroscopy_gi": {"wc": 40, "ww": 400},
112
+
113
+ # ANGIOGRAPHY
114
+ "angiography": {"wc": 300, "ww": 600},
115
+ "angiography_cardiac": {"wc": 300, "ww": 600},
116
+ "angiography_cerebral": {"wc": 300, "ww": 600},
117
+
118
+ # DEXA (Bone Density)
119
+ "dexa": {"wc": 128, "ww": 256},
120
+
121
+ # Default fallback
122
+ "default": {"wc": 127, "ww": 255},
123
+ }
124
+
125
+ # ── Modality-specific enhancement settings ───────────────────────────
126
+ ENHANCEMENT_SETTINGS = {
127
+ "xray": {"clahe": True, "clahe_clip": 3.0, "denoise": False},
128
+ "ct": {"clahe": False, "clahe_clip": 2.0, "denoise": False},
129
+ "mri": {"clahe": True, "clahe_clip": 2.0, "denoise": True},
130
+ "ultrasound": {"clahe": True, "clahe_clip": 4.0, "denoise": True},
131
+ "pet": {"clahe": False, "clahe_clip": 2.0, "denoise": False},
132
+ "mammography": {"clahe": True, "clahe_clip": 2.5, "denoise": True},
133
+ "fluoroscopy": {"clahe": True, "clahe_clip": 3.0, "denoise": False},
134
+ "angiography": {"clahe": True, "clahe_clip": 2.0, "denoise": False},
135
+ "dexa": {"clahe": False, "clahe_clip": 2.0, "denoise": False},
136
+ "default": {"clahe": True, "clahe_clip": 2.0, "denoise": False},
137
+ }
138
+
139
+ def __init__(self, modality: str = "xray", body_region: str = "chest"):
140
+ self.modality = modality.lower().replace("-", " ").replace("_", " ")
141
+ self.body_region = body_region.lower().replace("-", " ")
142
+ self._window_preset = self._select_window_preset()
143
+ self._enhancement = self._select_enhancement()
144
+
145
+ # ------------------------------------------------------------------
146
+ # Public API
147
+ # ------------------------------------------------------------------
148
+
149
+ def preprocess(self, file_bytes: bytes, filename: str) -> np.ndarray:
150
+ """Returns float32 numpy array (3, 224, 224) normalised to [0, 1]."""
151
+ ext = Path(filename).suffix.lower()
152
+ if ext == ".dcm":
153
+ image = self._load_dicom(file_bytes)
154
+ elif ext in {".png", ".jpg", ".jpeg"}:
155
+ image = self._load_standard_image(file_bytes)
156
+ else:
157
+ raise ValueError(f"Unsupported format: {ext}. Use .dcm, .png, or .jpg")
158
+
159
+ image = self._apply_modality_normalisation(image)
160
+ image = self._apply_enhancement(image)
161
+ image = self._resize(image)
162
+ return self._to_tensor(image)
163
+
164
+ def preprocess_pil(self, file_bytes: bytes, filename: str) -> Image.Image:
165
+ """Returns a PIL Image for model input."""
166
+ tensor = self.preprocess(file_bytes, filename)
167
+ arr = (tensor.transpose(1, 2, 0) * 255).astype(np.uint8)
168
+ return Image.fromarray(arr)
169
+
170
+ # ------------------------------------------------------------------
171
+ # Private
172
+ # ------------------------------------------------------------------
173
+
174
+ def _select_window_preset(self) -> dict:
175
+ # Try modality_region key first
176
+ mod_key = self.modality.replace(" ", "_")
177
+ reg_key = self.body_region.replace(" ", "_")
178
+ combined = f"{mod_key}_{reg_key}"
179
+
180
+ if combined in self.WINDOW_PRESETS:
181
+ return self.WINDOW_PRESETS[combined]
182
+ if mod_key in self.WINDOW_PRESETS:
183
+ return self.WINDOW_PRESETS[mod_key]
184
+ return self.WINDOW_PRESETS["default"]
185
+
186
+ def _select_enhancement(self) -> dict:
187
+ mod_key = self.modality.replace(" ", "_").split("_")[0]
188
+ return self.ENHANCEMENT_SETTINGS.get(
189
+ mod_key, self.ENHANCEMENT_SETTINGS["default"]
190
+ )
191
+
192
+ def _load_dicom(self, file_bytes: bytes) -> np.ndarray:
193
+ try:
194
+ import pydicom
195
+ except ImportError:
196
+ raise RuntimeError("Install pydicom: pip install pydicom")
197
+
198
+ ds = pydicom.dcmread(io.BytesIO(file_bytes))
199
+ pixel_array = ds.pixel_array.astype(np.float32)
200
+
201
+ slope = float(getattr(ds, "RescaleSlope", 1))
202
+ intercept = float(getattr(ds, "RescaleIntercept", 0))
203
+ pixel_array = pixel_array * slope + intercept
204
+
205
+ # Try DICOM tags first, fall back to preset
206
+ wc = float(getattr(ds, "WindowCenter",
207
+ self._window_preset["wc"]))
208
+ ww = float(getattr(ds, "WindowWidth",
209
+ self._window_preset["ww"]))
210
+
211
+ try:
212
+ import pydicom.multival
213
+ if isinstance(wc, pydicom.multival.MultiValue):
214
+ wc = float(wc[0])
215
+ if isinstance(ww, pydicom.multival.MultiValue):
216
+ ww = float(ww[0])
217
+ except Exception:
218
+ pass
219
+
220
+ return self._apply_windowing(pixel_array, wc, ww)
221
+
222
+ def _load_standard_image(self, file_bytes: bytes) -> np.ndarray:
223
+ arr = np.frombuffer(file_bytes, dtype=np.uint8)
224
+ img = cv2.imdecode(arr, cv2.IMREAD_GRAYSCALE)
225
+ if img is None:
226
+ raise ValueError("Could not decode image.")
227
+ return img.astype(np.float32)
228
+
229
+ @staticmethod
230
+ def _apply_windowing(pixel_array, wc, ww) -> np.ndarray:
231
+ lower = wc - ww / 2
232
+ upper = wc + ww / 2
233
+ windowed = np.clip(pixel_array, lower, upper)
234
+ windowed = (windowed - lower) / (ww + 1e-8)
235
+ return (windowed * 255).astype(np.float32)
236
+
237
+ def _apply_modality_normalisation(self, image: np.ndarray) -> np.ndarray:
238
+ p2, p98 = np.percentile(image, [2, 98])
239
+ image = np.clip(image, p2, p98)
240
+ image = (image - p2) / (p98 - p2 + 1e-8) * 255.0
241
+ return image.astype(np.float32)
242
+
243
+ def _apply_enhancement(self, image: np.ndarray) -> np.ndarray:
244
+ """Apply CLAHE and/or denoising based on modality."""
245
+ img_uint8 = np.clip(image, 0, 255).astype(np.uint8)
246
+
247
+ if self._enhancement.get("denoise"):
248
+ img_uint8 = cv2.fastNlMeansDenoising(img_uint8, h=10)
249
+
250
+ if self._enhancement.get("clahe"):
251
+ clip = self._enhancement.get("clahe_clip", 2.0)
252
+ clahe = cv2.createCLAHE(clipLimit=clip, tileGridSize=(8, 8))
253
+ img_uint8 = clahe.apply(img_uint8)
254
+
255
+ return img_uint8.astype(np.float32)
256
+
257
+ def _resize(self, image: np.ndarray) -> np.ndarray:
258
+ h, w = self.TARGET_SIZE
259
+ return cv2.resize(image, (w, h), interpolation=cv2.INTER_LANCZOS4)
260
+
261
+ def _to_tensor(self, image: np.ndarray) -> np.ndarray:
262
+ normalised = image / 255.0
263
+ return np.stack([normalised, normalised, normalised], axis=0).astype(np.float32)
report_generator.py ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ report_generator.py v6 — BioMistral-7B powered
3
+ ------------------------------------------------
4
+ Complete file with all methods intact.
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ import time
10
+ from dataclasses import dataclass
11
+
12
+ import torch
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class PatientInfo:
19
+ age: int
20
+ gender: str
21
+ symptoms: str
22
+ clinical_history: str
23
+
24
+
25
+ @dataclass
26
+ class ImagingInfo:
27
+ modality: str
28
+ body_region: str
29
+ view: str = ""
30
+
31
+
32
+ @dataclass
33
+ class RadiologyReport:
34
+ examination_details: str = ""
35
+ clinical_information: str = ""
36
+ findings: str = ""
37
+ impression: str = ""
38
+ recommendations: str = ""
39
+ confidence_score: float = 0.0
40
+ generation_time_seconds: float = 0.0
41
+ raw_text: str = ""
42
+ image_caption: str = ""
43
+
44
+ def to_dict(self) -> dict:
45
+ return {
46
+ "examination_details": self.examination_details,
47
+ "clinical_information": self.clinical_information,
48
+ "findings": self.findings,
49
+ "impression": self.impression,
50
+ "recommendations": self.recommendations,
51
+ "confidence_score": self.confidence_score,
52
+ "generation_time_seconds": self.generation_time_seconds,
53
+ "image_caption": self.image_caption,
54
+ }
55
+
56
+ def to_text(self) -> str:
57
+ return "\n".join([
58
+ "RADIOLOGY REPORT",
59
+ "=" * 60,
60
+ "",
61
+ "EXAMINATION DETAILS:",
62
+ self.examination_details,
63
+ "",
64
+ "CLINICAL INFORMATION:",
65
+ self.clinical_information,
66
+ "",
67
+ "FINDINGS:",
68
+ self.findings,
69
+ "",
70
+ "IMPRESSION:",
71
+ self.impression,
72
+ "",
73
+ "RECOMMENDATIONS:",
74
+ self.recommendations,
75
+ "",
76
+ f"CONFIDENCE SCORE: {self.confidence_score:.2f}",
77
+ "",
78
+ "=" * 60,
79
+ "END OF REPORT",
80
+ ])
81
+
82
+
83
+ REGION_FOCUS = {
84
+ "chest": "lung fields, cardiac silhouette, mediastinum, pleural spaces, bony thorax",
85
+ "brain": "brain parenchyma, ventricles, sulci, white matter, midline structures, posterior fossa",
86
+ "abdomen": "liver, spleen, kidneys, pancreas, bowel loops, peritoneal spaces",
87
+ "pelvis": "pelvic bones, hip joints, bladder, iliac crests, femoral heads, any fixation hardware",
88
+ "spine": "vertebral bodies, disc spaces, spinal canal, neural foramina, alignment",
89
+ "knee": "femoral condyles, tibial plateau, patella, joint space, menisci",
90
+ "shoulder": "glenohumeral joint, rotator cuff, humeral head, acromion",
91
+ "neck": "cervical spine, soft tissues, thyroid, lymph nodes",
92
+ "breast": "fibroglandular tissue, lesions, microcalcifications, axillary nodes",
93
+ "cardiac": "cardiac chambers, great vessels, pericardium",
94
+ "liver": "hepatic parenchyma, biliary system, portal veins",
95
+ "thyroid": "thyroid lobes, echogenicity, nodules, vascularity",
96
+ "obstetric": "fetal anatomy, placenta, amniotic fluid, cervix",
97
+ }
98
+
99
+
100
+ def build_meditron_prompt(patient: PatientInfo, imaging: ImagingInfo,
101
+ image_caption: str) -> str:
102
+ view_str = f", {imaging.view} view" if imaging.view else ""
103
+ focus = REGION_FOCUS.get(imaging.body_region.lower(),
104
+ f"{imaging.body_region} anatomy and pathology")
105
+ vision_line = ""
106
+ if image_caption and len(image_caption) > 20:
107
+ vision_line = f"\nIMAGE DESCRIPTION: {image_caption}"
108
+
109
+ prompt = (
110
+ f"### Instruction:\n"
111
+ f"You are a consultant radiologist. Write a structured radiology report "
112
+ f"using standard clinical terminology. Be concise and specific. "
113
+ f"Only report what is visible. Do not speculate beyond the findings.\n\n"
114
+ f"STUDY: {imaging.modality} {imaging.body_region}{view_str}\n"
115
+ f"PATIENT: {patient.age}-year-old {patient.gender}\n"
116
+ f"CLINICAL INDICATION: {patient.symptoms}\n"
117
+ f"HISTORY: {patient.clinical_history}\n"
118
+ f"KEY STRUCTURES: {focus}{vision_line}\n\n"
119
+ f"Write exactly three sections:\n"
120
+ f"FINDINGS: (2-4 sentences describing what is seen)\n"
121
+ f"IMPRESSION: (1-2 sentences — the diagnosis or conclusion)\n"
122
+ f"RECOMMENDATIONS: (1-2 sentences — next clinical steps)\n\n"
123
+ f"### Response:\n"
124
+ f"FINDINGS: The {imaging.body_region} demonstrates"
125
+ )
126
+ return prompt
127
+
128
+
129
+ class ReportGenerator:
130
+ MAX_NEW_TOKENS = 300
131
+ TEMPERATURE = 0.1
132
+ TOP_P = 0.85
133
+ REPETITION_PENALTY = 1.2
134
+
135
+ def __init__(self, slm_model=None, slm_tokenizer=None):
136
+ self.model = slm_model
137
+ self.tokenizer = slm_tokenizer
138
+ self._device = "cuda" if torch.cuda.is_available() else "cpu"
139
+
140
+ def generate(self, patient: PatientInfo, imaging: ImagingInfo,
141
+ image_caption: str = "", visual_features=None) -> RadiologyReport:
142
+ prompt = build_meditron_prompt(patient, imaging, image_caption)
143
+ start = time.time()
144
+ raw = self._run_inference(prompt)
145
+ elapsed = time.time() - start
146
+ logger.info(f"Raw (250c): {raw[:250]}")
147
+ report = self._parse(raw, patient, imaging)
148
+ self._validate_consistency(report, imaging)
149
+ report.raw_text = raw
150
+ report.image_caption = image_caption
151
+ report.generation_time_seconds = round(elapsed, 2)
152
+ report.confidence_score = self._score(report, imaging)
153
+ return report
154
+
155
+ # ─────────────────────────────────────────────────────────────────
156
+ # Inference
157
+ # ─────────────────────────────────────────────────────────────────
158
+
159
+ def _run_inference(self, prompt: str) -> str:
160
+ inputs = self.tokenizer(
161
+ prompt,
162
+ return_tensors="pt",
163
+ truncation=True,
164
+ max_length=800,
165
+ padding=False,
166
+ ).to(self._device)
167
+
168
+ with torch.no_grad():
169
+ out = self.model.generate(
170
+ **inputs,
171
+ max_new_tokens=self.MAX_NEW_TOKENS,
172
+ temperature=self.TEMPERATURE,
173
+ top_p=self.TOP_P,
174
+ repetition_penalty=self.REPETITION_PENALTY,
175
+ do_sample=True,
176
+ pad_token_id=self.tokenizer.eos_token_id,
177
+ eos_token_id=self.tokenizer.eos_token_id,
178
+ )
179
+ new = out[0][inputs["input_ids"].shape[1]:]
180
+ return self.tokenizer.decode(new, skip_special_tokens=True)
181
+
182
+ # ─────────────────────────────────────────────────────────────────
183
+ # Text cleaning
184
+ # ─────────────────────────────────────────────────────────────────
185
+
186
+ def _clean(self, text: str) -> str:
187
+ text = re.sub(r"```[\s\S]*?```", " ", text)
188
+ text = re.sub(r"^###.*$", "", text, flags=re.MULTILINE)
189
+ text = re.sub(r"\n{3,}", "\n\n", text)
190
+ text = re.sub(r"[ \t]{2,}", " ", text)
191
+ return text.strip()
192
+
193
+ def _cut_at_sentences(self, text: str, max_sent: int = 4) -> str:
194
+ sentences = re.split(r'(?<=[.!?])\s+', text.strip())
195
+ good = []
196
+ for s in sentences:
197
+ s = s.strip()
198
+ if len(s) < 8:
199
+ continue
200
+ good.append(s)
201
+ if len(good) >= max_sent:
202
+ break
203
+ return " ".join(good)
204
+
205
+ def _extract(self, text: str, section: str, max_sent: int = 3) -> str:
206
+ aliases = {
207
+ "findings": ["FINDINGS", "Findings"],
208
+ "impression": ["IMPRESSION", "Impression", "CONCLUSION",
209
+ "Conclusion", "DIAGNOSIS"],
210
+ "recommendations": ["RECOMMENDATIONS", "Recommendations",
211
+ "RECOMMENDATION", "PLAN", "Plan"],
212
+ }
213
+ all_hdrs = [h for v in aliases.values() for h in v]
214
+ headers = aliases.get(section, [section.upper()])
215
+ stopper = "|".join(re.escape(h) for h in all_hdrs if h not in headers)
216
+
217
+ for hdr in headers:
218
+ pat = (rf"(?:^|\n){re.escape(hdr)}\s*:?\s*(.*?)"
219
+ rf"(?=\n(?:{stopper})\s*:?|\Z)")
220
+ m = re.search(pat, text, re.DOTALL | re.IGNORECASE)
221
+ if m:
222
+ val = self._clean(m.group(1))
223
+ val = self._cut_at_sentences(val, max_sent)
224
+ val = re.sub(r"\s{2,}", " ", val).strip("- \n")
225
+ if len(val) > 15:
226
+ return val
227
+ return ""
228
+
229
+ # ─────────────────────────────────────────────────────────────────
230
+ # Parsing
231
+ # ─────────────────────────────────────────────────────────────────
232
+
233
+ def _parse(self, raw: str, patient: PatientInfo,
234
+ imaging: ImagingInfo) -> RadiologyReport:
235
+ report = RadiologyReport()
236
+ view_str = f", {imaging.view} view" if imaging.view else ""
237
+
238
+ report.examination_details = (
239
+ f"{imaging.modality} examination of the {imaging.body_region}"
240
+ f"{view_str} was performed."
241
+ )
242
+ # Fix double periods (e.g. "infection.." -> "infection.")
243
+ symptoms_clean = patient.symptoms.rstrip(". ")
244
+ history_clean = patient.clinical_history.rstrip(". ")
245
+ report.clinical_information = (
246
+ f"{patient.age}-year-old {patient.gender} presenting with "
247
+ f"{symptoms_clean}. History: {history_clean}."
248
+ )
249
+
250
+ full = self._clean(
251
+ f"FINDINGS: The {imaging.body_region} demonstrates " + raw
252
+ )
253
+
254
+ report.findings = self._extract(full, "findings", 3)
255
+ report.impression = self._extract(full, "impression", 2)
256
+ report.recommendations = self._extract(full, "recommendations", 2)
257
+
258
+ # Findings fallback
259
+ if len(report.findings) < 15:
260
+ lines = [l.strip() for l in full.split("\n") if len(l.strip()) > 30]
261
+ if lines:
262
+ candidate = re.sub(r"^FINDINGS\s*:\s*", "", lines[0], flags=re.I)
263
+ report.findings = self._cut_at_sentences(candidate, 3)
264
+
265
+ # Impression fallback
266
+ if len(report.impression) < 15:
267
+ f_lower = report.findings.lower()
268
+ abnormal_keywords = [
269
+ "opacity", "opacities", "ground glass", "consolidation",
270
+ "lesion", "mass", "nodule", "effusion", "infiltrate",
271
+ "atelectasis", "fibrosis", "infection",
272
+ ]
273
+ normal_keywords = [
274
+ "no acute", "no evidence", "unremarkable",
275
+ "within normal", "normal", "intact",
276
+ ]
277
+ if any(w in f_lower for w in abnormal_keywords):
278
+ report.impression = (
279
+ f"Abnormal findings in the {imaging.body_region.lower()} as described. "
280
+ f"Clinical correlation is recommended."
281
+ )
282
+ elif any(w in f_lower for w in normal_keywords):
283
+ report.impression = (
284
+ f"No acute {imaging.body_region.lower()} abnormality identified. "
285
+ f"Clinical correlation is recommended."
286
+ )
287
+ else:
288
+ report.impression = (
289
+ f"Findings in the {imaging.body_region.lower()} as described. "
290
+ f"Clinical correlation is recommended."
291
+ )
292
+
293
+ # Recommendations fallback
294
+ if len(report.recommendations) < 15:
295
+ report.recommendations = (
296
+ "Clinical correlation with the patient's history and examination "
297
+ "findings is recommended. Follow-up imaging as clinically indicated."
298
+ )
299
+
300
+ return report
301
+
302
+ # ─────────────────────────────────────────────────────────────────
303
+ # Consistency check
304
+ # ─────────────────────────────────────────────────────────────────
305
+
306
+ def _validate_consistency(self, report: RadiologyReport,
307
+ imaging: ImagingInfo):
308
+ f = report.findings.lower()
309
+ imp = report.impression.lower()
310
+ abnormal_keywords = [
311
+ "opacity", "opacities", "ground glass", "consolidation",
312
+ "lesion", "mass", "nodule", "effusion", "infiltrate",
313
+ ]
314
+ if any(w in f for w in abnormal_keywords):
315
+ if any(x in imp for x in ["no acute", "normal", "unremarkable"]):
316
+ report.impression = self._build_specific_impression(
317
+ report, imaging
318
+ )
319
+
320
+ def _build_specific_impression(self, report: RadiologyReport,
321
+ imaging: ImagingInfo) -> str:
322
+ """
323
+ Builds a specific, clinically meaningful impression by:
324
+ 1. Extracting the key finding from FINDINGS
325
+ 2. Incorporating clinical history context
326
+ 3. Suggesting a differential diagnosis
327
+ """
328
+ f = report.findings.lower()
329
+ hist = report.clinical_information.lower()
330
+ region = imaging.body_region.lower()
331
+
332
+ # ── Identify key finding ───────────────────────────────────
333
+ if "ground glass" in f or "ggo" in f:
334
+ key_finding = "Ground-glass opacities"
335
+ if "bilateral" in f or "both lung" in f:
336
+ key_finding = "Bilateral ground-glass opacities"
337
+ elif "consolidation" in f:
338
+ key_finding = "Airspace consolidation"
339
+ if "bilateral" in f:
340
+ key_finding = "Bilateral consolidation"
341
+ elif "right" in f:
342
+ key_finding = "Right-sided consolidation"
343
+ elif "left" in f:
344
+ key_finding = "Left-sided consolidation"
345
+ elif "opacity" in f or "opacities" in f:
346
+ key_finding = "Pulmonary opacities"
347
+ if "bilateral" in f:
348
+ key_finding = "Bilateral pulmonary opacities"
349
+ elif "nodule" in f:
350
+ key_finding = "Pulmonary nodule(s)"
351
+ elif "effusion" in f:
352
+ key_finding = "Pleural effusion"
353
+ if "bilateral" in f:
354
+ key_finding = "Bilateral pleural effusions"
355
+ elif "mass" in f or "lesion" in f:
356
+ key_finding = f"Focal {region} lesion"
357
+ elif "infiltrate" in f:
358
+ key_finding = "Pulmonary infiltrates"
359
+ else:
360
+ key_finding = f"Abnormal {region} findings"
361
+
362
+ # ── Clinical context from history ──────────────────────────
363
+ context = ""
364
+ if any(w in hist for w in ["immunosuppress", "immunocompromis",
365
+ "mycophenol", "steroid", "transplant",
366
+ "hiv", "aids", "chemotherapy"]):
367
+ context = (
368
+ f" In the context of immunosuppression, opportunistic "
369
+ f"infection (e.g. PCP, fungal, CMV pneumonitis) should "
370
+ f"be considered."
371
+ )
372
+ elif any(w in hist for w in ["infection", "pneumonia", "fever", "cough"]):
373
+ context = (
374
+ f" Findings are consistent with an infective or "
375
+ f"inflammatory aetiology."
376
+ )
377
+ elif any(w in hist for w in ["malignancy", "cancer", "tumour",
378
+ "tumor", "oncology", "lymphoma"]):
379
+ context = (
380
+ f" In the context of known malignancy, metastatic "
381
+ f"disease should be considered."
382
+ )
383
+ elif any(w in hist for w in ["autoimmune", "lupus", "rheumatoid",
384
+ "sarcoid", "vasculitis"]):
385
+ context = (
386
+ f" In the context of autoimmune disease, an inflammatory "
387
+ f"or drug-related aetiology should be considered."
388
+ )
389
+
390
+ # ── Build impression ───────────────────────────────────────
391
+ impression = (
392
+ f"{key_finding} as described above, likely representing "
393
+ f"an infective or inflammatory process.{context} "
394
+ f"Clinical correlation is recommended."
395
+ )
396
+ return impression
397
+
398
+ # ─────────────────────────────────────────────────────────────────
399
+ # Confidence scoring
400
+ # ─────────────────────────────────────────────────────────────────
401
+
402
+ def _score(self, report: RadiologyReport, imaging: ImagingInfo) -> float:
403
+ score = 0.45
404
+ f = report.findings
405
+
406
+ if len(f) > 40: score += 0.10
407
+ if len(f) > 100: score += 0.05
408
+
409
+ anatomy = {
410
+ "brain": ["parenchyma", "ventricle", "sulci", "cortex",
411
+ "signal", "midline", "fossa", "white matter"],
412
+ "chest": ["lung", "cardiac", "pleural", "mediastinum",
413
+ "consolidation", "silhouette", "costophrenic"],
414
+ "pelvis": ["femoral", "acetabul", "iliac", "hip", "sacrum",
415
+ "hardware", "screw", "fixation", "prosthes"],
416
+ "abdomen": ["liver", "spleen", "kidney", "bowel",
417
+ "pancreas", "peritoneal", "aorta"],
418
+ "spine": ["vertebr", "disc", "canal", "foramina",
419
+ "alignment", "cord", "facet"],
420
+ "knee": ["condyle", "meniscus", "patella", "tibial",
421
+ "ligament", "joint space"],
422
+ }
423
+ region_key = imaging.body_region.lower()
424
+ terms = anatomy.get(region_key, [])
425
+ matched = sum(1 for t in terms if t in f.lower())
426
+ score += min(matched * 0.03, 0.15)
427
+
428
+ imp = report.impression
429
+ if len(imp) > 30: score += 0.08
430
+ good = ["consistent with", "no evidence", "no acute", "identified",
431
+ "findings", "normal", "unremarkable", "in keeping with"]
432
+ if any(p in imp.lower() for p in good):
433
+ score += 0.08
434
+ if "clinical presentation of a" not in imp:
435
+ score += 0.05
436
+
437
+ if len(report.recommendations) > 20:
438
+ score += 0.04
439
+
440
+ return round(max(0.45, min(0.88, score)), 2)
requirements.txt ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # Radiology AI System — Requirements
3
+ # Meditron-7B + BLIP-2 + RTX 4060 8GB
4
+ # ============================================================
5
+
6
+ # Core API
7
+ fastapi>=0.111.0
8
+ uvicorn[standard]>=0.29.0
9
+ python-multipart>=0.0.9
10
+
11
+ # AI / ML — install torch separately with CUDA first
12
+ transformers==4.51.3
13
+ accelerate>=0.34.0
14
+ bitsandbytes>=0.43.0
15
+ peft>=0.11.0
16
+ sentencepiece>=0.2.0
17
+ protobuf>=4.25.0
18
+
19
+ # Fine-tuning
20
+ unsloth
21
+ unsloth_zoo
22
+ trl==0.18.2
23
+ datasets==4.3.0
24
+
25
+ # Image processing
26
+ Pillow>=10.3.0
27
+ opencv-python>=4.9.0
28
+ numpy>=1.26.0
29
+
30
+ # Frontend
31
+ gradio>=4.44.0
32
+
33
+ # PDF export
34
+ reportlab>=4.2.0
35
+
36
+ # Utilities
37
+ python-dotenv>=1.0.0
38
+ pandas>=2.2.0
39
+ requests>=2.32.0
40
+ tqdm>=4.66.0
41
+ msgspec
42
+ torchao
43
+ triton-windows
run_backend.bat ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ echo Starting Radiology AI Backend (BioMistral-7B + BLIP-2)...
3
+ call venv\Scripts\activate
4
+ set USE_4BIT=true
5
+ set ENABLE_VISION_CAPTION=true
6
+ set SLM_MODEL_ID=BioMistral/BioMistral-7B
7
+ uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
run_frontend.bat ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ @echo off
2
+ echo Starting Radiology AI Frontend...
3
+ call venv\Scripts\activate
4
+ python frontend/ui.py
run_training.bat ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ echo ============================================================
3
+ echo Fine-tuning Meditron-7B on unsloth/Radiology_mini
4
+ echo Time: ~45 minutes on RTX 4060
5
+ echo ============================================================
6
+ call venv\Scripts\activate
7
+ python training/train_unsloth.py --max_steps 300 --batch_size 1 --lora_r 8
setup.bat ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ echo ============================================================
3
+ echo Radiology AI System — Setup Script
4
+ echo Run this ONCE after downloading the project
5
+ echo ============================================================
6
+
7
+ echo.
8
+ echo [1/4] Creating virtual environment...
9
+ python -m venv venv
10
+ call venv\Scripts\activate
11
+
12
+ echo.
13
+ echo [2/4] Installing PyTorch with CUDA 12.8...
14
+ pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
15
+
16
+ echo.
17
+ echo [3/4] Installing all dependencies...
18
+ pip install -r requirements.txt
19
+
20
+ echo.
21
+ echo [4/4] Verifying GPU...
22
+ python -c "import torch; print('CUDA:', torch.cuda.is_available()); print('GPU:', torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'None')"
23
+
24
+ echo.
25
+ echo ============================================================
26
+ echo Setup complete!
27
+ echo Next steps:
28
+ echo 1. Fine-tune: run_training.bat
29
+ echo 2. Start app: run_backend.bat (Terminal 1)
30
+ echo run_frontend.bat (Terminal 2)
31
+ echo ============================================================
32
+ pause
ui.py ADDED
@@ -0,0 +1,707 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ui.py - Gradio 6.x compatible UI - Extended timeout version
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+
10
+ import gradio as gr
11
+ import httpx
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
16
+ REQUEST_TIMEOUT = 600 # 10 minutes — Phi-2 on CPU is slow
17
+
18
+ CUSTOM_CSS = """
19
+ .gr-markdown h1 { font-size: 2rem !important; color: #1a4a8a !important; font-weight: 700; border-bottom: 3px solid #1a4a8a; padding-bottom: 8px; }
20
+ .gr-markdown h3 { color: #1a4a8a !important; font-weight: 600; }
21
+ #report-box textarea { font-family: 'Courier New', monospace !important; font-size: 13px !important; line-height: 1.7 !important; background: #1e1e2e !important; color: #e0e0e0 !important; border: 1.5px solid #1a4a8a !important; border-radius: 8px !important; }
22
+ """
23
+
24
+ def _check_backend() -> bool:
25
+ try:
26
+ r = httpx.get(f"{BACKEND_URL}/api/health", timeout=5)
27
+ return r.status_code == 200
28
+ except Exception:
29
+ return False
30
+
31
+
32
+ def _call_generate_report(image_path, patient_name, patient_age, patient_gender,
33
+ symptoms, clinical_history, modality, body_region, view):
34
+ with open(image_path, "rb") as f:
35
+ image_bytes = f.read()
36
+ filename = Path(image_path).name
37
+
38
+ with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
39
+ response = client.post(
40
+ f"{BACKEND_URL}/api/generate-report",
41
+ files={"image": (filename, image_bytes, "application/octet-stream")},
42
+ data={
43
+ "patient_name": patient_name,
44
+ "patient_age": str(patient_age),
45
+ "patient_gender": patient_gender,
46
+ "symptoms": symptoms,
47
+ "clinical_history": clinical_history,
48
+ "modality": modality,
49
+ "body_region": body_region,
50
+ "view": view,
51
+ },
52
+ )
53
+ response.raise_for_status()
54
+ return response.json()
55
+
56
+
57
+ def generate_report(image_file, patient_name, patient_age, patient_gender,
58
+ symptoms, clinical_history, modality, body_region, view):
59
+ if image_file is None:
60
+ return "", "", "⚠️ Please upload a radiology image.", "", "❌ No image uploaded."
61
+ if not patient_name or not patient_name.strip():
62
+ return "", "", "⚠️ Please enter the patient name.", "", "❌ Patient name required."
63
+ if not patient_age or int(patient_age) <= 0:
64
+ return "", "", "⚠️ Please enter a valid patient age.", "", "❌ Invalid age."
65
+ if not symptoms.strip():
66
+ return "", "", "⚠️ Please enter presenting symptoms.", "", "❌ Symptoms required."
67
+ if not _check_backend():
68
+ return "", "", "❌ Backend offline. Run: uvicorn backend.main:app --reload", "", "❌ Backend offline."
69
+
70
+ try:
71
+ data = _call_generate_report(
72
+ image_path=image_file,
73
+ patient_name=patient_name.strip(),
74
+ patient_age=int(patient_age),
75
+ patient_gender=patient_gender,
76
+ symptoms=symptoms,
77
+ clinical_history=clinical_history,
78
+ modality=modality,
79
+ body_region=body_region,
80
+ view=view,
81
+ )
82
+ except httpx.ReadTimeout:
83
+ err = "❌ Request timed out — model is still processing. Wait 2 min and try again, or restart backend."
84
+ return "", "", err, "", err
85
+ except httpx.HTTPStatusError as exc:
86
+ err = f"❌ Server error {exc.response.status_code}: {exc.response.text[:200]}"
87
+ return "", "", err, "", err
88
+ except Exception as exc:
89
+ err = f"❌ Request failed: {exc}"
90
+ return "", "", err, "", err
91
+
92
+ report = data.get("report", {})
93
+ validation = data.get("validation", {})
94
+ report_id = data.get("report_id", "")
95
+ confidence = validation.get("confidence_score", 0.0)
96
+ gen_time = report.get("generation_time_seconds", 0)
97
+
98
+ report_text = data.get("report_text", "")
99
+ if not report_text:
100
+ r = data.get("report", {})
101
+ report_text = f"""RADIOLOGY REPORT
102
+ {'='*60}
103
+
104
+ EXAMINATION DETAILS:
105
+ {r.get('examination_details','—')}
106
+
107
+ CLINICAL INFORMATION:
108
+ {r.get('clinical_information','—')}
109
+
110
+ FINDINGS:
111
+ {r.get('findings','—')}
112
+
113
+ IMPRESSION:
114
+ {r.get('impression','—')}
115
+
116
+ RECOMMENDATIONS:
117
+ {r.get('recommendations','—')}
118
+
119
+ CONFIDENCE SCORE: {confidence:.2f}
120
+ {'='*60}
121
+ END OF REPORT"""
122
+
123
+ if confidence >= 0.80:
124
+ conf_label, conf_color = "HIGH", "#138808"
125
+ elif confidence >= 0.60:
126
+ conf_label, conf_color = "MODERATE", "#e67e00"
127
+ else:
128
+ conf_label, conf_color = "LOW", "#c0392b"
129
+
130
+ confidence_html = f"""
131
+ <div style="text-align:center;padding:16px;background:#f0f6ff;
132
+ border:2px solid #1a4a8a;border-radius:12px;margin-top:8px;">
133
+ <div style="font-size:0.85rem;color:#555;margin-bottom:4px;">CONFIDENCE SCORE</div>
134
+ <div style="font-size:1.5rem;font-weight:bold;color:{conf_color};">
135
+ {confidence:.2f} — {conf_label}
136
+ </div>
137
+ <div style="font-size:0.78rem;color:#888;margin-top:6px;">
138
+ Generated in {gen_time:.1f}s
139
+ </div>
140
+ </div>"""
141
+
142
+ issues = validation.get("issues", [])
143
+ warnings = validation.get("warnings", [])
144
+ suggestions = validation.get("suggestions", [])
145
+ section_scores = validation.get("section_scores", {})
146
+
147
+ val_lines = ["**Validation Results**\n"]
148
+ val_lines.append(f"{'✅ VALID' if validation.get('is_valid') else '⚠️ REVIEW NEEDED'}")
149
+ if section_scores:
150
+ val_lines.append("\n**Section Scores:**")
151
+ for sec, score in section_scores.items():
152
+ bar = "█" * int(score * 10) + "░" * (10 - int(score * 10))
153
+ val_lines.append(f" {sec.replace('_',' ').title()}: {bar} {score:.2f}")
154
+ if issues:
155
+ val_lines.append("\n**Issues:**")
156
+ val_lines.extend(f" ❌ {i}" for i in issues)
157
+ if warnings:
158
+ val_lines.append("\n**Warnings:**")
159
+ val_lines.extend(f" ⚠️ {w}" for w in warnings)
160
+ if suggestions:
161
+ val_lines.append("\n**Suggestions:**")
162
+ val_lines.extend(f" 💡 {s}" for s in suggestions)
163
+
164
+ # Medical Coding Block
165
+ coding = data.get("medical_coding", {})
166
+ val_lines.append("\n---")
167
+ val_lines.append("## Medical Coding (UMLS / ICD-10 / SNOMED CT)")
168
+ if coding and coding.get("total_findings", 0) > 0:
169
+ primary = coding.get("primary_diagnosis", {})
170
+ val_lines.append(f"**Primary Diagnosis:** {primary.get('label','—')} | CUI: {primary.get('cui','—')}")
171
+ val_lines.append(f"**Overall Severity:** {coding.get('overall_severity','—')}")
172
+ icd_list = coding.get("icd10_codes", [])
173
+ if icd_list:
174
+ val_lines.append(f"**ICD-10 Codes:** {' | '.join(icd_list)}")
175
+ val_lines.append(f"**All Concepts Found ({coding.get('total_findings',0)}):**")
176
+ for mc in coding.get("medical_codes", []):
177
+ val_lines.append(f" * {mc['label']} | CUI: {mc['cui']} | ICD-10: {mc['icd10']} | Severity: {mc['severity']}")
178
+ else:
179
+ val_lines.append("No significant pathological concepts detected (normal study).")
180
+
181
+ validation_text = "\n".join(val_lines)
182
+ status_final = f"✅ Report generated | ID: {report_id[:8]}"
183
+ return report_text, confidence_html, validation_text, report_id, status_final
184
+
185
+
186
+ def _downloads_dir() -> Path:
187
+ """Returns a temp download folder that works on both Windows and Linux."""
188
+ d = Path(os.environ.get("TEMP", os.environ.get("TMP", "/tmp"))) / "radiology_reports"
189
+ d.mkdir(parents=True, exist_ok=True)
190
+ return d
191
+
192
+
193
+ def export_pdf(report_id):
194
+ if not report_id:
195
+ return None
196
+ try:
197
+ r = httpx.get(f"{BACKEND_URL}/api/export/pdf/{report_id}", timeout=60)
198
+ r.raise_for_status()
199
+ path = _downloads_dir() / f"radiology_report_{report_id[:8]}.pdf"
200
+ with open(path, "wb") as f:
201
+ f.write(r.content)
202
+ logger.info(f"PDF saved: {path}")
203
+ return str(path)
204
+ except Exception as exc:
205
+ logger.error(f"PDF export error: {exc}")
206
+ return None
207
+
208
+
209
+ def export_json_file(report_id):
210
+ if not report_id:
211
+ return None
212
+ try:
213
+ r = httpx.get(f"{BACKEND_URL}/api/export/json/{report_id}", timeout=30)
214
+ r.raise_for_status()
215
+ path = _downloads_dir() / f"radiology_report_{report_id[:8]}.json"
216
+ with open(path, "wb") as f:
217
+ f.write(r.content)
218
+ logger.info(f"JSON saved: {path}")
219
+ return str(path)
220
+ except Exception as exc:
221
+ logger.error(f"JSON export error: {exc}")
222
+ return None
223
+
224
+
225
+ def fill_demo():
226
+ return (
227
+ 45, "Male",
228
+ "Left abdominal pain, swelling of the abdominal wall, fever",
229
+ "Suspected abdominal wall infection.",
230
+ "CT", "Abdomen", "Axial",
231
+ )
232
+
233
+
234
+ def check_backend_status():
235
+ if _check_backend():
236
+ return "🟢 Backend online — ready to generate reports."
237
+ return "🔴 Backend offline — run: uvicorn backend.main:app --reload"
238
+
239
+
240
+ def fetch_report_history():
241
+ """Fetch all reports from DB for history tab."""
242
+ try:
243
+ r = httpx.get(f"{BACKEND_URL}/api/reports?limit=50", timeout=10)
244
+ r.raise_for_status()
245
+ reports = r.json().get("reports", [])
246
+ if not reports:
247
+ return "No reports found in database yet."
248
+ lines = ["| Patient Name | Age | Gender | Modality | Region | Confidence | Date |",
249
+ "|---|---|---|---|---|---|---|"]
250
+ for rep in reports:
251
+ conf = rep.get("confidence_score", 0) or 0
252
+ lines.append(
253
+ f"| {rep.get('patient_name','—')} "
254
+ f"| {rep.get('age','—')} "
255
+ f"| {rep.get('gender','—')} "
256
+ f"| {rep.get('modality','—')} "
257
+ f"| {rep.get('body_region','—')} "
258
+ f"| {conf:.2f} "
259
+ f"| {str(rep.get('created_at','—'))[:16]} |"
260
+ )
261
+ return "\n".join(lines)
262
+ except Exception as e:
263
+ return f"❌ Could not load history: {e}"
264
+
265
+
266
+ def fetch_db_stats():
267
+ """Fetch database statistics."""
268
+ try:
269
+ r = httpx.get(f"{BACKEND_URL}/api/stats", timeout=10)
270
+ r.raise_for_status()
271
+ s = r.json()
272
+ mod_counts = s.get("modality_counts", {})
273
+ mod_text = " | ".join(f"{k}: {v}" for k,v in mod_counts.items())
274
+ return (
275
+ f"**Total Reports:** {s.get('total_reports', 0)} \n"
276
+ f"**Unique Patients:** {s.get('total_patients', 0)} \n"
277
+ f"**Avg Confidence:** {s.get('avg_confidence', 0):.2f} \n"
278
+ f"**By Modality:** {mod_text or 'None yet'}"
279
+ )
280
+ except Exception as e:
281
+ return f"❌ Could not load stats: {e}"
282
+
283
+
284
+ def search_patient_reports(name: str):
285
+ """Search reports by patient name."""
286
+ if not name.strip():
287
+ return "Enter a patient name to search."
288
+ try:
289
+ r = httpx.get(
290
+ f"{BACKEND_URL}/api/reports/patient/{name.strip()}",
291
+ timeout=10
292
+ )
293
+ r.raise_for_status()
294
+ data = r.json()
295
+ reports = data.get("reports", [])
296
+ if not reports:
297
+ return f"No reports found for patient: {name}"
298
+ lines = [f"**{len(reports)} report(s) found for: {name}**\n",
299
+ "| Date | Modality | Region | Confidence | Impression |",
300
+ "|---|---|---|---|---|"]
301
+ for rep in reports:
302
+ conf = rep.get("confidence_score", 0) or 0
303
+ imp = str(rep.get("impression","—"))[:60] + "..."
304
+ lines.append(
305
+ f"| {str(rep.get('created_at','—'))[:16]} "
306
+ f"| {rep.get('modality','—')} "
307
+ f"| {rep.get('body_region','—')} "
308
+ f"| {conf:.2f} "
309
+ f"| {imp} |"
310
+ )
311
+ return "\n".join(lines)
312
+ except Exception as e:
313
+ return f"❌ Search failed: {e}"
314
+
315
+
316
+ def get_report_uuids_for_patient(name: str):
317
+ """Returns list of (label, uuid) tuples for a patient's reports."""
318
+ if not name.strip():
319
+ return []
320
+ try:
321
+ r = httpx.get(
322
+ f"{BACKEND_URL}/api/reports/patient/{name.strip()}", timeout=10
323
+ )
324
+ r.raise_for_status()
325
+ reports = r.json().get("reports", [])
326
+ choices = []
327
+ for rep in reports:
328
+ label = (
329
+ f"{str(rep.get('created_at',''))[:16]} | "
330
+ f"{rep.get('modality','—')} {rep.get('body_region','—')} | "
331
+ f"Conf: {rep.get('confidence_score',0):.2f}"
332
+ )
333
+ choices.append((label, rep.get("report_uuid", "")))
334
+ return choices
335
+ except Exception:
336
+ return []
337
+
338
+
339
+ def load_patient_report_choices(name: str):
340
+ """Populate the dropdown with patient's reports."""
341
+ choices = get_report_uuids_for_patient(name)
342
+ if not choices:
343
+ return gr.update(choices=[], value=None,
344
+ label="No reports found for this patient")
345
+ return gr.update(choices=choices, value=choices[0][1],
346
+ label=f"{len(choices)} report(s) found — select one to download")
347
+
348
+
349
+ def download_report_pdf(report_uuid: str):
350
+ """Download PDF for a specific report UUID from DB."""
351
+ if not report_uuid:
352
+ return None
353
+ try:
354
+ r = httpx.get(
355
+ f"{BACKEND_URL}/api/export/pdf/{report_uuid}", timeout=60
356
+ )
357
+ r.raise_for_status()
358
+ path = _downloads_dir() / f"report_{report_uuid[:8]}.pdf"
359
+ with open(path, "wb") as f:
360
+ f.write(r.content)
361
+ return str(path)
362
+ except Exception as exc:
363
+ logger.error(f"PDF download failed: {exc}")
364
+ return None
365
+
366
+
367
+ def download_report_json(report_uuid: str):
368
+ """Download JSON for a specific report UUID from DB."""
369
+ if not report_uuid:
370
+ return None
371
+ try:
372
+ r = httpx.get(
373
+ f"{BACKEND_URL}/api/export/json/{report_uuid}", timeout=30
374
+ )
375
+ r.raise_for_status()
376
+ path = _downloads_dir() / f"report_{report_uuid[:8]}.json"
377
+ with open(path, "wb") as f:
378
+ f.write(r.content)
379
+ return str(path)
380
+ except Exception as exc:
381
+ logger.error(f"JSON download failed: {exc}")
382
+ return None
383
+
384
+
385
+ def preview_selected_report(report_uuid: str):
386
+ """Show full report text for selected report UUID."""
387
+ if not report_uuid:
388
+ return "Select a report to preview."
389
+ try:
390
+ r = httpx.get(
391
+ f"{BACKEND_URL}/api/report/{report_uuid}", timeout=10
392
+ )
393
+ r.raise_for_status()
394
+ data = r.json()
395
+ rep = data.get("report", {})
396
+ pat = data.get("patient", {})
397
+ img = data.get("imaging", {})
398
+ valid = data.get("validation", {})
399
+ conf = valid.get("confidence_score", rep.get("confidence_score", 0)) or 0
400
+ name = data.get("patient_name", pat.get("name", "Unknown"))
401
+ return f"""RADIOLOGY REPORT
402
+ {'='*60}
403
+ Patient Name : {name}
404
+ Age / Gender : {pat.get('age','—')} / {pat.get('gender','—')}
405
+ Study : {img.get('modality','—')} {img.get('body_region','—')} {img.get('view','') or ''}
406
+ Date : {str(data.get('timestamp',''))[:16]}
407
+ {'='*60}
408
+
409
+ EXAMINATION DETAILS:
410
+ {rep.get('examination_details','—')}
411
+
412
+ CLINICAL INFORMATION:
413
+ {rep.get('clinical_information','—')}
414
+
415
+ FINDINGS:
416
+ {rep.get('findings','—')}
417
+
418
+ IMPRESSION:
419
+ {rep.get('impression','—')}
420
+
421
+ RECOMMENDATIONS:
422
+ {rep.get('recommendations','—')}
423
+
424
+ CONFIDENCE SCORE: {conf:.2f}
425
+ {'='*60}
426
+ END OF REPORT"""
427
+ except Exception as exc:
428
+ return f"❌ Could not load report: {exc}"
429
+
430
+
431
+ def build_interface() -> gr.Blocks:
432
+ with gr.Blocks(title="Radiology AI — Report Generation System") as demo:
433
+
434
+ report_id_state = gr.State("")
435
+
436
+ gr.Markdown(
437
+ "# 🏥 Radiology AI — Report Generation System\n"
438
+ "### AI-Based Structured Radiology Report Generation with Clinical Validation & Patient Database"
439
+ )
440
+
441
+ backend_status = gr.Markdown("🔴 Checking backend…")
442
+
443
+ with gr.Tabs():
444
+
445
+ # ── TAB 1: Generate Report ─────────────────────────────────
446
+ with gr.Tab("🚀 Generate Report"):
447
+ gr.Markdown(
448
+ "> ⏳ **Note:** First report generation takes **30–60 seconds on GPU**. "
449
+ "Please click Generate Report and wait — do not click again."
450
+ )
451
+
452
+ with gr.Row(equal_height=False):
453
+
454
+ # LEFT: Inputs
455
+ with gr.Column(scale=1, min_width=340):
456
+
457
+ gr.Markdown("### 📤 Step 1: Upload Radiology Image")
458
+ image_input = gr.File(
459
+ label="Upload Image (.dcm / .png / .jpg)",
460
+ file_types=[".dcm", ".png", ".jpg", ".jpeg"],
461
+ type="filepath",
462
+ )
463
+ image_preview = gr.Image(
464
+ label="Preview",
465
+ interactive=False,
466
+ height=220,
467
+ )
468
+
469
+ gr.Markdown("### 👤 Step 2: Patient Information")
470
+ patient_name = gr.Textbox(
471
+ label="Patient Name",
472
+ placeholder="e.g. John Smith",
473
+ lines=1,
474
+ )
475
+ with gr.Row():
476
+ patient_age = gr.Number(
477
+ label="Age (years)", minimum=0, maximum=130, step=1, value=50
478
+ )
479
+ patient_gender = gr.Dropdown(
480
+ label="Gender",
481
+ choices=["Male", "Female", "Other"],
482
+ value="Male",
483
+ )
484
+ symptoms = gr.Textbox(
485
+ label="Presenting Symptoms",
486
+ placeholder="e.g. abdominal pain, fever, swelling",
487
+ lines=2,
488
+ )
489
+ clinical_history = gr.Textbox(
490
+ label="Clinical History",
491
+ placeholder="e.g. suspected infection, prior surgeries",
492
+ lines=2,
493
+ )
494
+
495
+ gr.Markdown("### 🔬 Step 3: Imaging Information")
496
+ with gr.Row():
497
+ modality = gr.Dropdown(
498
+ label="Modality",
499
+ choices=[
500
+ "X-ray", "CT", "MRI", "Ultrasound", "PET",
501
+ "Mammography", "Fluoroscopy", "Angiography",
502
+ "Nuclear Medicine",
503
+ ],
504
+ value="X-ray",
505
+ )
506
+ body_region = gr.Dropdown(
507
+ label="Body Region",
508
+ choices=[
509
+ "Chest", "Brain", "Abdomen", "Pelvis",
510
+ "Spine", "Knee", "Hip", "Shoulder", "Neck",
511
+ "Cardiac", "Breast", "Liver", "Kidney",
512
+ "Thyroid", "Hand", "Wrist", "Foot", "Ankle",
513
+ "Skull", "Prostate", "Obstetric", "Vascular",
514
+ ],
515
+ value="Chest",
516
+ )
517
+ view = gr.Dropdown(
518
+ label="View / Projection",
519
+ choices=[
520
+ "PA", "AP", "Lateral", "Axial", "Coronal",
521
+ "Sagittal", "Oblique", "Left Lateral", "Right Lateral",
522
+ "Supine", "Prone", "Decubitus", "Cephalocaudal (CC)",
523
+ "Mediolateral Oblique (MLO)", "T1", "T2", "FLAIR",
524
+ "DWI", "STIR", "",
525
+ ],
526
+ value="PA",
527
+ )
528
+
529
+ with gr.Row():
530
+ demo_btn = gr.Button("📋 Fill Demo", variant="secondary", size="sm")
531
+ generate_btn = gr.Button("🚀 Generate Report", variant="primary", size="lg")
532
+
533
+ status_label = gr.Markdown("")
534
+
535
+ # RIGHT: Outputs
536
+ with gr.Column(scale=2, min_width=480):
537
+
538
+ gr.Markdown("### 📄 Generated Radiology Report")
539
+ report_output = gr.Textbox(
540
+ label="Structured Report",
541
+ lines=22,
542
+ interactive=False,
543
+ elem_id="report-box",
544
+ )
545
+
546
+ confidence_output = gr.HTML(
547
+ value="<div style='text-align:center;color:#aaa;padding:16px;'>"
548
+ "Confidence score will appear here after generation.</div>"
549
+ )
550
+
551
+ with gr.Accordion("📊 Validation Details", open=False):
552
+ validation_output = gr.Markdown("")
553
+
554
+ gr.Markdown("### 💾 Export Report")
555
+ with gr.Row():
556
+ export_pdf_btn = gr.Button("⬇️ Download PDF", variant="secondary")
557
+ export_json_btn = gr.Button("⬇️ Download JSON", variant="secondary")
558
+
559
+ pdf_file = gr.File(label="PDF Report", visible=False)
560
+ json_file = gr.File(label="JSON Report", visible=False)
561
+
562
+ # ── TAB 2: Patient History ─────────────────────────────────
563
+ with gr.Tab("📋 Patient History"):
564
+ gr.Markdown("## 📋 Report History Database")
565
+
566
+ with gr.Row():
567
+ with gr.Column(scale=1):
568
+ gr.Markdown("### 🔍 Search by Patient Name")
569
+ search_input = gr.Textbox(
570
+ label="Patient Name", placeholder="e.g. John Smith"
571
+ )
572
+ search_btn = gr.Button("🔍 Search", variant="primary")
573
+ search_output = gr.Markdown("")
574
+
575
+ with gr.Column(scale=1):
576
+ gr.Markdown("### 📊 Database Statistics")
577
+ stats_output = gr.Markdown("")
578
+ refresh_stats_btn = gr.Button("🔄 Refresh Stats", variant="secondary")
579
+
580
+ # ── Download Previous Reports ──────────────────────────
581
+ gr.Markdown("---")
582
+ gr.Markdown("### ⬇️ Download Previous Reports")
583
+ with gr.Row():
584
+ with gr.Column(scale=2):
585
+ dl_patient_input = gr.Textbox(
586
+ label="Enter Patient Name to Load Reports",
587
+ placeholder="e.g. Gopalamanikandan A",
588
+ )
589
+ dl_load_btn = gr.Button(
590
+ "📂 Load Reports for This Patient",
591
+ variant="primary"
592
+ )
593
+ report_selector = gr.Dropdown(
594
+ label="Select Report to Download",
595
+ choices=[],
596
+ value=None,
597
+ interactive=True,
598
+ )
599
+
600
+ with gr.Column(scale=2):
601
+ gr.Markdown("#### 👁️ Report Preview")
602
+ report_preview = gr.Textbox(
603
+ label="Selected Report",
604
+ lines=18,
605
+ interactive=False,
606
+ elem_id="report-box",
607
+ )
608
+
609
+ with gr.Row():
610
+ dl_pdf_btn = gr.Button("⬇️ Download PDF", variant="secondary")
611
+ dl_json_btn = gr.Button("⬇️ Download JSON", variant="secondary")
612
+ dl_pdf_file = gr.File(label="PDF", visible=False)
613
+ dl_json_file = gr.File(label="JSON", visible=False)
614
+
615
+ # ── All Reports Table ──────────────────────────────────
616
+ gr.Markdown("---")
617
+ gr.Markdown("### 📜 All Reports")
618
+ history_btn = gr.Button("🔄 Load All Reports", variant="secondary")
619
+ history_output = gr.Markdown("")
620
+
621
+ # ── Events ────────────────────────────────────────────────────
622
+ image_input.change(fn=lambda f: f, inputs=image_input, outputs=image_preview)
623
+
624
+ demo_btn.click(
625
+ fn=fill_demo, inputs=[],
626
+ outputs=[patient_age, patient_gender, symptoms, clinical_history,
627
+ modality, body_region, view],
628
+ )
629
+
630
+ generate_btn.click(
631
+ fn=generate_report,
632
+ inputs=[image_input, patient_name, patient_age, patient_gender,
633
+ symptoms, clinical_history, modality, body_region, view],
634
+ outputs=[report_output, confidence_output, validation_output,
635
+ report_id_state, status_label],
636
+ )
637
+
638
+ export_pdf_btn.click(
639
+ fn=export_pdf, inputs=[report_id_state], outputs=[pdf_file],
640
+ ).then(
641
+ fn=lambda f: gr.update(visible=f is not None),
642
+ inputs=[pdf_file], outputs=[pdf_file],
643
+ )
644
+
645
+ export_json_btn.click(
646
+ fn=export_json_file, inputs=[report_id_state], outputs=[json_file],
647
+ ).then(
648
+ fn=lambda f: gr.update(visible=f is not None),
649
+ inputs=[json_file], outputs=[json_file],
650
+ )
651
+
652
+ # History tab events
653
+ history_btn.click(
654
+ fn=fetch_report_history, inputs=[], outputs=[history_output]
655
+ )
656
+ search_btn.click(
657
+ fn=search_patient_reports, inputs=[search_input], outputs=[search_output]
658
+ )
659
+ refresh_stats_btn.click(
660
+ fn=fetch_db_stats, inputs=[], outputs=[stats_output]
661
+ )
662
+
663
+ # Download previous report events
664
+ dl_load_btn.click(
665
+ fn=load_patient_report_choices,
666
+ inputs=[dl_patient_input],
667
+ outputs=[report_selector],
668
+ )
669
+ report_selector.change(
670
+ fn=preview_selected_report,
671
+ inputs=[report_selector],
672
+ outputs=[report_preview],
673
+ )
674
+ dl_pdf_btn.click(
675
+ fn=download_report_pdf,
676
+ inputs=[report_selector],
677
+ outputs=[dl_pdf_file],
678
+ ).then(
679
+ fn=lambda f: gr.update(visible=f is not None),
680
+ inputs=[dl_pdf_file], outputs=[dl_pdf_file],
681
+ )
682
+ dl_json_btn.click(
683
+ fn=download_report_json,
684
+ inputs=[report_selector],
685
+ outputs=[dl_json_file],
686
+ ).then(
687
+ fn=lambda f: gr.update(visible=f is not None),
688
+ inputs=[dl_json_file], outputs=[dl_json_file],
689
+ )
690
+
691
+ demo.load(fn=check_backend_status, outputs=[backend_status])
692
+ demo.load(fn=fetch_db_stats, outputs=[stats_output])
693
+
694
+ return demo
695
+
696
+
697
+ if __name__ == "__main__":
698
+ logging.basicConfig(level=logging.INFO)
699
+ interface = build_interface()
700
+ interface.launch(
701
+ server_name="0.0.0.0",
702
+ server_port=int(os.getenv("GRADIO_PORT", "7860")),
703
+ share=False,
704
+ show_error=True,
705
+ css=CUSTOM_CSS,
706
+ theme=gr.themes.Soft(primary_hue="blue", neutral_hue="slate"),
707
+ )
validation_module.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ validation_module.py
3
+ --------------------
4
+ Clinical validation module for radiology reports.
5
+
6
+ Performs:
7
+ 1. Structural completeness check
8
+ 2. Medical terminology validation
9
+ 3. Logical consistency analysis (findings → impression)
10
+ 4. Confidence score computation
11
+ 5. Detailed validation feedback
12
+ """
13
+
14
+ import logging
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from typing import Dict, List, Optional, Tuple
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Constants — Medical Terminology Dictionaries
24
+ # ---------------------------------------------------------------------------
25
+
26
+ # Positive radiological finding terms (suggest pathology is mentioned)
27
+ FINDING_TERMS = {
28
+ # Pulmonary
29
+ "opacity", "consolidation", "infiltrate", "effusion", "pneumothorax",
30
+ "atelectasis", "nodule", "mass", "lesion", "cardiomegaly", "pneumonia",
31
+ "edema", "fibrosis", "emphysema", "pleural", "cavitation", "calcification",
32
+ # Neuro
33
+ "hemorrhage", "infarct", "midline shift", "herniation", "hydrocephalus",
34
+ "atrophy", "gliosis", "white matter", "leukoencephalopathy",
35
+ # Abdominal
36
+ "hepatomegaly", "splenomegaly", "ascites", "obstruction", "perforation",
37
+ "ileus", "dilatation", "gallstone", "calculi", "adenopathy",
38
+ # Musculoskeletal
39
+ "fracture", "dislocation", "lytic", "sclerotic", "arthritis", "erosion",
40
+ "osteoporosis", "osteopenia", "hardware", "implant", "arthroplasty",
41
+ "healed", "healing", "surgical", "fixation", "screw", "prosthesis",
42
+ "joint", "bone", "density", "cortical", "trabecular", "alignment",
43
+ # Vascular
44
+ "stenosis", "occlusion", "aneurysm", "thrombosis", "calcified",
45
+ # Soft tissue
46
+ "swelling", "inflammation", "fluid", "collection", "thickening",
47
+ # Normal descriptors
48
+ "normal", "unremarkable", "no acute", "within normal limits", "clear",
49
+ "intact", "preserved", "well-healed", "satisfactory", "stable",
50
+ "demonstrates", "appears", "shows", "noted", "visible", "evident",
51
+ }
52
+
53
+ # Impression must include at least one of these conclusion terms
54
+ IMPRESSION_CONCLUSION_TERMS = {
55
+ "consistent with", "suggestive of", "findings indicate", "impression",
56
+ "likely", "possible", "probable", "no evidence", "cannot exclude",
57
+ "differential", "consider", "normal", "unremarkable", "recommend",
58
+ "represents", "compatible with", "appears to", "evidence of",
59
+ "healed", "healing", "demonstrates", "shows", "indicates",
60
+ "no significant", "well-healed", "satisfactory", "stable",
61
+ "there is", "there are", "findings are",
62
+ }
63
+
64
+ # Recommendation terms
65
+ RECOMMENDATION_TERMS = {
66
+ "correlation", "follow-up", "repeat", "ct scan", "mri", "biopsy",
67
+ "consult", "refer", "further", "additional", "clinical", "evaluation",
68
+ "monitoring", "treatment", "management", "urgent", "emergent",
69
+ "recommended", "suggested", "advised", "periodic", "regular",
70
+ "orthopedic", "specialist", "physiotherapy", "physical therapy",
71
+ "bone density", "dexa", "rheumatologist", "scan", "imaging",
72
+ }
73
+
74
+ # Required report sections
75
+ REQUIRED_SECTIONS = [
76
+ "examination_details",
77
+ "clinical_information",
78
+ "findings",
79
+ "impression",
80
+ "recommendations",
81
+ ]
82
+
83
+ # Minimum word counts per section
84
+ MINIMUM_WORDS = {
85
+ "examination_details": 5,
86
+ "clinical_information": 8,
87
+ "findings": 10,
88
+ "impression": 5,
89
+ "recommendations": 5,
90
+ }
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Data Models
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ @dataclass
99
+ class ValidationResult:
100
+ is_valid: bool = True
101
+ confidence_score: float = 0.50
102
+ section_scores: Dict[str, float] = field(default_factory=dict)
103
+ issues: List[str] = field(default_factory=list)
104
+ warnings: List[str] = field(default_factory=list)
105
+ suggestions: List[str] = field(default_factory=list)
106
+
107
+ def to_dict(self) -> dict:
108
+ return {
109
+ "is_valid": self.is_valid,
110
+ "confidence_score": round(self.confidence_score, 3),
111
+ "section_scores": {k: round(v, 3) for k, v in self.section_scores.items()},
112
+ "issues": self.issues,
113
+ "warnings": self.warnings,
114
+ "suggestions": self.suggestions,
115
+ }
116
+
117
+ def summary(self) -> str:
118
+ status = "✓ VALID" if self.is_valid else "✗ INVALID"
119
+ lines = [
120
+ f"Validation Status : {status}",
121
+ f"Confidence Score : {self.confidence_score:.2f}",
122
+ ]
123
+ if self.issues:
124
+ lines.append("Issues:")
125
+ lines.extend(f" • {i}" for i in self.issues)
126
+ if self.warnings:
127
+ lines.append("Warnings:")
128
+ lines.extend(f" ⚠ {w}" for w in self.warnings)
129
+ if self.suggestions:
130
+ lines.append("Suggestions:")
131
+ lines.extend(f" → {s}" for s in self.suggestions)
132
+ return "\n".join(lines)
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Validator
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ class ClinicalValidator:
141
+ """
142
+ Multi-stage clinical validation pipeline for radiology reports.
143
+ """
144
+
145
+ WEIGHTS = {
146
+ "completeness": 0.30,
147
+ "content_length": 0.15,
148
+ "terminology": 0.25,
149
+ "logical_consistency": 0.20,
150
+ "recommendation_quality": 0.10,
151
+ }
152
+
153
+ def validate(self, report) -> ValidationResult:
154
+ result = ValidationResult()
155
+ scores: Dict[str, float] = {}
156
+
157
+ sections = {
158
+ "examination_details": report.examination_details or "",
159
+ "clinical_information": report.clinical_information or "",
160
+ "findings": report.findings or "",
161
+ "impression": report.impression or "",
162
+ "recommendations": report.recommendations or "",
163
+ }
164
+
165
+ scores["completeness"] = self._check_completeness(sections, result)
166
+ scores["content_length"] = self._check_content_length(sections, result)
167
+ scores["terminology"] = self._check_terminology(sections, result)
168
+
169
+ # ✅ UPDATED LOGIC USED HERE
170
+ scores["logical_consistency"] = self._check_logical_consistency(sections, result)
171
+
172
+ scores["recommendation_quality"] = self._check_recommendations(sections, result)
173
+
174
+ weighted = sum(scores[k] * self.WEIGHTS[k] for k in self.WEIGHTS)
175
+
176
+ result.confidence_score = round(min(max(weighted, 0.30), 0.88), 3)
177
+ result.section_scores = scores
178
+
179
+ result.is_valid = (
180
+ result.confidence_score >= 0.55
181
+ and len(result.issues) == 0
182
+ )
183
+
184
+ return result
185
+
186
+ # ------------------------------------------------------------------
187
+
188
+ def _check_completeness(self, sections: Dict[str, str], result: ValidationResult) -> float:
189
+ missing = []
190
+ for sec in REQUIRED_SECTIONS:
191
+ if not sections.get(sec, "").strip():
192
+ missing.append(sec)
193
+
194
+ if missing:
195
+ result.issues.append(
196
+ f"Missing required sections: {', '.join(missing)}"
197
+ )
198
+ return 1.0 - (len(missing) / len(REQUIRED_SECTIONS))
199
+
200
+ return 1.0
201
+
202
+ # ------------------------------------------------------------------
203
+
204
+ def _check_content_length(self, sections: Dict[str, str], result: ValidationResult) -> float:
205
+ total, passing = 0, 0
206
+ for sec, min_words in MINIMUM_WORDS.items():
207
+ total += 1
208
+ word_count = len(sections.get(sec, "").split())
209
+ if word_count >= min_words:
210
+ passing += 1
211
+ else:
212
+ result.warnings.append(
213
+ f"Section '{sec}' is brief ({word_count} words; minimum recommended: {min_words})."
214
+ )
215
+
216
+ return passing / total if total > 0 else 0.0
217
+
218
+ # ------------------------------------------------------------------
219
+
220
+ def _check_terminology(self, sections: Dict[str, str], result: ValidationResult) -> float:
221
+ findings_text = sections.get("findings", "").lower()
222
+ impression_text = sections.get("impression", "").lower()
223
+
224
+ combined = findings_text + " " + impression_text
225
+
226
+ matched_terms = FINDING_TERMS.intersection(
227
+ set(re.findall(r"\b\w+\b", combined))
228
+ )
229
+
230
+ for term in FINDING_TERMS:
231
+ if " " in term and term in combined:
232
+ matched_terms.add(term)
233
+
234
+ score = min(len(matched_terms) / 2.0, 1.0)
235
+
236
+ if len(matched_terms) == 0:
237
+ result.issues.append(
238
+ "No recognised radiological terminology found in findings/impression."
239
+ )
240
+ elif len(matched_terms) < 2:
241
+ result.warnings.append(
242
+ "Limited radiological terminology detected. Report may lack specificity."
243
+ )
244
+ else:
245
+ result.suggestions.append(
246
+ f"Detected radiological terms: {', '.join(sorted(matched_terms)[:6])}."
247
+ )
248
+
249
+ return score
250
+
251
+ # ------------------------------------------------------------------
252
+ # ✅ FIXED FUNCTION (MAIN CHANGE)
253
+ # ------------------------------------------------------------------
254
+
255
+ def _check_logical_consistency(
256
+ self, sections: Dict[str, str], result: ValidationResult
257
+ ) -> float:
258
+
259
+ findings = sections.get("findings", "").lower()
260
+ impression = sections.get("impression", "").lower()
261
+
262
+ if not findings or not impression:
263
+ return 0.5
264
+
265
+ score = 1.0
266
+
267
+ has_conclusion = any(
268
+ term in impression for term in IMPRESSION_CONCLUSION_TERMS
269
+ )
270
+ if not has_conclusion:
271
+ result.warnings.append(
272
+ "Impression does not contain a clear diagnostic conclusion."
273
+ )
274
+ score -= 0.3
275
+
276
+ # ✅ NEW CLEAN LOGIC
277
+ ABNORMAL_TERMS = {
278
+ "opacity", "opacities", "ground glass", "consolidation",
279
+ "lesion", "mass", "nodule", "effusion", "infiltrate",
280
+ "atelectasis", "fibrosis", "infection"
281
+ }
282
+
283
+ NORMAL_TERMS = {
284
+ "normal", "unremarkable", "no acute", "within normal limits",
285
+ "clear", "intact", "preserved"
286
+ }
287
+
288
+ findings_has_pathology = any(t in findings for t in ABNORMAL_TERMS)
289
+ impression_says_normal = any(t in impression for t in NORMAL_TERMS)
290
+
291
+ # 🚨 STRONG ERROR DETECTION
292
+ if findings_has_pathology and impression_says_normal:
293
+ result.issues.append(
294
+ "Critical error: Findings indicate abnormal pathology but impression states normal."
295
+ )
296
+ result.is_valid = False
297
+ score -= 0.7
298
+
299
+ # ⚠ Mixed statement detection
300
+ if "no" in findings and findings_has_pathology:
301
+ result.warnings.append(
302
+ "Findings contain both abnormal and negative descriptors; verify interpretation."
303
+ )
304
+
305
+ return max(score, 0.0)
306
+
307
+ # ------------------------------------------------------------------
308
+
309
+ def _check_recommendations(self, sections: Dict[str, str], result: ValidationResult) -> float:
310
+ recs = sections.get("recommendations", "").lower()
311
+
312
+ if not recs:
313
+ result.warnings.append("Recommendations section is empty.")
314
+ return 0.0
315
+
316
+ matched = RECOMMENDATION_TERMS.intersection(
317
+ set(re.findall(r"\b\w+\b", recs))
318
+ )
319
+
320
+ for term in RECOMMENDATION_TERMS:
321
+ if " " in term and term in recs:
322
+ matched.add(term)
323
+
324
+ score = min(len(matched) / 2.0, 1.0)
325
+
326
+ if len(matched) == 0:
327
+ result.warnings.append(
328
+ "Recommendations section does not contain specific clinical guidance."
329
+ )
330
+
331
+ return score
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # Convenience Function
336
+ # ---------------------------------------------------------------------------
337
+
338
+
339
+ def validate_report(report) -> ValidationResult:
340
+ """Top-level convenience function."""
341
+ validator = ClinicalValidator()
342
+ return validator.validate(report)
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # Smoke Test
347
+ # ---------------------------------------------------------------------------
348
+
349
+ if __name__ == "__main__":
350
+ import sys
351
+ sys.path.insert(0, ".")
352
+ from backend.report_generator import PatientInfo, ImagingInfo, RadiologyReport
353
+
354
+ mock_report = RadiologyReport(
355
+ examination_details="Chest X-ray, posteroanterior view.",
356
+ clinical_information="52-year-old male presenting with persistent cough and fever for 3 days.",
357
+ findings=(
358
+ "Patchy consolidation in the right lower lobe with air bronchograms. "
359
+ "No pleural effusion. Cardiac silhouette within normal limits. "
360
+ "Costophrenic angles are sharp bilaterally."
361
+ ),
362
+ impression=(
363
+ "Findings are consistent with right lower lobe pneumonia. "
364
+ "No evidence of pleural complication."
365
+ ),
366
+ recommendations=(
367
+ "Clinical correlation recommended. "
368
+ "Consider repeat chest X-ray in 4 to 6 weeks to confirm resolution. "
369
+ "If symptoms persist, CT chest may be warranted."
370
+ ),
371
+ )
372
+
373
+ result = validate_report(mock_report)
374
+ print(result.summary())
375
+ print(f"\nFull JSON: {result.to_dict()}")