| """ |
| LLM Service for intelligent ranking and scoring of clinical trials. |
| Supports Hugging Face models including DeepSeek-V3.2. |
| """ |
| import os |
| import logging |
| from typing import List, Dict, Optional, Set |
| import re |
|
|
| |
| torch = None |
| AutoTokenizer = None |
| AutoModelForCausalLM = None |
| pipeline = None |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| _ROUTER_FALLBACK_STATUSES = (400, 404, 405, 415, 422) |
| _DEFAULT_FALLBACK_MODELS = ( |
| "Qwen/Qwen2.5-7B-Instruct", |
| "mistralai/Mistral-7B-Instruct-v0.3", |
| "google/gemma-2-9b-it", |
| "meta-llama/Llama-3.1-8B-Instruct", |
| ) |
| _BIOMEDICAL_SYNONYMS = { |
| "kras": ["k-ras", "kras g12c", "kras g12d", "krasi"], |
| "egfr": ["erbb1", "epidermal growth factor receptor"], |
| "nsclc": ["non-small cell lung cancer", "non small cell lung cancer"], |
| "immunotherapy": ["checkpoint inhibitor", "pd-1", "pd-l1", "ctla-4", "io therapy"], |
| "daraxonrasib": ["rmc-6236", "ras(onc) inhibitor", "ras on inhibitor"], |
| "adagrasib": ["mrtx849"], |
| "sotorasib": ["amg 510"], |
| "pancreatic cancer": ["pdac", "pancreatic adenocarcinoma"], |
| } |
|
|
| def _import_local_dependencies(): |
| """Import torch and transformers only when needed for local model mode""" |
| global torch, AutoTokenizer, AutoModelForCausalLM, pipeline |
| if torch is None: |
| try: |
| import torch |
| from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline |
| except ImportError as e: |
| raise ImportError( |
| "torch and transformers are required for local model mode. " |
| "Install them with: pip install torch transformers accelerate sentencepiece\n" |
| "Or use API mode by setting USE_HF_API=true" |
| ) from e |
|
|
| class LLMService: |
| """Service for interacting with Hugging Face LLM models""" |
| |
| def __init__(self, model_name: Optional[str] = None, use_api: bool = False, api_token: Optional[str] = None): |
| """ |
| Initialize LLM service |
| |
| Args: |
| model_name: Hugging Face model identifier (e.g., 'deepseek-ai/DeepSeek-V3.2') |
| If None, uses DEEPSEEK_MODEL env var or defaults to DeepSeek-V3.2 |
| use_api: If True, use Hugging Face Inference API instead of local model |
| api_token: Hugging Face API token (required if use_api=True) |
| """ |
| self.model_name = model_name or os.environ.get('DEEPSEEK_MODEL', 'deepseek-ai/DeepSeek-V3.2') |
| self.use_api = use_api or os.environ.get('USE_HF_API', 'false').lower() == 'true' |
| self.api_token = api_token or os.environ.get('HUGGINGFACE_API_TOKEN', '') |
| fallback_models_raw = os.environ.get("HF_FALLBACK_MODELS", "") |
| parsed_fallbacks = [m.strip() for m in fallback_models_raw.split(",") if m.strip()] |
| fallback_candidates = parsed_fallbacks or list(_DEFAULT_FALLBACK_MODELS) |
| self.fallback_models = [m for m in fallback_candidates if m != self.model_name] |
| |
| self.tokenizer = None |
| self.model = None |
| self.pipeline = None |
| |
| if not self.use_api: |
| self._load_local_model() |
| else: |
| if not self.api_token: |
| logger.warning("Hugging Face API token not provided. Set HUGGINGFACE_API_TOKEN env var.") |
| |
| def _load_local_model(self): |
| """Load model locally using transformers""" |
| try: |
| |
| _import_local_dependencies() |
| |
| logger.info(f"Loading model: {self.model_name}") |
| |
| |
| device = "cuda" if torch.cuda.is_available() else "cpu" |
| logger.info(f"Using device: {device}") |
| |
| |
| self.tokenizer = AutoTokenizer.from_pretrained( |
| self.model_name, |
| trust_remote_code=True |
| ) |
| |
| |
| self.model = AutoModelForCausalLM.from_pretrained( |
| self.model_name, |
| trust_remote_code=True, |
| torch_dtype=torch.float16 if device == "cuda" else torch.float32, |
| device_map="auto" if device == "cuda" else None, |
| low_cpu_mem_usage=True |
| ) |
| |
| if device == "cpu": |
| self.model = self.model.to(device) |
| |
| |
| self.pipeline = pipeline( |
| "text-generation", |
| model=self.model, |
| tokenizer=self.tokenizer, |
| device=0 if device == "cuda" else -1, |
| torch_dtype=torch.float16 if device == "cuda" else torch.float32 |
| ) |
| |
| logger.info(f"Model {self.model_name} loaded successfully") |
| |
| except Exception as e: |
| logger.error(f"Error loading model: {str(e)}") |
| raise |
| |
| def rank_studies(self, studies: List[Dict], ranking_terms: str) -> List[Dict]: |
| """ |
| Rank studies based on relevance to ranking terms using LLM |
| |
| Args: |
| studies: List of study dictionaries |
| ranking_terms: Terms to use for ranking (e.g., "KRAS mutation, immunotherapy") |
| |
| Returns: |
| List of studies sorted by relevance score (highest first), with ranking_reasoning added |
| """ |
| if not ranking_terms or not ranking_terms.strip(): |
| return studies |
| |
| if not studies: |
| return studies |
| |
| try: |
| |
| scored_studies = [] |
| for study in studies: |
| score, reasoning, match_data = self._score_study(study, ranking_terms) |
| study_with_score = study.copy() |
| study_with_score['relevance_score'] = score |
| study_with_score['ranking_reasoning'] = reasoning |
| study_with_score['ranking_match_terms'] = match_data.get('matched_terms', []) |
| study_with_score['ranking_match_fields'] = match_data.get('matched_fields', {}) |
| study_with_score['ranking_score_breakdown'] = match_data.get('score_breakdown', {}) |
| scored_studies.append(study_with_score) |
| |
| |
| scored_studies.sort(key=lambda x: x.get('relevance_score', 0), reverse=True) |
| |
| return scored_studies |
| |
| except Exception as e: |
| logger.error(f"Error ranking studies: {str(e)}") |
| |
| return studies |
| |
| def _score_study(self, study: Dict, ranking_terms: str) -> tuple: |
| """ |
| Score a single study's relevance to ranking terms and get reasoning |
| |
| Args: |
| study: Study dictionary |
| ranking_terms: Terms to match against |
| |
| Returns: |
| Tuple of (relevance score (0.0 to 1.0), reasoning explanation) |
| """ |
| try: |
| |
| study_text = self._build_study_context(study) |
| hybrid_match = self._compute_hybrid_match(study, ranking_terms) |
| |
| |
| prompt = f"""You are a helpful medical research assistant. The user is looking for clinical trials that relate to: "{ranking_terms}" |
| |
| Consider this clinical trial: |
| {study_text} |
| |
| Think about how well this trial matches what the user is looking for. Consider: |
| - Direct matches (exact terms mentioned) |
| - Related concepts and synonyms |
| - Contextual relevance (even if exact terms aren't used) |
| - Overall alignment with the user's intent |
| |
| Rate the relevance on a scale of 0.0 to 1.0, where: |
| - 0.9-1.0: Highly relevant, directly matches what the user wants |
| - 0.7-0.8: Very relevant, strong connection |
| - 0.5-0.6: Moderately relevant, some connection |
| - 0.3-0.4: Somewhat relevant, weak connection |
| - 0.0-0.2: Not very relevant |
| |
| Be flexible and consider the user's intent, not just exact word matches. |
| |
| Provide your response in this format: |
| SCORE: [number between 0.0 and 1.0] |
| REASONING: [brief, natural explanation of why this score was assigned]""" |
|
|
| if self.use_api: |
| llm_score, reasoning = self._score_with_reasoning_api(prompt) |
| else: |
| llm_score, reasoning = self._score_with_reasoning_local(prompt) |
|
|
| |
| if self.use_api and self._is_api_failure_reason(reasoning): |
| score = hybrid_match['hybrid_score'] |
| reasoning = ( |
| "Hybrid keyword/synonym fallback score used because Hugging Face ranking failed. " |
| f"Details: {reasoning}" |
| ) |
| score_breakdown = { |
| "hybrid_score": hybrid_match['hybrid_score'], |
| "llm_score": None, |
| "final_score": score, |
| } |
| else: |
| score = (0.55 * llm_score) + (0.45 * hybrid_match['hybrid_score']) |
| score_breakdown = { |
| "hybrid_score": hybrid_match['hybrid_score'], |
| "llm_score": llm_score, |
| "final_score": score, |
| } |
| if hybrid_match['matched_terms']: |
| reasoning = ( |
| f"{reasoning} Hybrid signals matched: " |
| f"{', '.join(hybrid_match['matched_terms'][:8])}." |
| ) |
| |
| |
| score = max(0.0, min(1.0, float(score))) |
| |
| return score, reasoning, { |
| "matched_terms": hybrid_match['matched_terms'], |
| "matched_fields": hybrid_match['matched_fields'], |
| "score_breakdown": score_breakdown, |
| } |
| |
| except Exception as e: |
| logger.error(f"Error scoring study {study.get('nctId', 'unknown')}: {str(e)}") |
| return 0.5, "Scoring fallback used because the model response could not be processed.", { |
| "matched_terms": [], |
| "matched_fields": {}, |
| "score_breakdown": { |
| "hybrid_score": None, |
| "llm_score": None, |
| "final_score": 0.5, |
| }, |
| } |
|
|
| def _is_api_failure_reason(self, reasoning: str) -> bool: |
| """Identify known HF API failure messages returned by scoring helpers.""" |
| if not reasoning: |
| return False |
| markers = ( |
| "Hugging Face token missing", |
| "Hugging Face auth failed", |
| "Hugging Face rate limit reached", |
| "Model/endpoint mismatch", |
| "Hugging Face API error", |
| "API request failed", |
| ) |
| return any(marker in reasoning for marker in markers) |
|
|
| def _extract_query_terms(self, ranking_terms: str) -> List[str]: |
| """Extract normalized query concepts (supports comma-separated concepts).""" |
| raw_parts = [p.strip().lower() for p in (ranking_terms or "").split(",") if p.strip()] |
| if raw_parts: |
| return raw_parts |
|
|
| token_terms = [ |
| t for t in re.findall(r"[a-z0-9][a-z0-9\-\+]*", (ranking_terms or "").lower()) |
| if len(t) > 2 |
| ] |
| phrase = (ranking_terms or "").strip().lower() |
| if phrase and " " in phrase: |
| return [phrase] + token_terms |
| return token_terms |
|
|
| def _expand_term_variants(self, term: str) -> Set[str]: |
| """Expand a term into aliases/synonyms for biomedical matching.""" |
| variants = {term} |
| synonyms = _BIOMEDICAL_SYNONYMS.get(term.lower(), []) |
| variants.update(s.lower() for s in synonyms) |
|
|
| for canonical, alias_list in _BIOMEDICAL_SYNONYMS.items(): |
| alias_lower = [a.lower() for a in alias_list] |
| if term.lower() == canonical or term.lower() in alias_lower: |
| variants.add(canonical) |
| variants.update(alias_lower) |
| return variants |
|
|
| def _find_matches_in_text(self, text: str, variants: Set[str]) -> Set[str]: |
| """Return matched variants found in text with word-boundary checks.""" |
| if not text: |
| return set() |
| haystack = text.lower() |
| matched = set() |
| for variant in variants: |
| pattern = r"\b" + re.escape(variant) + r"\b" |
| if re.search(pattern, haystack): |
| matched.add(variant) |
| return matched |
|
|
| def _compute_hybrid_match(self, study: Dict, ranking_terms: str) -> Dict: |
| """Compute biomedical keyword/synonym relevance signals.""" |
| stop_words = { |
| "a", "an", "and", "or", "the", "to", "for", "of", "in", "on", "with", "by" |
| } |
| query_terms = [t for t in self._extract_query_terms(ranking_terms) if t not in stop_words] |
| if not query_terms: |
| return { |
| "hybrid_score": 0.5, |
| "matched_terms": [], |
| "matched_fields": {}, |
| } |
|
|
| fields = { |
| "title": study.get("title", ""), |
| "conditions": " ".join(study.get("conditions", []) or []), |
| "summary": study.get("briefSummary", ""), |
| "inclusionCriteria": " ".join(study.get("inclusionCriteria", []) or []), |
| } |
|
|
| matched_canonical_terms = set() |
| matched_variants_for_display = set() |
| matched_fields = {} |
| total_hits = 0 |
|
|
| for term in query_terms: |
| variants = self._expand_term_variants(term) |
| term_matched_any = False |
| for field_name, field_text in fields.items(): |
| field_matches = self._find_matches_in_text(field_text, variants) |
| if field_matches: |
| matched_fields.setdefault(field_name, set()).update(field_matches) |
| matched_variants_for_display.update(field_matches) |
| total_hits += len(field_matches) |
| term_matched_any = True |
| if term_matched_any: |
| matched_canonical_terms.add(term) |
|
|
| concept_ratio = len(matched_canonical_terms) / max(len(query_terms), 1) |
| field_coverage = len(matched_fields) / max(len(fields), 1) |
| density = min(total_hits / 8.0, 1.0) |
| hybrid_score = max(0.0, min(1.0, (0.65 * concept_ratio) + (0.2 * field_coverage) + (0.15 * density))) |
|
|
| cleaned_fields = { |
| field: sorted(values, key=len, reverse=True) |
| for field, values in matched_fields.items() |
| } |
|
|
| return { |
| "hybrid_score": hybrid_score, |
| "matched_terms": sorted(matched_variants_for_display, key=len, reverse=True), |
| "matched_fields": cleaned_fields, |
| } |
| |
| def _build_study_context(self, study: Dict) -> str: |
| """Build a text context from study data""" |
| parts = [] |
| |
| if study.get('title'): |
| parts.append(f"Title: {study['title']}") |
| |
| if study.get('sponsor'): |
| parts.append(f"Sponsor: {study['sponsor']}") |
| |
| if study.get('briefSummary'): |
| parts.append(f"Summary: {study['briefSummary'][:500]}") |
| |
| if study.get('conditions'): |
| parts.append(f"Conditions: {', '.join(study['conditions'])}") |
| |
| if study.get('inclusionCriteria'): |
| inclusion_text = ' '.join(study['inclusionCriteria'][:3]) |
| parts.append(f"Inclusion Criteria: {inclusion_text[:300]}") |
| |
| return "\n".join(parts) |
|
|
| def _extract_text_from_hf_response(self, result: object) -> str: |
| """Extract generated text across HF inference and chat response formats.""" |
| if isinstance(result, list) and result: |
| first = result[0] |
| if isinstance(first, dict): |
| return first.get("generated_text", "") or str(first) |
| return str(first) |
|
|
| if isinstance(result, dict): |
| |
| if result.get("generated_text"): |
| return str(result.get("generated_text")) |
|
|
| |
| choices = result.get("choices") |
| if isinstance(choices, list) and choices: |
| first_choice = choices[0] if isinstance(choices[0], dict) else {} |
| message = first_choice.get("message", {}) if isinstance(first_choice, dict) else {} |
| content = message.get("content") if isinstance(message, dict) else None |
| if content: |
| return str(content) |
| if first_choice.get("text"): |
| return str(first_choice.get("text")) |
|
|
| |
| if result.get("error"): |
| return str(result.get("error")) |
|
|
| return str(result) |
|
|
| def _parse_score_and_reasoning(self, text: str) -> tuple: |
| """Parse score and reasoning robustly from model output.""" |
| response_text = (text or "").strip() |
|
|
| |
| score_match = re.search( |
| r"(?:^|\b)score\s*[:=\-]?\s*(\d+(?:\.\d+)?)\s*%?", |
| response_text, |
| re.IGNORECASE | re.MULTILINE, |
| ) |
| reasoning_match = re.search( |
| r"(?:^|\b)reasoning\s*[:=\-]\s*(.+?)(?=\n\s*(?:score|reasoning)\s*[:=\-]|$)", |
| response_text, |
| re.IGNORECASE | re.DOTALL, |
| ) |
|
|
| score = 0.5 |
| if score_match: |
| score = float(score_match.group(1)) |
| if score > 1.0: |
| score = score / 100.0 |
| score = max(0.0, min(1.0, score)) |
| else: |
| numbers = re.findall(r"\d+(?:\.\d+)?", response_text) |
| if numbers: |
| score = float(numbers[0]) |
| if score > 1.0: |
| score = score / 100.0 |
| score = max(0.0, min(1.0, score)) |
|
|
| reasoning = "No specific reasoning provided." |
| if reasoning_match: |
| reasoning = reasoning_match.group(1).strip() |
| elif response_text: |
| reasoning = response_text[:200] |
|
|
| |
| reasoning_clean = reasoning.replace("\\", "").replace("/", "") |
| return score, reasoning_clean |
|
|
| def _call_hf_router_api( |
| self, |
| prompt: str, |
| max_tokens: int, |
| temperature: float = 0.7, |
| top_p: float = 0.9, |
| timeout: int = 60, |
| allow_chat_fallback: bool = True, |
| model_override: Optional[str] = None, |
| ): |
| """Post to HF router across model and endpoint fallbacks.""" |
| import requests |
|
|
| headers = { |
| "Authorization": f"Bearer {self.api_token}", |
| "Content-Type": "application/json", |
| } |
| models_to_try = [model_override] if model_override else [self.model_name] + self.fallback_models |
| last_response = None |
| last_model = self.model_name |
|
|
| for model_name in models_to_try: |
| attempts = [ |
| ( |
| f"https://router.huggingface.co/v1/models/{model_name}/generate", |
| { |
| "inputs": prompt, |
| "parameters": { |
| "max_new_tokens": max_tokens, |
| "temperature": temperature, |
| "top_p": top_p, |
| "return_full_text": False, |
| }, |
| }, |
| ), |
| ( |
| "https://router.huggingface.co/v1/completions", |
| { |
| "model": model_name, |
| "prompt": prompt, |
| "max_tokens": max_tokens, |
| "temperature": temperature, |
| "top_p": top_p, |
| }, |
| ), |
| ] |
| if allow_chat_fallback: |
| attempts.append( |
| ( |
| "https://router.huggingface.co/v1/chat/completions", |
| { |
| "model": model_name, |
| "messages": [{"role": "user", "content": prompt}], |
| "max_tokens": max_tokens, |
| "temperature": temperature, |
| "top_p": top_p, |
| }, |
| ) |
| ) |
|
|
| for api_url, payload in attempts: |
| response = requests.post(api_url, headers=headers, json=payload, timeout=timeout) |
| last_response = response |
| last_model = model_name |
| if response.status_code not in _ROUTER_FALLBACK_STATUSES: |
| return response, model_name |
|
|
| logger.warning("HF router model incompatible for ranking: %s", model_name) |
|
|
| return last_response, last_model |
| |
| def _score_with_local_model(self, prompt: str) -> float: |
| """Score using local model (legacy method)""" |
| try: |
| |
| outputs = self.pipeline( |
| prompt, |
| max_new_tokens=10, |
| temperature=0.1, |
| do_sample=False, |
| return_full_text=False |
| ) |
| |
| |
| response_text = outputs[0]['generated_text'].strip() |
| |
| |
| import re |
| numbers = re.findall(r'\d+\.?\d*', response_text) |
| if numbers: |
| score = float(numbers[0]) |
| |
| if score > 1.0: |
| score = score / 100.0 |
| return score |
| |
| return 0.5 |
| |
| except Exception as e: |
| logger.error(f"Error in local model scoring: {str(e)}") |
| return 0.5 |
| |
| def _score_with_reasoning_local(self, prompt: str) -> tuple: |
| """Score with reasoning using local model""" |
| try: |
| |
| outputs = self.pipeline( |
| prompt, |
| max_new_tokens=200, |
| temperature=0.7, |
| do_sample=True, |
| top_p=0.9, |
| return_full_text=False |
| ) |
| |
| |
| response_text = outputs[0]['generated_text'].strip() |
| return self._parse_score_and_reasoning(response_text) |
| |
| except Exception as e: |
| logger.error(f"Error in local model scoring with reasoning: {str(e)}") |
| return 0.5, "Local-model fallback used because response generation failed." |
| |
| def _score_with_api(self, prompt: str) -> float: |
| """Score using Hugging Face Inference API (legacy method)""" |
| try: |
| response, _ = self._call_hf_router_api( |
| prompt, max_tokens=10, temperature=0.1, top_p=1.0, timeout=30, allow_chat_fallback=True |
| ) |
| response.raise_for_status() |
| generated_text = self._extract_text_from_hf_response(response.json()) |
| score, _ = self._parse_score_and_reasoning(generated_text) |
| return score |
| except Exception as e: |
| logger.error(f"Error in API scoring: {str(e)}") |
| return 0.5 |
| |
| def _score_with_reasoning_api(self, prompt: str) -> tuple: |
| """Score with reasoning using Hugging Face Inference API""" |
| try: |
| if not self.api_token: |
| return 0.5, "Hugging Face token missing. Add HUGGINGFACE_API_TOKEN in Space Secrets." |
|
|
| response, used_model = self._call_hf_router_api( |
| prompt, max_tokens=200, timeout=60, allow_chat_fallback=True |
| ) |
| |
| if not response.ok: |
| error_preview = response.text[:500] |
| logger.error( |
| "HF API request failed (status=%s): %s", |
| response.status_code, |
| error_preview, |
| ) |
|
|
| if response.status_code in (401, 403): |
| return 0.5, "Hugging Face auth failed (401/403). Check HUGGINGFACE_API_TOKEN permissions." |
| if response.status_code == 429: |
| return 0.5, "Hugging Face rate limit reached (429). Please retry shortly." |
| if response.status_code in (400, 404, 422): |
| return 0.5, ( |
| f"Model/endpoint mismatch ({response.status_code}). " |
| "Set DEEPSEEK_MODEL or HF_FALLBACK_MODELS to router-supported models." |
| ) |
|
|
| return 0.5, f"Hugging Face API error {response.status_code}: {error_preview[:140]}" |
|
|
| result = response.json() |
| generated_text = self._extract_text_from_hf_response(result) |
| score, reasoning = self._parse_score_and_reasoning(generated_text) |
| if used_model != self.model_name: |
| reasoning = f"{reasoning} (scored using fallback model {used_model})" |
| return score, reasoning |
| |
| except Exception as e: |
| logger.error(f"Error in API scoring with reasoning: {str(e)}") |
| return 0.5, f"API request failed: {str(e)[:140]}" |
|
|
|
|
| |
| _llm_service = None |
|
|
| def get_llm_service() -> Optional[LLMService]: |
| """Get or create LLM service instance""" |
| global _llm_service |
| |
| if _llm_service is None: |
| try: |
| use_api = os.environ.get('USE_HF_API', 'false').lower() == 'true' |
| api_token = os.environ.get('HUGGINGFACE_API_TOKEN', '') |
| |
| print(f"Initializing LLM service - USE_HF_API: {use_api}, Token set: {bool(api_token)}") |
| |
| if use_api and not api_token: |
| logger.warning("USE_HF_API is true but HUGGINGFACE_API_TOKEN is not set!") |
| print("ERROR: HUGGINGFACE_API_TOKEN environment variable is required for API mode.") |
| return None |
| |
| _llm_service = LLMService(use_api=use_api, api_token=api_token if api_token else None) |
| print(f"LLM service initialized successfully. Model: {_llm_service.model_name}") |
| except Exception as e: |
| logger.error(f"Failed to initialize LLM service: {str(e)}") |
| import traceback |
| traceback.print_exc() |
| return None |
| |
| return _llm_service |
|
|
|
|