Coverage for src/local_deep_research/web_search_engines/engines/search_engine_arxiv.py: 97%

162 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1from typing import Any, Dict, List, Optional 

2 

3import arxiv 

4from langchain_core.language_models import BaseLLM 

5 

6from ...constants import SNIPPET_LENGTH_SHORT 

7from ...security.secure_logging import logger 

8from ..rate_limiting import RateLimitError 

9from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

10 

11 

12class ArXivSearchEngine(BaseSearchEngine): 

13 """arXiv search engine implementation with two-phase approach""" 

14 

15 # Mark as public search engine 

16 is_public = True 

17 egress_sensitivity = Sensitivity.NON_SENSITIVE 

18 egress_exposure = Exposure.EXPOSING 

19 # Not a generic search engine (specialized for academic papers) 

20 is_generic = False 

21 # Scientific/academic search engine 

22 is_scientific = True 

23 is_lexical = True 

24 needs_llm_relevance_filter = True 

25 

26 def __init__( 

27 self, 

28 max_results: int = 10, 

29 sort_by: str = "relevance", 

30 sort_order: str = "descending", 

31 include_full_text: bool = False, 

32 download_dir: Optional[str] = None, 

33 max_full_text: int = 1, 

34 llm: Optional[BaseLLM] = None, 

35 max_filtered_results: Optional[int] = None, 

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

37 ): # Added this parameter 

38 """ 

39 Initialize the arXiv search engine. 

40 

41 Args: 

42 max_results: Maximum number of search results 

43 sort_by: Sorting criteria ('relevance', 'lastUpdatedDate', or 'submittedDate') 

44 sort_order: Sort order ('ascending' or 'descending') 

45 include_full_text: Whether to include full paper content in results (downloads PDF) 

46 download_dir: Directory to download PDFs to (if include_full_text is True) 

47 max_full_text: Maximum number of PDFs to download and process (default: 1) 

48 llm: Language model for relevance filtering 

49 max_filtered_results: Maximum number of results to keep after filtering 

50 settings_snapshot: Settings snapshot for thread context 

51 """ 

52 # Initialize the journal reputation filter if needed. 

53 # Runs as a preview filter (before LLM relevance) because Tiers 1-3 

54 # are instant data lookups — no point sending irrelevant journals 

55 # through the expensive LLM relevance filter. 

56 preview_filters = [] 

57 journal_filter = self._create_journal_filter( 

58 "arxiv", llm, settings_snapshot 

59 ) 

60 if journal_filter is not None: 

61 preview_filters.append(journal_filter) 

62 

63 super().__init__( 

64 llm=llm, 

65 max_filtered_results=max_filtered_results, 

66 max_results=max_results, 

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

68 settings_snapshot=settings_snapshot, 

69 ) 

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

71 self.sort_by = sort_by 

72 self.sort_order = sort_order 

73 self.include_full_text = include_full_text 

74 self.download_dir = download_dir 

75 self.max_full_text = max_full_text 

76 

77 # Map sort parameters to arxiv package parameters 

78 self.sort_criteria = { 

79 "relevance": arxiv.SortCriterion.Relevance, 

80 "lastUpdatedDate": arxiv.SortCriterion.LastUpdatedDate, 

81 "submittedDate": arxiv.SortCriterion.SubmittedDate, 

82 } 

83 

84 self.sort_directions = { 

85 "ascending": arxiv.SortOrder.Ascending, 

86 "descending": arxiv.SortOrder.Descending, 

87 } 

88 

89 def _get_search_results(self, query: str) -> List[Any]: 

90 """ 

91 Helper method to get search results from arXiv API. 

92 

93 Args: 

94 query: The search query 

95 

96 Returns: 

97 List of arXiv paper objects 

98 """ 

99 # Configure the search client 

100 sort_criteria = self.sort_criteria.get( 

101 self.sort_by, arxiv.SortCriterion.Relevance 

102 ) 

103 sort_order = self.sort_directions.get( 

104 self.sort_order, arxiv.SortOrder.Descending 

105 ) 

106 

107 # Create the search client 

108 client = arxiv.Client(page_size=self.max_results) 

109 

110 # Create the search query 

111 search = arxiv.Search( 

112 query=query, 

113 max_results=self.max_results, 

114 sort_by=sort_criteria, 

115 sort_order=sort_order, 

116 ) 

117 

118 # Apply rate limiting before making the request 

119 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

120 self.engine_type 

121 ) 

122 

123 # Get the search results 

124 return list(client.results(search)) 

125 

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

127 """ 

128 Get preview information for arXiv papers. 

129 

130 Args: 

131 query: The search query 

132 

133 Returns: 

134 List of preview dictionaries 

135 """ 

136 logger.info("Getting paper previews from arXiv") 

137 

138 try: 

139 # Get search results from arXiv 

140 papers = self._get_search_results(query) 

141 

142 # Store the paper objects for later use 

143 self._papers = {paper.entry_id: paper for paper in papers} 

144 

145 # Format results as previews with basic information 

146 previews = [] 

147 for paper in papers: 

148 preview = { 

149 "id": paper.entry_id, # Use entry_id as ID 

150 "title": paper.title, 

151 "link": paper.entry_id, # arXiv URL 

152 "snippet": ( 

153 paper.summary[:SNIPPET_LENGTH_SHORT] + "..." 

154 if len(paper.summary) > SNIPPET_LENGTH_SHORT 

155 else paper.summary 

156 ), 

157 "authors": [ 

158 author.name for author in paper.authors[:3] 

159 ], # First 3 authors 

160 "published": ( 

161 paper.published.strftime("%Y-%m-%d") 

162 if paper.published 

163 else None 

164 ), 

165 "journal_ref": paper.journal_ref, 

166 "source": "arXiv", 

167 } 

168 

169 previews.append(preview) 

170 

171 return previews 

172 

173 except Exception as e: 

174 error_msg = str(e) 

175 safe_msg = self._scrub_error(e) 

176 logger.exception( 

177 f"Error getting arXiv previews ({type(e).__name__}): {safe_msg}" 

178 ) 

179 

180 # Check for rate limiting patterns 

181 if ( 

182 "429" in error_msg 

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

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

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

186 or "503" in error_msg 

187 ): 

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

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

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

191 # secret that safe_msg just scrubbed. 

192 raise RateLimitError( 

193 f"arXiv rate limit hit: {safe_msg}" 

194 ) from None 

195 

196 return [] 

197 

198 def _get_full_content( 

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

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

201 """ 

202 Get full content for the relevant arXiv papers. 

203 Downloads PDFs and extracts text when include_full_text is True. 

204 Limits the number of PDFs processed to max_full_text. 

205 

206 Args: 

207 relevant_items: List of relevant preview dictionaries 

208 

209 Returns: 

210 List of result dictionaries with full content 

211 """ 

212 logger.info("Getting full content for relevant arXiv papers") 

213 

214 results = [] 

215 pdf_count = 0 # Track number of PDFs processed 

216 

217 for item in relevant_items: 

218 # Start with the preview data 

219 result = item.copy() 

220 

221 # Get the paper ID 

222 paper_id = item.get("id") 

223 

224 # Try to get the full paper from our cache 

225 paper = None 

226 if hasattr(self, "_papers") and paper_id in self._papers: 

227 paper = self._papers[paper_id] 

228 

229 if paper: 

230 # Add complete paper information 

231 result.update( 

232 { 

233 "pdf_url": paper.pdf_url, 

234 "authors": [ 

235 author.name for author in paper.authors 

236 ], # All authors 

237 "published": ( 

238 paper.published.strftime("%Y-%m-%d") 

239 if paper.published 

240 else None 

241 ), 

242 "updated": ( 

243 paper.updated.strftime("%Y-%m-%d") 

244 if paper.updated 

245 else None 

246 ), 

247 "categories": paper.categories, 

248 "summary": paper.summary, # Full summary 

249 "comment": paper.comment, 

250 "doi": paper.doi, 

251 # Explicitly forward for journal quality filter 

252 "journal_ref": paper.journal_ref, 

253 } 

254 ) 

255 

256 # Default to using summary as content 

257 result["content"] = paper.summary 

258 result["full_content"] = paper.summary 

259 

260 # Download PDF and extract text if requested and within limit 

261 if ( 

262 self.include_full_text 

263 and self.download_dir 

264 and pdf_count < self.max_full_text 

265 ): 

266 try: 

267 # Download the paper 

268 pdf_count += ( 

269 1 # Increment counter before attempting download 

270 ) 

271 # Apply rate limiting before PDF download 

272 self.rate_tracker.apply_rate_limit(self.engine_type) 

273 

274 paper_path = paper.download_pdf( 

275 dirpath=self.download_dir 

276 ) 

277 result["pdf_path"] = str(paper_path) 

278 

279 # Extract text from PDF 

280 try: 

281 # Try pypdf first 

282 try: 

283 from pypdf import PdfReader 

284 

285 with open(paper_path, "rb") as pdf_file: 

286 pdf_reader = PdfReader(pdf_file) 

287 pdf_text = "" 

288 for page in pdf_reader.pages: 

289 pdf_text += page.extract_text() + "\n\n" 

290 

291 if ( 

292 pdf_text.strip() 

293 ): # Only use if we got meaningful text 

294 result["content"] = pdf_text 

295 result["full_content"] = pdf_text 

296 logger.info( 

297 "Successfully extracted text from PDF using pypdf" 

298 ) 

299 except (ImportError, Exception) as e1: 

300 # Fall back to pdfplumber 

301 try: 

302 import pdfplumber 

303 

304 with pdfplumber.open(paper_path) as pdf: 

305 pdf_text = "" 

306 for plumber_page in pdf.pages: 

307 pdf_text += ( 

308 plumber_page.extract_text() 

309 + "\n\n" 

310 ) 

311 

312 if ( 312 ↛ 356line 312 didn't jump to line 356

313 pdf_text.strip() 

314 ): # Only use if we got meaningful text 

315 result["content"] = pdf_text 

316 result["full_content"] = pdf_text 

317 logger.info( 

318 "Successfully extracted text from PDF using pdfplumber" 

319 ) 

320 except (ImportError, Exception) as e2: 

321 safe_e1 = self._scrub_error(e1) 

322 safe_e2 = self._scrub_error(e2) 

323 logger.exception( 

324 f"PDF text extraction failed ({type(e1).__name__}, then {type(e2).__name__}): {safe_e1}, then {safe_e2}" 

325 ) 

326 logger.info( 

327 "Using paper summary as content instead" 

328 ) 

329 except Exception as e: 

330 safe_msg = self._scrub_error(e) 

331 logger.exception( 

332 f"Error extracting text from PDF ({type(e).__name__}): {safe_msg}" 

333 ) 

334 logger.info( 

335 "Using paper summary as content instead" 

336 ) 

337 except Exception as e: 

338 safe_msg = self._scrub_error(e) 

339 logger.exception( 

340 f"Error downloading paper {paper.title} ({type(e).__name__}): {safe_msg}" 

341 ) 

342 result["pdf_path"] = None 

343 pdf_count -= 1 # Decrement counter if download fails 

344 elif ( 

345 self.include_full_text 

346 and self.download_dir 

347 and pdf_count >= self.max_full_text 

348 ): 

349 # Reached PDF limit 

350 logger.info( 

351 f"Maximum number of PDFs ({self.max_full_text}) reached. Skipping remaining PDFs." 

352 ) 

353 result["content"] = paper.summary 

354 result["full_content"] = paper.summary 

355 

356 results.append(result) 

357 

358 return results 

359 

360 def run( 

361 self, query: str, research_context: Dict[str, Any] | None = None 

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

363 """ 

364 Execute a search using arXiv with the two-phase approach. 

365 

366 Args: 

367 query: The search query 

368 research_context: Context from previous research to use. 

369 

370 Returns: 

371 List of search results 

372 """ 

373 logger.info("---Execute a search using arXiv---") 

374 

375 # Use the implementation from the parent class which handles all phases 

376 results = super().run(query, research_context=research_context) 

377 

378 # Clean up 

379 if hasattr(self, "_papers"): 

380 del self._papers 

381 

382 return results 

383 

384 def get_paper_details(self, arxiv_id: str) -> Dict[str, Any]: 

385 """ 

386 Get detailed information about a specific arXiv paper. 

387 

388 Args: 

389 arxiv_id: arXiv ID of the paper (e.g., '2101.12345') 

390 

391 Returns: 

392 Dictionary with paper information 

393 """ 

394 try: 

395 # Create the search client 

396 client = arxiv.Client() 

397 

398 # Search for the specific paper 

399 search = arxiv.Search(id_list=[arxiv_id], max_results=1) 

400 

401 # Apply rate limiting before fetching paper by ID 

402 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

403 self.engine_type 

404 ) 

405 

406 # Get the paper 

407 papers = list(client.results(search)) 

408 if not papers: 

409 return {} 

410 

411 paper = papers[0] 

412 

413 # Format result based on config 

414 result = { 

415 "title": paper.title, 

416 "link": paper.entry_id, 

417 "snippet": ( 

418 paper.summary[:250] + "..." 

419 if len(paper.summary) > 250 

420 else paper.summary 

421 ), 

422 "authors": [ 

423 author.name for author in paper.authors[:3] 

424 ], # First 3 authors 

425 "journal_ref": paper.journal_ref, 

426 } 

427 

428 result.update( 

429 { 

430 "pdf_url": paper.pdf_url, 

431 "authors": [ 

432 author.name for author in paper.authors 

433 ], # All authors 

434 "published": ( 

435 paper.published.strftime("%Y-%m-%d") 

436 if paper.published 

437 else None 

438 ), 

439 "updated": ( 

440 paper.updated.strftime("%Y-%m-%d") 

441 if paper.updated 

442 else None 

443 ), 

444 "categories": paper.categories, 

445 "summary": paper.summary, # Full summary 

446 "comment": paper.comment, 

447 "doi": paper.doi, 

448 "content": paper.summary, # Use summary as content 

449 "full_content": paper.summary, # For consistency 

450 } 

451 ) 

452 

453 # Download PDF if requested 

454 if self.include_full_text and self.download_dir: 

455 try: 

456 # Apply rate limiting before PDF download 

457 self.rate_tracker.apply_rate_limit(self.engine_type) 

458 

459 # Download the paper 

460 paper_path = paper.download_pdf(dirpath=self.download_dir) 

461 result["pdf_path"] = str(paper_path) 

462 except Exception as e: 

463 safe_msg = self._scrub_error(e) 

464 logger.exception( 

465 f"Error downloading paper ({type(e).__name__}): {safe_msg}" 

466 ) 

467 

468 return result 

469 

470 except Exception as e: 

471 safe_msg = self._scrub_error(e) 

472 logger.exception( 

473 f"Error getting paper details ({type(e).__name__}): {safe_msg}" 

474 ) 

475 return {} 

476 

477 def search_by_author( 

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

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

480 """ 

481 Search for papers by a specific author. 

482 

483 Args: 

484 author_name: Name of the author 

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

486 

487 Returns: 

488 List of papers by the author 

489 """ 

490 original_max_results = self.max_results 

491 

492 try: 

493 if max_results: 

494 self.max_results = max_results 

495 

496 query = f'au:"{author_name}"' 

497 return self.run(query) 

498 

499 finally: 

500 # Restore original value 

501 self.max_results = original_max_results 

502 

503 def search_by_category( 

504 self, category: str, max_results: Optional[int] = None 

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

506 """ 

507 Search for papers in a specific arXiv category. 

508 

509 Args: 

510 category: arXiv category (e.g., 'cs.AI', 'physics.optics') 

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

512 

513 Returns: 

514 List of papers in the category 

515 """ 

516 original_max_results = self.max_results 

517 

518 try: 

519 if max_results: 

520 self.max_results = max_results 

521 

522 query = f"cat:{category}" 

523 return self.run(query) 

524 

525 finally: 

526 # Restore original value 

527 self.max_results = original_max_results