Spaces:
Running
Running
Chris McMaster
commited on
Commit
·
a04c9e9
0
Parent(s):
Initial commit
Browse files- app.py +641 -0
- brand_to_generic.py +365 -0
- caching.py +70 -0
- clinical_calculators.py +436 -0
- dbi_mcp.py +293 -0
- dbi_reference_by_route.csv +119 -0
- drug_data_endpoints.py +358 -0
- requirements.txt +5 -0
- utils.py +104 -0
app.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
|
| 4 |
+
from brand_to_generic import brand_lookup
|
| 5 |
+
from dbi_mcp import dbi_mcp
|
| 6 |
+
from clinical_calculators import (
|
| 7 |
+
cockcroft_gault_creatinine_clearance,
|
| 8 |
+
ckd_epi_egfr,
|
| 9 |
+
child_pugh_score,
|
| 10 |
+
bmi_calculator,
|
| 11 |
+
ideal_body_weight,
|
| 12 |
+
dosing_weight_recommendation,
|
| 13 |
+
creatinine_conversion,
|
| 14 |
+
)
|
| 15 |
+
from caching import with_caching
|
| 16 |
+
from utils import with_error_handling, standardize_response, format_json_output
|
| 17 |
+
from drug_data_endpoints import (
|
| 18 |
+
search_adverse_events,
|
| 19 |
+
fetch_event_details,
|
| 20 |
+
drug_label_warnings,
|
| 21 |
+
drug_recalls,
|
| 22 |
+
drug_pregnancy_lactation,
|
| 23 |
+
drug_dose_adjustments,
|
| 24 |
+
drug_livertox_summary,
|
| 25 |
+
)
|
| 26 |
+
import logging
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# Configure logging
|
| 30 |
+
logging.basicConfig(
|
| 31 |
+
level=logging.INFO,
|
| 32 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 33 |
+
handlers=[logging.FileHandler("mcp_server.log"), logging.StreamHandler()],
|
| 34 |
+
)
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@with_error_handling
|
| 39 |
+
def _brand_lookup_gradio(brand_name: str, prefer_countries_str: str = ""):
|
| 40 |
+
"""Brand to generic lookup for single input."""
|
| 41 |
+
prefer_countries_list = (
|
| 42 |
+
[c.strip().upper() for c in prefer_countries_str.split(",") if c.strip()]
|
| 43 |
+
if prefer_countries_str
|
| 44 |
+
else None
|
| 45 |
+
)
|
| 46 |
+
result = brand_lookup(brand_name, prefer_countries=prefer_countries_list)
|
| 47 |
+
return standardize_response(result, "brand_to_generic")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@with_error_handling
|
| 51 |
+
def _dbi_mcp_gradio(text_block: str, route: str = "oral"):
|
| 52 |
+
result = dbi_mcp(text_block, route=route, ref_csv="dbi_reference_by_route.csv")
|
| 53 |
+
return standardize_response(result, "dbi_calculator")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@with_error_handling
|
| 57 |
+
def _cockcroft_gault_gradio(
|
| 58 |
+
age: int, weight_kg: float, serum_creatinine: float, is_female: bool
|
| 59 |
+
):
|
| 60 |
+
result = cockcroft_gault_creatinine_clearance(
|
| 61 |
+
age, weight_kg, serum_creatinine, is_female
|
| 62 |
+
)
|
| 63 |
+
return standardize_response(result, "cockcroft_gault_calculator")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@with_error_handling
|
| 67 |
+
def _ckd_epi_gradio(age: int, serum_creatinine: float, is_female: bool, is_black: bool):
|
| 68 |
+
result = ckd_epi_egfr(age, serum_creatinine, is_female, is_black)
|
| 69 |
+
return standardize_response(result, "ckd_epi_calculator")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@with_error_handling
|
| 73 |
+
def _child_pugh_gradio(
|
| 74 |
+
bilirubin: float, albumin: float, inr: float, ascites: str, encephalopathy: str
|
| 75 |
+
):
|
| 76 |
+
result = child_pugh_score(bilirubin, albumin, inr, ascites, encephalopathy)
|
| 77 |
+
return standardize_response(result, "child_pugh_calculator")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@with_error_handling
|
| 81 |
+
def _bmi_gradio(weight_kg: float, height_cm: float):
|
| 82 |
+
result = bmi_calculator(weight_kg, height_cm)
|
| 83 |
+
return standardize_response(result, "bmi_calculator")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@with_error_handling
|
| 87 |
+
def _ideal_body_weight_gradio(height_cm: float, is_male: bool):
|
| 88 |
+
result = ideal_body_weight(height_cm, is_male)
|
| 89 |
+
return standardize_response(result, "ideal_body_weight_calculator")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@with_error_handling
|
| 93 |
+
def _dosing_weight_gradio(actual_weight: float, height_cm: float, is_male: bool):
|
| 94 |
+
result = dosing_weight_recommendation(actual_weight, height_cm, is_male)
|
| 95 |
+
return standardize_response(result, "dosing_weight_calculator")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@with_error_handling
|
| 99 |
+
def _creatinine_conversion_gradio(value: float, from_unit: str, to_unit: str):
|
| 100 |
+
result = creatinine_conversion(value, from_unit, to_unit)
|
| 101 |
+
return standardize_response(result, "creatinine_conversion")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@with_error_handling
|
| 105 |
+
@with_caching(ttl=1800)
|
| 106 |
+
def search_adverse_events_mcp(drug_name: str, limit: str = "5") -> str:
|
| 107 |
+
"""
|
| 108 |
+
Search FAERS for adverse events related to a drug and return brief summaries.
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
drug_name (str): Generic or brand name to search (case-insensitive)
|
| 112 |
+
limit (str): Maximum number of FAERS safety reports to return (default: "5")
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
str: JSON string with adverse event contexts and metadata
|
| 116 |
+
"""
|
| 117 |
+
limit_int = int(limit) if limit.isdigit() else 5
|
| 118 |
+
result = search_adverse_events(drug_name, limit_int)
|
| 119 |
+
return format_json_output(result)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@with_error_handling
|
| 123 |
+
@with_caching(ttl=3600)
|
| 124 |
+
def fetch_event_details_mcp(event_id: str) -> str:
|
| 125 |
+
"""
|
| 126 |
+
Fetch full FAERS case details by safety-report ID.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
event_id (str): Numeric FAERS safetyreportid string
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
str: JSON string with structured case data including drugs, reactions, and full record
|
| 133 |
+
"""
|
| 134 |
+
result = fetch_event_details(event_id)
|
| 135 |
+
return format_json_output(result)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@with_error_handling
|
| 139 |
+
@with_caching(ttl=7200)
|
| 140 |
+
def drug_label_warnings_mcp(drug_name: str) -> str:
|
| 141 |
+
"""
|
| 142 |
+
Get FDA label warnings including boxed warnings, contraindications, and drug interactions.
|
| 143 |
+
|
| 144 |
+
Args:
|
| 145 |
+
drug_name (str): Generic drug name preferred
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
str: JSON string with boxed warnings, contraindications, and interaction data
|
| 149 |
+
"""
|
| 150 |
+
result = drug_label_warnings(drug_name)
|
| 151 |
+
return format_json_output(result)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@with_error_handling
|
| 155 |
+
@with_caching(ttl=3600)
|
| 156 |
+
def drug_recalls_mcp(drug_name: str, limit: str = "5") -> str:
|
| 157 |
+
"""
|
| 158 |
+
Find recent FDA recall events for a drug.
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
drug_name (str): Free-text search string for the drug
|
| 162 |
+
limit (str): Maximum number of recall notices to return (default: "5")
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
str: JSON string with recall notices including recall number, status, and reason
|
| 166 |
+
"""
|
| 167 |
+
limit_int = int(limit) if limit.isdigit() else 5
|
| 168 |
+
result = drug_recalls(drug_name, limit_int)
|
| 169 |
+
return format_json_output(result)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@with_error_handling
|
| 173 |
+
@with_caching(ttl=7200)
|
| 174 |
+
def drug_pregnancy_lactation_mcp(drug_name: str) -> str:
|
| 175 |
+
"""
|
| 176 |
+
Get pregnancy and lactation information from FDA drug labels.
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
drug_name (str): Generic drug name preferred
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
str: JSON string with pregnancy text, lactation text, and reproductive potential information
|
| 183 |
+
"""
|
| 184 |
+
result = drug_pregnancy_lactation(drug_name)
|
| 185 |
+
return format_json_output(result)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@with_error_handling
|
| 189 |
+
@with_caching(ttl=7200) # 2 hours cache
|
| 190 |
+
def drug_dose_adjustments_mcp(drug_name: str) -> str:
|
| 191 |
+
"""
|
| 192 |
+
Get renal and hepatic dose adjustment information from FDA labels.
|
| 193 |
+
|
| 194 |
+
Args:
|
| 195 |
+
drug_name (str): Generic drug name
|
| 196 |
+
|
| 197 |
+
Returns:
|
| 198 |
+
str: JSON string with renal and hepatic dosing excerpts
|
| 199 |
+
"""
|
| 200 |
+
result = drug_dose_adjustments(drug_name)
|
| 201 |
+
return format_json_output(result)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
@with_error_handling
|
| 205 |
+
@with_caching(ttl=1800) # 30 minutes cache
|
| 206 |
+
def drug_livertox_summary_mcp(drug_name: str) -> str:
|
| 207 |
+
"""
|
| 208 |
+
Get hepatotoxicity information from the LiverTox database.
|
| 209 |
+
|
| 210 |
+
Args:
|
| 211 |
+
drug_name (str): Drug name to search for (case-insensitive)
|
| 212 |
+
|
| 213 |
+
Returns:
|
| 214 |
+
str: JSON string with hepatotoxicity data, mechanism of injury, and management information
|
| 215 |
+
"""
|
| 216 |
+
result = drug_livertox_summary(drug_name)
|
| 217 |
+
return format_json_output(result)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
@with_error_handling
|
| 221 |
+
def brand_to_generic_lookup_mcp(brand_name: str, prefer_countries: str = "US") -> str:
|
| 222 |
+
"""
|
| 223 |
+
Look up generic drug information from brand names.
|
| 224 |
+
|
| 225 |
+
Args:
|
| 226 |
+
brand_name (str): Brand name to look up
|
| 227 |
+
prefer_countries (str): Comma-separated ISO country codes (e.g., "US,CA")
|
| 228 |
+
|
| 229 |
+
Returns:
|
| 230 |
+
str: JSON string with generic drug information and country-specific data
|
| 231 |
+
"""
|
| 232 |
+
result = _brand_lookup_gradio(brand_name, prefer_countries)
|
| 233 |
+
return format_json_output(result)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
@with_error_handling
|
| 237 |
+
def calculate_drug_burden_index_mcp(drug_list: str, route: str = "oral") -> str:
|
| 238 |
+
"""
|
| 239 |
+
Calculate Drug Burden Index (DBI) from a list of medications.
|
| 240 |
+
|
| 241 |
+
Args:
|
| 242 |
+
drug_list (str): Drug list (one per line, include dose and frequency)
|
| 243 |
+
route (str): Route of administration (default: "oral")
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
str: JSON string with DBI calculation results and individual drug contributions
|
| 247 |
+
"""
|
| 248 |
+
result = _dbi_mcp_gradio(drug_list, route)
|
| 249 |
+
return format_json_output(result)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
@with_error_handling
|
| 253 |
+
def calculate_creatinine_clearance_mcp(
|
| 254 |
+
age: str, weight_kg: str, serum_creatinine: str, is_female: str
|
| 255 |
+
) -> str:
|
| 256 |
+
"""
|
| 257 |
+
Calculate creatinine clearance using Cockcroft-Gault equation.
|
| 258 |
+
|
| 259 |
+
Args:
|
| 260 |
+
age (str): Patient's age in years
|
| 261 |
+
weight_kg (str): Patient's weight in kilograms
|
| 262 |
+
serum_creatinine (str): Patient's serum creatinine in mg/dL
|
| 263 |
+
is_female (str): "true" if patient is female, "false" if male
|
| 264 |
+
|
| 265 |
+
Returns:
|
| 266 |
+
str: JSON string with creatinine clearance calculation and interpretation
|
| 267 |
+
"""
|
| 268 |
+
age_int = int(age)
|
| 269 |
+
weight_float = float(weight_kg)
|
| 270 |
+
creat_float = float(serum_creatinine)
|
| 271 |
+
is_female_bool = is_female.lower() == "true"
|
| 272 |
+
|
| 273 |
+
result = _cockcroft_gault_gradio(
|
| 274 |
+
age_int, weight_float, creat_float, is_female_bool
|
| 275 |
+
)
|
| 276 |
+
return format_json_output(result)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
@with_error_handling
|
| 280 |
+
def calculate_egfr_mcp(
|
| 281 |
+
age: str, serum_creatinine: str, is_female: str, is_black: str
|
| 282 |
+
) -> str:
|
| 283 |
+
"""
|
| 284 |
+
Calculate estimated glomerular filtration rate using CKD-EPI equation.
|
| 285 |
+
|
| 286 |
+
Args:
|
| 287 |
+
age (str): Patient's age in years
|
| 288 |
+
serum_creatinine (str): Patient's serum creatinine in mg/dL
|
| 289 |
+
is_female (str): "true" if patient is female, "false" if male
|
| 290 |
+
is_black (str): "true" if patient is Black, "false" otherwise
|
| 291 |
+
|
| 292 |
+
Returns:
|
| 293 |
+
str: JSON string with eGFR calculation and CKD stage interpretation
|
| 294 |
+
"""
|
| 295 |
+
age_int = int(age)
|
| 296 |
+
creat_float = float(serum_creatinine)
|
| 297 |
+
is_female_bool = is_female.lower() == "true"
|
| 298 |
+
is_black_bool = is_black.lower() == "true"
|
| 299 |
+
|
| 300 |
+
result = _ckd_epi_gradio(age_int, creat_float, is_female_bool, is_black_bool)
|
| 301 |
+
return format_json_output(result)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
@with_error_handling
|
| 305 |
+
def calculate_child_pugh_score_mcp(
|
| 306 |
+
bilirubin: str, albumin: str, inr: str, ascites: str, encephalopathy: str
|
| 307 |
+
) -> str:
|
| 308 |
+
"""
|
| 309 |
+
Calculate Child-Pugh score for liver function assessment.
|
| 310 |
+
|
| 311 |
+
Args:
|
| 312 |
+
bilirubin (str): Total bilirubin in mg/dL
|
| 313 |
+
albumin (str): Serum albumin in g/dL
|
| 314 |
+
inr (str): INR value
|
| 315 |
+
ascites (str): Ascites level ("none", "mild", "moderate-severe")
|
| 316 |
+
encephalopathy (str): Encephalopathy grade ("none", "grade-1-2", "grade-3-4")
|
| 317 |
+
|
| 318 |
+
Returns:
|
| 319 |
+
str: JSON string with Child-Pugh score, class, and prognosis information
|
| 320 |
+
"""
|
| 321 |
+
bilirubin_float = float(bilirubin)
|
| 322 |
+
albumin_float = float(albumin)
|
| 323 |
+
inr_float = float(inr)
|
| 324 |
+
|
| 325 |
+
result = _child_pugh_gradio(
|
| 326 |
+
bilirubin_float, albumin_float, inr_float, ascites, encephalopathy
|
| 327 |
+
)
|
| 328 |
+
return format_json_output(result)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
@with_error_handling
|
| 332 |
+
def calculate_bmi_mcp(weight_kg: str, height_cm: str) -> str:
|
| 333 |
+
"""
|
| 334 |
+
Calculate Body Mass Index (BMI) and weight category.
|
| 335 |
+
|
| 336 |
+
Args:
|
| 337 |
+
weight_kg (str): Weight in kilograms
|
| 338 |
+
height_cm (str): Height in centimeters
|
| 339 |
+
|
| 340 |
+
Returns:
|
| 341 |
+
str: JSON string with BMI calculation and weight category classification
|
| 342 |
+
"""
|
| 343 |
+
weight_float = float(weight_kg)
|
| 344 |
+
height_float = float(height_cm)
|
| 345 |
+
|
| 346 |
+
result = _bmi_gradio(weight_float, height_float)
|
| 347 |
+
return format_json_output(result)
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
@with_error_handling
|
| 351 |
+
def calculate_ideal_body_weight_mcp(height_cm: str, is_male: str) -> str:
|
| 352 |
+
"""
|
| 353 |
+
Calculate Ideal Body Weight (IBW) using the Devine formula.
|
| 354 |
+
|
| 355 |
+
Args:
|
| 356 |
+
height_cm (str): Patient's height in cm
|
| 357 |
+
is_male (str): "true" if patient is male, "false" if female
|
| 358 |
+
|
| 359 |
+
Returns:
|
| 360 |
+
str: JSON string with IBW calculation.
|
| 361 |
+
"""
|
| 362 |
+
height_float = float(height_cm)
|
| 363 |
+
is_male_bool = is_male.lower() == "true"
|
| 364 |
+
|
| 365 |
+
result = _ideal_body_weight_gradio(height_float, is_male_bool)
|
| 366 |
+
return format_json_output(result)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
@with_error_handling
|
| 370 |
+
def recommend_dosing_weight_mcp(
|
| 371 |
+
actual_weight: str, height_cm: str, is_male: str
|
| 372 |
+
) -> str:
|
| 373 |
+
"""
|
| 374 |
+
Recommend appropriate weight for medication dosing calculations.
|
| 375 |
+
|
| 376 |
+
Args:
|
| 377 |
+
actual_weight (str): Patient's actual weight in kg
|
| 378 |
+
height_cm (str): Patient's height in cm
|
| 379 |
+
is_male (str): "true" if patient is male, "false" if female
|
| 380 |
+
|
| 381 |
+
Returns:
|
| 382 |
+
str: JSON string with dosing weight recommendation and rationale
|
| 383 |
+
"""
|
| 384 |
+
weight_float = float(actual_weight)
|
| 385 |
+
height_float = float(height_cm)
|
| 386 |
+
is_male_bool = is_male.lower() == "true"
|
| 387 |
+
|
| 388 |
+
result = _dosing_weight_gradio(weight_float, height_float, is_male_bool)
|
| 389 |
+
return format_json_output(result)
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
@with_error_handling
|
| 393 |
+
def convert_creatinine_units_mcp(value: str, from_unit: str, to_unit: str) -> str:
|
| 394 |
+
"""
|
| 395 |
+
Convert creatinine values between mg/dL and μmol/L units.
|
| 396 |
+
|
| 397 |
+
Args:
|
| 398 |
+
value (str): The creatinine value to convert
|
| 399 |
+
from_unit (str): The original unit ("mg_dl" or "umol_l")
|
| 400 |
+
to_unit (str): The target unit ("mg_dl" or "umol_l")
|
| 401 |
+
|
| 402 |
+
Returns:
|
| 403 |
+
str: JSON string with converted creatinine value and conversion factor
|
| 404 |
+
"""
|
| 405 |
+
value_float = float(value)
|
| 406 |
+
|
| 407 |
+
result = _creatinine_conversion_gradio(value_float, from_unit, to_unit)
|
| 408 |
+
return format_json_output(result)
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
ae_search_ui = gr.Interface(
|
| 412 |
+
fn=search_adverse_events_mcp,
|
| 413 |
+
inputs=[gr.Text(label="Drug Name"), gr.Text(label="Limit", value="5")],
|
| 414 |
+
outputs=gr.JSON(label="Output"),
|
| 415 |
+
title="AE Search",
|
| 416 |
+
api_name="ae_search",
|
| 417 |
+
description="Search FAERS for adverse events.",
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
ae_details_ui = gr.Interface(
|
| 421 |
+
fn=fetch_event_details_mcp,
|
| 422 |
+
inputs=gr.Text(label="FAERS Event ID"),
|
| 423 |
+
outputs=gr.JSON(label="Output"),
|
| 424 |
+
title="AE Details",
|
| 425 |
+
api_name="ae_details",
|
| 426 |
+
description="Fetch a full FAERS case by safety-report ID.",
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
warnings_ui = gr.Interface(
|
| 430 |
+
fn=drug_label_warnings_mcp,
|
| 431 |
+
inputs=gr.Text(label="Drug Name"),
|
| 432 |
+
outputs=gr.JSON(label="Output"),
|
| 433 |
+
title="Label Warnings",
|
| 434 |
+
api_name="label_warnings",
|
| 435 |
+
description="Get FDA label warnings.",
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
recalls_ui = gr.Interface(
|
| 439 |
+
fn=drug_recalls_mcp,
|
| 440 |
+
inputs=[gr.Text(label="Drug"), gr.Text(label="Limit", value="5")],
|
| 441 |
+
outputs=gr.JSON(label="Output"),
|
| 442 |
+
title="Drug Recalls",
|
| 443 |
+
api_name="drug_recalls",
|
| 444 |
+
description="Return recent FDA recall events for a drug.",
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
pregnancy_ui = gr.Interface(
|
| 448 |
+
fn=drug_pregnancy_lactation_mcp,
|
| 449 |
+
inputs=gr.Text(label="Drug"),
|
| 450 |
+
outputs=gr.JSON(label="Output"),
|
| 451 |
+
title="Pregnancy & Lactation",
|
| 452 |
+
api_name="pregnancy_lactation",
|
| 453 |
+
description="Return Pregnancy & Lactation text from FDA label.",
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
adjustments_ui = gr.Interface(
|
| 457 |
+
fn=drug_dose_adjustments_mcp,
|
| 458 |
+
inputs=gr.Text(label="Drug"),
|
| 459 |
+
outputs=gr.JSON(label="Output"),
|
| 460 |
+
title="Dose Adjustments",
|
| 461 |
+
api_name="dose_adjustments",
|
| 462 |
+
description="Return renal & hepatic dosing excerpts from FDA label.",
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
livertox_ui = gr.Interface(
|
| 466 |
+
fn=drug_livertox_summary_mcp,
|
| 467 |
+
inputs=gr.Text(label="Drug Name"),
|
| 468 |
+
outputs=gr.JSON(label="Output"),
|
| 469 |
+
title="LiverTox Summary",
|
| 470 |
+
api_name="livertox_summary",
|
| 471 |
+
description="Get hepatotoxicity information.",
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
brand_generic_ui = gr.Interface(
|
| 475 |
+
fn=brand_to_generic_lookup_mcp,
|
| 476 |
+
inputs=[
|
| 477 |
+
gr.Text(label="Brand Name"),
|
| 478 |
+
gr.Text(
|
| 479 |
+
label="Preferred Countries (comma-separated ISO codes, e.g., US,CA)",
|
| 480 |
+
value="US",
|
| 481 |
+
),
|
| 482 |
+
],
|
| 483 |
+
outputs=gr.JSON(label="Output"),
|
| 484 |
+
title="Brand to Generic",
|
| 485 |
+
api_name="brand_to_generic",
|
| 486 |
+
description="Look up generic drug information.",
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
dbi_calculator_ui = gr.Interface(
|
| 490 |
+
fn=calculate_drug_burden_index_mcp,
|
| 491 |
+
inputs=[
|
| 492 |
+
gr.Textbox(
|
| 493 |
+
label="Drug List (one per line, include dose and frequency)",
|
| 494 |
+
lines=10,
|
| 495 |
+
placeholder="e.g., Aspirin 100mg daily\nFurosemide 40mg PRN",
|
| 496 |
+
),
|
| 497 |
+
gr.Text(label="Route of Administration", value="oral"),
|
| 498 |
+
],
|
| 499 |
+
outputs=gr.JSON(label="DBI Calculation"),
|
| 500 |
+
title="DBI Calculator",
|
| 501 |
+
api_name="dbi_calculator",
|
| 502 |
+
description="Calculate Drug Burden Index (DBI) from a list of medications. Supports PRN and various dose formats.",
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
cockcroft_gault_ui = gr.Interface(
|
| 506 |
+
fn=calculate_creatinine_clearance_mcp,
|
| 507 |
+
inputs=[
|
| 508 |
+
gr.Text(label="Age (years)", value="65"),
|
| 509 |
+
gr.Text(label="Weight (kg)", value="70"),
|
| 510 |
+
gr.Text(label="Serum Creatinine (mg/dL)", value="1.2"),
|
| 511 |
+
gr.Text(label="Is Female (true/false)", value="false"),
|
| 512 |
+
],
|
| 513 |
+
outputs=gr.JSON(label="Creatinine Clearance"),
|
| 514 |
+
title="Cockcroft-Gault Calculator",
|
| 515 |
+
api_name="cockcroft_gault",
|
| 516 |
+
description="Calculate creatinine clearance using Cockcroft-Gault equation for dose adjustments.",
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
ckd_epi_ui = gr.Interface(
|
| 520 |
+
fn=calculate_egfr_mcp,
|
| 521 |
+
inputs=[
|
| 522 |
+
gr.Text(label="Age (years)", value="65"),
|
| 523 |
+
gr.Text(label="Serum Creatinine (mg/dL)", value="1.2"),
|
| 524 |
+
gr.Text(label="Is Female (true/false)", value="false"),
|
| 525 |
+
gr.Text(label="Is Black (true/false)", value="false"),
|
| 526 |
+
],
|
| 527 |
+
outputs=gr.JSON(label="eGFR"),
|
| 528 |
+
title="CKD-EPI eGFR Calculator",
|
| 529 |
+
api_name="ckd_epi",
|
| 530 |
+
description="Calculate estimated glomerular filtration rate using CKD-EPI equation.",
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
child_pugh_ui = gr.Interface(
|
| 534 |
+
fn=calculate_child_pugh_score_mcp,
|
| 535 |
+
inputs=[
|
| 536 |
+
gr.Text(label="Total Bilirubin (mg/dL)", value="1.5"),
|
| 537 |
+
gr.Text(label="Serum Albumin (g/dL)", value="3.5"),
|
| 538 |
+
gr.Text(label="INR", value="1.3"),
|
| 539 |
+
gr.Dropdown(["none", "mild", "moderate-severe"], value="none", label="Ascites"),
|
| 540 |
+
gr.Dropdown(
|
| 541 |
+
["none", "grade-1-2", "grade-3-4"], value="none", label="Encephalopathy"
|
| 542 |
+
),
|
| 543 |
+
],
|
| 544 |
+
outputs=gr.JSON(label="Child-Pugh Score"),
|
| 545 |
+
title="Child-Pugh Score Calculator",
|
| 546 |
+
api_name="child_pugh",
|
| 547 |
+
description="Calculate Child-Pugh score for liver function assessment and dose adjustments.",
|
| 548 |
+
)
|
| 549 |
+
|
| 550 |
+
bmi_ui = gr.Interface(
|
| 551 |
+
fn=calculate_bmi_mcp,
|
| 552 |
+
inputs=[
|
| 553 |
+
gr.Text(label="Weight (kg)", value="70"),
|
| 554 |
+
gr.Text(label="Height (cm)", value="170"),
|
| 555 |
+
],
|
| 556 |
+
outputs=gr.JSON(label="BMI Calculation"),
|
| 557 |
+
title="BMI Calculator",
|
| 558 |
+
api_name="bmi_calculator",
|
| 559 |
+
description="Calculate Body Mass Index and weight category assessment.",
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
+
ideal_body_weight_ui = gr.Interface(
|
| 563 |
+
fn=calculate_ideal_body_weight_mcp,
|
| 564 |
+
inputs=[
|
| 565 |
+
gr.Text(label="Height (cm)", value="170"),
|
| 566 |
+
gr.Text(label="Is Male (true/false)", value="true"),
|
| 567 |
+
],
|
| 568 |
+
outputs=gr.JSON(label="Ideal Body Weight Calculation"),
|
| 569 |
+
title="Ideal Body Weight (IBW) Calculator",
|
| 570 |
+
api_name="ideal_body_weight",
|
| 571 |
+
description="Calculate Ideal Body Weight using the Devine formula.",
|
| 572 |
+
)
|
| 573 |
+
|
| 574 |
+
dosing_weight_ui = gr.Interface(
|
| 575 |
+
fn=recommend_dosing_weight_mcp,
|
| 576 |
+
inputs=[
|
| 577 |
+
gr.Text(label="Actual Weight (kg)", value="85"),
|
| 578 |
+
gr.Text(label="Height (cm)", value="170"),
|
| 579 |
+
gr.Text(label="Is Male (true/false)", value="true"),
|
| 580 |
+
],
|
| 581 |
+
outputs=gr.JSON(label="Dosing Weight Recommendation"),
|
| 582 |
+
title="Dosing Weight Calculator",
|
| 583 |
+
api_name="dosing_weight",
|
| 584 |
+
description="Recommend appropriate weight for medication dosing calculations.",
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
creatinine_convert_ui = gr.Interface(
|
| 588 |
+
fn=convert_creatinine_units_mcp,
|
| 589 |
+
inputs=[
|
| 590 |
+
gr.Text(label="Creatinine Value", value="1.2"),
|
| 591 |
+
gr.Dropdown(["mg_dl", "umol_l"], value="mg_dl", label="From Unit"),
|
| 592 |
+
gr.Dropdown(["mg_dl", "umol_l"], value="umol_l", label="To Unit"),
|
| 593 |
+
],
|
| 594 |
+
outputs=gr.JSON(label="Converted Value"),
|
| 595 |
+
title="Creatinine Unit Converter",
|
| 596 |
+
api_name="creatinine_converter",
|
| 597 |
+
description="Convert creatinine values between mg/dL and μmol/L.",
|
| 598 |
+
)
|
| 599 |
+
|
| 600 |
+
demo = gr.TabbedInterface(
|
| 601 |
+
[
|
| 602 |
+
ae_search_ui,
|
| 603 |
+
ae_details_ui,
|
| 604 |
+
warnings_ui,
|
| 605 |
+
recalls_ui,
|
| 606 |
+
pregnancy_ui,
|
| 607 |
+
adjustments_ui,
|
| 608 |
+
livertox_ui,
|
| 609 |
+
brand_generic_ui,
|
| 610 |
+
dbi_calculator_ui,
|
| 611 |
+
cockcroft_gault_ui,
|
| 612 |
+
ckd_epi_ui,
|
| 613 |
+
child_pugh_ui,
|
| 614 |
+
bmi_ui,
|
| 615 |
+
ideal_body_weight_ui,
|
| 616 |
+
dosing_weight_ui,
|
| 617 |
+
creatinine_convert_ui,
|
| 618 |
+
],
|
| 619 |
+
[
|
| 620 |
+
"AE Search",
|
| 621 |
+
"AE Details",
|
| 622 |
+
"Label Warnings",
|
| 623 |
+
"Recalls",
|
| 624 |
+
"Pregnancy",
|
| 625 |
+
"Dose Adjustments",
|
| 626 |
+
"LiverTox",
|
| 627 |
+
"Brand to Generic",
|
| 628 |
+
"DBI Calculator",
|
| 629 |
+
"Creatinine CL",
|
| 630 |
+
"eGFR",
|
| 631 |
+
"Child-Pugh",
|
| 632 |
+
"BMI",
|
| 633 |
+
"IBW",
|
| 634 |
+
"Dosing Weight",
|
| 635 |
+
"Unit Converter",
|
| 636 |
+
],
|
| 637 |
+
)
|
| 638 |
+
|
| 639 |
+
if __name__ == "__main__":
|
| 640 |
+
logger.info("Starting Pharmacist MCP Server v1.1.0")
|
| 641 |
+
demo.launch(mcp_server=True, show_error=True)
|
brand_to_generic.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
import time
|
| 6 |
+
import functools
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Dict, List, Optional
|
| 9 |
+
import requests
|
| 10 |
+
import csv
|
| 11 |
+
from io import StringIO
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
_session = requests.Session()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class _Throttle:
|
| 19 |
+
"""Simple host-level throttle (~1 rps)."""
|
| 20 |
+
|
| 21 |
+
_stamp: Dict[str, float] = {}
|
| 22 |
+
|
| 23 |
+
@classmethod
|
| 24 |
+
def wait(cls, host: str, gap: float = 1.0):
|
| 25 |
+
last = cls._stamp.get(host, 0.0)
|
| 26 |
+
now = time.time()
|
| 27 |
+
delta = now - last
|
| 28 |
+
if delta < gap:
|
| 29 |
+
time.sleep(gap - delta)
|
| 30 |
+
cls._stamp[host] = time.time()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _get(url: str, **kw):
|
| 34 |
+
host = requests.utils.urlparse(url).netloc
|
| 35 |
+
_Throttle.wait(host)
|
| 36 |
+
try:
|
| 37 |
+
requests_kwargs = {"timeout": 10}
|
| 38 |
+
if host in ("dmd.nhs.uk", "www.nhsbsa.nhs.uk"):
|
| 39 |
+
requests_kwargs["verify"] = False
|
| 40 |
+
logger.warning("Disabling SSL certificate verification for host: %s", host)
|
| 41 |
+
|
| 42 |
+
r = _session.get(url, **requests_kwargs, **kw)
|
| 43 |
+
r.raise_for_status()
|
| 44 |
+
return r
|
| 45 |
+
except Exception as exc:
|
| 46 |
+
logger.warning("%s → %s", url, exc)
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
_RX_RE_FMT = (
|
| 51 |
+
"https://rxnav.nlm.nih.gov/REST/rxcui/{rxcui}/related.json?rela=tradename_of"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@functools.lru_cache(maxsize=512)
|
| 56 |
+
def _rxnorm_lookup(brand: str):
|
| 57 |
+
r = _get("https://rxnav.nlm.nih.gov/REST/rxcui.json", params={"name": brand})
|
| 58 |
+
if not r or not r.json().get("idGroup", {}).get("rxnormId"):
|
| 59 |
+
return []
|
| 60 |
+
rxcui = r.json()["idGroup"]["rxnormId"][0]
|
| 61 |
+
rel = _get(_RX_RE_FMT.format(rxcui=rxcui))
|
| 62 |
+
out = []
|
| 63 |
+
if rel:
|
| 64 |
+
for grp in rel.json().get("relatedGroup", {}).get("conceptGroup", []):
|
| 65 |
+
for c in grp.get("conceptProperties", []):
|
| 66 |
+
out.append(
|
| 67 |
+
{
|
| 68 |
+
"generic_name": c["name"],
|
| 69 |
+
"strength": c.get("strength"),
|
| 70 |
+
"dosage_form": c.get(
|
| 71 |
+
"tty"
|
| 72 |
+
), # SCDS/SCD etc. not ideal but indicative
|
| 73 |
+
"route": None,
|
| 74 |
+
"country": "US",
|
| 75 |
+
"source": "RxNorm",
|
| 76 |
+
"ids": {"rxcui": c["rxcui"]},
|
| 77 |
+
"source_url": _RX_RE_FMT.format(rxcui=rxcui),
|
| 78 |
+
}
|
| 79 |
+
)
|
| 80 |
+
return out
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
_OPENFDA_NDC = "https://api.fda.gov/drug/ndc.json"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@functools.lru_cache(maxsize=512)
|
| 87 |
+
def _openfda_ndc(brand: str):
|
| 88 |
+
r = _get(_OPENFDA_NDC, params={"search": f'brand_name:"{brand}"', "limit": 20})
|
| 89 |
+
if not r:
|
| 90 |
+
return []
|
| 91 |
+
out: List[dict] = []
|
| 92 |
+
for prod in r.json().get("results", []):
|
| 93 |
+
api_gn = prod.get("generic_name")
|
| 94 |
+
display_gn: str
|
| 95 |
+
if isinstance(api_gn, str):
|
| 96 |
+
display_gn = api_gn # Use the string directly
|
| 97 |
+
elif isinstance(api_gn, list):
|
| 98 |
+
display_gn = ", ".join(
|
| 99 |
+
str(g) for g in api_gn
|
| 100 |
+
) # Join list elements, ensuring they are strings
|
| 101 |
+
else:
|
| 102 |
+
display_gn = "" # Default for None or other unexpected types
|
| 103 |
+
|
| 104 |
+
out.append(
|
| 105 |
+
{
|
| 106 |
+
"generic_name": display_gn,
|
| 107 |
+
"strength": prod.get("active_ingredients", [{}])[0].get("strength"),
|
| 108 |
+
"dosage_form": prod.get("dosage_form"),
|
| 109 |
+
"route": prod.get("route"),
|
| 110 |
+
"country": "US",
|
| 111 |
+
"source": "openFDA-NDC",
|
| 112 |
+
"ids": {"ndc": prod.get("product_ndc"), "spl_id": prod.get("spl_id")},
|
| 113 |
+
"source_url": _OPENFDA_NDC,
|
| 114 |
+
}
|
| 115 |
+
)
|
| 116 |
+
return out
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
_DPD = "https://health-products.canada.ca/api/drug/drugproduct/"
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@functools.lru_cache(maxsize=512)
|
| 123 |
+
def _dpd_lookup(brand: str):
|
| 124 |
+
r = _get(_DPD, params={"brandname": brand, "lang": "en", "type": "json"})
|
| 125 |
+
if not r:
|
| 126 |
+
return []
|
| 127 |
+
out = []
|
| 128 |
+
for prod in r.json():
|
| 129 |
+
for ai in prod.get("active_ingredient", []):
|
| 130 |
+
out.append(
|
| 131 |
+
{
|
| 132 |
+
"generic_name": ai.get("ingredient_name"),
|
| 133 |
+
"strength": ai.get("strength"),
|
| 134 |
+
"dosage_form": prod.get("dosage_form"),
|
| 135 |
+
"route": prod.get("route_of_administration"),
|
| 136 |
+
"country": "CA",
|
| 137 |
+
"source": "Health Canada DPD",
|
| 138 |
+
"ids": {"din": prod.get("drug_identification_number")},
|
| 139 |
+
"source_url": _DPD,
|
| 140 |
+
}
|
| 141 |
+
)
|
| 142 |
+
return out
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
_PBS_V3_BASE_URL = "https://data-api.health.gov.au/pbs/api/v3"
|
| 146 |
+
_PBS_SUBSCRIPTION_KEY = os.getenv(
|
| 147 |
+
"PBS_API_SUBSCRIPTION_KEY", "2384af7c667342ceb5a736fe29f1dc6b"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _pbs_v3_get(
|
| 152 |
+
endpoint: str, params: Optional[Dict] = None, accept_type: str = "application/json"
|
| 153 |
+
):
|
| 154 |
+
"""Helper to make GET requests to PBS API v3 with auth and throttling."""
|
| 155 |
+
url = f"{_PBS_V3_BASE_URL}/{endpoint}"
|
| 156 |
+
headers = {"subscription-key": _PBS_SUBSCRIPTION_KEY, "Accept": accept_type}
|
| 157 |
+
host = requests.utils.urlparse(url).netloc
|
| 158 |
+
_Throttle.wait(host, gap=5.0) # PBS API specific throttle (1 req per 5 sec)
|
| 159 |
+
try:
|
| 160 |
+
r = _session.get(url, headers=headers, params=params, timeout=20)
|
| 161 |
+
r.raise_for_status()
|
| 162 |
+
return r
|
| 163 |
+
except Exception as exc:
|
| 164 |
+
logger.warning(
|
| 165 |
+
"PBS API v3 request failed for %s (params: %s): %s", url, params, exc
|
| 166 |
+
)
|
| 167 |
+
return None
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _parse_li_form(li_form_str: Optional[str]) -> Dict[str, Optional[str]]:
|
| 171 |
+
"""Parses strength and dosage form from an li_form string."""
|
| 172 |
+
if not li_form_str:
|
| 173 |
+
return {"strength": None, "dosage_form": None}
|
| 174 |
+
|
| 175 |
+
strength_regex = r"(\\d[\\d.,\\s]*(?:mg|mcg|g|mL|L|microlitres|nanograms|IU|%|mmol)(?:[\\s\\/][\\d.,\\s]*(?:mg|mcg|g|mL|L|microlitres|dose(?:s)?))?(?:\\s*\\(.*?\\))?(?:\\s+in\\s+[\\d.,\\s]*(?:mL|L|g|mg))?)"
|
| 176 |
+
|
| 177 |
+
strength_match = re.search(strength_regex, li_form_str, re.IGNORECASE)
|
| 178 |
+
|
| 179 |
+
extracted_strength = None
|
| 180 |
+
extracted_form = None
|
| 181 |
+
|
| 182 |
+
if strength_match:
|
| 183 |
+
extracted_strength = strength_match.group(0).strip()
|
| 184 |
+
form_before = li_form_str[: strength_match.start()].strip().rstrip(",").strip()
|
| 185 |
+
form_after = li_form_str[strength_match.end() :].strip().lstrip(",").strip()
|
| 186 |
+
|
| 187 |
+
if form_before and form_after:
|
| 188 |
+
extracted_form = f"{form_before} {form_after}".strip()
|
| 189 |
+
elif form_before:
|
| 190 |
+
extracted_form = form_before
|
| 191 |
+
elif form_after:
|
| 192 |
+
extracted_form = form_after
|
| 193 |
+
|
| 194 |
+
if not extracted_form and not extracted_strength:
|
| 195 |
+
if not re.search(r"\\d", li_form_str):
|
| 196 |
+
extracted_form = li_form_str.strip()
|
| 197 |
+
else:
|
| 198 |
+
extracted_form = li_form_str.strip()
|
| 199 |
+
|
| 200 |
+
return {
|
| 201 |
+
"strength": extracted_strength or None,
|
| 202 |
+
"dosage_form": extracted_form or None,
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@functools.lru_cache(maxsize=512)
|
| 207 |
+
def _pbs_lookup(brand: str):
|
| 208 |
+
schedules_resp = _pbs_v3_get("schedules", params={"limit": 1})
|
| 209 |
+
if not schedules_resp:
|
| 210 |
+
return []
|
| 211 |
+
try:
|
| 212 |
+
schedules_data = schedules_resp.json()
|
| 213 |
+
if not schedules_data.get("data") or not schedules_data["data"][0].get(
|
| 214 |
+
"schedule_code"
|
| 215 |
+
):
|
| 216 |
+
logger.warning(
|
| 217 |
+
"PBS API v3: Could not get schedule code from response: %s",
|
| 218 |
+
schedules_data,
|
| 219 |
+
)
|
| 220 |
+
return []
|
| 221 |
+
schedule_code = schedules_data["data"][0]["schedule_code"]
|
| 222 |
+
except (ValueError, IndexError, KeyError) as e:
|
| 223 |
+
logger.warning("PBS API v3: Error parsing schedules response: %s", e)
|
| 224 |
+
return []
|
| 225 |
+
|
| 226 |
+
items_resp = _pbs_v3_get(
|
| 227 |
+
"items",
|
| 228 |
+
params={"schedule_code": schedule_code, "brand_name": brand, "limit": 20},
|
| 229 |
+
accept_type="text/csv",
|
| 230 |
+
)
|
| 231 |
+
if not items_resp:
|
| 232 |
+
return []
|
| 233 |
+
|
| 234 |
+
out = []
|
| 235 |
+
try:
|
| 236 |
+
csv_text = items_resp.text
|
| 237 |
+
if not csv_text.strip():
|
| 238 |
+
logger.info(
|
| 239 |
+
"PBS API v3: Received empty CSV for brand '%s' with schedule '%s'",
|
| 240 |
+
brand,
|
| 241 |
+
schedule_code,
|
| 242 |
+
)
|
| 243 |
+
return []
|
| 244 |
+
|
| 245 |
+
csvfile = StringIO(csv_text)
|
| 246 |
+
reader = csv.DictReader(csvfile)
|
| 247 |
+
for row in reader:
|
| 248 |
+
li_form = row.get("li_form")
|
| 249 |
+
parsed_form_strength = _parse_li_form(li_form)
|
| 250 |
+
|
| 251 |
+
generic_name = row.get("drug_name", "").strip() or None
|
| 252 |
+
|
| 253 |
+
query_params = {
|
| 254 |
+
"schedule_code": schedule_code,
|
| 255 |
+
"brand_name": requests.utils.quote(brand),
|
| 256 |
+
}
|
| 257 |
+
source_url_params = "&".join([f"{k}={v}" for k, v in query_params.items()])
|
| 258 |
+
source_url = f"{_PBS_V3_BASE_URL}/items?{source_url_params}"
|
| 259 |
+
|
| 260 |
+
out.append(
|
| 261 |
+
{
|
| 262 |
+
"generic_name": generic_name,
|
| 263 |
+
"strength": parsed_form_strength["strength"],
|
| 264 |
+
"dosage_form": parsed_form_strength["dosage_form"],
|
| 265 |
+
"route": row.get("manner_of_administration", "").strip() or None,
|
| 266 |
+
"country": "AU",
|
| 267 |
+
"source": "PBS API v3",
|
| 268 |
+
"ids": {"pbs_item_code": row.get("pbs_code", "").strip()},
|
| 269 |
+
"source_url": source_url,
|
| 270 |
+
}
|
| 271 |
+
)
|
| 272 |
+
except csv.Error as e:
|
| 273 |
+
logger.warning(
|
| 274 |
+
"PBS API v3: CSV parsing error for brand '%s': %s. CSV content: %s",
|
| 275 |
+
brand,
|
| 276 |
+
e,
|
| 277 |
+
csv_text[:500],
|
| 278 |
+
)
|
| 279 |
+
return []
|
| 280 |
+
except Exception as e:
|
| 281 |
+
logger.exception(
|
| 282 |
+
"PBS API v3: Unexpected error processing items for brand '%s': %s", brand, e
|
| 283 |
+
)
|
| 284 |
+
return []
|
| 285 |
+
|
| 286 |
+
return out
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
@functools.lru_cache(maxsize=512)
|
| 290 |
+
def _pubchem_synonym_lookup(brand: str):
|
| 291 |
+
url = f"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/{requests.utils.quote(brand)}/synonyms/JSON"
|
| 292 |
+
r = _get(url)
|
| 293 |
+
if not r:
|
| 294 |
+
return []
|
| 295 |
+
syns = (
|
| 296 |
+
r.json()
|
| 297 |
+
.get("InformationList", {})
|
| 298 |
+
.get("Information", [{}])[0]
|
| 299 |
+
.get("Synonym", [])
|
| 300 |
+
)
|
| 301 |
+
generic = syns[0]
|
| 302 |
+
if not generic:
|
| 303 |
+
return []
|
| 304 |
+
return [
|
| 305 |
+
{
|
| 306 |
+
"generic_name": generic,
|
| 307 |
+
"strength": None,
|
| 308 |
+
"dosage_form": None,
|
| 309 |
+
"route": None,
|
| 310 |
+
"country": None,
|
| 311 |
+
"source": "PubChem",
|
| 312 |
+
"ids": {},
|
| 313 |
+
"source_url": url,
|
| 314 |
+
}
|
| 315 |
+
]
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def brand_lookup(
|
| 319 |
+
brand_name: str, *, prefer_countries: Optional[List[str]] = None
|
| 320 |
+
) -> dict:
|
| 321 |
+
"""Resolve *brand_name* to generic + strength/form using multiple datasets.
|
| 322 |
+
|
| 323 |
+
If a data source returns results, those results are processed and returned immediately.
|
| 324 |
+
Subsequent data sources are not queried.
|
| 325 |
+
|
| 326 |
+
``prefer_countries`` (ISO alpha-2 list) controls result ordering for the successful source.
|
| 327 |
+
"""
|
| 328 |
+
brand = brand_name.strip()
|
| 329 |
+
|
| 330 |
+
for fn in (
|
| 331 |
+
_pbs_lookup,
|
| 332 |
+
_rxnorm_lookup,
|
| 333 |
+
_openfda_ndc,
|
| 334 |
+
_dpd_lookup,
|
| 335 |
+
_pubchem_synonym_lookup,
|
| 336 |
+
):
|
| 337 |
+
try:
|
| 338 |
+
current_results: List[dict] = fn(brand)
|
| 339 |
+
if current_results:
|
| 340 |
+
uniq = {}
|
| 341 |
+
for rec in current_results:
|
| 342 |
+
key = (rec["generic_name"], rec.get("strength"), rec.get("country"))
|
| 343 |
+
uniq[key] = rec
|
| 344 |
+
|
| 345 |
+
processed_results = list(uniq.values())
|
| 346 |
+
|
| 347 |
+
if prefer_countries:
|
| 348 |
+
processed_results.sort(
|
| 349 |
+
key=lambda r: (
|
| 350 |
+
0 if r["country"] in prefer_countries else 1,
|
| 351 |
+
r["country"] or "",
|
| 352 |
+
)
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
return {"brand_searched": brand, "results": processed_results}
|
| 356 |
+
except Exception as exc:
|
| 357 |
+
logger.exception("%s failed", fn.__name__)
|
| 358 |
+
|
| 359 |
+
return {"brand_searched": brand, "results": []}
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
if __name__ == "__main__":
|
| 363 |
+
import sys, pprint
|
| 364 |
+
|
| 365 |
+
pprint.pp(brand_lookup(sys.argv[1] if len(sys.argv) > 1 else "Panadol Rapid"))
|
caching.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import hashlib
|
| 3 |
+
import logging
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from functools import wraps
|
| 6 |
+
from typing import Dict, Any, Optional
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class SimpleCache:
|
| 11 |
+
def __init__(self, default_ttl: int = 3600):
|
| 12 |
+
self.cache: Dict[str, Dict[str, Any]] = {}
|
| 13 |
+
self.default_ttl = default_ttl
|
| 14 |
+
|
| 15 |
+
def _is_expired(self, entry: Dict[str, Any]) -> bool:
|
| 16 |
+
return datetime.now() > entry['expires']
|
| 17 |
+
|
| 18 |
+
def get(self, key: str) -> Optional[Any]:
|
| 19 |
+
if key in self.cache:
|
| 20 |
+
entry = self.cache[key]
|
| 21 |
+
if not self._is_expired(entry):
|
| 22 |
+
logger.debug(f"Cache hit for key: {key}")
|
| 23 |
+
return entry['data']
|
| 24 |
+
else:
|
| 25 |
+
del self.cache[key]
|
| 26 |
+
logger.debug(f"Cache expired for key: {key}")
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
def set(self, key: str, data: Any, ttl: Optional[int] = None) -> None:
|
| 30 |
+
ttl = ttl or self.default_ttl
|
| 31 |
+
self.cache[key] = {
|
| 32 |
+
'data': data,
|
| 33 |
+
'expires': datetime.now() + timedelta(seconds=ttl)
|
| 34 |
+
}
|
| 35 |
+
logger.debug(f"Cached data for key: {key}")
|
| 36 |
+
|
| 37 |
+
api_cache = SimpleCache()
|
| 38 |
+
|
| 39 |
+
def generate_cache_key(*args: Any, **kwargs: Any) -> str:
|
| 40 |
+
"""Generate a cache key from function arguments."""
|
| 41 |
+
try:
|
| 42 |
+
# Convert args to strings to avoid serialization issues
|
| 43 |
+
safe_args = []
|
| 44 |
+
for arg in args:
|
| 45 |
+
if isinstance(arg, (str, int, float, bool, type(None))):
|
| 46 |
+
safe_args.append(arg)
|
| 47 |
+
else:
|
| 48 |
+
safe_args.append(str(arg))
|
| 49 |
+
|
| 50 |
+
key_data = json.dumps([safe_args, sorted(kwargs.items())], sort_keys=True, default=str)
|
| 51 |
+
return hashlib.md5(key_data.encode()).hexdigest()
|
| 52 |
+
except Exception:
|
| 53 |
+
fallback_key = f"{args}_{kwargs}"
|
| 54 |
+
return hashlib.md5(fallback_key.encode()).hexdigest()
|
| 55 |
+
|
| 56 |
+
def with_caching(ttl: int = 3600):
|
| 57 |
+
"""Decorator to add caching to functions."""
|
| 58 |
+
def decorator(func):
|
| 59 |
+
@wraps(func)
|
| 60 |
+
def wrapper(*args, **kwargs):
|
| 61 |
+
cache_key = f"{func.__name__}:{generate_cache_key(*args, **kwargs)}"
|
| 62 |
+
cached_result = api_cache.get(cache_key)
|
| 63 |
+
if cached_result is not None:
|
| 64 |
+
return cached_result
|
| 65 |
+
|
| 66 |
+
result = func(*args, **kwargs)
|
| 67 |
+
api_cache.set(cache_key, result, ttl)
|
| 68 |
+
return result
|
| 69 |
+
return wrapper
|
| 70 |
+
return decorator
|
clinical_calculators.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Clinical Calculator Suite - Phase 1.2 MCP Development
|
| 3 |
+
Implements common clinical calculations for pharmacist workflow
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Dict, Any
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
def cockcroft_gault_creatinine_clearance(
|
| 12 |
+
age: int,
|
| 13 |
+
weight_kg: float,
|
| 14 |
+
serum_creatinine_mg_dl: float,
|
| 15 |
+
is_female: bool = False
|
| 16 |
+
) -> Dict[str, Any]:
|
| 17 |
+
"""
|
| 18 |
+
Calculate creatinine clearance using Cockcroft-Gault equation.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
age: Patient age in years
|
| 22 |
+
weight_kg: Weight in kilograms
|
| 23 |
+
serum_creatinine_mg_dl: Serum creatinine in mg/dL
|
| 24 |
+
is_female: True if patient is female
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
Dict with calculated creatinine clearance and interpretation
|
| 28 |
+
"""
|
| 29 |
+
if age <= 0 or weight_kg <= 0 or serum_creatinine_mg_dl <= 0:
|
| 30 |
+
raise ValueError("All values must be positive")
|
| 31 |
+
|
| 32 |
+
clearance = ((140 - age) * weight_kg) / (72 * serum_creatinine_mg_dl)
|
| 33 |
+
|
| 34 |
+
if is_female:
|
| 35 |
+
clearance *= 0.85
|
| 36 |
+
|
| 37 |
+
if clearance >= 90:
|
| 38 |
+
stage = "Normal or high"
|
| 39 |
+
category = "G1"
|
| 40 |
+
elif clearance >= 60:
|
| 41 |
+
stage = "Mildly decreased"
|
| 42 |
+
category = "G2"
|
| 43 |
+
elif clearance >= 45:
|
| 44 |
+
stage = "Mild to moderately decreased"
|
| 45 |
+
category = "G3a"
|
| 46 |
+
elif clearance >= 30:
|
| 47 |
+
stage = "Moderately to severely decreased"
|
| 48 |
+
category = "G3b"
|
| 49 |
+
elif clearance >= 15:
|
| 50 |
+
stage = "Severely decreased"
|
| 51 |
+
category = "G4"
|
| 52 |
+
else:
|
| 53 |
+
stage = "Kidney failure"
|
| 54 |
+
category = "G5"
|
| 55 |
+
|
| 56 |
+
return {
|
| 57 |
+
"creatinine_clearance_ml_min": round(clearance, 1),
|
| 58 |
+
"kidney_function_stage": stage,
|
| 59 |
+
"gfr_category": category,
|
| 60 |
+
"formula_used": "Cockcroft-Gault",
|
| 61 |
+
"requires_dose_adjustment": clearance < 60,
|
| 62 |
+
"patient_info": {
|
| 63 |
+
"age": age,
|
| 64 |
+
"weight_kg": weight_kg,
|
| 65 |
+
"serum_creatinine_mg_dl": serum_creatinine_mg_dl,
|
| 66 |
+
"is_female": is_female
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
def ckd_epi_egfr(
|
| 71 |
+
age: int,
|
| 72 |
+
serum_creatinine_mg_dl: float,
|
| 73 |
+
is_female: bool = False,
|
| 74 |
+
is_black: bool = False
|
| 75 |
+
) -> Dict[str, Any]:
|
| 76 |
+
"""
|
| 77 |
+
Calculate estimated GFR using CKD-EPI equation.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
age: Patient age in years
|
| 81 |
+
serum_creatinine_mg_dl: Serum creatinine in mg/dL
|
| 82 |
+
is_female: True if patient is female
|
| 83 |
+
is_black: True if patient is Black
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
Dict with calculated eGFR and interpretation
|
| 87 |
+
"""
|
| 88 |
+
if age <= 0 or serum_creatinine_mg_dl <= 0:
|
| 89 |
+
raise ValueError("Age and creatinine must be positive")
|
| 90 |
+
|
| 91 |
+
if is_female:
|
| 92 |
+
kappa = 0.7
|
| 93 |
+
alpha = -0.329
|
| 94 |
+
if serum_creatinine_mg_dl <= kappa:
|
| 95 |
+
alpha = -0.411
|
| 96 |
+
else:
|
| 97 |
+
kappa = 0.9
|
| 98 |
+
alpha = -0.411
|
| 99 |
+
if serum_creatinine_mg_dl <= kappa:
|
| 100 |
+
alpha = -0.302
|
| 101 |
+
|
| 102 |
+
scr_kappa_ratio = serum_creatinine_mg_dl / kappa
|
| 103 |
+
if serum_creatinine_mg_dl <= kappa:
|
| 104 |
+
egfr = 141 * (scr_kappa_ratio ** alpha) * (0.993 ** age)
|
| 105 |
+
else:
|
| 106 |
+
egfr = 141 * (scr_kappa_ratio ** -1.209) * (0.993 ** age)
|
| 107 |
+
|
| 108 |
+
if is_female:
|
| 109 |
+
egfr *= 1.018
|
| 110 |
+
|
| 111 |
+
if is_black:
|
| 112 |
+
egfr *= 1.159
|
| 113 |
+
|
| 114 |
+
if egfr >= 90:
|
| 115 |
+
stage = "Normal or high"
|
| 116 |
+
category = "G1"
|
| 117 |
+
elif egfr >= 60:
|
| 118 |
+
stage = "Mildly decreased"
|
| 119 |
+
category = "G2"
|
| 120 |
+
elif egfr >= 45:
|
| 121 |
+
stage = "Mild to moderately decreased"
|
| 122 |
+
category = "G3a"
|
| 123 |
+
elif egfr >= 30:
|
| 124 |
+
stage = "Moderately to severely decreased"
|
| 125 |
+
category = "G3b"
|
| 126 |
+
elif egfr >= 15:
|
| 127 |
+
stage = "Severely decreased"
|
| 128 |
+
category = "G4"
|
| 129 |
+
else:
|
| 130 |
+
stage = "Kidney failure"
|
| 131 |
+
category = "G5"
|
| 132 |
+
|
| 133 |
+
return {
|
| 134 |
+
"egfr_ml_min_1_73m2": round(egfr, 1),
|
| 135 |
+
"kidney_function_stage": stage,
|
| 136 |
+
"gfr_category": category,
|
| 137 |
+
"formula_used": "CKD-EPI",
|
| 138 |
+
"requires_dose_adjustment": egfr < 60,
|
| 139 |
+
"patient_info": {
|
| 140 |
+
"age": age,
|
| 141 |
+
"serum_creatinine_mg_dl": serum_creatinine_mg_dl,
|
| 142 |
+
"is_female": is_female,
|
| 143 |
+
"is_black": is_black
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
def child_pugh_score(
|
| 148 |
+
bilirubin_mg_dl: float,
|
| 149 |
+
albumin_g_dl: float,
|
| 150 |
+
inr: float,
|
| 151 |
+
ascites: str,
|
| 152 |
+
encephalopathy: str
|
| 153 |
+
) -> Dict[str, Any]:
|
| 154 |
+
"""
|
| 155 |
+
Calculate Child-Pugh score for liver function assessment.
|
| 156 |
+
|
| 157 |
+
Args:
|
| 158 |
+
bilirubin_mg_dl: Total bilirubin in mg/dL
|
| 159 |
+
albumin_g_dl: Serum albumin in g/dL
|
| 160 |
+
inr: International Normalized Ratio
|
| 161 |
+
ascites: 'none', 'mild', or 'moderate-severe'
|
| 162 |
+
encephalopathy: 'none', 'grade-1-2', or 'grade-3-4'
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
Dict with Child-Pugh score, class, and interpretation
|
| 166 |
+
"""
|
| 167 |
+
score = 0
|
| 168 |
+
|
| 169 |
+
if bilirubin_mg_dl < 2:
|
| 170 |
+
score += 1
|
| 171 |
+
elif bilirubin_mg_dl <= 3:
|
| 172 |
+
score += 2
|
| 173 |
+
else:
|
| 174 |
+
score += 3
|
| 175 |
+
|
| 176 |
+
if albumin_g_dl > 3.5:
|
| 177 |
+
score += 1
|
| 178 |
+
elif albumin_g_dl >= 2.8:
|
| 179 |
+
score += 2
|
| 180 |
+
else:
|
| 181 |
+
score += 3
|
| 182 |
+
|
| 183 |
+
if inr < 1.7:
|
| 184 |
+
score += 1
|
| 185 |
+
elif inr <= 2.3:
|
| 186 |
+
score += 2
|
| 187 |
+
else:
|
| 188 |
+
score += 3
|
| 189 |
+
|
| 190 |
+
ascites_lower = ascites.lower()
|
| 191 |
+
if 'none' in ascites_lower:
|
| 192 |
+
score += 1
|
| 193 |
+
elif 'mild' in ascites_lower:
|
| 194 |
+
score += 2
|
| 195 |
+
else:
|
| 196 |
+
score += 3
|
| 197 |
+
|
| 198 |
+
encephalopathy_lower = encephalopathy.lower()
|
| 199 |
+
if 'none' in encephalopathy_lower:
|
| 200 |
+
score += 1
|
| 201 |
+
elif 'grade-1-2' in encephalopathy_lower or '1-2' in encephalopathy_lower:
|
| 202 |
+
score += 2
|
| 203 |
+
else:
|
| 204 |
+
score += 3
|
| 205 |
+
|
| 206 |
+
if score <= 6:
|
| 207 |
+
child_class = "A"
|
| 208 |
+
mortality_1yr = "< 10%"
|
| 209 |
+
mortality_2yr = "< 15%"
|
| 210 |
+
perioperative_mortality = "10%"
|
| 211 |
+
elif score <= 9:
|
| 212 |
+
child_class = "B"
|
| 213 |
+
mortality_1yr = "10-20%"
|
| 214 |
+
mortality_2yr = "20-30%"
|
| 215 |
+
perioperative_mortality = "30%"
|
| 216 |
+
else:
|
| 217 |
+
child_class = "C"
|
| 218 |
+
mortality_1yr = "> 20%"
|
| 219 |
+
mortality_2yr = "> 35%"
|
| 220 |
+
perioperative_mortality = "50%"
|
| 221 |
+
|
| 222 |
+
return {
|
| 223 |
+
"child_pugh_score": score,
|
| 224 |
+
"child_pugh_class": child_class,
|
| 225 |
+
"one_year_mortality": mortality_1yr,
|
| 226 |
+
"two_year_mortality": mortality_2yr,
|
| 227 |
+
"perioperative_mortality": perioperative_mortality,
|
| 228 |
+
"requires_dose_adjustment": child_class in ["B", "C"],
|
| 229 |
+
"severe_impairment": child_class == "C",
|
| 230 |
+
"components": {
|
| 231 |
+
"bilirubin_mg_dl": bilirubin_mg_dl,
|
| 232 |
+
"albumin_g_dl": albumin_g_dl,
|
| 233 |
+
"inr": inr,
|
| 234 |
+
"ascites": ascites,
|
| 235 |
+
"encephalopathy": encephalopathy
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
def bmi_calculator(
|
| 240 |
+
weight_kg: float,
|
| 241 |
+
height_cm: float
|
| 242 |
+
) -> Dict[str, Any]:
|
| 243 |
+
"""
|
| 244 |
+
Calculate Body Mass Index and provide interpretation.
|
| 245 |
+
|
| 246 |
+
Args:
|
| 247 |
+
weight_kg: Weight in kilograms
|
| 248 |
+
height_cm: Height in centimeters
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
Dict with BMI and weight category
|
| 252 |
+
"""
|
| 253 |
+
if weight_kg <= 0 or height_cm <= 0:
|
| 254 |
+
raise ValueError("Weight and height must be positive")
|
| 255 |
+
|
| 256 |
+
height_m = height_cm / 100
|
| 257 |
+
bmi = weight_kg / (height_m ** 2)
|
| 258 |
+
|
| 259 |
+
if bmi < 18.5:
|
| 260 |
+
category = "Underweight"
|
| 261 |
+
risk = "Increased risk of malnutrition"
|
| 262 |
+
elif bmi < 25:
|
| 263 |
+
category = "Normal weight"
|
| 264 |
+
risk = "Low risk"
|
| 265 |
+
elif bmi < 30:
|
| 266 |
+
category = "Overweight"
|
| 267 |
+
risk = "Increased risk"
|
| 268 |
+
elif bmi < 35:
|
| 269 |
+
category = "Obesity Class I"
|
| 270 |
+
risk = "High risk"
|
| 271 |
+
elif bmi < 40:
|
| 272 |
+
category = "Obesity Class II"
|
| 273 |
+
risk = "Very high risk"
|
| 274 |
+
else:
|
| 275 |
+
category = "Obesity Class III"
|
| 276 |
+
risk = "Extremely high risk"
|
| 277 |
+
|
| 278 |
+
return {
|
| 279 |
+
"bmi": round(bmi, 1),
|
| 280 |
+
"category": category,
|
| 281 |
+
"health_risk": risk,
|
| 282 |
+
"weight_kg": weight_kg,
|
| 283 |
+
"height_cm": height_cm
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
def ideal_body_weight(
|
| 287 |
+
height_cm: float,
|
| 288 |
+
is_male: bool = True
|
| 289 |
+
) -> Dict[str, Any]:
|
| 290 |
+
"""
|
| 291 |
+
Calculate Ideal Body Weight using Devine formula.
|
| 292 |
+
|
| 293 |
+
Args:
|
| 294 |
+
height_cm: Height in centimeters
|
| 295 |
+
is_male: True if patient is male
|
| 296 |
+
|
| 297 |
+
Returns:
|
| 298 |
+
Dict with ideal body weight
|
| 299 |
+
"""
|
| 300 |
+
if height_cm <= 0:
|
| 301 |
+
raise ValueError("Height must be positive")
|
| 302 |
+
|
| 303 |
+
height_inches = height_cm / 2.54
|
| 304 |
+
|
| 305 |
+
if is_male:
|
| 306 |
+
ibw_kg = 50 + 2.3 * (height_inches - 60)
|
| 307 |
+
else:
|
| 308 |
+
ibw_kg = 45.5 + 2.3 * (height_inches - 60)
|
| 309 |
+
|
| 310 |
+
ibw_kg = max(ibw_kg, 30)
|
| 311 |
+
|
| 312 |
+
return {
|
| 313 |
+
"ideal_body_weight_kg": round(ibw_kg, 1),
|
| 314 |
+
"height_cm": height_cm,
|
| 315 |
+
"is_male": is_male,
|
| 316 |
+
"formula_used": "Devine"
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
def adjusted_body_weight(
|
| 320 |
+
actual_weight_kg: float,
|
| 321 |
+
ideal_weight_kg: float,
|
| 322 |
+
correction_factor: float = 0.4
|
| 323 |
+
) -> Dict[str, Any]:
|
| 324 |
+
"""
|
| 325 |
+
Calculate Adjusted Body Weight for obese patients.
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
actual_weight_kg: Actual body weight in kg
|
| 329 |
+
ideal_weight_kg: Ideal body weight in kg
|
| 330 |
+
correction_factor: Correction factor (default 0.4)
|
| 331 |
+
|
| 332 |
+
Returns:
|
| 333 |
+
Dict with adjusted body weight
|
| 334 |
+
"""
|
| 335 |
+
if actual_weight_kg <= 0 or ideal_weight_kg <= 0:
|
| 336 |
+
raise ValueError("Weights must be positive")
|
| 337 |
+
|
| 338 |
+
if actual_weight_kg <= ideal_weight_kg * 1.2:
|
| 339 |
+
adjusted_weight = actual_weight_kg
|
| 340 |
+
adjustment_needed = False
|
| 341 |
+
else:
|
| 342 |
+
adjusted_weight = ideal_weight_kg + correction_factor * (actual_weight_kg - ideal_weight_kg)
|
| 343 |
+
adjustment_needed = True
|
| 344 |
+
|
| 345 |
+
return {
|
| 346 |
+
"adjusted_body_weight_kg": round(adjusted_weight, 1),
|
| 347 |
+
"actual_weight_kg": actual_weight_kg,
|
| 348 |
+
"ideal_weight_kg": ideal_weight_kg,
|
| 349 |
+
"correction_factor": correction_factor,
|
| 350 |
+
"adjustment_needed": adjustment_needed,
|
| 351 |
+
"percent_above_ideal": round(((actual_weight_kg / ideal_weight_kg) - 1) * 100, 1)
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
def creatinine_conversion(
|
| 355 |
+
creatinine_value: float,
|
| 356 |
+
from_unit: str,
|
| 357 |
+
to_unit: str
|
| 358 |
+
) -> Dict[str, Any]:
|
| 359 |
+
"""
|
| 360 |
+
Convert creatinine between mg/dL and μmol/L.
|
| 361 |
+
|
| 362 |
+
Args:
|
| 363 |
+
creatinine_value: Creatinine value to convert
|
| 364 |
+
from_unit: 'mg_dl' or 'umol_l'
|
| 365 |
+
to_unit: 'mg_dl' or 'umol_l'
|
| 366 |
+
|
| 367 |
+
Returns:
|
| 368 |
+
Dict with converted value
|
| 369 |
+
"""
|
| 370 |
+
if creatinine_value <= 0:
|
| 371 |
+
raise ValueError("Creatinine value must be positive")
|
| 372 |
+
|
| 373 |
+
conversion_factor = 88.42
|
| 374 |
+
|
| 375 |
+
if from_unit == to_unit:
|
| 376 |
+
converted_value = creatinine_value
|
| 377 |
+
elif from_unit == 'mg_dl' and to_unit == 'umol_l':
|
| 378 |
+
converted_value = creatinine_value * conversion_factor
|
| 379 |
+
elif from_unit == 'umol_l' and to_unit == 'mg_dl':
|
| 380 |
+
converted_value = creatinine_value / conversion_factor
|
| 381 |
+
else:
|
| 382 |
+
raise ValueError("Invalid units. Use 'mg_dl' or 'umol_l'")
|
| 383 |
+
|
| 384 |
+
return {
|
| 385 |
+
"original_value": creatinine_value,
|
| 386 |
+
"original_unit": from_unit,
|
| 387 |
+
"converted_value": round(converted_value, 2),
|
| 388 |
+
"converted_unit": to_unit,
|
| 389 |
+
"conversion_factor": conversion_factor
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
def dosing_weight_recommendation(
|
| 393 |
+
actual_weight_kg: float,
|
| 394 |
+
height_cm: float,
|
| 395 |
+
is_male: bool = True
|
| 396 |
+
) -> Dict[str, Any]:
|
| 397 |
+
"""
|
| 398 |
+
Recommend appropriate weight for dosing calculations.
|
| 399 |
+
|
| 400 |
+
Args:
|
| 401 |
+
actual_weight_kg: Actual body weight in kg
|
| 402 |
+
height_cm: Height in centimeters
|
| 403 |
+
is_male: True if patient is male
|
| 404 |
+
|
| 405 |
+
Returns:
|
| 406 |
+
Dict with dosing weight recommendation
|
| 407 |
+
"""
|
| 408 |
+
ibw_result = ideal_body_weight(height_cm, is_male)
|
| 409 |
+
ibw = ibw_result["ideal_body_weight_kg"]
|
| 410 |
+
|
| 411 |
+
bmi_result = bmi_calculator(actual_weight_kg, height_cm)
|
| 412 |
+
bmi = bmi_result["bmi"]
|
| 413 |
+
|
| 414 |
+
if actual_weight_kg <= ibw * 1.2:
|
| 415 |
+
dosing_weight = actual_weight_kg
|
| 416 |
+
recommendation = "Use actual body weight"
|
| 417 |
+
rationale = "Patient weight is within 20% of ideal body weight"
|
| 418 |
+
elif bmi >= 30:
|
| 419 |
+
adj_weight_result = adjusted_body_weight(actual_weight_kg, ibw)
|
| 420 |
+
dosing_weight = adj_weight_result["adjusted_body_weight_kg"]
|
| 421 |
+
recommendation = "Use adjusted body weight"
|
| 422 |
+
rationale = "Patient is obese (BMI ≥ 30); adjusted weight recommended for most drugs"
|
| 423 |
+
else:
|
| 424 |
+
dosing_weight = actual_weight_kg
|
| 425 |
+
recommendation = "Use actual body weight (consider drug-specific guidelines)"
|
| 426 |
+
rationale = "Patient is overweight but not obese; actual weight typically appropriate"
|
| 427 |
+
|
| 428 |
+
return {
|
| 429 |
+
"recommended_dosing_weight_kg": dosing_weight,
|
| 430 |
+
"recommendation": recommendation,
|
| 431 |
+
"rationale": rationale,
|
| 432 |
+
"actual_weight_kg": actual_weight_kg,
|
| 433 |
+
"ideal_weight_kg": ibw,
|
| 434 |
+
"bmi": bmi,
|
| 435 |
+
"bmi_category": bmi_result["category"]
|
| 436 |
+
}
|
dbi_mcp.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
import re
|
| 5 |
+
import logging
|
| 6 |
+
import functools
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Dict, List, Tuple, Optional, Union, Mapping, Sequence
|
| 9 |
+
|
| 10 |
+
from brand_to_generic import brand_lookup
|
| 11 |
+
|
| 12 |
+
import csv
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
import pandas as pd
|
| 16 |
+
except ImportError:
|
| 17 |
+
pd = None
|
| 18 |
+
|
| 19 |
+
__all__ = [
|
| 20 |
+
"load_reference",
|
| 21 |
+
"load_patient_meds",
|
| 22 |
+
"calculate_dbi",
|
| 23 |
+
"print_report",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
PatientInput = Union[
|
| 27 |
+
Path,
|
| 28 |
+
Sequence[Tuple[str, float]],
|
| 29 |
+
Mapping[str, float],
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _normalise_name(name: str) -> str:
|
| 34 |
+
"""Strip/-lower a drug name for key matching."""
|
| 35 |
+
return name.strip().lower()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def load_reference(
|
| 39 |
+
ref_path: Path,
|
| 40 |
+
*,
|
| 41 |
+
route: str = "oral",
|
| 42 |
+
use_pandas: bool | None = None,
|
| 43 |
+
) -> Dict[str, Tuple[float, str]]:
|
| 44 |
+
"""Return mapping **generic → (δ<sub>route</sub>, drug_class)**.
|
| 45 |
+
|
| 46 |
+
If a drug lacks the requested route it is silently skipped. Callers may
|
| 47 |
+
retry with ``route=None`` to get the *first* available dose instead.
|
| 48 |
+
"""
|
| 49 |
+
if use_pandas is None:
|
| 50 |
+
use_pandas = pd is not None
|
| 51 |
+
|
| 52 |
+
ref: Dict[str, Tuple[float, str]] = {}
|
| 53 |
+
|
| 54 |
+
if use_pandas:
|
| 55 |
+
df = pd.read_csv(ref_path)
|
| 56 |
+
df = df[df["route"].str.lower() == route.lower()]
|
| 57 |
+
for _, row in df.iterrows():
|
| 58 |
+
ref[_normalise_name(row["generic_name"])] = (
|
| 59 |
+
float(row["min_daily_dose_mg"]),
|
| 60 |
+
row["drug_class"].strip().lower(),
|
| 61 |
+
)
|
| 62 |
+
else:
|
| 63 |
+
with ref_path.open(newline="") as f:
|
| 64 |
+
rdr = csv.DictReader(f)
|
| 65 |
+
for row in rdr:
|
| 66 |
+
if row["route"].strip().lower() != route.lower():
|
| 67 |
+
continue
|
| 68 |
+
ref[_normalise_name(row["generic_name"])] = (
|
| 69 |
+
float(row["min_daily_dose_mg"]),
|
| 70 |
+
row["drug_class"].strip().lower(),
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
return ref
|
| 74 |
+
|
| 75 |
+
def calculate_dbi(
|
| 76 |
+
patient_meds: Mapping[str, float],
|
| 77 |
+
reference: Mapping[str, Tuple[float, str]],
|
| 78 |
+
) -> Tuple[float, List[Tuple[str, float, float, float]]]:
|
| 79 |
+
"""Return ``(total, details)`` where *details* is a list of
|
| 80 |
+
``(generic_name, dose_mg, δ_mg, DBI_i)``.
|
| 81 |
+
"""
|
| 82 |
+
details: List[Tuple[str, float, float, float]] = []
|
| 83 |
+
total = 0.0
|
| 84 |
+
|
| 85 |
+
for drug, dose in patient_meds.items():
|
| 86 |
+
ref = reference.get(drug)
|
| 87 |
+
if not ref:
|
| 88 |
+
continue # unknown or route-mismatch
|
| 89 |
+
delta, drug_class = ref
|
| 90 |
+
if drug_class not in {"anticholinergic", "sedative", "both"}:
|
| 91 |
+
continue
|
| 92 |
+
dbi_i = dose / (delta + dose)
|
| 93 |
+
details.append((drug, dose, delta, dbi_i))
|
| 94 |
+
total += dbi_i
|
| 95 |
+
|
| 96 |
+
return total, details
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
logger = logging.getLogger(__name__)
|
| 100 |
+
|
| 101 |
+
UNIT_PAT = re.compile(r"(?P<val>\d+(?:[.,]\d+)?)(?:\s*)(?P<unit>mcg|μg|mg|g)\b", re.I)
|
| 102 |
+
|
| 103 |
+
PATCH_PAT = re.compile(r"(?P<val>\d+(?:[.,]\d+)?)(?:\s*)(mcg|μg)\s*/\s*hr", re.I)
|
| 104 |
+
|
| 105 |
+
CONC_PAT = re.compile(r"(?P<drug_val>\d+(?:[.,]\d+)?)(?:\s*)(?P<drug_unit>mcg|μg|mg|g)\s*/\s*(?P<vol_val>\d+(?:[.,]\d+)?)(?:\s*)m ?l", re.I)
|
| 106 |
+
|
| 107 |
+
VOL_PAT = re.compile(r"(?P<voldose>\d+(?:[.,]\d+)?)(?:\s*)m ?l", re.I)
|
| 108 |
+
|
| 109 |
+
QTY_PAT = re.compile(r"(?<!\d)(?P<qty>\d+)\s*(?:tab|caps?|puff|spray|patch|patches)s?\b", re.I)
|
| 110 |
+
|
| 111 |
+
FREQ_PAT = re.compile(r"\b(q\d{1,2}h|qd|od|daily|once daily|bid|bd|twice daily|tid|tds|three times daily|qid|four times daily|nocte|mane|am|pm)\b", re.I)
|
| 112 |
+
EVERY_HOURS_PAT = re.compile(r"q(\d{1,2})h", re.I)
|
| 113 |
+
|
| 114 |
+
_FREQ_MAP = {
|
| 115 |
+
"qd": 1, "od": 1, "daily": 1, "once daily": 1,
|
| 116 |
+
"bid": 2, "bd": 2, "twice daily": 2,
|
| 117 |
+
"tid": 3, "tds": 3, "three times daily": 3,
|
| 118 |
+
"qid": 4, "four times daily": 4,
|
| 119 |
+
"nocte": 1, "pm": 1,
|
| 120 |
+
"mane": 1, "am": 1,
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
def _unit_to_mg(val: float, unit: str) -> float:
|
| 124 |
+
unit = unit.lower()
|
| 125 |
+
if unit == "mg":
|
| 126 |
+
return val
|
| 127 |
+
if unit in {"g"}:
|
| 128 |
+
return val * 1_000
|
| 129 |
+
if unit in {"mcg", "μg"}:
|
| 130 |
+
return val / 1_000
|
| 131 |
+
return math.nan
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _freq_to_per_day(token: str) -> float:
|
| 135 |
+
token = token.lower()
|
| 136 |
+
if token in _FREQ_MAP:
|
| 137 |
+
return _FREQ_MAP[token]
|
| 138 |
+
m = EVERY_HOURS_PAT.fullmatch(token)
|
| 139 |
+
if m:
|
| 140 |
+
hrs = int(m.group(1))
|
| 141 |
+
return 24 / hrs if hrs else 1
|
| 142 |
+
return 1
|
| 143 |
+
|
| 144 |
+
Parsed = Tuple[str, float, bool]
|
| 145 |
+
|
| 146 |
+
@functools.lru_cache(maxsize=2048)
|
| 147 |
+
def _parse_line(line: str) -> Optional[Parsed]:
|
| 148 |
+
original = line.strip()
|
| 149 |
+
if not original:
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
is_prn = "prn" in original.lower()
|
| 153 |
+
|
| 154 |
+
m_patch = PATCH_PAT.search(original)
|
| 155 |
+
if m_patch:
|
| 156 |
+
mcg_hr = float(m_patch.group("val").replace(",", "."))
|
| 157 |
+
mg_day = (mcg_hr * 24) / 1_000 # µg/hr → mg/day
|
| 158 |
+
name_part = PATCH_PAT.sub("", original).split()[0]
|
| 159 |
+
return (name_part, mg_day, is_prn)
|
| 160 |
+
|
| 161 |
+
m_conc = CONC_PAT.search(original)
|
| 162 |
+
m_vol = VOL_PAT.search(original)
|
| 163 |
+
if m_conc and m_vol:
|
| 164 |
+
drug_val = _unit_to_mg(float(m_conc.group("drug_val").replace(",", ".")), m_conc.group("drug_unit"))
|
| 165 |
+
vol_val = float(m_conc.group("vol_val").replace(",", "."))
|
| 166 |
+
voldose = float(m_vol.group("voldose").replace(",", "."))
|
| 167 |
+
if vol_val == 0:
|
| 168 |
+
logger.warning("volume 0 in concentration parse – %s", original)
|
| 169 |
+
return None
|
| 170 |
+
mg_per_dose = drug_val * (voldose / vol_val)
|
| 171 |
+
qty = 1
|
| 172 |
+
freq = 1.0
|
| 173 |
+
m_freq = FREQ_PAT.search(original)
|
| 174 |
+
if m_freq:
|
| 175 |
+
freq = _freq_to_per_day(m_freq.group(0))
|
| 176 |
+
mg_day = mg_per_dose * freq
|
| 177 |
+
name_part = CONC_PAT.split(original)[0].strip()
|
| 178 |
+
return (name_part, mg_day, is_prn)
|
| 179 |
+
|
| 180 |
+
m = UNIT_PAT.search(original)
|
| 181 |
+
if m:
|
| 182 |
+
strength_mg = _unit_to_mg(float(m.group("val").replace(",", ".")), m.group("unit"))
|
| 183 |
+
qty = 1
|
| 184 |
+
m_qty = QTY_PAT.search(original)
|
| 185 |
+
if m_qty:
|
| 186 |
+
qty = int(m_qty.group("qty"))
|
| 187 |
+
freq = 1.0
|
| 188 |
+
m_freq = FREQ_PAT.search(original)
|
| 189 |
+
if m_freq:
|
| 190 |
+
freq = _freq_to_per_day(m_freq.group(0))
|
| 191 |
+
mg_day = strength_mg * qty * freq
|
| 192 |
+
name_part = original[:m.start()].strip()
|
| 193 |
+
name_part = re.sub(r"[^A-Za-z0-9\s]", " ", name_part)
|
| 194 |
+
name_part = re.sub(r"\s+", " ", name_part).strip()
|
| 195 |
+
return (name_part, mg_day, is_prn)
|
| 196 |
+
|
| 197 |
+
logger.debug("unhandled line: %s", original)
|
| 198 |
+
return None
|
| 199 |
+
|
| 200 |
+
def _smart_drug_lookup(raw_name: str, reference_data: dict) -> str:
|
| 201 |
+
"""
|
| 202 |
+
Smart drug name resolution that avoids unnecessary API calls.
|
| 203 |
+
|
| 204 |
+
1. First checks if the name (or close variant) exists in reference data
|
| 205 |
+
2. Only calls brand_lookup API if not found in reference
|
| 206 |
+
3. Returns the best generic name match
|
| 207 |
+
"""
|
| 208 |
+
clean_name = raw_name.strip().lower()
|
| 209 |
+
|
| 210 |
+
if clean_name in reference_data:
|
| 211 |
+
logger.debug(f"Direct match found for '{raw_name}' in reference data")
|
| 212 |
+
return clean_name
|
| 213 |
+
|
| 214 |
+
for ref_name in reference_data.keys():
|
| 215 |
+
if len(clean_name) >= 4 and len(ref_name) >= 4:
|
| 216 |
+
if clean_name in ref_name or ref_name in clean_name:
|
| 217 |
+
logger.debug(f"Partial match found: '{raw_name}' -> '{ref_name}' in reference data")
|
| 218 |
+
return ref_name
|
| 219 |
+
|
| 220 |
+
common_variations = {
|
| 221 |
+
'acetaminophen': 'paracetamol',
|
| 222 |
+
'paracetamol': 'acetaminophen',
|
| 223 |
+
'hydrochlorothiazide': 'hctz',
|
| 224 |
+
'hctz': 'hydrochlorothiazide',
|
| 225 |
+
'furosemide': 'frusemide',
|
| 226 |
+
'frusemide': 'furosemide',
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
if clean_name in common_variations:
|
| 230 |
+
alt_name = common_variations[clean_name]
|
| 231 |
+
if alt_name in reference_data:
|
| 232 |
+
logger.debug(f"Found common variation: '{raw_name}' -> '{alt_name}' in reference data")
|
| 233 |
+
return alt_name
|
| 234 |
+
|
| 235 |
+
logger.debug(f"'{raw_name}' not found in reference data, trying brand lookup API")
|
| 236 |
+
try:
|
| 237 |
+
lookup = brand_lookup(raw_name)
|
| 238 |
+
if lookup["results"]:
|
| 239 |
+
generic_name = lookup["results"][0]["generic_name"].lower().strip()
|
| 240 |
+
logger.debug(f"Brand lookup successful: '{raw_name}' -> '{generic_name}'")
|
| 241 |
+
return generic_name
|
| 242 |
+
else:
|
| 243 |
+
logger.debug(f"Brand lookup returned no results for '{raw_name}'")
|
| 244 |
+
return clean_name
|
| 245 |
+
except Exception as e:
|
| 246 |
+
logger.warning(f"Brand lookup failed for '{raw_name}': {e}")
|
| 247 |
+
return clean_name
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def dbi_mcp(text_block: str, *, ref_csv: Union[str, Path] = "dbi_reference_by_route.csv", route: str = "oral") -> dict:
|
| 251 |
+
"""End-to-end DBI calculator with dual PRN handling and smart drug name resolution."""
|
| 252 |
+
ref = load_reference(Path(ref_csv), route=route)
|
| 253 |
+
|
| 254 |
+
parsed: List[Parsed] = []
|
| 255 |
+
unmatched: List[str] = []
|
| 256 |
+
for ln in text_block.splitlines():
|
| 257 |
+
res = _parse_line(ln)
|
| 258 |
+
if res:
|
| 259 |
+
parsed.append(res)
|
| 260 |
+
else:
|
| 261 |
+
unmatched.append(ln)
|
| 262 |
+
|
| 263 |
+
meds_with: Dict[str, float] = {}
|
| 264 |
+
meds_without: Dict[str, float] = {}
|
| 265 |
+
|
| 266 |
+
for raw_name, mg_day, is_prn in parsed:
|
| 267 |
+
generic = _smart_drug_lookup(raw_name, ref)
|
| 268 |
+
|
| 269 |
+
meds_with[generic] = meds_with.get(generic, 0.0) + mg_day
|
| 270 |
+
if not is_prn:
|
| 271 |
+
meds_without[generic] = meds_without.get(generic, 0.0) + mg_day
|
| 272 |
+
|
| 273 |
+
total_no, details_no = calculate_dbi(meds_without, ref)
|
| 274 |
+
total_with, details_with = calculate_dbi(meds_with, ref)
|
| 275 |
+
|
| 276 |
+
def _details_to_list(details):
|
| 277 |
+
return [dict(generic_name=g, dose_mg_day=d, delta_mg=delta, dbi_component=dbi) for g, d, delta, dbi in details]
|
| 278 |
+
|
| 279 |
+
return {
|
| 280 |
+
"route": route,
|
| 281 |
+
"dbi_without_prn": round(total_no, 2),
|
| 282 |
+
"dbi_with_prn": round(total_with, 2),
|
| 283 |
+
"details_without_prn": _details_to_list(details_no),
|
| 284 |
+
"details_with_prn": _details_to_list(details_with),
|
| 285 |
+
"unmatched_input": unmatched,
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
if __name__ == "__main__":
|
| 290 |
+
import sys
|
| 291 |
+
import pprint
|
| 292 |
+
text = sys.stdin.read() if not sys.stdin.isatty() else "\n".join(sys.argv[1:])
|
| 293 |
+
pprint.pp(dbi_mcp(text))
|
dbi_reference_by_route.csv
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
generic_name,route,min_daily_dose_mg,drug_class
|
| 2 |
+
alimemazine,oral,10,both
|
| 3 |
+
amitriptyline,oral,10,both
|
| 4 |
+
aripiprazole,oral,10,sedative
|
| 5 |
+
aripiprazole,parenteral,10,sedative
|
| 6 |
+
atropine,oral,0.6,anticholinergic
|
| 7 |
+
atropine,parenteral,0.3,anticholinergic
|
| 8 |
+
baclofen,oral,30,sedative
|
| 9 |
+
baclofen,parenteral,30,sedative
|
| 10 |
+
benzatropine,oral,0.5,anticholinergic
|
| 11 |
+
benzatropine,parenteral,0.5,anticholinergic
|
| 12 |
+
biperiden,oral,1,both
|
| 13 |
+
brompheniramine,oral,16,both
|
| 14 |
+
buclizine,oral,12.5,both
|
| 15 |
+
buprenorphine,oral,0.12,sedative
|
| 16 |
+
buprenorphine,parenteral,0.4,sedative
|
| 17 |
+
buprenorphine,sublingual_buccal,0.12,sedative
|
| 18 |
+
carbamazepine,oral,400,both
|
| 19 |
+
carbamazepine,parenteral,500,both
|
| 20 |
+
chlorphenamine,oral,8,both
|
| 21 |
+
chlorphenamine,parenteral,3,both
|
| 22 |
+
chlorpromazine,oral,30,both
|
| 23 |
+
chlorpromazine,parenteral,6,both
|
| 24 |
+
cinnarizine,oral,60,both
|
| 25 |
+
clemastine,oral,2,both
|
| 26 |
+
clomipramine,oral,30,both
|
| 27 |
+
clonazepam,oral,0.5,sedative
|
| 28 |
+
clonazepam,parenteral,0.5,sedative
|
| 29 |
+
clozapine,oral,25,both
|
| 30 |
+
cyclizine,oral,50,both
|
| 31 |
+
cyclizine,parenteral,50,both
|
| 32 |
+
cyproheptadine,oral,4,both
|
| 33 |
+
dexchlorpheniramine,oral,8,both
|
| 34 |
+
diazepam,oral,1,sedative
|
| 35 |
+
diazepam,parenteral,1,sedative
|
| 36 |
+
diazepam,sublingual_buccal,1,sedative
|
| 37 |
+
dimenhydrinate,oral,150,both
|
| 38 |
+
diphenhydramine,oral,50,both
|
| 39 |
+
disopyramide,oral,300,anticholinergic
|
| 40 |
+
disopyramide,parenteral,300,anticholinergic
|
| 41 |
+
dosulepin,oral,50,both
|
| 42 |
+
doxepin,oral,25,both
|
| 43 |
+
doxylamine,oral,25,both
|
| 44 |
+
fentanyl,oral,0.3,sedative
|
| 45 |
+
fentanyl,parenteral,0.6,sedative
|
| 46 |
+
fentanyl,sublingual_buccal,0.3,sedative
|
| 47 |
+
flavoxate,oral,600,both
|
| 48 |
+
flupentixol,oral,0.5,both
|
| 49 |
+
flupentixol,parenteral,0.3,both
|
| 50 |
+
fluphenazine,oral,0.36,both
|
| 51 |
+
glycopyrronium,oral,2,both
|
| 52 |
+
glycopyrronium,parenteral,0.2,both
|
| 53 |
+
haloperidol,oral,0.5,sedative
|
| 54 |
+
haloperidol,parenteral,0.25,sedative
|
| 55 |
+
hydromorphone,oral,7.8,sedative
|
| 56 |
+
hydromorphone,parenteral,2.6,sedative
|
| 57 |
+
hydroxyzine,oral,25,both
|
| 58 |
+
hyoscine base,oral,0.5,both
|
| 59 |
+
hyoscine butylbromide,oral,30,anticholinergic
|
| 60 |
+
hyoscine butylbromide,parenteral,30,anticholinergic
|
| 61 |
+
hyoscine hydrobromide,oral,0.9,both
|
| 62 |
+
hyoscine hydrobromide,parenteral,0.5,both
|
| 63 |
+
imipramine,oral,30,both
|
| 64 |
+
levomepromazine,oral,37.5,both
|
| 65 |
+
levomepromazine,parenteral,37.5,both
|
| 66 |
+
lofepramine,oral,140,both
|
| 67 |
+
lorazepam,oral,0.5,sedative
|
| 68 |
+
lorazepam,parenteral,0.5,sedative
|
| 69 |
+
loxapine,oral,4.5,both
|
| 70 |
+
meclozine,oral,25,both
|
| 71 |
+
methocarbamol,oral,2.25e+03,both
|
| 72 |
+
metoclopramide,oral,15,sedative
|
| 73 |
+
metoclopramide,parenteral,15,sedative
|
| 74 |
+
morphine,oral,20,sedative
|
| 75 |
+
morphine,parenteral,6.7,sedative
|
| 76 |
+
nefopam,oral,90,both
|
| 77 |
+
nortriptyline,oral,30,both
|
| 78 |
+
olanzapine,oral,5,both
|
| 79 |
+
olanzapine,parenteral,5,both
|
| 80 |
+
oxybutynin,oral,5,both
|
| 81 |
+
oxybutynin,parenteral,1.95,both
|
| 82 |
+
oxycodone,oral,20,sedative
|
| 83 |
+
oxycodone,parenteral,10,sedative
|
| 84 |
+
oxycodone,sublingual_buccal,20,sedative
|
| 85 |
+
paliperidone,oral,3,sedative
|
| 86 |
+
paliperidone,parenteral,0.89,sedative
|
| 87 |
+
paroxetine,oral,20,both
|
| 88 |
+
pentazocine,oral,300,sedative
|
| 89 |
+
pentazocine,parenteral,180,sedative
|
| 90 |
+
pericyazine,oral,5,both
|
| 91 |
+
perphenazine,oral,3,both
|
| 92 |
+
pethidine,oral,300,sedative
|
| 93 |
+
pethidine,parenteral,150,sedative
|
| 94 |
+
phenobarbital,oral,60,sedative
|
| 95 |
+
phenobarbital,parenteral,60,sedative
|
| 96 |
+
phenytoin,oral,200,sedative
|
| 97 |
+
phenytoin,parenteral,200,sedative
|
| 98 |
+
pimozide,oral,2,both
|
| 99 |
+
pizotifen,oral,1.5,both
|
| 100 |
+
prochlorperazine,oral,10,both
|
| 101 |
+
prochlorperazine,parenteral,1.25,both
|
| 102 |
+
prochlorperazine,sublingual_buccal,4,both
|
| 103 |
+
promazine,oral,100,both
|
| 104 |
+
promethazine,oral,20,both
|
| 105 |
+
promethazine,parenteral,5,both
|
| 106 |
+
propiverine,oral,15,both
|
| 107 |
+
quetiapine,oral,50,both
|
| 108 |
+
risperidone,oral,1,sedative
|
| 109 |
+
risperidone,parenteral,0.7,sedative
|
| 110 |
+
sulpiride,oral,400,both
|
| 111 |
+
tizanidine,oral,6,both
|
| 112 |
+
tolterodine,oral,2,both
|
| 113 |
+
tramadol,oral,200,sedative
|
| 114 |
+
tramadol,parenteral,200,sedative
|
| 115 |
+
trifluoperazine,oral,2,both
|
| 116 |
+
trimipramine,oral,37.5,both
|
| 117 |
+
triprolidine,oral,10,both
|
| 118 |
+
zuclopenthixol,oral,20,both
|
| 119 |
+
zuclopenthixol,parenteral,11.4,both
|
drug_data_endpoints.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import re
|
| 3 |
+
import bs4
|
| 4 |
+
from datasets import load_dataset
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
from caching import with_caching
|
| 9 |
+
from utils import with_error_handling, make_api_request
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
livertox_dataset = load_dataset("cmcmaster/livertox", split="train")
|
| 15 |
+
livertox_df = livertox_dataset.to_pandas()
|
| 16 |
+
logger.info(f"Loaded LiverTox dataset with {len(livertox_df)} drugs")
|
| 17 |
+
except Exception as e:
|
| 18 |
+
logger.error(f"Could not load LiverTox dataset: {e}")
|
| 19 |
+
livertox_df = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@with_error_handling
|
| 23 |
+
@with_caching(ttl=1800)
|
| 24 |
+
def search_adverse_events(drug_name: str, limit: int = 5):
|
| 25 |
+
"""
|
| 26 |
+
Search FAERS for a drug and return brief summaries.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
drug_name: Generic or brand name to search (case-insensitive).
|
| 30 |
+
limit: Maximum number of FAERS safety reports to return.
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Dict with a ``contexts`` key - list of objects ``{id, text}`` suitable
|
| 34 |
+
for an LLM to inject as context.
|
| 35 |
+
"""
|
| 36 |
+
base_url = "https://api.fda.gov/drug/event.json"
|
| 37 |
+
query_params = {
|
| 38 |
+
"search": f'patient.drug.medicinalproduct:"{drug_name}"',
|
| 39 |
+
"limit": min(limit, 100)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
response = make_api_request(base_url, query_params)
|
| 43 |
+
|
| 44 |
+
if response.status_code != 200:
|
| 45 |
+
raise requests.exceptions.RequestException(f"FAERS search failed: {response.status_code}")
|
| 46 |
+
|
| 47 |
+
data = response.json()
|
| 48 |
+
ctx = []
|
| 49 |
+
for rec in data.get("results", []):
|
| 50 |
+
rid = rec.get("safetyreportid")
|
| 51 |
+
terms = [rx.get("reactionmeddrapt", "") for rx in rec.get("patient", {}).get("reaction", [])[:3]]
|
| 52 |
+
ctx.append({"id": str(rid), "text": "; ".join(terms)})
|
| 53 |
+
|
| 54 |
+
return {
|
| 55 |
+
"contexts": ctx,
|
| 56 |
+
"total_found": data.get("meta", {}).get("results", {}).get("total", 0),
|
| 57 |
+
"query": drug_name
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
@with_error_handling
|
| 61 |
+
@with_caching(ttl=3600)
|
| 62 |
+
def fetch_event_details(event_id: str):
|
| 63 |
+
"""
|
| 64 |
+
Fetch a full FAERS case by safety-report ID.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
event_id: Numeric FAERS ``safetyreportid`` string.
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
Structured JSON with patient drugs, reactions, seriousness flag and the
|
| 71 |
+
full raw record (under ``full_record``).
|
| 72 |
+
"""
|
| 73 |
+
base_url = "https://api.fda.gov/drug/event.json"
|
| 74 |
+
query_params = {
|
| 75 |
+
"search": f'safetyreportid:"{event_id}"'
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
response = make_api_request(base_url, query_params)
|
| 79 |
+
|
| 80 |
+
if response.status_code != 200:
|
| 81 |
+
raise requests.exceptions.RequestException(f"Event fetch failed: {response.status_code}")
|
| 82 |
+
|
| 83 |
+
data = response.json()
|
| 84 |
+
if not data.get("results"):
|
| 85 |
+
raise ValueError("Record not found")
|
| 86 |
+
|
| 87 |
+
rec = data["results"][0]
|
| 88 |
+
patient = rec.get("patient", {})
|
| 89 |
+
|
| 90 |
+
return {
|
| 91 |
+
"event_id": event_id,
|
| 92 |
+
"drugs": [d.get("medicinalproduct") for d in patient.get("drug", [])],
|
| 93 |
+
"reactions": [rx.get("reactionmeddrapt") for rx in patient.get("reaction", [])],
|
| 94 |
+
"serious": bool(int(rec.get("serious", "0"))),
|
| 95 |
+
"full_record": rec
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
@with_error_handling
|
| 99 |
+
@with_caching(ttl=7200)
|
| 100 |
+
def drug_label_warnings(drug_name: str):
|
| 101 |
+
"""
|
| 102 |
+
Return boxed warning, contraindications, interactions text and parsed interaction table.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
drug_name: Generic name preferred.
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
Dict with ``boxed_warning``, ``contraindications``,
|
| 109 |
+
``drug_interactions_section`` (strings) and ``drug_interactions_table`` (parsed list).
|
| 110 |
+
"""
|
| 111 |
+
base_url = "https://api.fda.gov/drug/label.json"
|
| 112 |
+
query_params = {
|
| 113 |
+
"search": f'openfda.generic_name:"{drug_name}"',
|
| 114 |
+
"limit": 1
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
response = make_api_request(base_url, query_params)
|
| 118 |
+
|
| 119 |
+
if response.status_code != 200:
|
| 120 |
+
raise requests.exceptions.RequestException(f"Label search failed: {response.status_code}")
|
| 121 |
+
|
| 122 |
+
data = response.json()
|
| 123 |
+
if not data.get("results"):
|
| 124 |
+
raise ValueError("Label not found")
|
| 125 |
+
|
| 126 |
+
lab = data["results"][0]
|
| 127 |
+
|
| 128 |
+
parsed_interactions_table = []
|
| 129 |
+
interactions_table_html_list = lab.get("drug_interactions_table", [])
|
| 130 |
+
if interactions_table_html_list:
|
| 131 |
+
interactions_table_html = interactions_table_html_list[0]
|
| 132 |
+
if interactions_table_html and isinstance(interactions_table_html, str) and "<table" in interactions_table_html:
|
| 133 |
+
soup = bs4.BeautifulSoup(interactions_table_html, "html.parser")
|
| 134 |
+
table = soup.find("table")
|
| 135 |
+
if table:
|
| 136 |
+
rows = table.find_all("tr")
|
| 137 |
+
for row in rows:
|
| 138 |
+
cols = row.find_all("td")
|
| 139 |
+
if len(cols) >= 2:
|
| 140 |
+
col1_items = [item.get_text(strip=True) for item in cols[0].find_all("item")]
|
| 141 |
+
col1_text = "; ".join(col1_items) if col1_items else cols[0].get_text(strip=True)
|
| 142 |
+
|
| 143 |
+
col2_items = [item.get_text(strip=True) for item in cols[1].find_all("item")]
|
| 144 |
+
col2_text = "; ".join(col2_items) if col2_items else cols[1].get_text(strip=True)
|
| 145 |
+
|
| 146 |
+
if col1_text or col2_text:
|
| 147 |
+
parsed_interactions_table.append({
|
| 148 |
+
"drug_or_category1": col1_text,
|
| 149 |
+
"drug_or_category2": col2_text
|
| 150 |
+
})
|
| 151 |
+
else:
|
| 152 |
+
parsed_interactions_table.append({
|
| 153 |
+
"raw_html_content": interactions_table_html,
|
| 154 |
+
"parsing_error": "No <table> tag found."
|
| 155 |
+
})
|
| 156 |
+
|
| 157 |
+
return {
|
| 158 |
+
"boxed_warning": lab.get("boxed_warning", [""])[0],
|
| 159 |
+
"contraindications": lab.get("contraindications", [""])[0],
|
| 160 |
+
"drug_interactions_section": lab.get("drug_interactions", [""])[0],
|
| 161 |
+
"drug_interactions_table": parsed_interactions_table if parsed_interactions_table else "Not found or not applicable.",
|
| 162 |
+
"drug_name": drug_name
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
@with_error_handling
|
| 166 |
+
@with_caching(ttl=3600)
|
| 167 |
+
def drug_recalls(drug_name: str, limit: int = 5):
|
| 168 |
+
"""
|
| 169 |
+
Return recent FDA recall events for a drug.
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
drug_name: Free-text search string.
|
| 173 |
+
limit: Max rows.
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
List of recall notices with recall_number, status, classification, reason.
|
| 177 |
+
"""
|
| 178 |
+
base_url = "https://api.fda.gov/drug/enforcement.json"
|
| 179 |
+
query_params = {
|
| 180 |
+
"search": f'product_description:"{drug_name}"',
|
| 181 |
+
"limit": min(limit, 50)
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
response = make_api_request(base_url, query_params)
|
| 185 |
+
|
| 186 |
+
if response.status_code != 200:
|
| 187 |
+
raise requests.exceptions.RequestException(f"Recall search failed: {response.status_code}")
|
| 188 |
+
|
| 189 |
+
data = response.json()
|
| 190 |
+
events = []
|
| 191 |
+
for e in data.get("results", []):
|
| 192 |
+
events.append({
|
| 193 |
+
"recall_number": e.get("recall_number"),
|
| 194 |
+
"status": e.get("status"),
|
| 195 |
+
"classification": e.get("classification"),
|
| 196 |
+
"reason": e.get("reason_for_recall", "")[:120] + ("…" if len(e.get("reason_for_recall", "")) > 120 else "")
|
| 197 |
+
})
|
| 198 |
+
|
| 199 |
+
return {
|
| 200 |
+
"recalls": events,
|
| 201 |
+
"total_found": data.get("meta", {}).get("results", {}).get("total", 0),
|
| 202 |
+
"query": drug_name
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
LACTATION_PAT = re.compile(r"(?:8\.2\s*Lactation|Lactation\s*Risk\s*Summary)\s*(.*?)(?:\n\s*8\.\d|\n\s*[A-Z][a-z]+ and [A-Z][a-z]+ of Reproductive Potential|$)", re.I | re.S)
|
| 207 |
+
REPRODUCTIVE_POTENTIAL_PAT = re.compile(r"(?:8\.3\s*(?:Females\s+and\s+Males\s+of\s+Reproductive\s+Potential|Reproductive\s+Potential))\s*(.*?)(?:\n\s*8\.\d|\n\s*[A-Z][a-z]+ Use|$)", re.I | re.S)
|
| 208 |
+
|
| 209 |
+
@with_error_handling
|
| 210 |
+
@with_caching(ttl=7200)
|
| 211 |
+
def drug_pregnancy_lactation(drug_name: str):
|
| 212 |
+
"""
|
| 213 |
+
Return Pregnancy & Lactation text from FDA label.
|
| 214 |
+
|
| 215 |
+
Args:
|
| 216 |
+
drug_name: Generic name preferred.
|
| 217 |
+
|
| 218 |
+
Returns:
|
| 219 |
+
Dict with pregnancy_text, pregnancy_registry, lactation_text, and reproductive_potential_text.
|
| 220 |
+
"""
|
| 221 |
+
base_url = "https://api.fda.gov/drug/label.json"
|
| 222 |
+
query_params = {
|
| 223 |
+
"search": f'openfda.generic_name:"{drug_name}"',
|
| 224 |
+
"limit": 1
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
response = make_api_request(base_url, query_params)
|
| 228 |
+
|
| 229 |
+
if response.status_code != 200:
|
| 230 |
+
raise requests.exceptions.RequestException(f"Label search failed: {response.status_code}")
|
| 231 |
+
|
| 232 |
+
data = response.json()
|
| 233 |
+
if not data.get("results"):
|
| 234 |
+
raise ValueError("Label not found")
|
| 235 |
+
|
| 236 |
+
lab = data["results"][0]
|
| 237 |
+
|
| 238 |
+
use_in_specific_populations_text = "\n".join(lab.get("use_in_specific_populations", []))
|
| 239 |
+
|
| 240 |
+
lactation_match = LACTATION_PAT.search(use_in_specific_populations_text)
|
| 241 |
+
lactation_text = lactation_match.group(1).strip() if lactation_match else lab.get("lactation", [""])[0]
|
| 242 |
+
if not lactation_text and lactation_match:
|
| 243 |
+
lactation_text = lactation_match.group(1).strip()
|
| 244 |
+
elif not lactation_text and not lab.get("lactation", [""])[0]:
|
| 245 |
+
lactation_text = "Not found or not specified in the label."
|
| 246 |
+
|
| 247 |
+
reproductive_potential_match = REPRODUCTIVE_POTENTIAL_PAT.search(use_in_specific_populations_text)
|
| 248 |
+
reproductive_potential_text = reproductive_potential_match.group(1).strip() if reproductive_potential_match else "Not found or not specified in the label."
|
| 249 |
+
|
| 250 |
+
return {
|
| 251 |
+
"pregnancy_text": lab.get("pregnancy", [""])[0] or "Not found or not specified in the label.",
|
| 252 |
+
"pregnancy_registry": lab.get("pregnancy_exposure_registry", [""])[0] or "Not specified.",
|
| 253 |
+
"lactation_text": lactation_text,
|
| 254 |
+
"reproductive_potential_text": reproductive_potential_text,
|
| 255 |
+
"drug_name": drug_name
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
RENAL_PAT = re.compile(r"\brenal\b.*?\b(impairment|dysfunction|failure)\b", re.I | re.S)
|
| 259 |
+
HEP_PAT = re.compile(r"\bhepatic\b.*?\b(impairment|dysfunction|child(?:--|\s|-)?pugh)\b", re.I | re.S)
|
| 260 |
+
|
| 261 |
+
@with_error_handling
|
| 262 |
+
@with_caching(ttl=7200)
|
| 263 |
+
def drug_dose_adjustments(drug_name: str):
|
| 264 |
+
"""
|
| 265 |
+
Return renal & hepatic dosing excerpts from FDA label.
|
| 266 |
+
|
| 267 |
+
Args:
|
| 268 |
+
drug_name: Generic name.
|
| 269 |
+
|
| 270 |
+
Returns:
|
| 271 |
+
Dict with renal_excerpt and hepatic_excerpt strings (<=1000 chars each).
|
| 272 |
+
"""
|
| 273 |
+
base_url = "https://api.fda.gov/drug/label.json"
|
| 274 |
+
query_params = {
|
| 275 |
+
"search": f'openfda.generic_name:"{drug_name}"',
|
| 276 |
+
"limit": 1
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
response = make_api_request(base_url, query_params)
|
| 280 |
+
|
| 281 |
+
if response.status_code != 200:
|
| 282 |
+
raise requests.exceptions.RequestException(f"Label search failed: {response.status_code}")
|
| 283 |
+
|
| 284 |
+
data = response.json()
|
| 285 |
+
if not data.get("results"):
|
| 286 |
+
raise ValueError("Label not found")
|
| 287 |
+
|
| 288 |
+
label = data["results"][0]
|
| 289 |
+
sections = "\n".join(label.get(k, [""])[0] for k in ("dosage_and_administration", "use_in_specific_populations"))
|
| 290 |
+
|
| 291 |
+
renal = RENAL_PAT.search(sections)
|
| 292 |
+
hepatic = HEP_PAT.search(sections)
|
| 293 |
+
|
| 294 |
+
return {
|
| 295 |
+
"renal_excerpt": renal.group(0)[:1000] if renal else "Not found",
|
| 296 |
+
"hepatic_excerpt": hepatic.group(0)[:1000] if hepatic else "Not found",
|
| 297 |
+
"drug_name": drug_name
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
@with_error_handling
|
| 301 |
+
@with_caching(ttl=1800)
|
| 302 |
+
def drug_livertox_summary(drug_name: str):
|
| 303 |
+
"""
|
| 304 |
+
Return hepatotoxicity summary from LiverTox dataset.
|
| 305 |
+
|
| 306 |
+
Args:
|
| 307 |
+
drug_name: Drug name to search for (case-insensitive).
|
| 308 |
+
|
| 309 |
+
Returns:
|
| 310 |
+
Dict with drug info including hepatotoxicity, management, trade names, etc.
|
| 311 |
+
"""
|
| 312 |
+
if livertox_df is None:
|
| 313 |
+
raise ValueError("LiverTox dataset not available")
|
| 314 |
+
|
| 315 |
+
drug_name_clean = drug_name.strip().lower()
|
| 316 |
+
|
| 317 |
+
mask = livertox_df['drug_name'].str.lower() == drug_name_clean
|
| 318 |
+
matches = livertox_df[mask]
|
| 319 |
+
|
| 320 |
+
if matches.empty:
|
| 321 |
+
mask = livertox_df['drug_name'].str.lower().str.contains(drug_name_clean, na=False)
|
| 322 |
+
matches = livertox_df[mask]
|
| 323 |
+
|
| 324 |
+
if matches.empty:
|
| 325 |
+
mask = livertox_df['trade_names'].str.lower().str.contains(drug_name_clean, na=False)
|
| 326 |
+
matches = livertox_df[mask]
|
| 327 |
+
|
| 328 |
+
if matches.empty:
|
| 329 |
+
raise ValueError(f"Drug '{drug_name}' not found in LiverTox dataset")
|
| 330 |
+
|
| 331 |
+
drug_info = matches.iloc[0]
|
| 332 |
+
|
| 333 |
+
response = {
|
| 334 |
+
"drug_name": drug_info.get('drug_name', 'N/A'),
|
| 335 |
+
"trade_names": drug_info.get('trade_names', 'N/A'),
|
| 336 |
+
"drug_class": drug_info.get('drug_class', 'N/A'),
|
| 337 |
+
"last_updated": drug_info.get('last_updated', 'N/A'),
|
| 338 |
+
"hepatotoxicity": drug_info.get('hepatotoxicity', 'N/A'),
|
| 339 |
+
"mechanism_of_injury": drug_info.get('mechanism_of_injury', 'N/A'),
|
| 340 |
+
"outcome_and_management": drug_info.get('outcome_and_management', 'N/A'),
|
| 341 |
+
"introduction": drug_info.get('introduction', 'N/A'),
|
| 342 |
+
"background": drug_info.get('background', 'N/A'),
|
| 343 |
+
"source": "LiverTox Dataset (cmcmaster/livertox)",
|
| 344 |
+
"total_matches": len(matches),
|
| 345 |
+
"query": drug_name
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
if pd.notna(drug_info.get('components')):
|
| 349 |
+
try:
|
| 350 |
+
components = drug_info.get('components')
|
| 351 |
+
if isinstance(components, str) and components.startswith('['):
|
| 352 |
+
import ast
|
| 353 |
+
components = ast.literal_eval(components)
|
| 354 |
+
response["components"] = components
|
| 355 |
+
except:
|
| 356 |
+
response["components"] = drug_info.get('components')
|
| 357 |
+
|
| 358 |
+
return response
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio
|
| 2 |
+
requests
|
| 3 |
+
datasets
|
| 4 |
+
beautifulsoup4
|
| 5 |
+
pandas
|
utils.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
import requests
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from functools import wraps
|
| 7 |
+
from typing import Dict, Any
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
def with_error_handling(func):
|
| 12 |
+
"""Decorator to add comprehensive error handling."""
|
| 13 |
+
@wraps(func)
|
| 14 |
+
def wrapper(*args, **kwargs):
|
| 15 |
+
start_time = time.time()
|
| 16 |
+
try:
|
| 17 |
+
safe_args = []
|
| 18 |
+
for i, arg in enumerate(args[:2]):
|
| 19 |
+
if isinstance(arg, (str, int, float, bool)):
|
| 20 |
+
safe_args.append(str(arg))
|
| 21 |
+
else:
|
| 22 |
+
safe_args.append(f"<{type(arg).__name__}>")
|
| 23 |
+
logger.info(f"Starting {func.__name__} with args: {safe_args}")
|
| 24 |
+
|
| 25 |
+
result = func(*args, **kwargs)
|
| 26 |
+
|
| 27 |
+
if isinstance(result, dict) and 'error' not in result:
|
| 28 |
+
result = standardize_response(result, func.__name__)
|
| 29 |
+
|
| 30 |
+
execution_time = time.time() - start_time
|
| 31 |
+
logger.info(f"Completed {func.__name__} in {execution_time:.2f}s")
|
| 32 |
+
return result
|
| 33 |
+
|
| 34 |
+
except requests.exceptions.Timeout:
|
| 35 |
+
logger.error(f"Timeout in {func.__name__}")
|
| 36 |
+
return create_error_response("Request timeout", func.__name__)
|
| 37 |
+
except requests.exceptions.ConnectionError:
|
| 38 |
+
logger.error(f"Connection error in {func.__name__}")
|
| 39 |
+
return create_error_response("Connection failed", func.__name__)
|
| 40 |
+
except requests.exceptions.RequestException as e:
|
| 41 |
+
logger.error(f"Request error in {func.__name__}: {e}")
|
| 42 |
+
return create_error_response(f"Request failed: {str(e)}", func.__name__)
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.error(f"Unexpected error in {func.__name__}: {e}")
|
| 45 |
+
return create_error_response(f"Unexpected error: {str(e)}", func.__name__)
|
| 46 |
+
|
| 47 |
+
return wrapper
|
| 48 |
+
|
| 49 |
+
def standardize_response(data: Dict[str, Any], source: str) -> Dict[str, Any]:
|
| 50 |
+
"""Standardize API response format with metadata."""
|
| 51 |
+
return {
|
| 52 |
+
"data": data,
|
| 53 |
+
"metadata": {
|
| 54 |
+
"source": source,
|
| 55 |
+
"timestamp": datetime.now().isoformat(),
|
| 56 |
+
"version": "1.1.0",
|
| 57 |
+
"cached": False
|
| 58 |
+
},
|
| 59 |
+
"status": "success"
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
def create_error_response(error_msg: str, source: str) -> Dict[str, Any]:
|
| 63 |
+
"""Create standardized error response."""
|
| 64 |
+
return {
|
| 65 |
+
"data": None,
|
| 66 |
+
"metadata": {
|
| 67 |
+
"source": source,
|
| 68 |
+
"timestamp": datetime.now().isoformat(),
|
| 69 |
+
"version": "1.1.0"
|
| 70 |
+
},
|
| 71 |
+
"status": "error",
|
| 72 |
+
"error": error_msg
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
def make_api_request(url: str, params: Dict[str, Any], timeout: int = 15, max_retries: int = 3) -> requests.Response:
|
| 76 |
+
"""Make API request with retry logic and rate limiting."""
|
| 77 |
+
for attempt in range(max_retries):
|
| 78 |
+
try:
|
| 79 |
+
time.sleep(0.1 * attempt)
|
| 80 |
+
response = requests.get(url, params=params, timeout=timeout)
|
| 81 |
+
if response.status_code == 429:
|
| 82 |
+
wait_time = 2 ** attempt
|
| 83 |
+
logger.warning(f"Rate limited, waiting {wait_time}s before retry {attempt + 1}")
|
| 84 |
+
time.sleep(wait_time)
|
| 85 |
+
continue
|
| 86 |
+
return response
|
| 87 |
+
except requests.exceptions.RequestException as e:
|
| 88 |
+
if attempt == max_retries - 1:
|
| 89 |
+
raise
|
| 90 |
+
logger.warning(f"Request attempt {attempt + 1} failed: {e}")
|
| 91 |
+
|
| 92 |
+
raise requests.exceptions.RequestException("Max retries exceeded")
|
| 93 |
+
|
| 94 |
+
def format_json_output(obj: Any) -> str:
|
| 95 |
+
"""
|
| 96 |
+
Formats a Python object as a pretty-printed JSON string.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
obj: The Python object to format.
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
A JSON string with an indent of 2 and UTF-8 characters preserved.
|
| 103 |
+
"""
|
| 104 |
+
return json.dumps(obj, indent=2, ensure_ascii=False)
|