Spaces:
Sleeping
Sleeping
File size: 7,733 Bytes
22195c1 e15864e 22195c1 e15864e 22195c1 e15864e 22195c1 e15864e 22195c1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | from __future__ import annotations
import time
import httpx
from beacon_logging import get_logger
from config import CTGOV_BASE
from models import haversine_miles
from _console import console
_logger = get_logger("trials_api")
def search_trials_api(
condition: str,
lat: float,
lon: float,
radius_miles: int = 100,
phases: list[str] | None = None,
study_type: str = "INTERVENTIONAL",
) -> list[dict]:
is_eap = study_type == "EXPANDED_ACCESS"
is_observational = study_type == "OBSERVATIONAL"
params: dict[str, str | int] = {
"query.cond": condition,
"filter.overallStatus": "AVAILABLE" if is_eap else "RECRUITING",
"filter.geo": f"distance({lat},{lon},{radius_miles}mi)",
"pageSize": 1000,
"format": "json",
}
# aggFilters supports comma-separated keys (e.g. "studyType:exp,phase:3 4").
# RECRUITING status already excludes EAPs, so studyType:int is only needed
# when no phase filter is applied. studyType:int returns all phases including N/A.
# Observational studies use studyType:obs; phases don't apply to them.
#
# Case matrix:
# EAP only β studyType:exp
# EAP + specific phases β studyType:exp,phase:X Y (combine both filters)
# EAP + all phases β studyType:exp (no phase filter needed)
# Interventional, specific β phase:X Y
# Interventional, all/NA β studyType:int (returns NA trials too)
if is_eap:
numbered = [p for p in (phases or []) if p != "na"]
if numbered:
params["aggFilters"] = "studyType:exp,phase:" + " ".join(numbered)
else:
params["aggFilters"] = "studyType:exp"
elif is_observational:
params["aggFilters"] = "studyType:obs"
elif phases:
# Exclude "na" from the phase filter β N/A trials have no phase value to match on;
# they appear naturally when no phase filter is applied (studyType:int branch).
numbered = [p for p in phases if p != "na"]
if numbered:
params["aggFilters"] = "phase:" + " ".join(numbered)
else:
params["aggFilters"] = "studyType:int"
else:
params["aggFilters"] = "studyType:int"
_logger.info(
"ClinicalTrials.gov API request",
extra={"data": {"endpoint": CTGOV_BASE, "params": dict(params)}},
)
all_studies: list[dict] = []
while True:
for attempt in range(3):
try:
resp = httpx.get(CTGOV_BASE, params=params, timeout=30)
resp.raise_for_status()
body = resp.json()
break
except httpx.HTTPError as exc:
if attempt == 2:
raise
wait = 2 ** attempt
console.print(f"[yellow]API warning:[/yellow] {exc} β retrying in {wait}s (attempt {attempt + 1}/3)β¦")
time.sleep(wait)
page_studies = body.get("studies", [])
all_studies.extend(page_studies)
next_token = body.get("nextPageToken")
_logger.debug(
"ClinicalTrials.gov API page received",
extra={"data": {"page_count": len(page_studies), "has_next_page": bool(next_token)}},
)
if not next_token:
break
params["pageToken"] = next_token
_logger.info(
"ClinicalTrials.gov API response complete",
extra={"data": {"total_studies": len(all_studies)}},
)
return all_studies
def _flatten_and_rank(studies: list[dict], patient_lat: float, patient_lon: float) -> list[dict]:
result = []
for study in studies:
proto = study.get("protocolSection", {})
id_mod = proto.get("identificationModule", {})
desc_mod = proto.get("descriptionModule", {})
elig_mod = proto.get("eligibilityModule", {})
contacts_mod = proto.get("contactsLocationsModule", {})
sponsor_mod = proto.get("sponsorCollaboratorsModule", {})
design_mod = proto.get("designModule", {})
conditions_mod = proto.get("conditionsModule", {})
arms_mod = proto.get("armsInterventionsModule", {})
central_contacts = contacts_mod.get("centralContacts", [])
central_phone = next((c.get("phone", "") for c in central_contacts if c.get("phone")), "")
central_email = next((c.get("email", "") for c in central_contacts if c.get("email")), "")
officials = contacts_mod.get("overallOfficials", [])
pi = next(
(o.get("name", "") for o in officials if o.get("role") == "PRINCIPAL_INVESTIGATOR"),
officials[0].get("name", "") if officials else "",
)
sites_with_dist: list[tuple[float, dict]] = []
for loc in contacts_mod.get("locations", []):
geo = loc.get("geoPoint", {})
if geo.get("lat") and geo.get("lon"):
d = haversine_miles(patient_lat, patient_lon, geo["lat"], geo["lon"])
loc_contacts = loc.get("contacts", [])
loc_phone = next((c.get("phone", "") for c in loc_contacts if c.get("phone")), "")
loc_email = next((c.get("email", "") for c in loc_contacts if c.get("email")), "")
sites_with_dist.append((d, {
"label": (
f"{loc.get('facility', '').strip()} β "
f"{loc.get('city', '')}, "
f"{loc.get('state', loc.get('country', ''))} "
f"({d:.0f} mi)"
),
"facility": loc.get("facility", "").strip(),
"city": loc.get("city", ""),
"state": loc.get("state", loc.get("country", "")),
"distance_miles": round(d, 1),
"phone": loc_phone or central_phone,
"email": loc_email or central_email,
}))
sites_with_dist.sort(key=lambda x: x[0])
closest_dist = sites_with_dist[0][0] if sites_with_dist else None
result.append({
"nct_id": id_mod.get("nctId", ""),
"title": id_mod.get("briefTitle", ""),
"phase": ", ".join(design_mod.get("phases", [])) or "N/A",
"sponsor": sponsor_mod.get("leadSponsor", {}).get("name", ""),
"principal_investigator": pi,
"contact_phone": central_phone,
"contact_email": central_email,
"summary": desc_mod.get("briefSummary", ""),
"eligibility": elig_mod.get("eligibilityCriteria", ""),
"min_age": elig_mod.get("minimumAge", ""),
"max_age": elig_mod.get("maximumAge", ""),
"sex": elig_mod.get("sex", "ALL"),
"healthy_volunteers": elig_mod.get("healthyVolunteers", ""),
"std_ages": elig_mod.get("stdAges", []),
"study_type": design_mod.get("studyType", ""),
"enrollment": design_mod.get("enrollmentInfo", {}).get("count"),
"conditions": conditions_mod.get("conditions", []),
"keywords": conditions_mod.get("keywords", []),
"interventions": [
{
"type": iv.get("type", ""),
"name": iv.get("name", ""),
"description": iv.get("description", ""),
}
for iv in arms_mod.get("interventions", [])
],
"closest_site_miles": round(closest_dist, 1) if closest_dist is not None else None,
"nearest_sites": [info for _, info in sites_with_dist[:5]],
})
result.sort(key=lambda x: x["closest_site_miles"] if x["closest_site_miles"] is not None else float("inf"))
return result
|