SmartHeal commited on
Commit
39b256f
·
verified ·
1 Parent(s): 0165d1a

Update src/patient_history.py

Browse files
Files changed (1) hide show
  1. src/patient_history.py +302 -179
src/patient_history.py CHANGED
@@ -1,8 +1,10 @@
 
1
  import logging
2
  import json
3
  import html
4
  from datetime import datetime
5
- from typing import List, Dict, Optional, Tuple
 
6
 
7
  class PatientHistoryManager:
8
  """
@@ -11,7 +13,7 @@ class PatientHistoryManager:
11
  Key schema expectations this class honors:
12
  - questionnaire_responses.patient_id -> patients.id (INT/BIGINT)
13
  - patients.uuid is the stable string identifier for string-FK tables
14
- - wounds.patient_id, wound_images.patient_id, notes.patient_id are VARCHAR -> store patients.uuid
15
  - ai_analyses.questionnaire_id -> questionnaires.id (template-level linkage)
16
  """
17
 
@@ -63,7 +65,8 @@ class PatientHistoryManager:
63
  ) -> List[Dict]:
64
  """
65
  Full visit list for a practitioner, optionally filtered by patient name.
66
- Includes joins to wounds, wound_images (via patients.uuid) and ai_analyses (via questionnaire template).
 
67
  """
68
  try:
69
  # Defensive bounds for pagination
@@ -77,7 +80,7 @@ class PatientHistoryManager:
77
  qr.submitted_at AS visit_date,
78
  qr.response_data,
79
 
80
- p.id AS patient_id_int,
81
  p.uuid AS patient_uuid,
82
  p.name AS patient_name,
83
  p.age AS patient_age,
@@ -121,7 +124,7 @@ class PatientHistoryManager:
121
  return rows
122
 
123
  except Exception as e:
124
- logging.error(f"Error fetching patient complete history: {e}")
125
  return []
126
 
127
  def get_patient_list(self, user_id: int) -> List[Dict]:
@@ -131,6 +134,8 @@ class PatientHistoryManager:
131
  try:
132
  sql = """
133
  SELECT
 
 
134
  p.name AS patient_name,
135
  p.age AS patient_age,
136
  p.gender AS patient_gender,
@@ -140,17 +145,18 @@ class PatientHistoryManager:
140
  FROM questionnaire_responses qr
141
  JOIN patients p ON p.id = qr.patient_id
142
  WHERE qr.practitioner_id = %s
143
- GROUP BY p.name, p.age, p.gender
144
  ORDER BY last_visit DESC
145
  """
146
  return self.db.execute_query(sql, (user_id,), fetch=True) or []
147
  except Exception as e:
148
- logging.error(f"Error fetching patient list: {e}")
149
  return []
150
 
151
  def get_wound_progression(self, user_id: int, patient_name: str) -> List[Dict]:
152
  """
153
- Ascending temporal list for one patient — useful for timeline charts.
 
154
  """
155
  try:
156
  sql = """
@@ -166,7 +172,9 @@ class PatientHistoryManager:
166
  a.risk_level,
167
  a.summary,
168
 
169
- wi.image AS image_url
 
 
170
  FROM questionnaire_responses qr
171
  JOIN patients p ON p.id = qr.patient_id
172
  LEFT JOIN wounds w
@@ -187,7 +195,52 @@ class PatientHistoryManager:
187
  r["wound_location"] = loc_json
188
  return rows
189
  except Exception as e:
190
- logging.error(f"Error fetching wound progression: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  return []
192
 
193
  def save_patient_note(self, user_id: int, patient_name: str, note: str) -> bool:
@@ -221,21 +274,17 @@ class PatientHistoryManager:
221
  )
222
  return bool(rc)
223
  except Exception as e:
224
- logging.error(f"Error saving patient note: {e}")
225
  return False
226
 
227
  # --------------------------- UI Wrappers ---------------------------
228
 
229
  def get_user_patient_history(self, user_id: int) -> List[Dict]:
230
- """
231
- Wrapper used by UI: returns (paginated) latest history for all patients.
232
- """
233
  return self.get_patient_complete_history(user_id=user_id, limit=100, offset=0)
234
 
235
  def search_patient_by_name(self, user_id: int, patient_name: str) -> List[Dict]:
236
- """
237
- Wrapper used by UI: returns history for a single patient name.
238
- """
239
  return self.get_patient_complete_history(user_id=user_id, patient_name=patient_name, limit=100, offset=0)
240
 
241
  # --------------------------- Render helpers ---------------------------
@@ -245,7 +294,6 @@ class PatientHistoryManager:
245
  if hasattr(dt_obj, "strftime"):
246
  return dt_obj.strftime('%b %d, %Y %I:%M %p')
247
  if isinstance(dt_obj, str):
248
- # Attempt to prettify ISO-like strings
249
  try:
250
  dt = datetime.fromisoformat(dt_obj.replace('Z', '+00:00'))
251
  return dt.strftime('%b %d, %Y %I:%M %p')
@@ -264,7 +312,10 @@ class PatientHistoryManager:
264
  bg, fg = "#fff3cd", "#856404"
265
  elif rl.startswith("high"):
266
  bg, fg = "#f8d7da", "#721c24"
267
- return f"<span style='background:{bg};color:{fg};padding:2px 8px;border-radius:10px;font-size:12px;font-weight:600;'>{html.escape(risk_level or 'Unknown')}</span>"
 
 
 
268
 
269
  def format_history_for_display(self, rows: List[Dict]) -> str:
270
  """
@@ -287,28 +338,88 @@ class PatientHistoryManager:
287
  img = r.get("image_url")
288
 
289
  parts.append("<div style='padding:12px;border:1px solid #e2e8f0;border-radius:10px;background:#fff;'>")
290
- parts.append(f"<div style='display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap'>"
291
- f"<div><strong>{patient}</strong> • {age} • {gender}</div>"
292
- f"<div style='color:#4a5568;'>{dt}</div>"
293
- f"</div>")
 
 
294
 
295
- row2 = (f"Wound: {wound_loc} • Pain: {pain} • Risk: {risk_chip}")
296
  parts.append(f"<div style='margin-top:6px'>{row2}</div>")
297
 
298
  if summary:
299
- parts.append(f"<div style='margin-top:6px;color:#2d3748'><em>{html.escape(str(summary))}</em></div>")
 
 
 
 
300
 
301
  if img:
302
- parts.append(f"<div style='margin-top:10px'><img src='{html.escape(img)}' style='max-width:260px;border-radius:8px;border:1px solid #edf2f7'></div>")
 
 
 
 
303
 
304
  parts.append("</div>") # card
305
  parts.append("</div>")
306
  return "".join(parts)
307
 
308
- def format_patient_data_for_display(self, rows: List[Dict]) -> str:
309
  """
310
- Renderer for a single patient's history (reuses card format).
311
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  return self.format_history_for_display(rows)
313
 
314
 
@@ -348,145 +459,146 @@ class ReportGenerator:
348
  lis = "".join(f"<li>{html.escape(i)}</li>" for i in items if len(i) > 2)
349
  return f"<ul>{lis}</ul>"
350
 
351
- def generate_analysis_report(self, patient_data: Dict, analysis_data: Dict, image_url: str = None) -> str:
352
- """
353
- Generate comprehensive printable HTML report for a single analysis.
354
- """
355
- risk_level = (analysis_data or {}).get("risk_level", "Unknown")
356
- risk_class = f"risk-{str(risk_level).lower().replace(' ', '-')}"
357
- summary = (analysis_data or {}).get("summary", "No analysis summary available.")
358
- recs = self._format_recommendations((analysis_data or {}).get("recommendations", ""))
359
-
360
- # Build optional image section first to avoid nested f-strings
 
 
 
 
361
  image_section = (
362
- f"""
363
- <div class="section">
364
- <h2>Wound Image</h2>
365
- <div class="image-wrap">
366
- <img src="{html.escape(image_url)}" alt="Wound Image" class="wound-image" />
367
- </div>
368
- </div>
369
- """ if image_url else ""
370
  )
371
-
372
- return f"""
373
- <!DOCTYPE html>
374
- <html lang="en">
375
- <head>
376
- <meta charset="UTF-8">
377
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
378
- <title>SmartHeal AI - Wound Analysis Report</title>
379
- <style>
380
- body {{
381
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
382
- line-height: 1.6; margin: 0; padding: 20px; background-color: #f8f9fa;
383
- }}
384
- .report-container {{ max-width: 920px; margin: 0 auto; background: white; border-radius: 10px;
385
- box-shadow: 0 4px 20px rgba(0,0,0,0.1); overflow: hidden; }}
386
- .header {{ background: linear-gradient(135deg, #3182ce 0%, #2c5aa0 100%); color: white; padding: 30px; text-align: center; }}
387
- .header h1 {{ margin: 0; font-size: 28px; font-weight: 600; }}
388
- .header p {{ margin: 10px 0 0 0; opacity: 0.9; font-size: 16px; }}
389
- .content {{ padding: 30px; }}
390
- .section {{ margin-bottom: 30px; border-left: 4px solid #3182ce; padding-left: 20px; }}
391
- .section h2 {{ color: #2c5aa0; margin-top: 0; font-size: 20px; font-weight: 600; }}
392
- .grid2 {{ display:grid; grid-template-columns:1fr 1fr; gap:20px; }}
393
- .card {{ background:#f8f9fa; padding:15px; border-radius:8px; border:1px solid #e9ecef; }}
394
- .card h3 {{ margin:0 0 10px 0; color:#495057; font-size:13px; font-weight:700; text-transform:uppercase; }}
395
- .card p {{ margin:0; font-weight:500; color:#212529; }}
396
- .risk-indicator {{ display:inline-block; padding:8px 16px; border-radius:20px; font-weight:600; text-transform:uppercase; font-size:12px; letter-spacing:.5px; }}
397
- .risk-low {{ background:#d4edda; color:#155724; }}
398
- .risk-moderate {{ background:#fff3cd; color:#856404; }}
399
- .risk-high {{ background:#f8d7da; color:#721c24; }}
400
- .recommendations {{ background:#e7f3ff; border:1px solid #b3d9ff; border-radius:8px; padding:20px; margin-top:15px; }}
401
- .image-wrap {{ text-align:center; margin:20px 0; }}
402
- .wound-image {{ max-width:100%; height:auto; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,.15); }}
403
- .footer {{ background:#f8f9fa; padding:20px 30px; text-align:center; color:#6c757d; border-top:1px solid #e9ecef; }}
404
- @media print {{ body {{ background:white; }} .report-container {{ box-shadow:none; }} }}
405
- </style>
406
- </head>
407
- <body>
408
- <div class="report-container">
409
- <div class="header">
410
- <h1>🩺 SmartHeal AI Wound Analysis Report</h1>
411
- <p>Advanced AI-Powered Clinical Assessment</p>
412
- </div>
413
-
414
- <div class="content">
415
- <div class="section">
416
- <h2>Patient Information</h2>
417
- <div class="grid2">
418
- <div class="card"><h3>Patient Name</h3><p>{html.escape(str(patient_data.get('patient_name','N/A')))}</p></div>
419
- <div class="card"><h3>Age</h3><p>{html.escape(str(patient_data.get('patient_age','N/A')))} years</p></div>
420
- <div class="card"><h3>Gender</h3><p>{html.escape(str(patient_data.get('patient_gender','N/A')))}</p></div>
421
- <div class="card"><h3>Assessment Date</h3><p>{datetime.now().strftime('%B %d, %Y at %I:%M %p')}</p></div>
422
- </div>
423
- </div>
424
-
425
- <div class="section">
426
- <h2>Wound Assessment</h2>
427
- <div class="grid2">
428
- <div class="card"><h3>Location</h3><p>{html.escape(str(patient_data.get('wound_location','N/A')))}</p></div>
429
- <div class="card"><h3>Duration</h3><p>{html.escape(str(patient_data.get('wound_duration','N/A')))}</p></div>
430
- <div class="card"><h3>Pain Level</h3><p>{html.escape(str(patient_data.get('pain_level','N/A')))} / 10</p></div>
431
- <div class="card"><h3>Risk Assessment</h3>
432
- <p><span class="risk-indicator risk-{html.escape(risk_class)}">{html.escape(str(risk_level))} Risk</span></p>
433
- </div>
434
- </div>
435
- </div>
436
-
437
- {image_section}
438
-
439
- <div class="section">
440
- <h2>AI Analysis Summary</h2>
441
- <p>{html.escape(str(summary))}</p>
442
- <div class="recommendations">
443
- <h3>🎯 Clinical Recommendations</h3>
444
- {recs}
445
- </div>
446
- </div>
447
-
448
- <div class="section">
449
- <h2>Medical History</h2>
450
- <div class="grid2">
451
- <div class="card"><h3>Medical History</h3><p>{html.escape(str(patient_data.get('medical_history','None reported')))}</p></div>
452
- <div class="card"><h3>Current Medications</h3><p>{html.escape(str(patient_data.get('medications','None reported')))}</p></div>
453
- <div class="card"><h3>Known Allergies</h3><p>{html.escape(str(patient_data.get('allergies','None reported')))}</p></div>
454
- <div class="card"><h3>Additional Notes</h3><p>{html.escape(str(patient_data.get('additional_notes','None')))}</p></div>
455
- </div>
456
- </div>
457
- </div>
458
-
459
- <div class="footer">
460
- <p><strong>SmartHeal AI</strong> — Advanced Wound Care Analysis & Clinical Support System</p>
461
- <p>Generated on {datetime.now().strftime('%B %d, %Y at %I:%M %p')} | For professional medical use only</p>
462
- <p>⚠️ This AI analysis is for clinical support only. Always consult qualified professionals.</p>
463
- </div>
464
- </div>
465
- </body>
466
- </html>
467
- """
468
-
469
-
470
-
 
 
 
471
  def generate_patient_history_report(self, patient_history: List[Dict]) -> str:
472
  """
473
  Large, printable HTML summarizing multiple visits for a patient.
 
474
  """
475
  if not patient_history:
476
  return "<p>No patient history available.</p>"
477
 
478
  pname = html.escape(str(patient_history[0].get('patient_name', 'Unknown Patient')))
479
- rows_html = []
480
 
481
- # Render each visit
482
  for i, visit in enumerate(patient_history):
483
  dt = visit.get('visit_date')
484
- try:
485
- if hasattr(dt, "strftime"):
486
- dt_str = dt.strftime('%B %d, %Y')
487
- else:
488
- dt_str = str(dt)
489
- except Exception:
490
  dt_str = str(dt)
491
 
492
  wound_loc = html.escape(str(visit.get('wound_location', 'N/A')))
@@ -494,29 +606,40 @@ class ReportGenerator:
494
  risk = html.escape(str(visit.get('risk_level', 'Unknown')))
495
  summary = visit.get('summary')
496
 
497
- rows_html.append(f"""
498
- <div style="padding:20px;border-bottom:1px solid #f0f0f0;">
499
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
500
- <h3 style="margin:0;color:#2c5aa0;">Visit #{len(patient_history)-i}</h3>
501
- <span style="color:#6c757d;font-size:14px;">{dt_str}</span>
502
- </div>
503
- <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin:10px 0;">
504
- <div style="background:#f8f9fa;padding:10px;border-radius:6px;"><strong>Location:</strong> {wound_loc}</div>
505
- <div style="background:#f8f9fa;padding:10px;border-radius:6px;"><strong>Pain:</strong> {pain}/10</div>
506
- <div style="background:#f8f9fa;padding:10px;border-radius:6px;"><strong>Risk:</strong> {risk}</div>
507
- </div>
508
- {f"<p style='margin:0.5rem 0 0 0'><strong>Summary:</strong> {html.escape(str(summary))}</p>" if summary else ""}
509
- </div>
510
- """)
 
 
 
 
 
 
 
 
 
 
511
 
512
- return f"""
513
- <div style="max-width:920px;margin:0 auto;font-family:'Segoe UI', sans-serif;">
514
- <div style="background:linear-gradient(135deg,#3182ce 0%,#2c5aa0 100%);color:white;padding:20px;border-radius:10px 10px 0 0;">
515
- <h2 style="margin:0;">📋 Patient History: {pname}</h2>
516
- <p style="margin:8px 0 0 0;opacity:0.9;">Complete Treatment Timeline</p>
517
- </div>
518
- <div style="background:white;border:1px solid #e9ecef;border-top:none;border-radius:0 0 10px 10px;">
519
- {''.join(rows_html)}
520
- </div>
521
- </div>
522
- """
 
 
1
+ # src/patient_history.py
2
  import logging
3
  import json
4
  import html
5
  from datetime import datetime
6
+ from typing import List, Dict, Optional, Tuple, Any
7
+
8
 
9
  class PatientHistoryManager:
10
  """
 
13
  Key schema expectations this class honors:
14
  - questionnaire_responses.patient_id -> patients.id (INT/BIGINT)
15
  - patients.uuid is the stable string identifier for string-FK tables
16
+ - wounds.patient_id, wound_images.patient_id, notes.patient_id may be VARCHAR -> store patients.uuid
17
  - ai_analyses.questionnaire_id -> questionnaires.id (template-level linkage)
18
  """
19
 
 
65
  ) -> List[Dict]:
66
  """
67
  Full visit list for a practitioner, optionally filtered by patient name.
68
+ Includes joins to wounds, wound_images (via patients.uuid or string id)
69
+ and ai_analyses (via questionnaire template).
70
  """
71
  try:
72
  # Defensive bounds for pagination
 
80
  qr.submitted_at AS visit_date,
81
  qr.response_data,
82
 
83
+ p.id AS patient_id,
84
  p.uuid AS patient_uuid,
85
  p.name AS patient_name,
86
  p.age AS patient_age,
 
124
  return rows
125
 
126
  except Exception as e:
127
+ logging.error(f"Error fetching patient complete history: {e}", exc_info=True)
128
  return []
129
 
130
  def get_patient_list(self, user_id: int) -> List[Dict]:
 
134
  try:
135
  sql = """
136
  SELECT
137
+ p.id AS id,
138
+ p.uuid,
139
  p.name AS patient_name,
140
  p.age AS patient_age,
141
  p.gender AS patient_gender,
 
145
  FROM questionnaire_responses qr
146
  JOIN patients p ON p.id = qr.patient_id
147
  WHERE qr.practitioner_id = %s
148
+ GROUP BY p.id, p.uuid, p.name, p.age, p.gender
149
  ORDER BY last_visit DESC
150
  """
151
  return self.db.execute_query(sql, (user_id,), fetch=True) or []
152
  except Exception as e:
153
+ logging.error(f"Error fetching patient list: {e}", exc_info=True)
154
  return []
155
 
156
  def get_wound_progression(self, user_id: int, patient_name: str) -> List[Dict]:
157
  """
158
+ Ascending temporal list for one patient (by name) kept for backward compatibility.
159
+ Prefer get_wound_progression_by_id().
160
  """
161
  try:
162
  sql = """
 
172
  a.risk_level,
173
  a.summary,
174
 
175
+ wi.image AS image_url,
176
+
177
+ p.name AS patient_name
178
  FROM questionnaire_responses qr
179
  JOIN patients p ON p.id = qr.patient_id
180
  LEFT JOIN wounds w
 
195
  r["wound_location"] = loc_json
196
  return rows
197
  except Exception as e:
198
+ logging.error(f"Error fetching wound progression: {e}", exc_info=True)
199
+ return []
200
+
201
+ def get_wound_progression_by_id(self, user_id: int, patient_id: int) -> List[Dict]:
202
+ """
203
+ Ascending temporal list for one patient (by numeric patient_id).
204
+ Use this for “View Details” when a patient is chosen from a dropdown.
205
+ """
206
+ try:
207
+ sql = """
208
+ SELECT
209
+ qr.submitted_at AS visit_date,
210
+ qr.response_data,
211
+
212
+ w.position AS wound_location,
213
+ w.moisture,
214
+ w.infection,
215
+
216
+ a.risk_score,
217
+ a.risk_level,
218
+ a.summary,
219
+
220
+ wi.image AS image_url,
221
+
222
+ p.name AS patient_name
223
+ FROM questionnaire_responses qr
224
+ JOIN patients p ON p.id = qr.patient_id
225
+ LEFT JOIN wounds w
226
+ ON (w.patient_id = p.uuid OR w.patient_id = CAST(p.id AS CHAR))
227
+ LEFT JOIN wound_images wi
228
+ ON (wi.patient_id = p.uuid OR wi.patient_id = CAST(p.id AS CHAR))
229
+ LEFT JOIN ai_analyses a
230
+ ON a.questionnaire_id = qr.questionnaire_id
231
+ WHERE qr.practitioner_id = %s
232
+ AND p.id = %s
233
+ ORDER BY qr.submitted_at ASC
234
+ """
235
+ rows = self.db.execute_query(sql, (user_id, int(patient_id)), fetch=True) or []
236
+ for r in rows:
237
+ r["pain_level"] = self._from_response(r, ["wound_details", "pain_level"])
238
+ loc_json = self._from_response(r, ["wound_details", "location"])
239
+ if loc_json:
240
+ r["wound_location"] = loc_json
241
+ return rows
242
+ except Exception as e:
243
+ logging.error(f"Error fetching wound progression by id: {e}", exc_info=True)
244
  return []
245
 
246
  def save_patient_note(self, user_id: int, patient_name: str, note: str) -> bool:
 
274
  )
275
  return bool(rc)
276
  except Exception as e:
277
+ logging.error(f"Error saving patient note: {e}", exc_info=True)
278
  return False
279
 
280
  # --------------------------- UI Wrappers ---------------------------
281
 
282
  def get_user_patient_history(self, user_id: int) -> List[Dict]:
283
+ """Wrapper used by UI: latest history for all patients."""
 
 
284
  return self.get_patient_complete_history(user_id=user_id, limit=100, offset=0)
285
 
286
  def search_patient_by_name(self, user_id: int, patient_name: str) -> List[Dict]:
287
+ """Wrapper used by UI: history filtered to a single patient name."""
 
 
288
  return self.get_patient_complete_history(user_id=user_id, patient_name=patient_name, limit=100, offset=0)
289
 
290
  # --------------------------- Render helpers ---------------------------
 
294
  if hasattr(dt_obj, "strftime"):
295
  return dt_obj.strftime('%b %d, %Y %I:%M %p')
296
  if isinstance(dt_obj, str):
 
297
  try:
298
  dt = datetime.fromisoformat(dt_obj.replace('Z', '+00:00'))
299
  return dt.strftime('%b %d, %Y %I:%M %p')
 
312
  bg, fg = "#fff3cd", "#856404"
313
  elif rl.startswith("high"):
314
  bg, fg = "#f8d7da", "#721c24"
315
+ return (
316
+ "<span style='background:{bg};color:{fg};padding:2px 8px;border-radius:10px;"
317
+ "font-size:12px;font-weight:600;'>{txt}</span>"
318
+ ).format(bg=bg, fg=fg, txt=html.escape(risk_level or "Unknown"))
319
 
320
  def format_history_for_display(self, rows: List[Dict]) -> str:
321
  """
 
338
  img = r.get("image_url")
339
 
340
  parts.append("<div style='padding:12px;border:1px solid #e2e8f0;border-radius:10px;background:#fff;'>")
341
+ parts.append(
342
+ "<div style='display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap'>"
343
+ f"<div><strong>{patient}</strong> • {age} • {gender}</div>"
344
+ f"<div style='color:#4a5568;'>{dt}</div>"
345
+ "</div>"
346
+ )
347
 
348
+ row2 = f"Wound: {wound_loc} • Pain: {pain} • Risk: {risk_chip}"
349
  parts.append(f"<div style='margin-top:6px'>{row2}</div>")
350
 
351
  if summary:
352
+ parts.append(
353
+ "<div style='margin-top:6px;color:#2d3748'><em>"
354
+ f"{html.escape(str(summary))}"
355
+ "</em></div>"
356
+ )
357
 
358
  if img:
359
+ parts.append(
360
+ "<div style='margin-top:10px'><img src='{}' "
361
+ "style='max-width:260px;border-radius:8px;border:1px solid #edf2f7'></div>"
362
+ .format(html.escape(img))
363
+ )
364
 
365
  parts.append("</div>") # card
366
  parts.append("</div>")
367
  return "".join(parts)
368
 
369
+ def format_patient_progress_for_display(self, rows: List[Dict]) -> str:
370
  """
371
+ Professional timeline for a single patient (used by 'View Details').
372
  """
373
+ if not rows:
374
+ return "<div class='status-warning'>No progression data available.</div>"
375
+
376
+ pname = html.escape(str(rows[0].get("patient_name", "Unknown Patient")))
377
+ header = (
378
+ "<div style='background:linear-gradient(135deg,#3182ce 0%,#2c5aa0 100%);color:#fff;"
379
+ "padding:16px;border-radius:12px;margin-bottom:14px;'>"
380
+ f"<h3 style='margin:0'>🧭 Wound Progress — {pname}</h3>"
381
+ "</div>"
382
+ )
383
+
384
+ items: List[str] = []
385
+ for r in rows:
386
+ visit_date = r.get("visit_date")
387
+ disp_date = visit_date.strftime("%B %d, %Y") if hasattr(visit_date, "strftime") else str(visit_date)
388
+ img = r.get("image_url") or ""
389
+ img_tag = (
390
+ "<img src='{src}' style='max-width:380px;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.08);' />"
391
+ .format(src=html.escape(img))
392
+ if img else ""
393
+ )
394
+ risk = str(r.get("risk_level", "Unknown"))
395
+ risk_chip = self._risk_chip(risk)
396
+ summary = html.escape(str(r.get("summary") or "No summary."))
397
+
398
+ items.append(
399
+ "<div style='display:grid;grid-template-columns:1fr 2fr;gap:16px;align-items:start;"
400
+ "background:#fff;border:1px solid #e9ecef;border-radius:12px;padding:16px;'>"
401
+ "<div style='text-align:center;'>"
402
+ f"<div style='font-weight:700;color:#0f172a;'>{html.escape(disp_date)}</div>"
403
+ f"<div style='margin-top:8px;'>{img_tag}</div>"
404
+ "</div>"
405
+ "<div>"
406
+ f"<div style='margin-bottom:10px;'>{risk_chip}</div>"
407
+ f"<div style='color:#334155;line-height:1.6;'>{summary}</div>"
408
+ "</div>"
409
+ "</div>"
410
+ )
411
+
412
+ body = "<div style='display:flex;flex-direction:column;gap:14px;'>" + "\n".join(items) + "</div>"
413
+ container = (
414
+ "<div style='max-width:1200px;margin:0 auto;'>"
415
+ f"{header}"
416
+ f"{body}"
417
+ "</div>"
418
+ )
419
+ return container
420
+
421
+ def format_patient_data_for_display(self, rows: List[Dict]) -> str:
422
+ """Renderer for a single patient's history (reuses card format)."""
423
  return self.format_history_for_display(rows)
424
 
425
 
 
459
  lis = "".join(f"<li>{html.escape(i)}</li>" for i in items if len(i) > 2)
460
  return f"<ul>{lis}</ul>"
461
 
462
+ def generate_analysis_report(
463
+ self, patient_data: Dict[str, Any], analysis_data: Dict[str, Any], image_url: Optional[str] = None
464
+ ) -> str:
465
+ """
466
+ Generate comprehensive printable HTML report for a single analysis.
467
+ (No nested f-strings)
468
+ """
469
+ risk_level = (analysis_data or {}).get("risk_level", "Unknown")
470
+ risk_class = f"risk-{str(risk_level).lower().replace(' ', '-')}"
471
+ summary = (analysis_data or {}).get("summary", "No analysis summary available.")
472
+ recs = self._format_recommendations((analysis_data or {}).get("recommendations", ""))
473
+
474
+ # Build optional image section first to avoid nested f-strings
475
+ if image_url:
476
  image_section = (
477
+ "<div class=\"section\">"
478
+ "<h2>Wound Image</h2>"
479
+ "<div class=\"image-wrap\">"
480
+ f"<img src=\"{html.escape(image_url)}\" alt=\"Wound Image\" class=\"wound-image\" />"
481
+ "</div></div>"
 
 
 
482
  )
483
+ else:
484
+ image_section = ""
485
+
486
+ # Note: this is an f-string. All literal braces are doubled.
487
+ return f"""
488
+ <!DOCTYPE html>
489
+ <html lang="en">
490
+ <head>
491
+ <meta charset="UTF-8">
492
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
493
+ <title>SmartHeal AI - Wound Analysis Report</title>
494
+ <style>
495
+ body {{
496
+ font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-serif; line-height:1.6; margin:0; padding:20px; background-color:#f8f9fa;
497
+ }}
498
+ .report-container {{
499
+ max-width:920px; margin:0 auto; background:#fff; border-radius:10px; box-shadow:0 4px 20px rgba(0,0,0,0.1); overflow:hidden;
500
+ }}
501
+ .header {{
502
+ background:linear-gradient(135deg,#3182ce 0%,#2c5aa0 100%); color:#fff; padding:30px; text-align:center;
503
+ }}
504
+ .header h1 {{ margin:0; font-size:28px; font-weight:600; }}
505
+ .header p {{ margin:10px 0 0 0; opacity:.9; font-size:16px; }}
506
+ .content {{ padding:30px; }}
507
+ .section {{ margin-bottom:30px; border-left:4px solid #3182ce; padding-left:20px; }}
508
+ .section h2 {{ color:#2c5aa0; margin-top:0; font-size:20px; font-weight:600; }}
509
+ .grid2 {{ display:grid; grid-template-columns:1fr 1fr; gap:20px; }}
510
+ .card {{ background:#f8f9fa; padding:15px; border-radius:8px; border:1px solid #e9ecef; }}
511
+ .card h3 {{ margin:0 0 10px 0; color:#495057; font-size:13px; font-weight:700; text-transform:uppercase; }}
512
+ .card p {{ margin:0; font-weight:500; color:#212529; }}
513
+ .risk-indicator {{ display:inline-block; padding:8px 16px; border-radius:20px; font-weight:600; text-transform:uppercase; font-size:12px; letter-spacing:.5px; }}
514
+ .risk-low {{ background:#d4edda; color:#155724; }}
515
+ .risk-moderate {{ background:#fff3cd; color:#856404; }}
516
+ .risk-high {{ background:#f8d7da; color:#721c24; }}
517
+ .recommendations {{ background:#e7f3ff; border:1px solid #b3d9ff; border-radius:8px; padding:20px; margin-top:15px; }}
518
+ .image-wrap {{ text-align:center; margin:20px 0; }}
519
+ .wound-image {{ max-width:100%; height:auto; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,.15); }}
520
+ .footer {{ background:#f8f9fa; padding:20px 30px; text-align:center; color:#6c757d; border-top:1px solid #e9ecef; }}
521
+ @media print {{ body {{ background:white; }} .report-container {{ box-shadow:none; }} }}
522
+ </style>
523
+ </head>
524
+ <body>
525
+ <div class="report-container">
526
+ <div class="header">
527
+ <h1>🩺 SmartHeal AI Wound Analysis Report</h1>
528
+ <p>Advanced AI-Powered Clinical Assessment</p>
529
+ </div>
530
+
531
+ <div class="content">
532
+ <div class="section">
533
+ <h2>Patient Information</h2>
534
+ <div class="grid2">
535
+ <div class="card"><h3>Patient Name</h3><p>{html.escape(str(patient_data.get('patient_name','N/A')))}</p></div>
536
+ <div class="card"><h3>Age</h3><p>{html.escape(str(patient_data.get('patient_age','N/A')))} years</p></div>
537
+ <div class="card"><h3>Gender</h3><p>{html.escape(str(patient_data.get('patient_gender','N/A')))}</p></div>
538
+ <div class="card"><h3>Assessment Date</h3><p>{datetime.now().strftime('%B %d, %Y at %I:%M %p')}</p></div>
539
+ </div>
540
+ </div>
541
+
542
+ <div class="section">
543
+ <h2>Wound Assessment</h2>
544
+ <div class="grid2">
545
+ <div class="card"><h3>Location</h3><p>{html.escape(str(patient_data.get('wound_location','N/A')))}</p></div>
546
+ <div class="card"><h3>Duration</h3><p>{html.escape(str(patient_data.get('wound_duration','N/A')))}</p></div>
547
+ <div class="card"><h3>Pain Level</h3><p>{html.escape(str(patient_data.get('pain_level','N/A')))} / 10</p></div>
548
+ <div class="card"><h3>Risk Assessment</h3>
549
+ <p><span class="risk-indicator risk-{html.escape(risk_class)}">{html.escape(str(risk_level))} Risk</span></p>
550
+ </div>
551
+ </div>
552
+ </div>
553
+
554
+ {image_section}
555
+
556
+ <div class="section">
557
+ <h2>AI Analysis Summary</h2>
558
+ <p>{html.escape(str(summary))}</p>
559
+ <div class="recommendations">
560
+ <h3>🎯 Clinical Recommendations</h3>
561
+ {recs}
562
+ </div>
563
+ </div>
564
+
565
+ <div class="section">
566
+ <h2>Medical History</h2>
567
+ <div class="grid2">
568
+ <div class="card"><h3>Medical History</h3><p>{html.escape(str(patient_data.get('medical_history','None reported')))}</p></div>
569
+ <div class="card"><h3>Current Medications</h3><p>{html.escape(str(patient_data.get('medications','None reported')))}</p></div>
570
+ <div class="card"><h3>Known Allergies</h3><p>{html.escape(str(patient_data.get('allergies','None reported')))}</p></div>
571
+ <div class="card"><h3>Additional Notes</h3><p>{html.escape(str(patient_data.get('additional_notes','None')))}</p></div>
572
+ </div>
573
+ </div>
574
+ </div>
575
+
576
+ <div class="footer">
577
+ <p><strong>SmartHeal AI</strong> — Advanced Wound Care Analysis & Clinical Support System</p>
578
+ <p>Generated on {datetime.now().strftime('%B %d, %Y at %I:%M %p')} | For professional medical use only</p>
579
+ <p>⚠️ This AI analysis is for clinical support only. Always consult qualified professionals.</p>
580
+ </div>
581
+ </div>
582
+ </body>
583
+ </html>
584
+ """
585
+
586
  def generate_patient_history_report(self, patient_history: List[Dict]) -> str:
587
  """
588
  Large, printable HTML summarizing multiple visits for a patient.
589
+ (Fixed: no nested f-strings)
590
  """
591
  if not patient_history:
592
  return "<p>No patient history available.</p>"
593
 
594
  pname = html.escape(str(patient_history[0].get('patient_name', 'Unknown Patient')))
595
+ rows_html: List[str] = []
596
 
 
597
  for i, visit in enumerate(patient_history):
598
  dt = visit.get('visit_date')
599
+ if hasattr(dt, "strftime"):
600
+ dt_str = dt.strftime('%B %d, %Y')
601
+ else:
 
 
 
602
  dt_str = str(dt)
603
 
604
  wound_loc = html.escape(str(visit.get('wound_location', 'N/A')))
 
606
  risk = html.escape(str(visit.get('risk_level', 'Unknown')))
607
  summary = visit.get('summary')
608
 
609
+ # Build optional summary block separately to avoid nested f-string
610
+ if summary:
611
+ summary_block = (
612
+ "<p style='margin:0.5rem 0 0 0'><strong>Summary:</strong> "
613
+ f"{html.escape(str(summary))}"
614
+ "</p>"
615
+ )
616
+ else:
617
+ summary_block = ""
618
+
619
+ rows_html.append(
620
+ "<div style='padding:20px;border-bottom:1px solid #f0f0f0;'>"
621
+ "<div style='display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;'>"
622
+ f"<h3 style='margin:0;color:#2c5aa0;'>Visit #{len(patient_history)-i}</h3>"
623
+ f"<span style='color:#6c757d;font-size:14px;'>{html.escape(dt_str)}</span>"
624
+ "</div>"
625
+ "<div style='display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin:10px 0;'>"
626
+ f"<div style='background:#f8f9fa;padding:10px;border-radius:6px;'><strong>Location:</strong> {wound_loc}</div>"
627
+ f"<div style='background:#f8f9fa;padding:10px;border-radius:6px;'><strong>Pain:</strong> {pain}/10</div>"
628
+ f"<div style='background:#f8f9fa;padding:10px;border-radius:6px;'><strong>Risk:</strong> {risk}</div>"
629
+ "</div>"
630
+ f"{summary_block}"
631
+ "</div>"
632
+ )
633
 
634
+ return (
635
+ "<div style=\"max-width:920px;margin:0 auto;font-family:'Segoe UI', sans-serif;\">"
636
+ "<div style=\"background:linear-gradient(135deg,#3182ce 0%,#2c5aa0 100%);color:white;"
637
+ "padding:20px;border-radius:10px 10px 0 0;\">"
638
+ f"<h2 style=\"margin:0;\">📋 Patient History: {pname}</h2>"
639
+ "<p style=\"margin:8px 0 0 0;opacity:0.9;\">Complete Treatment Timeline</p>"
640
+ "</div>"
641
+ "<div style=\"background:white;border:1px solid #e9ecef;border-top:none;border-radius:0 0 10px 10px;\">"
642
+ + "".join(rows_html) +
643
+ "</div>"
644
+ "</div>"
645
+ )