226 lines
9.9 KiB
Python
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
|