Coverage for src/local_deep_research/web_search_engines/engines/search_engine_pubmed.py: 93%

717 statements  

« 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 

3 

4from defusedxml import ElementTree as ET 

5 

6from langchain_core.language_models import BaseLLM 

7 

8from ...constants import SNIPPET_LENGTH_LONG 

9from ...security.safe_requests import safe_get 

10from ...security.secure_logging import logger 

11from ..rate_limiting import RateLimitError 

12from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

13 

14 

15class PubMedSearchEngine(BaseSearchEngine): 

16 """ 

17 PubMed search engine implementation with two-phase approach and adaptive search. 

18 Provides efficient access to biomedical literature while minimizing API usage. 

19 """ 

20 

21 # Mark as public search engine 

22 is_public = True 

23 egress_sensitivity = Sensitivity.NON_SENSITIVE 

24 egress_exposure = Exposure.EXPOSING 

25 # Scientific/medical search engine 

26 is_scientific = True 

27 is_lexical = True 

28 needs_llm_relevance_filter = True 

29 

30 def __init__( 

31 self, 

32 max_results: int = 10, 

33 api_key: Optional[str] = None, 

34 days_limit: Optional[int] = None, 

35 get_abstracts: bool = True, 

36 get_full_text: bool = False, 

37 full_text_limit: int = 3, 

38 llm: Optional[BaseLLM] = None, 

39 max_filtered_results: Optional[int] = None, 

40 optimize_queries: bool = True, 

41 include_publication_type_in_context: bool = True, 

42 include_journal_in_context: bool = True, 

43 include_year_in_context: bool = True, 

44 include_authors_in_context: bool = False, 

45 include_full_date_in_context: bool = False, 

46 include_mesh_terms_in_context: bool = True, 

47 include_keywords_in_context: bool = True, 

48 include_doi_in_context: bool = False, 

49 include_pmid_in_context: bool = False, 

50 include_pmc_availability_in_context: bool = False, 

51 max_mesh_terms: int = 3, 

52 max_keywords: int = 3, 

53 include_citation_in_context: bool = False, 

54 include_language_in_context: bool = False, 

55 settings_snapshot: Optional[Dict[str, Any]] = None, 

56 ): 

57 """ 

58 Initialize the PubMed search engine. 

59 

60 Args: 

61 max_results: Maximum number of search results 

62 api_key: NCBI API key for higher rate limits (optional) 

63 days_limit: Limit results to N days (optional) 

64 get_abstracts: Whether to fetch abstracts for all results 

65 get_full_text: Whether to fetch full text content (when available in PMC) 

66 full_text_limit: Max number of full-text articles to retrieve 

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 for PubMed 

70 """ 

71 # Wire up the journal reputation filter as a preview filter so 

72 # results are scored against bundled OpenAlex/DOAJ/predatory data 

73 # before the (more expensive) LLM relevance pass. 

74 preview_filters = [] 

75 journal_filter = self._create_journal_filter( 

76 "pubmed", llm, settings_snapshot 

77 ) 

78 if journal_filter is not None: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true

79 preview_filters.append(journal_filter) 

80 

81 # Initialize the BaseSearchEngine with LLM, max_filtered_results, and max_results 

82 super().__init__( 

83 llm=llm, 

84 max_filtered_results=max_filtered_results, 

85 max_results=max_results, 

86 preview_filters=preview_filters, # type: ignore[arg-type] 

87 settings_snapshot=settings_snapshot, 

88 ) 

89 self.max_results = max(self.max_results, 25) 

90 self.api_key = api_key 

91 self.days_limit = days_limit 

92 self.get_abstracts = get_abstracts 

93 self.get_full_text = get_full_text 

94 self.full_text_limit = full_text_limit 

95 self.optimize_queries = optimize_queries 

96 self.include_publication_type_in_context = ( 

97 include_publication_type_in_context 

98 ) 

99 self.include_journal_in_context = include_journal_in_context 

100 self.include_year_in_context = include_year_in_context 

101 self.include_authors_in_context = include_authors_in_context 

102 self.include_full_date_in_context = include_full_date_in_context 

103 self.include_mesh_terms_in_context = include_mesh_terms_in_context 

104 self.include_keywords_in_context = include_keywords_in_context 

105 self.include_doi_in_context = include_doi_in_context 

106 self.include_pmid_in_context = include_pmid_in_context 

107 self.include_pmc_availability_in_context = ( 

108 include_pmc_availability_in_context 

109 ) 

110 self.max_mesh_terms = max_mesh_terms 

111 self.max_keywords = max_keywords 

112 self.include_citation_in_context = include_citation_in_context 

113 self.include_language_in_context = include_language_in_context 

114 

115 # Base API URLs 

116 self.base_url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" 

117 self.search_url = f"{self.base_url}/esearch.fcgi" 

118 self.summary_url = f"{self.base_url}/esummary.fcgi" 

119 self.fetch_url = f"{self.base_url}/efetch.fcgi" 

120 self.link_url = f"{self.base_url}/elink.fcgi" 

121 

122 # PMC base URL for full text 

123 self.pmc_url = "https://www.ncbi.nlm.nih.gov/pmc/articles/" 

124 

125 def _get_result_count(self, query: str) -> int: 

126 """ 

127 Get the total number of results for a query without retrieving the results themselves. 

128 

129 Args: 

130 query: The search query 

131 

132 Returns: 

133 Total number of matching results 

134 """ 

135 try: 

136 # Prepare search parameters 

137 params = { 

138 "db": "pubmed", 

139 "term": query, 

140 "retmode": "json", 

141 "retmax": 0, # Don't need actual results, just the count 

142 } 

143 

144 # Add API key if available 

145 if self.api_key: 

146 params["api_key"] = self.api_key 

147 

148 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

149 self.engine_type 

150 ) 

151 

152 # Execute search request 

153 response = safe_get(self.search_url, params=params) 

154 response.raise_for_status() 

155 

156 # Parse response 

157 data = response.json() 

158 count = int(data["esearchresult"]["count"]) 

159 

160 logger.info( 

161 "Query '{}' has {} total results in PubMed", query, count 

162 ) 

163 return count 

164 

165 except Exception as e: 

166 safe_msg = self._scrub_error(e) 

167 logger.warning( 

168 f"Error getting result count ({type(e).__name__}): {safe_msg}" 

169 ) 

170 return 0 

171 

172 def _extract_core_terms(self, query: str) -> str: 

173 """ 

174 Extract core terms from a complex query for volume estimation. 

175 

176 Args: 

177 query: PubMed query string 

178 

179 Returns: 

180 Simplified query with core terms 

181 """ 

182 # Remove field specifications and operators 

183 simplified = re.sub(r"\[\w+\]", "", query) # Remove [Field] tags 

184 simplified = re.sub( 

185 r"\b(AND|OR|NOT)\b", "", simplified 

186 ) # Remove operators 

187 

188 # Remove quotes and parentheses 

189 simplified = ( 

190 simplified.replace('"', "").replace("(", "").replace(")", "") 

191 ) 

192 

193 # Split by whitespace and join terms with 4+ chars (likely meaningful) 

194 terms = [term for term in simplified.split() if len(term) >= 4] 

195 

196 # Join with AND to create a basic search 

197 return " ".join(terms[:5]) # Limit to top 5 terms 

198 

199 def _expand_time_window(self, time_filter: str) -> str: 

200 """ 

201 Expand a time window to get more results. 

202 

203 Args: 

204 time_filter: Current time filter 

205 

206 Returns: 

207 Expanded time filter 

208 """ 

209 # Parse current time window 

210 import re 

211 

212 match = re.match(r'"last (\d+) (\w+)"[pdat]', time_filter) 

213 if not match: 

214 return '"last 10 years"[pdat]' 

215 

216 amount, unit = int(match.group(1)), match.group(2) 

217 

218 # Expand based on current unit 

219 if unit == "months" or unit == "month": 

220 if amount < 6: 

221 return '"last 6 months"[pdat]' 

222 if amount < 12: 

223 return '"last 1 year"[pdat]' 

224 return '"last 2 years"[pdat]' 

225 if unit == "years" or unit == "year": 225 ↛ 232line 225 didn't jump to line 232 because the condition on line 225 was always true

226 if amount < 2: 

227 return '"last 2 years"[pdat]' 

228 if amount < 5: 

229 return '"last 5 years"[pdat]' 

230 return '"last 10 years"[pdat]' 

231 

232 return '"last 10 years"[pdat]' 

233 

234 def _optimize_query_for_pubmed(self, query: str) -> str: 

235 """ 

236 Optimize a natural language query for PubMed search. 

237 Uses LLM to transform questions into effective keyword-based queries. 

238 

239 Args: 

240 query: Natural language query 

241 

242 Returns: 

243 Optimized query string for PubMed 

244 """ 

245 if not self.llm or not self.optimize_queries: 

246 # Return original query if no LLM available or optimization disabled 

247 return query 

248 

249 try: 

250 # Prompt for query optimization 

251 prompt = f"""Transform this natural language question into an optimized PubMed search query. 

252 

253Original query: "{query}" 

254 

255CRITICAL RULES: 

2561. ONLY RETURN THE EXACT SEARCH QUERY - NO EXPLANATIONS, NO COMMENTS 

2572. DO NOT wrap the entire query in quotes 

2583. DO NOT include ANY date restrictions or year filters 

2594. Use parentheses around OR statements: (term1[Field] OR term2[Field]) 

2605. Use only BASIC MeSH terms - stick to broad categories like "Vaccines"[Mesh] 

2616. KEEP IT SIMPLE - use 2-3 main concepts maximum 

2627. Focus on Title/Abstract searches for reliability: term[Title/Abstract] 

2638. Use wildcards for variations: vaccin*[Title/Abstract] 

264 

265EXAMPLE QUERIES: 

266✓ GOOD: (mRNA[Title/Abstract] OR "messenger RNA"[Title/Abstract]) AND vaccin*[Title/Abstract] 

267✓ GOOD: (influenza[Title/Abstract] OR flu[Title/Abstract]) AND treatment[Title/Abstract] 

268✗ BAD: (mRNA[Title/Abstract]) AND "specific disease"[Mesh] AND treatment[Title/Abstract] AND 2023[dp] 

269✗ BAD: "Here's a query to find articles about vaccines..." 

270 

271Return ONLY the search query without any explanations. 

272""" 

273 

274 # Get response from LLM 

275 response = self.llm.invoke(prompt) 

276 raw_response = ( 

277 str(response.content) 

278 if hasattr(response, "content") 

279 else str(response) 

280 ).strip() 

281 

282 # Clean up the query - extract only the actual query and remove any explanations 

283 # First check if there are multiple lines and take the first non-empty line 

284 lines = raw_response.split("\n") 

285 cleaned_lines = [line.strip() for line in lines if line.strip()] 

286 

287 if cleaned_lines: 287 ↛ 337line 287 didn't jump to line 337 because the condition on line 287 was always true

288 optimized_query = cleaned_lines[0] 

289 

290 # Remove any quotes that wrap the entire query 

291 if optimized_query.startswith('"') and optimized_query.endswith( 

292 '"' 

293 ): 

294 optimized_query = optimized_query[1:-1] 

295 

296 # Remove any explanation phrases that might be at the beginning 

297 explanation_starters = [ 

298 "here is", 

299 "here's", 

300 "this query", 

301 "the following", 

302 ] 

303 for starter in explanation_starters: 

304 if optimized_query.lower().startswith(starter): 

305 # Find the actual query part - typically after a colon 

306 colon_pos = optimized_query.find(":") 

307 if colon_pos > 0: 

308 optimized_query = optimized_query[ 

309 colon_pos + 1 : 

310 ].strip() 

311 

312 # Check if the query still seems to contain explanations 

313 if ( 

314 len(optimized_query) > 200 

315 or "this query will" in optimized_query.lower() 

316 ): 

317 # It's probably still an explanation - try to extract just the query part 

318 # Look for common patterns in the explanation like parentheses 

319 pattern = r"\([^)]+\)\s+AND\s+" 

320 import re 

321 

322 matches = re.findall(pattern, optimized_query) 

323 if matches: 323 ↛ 343line 323 didn't jump to line 343 because the condition on line 323 was always true

324 # Extract just the query syntax parts 

325 query_parts = [] 

326 for part in re.split(r"\.\s+", optimized_query): 

327 if ( 

328 "(" in part 

329 and ")" in part 

330 and ("AND" in part or "OR" in part) 

331 ): 

332 query_parts.append(part) 

333 if query_parts: 333 ↛ 343line 333 didn't jump to line 343 because the condition on line 333 was always true

334 optimized_query = " ".join(query_parts) 

335 else: 

336 # Fall back to original query if cleaning fails 

337 logger.warning( 

338 "Failed to extract a clean query from LLM response" 

339 ) 

340 optimized_query = query 

341 

342 # Final safety check - if query looks too much like an explanation, use original 

343 if len(optimized_query.split()) > 30: 

344 logger.warning( 

345 "Query too verbose, falling back to simpler form" 

346 ) 

347 # Create a simple query from the original 

348 words = [ 

349 w 

350 for w in query.split() 

351 if len(w) > 3 

352 and w.lower() 

353 not in ( 

354 "what", 

355 "are", 

356 "the", 

357 "and", 

358 "for", 

359 "with", 

360 "from", 

361 "have", 

362 "been", 

363 "recent", 

364 ) 

365 ] 

366 optimized_query = " AND ".join(words[:3]) 

367 

368 # Basic cleanup: standardize field tag case for consistency 

369 import re 

370 

371 optimized_query = re.sub( 

372 r"\[mesh\]", "[Mesh]", optimized_query, flags=re.IGNORECASE 

373 ) 

374 optimized_query = re.sub( 

375 r"\[title/abstract\]", 

376 "[Title/Abstract]", 

377 optimized_query, 

378 flags=re.IGNORECASE, 

379 ) 

380 optimized_query = re.sub( 

381 r"\[publication type\]", 

382 "[Publication Type]", 

383 optimized_query, 

384 flags=re.IGNORECASE, 

385 ) 

386 

387 # Fix unclosed quotes followed by field tags 

388 # Pattern: "term[Field] -> "term"[Field] 

389 optimized_query = re.sub(r'"([^"]+)\[', r'"\1"[', optimized_query) 

390 

391 # Simplify the query if still no results are found 

392 self._simplify_query_cache = optimized_query 

393 

394 # Log original and optimized queries 

395 logger.info("Original query: '{}'", query) 

396 logger.info(f"Optimized for PubMed: '{optimized_query}'") 

397 logger.debug( 

398 f"Query optimization complete: '{query[:50]}...' -> '{optimized_query[:100]}...'" 

399 ) 

400 

401 return optimized_query 

402 

403 except Exception as e: 

404 safe_msg = self._scrub_error(e) 

405 logger.exception( 

406 f"Error optimizing query ({type(e).__name__}): {safe_msg}" 

407 ) 

408 logger.debug(f"Falling back to original query: '{query}'") 

409 return query # Fall back to original query on error 

410 

411 def _simplify_query(self, query: str) -> str: 

412 """ 

413 Simplify a PubMed query that returned no results. 

414 Progressively removes elements to get a more basic query. 

415 

416 Args: 

417 query: The original query that returned no results 

418 

419 Returns: 

420 Simplified query 

421 """ 

422 logger.info(f"Simplifying query: {query}") 

423 logger.debug(f"Query simplification started for: '{query[:100]}...'") 

424 

425 # Simple approach: remove field restrictions to broaden the search 

426 import re 

427 

428 # Remove field tags to make search broader 

429 simplified = query 

430 

431 # Remove [Mesh] tags - search in all fields instead 

432 simplified = re.sub(r"\[Mesh\]", "", simplified, flags=re.IGNORECASE) 

433 

434 # Remove [Publication Type] tags 

435 simplified = re.sub( 

436 r"\[Publication Type\]", "", simplified, flags=re.IGNORECASE 

437 ) 

438 

439 # Keep [Title/Abstract] as it's usually helpful 

440 # Clean up any double spaces 

441 simplified = re.sub(r"\s+", " ", simplified).strip() 

442 

443 # If no simplification was possible, return the original query 

444 if simplified == query: 

445 logger.debug("No simplification possible, returning original query") 

446 

447 logger.info(f"Simplified query: {simplified}") 

448 logger.debug( 

449 f"Query simplified from {len(query)} to {len(simplified)} chars" 

450 ) 

451 return simplified 

452 

453 def _is_historical_focused(self, query: str) -> bool: 

454 """ 

455 Determine if a query is specifically focused on historical/older information using LLM. 

456 Default assumption is that queries should prioritize recent information unless 

457 explicitly asking for historical content. 

458 

459 Args: 

460 query: The search query 

461 

462 Returns: 

463 Boolean indicating if the query is focused on historical information 

464 """ 

465 if not self.llm: 

466 # Fall back to basic keyword check if no LLM available 

467 historical_terms = [ 

468 "history", 

469 "historical", 

470 "early", 

471 "initial", 

472 "first", 

473 "original", 

474 "before", 

475 "prior to", 

476 "origins", 

477 "evolution", 

478 "development", 

479 ] 

480 historical_years = [str(year) for year in range(1900, 2020)] 

481 

482 query_lower = query.lower() 

483 has_historical_term = any( 

484 term in query_lower for term in historical_terms 

485 ) 

486 has_past_year = any(year in query for year in historical_years) 

487 

488 return has_historical_term or has_past_year 

489 

490 try: 

491 # Use LLM to determine if the query is focused on historical information 

492 prompt = f"""Determine if this query is specifically asking for HISTORICAL or OLDER information. 

493 

494Query: "{query}" 

495 

496Answer ONLY "yes" if the query is clearly asking for historical, early, original, or past information from more than 5 years ago. 

497Answer ONLY "no" if the query is asking about recent, current, or new information, or if it's a general query without a specific time focus. 

498 

499The default assumption should be that medical and scientific queries want RECENT information unless clearly specified otherwise. 

500""" 

501 

502 response = self.llm.invoke(prompt) 

503 answer = ( 

504 ( 

505 str(response.content) 

506 if hasattr(response, "content") 

507 else str(response) 

508 ) 

509 .strip() 

510 .lower() 

511 ) 

512 

513 # Log the determination 

514 logger.info(f"Historical focus determination for query: '{query}'") 

515 logger.info(f"LLM determined historical focus: {answer}") 

516 

517 return "yes" in answer 

518 

519 except Exception as e: 

520 safe_msg = self._scrub_error(e) 

521 logger.exception( 

522 f"Error determining historical focus ({type(e).__name__}): {safe_msg}" 

523 ) 

524 # Fall back to basic keyword check 

525 historical_terms = [ 

526 "history", 

527 "historical", 

528 "early", 

529 "initial", 

530 "first", 

531 "original", 

532 "before", 

533 "prior to", 

534 "origins", 

535 "evolution", 

536 "development", 

537 ] 

538 return any(term in query.lower() for term in historical_terms) 

539 

540 def _adaptive_search(self, query: str) -> Tuple[List[str], str]: 

541 """ 

542 Perform an adaptive search that adjusts based on topic volume and whether 

543 the query focuses on historical information. 

544 

545 Args: 

546 query: The search query (already optimized) 

547 

548 Returns: 

549 Tuple of (list of PMIDs, search strategy used) 

550 """ 

551 # Estimate topic volume 

552 estimated_volume = self._get_result_count(query) 

553 

554 # Determine if the query is focused on historical information 

555 is_historical_focused = self._is_historical_focused(query) 

556 

557 if is_historical_focused: 

558 # User wants historical information - no date filtering 

559 time_filter = None 

560 strategy = "historical_focus" 

561 elif estimated_volume > 5000: 

562 # Very common topic - use tighter recency filter 

563 time_filter = '"last 1 year"[pdat]' 

564 strategy = "high_volume" 

565 elif estimated_volume > 1000: 

566 # Common topic 

567 time_filter = '"last 3 years"[pdat]' 

568 strategy = "common_topic" 

569 elif estimated_volume > 100: 

570 # Moderate volume 

571 time_filter = '"last 5 years"[pdat]' 

572 strategy = "moderate_volume" 

573 else: 

574 # Rare topic - still use recency but with wider range 

575 time_filter = '"last 10 years"[pdat]' 

576 strategy = "rare_topic" 

577 

578 # Run search based on strategy 

579 if time_filter: 

580 # Try with adaptive time filter 

581 query_with_time = f"({query}) AND {time_filter}" 

582 logger.info( 

583 f"Using adaptive search strategy: {strategy} with filter: {time_filter}" 

584 ) 

585 results = self._search_pubmed(query_with_time) 

586 

587 # If too few results, gradually expand time window 

588 if len(results) < 5 and '"last 10 years"[pdat]' not in time_filter: 

589 logger.info( 

590 f"Insufficient results ({len(results)}), expanding time window" 

591 ) 

592 expanded_time = self._expand_time_window(time_filter) 

593 query_with_expanded_time = f"({query}) AND {expanded_time}" 

594 expanded_results = self._search_pubmed(query_with_expanded_time) 

595 

596 if len(expanded_results) > len(results): 

597 logger.info( 

598 f"Expanded time window yielded {len(expanded_results)} results" 

599 ) 

600 return expanded_results, f"{strategy}_expanded" 

601 

602 # If still no results, try without time filter 

603 if not results: 

604 logger.info( 

605 "No results with time filter, trying without time restrictions" 

606 ) 

607 results = self._search_pubmed(query) 

608 strategy = "no_time_filter" 

609 else: 

610 # Historical query - run without time filter 

611 logger.info( 

612 "Using historical search strategy without date filtering" 

613 ) 

614 results = self._search_pubmed(query) 

615 

616 return results, strategy 

617 

618 def _search_pubmed(self, query: str) -> List[str]: 

619 """ 

620 Search PubMed and return a list of article IDs. 

621 

622 Args: 

623 query: The search query 

624 

625 Returns: 

626 List of PubMed IDs matching the query 

627 """ 

628 try: 

629 # Prepare search parameters 

630 params = { 

631 "db": "pubmed", 

632 "term": query, 

633 "retmode": "json", 

634 # Cap PMIDs fetched per query so a large user-supplied 

635 # max_results can't trigger an unbounded esearch/efetch load. 

636 "retmax": min(self.max_results, 100), 

637 "usehistory": "y", 

638 } 

639 

640 # Add API key if available 

641 if self.api_key: 

642 params["api_key"] = self.api_key 

643 logger.debug("Using PubMed API key for higher rate limits") 

644 else: 

645 logger.debug("No PubMed API key - using default rate limits") 

646 

647 # Add date restriction if specified 

648 if self.days_limit: 

649 params["reldate"] = self.days_limit 

650 params["datetype"] = "pdat" # Publication date 

651 logger.debug(f"Limiting results to last {self.days_limit} days") 

652 

653 logger.debug( 

654 f"PubMed search query: '{query}' with max_results={self.max_results}" 

655 ) 

656 

657 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

658 self.engine_type 

659 ) 

660 logger.debug( 

661 f"Applied rate limit wait: {self._last_wait_time:.2f}s" 

662 ) 

663 

664 # Execute search request 

665 logger.debug(f"Sending request to PubMed API: {self.search_url}") 

666 response = safe_get(self.search_url, params=params) 

667 response.raise_for_status() 

668 logger.debug(f"PubMed API response status: {response.status_code}") 

669 

670 # Parse response 

671 data = response.json() 

672 id_list: list[str] = data["esearchresult"]["idlist"] 

673 total_count = data["esearchresult"].get("count", "unknown") 

674 

675 logger.info( 

676 f"PubMed search for '{query}' found {len(id_list)} results (total available: {total_count})" 

677 ) 

678 if len(id_list) > 0: 

679 logger.debug(f"First 5 PMIDs: {id_list[:5]}") 

680 return id_list 

681 

682 except Exception as e: 

683 safe_msg = self._scrub_error(e) 

684 logger.warning( 

685 f"Error searching PubMed for query '{query}' ({type(e).__name__}): {safe_msg}" 

686 ) 

687 return [] 

688 

689 def _get_article_summaries( 

690 self, id_list: List[str] 

691 ) -> List[Dict[str, Any]]: 

692 """ 

693 Get summaries for a list of PubMed article IDs. 

694 

695 Args: 

696 id_list: List of PubMed IDs 

697 

698 Returns: 

699 List of article summary dictionaries 

700 """ 

701 if not id_list: 

702 logger.debug("Empty ID list provided to _get_article_summaries") 

703 return [] 

704 

705 logger.debug(f"Fetching summaries for {len(id_list)} PubMed articles") 

706 

707 try: 

708 # Prepare parameters 

709 params = { 

710 "db": "pubmed", 

711 "id": ",".join(id_list), 

712 "retmode": "json", 

713 "rettype": "summary", 

714 } 

715 

716 # Add API key if available 

717 if self.api_key: 

718 params["api_key"] = self.api_key 

719 

720 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

721 self.engine_type 

722 ) 

723 logger.debug( 

724 f"Applied rate limit wait: {self._last_wait_time:.2f}s" 

725 ) 

726 

727 # Execute request 

728 logger.debug(f"Requesting summaries from: {self.summary_url}") 

729 response = safe_get(self.summary_url, params=params) 

730 response.raise_for_status() 

731 logger.debug(f"Summary API response status: {response.status_code}") 

732 

733 # Parse response 

734 data = response.json() 

735 logger.debug( 

736 f"PubMed API returned data for {len(id_list)} requested IDs" 

737 ) 

738 summaries = [] 

739 

740 for pmid in id_list: 

741 if pmid in data["result"]: 741 ↛ 789line 741 didn't jump to line 789 because the condition on line 741 was always true

742 article = data["result"][pmid] 

743 logger.debug( 

744 f"Processing article {pmid}: {article.get('title', 'NO TITLE')[:50]}" 

745 ) 

746 

747 # Extract authors (if available) 

748 authors = [] 

749 if "authors" in article: 749 ↛ 755line 749 didn't jump to line 755 because the condition on line 749 was always true

750 authors = [ 

751 author["name"] for author in article["authors"] 

752 ] 

753 

754 # Extract DOI from articleids if not in main field 

755 doi = article.get("doi", "") 

756 if not doi and "articleids" in article: 756 ↛ 763line 756 didn't jump to line 763 because the condition on line 756 was always true

757 for aid in article["articleids"]: 757 ↛ 763line 757 didn't jump to line 763 because the loop on line 757 didn't complete

758 if aid.get("idtype") == "doi": 758 ↛ 757line 758 didn't jump to line 757 because the condition on line 758 was always true

759 doi = aid.get("value", "") 

760 break 

761 

762 # Create summary dictionary with all available fields 

763 summary = { 

764 "id": pmid, 

765 "title": article.get("title", ""), 

766 "pubdate": article.get("pubdate", ""), 

767 "epubdate": article.get("epubdate", ""), 

768 "source": article.get("source", ""), 

769 "authors": authors, 

770 "lastauthor": article.get("lastauthor", ""), 

771 "journal": article.get("fulljournalname", ""), 

772 "volume": article.get("volume", ""), 

773 "issue": article.get("issue", ""), 

774 "pages": article.get("pages", ""), 

775 "doi": doi, 

776 "issn": article.get("issn", ""), 

777 "essn": article.get("essn", ""), 

778 "pubtype": article.get( 

779 "pubtype", [] 

780 ), # Publication types from esummary 

781 "recordstatus": article.get("recordstatus", ""), 

782 "lang": article.get("lang", []), 

783 "pmcrefcount": article.get("pmcrefcount", None), 

784 "link": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/", 

785 } 

786 

787 summaries.append(summary) 

788 else: 

789 logger.warning( 

790 f"PMID {pmid} not found in PubMed API response" 

791 ) 

792 

793 return summaries 

794 

795 except Exception as e: 

796 error_msg = str(e) 

797 safe_msg = self._scrub_error(error_msg) 

798 logger.warning( 

799 f"Error getting article summaries for {len(id_list)} articles ({type(e).__name__}): {safe_msg}" 

800 ) 

801 

802 # Check for rate limiting patterns 

803 if ( 

804 "429" in error_msg 

805 or "too many requests" in error_msg.lower() 

806 or "rate limit" in error_msg.lower() 

807 or "service unavailable" in error_msg.lower() 

808 or "503" in error_msg 

809 or "403" in error_msg 

810 ): 

811 # `from None` suppresses the implicit __context__ chain: 

812 # the original exception still carries the raw message, so 

813 # a full traceback render (chain=True) would re-leak the 

814 # secret that safe_msg just scrubbed. 

815 raise RateLimitError( 

816 f"PubMed rate limit hit: {safe_msg}" 

817 ) from None 

818 

819 return [] 

820 

821 def _get_article_abstracts(self, id_list: List[str]) -> Dict[str, str]: 

822 """ 

823 Get abstracts for a list of PubMed article IDs. 

824 

825 Args: 

826 id_list: List of PubMed IDs 

827 

828 Returns: 

829 Dictionary mapping PubMed IDs to their abstracts 

830 """ 

831 if not id_list: 

832 logger.debug("Empty ID list provided to _get_article_abstracts") 

833 return {} 

834 

835 logger.debug(f"Fetching abstracts for {len(id_list)} PubMed articles") 

836 

837 try: 

838 # Prepare parameters 

839 params = { 

840 "db": "pubmed", 

841 "id": ",".join(id_list), 

842 "retmode": "xml", 

843 "rettype": "abstract", 

844 } 

845 

846 # Add API key if available 

847 if self.api_key: 

848 params["api_key"] = self.api_key 

849 

850 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

851 self.engine_type 

852 ) 

853 logger.debug( 

854 f"Applied rate limit wait: {self._last_wait_time:.2f}s" 

855 ) 

856 

857 # Execute request 

858 logger.debug(f"Requesting abstracts from: {self.fetch_url}") 

859 response = safe_get(self.fetch_url, params=params) 

860 response.raise_for_status() 

861 logger.debug( 

862 f"Abstract fetch response status: {response.status_code}, size: {len(response.text)} bytes" 

863 ) 

864 

865 # Parse XML response 

866 root = ET.fromstring(response.text) 

867 logger.debug( 

868 f"Parsing abstracts from XML for {len(id_list)} articles" 

869 ) 

870 

871 # Extract abstracts 

872 abstracts = {} 

873 

874 for article in root.findall(".//PubmedArticle"): 

875 pmid_elem = article.find(".//PMID") 

876 pmid = pmid_elem.text if pmid_elem is not None else None 

877 

878 if pmid is None: 

879 continue 

880 

881 # Find abstract text 

882 abstract_text = "" 

883 abstract_elem = article.find(".//AbstractText") 

884 

885 if abstract_elem is not None: 885 ↛ 889line 885 didn't jump to line 889 because the condition on line 885 was always true

886 abstract_text = abstract_elem.text or "" 

887 

888 # Some abstracts are split into multiple sections 

889 abstract_sections = article.findall(".//AbstractText") 

890 if len(abstract_sections) > 1: 

891 logger.debug( 

892 f"Article {pmid} has {len(abstract_sections)} abstract sections" 

893 ) 

894 

895 for section in abstract_sections: 

896 # Get section label if it exists 

897 label = section.get("Label") 

898 section_text = section.text or "" 

899 

900 if label and section_text: 

901 if abstract_text: 901 ↛ 904line 901 didn't jump to line 904 because the condition on line 901 was always true

902 abstract_text += f"\n\n{label}: {section_text}" 

903 else: 

904 abstract_text = f"{label}: {section_text}" 

905 elif section_text: 

906 if abstract_text: 906 ↛ 909line 906 didn't jump to line 909 because the condition on line 906 was always true

907 abstract_text += f"\n\n{section_text}" 

908 else: 

909 abstract_text = section_text 

910 

911 # Store in dictionary 

912 if pmid and abstract_text: 

913 abstracts[pmid] = abstract_text 

914 logger.debug( 

915 f"Abstract for {pmid}: {len(abstract_text)} chars" 

916 ) 

917 elif pmid: 917 ↛ 874line 917 didn't jump to line 874 because the condition on line 917 was always true

918 logger.warning(f"No abstract found for PMID {pmid}") 

919 

920 logger.info( 

921 f"Successfully retrieved {len(abstracts)} abstracts out of {len(id_list)} requested" 

922 ) 

923 return abstracts 

924 

925 except Exception as e: 

926 safe_msg = self._scrub_error(e) 

927 logger.warning( 

928 f"Error getting article abstracts for {len(id_list)} articles ({type(e).__name__}): {safe_msg}" 

929 ) 

930 return {} 

931 

932 def _get_article_detailed_metadata( 

933 self, id_list: List[str] 

934 ) -> Dict[str, Dict[str, Any]]: 

935 """ 

936 Get detailed metadata for PubMed articles including publication types, 

937 MeSH terms, keywords, and affiliations. 

938 

939 Args: 

940 id_list: List of PubMed IDs 

941 

942 Returns: 

943 Dictionary mapping PubMed IDs to their detailed metadata 

944 """ 

945 if not id_list: 

946 return {} 

947 

948 try: 

949 # Prepare parameters 

950 params = { 

951 "db": "pubmed", 

952 "id": ",".join(id_list), 

953 "retmode": "xml", 

954 "rettype": "medline", 

955 } 

956 

957 # Add API key if available 

958 if self.api_key: 

959 params["api_key"] = self.api_key 

960 

961 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

962 self.engine_type 

963 ) 

964 

965 # Execute request 

966 response = safe_get(self.fetch_url, params=params) 

967 response.raise_for_status() 

968 

969 # Parse XML response 

970 root = ET.fromstring(response.text) 

971 

972 metadata = {} 

973 

974 for article in root.findall(".//PubmedArticle"): 

975 pmid_elem = article.find(".//PMID") 

976 pmid = pmid_elem.text if pmid_elem is not None else None 

977 

978 if pmid is None: 978 ↛ 979line 978 didn't jump to line 979 because the condition on line 978 was never true

979 continue 

980 

981 article_metadata: Dict[str, Any] = {} 

982 

983 # Extract publication types 

984 pub_types = [] 

985 for pub_type in article.findall(".//PublicationType"): 

986 if pub_type.text: 986 ↛ 985line 986 didn't jump to line 985 because the condition on line 986 was always true

987 pub_types.append(pub_type.text) 

988 if pub_types: 

989 article_metadata["publication_types"] = pub_types 

990 

991 # Extract MeSH terms 

992 mesh_terms = [] 

993 for mesh in article.findall(".//MeshHeading"): 

994 descriptor = mesh.find(".//DescriptorName") 

995 if descriptor is not None and descriptor.text: 995 ↛ 993line 995 didn't jump to line 993 because the condition on line 995 was always true

996 mesh_terms.append(descriptor.text) 

997 if mesh_terms: 

998 article_metadata["mesh_terms"] = mesh_terms 

999 

1000 # Extract keywords 

1001 keywords = [] 

1002 for keyword in article.findall(".//Keyword"): 

1003 if keyword.text: 1003 ↛ 1002line 1003 didn't jump to line 1002 because the condition on line 1003 was always true

1004 keywords.append(keyword.text) 

1005 if keywords: 

1006 article_metadata["keywords"] = keywords 

1007 

1008 # Extract affiliations 

1009 affiliations = [] 

1010 for affiliation in article.findall(".//Affiliation"): 

1011 if affiliation.text: 1011 ↛ 1010line 1011 didn't jump to line 1010 because the condition on line 1011 was always true

1012 affiliations.append(affiliation.text) 

1013 if affiliations: 

1014 article_metadata["affiliations"] = affiliations 

1015 

1016 # Extract grant information 

1017 grants = [] 

1018 for grant in article.findall(".//Grant"): 

1019 grant_info = {} 

1020 grant_id = grant.find(".//GrantID") 

1021 if grant_id is not None and grant_id.text: 1021 ↛ 1023line 1021 didn't jump to line 1023 because the condition on line 1021 was always true

1022 grant_info["id"] = grant_id.text 

1023 agency = grant.find(".//Agency") 

1024 if agency is not None and agency.text: 1024 ↛ 1026line 1024 didn't jump to line 1026 because the condition on line 1024 was always true

1025 grant_info["agency"] = agency.text 

1026 if grant_info: 1026 ↛ 1018line 1026 didn't jump to line 1018 because the condition on line 1026 was always true

1027 grants.append(grant_info) 

1028 if grants: 

1029 article_metadata["grants"] = grants 

1030 

1031 # Check for free full text in PMC 

1032 pmc_elem = article.find(".//ArticleId[@IdType='pmc']") 

1033 if pmc_elem is not None: 

1034 article_metadata["has_free_full_text"] = True 

1035 article_metadata["pmc_id"] = pmc_elem.text 

1036 

1037 # Extract conflict of interest statement 

1038 coi_elem = article.find(".//CoiStatement") 

1039 if coi_elem is not None and coi_elem.text: 

1040 article_metadata["conflict_of_interest"] = coi_elem.text 

1041 

1042 metadata[pmid] = article_metadata 

1043 

1044 return metadata 

1045 

1046 except Exception as e: 

1047 safe_msg = self._scrub_error(e) 

1048 logger.warning( 

1049 f"Error getting detailed article metadata ({type(e).__name__}): {safe_msg}" 

1050 ) 

1051 return {} 

1052 

1053 def _create_enriched_content( 

1054 self, result: Dict[str, Any], base_content: str 

1055 ) -> str: 

1056 """ 

1057 Create enriched content by adding relevant metadata context to help the LLM. 

1058 

1059 Args: 

1060 result: The result dictionary with metadata 

1061 base_content: The base content (abstract or full text) 

1062 

1063 Returns: 

1064 Enriched content string with metadata context 

1065 """ 

1066 enriched_parts = [] 

1067 

1068 # Add study type information 

1069 if "publication_types" in result: 

1070 pub_types = result["publication_types"] 

1071 # Filter for significant types 

1072 significant_types = [ 

1073 pt 

1074 for pt in pub_types 

1075 if any( 

1076 key in pt.lower() 

1077 for key in [ 

1078 "clinical trial", 

1079 "randomized", 

1080 "meta-analysis", 

1081 "systematic review", 

1082 "case report", 

1083 "guideline", 

1084 "comparative study", 

1085 "multicenter", 

1086 ] 

1087 ) 

1088 ] 

1089 if significant_types: 

1090 enriched_parts.append( 

1091 f"[Study Type: {', '.join(significant_types)}]" 

1092 ) 

1093 

1094 # Add the main content 

1095 enriched_parts.append(base_content) 

1096 

1097 # Add metadata footer 

1098 metadata_footer = [] 

1099 

1100 # Add ALL MeSH terms 

1101 if "mesh_terms" in result and len(result["mesh_terms"]) > 0: 

1102 metadata_footer.append( 

1103 f"Medical Topics (MeSH): {', '.join(result['mesh_terms'])}" 

1104 ) 

1105 

1106 # Add ALL keywords 

1107 if "keywords" in result and len(result["keywords"]) > 0: 

1108 metadata_footer.append(f"Keywords: {', '.join(result['keywords'])}") 

1109 

1110 # Add ALL affiliations 

1111 if "affiliations" in result and len(result["affiliations"]) > 0: 

1112 if len(result["affiliations"]) == 1: 

1113 metadata_footer.append( 

1114 f"Institution: {result['affiliations'][0]}" 

1115 ) 

1116 else: 

1117 affiliations_text = "\n - " + "\n - ".join( 

1118 result["affiliations"] 

1119 ) 

1120 metadata_footer.append(f"Institutions:{affiliations_text}") 

1121 

1122 # Add ALL funding information with full details 

1123 if "grants" in result and len(result["grants"]) > 0: 

1124 grant_details = [] 

1125 for grant in result["grants"]: 

1126 grant_text = [] 

1127 if "agency" in grant: 

1128 grant_text.append(grant["agency"]) 

1129 if "id" in grant: 

1130 grant_text.append(f"(Grant ID: {grant['id']})") 

1131 if grant_text: 

1132 grant_details.append(" ".join(grant_text)) 

1133 if grant_details: 

1134 if len(grant_details) == 1: 

1135 metadata_footer.append(f"Funded by: {grant_details[0]}") 

1136 else: 

1137 funding_text = "\n - " + "\n - ".join(grant_details) 

1138 metadata_footer.append(f"Funding Sources:{funding_text}") 

1139 

1140 # Add FULL conflict of interest statement 

1141 if "conflict_of_interest" in result: 

1142 coi_text = result["conflict_of_interest"] 

1143 if coi_text: 

1144 # Still skip trivial "no conflict" statements to reduce noise 

1145 if not any( 

1146 phrase in coi_text.lower() 

1147 for phrase in [ 

1148 "no conflict", 

1149 "no competing", 

1150 "nothing to disclose", 

1151 "none declared", 

1152 "authors declare no", 

1153 ] 

1154 ): 

1155 metadata_footer.append(f"Conflict of Interest: {coi_text}") 

1156 elif ( 

1157 "but" in coi_text.lower() 

1158 or "except" in coi_text.lower() 

1159 or "however" in coi_text.lower() 

1160 ): 

1161 # Include if there's a "no conflict BUT..." type statement 

1162 metadata_footer.append(f"Conflict of Interest: {coi_text}") 

1163 

1164 # Combine everything 

1165 if metadata_footer: 

1166 enriched_parts.append("\n---\nStudy Metadata:") 

1167 enriched_parts.extend(metadata_footer) 

1168 

1169 return "\n".join(enriched_parts) 

1170 

1171 def _find_pmc_ids(self, pmid_list: List[str]) -> Dict[str, str]: 

1172 """ 

1173 Find PMC IDs for the given PubMed IDs (for full-text access). 

1174 

1175 Args: 

1176 pmid_list: List of PubMed IDs 

1177 

1178 Returns: 

1179 Dictionary mapping PubMed IDs to their PMC IDs (if available) 

1180 """ 

1181 if not pmid_list or not self.get_full_text: 

1182 return {} 

1183 

1184 try: 

1185 # Prepare parameters 

1186 params = { 

1187 "dbfrom": "pubmed", 

1188 "db": "pmc", 

1189 "linkname": "pubmed_pmc", 

1190 "id": ",".join(pmid_list), 

1191 "retmode": "json", 

1192 } 

1193 

1194 # Add API key if available 

1195 if self.api_key: 

1196 params["api_key"] = self.api_key 

1197 

1198 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

1199 self.engine_type 

1200 ) 

1201 

1202 # Execute request 

1203 response = safe_get(self.link_url, params=params) 

1204 response.raise_for_status() 

1205 

1206 # Parse response 

1207 data = response.json() 

1208 

1209 # Map PubMed IDs to PMC IDs 

1210 pmid_to_pmcid = {} 

1211 

1212 for linkset in data.get("linksets", []): 

1213 pmid = linkset.get("ids", [None])[0] 

1214 

1215 if not pmid: 1215 ↛ 1216line 1215 didn't jump to line 1216 because the condition on line 1215 was never true

1216 continue 

1217 

1218 for link in linkset.get("linksetdbs", []): 

1219 if link.get("linkname") == "pubmed_pmc": 1219 ↛ 1218line 1219 didn't jump to line 1218 because the condition on line 1219 was always true

1220 pmcids = link.get("links", []) 

1221 if pmcids: 1221 ↛ 1218line 1221 didn't jump to line 1218 because the condition on line 1221 was always true

1222 pmid_to_pmcid[str(pmid)] = f"PMC{pmcids[0]}" 

1223 

1224 logger.info( 

1225 f"Found {len(pmid_to_pmcid)} PMC IDs for full-text access" 

1226 ) 

1227 return pmid_to_pmcid 

1228 

1229 except Exception as e: 

1230 safe_msg = self._scrub_error(e) 

1231 logger.warning( 

1232 f"Error finding PMC IDs ({type(e).__name__}): {safe_msg}" 

1233 ) 

1234 return {} 

1235 

1236 def _get_pmc_full_text(self, pmcid: str) -> str: 

1237 """ 

1238 Get full text for a PMC article. 

1239 

1240 Args: 

1241 pmcid: PMC ID of the article 

1242 

1243 Returns: 

1244 Full text content or empty string if not available 

1245 """ 

1246 try: 

1247 # Prepare parameters 

1248 params = { 

1249 "db": "pmc", 

1250 "id": pmcid, 

1251 "retmode": "xml", 

1252 "rettype": "full", 

1253 } 

1254 

1255 # Add API key if available 

1256 if self.api_key: 

1257 params["api_key"] = self.api_key 

1258 

1259 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

1260 self.engine_type 

1261 ) 

1262 

1263 # Execute request 

1264 response = safe_get(self.fetch_url, params=params) 

1265 response.raise_for_status() 

1266 

1267 # Parse XML response 

1268 root = ET.fromstring(response.text) 

1269 

1270 # Extract full text 

1271 full_text = [] 

1272 

1273 # Extract article title 

1274 title_elem = root.find(".//article-title") 

1275 if title_elem is not None and title_elem.text: 1275 ↛ 1279line 1275 didn't jump to line 1279 because the condition on line 1275 was always true

1276 full_text.append(f"# {title_elem.text}") 

1277 

1278 # Extract abstract 

1279 abstract_paras = root.findall(".//abstract//p") 

1280 if abstract_paras: 

1281 full_text.append("\n## Abstract\n") 

1282 for p in abstract_paras: 

1283 text = "".join(p.itertext()) 

1284 if text: 1284 ↛ 1282line 1284 didn't jump to line 1282 because the condition on line 1284 was always true

1285 full_text.append(text) 

1286 

1287 # Extract body content 

1288 body = root.find(".//body") 

1289 if body is not None: 1289 ↛ 1302line 1289 didn't jump to line 1302 because the condition on line 1289 was always true

1290 for section in body.findall(".//sec"): 

1291 # Get section title 

1292 title = section.find(".//title") 

1293 if title is not None and title.text: 1293 ↛ 1297line 1293 didn't jump to line 1297 because the condition on line 1293 was always true

1294 full_text.append(f"\n## {title.text}\n") 

1295 

1296 # Get paragraphs 

1297 for p in section.findall(".//p"): 

1298 text = "".join(p.itertext()) 

1299 if text: 1299 ↛ 1297line 1299 didn't jump to line 1297 because the condition on line 1299 was always true

1300 full_text.append(text) 

1301 

1302 result_text = "\n\n".join(full_text) 

1303 logger.debug( 

1304 f"Successfully extracted {len(result_text)} chars of PMC full text with {len(full_text)} sections" 

1305 ) 

1306 return result_text 

1307 

1308 except Exception as e: 

1309 safe_msg = self._scrub_error(e) 

1310 logger.warning( 

1311 f"Error getting PMC full text ({type(e).__name__}): {safe_msg}" 

1312 ) 

1313 return "" 

1314 

1315 def _get_previews(self, query: str) -> List[Dict[str, Any]]: 

1316 """ 

1317 Get preview information for PubMed articles. 

1318 

1319 Args: 

1320 query: The search query 

1321 

1322 Returns: 

1323 List of preview dictionaries 

1324 """ 

1325 logger.info(f"Getting PubMed previews for query: {query}") 

1326 

1327 # Optimize the query for PubMed if LLM is available 

1328 optimized_query = self._optimize_query_for_pubmed(query) 

1329 

1330 # Perform adaptive search 

1331 pmid_list, strategy = self._adaptive_search(optimized_query) 

1332 

1333 # If no results, try a simplified query 

1334 if not pmid_list: 

1335 logger.warning( 

1336 f"No PubMed results found using strategy: {strategy}" 

1337 ) 

1338 simplified_query = self._simplify_query(optimized_query) 

1339 if simplified_query != optimized_query: 

1340 logger.info(f"Trying with simplified query: {simplified_query}") 

1341 pmid_list, strategy = self._adaptive_search(simplified_query) 

1342 if pmid_list: 

1343 logger.info( 

1344 f"Simplified query found {len(pmid_list)} results" 

1345 ) 

1346 

1347 if not pmid_list: 

1348 logger.warning("No PubMed results found after query simplification") 

1349 return [] 

1350 

1351 # Get article summaries 

1352 logger.debug(f"Fetching article summaries for {len(pmid_list)} PMIDs") 

1353 summaries = self._get_article_summaries(pmid_list) 

1354 logger.debug(f"Retrieved {len(summaries)} summaries") 

1355 

1356 # ALWAYS fetch abstracts for snippet-only mode to provide context for LLM 

1357 logger.debug( 

1358 f"Fetching abstracts for {len(pmid_list)} articles for snippet enrichment" 

1359 ) 

1360 abstracts = self._get_article_abstracts(pmid_list) 

1361 logger.debug(f"Retrieved {len(abstracts)} abstracts") 

1362 

1363 # Format as previews 

1364 previews = [] 

1365 for summary in summaries: 

1366 # Build snippet from individual metadata preferences 

1367 snippet_parts = [] 

1368 

1369 # Check for publication type from esummary (earlier than detailed metadata) 

1370 pub_type_prefix = "" 

1371 if self.include_publication_type_in_context and summary.get( 1371 ↛ 1375line 1371 didn't jump to line 1375 because the condition on line 1371 was never true

1372 "pubtype" 

1373 ): 

1374 # Use first publication type from esummary 

1375 pub_type_prefix = f"[{summary['pubtype'][0]}] " 

1376 

1377 # Add authors if enabled 

1378 if self.include_authors_in_context and summary.get("authors"): 

1379 authors_text = ", ".join(summary.get("authors", [])) 

1380 if len(authors_text) > 100: 

1381 # Truncate long author lists 

1382 authors_text = authors_text[:97] + "..." 

1383 snippet_parts.append(authors_text) 

1384 

1385 # Add journal if enabled 

1386 if self.include_journal_in_context and summary.get("journal"): 1386 ↛ 1390line 1386 didn't jump to line 1390 because the condition on line 1386 was always true

1387 snippet_parts.append(summary["journal"]) 

1388 

1389 # Add date (full or year only) 

1390 if summary.get("pubdate"): 1390 ↛ 1400line 1390 didn't jump to line 1400 because the condition on line 1390 was always true

1391 if self.include_full_date_in_context: 1391 ↛ 1392line 1391 didn't jump to line 1392 because the condition on line 1391 was never true

1392 snippet_parts.append(summary["pubdate"]) 

1393 elif ( 1393 ↛ 1400line 1393 didn't jump to line 1400 because the condition on line 1393 was always true

1394 self.include_year_in_context 

1395 and len(summary["pubdate"]) >= 4 

1396 ): 

1397 snippet_parts.append(summary["pubdate"][:4]) 

1398 

1399 # Add citation details if enabled 

1400 if self.include_citation_in_context: 

1401 citation_parts = [] 

1402 if summary.get("volume"): 1402 ↛ 1404line 1402 didn't jump to line 1404 because the condition on line 1402 was always true

1403 citation_parts.append(f"Vol {summary['volume']}") 

1404 if summary.get("issue"): 1404 ↛ 1406line 1404 didn't jump to line 1406 because the condition on line 1404 was always true

1405 citation_parts.append(f"Issue {summary['issue']}") 

1406 if summary.get("pages"): 1406 ↛ 1408line 1406 didn't jump to line 1408 because the condition on line 1406 was always true

1407 citation_parts.append(f"pp {summary['pages']}") 

1408 if citation_parts: 1408 ↛ 1412line 1408 didn't jump to line 1412 because the condition on line 1408 was always true

1409 snippet_parts.append(f"({', '.join(citation_parts)})") 

1410 

1411 # Join snippet parts or provide default 

1412 if snippet_parts: 1412 ↛ 1423line 1412 didn't jump to line 1423 because the condition on line 1412 was always true

1413 # Use different separators based on what's included 

1414 if self.include_authors_in_context: 

1415 snippet = ". ".join( 

1416 snippet_parts 

1417 ) # Authors need period separator 

1418 else: 

1419 snippet = " - ".join( 

1420 snippet_parts 

1421 ) # Journal and year use dash 

1422 else: 

1423 snippet = "Research article" 

1424 

1425 # Add publication type prefix 

1426 snippet = pub_type_prefix + snippet 

1427 

1428 # Add language indicator if not English 

1429 if self.include_language_in_context and summary.get("lang"): 

1430 langs = summary["lang"] 

1431 if langs and langs[0] != "eng" and langs[0]: 1431 ↛ 1435line 1431 didn't jump to line 1435 because the condition on line 1431 was always true

1432 snippet = f"{snippet} [{langs[0].upper()}]" 

1433 

1434 # Add identifiers if enabled 

1435 identifier_parts = [] 

1436 if self.include_pmid_in_context and summary.get("id"): 

1437 identifier_parts.append(f"PMID: {summary['id']}") 

1438 if self.include_doi_in_context and summary.get("doi"): 

1439 identifier_parts.append(f"DOI: {summary['doi']}") 

1440 

1441 if identifier_parts: 

1442 snippet = f"{snippet} | {' | '.join(identifier_parts)}" 

1443 

1444 # ALWAYS include title and abstract in snippet for LLM analysis 

1445 pmid = summary["id"] 

1446 title = summary["title"] 

1447 abstract_text = abstracts.get(pmid, "") 

1448 

1449 # Truncate abstract if too long 

1450 if len(abstract_text) > 500: 1450 ↛ 1451line 1450 didn't jump to line 1451 because the condition on line 1450 was never true

1451 abstract_text = abstract_text[:497] + "..." 

1452 

1453 # Build the enriched snippet with title and abstract 

1454 if abstract_text: 

1455 enriched_snippet = f"Title: {title}\n\nAbstract: {abstract_text}\n\nMetadata: {snippet}" 

1456 else: 

1457 enriched_snippet = f"Title: {title}\n\nMetadata: {snippet}" 

1458 

1459 # Log the complete snippet for debugging 

1460 logger.debug(f"Complete snippet for PMID {pmid}:") 

1461 logger.debug(f" Title: {title[:100]}...") 

1462 logger.debug(f" Abstract length: {len(abstract_text)} chars") 

1463 logger.debug(f" Metadata: {snippet}") 

1464 logger.debug( 

1465 f" Full enriched snippet ({len(enriched_snippet)} chars): {enriched_snippet[:500]}..." 

1466 ) 

1467 

1468 # Create preview with basic information 

1469 preview = { 

1470 "id": summary["id"], 

1471 "title": summary["title"], 

1472 "link": summary["link"], 

1473 "snippet": enriched_snippet, # Use enriched snippet with title and abstract 

1474 "authors": summary.get("authors", []), 

1475 "journal": summary.get("journal", ""), 

1476 # Alias for the journal reputation filter, which reads 

1477 # `journal_ref` (the field name used by arXiv). 

1478 # Use None (not empty string) to match other engines so the 

1479 # filter treats missing journals consistently. 

1480 "journal_ref": summary.get("journal") or None, 

1481 # Forward the print / linking ISSN so the reputation 

1482 # filter's Tier 2/3 lookups can key on it (faster and 

1483 # more reliable than fuzzy name matching). essn is the 

1484 # electronic ISSN; prefer it when issn is blank. 

1485 "issn": summary.get("issn") or summary.get("essn") or None, 

1486 "pubdate": summary.get("pubdate", ""), 

1487 "doi": summary.get("doi", ""), 

1488 "source": "PubMed", 

1489 "_pmid": summary["id"], # Store PMID for later use 

1490 "_search_strategy": strategy, # Store search strategy for analytics 

1491 } 

1492 

1493 previews.append(preview) 

1494 

1495 logger.info( 

1496 f"Found {len(previews)} PubMed previews using strategy: {strategy}" 

1497 ) 

1498 if previews: 1498 ↛ 1502line 1498 didn't jump to line 1502 because the condition on line 1498 was always true

1499 logger.debug( 

1500 f"Sample preview title: '{previews[0].get('title', 'NO TITLE')[:80]}...'" 

1501 ) 

1502 return previews 

1503 

1504 def _get_full_content( 

1505 self, relevant_items: List[Dict[str, Any]] 

1506 ) -> List[Dict[str, Any]]: 

1507 """ 

1508 Get full content for the relevant PubMed articles. 

1509 Efficiently manages which content to retrieve (abstracts and/or full text). 

1510 

1511 Args: 

1512 relevant_items: List of relevant preview dictionaries 

1513 

1514 Returns: 

1515 List of result dictionaries with full content 

1516 """ 

1517 logger.info( 

1518 f"Getting content for {len(relevant_items)} PubMed articles" 

1519 ) 

1520 

1521 # Collect all PMIDs for relevant items 

1522 pmids = [] 

1523 for item in relevant_items: 

1524 if "_pmid" in item: 

1525 pmids.append(item["_pmid"]) 

1526 

1527 # Get abstracts if requested and PMIDs exist 

1528 # In snippet-only mode, always get abstracts as they serve as snippets 

1529 abstracts = {} 

1530 if self.get_abstracts and pmids: 

1531 abstracts = self._get_article_abstracts(pmids) 

1532 

1533 # Get detailed metadata for all articles (publication types, MeSH terms, etc.) 

1534 detailed_metadata = {} 

1535 if pmids: 

1536 detailed_metadata = self._get_article_detailed_metadata(pmids) 

1537 

1538 # Find PMC IDs for full-text retrieval (if enabled and not in snippet-only mode) 

1539 pmid_to_pmcid = {} 

1540 if self.get_full_text and pmids: 

1541 pmid_to_pmcid = self._find_pmc_ids(pmids) 

1542 

1543 # Add content to results 

1544 results: List[Dict[str, Any]] = [] 

1545 for item in relevant_items: 

1546 result = item.copy() 

1547 pmid = item.get("_pmid", "") 

1548 

1549 # Add detailed metadata if available 

1550 if pmid in detailed_metadata: 

1551 metadata = detailed_metadata[pmid] 

1552 

1553 # Add publication types (e.g., "Clinical Trial", "Meta-Analysis") 

1554 if "publication_types" in metadata: 

1555 result["publication_types"] = metadata["publication_types"] 

1556 

1557 # Add first publication type to snippet if enabled 

1558 if ( 1558 ↛ 1570line 1558 didn't jump to line 1570 because the condition on line 1558 was always true

1559 self.include_publication_type_in_context 

1560 and metadata["publication_types"] 

1561 ): 

1562 # Just take the first publication type as is 

1563 pub_type = metadata["publication_types"][0] 

1564 if "snippet" in result: 1564 ↛ 1570line 1564 didn't jump to line 1570 because the condition on line 1564 was always true

1565 result["snippet"] = ( 

1566 f"[{pub_type}] {result['snippet']}" 

1567 ) 

1568 

1569 # Add MeSH terms for medical categorization 

1570 if "mesh_terms" in metadata: 

1571 result["mesh_terms"] = metadata["mesh_terms"] 

1572 

1573 # Add MeSH terms to snippet if enabled 

1574 if ( 1574 ↛ 1590line 1574 didn't jump to line 1590 because the condition on line 1574 was always true

1575 self.include_mesh_terms_in_context 

1576 and metadata["mesh_terms"] 

1577 ): 

1578 mesh_to_show = ( 

1579 metadata["mesh_terms"][: self.max_mesh_terms] 

1580 if self.max_mesh_terms > 0 

1581 else metadata["mesh_terms"] 

1582 ) 

1583 if mesh_to_show and "snippet" in result: 1583 ↛ 1590line 1583 didn't jump to line 1590 because the condition on line 1583 was always true

1584 mesh_text = "MeSH: " + ", ".join(mesh_to_show) 

1585 result["snippet"] = ( 

1586 f"{result['snippet']} | {mesh_text}" 

1587 ) 

1588 

1589 # Add keywords 

1590 if "keywords" in metadata: 

1591 result["keywords"] = metadata["keywords"] 

1592 

1593 # Add keywords to snippet if enabled 

1594 if ( 1594 ↛ 1612line 1594 didn't jump to line 1612 because the condition on line 1594 was always true

1595 self.include_keywords_in_context 

1596 and metadata["keywords"] 

1597 ): 

1598 keywords_to_show = ( 

1599 metadata["keywords"][: self.max_keywords] 

1600 if self.max_keywords > 0 

1601 else metadata["keywords"] 

1602 ) 

1603 if keywords_to_show and "snippet" in result: 1603 ↛ 1612line 1603 didn't jump to line 1612 because the condition on line 1603 was always true

1604 keywords_text = "Keywords: " + ", ".join( 

1605 keywords_to_show 

1606 ) 

1607 result["snippet"] = ( 

1608 f"{result['snippet']} | {keywords_text}" 

1609 ) 

1610 

1611 # Add affiliations 

1612 if "affiliations" in metadata: 1612 ↛ 1613line 1612 didn't jump to line 1613 because the condition on line 1612 was never true

1613 result["affiliations"] = metadata["affiliations"] 

1614 

1615 # Add funding/grant information 

1616 if "grants" in metadata: 1616 ↛ 1617line 1616 didn't jump to line 1617 because the condition on line 1616 was never true

1617 result["grants"] = metadata["grants"] 

1618 

1619 # Add conflict of interest statement 

1620 if "conflict_of_interest" in metadata: 1620 ↛ 1621line 1620 didn't jump to line 1621 because the condition on line 1620 was never true

1621 result["conflict_of_interest"] = metadata[ 

1622 "conflict_of_interest" 

1623 ] 

1624 

1625 # Add free full text availability 

1626 if "has_free_full_text" in metadata: 

1627 result["has_free_full_text"] = metadata[ 

1628 "has_free_full_text" 

1629 ] 

1630 if "pmc_id" in metadata: 1630 ↛ 1634line 1630 didn't jump to line 1634 because the condition on line 1630 was always true

1631 result["pmc_id"] = metadata["pmc_id"] 

1632 

1633 # Add PMC availability to snippet if enabled 

1634 if ( 1634 ↛ 1644line 1634 didn't jump to line 1644 because the condition on line 1634 was always true

1635 self.include_pmc_availability_in_context 

1636 and metadata["has_free_full_text"] 

1637 and "snippet" in result 

1638 ): 

1639 result["snippet"] = ( 

1640 f"{result['snippet']} | [Free Full Text]" 

1641 ) 

1642 

1643 # Add abstract if available 

1644 if pmid in abstracts: 

1645 result["abstract"] = abstracts[pmid] 

1646 

1647 # Create enriched content with metadata context 

1648 enriched_content = self._create_enriched_content( 

1649 result, abstracts[pmid] 

1650 ) 

1651 

1652 # ALWAYS include title and abstract in snippet for LLM analysis 

1653 # Build comprehensive snippet with title and abstract 

1654 title = result.get("title", "") 

1655 abstract_text = ( 

1656 abstracts[pmid][:SNIPPET_LENGTH_LONG] 

1657 if len(abstracts[pmid]) > SNIPPET_LENGTH_LONG 

1658 else abstracts[pmid] 

1659 ) 

1660 

1661 # Prepend title and abstract to the existing metadata snippet 

1662 if "snippet" in result: 1662 ↛ 1669line 1662 didn't jump to line 1669 because the condition on line 1662 was always true

1663 # Keep metadata snippet and add content 

1664 result["snippet"] = ( 

1665 f"Title: {title}\n\nAbstract: {abstract_text}\n\nMetadata: {result['snippet']}" 

1666 ) 

1667 else: 

1668 # No metadata snippet, just title and abstract 

1669 result["snippet"] = ( 

1670 f"Title: {title}\n\nAbstract: {abstract_text}" 

1671 ) 

1672 

1673 # Use abstract as content if no full text 

1674 if pmid not in pmid_to_pmcid: 

1675 result["full_content"] = enriched_content 

1676 result["content"] = enriched_content 

1677 result["content_type"] = "abstract" 

1678 

1679 # Add full text for a limited number of top articles 

1680 if ( 

1681 pmid in pmid_to_pmcid 

1682 and self.get_full_text 

1683 and len( 

1684 [r for r in results if r.get("content_type") == "full_text"] 

1685 ) 

1686 < self.full_text_limit 

1687 ): 

1688 # Get full text content 

1689 pmcid = pmid_to_pmcid[pmid] 

1690 full_text = self._get_pmc_full_text(pmcid) 

1691 

1692 if full_text: 

1693 enriched_full_text = self._create_enriched_content( 

1694 result, full_text 

1695 ) 

1696 result["full_content"] = enriched_full_text 

1697 result["content"] = enriched_full_text 

1698 result["content_type"] = "full_text" 

1699 result["pmcid"] = pmcid 

1700 elif pmid in abstracts: 1700 ↛ 1710line 1700 didn't jump to line 1710 because the condition on line 1700 was always true

1701 # Fall back to abstract if full text retrieval fails 

1702 enriched_content = self._create_enriched_content( 

1703 result, abstracts[pmid] 

1704 ) 

1705 result["full_content"] = enriched_content 

1706 result["content"] = enriched_content 

1707 result["content_type"] = "abstract" 

1708 

1709 # Remove temporary fields 

1710 if "_pmid" in result: 

1711 del result["_pmid"] 

1712 if "_search_strategy" in result: 

1713 del result["_search_strategy"] 

1714 

1715 results.append(result) 

1716 

1717 return results 

1718 

1719 def search_by_author( 

1720 self, author_name: str, max_results: Optional[int] = None 

1721 ) -> List[Dict[str, Any]]: 

1722 """ 

1723 Search for articles by a specific author. 

1724 

1725 Args: 

1726 author_name: Name of the author 

1727 max_results: Maximum number of results (defaults to self.max_results) 

1728 

1729 Returns: 

1730 List of articles by the author 

1731 """ 

1732 original_max_results = self.max_results 

1733 

1734 try: 

1735 if max_results: 

1736 self.max_results = max_results 

1737 

1738 query = f"{author_name}[Author]" 

1739 return self.run(query) 

1740 

1741 finally: 

1742 # Restore original value 

1743 self.max_results = original_max_results 

1744 

1745 def search_by_journal( 

1746 self, journal_name: str, max_results: Optional[int] = None 

1747 ) -> List[Dict[str, Any]]: 

1748 """ 

1749 Search for articles in a specific journal. 

1750 

1751 Args: 

1752 journal_name: Name of the journal 

1753 max_results: Maximum number of results (defaults to self.max_results) 

1754 

1755 Returns: 

1756 List of articles from the journal 

1757 """ 

1758 original_max_results = self.max_results 

1759 

1760 try: 

1761 if max_results: 

1762 self.max_results = max_results 

1763 

1764 query = f"{journal_name}[Journal]" 

1765 return self.run(query) 

1766 

1767 finally: 

1768 # Restore original value 

1769 self.max_results = original_max_results 

1770 

1771 def search_recent( 

1772 self, query: str, days: int = 30, max_results: Optional[int] = None 

1773 ) -> List[Dict[str, Any]]: 

1774 """ 

1775 Search for recent articles matching the query. 

1776 

1777 Args: 

1778 query: The search query 

1779 days: Number of days to look back 

1780 max_results: Maximum number of results (defaults to self.max_results) 

1781 

1782 Returns: 

1783 List of recent articles matching the query 

1784 """ 

1785 original_max_results = self.max_results 

1786 original_days_limit = self.days_limit 

1787 

1788 try: 

1789 if max_results: 

1790 self.max_results = max_results 

1791 

1792 # Set days limit for this search 

1793 self.days_limit = days 

1794 

1795 return self.run(query) 

1796 

1797 finally: 

1798 # Restore original values 

1799 self.max_results = original_max_results 

1800 self.days_limit = original_days_limit 

1801 

1802 def advanced_search( 

1803 self, terms: Dict[str, str], max_results: Optional[int] = None 

1804 ) -> List[Dict[str, Any]]: 

1805 """ 

1806 Perform an advanced search with field-specific terms. 

1807 

1808 Args: 

1809 terms: Dictionary mapping fields to search terms 

1810 Valid fields: Author, Journal, Title, MeSH, Affiliation, etc. 

1811 max_results: Maximum number of results (defaults to self.max_results) 

1812 

1813 Returns: 

1814 List of articles matching the advanced query 

1815 """ 

1816 original_max_results = self.max_results 

1817 

1818 try: 

1819 if max_results: 

1820 self.max_results = max_results 

1821 

1822 # Build advanced query string 

1823 query_parts = [] 

1824 for field, term in terms.items(): 

1825 query_parts.append(f"{term}[{field}]") 

1826 

1827 query = " AND ".join(query_parts) 

1828 return self.run(query) 

1829 

1830 finally: 

1831 # Restore original value 

1832 self.max_results = original_max_results