Spaces:
Sleeping
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>
- clinical_trials_guru.py +67 -16
|
@@ -123,7 +123,19 @@ REQUIRED:
|
|
| 123 |
|
| 124 |
OPTIONAL (ask based on disease):
|
| 125 |
β’ Disease-specific benchmark scores:
|
| 126 |
-
ALS β ALSFRS-R (0-48)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
**
|
| 175 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 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"))
|