From 72b61cdf5dbab16b483ae7dfabf0120268ccfa29 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 7 Jun 2026 01:39:01 +0500 Subject: [PATCH 1/4] Add clinical-trial-finder ability: voice search for recruiting clinical trials Searches ClinicalTrials.gov by condition and location, surfaces eligibility and contact details via LLM summaries, and monitors a saved watchlist with weekly status-change alerts via a background daemon. --- community/clinical-trial-finder/README.md | 60 +++ community/clinical-trial-finder/__init__.py | 0 community/clinical-trial-finder/background.py | 163 +++++++ community/clinical-trial-finder/main.py | 413 ++++++++++++++++++ 4 files changed, 636 insertions(+) create mode 100644 community/clinical-trial-finder/README.md create mode 100644 community/clinical-trial-finder/__init__.py create mode 100644 community/clinical-trial-finder/background.py create mode 100644 community/clinical-trial-finder/main.py diff --git a/community/clinical-trial-finder/README.md b/community/clinical-trial-finder/README.md new file mode 100644 index 00000000..f927ea01 --- /dev/null +++ b/community/clinical-trial-finder/README.md @@ -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. diff --git a/community/clinical-trial-finder/__init__.py b/community/clinical-trial-finder/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/clinical-trial-finder/background.py b/community/clinical-trial-finder/background.py new file mode 100644 index 00000000..f2973496 --- /dev/null +++ b/community/clinical-trial-finder/background.py @@ -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()) diff --git a/community/clinical-trial-finder/main.py b/community/clinical-trial-finder/main.py new file mode 100644 index 00000000..fa443e58 --- /dev/null +++ b/community/clinical-trial-finder/main.py @@ -0,0 +1,413 @@ +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"} + +HOTWORDS = { + "clinical trial", "clinical study", "medical trial", "medical study", + "research trial", "research study", "find a trial", "find trials", + "trial for", "trials for", "enroll in a study", "join a study", + "join a trial", "trial near", "trials near", "participate in a study", +} + +EXIT_WORDS = {"stop", "exit", "quit", "done", "cancel", "bye", "goodbye", "that's all"} + +INTENT_PROMPT = """Classify the user's input into exactly one of these intents: +SEARCH - searching for trials for a condition or disease +DETAILS - asking for more information about a specific trial (by number or "this one") +ELIGIBILITY - asking about requirements, who can join, age limits, criteria +CONTACT - asking how to contact a trial or get phone/email +SAVE - wanting to save or bookmark a trial to their watchlist +MORE - asking to see more results or next page +EXIT - done, stop, quit, goodbye + +Return ONLY the intent label. Input: {text}""" + +EXTRACT_CONDITION_PROMPT = ( + "Extract ONLY the medical condition or disease name from this text: '{text}'. " + "Reply with the condition name only — no extra words, no punctuation. " + "If no condition is mentioned, reply NONE." +) + +EXTRACT_LOCATION_PROMPT = ( + "Extract ONLY the city and/or state or country from this text: '{text}'. " + "Reply in 'City, State' or 'City, Country' format. " + "If no location is mentioned, reply NONE." +) + +EXTRACT_TRIAL_NUMBER_PROMPT = ( + "The user said: '{text}'. They are referring to one of a numbered list of trials. " + "Extract ONLY the number they mentioned (1, 2, 3, 4, or 5). " + "Reply with just the digit. If unclear, reply 1." +) + +TRIAL_SUMMARY_PROMPT = ( + "Summarise this clinical trial in exactly 2 spoken sentences for a voice assistant. " + "Sentence 1: what the trial is studying. " + "Sentence 2: who can join, including age range if available. " + "No markdown. Plain spoken English. Trial data: {data}" +) + +ELIGIBILITY_PROMPT = ( + "Summarise these eligibility criteria in 2 spoken sentences for a voice assistant. " + "Include the age requirement and the 2 most important inclusion or exclusion criteria. " + "No markdown. Plain spoken English. Criteria: {criteria}" +) + + +def _empty_data() -> dict: + return { + "watchlist": [], + "saved_conditions": [], + "preferred_location": "", + } + + +class ClinicalTrialFinderCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + _last_results: list = [] + _active_trial: dict = {} + _next_page_token: str = "" + _last_condition: str = "" + _last_location: str = "" + + # Do not change following tag of register capability + # {{register capability}} + + def does_match(self, text: str) -> bool: + t = text.lower().strip() + return any(hw in t for hw in HOTWORDS) + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self._run()) + + # ------------------------------------------------------------------ + # 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"[ClinicalTrials] 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"[ClinicalTrials] Save error: {e!r}") + + # ------------------------------------------------------------------ + # API helpers + # ------------------------------------------------------------------ + + def _search_trials(self, condition: str, location: str = "", page_token: str = "") -> dict: + params = { + "query.cond": condition, + "filter.overallStatus": "RECRUITING", + "pageSize": 5, + "format": "json", + } + if location: + params["query.locn"] = location + if page_token: + params["pageToken"] = page_token + try: + resp = requests.get(CLINICALTRIALS_URL, params=params, headers=CT_HEADERS, timeout=10) + if resp.status_code == 200: + return resp.json() + except Exception as e: + self.worker.editor_logging_handler.error(f"[ClinicalTrials] Search error: {e!r}") + return {} + + def _parse_trials(self, raw: dict) -> list: + studies = [] + for s in raw.get("studies", []): + ps = s.get("protocolSection", {}) + id_mod = ps.get("identificationModule", {}) + status_mod = ps.get("statusModule", {}) + design_mod = ps.get("designModule", {}) + cond_mod = ps.get("conditionsModule", {}) + elig_mod = ps.get("eligibilityModule", {}) + desc_mod = ps.get("descriptionModule", {}) + contacts_mod = ps.get("contactsLocationsModule", {}) + + locations = contacts_mod.get("locations", []) + loc_parts = [] + if locations: + first = locations[0] + city = first.get("city", "") + state = first.get("state", "") + country = first.get("country", "") + loc_parts = [p for p in [city, state or country] if p] + + contacts = contacts_mod.get("centralContacts", []) + contact = contacts[0] if contacts else {} + + studies.append({ + "nct_id": id_mod.get("nctId", ""), + "title": id_mod.get("briefTitle", "Unknown trial"), + "status": status_mod.get("overallStatus", ""), + "phases": design_mod.get("phases", []), + "conditions": cond_mod.get("conditions", []), + "location_str": ", ".join(loc_parts) if loc_parts else "location not listed", + "location_count": len(locations), + "min_age": elig_mod.get("minimumAge", ""), + "max_age": elig_mod.get("maximumAge", ""), + "eligibility": elig_mod.get("eligibilityCriteria", ""), + "summary": desc_mod.get("briefSummary", ""), + "contact_name": contact.get("name", ""), + "contact_phone": contact.get("phone", ""), + "contact_email": contact.get("email", ""), + }) + return studies + + # ------------------------------------------------------------------ + # LLM helpers + # ------------------------------------------------------------------ + + def _classify_intent(self, text: str) -> str: + raw = self.capability_worker.text_to_text_response(INTENT_PROMPT.format(text=text)) + result = raw.strip().upper().split()[0] + valid = {"SEARCH", "DETAILS", "ELIGIBILITY", "CONTACT", "SAVE", "MORE", "EXIT"} + return result if result in valid else "SEARCH" + + def _extract_condition(self, text: str) -> str: + raw = self.capability_worker.text_to_text_response( + EXTRACT_CONDITION_PROMPT.format(text=text) + ).strip() + return "" if raw.upper() == "NONE" or not raw else raw + + def _extract_location(self, text: str) -> str: + raw = self.capability_worker.text_to_text_response( + EXTRACT_LOCATION_PROMPT.format(text=text) + ).strip() + return "" if raw.upper() == "NONE" or not raw else raw + + def _extract_trial_number(self, text: str) -> int: + raw = self.capability_worker.text_to_text_response( + EXTRACT_TRIAL_NUMBER_PROMPT.format(text=text) + ).strip() + try: + n = int(raw) + return n if 1 <= n <= 5 else 1 + except (ValueError, TypeError): + return 1 + + def _summarise_trial(self, trial: dict) -> str: + data = { + "title": trial["title"], + "summary": trial["summary"][:600] if trial["summary"] else "No summary available.", + "min_age": trial["min_age"], + "max_age": trial["max_age"], + "conditions": trial["conditions"], + } + return self.capability_worker.text_to_text_response( + TRIAL_SUMMARY_PROMPT.format(data=data) + ) + + def _summarise_eligibility(self, trial: dict) -> str: + criteria = trial.get("eligibility", "") + if not criteria: + age_str = "" + if trial["min_age"]: + age_str = f"Minimum age {trial['min_age']}" + if trial["max_age"]: + age_str += f", maximum age {trial['max_age']}" + return age_str or "Eligibility details are not available for this trial." + return self.capability_worker.text_to_text_response( + ELIGIBILITY_PROMPT.format(criteria=criteria[:800]) + ) + + # ------------------------------------------------------------------ + # Voice output helpers + # ------------------------------------------------------------------ + + def _speak_results(self, trials: list) -> str: + if not trials: + return "No recruiting trials found." + lines = [] + for i, t in enumerate(trials, 1): + loc = t["location_str"] + extra = f" and {t['location_count'] - 1} other sites" if t["location_count"] > 1 else "" + lines.append(f"Trial {i}: {t['title']}, located in {loc}{extra}.") + return " ".join(lines) + + # ------------------------------------------------------------------ + # Main flow + # ------------------------------------------------------------------ + + async def _run(self): + try: + trigger = await self.capability_worker.wait_for_complete_transcription() + self.worker.editor_logging_handler.info(f"[ClinicalTrials] Trigger: {trigger!r}") + + data = self._load_data() + + condition = self._extract_condition(trigger or "") + location = self._extract_location(trigger or "") + + if not location and data.get("preferred_location"): + location = data["preferred_location"] + + if not condition: + reply = await self.capability_worker.user_response() + if not reply or any(w in reply.lower() for w in EXIT_WORDS): + return + condition = self._extract_condition(reply) + if not condition: + condition = reply.strip() + + self._last_condition = condition + self._last_location = location + + await self.capability_worker.speak( + f"Searching for recruiting trials for {condition}" + + (f" near {location}" if location else "") + ". One moment." + ) + + raw = self._search_trials(condition, location) + self._last_results = self._parse_trials(raw) + self._next_page_token = raw.get("nextPageToken", "") + + if not self._last_results: + await self.capability_worker.speak( + f"I couldn't find any recruiting trials for {condition}" + + (f" near {location}" if location else "") + + ". Try a broader condition name or a different location." + ) + return + + self._active_trial = self._last_results[0] + await self.capability_worker.speak(self._speak_results(self._last_results)) + await self.capability_worker.speak( + "Say a number to hear details, 'requirements' for eligibility, " + "'contact' for contact info, 'save' to add to your watchlist, " + "'more' for next page, or 'done' to exit." + ) + + while True: + reply = await self.capability_worker.user_response() + if not reply or any(w in reply.lower() for w in EXIT_WORDS): + break + + intent = self._classify_intent(reply) + self.worker.editor_logging_handler.info(f"[ClinicalTrials] Intent: {intent}") + + if intent == "EXIT": + break + + elif intent == "SEARCH": + new_cond = self._extract_condition(reply) + new_loc = self._extract_location(reply) + if new_cond: + condition = new_cond + self._last_condition = condition + if new_loc: + location = new_loc + self._last_location = location + await self.capability_worker.speak( + f"Searching for {condition}" + + (f" near {location}" if location else "") + "." + ) + raw = self._search_trials(condition, location) + self._last_results = self._parse_trials(raw) + self._next_page_token = raw.get("nextPageToken", "") + if not self._last_results: + await self.capability_worker.speak("No recruiting trials found for that search.") + continue + self._active_trial = self._last_results[0] + await self.capability_worker.speak(self._speak_results(self._last_results)) + + elif intent == "DETAILS": + n = self._extract_trial_number(reply) + if n <= len(self._last_results): + self._active_trial = self._last_results[n - 1] + summary = self._summarise_trial(self._active_trial) + await self.capability_worker.speak(summary) + + elif intent == "ELIGIBILITY": + elig = self._summarise_eligibility(self._active_trial) + await self.capability_worker.speak(elig) + + elif intent == "CONTACT": + t = self._active_trial + if t.get("contact_name") or t.get("contact_phone") or t.get("contact_email"): + parts = [] + if t["contact_name"]: + parts.append(f"Contact {t['contact_name']}") + if t["contact_phone"]: + parts.append(f"phone {t['contact_phone']}") + if t["contact_email"]: + parts.append(f"email {t['contact_email']}") + await self.capability_worker.speak(". ".join(parts) + ".") + else: + await self.capability_worker.speak( + f"No direct contact listed. Visit clinicaltrials.gov and search " + f"{t.get('nct_id', 'the trial')} for details." + ) + + elif intent == "SAVE": + t = self._active_trial + data = self._load_data() + watchlist = data.get("watchlist", []) + if any(w["nct_id"] == t["nct_id"] for w in watchlist): + await self.capability_worker.speak("That trial is already in your watchlist.") + else: + watchlist.append({ + "nct_id": t["nct_id"], + "title": t["title"], + "condition": self._last_condition, + "status": t["status"], + "saved_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"), + }) + data["watchlist"] = watchlist + saved_conds = data.get("saved_conditions", []) + if self._last_condition and self._last_condition not in saved_conds: + saved_conds.append(self._last_condition) + data["saved_conditions"] = saved_conds + if self._last_location and not data.get("preferred_location"): + data["preferred_location"] = self._last_location + self._save_data(data) + await self.capability_worker.speak( + f"Saved. I'll alert you weekly if the status of " + f"{t['title'][:60]} changes." + ) + + elif intent == "MORE": + if not self._next_page_token: + await self.capability_worker.speak("No more results for this search.") + continue + raw = self._search_trials( + self._last_condition, self._last_location, self._next_page_token + ) + self._last_results = self._parse_trials(raw) + self._next_page_token = raw.get("nextPageToken", "") + if not self._last_results: + await self.capability_worker.speak("No more results found.") + continue + self._active_trial = self._last_results[0] + await self.capability_worker.speak(self._speak_results(self._last_results)) + + except Exception as e: + self.worker.editor_logging_handler.error(f"[ClinicalTrials] Error: {e!r}") + await self.capability_worker.speak("Something went wrong. Please try again in a moment.") + finally: + self.capability_worker.resume_normal_flow() From 1527080916a8b2a504baa35c905ef52297816375 Mon Sep 17 00:00:00 2001 From: Uzair Ullah Date: Mon, 8 Jun 2026 12:42:35 +0500 Subject: [PATCH 2/4] Refactor user response handling for condition input Signed-off-by: Uzair Ullah --- community/clinical-trial-finder/main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/community/clinical-trial-finder/main.py b/community/clinical-trial-finder/main.py index fa443e58..f4939093 100644 --- a/community/clinical-trial-finder/main.py +++ b/community/clinical-trial-finder/main.py @@ -268,13 +268,16 @@ async def _run(self): location = data["preferred_location"] if not condition: - reply = await self.capability_worker.user_response() + reply = await self.capability_worker.run_io_loop( + "What condition or disease are you searching for?" + ) if not reply or any(w in reply.lower() for w in EXIT_WORDS): return condition = self._extract_condition(reply) if not condition: condition = reply.strip() + self._last_condition = condition self._last_location = location From 6c94cf681163535173cfd3c2c713ce298b2dbc1d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Jun 2026 07:42:48 +0000 Subject: [PATCH 3/4] style: auto-format Python files with autoflake + autopep8 --- community/clinical-trial-finder/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/community/clinical-trial-finder/main.py b/community/clinical-trial-finder/main.py index f4939093..d40c68a1 100644 --- a/community/clinical-trial-finder/main.py +++ b/community/clinical-trial-finder/main.py @@ -277,7 +277,6 @@ async def _run(self): if not condition: condition = reply.strip() - self._last_condition = condition self._last_location = location From 295a19f31ea5d31866e9df5084fb77dbc95cb823 Mon Sep 17 00:00:00 2001 From: Uzair Ullah Date: Mon, 8 Jun 2026 13:16:19 +0500 Subject: [PATCH 4/4] Enhance README with detailed feature descriptions Expanded the README to provide detailed information about the Clinical Trial Finder's features, usage, and functionality. Signed-off-by: Uzair Ullah --- community/clinical-trial-finder/README.md | 144 +++++++++++++++++----- 1 file changed, 110 insertions(+), 34 deletions(-) diff --git a/community/clinical-trial-finder/README.md b/community/clinical-trial-finder/README.md index f927ea01..5823388b 100644 --- a/community/clinical-trial-finder/README.md +++ b/community/clinical-trial-finder/README.md @@ -1,60 +1,136 @@ # 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. +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) -**No API keys required.** +Find recruiting clinical trials by voice — no screen, no searching, no time spent on a database that wasn't built for people. ---- +## What It Does + +Clinical Trial Finder is a voice-first OpenHome ability that searches ClinicalTrials.gov by condition and location and reads results back in plain spoken English. It handles the full discovery workflow — finding trials, summarising eligibility, surfacing contact details, and saving trials to a personal watchlist that the background daemon monitors weekly for status changes. + +Finding trials the traditional way is genuinely painful. The official database has 500,000+ studies and zero voice support. This ability is built for patients and caregivers, people exploring options after a new diagnosis, family members researching on someone else's behalf — anyone who's spent an hour on that website trying to find something relevant. + +Just talk naturally: + +- *"Find trials for Parkinson's near Houston"* +- *"Any studies for diabetes in Chicago?"* +- *"What are the requirements?"* +- *"How do I contact them?"* +- *"Save this one"* +- *"Show me more"* + +## Suggested Trigger Words -## Trigger Phrases +- "Find clinical trials" +- "Clinical trial finder" +- "Find medical studies" +- "Research trials" -| What you say | What happens | +## Intents + +The ability uses a single LLM router per turn to classify what the user wants: + +| Intent | What it handles | |---|---| -| "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 | +| `SEARCH` | Find recruiting trials for a condition and location ("find trials for Parkinson's near Houston") | +| `DETAILS` | Spoken summary of a specific trial ("tell me more about trial 2", "this one") | +| `ELIGIBILITY` | Who can join — age limits and key inclusion/exclusion criteria ("what are the requirements?", "who can join?") | +| `CONTACT` | Trial coordinator name, phone, and email ("how do I contact them?") | +| `SAVE` | Add the current trial to the watchlist ("save this one") | +| `MORE` | Next page of results ("show me more") | +| `EXIT` | End the session ("done", "stop") | ---- +Common exit phrases are caught by a fast-path check before the LLM router runs, making exits instant and reliable. + +## Features + +- **Voice-first search** — one LLM call per turn decides the intent. Follow-up questions ("what are the requirements?", "how do I contact them?") resolve against the currently active trial without losing context. +- **Results filtered to RECRUITING** — every result returned is actionable. Completed, suspended, or not-yet-open trials are excluded. +- **Eligibility in plain English** — full eligibility text is distilled to two spoken sentences covering the age requirement and the most important criteria. +- **Contact details on demand** — coordinator name, phone number, and email spoken directly. +- **Pagination** — say "more" to move to the next page of results within the same search. +- **Mid-session re-search** — say a new condition at any point to start a fresh search without exiting. +- **Watchlist** — saved trials persist across sessions. The daemon monitors them weekly and alerts when status changes. +- **Background digest** — weekly reminder of how many recruiting trials are available for each of your saved conditions. +- **Location memory** — your first location search is saved as your preferred location and used automatically in future digests. +- **Graceful degradation** — API failures are logged silently. Missing contact details produce a short notice rather than breaking the session. + +## Setup -## Within a Session +No configuration required. The ability works out of the box — no API keys, no linked accounts. -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 +### APIs Used + +| Service | Auth required | Purpose | +|---|:---:|---| +| ClinicalTrials.gov v2 | None | Global trial search — 500,000+ studies | +| OpenHome Context Storage | OpenHome SDK | Persist watchlist, saved conditions, and preferred location | + +## Example Conversations + +### Finding trials for a condition + +> **User:** "Find trials for Parkinson's near Houston" +> +> **Ability:** "Searching for recruiting trials for Parkinson's near Houston. One moment." +> +> **Ability:** "Found 5 results. Trial 1: A Phase 3 study of levodopa carbidopa intestinal gel, located in Houston, Texas and 4 other sites. Trial 2: Deep brain stimulation for early Parkinson's, located in Houston, Texas..." +> +> **Ability:** "Say a number to hear details, 'requirements' for eligibility, 'contact' for contact info, 'save' to add to your watchlist, 'more' for next page, or 'done' to exit." --- -## Background Alerts +### Drilling into eligibility + +> **User:** "What are the requirements for trial 1?" +> +> **Ability:** "Participants must be between 30 and 80 years old. Key requirements are a confirmed Parkinson's diagnosis of at least 3 years and no prior deep brain stimulation surgery." -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 +### Saving a trial -Both alert types fire once per weekly cycle. +> **User:** "Save this one" +> +> **Ability:** "Saved. I'll alert you weekly if the status of that trial changes." --- -## Watchlist +### Background alert -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. +> **[Proactive alert from daemon]** +> +> **Ability:** "Trial update: A Phase 3 study of levodopa carbidopa intestinal gel — status changed from RECRUITING to COMPLETED." --- -## Data Source +### Exiting -| Source | Coverage | Key required | +> **User:** "Done" *(or "stop")* +> +> **Ability:** *(hands control back to OpenHome)* + +## How It Works + +1. **Trigger** — OpenHome routes the user's voice to this ability. +2. **Condition extraction** — the LLM extracts the condition and location from the trigger phrase. If no condition is detected, the ability asks before searching. +3. **Fast-path check** — obvious exit phrases are caught before any model call for instant exits. +4. **Search** — ClinicalTrials.gov is queried with the condition, location, and RECRUITING status filter. Up to 5 results are returned per page. +5. **Routing** — each follow-up turn is classified by a single LLM call, with the active trial and last search injected as context so follow-ups resolve naturally. +6. **Watchlist** — saved trials are stored in Context Storage. The background daemon polls each one weekly and fires a spoken alert on any status change. + +## Persistence + +All state lives under a single OpenHome Context Storage key (`clinical_trial_data`): + +| Sub-attribute | Value | Used for | |---|---|---| -| [ClinicalTrials.gov](https://clinicaltrials.gov) | Global — 500,000+ studies | None | +| `watchlist` | List of saved trials | Weekly status monitoring and change alerts | +| `saved_conditions` | List of condition names | Weekly digest of recruiting trial counts | +| `preferred_location` | Location string from first search | Auto-applied to condition digests | + +## Notes -Results are filtered to **RECRUITING** status only so every result is actionable. +- All API failures are logged with the `[ClinicalTrials]` prefix and the session continues — no spoken error noise. +- The daemon polls every 7 days. Status change alerts fire at most once per weekly cycle per trial. +- Results are limited to 5 per page to keep voice output concise. Say "more" to paginate.