Spaces:
Sleeping
feat: SRP refactor, streaming UX fix, and semantic disease detection (#12)
Browse files* feat: SRP refactor, streaming UX fix, and semantic disease detection
- Split 652-line clinical_trials_guru.py monolith into focused modules:
config, models, tools, prompts, trials_api, _console, agents/intake,
agents/research. clinical_trials_guru.py now ~60 lines of re-exports.
- Move tool schemas and disease benchmark profiles to JSON files under
data/; adding a new disease requires only a new JSON file, no code change.
- Fix Gradio 6.x input-locking bug by switching all intake/research turns
to Anthropic streaming API β inputs unblock after milliseconds, not 30s.
- Replace brittle substring match_disease() with identify_disease tool:
the intake agent calls identify_disease(standardized_name=...) when it
semantically identifies the disease; system does reliable registry lookup
via lookup_disease_profile(). Handles all languages, synonyms, and symptom
descriptions that keyword matching could not.
- Pin Gradio to >=6.14.0,<7.0.0 to prevent unexpected major-version breaks.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor: move all business logic out of app.py β pure UI wiring
- Add INTAKE_TOOLS / RESEARCH_TOOLS grouping constants to tools.py
- Add intake_greeting() (blocking) and stream_intake_turn() (event
generator) to agents/intake.py; handles identify_disease tool and
PatientProfile construction internally
- Add stream_research_agent() (event generator) to agents/research.py;
handles search_clinical_trials tool internally; uses profile.lang for
LANGUAGE_DIRECTIVE so app.py never touches system prompts
- Update agents/__init__.py exports
- Update clinical_trials_guru.py re-exports
- Rewrite app.py: imports only intake_greeting, stream_intake_turn,
stream_research_agent, PatientProfile, LANGUAGES, UI β no tool
objects, no tool names, no profile construction, no system prompts
app.py now maps agent events ("token"/"text"/"profile"/"done") to
Gradio state updates and nothing more.
Co-authored-by: KevinIsInCoding <KevinIsInCoding@users.noreply.github.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: KevinIsInCoding <KevinIsInCoding@users.noreply.github.com>
- _console.py +3 -0
- agents/__init__.py +10 -0
- agents/intake.py +219 -0
- agents/research.py +155 -0
- app.py +61 -159
- clinical_trials_guru.py +8 -600
- config.py +3 -0
- data/diseases/als.json +25 -0
- data/diseases/duchenne.json +13 -0
- data/diseases/friedreichs.json +13 -0
- data/diseases/huntingtons.json +19 -0
- data/diseases/ms.json +13 -0
- data/diseases/parkinsons.json +13 -0
- data/diseases/pompe.json +13 -0
- data/diseases/sma.json +19 -0
- data/tools/identify_disease.json +10 -0
- data/tools/search_trials.json +23 -0
- data/tools/submit_profile.json +46 -0
- models.py +77 -0
- prompts.py +130 -0
- pyproject.toml +1 -1
- tools.py +49 -0
- trials_api.py +162 -0
- uv.lock +1 -1
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from rich.console import Console
|
| 2 |
+
|
| 3 |
+
console = Console()
|
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .intake import run_intake_agent, intake_greeting, stream_intake_turn
|
| 2 |
+
from .research import run_research_agent, stream_research_agent
|
| 3 |
+
|
| 4 |
+
__all__ = [
|
| 5 |
+
"run_intake_agent",
|
| 6 |
+
"intake_greeting",
|
| 7 |
+
"stream_intake_turn",
|
| 8 |
+
"run_research_agent",
|
| 9 |
+
"stream_research_agent",
|
| 10 |
+
]
|
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import datetime
|
| 4 |
+
import json
|
| 5 |
+
from dataclasses import asdict
|
| 6 |
+
from typing import Generator
|
| 7 |
+
|
| 8 |
+
import anthropic
|
| 9 |
+
from rich.panel import Panel
|
| 10 |
+
from rich.text import Text
|
| 11 |
+
|
| 12 |
+
from beacon_logging import get_logger
|
| 13 |
+
from config import INTAKE_MODEL
|
| 14 |
+
from models import PatientProfile, geocode_zip
|
| 15 |
+
from prompts import INTAKE_SYSTEM, lookup_disease_profile
|
| 16 |
+
from tools import INTAKE_TOOLS
|
| 17 |
+
from translations import LANGUAGE_DIRECTIVE
|
| 18 |
+
from _console import console
|
| 19 |
+
|
| 20 |
+
_logger = get_logger("agents.intake")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def run_intake_agent(client: anthropic.Anthropic) -> PatientProfile:
|
| 24 |
+
today = datetime.date.today().strftime("%B %d, %Y")
|
| 25 |
+
|
| 26 |
+
console.print()
|
| 27 |
+
console.print(Panel(
|
| 28 |
+
Text("Beacon β Rare Disease Clinical Trial Finder", justify="center", style="bold cyan"),
|
| 29 |
+
border_style="cyan",
|
| 30 |
+
padding=(1, 4),
|
| 31 |
+
))
|
| 32 |
+
|
| 33 |
+
messages: list[anthropic.types.MessageParam] = [
|
| 34 |
+
{"role": "user", "content": "Please begin."}
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
while True:
|
| 38 |
+
response = client.messages.create(
|
| 39 |
+
model=INTAKE_MODEL,
|
| 40 |
+
max_tokens=1024,
|
| 41 |
+
system=f"Today's date is {today}.\n\n" + INTAKE_SYSTEM,
|
| 42 |
+
tools=INTAKE_TOOLS,
|
| 43 |
+
messages=messages,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
text = next((b.text for b in response.content if b.type == "text"), "")
|
| 47 |
+
if text:
|
| 48 |
+
console.print(f"\n[bold cyan]Beacon:[/bold cyan] {text}")
|
| 49 |
+
|
| 50 |
+
identify_block = next(
|
| 51 |
+
(b for b in response.content if b.type == "tool_use" and b.name == "identify_disease"),
|
| 52 |
+
None,
|
| 53 |
+
)
|
| 54 |
+
submit_block = next(
|
| 55 |
+
(b for b in response.content if b.type == "tool_use" and b.name == "submit_profile"),
|
| 56 |
+
None,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
if identify_block:
|
| 60 |
+
disease = lookup_disease_profile(identify_block.input["standardized_name"])
|
| 61 |
+
tool_result = json.dumps({
|
| 62 |
+
"benchmarks_to_collect": disease["benchmarks"] if disease else [],
|
| 63 |
+
"message": (
|
| 64 |
+
f"Collect these benchmarks for {disease['full_name']}"
|
| 65 |
+
if disease else "Disease not in registry β skip benchmark questions."
|
| 66 |
+
),
|
| 67 |
+
})
|
| 68 |
+
messages.append({"role": "assistant", "content": response.content})
|
| 69 |
+
messages.append({"role": "user", "content": [{
|
| 70 |
+
"type": "tool_result",
|
| 71 |
+
"tool_use_id": identify_block.id,
|
| 72 |
+
"content": tool_result,
|
| 73 |
+
}]})
|
| 74 |
+
continue
|
| 75 |
+
|
| 76 |
+
if submit_block:
|
| 77 |
+
data = submit_block.input
|
| 78 |
+
try:
|
| 79 |
+
with console.status("[cyan]Geocoding locationβ¦[/cyan]", spinner="dots"):
|
| 80 |
+
lat, lon = geocode_zip(data["zip_code"], data.get("country_code", "US"))
|
| 81 |
+
except Exception as exc:
|
| 82 |
+
console.print(f"[yellow]Warning:[/yellow] Geocoding failed ({exc}) β coordinates set to 0,0.")
|
| 83 |
+
lat, lon = 0.0, 0.0
|
| 84 |
+
profile = PatientProfile(
|
| 85 |
+
disease=data["disease"],
|
| 86 |
+
age=data["age"],
|
| 87 |
+
onset_months=data["onset_months"],
|
| 88 |
+
diagnosis_months=data.get("diagnosis_months", 0),
|
| 89 |
+
benchmarks=data.get("benchmarks") or {},
|
| 90 |
+
zip_code=data["zip_code"],
|
| 91 |
+
country_code=data.get("country_code", "US"),
|
| 92 |
+
lat=lat,
|
| 93 |
+
lon=lon,
|
| 94 |
+
radius_miles=data.get("radius_miles", 100),
|
| 95 |
+
phases=data.get("phases") or [],
|
| 96 |
+
include_eap=data.get("include_eap", False),
|
| 97 |
+
include_observational=data.get("include_observational", False),
|
| 98 |
+
)
|
| 99 |
+
_logger.info(
|
| 100 |
+
"Patient intake complete (CLI)",
|
| 101 |
+
extra={"data": {"intake_summary": asdict(profile)}},
|
| 102 |
+
)
|
| 103 |
+
return profile
|
| 104 |
+
|
| 105 |
+
messages.append({"role": "assistant", "content": response.content})
|
| 106 |
+
user_input = input("\nYou: ").strip() or "(no response)"
|
| 107 |
+
messages.append({"role": "user", "content": user_input})
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def intake_greeting(client: anthropic.Anthropic, lang: str = "en") -> tuple[str, list]:
|
| 111 |
+
"""Run the opening intake turn (blocking). Returns (greeting_text, initial_messages)."""
|
| 112 |
+
today = datetime.date.today().strftime("%B %d, %Y")
|
| 113 |
+
system = f"Today's date is {today}.\n\n" + LANGUAGE_DIRECTIVE[lang] + INTAKE_SYSTEM
|
| 114 |
+
messages: list[anthropic.types.MessageParam] = [{"role": "user", "content": "Please begin."}]
|
| 115 |
+
response = client.messages.create(
|
| 116 |
+
model=INTAKE_MODEL,
|
| 117 |
+
max_tokens=1024,
|
| 118 |
+
system=system,
|
| 119 |
+
tools=INTAKE_TOOLS,
|
| 120 |
+
messages=messages,
|
| 121 |
+
)
|
| 122 |
+
text = next((b.text for b in response.content if b.type == "text"), "")
|
| 123 |
+
return text, messages + [{"role": "assistant", "content": response.content}]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def stream_intake_turn(
|
| 127 |
+
client: anthropic.Anthropic,
|
| 128 |
+
messages: list[anthropic.types.MessageParam],
|
| 129 |
+
lang: str = "en",
|
| 130 |
+
) -> Generator[tuple, None, None]:
|
| 131 |
+
"""
|
| 132 |
+
Stream one user turn of the intake conversation.
|
| 133 |
+
|
| 134 |
+
Yields:
|
| 135 |
+
("token", str) β partial text chunk
|
| 136 |
+
("reset_stream",) β identify_disease handled; clear token buffer
|
| 137 |
+
("text", str, list) β model responded with text; updated messages
|
| 138 |
+
("profile", PatientProfile, list) β profile submitted; updated messages
|
| 139 |
+
"""
|
| 140 |
+
today = datetime.date.today().strftime("%B %d, %Y")
|
| 141 |
+
system = f"Today's date is {today}.\n\n" + LANGUAGE_DIRECTIVE[lang] + INTAKE_SYSTEM
|
| 142 |
+
new_msgs = list(messages)
|
| 143 |
+
|
| 144 |
+
while True:
|
| 145 |
+
intake_text = ""
|
| 146 |
+
with client.messages.stream(
|
| 147 |
+
model=INTAKE_MODEL,
|
| 148 |
+
max_tokens=1024,
|
| 149 |
+
system=system,
|
| 150 |
+
tools=INTAKE_TOOLS,
|
| 151 |
+
messages=new_msgs,
|
| 152 |
+
) as stream:
|
| 153 |
+
for chunk in stream.text_stream:
|
| 154 |
+
intake_text += chunk
|
| 155 |
+
yield ("token", chunk)
|
| 156 |
+
response = stream.get_final_message()
|
| 157 |
+
|
| 158 |
+
intake_text = intake_text or next(
|
| 159 |
+
(b.text for b in response.content if b.type == "text"), ""
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
identify_block = next(
|
| 163 |
+
(b for b in response.content if b.type == "tool_use" and b.name == "identify_disease"),
|
| 164 |
+
None,
|
| 165 |
+
)
|
| 166 |
+
submit_block = next(
|
| 167 |
+
(b for b in response.content if b.type == "tool_use" and b.name == "submit_profile"),
|
| 168 |
+
None,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
if identify_block:
|
| 172 |
+
disease = lookup_disease_profile(identify_block.input["standardized_name"])
|
| 173 |
+
tool_result = json.dumps({
|
| 174 |
+
"benchmarks_to_collect": disease["benchmarks"] if disease else [],
|
| 175 |
+
"message": (
|
| 176 |
+
f"Collect these benchmarks for {disease['full_name']}"
|
| 177 |
+
if disease else "Disease not in registry β skip benchmark questions."
|
| 178 |
+
),
|
| 179 |
+
})
|
| 180 |
+
new_msgs = new_msgs + [
|
| 181 |
+
{"role": "assistant", "content": response.content},
|
| 182 |
+
{"role": "user", "content": [{
|
| 183 |
+
"type": "tool_result",
|
| 184 |
+
"tool_use_id": identify_block.id,
|
| 185 |
+
"content": tool_result,
|
| 186 |
+
}]},
|
| 187 |
+
]
|
| 188 |
+
yield ("reset_stream",)
|
| 189 |
+
continue
|
| 190 |
+
|
| 191 |
+
new_msgs = new_msgs + [{"role": "assistant", "content": response.content}]
|
| 192 |
+
|
| 193 |
+
if submit_block:
|
| 194 |
+
data = submit_block.input
|
| 195 |
+
try:
|
| 196 |
+
lat, lon = geocode_zip(data["zip_code"], data.get("country_code", "US"))
|
| 197 |
+
except Exception:
|
| 198 |
+
lat, lon = 0.0, 0.0
|
| 199 |
+
profile = PatientProfile(
|
| 200 |
+
disease=data["disease"],
|
| 201 |
+
age=data["age"],
|
| 202 |
+
onset_months=data["onset_months"],
|
| 203 |
+
diagnosis_months=data.get("diagnosis_months", 0),
|
| 204 |
+
benchmarks=data.get("benchmarks") or {},
|
| 205 |
+
zip_code=data["zip_code"],
|
| 206 |
+
country_code=data.get("country_code", "US"),
|
| 207 |
+
lat=lat,
|
| 208 |
+
lon=lon,
|
| 209 |
+
radius_miles=data.get("radius_miles", 100),
|
| 210 |
+
phases=data.get("phases") or [],
|
| 211 |
+
include_eap=data.get("include_eap", False),
|
| 212 |
+
include_observational=data.get("include_observational", False),
|
| 213 |
+
lang=lang,
|
| 214 |
+
)
|
| 215 |
+
yield ("profile", profile, new_msgs)
|
| 216 |
+
return
|
| 217 |
+
|
| 218 |
+
yield ("text", intake_text, new_msgs)
|
| 219 |
+
return
|
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Generator
|
| 5 |
+
|
| 6 |
+
import anthropic
|
| 7 |
+
|
| 8 |
+
from beacon_logging import get_logger
|
| 9 |
+
from config import RESEARCH_MODEL
|
| 10 |
+
from models import PatientProfile
|
| 11 |
+
from prompts import RESEARCH_SYSTEM
|
| 12 |
+
from tools import RESEARCH_TOOLS
|
| 13 |
+
from translations import LANGUAGE_DIRECTIVE
|
| 14 |
+
from trials_api import search_trials_api, _flatten_and_rank
|
| 15 |
+
from _console import console
|
| 16 |
+
|
| 17 |
+
_logger = get_logger("agents.research")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def run_research_agent(client: anthropic.Anthropic, profile: PatientProfile) -> str:
|
| 21 |
+
messages: list[anthropic.types.MessageParam] = [
|
| 22 |
+
{
|
| 23 |
+
"role": "user",
|
| 24 |
+
"content": (
|
| 25 |
+
f"Find clinical trials for this patient:\n\n{profile.summary()}\n\n"
|
| 26 |
+
"Search within the specified radius and rank results by distance."
|
| 27 |
+
),
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
while True:
|
| 32 |
+
response = client.messages.create(
|
| 33 |
+
model=RESEARCH_MODEL,
|
| 34 |
+
max_tokens=8096,
|
| 35 |
+
system=RESEARCH_SYSTEM,
|
| 36 |
+
tools=RESEARCH_TOOLS,
|
| 37 |
+
messages=messages,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
messages.append({"role": "assistant", "content": response.content})
|
| 41 |
+
|
| 42 |
+
if response.stop_reason == "end_turn":
|
| 43 |
+
return next(
|
| 44 |
+
(b.text for b in response.content if b.type == "text"),
|
| 45 |
+
"No analysis produced.",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
tool_results: list[anthropic.types.ToolResultBlockParam] = []
|
| 49 |
+
for block in response.content:
|
| 50 |
+
if block.type != "tool_use" or block.name != "search_clinical_trials":
|
| 51 |
+
continue
|
| 52 |
+
args = block.input
|
| 53 |
+
radius = args.get("radius_miles", profile.radius_miles)
|
| 54 |
+
phases = args.get("phases") or None
|
| 55 |
+
study_type = args.get("study_type", "INTERVENTIONAL")
|
| 56 |
+
status_msg = (
|
| 57 |
+
f"[cyan]Searching:[/cyan] '[bold]{args['condition']}[/bold]' | "
|
| 58 |
+
f"radius=[bold]{radius}[/bold] mi | "
|
| 59 |
+
f"type=[bold]{study_type}[/bold] | "
|
| 60 |
+
f"phases=[bold]{phases or 'all'}[/bold]"
|
| 61 |
+
)
|
| 62 |
+
try:
|
| 63 |
+
with console.status(status_msg, spinner="dots"):
|
| 64 |
+
studies = search_trials_api(
|
| 65 |
+
condition=args["condition"],
|
| 66 |
+
lat=args["lat"],
|
| 67 |
+
lon=args["lon"],
|
| 68 |
+
radius_miles=radius,
|
| 69 |
+
phases=phases,
|
| 70 |
+
study_type=study_type,
|
| 71 |
+
)
|
| 72 |
+
ranked = _flatten_and_rank(studies, profile.lat, profile.lon)
|
| 73 |
+
console.print(f" [green]β[/green] {len(ranked)} trial(s) found.")
|
| 74 |
+
content = json.dumps(ranked)
|
| 75 |
+
is_error = False
|
| 76 |
+
except Exception as exc:
|
| 77 |
+
console.print(f"[red bold]API error:[/red bold] {exc}")
|
| 78 |
+
content = f"API request failed: {exc}. The ClinicalTrials.gov endpoint may be temporarily unavailable."
|
| 79 |
+
is_error = True
|
| 80 |
+
tool_results.append({
|
| 81 |
+
"type": "tool_result",
|
| 82 |
+
"tool_use_id": block.id,
|
| 83 |
+
"content": content,
|
| 84 |
+
"is_error": is_error,
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
messages.append({"role": "user", "content": tool_results})
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def stream_research_agent(
|
| 91 |
+
client: anthropic.Anthropic,
|
| 92 |
+
profile: PatientProfile,
|
| 93 |
+
) -> Generator[tuple, None, None]:
|
| 94 |
+
"""
|
| 95 |
+
Stream the research agent for a given patient profile.
|
| 96 |
+
|
| 97 |
+
Yields:
|
| 98 |
+
("token", str) β partial text chunk
|
| 99 |
+
("done", str) β research complete; the full analysis text (may be empty)
|
| 100 |
+
"""
|
| 101 |
+
messages: list[anthropic.types.MessageParam] = [{
|
| 102 |
+
"role": "user",
|
| 103 |
+
"content": (
|
| 104 |
+
f"Find clinical trials for this patient:\n\n{profile.summary()}\n\n"
|
| 105 |
+
"Search within the specified radius and rank results by distance."
|
| 106 |
+
),
|
| 107 |
+
}]
|
| 108 |
+
|
| 109 |
+
while True:
|
| 110 |
+
stream_text = ""
|
| 111 |
+
with client.messages.stream(
|
| 112 |
+
model=RESEARCH_MODEL,
|
| 113 |
+
max_tokens=8096,
|
| 114 |
+
system=LANGUAGE_DIRECTIVE[profile.lang] + RESEARCH_SYSTEM,
|
| 115 |
+
tools=RESEARCH_TOOLS,
|
| 116 |
+
messages=messages,
|
| 117 |
+
) as stream:
|
| 118 |
+
for chunk in stream.text_stream:
|
| 119 |
+
stream_text += chunk
|
| 120 |
+
yield ("token", chunk)
|
| 121 |
+
rresponse = stream.get_final_message()
|
| 122 |
+
|
| 123 |
+
messages.append({"role": "assistant", "content": rresponse.content})
|
| 124 |
+
|
| 125 |
+
if rresponse.stop_reason == "end_turn":
|
| 126 |
+
yield ("done", stream_text)
|
| 127 |
+
return
|
| 128 |
+
|
| 129 |
+
tool_results: list[anthropic.types.ToolResultBlockParam] = []
|
| 130 |
+
for block in rresponse.content:
|
| 131 |
+
if block.type != "tool_use" or block.name != "search_clinical_trials":
|
| 132 |
+
continue
|
| 133 |
+
args = block.input
|
| 134 |
+
try:
|
| 135 |
+
studies = search_trials_api(
|
| 136 |
+
condition=args["condition"],
|
| 137 |
+
lat=args["lat"],
|
| 138 |
+
lon=args["lon"],
|
| 139 |
+
radius_miles=args.get("radius_miles", profile.radius_miles),
|
| 140 |
+
phases=args.get("phases") or None,
|
| 141 |
+
study_type=args.get("study_type", "INTERVENTIONAL"),
|
| 142 |
+
)
|
| 143 |
+
ranked = _flatten_and_rank(studies, profile.lat, profile.lon)
|
| 144 |
+
content = json.dumps(ranked)
|
| 145 |
+
is_error = False
|
| 146 |
+
except Exception as exc:
|
| 147 |
+
content = f"API request failed: {exc}. The ClinicalTrials.gov endpoint may be temporarily unavailable."
|
| 148 |
+
is_error = True
|
| 149 |
+
tool_results.append({
|
| 150 |
+
"type": "tool_result",
|
| 151 |
+
"tool_use_id": block.id,
|
| 152 |
+
"content": content,
|
| 153 |
+
"is_error": is_error,
|
| 154 |
+
})
|
| 155 |
+
messages.append({"role": "user", "content": tool_results})
|
|
@@ -1,8 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import dataclasses
|
| 4 |
-
import datetime
|
| 5 |
-
import json
|
| 6 |
from typing import Generator
|
| 7 |
|
| 8 |
import anthropic
|
|
@@ -10,136 +8,19 @@ import gradio as gr
|
|
| 10 |
from beacon_logging import get_logger
|
| 11 |
from dotenv import load_dotenv
|
| 12 |
|
| 13 |
-
from
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
RESEARCH_SYSTEM,
|
| 18 |
-
SEARCH_TRIALS_TOOL,
|
| 19 |
-
SUBMIT_PROFILE_TOOL,
|
| 20 |
-
PatientProfile,
|
| 21 |
-
_flatten_and_rank,
|
| 22 |
-
geocode_zip,
|
| 23 |
-
search_trials_api,
|
| 24 |
-
)
|
| 25 |
-
from translations import LANGUAGE_DIRECTIVE, LANGUAGES, UI
|
| 26 |
|
| 27 |
load_dotenv()
|
| 28 |
|
| 29 |
_logger = get_logger("app")
|
| 30 |
|
| 31 |
|
| 32 |
-
def _intake_turn(
|
| 33 |
-
user_text: str, messages: list, lang: str = "en"
|
| 34 |
-
) -> tuple[str, list, PatientProfile | None]:
|
| 35 |
-
today = datetime.date.today().strftime("%B %d, %Y")
|
| 36 |
-
messages = messages + [{"role": "user", "content": user_text}]
|
| 37 |
-
client = anthropic.Anthropic()
|
| 38 |
-
system = f"Today's date is {today}.\n\n" + LANGUAGE_DIRECTIVE[lang] + INTAKE_SYSTEM
|
| 39 |
-
response = client.messages.create(
|
| 40 |
-
model=INTAKE_MODEL,
|
| 41 |
-
max_tokens=1024,
|
| 42 |
-
system=system,
|
| 43 |
-
tools=[SUBMIT_PROFILE_TOOL],
|
| 44 |
-
messages=messages,
|
| 45 |
-
)
|
| 46 |
-
text = next((b.text for b in response.content if b.type == "text"), "")
|
| 47 |
-
tool_block = next(
|
| 48 |
-
(b for b in response.content if b.type == "tool_use" and b.name == "submit_profile"),
|
| 49 |
-
None,
|
| 50 |
-
)
|
| 51 |
-
messages = messages + [{"role": "assistant", "content": response.content}]
|
| 52 |
-
|
| 53 |
-
if tool_block:
|
| 54 |
-
data = tool_block.input
|
| 55 |
-
try:
|
| 56 |
-
lat, lon = geocode_zip(data["zip_code"], data.get("country_code", "US"))
|
| 57 |
-
except Exception:
|
| 58 |
-
lat, lon = 0.0, 0.0
|
| 59 |
-
profile = PatientProfile(
|
| 60 |
-
disease=data["disease"],
|
| 61 |
-
age=data["age"],
|
| 62 |
-
onset_months=data["onset_months"],
|
| 63 |
-
diagnosis_months=data.get("diagnosis_months", 0),
|
| 64 |
-
benchmarks=data.get("benchmarks") or {},
|
| 65 |
-
zip_code=data["zip_code"],
|
| 66 |
-
country_code=data.get("country_code", "US"),
|
| 67 |
-
lat=lat,
|
| 68 |
-
lon=lon,
|
| 69 |
-
radius_miles=data.get("radius_miles", 100),
|
| 70 |
-
phases=data.get("phases") or [],
|
| 71 |
-
include_eap=data.get("include_eap", False),
|
| 72 |
-
include_observational=data.get("include_observational", False),
|
| 73 |
-
lang=lang,
|
| 74 |
-
)
|
| 75 |
-
_logger.info(
|
| 76 |
-
"Patient intake complete (web)",
|
| 77 |
-
extra={"data": {"intake_summary": dataclasses.asdict(profile)}},
|
| 78 |
-
)
|
| 79 |
-
return text or UI[lang]["got_it"], messages, profile
|
| 80 |
-
|
| 81 |
-
return text, messages, None
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def _run_research(profile: PatientProfile) -> str:
|
| 85 |
-
lang = profile.lang
|
| 86 |
-
client = anthropic.Anthropic()
|
| 87 |
-
messages: list[anthropic.types.MessageParam] = [
|
| 88 |
-
{
|
| 89 |
-
"role": "user",
|
| 90 |
-
"content": (
|
| 91 |
-
f"Find clinical trials for this patient:\n\n{profile.summary()}\n\n"
|
| 92 |
-
"Search within the specified radius and rank results by distance."
|
| 93 |
-
),
|
| 94 |
-
}
|
| 95 |
-
]
|
| 96 |
-
while True:
|
| 97 |
-
response = client.messages.create(
|
| 98 |
-
model=RESEARCH_MODEL,
|
| 99 |
-
max_tokens=8096,
|
| 100 |
-
system=LANGUAGE_DIRECTIVE[lang] + RESEARCH_SYSTEM,
|
| 101 |
-
tools=[SEARCH_TRIALS_TOOL],
|
| 102 |
-
messages=messages,
|
| 103 |
-
)
|
| 104 |
-
messages.append({"role": "assistant", "content": response.content})
|
| 105 |
-
if response.stop_reason == "end_turn":
|
| 106 |
-
return next(
|
| 107 |
-
(b.text for b in response.content if b.type == "text"),
|
| 108 |
-
UI[lang]["no_analysis"],
|
| 109 |
-
)
|
| 110 |
-
tool_results: list[anthropic.types.ToolResultBlockParam] = []
|
| 111 |
-
for block in response.content:
|
| 112 |
-
if block.type != "tool_use" or block.name != "search_clinical_trials":
|
| 113 |
-
continue
|
| 114 |
-
args = block.input
|
| 115 |
-
try:
|
| 116 |
-
studies = search_trials_api(
|
| 117 |
-
condition=args["condition"],
|
| 118 |
-
lat=args["lat"],
|
| 119 |
-
lon=args["lon"],
|
| 120 |
-
radius_miles=args.get("radius_miles", profile.radius_miles),
|
| 121 |
-
phases=args.get("phases") or None,
|
| 122 |
-
study_type=args.get("study_type", "INTERVENTIONAL"),
|
| 123 |
-
)
|
| 124 |
-
ranked = _flatten_and_rank(studies, profile.lat, profile.lon)
|
| 125 |
-
content = json.dumps(ranked)
|
| 126 |
-
is_error = False
|
| 127 |
-
except Exception as exc:
|
| 128 |
-
content = UI[lang]["api_error"].format(exc=exc)
|
| 129 |
-
is_error = True
|
| 130 |
-
tool_results.append(
|
| 131 |
-
{
|
| 132 |
-
"type": "tool_result",
|
| 133 |
-
"tool_use_id": block.id,
|
| 134 |
-
"content": content,
|
| 135 |
-
"is_error": is_error,
|
| 136 |
-
}
|
| 137 |
-
)
|
| 138 |
-
messages.append({"role": "user", "content": tool_results})
|
| 139 |
-
|
| 140 |
-
|
| 141 |
def initialize(lang: str = "en"):
|
| 142 |
-
|
|
|
|
| 143 |
chat = [{"role": "assistant", "content": text}]
|
| 144 |
return chat, msgs, None, "intake"
|
| 145 |
|
|
@@ -148,7 +29,7 @@ def respond(
|
|
| 148 |
user_msg: str,
|
| 149 |
chat_history: list,
|
| 150 |
intake_msgs: list,
|
| 151 |
-
profile,
|
| 152 |
phase: str,
|
| 153 |
lang: str,
|
| 154 |
) -> Generator:
|
|
@@ -157,43 +38,64 @@ def respond(
|
|
| 157 |
return
|
| 158 |
|
| 159 |
t = UI[lang]
|
|
|
|
|
|
|
| 160 |
chat_history = chat_history + [{"role": "user", "content": user_msg}]
|
|
|
|
| 161 |
yield chat_history, intake_msgs, profile, phase, gr.update(value=""), gr.update()
|
| 162 |
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
"
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
)
|
| 197 |
|
| 198 |
|
| 199 |
def change_language(lang: str):
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import dataclasses
|
|
|
|
|
|
|
| 4 |
from typing import Generator
|
| 5 |
|
| 6 |
import anthropic
|
|
|
|
| 8 |
from beacon_logging import get_logger
|
| 9 |
from dotenv import load_dotenv
|
| 10 |
|
| 11 |
+
from agents.intake import intake_greeting, stream_intake_turn
|
| 12 |
+
from agents.research import stream_research_agent
|
| 13 |
+
from models import PatientProfile
|
| 14 |
+
from translations import LANGUAGES, UI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
load_dotenv()
|
| 17 |
|
| 18 |
_logger = get_logger("app")
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def initialize(lang: str = "en"):
|
| 22 |
+
client = anthropic.Anthropic()
|
| 23 |
+
text, msgs = intake_greeting(client, lang)
|
| 24 |
chat = [{"role": "assistant", "content": text}]
|
| 25 |
return chat, msgs, None, "intake"
|
| 26 |
|
|
|
|
| 29 |
user_msg: str,
|
| 30 |
chat_history: list,
|
| 31 |
intake_msgs: list,
|
| 32 |
+
profile: PatientProfile | None,
|
| 33 |
phase: str,
|
| 34 |
lang: str,
|
| 35 |
) -> Generator:
|
|
|
|
| 38 |
return
|
| 39 |
|
| 40 |
t = UI[lang]
|
| 41 |
+
client = anthropic.Anthropic()
|
| 42 |
+
|
| 43 |
chat_history = chat_history + [{"role": "user", "content": user_msg}]
|
| 44 |
+
new_intake_msgs = intake_msgs + [{"role": "user", "content": user_msg}]
|
| 45 |
yield chat_history, intake_msgs, profile, phase, gr.update(value=""), gr.update()
|
| 46 |
|
| 47 |
+
intake_text = ""
|
| 48 |
+
for event in stream_intake_turn(client, new_intake_msgs, lang):
|
| 49 |
+
if event[0] == "token":
|
| 50 |
+
intake_text += event[1]
|
| 51 |
+
yield (
|
| 52 |
+
chat_history + [{"role": "assistant", "content": intake_text}],
|
| 53 |
+
intake_msgs, profile, phase, gr.update(), gr.update(),
|
| 54 |
+
)
|
| 55 |
+
elif event[0] == "reset_stream":
|
| 56 |
+
intake_text = ""
|
| 57 |
+
elif event[0] == "text":
|
| 58 |
+
_, full_text, updated_msgs = event
|
| 59 |
+
chat_history = chat_history + [{"role": "assistant", "content": full_text}]
|
| 60 |
+
yield (
|
| 61 |
+
chat_history, updated_msgs, profile, "intake",
|
| 62 |
+
gr.update(interactive=True), gr.update(),
|
| 63 |
+
)
|
| 64 |
+
return
|
| 65 |
+
elif event[0] == "profile":
|
| 66 |
+
_, new_profile, updated_msgs = event
|
| 67 |
+
_logger.info(
|
| 68 |
+
"Patient intake complete (web)",
|
| 69 |
+
extra={"data": {"intake_summary": dataclasses.asdict(new_profile)}},
|
| 70 |
+
)
|
| 71 |
+
if intake_text:
|
| 72 |
+
chat_history = chat_history + [{"role": "assistant", "content": intake_text}]
|
| 73 |
+
chat_history = chat_history + [{"role": "assistant", "content": t["status_searching"]}]
|
| 74 |
+
yield (
|
| 75 |
+
chat_history, updated_msgs, new_profile, "researching",
|
| 76 |
+
gr.update(interactive=False, placeholder=t["searching"]),
|
| 77 |
+
gr.update(visible=False),
|
| 78 |
+
)
|
| 79 |
|
| 80 |
+
stream_text = ""
|
| 81 |
+
for rev in stream_research_agent(client, new_profile):
|
| 82 |
+
if rev[0] == "token":
|
| 83 |
+
stream_text += rev[1]
|
| 84 |
+
yield (
|
| 85 |
+
chat_history + [{"role": "assistant", "content": stream_text}],
|
| 86 |
+
updated_msgs, new_profile, "researching",
|
| 87 |
+
gr.update(interactive=False, placeholder=t["searching"]),
|
| 88 |
+
gr.update(visible=False),
|
| 89 |
+
)
|
| 90 |
+
elif rev[0] == "done":
|
| 91 |
+
analysis = rev[1] or t["no_analysis"]
|
| 92 |
+
chat_history = chat_history + [{"role": "assistant", "content": analysis}]
|
| 93 |
+
yield (
|
| 94 |
+
chat_history, updated_msgs, new_profile, "done",
|
| 95 |
+
gr.update(interactive=False, placeholder=t["search_complete"]),
|
| 96 |
+
gr.update(visible=True),
|
| 97 |
+
)
|
| 98 |
+
return
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
def change_language(lang: str):
|
|
@@ -1,614 +1,22 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import json
|
| 4 |
-
import math
|
| 5 |
-
import time
|
| 6 |
-
from dataclasses import asdict, dataclass, field
|
| 7 |
from typing import Optional, TypedDict
|
| 8 |
|
| 9 |
-
from beacon_logging import get_logger
|
| 10 |
-
from translations import LANGUAGE_DIRECTIVE
|
| 11 |
-
|
| 12 |
import anthropic
|
| 13 |
-
import httpx
|
| 14 |
from rich import box
|
| 15 |
-
from rich.console import Console
|
| 16 |
from rich.markdown import Markdown
|
| 17 |
from rich.panel import Panel
|
| 18 |
-
from rich.text import Text
|
| 19 |
from langgraph.graph import StateGraph, START, END
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
# ββ Tool schemas ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
-
|
| 31 |
-
SUBMIT_PROFILE_TOOL: anthropic.types.ToolParam = {
|
| 32 |
-
"name": "submit_profile",
|
| 33 |
-
"description": (
|
| 34 |
-
"Call this when you have collected all required information. "
|
| 35 |
-
"Standardize the disease name to its full medical term."
|
| 36 |
-
),
|
| 37 |
-
"input_schema": {
|
| 38 |
-
"type": "object",
|
| 39 |
-
"properties": {
|
| 40 |
-
"disease": {
|
| 41 |
-
"type": "string",
|
| 42 |
-
"description": "Full medical name (e.g. 'Amyotrophic Lateral Sclerosis')",
|
| 43 |
-
},
|
| 44 |
-
"age": {"type": "integer"},
|
| 45 |
-
"onset_months": {
|
| 46 |
-
"type": "integer",
|
| 47 |
-
"description": "Months since first symptom onset",
|
| 48 |
-
},
|
| 49 |
-
"diagnosis_months": {
|
| 50 |
-
"type": "integer",
|
| 51 |
-
"description": "Months since formal/official diagnosis",
|
| 52 |
-
},
|
| 53 |
-
"benchmarks": {
|
| 54 |
-
"type": "object",
|
| 55 |
-
"description": "Disease-specific scores, e.g. {\"ALSFRS-R\": \"38\"}",
|
| 56 |
-
"additionalProperties": {"type": "string"},
|
| 57 |
-
},
|
| 58 |
-
"zip_code": {"type": "string", "description": "Patient ZIP / postal code"},
|
| 59 |
-
"country_code": {
|
| 60 |
-
"type": "string",
|
| 61 |
-
"description": "ISO 2-letter country code (default US)",
|
| 62 |
-
},
|
| 63 |
-
"radius_miles": {
|
| 64 |
-
"type": "integer",
|
| 65 |
-
"description": "Search radius in miles from patient location (default 100)",
|
| 66 |
-
},
|
| 67 |
-
"phases": {
|
| 68 |
-
"type": "array",
|
| 69 |
-
"items": {"type": "string", "enum": ["0", "1", "2", "3", "4", "na"]},
|
| 70 |
-
"description": "Desired trial phases (0=Early Phase 1, 1=Phase 1, 2=Phase 2, 3=Phase 3, 4=Phase 4, na=Not Applicable). Empty = all phases.",
|
| 71 |
-
},
|
| 72 |
-
"include_eap": {
|
| 73 |
-
"type": "boolean",
|
| 74 |
-
"description": "Whether patient is interested in Expanded Access Programs (compassionate use)",
|
| 75 |
-
},
|
| 76 |
-
"include_observational": {
|
| 77 |
-
"type": "boolean",
|
| 78 |
-
"description": "Whether patient is interested in observational studies (no experimental treatment; researchers observe and measure outcomes)",
|
| 79 |
-
},
|
| 80 |
-
},
|
| 81 |
-
"required": ["disease", "age", "onset_months", "diagnosis_months", "zip_code"],
|
| 82 |
-
},
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
SEARCH_TRIALS_TOOL: anthropic.types.ToolParam = {
|
| 86 |
-
"name": "search_clinical_trials",
|
| 87 |
-
"description": (
|
| 88 |
-
"Search ClinicalTrials.gov for studies within a geographic radius. "
|
| 89 |
-
"Results are pre-ranked by distance from the patient's location. "
|
| 90 |
-
"Call multiple times with different parameters (synonyms, broader radius, "
|
| 91 |
-
"different phases) if initial results are sparse. "
|
| 92 |
-
"Use study_type='EXPANDED_ACCESS' to search for Expanded Access Programs (EAP / compassionate use). "
|
| 93 |
-
"Use study_type='OBSERVATIONAL' to search for observational studies (no experimental treatment assigned)."
|
| 94 |
-
),
|
| 95 |
-
"input_schema": {
|
| 96 |
-
"type": "object",
|
| 97 |
-
"properties": {
|
| 98 |
-
"condition": {
|
| 99 |
-
"type": "string",
|
| 100 |
-
"description": "Disease / condition to search (medical name and/or abbreviation)",
|
| 101 |
-
},
|
| 102 |
-
"lat": {"type": "number", "description": "Patient latitude"},
|
| 103 |
-
"lon": {"type": "number", "description": "Patient longitude"},
|
| 104 |
-
"radius_miles": {"type": "integer", "description": "Search radius in miles"},
|
| 105 |
-
"phases": {
|
| 106 |
-
"type": "array",
|
| 107 |
-
"items": {"type": "string"},
|
| 108 |
-
"description": (
|
| 109 |
-
"Phase numbers to filter e.g. ['1','2','3']. "
|
| 110 |
-
"IMPORTANT: Never enumerate all phases to mean 'all phases' β "
|
| 111 |
-
"pass an empty array [] instead. NA-phase trials (device feasibility, "
|
| 112 |
-
"unphased studies) only appear when phases=[] (no filter). "
|
| 113 |
-
"Ignored for EAP."
|
| 114 |
-
),
|
| 115 |
-
},
|
| 116 |
-
"study_type": {
|
| 117 |
-
"type": "string",
|
| 118 |
-
"enum": ["INTERVENTIONAL", "EXPANDED_ACCESS", "OBSERVATIONAL"],
|
| 119 |
-
"description": "INTERVENTIONAL (default) for clinical trials; EXPANDED_ACCESS for EAP/compassionate use; OBSERVATIONAL for observational studies.",
|
| 120 |
-
},
|
| 121 |
-
},
|
| 122 |
-
"required": ["condition", "lat", "lon", "radius_miles"],
|
| 123 |
-
},
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
# ββ System prompts ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 127 |
-
|
| 128 |
-
INTAKE_SYSTEM = """\
|
| 129 |
-
You are Beacon's patient intake specialist for rare disease clinical trials.
|
| 130 |
-
Collect the following through a warm, conversational interview β do NOT present a form.
|
| 131 |
-
|
| 132 |
-
REQUIRED:
|
| 133 |
-
β’ Disease/condition (standardize: "Lou Gehrig's" β "Amyotrophic Lateral Sclerosis")
|
| 134 |
-
β’ Patient age
|
| 135 |
-
β’ Months since first symptom onset (convert dates/years as needed)
|
| 136 |
-
β’ Months since formal/official diagnosis (convert dates/years as needed; may differ from onset)
|
| 137 |
-
β’ ZIP/postal code and country for geographic search
|
| 138 |
-
|
| 139 |
-
OPTIONAL (ask based on disease):
|
| 140 |
-
β’ Disease-specific benchmark scores:
|
| 141 |
-
ALS β ALSFRS-R (0-48) + FVC % predicted (0-100%) + ALS subtype;
|
| 142 |
-
FVC (Forced Vital Capacity) measures how much air a person can forcibly exhale β
|
| 143 |
-
it reflects respiratory muscle strength. In ALS it is expressed as a percentage
|
| 144 |
-
of the value expected for someone of the same age/height/sex (e.g. "72%").
|
| 145 |
-
Many trials require FVC β₯ 50% or β₯ 60% for enrollment. If the patient has had
|
| 146 |
-
recent pulmonary function testing, ask for their FVC % predicted.
|
| 147 |
-
ALS subtype: ask whether the patient has sporadic ALS (no family history, ~90β95%
|
| 148 |
-
of cases) or familial/genetic ALS (inherited; ~5β10% of cases). If familial, ask
|
| 149 |
-
which gene mutation is involved if they know it (common ones: SOD1, C9orf72, FUS,
|
| 150 |
-
TDP-43). This affects trial eligibility β many gene-targeted trials require a
|
| 151 |
-
confirmed mutation. The patient may skip if unknown. Store as e.g.
|
| 152 |
-
{"ALS subtype": "sporadic"} or {"ALS subtype": "familial", "ALS gene": "SOD1"}.
|
| 153 |
-
MS β EDSS (0-10); Parkinson's β MDS-UPDRS III;
|
| 154 |
-
Huntington's β TFC (0-13) + CAG repeats; SMA β HFMS + SMA type;
|
| 155 |
-
Duchenne/Pompe β 6-Minute Walk Test; Friedreich's β SARA score
|
| 156 |
-
β’ Preferred search radius in miles (default 100)
|
| 157 |
-
β’ Study types of interest β ask whether the patient wants:
|
| 158 |
-
- Clinical trials. Briefly explain the phases so the patient can choose:
|
| 159 |
-
Early Phase 1 β First-in-human safety testing; tiny doses, very small group (~10β15 people);
|
| 160 |
-
no efficacy data yet; highest uncertainty.
|
| 161 |
-
Phase 1 β Establishes safe dosage range and identifies side effects;
|
| 162 |
-
small group (20β80 people); primary goal is safety, not treatment.
|
| 163 |
-
Phase 2 β Tests whether the treatment works and further evaluates safety;
|
| 164 |
-
larger group (100β300 people); patients more likely to receive active drug.
|
| 165 |
-
Phase 3 β Compares treatment against current standard of care in a large group
|
| 166 |
-
(1,000β3,000 people); required for regulatory approval; best efficacy evidence.
|
| 167 |
-
Phase 4 β Post-approval surveillance; treatment is already FDA-approved;
|
| 168 |
-
studies long-term safety, rare side effects, and new uses.
|
| 169 |
-
Not Applicable β Studies that do not fall into the standard phase framework
|
| 170 |
-
(e.g., device feasibility studies, behavioral/observational trials,
|
| 171 |
-
or studies where phase designation is not required by FDA).
|
| 172 |
-
- Observational studies β studies where researchers observe participants and collect
|
| 173 |
-
data without assigning treatments. No experimental drug or intervention is given.
|
| 174 |
-
Patients may contribute valuable data to disease understanding, registries, or
|
| 175 |
-
natural history studies. Often have broader eligibility than interventional trials.
|
| 176 |
-
- Expanded Access Programs (EAP / compassionate use) β a pathway for patients who
|
| 177 |
-
do not qualify for or cannot access a clinical trial to receive an investigational
|
| 178 |
-
drug, biologic, or device outside of a trial. Also called "compassionate use."
|
| 179 |
-
The treatment is not yet FDA-approved; a physician must submit the EAP request
|
| 180 |
-
to the drug sponsor and obtain FDA authorization. EAP does not guarantee efficacy
|
| 181 |
-
but may be an option when no approved treatments remain.
|
| 182 |
-
- Or any combination; or all types (default if no preference)
|
| 183 |
-
|
| 184 |
-
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.\
|
| 185 |
-
"""
|
| 186 |
-
|
| 187 |
-
RESEARCH_SYSTEM = """\
|
| 188 |
-
You are Beacon, an expert rare-disease clinical trial navigator.
|
| 189 |
-
You have a search_clinical_trials tool that queries ClinicalTrials.gov in real time.
|
| 190 |
-
Results are already ranked by geographic distance from the patient.
|
| 191 |
-
|
| 192 |
-
Workflow:
|
| 193 |
-
1. Search for the patient's disease. Use both the full medical name and common abbreviation.
|
| 194 |
-
- If the patient wants clinical trials, search with study_type="INTERVENTIONAL".
|
| 195 |
-
- If the patient wants observational studies, also search with study_type="OBSERVATIONAL".
|
| 196 |
-
- If the patient wants Expanded Access Programs (EAP), also search with study_type="EXPANDED_ACCESS".
|
| 197 |
-
- Run a separate search for each study_type the patient is interested in.
|
| 198 |
-
2. IMPORTANT β phase filtering: Never pass phases=["1","2","3","4"] to mean "all phases."
|
| 199 |
-
Always pass phases=[] (omit the field) when the patient has no phase preference.
|
| 200 |
-
NA-phase trials (device feasibility studies, unphased interventions) only appear
|
| 201 |
-
when no phase filter is applied. Passing explicit phase numbers silently excludes them.
|
| 202 |
-
Phase filters do not apply to OBSERVATIONAL or EXPANDED_ACCESS searches.
|
| 203 |
-
3. If fewer than 3 results are found, retry with: a wider radius, a disease synonym,
|
| 204 |
-
or drop phase filters entirely (phases=[]).
|
| 205 |
-
4. Produce a final report. Use separate sections for Clinical Trials, Observational Studies, and Expanded Access as applicable.
|
| 206 |
-
List the top 10 results per section ranked by site proximity.
|
| 207 |
-
For EACH entry use exactly this format (repeat the block per entry):
|
| 208 |
-
|
| 209 |
-
π **[Closest hospital/facility name]** β [City, State] ([X] mi)
|
| 210 |
-
**Trial:** [Full title] ([Phase] β or "Observational" / "Expanded Access" as applicable)
|
| 211 |
-
**Sponsor:** [Lead sponsor]
|
| 212 |
-
**Principal Investigator:** [Name β or "Not listed" if absent]
|
| 213 |
-
**Contact:** [Phone number] | [Email address] (use "Not listed" for any missing field)
|
| 214 |
-
**Summary:** [2β3 sentence plain-language description of what the trial/program is testing
|
| 215 |
-
and why it may matter for this patient]
|
| 216 |
-
**Qualification criteria:** [Key inclusion AND exclusion criteria relevant to this patient,
|
| 217 |
-
including age range, functional score thresholds, FVC cutoffs,
|
| 218 |
-
and any red flags. Be specific β use exact numbers from the data.]
|
| 219 |
-
**Link:** https://clinicaltrials.gov/study/[NCT_ID]
|
| 220 |
-
|
| 221 |
-
---
|
| 222 |
-
|
| 223 |
-
5. After the results add a short "Next steps" section (bullet points).
|
| 224 |
-
For observational studies, note that participation typically involves check-ins, surveys, or sample collection with no experimental treatment.
|
| 225 |
-
For EAP results, note that patients typically need a physician to submit the EAP request.
|
| 226 |
-
|
| 227 |
-
IMPORTANT: Only report trials returned by the search_clinical_trials tool. Do NOT suggest,
|
| 228 |
-
list, or recommend any hospitals, centers, or trials that were not in the tool results β
|
| 229 |
-
even well-known institutions. If no results are found, say so clearly and suggest the patient
|
| 230 |
-
ask their neurologist or contact the ALS Association for a referral.
|
| 231 |
-
|
| 232 |
-
Be accurate. Do not fabricate details. If data is missing, say so.\
|
| 233 |
-
"""
|
| 234 |
-
|
| 235 |
-
# ββ Data model ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 236 |
-
|
| 237 |
-
@dataclass
|
| 238 |
-
class PatientProfile:
|
| 239 |
-
disease: str
|
| 240 |
-
age: int
|
| 241 |
-
onset_months: int
|
| 242 |
-
diagnosis_months: int = 0
|
| 243 |
-
benchmarks: dict[str, str] = field(default_factory=dict)
|
| 244 |
-
zip_code: str = ""
|
| 245 |
-
country_code: str = "US"
|
| 246 |
-
lat: float = 0.0
|
| 247 |
-
lon: float = 0.0
|
| 248 |
-
radius_miles: int = 100
|
| 249 |
-
phases: list[str] = field(default_factory=list)
|
| 250 |
-
include_eap: bool = False
|
| 251 |
-
include_observational: bool = False
|
| 252 |
-
lang: str = "en"
|
| 253 |
-
|
| 254 |
-
def summary(self) -> str:
|
| 255 |
-
lines = [
|
| 256 |
-
f"Disease: {self.disease}",
|
| 257 |
-
f"Age: {self.age}",
|
| 258 |
-
f"Symptom onset: {self.onset_months} months ago",
|
| 259 |
-
f"Formal diagnosis: {self.diagnosis_months} months ago",
|
| 260 |
-
]
|
| 261 |
-
if self.benchmarks:
|
| 262 |
-
lines.append("Benchmarks: " + ", ".join(f"{k}={v}" for k, v in self.benchmarks.items()))
|
| 263 |
-
lines.append(
|
| 264 |
-
f"Location: ZIP {self.zip_code}, {self.country_code} "
|
| 265 |
-
f"(lat={self.lat:.4f}, lon={self.lon:.4f})"
|
| 266 |
-
)
|
| 267 |
-
lines.append(f"Search radius: {self.radius_miles} miles")
|
| 268 |
-
if self.phases:
|
| 269 |
-
def _phase_label(p: str) -> str:
|
| 270 |
-
if p == "0":
|
| 271 |
-
return "Early Phase 1"
|
| 272 |
-
if p == "na":
|
| 273 |
-
return "Not Applicable"
|
| 274 |
-
return f"Phase {p}"
|
| 275 |
-
labels = [_phase_label(p) for p in self.phases]
|
| 276 |
-
lines.append(f"Phases: {', '.join(labels)}")
|
| 277 |
-
interests = ["Clinical trials"]
|
| 278 |
-
if self.include_observational:
|
| 279 |
-
interests.append("Observational studies")
|
| 280 |
-
if self.include_eap:
|
| 281 |
-
interests.append("Expanded Access Programs (EAP)")
|
| 282 |
-
lines.append(f"Study type interest: {', '.join(interests)}")
|
| 283 |
-
return "\n".join(lines)
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
# οΏ½οΏ½β Geocoding βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 287 |
-
|
| 288 |
-
def geocode_zip(zip_code: str, country_code: str = "US") -> tuple[float, float]:
|
| 289 |
-
resp = httpx.get(
|
| 290 |
-
"https://nominatim.openstreetmap.org/search",
|
| 291 |
-
params={"postalcode": zip_code, "country": country_code, "format": "json", "limit": 1},
|
| 292 |
-
headers={"User-Agent": "Beacon-ClinicalTrialFinder/1.0"},
|
| 293 |
-
timeout=10,
|
| 294 |
-
)
|
| 295 |
-
resp.raise_for_status()
|
| 296 |
-
results = resp.json()
|
| 297 |
-
if not results:
|
| 298 |
-
raise ValueError(f"Cannot geocode ZIP {zip_code!r} in {country_code!r}")
|
| 299 |
-
return float(results[0]["lat"]), float(results[0]["lon"])
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
# ββ Distance ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 303 |
-
|
| 304 |
-
def haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 305 |
-
R = 3958.8
|
| 306 |
-
Ο1, Ο2 = math.radians(lat1), math.radians(lat2)
|
| 307 |
-
dΟ, dΞ» = math.radians(lat2 - lat1), math.radians(lon2 - lon1)
|
| 308 |
-
a = math.sin(dΟ / 2) ** 2 + math.cos(Ο1) * math.cos(Ο2) * math.sin(dΞ» / 2) ** 2
|
| 309 |
-
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
# ββ ClinicalTrials.gov ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 313 |
-
|
| 314 |
-
def search_trials_api(
|
| 315 |
-
condition: str,
|
| 316 |
-
lat: float,
|
| 317 |
-
lon: float,
|
| 318 |
-
radius_miles: int = 100,
|
| 319 |
-
phases: list[str] | None = None,
|
| 320 |
-
study_type: str = "INTERVENTIONAL",
|
| 321 |
-
) -> list[dict]:
|
| 322 |
-
is_eap = study_type == "EXPANDED_ACCESS"
|
| 323 |
-
is_observational = study_type == "OBSERVATIONAL"
|
| 324 |
-
params: dict[str, str | int] = {
|
| 325 |
-
"query.cond": condition,
|
| 326 |
-
"filter.overallStatus": "AVAILABLE" if is_eap else "RECRUITING",
|
| 327 |
-
"filter.geo": f"distance({lat},{lon},{radius_miles}mi)",
|
| 328 |
-
"pageSize": 200, # max page size; we paginate until exhausted
|
| 329 |
-
"format": "json",
|
| 330 |
-
}
|
| 331 |
-
# aggFilters supports comma-separated keys (e.g. "studyType:exp,phase:3 4").
|
| 332 |
-
# RECRUITING status already excludes EAPs, so studyType:int is only needed
|
| 333 |
-
# when no phase filter is applied. studyType:int returns all phases including N/A.
|
| 334 |
-
# Observational studies use studyType:obs; phases don't apply to them.
|
| 335 |
-
#
|
| 336 |
-
# Case matrix:
|
| 337 |
-
# EAP only β studyType:exp
|
| 338 |
-
# EAP + specific phases β studyType:exp,phase:X Y (combine both filters)
|
| 339 |
-
# EAP + all phases β studyType:exp (no phase filter needed)
|
| 340 |
-
# Interventional, specific β phase:X Y
|
| 341 |
-
# Interventional, all/NA β studyType:int (returns NA trials too)
|
| 342 |
-
if is_eap:
|
| 343 |
-
numbered = [p for p in (phases or []) if p != "na"]
|
| 344 |
-
if numbered:
|
| 345 |
-
# EAP + specific phases: combine studyType:exp with phase filter
|
| 346 |
-
params["aggFilters"] = "studyType:exp,phase:" + " ".join(numbered)
|
| 347 |
-
else:
|
| 348 |
-
# EAP only or EAP + all phases (no phase restriction)
|
| 349 |
-
params["aggFilters"] = "studyType:exp"
|
| 350 |
-
elif is_observational:
|
| 351 |
-
params["aggFilters"] = "studyType:obs"
|
| 352 |
-
elif phases:
|
| 353 |
-
# Exclude "na" from the phase filter β N/A trials have no phase value to match on;
|
| 354 |
-
# they appear naturally when no phase filter is applied (studyType:int branch).
|
| 355 |
-
numbered = [p for p in phases if p != "na"]
|
| 356 |
-
if numbered:
|
| 357 |
-
params["aggFilters"] = "phase:" + " ".join(numbered)
|
| 358 |
-
else:
|
| 359 |
-
# Only "na" was requested β use studyType:int (all phases including N/A appear)
|
| 360 |
-
params["aggFilters"] = "studyType:int"
|
| 361 |
-
else:
|
| 362 |
-
# No phase preference β return all interventional studies including N/A phase
|
| 363 |
-
params["aggFilters"] = "studyType:int"
|
| 364 |
-
|
| 365 |
-
_logger.info(
|
| 366 |
-
"ClinicalTrials.gov API request",
|
| 367 |
-
extra={"data": {"endpoint": CTGOV_BASE, "params": dict(params)}},
|
| 368 |
-
)
|
| 369 |
-
|
| 370 |
-
all_studies: list[dict] = []
|
| 371 |
-
while True:
|
| 372 |
-
for attempt in range(3):
|
| 373 |
-
try:
|
| 374 |
-
resp = httpx.get(CTGOV_BASE, params=params, timeout=30)
|
| 375 |
-
resp.raise_for_status()
|
| 376 |
-
body = resp.json()
|
| 377 |
-
break
|
| 378 |
-
except httpx.HTTPError as exc:
|
| 379 |
-
if attempt == 2:
|
| 380 |
-
raise
|
| 381 |
-
wait = 2 ** attempt
|
| 382 |
-
console.print(f"[yellow]API warning:[/yellow] {exc} β retrying in {wait}s (attempt {attempt + 1}/3)β¦")
|
| 383 |
-
time.sleep(wait)
|
| 384 |
-
page_studies = body.get("studies", [])
|
| 385 |
-
all_studies.extend(page_studies)
|
| 386 |
-
next_token = body.get("nextPageToken")
|
| 387 |
-
_logger.debug(
|
| 388 |
-
"ClinicalTrials.gov API page received",
|
| 389 |
-
extra={"data": {"page_count": len(page_studies), "has_next_page": bool(next_token)}},
|
| 390 |
-
)
|
| 391 |
-
if not next_token:
|
| 392 |
-
break
|
| 393 |
-
params["pageToken"] = next_token
|
| 394 |
-
|
| 395 |
-
_logger.info(
|
| 396 |
-
"ClinicalTrials.gov API response complete",
|
| 397 |
-
extra={"data": {"total_studies": len(all_studies)}},
|
| 398 |
-
)
|
| 399 |
-
return all_studies
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
def _flatten_and_rank(studies: list[dict], patient_lat: float, patient_lon: float) -> list[dict]:
|
| 403 |
-
result = []
|
| 404 |
-
for study in studies:
|
| 405 |
-
proto = study.get("protocolSection", {})
|
| 406 |
-
id_mod = proto.get("identificationModule", {})
|
| 407 |
-
desc_mod = proto.get("descriptionModule", {})
|
| 408 |
-
elig_mod = proto.get("eligibilityModule", {})
|
| 409 |
-
contacts_mod = proto.get("contactsLocationsModule", {})
|
| 410 |
-
sponsor_mod = proto.get("sponsorCollaboratorsModule", {})
|
| 411 |
-
design_mod = proto.get("designModule", {})
|
| 412 |
-
|
| 413 |
-
# Central (overall) contacts
|
| 414 |
-
central_contacts = contacts_mod.get("centralContacts", [])
|
| 415 |
-
central_phone = next((c.get("phone", "") for c in central_contacts if c.get("phone")), "")
|
| 416 |
-
central_email = next((c.get("email", "") for c in central_contacts if c.get("email")), "")
|
| 417 |
-
|
| 418 |
-
# Principal investigator from overallOfficials
|
| 419 |
-
officials = contacts_mod.get("overallOfficials", [])
|
| 420 |
-
pi = next(
|
| 421 |
-
(o.get("name", "") for o in officials if o.get("role") == "PRINCIPAL_INVESTIGATOR"),
|
| 422 |
-
officials[0].get("name", "") if officials else "",
|
| 423 |
-
)
|
| 424 |
-
|
| 425 |
-
sites_with_dist: list[tuple[float, dict]] = []
|
| 426 |
-
for loc in contacts_mod.get("locations", []):
|
| 427 |
-
geo = loc.get("geoPoint", {})
|
| 428 |
-
if geo.get("lat") and geo.get("lon"):
|
| 429 |
-
d = haversine_miles(patient_lat, patient_lon, geo["lat"], geo["lon"])
|
| 430 |
-
loc_contacts = loc.get("contacts", [])
|
| 431 |
-
loc_phone = next((c.get("phone", "") for c in loc_contacts if c.get("phone")), "")
|
| 432 |
-
loc_email = next((c.get("email", "") for c in loc_contacts if c.get("email")), "")
|
| 433 |
-
sites_with_dist.append((d, {
|
| 434 |
-
"label": (
|
| 435 |
-
f"{loc.get('facility', '').strip()} β "
|
| 436 |
-
f"{loc.get('city', '')}, "
|
| 437 |
-
f"{loc.get('state', loc.get('country', ''))} "
|
| 438 |
-
f"({d:.0f} mi)"
|
| 439 |
-
),
|
| 440 |
-
"facility": loc.get("facility", "").strip(),
|
| 441 |
-
"city": loc.get("city", ""),
|
| 442 |
-
"state": loc.get("state", loc.get("country", "")),
|
| 443 |
-
"distance_miles": round(d, 1),
|
| 444 |
-
"phone": loc_phone or central_phone,
|
| 445 |
-
"email": loc_email or central_email,
|
| 446 |
-
}))
|
| 447 |
-
sites_with_dist.sort(key=lambda x: x[0])
|
| 448 |
-
|
| 449 |
-
closest_dist = sites_with_dist[0][0] if sites_with_dist else None
|
| 450 |
-
result.append({
|
| 451 |
-
"nct_id": id_mod.get("nctId", ""),
|
| 452 |
-
"title": id_mod.get("briefTitle", ""),
|
| 453 |
-
"phase": ", ".join(design_mod.get("phases", [])) or "N/A",
|
| 454 |
-
"sponsor": sponsor_mod.get("leadSponsor", {}).get("name", ""),
|
| 455 |
-
"principal_investigator": pi,
|
| 456 |
-
"contact_phone": central_phone,
|
| 457 |
-
"contact_email": central_email,
|
| 458 |
-
"summary": desc_mod.get("briefSummary", "")[:500],
|
| 459 |
-
"eligibility": elig_mod.get("eligibilityCriteria", "")[:1000],
|
| 460 |
-
"min_age": elig_mod.get("minimumAge", ""),
|
| 461 |
-
"max_age": elig_mod.get("maximumAge", ""),
|
| 462 |
-
"closest_site_miles": round(closest_dist, 1) if closest_dist is not None else None,
|
| 463 |
-
"nearest_sites": [info for _, info in sites_with_dist[:5]],
|
| 464 |
-
})
|
| 465 |
-
|
| 466 |
-
result.sort(key=lambda x: x["closest_site_miles"] if x["closest_site_miles"] is not None else float("inf"))
|
| 467 |
-
return result
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
# ββ Intake agent ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 471 |
-
|
| 472 |
-
def run_intake_agent(client: anthropic.Anthropic) -> PatientProfile:
|
| 473 |
-
import datetime
|
| 474 |
-
today = datetime.date.today().strftime("%B %d, %Y")
|
| 475 |
-
|
| 476 |
-
console.print()
|
| 477 |
-
console.print(Panel(
|
| 478 |
-
Text("Beacon β Rare Disease Clinical Trial Finder", justify="center", style="bold cyan"),
|
| 479 |
-
border_style="cyan",
|
| 480 |
-
padding=(1, 4),
|
| 481 |
-
))
|
| 482 |
-
|
| 483 |
-
messages: list[anthropic.types.MessageParam] = [
|
| 484 |
-
{"role": "user", "content": "Please begin."}
|
| 485 |
-
]
|
| 486 |
-
|
| 487 |
-
while True:
|
| 488 |
-
response = client.messages.create(
|
| 489 |
-
model=INTAKE_MODEL,
|
| 490 |
-
max_tokens=1024,
|
| 491 |
-
system=f"Today's date is {today}.\n\n" + INTAKE_SYSTEM,
|
| 492 |
-
tools=[SUBMIT_PROFILE_TOOL],
|
| 493 |
-
messages=messages,
|
| 494 |
-
)
|
| 495 |
-
|
| 496 |
-
text = next((b.text for b in response.content if b.type == "text"), "")
|
| 497 |
-
if text:
|
| 498 |
-
console.print(f"\n[bold cyan]Beacon:[/bold cyan] {text}")
|
| 499 |
-
|
| 500 |
-
tool_block = next(
|
| 501 |
-
(b for b in response.content if b.type == "tool_use" and b.name == "submit_profile"),
|
| 502 |
-
None,
|
| 503 |
-
)
|
| 504 |
-
if tool_block:
|
| 505 |
-
data = tool_block.input
|
| 506 |
-
try:
|
| 507 |
-
with console.status("[cyan]Geocoding locationβ¦[/cyan]", spinner="dots"):
|
| 508 |
-
lat, lon = geocode_zip(data["zip_code"], data.get("country_code", "US"))
|
| 509 |
-
except Exception as exc:
|
| 510 |
-
console.print(f"[yellow]Warning:[/yellow] Geocoding failed ({exc}) β coordinates set to 0,0.")
|
| 511 |
-
lat, lon = 0.0, 0.0
|
| 512 |
-
profile = PatientProfile(
|
| 513 |
-
disease=data["disease"],
|
| 514 |
-
age=data["age"],
|
| 515 |
-
onset_months=data["onset_months"],
|
| 516 |
-
diagnosis_months=data.get("diagnosis_months", 0),
|
| 517 |
-
benchmarks=data.get("benchmarks") or {},
|
| 518 |
-
zip_code=data["zip_code"],
|
| 519 |
-
country_code=data.get("country_code", "US"),
|
| 520 |
-
lat=lat,
|
| 521 |
-
lon=lon,
|
| 522 |
-
radius_miles=data.get("radius_miles", 100),
|
| 523 |
-
phases=data.get("phases") or [],
|
| 524 |
-
include_eap=data.get("include_eap", False),
|
| 525 |
-
include_observational=data.get("include_observational", False),
|
| 526 |
-
)
|
| 527 |
-
_logger.info(
|
| 528 |
-
"Patient intake complete (CLI)",
|
| 529 |
-
extra={"data": {"intake_summary": asdict(profile)}},
|
| 530 |
-
)
|
| 531 |
-
return profile
|
| 532 |
-
|
| 533 |
-
messages.append({"role": "assistant", "content": response.content})
|
| 534 |
-
user_input = input("\nYou: ").strip() or "(no response)"
|
| 535 |
-
messages.append({"role": "user", "content": user_input})
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
# ββ Research agent ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 539 |
-
|
| 540 |
-
def run_research_agent(client: anthropic.Anthropic, profile: PatientProfile) -> str:
|
| 541 |
-
messages: list[anthropic.types.MessageParam] = [
|
| 542 |
-
{
|
| 543 |
-
"role": "user",
|
| 544 |
-
"content": (
|
| 545 |
-
f"Find clinical trials for this patient:\n\n{profile.summary()}\n\n"
|
| 546 |
-
"Search within the specified radius and rank results by distance."
|
| 547 |
-
),
|
| 548 |
-
}
|
| 549 |
-
]
|
| 550 |
-
|
| 551 |
-
while True:
|
| 552 |
-
response = client.messages.create(
|
| 553 |
-
model=RESEARCH_MODEL,
|
| 554 |
-
max_tokens=8096,
|
| 555 |
-
system=RESEARCH_SYSTEM,
|
| 556 |
-
tools=[SEARCH_TRIALS_TOOL],
|
| 557 |
-
messages=messages,
|
| 558 |
-
)
|
| 559 |
-
|
| 560 |
-
messages.append({"role": "assistant", "content": response.content})
|
| 561 |
-
|
| 562 |
-
if response.stop_reason == "end_turn":
|
| 563 |
-
return next(
|
| 564 |
-
(b.text for b in response.content if b.type == "text"),
|
| 565 |
-
"No analysis produced.",
|
| 566 |
-
)
|
| 567 |
-
|
| 568 |
-
tool_results: list[anthropic.types.ToolResultBlockParam] = []
|
| 569 |
-
for block in response.content:
|
| 570 |
-
if block.type != "tool_use" or block.name != "search_clinical_trials":
|
| 571 |
-
continue
|
| 572 |
-
args = block.input
|
| 573 |
-
radius = args.get("radius_miles", profile.radius_miles)
|
| 574 |
-
phases = args.get("phases") or None
|
| 575 |
-
study_type = args.get("study_type", "INTERVENTIONAL")
|
| 576 |
-
status_msg = (
|
| 577 |
-
f"[cyan]Searching:[/cyan] '[bold]{args['condition']}[/bold]' | "
|
| 578 |
-
f"radius=[bold]{radius}[/bold] mi | "
|
| 579 |
-
f"type=[bold]{study_type}[/bold] | "
|
| 580 |
-
f"phases=[bold]{phases or 'all'}[/bold]"
|
| 581 |
-
)
|
| 582 |
-
try:
|
| 583 |
-
with console.status(status_msg, spinner="dots"):
|
| 584 |
-
studies = search_trials_api(
|
| 585 |
-
condition=args["condition"],
|
| 586 |
-
lat=args["lat"],
|
| 587 |
-
lon=args["lon"],
|
| 588 |
-
radius_miles=radius,
|
| 589 |
-
phases=phases,
|
| 590 |
-
study_type=study_type,
|
| 591 |
-
max_results=args.get("max_results", 20),
|
| 592 |
-
)
|
| 593 |
-
ranked = _flatten_and_rank(studies, profile.lat, profile.lon)
|
| 594 |
-
console.print(f" [green]β[/green] {len(ranked)} trial(s) found.")
|
| 595 |
-
content = json.dumps(ranked)
|
| 596 |
-
is_error = False
|
| 597 |
-
except Exception as exc:
|
| 598 |
-
console.print(f"[red bold]API error:[/red bold] {exc}")
|
| 599 |
-
content = f"API request failed: {exc}. The ClinicalTrials.gov endpoint may be temporarily unavailable."
|
| 600 |
-
is_error = True
|
| 601 |
-
tool_results.append({
|
| 602 |
-
"type": "tool_result",
|
| 603 |
-
"tool_use_id": block.id,
|
| 604 |
-
"content": content,
|
| 605 |
-
"is_error": is_error,
|
| 606 |
-
})
|
| 607 |
-
|
| 608 |
-
messages.append({"role": "user", "content": tool_results})
|
| 609 |
-
|
| 610 |
|
| 611 |
-
# ββ LangGraph βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 612 |
|
| 613 |
class BeaconState(TypedDict):
|
| 614 |
profile: Optional[PatientProfile]
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from typing import Optional, TypedDict
|
| 4 |
|
|
|
|
|
|
|
|
|
|
| 5 |
import anthropic
|
|
|
|
| 6 |
from rich import box
|
|
|
|
| 7 |
from rich.markdown import Markdown
|
| 8 |
from rich.panel import Panel
|
|
|
|
| 9 |
from langgraph.graph import StateGraph, START, END
|
| 10 |
|
| 11 |
+
# Re-exports β backward compat for app.py and any external callers
|
| 12 |
+
from config import CTGOV_BASE, INTAKE_MODEL, RESEARCH_MODEL # noqa: F401
|
| 13 |
+
from models import PatientProfile, geocode_zip, haversine_miles # noqa: F401
|
| 14 |
+
from prompts import INTAKE_SYSTEM, RESEARCH_SYSTEM, build_intake_system, lookup_disease_profile # noqa: F401
|
| 15 |
+
from tools import SUBMIT_PROFILE_TOOL, SEARCH_TRIALS_TOOL, IDENTIFY_DISEASE_TOOL, INTAKE_TOOLS, RESEARCH_TOOLS # noqa: F401
|
| 16 |
+
from trials_api import search_trials_api, _flatten_and_rank # noqa: F401
|
| 17 |
+
from agents import run_intake_agent, run_research_agent, intake_greeting, stream_intake_turn, stream_research_agent # noqa: F401
|
| 18 |
+
from _console import console
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
|
|
|
| 20 |
|
| 21 |
class BeaconState(TypedDict):
|
| 22 |
profile: Optional[PatientProfile]
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CTGOV_BASE = "https://clinicaltrials.gov/api/v2/studies"
|
| 2 |
+
INTAKE_MODEL = "claude-sonnet-4-6"
|
| 3 |
+
RESEARCH_MODEL = "claude-opus-4-7"
|
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "als",
|
| 3 |
+
"full_name": "Amyotrophic Lateral Sclerosis",
|
| 4 |
+
"synonyms": ["ALS", "Lou Gehrig's disease", "Lou Gehrig's", "motor neuron disease", "MND"],
|
| 5 |
+
"benchmarks": [
|
| 6 |
+
{
|
| 7 |
+
"key": "ALSFRS-R",
|
| 8 |
+
"label": "ALSFRS-R",
|
| 9 |
+
"range": "0-48",
|
| 10 |
+
"guidance": "ALSFRS-R (ALS Functional Rating Scale-Revised) measures functional status across 12 domains (speech, swallowing, handwriting, walking, breathing, etc.). 48 = fully functional, 0 = total loss of function. Ask the patient for their most recent score."
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"key": "FVC %",
|
| 14 |
+
"label": "FVC % predicted",
|
| 15 |
+
"range": "0-100%",
|
| 16 |
+
"guidance": "FVC (Forced Vital Capacity) measures how much air a person can forcibly exhale β it reflects respiratory muscle strength. In ALS it is expressed as a percentage of the value expected for someone of the same age/height/sex (e.g. '72%'). Many trials require FVC β₯ 50% or β₯ 60% for enrollment. If the patient has had recent pulmonary function testing, ask for their FVC % predicted."
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"key": "ALS subtype",
|
| 20 |
+
"label": "ALS subtype",
|
| 21 |
+
"range": null,
|
| 22 |
+
"guidance": "Ask whether the patient has sporadic ALS (no family history, ~90β95% of cases) or familial/genetic ALS (inherited; ~5β10% of cases). If familial, ask which gene mutation is involved if they know it (common ones: SOD1, C9orf72, FUS, TDP-43). This affects trial eligibility β many gene-targeted trials require a confirmed mutation. The patient may skip if unknown. Store as e.g. {\"ALS subtype\": \"sporadic\"} or {\"ALS subtype\": \"familial\", \"ALS gene\": \"SOD1\"}."
|
| 23 |
+
}
|
| 24 |
+
]
|
| 25 |
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "duchenne",
|
| 3 |
+
"full_name": "Duchenne Muscular Dystrophy",
|
| 4 |
+
"synonyms": ["DMD", "Duchenne", "Duchenne muscular dystrophy"],
|
| 5 |
+
"benchmarks": [
|
| 6 |
+
{
|
| 7 |
+
"key": "6MWT",
|
| 8 |
+
"label": "6-Minute Walk Test",
|
| 9 |
+
"range": null,
|
| 10 |
+
"guidance": "The 6-Minute Walk Test (6MWT) measures how far a patient can walk in 6 minutes (in meters). It is the primary endpoint in many DMD trials. Ask the patient for their most recent result if they know it."
|
| 11 |
+
}
|
| 12 |
+
]
|
| 13 |
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "friedreichs",
|
| 3 |
+
"full_name": "Friedreich's Ataxia",
|
| 4 |
+
"synonyms": ["FA", "Friedreich's ataxia", "Friedreich ataxia", "FRDA"],
|
| 5 |
+
"benchmarks": [
|
| 6 |
+
{
|
| 7 |
+
"key": "SARA",
|
| 8 |
+
"label": "SARA score",
|
| 9 |
+
"range": "0-40",
|
| 10 |
+
"guidance": "SARA (Scale for the Assessment and Rating of Ataxia) scores ataxia severity across gait, stance, sitting, speech, finger chase, nose-finger test, fast alternating movements, and heel-shin test. 0 = no ataxia, 40 = most severe. Ask the patient for their most recent score if they know it."
|
| 11 |
+
}
|
| 12 |
+
]
|
| 13 |
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "huntingtons",
|
| 3 |
+
"full_name": "Huntington's Disease",
|
| 4 |
+
"synonyms": ["Huntington's", "Huntington's disease", "HD", "Huntington disease"],
|
| 5 |
+
"benchmarks": [
|
| 6 |
+
{
|
| 7 |
+
"key": "TFC",
|
| 8 |
+
"label": "TFC",
|
| 9 |
+
"range": "0-13",
|
| 10 |
+
"guidance": "TFC (Total Functional Capacity) measures ability to engage in work, finances, domestic chores, activities of daily living, and care level. 13 = fully functional, 0 = total dependence. Ask the patient for their most recent score if they know it."
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"key": "CAG repeats",
|
| 14 |
+
"label": "CAG repeats",
|
| 15 |
+
"range": null,
|
| 16 |
+
"guidance": "The number of CAG trinucleotide repeats in the HTT gene. β₯36 repeats is associated with Huntington's; β₯40 is fully penetrant. Many trials stratify by CAG repeat length. Ask the patient if they know their CAG repeat count from genetic testing."
|
| 17 |
+
}
|
| 18 |
+
]
|
| 19 |
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "ms",
|
| 3 |
+
"full_name": "Multiple Sclerosis",
|
| 4 |
+
"synonyms": ["MS", "multiple sclerosis", "relapsing-remitting MS", "RRMS", "PPMS", "SPMS"],
|
| 5 |
+
"benchmarks": [
|
| 6 |
+
{
|
| 7 |
+
"key": "EDSS",
|
| 8 |
+
"label": "EDSS",
|
| 9 |
+
"range": "0-10",
|
| 10 |
+
"guidance": "EDSS (Expanded Disability Status Scale) measures neurological disability. 0 = normal neurological exam, 10 = death due to MS. Ask the patient for their most recent EDSS score if they know it."
|
| 11 |
+
}
|
| 12 |
+
]
|
| 13 |
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "parkinsons",
|
| 3 |
+
"full_name": "Parkinson's Disease",
|
| 4 |
+
"synonyms": ["Parkinson's", "Parkinson's disease", "PD", "Parkinsonism"],
|
| 5 |
+
"benchmarks": [
|
| 6 |
+
{
|
| 7 |
+
"key": "MDS-UPDRS III",
|
| 8 |
+
"label": "MDS-UPDRS III",
|
| 9 |
+
"range": "0-132",
|
| 10 |
+
"guidance": "MDS-UPDRS Part III (Movement Disorder Society Unified Parkinson's Disease Rating Scale, motor examination) assesses motor symptoms. Lower is better. Ask the patient for their most recent score if they know it."
|
| 11 |
+
}
|
| 12 |
+
]
|
| 13 |
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "pompe",
|
| 3 |
+
"full_name": "Pompe Disease",
|
| 4 |
+
"synonyms": ["Pompe", "Pompe disease", "glycogen storage disease type II", "acid maltase deficiency", "GSDII"],
|
| 5 |
+
"benchmarks": [
|
| 6 |
+
{
|
| 7 |
+
"key": "6MWT",
|
| 8 |
+
"label": "6-Minute Walk Test",
|
| 9 |
+
"range": null,
|
| 10 |
+
"guidance": "The 6-Minute Walk Test (6MWT) measures how far a patient can walk in 6 minutes (in meters). It is used as a key functional endpoint in Pompe disease trials. Ask the patient for their most recent result if they know it."
|
| 11 |
+
}
|
| 12 |
+
]
|
| 13 |
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "sma",
|
| 3 |
+
"full_name": "Spinal Muscular Atrophy",
|
| 4 |
+
"synonyms": ["SMA", "spinal muscular atrophy", "SMA type 1", "SMA type 2", "SMA type 3"],
|
| 5 |
+
"benchmarks": [
|
| 6 |
+
{
|
| 7 |
+
"key": "HFMS",
|
| 8 |
+
"label": "HFMS",
|
| 9 |
+
"range": "0-40",
|
| 10 |
+
"guidance": "HFMS (Hammersmith Functional Motor Scale) assesses motor function. Ask the patient for their most recent score if they know it."
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"key": "SMA type",
|
| 14 |
+
"label": "SMA type",
|
| 15 |
+
"range": null,
|
| 16 |
+
"guidance": "SMA type affects trial eligibility. Type 1 (never sat), Type 2 (sits but never walked), Type 3 (walked independently, onset after 18 months). Ask the patient which type they have."
|
| 17 |
+
}
|
| 18 |
+
]
|
| 19 |
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"type": "object",
|
| 3 |
+
"properties": {
|
| 4 |
+
"standardized_name": {
|
| 5 |
+
"type": "string",
|
| 6 |
+
"description": "Full standardized medical name of the patient's disease (e.g. 'Amyotrophic Lateral Sclerosis')"
|
| 7 |
+
}
|
| 8 |
+
},
|
| 9 |
+
"required": ["standardized_name"]
|
| 10 |
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"type": "object",
|
| 3 |
+
"properties": {
|
| 4 |
+
"condition": {
|
| 5 |
+
"type": "string",
|
| 6 |
+
"description": "Disease / condition to search (medical name and/or abbreviation)"
|
| 7 |
+
},
|
| 8 |
+
"lat": { "type": "number", "description": "Patient latitude" },
|
| 9 |
+
"lon": { "type": "number", "description": "Patient longitude" },
|
| 10 |
+
"radius_miles": { "type": "integer", "description": "Search radius in miles" },
|
| 11 |
+
"phases": {
|
| 12 |
+
"type": "array",
|
| 13 |
+
"items": { "type": "string" },
|
| 14 |
+
"description": "Phase numbers to filter e.g. ['1','2','3']. IMPORTANT: Never enumerate all phases to mean 'all phases' β pass an empty array [] instead. NA-phase trials (device feasibility, unphased studies) only appear when phases=[] (no filter). Ignored for EAP."
|
| 15 |
+
},
|
| 16 |
+
"study_type": {
|
| 17 |
+
"type": "string",
|
| 18 |
+
"enum": ["INTERVENTIONAL", "EXPANDED_ACCESS", "OBSERVATIONAL"],
|
| 19 |
+
"description": "INTERVENTIONAL (default) for clinical trials; EXPANDED_ACCESS for EAP/compassionate use; OBSERVATIONAL for observational studies."
|
| 20 |
+
}
|
| 21 |
+
},
|
| 22 |
+
"required": ["condition", "lat", "lon", "radius_miles"]
|
| 23 |
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"type": "object",
|
| 3 |
+
"properties": {
|
| 4 |
+
"disease": {
|
| 5 |
+
"type": "string",
|
| 6 |
+
"description": "Full medical name (e.g. 'Amyotrophic Lateral Sclerosis')"
|
| 7 |
+
},
|
| 8 |
+
"age": { "type": "integer" },
|
| 9 |
+
"onset_months": {
|
| 10 |
+
"type": "integer",
|
| 11 |
+
"description": "Months since first symptom onset"
|
| 12 |
+
},
|
| 13 |
+
"diagnosis_months": {
|
| 14 |
+
"type": "integer",
|
| 15 |
+
"description": "Months since formal/official diagnosis"
|
| 16 |
+
},
|
| 17 |
+
"benchmarks": {
|
| 18 |
+
"type": "object",
|
| 19 |
+
"description": "Disease-specific scores, e.g. {\"ALSFRS-R\": \"38\"}",
|
| 20 |
+
"additionalProperties": { "type": "string" }
|
| 21 |
+
},
|
| 22 |
+
"zip_code": { "type": "string", "description": "Patient ZIP / postal code" },
|
| 23 |
+
"country_code": {
|
| 24 |
+
"type": "string",
|
| 25 |
+
"description": "ISO 2-letter country code (default US)"
|
| 26 |
+
},
|
| 27 |
+
"radius_miles": {
|
| 28 |
+
"type": "integer",
|
| 29 |
+
"description": "Search radius in miles from patient location (default 100)"
|
| 30 |
+
},
|
| 31 |
+
"phases": {
|
| 32 |
+
"type": "array",
|
| 33 |
+
"items": { "type": "string", "enum": ["0", "1", "2", "3", "4", "na"] },
|
| 34 |
+
"description": "Desired trial phases (0=Early Phase 1, 1=Phase 1, 2=Phase 2, 3=Phase 3, 4=Phase 4, na=Not Applicable). Empty = all phases."
|
| 35 |
+
},
|
| 36 |
+
"include_eap": {
|
| 37 |
+
"type": "boolean",
|
| 38 |
+
"description": "Whether patient is interested in Expanded Access Programs (compassionate use)"
|
| 39 |
+
},
|
| 40 |
+
"include_observational": {
|
| 41 |
+
"type": "boolean",
|
| 42 |
+
"description": "Whether patient is interested in observational studies (no experimental treatment; researchers observe and measure outcomes)"
|
| 43 |
+
}
|
| 44 |
+
},
|
| 45 |
+
"required": ["disease", "age", "onset_months", "diagnosis_months", "zip_code"]
|
| 46 |
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class PatientProfile:
|
| 11 |
+
disease: str
|
| 12 |
+
age: int
|
| 13 |
+
onset_months: int
|
| 14 |
+
diagnosis_months: int = 0
|
| 15 |
+
benchmarks: dict[str, str] = field(default_factory=dict)
|
| 16 |
+
zip_code: str = ""
|
| 17 |
+
country_code: str = "US"
|
| 18 |
+
lat: float = 0.0
|
| 19 |
+
lon: float = 0.0
|
| 20 |
+
radius_miles: int = 100
|
| 21 |
+
phases: list[str] = field(default_factory=list)
|
| 22 |
+
include_eap: bool = False
|
| 23 |
+
include_observational: bool = False
|
| 24 |
+
lang: str = "en"
|
| 25 |
+
|
| 26 |
+
def summary(self) -> str:
|
| 27 |
+
lines = [
|
| 28 |
+
f"Disease: {self.disease}",
|
| 29 |
+
f"Age: {self.age}",
|
| 30 |
+
f"Symptom onset: {self.onset_months} months ago",
|
| 31 |
+
f"Formal diagnosis: {self.diagnosis_months} months ago",
|
| 32 |
+
]
|
| 33 |
+
if self.benchmarks:
|
| 34 |
+
lines.append("Benchmarks: " + ", ".join(f"{k}={v}" for k, v in self.benchmarks.items()))
|
| 35 |
+
lines.append(
|
| 36 |
+
f"Location: ZIP {self.zip_code}, {self.country_code} "
|
| 37 |
+
f"(lat={self.lat:.4f}, lon={self.lon:.4f})"
|
| 38 |
+
)
|
| 39 |
+
lines.append(f"Search radius: {self.radius_miles} miles")
|
| 40 |
+
if self.phases:
|
| 41 |
+
def _phase_label(p: str) -> str:
|
| 42 |
+
if p == "0":
|
| 43 |
+
return "Early Phase 1"
|
| 44 |
+
if p == "na":
|
| 45 |
+
return "Not Applicable"
|
| 46 |
+
return f"Phase {p}"
|
| 47 |
+
labels = [_phase_label(p) for p in self.phases]
|
| 48 |
+
lines.append(f"Phases: {', '.join(labels)}")
|
| 49 |
+
interests = ["Clinical trials"]
|
| 50 |
+
if self.include_observational:
|
| 51 |
+
interests.append("Observational studies")
|
| 52 |
+
if self.include_eap:
|
| 53 |
+
interests.append("Expanded Access Programs (EAP)")
|
| 54 |
+
lines.append(f"Study type interest: {', '.join(interests)}")
|
| 55 |
+
return "\n".join(lines)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def geocode_zip(zip_code: str, country_code: str = "US") -> tuple[float, float]:
|
| 59 |
+
resp = httpx.get(
|
| 60 |
+
"https://nominatim.openstreetmap.org/search",
|
| 61 |
+
params={"postalcode": zip_code, "country": country_code, "format": "json", "limit": 1},
|
| 62 |
+
headers={"User-Agent": "Beacon-ClinicalTrialFinder/1.0"},
|
| 63 |
+
timeout=10,
|
| 64 |
+
)
|
| 65 |
+
resp.raise_for_status()
|
| 66 |
+
results = resp.json()
|
| 67 |
+
if not results:
|
| 68 |
+
raise ValueError(f"Cannot geocode ZIP {zip_code!r} in {country_code!r}")
|
| 69 |
+
return float(results[0]["lat"]), float(results[0]["lon"])
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 73 |
+
R = 3958.8
|
| 74 |
+
Ο1, Ο2 = math.radians(lat1), math.radians(lat2)
|
| 75 |
+
dΟ, dΞ» = math.radians(lat2 - lat1), math.radians(lon2 - lon1)
|
| 76 |
+
a = math.sin(dΟ / 2) ** 2 + math.cos(Ο1) * math.cos(Ο2) * math.sin(dΞ» / 2) ** 2
|
| 77 |
+
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
_DISEASES_DIR = Path(__file__).parent / "data" / "diseases"
|
| 7 |
+
|
| 8 |
+
_ALL_DISEASES: list[dict] = [
|
| 9 |
+
json.loads(p.read_text())
|
| 10 |
+
for p in sorted(_DISEASES_DIR.glob("*.json"))
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def lookup_disease_profile(standardized_name: str) -> dict | None:
|
| 15 |
+
"""Match an LLM-normalized disease name to a registry profile."""
|
| 16 |
+
lower = standardized_name.lower().strip()
|
| 17 |
+
for disease in _ALL_DISEASES:
|
| 18 |
+
if disease["full_name"].lower() == lower:
|
| 19 |
+
return disease
|
| 20 |
+
for synonym in disease.get("synonyms", []):
|
| 21 |
+
if synonym.lower() == lower:
|
| 22 |
+
return disease
|
| 23 |
+
# Partial containment fallback (e.g. "ALS (Amyotrophic Lateral Sclerosis)")
|
| 24 |
+
for disease in _ALL_DISEASES:
|
| 25 |
+
if disease["full_name"].lower() in lower or lower in disease["full_name"].lower():
|
| 26 |
+
return disease
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
INTAKE_SYSTEM = """\
|
| 32 |
+
You are Beacon's patient intake specialist for rare disease clinical trials.
|
| 33 |
+
Collect the following through a warm, conversational interview β do NOT present a form.
|
| 34 |
+
|
| 35 |
+
REQUIRED:
|
| 36 |
+
β’ Disease/condition (standardize: "Lou Gehrig's" β "Amyotrophic Lateral Sclerosis")
|
| 37 |
+
β’ Patient age
|
| 38 |
+
β’ Months since first symptom onset (convert dates/years as needed)
|
| 39 |
+
β’ Months since formal/official diagnosis (convert dates/years as needed; may differ from onset)
|
| 40 |
+
β’ ZIP/postal code and country for geographic search
|
| 41 |
+
|
| 42 |
+
OPTIONAL (disease-specific benchmarks):
|
| 43 |
+
As soon as you know the patient's disease, call identify_disease(standardized_name=...).
|
| 44 |
+
The tool response will list the benchmark scores to collect for that specific disease.
|
| 45 |
+
If the disease is not recognized, skip benchmarks and proceed with required fields only.
|
| 46 |
+
If the patient describes symptoms without naming a disease, ask clarifying questions first.
|
| 47 |
+
|
| 48 |
+
β’ Preferred search radius in miles (default 100)
|
| 49 |
+
β’ Study types of interest β ask whether the patient wants:
|
| 50 |
+
- Clinical trials. Briefly explain the phases so the patient can choose:
|
| 51 |
+
Early Phase 1 β First-in-human safety testing; tiny doses, very small group (~10β15 people);
|
| 52 |
+
no efficacy data yet; highest uncertainty.
|
| 53 |
+
Phase 1 β Establishes safe dosage range and identifies side effects;
|
| 54 |
+
small group (20β80 people); primary goal is safety, not treatment.
|
| 55 |
+
Phase 2 β Tests whether the treatment works and further evaluates safety;
|
| 56 |
+
larger group (100β300 people); patients more likely to receive active drug.
|
| 57 |
+
Phase 3 β Compares treatment against current standard of care in a large group
|
| 58 |
+
(1,000β3,000 people); required for regulatory approval; best efficacy evidence.
|
| 59 |
+
Phase 4 β Post-approval surveillance; treatment is already FDA-approved;
|
| 60 |
+
studies long-term safety, rare side effects, and new uses.
|
| 61 |
+
Not Applicable β Studies that do not fall into the standard phase framework
|
| 62 |
+
(e.g., device feasibility studies, behavioral/observational trials,
|
| 63 |
+
or studies where phase designation is not required by FDA).
|
| 64 |
+
- Observational studies β studies where researchers observe participants and collect
|
| 65 |
+
data without assigning treatments. No experimental drug or intervention is given.
|
| 66 |
+
Patients may contribute valuable data to disease understanding, registries, or
|
| 67 |
+
natural history studies. Often have broader eligibility than interventional trials.
|
| 68 |
+
- Expanded Access Programs (EAP / compassionate use) β a pathway for patients who
|
| 69 |
+
do not qualify for or cannot access a clinical trial to receive an investigational
|
| 70 |
+
drug, biologic, or device outside of a trial. Also called "compassionate use."
|
| 71 |
+
The treatment is not yet FDA-approved; a physician must submit the EAP request
|
| 72 |
+
to the drug sponsor and obtain FDA authorization. EAP does not guarantee efficacy
|
| 73 |
+
but may be an option when no approved treatments remain.
|
| 74 |
+
- Or any combination; or all types (default if no preference)
|
| 75 |
+
|
| 76 |
+
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.\
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def build_intake_system() -> str:
|
| 81 |
+
"""Kept for backward compatibility. Returns INTAKE_SYSTEM unchanged."""
|
| 82 |
+
return INTAKE_SYSTEM
|
| 83 |
+
|
| 84 |
+
RESEARCH_SYSTEM = """\
|
| 85 |
+
You are Beacon, an expert rare-disease clinical trial navigator.
|
| 86 |
+
You have a search_clinical_trials tool that queries ClinicalTrials.gov in real time.
|
| 87 |
+
Results are already ranked by geographic distance from the patient.
|
| 88 |
+
|
| 89 |
+
Workflow:
|
| 90 |
+
1. Search for the patient's disease. Use both the full medical name and common abbreviation.
|
| 91 |
+
- If the patient wants clinical trials, search with study_type="INTERVENTIONAL".
|
| 92 |
+
- If the patient wants observational studies, also search with study_type="OBSERVATIONAL".
|
| 93 |
+
- If the patient wants Expanded Access Programs (EAP), also search with study_type="EXPANDED_ACCESS".
|
| 94 |
+
- Run a separate search for each study_type the patient is interested in.
|
| 95 |
+
2. IMPORTANT β phase filtering: Never pass phases=["1","2","3","4"] to mean "all phases."
|
| 96 |
+
Always pass phases=[] (omit the field) when the patient has no phase preference.
|
| 97 |
+
NA-phase trials (device feasibility studies, unphased interventions) only appear
|
| 98 |
+
when no phase filter is applied. Passing explicit phase numbers silently excludes them.
|
| 99 |
+
Phase filters do not apply to OBSERVATIONAL or EXPANDED_ACCESS searches.
|
| 100 |
+
3. If fewer than 3 results are found, retry with: a wider radius, a disease synonym,
|
| 101 |
+
or drop phase filters entirely (phases=[]).
|
| 102 |
+
4. Produce a final report. Use separate sections for Clinical Trials, Observational Studies, and Expanded Access as applicable.
|
| 103 |
+
List the top 10 results per section ranked by site proximity.
|
| 104 |
+
For EACH entry use exactly this format (repeat the block per entry):
|
| 105 |
+
|
| 106 |
+
π **[Closest hospital/facility name]** β [City, State] ([X] mi)
|
| 107 |
+
**Trial:** [Full title] ([Phase] β or "Observational" / "Expanded Access" as applicable)
|
| 108 |
+
**Sponsor:** [Lead sponsor]
|
| 109 |
+
**Principal Investigator:** [Name β or "Not listed" if absent]
|
| 110 |
+
**Contact:** [Phone number] | [Email address] (use "Not listed" for any missing field)
|
| 111 |
+
**Summary:** [2β3 sentence plain-language description of what the trial/program is testing
|
| 112 |
+
and why it may matter for this patient]
|
| 113 |
+
**Qualification criteria:** [Key inclusion AND exclusion criteria relevant to this patient,
|
| 114 |
+
including age range, functional score thresholds, FVC cutoffs,
|
| 115 |
+
and any red flags. Be specific β use exact numbers from the data.]
|
| 116 |
+
**Link:** https://clinicaltrials.gov/study/[NCT_ID]
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
|
| 120 |
+
5. After the results add a short "Next steps" section (bullet points).
|
| 121 |
+
For observational studies, note that participation typically involves check-ins, surveys, or sample collection with no experimental treatment.
|
| 122 |
+
For EAP results, note that patients typically need a physician to submit the EAP request.
|
| 123 |
+
|
| 124 |
+
IMPORTANT: Only report trials returned by the search_clinical_trials tool. Do NOT suggest,
|
| 125 |
+
list, or recommend any hospitals, centers, or trials that were not in the tool results β
|
| 126 |
+
even well-known institutions. If no results are found, say so clearly and suggest the patient
|
| 127 |
+
ask their neurologist or contact the ALS Association for a referral.
|
| 128 |
+
|
| 129 |
+
Be accurate. Do not fabricate details. If data is missing, say so.\
|
| 130 |
+
"""
|
|
@@ -10,5 +10,5 @@ dependencies = [
|
|
| 10 |
"openai>=1.0.0",
|
| 11 |
"python-dotenv>=1.2.2",
|
| 12 |
"rich>=13.0.0",
|
| 13 |
-
"gradio>=
|
| 14 |
]
|
|
|
|
| 10 |
"openai>=1.0.0",
|
| 11 |
"python-dotenv>=1.2.2",
|
| 12 |
"rich>=13.0.0",
|
| 13 |
+
"gradio>=6.14.0,<7.0.0",
|
| 14 |
]
|
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import anthropic
|
| 7 |
+
|
| 8 |
+
_DATA = Path(__file__).parent / "data" / "tools"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _load(name: str) -> dict:
|
| 12 |
+
return json.loads((_DATA / f"{name}.json").read_text())
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
SUBMIT_PROFILE_TOOL: anthropic.types.ToolParam = {
|
| 16 |
+
"name": "submit_profile",
|
| 17 |
+
"description": (
|
| 18 |
+
"Call this when you have collected all required information. "
|
| 19 |
+
"Standardize the disease name to its full medical term."
|
| 20 |
+
),
|
| 21 |
+
"input_schema": _load("submit_profile"),
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
IDENTIFY_DISEASE_TOOL: anthropic.types.ToolParam = {
|
| 25 |
+
"name": "identify_disease",
|
| 26 |
+
"description": (
|
| 27 |
+
"Call this as soon as you have identified the patient's disease β "
|
| 28 |
+
"either from a direct statement or from symptom description. "
|
| 29 |
+
"The response tells you which benchmark scores to collect for that disease. "
|
| 30 |
+
"Call this before asking any disease-specific questions."
|
| 31 |
+
),
|
| 32 |
+
"input_schema": _load("identify_disease"),
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
SEARCH_TRIALS_TOOL: anthropic.types.ToolParam = {
|
| 36 |
+
"name": "search_clinical_trials",
|
| 37 |
+
"description": (
|
| 38 |
+
"Search ClinicalTrials.gov for studies within a geographic radius. "
|
| 39 |
+
"Results are pre-ranked by distance from the patient's location. "
|
| 40 |
+
"Call multiple times with different parameters (synonyms, broader radius, "
|
| 41 |
+
"different phases) if initial results are sparse. "
|
| 42 |
+
"Use study_type='EXPANDED_ACCESS' to search for Expanded Access Programs (EAP / compassionate use). "
|
| 43 |
+
"Use study_type='OBSERVATIONAL' to search for observational studies (no experimental treatment assigned)."
|
| 44 |
+
),
|
| 45 |
+
"input_schema": _load("search_trials"),
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
INTAKE_TOOLS: list[anthropic.types.ToolParam] = [SUBMIT_PROFILE_TOOL, IDENTIFY_DISEASE_TOOL]
|
| 49 |
+
RESEARCH_TOOLS: list[anthropic.types.ToolParam] = [SEARCH_TRIALS_TOOL]
|
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
|
| 7 |
+
from beacon_logging import get_logger
|
| 8 |
+
from config import CTGOV_BASE
|
| 9 |
+
from models import haversine_miles
|
| 10 |
+
from _console import console
|
| 11 |
+
|
| 12 |
+
_logger = get_logger("trials_api")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def search_trials_api(
|
| 16 |
+
condition: str,
|
| 17 |
+
lat: float,
|
| 18 |
+
lon: float,
|
| 19 |
+
radius_miles: int = 100,
|
| 20 |
+
phases: list[str] | None = None,
|
| 21 |
+
study_type: str = "INTERVENTIONAL",
|
| 22 |
+
) -> list[dict]:
|
| 23 |
+
is_eap = study_type == "EXPANDED_ACCESS"
|
| 24 |
+
is_observational = study_type == "OBSERVATIONAL"
|
| 25 |
+
params: dict[str, str | int] = {
|
| 26 |
+
"query.cond": condition,
|
| 27 |
+
"filter.overallStatus": "AVAILABLE" if is_eap else "RECRUITING",
|
| 28 |
+
"filter.geo": f"distance({lat},{lon},{radius_miles}mi)",
|
| 29 |
+
"pageSize": 200,
|
| 30 |
+
"format": "json",
|
| 31 |
+
}
|
| 32 |
+
# aggFilters supports comma-separated keys (e.g. "studyType:exp,phase:3 4").
|
| 33 |
+
# RECRUITING status already excludes EAPs, so studyType:int is only needed
|
| 34 |
+
# when no phase filter is applied. studyType:int returns all phases including N/A.
|
| 35 |
+
# Observational studies use studyType:obs; phases don't apply to them.
|
| 36 |
+
#
|
| 37 |
+
# Case matrix:
|
| 38 |
+
# EAP only β studyType:exp
|
| 39 |
+
# EAP + specific phases β studyType:exp,phase:X Y (combine both filters)
|
| 40 |
+
# EAP + all phases β studyType:exp (no phase filter needed)
|
| 41 |
+
# Interventional, specific β phase:X Y
|
| 42 |
+
# Interventional, all/NA β studyType:int (returns NA trials too)
|
| 43 |
+
if is_eap:
|
| 44 |
+
numbered = [p for p in (phases or []) if p != "na"]
|
| 45 |
+
if numbered:
|
| 46 |
+
params["aggFilters"] = "studyType:exp,phase:" + " ".join(numbered)
|
| 47 |
+
else:
|
| 48 |
+
params["aggFilters"] = "studyType:exp"
|
| 49 |
+
elif is_observational:
|
| 50 |
+
params["aggFilters"] = "studyType:obs"
|
| 51 |
+
elif phases:
|
| 52 |
+
# Exclude "na" from the phase filter β N/A trials have no phase value to match on;
|
| 53 |
+
# they appear naturally when no phase filter is applied (studyType:int branch).
|
| 54 |
+
numbered = [p for p in phases if p != "na"]
|
| 55 |
+
if numbered:
|
| 56 |
+
params["aggFilters"] = "phase:" + " ".join(numbered)
|
| 57 |
+
else:
|
| 58 |
+
params["aggFilters"] = "studyType:int"
|
| 59 |
+
else:
|
| 60 |
+
params["aggFilters"] = "studyType:int"
|
| 61 |
+
|
| 62 |
+
_logger.info(
|
| 63 |
+
"ClinicalTrials.gov API request",
|
| 64 |
+
extra={"data": {"endpoint": CTGOV_BASE, "params": dict(params)}},
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
all_studies: list[dict] = []
|
| 68 |
+
while True:
|
| 69 |
+
for attempt in range(3):
|
| 70 |
+
try:
|
| 71 |
+
resp = httpx.get(CTGOV_BASE, params=params, timeout=30)
|
| 72 |
+
resp.raise_for_status()
|
| 73 |
+
body = resp.json()
|
| 74 |
+
break
|
| 75 |
+
except httpx.HTTPError as exc:
|
| 76 |
+
if attempt == 2:
|
| 77 |
+
raise
|
| 78 |
+
wait = 2 ** attempt
|
| 79 |
+
console.print(f"[yellow]API warning:[/yellow] {exc} β retrying in {wait}s (attempt {attempt + 1}/3)β¦")
|
| 80 |
+
time.sleep(wait)
|
| 81 |
+
page_studies = body.get("studies", [])
|
| 82 |
+
all_studies.extend(page_studies)
|
| 83 |
+
next_token = body.get("nextPageToken")
|
| 84 |
+
_logger.debug(
|
| 85 |
+
"ClinicalTrials.gov API page received",
|
| 86 |
+
extra={"data": {"page_count": len(page_studies), "has_next_page": bool(next_token)}},
|
| 87 |
+
)
|
| 88 |
+
if not next_token:
|
| 89 |
+
break
|
| 90 |
+
params["pageToken"] = next_token
|
| 91 |
+
|
| 92 |
+
_logger.info(
|
| 93 |
+
"ClinicalTrials.gov API response complete",
|
| 94 |
+
extra={"data": {"total_studies": len(all_studies)}},
|
| 95 |
+
)
|
| 96 |
+
return all_studies
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _flatten_and_rank(studies: list[dict], patient_lat: float, patient_lon: float) -> list[dict]:
|
| 100 |
+
result = []
|
| 101 |
+
for study in studies:
|
| 102 |
+
proto = study.get("protocolSection", {})
|
| 103 |
+
id_mod = proto.get("identificationModule", {})
|
| 104 |
+
desc_mod = proto.get("descriptionModule", {})
|
| 105 |
+
elig_mod = proto.get("eligibilityModule", {})
|
| 106 |
+
contacts_mod = proto.get("contactsLocationsModule", {})
|
| 107 |
+
sponsor_mod = proto.get("sponsorCollaboratorsModule", {})
|
| 108 |
+
design_mod = proto.get("designModule", {})
|
| 109 |
+
|
| 110 |
+
central_contacts = contacts_mod.get("centralContacts", [])
|
| 111 |
+
central_phone = next((c.get("phone", "") for c in central_contacts if c.get("phone")), "")
|
| 112 |
+
central_email = next((c.get("email", "") for c in central_contacts if c.get("email")), "")
|
| 113 |
+
|
| 114 |
+
officials = contacts_mod.get("overallOfficials", [])
|
| 115 |
+
pi = next(
|
| 116 |
+
(o.get("name", "") for o in officials if o.get("role") == "PRINCIPAL_INVESTIGATOR"),
|
| 117 |
+
officials[0].get("name", "") if officials else "",
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
sites_with_dist: list[tuple[float, dict]] = []
|
| 121 |
+
for loc in contacts_mod.get("locations", []):
|
| 122 |
+
geo = loc.get("geoPoint", {})
|
| 123 |
+
if geo.get("lat") and geo.get("lon"):
|
| 124 |
+
d = haversine_miles(patient_lat, patient_lon, geo["lat"], geo["lon"])
|
| 125 |
+
loc_contacts = loc.get("contacts", [])
|
| 126 |
+
loc_phone = next((c.get("phone", "") for c in loc_contacts if c.get("phone")), "")
|
| 127 |
+
loc_email = next((c.get("email", "") for c in loc_contacts if c.get("email")), "")
|
| 128 |
+
sites_with_dist.append((d, {
|
| 129 |
+
"label": (
|
| 130 |
+
f"{loc.get('facility', '').strip()} β "
|
| 131 |
+
f"{loc.get('city', '')}, "
|
| 132 |
+
f"{loc.get('state', loc.get('country', ''))} "
|
| 133 |
+
f"({d:.0f} mi)"
|
| 134 |
+
),
|
| 135 |
+
"facility": loc.get("facility", "").strip(),
|
| 136 |
+
"city": loc.get("city", ""),
|
| 137 |
+
"state": loc.get("state", loc.get("country", "")),
|
| 138 |
+
"distance_miles": round(d, 1),
|
| 139 |
+
"phone": loc_phone or central_phone,
|
| 140 |
+
"email": loc_email or central_email,
|
| 141 |
+
}))
|
| 142 |
+
sites_with_dist.sort(key=lambda x: x[0])
|
| 143 |
+
|
| 144 |
+
closest_dist = sites_with_dist[0][0] if sites_with_dist else None
|
| 145 |
+
result.append({
|
| 146 |
+
"nct_id": id_mod.get("nctId", ""),
|
| 147 |
+
"title": id_mod.get("briefTitle", ""),
|
| 148 |
+
"phase": ", ".join(design_mod.get("phases", [])) or "N/A",
|
| 149 |
+
"sponsor": sponsor_mod.get("leadSponsor", {}).get("name", ""),
|
| 150 |
+
"principal_investigator": pi,
|
| 151 |
+
"contact_phone": central_phone,
|
| 152 |
+
"contact_email": central_email,
|
| 153 |
+
"summary": desc_mod.get("briefSummary", "")[:500],
|
| 154 |
+
"eligibility": elig_mod.get("eligibilityCriteria", "")[:1000],
|
| 155 |
+
"min_age": elig_mod.get("minimumAge", ""),
|
| 156 |
+
"max_age": elig_mod.get("maximumAge", ""),
|
| 157 |
+
"closest_site_miles": round(closest_dist, 1) if closest_dist is not None else None,
|
| 158 |
+
"nearest_sites": [info for _, info in sites_with_dist[:5]],
|
| 159 |
+
})
|
| 160 |
+
|
| 161 |
+
result.sort(key=lambda x: x["closest_site_miles"] if x["closest_site_miles"] is not None else float("inf"))
|
| 162 |
+
return result
|
|
@@ -135,7 +135,7 @@ dependencies = [
|
|
| 135 |
[package.metadata]
|
| 136 |
requires-dist = [
|
| 137 |
{ name = "anthropic", specifier = ">=0.50.0" },
|
| 138 |
-
{ name = "gradio", specifier = ">=
|
| 139 |
{ name = "langgraph", specifier = ">=1.2.0" },
|
| 140 |
{ name = "openai", specifier = ">=1.0.0" },
|
| 141 |
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
|
|
|
| 135 |
[package.metadata]
|
| 136 |
requires-dist = [
|
| 137 |
{ name = "anthropic", specifier = ">=0.50.0" },
|
| 138 |
+
{ name = "gradio", specifier = ">=6.14.0,<7.0.0" },
|
| 139 |
{ name = "langgraph", specifier = ">=1.2.0" },
|
| 140 |
{ name = "openai", specifier = ">=1.0.0" },
|
| 141 |
{ name = "python-dotenv", specifier = ">=1.2.2" },
|