SmartHeal commited on
Commit
ed01628
·
verified ·
1 Parent(s): 3ed15a0

Update src/patient_history.py

Browse files
Files changed (1) hide show
  1. src/patient_history.py +385 -65
src/patient_history.py CHANGED
@@ -1,22 +1,49 @@
1
  import logging
2
  import json
 
3
  from datetime import datetime
4
- from typing import List, Dict
5
 
6
  class PatientHistoryManager:
7
- """Complete patient history and wound tracking system (schema-aligned)"""
 
 
 
 
 
 
 
 
8
 
9
  def __init__(self, database_manager):
10
  self.db = database_manager
11
 
12
- # ---------- INTERNAL HELPERS ----------
13
- def _parse_response_field(self, row: Dict, field_path: list, default=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  try:
15
- data = row.get("response_data")
16
- if isinstance(data, str):
17
- data = json.loads(data)
18
- cur = data
19
- for key in field_path:
20
  if isinstance(cur, dict) and key in cur:
21
  cur = cur[key]
22
  else:
@@ -25,22 +52,44 @@ class PatientHistoryManager:
25
  except Exception:
26
  return default
27
 
28
- # ---------- CORE QUERIES (fixed joins) ----------
29
- def get_patient_complete_history(self, user_id: int, patient_name: str = None) -> List[Dict]:
 
 
 
 
 
 
 
 
 
 
 
30
  try:
 
 
 
 
31
  sql = f"""
32
  SELECT
33
  qr.id AS response_id,
 
 
 
 
 
 
34
  p.name AS patient_name,
35
- p.age AS patient_age,
36
  p.gender AS patient_gender,
37
- qr.response_data,
38
- qr.submitted_at AS visit_date,
39
  w.position AS wound_location,
40
  w.moisture,
41
  w.infection,
42
  w.notes,
 
43
  wi.image AS image_url,
 
44
  a.analysis_data,
45
  a.summary,
46
  a.recommendations,
@@ -57,23 +106,31 @@ class PatientHistoryManager:
57
  WHERE qr.practitioner_id = %s
58
  { "AND p.name = %s" if patient_name else "" }
59
  ORDER BY qr.submitted_at DESC
 
60
  """
61
- params = (user_id, patient_name) if patient_name else (user_id,)
 
62
  rows = self.db.execute_query(sql, params, fetch=True) or []
 
 
63
  for r in rows:
64
- r["pain_level"] = self._parse_response_field(r, ["wound_details", "pain_level"])
65
- wl = self._parse_response_field(r, ["wound_details", "location"])
66
- if wl:
67
- r["wound_location"] = wl
68
  return rows
 
69
  except Exception as e:
70
- logging.error(f"Error fetching patient history: {e}")
71
  return []
72
 
73
  def get_patient_list(self, user_id: int) -> List[Dict]:
 
 
 
74
  try:
75
  sql = """
76
- SELECT DISTINCT
77
  p.name AS patient_name,
78
  p.age AS patient_age,
79
  p.gender AS patient_gender,
@@ -92,17 +149,23 @@ class PatientHistoryManager:
92
  return []
93
 
94
  def get_wound_progression(self, user_id: int, patient_name: str) -> List[Dict]:
 
 
 
95
  try:
96
  sql = """
97
  SELECT
98
  qr.submitted_at AS visit_date,
99
  qr.response_data,
 
100
  w.position AS wound_location,
101
  w.moisture,
102
  w.infection,
 
103
  a.risk_score,
104
  a.risk_level,
105
  a.summary,
 
106
  wi.image AS image_url
107
  FROM questionnaire_responses qr
108
  JOIN patients p ON p.id = qr.patient_id
@@ -118,53 +181,20 @@ class PatientHistoryManager:
118
  """
119
  rows = self.db.execute_query(sql, (user_id, patient_name), fetch=True) or []
120
  for r in rows:
121
- r["pain_level"] = self._parse_response_field(r, ["wound_details", "pain_level"])
122
- wl = self._parse_response_field(r, ["wound_details", "location"])
123
- if wl:
124
- r["wound_location"] = wl
125
  return rows
126
  except Exception as e:
127
  logging.error(f"Error fetching wound progression: {e}")
128
  return []
129
 
130
- # ---------- WRAPPERS EXPECTED BY UI ----------
131
- def get_user_patient_history(self, user_id: int) -> List[Dict]:
132
- """Alias for UI: returns all patients’ latest visits for this practitioner."""
133
- return self.get_patient_complete_history(user_id=user_id)
134
-
135
- def search_patient_by_name(self, user_id: int, patient_name: str) -> List[Dict]:
136
- """Alias for UI: filtered history for a single patient."""
137
- return self.get_patient_complete_history(user_id=user_id, patient_name=patient_name)
138
-
139
- # ---------- SIMPLE HTML FORMATTERS FOR UI ----------
140
- def format_history_for_display(self, rows: List[Dict]) -> str:
141
- if not rows:
142
- return "<div class='status-warning'>No history found.</div>"
143
- html = ["<div style='display:flex;flex-direction:column;gap:12px;'>"]
144
- for r in rows:
145
- dt = r.get("visit_date")
146
- try:
147
- dt = dt.strftime('%b %d, %Y %I:%M %p') if hasattr(dt, "strftime") else str(dt)
148
- except Exception:
149
- dt = str(r.get("visit_date"))
150
- html.append(f"""
151
- <div style="padding:12px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;">
152
- <div><strong>{r.get('patient_name','')}</strong> • {r.get('patient_age','N/A')} • {r.get('patient_gender','')}</div>
153
- <div style="color:#4a5568;">{dt}</div>
154
- <div>Wound: {r.get('wound_location','N/A')} • Pain: {r.get('pain_level','N/A')}</div>
155
- <div>Risk: {r.get('risk_level','Unknown')}</div>
156
- {f"<div style='margin-top:6px;color:#2d3748;'><em>{r.get('summary')}</em></div>" if r.get('summary') else ""}
157
- </div>
158
- """)
159
- html.append("</div>")
160
- return "".join(html)
161
-
162
- def format_patient_data_for_display(self, rows: List[Dict]) -> str:
163
- """Same renderer but for a single-patient search results."""
164
- return self.format_history_for_display(rows)
165
-
166
- # ---------- NOTES ----------
167
  def save_patient_note(self, user_id: int, patient_name: str, note: str) -> bool:
 
 
 
 
168
  try:
169
  row = self.db.execute_query_one(
170
  """
@@ -180,15 +210,305 @@ class PatientHistoryManager:
180
  if not row or not row.get("uuid"):
181
  logging.error("save_patient_note: could not resolve patient uuid")
182
  return False
183
-
184
  patient_uuid = row["uuid"]
185
- return bool(self.db.execute_query(
 
186
  """
187
  INSERT INTO notes (uuid, patient_id, note, added_by, created_at, updated_at)
188
  VALUES (UUID(), %s, %s, %s, NOW(), NOW())
189
  """,
190
  (patient_uuid, note, str(user_id))
191
- ))
 
192
  except Exception as e:
193
  logging.error(f"Error saving patient note: {e}")
194
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ """
9
+ Complete patient history and wound tracking system (schema-aligned).
10
+
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
 
18
  def __init__(self, database_manager):
19
  self.db = database_manager
20
 
21
+ # --------------------------- JSON helpers ---------------------------
22
+
23
+ def _safe_json(self, maybe_json) -> Optional[dict]:
24
+ """Parse JSON from str/dict safely."""
25
+ try:
26
+ if maybe_json is None:
27
+ return None
28
+ if isinstance(maybe_json, dict):
29
+ return maybe_json
30
+ if isinstance(maybe_json, (bytes, bytearray)):
31
+ maybe_json = maybe_json.decode("utf-8", errors="ignore")
32
+ if isinstance(maybe_json, str) and maybe_json.strip():
33
+ return json.loads(maybe_json)
34
+ except Exception:
35
+ return None
36
+ return None
37
+
38
+ def _from_response(self, row: Dict, path: List[str], default=None):
39
+ """
40
+ Extract a nested field from qr.response_data JSON.
41
+ path example: ["wound_details", "pain_level"]
42
+ """
43
+ data = self._safe_json(row.get("response_data"))
44
+ cur = data
45
  try:
46
+ for key in path:
 
 
 
 
47
  if isinstance(cur, dict) and key in cur:
48
  cur = cur[key]
49
  else:
 
52
  except Exception:
53
  return default
54
 
55
+ # --------------------------- Core queries ---------------------------
56
+
57
+ def get_patient_complete_history(
58
+ self,
59
+ user_id: int,
60
+ patient_name: Optional[str] = None,
61
+ limit: int = 100,
62
+ offset: int = 0
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
70
+ limit = max(1, min(int(limit), 500))
71
+ offset = max(0, int(offset))
72
+
73
  sql = f"""
74
  SELECT
75
  qr.id AS response_id,
76
+ qr.questionnaire_id,
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,
84
  p.gender AS patient_gender,
85
+
 
86
  w.position AS wound_location,
87
  w.moisture,
88
  w.infection,
89
  w.notes,
90
+
91
  wi.image AS image_url,
92
+
93
  a.analysis_data,
94
  a.summary,
95
  a.recommendations,
 
106
  WHERE qr.practitioner_id = %s
107
  { "AND p.name = %s" if patient_name else "" }
108
  ORDER BY qr.submitted_at DESC
109
+ LIMIT %s OFFSET %s
110
  """
111
+
112
+ params: Tuple = (user_id, patient_name, limit, offset) if patient_name else (user_id, limit, offset)
113
  rows = self.db.execute_query(sql, params, fetch=True) or []
114
+
115
+ # Enrich with JSON-derived fields
116
  for r in rows:
117
+ r["pain_level"] = self._from_response(r, ["wound_details", "pain_level"])
118
+ loc_json = self._from_response(r, ["wound_details", "location"])
119
+ if loc_json:
120
+ r["wound_location"] = loc_json
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]:
128
+ """
129
+ Unique patients seen by this practitioner with first/last visit and count.
130
+ """
131
  try:
132
  sql = """
133
+ SELECT
134
  p.name AS patient_name,
135
  p.age AS patient_age,
136
  p.gender AS patient_gender,
 
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 = """
157
  SELECT
158
  qr.submitted_at AS visit_date,
159
  qr.response_data,
160
+
161
  w.position AS wound_location,
162
  w.moisture,
163
  w.infection,
164
+
165
  a.risk_score,
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
 
181
  """
182
  rows = self.db.execute_query(sql, (user_id, patient_name), fetch=True) or []
183
  for r in rows:
184
+ r["pain_level"] = self._from_response(r, ["wound_details", "pain_level"])
185
+ loc_json = self._from_response(r, ["wound_details", "location"])
186
+ if loc_json:
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:
194
+ """
195
+ Persist a clinical note for the patient's UUID into `notes`.
196
+ Finds the patient by latest encounter under this practitioner.
197
+ """
198
  try:
199
  row = self.db.execute_query_one(
200
  """
 
210
  if not row or not row.get("uuid"):
211
  logging.error("save_patient_note: could not resolve patient uuid")
212
  return False
 
213
  patient_uuid = row["uuid"]
214
+
215
+ rc = self.db.execute_query(
216
  """
217
  INSERT INTO notes (uuid, patient_id, note, added_by, created_at, updated_at)
218
  VALUES (UUID(), %s, %s, %s, NOW(), NOW())
219
  """,
220
  (patient_uuid, note, str(user_id))
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 ---------------------------
242
+
243
+ def _fmt_dt(self, dt_obj) -> str:
244
+ try:
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')
252
+ except Exception:
253
+ return dt_obj
254
+ return str(dt_obj)
255
+ except Exception:
256
+ return str(dt_obj)
257
+
258
+ def _risk_chip(self, risk_level: Optional[str]) -> str:
259
+ rl = (risk_level or "Unknown").strip().lower()
260
+ bg = "#f0f0f0"; fg = "#333"
261
+ if rl.startswith("low"):
262
+ bg, fg = "#d4edda", "#155724"
263
+ elif rl.startswith("moderate"):
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
+ """
271
+ Card-style HTML renderer for the general history list.
272
+ Safe for embedding in Gradio HTML.
273
+ """
274
+ if not rows:
275
+ return "<div class='status-warning'>No history found.</div>"
276
+
277
+ parts = ["<div style='display:flex;flex-direction:column;gap:12px;'>"]
278
+ for r in rows:
279
+ dt = self._fmt_dt(r.get("visit_date"))
280
+ patient = f"{html.escape(str(r.get('patient_name') or ''))}"
281
+ age = html.escape(str(r.get("patient_age") or "N/A"))
282
+ gender = html.escape(str(r.get("patient_gender") or ""))
283
+ wound_loc = html.escape(str(r.get("wound_location") or "N/A"))
284
+ pain = html.escape(str(r.get("pain_level") or "N/A"))
285
+ risk_chip = self._risk_chip(r.get("risk_level"))
286
+ summary = r.get("summary")
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
+
315
+ # ===================== REPORT GENERATOR =====================
316
+
317
+ class ReportGenerator:
318
+ """Professional HTML report generator for wound analysis."""
319
+
320
+ def __init__(self):
321
+ pass
322
+
323
+ def _format_recommendations(self, recommendations) -> str:
324
+ """
325
+ Accepts str OR list OR dict and returns an HTML <ul>.
326
+ """
327
+ # If already a list
328
+ if isinstance(recommendations, list):
329
+ items = [str(x).strip() for x in recommendations if str(x).strip()]
330
+ # If dict, join "key: value"
331
+ elif isinstance(recommendations, dict):
332
+ items = [f"{k}: {v}" for k, v in recommendations.items() if str(v).strip()]
333
+ else:
334
+ # str → split on common delimiters
335
+ rec = str(recommendations or "").strip()
336
+ if not rec:
337
+ return "<p>No specific recommendations available.</p>"
338
+ for delim in ["\n", ". ", "; "]:
339
+ if delim in rec:
340
+ items = [x.strip() for x in rec.split(delim) if x.strip()]
341
+ break
342
+ else:
343
+ items = [rec]
344
+
345
+ if not items:
346
+ return "<p>No specific recommendations available.</p>"
347
+
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
+ return f"""
361
+ <!DOCTYPE html>
362
+ <html lang="en">
363
+ <head>
364
+ <meta charset="UTF-8">
365
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
366
+ <title>SmartHeal AI - Wound Analysis Report</title>
367
+ <style>
368
+ body {{
369
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
370
+ line-height: 1.6; margin: 0; padding: 20px; background-color: #f8f9fa;
371
+ }}
372
+ .report-container {{ max-width: 920px; margin: 0 auto; background: white; border-radius: 10px;
373
+ box-shadow: 0 4px 20px rgba(0,0,0,0.1); overflow: hidden; }}
374
+ .header {{ background: linear-gradient(135deg, #3182ce 0%, #2c5aa0 100%); color: white; padding: 30px; text-align: center; }}
375
+ .header h1 {{ margin: 0; font-size: 28px; font-weight: 600; }}
376
+ .header p {{ margin: 10px 0 0 0; opacity: 0.9; font-size: 16px; }}
377
+ .content {{ padding: 30px; }}
378
+ .section {{ margin-bottom: 30px; border-left: 4px solid #3182ce; padding-left: 20px; }}
379
+ .section h2 {{ color: #2c5aa0; margin-top: 0; font-size: 20px; font-weight: 600; }}
380
+ .grid2 {{ display:grid; grid-template-columns:1fr 1fr; gap:20px; }}
381
+ .card {{ background:#f8f9fa; padding:15px; border-radius:8px; border:1px solid #e9ecef; }}
382
+ .card h3 {{ margin:0 0 10px 0; color:#495057; font-size:13px; font-weight:700; text-transform:uppercase; }}
383
+ .card p {{ margin:0; font-weight:500; color:#212529; }}
384
+ .risk-indicator {{ display:inline-block; padding:8px 16px; border-radius:20px; font-weight:600; text-transform:uppercase; font-size:12px; letter-spacing:.5px; }}
385
+ .risk-low {{ background:#d4edda; color:#155724; }}
386
+ .risk-moderate {{ background:#fff3cd; color:#856404; }}
387
+ .risk-high {{ background:#f8d7da; color:#721c24; }}
388
+ .recommendations {{ background:#e7f3ff; border:1px solid #b3d9ff; border-radius:8px; padding:20px; margin-top:15px; }}
389
+ .image-wrap {{ text-align:center; margin:20px 0; }}
390
+ .wound-image {{ max-width:100%; height:auto; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,.15); }}
391
+ .footer {{ background:#f8f9fa; padding:20px 30px; text-align:center; color:#6c757d; border-top:1px solid #e9ecef; }}
392
+ @media print {{ body {{ background:white; }} .report-container {{ box-shadow:none; }} }}
393
+ </style>
394
+ </head>
395
+ <body>
396
+ <div class="report-container">
397
+ <div class="header">
398
+ <h1>🩺 SmartHeal AI Wound Analysis Report</h1>
399
+ <p>Advanced AI-Powered Clinical Assessment</p>
400
+ </div>
401
+
402
+ <div class="content">
403
+ <div class="section">
404
+ <h2>Patient Information</h2>
405
+ <div class="grid2">
406
+ <div class="card"><h3>Patient Name</h3><p>{html.escape(str(patient_data.get('patient_name','N/A')))}</p></div>
407
+ <div class="card"><h3>Age</h3><p>{html.escape(str(patient_data.get('patient_age','N/A')))} years</p></div>
408
+ <div class="card"><h3>Gender</h3><p>{html.escape(str(patient_data.get('patient_gender','N/A')))}</p></div>
409
+ <div class="card"><h3>Assessment Date</h3><p>{datetime.now().strftime('%B %d, %Y at %I:%M %p')}</p></div>
410
+ </div>
411
+ </div>
412
+
413
+ <div class="section">
414
+ <h2>Wound Assessment</h2>
415
+ <div class="grid2">
416
+ <div class="card"><h3>Location</h3><p>{html.escape(str(patient_data.get('wound_location','N/A')))}</p></div>
417
+ <div class="card"><h3>Duration</h3><p>{html.escape(str(patient_data.get('wound_duration','N/A')))}</p></div>
418
+ <div class="card"><h3>Pain Level</h3><p>{html.escape(str(patient_data.get('pain_level','N/A')))} / 10</p></div>
419
+ <div class="card"><h3>Risk Assessment</h3>
420
+ <p><span class="risk-indicator risk-{html.escape(risk_class)}">{html.escape(str(risk_level))} Risk</span></p>
421
+ </div>
422
+ </div>
423
+ </div>
424
+
425
+ {f"""
426
+ <div class="section">
427
+ <h2>Wound Image</h2>
428
+ <div class="image-wrap">
429
+ <img src="{html.escape(image_url)}" alt="Wound Image" class="wound-image" />
430
+ </div>
431
+ </div>""" if image_url else ""}
432
+
433
+ <div class="section">
434
+ <h2>AI Analysis Summary</h2>
435
+ <p>{html.escape(str(summary))}</p>
436
+ <div class="recommendations">
437
+ <h3>🎯 Clinical Recommendations</h3>
438
+ {recs}
439
+ </div>
440
+ </div>
441
+
442
+ <div class="section">
443
+ <h2>Medical History</h2>
444
+ <div class="grid2">
445
+ <div class="card"><h3>Medical History</h3><p>{html.escape(str(patient_data.get('medical_history','None reported')))}</p></div>
446
+ <div class="card"><h3>Current Medications</h3><p>{html.escape(str(patient_data.get('medications','None reported')))}</p></div>
447
+ <div class="card"><h3>Known Allergies</h3><p>{html.escape(str(patient_data.get('allergies','None reported')))}</p></div>
448
+ <div class="card"><h3>Additional Notes</h3><p>{html.escape(str(patient_data.get('additional_notes','None')))}</p></div>
449
+ </div>
450
+ </div>
451
+ </div>
452
+
453
+ <div class="footer">
454
+ <p><strong>SmartHeal AI</strong> — Advanced Wound Care Analysis & Clinical Support System</p>
455
+ <p>Generated on {datetime.now().strftime('%B %d, %Y at %I:%M %p')} | For professional medical use only</p>
456
+ <p>⚠️ This AI analysis is for clinical support only. Always consult qualified professionals.</p>
457
+ </div>
458
+ </div>
459
+ </body>
460
+ </html>
461
+ """
462
+
463
+ def generate_patient_history_report(self, patient_history: List[Dict]) -> str:
464
+ """
465
+ Large, printable HTML summarizing multiple visits for a patient.
466
+ """
467
+ if not patient_history:
468
+ return "<p>No patient history available.</p>"
469
+
470
+ pname = html.escape(str(patient_history[0].get('patient_name', 'Unknown Patient')))
471
+ rows_html = []
472
+
473
+ # Render each visit
474
+ for i, visit in enumerate(patient_history):
475
+ dt = visit.get('visit_date')
476
+ try:
477
+ if hasattr(dt, "strftime"):
478
+ dt_str = dt.strftime('%B %d, %Y')
479
+ else:
480
+ dt_str = str(dt)
481
+ except Exception:
482
+ dt_str = str(dt)
483
+
484
+ wound_loc = html.escape(str(visit.get('wound_location', 'N/A')))
485
+ pain = html.escape(str(visit.get('pain_level', 'N/A')))
486
+ risk = html.escape(str(visit.get('risk_level', 'Unknown')))
487
+ summary = visit.get('summary')
488
+
489
+ rows_html.append(f"""
490
+ <div style="padding:20px;border-bottom:1px solid #f0f0f0;">
491
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
492
+ <h3 style="margin:0;color:#2c5aa0;">Visit #{len(patient_history)-i}</h3>
493
+ <span style="color:#6c757d;font-size:14px;">{dt_str}</span>
494
+ </div>
495
+ <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin:10px 0;">
496
+ <div style="background:#f8f9fa;padding:10px;border-radius:6px;"><strong>Location:</strong> {wound_loc}</div>
497
+ <div style="background:#f8f9fa;padding:10px;border-radius:6px;"><strong>Pain:</strong> {pain}/10</div>
498
+ <div style="background:#f8f9fa;padding:10px;border-radius:6px;"><strong>Risk:</strong> {risk}</div>
499
+ </div>
500
+ {f"<p style='margin:0.5rem 0 0 0'><strong>Summary:</strong> {html.escape(str(summary))}</p>" if summary else ""}
501
+ </div>
502
+ """)
503
+
504
+ return f"""
505
+ <div style="max-width:920px;margin:0 auto;font-family:'Segoe UI', sans-serif;">
506
+ <div style="background:linear-gradient(135deg,#3182ce 0%,#2c5aa0 100%);color:white;padding:20px;border-radius:10px 10px 0 0;">
507
+ <h2 style="margin:0;">📋 Patient History: {pname}</h2>
508
+ <p style="margin:8px 0 0 0;opacity:0.9;">Complete Treatment Timeline</p>
509
+ </div>
510
+ <div style="background:white;border:1px solid #e9ecef;border-top:none;border-radius:0 0 10px 10px;">
511
+ {''.join(rows_html)}
512
+ </div>
513
+ </div>
514
+ """