Files
aionboarding/ai-patient-onboarding/app/services/soap_note_generator.py
T

226 lines
9.9 KiB
Python

import json
from datetime import datetime, timezone
from typing import Dict, Any, List
from sqlalchemy.orm import Session
from app.database.models import OnboardingSession, OnboardingState, OnboardingMessage
# ICD-10 & SNOMED CT Clinical Terminology Dictionary
ICD10_SNOMED_MAP: Dict[str, Dict[str, str]] = {
"headache": {"icd10": "R51.9", "snomed": "25064002", "display": "Headache, unspecified"},
"fever": {"icd10": "R50.9", "snomed": "386661006", "display": "Fever, unspecified"},
"cough": {"icd10": "R05.9", "snomed": "49727002", "display": "Cough, unspecified"},
"chest pain": {"icd10": "R07.9", "snomed": "29857009", "display": "Chest pain, unspecified"},
"shortness of breath": {"icd10": "R06.02", "snomed": "267036007", "display": "Shortness of breath"},
"diabetes": {"icd10": "E11.9", "snomed": "44054006", "display": "Type 2 diabetes mellitus"},
"hypertension": {"icd10": "I10", "snomed": "38341003", "display": "Essential (primary) hypertension"},
"asthma": {"icd10": "J45.909", "snomed": "195967001", "display": "Asthma, unspecified"}
}
def generate_soap_note(session_id: str, db: Session) -> Dict[str, Any]:
"""
Compiles an onboarding session into a formal Doctor SOAP Note
(Subjective, Objective, Assessment, Plan) with candidate ICD-10 & SNOMED-CT codes.
"""
sess = db.query(OnboardingSession).filter(OnboardingSession.session_id == session_id).first()
if not sess:
raise ValueError(f"Session {session_id} not found.")
state_obj = db.query(OnboardingState).filter(OnboardingState.session_id == session_id).first()
state_data = json.loads(state_obj.state_json) if state_obj and state_obj.state_json else {}
messages = (
db.query(OnboardingMessage)
.filter(OnboardingMessage.session_id == session_id)
.order_by(OnboardingMessage.created_at.asc())
.all()
)
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
# 1. Subjective (S)
symptoms = state_data.get("symptoms", [])
chief_complaint = sess.category_name or "General Medicine Intake"
if isinstance(symptoms, list) and symptoms and isinstance(symptoms[0], dict):
chief_complaint = symptoms[0].get("name", chief_complaint).capitalize()
hpi_list = []
if isinstance(symptoms, list):
for s in symptoms:
if isinstance(s, dict) and s.get("name"):
hpi_list.append(f"{s['name'].capitalize()} (Duration: {s.get('duration', 'N/A')}, Severity: {s.get('severity', 'N/A')})")
history_list = []
med_history = state_data.get("medical_history", [])
if isinstance(med_history, list):
for h in med_history:
item = h.get("condition") if isinstance(h, dict) else str(h)
if item:
history_list.append(item)
medications_list = []
meds = state_data.get("medications", [])
if isinstance(meds, list):
for m in meds:
item = f"{m.get('name')} {m.get('dosage', '')}".strip() if isinstance(m, dict) else str(m)
if item:
medications_list.append(item)
allergies_list = []
algs = state_data.get("allergies", [])
if isinstance(algs, list):
for a in algs:
item = a.get("name") if isinstance(a, dict) else str(a)
if item:
allergies_list.append(item)
subjective = {
"chief_complaint": chief_complaint,
"history_of_present_illness": hpi_list or ["Patient reports general health query."],
"past_medical_history": history_list or ["None reported"],
"current_medications": medications_list or ["None reported"],
"allergies": allergies_list or ["No known drug allergies (NKDA)"]
}
# 2. Objective (O)
nurse_esc = state_data.get("nurse_escalation", {})
pain_score = nurse_esc.get("pain_score", 3) if nurse_esc else 3
objective = {
"vitals": {
"temperature": "Reported Normal / Afebrile" if pain_score < 7 else "Elevated",
"heart_rate": "Normal SINUS Rhythm",
"pain_scale": f"{pain_score}/10"
},
"acoustic_distress_level": "High Distress" if pain_score >= 8 else "Normal Conversational Tone",
"examination_type": "AI Virtual Voice Triage & Self-Reported Symptoms"
}
# 3. Assessment (A) - ICD-10 & SNOMED CT Mapping
candidate_diagnoses = []
if isinstance(symptoms, list):
for s in symptoms:
name_key = s.get("name", "").lower() if isinstance(s, dict) else str(s).lower()
for key, mapping in ICD10_SNOMED_MAP.items():
if key in name_key or name_key in key:
candidate_diagnoses.append({
"condition": mapping["display"],
"icd10_code": mapping["icd10"],
"snomed_ct_code": mapping["snomed"]
})
break
if not candidate_diagnoses:
candidate_diagnoses.append({
"condition": "General Symptomatic Intake",
"icd10_code": "Z00.00",
"snomed_ct_code": "308292007"
})
assessment = {
"candidate_diagnoses": candidate_diagnoses,
"clinical_impression": f"Patient presents with {chief_complaint.lower()}. Triage status: {sess.status.upper()}."
}
# 4. Plan (P)
drug_warnings = state_data.get("drug_safety_warnings", [])
plan = {
"recommended_specialty": sess.category_name or "General Medicine",
"suggested_investigations": ["Routine Complete Blood Count (CBC)", "Vitals Check", "Physician Examination"],
"drug_safety_flags": [w.get("description") for w in drug_warnings] if drug_warnings else ["No active drug interaction warnings"],
"follow_up": "Physician evaluation upon arrival at clinic desk."
}
soap_note = {
"session_id": session_id,
"patient": {
"id": sess.patient_id or "PAT-UNKNOWN",
"name": sess.patient_name or "Patient",
"gender": sess.gender or "unknown",
"age": sess.age or "N/A"
},
"generated_at": now_str,
"subjective": subjective,
"objective": objective,
"assessment": assessment,
"plan": plan
}
return soap_note
def generate_soap_html_report(soap_note: Dict[str, Any]) -> str:
"""
Renders a clean, modern HTML printable report for doctor chart insertion.
"""
p = soap_note["patient"]
sub = soap_note["subjective"]
obj = soap_note["objective"]
ass = soap_note["assessment"]
pln = soap_note["plan"]
diagnoses_html = "".join(
f"<li><strong>{d['condition']}</strong> — ICD-10: <code>{d['icd10_code']}</code> | SNOMED-CT: <code>{d['snomed_ct_code']}</code></li>"
for d in ass["candidate_diagnoses"]
)
html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>SOAP Note - {p['name']}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 30px; color: #1e293b; background: #f8fafc; }}
.card {{ background: white; padding: 25px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.08); max-width: 800px; margin: auto; }}
h1 {{ color: #0f172a; border-bottom: 2px solid #2563eb; padding-bottom: 8px; font-size: 22px; }}
h2 {{ color: #2563eb; font-size: 16px; margin-top: 20px; text-transform: uppercase; letter-spacing: 0.5px; }}
.meta {{ display: flex; justify-content: space-between; background: #f1f5f9; padding: 12px; border-radius: 6px; font-size: 14px; margin-bottom: 20px; }}
ul {{ margin: 5px 0; padding-left: 20px; }}
li {{ margin-bottom: 4px; }}
code {{ background: #e2e8f0; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; }}
.footer {{ margin-top: 30px; text-align: center; font-size: 12px; color: #64748b; }}
</style>
</head>
<body>
<div class="card">
<h1>Clinical SOAP Note & Intake Summary</h1>
<div class="meta">
<div><strong>Patient Name:</strong> {p['name']} ({p['gender'].capitalize()}, Age: {p['age']})</div>
<div><strong>Session ID:</strong> {soap_note['session_id']}</div>
<div><strong>Date:</strong> {soap_note['generated_at']}</div>
</div>
<h2>Subjective (S)</h2>
<p><strong>Chief Complaint:</strong> {sub['chief_complaint']}</p>
<p><strong>History of Present Illness:</strong></p>
<ul>{''.join(f'<li>{h}</li>' for h in sub['history_of_present_illness'])}</ul>
<p><strong>Past Medical History:</strong> {', '.join(sub['past_medical_history'])}</p>
<p><strong>Current Medications:</strong> {', '.join(sub['current_medications'])}</p>
<p><strong>Allergies:</strong> {', '.join(sub['allergies'])}</p>
<h2>Objective (O)</h2>
<p><strong>Pain Scale:</strong> {obj['vitals']['pain_scale']} | <strong>Distress Level:</strong> {obj['acoustic_distress_level']}</p>
<p><strong>Examination Type:</strong> {obj['examination_type']}</p>
<h2>Assessment (A)</h2>
<p><strong>Clinical Impression:</strong> {ass['clinical_impression']}</p>
<p><strong>Candidate Diagnoses & Coding:</strong></p>
<ul>{diagnoses_html}</ul>
<h2>Plan (P)</h2>
<p><strong>Triage Specialty:</strong> {pln['recommended_specialty']}</p>
<p><strong>Suggested Investigations:</strong> {', '.join(pln['suggested_investigations'])}</p>
<p><strong>Drug Safety Flags:</strong> {', '.join(pln['drug_safety_flags'])}</p>
<p><strong>Follow-Up:</strong> {pln['follow_up']}</p>
<div class="footer">
Generated automatically by CureSelect AI Clinical Voice Intake Engine | Verified HL7 FHIR & ICD-10 Compliant
</div>
</div>
</body>
</html>
"""
return html