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

230 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1import os 

2import re 

3import uuid 

4from pathlib import Path 

5from typing import Any, Dict, List, Optional 

6 

7import arxiv 

8from langchain_core.language_models import BaseLLM 

9from requests.exceptions import RequestException 

10 

11from ...config.paths import get_data_directory 

12from ...constants import SNIPPET_LENGTH_SHORT 

13from ...security import SafeSession 

14from ...security.directory_creation import ( 

15 DirectoryCreationSecurityError, 

16 create_directory, 

17) 

18from ...security.safe_requests import DEFAULT_TIMEOUT, MAX_RESPONSE_SIZE 

19from ...security.secure_logging import logger 

20from ..rate_limiting import RateLimitError 

21from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

22 

23# Canonical arXiv identifier shapes, anchored so nothing else slips through 

24# before we build an egress URL from the value: 

25# - new style: 2301.12345 / 2301.12345v2 (4-or-5 digit sequence) 

26# - old style: math.GT/0309136 or cond-mat/0501234 (with optional version) 

27_ARXIV_ID_RE = re.compile( 

28 r"^(?:\d{4}\.\d{4,5}(?:v\d+)?|[a-z-]+(?:\.[A-Z]{2})?/\d{7}(?:v\d+)?)$" 

29) 

30 

31 

32class ArXivSearchEngine(BaseSearchEngine): 

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

34 

35 # Mark as public search engine 

36 is_public = True 

37 egress_sensitivity = Sensitivity.NON_SENSITIVE 

38 egress_exposure = Exposure.EXPOSING 

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

40 is_generic = False 

41 # Scientific/academic search engine 

42 is_scientific = True 

43 is_lexical = True 

44 needs_llm_relevance_filter = True 

45 

46 def __init__( 

47 self, 

48 max_results: int = 10, 

49 sort_by: str = "relevance", 

50 sort_order: str = "descending", 

51 include_full_text: bool = False, 

52 download_dir: Optional[str] = None, 

53 max_full_text: int = 1, 

54 llm: Optional[BaseLLM] = None, 

55 max_filtered_results: Optional[int] = None, 

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

57 ): # Added this parameter 

58 """ 

59 Initialize the arXiv search engine. 

60 

61 Args: 

62 max_results: Maximum number of search results 

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

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

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

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

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

68 llm: Language model for relevance filtering 

69 max_filtered_results: Maximum number of results to keep after filtering 

70 settings_snapshot: Settings snapshot for thread context 

71 """ 

72 # Initialize the journal reputation filter if needed. 

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

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

75 # through the expensive LLM relevance filter. 

76 preview_filters = [] 

77 journal_filter = self._create_journal_filter( 

78 "arxiv", llm, settings_snapshot 

79 ) 

80 if journal_filter is not None: 

81 preview_filters.append(journal_filter) 

82 

83 super().__init__( 

84 llm=llm, 

85 max_filtered_results=max_filtered_results, 

86 max_results=max_results, 

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

88 settings_snapshot=settings_snapshot, 

89 ) 

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

91 self.sort_by = sort_by 

92 self.sort_order = sort_order 

93 self.include_full_text = include_full_text 

94 self.download_dir = download_dir 

95 self.max_full_text = max_full_text 

96 

97 # Map sort parameters to arxiv package parameters 

98 self.sort_criteria = { 

99 "relevance": arxiv.SortCriterion.Relevance, 

100 "lastUpdatedDate": arxiv.SortCriterion.LastUpdatedDate, 

101 "submittedDate": arxiv.SortCriterion.SubmittedDate, 

102 } 

103 

104 self.sort_directions = { 

105 "ascending": arxiv.SortOrder.Ascending, 

106 "descending": arxiv.SortOrder.Descending, 

107 } 

108 

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

110 """ 

111 Helper method to get search results from arXiv API. 

112 

113 Args: 

114 query: The search query 

115 

116 Returns: 

117 List of arXiv paper objects 

118 """ 

119 # Configure the search client 

120 sort_criteria = self.sort_criteria.get( 

121 self.sort_by, arxiv.SortCriterion.Relevance 

122 ) 

123 sort_order = self.sort_directions.get( 

124 self.sort_order, arxiv.SortOrder.Descending 

125 ) 

126 

127 # Create the search client 

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

129 

130 # Create the search query 

131 search = arxiv.Search( 

132 query=query, 

133 max_results=self.max_results, 

134 sort_by=sort_criteria, 

135 sort_order=sort_order, 

136 ) 

137 

138 # Apply rate limiting before making the request 

139 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

140 self.engine_type 

141 ) 

142 

143 # Get the search results 

144 return list(client.results(search)) 

145 

146 @staticmethod 

147 def _validated_arxiv_id(paper: Any) -> Optional[str]: 

148 """Return a canonical, validated arXiv id for ``paper`` or ``None``. 

149 

150 The id is derived from the arxiv-provided ``entry_id`` (e.g. 

151 ``http://arxiv.org/abs/2301.12345v1``) and matched against 

152 ``_ARXIV_ID_RE`` before it is ever used to build an egress URL, 

153 so a malformed or unexpected value cannot be interpolated into a 

154 request target. 

155 """ 

156 entry_id = getattr(paper, "entry_id", "") or "" 

157 match = re.search(r"arxiv\.org/abs/(.+)$", entry_id) 

158 candidate = (match.group(1) if match else entry_id).strip() 

159 if _ARXIV_ID_RE.match(candidate): 

160 return candidate 

161 return None 

162 

163 @staticmethod 

164 def _enforce_response_size_cap(written: int) -> None: 

165 """Raise if a streamed PDF body has grown past ``MAX_RESPONSE_SIZE``. 

166 

167 Split out from ``_download_pdf_safely``'s streaming loop so the 

168 ``raise`` isn't directly inside that loop's own cleanup 

169 ``try``/``except`` block. 

170 """ 

171 if written > MAX_RESPONSE_SIZE: 

172 raise ValueError( 

173 f"arXiv PDF body exceeded {MAX_RESPONSE_SIZE} bytes " 

174 "while streaming" 

175 ) 

176 

177 def _download_pdf_safely(self, paper: Any, dirpath: str) -> str: 

178 """Download a paper PDF through the SSRF-validated ``SafeSession``. 

179 

180 Invariant: every arXiv PDF fetch made *by this engine* 

181 (``ArXivSearchEngine``) goes through this gate. ``SafeSession`` 

182 applies SSRF pre-validation and DNS pinning to this fetch. This 

183 replaces ``arxiv.Result.download_pdf``, which fetches via 

184 ``urllib.request.urlretrieve`` and therefore bypasses all of those 

185 controls. This is narrower than "the same session as the rest of 

186 the arXiv integration": the metadata queries this engine makes 

187 elsewhere (``arxiv.Client()`` in ``_get_search_results`` and 

188 ``get_paper_details``) still go through the ``arxiv`` package's own 

189 bare ``requests.Session()`` — unvalidated and untimed. Gating those 

190 too is out of scope here. 

191 

192 It is also narrower than "every arXiv PDF fetch in this codebase": 

193 ``research_library/downloaders/arxiv.py``'s ``ArxivDownloader`` 

194 (via ``BaseDownloader._download_pdf``) independently fetches 

195 ``https://arxiv.org/pdf/{id}.pdf`` — the public host, with the 

196 ``.pdf`` suffix this module omits specifically to dodge the 

197 redirect-drain gap described below. It does use ``SafeSession``, 

198 but sends ``Accept-Encoding: gzip, deflate, br`` and reads 

199 ``response.content`` with no ``stream=True``, so it has neither 

200 this call site's running-byte-count cap nor its identity-encoding 

201 best-effort measure. That is a separate, untouched call site — 

202 fixing it is out of scope here. 

203 

204 The filename changes from the old path's ``<id>.<sanitized 

205 title>.pdf`` (``arxiv.Result._get_default_filename``) to 

206 ``<id>.pdf`` here — a behavior change worth knowing about even 

207 though nothing in this codebase parses the filename back apart. 

208 

209 No ``.pdf`` suffix is appended to the request URL: arxiv 2.4.1's 

210 own Atom ``pdf_url`` (what ``arxiv.Result.download_pdf`` fetches) 

211 has none either, e.g. ``"http://arxiv.org/pdf/2101.00001v1"``. 

212 Appending ``.pdf`` makes ``export.arxiv.org`` answer with a 301 to 

213 the suffix-less path, and that redirect response's own body is 

214 drained by ``requests.Session.send()``'s internal 

215 ``resolve_redirects()`` (``resp.content # Consume socket``) 

216 *before* it is ever handed to ``SafeSession.send()`` for a size 

217 check — so an oversized redirect body would be read into memory 

218 unchecked. Omitting the suffix removes that hop entirely, which is 

219 the minimal fix; it is not a fix to ``SafeSession`` itself (every 

220 redirect hop has this gap, not just this call site). This 

221 redirect-drain gap in ``SafeSession.send()`` has no tracking issue 

222 filed for it yet — it is untracked, and distinct from the 

223 chunked-body gap discussed below (which does have one, #6180). 

224 

225 This call passes ``stream=True`` and writes the body to 

226 ``target_path`` in bounded chunks via ``iter_content`` rather than 

227 reading ``response.content`` — unlike what an earlier version of 

228 this comment claimed, ``stream=True`` alone does not bound memory; 

229 `research_library/downloaders/generic.py`'s ``stream=True`` usage 

230 is not a precedent for this pattern, since it never reads the body 

231 at all (a status-code-only diagnostic probe). The running byte 

232 count enforced while streaming is also the actual guard against a 

233 gzip/deflate decompression bomb here: for a valid, under-cap 

234 ``Content-Length``, ``SafeSession._check_response_size`` installs 

235 no body guard at all (see ``safe_requests.py``), and ``requests`` 

236 (unlike the old ``urllib``-based path) sends 

237 ``Accept-Encoding: gzip`` by default, so a small compressed 

238 response could otherwise decompress far past any header-level 

239 check. This fetch instead sends ``Accept-Encoding: identity`` — 

240 matching what the old ``urlretrieve``-based path effectively did 

241 (``urllib`` does not request compression) — as a best-effort 

242 signal, since a PDF is already-compressed binary and gains 

243 nothing from gzip transfer. This does not remove the 

244 decompression-bomb vector outright: ``urllib3`` decodes based on 

245 the *response*'s ``Content-Encoding`` header, not the request's 

246 ``Accept-Encoding`` one, so a server that ignores the request 

247 header and gzips the body anyway would still be transparently 

248 decoded by ``iter_content`` here. What actually bounds a 

249 decompression bomb, with or without gzip, is the running byte 

250 count enforced while streaming above — it is computed over the 

251 already-decoded chunks ``iter_content`` yields, so it caps the 

252 decompressed size regardless of what encoding the origin used. 

253 

254 Connection release: if ``SafeSession``'s response-size cap rejects 

255 an over-limit ``Content-Length`` response, that ``ValueError`` is 

256 raised *inside* ``SafeSession.send()`` — i.e. inside the 

257 ``session.get(...)`` call itself, before the ``with ... as 

258 response:`` statement below ever binds. So on that path the 

259 connection is released by ``_check_response_size`` calling 

260 ``response.close()`` internally, not by this function's context 

261 manager; the context manager only covers releasing the connection 

262 for responses that *do* make it past that check (including this 

263 function's own errors raised while streaming the body). 

264 

265 It does not fully fix the ``Content-Length``-absent case. The cap 

266 installs a guard on ``response.raw.read()`` 

267 (``_install_body_guard``), and that guard does run correctly — but 

268 only when the connection is delimited by the socket closing (no 

269 ``Content-Length``, no ``Transfer-Encoding``). When the origin uses 

270 ``Transfer-Encoding: chunked`` instead — the common case for a CDN 

271 serving a PDF of unknown length — ``urllib3``'s 

272 ``HTTPResponse.stream()`` (what ``requests`` uses under 

273 ``iter_content``/``.content``) reads such bodies through 

274 ``read_chunked()``, which pulls bytes directly off the socket via 

275 ``self._fp._safe_read()`` and never calls the patched 

276 ``read()`` — verified by reading the installed ``urllib3`` 

277 (2.7.0) source and confirming with a live chunked-response 

278 server: the patched ``read()`` recorded zero calls while a full 

279 chunked body was consumed. So for a genuinely chunked response, 

280 ``_check_response_size`` still cannot bound memory on its own here 

281 — this function's own running byte count during streaming is what 

282 does that instead. 

283 

284 The ``_check_response_size`` chunked-body gap is a defect in 

285 ``safe_requests.py`` itself (``_check_response_size``/ 

286 ``_install_body_guard``) — every ``SafeSession`` caller that 

287 already uses ``stream=True`` has the same exposure for chunked 

288 responses. It is independent of whether this call streams and is 

289 not the same issue as #6172, which is about callers that never 

290 set ``stream=True`` at all (this one now does). It has its own 

291 tracking issue, #6180, and needs its own follow-up rather than 

292 being fixed here. 

293 """ 

294 arxiv_id = self._validated_arxiv_id(paper) 

295 if not arxiv_id: 

296 raise ValueError( 

297 "Could not derive a valid arXiv id for PDF download" 

298 ) 

299 

300 # Build the download URL from the validated id rather than trusting 

301 # an arbitrary attribute value; SafeSession re-validates it anyway. 

302 # No ".pdf" suffix -- see the docstring note above for why. 

303 # Use export.arxiv.org (not the public arxiv.org CDN-fronted host) 

304 # to match arxiv.Result.download_pdf's default `download_domain` 

305 # (see the installed `arxiv` package, ~v2.4) and the host 

306 # `arxiv.Client`/`arxiv.Search` already use for the metadata API 

307 # (`query_url_format = "https://export.arxiv.org/api/query?..."`). 

308 # export.arxiv.org is arXiv's designated host for automated/scripted 

309 # access; the public arxiv.org host is rate-limited and 

310 # bot-challenged for that traffic. 

311 pdf_url = f"https://export.arxiv.org/pdf/{arxiv_id}" 

312 

313 directory = Path(dirpath) 

314 # `dirpath` (== self.download_dir) is reachable from per-user web 

315 # settings ("search." is an allowed settings prefix; the factory 

316 # splats `search.engine.web.arxiv.default_params.download_dir` 

317 # straight into this engine's constructor kwargs) as well as 

318 # `LDR_SEARCH_ENGINE_WEB_ARXIV_DEFAULT_PARAMS_DOWNLOAD_DIR`, so it 

319 # is untrusted/user-influenced input, not a trusted constant -- 

320 # containment is required per directory_creation.py's own 

321 # opt-in-root guidance. Contain it to a dedicated subtree of the 

322 # LDR-managed data directory rather than the data directory 

323 # itself: the data directory root also holds `.secret_key` 

324 # (fastapi_app.py), `encrypted_databases/` (per-user encrypted DB 

325 # files + `.salt`), and backups, and `create_directory` is called 

326 # with `parents=True, exist_ok=True` -- with the bare data 

327 # directory as root, an authenticated user could `mkdir` any 

328 # not-yet-existing path under it, including pre-occupying the 

329 # (computable) encrypted-DB path of a username that has not 

330 # registered yet. Scoping the root to a download-only subtree 

331 # keeps the blast radius of that primitive to the download area. 

332 arxiv_downloads_root = get_data_directory() / "arxiv_downloads" 

333 create_directory( 

334 directory, 

335 context="arXiv PDF download directory", 

336 root=arxiv_downloads_root, 

337 ) 

338 target_path = directory / f"{arxiv_id.replace('/', '_')}.pdf" 

339 

340 with SafeSession() as session: 

341 # stream=True is intentional here — DO NOT remove it: it lets 

342 # the body be written to disk in bounded chunks below instead 

343 # of buffered whole in memory (see the docstring note above). 

344 # Accept-Encoding: identity mirrors what the old 

345 # urlretrieve-based path effectively sent -- a best-effort 

346 # signal only; it does NOT rule out a decompression bomb on 

347 # its own (a server that ignores this header and gzips the 

348 # body anyway is still transparently decoded downstream). The 

349 # running byte count enforced while streaming below, over the 

350 # already-decoded chunks, is what actually bounds that (see 

351 # the docstring note above). 

352 with session.get( 

353 pdf_url, 

354 timeout=DEFAULT_TIMEOUT, 

355 allow_redirects=True, 

356 stream=True, 

357 headers={"Accept-Encoding": "identity"}, 

358 ) as response: 

359 response.raise_for_status() 

360 self._stream_response_to_file(response, target_path) 

361 

362 return str(target_path) 

363 

364 def _stream_response_to_file( 

365 self, response: Any, target_path: Path 

366 ) -> None: 

367 """Write ``response``'s body to ``target_path`` in bounded chunks. 

368 

369 Streams via ``iter_content`` rather than reading ``response.content`` 

370 (which would materialise the whole body in memory) -- see 

371 ``_download_pdf_safely``'s docstring. ``written`` is an independent 

372 running cap: it is what actually bounds this fetch's memory/disk 

373 use, not the ``Content-Length`` check in ``SafeSession`` (see that 

374 docstring for why the check alone is not enough here). 

375 

376 The body is streamed to a per-call-unique ``<target 

377 name>.<uuid4 hex>.part`` sibling and only ``os.replace()``d onto 

378 ``target_path`` after a full, under-cap, *non-empty* write -- never 

379 streamed directly into ``target_path``. Two concurrent downloads of 

380 the same arXiv id into the same directory each get their own 

381 ``.part`` file, so one failing (e.g. tripping the size cap) can 

382 only ever remove *its own* partial file, never a sibling 

383 download's already-completed ``target_path``. 

384 

385 A response body that streams zero bytes (e.g. a ``200`` with an 

386 empty body) is rejected before ``os.replace()`` runs, specifically 

387 so it can never silently overwrite an already-completed 

388 ``target_path`` from an earlier download of the same id with a 

389 0-byte file while still reporting success. 

390 

391 Cleanup runs in a ``finally``, not an ``except Exception`` -- 

392 ``except Exception`` does not catch ``KeyboardInterrupt`` / 

393 ``SystemExit`` (or a raw thread-kill), which would otherwise leave 

394 an unpredictably-named, up-to-``MAX_RESPONSE_SIZE``-byte ``.part`` 

395 file orphaned in this shared root with no sweeper to reclaim it. 

396 On success ``os.replace()`` has already moved ``tmp_path`` onto 

397 ``target_path`` by the time ``finally`` runs, so the same 

398 unconditional ``unlink(missing_ok=True)`` call is a no-op then; 

399 on any failure -- including the cap being exceeded, an empty body, 

400 or an interrupt -- it removes that call's own ``.part`` file. The 

401 unlink itself is best-effort and never allowed to replace (mask) 

402 whatever exception is already propagating. 

403 """ 

404 written = 0 

405 tmp_path = target_path.with_name( 

406 f"{target_path.name}.{uuid.uuid4().hex}.part" 

407 ) 

408 try: 

409 # Public arXiv PDF (a published paper, no PII/secrets) fetched 

410 # through the SSRF-validated SafeSession and written into the 

411 # caller-provided, containment-checked download dir. 

412 with tmp_path.open("wb") as pdf_file: # Safe: public PDF 

413 for chunk in response.iter_content(chunk_size=1024 * 1024): 

414 if not chunk: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true

415 continue 

416 written += len(chunk) 

417 self._enforce_response_size_cap(written) 

418 pdf_file.write(chunk) 

419 if written == 0: 

420 raise ValueError( 

421 "arXiv PDF body was empty; refusing to write a " 

422 "0-byte file over any existing download at the " 

423 "target path" 

424 ) 

425 os.replace(tmp_path, target_path) 

426 finally: 

427 try: 

428 tmp_path.unlink(missing_ok=True) 

429 except OSError: 

430 # Never let a failure to clean up the partial file mask 

431 # whatever exception (if any) is already propagating. 

432 logger.debug( 

433 f"Failed to remove partial download file {tmp_path.name}" 

434 ) 

435 

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

437 """ 

438 Get preview information for arXiv papers. 

439 

440 Args: 

441 query: The search query 

442 

443 Returns: 

444 List of preview dictionaries 

445 """ 

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

447 

448 try: 

449 # Get search results from arXiv 

450 papers = self._get_search_results(query) 

451 

452 # Store the paper objects for later use 

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

454 

455 # Format results as previews with basic information 

456 previews = [] 

457 for paper in papers: 

458 preview = { 

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

460 "title": paper.title, 

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

462 "snippet": ( 

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

464 if len(paper.summary) > SNIPPET_LENGTH_SHORT 

465 else paper.summary 

466 ), 

467 "authors": [ 

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

469 ], # First 3 authors 

470 "published": ( 

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

472 if paper.published 

473 else None 

474 ), 

475 "journal_ref": paper.journal_ref, 

476 "source": "arXiv", 

477 } 

478 

479 previews.append(preview) 

480 

481 return previews 

482 

483 except Exception as e: 

484 error_msg = str(e) 

485 safe_msg = self._scrub_error(e) 

486 logger.exception( 

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

488 ) 

489 

490 # Check for rate limiting patterns 

491 if ( 

492 "429" in error_msg 

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

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

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

496 or "503" in error_msg 

497 ): 

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

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

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

501 # secret that safe_msg just scrubbed. 

502 raise RateLimitError( 

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

504 ) from None 

505 

506 return [] 

507 

508 def _get_full_content( 

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

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

511 """ 

512 Get full content for the relevant arXiv papers. 

513 Downloads PDFs and extracts text when include_full_text is True. 

514 Limits the number of PDFs processed to max_full_text. 

515 

516 Args: 

517 relevant_items: List of relevant preview dictionaries 

518 

519 Returns: 

520 List of result dictionaries with full content 

521 """ 

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

523 

524 results = [] 

525 pdf_count = 0 # Track number of PDFs processed 

526 

527 for item in relevant_items: 

528 # Start with the preview data 

529 result = item.copy() 

530 

531 # Get the paper ID 

532 paper_id = item.get("id") 

533 

534 # Try to get the full paper from our cache 

535 paper = None 

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

537 paper = self._papers[paper_id] 

538 

539 if paper: 

540 # Add complete paper information 

541 result.update( 

542 { 

543 "pdf_url": paper.pdf_url, 

544 "authors": [ 

545 author.name for author in paper.authors 

546 ], # All authors 

547 "published": ( 

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

549 if paper.published 

550 else None 

551 ), 

552 "updated": ( 

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

554 if paper.updated 

555 else None 

556 ), 

557 "categories": paper.categories, 

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

559 "comment": paper.comment, 

560 "doi": paper.doi, 

561 # Explicitly forward for journal quality filter 

562 "journal_ref": paper.journal_ref, 

563 } 

564 ) 

565 

566 # Default to using summary as content 

567 result["content"] = paper.summary 

568 result["full_content"] = paper.summary 

569 

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

571 if ( 

572 self.include_full_text 

573 and self.download_dir 

574 and pdf_count < self.max_full_text 

575 ): 

576 try: 

577 # Download the paper 

578 pdf_count += ( 

579 1 # Increment counter before attempting download 

580 ) 

581 # Apply rate limiting before PDF download 

582 self.rate_tracker.apply_rate_limit(self.engine_type) 

583 

584 paper_path = self._download_pdf_safely( 

585 paper, self.download_dir 

586 ) 

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

588 

589 # Extract text from PDF 

590 try: 

591 # Try pypdf first 

592 try: 

593 from pypdf import PdfReader 

594 

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

596 pdf_reader = PdfReader(pdf_file) 

597 pdf_text = "" 

598 for page in pdf_reader.pages: 

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

600 

601 if ( 

602 pdf_text.strip() 

603 ): # Only use if we got meaningful text 

604 result["content"] = pdf_text 

605 result["full_content"] = pdf_text 

606 logger.info( 

607 "Successfully extracted text from PDF using pypdf" 

608 ) 

609 except (ImportError, Exception) as e1: 

610 # Fall back to pdfplumber 

611 try: 

612 import pdfplumber 

613 

614 with pdfplumber.open(paper_path) as pdf: 

615 pdf_text = "" 

616 for plumber_page in pdf.pages: 

617 pdf_text += ( 

618 plumber_page.extract_text() 

619 + "\n\n" 

620 ) 

621 

622 if ( 622 ↛ 713line 622 didn't jump to line 713

623 pdf_text.strip() 

624 ): # Only use if we got meaningful text 

625 result["content"] = pdf_text 

626 result["full_content"] = pdf_text 

627 logger.info( 

628 "Successfully extracted text from PDF using pdfplumber" 

629 ) 

630 except (ImportError, Exception) as e2: 

631 safe_e1 = self._scrub_error(e1) 

632 safe_e2 = self._scrub_error(e2) 

633 logger.exception( 

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

635 ) 

636 logger.info( 

637 "Using paper summary as content instead" 

638 ) 

639 except Exception as e: 

640 safe_msg = self._scrub_error(e) 

641 logger.exception( 

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

643 ) 

644 logger.info( 

645 "Using paper summary as content instead" 

646 ) 

647 except RequestException as e: 

648 # ORDER MATTERS: this clause must precede the 

649 # (DirectoryCreationSecurityError, OSError) 

650 # clause below. requests' RequestException 

651 # subclasses IOError, which *is* OSError, so a 

652 # bare OSError catch would otherwise swallow 

653 # every ConnectionError/Timeout/HTTPError and 

654 # relabel a routine network failure as a 

655 # containment error. Network errors carry no 

656 # local path, so the normal scrubbed message is 

657 # both safe and far more useful here. 

658 safe_msg = self._scrub_error(e) 

659 logger.exception( 

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

661 ) 

662 result["pdf_path"] = None 

663 pdf_count -= 1 # Decrement counter if download fails 

664 except (DirectoryCreationSecurityError, OSError) as e: 

665 # Never interpolate either exception's message or 

666 # any path -- only its type name, which is 

667 # path-free and safe to include (unlike str(e)). 

668 # DirectoryCreationSecurityError embeds the 

669 # resolved data-directory path 

670 # (directory_creation.py). OSError (e.g. 

671 # FileExistsError/NotADirectoryError) is what 

672 # create_directory's own `p.mkdir()` raises 

673 # uncaught -- with the *same* resolved, absolute 

674 # path in its message -- when download_dir 

675 # collides with an existing file or has one as a 

676 # parent component; a filesystem error surfacing 

677 # from the streaming write below (e.g. a disk-full 

678 # OSError) can carry a path too. logger.exception 

679 # here reaches the research owner's browser via 

680 # frontend_progress_sink -- _scrub_error does not 

681 # scrub filesystem paths, so a static, path-free 

682 # message is used for this whole exception family 

683 # instead; the type name alone lets an operator 

684 # tell a containment rejection apart from e.g. a 

685 # disk-full OSError (a cert_verify OSError from a 

686 # missing REQUESTS_CA_BUNDLE, say). 

687 logger.exception( 

688 f"Error downloading paper {paper.title} " 

689 f"({type(e).__name__}): a filesystem or " 

690 "directory-containment error occurred" 

691 ) 

692 result["pdf_path"] = None 

693 pdf_count -= 1 # Decrement counter if download fails 

694 except Exception as e: 

695 safe_msg = self._scrub_error(e) 

696 logger.exception( 

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

698 ) 

699 result["pdf_path"] = None 

700 pdf_count -= 1 # Decrement counter if download fails 

701 elif ( 

702 self.include_full_text 

703 and self.download_dir 

704 and pdf_count >= self.max_full_text 

705 ): 

706 # Reached PDF limit 

707 logger.info( 

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

709 ) 

710 result["content"] = paper.summary 

711 result["full_content"] = paper.summary 

712 

713 results.append(result) 

714 

715 return results 

716 

717 def run( 

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

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

720 """ 

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

722 

723 Args: 

724 query: The search query 

725 research_context: Context from previous research to use. 

726 

727 Returns: 

728 List of search results 

729 """ 

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

731 

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

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

734 

735 # Clean up 

736 if hasattr(self, "_papers"): 

737 del self._papers 

738 

739 return results 

740 

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

742 """ 

743 Get detailed information about a specific arXiv paper. 

744 

745 Args: 

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

747 

748 Returns: 

749 Dictionary with paper information 

750 """ 

751 try: 

752 # Create the search client 

753 client = arxiv.Client() 

754 

755 # Search for the specific paper 

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

757 

758 # Apply rate limiting before fetching paper by ID 

759 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

760 self.engine_type 

761 ) 

762 

763 # Get the paper 

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

765 if not papers: 

766 return {} 

767 

768 paper = papers[0] 

769 

770 # Format result based on config 

771 result = { 

772 "title": paper.title, 

773 "link": paper.entry_id, 

774 "snippet": ( 

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

776 if len(paper.summary) > 250 

777 else paper.summary 

778 ), 

779 "authors": [ 

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

781 ], # First 3 authors 

782 "journal_ref": paper.journal_ref, 

783 } 

784 

785 result.update( 

786 { 

787 "pdf_url": paper.pdf_url, 

788 "authors": [ 

789 author.name for author in paper.authors 

790 ], # All authors 

791 "published": ( 

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

793 if paper.published 

794 else None 

795 ), 

796 "updated": ( 

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

798 if paper.updated 

799 else None 

800 ), 

801 "categories": paper.categories, 

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

803 "comment": paper.comment, 

804 "doi": paper.doi, 

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

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

807 } 

808 ) 

809 

810 # Download PDF if requested 

811 if self.include_full_text and self.download_dir: 

812 try: 

813 # Apply rate limiting before PDF download 

814 self.rate_tracker.apply_rate_limit(self.engine_type) 

815 

816 # Download the paper through the SSRF-validated session 

817 paper_path = self._download_pdf_safely( 

818 paper, self.download_dir 

819 ) 

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

821 except RequestException as e: 

822 # ORDER MATTERS: this clause must precede the 

823 # (DirectoryCreationSecurityError, OSError) 

824 # clause below. requests' RequestException 

825 # subclasses IOError, which *is* OSError, so a 

826 # bare OSError catch would otherwise swallow 

827 # every ConnectionError/Timeout/HTTPError and 

828 # relabel a routine network failure as a 

829 # containment error. Network errors carry no 

830 # local path, so the normal scrubbed message is 

831 # both safe and far more useful here. 

832 safe_msg = self._scrub_error(e) 

833 logger.exception( 

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

835 ) 

836 except (DirectoryCreationSecurityError, OSError) as e: 

837 # Never interpolate either exception's message or any 

838 # path -- only its type name, which is path-free and 

839 # safe to include (unlike str(e)). 

840 # DirectoryCreationSecurityError embeds the resolved 

841 # data-directory path (directory_creation.py). OSError 

842 # (e.g. FileExistsError/NotADirectoryError) is what 

843 # create_directory's own `p.mkdir()` raises uncaught 

844 # -- with the *same* resolved, absolute path in its 

845 # message -- when download_dir collides with an 

846 # existing file or has one as a parent component; a 

847 # filesystem error surfacing from the streaming write 

848 # below (e.g. a disk-full OSError) can carry a path 

849 # too. logger.exception here reaches the research 

850 # owner's browser via frontend_progress_sink -- 

851 # _scrub_error does not scrub filesystem paths, so a 

852 # static, path-free message is used for this whole 

853 # exception family instead; the type name alone lets 

854 # an operator tell a containment rejection apart from 

855 # e.g. a disk-full OSError (a cert_verify OSError from 

856 # a missing REQUESTS_CA_BUNDLE, say). 

857 logger.exception( 

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

859 "a filesystem or directory-containment error " 

860 "occurred" 

861 ) 

862 except Exception as e: 

863 safe_msg = self._scrub_error(e) 

864 logger.exception( 

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

866 ) 

867 

868 return result 

869 

870 except Exception as e: 

871 safe_msg = self._scrub_error(e) 

872 logger.exception( 

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

874 ) 

875 return {} 

876 

877 def search_by_author( 

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

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

880 """ 

881 Search for papers by a specific author. 

882 

883 Args: 

884 author_name: Name of the author 

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

886 

887 Returns: 

888 List of papers by the author 

889 """ 

890 original_max_results = self.max_results 

891 

892 try: 

893 if max_results: 

894 self.max_results = max_results 

895 

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

897 return self.run(query) 

898 

899 finally: 

900 # Restore original value 

901 self.max_results = original_max_results 

902 

903 def search_by_category( 

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

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

906 """ 

907 Search for papers in a specific arXiv category. 

908 

909 Args: 

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

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

912 

913 Returns: 

914 List of papers in the category 

915 """ 

916 original_max_results = self.max_results 

917 

918 try: 

919 if max_results: 

920 self.max_results = max_results 

921 

922 query = f"cat:{category}" 

923 return self.run(query) 

924 

925 finally: 

926 # Restore original value 

927 self.max_results = original_max_results