Spaces:
Sleeping
Sleeping
KevinIsInCoding Claude Sonnet 4.6 commited on
Commit ·
6977736
0
Parent(s):
Initial release: Beacon rare disease clinical trial finder
Browse filesConversational 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>
- .env.example +8 -0
- .gitignore +13 -0
- .python-version +1 -0
- README.md +99 -0
- clinical_trials_guru.py +454 -0
- llm.py +76 -0
- main.py +9 -0
- pyproject.toml +13 -0
- 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
|
|
|