""" Gradio app for Clinical Trial Matcher For Hugging Face Spaces deployment """ import gradio as gr import requests import re import os import html from typing import Tuple, Optional # ClinicalTrials.gov API base URL CLINICALTRIALS_API_BASE = "https://clinicaltrials.gov/api/v2/studies" APP_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); :root { --ctm-bg: #f4f7ff; --ctm-card-bg: #ffffff; --ctm-card-border: #dfe7ff; --ctm-text-primary: #111827; --ctm-text-secondary: #4b5563; --ctm-link: #4f46e5; --ctm-accent-soft: #e0e7ff; } .dark, [data-theme="dark"] { --ctm-bg: #0f172a; --ctm-card-bg: #111827; --ctm-card-border: #334155; --ctm-text-primary: #f8fafc; --ctm-text-secondary: #cbd5e1; --ctm-link: #93c5fd; --ctm-accent-soft: #1e293b; } .ctm-results { color: var(--ctm-text-primary); font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .ctm-count { margin: 0.5rem 0 1rem 0; color: var(--ctm-text-primary); font-size: 1.45rem; font-weight: 700; letter-spacing: 0.2px; } .ctm-banner { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 60%, #db2777 100%); color: #ffffff; padding: 1rem 1.1rem; border-radius: 14px; margin: 1rem 0; box-shadow: 0 8px 24px rgba(79, 70, 229, 0.25); } .ctm-info { margin: 1rem 0; padding: 0.9rem 1rem; background: var(--ctm-accent-soft); color: var(--ctm-text-primary); border-left: 4px solid #6366f1; border-radius: 10px; } .ctm-warning { background: #fff7e6; color: #7a4600; padding: 1rem; border-radius: 12px; margin: 1rem 0; border: 1px solid #f2c97d; } .dark .ctm-warning, [data-theme="dark"] .ctm-warning { background: #3a2a0d; color: #fde68a; border-color: #c08425; } .ctm-card { border: 1px solid var(--ctm-card-border); border-radius: 16px; padding: 1.25rem; margin: 1rem 0; background: var(--ctm-card-bg); color: var(--ctm-text-primary); box-shadow: 0 6px 20px rgba(2, 6, 23, 0.08); } .ctm-title { margin: 0; color: var(--ctm-text-primary); flex: 1; font-weight: 700; } .ctm-row { display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem; } .ctm-meta { color: var(--ctm-text-primary); margin: 0.4rem 0; } .ctm-summary { color: var(--ctm-text-secondary); line-height: 1.65; } .ctm-details { margin-top: 1rem; } .ctm-details summary { cursor: pointer; color: var(--ctm-link); font-weight: 600; } .ctm-reason { margin-top: 1rem; padding: 0.8rem 0.9rem; background: var(--ctm-accent-soft); border-left: 4px solid #6366f1; border-radius: 8px; font-size: 0.92rem; color: var(--ctm-text-primary); } .ctm-footer { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--ctm-card-border); display: flex; justify-content: space-between; align-items: center; } .ctm-muted { color: var(--ctm-text-secondary); font-size: 0.9rem; } .ctm-link { color: var(--ctm-link); text-decoration: none; font-weight: 600; } .ctm-relevance { display: inline-block; margin: 0.3rem 0 0.8rem; background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); color: #fff; padding: 0.26rem 0.65rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; } .ctm-match-highlight { background: #fde68a; color: #1f2937; border-radius: 4px; padding: 0 0.1rem; } .ctm-match-box { margin: 0.8rem 0 0.9rem; padding: 0.75rem 0.85rem; background: var(--ctm-accent-soft); border-left: 4px solid #6366f1; border-radius: 8px; font-size: 0.9rem; color: var(--ctm-text-primary); } .ctm-match-pill { display: inline-block; margin: 0 0.3rem 0.32rem 0; padding: 0.16rem 0.52rem; background: #e0e7ff; color: #3730a3; border-radius: 999px; font-size: 0.75rem; font-weight: 600; } .gradio-container { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; background: radial-gradient(circle at top right, #eef2ff 0%, #f7f9ff 45%, #f4f7ff 100%) !important; color: var(--ctm-text-primary) !important; } .gradio-container .main { max-width: 1180px !important; margin: 0 auto !important; } .gradio-container .block { border-radius: 16px !important; } .gradio-container .gr-box, .gradio-container [class*="panel"] { border-radius: 14px !important; border: 1px solid #e3e8ff !important; box-shadow: 0 8px 24px rgba(34, 46, 120, 0.08) !important; background: var(--ctm-card-bg) !important; color: var(--ctm-text-primary) !important; } .dark .gradio-container .gr-box, .dark .gradio-container [class*="panel"], [data-theme="dark"] .gradio-container .gr-box, [data-theme="dark"] .gradio-container [class*="panel"] { background: #111827 !important; border-color: #334155 !important; color: #f8fafc !important; } .dark .gradio-container, [data-theme="dark"] .gradio-container { background: radial-gradient(circle at top right, #111827 0%, #0f172a 55%, #0b1220 100%) !important; } .gradio-container button { border-radius: 12px !important; font-weight: 600 !important; letter-spacing: 0.01em !important; transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease !important; } .gradio-container button:hover { transform: translateY(-1px) !important; box-shadow: 0 10px 20px rgba(76, 70, 229, 0.22) !important; filter: brightness(1.02) !important; } .gradio-container input, .gradio-container textarea, .gradio-container select { border-radius: 12px !important; border: 1px solid #dbe4ff !important; box-shadow: none !important; background: var(--ctm-card-bg) !important; color: var(--ctm-text-primary) !important; } .gradio-container input:focus, .gradio-container textarea:focus, .gradio-container select:focus { border-color: #6366f1 !important; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15) !important; } .gradio-container h1 { letter-spacing: -0.02em !important; font-weight: 700 !important; } .gradio-container h2, .gradio-container h3 { letter-spacing: -0.01em !important; } .ctm-rank-input textarea, .ctm-rank-input input { border-radius: 10px !important; border: 1px solid #dbe4ff !important; } .ctm-rank-btn button { background: linear-gradient(135deg, #5b6cf9 0%, #7a56da 100%) !important; border: none !important; border-radius: 10px !important; font-weight: 600 !important; } .ctm-rank-panel { margin-top: 0.75rem; padding: 0.9rem; border: 1px solid #dbe4ff; border-radius: 12px; background: linear-gradient(180deg, #f8faff 0%, #f3f6ff 100%); } .ctm-rank-help { margin-bottom: 0.55rem; color: #34435f; font-size: 0.9rem; line-height: 1.45; } .dark .ctm-rank-panel, [data-theme="dark"] .ctm-rank-panel { border-color: #334155; background: linear-gradient(180deg, #111827 0%, #182133 100%); } .dark .ctm-rank-help, [data-theme="dark"] .ctm-rank-help { color: #dbe6ff; } .dark .ctm-rank-input textarea, .dark .ctm-rank-input input, [data-theme="dark"] .ctm-rank-input textarea, [data-theme="dark"] .ctm-rank-input input { border-color: #334155 !important; background: #111827 !important; color: #f8fafc !important; } """ def _highlight_html(text: str, terms: list) -> str: """Highlight matched terms in plain text and return safe HTML.""" safe_text = html.escape(text or "") valid_terms = sorted({(t or "").strip() for t in (terms or []) if (t or "").strip()}, key=len, reverse=True) if not safe_text or not valid_terms: return safe_text highlighted = safe_text for term in valid_terms: safe_term = html.escape(term) if len(safe_term) < 2: continue pattern = re.compile(re.escape(safe_term), re.IGNORECASE) highlighted = pattern.sub(lambda m: f"{m.group(0)}", highlighted) return highlighted # Import LLM service (optional - will gracefully degrade if not available) LLM_AVAILABLE = False get_llm_service = None # Check environment variables USE_HF_API = os.environ.get('USE_HF_API', 'false').lower() == 'true' HF_TOKEN = os.environ.get('HUGGINGFACE_API_TOKEN', '') print(f"Environment check - USE_HF_API: {USE_HF_API}, HF_TOKEN set: {bool(HF_TOKEN)}") try: from llm_service import get_llm_service LLM_AVAILABLE = True print("LLM service imported successfully.") except ImportError as e: print(f"LLM service not available. Ranking will be disabled. Error: {str(e)}") except Exception as e: print(f"Error importing LLM service: {str(e)}") import traceback traceback.print_exc() def search_trials(query: str, country: str, status: str, ranking_terms: str = "") -> Tuple[str, int, Optional[str]]: """ Search clinical trials and return formatted results Returns: tuple: (results_html, total_count, llm_model_name) """ if not query: return "Please enter search keywords.", 0, None try: # Build API request parameters params = { 'format': 'json', 'pageSize': 20 } # Build query filter query_parts = [] if query: query_parts.append(query) if country: query_parts.append(f'AREA[LocationCountry]{country}') if status: query_parts.append(f'AREA[OverallStatus]{status}') if query_parts: params['query.term'] = ' AND '.join(query_parts) # Make request to ClinicalTrials.gov API response = requests.get(CLINICALTRIALS_API_BASE, params=params, timeout=30) response.raise_for_status() data = response.json() # Extract and format relevant information studies = [] if 'studies' in data: for study in data['studies']: protocol_section = study.get('protocolSection', {}) identification = protocol_section.get('identificationModule', {}) nct_id = identification.get('nctId', '') organization = identification.get('organization', {}) sponsor = organization.get('fullName', '') if organization else '' status_module = protocol_section.get('statusModule', {}) description = protocol_section.get('descriptionModule', {}) conditions = protocol_section.get('conditionsModule', {}) locations_module = protocol_section.get('contactsLocationsModule', {}) # Fetch full study details to get ALL locations and eligibility criteria locations = [] inclusion_criteria = [] exclusion_criteria = [] detail_data = None if nct_id: try: detail_url = f"https://clinicaltrials.gov/api/v2/studies/{nct_id}" detail_response = requests.get(detail_url, params={'format': 'json'}, timeout=60) detail_response.raise_for_status() detail_data = detail_response.json() detail_protocol = detail_data.get('protocolSection', {}) detail_locations_module = detail_protocol.get('contactsLocationsModule', {}) if 'locations' in detail_locations_module: all_locations = detail_locations_module['locations'] if isinstance(all_locations, list) and len(all_locations) > 0: for loc in all_locations: locations.append({ 'facility': loc.get('facility', ''), 'city': loc.get('city', ''), 'country': loc.get('country', '') }) except Exception: pass # Extract eligibility criteria from detail data if available if detail_data: detail_protocol = detail_data.get('protocolSection', {}) eligibility_module = detail_protocol.get('eligibilityModule', {}) eligibility_text = eligibility_module.get('eligibilityCriteria', '') if eligibility_text: # Parse inclusion and exclusion criteria with multiple format support inclusion_patterns = [ r'(?:Key\s+)?Inclusion\s+Criteria[^:\n]*:?\s*(.*?)(?=(?:Key\s+)?Exclusion\s+Criteria[^:\n]*:?|$)', r'Inclusion\s+Criteria[^:\n]*:?\s*(.*?)(?=Exclusion\s+Criteria[^:\n]*:?|$)', ] exclusion_patterns = [ r'(?:Key\s+)?Exclusion\s+Criteria[^:\n]*:?\s*(.*?)$', r'Exclusion\s+Criteria[^:\n]*:?\s*(.*?)$', ] inclusion_match = None exclusion_match = None for pattern in inclusion_patterns: inclusion_match = re.search(pattern, eligibility_text, re.IGNORECASE | re.DOTALL) if inclusion_match: break for pattern in exclusion_patterns: exclusion_match = re.search(pattern, eligibility_text, re.IGNORECASE | re.DOTALL) if exclusion_match: break if inclusion_match: inclusion_text = inclusion_match.group(1).strip() inclusion_lines = [line.strip() for line in inclusion_text.split('\n') if line.strip()] inclusion_criteria = [re.sub(r'^[\*\-\•]\s*', '', line).strip() for line in inclusion_lines if line.strip()] inclusion_criteria = [item for item in inclusion_criteria if item and len(item) > 3] if exclusion_match: exclusion_text = exclusion_match.group(1).strip() exclusion_lines = [line.strip() for line in exclusion_text.split('\n') if line.strip()] exclusion_criteria = [re.sub(r'^[\*\-\•]\s*', '', line).strip() for line in exclusion_lines if line.strip()] exclusion_criteria = [item for item in exclusion_criteria if item and len(item) > 3] else: # Try to get eligibility from search results as fallback eligibility_module = protocol_section.get('eligibilityModule', {}) eligibility_text = eligibility_module.get('eligibilityCriteria', '') if eligibility_text: inclusion_patterns = [ r'(?:Key\s+)?Inclusion\s+Criteria[^:\n]*:?\s*(.*?)(?=(?:Key\s+)?Exclusion\s+Criteria[^:\n]*:?|$)', r'Inclusion\s+Criteria[^:\n]*:?\s*(.*?)(?=Exclusion\s+Criteria[^:\n]*:?|$)', ] exclusion_patterns = [ r'(?:Key\s+)?Exclusion\s+Criteria[^:\n]*:?\s*(.*?)$', r'Exclusion\s+Criteria[^:\n]*:?\s*(.*?)$', ] inclusion_match = None exclusion_match = None for pattern in inclusion_patterns: inclusion_match = re.search(pattern, eligibility_text, re.IGNORECASE | re.DOTALL) if inclusion_match: break for pattern in exclusion_patterns: exclusion_match = re.search(pattern, eligibility_text, re.IGNORECASE | re.DOTALL) if exclusion_match: break if inclusion_match: inclusion_text = inclusion_match.group(1).strip() inclusion_lines = [line.strip() for line in inclusion_text.split('\n') if line.strip()] inclusion_criteria = [re.sub(r'^[\*\-\•]\s*', '', line).strip() for line in inclusion_lines if line.strip()] inclusion_criteria = [item for item in inclusion_criteria if item and len(item) > 3] if exclusion_match: exclusion_text = exclusion_match.group(1).strip() exclusion_lines = [line.strip() for line in exclusion_text.split('\n') if line.strip()] exclusion_criteria = [re.sub(r'^[\*\-\•]\s*', '', line).strip() for line in exclusion_lines if line.strip()] exclusion_criteria = [item for item in exclusion_criteria if item and len(item) > 3] # If detail fetch failed or returned no locations, use search results as fallback if len(locations) == 0 and 'locations' in locations_module: all_locations = locations_module.get('locations', []) if isinstance(all_locations, list): for loc in all_locations: locations.append({ 'facility': loc.get('facility', ''), 'city': loc.get('city', ''), 'country': loc.get('country', '') }) # Sort locations: searched country first, then others if country and locations: search_country_lower = country.lower().strip() def location_sort_key(loc): loc_country = loc.get('country', '').lower().strip() if loc_country == search_country_lower: return 0 elif search_country_lower in loc_country or loc_country in search_country_lower: return 1 else: return 2 locations.sort(key=location_sort_key) study_info = { 'nctId': nct_id, 'title': identification.get('officialTitle') or identification.get('briefTitle', ''), 'sponsor': sponsor, 'status': status_module.get('overallStatus', ''), 'conditions': conditions.get('conditions', []), 'briefSummary': description.get('briefSummary', ''), 'locations': locations, 'inclusionCriteria': inclusion_criteria, 'exclusionCriteria': exclusion_criteria, 'lastUpdateDate': status_module.get('lastUpdateSubmitDate', '') } studies.append(study_info) # Apply LLM-based ranking if ranking terms provided llm_model_name = None if ranking_terms and ranking_terms.strip(): if not LLM_AVAILABLE: print("Warning: LLM service not available. Ranking disabled.") print(f" - LLM_AVAILABLE: {LLM_AVAILABLE}") print(f" - get_llm_service: {get_llm_service}") elif not get_llm_service: print("Warning: get_llm_service is None. Ranking disabled.") else: try: llm_service = get_llm_service() if llm_service: llm_model_name = llm_service.model_name print(f"Ranking studies with {llm_model_name} using terms: {ranking_terms}") studies = llm_service.rank_studies(studies, ranking_terms) print(f"Ranking completed. {len(studies)} studies ranked.") else: print("Warning: LLM service returned None. Ranking disabled.") print(f" - Check environment variables:") print(f" USE_HF_API: {os.environ.get('USE_HF_API', 'NOT SET')}") print(f" HUGGINGFACE_API_TOKEN: {'SET' if os.environ.get('HUGGINGFACE_API_TOKEN') else 'NOT SET'}") except Exception as e: print(f"Error during LLM ranking: {str(e)}") import traceback traceback.print_exc() # Format results as HTML if not studies: return "No studies found. Try different search criteria.", 0, None html_results = f'
Ranking applied: Studies have been reordered by AI relevance to "{ranking_terms}". The model analyzed each study\'s title, summary, conditions, and inclusion criteria to determine how closely they match your ranking terms. Higher-ranked studies appear first.
' elif ranking_terms and ranking_terms.strip() and not llm_model_name: # Check what's wrong use_hf_api = os.environ.get('USE_HF_API', 'false').lower() == 'true' hf_token = os.environ.get('HUGGINGFACE_API_TOKEN', '') error_msg = "LLM service is not available." if not use_hf_api: error_msg += " Set USE_HF_API=true in your Space secrets." if not hf_token: error_msg += " Set HUGGINGFACE_API_TOKEN in your Space secrets." html_results += f'Summary: {_highlight_html(summary, match_terms)}
' if match_terms: html_results += "Ranking Reasoning: {html.escape(study['ranking_reasoning'])}
" html_results += f"""