Coverage for src/local_deep_research/web_search_engines/engines/search_engine_semantic_scholar.py: 97%
284 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1import re
2from typing import Any, Dict, List, Optional, Tuple
4import requests
5from langchain_core.language_models import BaseLLM
6from ...security.secure_logging import logger
7from requests.adapters import HTTPAdapter
8from urllib3.util import Retry
10from ...constants import SNIPPET_LENGTH_SHORT
11from ..rate_limiting import RateLimitError
12from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity
13from ...security import SafeSession
16class SemanticScholarSearchEngine(BaseSearchEngine):
17 """
18 Semantic Scholar search engine implementation with two-phase approach.
19 Provides efficient access to scientific literature across all fields.
20 """
22 # Mark as public search engine
23 is_public = True
24 egress_sensitivity = Sensitivity.NON_SENSITIVE
25 egress_exposure = Exposure.EXPOSING
26 # Scientific/academic search engine
27 is_scientific = True
28 is_lexical = True
29 needs_llm_relevance_filter = True
31 def __init__(
32 self,
33 max_results: int = 10,
34 api_key: Optional[str] = None,
35 year_range: Optional[Tuple[int, int]] = None,
36 get_abstracts: bool = True,
37 get_references: bool = False,
38 get_citations: bool = False,
39 get_embeddings: bool = False,
40 get_tldr: bool = True,
41 citation_limit: int = 10,
42 reference_limit: int = 10,
43 llm: Optional[BaseLLM] = None,
44 max_filtered_results: Optional[int] = None,
45 optimize_queries: bool = True,
46 max_retries: int = 5,
47 retry_backoff_factor: float = 1.0,
48 fields_of_study: Optional[List[str]] = None,
49 publication_types: Optional[List[str]] = None,
50 settings_snapshot: Optional[Dict[str, Any]] = None,
51 **kwargs,
52 ):
53 """
54 Initialize the Semantic Scholar search engine.
56 Args:
57 max_results: Maximum number of search results
58 api_key: Semantic Scholar API key for higher rate limits (optional)
59 year_range: Optional tuple of (start_year, end_year) to filter results
60 get_abstracts: Whether to fetch abstracts for all results
61 get_references: Whether to fetch references for papers
62 get_citations: Whether to fetch citations for papers
63 get_embeddings: Whether to fetch SPECTER embeddings for papers
64 get_tldr: Whether to fetch TLDR summaries for papers
65 citation_limit: Maximum number of citations to fetch per paper
66 reference_limit: Maximum number of references to fetch per paper
67 llm: Language model for relevance filtering
68 max_filtered_results: Maximum number of results to keep after filtering
69 optimize_queries: Whether to optimize natural language queries
70 max_retries: Maximum number of retries for API requests
71 retry_backoff_factor: Backoff factor for retries
72 fields_of_study: List of fields of study to filter results
73 publication_types: List of publication types to filter results
74 settings_snapshot: Settings snapshot for configuration
75 **kwargs: Additional parameters to pass to parent class
76 """
77 # Journal filter runs before LLM relevance (Tiers 1-3 are instant)
78 preview_filters = []
79 journal_filter = self._create_journal_filter(
80 "semantic_scholar", llm, settings_snapshot
81 )
82 if journal_filter is not None: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true
83 preview_filters.append(journal_filter)
85 super().__init__(
86 llm=llm,
87 max_filtered_results=max_filtered_results,
88 max_results=max_results,
89 preview_filters=preview_filters, # type: ignore[arg-type]
90 settings_snapshot=settings_snapshot,
91 **kwargs,
92 )
94 # Get API key from settings if not provided
95 if not api_key and settings_snapshot:
96 from ...config.search_config import get_setting_from_snapshot
98 try:
99 api_key = get_setting_from_snapshot(
100 "search.engine.web.semantic_scholar.api_key",
101 settings_snapshot=settings_snapshot,
102 )
103 except Exception:
104 logger.debug(
105 "Failed to read semantic_scholar.api_key from settings snapshot",
106 exc_info=True,
107 )
109 self.api_key = api_key
110 self.year_range = year_range
111 self.get_abstracts = get_abstracts
112 self.get_references = get_references
113 self.get_citations = get_citations
114 self.get_embeddings = get_embeddings
115 self.get_tldr = get_tldr
116 self.citation_limit = citation_limit
117 self.reference_limit = reference_limit
118 self.optimize_queries = optimize_queries
119 self.max_retries = max_retries
120 self.retry_backoff_factor = retry_backoff_factor
121 self.fields_of_study = (
122 self._ensure_list(fields_of_study)
123 if fields_of_study is not None
124 else None
125 )
126 self.publication_types = (
127 self._ensure_list(publication_types)
128 if publication_types is not None
129 else None
130 )
132 # Base API URLs
133 self.base_url = "https://api.semanticscholar.org/graph/v1"
134 self.paper_search_url = f"{self.base_url}/paper/search"
135 self.paper_details_url = f"{self.base_url}/paper"
137 # Create a session with retry capabilities
138 self.session: SafeSession | None = self._create_session()
140 # Log API key status
141 if self.api_key:
142 logger.info(
143 "Using Semantic Scholar with API key (higher rate limits)"
144 )
145 else:
146 logger.info(
147 "Using Semantic Scholar without API key (lower rate limits)"
148 )
150 def _create_session(self) -> SafeSession:
151 """Create and configure a requests session with retry capabilities"""
152 session = SafeSession()
154 # Configure automatic retries with exponential backoff
155 retry_strategy = Retry(
156 total=self.max_retries,
157 backoff_factor=self.retry_backoff_factor,
158 status_forcelist=[429, 500, 502, 503, 504],
159 allowed_methods={"HEAD", "GET", "POST", "OPTIONS"},
160 )
162 adapter = HTTPAdapter(max_retries=retry_strategy)
163 session.mount("https://", adapter)
165 # Set up headers
166 headers = {"Accept": "application/json"}
167 if self.api_key:
168 headers["x-api-key"] = self.api_key
170 session.headers.update(headers)
172 return session
174 def close(self):
175 """
176 Close the HTTP session and clean up resources.
178 Call this method when done using the search engine to prevent
179 connection/file descriptor leaks.
180 """
181 if hasattr(self, "session") and self.session:
182 try:
183 self.session.close()
184 except Exception as e:
185 safe_msg = self._scrub_error(e)
186 logger.warning(
187 f"Error closing SemanticScholar session: {safe_msg}"
188 )
189 finally:
190 self.session = None
191 # Close content filters (JournalReputationFilter) via parent
192 super().close()
194 def __del__(self):
195 """Destructor to ensure session is closed."""
196 self.close()
198 def __enter__(self):
199 """Context manager entry."""
200 return self
202 def __exit__(self, exc_type, exc_val, exc_tb):
203 """Context manager exit - ensures session cleanup."""
204 self.close()
205 return False
207 def _respect_rate_limit(self):
208 """Apply rate limiting between requests"""
209 # Apply rate limiting before request
210 self._last_wait_time = self.rate_tracker.apply_rate_limit(
211 self.engine_type
212 )
213 logger.debug(f"Applied rate limit wait: {self._last_wait_time:.2f}s")
215 def _make_request(
216 self,
217 url: str,
218 params: Optional[Dict] = None,
219 data: Optional[Dict] = None,
220 method: str = "GET",
221 ) -> Dict:
222 """
223 Make a request to the Semantic Scholar API.
225 Args:
226 url: API endpoint URL
227 params: Query parameters
228 data: JSON data for POST requests
229 method: HTTP method (GET or POST)
231 Returns:
232 API response as dictionary
233 """
234 self._respect_rate_limit()
236 try:
237 if self.session is None: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 raise RuntimeError("Session is not initialized")
239 if method.upper() == "GET":
240 response = self.session.get(url, params=params, timeout=30)
241 elif method.upper() == "POST":
242 response = self.session.post(
243 url, params=params, json=data, timeout=30
244 )
245 else:
246 raise ValueError(f"Unsupported HTTP method: {method}")
248 # Handle rate limiting
249 if response.status_code == 429:
250 logger.warning("Semantic Scholar rate limit exceeded")
251 raise RateLimitError("Semantic Scholar rate limit exceeded")
253 response.raise_for_status()
254 return response.json() # type: ignore[no-any-return]
255 except requests.RequestException as e:
256 safe_msg = self._scrub_error(e)
257 logger.warning(f"API request failed: {safe_msg}")
258 return {}
260 def _optimize_query(self, query: str) -> str:
261 """
262 Optimize a natural language query for Semantic Scholar search.
263 If LLM is available, uses it to extract key terms and concepts.
265 Args:
266 query: Natural language query
268 Returns:
269 Optimized query string
270 """
271 if not self.llm or not self.optimize_queries:
272 return query
274 try:
275 prompt = f"""Transform this natural language question into an optimized academic search query.
277Original query: "{query}"
279INSTRUCTIONS:
2801. Extract key academic concepts, technical terms, and proper nouns
2812. Remove generic words, filler words, and non-technical terms
2823. Add quotation marks around specific phrases that should be kept together
2834. Return ONLY the optimized search query with no explanation
2845. Keep it under 100 characters if possible
286EXAMPLE TRANSFORMATIONS:
287"What are the latest findings about mRNA vaccines and COVID-19?" → "mRNA vaccines COVID-19 recent findings"
288"How does machine learning impact climate change prediction?" → "machine learning "climate change" prediction"
289"Tell me about quantum computing approaches for encryption" → "quantum computing encryption"
291Return ONLY the optimized search query with no explanation.
292"""
294 response = self.llm.invoke(prompt)
295 optimized_query = (
296 str(response.content)
297 if hasattr(response, "content")
298 else str(response)
299 ).strip()
301 # Clean up the query - remove any explanations
302 lines = optimized_query.split("\n")
303 optimized_query = lines[0].strip()
305 # Safety check - if query looks too much like an explanation, use original
306 if len(optimized_query.split()) > 15 or ":" in optimized_query:
307 logger.warning(
308 "Query optimization result looks too verbose, using original"
309 )
310 return query
312 logger.info(f"Original query: '{query}'")
313 logger.info(f"Optimized for search: '{optimized_query}'")
315 return optimized_query
316 except Exception as e:
317 safe_msg = self._scrub_error(e)
318 logger.warning(f"Error optimizing query: {safe_msg}")
319 return query # Fall back to original query on error
321 def _direct_search(self, query: str) -> List[Dict[str, Any]]:
322 """
323 Make a direct search request to the Semantic Scholar API.
325 Args:
326 query: The search query
328 Returns:
329 List of paper dictionaries
330 """
331 try:
332 # Configure fields to retrieve
333 fields = [
334 "paperId",
335 "externalIds",
336 "url",
337 "title",
338 "abstract",
339 "venue",
340 "publicationVenue", # Structured venue with name/type/ISSN
341 "year",
342 "authors",
343 "citationCount", # Add citation count for ranking
344 "openAccessPdf", # PDF URL for open access papers
345 ]
347 if self.get_tldr:
348 fields.append("tldr")
350 params = {
351 "query": query,
352 "limit": min(
353 self.max_results, 100
354 ), # API limit is 100 per request
355 "fields": ",".join(fields),
356 }
358 # Add year filter if specified
359 if self.year_range:
360 start_year, end_year = self.year_range
361 params["year"] = f"{start_year}-{end_year}"
363 # Add fields of study filter if specified
364 if self.fields_of_study:
365 params["fieldsOfStudy"] = ",".join(self.fields_of_study)
367 # Add publication types filter if specified
368 if self.publication_types:
369 params["publicationTypes"] = ",".join(self.publication_types)
371 response = self._make_request(self.paper_search_url, params)
373 if "data" in response:
374 papers = response["data"]
375 logger.info(
376 f"Found {len(papers)} papers with direct search for query: '{query}'"
377 )
378 return papers # type: ignore[no-any-return]
379 logger.warning(
380 f"No data in response for direct search query: '{query}'"
381 )
382 return []
384 except Exception as e:
385 safe_msg = self._scrub_error(e)
386 logger.warning(f"Error in direct search: {safe_msg}")
387 return []
389 def _adaptive_search(self, query: str) -> Tuple[List[Dict[str, Any]], str]:
390 """
391 Perform an adaptive search that adjusts based on result volume.
392 Uses LLM to generate better fallback queries when available.
394 Args:
395 query: The search query
397 Returns:
398 Tuple of (list of paper results, search strategy used)
399 """
400 # Start with a standard search
401 papers = self._direct_search(query)
402 strategy = "standard"
404 # If no results, try different variations
405 if not papers:
406 # Try removing quotes to broaden search
407 if '"' in query:
408 unquoted_query = query.replace('"', "")
409 logger.info(
410 "No results with quoted terms, trying without quotes: {}",
411 unquoted_query,
412 )
413 papers = self._direct_search(unquoted_query)
415 if papers:
416 strategy = "unquoted"
417 return papers, strategy
419 # If LLM is available, use it to generate better fallback queries
420 if self.llm:
421 try:
422 # Generate alternate search queries focusing on core concepts
423 prompt = f"""You are helping refine a search query that returned no results.
425Original query: "{query}"
427The query might be too specific or use natural language phrasing that doesn't match academic paper keywords.
429Please provide THREE alternative search queries that:
4301. Focus on the core academic concepts
4312. Use precise terminology commonly found in academic papers
4323. Break down complex queries into more searchable components
4334. Format each as a concise keyword-focused search term (not a natural language question)
435Format each query on a new line with no numbering or explanation. Keep each query under 8 words and very focused.
436"""
437 # Get the LLM's response
438 response = self.llm.invoke(prompt)
440 # Extract the alternative queries
441 alt_queries = []
442 if hasattr(
443 response, "content"
444 ): # Handle various LLM response formats
445 content = response.content
446 alt_queries = [
447 q.strip()
448 for q in content.strip().split("\n")
449 if q.strip()
450 ]
451 elif isinstance(response, str): 451 ↛ 459line 451 didn't jump to line 459 because the condition on line 451 was always true
452 alt_queries = [
453 q.strip()
454 for q in response.strip().split("\n")
455 if q.strip()
456 ]
458 # Try each alternative query
459 for alt_query in alt_queries[
460 :3
461 ]: # Limit to first 3 alternatives
462 logger.info("Trying LLM-suggested query: {}", alt_query)
463 alt_papers = self._direct_search(alt_query)
465 if alt_papers:
466 logger.info(
467 "Found {} papers using LLM-suggested query: {}",
468 len(alt_papers),
469 alt_query,
470 )
471 strategy = "llm_alternative"
472 return alt_papers, strategy
473 except Exception as e:
474 safe_msg = self._scrub_error(e)
475 logger.warning(
476 f"Error using LLM for query refinement: {safe_msg}"
477 )
478 # Fall through to simpler strategies
480 # Fallback: Try with the longest words (likely specific terms)
481 words = re.findall(r"\w+", query)
482 longer_words = [word for word in words if len(word) > 6]
483 if longer_words:
484 # Use up to 3 of the longest words
485 longer_words = sorted(longer_words, key=len, reverse=True)[:3]
486 key_terms_query = " ".join(longer_words)
487 logger.info("Trying with key terms: {}", key_terms_query)
488 papers = self._direct_search(key_terms_query)
490 if papers:
491 strategy = "key_terms"
492 return papers, strategy
494 # Final fallback: Try with just the longest word
495 if words: 495 ↛ 505line 495 didn't jump to line 505 because the condition on line 495 was always true
496 longest_word = max(words, key=len)
497 if len(longest_word) > 5: # Only use if it's reasonably long
498 logger.info("Trying with single key term: {}", longest_word)
499 papers = self._direct_search(longest_word)
501 if papers:
502 strategy = "single_term"
503 return papers, strategy
505 return papers, strategy
507 def _get_paper_details(self, paper_id: str) -> Dict[str, Any]:
508 """
509 Get detailed information about a specific paper.
511 Args:
512 paper_id: Semantic Scholar Paper ID
514 Returns:
515 Dictionary with paper details
516 """
517 try:
518 # Construct fields parameter
519 fields = [
520 "paperId",
521 "externalIds",
522 "corpusId",
523 "url",
524 "title",
525 "abstract",
526 "venue",
527 "publicationVenue", # Structured venue with name/type/ISSN
528 "year",
529 "authors",
530 "fieldsOfStudy",
531 "citationCount", # Add citation count
532 ]
534 if self.get_tldr:
535 fields.append("tldr")
537 if self.get_embeddings:
538 fields.append("embedding")
540 # Add citation and reference fields if requested
541 if self.get_citations:
542 fields.append(f"citations.limit({self.citation_limit})")
544 if self.get_references:
545 fields.append(f"references.limit({self.reference_limit})")
547 # Make the request
548 url = f"{self.paper_details_url}/{paper_id}"
549 params = {"fields": ",".join(fields)}
551 return self._make_request(url, params)
553 except Exception as e:
554 safe_msg = self._scrub_error(e)
555 logger.warning(f"Error getting paper details: {safe_msg}")
556 return {}
558 def _get_previews(self, query: str) -> List[Dict[str, Any]]:
559 """
560 Get preview information for Semantic Scholar papers.
562 Args:
563 query: The search query
565 Returns:
566 List of preview dictionaries
567 """
568 logger.info(f"Getting Semantic Scholar previews for query: {query}")
570 # Optimize the query if LLM is available
571 optimized_query = self._optimize_query(query)
573 # Use the adaptive search approach
574 papers, strategy = self._adaptive_search(optimized_query)
576 if not papers:
577 logger.warning("No Semantic Scholar results found")
578 return []
580 # Format as previews
581 previews = []
582 for paper in papers:
583 try:
584 # Format authors - ensure we have a valid list with string values
585 authors = []
586 if paper.get("authors"):
587 authors = [
588 author.get("name", "")
589 for author in paper["authors"]
590 if author and author.get("name")
591 ]
593 # Ensure we have valid strings for all fields
594 paper_id = paper.get("paperId", "")
595 title = paper.get("title", "")
596 url = paper.get("url", "")
598 # Handle abstract safely, ensuring we always have a string
599 abstract = paper.get("abstract")
600 snippet = ""
601 if abstract:
602 snippet = (
603 abstract[:SNIPPET_LENGTH_SHORT] + "..."
604 if len(abstract) > SNIPPET_LENGTH_SHORT
605 else abstract
606 )
608 # Prefer publicationVenue (structured, with ISSN) over
609 # venue (plain string, often empty for many papers).
610 pub_venue = paper.get("publicationVenue") or {}
611 venue_name = pub_venue.get("name") or paper.get("venue", "")
612 venue_issn = pub_venue.get("issn")
614 year = paper.get("year")
615 external_ids = paper.get("externalIds", {})
617 # Handle TLDR safely
618 tldr_text = ""
619 if paper.get("tldr") and isinstance(paper.get("tldr"), dict):
620 tldr_text = paper.get("tldr", {}).get("text", "")
622 # Create preview with basic information, ensuring no None values
623 preview = {
624 "id": paper_id if paper_id else "",
625 "title": title if title else "",
626 "link": url if url else "",
627 "snippet": snippet,
628 "authors": authors,
629 "venue": venue_name if venue_name else "",
630 "journal_ref": venue_name if venue_name else None,
631 "issn": venue_issn,
632 "year": year,
633 "external_ids": external_ids if external_ids else {},
634 "source": "Semantic Scholar",
635 "_paper_id": paper_id if paper_id else "",
636 "_search_strategy": strategy,
637 "tldr": tldr_text,
638 }
640 # Store the full paper object for later reference
641 preview["_full_paper"] = paper
643 previews.append(preview)
644 except Exception as e:
645 safe_msg = self._scrub_error(e)
646 logger.warning(f"Error processing paper preview: {safe_msg}")
647 # Continue with the next paper
649 # Sort by year (newer first) if available
650 def _year_key(p: dict[str, Any]) -> int:
651 year = p.get("year")
652 try:
653 return int(year) if year is not None else 0
654 except (TypeError, ValueError):
655 return 0
657 previews = sorted(previews, key=_year_key, reverse=True)
659 logger.info(
660 f"Found {len(previews)} Semantic Scholar previews using strategy: {strategy}"
661 )
662 return previews
664 def _get_full_content(
665 self, relevant_items: List[Dict[str, Any]]
666 ) -> List[Dict[str, Any]]:
667 """
668 Get full content for the relevant Semantic Scholar papers.
669 Gets additional details like citations, references, and full metadata.
671 Args:
672 relevant_items: List of relevant preview dictionaries
674 Returns:
675 List of result dictionaries with full content
676 """
677 # For Semantic Scholar, we already have most content from the preview
678 # Additional API calls are only needed for citations/references
680 logger.info(
681 f"Getting content for {len(relevant_items)} Semantic Scholar papers"
682 )
684 results = []
685 for item in relevant_items:
686 result = item.copy()
687 paper_id = item.get("_paper_id", "")
689 # Skip if no paper ID
690 if not paper_id:
691 results.append(result)
692 continue
694 # Get paper details if citations or references are requested
695 if self.get_citations or self.get_references or self.get_embeddings:
696 paper_details = self._get_paper_details(paper_id)
698 if paper_details:
699 # Add citation information
700 if self.get_citations and "citations" in paper_details:
701 result["citations"] = paper_details["citations"]
703 # Add reference information
704 if self.get_references and "references" in paper_details:
705 result["references"] = paper_details["references"]
707 # Add embedding if available
708 if self.get_embeddings and "embedding" in paper_details:
709 result["embedding"] = paper_details["embedding"]
711 # Add fields of study
712 if "fieldsOfStudy" in paper_details:
713 result["fields_of_study"] = paper_details[
714 "fieldsOfStudy"
715 ]
717 # Promote useful fields from _full_paper to top level before
718 # dropping the raw paper (consistent with NASA ADS/OpenAlex which
719 # expose citations/journal_ref at the top level).
720 full_paper = result.get("_full_paper") or {}
721 if ( 721 ↛ 725line 721 didn't jump to line 725 because the condition on line 721 was never true
722 full_paper.get("citationCount") is not None
723 and "citations" not in result
724 ):
725 result["citations"] = full_paper.get("citationCount")
727 # Remove temporary fields
728 if "_paper_id" in result: 728 ↛ 730line 728 didn't jump to line 730 because the condition on line 728 was always true
729 del result["_paper_id"]
730 if "_search_strategy" in result: 730 ↛ 732line 730 didn't jump to line 732 because the condition on line 730 was always true
731 del result["_search_strategy"]
732 if "_full_paper" in result: 732 ↛ 735line 732 didn't jump to line 735 because the condition on line 732 was always true
733 del result["_full_paper"]
735 results.append(result)
737 return results