Chris McMaster commited on
Commit
a04c9e9
·
0 Parent(s):

Initial commit

Browse files
Files changed (9) hide show
  1. app.py +641 -0
  2. brand_to_generic.py +365 -0
  3. caching.py +70 -0
  4. clinical_calculators.py +436 -0
  5. dbi_mcp.py +293 -0
  6. dbi_reference_by_route.csv +119 -0
  7. drug_data_endpoints.py +358 -0
  8. requirements.txt +5 -0
  9. 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)