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
Files changed (6) hide show
  1. app_new.py +116 -20
  2. models.py +2 -1
  3. run_interface.py +91 -0
  4. security.py +1 -1
  5. templates/detail.html +18 -10
  6. 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
- gradcam_file: str | None = None
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.gradcam_file),
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
- report = ScreeningReport.query.filter_by(user_id=current_user.id, image_id=image_id).first()
852
- if not report:
853
- abort(404)
854
 
855
  user_reports_dir = UserDataManager(UPLOAD_BASE_DIR).get_user_reports_dir(current_user.id)
856
- report_path = user_reports_dir / f"{image_id}_report.json"
857
-
858
- if not report_path.exists():
859
- abort(404)
860
-
861
- try:
862
- with open(report_path) as f:
863
- report_data = json.load(f)
864
- except (json.JSONDecodeError, OSError):
865
- abort(500)
866
-
867
- log_audit("report_viewed", user_id=current_user.id, resource_type="report", resource_id=report.id)
868
- return render_template("detail.html", report=report_data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.Integer)
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.gradcam_file %}
116
  <img class="heatmap-img"
117
- src="{{ url_for('serve_gradcam', filename=row.gradcam_file) }}"
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
- screening decision. These are <strong>not</strong> confirmed anatomical
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
- {% if row.gradcam_file %}
148
- <a href="{{ url_for('serve_gradcam', filename=row.gradcam_file) }}"
149
- target="_blank" class="link-icon" title="View Grad-CAM">
150
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
151
- stroke="currentColor" stroke-width="2">
 
 
 
 
 
 
 
 
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