KevinIsInCoding Claude Sonnet 4.6 commited on
Commit
6977736
·
0 Parent(s):

Initial release: Beacon rare disease clinical trial finder

Browse files

Conversational AI assistant that interviews patients, geocodes their location,
and searches ClinicalTrials.gov in real time to surface nearby recruiting trials.

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

Files changed (9) hide show
  1. .env.example +8 -0
  2. .gitignore +13 -0
  3. .python-version +1 -0
  4. README.md +99 -0
  5. clinical_trials_guru.py +454 -0
  6. llm.py +76 -0
  7. main.py +9 -0
  8. pyproject.toml +13 -0
  9. uv.lock +0 -0
.env.example ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # ⚠️ Copy this file to .env and replace with your actual API keys.
2
+ # Never commit .env or real API keys to version control.
3
+
4
+ OPENAI_API_KEY=your_openai_api_key_here
5
+ ANTHROPIC_API_KEY=your_anthropic_api_key_here
6
+
7
+ # Optional: switch LLM backend (default: anthropic)
8
+ # LLM_PROVIDER=openai
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Secrets
13
+ .env
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.11
README.md ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Beacon — Rare Disease Clinical Trial Finder
2
+
3
+ Beacon is a conversational AI assistant that helps patients with rare diseases find relevant recruiting clinical trials near them. It conducts a warm intake interview, geocodes the patient's location, queries [ClinicalTrials.gov](https://clinicaltrials.gov) in real time, and produces a ranked report of the closest matching trials.
4
+
5
+ ## How it works
6
+
7
+ 1. **Intake agent** (Claude Sonnet) — interviews the patient conversationally to collect disease, age, symptom onset, location, and optional benchmark scores.
8
+ 2. **Research agent** (Claude Opus) — searches ClinicalTrials.gov via the official v2 API, retrying with synonyms or wider radii if results are sparse, then outputs a ranked trial report with eligibility notes and next steps.
9
+ 3. **LangGraph** orchestrates the two-node pipeline (intake → research).
10
+
11
+ ## Project setup
12
+
13
+ ### Prerequisites
14
+
15
+ - Python 3.11+
16
+ - [`uv`](https://docs.astral.sh/uv/) (recommended) or `pip`
17
+
18
+ ### 1. Clone the repo
19
+
20
+ ```bash
21
+ git clone <repo-url>
22
+ cd beacon
23
+ ```
24
+
25
+ ### 2. Install dependencies
26
+
27
+ ```bash
28
+ uv sync
29
+ ```
30
+
31
+ Or with pip:
32
+
33
+ ```bash
34
+ pip install -e .
35
+ ```
36
+
37
+ ### 3. Configure API keys
38
+
39
+ Copy the example env file and fill in your keys:
40
+
41
+ ```bash
42
+ cp .env.example .env
43
+ ```
44
+
45
+ Open `.env` and replace the placeholder values:
46
+
47
+ ```env
48
+ # ⚠️ Replace with your actual API keys — never commit real keys to version control
49
+ OPENAI_API_KEY=your_openai_api_key_here
50
+ ANTHROPIC_API_KEY=your_anthropic_api_key_here
51
+ ```
52
+
53
+ - Get your Anthropic key at <https://console.anthropic.com>
54
+ - Get your OpenAI key at <https://platform.openai.com/api-keys>
55
+
56
+ ### 4. Run
57
+
58
+ ```bash
59
+ uv run python main.py
60
+ ```
61
+
62
+ Or if using a plain virtualenv:
63
+
64
+ ```bash
65
+ python main.py
66
+ ```
67
+
68
+ ## Configuration
69
+
70
+ | Environment variable | Default | Description |
71
+ |----------------------|-------------|--------------------------------------------------|
72
+ | `ANTHROPIC_API_KEY` | *(required)*| Anthropic API key |
73
+ | `OPENAI_API_KEY` | *(optional)*| OpenAI API key (only needed for OpenAI provider) |
74
+ | `LLM_PROVIDER` | `anthropic` | LLM backend: `anthropic` or `openai` |
75
+
76
+ To switch to the OpenAI backend, set `LLM_PROVIDER=openai` in `.env`.
77
+
78
+ ## Project structure
79
+
80
+ ```
81
+ beacon/
82
+ ├── main.py # Entry point
83
+ ├── clinical_trials_guru.py # Intake + research agents, LangGraph pipeline
84
+ ├── llm.py # LLM provider abstraction (Anthropic / OpenAI)
85
+ ├── pyproject.toml
86
+ ├── .env # Local secrets — not committed
87
+ └── .gitignore
88
+ ```
89
+
90
+ ## Dependencies
91
+
92
+ | Package | Purpose |
93
+ |------------------|--------------------------------------|
94
+ | `anthropic` | Claude API client |
95
+ | `openai` | OpenAI API client |
96
+ | `langgraph` | Agent pipeline orchestration |
97
+ | `rich` | Terminal UI (panels, markdown, etc.) |
98
+ | `python-dotenv` | `.env` file loading |
99
+ | `httpx` | HTTP client for ClinicalTrials.gov |
clinical_trials_guru.py ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional, TypedDict
8
+
9
+ import anthropic
10
+ import httpx
11
+ from rich import box
12
+ from rich.console import Console
13
+ from rich.markdown import Markdown
14
+ from rich.panel import Panel
15
+ from rich.text import Text
16
+ from langgraph.graph import StateGraph, START, END
17
+
18
+ CTGOV_BASE = "https://clinicaltrials.gov/api/v2/studies"
19
+ INTAKE_MODEL = "claude-sonnet-4-6"
20
+ RESEARCH_MODEL = "claude-opus-4-7"
21
+
22
+ console = Console()
23
+
24
+ # ── Tool schemas ──────────────────────────────────────────────────────────────
25
+
26
+ SUBMIT_PROFILE_TOOL: anthropic.types.ToolParam = {
27
+ "name": "submit_profile",
28
+ "description": (
29
+ "Call this when you have collected all required information. "
30
+ "Standardize the disease name to its full medical term."
31
+ ),
32
+ "input_schema": {
33
+ "type": "object",
34
+ "properties": {
35
+ "disease": {
36
+ "type": "string",
37
+ "description": "Full medical name (e.g. 'Amyotrophic Lateral Sclerosis')",
38
+ },
39
+ "age": {"type": "integer"},
40
+ "onset_months": {
41
+ "type": "integer",
42
+ "description": "Months since first symptom onset",
43
+ },
44
+ "benchmarks": {
45
+ "type": "object",
46
+ "description": "Disease-specific scores, e.g. {\"ALSFRS-R\": \"38\"}",
47
+ "additionalProperties": {"type": "string"},
48
+ },
49
+ "zip_code": {"type": "string", "description": "Patient ZIP / postal code"},
50
+ "country_code": {
51
+ "type": "string",
52
+ "description": "ISO 2-letter country code (default US)",
53
+ },
54
+ "radius_miles": {
55
+ "type": "integer",
56
+ "description": "Search radius in miles from patient location (default 100)",
57
+ },
58
+ "phases": {
59
+ "type": "array",
60
+ "items": {"type": "string", "enum": ["0", "1", "2", "3", "4"]},
61
+ "description": "Desired trial phases. Empty = all phases.",
62
+ },
63
+ },
64
+ "required": ["disease", "age", "onset_months", "zip_code"],
65
+ },
66
+ }
67
+
68
+ SEARCH_TRIALS_TOOL: anthropic.types.ToolParam = {
69
+ "name": "search_clinical_trials",
70
+ "description": (
71
+ "Search ClinicalTrials.gov for recruiting trials within a geographic radius. "
72
+ "Results are pre-ranked by distance from the patient's location. "
73
+ "Call multiple times with different parameters (synonyms, broader radius, "
74
+ "different phases) if initial results are sparse."
75
+ ),
76
+ "input_schema": {
77
+ "type": "object",
78
+ "properties": {
79
+ "condition": {
80
+ "type": "string",
81
+ "description": "Disease / condition to search (medical name and/or abbreviation)",
82
+ },
83
+ "lat": {"type": "number", "description": "Patient latitude"},
84
+ "lon": {"type": "number", "description": "Patient longitude"},
85
+ "radius_miles": {"type": "integer", "description": "Search radius in miles"},
86
+ "phases": {
87
+ "type": "array",
88
+ "items": {"type": "string"},
89
+ "description": "Phase numbers to filter ['1','2','3']. Empty = all.",
90
+ },
91
+ "max_results": {"type": "integer", "description": "Max trials to return (default 20)"},
92
+ },
93
+ "required": ["condition", "lat", "lon", "radius_miles"],
94
+ },
95
+ }
96
+
97
+ # ── System prompts ────────────────────────────────────────────────────────────
98
+
99
+ INTAKE_SYSTEM = """\
100
+ You are Beacon's patient intake specialist for rare disease clinical trials.
101
+ Collect the following through a warm, conversational interview — do NOT present a form.
102
+
103
+ REQUIRED:
104
+ • Disease/condition (standardize: "Lou Gehrig's" → "Amyotrophic Lateral Sclerosis")
105
+ • Patient age
106
+ • Months since first symptom onset (convert dates/years as needed)
107
+ • ZIP/postal code and country for geographic search
108
+
109
+ OPTIONAL (ask based on disease):
110
+ • Disease-specific benchmark scores:
111
+ ALS → ALSFRS-R (0-48); MS → EDSS (0-10); Parkinson's → MDS-UPDRS III;
112
+ Huntington's → TFC (0-13) + CAG repeats; SMA → HFMS + SMA type;
113
+ Duchenne/Pompe → 6-Minute Walk Test; Friedreich's → SARA score
114
+ • Preferred search radius in miles (default 100)
115
+ • Trial phases of interest (1 / 2 / 3 / 4 / early)
116
+
117
+ Ask naturally. Infer what you can. Once you have the required fields, call submit_profile.\
118
+ """
119
+
120
+ RESEARCH_SYSTEM = """\
121
+ You are Beacon, an expert rare-disease clinical trial navigator.
122
+ You have a search_clinical_trials tool that queries ClinicalTrials.gov in real time.
123
+ Results are already ranked by geographic distance from the patient.
124
+
125
+ Workflow:
126
+ 1. Search for the patient's disease. Use both the full medical name and common abbreviation.
127
+ 2. If fewer than 3 results are found, retry with: a wider radius, a disease synonym,
128
+ or fewer phase filters.
129
+ 3. Produce a final report listing the top 5 trials ranked by site proximity.
130
+ For EACH trial use exactly this format (repeat the block per trial):
131
+
132
+ 📍 **[Closest hospital name]** — [City, State] ([X] mi)
133
+ **Trial:** [Full trial title] ([Phase])
134
+ **Sponsor:** [Lead sponsor]
135
+ **Summary:** [2–3 sentence plain-language description of what the trial is testing
136
+ and why it may matter for this patient]
137
+ **Eligibility notes:** [Key inclusion/exclusion criteria relevant to this patient,
138
+ including any red flags]
139
+ **Link:** https://clinicaltrials.gov/study/[NCT_ID]
140
+
141
+ ---
142
+
143
+ 4. After the trial list add a short "Next steps" section (bullet points).
144
+
145
+ Be accurate. Do not fabricate details. If data is missing, say so.\
146
+ """
147
+
148
+ # ── Data model ────────────────────────────────────────────────────────────────
149
+
150
+ @dataclass
151
+ class PatientProfile:
152
+ disease: str
153
+ age: int
154
+ onset_months: int
155
+ benchmarks: dict[str, str] = field(default_factory=dict)
156
+ zip_code: str = ""
157
+ country_code: str = "US"
158
+ lat: float = 0.0
159
+ lon: float = 0.0
160
+ radius_miles: int = 100
161
+ phases: list[str] = field(default_factory=list)
162
+
163
+ def summary(self) -> str:
164
+ lines = [
165
+ f"Disease: {self.disease}",
166
+ f"Age: {self.age}",
167
+ f"Symptom onset: {self.onset_months} months ago",
168
+ ]
169
+ if self.benchmarks:
170
+ lines.append("Benchmarks: " + ", ".join(f"{k}={v}" for k, v in self.benchmarks.items()))
171
+ lines.append(
172
+ f"Location: ZIP {self.zip_code}, {self.country_code} "
173
+ f"(lat={self.lat:.4f}, lon={self.lon:.4f})"
174
+ )
175
+ lines.append(f"Search radius: {self.radius_miles} miles")
176
+ if self.phases:
177
+ labels = ["Early Phase 1" if p == "0" else f"Phase {p}" for p in self.phases]
178
+ lines.append(f"Phases: {', '.join(labels)}")
179
+ return "\n".join(lines)
180
+
181
+
182
+ # ── Geocoding ─────────────────────────────────────────────────────────────────
183
+
184
+ def geocode_zip(zip_code: str, country_code: str = "US") -> tuple[float, float]:
185
+ resp = httpx.get(
186
+ "https://nominatim.openstreetmap.org/search",
187
+ params={"postalcode": zip_code, "country": country_code, "format": "json", "limit": 1},
188
+ headers={"User-Agent": "Beacon-ClinicalTrialFinder/1.0"},
189
+ timeout=10,
190
+ )
191
+ resp.raise_for_status()
192
+ results = resp.json()
193
+ if not results:
194
+ raise ValueError(f"Cannot geocode ZIP {zip_code!r} in {country_code!r}")
195
+ return float(results[0]["lat"]), float(results[0]["lon"])
196
+
197
+
198
+ # ── Distance ──────────────────────────────────────────────────────────────────
199
+
200
+ def haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
201
+ R = 3958.8
202
+ φ1, φ2 = math.radians(lat1), math.radians(lat2)
203
+ dφ, dλ = math.radians(lat2 - lat1), math.radians(lon2 - lon1)
204
+ a = math.sin(dφ / 2) ** 2 + math.cos(φ1) * math.cos(φ2) * math.sin(dλ / 2) ** 2
205
+ return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
206
+
207
+
208
+ # ── ClinicalTrials.gov ────────────────────────────────────────────────────────
209
+
210
+ def search_trials_api(
211
+ condition: str,
212
+ lat: float,
213
+ lon: float,
214
+ radius_miles: int = 100,
215
+ phases: list[str] | None = None,
216
+ max_results: int = 20,
217
+ ) -> list[dict]:
218
+ params: dict[str, str | int] = {
219
+ "query.cond": condition,
220
+ "filter.overallStatus": "RECRUITING",
221
+ "filter.geo": f"distance({lat},{lon},{radius_miles}mi)",
222
+ "pageSize": max_results,
223
+ "format": "json",
224
+ }
225
+ if phases:
226
+ params["aggFilters"] = "phase:" + " ".join(phases)
227
+ for attempt in range(3):
228
+ try:
229
+ resp = httpx.get(CTGOV_BASE, params=params, timeout=30)
230
+ resp.raise_for_status()
231
+ return resp.json().get("studies", [])
232
+ except httpx.HTTPError as exc:
233
+ if attempt == 2:
234
+ raise
235
+ wait = 2 ** attempt
236
+ console.print(f"[yellow]API warning:[/yellow] {exc} — retrying in {wait}s (attempt {attempt + 1}/3)…")
237
+ time.sleep(wait)
238
+
239
+
240
+ def _flatten_and_rank(studies: list[dict], patient_lat: float, patient_lon: float) -> list[dict]:
241
+ result = []
242
+ for study in studies:
243
+ proto = study.get("protocolSection", {})
244
+ id_mod = proto.get("identificationModule", {})
245
+ desc_mod = proto.get("descriptionModule", {})
246
+ elig_mod = proto.get("eligibilityModule", {})
247
+ contacts_mod = proto.get("contactsLocationsModule", {})
248
+ sponsor_mod = proto.get("sponsorCollaboratorsModule", {})
249
+ design_mod = proto.get("designModule", {})
250
+
251
+ sites_with_dist: list[tuple[float, str]] = []
252
+ for loc in contacts_mod.get("locations", []):
253
+ geo = loc.get("geoPoint", {})
254
+ if geo.get("lat") and geo.get("lon"):
255
+ d = haversine_miles(patient_lat, patient_lon, geo["lat"], geo["lon"])
256
+ label = (
257
+ f"{loc.get('facility', '').strip()} — "
258
+ f"{loc.get('city', '')}, "
259
+ f"{loc.get('state', loc.get('country', ''))} "
260
+ f"({d:.0f} mi)"
261
+ )
262
+ sites_with_dist.append((d, label))
263
+ sites_with_dist.sort(key=lambda x: x[0])
264
+
265
+ closest_dist = sites_with_dist[0][0] if sites_with_dist else None
266
+ result.append({
267
+ "nct_id": id_mod.get("nctId", ""),
268
+ "title": id_mod.get("briefTitle", ""),
269
+ "phase": ", ".join(design_mod.get("phases", [])) or "N/A",
270
+ "sponsor": sponsor_mod.get("leadSponsor", {}).get("name", ""),
271
+ "summary": desc_mod.get("briefSummary", "")[:500],
272
+ "eligibility": elig_mod.get("eligibilityCriteria", "")[:1000],
273
+ "min_age": elig_mod.get("minimumAge", ""),
274
+ "max_age": elig_mod.get("maximumAge", ""),
275
+ "closest_site_miles": round(closest_dist, 1) if closest_dist is not None else None,
276
+ "nearest_sites": [label for _, label in sites_with_dist[:5]],
277
+ })
278
+
279
+ result.sort(key=lambda x: x["closest_site_miles"] if x["closest_site_miles"] is not None else float("inf"))
280
+ return result
281
+
282
+
283
+ # ── Intake agent ──────────────────────────────────────────────────────────────
284
+
285
+ def run_intake_agent(client: anthropic.Anthropic) -> PatientProfile:
286
+ import datetime
287
+ today = datetime.date.today().strftime("%B %d, %Y")
288
+
289
+ console.print()
290
+ console.print(Panel(
291
+ Text("Beacon — Rare Disease Clinical Trial Finder", justify="center", style="bold cyan"),
292
+ border_style="cyan",
293
+ padding=(1, 4),
294
+ ))
295
+
296
+ messages: list[anthropic.types.MessageParam] = [
297
+ {"role": "user", "content": "Please begin."}
298
+ ]
299
+
300
+ while True:
301
+ response = client.messages.create(
302
+ model=INTAKE_MODEL,
303
+ max_tokens=1024,
304
+ system=f"Today's date is {today}.\n\n" + INTAKE_SYSTEM,
305
+ tools=[SUBMIT_PROFILE_TOOL],
306
+ messages=messages,
307
+ )
308
+
309
+ text = next((b.text for b in response.content if b.type == "text"), "")
310
+ if text:
311
+ console.print(f"\n[bold cyan]Beacon:[/bold cyan] {text}")
312
+
313
+ tool_block = next(
314
+ (b for b in response.content if b.type == "tool_use" and b.name == "submit_profile"),
315
+ None,
316
+ )
317
+ if tool_block:
318
+ data = tool_block.input
319
+ try:
320
+ with console.status("[cyan]Geocoding location…[/cyan]", spinner="dots"):
321
+ lat, lon = geocode_zip(data["zip_code"], data.get("country_code", "US"))
322
+ except Exception as exc:
323
+ console.print(f"[yellow]Warning:[/yellow] Geocoding failed ({exc}) — coordinates set to 0,0.")
324
+ lat, lon = 0.0, 0.0
325
+ return PatientProfile(
326
+ disease=data["disease"],
327
+ age=data["age"],
328
+ onset_months=data["onset_months"],
329
+ benchmarks=data.get("benchmarks") or {},
330
+ zip_code=data["zip_code"],
331
+ country_code=data.get("country_code", "US"),
332
+ lat=lat,
333
+ lon=lon,
334
+ radius_miles=data.get("radius_miles", 100),
335
+ phases=data.get("phases") or [],
336
+ )
337
+
338
+ messages.append({"role": "assistant", "content": response.content})
339
+ user_input = input("\nYou: ").strip() or "(no response)"
340
+ messages.append({"role": "user", "content": user_input})
341
+
342
+
343
+ # ── Research agent ────────────────────────────────────────────────────────────
344
+
345
+ def run_research_agent(client: anthropic.Anthropic, profile: PatientProfile) -> str:
346
+ messages: list[anthropic.types.MessageParam] = [
347
+ {
348
+ "role": "user",
349
+ "content": (
350
+ f"Find clinical trials for this patient:\n\n{profile.summary()}\n\n"
351
+ "Search within the specified radius and rank results by distance."
352
+ ),
353
+ }
354
+ ]
355
+
356
+ while True:
357
+ response = client.messages.create(
358
+ model=RESEARCH_MODEL,
359
+ max_tokens=8096,
360
+ system=RESEARCH_SYSTEM,
361
+ tools=[SEARCH_TRIALS_TOOL],
362
+ messages=messages,
363
+ )
364
+
365
+ messages.append({"role": "assistant", "content": response.content})
366
+
367
+ if response.stop_reason == "end_turn":
368
+ return next(
369
+ (b.text for b in response.content if b.type == "text"),
370
+ "No analysis produced.",
371
+ )
372
+
373
+ tool_results: list[anthropic.types.ToolResultBlockParam] = []
374
+ for block in response.content:
375
+ if block.type != "tool_use" or block.name != "search_clinical_trials":
376
+ continue
377
+ args = block.input
378
+ radius = args.get("radius_miles", profile.radius_miles)
379
+ phases = args.get("phases") or None
380
+ status_msg = (
381
+ f"[cyan]Searching:[/cyan] '[bold]{args['condition']}[/bold]' | "
382
+ f"radius=[bold]{radius}[/bold] mi | "
383
+ f"phases=[bold]{phases or 'all'}[/bold]"
384
+ )
385
+ try:
386
+ with console.status(status_msg, spinner="dots"):
387
+ studies = search_trials_api(
388
+ condition=args["condition"],
389
+ lat=args["lat"],
390
+ lon=args["lon"],
391
+ radius_miles=radius,
392
+ phases=phases,
393
+ max_results=args.get("max_results", 20),
394
+ )
395
+ ranked = _flatten_and_rank(studies, profile.lat, profile.lon)
396
+ console.print(f" [green]✓[/green] {len(ranked)} trial(s) found.")
397
+ content = json.dumps(ranked)
398
+ is_error = False
399
+ except Exception as exc:
400
+ console.print(f"[red bold]API error:[/red bold] {exc}")
401
+ content = f"API request failed: {exc}. The ClinicalTrials.gov endpoint may be temporarily unavailable."
402
+ is_error = True
403
+ tool_results.append({
404
+ "type": "tool_result",
405
+ "tool_use_id": block.id,
406
+ "content": content,
407
+ "is_error": is_error,
408
+ })
409
+
410
+ messages.append({"role": "user", "content": tool_results})
411
+
412
+
413
+ # ── LangGraph ─────────────────────────────────────────────────────────────────
414
+
415
+ class BeaconState(TypedDict):
416
+ profile: Optional[PatientProfile]
417
+ analysis: str
418
+
419
+
420
+ def _build_graph(client: anthropic.Anthropic):
421
+ def intake_node(_state: BeaconState) -> BeaconState:
422
+ return {"profile": run_intake_agent(client), "analysis": ""}
423
+
424
+ def research_node(state: BeaconState) -> BeaconState:
425
+ analysis = run_research_agent(client, state["profile"])
426
+ console.print()
427
+ console.print(Panel(
428
+ Markdown(analysis),
429
+ title="[bold cyan]BEACON ANALYSIS[/bold cyan]",
430
+ border_style="cyan",
431
+ box=box.DOUBLE_EDGE,
432
+ padding=(1, 2),
433
+ ))
434
+ console.print()
435
+ return {"analysis": analysis}
436
+
437
+ graph = StateGraph(BeaconState)
438
+ graph.add_node("intake", intake_node)
439
+ graph.add_node("research", research_node)
440
+ graph.add_edge(START, "intake")
441
+ graph.add_edge("intake", "research")
442
+ graph.add_edge("research", END)
443
+ return graph.compile()
444
+
445
+
446
+ def guru_main(_provider=None): # _provider kept for backward-compat, unused
447
+ client = anthropic.Anthropic()
448
+ graph = _build_graph(client)
449
+ try:
450
+ graph.invoke({"profile": None, "analysis": ""})
451
+ except (KeyboardInterrupt, EOFError):
452
+ console.print("\n[dim]Goodbye.[/dim]")
453
+ return
454
+ console.print("\n[bold]Goodbye.[/bold] Beacon wishes the patient the best on their journey.")
llm.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from abc import ABC, abstractmethod
3
+
4
+ import anthropic
5
+ import openai
6
+
7
+
8
+ def _normalize_messages(messages: list) -> list[dict]:
9
+ """Convert LangGraph/LangChain message objects or dicts to {role, content} dicts."""
10
+ result = []
11
+ for msg in messages:
12
+ if hasattr(msg, "type"):
13
+ raw_role, content = msg.type, msg.content
14
+ else:
15
+ raw_role, content = msg.get("role", "user"), msg.get("content", "")
16
+
17
+ if raw_role in ("human", "user"):
18
+ role = "user"
19
+ elif raw_role in ("ai", "assistant"):
20
+ role = "assistant"
21
+ else:
22
+ continue
23
+
24
+ result.append({"role": role, "content": content})
25
+ return result
26
+
27
+
28
+ class LLMProvider(ABC):
29
+ @abstractmethod
30
+ def complete(self, messages: list, system: str = "") -> str: ...
31
+
32
+
33
+ class AnthropicProvider(LLMProvider):
34
+ MODEL = "claude-opus-4-7"
35
+
36
+ def __init__(self):
37
+ self._client = anthropic.Anthropic()
38
+
39
+ def complete(self, messages: list, system: str = "") -> str:
40
+ kwargs = dict(
41
+ model=self.MODEL,
42
+ max_tokens=16000,
43
+ thinking={"type": "adaptive"},
44
+ messages=_normalize_messages(messages),
45
+ )
46
+ if system:
47
+ kwargs["system"] = system
48
+ with self._client.messages.stream(**kwargs) as stream:
49
+ response = stream.get_final_message()
50
+ return next((b.text for b in response.content if b.type == "text"), "")
51
+
52
+
53
+ class OpenAIProvider(LLMProvider):
54
+ MODEL = "gpt-4o"
55
+
56
+ def __init__(self):
57
+ self._client = openai.OpenAI()
58
+
59
+ def complete(self, messages: list, system: str = "") -> str:
60
+ normalized = _normalize_messages(messages)
61
+ if system:
62
+ normalized = [{"role": "system", "content": system}] + normalized
63
+ response = self._client.chat.completions.create(
64
+ model=self.MODEL,
65
+ messages=normalized,
66
+ )
67
+ return response.choices[0].message.content or ""
68
+
69
+
70
+ def get_provider(name: str | None = None) -> LLMProvider:
71
+ name = name or os.getenv("LLM_PROVIDER", "anthropic")
72
+ if name == "openai":
73
+ return OpenAIProvider()
74
+ if name == "anthropic":
75
+ return AnthropicProvider()
76
+ raise ValueError(f"Unknown LLM provider: {name!r}. Choose 'anthropic' or 'openai'.")
main.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from dotenv import load_dotenv
2
+
3
+ from clinical_trials_guru import guru_main
4
+ from llm import get_provider
5
+
6
+ load_dotenv()
7
+
8
+ if __name__ == "__main__":
9
+ guru_main(get_provider("anthropic"))
pyproject.toml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "beacon"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "anthropic>=0.50.0",
9
+ "langgraph>=1.2.0",
10
+ "openai>=1.0.0",
11
+ "python-dotenv>=1.2.2",
12
+ "rich>=13.0.0",
13
+ ]
uv.lock ADDED
The diff for this file is too large to render. See raw diff