Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions community/clinical-trial-finder/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Clinical Trial Finder

Voice-first search for recruiting clinical trials. Search ClinicalTrials.gov by condition and location, drill into eligibility and contact details, save trials to a personal watchlist, and get proactive alerts when a saved trial's status changes.

**No API keys required.**

---

## Trigger Phrases

| What you say | What happens |
|---|---|
| "find clinical trials for Parkinson's" | Search recruiting trials for Parkinson's |
| "any trials near me for diabetes?" | Search trials filtered to your location |
| "find medical studies for breast cancer in Boston" | Search with explicit location |
| "tell me more about trial 2" | Spoken summary of result #2 |
| "what are the requirements?" | Eligibility criteria summary |
| "how do I contact them?" | Contact name, phone, and email |
| "save this trial" | Add to your watchlist |
| "show me more" | Next page of results |
| "search for trials for lupus" | New search within the session |

---

## Within a Session

After results are listed, you can:
- Say **"1", "2", "3"** etc. to focus on a specific trial
- Say **"requirements"** or **"who can join"** for eligibility details
- Say **"contact"** for the trial coordinator's contact info
- Say **"save"** to add the current trial to your watchlist
- Say **"more"** to see the next page of results
- Say **"done"** or **"stop"** to exit

---

## Background Alerts

The daemon runs a weekly check and speaks proactively when:

- **Status change** — a saved trial moves from RECRUITING to COMPLETED, SUSPENDED, etc.
- **Weekly digest** — a reminder of how many recruiting trials are currently available for each of your saved conditions

Both alert types fire once per weekly cycle.

---

## Watchlist

Saved trials persist across sessions. The daemon monitors them weekly and alerts you to any changes. Your preferred location (set automatically from your first location search) is used to filter condition digests.

---

## Data Source

| Source | Coverage | Key required |
|---|---|---|
| [ClinicalTrials.gov](https://clinicaltrials.gov) | Global — 500,000+ studies | None |

Results are filtered to **RECRUITING** status only so every result is actionable.
Empty file.
163 changes: 163 additions & 0 deletions community/clinical-trial-finder/background.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import requests
from datetime import datetime, timezone

from src.agent.capability import MatchingCapability
from src.agent.capability_worker import CapabilityWorker
from src.main import AgentWorker

STORAGE_KEY = "clinical_trial_data"
CLINICALTRIALS_URL = "https://clinicaltrials.gov/api/v2/studies"
CT_HEADERS = {"User-Agent": "OpenHome-ClinicalTrialFinder/1.0"}

POLL_INTERVAL = 604800.0 # 7 days
STARTUP_GRACE = 90 # seconds


def _empty_data() -> dict:
return {
"watchlist": [],
"saved_conditions": [],
"preferred_location": "",
}


class ClinicalTrialFinderBackground(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
background_daemon_mode: bool = False

# Do not change following tag of register capability
# {{register capability}}

# ------------------------------------------------------------------
# Storage
# ------------------------------------------------------------------

def _load_data(self) -> dict:
try:
result = self.capability_worker.get_single_key(STORAGE_KEY)
if result and result.get("value"):
return result["value"]
return _empty_data()
except Exception as e:
self.worker.editor_logging_handler.error(f"[ClinicalTrialsBG] Load error: {e!r}")
return _empty_data()

def _save_data(self, data: dict):
try:
self.capability_worker.create_key(STORAGE_KEY, data)
except Exception:
try:
self.capability_worker.update_key(STORAGE_KEY, data)
except Exception as e:
self.worker.editor_logging_handler.error(f"[ClinicalTrialsBG] Save error: {e!r}")

# ------------------------------------------------------------------
# API helpers
# ------------------------------------------------------------------

def _fetch_trial_status(self, nct_id: str) -> str:
try:
resp = requests.get(
f"{CLINICALTRIALS_URL}/{nct_id}",
params={"format": "json"},
headers=CT_HEADERS,
timeout=10,
)
if resp.status_code == 200:
ps = resp.json().get("protocolSection", {})
return ps.get("statusModule", {}).get("overallStatus", "")
except Exception as e:
self.worker.editor_logging_handler.error(
f"[ClinicalTrialsBG] Status fetch error for {nct_id}: {e!r}"
)
return ""

def _count_recruiting_trials(self, condition: str, location: str) -> int:
try:
params = {
"query.cond": condition,
"filter.overallStatus": "RECRUITING",
"pageSize": 5,
"format": "json",
}
if location:
params["query.locn"] = location
resp = requests.get(CLINICALTRIALS_URL, params=params, headers=CT_HEADERS, timeout=10)
if resp.status_code == 200:
return len(resp.json().get("studies", []))
except Exception as e:
self.worker.editor_logging_handler.error(
f"[ClinicalTrialsBG] Count error for {condition}: {e!r}"
)
return 0

# ------------------------------------------------------------------
# Daemon loop
# ------------------------------------------------------------------

async def watch_loop(self):
self.capability_worker.resume_normal_flow()
self.worker.editor_logging_handler.info("[ClinicalTrialsBG] Daemon started")

started_at = datetime.now(timezone.utc).timestamp()

while True:
try:
daemon_age = datetime.now(timezone.utc).timestamp() - started_at
data = self._load_data()

if daemon_age > STARTUP_GRACE:
alerts = []
watchlist = data.get("watchlist", [])
changed = False

for trial in watchlist:
new_status = self._fetch_trial_status(trial["nct_id"])
if new_status and new_status != trial.get("status", ""):
old = trial["status"]
trial["status"] = new_status
changed = True
alerts.append(
f"Trial update: {trial['title'][:70]} — "
f"status changed from {old} to {new_status}."
)
self.worker.editor_logging_handler.info(
f"[ClinicalTrialsBG] Status change for {trial['nct_id']}: "
f"{old} → {new_status}"
)

if changed:
self._save_data(data)

location = data.get("preferred_location", "")
for condition in data.get("saved_conditions", []):
count = self._count_recruiting_trials(condition, location)
if count > 0:
alerts.append(
f"Weekly update: {count} recruiting trial"
f"{'s' if count > 1 else ''} currently available for "
f"{condition}. Say 'find trials for {condition}' for details."
)

for msg in alerts:
await self.capability_worker.send_interrupt_signal()
await self.capability_worker.speak(msg)
self.worker.editor_logging_handler.info(
f"[ClinicalTrialsBG] Alert fired: {msg[:80]}"
)

except Exception as e:
self.worker.editor_logging_handler.error(f"[ClinicalTrialsBG] Loop error: {e!r}")

await self.worker.session_tasks.sleep(POLL_INTERVAL)

# ------------------------------------------------------------------
# Entry point
# ------------------------------------------------------------------

def call(self, worker: AgentWorker, background_daemon_mode: bool):
self.worker = worker
self.capability_worker = CapabilityWorker(self.worker)
self.background_daemon_mode = background_daemon_mode
self.worker.session_tasks.create(self.watch_loop())
Loading
Loading