Spaces:
Paused
Paused
| import json | |
| import os | |
| import re | |
| from html import escape | |
| from typing import Dict, List, Literal, Optional, Tuple | |
| import gradio as gr | |
| import pandas as pd | |
| # --------------------------------------------------------------------- | |
| # SentinelPlus — In-Class Presentation Build | |
| # Workflow: 1) Patient Check-in 2) Severity Score 3) ADR Triage 4) Doctor's View | |
| # --------------------------------------------------------------------- | |
| # Models used: | |
| # Severity — local DeBERTa v2 fine-tuned classifier (SAFE / INJECTION) | |
| # Extraction — Clinical-AI-Apollo/Medical-NER (HuggingFace Hub) | |
| # --------------------------------------------------------------------- | |
| DEMO_CASES: Dict[str, Dict] = { | |
| "DEMO-001 - Maria Thompson (Osteopenia / Alendronate)": { | |
| "patient_id": "DEMO-001", | |
| "patient_name": "Maria Thompson", | |
| "suspected_medication": "Alendronate", | |
| "expected_severity_label": "Severe", | |
| "expected_triage_summary": "Severe muscle pain, weakness, chills, difficulty walking, chest/rib tightness, emergency department evaluation after taking alendronate.", | |
| "profile": { | |
| "age": "67", "sex": "Female", "dob": "Not on file", "height": "5 ft 4 in", "weight": "142 lb", | |
| "race": "Not on file", "condition": "Osteopenia", | |
| "allergies": "Sulfa antibiotics", | |
| "relevant_history": "Hypertension, osteopenia, mild chronic kidney disease", | |
| "contact_status": "Pre-filled from SentinelPlus profile", | |
| }, | |
| "medications": { | |
| "rx": [ | |
| {"Medication": "Alendronate", "Dose": "70 mg weekly", "Start Date": "2026-04-28", "Status": "New / suspected"}, | |
| {"Medication": "Lisinopril", "Dose": "10 mg daily", "Start Date": "Ongoing", "Status": "Ongoing"}, | |
| ], | |
| "otc": [ | |
| {"Product": "Calcium carbonate", "Dose": "As directed", "Start Date": "Ongoing"}, | |
| {"Product": "Vitamin D3", "Dose": "As directed", "Start Date": "Ongoing"}, | |
| ], | |
| }, | |
| "biometrics": { | |
| "summary": "Elevated heart rate from pain and stress. Sleep disrupted by chills and deep muscle pain. Mobility sharply reduced because walking required assistance.", | |
| "flags": ["Difficulty walking", "Chills and weakness", "Chest/rib tightness"], | |
| "metrics": [ | |
| {"type": "heart_rate", "value": "108 bpm", "subtitle": "Elevated during pain episode", "flag": "Chest/rib tightness"}, | |
| {"type": "sleep", "value": "Poor", "subtitle": "Disrupted by chills and pain", "flag": "Chills and weakness"}, | |
| {"type": "mobility", "value": "Very Low", "subtitle": "Needed help walking to bathroom", "flag": "Difficulty walking"}, | |
| {"type": "weight", "value": "142 lb", "subtitle": "Patient weight on file", "flag": ""}, | |
| ], | |
| }, | |
| "checkins": [ | |
| {"Date": "2026-04-28", "Check-in": "Took weekly alendronate dose in the morning."}, | |
| {"Date": "2026-04-29", "Check-in": "Severe muscle pain, chills, weakness, and trouble walking; evaluated in emergency department."}, | |
| ], | |
| "testimony": ( | |
| "I took my weekly bone medication in the morning like usual. Later that night I started having deep muscle pain " | |
| "in my legs and back. By the next day the pain was much worse and I had chills, weakness, and trouble walking " | |
| "to the bathroom without help. My chest and ribs felt tight when I tried to take a deep breath. My daughter took " | |
| "me to urgent care and they sent me to the emergency department because I could barely stand." | |
| ), | |
| }, | |
| "DEMO-002 - James Carter (Skin Infection / TMP-SMX)": { | |
| "patient_id": "DEMO-002", | |
| "patient_name": "James Carter", | |
| "suspected_medication": "Trimethoprim-sulfamethoxazole", | |
| "expected_severity_label": "Severe", | |
| "expected_triage_summary": "Rapidly spreading rash, lip/facial swelling, lightheadedness, breathing concern, emergency response after starting trimethoprim-sulfamethoxazole.", | |
| "profile": { | |
| "age": "54", "sex": "Male", "dob": "Not on file", "height": "5 ft 11 in", "weight": "204 lb", | |
| "race": "Not on file", "condition": "Skin infection", | |
| "allergies": "None reported", | |
| "relevant_history": "Type 2 diabetes, high cholesterol", | |
| "contact_status": "Pre-filled from SentinelPlus profile", | |
| }, | |
| "medications": { | |
| "rx": [ | |
| {"Medication": "Trimethoprim-sulfamethoxazole", "Dose": "800/160 mg twice daily", "Start Date": "2026-05-01", "Status": "New / suspected"}, | |
| {"Medication": "Metformin", "Dose": "500 mg twice daily", "Start Date": "Ongoing", "Status": "Ongoing"}, | |
| {"Medication": "Atorvastatin", "Dose": "40 mg daily", "Start Date": "Ongoing", "Status": "Ongoing"}, | |
| ], | |
| "otc": [{"Product": "Ibuprofen", "Dose": "400 mg as needed", "Start Date": "Ongoing"}], | |
| }, | |
| "biometrics": { | |
| "summary": "Heart rate and breathing rate increased during rash and swelling episode. Lightheadedness reported when standing. Emergency services were contacted.", | |
| "flags": ["Rapidly spreading rash", "Lip/facial swelling", "Emergency response"], | |
| "metrics": [ | |
| {"type": "heart_rate", "value": "116 bpm", "subtitle": "Elevated during acute reaction", "flag": "Emergency response"}, | |
| {"type": "sleep", "value": "Disrupted", "subtitle": "Symptoms worsened overnight", "flag": "Rapidly spreading rash"}, | |
| {"type": "mobility", "value": "Reduced", "subtitle": "Lightheaded when standing", "flag": "Lip/facial swelling"}, | |
| {"type": "weight", "value": "204 lb", "subtitle": "Patient weight on file", "flag": ""}, | |
| ], | |
| }, | |
| "checkins": [ | |
| {"Date": "2026-05-03", "Check-in": "Rash appeared on chest and arms after two days of antibiotic use."}, | |
| {"Date": "2026-05-04", "Check-in": "Rash spread with lip/facial swelling, lightheadedness, and faster breathing; treated in emergency room."}, | |
| ], | |
| "testimony": ( | |
| "I was prescribed an antibiotic for a skin infection. After two days, I noticed a rash on my chest and arms. " | |
| "By the next morning the rash had spread, my lips felt swollen, and I felt lightheaded when I stood up. My wife " | |
| "said my face looked puffy and I was breathing faster than normal. We called 911 and I was treated in the emergency room." | |
| ), | |
| }, | |
| "DEMO-003 - Priya Shah (Anxiety / Sertraline)": { | |
| "patient_id": "DEMO-003", | |
| "patient_name": "Priya Shah", | |
| "suspected_medication": "Sertraline", | |
| "expected_severity_label": "Non-severe", | |
| "expected_triage_summary": "Mild nausea and dry mouth after starting sertraline, no emergency symptoms, patient remained functional and contacted doctor for guidance.", | |
| "profile": { | |
| "age": "31", "sex": "Female", "dob": "Not on file", "height": "5 ft 6 in", "weight": "129 lb", | |
| "race": "Not on file", "condition": "Anxiety symptoms", | |
| "allergies": "None reported", | |
| "relevant_history": "Seasonal allergies, migraines", | |
| "contact_status": "Pre-filled from SentinelPlus profile", | |
| }, | |
| "medications": { | |
| "rx": [ | |
| {"Medication": "Sertraline", "Dose": "25 mg daily", "Start Date": "2026-05-03", "Status": "New / suspected"}, | |
| {"Medication": "Sumatriptan", "Dose": "As needed", "Start Date": "Ongoing", "Status": "Ongoing"}, | |
| ], | |
| "otc": [{"Product": "Cetirizine", "Dose": "As needed", "Start Date": "Ongoing"}], | |
| }, | |
| "biometrics": { | |
| "summary": "No significant biometric changes. Patient remained functional, went to work, and tolerated small meals.", | |
| "flags": ["Mild nausea", "Dry mouth"], | |
| "metrics": [ | |
| {"type": "heart_rate", "value": "72 bpm", "subtitle": "Within usual range", "flag": ""}, | |
| {"type": "sleep", "value": "Normal", "subtitle": "No sleep disruption reported", "flag": ""}, | |
| {"type": "mobility", "value": "Normal", "subtitle": "Went to work and remained functional", "flag": ""}, | |
| {"type": "weight", "value": "129 lb", "subtitle": "Patient weight on file", "flag": "Mild nausea"}, | |
| ], | |
| }, | |
| "checkins": [ | |
| {"Date": "2026-05-05", "Check-in": "Mild morning nausea and dry mouth after starting sertraline."}, | |
| {"Date": "2026-05-06", "Check-in": "No swelling, breathing issues, chest pain, rash, or loss of function; contacted doctor's office."}, | |
| ], | |
| "testimony": ( | |
| "I started sertraline a few days ago. Since then I have had mild nausea in the morning and a dry mouth. I was " | |
| "still able to go to work and eat small meals. I did not have trouble breathing, swelling, chest pain, or a rash. " | |
| "I called my doctor's office to ask whether these side effects are expected." | |
| ), | |
| }, | |
| "DEMO-004 - Robert Nguyen (Seasonal Allergies / Loratadine)": { | |
| "patient_id": "DEMO-004", | |
| "patient_name": "Robert Nguyen", | |
| "suspected_medication": "Loratadine", | |
| "expected_severity_label": "Non-severe", | |
| "expected_triage_summary": "Fatigue and mild headache after loratadine, symptoms improved with rest, no signs of serious allergic reaction or emergency-level ADR.", | |
| "profile": { | |
| "age": "46", "sex": "Male", "dob": "Not on file", "height": "5 ft 8 in", "weight": "176 lb", | |
| "race": "Not on file", "condition": "Seasonal allergies", | |
| "allergies": "Penicillin causes mild rash", | |
| "relevant_history": "Acid reflux, seasonal allergies", | |
| "contact_status": "Pre-filled from SentinelPlus profile", | |
| }, | |
| "medications": { | |
| "rx": [{"Medication": "Omeprazole", "Dose": "20 mg daily", "Start Date": "Ongoing", "Status": "Ongoing"}], | |
| "otc": [ | |
| {"Product": "Loratadine", "Dose": "10 mg daily as needed", "Start Date": "2026-05-06"}, | |
| {"Product": "Acetaminophen", "Dose": "500 mg occasional", "Start Date": "Ongoing"}, | |
| ], | |
| }, | |
| "biometrics": { | |
| "summary": "Mild fatigue and headache with normal heart rate, normal breathing, and preserved mobility. Symptoms improved after hydration and rest.", | |
| "flags": ["Fatigue", "Mild headache"], | |
| "metrics": [ | |
| {"type": "heart_rate", "value": "68 bpm", "subtitle": "Within usual range", "flag": ""}, | |
| {"type": "sleep", "value": "Normal", "subtitle": "No sleep disruption reported", "flag": ""}, | |
| {"type": "mobility", "value": "Normal", "subtitle": "Symptoms improved with rest", "flag": "Fatigue"}, | |
| {"type": "weight", "value": "176 lb", "subtitle": "Patient weight on file", "flag": "Mild headache"}, | |
| ], | |
| }, | |
| "checkins": [ | |
| {"Date": "2026-05-06", "Check-in": "Felt more tired than usual after loratadine and had a slight headache."}, | |
| {"Date": "2026-05-07", "Check-in": "Headache improved with water and rest; no hives, swelling, breathing problems, fever, or severe pain."}, | |
| ], | |
| "testimony": ( | |
| "I took loratadine for allergies and noticed I felt more tired than usual that afternoon. I also had a slight " | |
| "headache, but it improved after drinking water and resting. I did not have hives, swelling, breathing problems, " | |
| "fever, or severe pain. I skipped the next dose and planned to ask the pharmacist if I should try a different allergy medicine." | |
| ), | |
| }, | |
| } | |
| EHR_EXTRAS: Dict[str, Dict] = { | |
| "DEMO-001": { | |
| "profile": { | |
| "dob": "1959-02-14", | |
| "race": "White", | |
| "ethnicity": "Not Hispanic or Latino", | |
| "address": "1846 Maple Ridge Lane", | |
| "city_state": "Columbus, OH", | |
| "zip": "43214", | |
| "country": "United States", | |
| "phone": "614-555-0184", | |
| "email": "maria.thompson@example.com", | |
| "initials": "M. T.", | |
| "signature": "Maria Thompson", | |
| }, | |
| "product_details": { | |
| "product_available": "Yes", | |
| "product_picture_available": "Yes", | |
| "manufacturer": "Generic alendronate manufacturer on pharmacy label", | |
| "product_type": "Drug or Biologic - Generic", | |
| "expiration_date": "2027-11-30", | |
| "lot_number": "ALN042826A", | |
| "ndc_number": "60505-2578-1", | |
| "quantity": "1 tablet", | |
| "frequency": "Once weekly", | |
| "route": "By mouth", | |
| "duration": "1 dose before event", | |
| "therapy_stop_date": "2026-04-29", | |
| "therapy_reduced_date": "Not applicable", | |
| "problem_stopped_after_reduced_or_stopped": "Not yet known", | |
| "problem_returned_after_restart": "Did not restart", | |
| }, | |
| }, | |
| "DEMO-002": { | |
| "profile": { | |
| "dob": "1972-07-09", | |
| "race": "Black or African American", | |
| "ethnicity": "Not Hispanic or Latino", | |
| "address": "7720 Brookstone Court", | |
| "city_state": "Charlotte, NC", | |
| "zip": "28210", | |
| "country": "United States", | |
| "phone": "704-555-0197", | |
| "email": "james.carter@example.com", | |
| "initials": "J. C.", | |
| "signature": "James Carter", | |
| }, | |
| "product_details": { | |
| "product_available": "Yes", | |
| "product_picture_available": "No", | |
| "manufacturer": "Dispensed generic trimethoprim-sulfamethoxazole", | |
| "product_type": "Drug or Biologic - Generic", | |
| "expiration_date": "2027-08-31", | |
| "lot_number": "TS050126B", | |
| "ndc_number": "65862-420-05", | |
| "quantity": "1 tablet", | |
| "frequency": "Twice daily", | |
| "route": "By mouth", | |
| "duration": "2 days", | |
| "therapy_stop_date": "2026-05-04", | |
| "therapy_reduced_date": "Not applicable", | |
| "problem_stopped_after_reduced_or_stopped": "Improved after emergency treatment", | |
| "problem_returned_after_restart": "Did not restart", | |
| }, | |
| }, | |
| "DEMO-003": { | |
| "profile": { | |
| "dob": "1995-11-21", | |
| "race": "Asian", | |
| "ethnicity": "Not Hispanic or Latino", | |
| "address": "4218 Cedar Park Drive", | |
| "city_state": "Austin, TX", | |
| "zip": "78731", | |
| "country": "United States", | |
| "phone": "512-555-0131", | |
| "email": "priya.shah@example.com", | |
| "initials": "P. S.", | |
| "signature": "Priya Shah", | |
| }, | |
| "product_details": { | |
| "product_available": "Yes", | |
| "product_picture_available": "Yes", | |
| "manufacturer": "Generic sertraline manufacturer on pharmacy label", | |
| "product_type": "Drug or Biologic - Generic", | |
| "expiration_date": "2027-10-31", | |
| "lot_number": "SER050326C", | |
| "ndc_number": "31722-214-30", | |
| "quantity": "1 tablet", | |
| "frequency": "Once daily", | |
| "route": "By mouth", | |
| "duration": "3 days at time of report", | |
| "therapy_stop_date": "Ongoing at time of report", | |
| "therapy_reduced_date": "Not applicable", | |
| "problem_stopped_after_reduced_or_stopped": "Not applicable", | |
| "problem_returned_after_restart": "Did not restart", | |
| }, | |
| }, | |
| "DEMO-004": { | |
| "profile": { | |
| "dob": "1980-03-18", | |
| "race": "Asian", | |
| "ethnicity": "Not Hispanic or Latino", | |
| "address": "936 Lakeview Avenue", | |
| "city_state": "Portland, OR", | |
| "zip": "97213", | |
| "country": "United States", | |
| "phone": "503-555-0168", | |
| "email": "robert.nguyen@example.com", | |
| "initials": "R. N.", | |
| "signature": "Robert Nguyen", | |
| }, | |
| "product_details": { | |
| "product_available": "Yes", | |
| "product_picture_available": "Yes", | |
| "manufacturer": "OTC loratadine private-label package", | |
| "product_type": "Over-the-Counter (OTC)", | |
| "expiration_date": "2027-06-30", | |
| "lot_number": "LOR050626D", | |
| "ndc_number": "68196-281-10", | |
| "quantity": "1 tablet", | |
| "frequency": "Once daily as needed", | |
| "route": "By mouth", | |
| "duration": "1 dose before event", | |
| "therapy_stop_date": "2026-05-07", | |
| "therapy_reduced_date": "Not applicable", | |
| "problem_stopped_after_reduced_or_stopped": "Improved with rest and hydration", | |
| "problem_returned_after_restart": "Did not restart", | |
| }, | |
| }, | |
| } | |
| for _case in DEMO_CASES.values(): | |
| _extra = EHR_EXTRAS.get(_case.get("patient_id"), {}) | |
| _case["profile"].update(_extra.get("profile", {})) | |
| _case["product_details"] = _extra.get("product_details", {}) | |
| _case["reporter"] = { | |
| "last_name": "SentinelPlus", | |
| "first_name": "Automated System", | |
| "address": "1200 Health System Way", | |
| "city_state": "Cleveland, OH", | |
| "zip": "44114", | |
| "country": "United States", | |
| "phone": "216-555-0100", | |
| "email": "DrOzOffice@Stayhealthy.com", | |
| "today_date": "2026-05-13", | |
| "reported_to_manufacturer": False, | |
| "do_not_disclose_identity": False, | |
| } | |
| # Terms used only for the driver-extraction helper. | |
| SYMPTOM_TERMS = [ | |
| "muscle pain", "deep muscle pain", "leg pain", "back pain", "chills", "weakness", | |
| "trouble walking", "difficulty walking", "chest tightness", "rib tightness", | |
| "rash", "spreading rash", "swollen lips", "lip swelling", "facial swelling", | |
| "lightheaded", "breathing faster", "breathing concern", "nausea", "mild nausea", | |
| "dry mouth", "tired", "fatigue", "headache", "mild headache", "hives", "swelling", | |
| "breathing problems", "chest pain", "fever", "severe pain", | |
| ] | |
| MEDICATION_TERMS = [ | |
| "alendronate", "lisinopril", "trimethoprim-sulfamethoxazole", | |
| "trimethoprim sulfamethoxazole", "tmp-smx", "antibiotic", "metformin", | |
| "atorvastatin", "ibuprofen", "sertraline", "sumatriptan", "cetirizine", | |
| "loratadine", "omeprazole", "acetaminophen", "calcium carbonate", "vitamin d3", | |
| ] | |
| TIMING_PATTERNS = [ | |
| r"\b\d+\s*hours?\s+later\b", | |
| r"\b\d+\s*days?\s+ago\b", | |
| r"\byesterday\b", | |
| r"\btwo\s+hours\s+after\b", | |
| r"\bafter\s+first\s+dose\b", | |
| r"\bover\s+several\s+days\b", | |
| r"\bthe\s+next\s+day\b", | |
| r"\b\d+\s*weeks?\b", | |
| r"\bwithin\s+\d+\s*hours?\b", | |
| r"\ba few days ago\b", | |
| r"\bafter two days\b", | |
| r"\bby the next morning\b", | |
| r"\blater that night\b", | |
| ] | |
| # HTML injected when "Submit to MedWatch" is clicked. | |
| MODAL_HTML = """ | |
| <div id="mw-overlay" | |
| onclick="if(event.target.id==='mw-overlay')document.getElementById('mw-overlay').remove()" | |
| style="position:fixed;top:0;left:0;width:100%;height:100%; | |
| background:rgba(0,0,0,0.88);z-index:2147483647; | |
| display:flex;align-items:center;justify-content:center; | |
| font-family:sans-serif;"> | |
| <div style="background:#ffffff;border-radius:16px;padding:48px 56px; | |
| text-align:center;max-width:440px;opacity:1; | |
| box-shadow:0 8px 60px rgba(0,0,0,0.6);"> | |
| <div style="font-size:64px;color:#27ae60;line-height:1;">✓</div> | |
| <h2 style="margin:12px 0 8px;color:#1a1a1a;font-size:24px;font-weight:700;"> | |
| Form 3500B Submitted | |
| </h2> | |
| <p style="color:#444;margin:0 0 24px;font-size:14px;line-height:1.6;"> | |
| Your MedWatch report has been submitted to the FDA.<br> | |
| A confirmation has been sent to the reporter on file. | |
| </p> | |
| <button onclick="document.getElementById('mw-overlay').remove()" | |
| style="background:#27ae60;color:#fff;border:none;padding:12px 36px; | |
| border-radius:8px;font-size:14px;cursor:pointer;font-weight:600;"> | |
| OK | |
| </button> | |
| </div> | |
| </div> | |
| """ | |
| # --------------------------------------------------------------------- | |
| # SCENARIO LOADING HELPERS | |
| # --------------------------------------------------------------------- | |
| def load_scenario(case_name: str) -> Tuple[str, str, str, str, List[Dict]]: | |
| case = DEMO_CASES[case_name] | |
| return ( | |
| case["testimony"], | |
| format_profile(case), | |
| format_medications(case), | |
| format_biometrics(case), | |
| [], | |
| ) | |
| def format_profile(case: Dict) -> str: | |
| p = case["profile"] | |
| def row(label: str, value: str) -> str: | |
| return ( | |
| f'<div style="display:contents;">' | |
| f'<div style="font-weight:600;color:#555;font-size:14px;padding:6px 10px 6px 0;' | |
| f'border-bottom:1px solid #eee;">{label}</div>' | |
| f'<div style="font-size:14px;color:#222;padding:6px 0;border-bottom:1px solid #eee;">' | |
| f'{value}</div>' | |
| f'</div>' | |
| ) | |
| fields = [ | |
| ("Patient ID", case.get("patient_id", "Not on file")), | |
| ("Patient", case["patient_name"]), | |
| ("Age", p["age"]), | |
| ("Sex", p["sex"]), | |
| ("Date of Birth", p["dob"]), | |
| ("Height", p.get("height", "Not on file")), | |
| ("Weight", p["weight"]), | |
| ("Race / Ethnicity", p["race"]), | |
| ("Ethnicity Detail", p.get("ethnicity", "Not on file")), | |
| ("Primary Condition", p["condition"]), | |
| ("Suspected Product", case.get("suspected_medication", "Not on file")), | |
| ("Allergies", p["allergies"]), | |
| ("Relevant History", p["relevant_history"]), | |
| ("Home Address", p.get("address", "Not on file")), | |
| ("City / State", p.get("city_state", "Not on file")), | |
| ("ZIP", p.get("zip", "Not on file")), | |
| ("Phone", p.get("phone", "Not on file")), | |
| ("Email", p.get("email", "Not on file")), | |
| ("Initials", p.get("initials", "Not on file")), | |
| ("Contact Status", p["contact_status"]), | |
| ] | |
| rows_html = "".join(row(lbl, val) for lbl, val in fields) | |
| return ( | |
| '<div style="background:#f0f4fe;border:1px solid #c5c6fb;border-radius:10px;' | |
| 'padding:16px 20px;font-family:sans-serif;">' | |
| '<div style="font-weight:700;font-size:14px;color:#6366F1;margin-bottom:12px;' | |
| 'letter-spacing:0.3px;">SentinelPlus Patient Profile</div>' | |
| '<div style="display:grid;grid-template-columns:140px 1fr;column-gap:12px;">' | |
| + rows_html + | |
| '</div></div>' | |
| ) | |
| def format_medications(case: Dict) -> str: | |
| rx = case["medications"]["rx"] | |
| otc = case["medications"]["otc"] | |
| rx_df = pd.DataFrame(rx) | |
| rx_part = "### Current RX Medications\n\n" + rx_df.to_markdown(index=False) | |
| if otc: | |
| otc_df = pd.DataFrame(otc) | |
| otc_part = "\n\n### Current OTC / Supplement Products\n\n" + otc_df.to_markdown(index=False) | |
| else: | |
| otc_part = "\n\n### Current OTC / Supplement Products\n\nNone listed." | |
| return rx_part + otc_part | |
| _METRIC_ICONS = { | |
| "heart_rate": "❤️", | |
| "sleep": "😴", | |
| "mobility": "🚶", | |
| "weight": "⚖️", | |
| } | |
| def format_biometrics(case: Dict) -> str: | |
| metrics = case["biometrics"].get("metrics", []) | |
| cards_html = "" | |
| for m in metrics: | |
| icon = _METRIC_ICONS.get(m["type"], "📊") | |
| if m["type"] == "heart_rate": | |
| animated = ' class="hb-icon"' | |
| elif m["type"] == "weight": | |
| animated = ' class="scale-icon"' | |
| else: | |
| animated = "" | |
| cards_html += f""" | |
| <div style="border:1px solid #e0e0e0;border-radius:14px;padding:16px 18px; | |
| min-width:140px;max-width:200px;background:#fff;color:#1f2937;text-align:center; | |
| box-shadow:0 1px 4px rgba(0,0,0,.06);"> | |
| <div style="font-size:38px;line-height:1.1;"{animated}>{icon}</div> | |
| <div style="font-size:20px;font-weight:700;margin-top:6px;color:#1f2937;">{m['value']}</div> | |
| </div>""" | |
| checkins_html = "".join( | |
| f'<li style="margin-bottom:4px;"><strong>{row["Date"]}:</strong> {row["Check-in"]}</li>' | |
| for row in case["checkins"] | |
| ) | |
| return f""" | |
| <style> | |
| @keyframes heartbeat {{ | |
| 0%,100% {{ transform: scale(1); }} | |
| 15% {{ transform: scale(1.25); }} | |
| 30% {{ transform: scale(1); }} | |
| 45% {{ transform: scale(1.15); }} | |
| 60% {{ transform: scale(1); }} | |
| }} | |
| .hb-icon {{ display:inline-block; animation: heartbeat 1.2s ease-in-out infinite; }} | |
| @keyframes scalebalance {{ | |
| 0%,100% {{ transform: rotate(0deg); }} | |
| 25% {{ transform: rotate(-14deg); }} | |
| 75% {{ transform: rotate(14deg); }} | |
| }} | |
| .scale-icon {{ display:inline-block; animation: scalebalance 2.4s ease-in-out infinite; | |
| transform-origin: center bottom; }} | |
| </style> | |
| <div style="font-family:sans-serif;color:#1f2937;"> | |
| <h3 style="margin-bottom:10px;color:#1f2937;">Wearable Biometric Summary</h3> | |
| <div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:18px;"> | |
| {cards_html} | |
| </div> | |
| <h3 style="margin-bottom:6px;color:#1f2937;">Recent Check-ins</h3> | |
| <ul style="padding-left:18px;margin:0;line-height:1.7;color:#374151;"> | |
| {checkins_html} | |
| </ul> | |
| </div>""" | |
| # --------------------------------------------------------------------- | |
| # MODEL LOADING (LAZY) | |
| # --------------------------------------------------------------------- | |
| _severity_pipe = None | |
| _ner_pipe = None | |
| def _get_severity_pipe(): | |
| global _severity_pipe | |
| if _severity_pipe is None: | |
| from transformers import ( | |
| AutoTokenizer, | |
| AutoModelForSequenceClassification, | |
| pipeline as hf_pipeline, | |
| ) | |
| MODEL_NAME = "Alanouette/sentinelplus-adr-severity" | |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) | |
| model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME) | |
| _severity_pipe = hf_pipeline( | |
| "text-classification", | |
| model=model, | |
| tokenizer=tokenizer, | |
| top_k=None, | |
| truncation=True, | |
| max_length=512, | |
| ) | |
| return _severity_pipe | |
| def _get_ner_pipe(): | |
| global _ner_pipe | |
| if _ner_pipe is None: | |
| from transformers import pipeline as hf_pipeline | |
| _ner_pipe = hf_pipeline( | |
| "token-classification", | |
| model="Clinical-AI-Apollo/Medical-NER", | |
| aggregation_strategy="simple", | |
| ) | |
| return _ner_pipe | |
| # --------------------------------------------------------------------- | |
| # MODEL FUNCTIONS | |
| # --------------------------------------------------------------------- | |
| _DRUG_KEYS = {"drug", "medication", "chemical", "medicine", "substance", "pharma"} | |
| _DISEASE_KEYS = {"disease", "disorder", "condition", "diagnosis", "pathology"} | |
| _TIMING_KEYS = {"time", "date", "duration", "temporal", "frequency", "period"} | |
| LIVE_AGENT_MODEL = os.getenv("SENTINELPLUS_AGENT_MODEL", "gpt-4.1-mini") | |
| ENABLE_LIVE_AGENT = os.getenv("SENTINELPLUS_LIVE_AGENT", "1").lower() not in {"0", "false", "no"} | |
| TRIAGE_AGENT_SYSTEM_PROMPT = """ | |
| You are SentinelPlus ADR Triage Agent, a narrow adverse drug reaction documentation assistant. | |
| Allowed scope: | |
| - Summarize possible adverse drug reaction documentation from the provided case context. | |
| - Return priority, next step, explanation, missing information, and mock FDA Form 3500B fields. | |
| - Ask or suggest clarifying documentation questions about symptoms, timing, medications, product details, allergies, medical history, and reporter fields. | |
| - Recommend human clinician/pharmacist review, urgent provider review, emergency care for red-flag symptoms, or completion of missing report fields. | |
| Hard prohibitions: | |
| - Do not diagnose. | |
| - Do not state that a medication caused the event. | |
| - Do not tell the user to start, stop, restart, skip, increase, decrease, or replace any medication. | |
| - Do not prescribe, dose, recommend, compare, or select prescription medications. | |
| - Do not answer unrelated requests such as restaurants, travel, entertainment, shopping, coding, homework, finance, or legal advice. | |
| - Do not invent facts that are not in the case context. Use "Not on file" or add the field to missing_information. | |
| Tone: | |
| - Plain language. | |
| - Human-in-the-loop. | |
| - Documentation and triage support only. | |
| """.strip() | |
| TRIAGE_QA_SYSTEM_PROMPT = """ | |
| You are SentinelPlus ADR Triage Agent in live Q&A mode. | |
| Answer only questions about the user's adverse drug reaction documentation, symptom clarification, | |
| medication/report context, missing information, or what to discuss with a human clinician. | |
| You must refuse off-topic requests. You must not diagnose or give prescription advice. | |
| For medication changes, advise the user to contact their prescribing clinician or pharmacist. | |
| For severe or emergency symptoms, advise urgent medical care. | |
| """.strip() | |
| OFF_TOPIC_KEYWORDS = { | |
| "restaurant", "restaurants", "pizza", "burger", "hotel", "flight", "vacation", | |
| "movie", "music", "sports", "stock", "crypto", "homework", "essay", "code", | |
| "python", "javascript", "recipe", "dating", "shopping", "weather", | |
| } | |
| PRESCRIPTION_ADVICE_PATTERNS = [ | |
| r"\bshould\s+i\s+(stop|start|take|skip|restart|continue|increase|decrease|double|halve)\b", | |
| r"\bcan\s+i\s+(stop|start|take|skip|restart|continue|increase|decrease|double|halve)\b", | |
| r"\bwhat\s+(dose|dosage)\b", | |
| r"\bprescribe\b", | |
| r"\brecommend\s+(a|another|different)?\s*(medication|drug|dose|dosage)\b", | |
| ] | |
| ADR_ALLOWED_KEYWORDS = { | |
| "adr", "adverse", "reaction", "side effect", "symptom", "symptoms", "medication", | |
| "medicine", "drug", "dose", "timing", "rash", "hives", "nausea", "vomit", | |
| "diarrhea", "pain", "swelling", "breathing", "dizzy", "doctor", "clinician", | |
| "pharmacist", "report", "medwatch", "fda", "3500b", "allergy", "allergies", | |
| "hospital", "urgent", "severe", "mild", "moderate", "biometric", "heart rate", | |
| "sleep", "mobility", "missing", "clarify", "form", | |
| } | |
| def _openai_client(): | |
| if not ENABLE_LIVE_AGENT or not os.getenv("OPENAI_API_KEY"): | |
| return None | |
| try: | |
| from openai import OpenAI | |
| return OpenAI() | |
| except Exception: | |
| return None | |
| def _friendly_live_ai_error(exc: Exception) -> str: | |
| text = str(exc).lower() | |
| if "insufficient_quota" in text or "exceeded your current quota" in text or "error code: 429" in text: | |
| return ( | |
| "Live AI Q&A is connected, but the OpenAI API key has no available quota right now. " | |
| "The local SentinelPlus triage workflow is still available. To enable live responses, " | |
| "update billing/quota for the OpenAI project behind the Hugging Face `OPENAI_API_KEY` secret " | |
| "or replace that secret with a key that has active quota." | |
| ) | |
| if "authentication" in text or "api key" in text or "401" in text: | |
| return ( | |
| "Live AI Q&A could not authenticate with OpenAI. Check the Hugging Face `OPENAI_API_KEY` " | |
| "secret and make sure it is current." | |
| ) | |
| return ( | |
| "Live AI Q&A is temporarily unavailable. The local SentinelPlus triage workflow is still available, " | |
| "and all outputs continue to require human clinical review." | |
| ) | |
| def _list_text(items: List[Dict], name_key: str) -> str: | |
| return "\n".join( | |
| f"- {item.get(name_key, 'Not listed')}, {item.get('Dose', 'dose not listed')}, " | |
| f"started {item.get('Start Date', 'date not listed')} ({item.get('Status', 'status not listed')})" | |
| for item in items | |
| ) or "None listed" | |
| def fallback_form_3500b_fields(case: Dict, severity: Dict) -> Dict: | |
| profile = case["profile"] | |
| rx = case["medications"]["rx"] | |
| otc = case["medications"]["otc"] | |
| testimony = case.get("testimony", "") | |
| suspected = case.get("suspected_medication", "") | |
| suspected_l = suspected.lower() | |
| primary_rx = next( | |
| (m for m in rx if suspected_l and suspected_l in m.get("Medication", "").lower()), | |
| rx[0] if rx else {}, | |
| ) | |
| primary_otc = next( | |
| (m for m in otc if suspected_l and suspected_l in m.get("Product", "").lower()), | |
| {}, | |
| ) | |
| primary_product = primary_rx or primary_otc | |
| product = case.get("product_details", {}) | |
| reporter = case.get("reporter", {}) | |
| is_severe = severity["label"] == "Severe ADR" | |
| hospitalized = "hospital" in testimony.lower() and is_severe | |
| name_parts = case["patient_name"].strip().split() | |
| return { | |
| "problem_type_hurt_or_side_effect": True, | |
| "problem_type_product_use_error": False, | |
| "problem_type_product_quality": False, | |
| "problem_type_switching_maker": False, | |
| "outcome_hospitalization": hospitalized, | |
| "outcome_required_help_prevent_harm": not is_severe, | |
| "outcome_disability": False, | |
| "outcome_birth_defect": False, | |
| "outcome_life_threatening": is_severe, | |
| "outcome_death": False, | |
| "outcome_other_serious_event": False, | |
| "date_problem_occurred": case["checkins"][0]["Date"] if case.get("checkins") else "Not on file", | |
| "event_narrative": testimony[:4000], | |
| "relevant_tests": "Vitals, medication list, allergy list, and encounter notes available in EHR.", | |
| "product_available": product.get("product_available", "Yes"), | |
| "product_picture_available": product.get("product_picture_available", "No"), | |
| "product_name": suspected or primary_rx.get("Medication") or primary_otc.get("Product", "Not listed"), | |
| "place_and_date_of_purchase": product.get("place_and_date_of_purchase", "Patient pharmacy record on file"), | |
| "therapy_ongoing": primary_rx.get("Status", "").lower() in ("ongoing", "continuing"), | |
| "manufacturer": product.get("manufacturer", "Manufacturer from product label on file"), | |
| "product_type": product.get("product_type", "Drug or Biologic"), | |
| "expiration_date": product.get("expiration_date", "On file"), | |
| "lot_number": product.get("lot_number", "On file"), | |
| "ndc_number": product.get("ndc_number", "On file"), | |
| "strength": primary_product.get("Dose", "Not listed"), | |
| "quantity": product.get("quantity", "1 dose"), | |
| "frequency": product.get("frequency", primary_product.get("Dose", "As directed")), | |
| "route": product.get("route", "By mouth"), | |
| "therapy_start_date": primary_product.get("Start Date", "Not listed"), | |
| "therapy_stop_date": product.get("therapy_stop_date", "Ongoing at time of report"), | |
| "therapy_reduced_date": product.get("therapy_reduced_date", "Not applicable"), | |
| "duration": product.get("duration", "On file"), | |
| "reason_for_use": profile.get("condition", "Not on file"), | |
| "problem_stopped_after_reduced_or_stopped": product.get("problem_stopped_after_reduced_or_stopped", "On file"), | |
| "problem_returned_after_restart": product.get("problem_returned_after_restart", "Did not restart"), | |
| "patient_initials": profile.get("initials") or ". ".join(p[0].upper() for p in name_parts if p and p[0].isalpha()) + ".", | |
| "patient_sex": profile.get("sex", "Not on file"), | |
| "patient_age": profile.get("age", "Not on file"), | |
| "patient_dob": profile.get("dob", "Not on file"), | |
| "patient_weight": profile.get("weight", "Not on file"), | |
| "patient_race_ethnicity": profile.get("race", "Not on file"), | |
| "known_medical_conditions": profile.get("relevant_history", "Not on file"), | |
| "allergies": profile.get("allergies", "Not on file"), | |
| "other_important_information": case["biometrics"].get("summary", "Not on file"), | |
| "otc_medications": "\n".join(f"- {m['Product']}, {m['Dose']}" for m in otc) or "None listed", | |
| "current_prescriptions": _list_text(rx, "Medication"), | |
| "reporter_last_name": reporter.get("last_name", "SentinelPlus"), | |
| "reporter_first_name": reporter.get("first_name", "Automated System"), | |
| "reporter_address": reporter.get("address", profile.get("address", "On file")), | |
| "reporter_city_state": reporter.get("city_state", profile.get("city_state", "On file")), | |
| "reporter_zip": reporter.get("zip", profile.get("zip", "On file")), | |
| "reporter_country": reporter.get("country", profile.get("country", "United States")), | |
| "reporter_phone": reporter.get("phone", profile.get("phone", "On file")), | |
| "reporter_email": reporter.get("email", profile.get("email", "On file")), | |
| "reporter_today_date": reporter.get("today_date", "2026-05-13"), | |
| "reported_to_manufacturer": reporter.get("reported_to_manufacturer", False), | |
| "do_not_disclose_identity": reporter.get("do_not_disclose_identity", False), | |
| } | |
| def build_agent_context(severity: Dict, extraction: Dict, case: Dict) -> Dict: | |
| profile = case["profile"] | |
| return { | |
| "purpose": "ADR documentation triage and mock FDA Form 3500B preparation only", | |
| "severity_model_output": severity, | |
| "ner_evidence": extraction, | |
| "patient_profile": { | |
| "age": profile.get("age"), | |
| "sex": profile.get("sex"), | |
| "height": profile.get("height"), | |
| "weight": profile.get("weight"), | |
| "race_ethnicity": profile.get("race"), | |
| "condition": profile.get("condition"), | |
| "allergies": profile.get("allergies"), | |
| "relevant_history": profile.get("relevant_history"), | |
| }, | |
| "suspected_medication": case.get("suspected_medication"), | |
| "medications": case["medications"], | |
| "product_details": case.get("product_details", {}), | |
| "biometrics": case["biometrics"], | |
| "checkins": case.get("checkins", []), | |
| "reporter": case.get("reporter", {}), | |
| "form_3500b_prefill": fallback_form_3500b_fields(case, severity), | |
| "patient_testimony": case.get("testimony", ""), | |
| "allowed_form": "FDA Form 3500B consumer voluntary report fields from Sections A, B, C, E, and F", | |
| } | |
| def is_question_in_scope(question: str) -> Tuple[bool, str]: | |
| q = question.lower().strip() | |
| if not q: | |
| return False, "Ask a question about symptoms, medication context, missing report details, or the ADR situation." | |
| if any(word in q for word in OFF_TOPIC_KEYWORDS) and not any(word in q for word in ADR_ALLOWED_KEYWORDS): | |
| return False, "I can only help with ADR documentation, symptom clarification, medication-context questions, and MedWatch report preparation." | |
| if any(re.search(pattern, q) for pattern in PRESCRIPTION_ADVICE_PATTERNS): | |
| return False, ( | |
| "I cannot recommend starting, stopping, changing, or dosing a medication. " | |
| "For medication decisions, contact the prescribing clinician or pharmacist. " | |
| "If symptoms feel severe or life-threatening, seek urgent medical care." | |
| ) | |
| if not any(word in q for word in ADR_ALLOWED_KEYWORDS): | |
| return False, "I can only answer questions related to this ADR case and report preparation." | |
| return True, "" | |
| def heuristic_severity_from_text(text: str) -> Dict: | |
| t = text.lower() | |
| severe_cues = [ | |
| "911", "emergency", "emergency room", "emergency department", "urgent care", | |
| "could barely stand", "trouble walking", "difficulty walking", "swollen", | |
| "swelling", "breathing faster", "breathing problems", "chest", "lightheaded", | |
| "face looked puffy", "lips felt swollen", | |
| ] | |
| score = 0.82 if any(cue in t for cue in severe_cues) else 0.18 | |
| label = "Severe ADR" if score >= 0.5 else "No Severe ADR Detected" | |
| return { | |
| "label": label, | |
| "probability": score, | |
| "drivers": [], | |
| "note": "Fallback severity estimate from testimony cues.", | |
| } | |
| def _terms_found(text: str, terms: List[str]) -> List[str]: | |
| t = text.lower() | |
| found = [term for term in terms if term.lower() in t] | |
| return sorted(set(found), key=lambda item: (t.find(item.lower()), item)) | |
| def run_severity_model(text: str) -> Dict: | |
| """ | |
| Binary ADR severity classifier. | |
| SAFE (0) = no severe ADR, INJECTION (1) = severe ADR detected. | |
| """ | |
| try: | |
| pipe = _get_severity_pipe() | |
| raw = pipe(text) | |
| except Exception: | |
| return heuristic_severity_from_text(text) | |
| # top_k=None returns [[{...}, {...}]]; normalize to a flat list of score dicts | |
| result = raw[0] if isinstance(raw[0], list) else raw | |
| inj_entry = next( | |
| (s for s in result if "INJECTION" in s["label"].upper() or s["label"] in ("LABEL_1", "1")), | |
| None, | |
| ) | |
| inj_score = inj_entry["score"] if inj_entry else max(s["score"] for s in result) | |
| label = "Severe ADR" if inj_score >= 0.5 else "No Severe ADR Detected" | |
| return { | |
| "label": label, | |
| "probability": round(inj_score, 3), | |
| "drivers": [], # populated from NER output in analyze() | |
| "note": "Output from trained DeBERTa v2 ADR severity classifier (SAFE / INJECTION).", | |
| } | |
| def run_medical_extraction_model(text: str) -> Dict: | |
| """Extract medical entities using Clinical-AI-Apollo/Medical-NER.""" | |
| try: | |
| pipe = _get_ner_pipe() | |
| entities = pipe(text) | |
| except Exception: | |
| entities = [] | |
| medications, symptoms, timing, patient_context = [], [], [], [] | |
| for ent in entities: | |
| grp = ent["entity_group"].lower() | |
| word = ent["word"].strip().strip("##") | |
| if not word: | |
| continue | |
| if any(k in grp for k in _DRUG_KEYS): | |
| medications.append(word) | |
| elif any(k in grp for k in _DISEASE_KEYS): | |
| patient_context.append(word) | |
| elif any(k in grp for k in _TIMING_KEYS): | |
| timing.append(word) | |
| else: | |
| symptoms.append(word) | |
| t = text.lower() | |
| for pattern in TIMING_PATTERNS: | |
| timing.extend(re.findall(pattern, t, flags=re.IGNORECASE)) | |
| medications.extend(_terms_found(text, MEDICATION_TERMS)) | |
| symptoms.extend(_terms_found(text, SYMPTOM_TERMS)) | |
| return { | |
| "medications": sorted(set(medications)) or ["Not detected"], | |
| "symptoms": sorted(set(symptoms)) or ["Not detected"], | |
| "timing": sorted(set(timing)) or ["Not detected"], | |
| "patient_context": sorted(set(patient_context)) or ["Not detected"], | |
| } | |
| # --------------------------------------------------------------------- | |
| # AGENTIC TRIAGE LAYER | |
| # --------------------------------------------------------------------- | |
| def run_live_triage_agent(severity: Dict, extraction: Dict, case: Dict) -> Optional[Dict]: | |
| client = _openai_client() | |
| if client is None: | |
| return None | |
| try: | |
| from pydantic import BaseModel, Field | |
| class Form3500BFields(BaseModel): | |
| problem_type_hurt_or_side_effect: bool | |
| problem_type_product_use_error: bool | |
| problem_type_product_quality: bool | |
| problem_type_switching_maker: bool | |
| outcome_hospitalization: bool | |
| outcome_required_help_prevent_harm: bool | |
| outcome_disability: bool | |
| outcome_birth_defect: bool | |
| outcome_life_threatening: bool | |
| outcome_death: bool | |
| outcome_other_serious_event: bool | |
| date_problem_occurred: str | |
| event_narrative: str = Field(max_length=4000) | |
| relevant_tests: str | |
| product_available: str | |
| product_picture_available: str | |
| product_name: str | |
| place_and_date_of_purchase: str | |
| therapy_ongoing: bool | |
| manufacturer: str | |
| product_type: str | |
| expiration_date: str | |
| lot_number: str | |
| ndc_number: str | |
| strength: str | |
| quantity: str | |
| frequency: str | |
| route: str | |
| therapy_start_date: str | |
| therapy_stop_date: str | |
| therapy_reduced_date: str | |
| duration: str | |
| reason_for_use: str | |
| problem_stopped_after_reduced_or_stopped: str | |
| problem_returned_after_restart: str | |
| patient_initials: str | |
| patient_sex: str | |
| patient_age: str | |
| patient_dob: str | |
| patient_weight: str | |
| patient_race_ethnicity: str | |
| known_medical_conditions: str | |
| allergies: str | |
| other_important_information: str | |
| otc_medications: str | |
| current_prescriptions: str | |
| reporter_last_name: str | |
| reporter_first_name: str | |
| reporter_address: str | |
| reporter_city_state: str | |
| reporter_zip: str | |
| reporter_country: str | |
| reporter_phone: str | |
| reporter_email: str | |
| reporter_today_date: str | |
| reported_to_manufacturer: bool | |
| do_not_disclose_identity: bool | |
| class AgentTriageOutput(BaseModel): | |
| priority: Literal[ | |
| "Urgent provider review", | |
| "Provider review - non-urgent", | |
| "Continue monitoring / routine review", | |
| ] | |
| next_step: str | |
| explanation: str | |
| missing: List[str] | |
| form_3500b: Form3500BFields | |
| response = client.responses.parse( | |
| model=LIVE_AGENT_MODEL, | |
| input=[ | |
| {"role": "system", "content": TRIAGE_AGENT_SYSTEM_PROMPT}, | |
| { | |
| "role": "user", | |
| "content": json.dumps( | |
| build_agent_context(severity, extraction, case), | |
| ensure_ascii=True, | |
| ), | |
| }, | |
| ], | |
| text_format=AgentTriageOutput, | |
| max_output_tokens=2500, | |
| safety_identifier=f"sentinelplus_demo_{case.get('patient_name', 'unknown').replace(' ', '_')}", | |
| ) | |
| parsed = response.output_parsed | |
| if parsed is None: | |
| return None | |
| result = parsed.model_dump() | |
| result["source"] = "live_ai_agent" | |
| return result | |
| except Exception: | |
| return None | |
| def triage_agent(severity: Dict, extraction: Dict, case: Dict) -> Dict: | |
| live = run_live_triage_agent(severity, extraction, case) | |
| if live: | |
| return live | |
| is_severe = severity["label"] == "Severe ADR" | |
| missing = [] | |
| if extraction["medications"] == ["Not detected"]: | |
| missing.append("Confirm suspected medication name") | |
| if extraction["timing"] == ["Not detected"]: | |
| missing.append("Confirm symptom onset date or timing after medication use") | |
| if extraction["symptoms"] == ["Not detected"]: | |
| missing.append("Confirm symptom description") | |
| if not case["medications"]["otc"]: | |
| missing.append("Confirm OTC medication and supplement use") | |
| if is_severe: | |
| priority = "Urgent provider review" | |
| next_step = "Notify care team immediately and prepare high-priority ADR report for human review." | |
| else: | |
| priority = "Provider review — non-urgent" | |
| next_step = "Continue monitoring. Prepare ADR report and confirm any missing details before submission." | |
| return { | |
| "priority": priority, | |
| "next_step": next_step, | |
| "missing": missing or ["No major missing fields detected"], | |
| "explanation": build_agent_explanation(severity, extraction, case), | |
| "form_3500b": fallback_form_3500b_fields(case, severity), | |
| "source": "local_guardrailed_fallback", | |
| } | |
| def build_agent_explanation(severity: Dict, extraction: Dict, case: Dict) -> str: | |
| label = severity["label"] | |
| driver_text = ", ".join(severity["drivers"]) | |
| bio_flags = ", ".join(case["biometrics"]["flags"]) or "none flagged" | |
| meds = ", ".join(extraction["medications"]) | |
| symptoms = ", ".join(extraction["symptoms"]) | |
| timing = ", ".join(extraction["timing"]) | |
| return ( | |
| f"Severity was classified as **{label}** because the testimony included: {driver_text}. " | |
| f"SentinelPlus also considered medication context ({meds}), symptoms ({symptoms}), " | |
| f"timing ({timing}), and biometric flags ({bio_flags})." | |
| ) | |
| # --------------------------------------------------------------------- | |
| # DISPLAY BUILDERS | |
| # --------------------------------------------------------------------- | |
| def build_severity_html(severity: Dict) -> str: | |
| label = severity["label"] | |
| is_severe = label == "Severe ADR" | |
| color = "#c0392b" if is_severe else "#27ae60" | |
| bg = "#fdf0ef" if is_severe else "#edfaf1" | |
| icon = "🚨" if is_severe else "✅" | |
| pulse = "animation:pulse 1.5s ease-in-out infinite;" if is_severe else "" | |
| drivers_html = "".join( | |
| f'<li style="margin:4px 0;color:#444;font-size:14px;">{d}</li>' | |
| for d in severity.get("drivers", []) | |
| ) | |
| return f""" | |
| <style> | |
| @keyframes pulse {{ | |
| 0%,100% {{ box-shadow: 0 0 0 0 {color}55; }} | |
| 50% {{ box-shadow: 0 0 0 10px transparent; }} | |
| }} | |
| </style> | |
| <div style="font-family:sans-serif;padding:12px;max-width:480px;"> | |
| <div style="background:{bg};border:2px solid {color};border-radius:12px; | |
| overflow:hidden;text-align:center;{pulse}"> | |
| <div style="height:6px;background:{color};"></div> | |
| <div style="padding:16px 20px;"> | |
| <div style="font-size:36px;margin-bottom:4px;">{icon}</div> | |
| <div style="font-size:20px;font-weight:800;color:{color};letter-spacing:0.3px;">{label}</div> | |
| </div> | |
| </div> | |
| <div style="margin-top:14px;"> | |
| <h3 style="margin-bottom:6px;color:#333;font-size:14px;">Evidence Cues Detected</h3> | |
| <ul style="padding-left:18px;line-height:1.8;margin:0;font-size:14px;"> | |
| {drivers_html or '<li style="color:#999;">No keyword cues detected in testimony</li>'} | |
| </ul> | |
| </div> | |
| <div style="margin-top:12px;padding:10px 14px;background:#f5f5f5; | |
| border-radius:8px;font-size:14px;color:#666;line-height:1.6;"> | |
| <strong>Model:</strong> DeBERTa v2 fine-tuned ADR severity classifier | |
| </div> | |
| </div>""" | |
| def build_severity_alert_html(severity: Dict) -> str: | |
| if severity["label"] != "Severe ADR": | |
| return "" | |
| return """ | |
| <style> | |
| @keyframes urgentblink { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.15; } | |
| } | |
| .urgent-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| background: #c0392b; | |
| color: #fff; | |
| font-weight: 700; | |
| font-size: 12px; | |
| padding: 6px 14px; | |
| border-radius: 6px; | |
| animation: urgentblink 1s ease-in-out infinite; | |
| letter-spacing: 0.3px; | |
| } | |
| </style> | |
| <div style="margin-bottom:14px;"> | |
| <span class="urgent-badge">⚠ RECOMMENDED: Send to Doctor</span> | |
| </div>""" | |
| def build_fda_3500b_html(case: Dict, severity: Dict, form_fields: Optional[Dict] = None) -> str: | |
| profile = case["profile"] | |
| rx = case["medications"]["rx"] | |
| otc = case["medications"]["otc"] | |
| testimony = case.get("testimony", "") | |
| form = form_fields or fallback_form_3500b_fields(case, severity) | |
| name_parts = case["patient_name"].strip().split() | |
| initials = ". ".join(p[0].upper() for p in name_parts if p and p[0].isalpha()) + "." | |
| is_severe = severity["label"] == "Severe ADR" | |
| hospitalized = "hospital" in testimony.lower() and is_severe | |
| def chk(val: bool) -> str: | |
| return "☑" if val else "☐" | |
| def filled(val: str) -> str: | |
| return ( | |
| f'<span style="background:#fffde7;border:1px solid #d4b800;padding:2px 8px;' | |
| f'border-radius:3px;font-weight:600;color:#222;">{escape(str(val))}</span>' | |
| ) | |
| def nof() -> str: | |
| return '<span style="color:#bbb;font-style:italic;font-size:12px;">Not on file</span>' | |
| def field(name: str, fallback: str = "Not on file") -> str: | |
| value = form.get(name, fallback) | |
| if value is None or value == "": | |
| return fallback | |
| return str(value) | |
| def bool_field(name: str, fallback: bool = False) -> bool: | |
| return bool(form.get(name, fallback)) | |
| date_occurred = field("date_problem_occurred", case["checkins"][0]["Date"] if case.get("checkins") else "Not on file") | |
| narrative = escape(field("event_narrative", testimony[:4000])[:4000]) if testimony or form else "" | |
| primary_rx = rx[0] if rx else {} | |
| is_ongoing = bool_field("therapy_ongoing", primary_rx.get("Status", "").lower() in ("ongoing", "continuing")) | |
| otc_text = "\n".join(f"• {m['Product']}, {m['Dose']}" for m in otc) or "None listed" | |
| rx_text = "\n".join( | |
| f"• {m['Medication']}, {m['Dose']}, started {m['Start Date']} ({m['Status']})" | |
| for m in rx | |
| ) or "None listed" | |
| css = """<style> | |
| .fda3500b{font-family:Arial,sans-serif;font-size:13px;max-width:860px; | |
| border:2px solid #003087;border-radius:4px;overflow:hidden;line-height:1.5;background:#fff;} | |
| .fda-mock-banner{background:#eef2f7;border-bottom:1px solid #b0bfcf;padding:4px 14px; | |
| text-align:right;font-weight:normal;font-size:11px;color:#444;} | |
| .fda-hdr{background:#2471a3;color:#fff;padding:10px 14px;display:flex;align-items:center;gap:14px;} | |
| .fda-logo-box{border:2px solid #fff;padding:4px 9px;font-weight:900;font-size:19px; | |
| letter-spacing:1px;flex-shrink:0;} | |
| .fda-hdr-text{font-size:13px;line-height:1.5;} | |
| .sec-hdr{background:#5b9bd5;color:#fff;padding:5px 12px;font-weight:bold; | |
| font-size:13px;border-top:1px solid #ccc;} | |
| .fda-body{padding:12px 14px;border-top:1px solid #ccc;} | |
| .frow{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;} | |
| .frow-full{margin-bottom:12px;} | |
| .flabel{font-weight:bold;font-size:12px;color:#333;margin-bottom:4px;} | |
| .chklist{line-height:2;font-size:13px;} | |
| .narrative-box{border:1px solid #ccc;padding:8px;border-radius:3px;min-height:80px; | |
| background:#fffde7;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;} | |
| .memo-box{border:1px solid #ccc;padding:8px;border-radius:3px;background:#f9f9f9; | |
| font-size:12px;line-height:1.7;white-space:pre-wrap;} | |
| </style>""" | |
| return css + f"""<div class="fda3500b"> | |
| <div class="fda-mock-banner">Form FDA 3500B (09/2025) | OMB Control No. 0910-0291 | Expiration Date: 09/30/2027 | <em>Pre-filled by SentinelPlus - Demonstration only</em></div> | |
| <div class="fda-hdr"> | |
| <div class="fda-logo-box">FDA</div> | |
| <div class="fda-hdr-text"> | |
| <strong>MedWatch FORM 3500B</strong><br> | |
| U.S. Dept. of Health and Human Services - Food and Drug Administration<br> | |
| Consumer Voluntary Reporting of Adverse Events, Product Problems and Medication Errors | |
| </div> | |
| </div> | |
| <div class="sec-hdr">Section A – About the Problem</div> | |
| <div class="fda-body"> | |
| <div class="frow"> | |
| <div> | |
| <div class="flabel">1. What kind of problem was it?</div> | |
| <div class="chklist"> | |
| {chk(bool_field("problem_type_hurt_or_side_effect", True))} Were hurt or had a bad side effect<br> | |
| {chk(bool_field("problem_type_product_use_error"))} Used a product incorrectly<br> | |
| {chk(bool_field("problem_type_product_quality"))} Noticed a problem with quality<br> | |
| {chk(bool_field("problem_type_switching_maker"))} Problems after switching product maker | |
| </div> | |
| </div> | |
| <div> | |
| <div class="flabel">2. Did any of the following happen?</div> | |
| <div class="chklist"> | |
| {chk(bool_field("outcome_hospitalization", hospitalized))} Hospitalization<br> | |
| {chk(bool_field("outcome_required_help_prevent_harm", not is_severe))} Required help to prevent permanent harm<br> | |
| {chk(bool_field("outcome_disability"))} Disability or health problem<br> | |
| {chk(bool_field("outcome_birth_defect"))} Birth defect<br> | |
| {chk(bool_field("outcome_life_threatening", is_severe))} Life-threatening<br> | |
| {chk(bool_field("outcome_death"))} Death<br> | |
| {chk(bool_field("outcome_other_serious_event"))} Other serious medical event | |
| </div> | |
| </div> | |
| </div> | |
| <div class="frow"> | |
| <div><div class="flabel">3. Date the problem occurred</div>{filled(date_occurred)}</div> | |
| </div> | |
| <div class="frow-full"> | |
| <div class="flabel">4. Tell us what happened</div> | |
| <div class="narrative-box">{narrative}</div> | |
| </div> | |
| <div class="frow-full"> | |
| <div class="flabel">5. Relevant tests / laboratory results</div> | |
| <div class="memo-box">{escape(field("relevant_tests"))}</div> | |
| </div> | |
| </div> | |
| <div class="sec-hdr">Section B – Product Availability</div> | |
| <div class="fda-body"> | |
| <div class="frow"> | |
| <div> | |
| <div class="flabel">1. Do you still have the product?</div> | |
| {filled(field("product_available"))} | |
| </div> | |
| <div> | |
| <div class="flabel">2. Do you have a picture of the product?</div> | |
| {filled(field("product_picture_available"))} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="sec-hdr">Section C – About the Products</div> | |
| <div class="fda-body"> | |
| <div class="frow-full"> | |
| <div class="flabel">1. Name of product</div> | |
| {filled(field("product_name", primary_rx.get("Medication", "Not listed"))) if primary_rx or field("product_name") != "Not on file" else nof()} | |
| </div> | |
| <div class="frow"> | |
| <div><div class="flabel">2. Therapy on-going</div>{chk(is_ongoing)} On-going</div> | |
| <div><div class="flabel">3. Company / manufacturer</div>{filled(field("manufacturer"))}</div> | |
| </div> | |
| <div class="frow"> | |
| <div><div class="flabel">4. Product type</div>{filled(field("product_type"))}</div> | |
| <div><div class="flabel">8. Strength / Dose</div>{filled(field("strength", primary_rx.get("Dose","Not listed")))}</div> | |
| </div> | |
| <div class="frow"> | |
| <div><div class="flabel">6. Lot number</div>{filled(field("lot_number"))}</div> | |
| <div><div class="flabel">7. NDC number</div>{filled(field("ndc_number"))}</div> | |
| </div> | |
| <div class="frow"> | |
| <div><div class="flabel">9. Quantity</div>{filled(field("quantity"))}</div> | |
| <div><div class="flabel">10. Frequency</div>{filled(field("frequency"))}</div> | |
| </div> | |
| <div class="frow"> | |
| <div><div class="flabel">11. How was it taken or used?</div>{filled(field("route"))}</div> | |
| <div><div class="flabel">13. Duration</div>{filled(field("duration"))}</div> | |
| </div> | |
| <div class="frow"> | |
| <div><div class="flabel">12a. Date first started</div>{filled(field("therapy_start_date", primary_rx.get("Start Date","Not listed")))}</div> | |
| <div><div class="flabel">12b. Date stopped</div>{filled(field("therapy_stop_date"))}</div> | |
| </div> | |
| <div class="frow-full"> | |
| <div class="flabel">14. Why was the person using the product?</div> | |
| {filled(field("reason_for_use", profile.get("condition",""))) if field("reason_for_use", profile.get("condition","")) else nof()} | |
| </div> | |
| </div> | |
| <div class="sec-hdr">Section E – About the Person Who Had the Problem</div> | |
| <div class="fda-body"> | |
| <div class="frow"> | |
| <div><div class="flabel">1. Initials</div>{filled(field("patient_initials", initials))}</div> | |
| <div> | |
| <div class="flabel">2. Sex</div> | |
| {chk(field("patient_sex", profile.get("sex","")).lower()=="male")} Male | |
| {chk(field("patient_sex", profile.get("sex","")).lower()=="female")} Female | |
| </div> | |
| </div> | |
| <div class="frow"> | |
| <div><div class="flabel">3. Age</div>{filled(field("patient_age", profile.get("age","")))} Year(s)</div> | |
| <div><div class="flabel">4. Date of Birth</div>{filled(field("patient_dob", profile.get("dob",""))) if field("patient_dob", profile.get("dob","")) else nof()}</div> | |
| </div> | |
| <div class="frow"> | |
| <div><div class="flabel">5. Weight</div>{filled(field("patient_weight", profile.get("weight",""))) if field("patient_weight", profile.get("weight","")) else nof()}</div> | |
| <div> | |
| <div class="flabel">6. Race / Ethnicity</div> | |
| <div class="chklist" style="font-size:12px;"> | |
| {chk(field("patient_race_ethnicity", profile.get("race",""))=="American Indian or Alaska Native")} American Indian or Alaska Native<br> | |
| {chk(field("patient_race_ethnicity", profile.get("race",""))=="Asian")} Asian<br> | |
| {chk(field("patient_race_ethnicity", profile.get("race",""))=="Black or African American")} Black or African American<br> | |
| {chk(field("patient_race_ethnicity", profile.get("race",""))=="Hispanic or Latino")} Hispanic or Latino<br> | |
| {chk(field("patient_race_ethnicity", profile.get("race",""))=="White")} White | |
| </div> | |
| </div> | |
| </div> | |
| <div class="frow-full"> | |
| <div class="flabel">7. Known medical conditions</div> | |
| {filled(field("known_medical_conditions", profile.get("relevant_history",""))) if field("known_medical_conditions", profile.get("relevant_history","")) else nof()} | |
| </div> | |
| <div class="frow-full"> | |
| <div class="flabel">8. Allergies</div> | |
| {filled(field("allergies", profile.get("allergies",""))) if field("allergies", profile.get("allergies","")) else nof()} | |
| </div> | |
| <div class="frow-full"> | |
| <div class="flabel">10. OTC medications, vitamins, supplements</div> | |
| <div class="memo-box">{escape(otc_text)}</div> | |
| </div> | |
| <div class="frow-full"> | |
| <div class="flabel">11. Current prescription medications</div> | |
| <div class="memo-box">{escape(rx_text)}</div> | |
| </div> | |
| </div> | |
| <div class="sec-hdr">Section F – About the Person Filling Out This Form</div> | |
| <div class="fda-body"> | |
| <div class="frow"> | |
| <div><div class="flabel">1 & 2. Name</div>{filled((field("reporter_first_name", "SentinelPlus Automated System") + " " + field("reporter_last_name", "")).strip())}</div> | |
| <div><div class="flabel">7. Telephone number</div>{filled(field("reporter_phone"))}</div> | |
| </div> | |
| <div class="frow"> | |
| <div><div class="flabel">3. Number / Street</div>{filled(field("reporter_address"))}</div> | |
| <div><div class="flabel">4. City and State / Province</div>{filled(field("reporter_city_state"))}</div> | |
| </div> | |
| <div class="frow"> | |
| <div><div class="flabel">5. ZIP or Postal code</div>{filled(field("reporter_zip"))}</div> | |
| <div><div class="flabel">6. Country</div>{filled(field("reporter_country"))}</div> | |
| </div> | |
| <div class="frow"> | |
| <div><div class="flabel">8. Email</div>{filled(field("reporter_email", "DrOzOffice@Stayhealthy.com"))}</div> | |
| <div><div class="flabel">9. Today's date</div>{filled(field("reporter_today_date", "Pre-filled by SentinelPlus"))}</div> | |
| </div> | |
| <div class="frow"> | |
| <div> | |
| <div class="flabel">10. Reported to manufacturer?</div> | |
| {chk(bool_field("reported_to_manufacturer"))} Yes {chk(not bool_field("reported_to_manufacturer"))} No | |
| </div> | |
| <div><div class="flabel">11. Identity disclosure preference</div>{chk(bool_field("do_not_disclose_identity"))} Do not disclose identity</div> | |
| </div> | |
| </div> | |
| </div>""" | |
| def highlight_text(text: str, extraction: Dict, severity: Dict) -> str: | |
| terms = [] | |
| for bucket in ["symptoms", "medications", "timing"]: | |
| terms.extend(v for v in extraction.get(bucket, []) if v != "Not detected") | |
| terms.extend(severity.get("drivers", [])) | |
| terms = sorted(set(terms), key=len, reverse=True) | |
| safe = escape(text) | |
| for term in terms: | |
| if not term: | |
| continue | |
| pattern = re.compile(re.escape(escape(term)), re.IGNORECASE) | |
| safe = pattern.sub(lambda m: f"<mark>{m.group(0)}</mark>", safe) | |
| return f""" | |
| <div style="line-height:1.7;font-size:14px;padding:16px;border:1px solid #ddd; | |
| border-radius:12px;background:#fafafa;"> | |
| {safe} | |
| </div> | |
| <p style="font-size:14px;color:#777;margin-top:6px;"> | |
| Highlighted terms were detected by the extraction model or flagged as severity evidence cues. | |
| </p>""" | |
| def results_table(severity: Dict, extraction: Dict) -> pd.DataFrame: | |
| rows = [ | |
| {"Output Type": "Severity Label", "Value": severity["label"]}, | |
| {"Output Type": "Evidence Cues", "Value": ", ".join(severity["drivers"])}, | |
| {"Output Type": "Medications Extracted", "Value": ", ".join(extraction["medications"])}, | |
| {"Output Type": "Symptoms Extracted", "Value": ", ".join(extraction["symptoms"])}, | |
| {"Output Type": "Timing Extracted", "Value": ", ".join(extraction["timing"])}, | |
| {"Output Type": "Patient Context Extracted", "Value": ", ".join(extraction["patient_context"])}, | |
| ] | |
| return pd.DataFrame(rows) | |
| def triage_markdown(triage: Dict) -> str: | |
| priority = triage["priority"] | |
| if "non-urgent" in priority.lower(): | |
| badge_color = "#e67e00" | |
| elif "urgent" in priority.lower(): | |
| badge_color = "#c0392b" | |
| else: | |
| badge_color = "#27ae60" | |
| missing_items = "".join( | |
| f'<li style="margin:3px 0;">{m}</li>' for m in triage["missing"] | |
| ) | |
| return f""" | |
| <div style="font-family:sans-serif;padding:4px 2px;line-height:1.7;"> | |
| <div style="font-size:16px;font-weight:800;color:#6366F1;margin-bottom:14px;"> | |
| SentinelPlus Triage Agent | |
| </div> | |
| <div style="margin-bottom:14px;"> | |
| <span style="font-size:13px;font-weight:700;color:#6366F1;text-transform:uppercase; | |
| letter-spacing:0.5px;">Priority</span><br> | |
| <span style="display:inline-block;margin-top:4px;background:{badge_color};color:#fff; | |
| font-weight:700;font-size:13px;padding:4px 16px;border-radius:20px;"> | |
| {priority} | |
| </span> | |
| </div> | |
| <div style="margin-bottom:14px;"> | |
| <div style="font-size:13px;font-weight:700;color:#6366F1;text-transform:uppercase; | |
| letter-spacing:0.5px;margin-bottom:4px;">Recommended Next Step</div> | |
| <div style="font-size:14px;color:#222;">{triage['next_step']}</div> | |
| </div> | |
| <div style="margin-bottom:14px;"> | |
| <div style="font-size:13px;font-weight:700;color:#6366F1;text-transform:uppercase; | |
| letter-spacing:0.5px;margin-bottom:4px;">Agent Explanation</div> | |
| <div style="font-size:14px;color:#222;">{triage['explanation']}</div> | |
| </div> | |
| <div> | |
| <div style="font-size:13px;font-weight:700;color:#6366F1;text-transform:uppercase; | |
| letter-spacing:0.5px;margin-bottom:4px;">Missing Information Checklist</div> | |
| <ul style="margin:0;padding-left:18px;font-size:14px;color:#444;"> | |
| {missing_items} | |
| </ul> | |
| </div> | |
| </div>""" | |
| def _chat_append(history: List[Dict], question: str, answer: str) -> List[Dict]: | |
| return history + [ | |
| {"role": "user", "content": question or ""}, | |
| {"role": "assistant", "content": answer}, | |
| ] | |
| def answer_triage_question(question: str, history: List[Dict], analysis_state: Dict, testimony: str): | |
| history = history or [] | |
| allowed, message = is_question_in_scope(question or "") | |
| if not allowed: | |
| return "", _chat_append(history, question, message) | |
| try: | |
| analysis_state = analysis_state or {} | |
| if not analysis_state.get("triage"): | |
| return "", _chat_append( | |
| history, | |
| question, | |
| "Run Analyze Updated Testimony first so I can answer from the current ADR analysis.", | |
| ) | |
| if (testimony or "").strip() != analysis_state.get("testimony", ""): | |
| return "", _chat_append( | |
| history, | |
| question, | |
| "The testimony has changed since the last analysis. Click Analyze Updated Testimony, then ask your question again.", | |
| ) | |
| case = analysis_state["case"] | |
| severity = analysis_state["severity"] | |
| extraction = analysis_state["extraction"] | |
| triage = analysis_state["triage"] | |
| client = _openai_client() | |
| if client is None: | |
| fallback = ( | |
| "Live AI Q&A is not connected yet. Based on the local triage output, " | |
| f"priority is: {triage['priority']}. The safest next step is: {triage['next_step']} " | |
| "I can help identify missing report details once OPENAI_API_KEY is configured." | |
| ) | |
| return "", _chat_append(history, question, fallback) | |
| response = client.responses.create( | |
| model=LIVE_AGENT_MODEL, | |
| input=[ | |
| {"role": "system", "content": TRIAGE_QA_SYSTEM_PROMPT}, | |
| { | |
| "role": "user", | |
| "content": json.dumps( | |
| { | |
| "case_context": build_agent_context(severity, extraction, case), | |
| "triage_output": { | |
| "priority": triage.get("priority"), | |
| "next_step": triage.get("next_step"), | |
| "explanation": triage.get("explanation"), | |
| "missing": triage.get("missing"), | |
| }, | |
| "recent_chat": history[-8:], | |
| "user_question": question, | |
| }, | |
| ensure_ascii=True, | |
| ), | |
| }, | |
| ], | |
| max_output_tokens=500, | |
| safety_identifier=f"sentinelplus_qa_{case.get('patient_name', 'unknown').replace(' ', '_')}", | |
| ) | |
| answer = getattr(response, "output_text", "").strip() | |
| if not answer: | |
| answer = "I could not generate a live response. Please ask a question about symptoms, timing, medications, or missing report details." | |
| return "", _chat_append(history, question, answer) | |
| except Exception as exc: | |
| return "", _chat_append(history, question, _friendly_live_ai_error(exc)) | |
| # --------------------------------------------------------------------- | |
| # MAIN ANALYSIS PIPELINE | |
| # --------------------------------------------------------------------- | |
| def analyze(case_name: str, testimony: str): | |
| try: | |
| case = DEMO_CASES[case_name].copy() | |
| testimony = (testimony or "").strip() | |
| if not testimony: | |
| msg = ( | |
| '<div style="padding:16px;background:#fff8e6;border:2px solid #e0a100;' | |
| 'border-radius:8px;color:#5c4300;font-family:sans-serif;line-height:1.6;">' | |
| '<strong>Testimony needed</strong><br>' | |
| 'Enter patient testimony, then click Analyze Updated Testimony.</div>' | |
| ) | |
| empty_df = pd.DataFrame([{"Output Type": "Input Needed", "Value": "Patient testimony is empty"}]) | |
| return msg, empty_df, msg, msg, "", build_fda_3500b_html(case, heuristic_severity_from_text("")), {} | |
| case["testimony"] = testimony | |
| severity = run_severity_model(testimony) | |
| extraction = run_medical_extraction_model(testimony) | |
| severity["drivers"] = extraction["symptoms"][:6] | |
| triage = triage_agent(severity, extraction, case) | |
| analysis_state = { | |
| "case_name": case_name, | |
| "testimony": testimony, | |
| "case": case, | |
| "severity": severity, | |
| "extraction": extraction, | |
| "triage": triage, | |
| } | |
| return ( | |
| build_severity_html(severity), | |
| results_table(severity, extraction), | |
| highlight_text(testimony, extraction, severity), | |
| triage_markdown(triage), | |
| build_severity_alert_html(severity), | |
| build_fda_3500b_html(case, severity, triage.get("form_3500b")), | |
| analysis_state, | |
| ) | |
| except Exception as exc: | |
| err_html = ( | |
| f'<div style="padding:16px;background:#fdf0ef;border:2px solid #c0392b;' | |
| f'border-radius:8px;color:#c0392b;font-family:sans-serif;line-height:1.6;">' | |
| f'<strong>⚠ Model Error</strong><br>{escape(str(exc))}<br>' | |
| f'<small>Ensure transformers, torch, and sentencepiece are installed ' | |
| f'and the model files are accessible.</small></div>' | |
| ) | |
| empty_df = pd.DataFrame([{"Output Type": "Error", "Value": str(exc)}]) | |
| return err_html, empty_df, err_html, err_html, "", err_html, {} | |
| # --------------------------------------------------------------------- | |
| # GRADIO UI | |
| # --------------------------------------------------------------------- | |
| _first_case = list(DEMO_CASES.keys())[0] | |
| CUSTOM_CSS = """ | |
| .gradio-container { | |
| background: #ffffff !important; | |
| } | |
| #main_tabs_container { | |
| background: #ffffff !important; | |
| } | |
| .tabitem { | |
| background: #ffffff !important; | |
| } | |
| #patient_dropdown, | |
| #testimony_box, | |
| #profile_panel, | |
| #biometric_panel, | |
| #meds_panel, | |
| #highlighted_panel, | |
| #severity_panel, | |
| #extraction_table, | |
| #triage_panel { | |
| border: 2px solid #6366F1 !important; | |
| border-radius: 10px !important; | |
| background: #ffffff !important; | |
| } | |
| #triage_chatbot, | |
| #triage_question { | |
| border: 2px solid #6366F1 !important; | |
| border-radius: 10px !important; | |
| background: #ffffff !important; | |
| } | |
| #extraction_table .table-wrap, | |
| #extraction_table .wrap, | |
| #extraction_table > div, | |
| #meds_panel .prose, | |
| #meds_panel > div { | |
| padding: 14px !important; | |
| } | |
| #biometric_panel, | |
| #biometric_panel *, | |
| #meds_panel, | |
| #meds_panel .prose, | |
| #meds_panel .prose *, | |
| #meds_panel table, | |
| #meds_panel td, | |
| #meds_panel th { | |
| color: #1f2937 !important; | |
| -webkit-text-fill-color: #1f2937 !important; | |
| } | |
| #meds_panel th { | |
| font-weight: 700 !important; | |
| } | |
| #extraction_table label, | |
| #extraction_table table, | |
| #extraction_table td, | |
| #extraction_table th { | |
| font-family: ui-sans-serif, system-ui, -apple-system, sans-serif !important; | |
| font-size: 14px !important; | |
| } | |
| """ | |
| with gr.Blocks(title="SentinelPlus - ADR Intelligent Assistant", theme=gr.themes.Soft(), | |
| css=CUSTOM_CSS) as demo: | |
| gr.HTML(""" | |
| <div style="display:flex;align-items:center;gap:16px;padding:12px 0 8px;"> | |
| <svg width="64" height="62" viewBox="0 0 110 106" xmlns="http://www.w3.org/2000/svg"> | |
| <!-- Vertical pill (light lavender, top-rounded) --> | |
| <rect x="64" y="0" width="46" height="106" rx="23" fill="#CACDF5"/> | |
| <!-- Horizontal pill (light lavender, left-rounded) --> | |
| <rect x="18" y="60" width="92" height="46" rx="23" fill="#CACDF5"/> | |
| <!-- Overlap square (solid indigo) --> | |
| <rect x="64" y="60" width="46" height="46" fill="#6366F1"/> | |
| <!-- Cross centered in overlap (87, 83) --> | |
| <rect x="81" y="68" width="12" height="30" fill="#F0F0FC"/> | |
| <rect x="72" y="77" width="30" height="12" fill="#F0F0FC"/> | |
| </svg> | |
| <div> | |
| <div style="font-size:26px;font-weight:800;color:#6366F1;line-height:1.1; | |
| font-family:sans-serif;letter-spacing:-0.3px;"> | |
| SentinelPlus - ADR Intelligent Assistant | |
| </div> | |
| <div style="font-size:13px;color:#6366F1;margin-top:3px;font-style:italic;font-family:sans-serif;"> | |
| AI-assisted adverse drug reaction detection and FDA MedWatch reporting. | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| main_tabs = gr.Tabs(elem_id="main_tabs_container") | |
| latest_analysis_state = gr.State({}) | |
| with main_tabs: | |
| # ---------------------------------------------------------------- Tab 1 | |
| with gr.Tab("1. Patient Check-in", id=0): | |
| gr.Markdown( | |
| "Select a patient to load their SentinelPlus profile, wearable biometrics, " | |
| "medications, and testimony." | |
| ) | |
| case_dropdown = gr.Dropdown( | |
| choices=list(DEMO_CASES.keys()), | |
| value=_first_case, | |
| label="Select Patient", | |
| elem_id="patient_dropdown", | |
| ) | |
| gr.HTML('<div style="display:inline-block;background:#6366F1;color:#fff;' | |
| 'font-size:11px;font-weight:700;padding:3px 12px;border-radius:20px;' | |
| 'letter-spacing:0.5px;margin-bottom:4px;">PATIENT TESTIMONY</div>') | |
| testimony_box = gr.Textbox( | |
| label="Patient Testimony", | |
| value=DEMO_CASES[_first_case]["testimony"], | |
| lines=8, | |
| interactive=True, | |
| elem_id="testimony_box", | |
| ) | |
| analyze_btn = gr.Button("Analyze Updated Testimony", variant="primary", size="lg") | |
| gr.HTML('<div style="display:inline-block;background:#6366F1;color:#fff;' | |
| 'font-size:11px;font-weight:700;padding:3px 12px;border-radius:20px;' | |
| 'letter-spacing:0.5px;margin:8px 0 4px;">PATIENT PROFILE & BIOMETRICS</div>') | |
| with gr.Row(): | |
| profile_md = gr.HTML(format_profile(DEMO_CASES[_first_case]), | |
| elem_id="profile_panel") | |
| biometric_html = gr.HTML(format_biometrics(DEMO_CASES[_first_case]), | |
| elem_id="biometric_panel") | |
| gr.HTML('<div style="display:inline-block;background:#6366F1;color:#fff;' | |
| 'font-size:11px;font-weight:700;padding:3px 12px;border-radius:20px;' | |
| 'letter-spacing:0.5px;margin:8px 0 4px;">MEDICATIONS</div>') | |
| meds_md = gr.Markdown(format_medications(DEMO_CASES[_first_case]), | |
| elem_id="meds_panel") | |
| # ---------------------------------------------------------------- Tab 2 | |
| with gr.Tab("2. Severity Score", id=1): | |
| gr.Markdown( | |
| "ADR severity output from the DeBERTa v2 fine-tuned classifier. " | |
| "Detected terms in the patient testimony are highlighted below." | |
| ) | |
| highlighted_html = gr.HTML(label="Highlighted Testimony", elem_id="highlighted_panel") | |
| severity_display = gr.HTML(elem_id="severity_panel") | |
| # ---------------------------------------------------------------- Tab 3 | |
| with gr.Tab("3. ADR Triage", id=2): | |
| gr.Markdown( | |
| "Medical entity extraction (Clinical-AI-Apollo/Medical-NER) and " | |
| "SentinelPlus agentic triage summary." | |
| ) | |
| output_table = gr.Dataframe(label="Extraction Results", wrap=True, | |
| elem_id="extraction_table") | |
| triage_output = gr.HTML(elem_id="triage_panel") | |
| gr.Markdown( | |
| "Ask SentinelPlus documentation questions about symptoms, medication context, " | |
| "missing report details, or what to clarify with a clinician." | |
| ) | |
| triage_chatbot = gr.Chatbot( | |
| label="Live ADR Questions", | |
| height=240, | |
| elem_id="triage_chatbot", | |
| ) | |
| with gr.Row(): | |
| triage_question = gr.Textbox( | |
| label="Ask about this ADR case", | |
| placeholder="Example: What details should I clarify about my symptoms before this goes to the doctor?", | |
| lines=2, | |
| elem_id="triage_question", | |
| ) | |
| triage_ask_btn = gr.Button("Ask Agent", variant="primary") | |
| severity_alert = gr.HTML() | |
| send_btn = gr.Button("📋 Send to Doctor", variant="secondary", size="lg") | |
| # ---------------------------------------------------------------- Tab 4 | |
| with gr.Tab("4. Doctor's View", id=3, elem_id="doctors_view_tab"): | |
| gr.Markdown( | |
| "Pre-filled FDA MedWatch Form 3500B. " | |
| "Click **Submit to MedWatch** to simulate report submission." | |
| ) | |
| fda_form_output = gr.HTML() | |
| submit_btn = gr.Button("📤 Submit to MedWatch", variant="primary", size="lg") | |
| modal_html = gr.HTML(value="") | |
| # Outputs from analyze() in order | |
| _analysis_outputs = [ | |
| severity_display, | |
| output_table, | |
| highlighted_html, | |
| triage_output, | |
| severity_alert, | |
| fda_form_output, | |
| latest_analysis_state, | |
| ] | |
| # Dropdown change → reload scenario fields, then auto-run analysis | |
| case_dropdown.change( | |
| fn=load_scenario, | |
| inputs=case_dropdown, | |
| outputs=[testimony_box, profile_md, meds_md, biometric_html, triage_chatbot], | |
| ).then( | |
| fn=analyze, | |
| inputs=[case_dropdown, testimony_box], | |
| outputs=_analysis_outputs, | |
| ) | |
| analyze_btn.click( | |
| fn=analyze, | |
| inputs=[case_dropdown, testimony_box], | |
| outputs=_analysis_outputs, | |
| ) | |
| # Send to Doctor → rerun current testimony, then jump to Tab 4 | |
| send_btn.click( | |
| fn=analyze, | |
| inputs=[case_dropdown, testimony_box], | |
| outputs=_analysis_outputs, | |
| ).then( | |
| fn=lambda: gr.update(selected=3), | |
| outputs=main_tabs, | |
| ) | |
| triage_ask_btn.click( | |
| fn=answer_triage_question, | |
| inputs=[triage_question, triage_chatbot, latest_analysis_state, testimony_box], | |
| outputs=[triage_question, triage_chatbot], | |
| ) | |
| triage_question.submit( | |
| fn=answer_triage_question, | |
| inputs=[triage_question, triage_chatbot, latest_analysis_state, testimony_box], | |
| outputs=[triage_question, triage_chatbot], | |
| ) | |
| # Submit to MedWatch → show confirmation popup | |
| submit_btn.click( | |
| fn=lambda: MODAL_HTML, | |
| outputs=modal_html, | |
| ) | |
| # Page load → run analysis on the default patient | |
| demo.load( | |
| fn=analyze, | |
| inputs=[case_dropdown, testimony_box], | |
| outputs=_analysis_outputs, | |
| ) | |
| gr.Markdown(""" | |
| --- | |
| *SentinelPlus does not diagnose. All outputs require human clinical review before action.* | |
| """) | |
| if __name__ == "__main__": | |
| demo.launch() | |