diff --git a/community/clinical-trial-finder/README.md b/community/clinical-trial-finder/README.md new file mode 100644 index 00000000..5823388b --- /dev/null +++ b/community/clinical-trial-finder/README.md @@ -0,0 +1,136 @@ +# Clinical Trial Finder + +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) + +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 + +- "Find clinical trials" +- "Clinical trial finder" +- "Find medical studies" +- "Research trials" + +## Intents + +The ability uses a single LLM router per turn to classify what the user wants: + +| Intent | What it handles | +|---|---| +| `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 + +No configuration required. The ability works out of the box — no API keys, no linked accounts. + +### 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." + +--- + +### 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." + +--- + +### Saving a trial + +> **User:** "Save this one" +> +> **Ability:** "Saved. I'll alert you weekly if the status of that trial changes." + +--- + +### Background alert + +> **[Proactive alert from daemon]** +> +> **Ability:** "Trial update: A Phase 3 study of levodopa carbidopa intestinal gel — status changed from RECRUITING to COMPLETED." + +--- + +### Exiting + +> **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 | +|---|---|---| +| `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 + +- 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. 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..d40c68a1 --- /dev/null +++ b/community/clinical-trial-finder/main.py @@ -0,0 +1,415 @@ +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.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 + + 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()