mwmathis commited on
Commit
bddfb9b
·
verified ·
1 Parent(s): 3ec3a23

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +66 -6
  2. llm_service.py +393 -0
  3. requirements.txt +11 -0
README.md CHANGED
@@ -1,13 +1,73 @@
1
  ---
2
- title: ClinicalTrialMatcher
3
- emoji:
4
- colorFrom: red
5
- colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.2.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Clinical Trial Matcher
3
+ emoji: 🔬
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 4.0.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
  ---
12
 
13
+ # 🔬 Clinical Trial Matcher
14
+
15
+ Search and filter clinical trials from [ClinicalTrials.gov](https://clinicaltrials.gov/) with AI-powered ranking.
16
+
17
+ ## Features
18
+
19
+ - 🔍 **Keyword Search**: Search clinical trials by disease, condition, or treatment
20
+ - 🌍 **Country Filter**: Filter trials by country/location
21
+ - 📊 **Status Filter**: Filter by recruitment status (Recruiting, Completed, etc.)
22
+ - 🤖 **AI-Powered Ranking**: Use Hugging Face LLMs (like DeepSeek-V3.2) to intelligently rank results by relevance
23
+ - 📋 **Detailed Results**: View inclusion/exclusion criteria, sponsor information, and more
24
+
25
+ ## How to Use
26
+
27
+ 1. Enter search keywords (e.g., "cancer", "diabetes", "PDAC")
28
+ 2. Optionally filter by country (default: Germany)
29
+ 3. Optionally filter by recruitment status
30
+ 4. Click "Search Clinical Trials"
31
+ 5. After results appear, optionally enter ranking terms and click "Rank Results" for AI-powered relevance ranking
32
+
33
+ ## AI-Powered Ranking
34
+
35
+ After your initial search, you can use AI to rank results by relevance:
36
+ - Enter specific terms you want to prioritize (e.g., "KRAS mutation", "immunotherapy")
37
+ - Click "Rank Results" to reorder studies by AI-determined relevance
38
+ - The model analyzes each study's title, summary, conditions, and inclusion criteria
39
+
40
+ ## Setup for Hugging Face Spaces
41
+
42
+ ### Required Files
43
+
44
+ 1. **app.py** - Main Gradio application (already provided)
45
+ 2. **llm_service.py** - LLM service for ranking (copy from `backend/llm_service.py`)
46
+ 3. **requirements.txt** - Python dependencies (use `requirements_gradio.txt`)
47
+
48
+ ### Environment Variables (Secrets)
49
+
50
+ Add these in your Space settings → Secrets:
51
+
52
+ - `HUGGINGFACE_API_TOKEN` - Your Hugging Face API token (required for ranking)
53
+ - `USE_HF_API` - Set to `true` to use Hugging Face Inference API
54
+ - `DEEPSEEK_MODEL` - Model to use (default: `deepseek-ai/DeepSeek-V3.2`)
55
+
56
+ ### Getting a Hugging Face API Token
57
+
58
+ 1. Go to [Hugging Face Settings](https://huggingface.co/settings/tokens)
59
+ 2. Create a new token with "Read" permissions
60
+ 3. Add it as a secret in your Space settings
61
+
62
+ ## Data Source
63
+
64
+ All data is sourced from [ClinicalTrials.gov](https://clinicaltrials.gov), the official database of clinical trials worldwide.
65
+
66
+ ## Developer
67
+
68
+ **Dev. by Mackenzie 🧡 | mackenzie@post.harvard.edu for Qs**
69
+
70
+ ## License
71
+
72
+ MIT License
73
+
llm_service.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM Service for intelligent ranking and scoring of clinical trials.
3
+ Supports Hugging Face models including DeepSeek-V3.2.
4
+ """
5
+ import os
6
+ import logging
7
+ from typing import List, Dict, Optional
8
+ import torch
9
+ from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class LLMService:
14
+ """Service for interacting with Hugging Face LLM models"""
15
+
16
+ def __init__(self, model_name: Optional[str] = None, use_api: bool = False, api_token: Optional[str] = None):
17
+ """
18
+ Initialize LLM service
19
+
20
+ Args:
21
+ model_name: Hugging Face model identifier (e.g., 'deepseek-ai/DeepSeek-V3.2')
22
+ If None, uses DEEPSEEK_MODEL env var or defaults to DeepSeek-V3.2
23
+ use_api: If True, use Hugging Face Inference API instead of local model
24
+ api_token: Hugging Face API token (required if use_api=True)
25
+ """
26
+ self.model_name = model_name or os.environ.get('DEEPSEEK_MODEL', 'deepseek-ai/DeepSeek-V3.2')
27
+ self.use_api = use_api or os.environ.get('USE_HF_API', 'false').lower() == 'true'
28
+ self.api_token = api_token or os.environ.get('HUGGINGFACE_API_TOKEN', '')
29
+
30
+ self.tokenizer = None
31
+ self.model = None
32
+ self.pipeline = None
33
+
34
+ if not self.use_api:
35
+ self._load_local_model()
36
+ else:
37
+ if not self.api_token:
38
+ logger.warning("Hugging Face API token not provided. Set HUGGINGFACE_API_TOKEN env var.")
39
+
40
+ def _load_local_model(self):
41
+ """Load model locally using transformers"""
42
+ try:
43
+ logger.info(f"Loading model: {self.model_name}")
44
+
45
+ # Check if CUDA is available
46
+ device = "cuda" if torch.cuda.is_available() else "cpu"
47
+ logger.info(f"Using device: {device}")
48
+
49
+ # Load tokenizer and model
50
+ self.tokenizer = AutoTokenizer.from_pretrained(
51
+ self.model_name,
52
+ trust_remote_code=True
53
+ )
54
+
55
+ # Load model with appropriate settings
56
+ self.model = AutoModelForCausalLM.from_pretrained(
57
+ self.model_name,
58
+ trust_remote_code=True,
59
+ torch_dtype=torch.float16 if device == "cuda" else torch.float32,
60
+ device_map="auto" if device == "cuda" else None,
61
+ low_cpu_mem_usage=True
62
+ )
63
+
64
+ if device == "cpu":
65
+ self.model = self.model.to(device)
66
+
67
+ # Create pipeline for easier text generation
68
+ self.pipeline = pipeline(
69
+ "text-generation",
70
+ model=self.model,
71
+ tokenizer=self.tokenizer,
72
+ device=0 if device == "cuda" else -1,
73
+ torch_dtype=torch.float16 if device == "cuda" else torch.float32
74
+ )
75
+
76
+ logger.info(f"Model {self.model_name} loaded successfully")
77
+
78
+ except Exception as e:
79
+ logger.error(f"Error loading model: {str(e)}")
80
+ raise
81
+
82
+ def rank_studies(self, studies: List[Dict], ranking_terms: str) -> List[Dict]:
83
+ """
84
+ Rank studies based on relevance to ranking terms using LLM
85
+
86
+ Args:
87
+ studies: List of study dictionaries
88
+ ranking_terms: Terms to use for ranking (e.g., "KRAS mutation, immunotherapy")
89
+
90
+ Returns:
91
+ List of studies sorted by relevance score (highest first), with ranking_reasoning added
92
+ """
93
+ if not ranking_terms or not ranking_terms.strip():
94
+ return studies
95
+
96
+ if not studies:
97
+ return studies
98
+
99
+ try:
100
+ # Score each study
101
+ scored_studies = []
102
+ for study in studies:
103
+ score, reasoning = self._score_study(study, ranking_terms)
104
+ study_with_score = study.copy()
105
+ study_with_score['relevance_score'] = score
106
+ study_with_score['ranking_reasoning'] = reasoning
107
+ scored_studies.append(study_with_score)
108
+
109
+ # Sort by score (highest first)
110
+ scored_studies.sort(key=lambda x: x.get('relevance_score', 0), reverse=True)
111
+
112
+ return scored_studies
113
+
114
+ except Exception as e:
115
+ logger.error(f"Error ranking studies: {str(e)}")
116
+ # Return original studies if ranking fails
117
+ return studies
118
+
119
+ def _score_study(self, study: Dict, ranking_terms: str) -> tuple:
120
+ """
121
+ Score a single study's relevance to ranking terms and get reasoning
122
+
123
+ Args:
124
+ study: Study dictionary
125
+ ranking_terms: Terms to match against
126
+
127
+ Returns:
128
+ Tuple of (relevance score (0.0 to 1.0), reasoning explanation)
129
+ """
130
+ try:
131
+ # Build context from study
132
+ study_text = self._build_study_context(study)
133
+
134
+ # Create prompt for scoring with reasoning
135
+ prompt = f"""You are a medical research assistant. Rate the relevance of this clinical trial to the search terms on a scale of 0.0 to 1.0.
136
+
137
+ Search terms: {ranking_terms}
138
+
139
+ Clinical Trial:
140
+ {study_text}
141
+
142
+ Provide your response in this exact format:
143
+ SCORE: [number between 0.0 and 1.0]
144
+ REASONING: [brief explanation of why this score was assigned, focusing on how the study matches or doesn't match the search terms]"""
145
+
146
+ if self.use_api:
147
+ score, reasoning = self._score_with_reasoning_api(prompt)
148
+ else:
149
+ score, reasoning = self._score_with_reasoning_local(prompt)
150
+
151
+ # Ensure score is between 0 and 1
152
+ score = max(0.0, min(1.0, float(score)))
153
+
154
+ return score, reasoning
155
+
156
+ except Exception as e:
157
+ logger.error(f"Error scoring study {study.get('nctId', 'unknown')}: {str(e)}")
158
+ return 0.0, "Unable to generate reasoning due to an error."
159
+
160
+ def _build_study_context(self, study: Dict) -> str:
161
+ """Build a text context from study data"""
162
+ parts = []
163
+
164
+ if study.get('title'):
165
+ parts.append(f"Title: {study['title']}")
166
+
167
+ if study.get('briefSummary'):
168
+ parts.append(f"Summary: {study['briefSummary'][:500]}") # Limit summary length
169
+
170
+ if study.get('conditions'):
171
+ parts.append(f"Conditions: {', '.join(study['conditions'])}")
172
+
173
+ if study.get('inclusionCriteria'):
174
+ inclusion_text = ' '.join(study['inclusionCriteria'][:3]) # First 3 criteria
175
+ parts.append(f"Inclusion Criteria: {inclusion_text[:300]}")
176
+
177
+ return "\n".join(parts)
178
+
179
+ def _score_with_local_model(self, prompt: str) -> float:
180
+ """Score using local model (legacy method)"""
181
+ try:
182
+ # Generate response
183
+ outputs = self.pipeline(
184
+ prompt,
185
+ max_new_tokens=10,
186
+ temperature=0.1,
187
+ do_sample=False,
188
+ return_full_text=False
189
+ )
190
+
191
+ # Extract score from response
192
+ response_text = outputs[0]['generated_text'].strip()
193
+
194
+ # Try to extract a number from the response
195
+ import re
196
+ numbers = re.findall(r'\d+\.?\d*', response_text)
197
+ if numbers:
198
+ score = float(numbers[0])
199
+ # Normalize if it's > 1 (might be percentage or 0-100 scale)
200
+ if score > 1.0:
201
+ score = score / 100.0
202
+ return score
203
+
204
+ return 0.5 # Default score if parsing fails
205
+
206
+ except Exception as e:
207
+ logger.error(f"Error in local model scoring: {str(e)}")
208
+ return 0.5
209
+
210
+ def _score_with_reasoning_local(self, prompt: str) -> tuple:
211
+ """Score with reasoning using local model"""
212
+ try:
213
+ # Generate response with more tokens for reasoning
214
+ outputs = self.pipeline(
215
+ prompt,
216
+ max_new_tokens=150,
217
+ temperature=0.3,
218
+ do_sample=True,
219
+ return_full_text=False
220
+ )
221
+
222
+ # Extract response text
223
+ response_text = outputs[0]['generated_text'].strip()
224
+
225
+ # Parse score and reasoning
226
+ import re
227
+ score_match = re.search(r'SCORE:\s*([\d.]+)', response_text, re.IGNORECASE)
228
+ reasoning_match = re.search(r'REASONING:\s*(.+?)(?=SCORE:|$)', response_text, re.IGNORECASE | re.DOTALL)
229
+
230
+ score = 0.5
231
+ if score_match:
232
+ score = float(score_match.group(1))
233
+ if score > 1.0:
234
+ score = score / 100.0
235
+ score = max(0.0, min(1.0, score))
236
+
237
+ reasoning = "No specific reasoning provided."
238
+ if reasoning_match:
239
+ reasoning = reasoning_match.group(1).strip()
240
+ elif not score_match:
241
+ # Fallback: try to extract any number as score
242
+ numbers = re.findall(r'\d+\.?\d*', response_text)
243
+ if numbers:
244
+ score = float(numbers[0])
245
+ if score > 1.0:
246
+ score = score / 100.0
247
+ score = max(0.0, min(1.0, score))
248
+ reasoning = response_text[:200] if len(response_text) > 0 else "Unable to parse reasoning."
249
+
250
+ return score, reasoning
251
+
252
+ except Exception as e:
253
+ logger.error(f"Error in local model scoring with reasoning: {str(e)}")
254
+ return 0.5, "Unable to generate reasoning due to an error."
255
+
256
+ def _score_with_api(self, prompt: str) -> float:
257
+ """Score using Hugging Face Inference API (legacy method)"""
258
+ try:
259
+ import requests
260
+
261
+ # Try router endpoint first, fallback to inference API
262
+ api_url = f"https://api-inference.huggingface.co/models/{self.model_name}"
263
+ headers = {"Authorization": f"Bearer {self.api_token}"}
264
+
265
+ payload = {
266
+ "inputs": prompt,
267
+ "parameters": {
268
+ "max_new_tokens": 10,
269
+ "temperature": 0.1,
270
+ "return_full_text": False
271
+ }
272
+ }
273
+
274
+ response = requests.post(api_url, headers=headers, json=payload, timeout=30)
275
+ response.raise_for_status()
276
+
277
+ result = response.json()
278
+
279
+ # Extract generated text
280
+ if isinstance(result, list) and len(result) > 0:
281
+ generated_text = result[0].get('generated_text', '')
282
+ else:
283
+ generated_text = str(result)
284
+
285
+ # Extract score
286
+ import re
287
+ numbers = re.findall(r'\d+\.?\d*', generated_text)
288
+ if numbers:
289
+ score = float(numbers[0])
290
+ if score > 1.0:
291
+ score = score / 100.0
292
+ return score
293
+
294
+ return 0.5
295
+
296
+ except Exception as e:
297
+ logger.error(f"Error in API scoring: {str(e)}")
298
+ return 0.5
299
+
300
+ def _score_with_reasoning_api(self, prompt: str) -> tuple:
301
+ """Score with reasoning using Hugging Face Inference API"""
302
+ try:
303
+ import requests
304
+
305
+ # Use router API endpoint (new format)
306
+ api_url = f"https://router.huggingface.co/v1/models/{self.model_name}/generate"
307
+ headers = {
308
+ "Authorization": f"Bearer {self.api_token}",
309
+ "Content-Type": "application/json"
310
+ }
311
+
312
+ payload = {
313
+ "inputs": prompt,
314
+ "parameters": {
315
+ "max_new_tokens": 150,
316
+ "temperature": 0.3,
317
+ "return_full_text": False
318
+ }
319
+ }
320
+
321
+ response = requests.post(api_url, headers=headers, json=payload, timeout=60)
322
+
323
+ # If router endpoint fails, try alternative format
324
+ if response.status_code == 404:
325
+ # Try OpenAI-compatible format
326
+ api_url = f"https://router.huggingface.co/v1/chat/completions"
327
+ payload = {
328
+ "model": self.model_name,
329
+ "messages": [{"role": "user", "content": prompt}],
330
+ "max_tokens": 150,
331
+ "temperature": 0.3
332
+ }
333
+ response = requests.post(api_url, headers=headers, json=payload, timeout=60)
334
+
335
+ response.raise_for_status()
336
+
337
+ result = response.json()
338
+
339
+ # Extract generated text
340
+ if isinstance(result, list) and len(result) > 0:
341
+ generated_text = result[0].get('generated_text', '')
342
+ else:
343
+ generated_text = str(result)
344
+
345
+ # Parse score and reasoning
346
+ import re
347
+ score_match = re.search(r'SCORE:\s*([\d.]+)', generated_text, re.IGNORECASE)
348
+ reasoning_match = re.search(r'REASONING:\s*(.+?)(?=SCORE:|$)', generated_text, re.IGNORECASE | re.DOTALL)
349
+
350
+ score = 0.5
351
+ if score_match:
352
+ score = float(score_match.group(1))
353
+ if score > 1.0:
354
+ score = score / 100.0
355
+ score = max(0.0, min(1.0, score))
356
+
357
+ reasoning = "No specific reasoning provided."
358
+ if reasoning_match:
359
+ reasoning = reasoning_match.group(1).strip()
360
+ elif not score_match:
361
+ # Fallback: try to extract any number as score
362
+ numbers = re.findall(r'\d+\.?\d*', generated_text)
363
+ if numbers:
364
+ score = float(numbers[0])
365
+ if score > 1.0:
366
+ score = score / 100.0
367
+ score = max(0.0, min(1.0, score))
368
+ reasoning = generated_text[:200] if len(generated_text) > 0 else "Unable to parse reasoning."
369
+
370
+ return score, reasoning
371
+
372
+ except Exception as e:
373
+ logger.error(f"Error in API scoring with reasoning: {str(e)}")
374
+ return 0.5, "Unable to generate reasoning due to an error."
375
+
376
+
377
+ # Global LLM service instance (lazy loaded)
378
+ _llm_service = None
379
+
380
+ def get_llm_service() -> Optional[LLMService]:
381
+ """Get or create LLM service instance"""
382
+ global _llm_service
383
+
384
+ if _llm_service is None:
385
+ try:
386
+ use_api = os.environ.get('USE_HF_API', 'false').lower() == 'true'
387
+ _llm_service = LLMService(use_api=use_api)
388
+ except Exception as e:
389
+ logger.error(f"Failed to initialize LLM service: {str(e)}")
390
+ return None
391
+
392
+ return _llm_service
393
+
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ requests>=2.31.0
3
+ # LLM dependencies for AI-powered ranking
4
+ # Using Hugging Face API mode (no local model needed)
5
+ # Set HUGGINGFACE_API_TOKEN environment variable in HF Spaces secrets
6
+ # Optional: If you want to use local models, uncomment these:
7
+ # transformers>=4.40.0
8
+ # torch>=2.0.0
9
+ # accelerate>=0.27.0
10
+ # sentencepiece>=0.1.99
11
+