Harshit Ghosh commited on
Commit ·
1499a5e
1
Parent(s): c4436fb
feat: add report deletion functionality and improve Grad-CAM URL handling with path fallback support
Browse files- app_new.py +116 -20
- models.py +2 -1
- run_interface.py +91 -0
- security.py +1 -1
- templates/detail.html +18 -10
- templates/reports.html +26 -5
app_new.py
CHANGED
|
@@ -366,7 +366,9 @@ def _run_inference_on_dcm(dcm_path: Path, user_id: int) -> tuple[dict[str, Any]
|
|
| 366 |
decision_threshold=pred.get("decision_threshold"),
|
| 367 |
triage_action=report.get("triage", {}).get("action"),
|
| 368 |
urgency=report.get("triage", {}).get("urgency"),
|
|
|
|
| 369 |
report_json_path=str(report_path.relative_to(BASE_DIR)),
|
|
|
|
| 370 |
generated_at=datetime.datetime.utcnow(),
|
| 371 |
)
|
| 372 |
db.session.add(screening_report)
|
|
@@ -491,7 +493,7 @@ class CaseRow:
|
|
| 491 |
urgency: str = "N/A"
|
| 492 |
generated_at: str = ""
|
| 493 |
report_file: str | None = None
|
| 494 |
-
|
| 495 |
|
| 496 |
@property
|
| 497 |
def date_display(self) -> str:
|
|
@@ -515,6 +517,13 @@ def _load_user_cases(user_id: int) -> list[CaseRow]:
|
|
| 515 |
|
| 516 |
cases = []
|
| 517 |
for r in reports:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
cases.append(CaseRow(
|
| 519 |
image_id=r.image_id,
|
| 520 |
outcome=r.screening_outcome or "Unknown",
|
|
@@ -525,6 +534,7 @@ def _load_user_cases(user_id: int) -> list[CaseRow]:
|
|
| 525 |
urgency=r.urgency or "N/A",
|
| 526 |
generated_at=r.generated_at.isoformat() if r.generated_at else "",
|
| 527 |
report_file=Path(r.report_json_path).name if r.report_json_path else None,
|
|
|
|
| 528 |
))
|
| 529 |
|
| 530 |
return cases
|
|
@@ -545,7 +555,7 @@ def compute_stats(rows: list[CaseRow]) -> dict[str, Any]:
|
|
| 545 |
"urgent": urgent,
|
| 546 |
"avg_cal_prob": avg_cal,
|
| 547 |
"pos_rate": pos_rate,
|
| 548 |
-
"heatmaps": sum(1 for r in rows if r.
|
| 549 |
}
|
| 550 |
|
| 551 |
|
|
@@ -718,7 +728,7 @@ def analyze_directory():
|
|
| 718 |
if not LOCAL_MODE:
|
| 719 |
abort(403)
|
| 720 |
|
| 721 |
-
dir_path_str = request.form.get("dir_path", "").strip()
|
| 722 |
if not dir_path_str:
|
| 723 |
flash("Please enter a directory path.", "error")
|
| 724 |
return redirect(url_for("upload"))
|
|
@@ -727,7 +737,7 @@ def analyze_directory():
|
|
| 727 |
flash("Path is too long to be a valid directory.", "error")
|
| 728 |
return redirect(url_for("upload"))
|
| 729 |
|
| 730 |
-
scan_dir = Path(dir_path_str)
|
| 731 |
try:
|
| 732 |
if not scan_dir.is_dir():
|
| 733 |
flash(f"Directory not found: {dir_path_str}", "error")
|
|
@@ -848,24 +858,49 @@ def reports():
|
|
| 848 |
@login_required
|
| 849 |
def case_detail(image_id):
|
| 850 |
"""View screening report details"""
|
| 851 |
-
|
| 852 |
-
if not report:
|
| 853 |
-
abort(404)
|
| 854 |
|
| 855 |
user_reports_dir = UserDataManager(UPLOAD_BASE_DIR).get_user_reports_dir(current_user.id)
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
|
| 870 |
@app.route("/logs")
|
| 871 |
@login_required
|
|
@@ -928,6 +963,67 @@ def serve_gradcam(filename: str):
|
|
| 928 |
reports_dir = UserDataManager(UPLOAD_BASE_DIR).get_user_reports_dir(current_user.id)
|
| 929 |
return send_from_directory(reports_dir, safe_name)
|
| 930 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 931 |
@app.errorhandler(401)
|
| 932 |
def unauthorized(e):
|
| 933 |
if request.path.startswith("/api/"):
|
|
|
|
| 366 |
decision_threshold=pred.get("decision_threshold"),
|
| 367 |
triage_action=report.get("triage", {}).get("action"),
|
| 368 |
urgency=report.get("triage", {}).get("urgency"),
|
| 369 |
+
llm_summary=report.get("llm_summary"),
|
| 370 |
report_json_path=str(report_path.relative_to(BASE_DIR)),
|
| 371 |
+
gradcam_image_path=report.get("cloudinary_heatmap_url") or str((user_reports_dir / f"{image_id}_gradcam.png").relative_to(BASE_DIR)),
|
| 372 |
generated_at=datetime.datetime.utcnow(),
|
| 373 |
)
|
| 374 |
db.session.add(screening_report)
|
|
|
|
| 493 |
urgency: str = "N/A"
|
| 494 |
generated_at: str = ""
|
| 495 |
report_file: str | None = None
|
| 496 |
+
gradcam_url: str | None = None
|
| 497 |
|
| 498 |
@property
|
| 499 |
def date_display(self) -> str:
|
|
|
|
| 517 |
|
| 518 |
cases = []
|
| 519 |
for r in reports:
|
| 520 |
+
# Fallback for old records with missing gradcam_image_path
|
| 521 |
+
g_url = r.gradcam_image_path
|
| 522 |
+
if not g_url:
|
| 523 |
+
fallback_path = UserDataManager(UPLOAD_BASE_DIR).get_user_reports_dir(current_user.id) / f"{r.image_id}_gradcam.png"
|
| 524 |
+
if fallback_path.exists():
|
| 525 |
+
g_url = url_for('serve_gradcam', filename=fallback_path.name)
|
| 526 |
+
|
| 527 |
cases.append(CaseRow(
|
| 528 |
image_id=r.image_id,
|
| 529 |
outcome=r.screening_outcome or "Unknown",
|
|
|
|
| 534 |
urgency=r.urgency or "N/A",
|
| 535 |
generated_at=r.generated_at.isoformat() if r.generated_at else "",
|
| 536 |
report_file=Path(r.report_json_path).name if r.report_json_path else None,
|
| 537 |
+
gradcam_url=g_url,
|
| 538 |
))
|
| 539 |
|
| 540 |
return cases
|
|
|
|
| 555 |
"urgent": urgent,
|
| 556 |
"avg_cal_prob": avg_cal,
|
| 557 |
"pos_rate": pos_rate,
|
| 558 |
+
"heatmaps": sum(1 for r in rows if r.gradcam_url),
|
| 559 |
}
|
| 560 |
|
| 561 |
|
|
|
|
| 728 |
if not LOCAL_MODE:
|
| 729 |
abort(403)
|
| 730 |
|
| 731 |
+
dir_path_str = request.form.get("dir_path", "").strip().strip("'\"")
|
| 732 |
if not dir_path_str:
|
| 733 |
flash("Please enter a directory path.", "error")
|
| 734 |
return redirect(url_for("upload"))
|
|
|
|
| 737 |
flash("Path is too long to be a valid directory.", "error")
|
| 738 |
return redirect(url_for("upload"))
|
| 739 |
|
| 740 |
+
scan_dir = Path(dir_path_str).expanduser().resolve()
|
| 741 |
try:
|
| 742 |
if not scan_dir.is_dir():
|
| 743 |
flash(f"Directory not found: {dir_path_str}", "error")
|
|
|
|
| 858 |
@login_required
|
| 859 |
def case_detail(image_id):
|
| 860 |
"""View screening report details"""
|
| 861 |
+
report_record = ScreeningReport.query.filter_by(user_id=current_user.id, image_id=image_id).first()
|
|
|
|
|
|
|
| 862 |
|
| 863 |
user_reports_dir = UserDataManager(UPLOAD_BASE_DIR).get_user_reports_dir(current_user.id)
|
| 864 |
+
report_data = None
|
| 865 |
+
if report_record and report_record.report_json_path:
|
| 866 |
+
rp = Path(BASE_DIR) / report_record.report_json_path
|
| 867 |
+
if rp.exists():
|
| 868 |
+
with open(rp, "r") as f:
|
| 869 |
+
report_data = json.load(f)
|
| 870 |
+
|
| 871 |
+
# Use existing record details or construct a CaseRow
|
| 872 |
+
if report_record:
|
| 873 |
+
g_url = report_record.gradcam_image_path
|
| 874 |
+
if not g_url:
|
| 875 |
+
fallback_path = user_reports_dir / f"{image_id}_gradcam.png"
|
| 876 |
+
if fallback_path.exists():
|
| 877 |
+
g_url = url_for('serve_gradcam', filename=fallback_path.name)
|
| 878 |
+
|
| 879 |
+
row = CaseRow(
|
| 880 |
+
image_id=image_id,
|
| 881 |
+
outcome=report_record.screening_outcome or "Unknown",
|
| 882 |
+
raw_prob=report_record.raw_probability,
|
| 883 |
+
cal_prob=report_record.calibrated_probability,
|
| 884 |
+
band=report_record.confidence_band or "N/A",
|
| 885 |
+
triage=report_record.triage_action or "N/A",
|
| 886 |
+
urgency=report_record.urgency or "N/A",
|
| 887 |
+
generated_at=report_record.generated_at.isoformat() if report_record.generated_at else "",
|
| 888 |
+
gradcam_url=g_url
|
| 889 |
+
)
|
| 890 |
+
else:
|
| 891 |
+
# fallback
|
| 892 |
+
row = CaseRow(
|
| 893 |
+
image_id=image_id, outcome="Unknown", raw_prob=None, cal_prob=None,
|
| 894 |
+
band="N/A", triage="N/A", urgency="N/A", generated_at=""
|
| 895 |
+
)
|
| 896 |
+
|
| 897 |
+
return render_template(
|
| 898 |
+
"detail.html",
|
| 899 |
+
image_id=image_id,
|
| 900 |
+
row=row,
|
| 901 |
+
payload=report_data,
|
| 902 |
+
report_record=report_record
|
| 903 |
+
)
|
| 904 |
|
| 905 |
@app.route("/logs")
|
| 906 |
@login_required
|
|
|
|
| 963 |
reports_dir = UserDataManager(UPLOAD_BASE_DIR).get_user_reports_dir(current_user.id)
|
| 964 |
return send_from_directory(reports_dir, safe_name)
|
| 965 |
|
| 966 |
+
@app.route("/report/<path:filename>")
|
| 967 |
+
@login_required
|
| 968 |
+
def serve_report_json(filename: str):
|
| 969 |
+
"""Serve a user's JSON report."""
|
| 970 |
+
safe_name = Path(filename).name
|
| 971 |
+
reports_dir = UserDataManager(UPLOAD_BASE_DIR).get_user_reports_dir(current_user.id)
|
| 972 |
+
return send_from_directory(reports_dir, safe_name)
|
| 973 |
+
|
| 974 |
+
@app.route("/report/<image_id>/delete", methods=["POST"])
|
| 975 |
+
@login_required
|
| 976 |
+
def delete_report(image_id: str):
|
| 977 |
+
"""Delete a single screening report and its local files."""
|
| 978 |
+
report = ScreeningReport.query.filter_by(user_id=current_user.id, image_id=image_id).first()
|
| 979 |
+
if not report:
|
| 980 |
+
flash("Report not found.", "error")
|
| 981 |
+
return redirect(url_for("reports"))
|
| 982 |
+
|
| 983 |
+
# Delete local files
|
| 984 |
+
user_reports_dir = UserDataManager(UPLOAD_BASE_DIR).get_user_reports_dir(current_user.id)
|
| 985 |
+
for suffix in ("_report.json", "_gradcam.png", "_preview.png"):
|
| 986 |
+
fp = user_reports_dir / f"{image_id}{suffix}"
|
| 987 |
+
fp.unlink(missing_ok=True)
|
| 988 |
+
|
| 989 |
+
# Delete associated upload record
|
| 990 |
+
if report.upload_id:
|
| 991 |
+
upload = db.session.get(ScreeningUpload, report.upload_id)
|
| 992 |
+
if upload:
|
| 993 |
+
db.session.delete(upload)
|
| 994 |
+
else:
|
| 995 |
+
db.session.delete(report)
|
| 996 |
+
else:
|
| 997 |
+
db.session.delete(report)
|
| 998 |
+
|
| 999 |
+
db.session.commit()
|
| 1000 |
+
log_audit("report_deleted", user_id=current_user.id, resource_type="report", resource_id=image_id)
|
| 1001 |
+
flash(f"Report {image_id} deleted.", "success")
|
| 1002 |
+
return redirect(url_for("reports"))
|
| 1003 |
+
|
| 1004 |
+
|
| 1005 |
+
@app.route("/reports/delete-all", methods=["POST"])
|
| 1006 |
+
@login_required
|
| 1007 |
+
def delete_all_reports():
|
| 1008 |
+
"""Delete ALL reports for the current user quickly."""
|
| 1009 |
+
import shutil
|
| 1010 |
+
|
| 1011 |
+
# 1. Delete physical files securely by dropping the entire user reports folder
|
| 1012 |
+
user_reports_dir = UserDataManager(UPLOAD_BASE_DIR).get_user_reports_dir(current_user.id)
|
| 1013 |
+
shutil.rmtree(user_reports_dir, ignore_errors=True)
|
| 1014 |
+
user_reports_dir.mkdir(parents=True, exist_ok=True)
|
| 1015 |
+
|
| 1016 |
+
# 2. Bulk delete database records
|
| 1017 |
+
# Because of cascade rules, deleting uploads will automatically delete reports too.
|
| 1018 |
+
# But to be completely safe, we can delete reports directly as well.
|
| 1019 |
+
report_count = db.session.query(ScreeningReport).filter_by(user_id=current_user.id).delete()
|
| 1020 |
+
upload_count = db.session.query(ScreeningUpload).filter_by(user_id=current_user.id).delete()
|
| 1021 |
+
|
| 1022 |
+
db.session.commit()
|
| 1023 |
+
log_audit("all_reports_deleted", user_id=current_user.id, resource_type="report", resource_id="all")
|
| 1024 |
+
flash(f"All {report_count} reports and their files have been deleted.", "success")
|
| 1025 |
+
return redirect(url_for("reports"))
|
| 1026 |
+
|
| 1027 |
@app.errorhandler(401)
|
| 1028 |
def unauthorized(e):
|
| 1029 |
if request.path.startswith("/api/"):
|
models.py
CHANGED
|
@@ -80,6 +80,7 @@ class ScreeningReport(db.Model):
|
|
| 80 |
# Triage information
|
| 81 |
triage_action = db.Column(db.String(100))
|
| 82 |
urgency = db.Column(db.String(50))
|
|
|
|
| 83 |
|
| 84 |
# Ground truth (for validation only)
|
| 85 |
true_label = db.Column(db.String(100))
|
|
@@ -104,7 +105,7 @@ class AuditLog(db.Model):
|
|
| 104 |
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
|
| 105 |
action = db.Column(db.String(100), nullable=False) # login, logout, upload, delete, download, etc.
|
| 106 |
resource_type = db.Column(db.String(50)) # upload, report, etc.
|
| 107 |
-
resource_id = db.Column(db.
|
| 108 |
details = db.Column(db.Text) # JSON or plain text with additional info
|
| 109 |
ip_address = db.Column(db.String(45)) # IPv4 or IPv6
|
| 110 |
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
|
|
|
| 80 |
# Triage information
|
| 81 |
triage_action = db.Column(db.String(100))
|
| 82 |
urgency = db.Column(db.String(50))
|
| 83 |
+
llm_summary = db.Column(db.Text)
|
| 84 |
|
| 85 |
# Ground truth (for validation only)
|
| 86 |
true_label = db.Column(db.String(100))
|
|
|
|
| 105 |
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
|
| 106 |
action = db.Column(db.String(100), nullable=False) # login, logout, upload, delete, download, etc.
|
| 107 |
resource_type = db.Column(db.String(50)) # upload, report, etc.
|
| 108 |
+
resource_id = db.Column(db.String(255))
|
| 109 |
details = db.Column(db.Text) # JSON or plain text with additional info
|
| 110 |
ip_address = db.Column(db.String(45)) # IPv4 or IPv6
|
| 111 |
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
run_interface.py
CHANGED
|
@@ -6,6 +6,7 @@ inference utilities in download_imp/run_inference.py.
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 9 |
from pathlib import Path
|
| 10 |
from typing import Any
|
| 11 |
|
|
@@ -13,6 +14,18 @@ import cv2
|
|
| 13 |
import numpy as np
|
| 14 |
import torch
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from download_imp import run_inference as core
|
| 17 |
|
| 18 |
ARCH = core.BACKBONE
|
|
@@ -137,6 +150,53 @@ def infer_single(
|
|
| 137 |
}
|
| 138 |
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
def build_report(
|
| 141 |
image_id: str,
|
| 142 |
inference: dict[str, Any],
|
|
@@ -179,4 +239,35 @@ def build_report(
|
|
| 179 |
report["prediction"]["raw_probability"] = round(float(inference["raw_prob_any"]), 6)
|
| 180 |
report["prediction"]["calibrated_probability"] = round(float(inference["cal_prob_any"]), 6)
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
return report
|
|
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import os
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Any
|
| 12 |
|
|
|
|
| 14 |
import numpy as np
|
| 15 |
import torch
|
| 16 |
|
| 17 |
+
try:
|
| 18 |
+
from groq import Groq
|
| 19 |
+
except ImportError:
|
| 20 |
+
Groq = None
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
import cloudinary
|
| 24 |
+
import cloudinary.uploader
|
| 25 |
+
import cloudinary.api
|
| 26 |
+
except ImportError:
|
| 27 |
+
cloudinary = None
|
| 28 |
+
|
| 29 |
from download_imp import run_inference as core
|
| 30 |
|
| 31 |
ARCH = core.BACKBONE
|
|
|
|
| 150 |
}
|
| 151 |
|
| 152 |
|
| 153 |
+
def generate_medical_summary(inference: dict[str, Any], calib_cfg: dict[str, Any], report: dict[str, Any]) -> str:
|
| 154 |
+
"""Generate a medical summary using Groq LLM API."""
|
| 155 |
+
if not Groq:
|
| 156 |
+
return "LLM integration not available (groq package not installed)."
|
| 157 |
+
|
| 158 |
+
groq_api_key = os.environ.get("GROQ_API_KEY")
|
| 159 |
+
if not groq_api_key:
|
| 160 |
+
return "LLM integration not configured (Missing GROQ_API_KEY)."
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
client = Groq(api_key=groq_api_key)
|
| 164 |
+
|
| 165 |
+
prob = float(inference.get("cal_prob_any", 0.0))
|
| 166 |
+
threshold = float(calib_cfg.get("threshold_at_spec90", 0.5))
|
| 167 |
+
is_positive = prob >= threshold
|
| 168 |
+
|
| 169 |
+
triage = report.get("triage", {})
|
| 170 |
+
action = triage.get("action", "Unknown")
|
| 171 |
+
urgency = triage.get("urgency", "Unknown")
|
| 172 |
+
|
| 173 |
+
prompt = f"""
|
| 174 |
+
You are an expert AI medical assistant analyzing a CT scan for Intracranial Hemorrhage.
|
| 175 |
+
|
| 176 |
+
Scan Results:
|
| 177 |
+
- Probability of Hemorrhage: {prob:.2%}
|
| 178 |
+
- Decision Threshold: {threshold:.2%}
|
| 179 |
+
- AI Assessment: {"Positive for Hemorrhage" if is_positive else "Negative for Hemorrhage"}
|
| 180 |
+
- Urgency: {urgency}
|
| 181 |
+
- Recommended Action: {action}
|
| 182 |
+
|
| 183 |
+
Based on this data, write a concise, professional 3-sentence medical triage summary.
|
| 184 |
+
Focus strictly on the AI's findings. Do not hallucinate patient data.
|
| 185 |
+
"""
|
| 186 |
+
|
| 187 |
+
model_name = os.environ.get("LLM_MODEL", "llama-3.1-8b-instant")
|
| 188 |
+
response = client.chat.completions.create(
|
| 189 |
+
messages=[{"role": "user", "content": prompt}],
|
| 190 |
+
model=model_name,
|
| 191 |
+
temperature=0.2,
|
| 192 |
+
max_tokens=150,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
return response.choices[0].message.content.strip()
|
| 196 |
+
except Exception as e:
|
| 197 |
+
return f"Failed to generate LLM summary: {str(e)}"
|
| 198 |
+
|
| 199 |
+
|
| 200 |
def build_report(
|
| 201 |
image_id: str,
|
| 202 |
inference: dict[str, Any],
|
|
|
|
| 239 |
report["prediction"]["raw_probability"] = round(float(inference["raw_prob_any"]), 6)
|
| 240 |
report["prediction"]["calibrated_probability"] = round(float(inference["cal_prob_any"]), 6)
|
| 241 |
|
| 242 |
+
report["llm_summary"] = generate_medical_summary(inference, calib_cfg, report)
|
| 243 |
+
|
| 244 |
+
# Cloudinary Integration
|
| 245 |
+
cloud_name = os.environ.get("CLOUDINARY_CLOUD_NAME")
|
| 246 |
+
api_key = os.environ.get("CLOUDINARY_API_KEY")
|
| 247 |
+
api_secret = os.environ.get("CLOUDINARY_API_SECRET")
|
| 248 |
+
|
| 249 |
+
if cloudinary and cloud_name and api_key and api_secret:
|
| 250 |
+
try:
|
| 251 |
+
cloudinary.config(
|
| 252 |
+
cloud_name=cloud_name,
|
| 253 |
+
api_key=api_key,
|
| 254 |
+
api_secret=api_secret,
|
| 255 |
+
secure=True
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
# Upload preview
|
| 259 |
+
preview_res = cloudinary.uploader.upload(str(preview_path), folder="ich_previews")
|
| 260 |
+
report["cloudinary_preview_url"] = preview_res.get("secure_url")
|
| 261 |
+
|
| 262 |
+
# Upload heatmap
|
| 263 |
+
heatmap_res = cloudinary.uploader.upload(str(heatmap_path), folder="ich_heatmaps")
|
| 264 |
+
report["cloudinary_heatmap_url"] = heatmap_res.get("secure_url")
|
| 265 |
+
|
| 266 |
+
# Delete local copies to save space since we have them in the cloud
|
| 267 |
+
preview_path.unlink(missing_ok=True)
|
| 268 |
+
heatmap_path.unlink(missing_ok=True)
|
| 269 |
+
|
| 270 |
+
except Exception as e:
|
| 271 |
+
print(f"Cloudinary upload failed: {e}")
|
| 272 |
+
|
| 273 |
return report
|
security.py
CHANGED
|
@@ -36,7 +36,7 @@ def init_security(app):
|
|
| 36 |
"script-src 'self' 'unsafe-inline'; " # Minimal unsafe-inline for compatibility
|
| 37 |
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
| 38 |
"font-src 'self' https://fonts.gstatic.com; "
|
| 39 |
-
"img-src 'self' data:; "
|
| 40 |
"connect-src 'self'; "
|
| 41 |
"frame-ancestors 'self'; "
|
| 42 |
"base-uri 'self'; "
|
|
|
|
| 36 |
"script-src 'self' 'unsafe-inline'; " # Minimal unsafe-inline for compatibility
|
| 37 |
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
| 38 |
"font-src 'self' https://fonts.gstatic.com; "
|
| 39 |
+
"img-src 'self' data: https://res.cloudinary.com; "
|
| 40 |
"connect-src 'self'; "
|
| 41 |
"frame-ancestors 'self'; "
|
| 42 |
"base-uri 'self'; "
|
templates/detail.html
CHANGED
|
@@ -112,29 +112,37 @@
|
|
| 112 |
<!-- Right: Grad-CAM -->
|
| 113 |
<article class="panel">
|
| 114 |
<h3>Grad-CAM Visualization</h3>
|
| 115 |
-
{% if row.
|
| 116 |
<img class="heatmap-img"
|
| 117 |
-
src="{{
|
| 118 |
alt="Grad-CAM for {{ row.image_id }}" />
|
| 119 |
<p class="muted small" style="margin-top: 10px">
|
| 120 |
Highlighted regions indicate areas with greatest influence on the
|
| 121 |
-
|
| 122 |
-
findings.
|
| 123 |
</p>
|
| 124 |
{% else %}
|
| 125 |
<div class="empty-state">
|
| 126 |
-
<svg width="48" height="48" viewBox="0 0 24 24" fill="none"
|
| 127 |
-
stroke="currentColor" stroke-width="1.5" opacity="0.3">
|
| 128 |
-
<rect x="3" y="3" width="18" height="18" rx="2" />
|
| 129 |
-
<circle cx="8.5" cy="8.5" r="1.5" />
|
| 130 |
-
<path d="m21 15-5-5L5 21" />
|
| 131 |
-
</svg>
|
| 132 |
<p class="muted">No Grad-CAM heatmap available for this case.</p>
|
| 133 |
</div>
|
| 134 |
{% endif %}
|
| 135 |
</article>
|
| 136 |
</section>
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
<!-- Model info (from payload) -->
|
| 139 |
{% if payload and payload.screening_module %}
|
| 140 |
<section class="panel" style="margin-top: 16px">
|
|
|
|
| 112 |
<!-- Right: Grad-CAM -->
|
| 113 |
<article class="panel">
|
| 114 |
<h3>Grad-CAM Visualization</h3>
|
| 115 |
+
{% if row.gradcam_url %}
|
| 116 |
<img class="heatmap-img"
|
| 117 |
+
src="{{ row.gradcam_url }}"
|
| 118 |
alt="Grad-CAM for {{ row.image_id }}" />
|
| 119 |
<p class="muted small" style="margin-top: 10px">
|
| 120 |
Highlighted regions indicate areas with greatest influence on the
|
| 121 |
+
model's decision.
|
|
|
|
| 122 |
</p>
|
| 123 |
{% else %}
|
| 124 |
<div class="empty-state">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
<p class="muted">No Grad-CAM heatmap available for this case.</p>
|
| 126 |
</div>
|
| 127 |
{% endif %}
|
| 128 |
</article>
|
| 129 |
</section>
|
| 130 |
|
| 131 |
+
<!-- AI LLM Summary -->
|
| 132 |
+
{% if report_record and report_record.llm_summary %}
|
| 133 |
+
<section class="panel" style="margin-bottom: 24px; border-left: 4px solid var(--primary, #007aff);">
|
| 134 |
+
<h3 style="color: var(--primary, #007aff); display: flex; align-items: center; gap: 8px;">
|
| 135 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 136 |
+
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
| 137 |
+
</svg>
|
| 138 |
+
AI Medical Intelligence Summary
|
| 139 |
+
</h3>
|
| 140 |
+
<p style="line-height: 1.6; font-size: 1.1rem; color: var(--text-dark, #2c3e50);">
|
| 141 |
+
{{ report_record.llm_summary }}
|
| 142 |
+
</p>
|
| 143 |
+
</section>
|
| 144 |
+
{% endif %}
|
| 145 |
+
|
| 146 |
<!-- Model info (from payload) -->
|
| 147 |
{% if payload and payload.screening_module %}
|
| 148 |
<section class="panel" style="margin-top: 16px">
|
templates/reports.html
CHANGED
|
@@ -100,6 +100,11 @@
|
|
| 100 |
<a href="{{ url_for('reports', page_size=page_size) }}" class="btn btn-ghost">Clear</a>
|
| 101 |
{% endif %}
|
| 102 |
</form>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
<!-- prettier-ignore-end -->
|
| 104 |
|
| 105 |
<!-- Results meta bar -->
|
|
@@ -125,6 +130,7 @@
|
|
| 125 |
<th>Urgency</th>
|
| 126 |
<th>Grad-CAM</th>
|
| 127 |
<th>Report</th>
|
|
|
|
| 128 |
</tr>
|
| 129 |
</thead>
|
| 130 |
<tbody>
|
|
@@ -144,16 +150,25 @@
|
|
| 144 |
<td><span class="badge badge-{{ row.band|lower }}">{{ row.band }}</span></td>
|
| 145 |
<td><span class="badge badge-{{ row.urgency|lower }}">{{ row.urgency }}</span></td>
|
| 146 |
<td>
|
| 147 |
-
{%
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
<rect x="3" y="3" width="18" height="18" rx="2" />
|
| 153 |
<circle cx="8.5" cy="8.5" r="1.5" />
|
| 154 |
<path d="m21 15-5-5L5 21" />
|
| 155 |
</svg>
|
| 156 |
</a>
|
|
|
|
| 157 |
{% else %}
|
| 158 |
<span class="muted">—</span>
|
| 159 |
{% endif %}
|
|
@@ -161,6 +176,12 @@
|
|
| 161 |
<td>
|
| 162 |
<a href="{{ url_for('case_detail', image_id=row.image_id) }}" class="btn btn-sm">Open</a>
|
| 163 |
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
</tr>
|
| 165 |
{% endfor %}
|
| 166 |
|
|
|
|
| 100 |
<a href="{{ url_for('reports', page_size=page_size) }}" class="btn btn-ghost">Clear</a>
|
| 101 |
{% endif %}
|
| 102 |
</form>
|
| 103 |
+
<!-- Delete All button -->
|
| 104 |
+
<form method="post" action="{{ url_for('delete_all_reports') }}" style="margin-top: 8px;"
|
| 105 |
+
onsubmit="return confirm('Delete ALL your reports and local files? This cannot be undone.')">
|
| 106 |
+
<button type="submit" class="btn btn-ghost" style="color: var(--red, #e74c3c); border-color: var(--red, #e74c3c);">🗑 Delete All Reports</button>
|
| 107 |
+
</form>
|
| 108 |
<!-- prettier-ignore-end -->
|
| 109 |
|
| 110 |
<!-- Results meta bar -->
|
|
|
|
| 130 |
<th>Urgency</th>
|
| 131 |
<th>Grad-CAM</th>
|
| 132 |
<th>Report</th>
|
| 133 |
+
<th></th>
|
| 134 |
</tr>
|
| 135 |
</thead>
|
| 136 |
<tbody>
|
|
|
|
| 150 |
<td><span class="badge badge-{{ row.band|lower }}">{{ row.band }}</span></td>
|
| 151 |
<td><span class="badge badge-{{ row.urgency|lower }}">{{ row.urgency }}</span></td>
|
| 152 |
<td>
|
| 153 |
+
{% set gc = row.gradcam_url %}
|
| 154 |
+
{% if gc %}
|
| 155 |
+
{% if gc.startswith('http') %}
|
| 156 |
+
<a href="{{ gc }}" target="_blank" class="link-icon" title="View Grad-CAM">
|
| 157 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 158 |
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
| 159 |
+
<circle cx="8.5" cy="8.5" r="1.5" />
|
| 160 |
+
<path d="m21 15-5-5L5 21" />
|
| 161 |
+
</svg>
|
| 162 |
+
</a>
|
| 163 |
+
{% else %}
|
| 164 |
+
<a href="{{ url_for('serve_gradcam', filename=gc.split('/')[-1]) }}" target="_blank" class="link-icon" title="View Grad-CAM">
|
| 165 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 166 |
<rect x="3" y="3" width="18" height="18" rx="2" />
|
| 167 |
<circle cx="8.5" cy="8.5" r="1.5" />
|
| 168 |
<path d="m21 15-5-5L5 21" />
|
| 169 |
</svg>
|
| 170 |
</a>
|
| 171 |
+
{% endif %}
|
| 172 |
{% else %}
|
| 173 |
<span class="muted">—</span>
|
| 174 |
{% endif %}
|
|
|
|
| 176 |
<td>
|
| 177 |
<a href="{{ url_for('case_detail', image_id=row.image_id) }}" class="btn btn-sm">Open</a>
|
| 178 |
</td>
|
| 179 |
+
<td>
|
| 180 |
+
<form method="post" action="{{ url_for('delete_report', image_id=row.image_id) }}"
|
| 181 |
+
onsubmit="return confirm('Delete report {{ row.image_id }}?')" style="display:inline">
|
| 182 |
+
<button type="submit" class="btn btn-sm" style="background:transparent; color:var(--red,#e74c3c); border-color:var(--red,#e74c3c)">✕</button>
|
| 183 |
+
</form>
|
| 184 |
+
</td>
|
| 185 |
</tr>
|
| 186 |
{% endfor %}
|
| 187 |
|