KevinIsInCoding Claude Sonnet 4.6 commited on
Commit
450de16
Β·
1 Parent(s): 2361439

Improve ALS intake fields, result format, and fix ClinicalTrials.gov API

Browse files

- Add FVC % predicted and ALS subtype (sporadic/familial + gene mutation)
to intake for ALS patients, both stored in benchmarks
- Fix ZIP code skipping: narrow "infer what you can" so the model always
asks the patient explicitly for their postal code
- Fix 400 Bad Request on every API call: replace invalid filter.studyType
with aggFilters=studyType:int/exp; phase and studyType filters are now
applied correctly for interventional trials and EAPs
- Extract PI name and contact phone/email from ClinicalTrials.gov API
response (centralContacts, overallOfficials, per-site contacts)
- Update result format: add Principal Investigator and Contact fields,
expand Eligibility notes β†’ Qualification criteria with explicit numbers
- Prohibit hallucinated fallback center lists when no API results are found

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. clinical_trials_guru.py +67 -16
clinical_trials_guru.py CHANGED
@@ -123,7 +123,19 @@ REQUIRED:
123
 
124
  OPTIONAL (ask based on disease):
125
  β€’ Disease-specific benchmark scores:
126
- ALS β†’ ALSFRS-R (0-48); MS β†’ EDSS (0-10); Parkinson's β†’ MDS-UPDRS III;
 
 
 
 
 
 
 
 
 
 
 
 
127
  Huntington's β†’ TFC (0-13) + CAG repeats; SMA β†’ HFMS + SMA type;
128
  Duchenne/Pompe β†’ 6-Minute Walk Test; Friedreich's β†’ SARA score
129
  β€’ Preferred search radius in miles (default 100)
@@ -147,7 +159,7 @@ OPTIONAL (ask based on disease):
147
  but may be an option when no approved treatments remain.
148
  - Or both; or all phases (default if no preference)
149
 
150
- Ask naturally. Infer what you can. Once you have the required fields, call submit_profile.\
151
  """
152
 
153
  RESEARCH_SYSTEM = """\
@@ -166,13 +178,16 @@ Workflow:
166
  List the top 5 results per section ranked by site proximity.
167
  For EACH entry use exactly this format (repeat the block per entry):
168
 
169
- πŸ“ **[Closest hospital name]** β€” [City, State] ([X] mi)
170
  **Trial:** [Full title] ([Phase] β€” or "Expanded Access" for EAP)
171
  **Sponsor:** [Lead sponsor]
 
 
172
  **Summary:** [2–3 sentence plain-language description of what the trial/program is testing
173
  and why it may matter for this patient]
174
- **Eligibility notes:** [Key inclusion/exclusion criteria relevant to this patient,
175
- including any red flags]
 
176
  **Link:** https://clinicaltrials.gov/study/[NCT_ID]
177
 
178
  ---
@@ -180,6 +195,11 @@ Workflow:
180
  4. After the results add a short "Next steps" section (bullet points).
181
  For EAP results, note that patients typically need a physician to submit the EAP request.
182
 
 
 
 
 
 
183
  Be accurate. Do not fabricate details. If data is missing, say so.\
184
  """
185
 
@@ -265,13 +285,19 @@ def search_trials_api(
265
  params: dict[str, str | int] = {
266
  "query.cond": condition,
267
  "filter.overallStatus": "AVAILABLE" if is_eap else "RECRUITING",
268
- "filter.studyType": study_type,
269
  "filter.geo": f"distance({lat},{lon},{radius_miles}mi)",
270
  "pageSize": max_results,
271
  "format": "json",
272
  }
273
- if phases and not is_eap:
 
 
 
 
 
274
  params["aggFilters"] = "phase:" + " ".join(phases)
 
 
275
  for attempt in range(3):
276
  try:
277
  resp = httpx.get(CTGOV_BASE, params=params, timeout=30)
@@ -296,18 +322,40 @@ def _flatten_and_rank(studies: list[dict], patient_lat: float, patient_lon: floa
296
  sponsor_mod = proto.get("sponsorCollaboratorsModule", {})
297
  design_mod = proto.get("designModule", {})
298
 
299
- sites_with_dist: list[tuple[float, str]] = []
 
 
 
 
 
 
 
 
 
 
 
 
300
  for loc in contacts_mod.get("locations", []):
301
  geo = loc.get("geoPoint", {})
302
  if geo.get("lat") and geo.get("lon"):
303
  d = haversine_miles(patient_lat, patient_lon, geo["lat"], geo["lon"])
304
- label = (
305
- f"{loc.get('facility', '').strip()} β€” "
306
- f"{loc.get('city', '')}, "
307
- f"{loc.get('state', loc.get('country', ''))} "
308
- f"({d:.0f} mi)"
309
- )
310
- sites_with_dist.append((d, label))
 
 
 
 
 
 
 
 
 
 
311
  sites_with_dist.sort(key=lambda x: x[0])
312
 
313
  closest_dist = sites_with_dist[0][0] if sites_with_dist else None
@@ -316,12 +364,15 @@ def _flatten_and_rank(studies: list[dict], patient_lat: float, patient_lon: floa
316
  "title": id_mod.get("briefTitle", ""),
317
  "phase": ", ".join(design_mod.get("phases", [])) or "N/A",
318
  "sponsor": sponsor_mod.get("leadSponsor", {}).get("name", ""),
 
 
 
319
  "summary": desc_mod.get("briefSummary", "")[:500],
320
  "eligibility": elig_mod.get("eligibilityCriteria", "")[:1000],
321
  "min_age": elig_mod.get("minimumAge", ""),
322
  "max_age": elig_mod.get("maximumAge", ""),
323
  "closest_site_miles": round(closest_dist, 1) if closest_dist is not None else None,
324
- "nearest_sites": [label for _, label in sites_with_dist[:5]],
325
  })
326
 
327
  result.sort(key=lambda x: x["closest_site_miles"] if x["closest_site_miles"] is not None else float("inf"))
 
123
 
124
  OPTIONAL (ask based on disease):
125
  β€’ Disease-specific benchmark scores:
126
+ ALS β†’ ALSFRS-R (0-48) + FVC % predicted (0-100%) + ALS subtype;
127
+ FVC (Forced Vital Capacity) measures how much air a person can forcibly exhale β€”
128
+ it reflects respiratory muscle strength. In ALS it is expressed as a percentage
129
+ of the value expected for someone of the same age/height/sex (e.g. "72%").
130
+ Many trials require FVC β‰₯ 50% or β‰₯ 60% for enrollment. If the patient has had
131
+ recent pulmonary function testing, ask for their FVC % predicted.
132
+ ALS subtype: ask whether the patient has sporadic ALS (no family history, ~90–95%
133
+ of cases) or familial/genetic ALS (inherited; ~5–10% of cases). If familial, ask
134
+ which gene mutation is involved if they know it (common ones: SOD1, C9orf72, FUS,
135
+ TDP-43). This affects trial eligibility β€” many gene-targeted trials require a
136
+ confirmed mutation. The patient may skip if unknown. Store as e.g.
137
+ {"ALS subtype": "sporadic"} or {"ALS subtype": "familial", "ALS gene": "SOD1"}.
138
+ MS β†’ EDSS (0-10); Parkinson's β†’ MDS-UPDRS III;
139
  Huntington's β†’ TFC (0-13) + CAG repeats; SMA β†’ HFMS + SMA type;
140
  Duchenne/Pompe β†’ 6-Minute Walk Test; Friedreich's β†’ SARA score
141
  β€’ Preferred search radius in miles (default 100)
 
159
  but may be an option when no approved treatments remain.
160
  - Or both; or all phases (default if no preference)
161
 
162
+ Ask naturally. You may infer disease synonyms and convert dates to months, but never infer or skip the ZIP/postal code β€” always ask the patient for it directly. Once you have every required field confirmed by the patient, call submit_profile.\
163
  """
164
 
165
  RESEARCH_SYSTEM = """\
 
178
  List the top 5 results per section ranked by site proximity.
179
  For EACH entry use exactly this format (repeat the block per entry):
180
 
181
+ πŸ“ **[Closest hospital/facility name]** β€” [City, State] ([X] mi)
182
  **Trial:** [Full title] ([Phase] β€” or "Expanded Access" for EAP)
183
  **Sponsor:** [Lead sponsor]
184
+ **Principal Investigator:** [Name β€” or "Not listed" if absent]
185
+ **Contact:** [Phone number] | [Email address] (use "Not listed" for any missing field)
186
  **Summary:** [2–3 sentence plain-language description of what the trial/program is testing
187
  and why it may matter for this patient]
188
+ **Qualification criteria:** [Key inclusion AND exclusion criteria relevant to this patient,
189
+ including age range, functional score thresholds, FVC cutoffs,
190
+ and any red flags. Be specific β€” use exact numbers from the data.]
191
  **Link:** https://clinicaltrials.gov/study/[NCT_ID]
192
 
193
  ---
 
195
  4. After the results add a short "Next steps" section (bullet points).
196
  For EAP results, note that patients typically need a physician to submit the EAP request.
197
 
198
+ IMPORTANT: Only report trials returned by the search_clinical_trials tool. Do NOT suggest,
199
+ list, or recommend any hospitals, centers, or trials that were not in the tool results β€”
200
+ even well-known institutions. If no results are found, say so clearly and suggest the patient
201
+ ask their neurologist or contact the ALS Association for a referral.
202
+
203
  Be accurate. Do not fabricate details. If data is missing, say so.\
204
  """
205
 
 
285
  params: dict[str, str | int] = {
286
  "query.cond": condition,
287
  "filter.overallStatus": "AVAILABLE" if is_eap else "RECRUITING",
 
288
  "filter.geo": f"distance({lat},{lon},{radius_miles}mi)",
289
  "pageSize": max_results,
290
  "format": "json",
291
  }
292
+ # aggFilters accepts only one value; studyType and phase can't be combined.
293
+ # RECRUITING status already excludes EAPs, so studyType:int is only needed
294
+ # when no phase filter is applied.
295
+ if is_eap:
296
+ params["aggFilters"] = "studyType:exp"
297
+ elif phases:
298
  params["aggFilters"] = "phase:" + " ".join(phases)
299
+ else:
300
+ params["aggFilters"] = "studyType:int"
301
  for attempt in range(3):
302
  try:
303
  resp = httpx.get(CTGOV_BASE, params=params, timeout=30)
 
322
  sponsor_mod = proto.get("sponsorCollaboratorsModule", {})
323
  design_mod = proto.get("designModule", {})
324
 
325
+ # Central (overall) contacts
326
+ central_contacts = contacts_mod.get("centralContacts", [])
327
+ central_phone = next((c.get("phone", "") for c in central_contacts if c.get("phone")), "")
328
+ central_email = next((c.get("email", "") for c in central_contacts if c.get("email")), "")
329
+
330
+ # Principal investigator from overallOfficials
331
+ officials = contacts_mod.get("overallOfficials", [])
332
+ pi = next(
333
+ (o.get("name", "") for o in officials if o.get("role") == "PRINCIPAL_INVESTIGATOR"),
334
+ officials[0].get("name", "") if officials else "",
335
+ )
336
+
337
+ sites_with_dist: list[tuple[float, dict]] = []
338
  for loc in contacts_mod.get("locations", []):
339
  geo = loc.get("geoPoint", {})
340
  if geo.get("lat") and geo.get("lon"):
341
  d = haversine_miles(patient_lat, patient_lon, geo["lat"], geo["lon"])
342
+ loc_contacts = loc.get("contacts", [])
343
+ loc_phone = next((c.get("phone", "") for c in loc_contacts if c.get("phone")), "")
344
+ loc_email = next((c.get("email", "") for c in loc_contacts if c.get("email")), "")
345
+ sites_with_dist.append((d, {
346
+ "label": (
347
+ f"{loc.get('facility', '').strip()} β€” "
348
+ f"{loc.get('city', '')}, "
349
+ f"{loc.get('state', loc.get('country', ''))} "
350
+ f"({d:.0f} mi)"
351
+ ),
352
+ "facility": loc.get("facility", "").strip(),
353
+ "city": loc.get("city", ""),
354
+ "state": loc.get("state", loc.get("country", "")),
355
+ "distance_miles": round(d, 1),
356
+ "phone": loc_phone or central_phone,
357
+ "email": loc_email or central_email,
358
+ }))
359
  sites_with_dist.sort(key=lambda x: x[0])
360
 
361
  closest_dist = sites_with_dist[0][0] if sites_with_dist else None
 
364
  "title": id_mod.get("briefTitle", ""),
365
  "phase": ", ".join(design_mod.get("phases", [])) or "N/A",
366
  "sponsor": sponsor_mod.get("leadSponsor", {}).get("name", ""),
367
+ "principal_investigator": pi,
368
+ "contact_phone": central_phone,
369
+ "contact_email": central_email,
370
  "summary": desc_mod.get("briefSummary", "")[:500],
371
  "eligibility": elig_mod.get("eligibilityCriteria", "")[:1000],
372
  "min_age": elig_mod.get("minimumAge", ""),
373
  "max_age": elig_mod.get("maximumAge", ""),
374
  "closest_site_miles": round(closest_dist, 1) if closest_dist is not None else None,
375
+ "nearest_sites": [info for _, info in sites_with_dist[:5]],
376
  })
377
 
378
  result.sort(key=lambda x: x["closest_site_miles"] if x["closest_site_miles"] is not None else float("inf"))