SmartHeal-Agentic-AI / src /patient_history.py
SmartHeal's picture
Update src/patient_history.py
ea28d19 verified
raw
history blame
24.4 kB
import logging
import json
import html
from datetime import datetime
from typing import List, Dict, Optional, Tuple
class PatientHistoryManager:
"""
Complete patient history and wound tracking system (schema-aligned).
Key schema expectations this class honors:
- questionnaire_responses.patient_id -> patients.id (INT/BIGINT)
- patients.uuid is the stable string identifier for string-FK tables
- wounds.patient_id, wound_images.patient_id, notes.patient_id are VARCHAR -> store patients.uuid
- ai_analyses.questionnaire_id -> questionnaires.id (template-level linkage)
"""
def __init__(self, database_manager):
self.db = database_manager
# --------------------------- JSON helpers ---------------------------
def _safe_json(self, maybe_json) -> Optional[dict]:
"""Parse JSON from str/dict safely."""
try:
if maybe_json is None:
return None
if isinstance(maybe_json, dict):
return maybe_json
if isinstance(maybe_json, (bytes, bytearray)):
maybe_json = maybe_json.decode("utf-8", errors="ignore")
if isinstance(maybe_json, str) and maybe_json.strip():
return json.loads(maybe_json)
except Exception:
return None
return None
def _from_response(self, row: Dict, path: List[str], default=None):
"""
Extract a nested field from qr.response_data JSON.
path example: ["wound_details", "pain_level"]
"""
data = self._safe_json(row.get("response_data"))
cur = data
try:
for key in path:
if isinstance(cur, dict) and key in cur:
cur = cur[key]
else:
return default
return cur
except Exception:
return default
# --------------------------- Core queries ---------------------------
def get_patient_complete_history(
self,
user_id: int,
patient_name: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[Dict]:
"""
Full visit list for a practitioner, optionally filtered by patient name.
Includes joins to wounds, wound_images (via patients.uuid) and ai_analyses (via questionnaire template).
"""
try:
# Defensive bounds for pagination
limit = max(1, min(int(limit), 500))
offset = max(0, int(offset))
sql = f"""
SELECT
qr.id AS response_id,
qr.questionnaire_id,
qr.submitted_at AS visit_date,
qr.response_data,
p.id AS patient_id_int,
p.uuid AS patient_uuid,
p.name AS patient_name,
p.age AS patient_age,
p.gender AS patient_gender,
w.position AS wound_location,
w.moisture,
w.infection,
w.notes,
wi.image AS image_url,
a.analysis_data,
a.summary,
a.recommendations,
a.risk_score,
a.risk_level
FROM questionnaire_responses qr
JOIN patients p ON p.id = qr.patient_id
LEFT JOIN wounds w
ON (w.patient_id = p.uuid OR w.patient_id = CAST(p.id AS CHAR))
LEFT JOIN wound_images wi
ON (wi.patient_id = p.uuid OR wi.patient_id = CAST(p.id AS CHAR))
LEFT JOIN ai_analyses a
ON a.questionnaire_id = qr.questionnaire_id
WHERE qr.practitioner_id = %s
{ "AND p.name = %s" if patient_name else "" }
ORDER BY qr.submitted_at DESC
LIMIT %s OFFSET %s
"""
params: Tuple = (user_id, patient_name, limit, offset) if patient_name else (user_id, limit, offset)
rows = self.db.execute_query(sql, params, fetch=True) or []
# Enrich with JSON-derived fields
for r in rows:
r["pain_level"] = self._from_response(r, ["wound_details", "pain_level"])
loc_json = self._from_response(r, ["wound_details", "location"])
if loc_json:
r["wound_location"] = loc_json
return rows
except Exception as e:
logging.error(f"Error fetching patient complete history: {e}")
return []
def get_patient_list(self, user_id: int) -> List[Dict]:
"""
Unique patients seen by this practitioner with first/last visit and count.
"""
try:
sql = """
SELECT
p.name AS patient_name,
p.age AS patient_age,
p.gender AS patient_gender,
COUNT(qr.id) AS total_visits,
MAX(qr.submitted_at) AS last_visit,
MIN(qr.submitted_at) AS first_visit
FROM questionnaire_responses qr
JOIN patients p ON p.id = qr.patient_id
WHERE qr.practitioner_id = %s
GROUP BY p.name, p.age, p.gender
ORDER BY last_visit DESC
"""
return self.db.execute_query(sql, (user_id,), fetch=True) or []
except Exception as e:
logging.error(f"Error fetching patient list: {e}")
return []
def get_wound_progression(self, user_id: int, patient_name: str) -> List[Dict]:
"""
Ascending temporal list for one patient — useful for timeline charts.
"""
try:
sql = """
SELECT
qr.submitted_at AS visit_date,
qr.response_data,
w.position AS wound_location,
w.moisture,
w.infection,
a.risk_score,
a.risk_level,
a.summary,
wi.image AS image_url
FROM questionnaire_responses qr
JOIN patients p ON p.id = qr.patient_id
LEFT JOIN wounds w
ON (w.patient_id = p.uuid OR w.patient_id = CAST(p.id AS CHAR))
LEFT JOIN wound_images wi
ON (wi.patient_id = p.uuid OR wi.patient_id = CAST(p.id AS CHAR))
LEFT JOIN ai_analyses a
ON a.questionnaire_id = qr.questionnaire_id
WHERE qr.practitioner_id = %s
AND p.name = %s
ORDER BY qr.submitted_at ASC
"""
rows = self.db.execute_query(sql, (user_id, patient_name), fetch=True) or []
for r in rows:
r["pain_level"] = self._from_response(r, ["wound_details", "pain_level"])
loc_json = self._from_response(r, ["wound_details", "location"])
if loc_json:
r["wound_location"] = loc_json
return rows
except Exception as e:
logging.error(f"Error fetching wound progression: {e}")
return []
def save_patient_note(self, user_id: int, patient_name: str, note: str) -> bool:
"""
Persist a clinical note for the patient's UUID into `notes`.
Finds the patient by latest encounter under this practitioner.
"""
try:
row = self.db.execute_query_one(
"""
SELECT p.uuid
FROM questionnaire_responses qr
JOIN patients p ON p.id = qr.patient_id
WHERE qr.practitioner_id = %s AND p.name = %s
ORDER BY qr.submitted_at DESC
LIMIT 1
""",
(user_id, patient_name)
)
if not row or not row.get("uuid"):
logging.error("save_patient_note: could not resolve patient uuid")
return False
patient_uuid = row["uuid"]
rc = self.db.execute_query(
"""
INSERT INTO notes (uuid, patient_id, note, added_by, created_at, updated_at)
VALUES (UUID(), %s, %s, %s, NOW(), NOW())
""",
(patient_uuid, note, str(user_id))
)
return bool(rc)
except Exception as e:
logging.error(f"Error saving patient note: {e}")
return False
# --------------------------- UI Wrappers ---------------------------
def get_user_patient_history(self, user_id: int) -> List[Dict]:
"""
Wrapper used by UI: returns (paginated) latest history for all patients.
"""
return self.get_patient_complete_history(user_id=user_id, limit=100, offset=0)
def search_patient_by_name(self, user_id: int, patient_name: str) -> List[Dict]:
"""
Wrapper used by UI: returns history for a single patient name.
"""
return self.get_patient_complete_history(user_id=user_id, patient_name=patient_name, limit=100, offset=0)
# --------------------------- Render helpers ---------------------------
def _fmt_dt(self, dt_obj) -> str:
try:
if hasattr(dt_obj, "strftime"):
return dt_obj.strftime('%b %d, %Y %I:%M %p')
if isinstance(dt_obj, str):
# Attempt to prettify ISO-like strings
try:
dt = datetime.fromisoformat(dt_obj.replace('Z', '+00:00'))
return dt.strftime('%b %d, %Y %I:%M %p')
except Exception:
return dt_obj
return str(dt_obj)
except Exception:
return str(dt_obj)
def _risk_chip(self, risk_level: Optional[str]) -> str:
rl = (risk_level or "Unknown").strip().lower()
bg = "#f0f0f0"; fg = "#333"
if rl.startswith("low"):
bg, fg = "#d4edda", "#155724"
elif rl.startswith("moderate"):
bg, fg = "#fff3cd", "#856404"
elif rl.startswith("high"):
bg, fg = "#f8d7da", "#721c24"
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>"
def format_history_for_display(self, rows: List[Dict]) -> str:
"""
Card-style HTML renderer for the general history list.
Safe for embedding in Gradio HTML.
"""
if not rows:
return "<div class='status-warning'>No history found.</div>"
parts = ["<div style='display:flex;flex-direction:column;gap:12px;'>"]
for r in rows:
dt = self._fmt_dt(r.get("visit_date"))
patient = f"{html.escape(str(r.get('patient_name') or ''))}"
age = html.escape(str(r.get("patient_age") or "N/A"))
gender = html.escape(str(r.get("patient_gender") or ""))
wound_loc = html.escape(str(r.get("wound_location") or "N/A"))
pain = html.escape(str(r.get("pain_level") or "N/A"))
risk_chip = self._risk_chip(r.get("risk_level"))
summary = r.get("summary")
img = r.get("image_url")
parts.append("<div style='padding:12px;border:1px solid #e2e8f0;border-radius:10px;background:#fff;'>")
parts.append(f"<div style='display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap'>"
f"<div><strong>{patient}</strong> • {age}{gender}</div>"
f"<div style='color:#4a5568;'>{dt}</div>"
f"</div>")
row2 = (f"Wound: {wound_loc} • Pain: {pain} • Risk: {risk_chip}")
parts.append(f"<div style='margin-top:6px'>{row2}</div>")
if summary:
parts.append(f"<div style='margin-top:6px;color:#2d3748'><em>{html.escape(str(summary))}</em></div>")
if img:
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>")
parts.append("</div>") # card
parts.append("</div>")
return "".join(parts)
def format_patient_data_for_display(self, rows: List[Dict]) -> str:
"""
Renderer for a single patient's history (reuses card format).
"""
return self.format_history_for_display(rows)
# ===================== REPORT GENERATOR =====================
class ReportGenerator:
"""Professional HTML report generator for wound analysis."""
def __init__(self):
pass
def _format_recommendations(self, recommendations) -> str:
"""
Accepts str OR list OR dict and returns an HTML <ul>.
"""
# If already a list
if isinstance(recommendations, list):
items = [str(x).strip() for x in recommendations if str(x).strip()]
# If dict, join "key: value"
elif isinstance(recommendations, dict):
items = [f"{k}: {v}" for k, v in recommendations.items() if str(v).strip()]
else:
# str → split on common delimiters
rec = str(recommendations or "").strip()
if not rec:
return "<p>No specific recommendations available.</p>"
for delim in ["\n", ". ", "; "]:
if delim in rec:
items = [x.strip() for x in rec.split(delim) if x.strip()]
break
else:
items = [rec]
if not items:
return "<p>No specific recommendations available.</p>"
lis = "".join(f"<li>{html.escape(i)}</li>" for i in items if len(i) > 2)
return f"<ul>{lis}</ul>"
def generate_analysis_report(self, patient_data: Dict, analysis_data: Dict, image_url: str = None) -> str:
"""
Generate comprehensive printable HTML report for a single analysis.
"""
risk_level = (analysis_data or {}).get("risk_level", "Unknown")
risk_class = f"risk-{str(risk_level).lower().replace(' ', '-')}"
summary = (analysis_data or {}).get("summary", "No analysis summary available.")
recs = self._format_recommendations((analysis_data or {}).get("recommendations", ""))
# Build optional image section first to avoid nested f-strings
image_section = (
f"""
<div class="section">
<h2>Wound Image</h2>
<div class="image-wrap">
<img src="{html.escape(image_url)}" alt="Wound Image" class="wound-image" />
</div>
</div>
""" if image_url else ""
)
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SmartHeal AI - Wound Analysis Report</title>
<style>
body {{
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6; margin: 0; padding: 20px; background-color: #f8f9fa;
}}
.report-container {{ max-width: 920px; margin: 0 auto; background: white; border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1); overflow: hidden; }}
.header {{ background: linear-gradient(135deg, #3182ce 0%, #2c5aa0 100%); color: white; padding: 30px; text-align: center; }}
.header h1 {{ margin: 0; font-size: 28px; font-weight: 600; }}
.header p {{ margin: 10px 0 0 0; opacity: 0.9; font-size: 16px; }}
.content {{ padding: 30px; }}
.section {{ margin-bottom: 30px; border-left: 4px solid #3182ce; padding-left: 20px; }}
.section h2 {{ color: #2c5aa0; margin-top: 0; font-size: 20px; font-weight: 600; }}
.grid2 {{ display:grid; grid-template-columns:1fr 1fr; gap:20px; }}
.card {{ background:#f8f9fa; padding:15px; border-radius:8px; border:1px solid #e9ecef; }}
.card h3 {{ margin:0 0 10px 0; color:#495057; font-size:13px; font-weight:700; text-transform:uppercase; }}
.card p {{ margin:0; font-weight:500; color:#212529; }}
.risk-indicator {{ display:inline-block; padding:8px 16px; border-radius:20px; font-weight:600; text-transform:uppercase; font-size:12px; letter-spacing:.5px; }}
.risk-low {{ background:#d4edda; color:#155724; }}
.risk-moderate {{ background:#fff3cd; color:#856404; }}
.risk-high {{ background:#f8d7da; color:#721c24; }}
.recommendations {{ background:#e7f3ff; border:1px solid #b3d9ff; border-radius:8px; padding:20px; margin-top:15px; }}
.image-wrap {{ text-align:center; margin:20px 0; }}
.wound-image {{ max-width:100%; height:auto; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,.15); }}
.footer {{ background:#f8f9fa; padding:20px 30px; text-align:center; color:#6c757d; border-top:1px solid #e9ecef; }}
@media print {{ body {{ background:white; }} .report-container {{ box-shadow:none; }} }}
</style>
</head>
<body>
<div class="report-container">
<div class="header">
<h1>🩺 SmartHeal AI Wound Analysis Report</h1>
<p>Advanced AI-Powered Clinical Assessment</p>
</div>
<div class="content">
<div class="section">
<h2>Patient Information</h2>
<div class="grid2">
<div class="card"><h3>Patient Name</h3><p>{html.escape(str(patient_data.get('patient_name','N/A')))}</p></div>
<div class="card"><h3>Age</h3><p>{html.escape(str(patient_data.get('patient_age','N/A')))} years</p></div>
<div class="card"><h3>Gender</h3><p>{html.escape(str(patient_data.get('patient_gender','N/A')))}</p></div>
<div class="card"><h3>Assessment Date</h3><p>{datetime.now().strftime('%B %d, %Y at %I:%M %p')}</p></div>
</div>
</div>
<div class="section">
<h2>Wound Assessment</h2>
<div class="grid2">
<div class="card"><h3>Location</h3><p>{html.escape(str(patient_data.get('wound_location','N/A')))}</p></div>
<div class="card"><h3>Duration</h3><p>{html.escape(str(patient_data.get('wound_duration','N/A')))}</p></div>
<div class="card"><h3>Pain Level</h3><p>{html.escape(str(patient_data.get('pain_level','N/A')))} / 10</p></div>
<div class="card"><h3>Risk Assessment</h3>
<p><span class="risk-indicator risk-{html.escape(risk_class)}">{html.escape(str(risk_level))} Risk</span></p>
</div>
</div>
</div>
{image_section}
<div class="section">
<h2>AI Analysis Summary</h2>
<p>{html.escape(str(summary))}</p>
<div class="recommendations">
<h3>🎯 Clinical Recommendations</h3>
{recs}
</div>
</div>
<div class="section">
<h2>Medical History</h2>
<div class="grid2">
<div class="card"><h3>Medical History</h3><p>{html.escape(str(patient_data.get('medical_history','None reported')))}</p></div>
<div class="card"><h3>Current Medications</h3><p>{html.escape(str(patient_data.get('medications','None reported')))}</p></div>
<div class="card"><h3>Known Allergies</h3><p>{html.escape(str(patient_data.get('allergies','None reported')))}</p></div>
<div class="card"><h3>Additional Notes</h3><p>{html.escape(str(patient_data.get('additional_notes','None')))}</p></div>
</div>
</div>
</div>
<div class="footer">
<p><strong>SmartHeal AI</strong> — Advanced Wound Care Analysis & Clinical Support System</p>
<p>Generated on {datetime.now().strftime('%B %d, %Y at %I:%M %p')} | For professional medical use only</p>
<p>⚠️ This AI analysis is for clinical support only. Always consult qualified professionals.</p>
</div>
</div>
</body>
</html>
"""
def generate_patient_history_report(self, patient_history: List[Dict]) -> str:
"""
Large, printable HTML summarizing multiple visits for a patient.
"""
if not patient_history:
return "<p>No patient history available.</p>"
pname = html.escape(str(patient_history[0].get('patient_name', 'Unknown Patient')))
rows_html = []
# Render each visit
for i, visit in enumerate(patient_history):
dt = visit.get('visit_date')
try:
if hasattr(dt, "strftime"):
dt_str = dt.strftime('%B %d, %Y')
else:
dt_str = str(dt)
except Exception:
dt_str = str(dt)
wound_loc = html.escape(str(visit.get('wound_location', 'N/A')))
pain = html.escape(str(visit.get('pain_level', 'N/A')))
risk = html.escape(str(visit.get('risk_level', 'Unknown')))
summary = visit.get('summary')
rows_html.append(f"""
<div style="padding:20px;border-bottom:1px solid #f0f0f0;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h3 style="margin:0;color:#2c5aa0;">Visit #{len(patient_history)-i}</h3>
<span style="color:#6c757d;font-size:14px;">{dt_str}</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin:10px 0;">
<div style="background:#f8f9fa;padding:10px;border-radius:6px;"><strong>Location:</strong> {wound_loc}</div>
<div style="background:#f8f9fa;padding:10px;border-radius:6px;"><strong>Pain:</strong> {pain}/10</div>
<div style="background:#f8f9fa;padding:10px;border-radius:6px;"><strong>Risk:</strong> {risk}</div>
</div>
{f"<p style='margin:0.5rem 0 0 0'><strong>Summary:</strong> {html.escape(str(summary))}</p>" if summary else ""}
</div>
""")
return f"""
<div style="max-width:920px;margin:0 auto;font-family:'Segoe UI', sans-serif;">
<div style="background:linear-gradient(135deg,#3182ce 0%,#2c5aa0 100%);color:white;padding:20px;border-radius:10px 10px 0 0;">
<h2 style="margin:0;">📋 Patient History: {pname}</h2>
<p style="margin:8px 0 0 0;opacity:0.9;">Complete Treatment Timeline</p>
</div>
<div style="background:white;border:1px solid #e9ecef;border-top:none;border-radius:0 0 10px 10px;">
{''.join(rows_html)}
</div>
</div>
"""