Coverage for src/local_deep_research/research_library/services/download_service.py: 94%

783 statements  

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

1""" 

2PDF Download Service for Research Library 

3 

4Handles downloading PDFs from various academic sources with: 

5- Deduplication using download tracker 

6- Source-specific download strategies (arXiv, PubMed, etc.) 

7- Progress tracking and error handling 

8- File organization and storage 

9""" 

10 

11import hashlib 

12import os 

13import re 

14import time 

15import uuid 

16from datetime import datetime, UTC 

17from pathlib import Path 

18from typing import Any, Dict, Optional, Tuple 

19from urllib.parse import urlparse 

20 

21import requests 

22from loguru import logger 

23from sqlalchemy.orm import Session 

24import pdfplumber 

25from pypdf import PdfReader 

26 

27from ...utilities.type_utils import unwrap_setting 

28from ...constants import FILE_PATH_SENTINELS, FILE_PATH_TEXT_ONLY 

29from ...database.models.download_tracker import ( 

30 DownloadAttempt, 

31 DownloadTracker, 

32) 

33 

34# SSRF chokepoint: every outbound fetch in this module goes through 

35# `safe_get`, which validates via ssrf_validator (raising on failure, 

36# re-validating after redirects, always blocking cloud-metadata 

37# addresses). `requests` is imported above for its exception types only 

38# — do not add a raw requests.get()/post() here. GHSA-9c54 was exactly 

39# that bypass, and `is_downloadable_domain` upstream is a relevance 

40# filter, not a security control. 

41from ...security import safe_get, sanitize_error_for_client 

42from ...database.models.library import ( 

43 Collection, 

44 Document as Document, 

45 DocumentStatus, 

46 DownloadQueue as LibraryDownloadQueue, 

47) 

48from .pdf_storage_manager import ( 

49 DEFAULT_MAX_PDF_SIZE_MB, 

50 PDFStorageManager, 

51 resolve_pdf_storage_mode, 

52) 

53from ...database.models.research import ResearchResource 

54from ...database.library_init import get_source_type_id, get_default_library_id 

55from ...database.session_context import get_user_db_session, safe_rollback 

56from ...utilities.db_utils import get_settings_manager 

57from ...library.download_management import RetryManager 

58from ...config.paths import get_library_directory 

59from ..utils import ( 

60 apply_user_subdir, 

61 ensure_in_collection, 

62 get_document_for_resource, 

63 get_url_hash, 

64 get_absolute_path_from_settings, 

65 is_downloadable_url, 

66) 

67 

68# Import our modular downloaders 

69from ..downloaders import ( 

70 ContentType, 

71 ArxivDownloader, 

72 PubMedDownloader, 

73 BioRxivDownloader, 

74 DirectPDFDownloader, 

75 SemanticScholarDownloader, 

76 OpenAlexDownloader, 

77 GenericDownloader, 

78) 

79from ...constants import DEFAULT_SEARCH_TOOL 

80 

81 

82class DownloadService: 

83 """Service for downloading and managing research PDFs.""" 

84 

85 def __init__( 

86 self, 

87 username: str, 

88 password: Optional[str] = None, 

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

90 ): 

91 """Initialize download service for a user. 

92 

93 Args: 

94 username: The username to download for 

95 password: Optional password for encrypted database access 

96 settings_snapshot: Optional snapshot used to build the 

97 EgressContext for per-URL scope gating. When None, 

98 downloads run without scope enforcement (back-compat 

99 for the existing caller; new callers must pass it). 

100 """ 

101 self.username = username 

102 self.password = password 

103 self.settings = get_settings_manager(username=username) 

104 self._closed = False 

105 # When no snapshot is passed (e.g. the library download routes, which 

106 # construct DownloadService(username, password) directly), build one 

107 # from this user's settings so egress policy is still enforced. 

108 # Without this, _egress_context stays None and _check_url_against_policy 

109 # returns (True, "no_context") — every download ungated. Best-effort: 

110 # on failure, fall through to the pre-policy behavior. 

111 # A real backend error (get_settings_snapshot raised) is different 

112 # from a test double returning a non-dict: under a private scope we 

113 # must NOT silently fall through to ungated downloads when we simply 

114 # failed to READ the user's settings. Track that case and fail closed. 

115 self._snapshot_build_failed = False 

116 if settings_snapshot is None: 

117 try: 

118 built = self.settings.get_settings_snapshot() 

119 # Only adopt a real dict — a test double or unavailable 

120 # backend hands back something else; treat that as "no 

121 # snapshot" (ungated back-compat) rather than letting the 

122 # policy evaluation fail closed on a non-dict. 

123 if isinstance(built, dict): 

124 settings_snapshot = built 

125 except Exception: 

126 # The user HAS a settings backend (self.settings exists) but 

127 # reading it raised — a private scope could be configured and 

128 # we just couldn't see it. Fail closed rather than ungate. 

129 logger.bind(policy_audit=True).warning( 

130 "DownloadService: settings snapshot build raised; " 

131 "locking downloads closed (fail-safe)" 

132 ) 

133 self._snapshot_build_failed = True 

134 self._settings_snapshot = settings_snapshot 

135 self._egress_context = self._build_egress_context(settings_snapshot) 

136 

137 # Debug settings manager and user context 

138 logger.info( 

139 f"[DOWNLOAD_SERVICE] Settings manager initialized: {type(self.settings)}, username: {self.username}" 

140 ) 

141 

142 # Get library path from settings (uses centralized path, respects LDR_DATA_DIR) 

143 storage_path_setting = self.settings.get_setting( 

144 "research_library.storage_path", 

145 str(get_library_directory()), 

146 ) 

147 logger.debug( 

148 f"[DOWNLOAD_SERVICE_INIT] Storage path setting retrieved: {storage_path_setting} (type: {type(storage_path_setting)})" 

149 ) 

150 

151 if storage_path_setting is None: 

152 logger.error( 

153 "[DOWNLOAD_SERVICE_INIT] CRITICAL: storage_path_setting is None!" 

154 ) 

155 raise ValueError("Storage path setting cannot be None") 

156 

157 # Per-user library root (issue #5521). PDFs are written to a per-user 

158 # subdirectory (<base>/<username>) instead of the shared base, so two 

159 # users' per-user autoincrement resource ids can't collide on the same 

160 # `pdfs/<resource_id>.pdf` path in one shared directory. This mirrors 

161 # get_library_storage_path()'s layout but derives the base from this 

162 # user's own settings manager (self.settings) so it stays correct in 

163 # background/scheduler threads that have no request context. 

164 # `legacy_library_root` is the old shared base: it is never written to, 

165 # only used as a READ fallback so PDFs downloaded before per-user 

166 # isolation still load (no blind bulk file move — see PR notes). 

167 base_library_root = ( 

168 Path(os.path.expandvars(storage_path_setting)) 

169 .expanduser() 

170 .resolve() 

171 ) 

172 shared_library = self.settings.get_setting( 

173 "research_library.shared_library", False 

174 ) 

175 self.legacy_library_root = str(base_library_root) 

176 self.library_root = str( 

177 apply_user_subdir(base_library_root, self.username, shared_library) 

178 ) 

179 logger.debug( 

180 f"[DOWNLOAD_SERVICE_INIT] Library root resolved to: {self.library_root} " 

181 f"(legacy shared root: {self.legacy_library_root})" 

182 ) 

183 

184 # Create directory structure 

185 self._setup_directories() 

186 

187 # Initialize modular downloaders 

188 # DirectPDFDownloader first for efficiency with direct PDF links 

189 

190 # Get Semantic Scholar API key from settings 

191 semantic_scholar_api_key = self.settings.get_setting( 

192 "search.engine.web.semantic_scholar.api_key", "" 

193 ) 

194 

195 self.downloaders = [ 

196 DirectPDFDownloader(timeout=30), # Handle direct PDF links first 

197 SemanticScholarDownloader( 

198 timeout=30, 

199 api_key=semantic_scholar_api_key 

200 if semantic_scholar_api_key 

201 else None, 

202 ), 

203 OpenAlexDownloader( 

204 timeout=30 

205 ), # OpenAlex with API lookup (no key needed) 

206 ArxivDownloader(timeout=30), 

207 PubMedDownloader(timeout=30, rate_limit_delay=1.0), 

208 BioRxivDownloader(timeout=30), 

209 GenericDownloader(timeout=30), # Generic should be last (fallback) 

210 ] 

211 

212 # Initialize retry manager for smart failure tracking 

213 self.retry_manager = RetryManager(username, password) 

214 logger.info( 

215 f"[DOWNLOAD_SERVICE] Initialized retry manager for user: {username}" 

216 ) 

217 

218 # PubMed rate limiting state 

219 self._pubmed_delay = 1.0 # 1 second delay for PubMed 

220 self._last_pubmed_request = 0.0 # Track last request time 

221 

222 def close(self): 

223 """Close all downloader resources.""" 

224 if self._closed: 

225 return 

226 self._closed = True 

227 

228 from ...utilities.resource_utils import safe_close 

229 

230 for downloader in self.downloaders: 

231 safe_close(downloader, "downloader") 

232 

233 # Close the settings manager's DB session to return the connection 

234 # to the pool. SettingsManager.close() is idempotent. 

235 safe_close(self.settings, "settings manager", allow_none=True) 

236 

237 # Clear references to allow garbage collection 

238 self.downloaders = [] 

239 self.retry_manager = None 

240 self.settings = None 

241 

242 def __enter__(self): 

243 """Enter context manager.""" 

244 return self 

245 

246 def __exit__(self, exc_type, exc_val, exc_tb): 

247 """Exit context manager, ensuring cleanup.""" 

248 self.close() 

249 return False 

250 

251 def _setup_directories(self): 

252 """Create library directory structure.""" 

253 from ...security.directory_creation import create_directory 

254 

255 # Only create the root and pdfs folder - flat structure. 

256 paths = [ 

257 self.library_root, 

258 str(Path(self.library_root) / "pdfs"), 

259 ] 

260 for path in paths: 

261 create_directory( 

262 path, 

263 context="download service library directory", 

264 ) 

265 

266 def _normalize_url(self, url: str) -> str: 

267 """Normalize URL for consistent hashing.""" 

268 # Remove protocol variations 

269 url = re.sub(r"^https?://", "", url) 

270 # Remove www 

271 url = re.sub(r"^www\.", "", url) 

272 # Remove trailing slashes 

273 url = url.rstrip("/") 

274 # Sort query parameters 

275 if "?" in url: 

276 base, query = url.split("?", 1) 

277 params = sorted(query.split("&")) 

278 url = f"{base}?{'&'.join(params)}" 

279 return url.lower() 

280 

281 def _get_url_hash(self, url: str) -> str: 

282 """Generate SHA256 hash of normalized URL.""" 

283 normalized = self._normalize_url(url) 

284 return get_url_hash(normalized) 

285 

286 def _build_egress_context(self, settings_snapshot): 

287 """Build an EgressContext from the supplied snapshot. Returns 

288 None when no snapshot is available — callers fall through to 

289 the pre-policy behavior (back-compat with non-scheduler callers). 

290 

291 Sets ``self._policy_locked = True`` when a snapshot WAS supplied 

292 but the policy itself cannot be evaluated (corrupt scope 

293 value). The check_url method honors 

294 this flag and fails closed — the previous code returned None on 

295 PolicyDeniedError and check_url then returned ``(True, 

296 "no_context")``, which silently allowed every download under a 

297 misconfigured policy. 

298 """ 

299 self._policy_locked = False 

300 # Use an explicit `is None` check, not a truthiness test: an empty 

301 # dict {} is a real (if minimal) snapshot, and `if not {}` would 

302 # silently skip policy gating for it — fail-open. Match the 

303 # `is None` guard used by the LLM/embeddings gates. 

304 if settings_snapshot is None: 

305 return None 

306 try: 

307 from ...security.egress.policy import ( 

308 PolicyDeniedError, 

309 context_from_snapshot, 

310 ) 

311 except ImportError: 

312 logger.debug( 

313 "egress_policy unavailable in DownloadService; " 

314 "downloads will not be scope-gated" 

315 ) 

316 return None 

317 

318 primary_raw = unwrap_setting( 

319 settings_snapshot.get("search.tool", DEFAULT_SEARCH_TOOL) 

320 ) 

321 try: 

322 return context_from_snapshot( 

323 settings_snapshot, 

324 primary_raw or DEFAULT_SEARCH_TOOL, 

325 username=self.username, 

326 ) 

327 except (PolicyDeniedError, ValueError) as exc: 

328 logger.bind(policy_audit=True).warning( 

329 "DownloadService policy unavailable; locking out " 

330 "every URL check", 

331 reason=str(exc), 

332 ) 

333 self._policy_locked = True 

334 return None 

335 

336 def _check_url_against_policy(self, url: str) -> Tuple[bool, str]: 

337 """Run evaluate_url against self._egress_context. Returns 

338 (allowed, reason). When no context is configured (legacy 

339 callers OR tests that mock __init__), returns (True, "no_context") 

340 so back-compat callers aren't broken — but new callers passing 

341 a snapshot DO get gated. Logged to policy_audit on denial. 

342 """ 

343 # ``_policy_locked`` is set by _build_egress_context when a 

344 # snapshot was supplied but the policy itself raised. Without 

345 # this short-circuit, _egress_context is None and the legacy 

346 # fall-through below would silently allow every URL. 

347 if getattr(self, "_policy_locked", False): 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true

348 return False, "policy_unavailable" 

349 

350 # Reading the user's settings raised during __init__ — we could not 

351 # determine the scope, so fail closed instead of ungating downloads. 

352 if getattr(self, "_snapshot_build_failed", False): 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true

353 return False, "settings_unavailable" 

354 

355 # Use getattr so tests that mock DownloadService.__init__ (and 

356 # therefore never set _egress_context) still work — they 

357 # operated under the pre-policy contract. 

358 egress_context = getattr(self, "_egress_context", None) 

359 if egress_context is None: 

360 return True, "no_context" 

361 from ...security.egress.policy import evaluate_url 

362 from ...security.ssrf_validator import redact_url_for_log 

363 

364 decision = evaluate_url(url, egress_context) 

365 if not decision.allowed: 

366 logger.bind(policy_audit=True).warning( 

367 "DownloadService URL denied by egress policy", 

368 # Redact userinfo creds / query-string tokens from the log. 

369 url=redact_url_for_log(url), 

370 scope=egress_context.scope.value, 

371 reason=decision.reason, 

372 ) 

373 return decision.allowed, decision.reason 

374 

375 def is_already_downloaded(self, url: str) -> Tuple[bool, Optional[str]]: 

376 """ 

377 Check if URL is already downloaded. 

378 

379 Returns: 

380 Tuple of (is_downloaded, file_path) 

381 """ 

382 url_hash = self._get_url_hash(url) 

383 

384 with get_user_db_session(self.username, self.password) as session: 

385 tracker = ( 

386 session.query(DownloadTracker) 

387 .filter_by(url_hash=url_hash, is_downloaded=True) 

388 .first() 

389 ) 

390 

391 if tracker and tracker.file_path: 

392 # Compute absolute path and verify file still exists. Pass the 

393 # username so per-user files resolve, with legacy-shared 

394 # fallback for pre-isolation downloads (issue #5521). Pass 

395 # self.settings so storage_path/shared_library are read from 

396 # this user's captured manager — the ambient one is db-less in 

397 # background/scheduler threads and would resolve the default 

398 # root, missing a user's UI-customized storage_path. Use 

399 # getattr: __init__ always sets self.settings in production 

400 # (incl. scheduler threads), but test doubles that construct 

401 # a DownloadService via __new__/partial mocks may not — fall 

402 # back to None (the ambient get_settings_manager()) rather 

403 # than raising AttributeError. 

404 absolute_path = get_absolute_path_from_settings( 

405 tracker.file_path, 

406 self.username, 

407 settings_manager=getattr(self, "settings", None), 

408 ) 

409 if absolute_path and absolute_path.is_file(): 

410 return True, str(absolute_path) 

411 if absolute_path: 

412 # File was deleted, mark as not downloaded 

413 tracker.is_downloaded = False 

414 session.commit() 

415 # If absolute_path is None, path was blocked - treat as not downloaded 

416 

417 return False, None 

418 

419 def get_text_content(self, resource_id: int) -> Optional[str]: 

420 """ 

421 Get text content for a research resource. 

422 

423 This will try to: 

424 1. Fetch text directly from APIs if available 

425 2. Extract text from downloaded PDF if exists 

426 3. Download PDF and extract text if not yet downloaded 

427 

428 Args: 

429 resource_id: ID of the research resource 

430 

431 Returns: 

432 Text content as string, or None if extraction failed 

433 """ 

434 with get_user_db_session(self.username, self.password) as session: 

435 resource = session.get(ResearchResource, resource_id) 

436 if not resource: 

437 logger.error(f"Resource {resource_id} not found") 

438 return None 

439 

440 url = resource.url 

441 

442 # Egress policy gate — denied URLs short-circuit before any 

443 # downloader fires a request. 

444 allowed, reason = self._check_url_against_policy(url) 

445 if not allowed: 445 ↛ 446line 445 didn't jump to line 446 because the condition on line 445 was never true

446 logger.warning( 

447 f"Skipping text extraction for {url}: refused by " 

448 f"egress policy ({reason})" 

449 ) 

450 return None 

451 

452 # Find appropriate downloader 

453 for downloader in self.downloaders: 

454 if downloader.can_handle(url): 

455 logger.info( 

456 f"Using {downloader.__class__.__name__} for text extraction from {url}" 

457 ) 

458 try: 

459 # Try to get text content 

460 text = downloader.download_text(url) 

461 if text: 

462 logger.info( 

463 f"Successfully extracted text for: {resource.title[:50]}" 

464 ) 

465 return text 

466 except Exception: 

467 logger.exception("Failed to extract text") 

468 break 

469 

470 logger.warning(f"Could not extract text for {url}") 

471 return None 

472 

473 def queue_research_downloads( 

474 self, research_id: str, collection_id: Optional[str] = None 

475 ) -> int: 

476 """ 

477 Queue all downloadable PDFs from a research session. 

478 

479 Args: 

480 research_id: The research session ID 

481 collection_id: Optional target collection ID (defaults to Library if not provided) 

482 

483 Returns: 

484 Number of items queued 

485 

486 Notes: 

487 Resets existing FAILED/COMPLETED queue entries for the research back 

488 to PENDING, effectively retrying them. Resources that already have a 

489 PENDING queue entry or a COMPLETED Document are skipped. As of #4685, 

490 ``download_bulk`` calls this unconditionally on every bulk run, so 

491 previously-failed downloads are automatically retried each time. 

492 A PROCESSING entry is in flight in another ``download_bulk`` stream, 

493 so it is left alone rather than reset: resetting it would let this 

494 run re-claim and re-download it (#4691). 

495 """ 

496 queued = 0 

497 

498 # Get default library collection if no collection_id provided 

499 if not collection_id: 

500 from ...database.library_init import get_default_library_id 

501 

502 collection_id = get_default_library_id(self.username, self.password) 

503 

504 with get_user_db_session(self.username, self.password) as session: 

505 # Get all resources for this research 

506 resources = ( 

507 session.query(ResearchResource) 

508 .filter_by(research_id=research_id) 

509 .all() 

510 ) 

511 

512 for resource in resources: 

513 if self._is_downloadable(resource): 

514 # Library resources linked via document_id are already done 

515 if resource.document_id: 

516 continue 

517 

518 # Egress policy gate before queueing. 

519 allowed, reason = self._check_url_against_policy( 

520 resource.url 

521 ) 

522 if not allowed: 522 ↛ 523line 522 didn't jump to line 523 because the condition on line 522 was never true

523 logger.warning( 

524 f"Skipping queue for {resource.url}: refused by " 

525 f"egress policy ({reason})" 

526 ) 

527 continue 

528 

529 # Check if already queued 

530 existing_queue = ( 

531 session.query(LibraryDownloadQueue) 

532 .filter_by( 

533 resource_id=resource.id, 

534 status=DocumentStatus.PENDING, 

535 ) 

536 .first() 

537 ) 

538 

539 # Check if already downloaded (trust the database status) 

540 existing_doc = ( 

541 session.query(Document) 

542 .filter_by( 

543 resource_id=resource.id, 

544 status=DocumentStatus.COMPLETED, 

545 ) 

546 .first() 

547 ) 

548 

549 # Queue if not already queued and not marked as completed 

550 if not existing_queue and not existing_doc: 550 ↛ 512line 550 didn't jump to line 512 because the condition on line 550 was always true

551 # Check one more time if ANY queue entry exists (regardless of status) 

552 any_queue = ( 

553 session.query(LibraryDownloadQueue) 

554 .filter_by(resource_id=resource.id) 

555 .first() 

556 ) 

557 

558 if any_queue: 

559 # Reset the existing queue entry, but never one a 

560 # concurrent download_bulk stream has claimed: a 

561 # PROCESSING row is in flight, and resetting it to 

562 # PENDING lets this stream re-claim and re-download 

563 # a resource the other stream is still downloading 

564 # (issue #4691). The status guard lives inside the 

565 # UPDATE, not in an `if` above it, so the check and 

566 # the write cannot straddle another stream's claim. 

567 reset = ( 

568 session.query(LibraryDownloadQueue) 

569 .filter( 

570 LibraryDownloadQueue.id == any_queue.id, 

571 LibraryDownloadQueue.status 

572 != DocumentStatus.PROCESSING, 

573 ) 

574 .update( 

575 { 

576 LibraryDownloadQueue.status: DocumentStatus.PENDING, 

577 LibraryDownloadQueue.research_id: research_id, 

578 LibraryDownloadQueue.collection_id: collection_id, 

579 }, 

580 synchronize_session=False, 

581 ) 

582 ) 

583 if reset: 

584 queued += 1 

585 else: 

586 # Add new queue entry 

587 queue_entry = LibraryDownloadQueue( 

588 resource_id=resource.id, 

589 research_id=research_id, 

590 collection_id=collection_id, 

591 priority=0, 

592 status=DocumentStatus.PENDING, 

593 ) 

594 session.add(queue_entry) 

595 queued += 1 

596 

597 session.commit() 

598 logger.info( 

599 f"Queued {queued} downloads for research {research_id} to collection {collection_id}" 

600 ) 

601 

602 return queued 

603 

604 def _is_downloadable(self, resource: ResearchResource) -> bool: 

605 """Check if a resource is likely downloadable as PDF. 

606 

607 Delegates to the consolidated is_downloadable_url() from utils. 

608 """ 

609 return is_downloadable_url(resource.url) 

610 

611 def download_resource(self, resource_id: int) -> Tuple[bool, Optional[str]]: 

612 """ 

613 Download a specific resource. 

614 

615 Returns: 

616 Tuple of (success: bool, skip_reason: str or None) 

617 """ 

618 with get_user_db_session(self.username, self.password) as session: 

619 resource = session.get(ResearchResource, resource_id) 

620 if not resource: 

621 logger.error(f"Resource {resource_id} not found") 

622 return False, "Resource not found" 

623 

624 # Check if already downloaded (trust the database after sync) 

625 existing_doc = ( 

626 session.query(Document) 

627 .filter_by( 

628 resource_id=resource_id, status=DocumentStatus.COMPLETED 

629 ) 

630 .first() 

631 ) 

632 

633 if existing_doc: 

634 logger.info( 

635 "Resource already downloaded (according to database)" 

636 ) 

637 return True, None 

638 

639 # Get collection_id from queue entry if it exists 

640 queue_entry = ( 

641 session.query(LibraryDownloadQueue) 

642 .filter_by(resource_id=resource_id) 

643 .first() 

644 ) 

645 collection_id = ( 

646 queue_entry.collection_id 

647 if queue_entry and queue_entry.collection_id 

648 else None 

649 ) 

650 

651 # Create download tracker entry 

652 url_hash = self._get_url_hash(resource.url) 

653 tracker = ( 

654 session.query(DownloadTracker) 

655 .filter_by(url_hash=url_hash) 

656 .first() 

657 ) 

658 

659 if not tracker: 

660 tracker = DownloadTracker( 

661 url=resource.url, 

662 url_hash=url_hash, 

663 first_resource_id=resource.id, 

664 is_downloaded=False, 

665 ) 

666 session.add(tracker) 

667 session.commit() 

668 

669 # Attempt download 

670 success, skip_reason, status_code = self._download_pdf( 

671 resource, tracker, session, collection_id 

672 ) 

673 

674 # Record attempt with retry manager for smart failure tracking 

675 self.retry_manager.record_attempt( 

676 resource_id=resource.id, 

677 result=(success, skip_reason), 

678 status_code=status_code, 

679 url=resource.url, 

680 details=skip_reason 

681 or ( 

682 "Successfully downloaded" if success else "Download failed" 

683 ), 

684 session=session, 

685 ) 

686 

687 # Update queue status if exists 

688 queue_entry = ( 

689 session.query(LibraryDownloadQueue) 

690 .filter_by(resource_id=resource_id) 

691 .first() 

692 ) 

693 

694 if queue_entry: 

695 queue_entry.status = ( 

696 DocumentStatus.COMPLETED 

697 if success 

698 else DocumentStatus.FAILED 

699 ) 

700 queue_entry.completed_at = datetime.now(UTC) 

701 

702 session.commit() 

703 

704 # Trigger auto-indexing for successfully downloaded documents 

705 if success and self.password: 

706 try: 

707 from ...web.routers.rag import trigger_auto_index 

708 from ...database.library_init import get_default_library_id 

709 

710 # Get the document that was just created 

711 doc = ( 

712 session.query(Document) 

713 .filter_by(resource_id=resource_id) 

714 .order_by(Document.created_at.desc()) 

715 .first() 

716 ) 

717 if doc: 

718 # Use collection_id from queue entry or default Library 

719 # NB: pass username string, not the SQLAlchemy session 

720 target_collection = ( 

721 collection_id 

722 or get_default_library_id( 

723 self.username, self.password 

724 ) 

725 ) 

726 if target_collection: 726 ↛ anywhereline 726 didn't jump anywhere: it always raised an exception.

727 trigger_auto_index( 

728 [doc.id], 

729 target_collection, 

730 self.username, 

731 self.password, 

732 ) 

733 except Exception: 

734 # The Document SELECT above runs on the shared session; a 

735 # connection-level failure there leaves it needing a 

736 # rollback. Auto-indexing is best-effort so we swallow and 

737 # return — the with-block exits normally and 

738 # get_user_db_session's rollback never fires. Recover the 

739 # session here so a later op on it (same request/thread) 

740 # doesn't cascade. (No-op for the other failures reachable 

741 # here: get_default_library_id self-heals via its own inner 

742 # get_user_db_session block, and trigger_auto_index does its 

743 # DB work off-thread, so neither dirties this session.) 

744 safe_rollback(session, "download_resource auto-index") 

745 logger.exception("Failed to trigger auto-indexing") 

746 

747 return success, skip_reason 

748 

749 def _download_pdf( 

750 self, 

751 resource: ResearchResource, 

752 tracker: DownloadTracker, 

753 session: Session, 

754 collection_id: Optional[str] = None, 

755 ) -> Tuple[bool, Optional[str], Optional[int]]: 

756 """ 

757 Perform the actual PDF download. 

758 

759 Args: 

760 resource: The research resource to download 

761 tracker: Download tracker for this URL 

762 session: Database session 

763 collection_id: Optional target collection ID (defaults to Library if not provided) 

764 

765 Returns: 

766 Tuple of (success: bool, skip_reason: Optional[str], status_code: Optional[int]) 

767 """ 

768 url = resource.url 

769 

770 # Egress policy gate at the network-fire point. The entry caller 

771 # (download_resource) does not gate, so this is the true PEP — a 

772 # denied URL must never reach a downloader regardless of route. 

773 allowed, reason = self._check_url_against_policy(url) 

774 if not allowed: 

775 logger.warning( 

776 f"Skipping PDF download for {url}: refused by egress " 

777 f"policy ({reason})" 

778 ) 

779 return False, f"egress_policy_denied:{reason}", None 

780 

781 # Log attempt 

782 attempt = DownloadAttempt( 

783 url_hash=tracker.url_hash, 

784 attempt_number=tracker.download_attempts.count() + 1 

785 if hasattr(tracker, "download_attempts") 

786 else 1, 

787 attempted_at=datetime.now(UTC), 

788 ) 

789 session.add(attempt) 

790 

791 try: 

792 # Use modular downloaders with detailed skip reasons 

793 pdf_content = None 

794 downloader_used = None 

795 skip_reason = None 

796 status_code = None 

797 

798 for downloader in self.downloaders: 

799 if downloader.can_handle(url): 

800 logger.info( 

801 f"Using {downloader.__class__.__name__} for {url}" 

802 ) 

803 result = downloader.download_with_result( 

804 url, ContentType.PDF 

805 ) 

806 downloader_used = downloader.__class__.__name__ 

807 

808 if result.is_success and result.content: 

809 pdf_content = result.content 

810 status_code = result.status_code 

811 break 

812 if result.skip_reason: 812 ↛ 798line 812 didn't jump to line 798 because the condition on line 812 was always true

813 skip_reason = result.skip_reason 

814 status_code = result.status_code 

815 logger.info(f"Download skipped: {skip_reason}") 

816 # Keep trying other downloaders unless it's the GenericDownloader 

817 if isinstance(downloader, GenericDownloader): 

818 break 

819 

820 if not downloader_used: 

821 logger.error(f"No downloader found for {url}") 

822 skip_reason = "No compatible downloader available" 

823 

824 if not pdf_content: 

825 error_msg = skip_reason or "Failed to download PDF content" 

826 # Store skip reason in attempt for retrieval 

827 attempt.error_message = error_msg 

828 attempt.succeeded = False 

829 session.commit() 

830 logger.info(f"Download failed with reason: {error_msg}") 

831 return False, error_msg, status_code 

832 

833 # Get PDF storage mode setting. Resolve through the operator gate 

834 # BEFORE any PDF is written: a stored value or an 

835 # LDR_RESEARCH_LIBRARY_PDF_STORAGE_MODE env override could still 

836 # read "filesystem" even though the settings UI withholds that 

837 # option, so the unencrypted mode is coerced to the encrypted 

838 # "database" default here unless the operator opted in. This is 

839 # the true enforcement point — hiding the dropdown alone is not 

840 # enough. 

841 pdf_storage_mode = resolve_pdf_storage_mode( 

842 self.settings.get_setting( 

843 "research_library.pdf_storage_mode", "none" 

844 ) 

845 ) 

846 max_pdf_size_mb = int( 

847 self.settings.get_setting( 

848 "research_library.max_pdf_size_mb", 

849 DEFAULT_MAX_PDF_SIZE_MB, 

850 ) 

851 ) 

852 logger.info( 

853 f"[DOWNLOAD_SERVICE] PDF storage mode: {pdf_storage_mode}" 

854 ) 

855 

856 # Update tracker 

857 import hashlib 

858 

859 tracker.file_hash = hashlib.sha256(pdf_content).hexdigest() 

860 tracker.file_size = len(pdf_content) 

861 tracker.is_downloaded = True 

862 tracker.downloaded_at = datetime.now(UTC) 

863 

864 # Initialize PDF storage manager. Writes go to the per-user root; 

865 # legacy_root gives read fallback for pre-isolation files (#5521). 

866 pdf_storage_manager = PDFStorageManager( 

867 library_root=self.library_root, 

868 storage_mode=pdf_storage_mode, 

869 max_pdf_size_mb=max_pdf_size_mb, 

870 legacy_root=self.legacy_library_root, 

871 ) 

872 

873 # Update attempt with success info 

874 attempt.succeeded = True 

875 

876 # Check if library document already exists 

877 existing_doc = get_document_for_resource(session, resource) 

878 

879 if existing_doc: 

880 # Update existing document. Only replace document_hash when 

881 # transitioning from FAILED — that's the placeholder hash from 

882 # _record_failed_text_extraction. For any other prior state 

883 # the hash is already a real content hash and clobbering it 

884 # risks UNIQUE-constraint collisions (issue #3827). 

885 was_failed = existing_doc.status == DocumentStatus.FAILED 

886 if was_failed: 

887 existing_doc.document_hash = tracker.file_hash 

888 existing_doc.file_size = len(pdf_content) 

889 existing_doc.status = DocumentStatus.COMPLETED 

890 existing_doc.processed_at = datetime.now(UTC) 

891 

892 # Save PDF using storage manager (updates storage_mode and file_path) 

893 file_path_result, _ = pdf_storage_manager.save_pdf( 

894 pdf_content=pdf_content, 

895 document=existing_doc, 

896 session=session, 

897 filename=f"{resource.id}.pdf", 

898 url=url, 

899 resource_id=resource.id, 

900 ) 

901 

902 # Update tracker 

903 tracker.file_path = ( 

904 file_path_result if file_path_result else None 

905 ) 

906 tracker.file_name = ( 

907 Path(file_path_result).name 

908 if file_path_result and file_path_result != "database" 

909 else None 

910 ) 

911 else: 

912 # Get source type ID for research downloads 

913 try: 

914 source_type_id = get_source_type_id( 

915 self.username, "research_download", self.password 

916 ) 

917 # Use provided collection_id or default to Library 

918 library_collection_id = ( 

919 collection_id 

920 or get_default_library_id(self.username, self.password) 

921 ) 

922 except Exception: 

923 logger.exception( 

924 "Failed to get source type or library collection" 

925 ) 

926 raise 

927 

928 # Create new unified document entry 

929 doc_id = str(uuid.uuid4()) 

930 doc = Document( 

931 id=doc_id, 

932 source_type_id=source_type_id, 

933 resource_id=resource.id, 

934 research_id=resource.research_id, 

935 document_hash=tracker.file_hash, 

936 original_url=url, 

937 file_size=len(pdf_content), 

938 file_type="pdf", 

939 mime_type="application/pdf", 

940 title=resource.title, 

941 status=DocumentStatus.COMPLETED, 

942 processed_at=datetime.now(UTC), 

943 storage_mode=pdf_storage_mode, 

944 ) 

945 session.add(doc) 

946 session.flush() # Ensure doc.id is available for blob storage 

947 

948 # Save PDF using storage manager (updates storage_mode and file_path) 

949 file_path_result, _ = pdf_storage_manager.save_pdf( 

950 pdf_content=pdf_content, 

951 document=doc, 

952 session=session, 

953 filename=f"{resource.id}.pdf", 

954 url=url, 

955 resource_id=resource.id, 

956 ) 

957 

958 # Update tracker 

959 tracker.file_path = ( 

960 file_path_result if file_path_result else None 

961 ) 

962 tracker.file_name = ( 

963 Path(file_path_result).name 

964 if file_path_result and file_path_result != "database" 

965 else None 

966 ) 

967 

968 # Link document to default Library collection 

969 ensure_in_collection(session, doc_id, library_collection_id) 

970 

971 # Update attempt 

972 attempt.succeeded = True 

973 attempt.bytes_downloaded = len(pdf_content) 

974 

975 if pdf_storage_mode == "database": 

976 logger.info( 

977 f"Successfully stored PDF in database: {resource.url}" 

978 ) 

979 elif pdf_storage_mode == "filesystem": 

980 logger.info(f"Successfully downloaded: {tracker.file_path}") 

981 else: 

982 logger.info(f"Successfully extracted text from: {resource.url}") 

983 

984 # Automatically extract and save text after successful PDF download 

985 try: 

986 logger.info( 

987 f"Extracting text from downloaded PDF for: {resource.title[:50]}" 

988 ) 

989 text = self._extract_text_from_pdf(pdf_content) 

990 

991 if text: 

992 # Get the document ID we just created/updated 

993 pdf_doc = get_document_for_resource(session, resource) 

994 pdf_document_id = pdf_doc.id if pdf_doc else None 

995 

996 # Save text to encrypted database 

997 self._save_text_with_db( 

998 resource=resource, 

999 text=text, 

1000 session=session, 

1001 extraction_method="pdf_extraction", 

1002 extraction_source="local_pdf", 

1003 pdf_document_id=pdf_document_id, 

1004 ) 

1005 logger.info( 

1006 f"Successfully extracted and saved text for: {resource.title[:50]}" 

1007 ) 

1008 else: 

1009 logger.warning( 

1010 f"Text extraction returned empty text for: {resource.title[:50]}" 

1011 ) 

1012 except Exception: 

1013 logger.exception( 

1014 "Failed to extract text from PDF, but PDF download succeeded" 

1015 ) 

1016 # Don't fail the entire download if text extraction fails 

1017 

1018 return True, None, status_code 

1019 

1020 except Exception as e: 

1021 logger.exception(f"Download failed for {url}") 

1022 # The error may have come from a failed flush/commit in the 

1023 # success path above (e.g. save_pdf / ensure_in_collection), 

1024 # leaving the *shared* session in PendingRollbackError. The caller 

1025 # (download_resource) keeps using this same session and commits 

1026 # again, so roll back here or that commit cascades. This swallow- 

1027 # and-return path is invisible to get_user_db_session's own 

1028 # rollback (we don't propagate), hence the explicit recovery. 

1029 safe_rollback(session, "_download_pdf") 

1030 tracker.is_accessible = False 

1031 # request/network errors can echo a resource URL carrying an 

1032 # api_key/token or user:pass@host — scrub before returning. The 

1033 # message is surfaced to the browser via the download SSE stream. 

1034 safe_error = sanitize_error_for_client(str(e)) 

1035 # The rollback discarded the pending ``attempt`` row added above, 

1036 # so re-record the failed attempt on the now-clean session for 

1037 # download_resource to commit. 

1038 session.add( 

1039 DownloadAttempt( 

1040 url_hash=tracker.url_hash, 

1041 attempt_number=tracker.download_attempts.count() + 1 

1042 if hasattr(tracker, "download_attempts") 

1043 else 1, 

1044 attempted_at=datetime.now(UTC), 

1045 succeeded=False, 

1046 error_type=type(e).__name__, 

1047 error_message=safe_error, 

1048 ) 

1049 ) 

1050 return False, safe_error, None 

1051 

1052 def _extract_text_from_pdf(self, pdf_content: bytes) -> Optional[str]: 

1053 """ 

1054 Extract text from PDF content using multiple methods for best results. 

1055 

1056 Args: 

1057 pdf_content: Raw PDF bytes 

1058 

1059 Returns: 

1060 Extracted text or None if extraction fails 

1061 """ 

1062 try: 

1063 # First try with pdfplumber (better for complex layouts) 

1064 import io 

1065 

1066 with pdfplumber.open(io.BytesIO(pdf_content)) as pdf: 

1067 text_parts = [] 

1068 for page in pdf.pages: 

1069 page_text = page.extract_text() 

1070 if page_text: 

1071 text_parts.append(page_text) 

1072 

1073 if text_parts: 

1074 return "\n\n".join(text_parts) 

1075 

1076 # Fallback to PyPDF if pdfplumber fails 

1077 reader = PdfReader(io.BytesIO(pdf_content)) 

1078 text_parts = [] 

1079 for page in reader.pages: 

1080 text = page.extract_text() 

1081 if text: 

1082 text_parts.append(text) 

1083 

1084 if text_parts: 

1085 return "\n\n".join(text_parts) 

1086 

1087 logger.warning("No text could be extracted from PDF") 

1088 return None 

1089 

1090 except Exception: 

1091 logger.exception("Failed to extract text from PDF") 

1092 return None 

1093 

1094 def download_as_text(self, resource_id: int) -> Tuple[bool, Optional[str]]: 

1095 """ 

1096 Download resource and extract text to encrypted database. 

1097 

1098 Args: 

1099 resource_id: ID of the resource to download 

1100 

1101 Returns: 

1102 Tuple of (success, error_message) 

1103 """ 

1104 with get_user_db_session(self.username, self.password) as session: 

1105 # Get the resource 

1106 resource = ( 

1107 session.query(ResearchResource) 

1108 .filter_by(id=resource_id) 

1109 .first() 

1110 ) 

1111 if not resource: 

1112 return False, "Resource not found" 

1113 

1114 # Handle library resources — content already in the database 

1115 # (local-only, no network — skip retry checks) 

1116 if resource.source_type == "library" or ( 

1117 resource.url and resource.url.startswith("/library/document/") 

1118 ): 

1119 return self._try_library_text_extraction(session, resource) 

1120 

1121 # Try existing text in database 

1122 result = self._try_existing_text(session, resource_id) 

1123 if result is not None: 

1124 return result 

1125 

1126 # Try legacy text files on disk 

1127 result = self._try_legacy_text_file(session, resource, resource_id) 

1128 if result is not None: 1128 ↛ 1129line 1128 didn't jump to line 1129 because the condition on line 1128 was never true

1129 return result 

1130 

1131 # Try extracting from existing PDF 

1132 result = self._try_existing_pdf_extraction( 

1133 session, resource, resource_id 

1134 ) 

1135 if result is not None: 1135 ↛ 1136line 1135 didn't jump to line 1136 because the condition on line 1135 was never true

1136 return result 

1137 

1138 # Check retry eligibility before network-dependent extraction 

1139 if self.retry_manager: 1139 ↛ 1148line 1139 didn't jump to line 1148 because the condition on line 1139 was always true

1140 decision = self.retry_manager.should_retry_resource(resource_id) 

1141 if not decision.can_retry: 1141 ↛ 1142line 1141 didn't jump to line 1142 because the condition on line 1141 was never true

1142 logger.info( 

1143 f"Skipping resource {resource_id}: {decision.reason}" 

1144 ) 

1145 return False, decision.reason 

1146 

1147 # Try API text extraction 

1148 result = self._try_api_text_extraction(session, resource) 

1149 if result is not None: 

1150 self._record_retry_attempt(resource, result, session) 

1151 return result 

1152 

1153 # Fallback: Download PDF and extract 

1154 result = self._fallback_pdf_extraction(session, resource) 

1155 self._record_retry_attempt(resource, result, session) 

1156 return result 

1157 

1158 def _record_retry_attempt( 

1159 self, resource, result: Tuple[bool, Optional[str]], session=None 

1160 ) -> None: 

1161 """Record a download attempt with the retry manager.""" 

1162 if not self.retry_manager: 1162 ↛ 1163line 1162 didn't jump to line 1163 because the condition on line 1162 was never true

1163 return 

1164 self.retry_manager.record_attempt( 

1165 resource_id=resource.id, 

1166 result=result, 

1167 url=resource.url or "", 

1168 details=result[1] 

1169 or ( 

1170 "Successfully extracted text" 

1171 if result[0] 

1172 else "Text extraction failed" 

1173 ), 

1174 session=session, 

1175 ) 

1176 

1177 def _try_library_text_extraction( 

1178 self, session, resource 

1179 ) -> Tuple[bool, Optional[str]]: 

1180 """Handle library resources — content already exists in local database. 

1181 

1182 Returns: 

1183 Tuple of (success, error_message). On success: (True, None). 

1184 On failure: (False, description of what went wrong). 

1185 """ 

1186 # 1. Extract document UUID from metadata (authoritative) or URL (fallback) 

1187 doc_id = None 

1188 metadata = (resource.resource_metadata or {}).get("original_data", {}) 

1189 if isinstance(metadata, dict): 

1190 meta_inner = metadata.get("metadata", {}) 

1191 if isinstance(meta_inner, dict): 

1192 doc_id = meta_inner.get("source_id") or meta_inner.get( 

1193 "document_id" 

1194 ) 

1195 

1196 if not doc_id and resource.url: 

1197 # Parse from /library/document/{uuid} or /library/document/{uuid}/pdf 

1198 match = re.match(r"^/library/document/([^/]+)", resource.url) 

1199 if match: 1199 ↛ 1202line 1199 didn't jump to line 1202 because the condition on line 1199 was always true

1200 doc_id = match.group(1) 

1201 

1202 if not doc_id: 

1203 return False, "Could not extract library document ID" 

1204 

1205 # 2. Query the existing Document by its primary key (UUID string) 

1206 doc = session.query(Document).filter_by(id=doc_id).first() 

1207 if not doc: 

1208 return False, f"Library document {doc_id} not found in database" 

1209 

1210 # 3. If text already exists and extraction succeeded, return success 

1211 if ( 

1212 doc.text_content 

1213 and doc.extraction_method 

1214 and doc.extraction_method != "failed" 

1215 ): 

1216 logger.info(f"Library document {doc_id} already has text content") 

1217 resource.document_id = doc.id 

1218 session.commit() 

1219 return True, None 

1220 

1221 # 4. Try extracting text from stored PDF 

1222 pdf_storage_mode = self.settings.get_setting( 

1223 "research_library.pdf_storage_mode", "none" 

1224 ) 

1225 pdf_manager = PDFStorageManager( 

1226 library_root=self.library_root, 

1227 storage_mode=pdf_storage_mode, 

1228 legacy_root=self.legacy_library_root, 

1229 ) 

1230 pdf_content = pdf_manager.load_pdf(doc, session) 

1231 

1232 if not pdf_content: 

1233 return ( 

1234 False, 

1235 f"Library document {doc_id} has no text or PDF content", 

1236 ) 

1237 

1238 text = self._extract_text_from_pdf(pdf_content) 

1239 if not text: 

1240 return ( 

1241 False, 

1242 f"Failed to extract text from library document {doc_id} PDF", 

1243 ) 

1244 

1245 # 5. Update Document directly (don't use _save_text_with_db — it 

1246 # queries by resource_id which mismatches for library docs) 

1247 doc.text_content = text 

1248 doc.character_count = len(text) 

1249 doc.word_count = len(text.split()) 

1250 doc.extraction_method = "pdf_extraction" 

1251 doc.extraction_source = "pdfplumber" 

1252 doc.extraction_quality = "medium" 

1253 

1254 resource.document_id = doc.id 

1255 session.commit() 

1256 

1257 logger.info( 

1258 f"Extracted text from library document {doc_id} " 

1259 f"({doc.word_count} words)" 

1260 ) 

1261 return True, None 

1262 

1263 def _try_existing_text( 

1264 self, session, resource_id: int 

1265 ) -> Optional[Tuple[bool, Optional[str]]]: 

1266 """Check if text already exists in database (in Document.text_content).""" 

1267 existing_doc = ( 

1268 session.query(Document).filter_by(resource_id=resource_id).first() 

1269 ) 

1270 

1271 if not existing_doc: 

1272 return None 

1273 

1274 # Check if text content exists and extraction was successful 

1275 if ( 

1276 existing_doc.text_content 

1277 and existing_doc.extraction_method 

1278 and existing_doc.extraction_method != "failed" 

1279 ): 

1280 logger.info( 

1281 f"Text content already exists in Document for resource_id={resource_id}, extraction_method={existing_doc.extraction_method}" 

1282 ) 

1283 return True, None 

1284 

1285 # No text content or failed extraction 

1286 logger.debug( 

1287 f"Document exists but no valid text content: resource_id={resource_id}, extraction_method={existing_doc.extraction_method}" 

1288 ) 

1289 return None # Fall through to re-extraction 

1290 

1291 def _try_legacy_text_file( 

1292 self, session, resource, resource_id: int 

1293 ) -> Optional[Tuple[bool, Optional[str]]]: 

1294 """Check for legacy text files on disk.""" 

1295 txt_path = Path(self.library_root) / "txt" 

1296 existing_files = ( 

1297 list(txt_path.glob(f"*_{resource_id}.txt")) 

1298 if txt_path.exists() 

1299 else [] 

1300 ) 

1301 

1302 if not existing_files: 

1303 return None 

1304 

1305 logger.info(f"Text file already exists on disk: {existing_files[0]}") 

1306 self._create_text_document_record( 

1307 session, 

1308 resource, 

1309 existing_files[0], 

1310 extraction_method="unknown", 

1311 extraction_source="legacy_file", 

1312 ) 

1313 session.commit() 

1314 return True, None 

1315 

1316 def _try_existing_pdf_extraction( 

1317 self, session, resource, resource_id: int 

1318 ) -> Optional[Tuple[bool, Optional[str]]]: 

1319 """Try extracting text from existing PDF in database.""" 

1320 pdf_document = ( 

1321 session.query(Document).filter_by(resource_id=resource_id).first() 

1322 ) 

1323 

1324 if not pdf_document or pdf_document.status != "completed": 

1325 return None 

1326 

1327 # Validate path to prevent path traversal attacks. Resolve against the 

1328 # per-user root with legacy-shared fallback (issue #5521) so a PDF 

1329 # downloaded before per-user isolation is still found. Pass 

1330 # self.settings so storage_path/shared_library come from this user's 

1331 # captured manager — the ambient one is db-less in background/scheduler 

1332 # threads (this runs under the document scheduler) and would resolve 

1333 # the default root, missing a user's UI-customized storage_path. Use 

1334 # getattr: __init__ always sets self.settings in production (incl. 

1335 # scheduler threads), but test doubles that construct a 

1336 # DownloadService via __new__/partial mocks may not — fall back to 

1337 # None (the ambient get_settings_manager()) rather than raising 

1338 # AttributeError. 

1339 if ( 

1340 not pdf_document.file_path 

1341 or pdf_document.file_path in FILE_PATH_SENTINELS 

1342 ): 

1343 return None 

1344 pdf_path = get_absolute_path_from_settings( 

1345 pdf_document.file_path, 

1346 self.username, 

1347 settings_manager=getattr(self, "settings", None), 

1348 ) 

1349 if not pdf_path or not pdf_path.is_file(): 

1350 return None 

1351 

1352 logger.info(f"Found existing PDF, extracting text from: {pdf_path}") 

1353 try: 

1354 with open(pdf_path, "rb") as f: 

1355 pdf_content = f.read() 

1356 text = self._extract_text_from_pdf(pdf_content) 

1357 

1358 if not text: 

1359 return None 

1360 

1361 self._save_text_with_db( 

1362 resource, 

1363 text, 

1364 session, 

1365 extraction_method="pdf_extraction", 

1366 extraction_source="pdfplumber", 

1367 pdf_document_id=pdf_document.id, 

1368 ) 

1369 session.commit() 

1370 return True, None 

1371 

1372 except Exception: 

1373 logger.exception( 

1374 f"Failed to extract text from existing PDF: {pdf_path}" 

1375 ) 

1376 # The commit above can fail and poison the shared session. 

1377 # download_as_text swallows this None and may then return cleanly 

1378 # (e.g. a non-retry decision), so the with-block exits without an 

1379 # exception — get_user_db_session's rollback never fires. Recover 

1380 # the session here or the next operation on this thread cascades. 

1381 safe_rollback(session, "_try_existing_pdf_extraction") 

1382 return None # Fall through to other methods 

1383 

1384 def _try_api_text_extraction( 

1385 self, session, resource 

1386 ) -> Optional[Tuple[bool, Optional[str]]]: 

1387 """Try direct API text extraction.""" 

1388 logger.info( 

1389 f"Attempting direct API text extraction from: {resource.url}" 

1390 ) 

1391 

1392 downloader = self._get_downloader(resource.url) 

1393 if not downloader: 

1394 return None 

1395 

1396 # Egress policy gate. Return a non-None tuple (not None) on denial 

1397 # so download_as_text hard-stops here instead of falling through 

1398 # to _fallback_pdf_extraction and firing a second request. 

1399 allowed, reason = self._check_url_against_policy(resource.url) 

1400 if not allowed: 

1401 logger.warning( 

1402 f"Skipping API text extraction for {resource.url}: refused " 

1403 f"by egress policy ({reason})" 

1404 ) 

1405 return False, f"egress_policy_denied:{reason}" 

1406 

1407 result = downloader.download_with_result(resource.url, ContentType.TEXT) 

1408 

1409 if not result.is_success or not result.content: 

1410 return None 

1411 

1412 # Decode text content 

1413 text = ( 

1414 result.content.decode("utf-8", errors="ignore") 

1415 if isinstance(result.content, bytes) 

1416 else result.content 

1417 ) 

1418 

1419 # Determine extraction source 

1420 extraction_source = "unknown" 

1421 if isinstance(downloader, ArxivDownloader): 

1422 extraction_source = "arxiv_api" 

1423 elif isinstance(downloader, PubMedDownloader): 1423 ↛ 1424line 1423 didn't jump to line 1424 because the condition on line 1423 was never true

1424 extraction_source = "pubmed_api" 

1425 

1426 try: 

1427 self._save_text_with_db( 

1428 resource, 

1429 text, 

1430 session, 

1431 extraction_method="native_api", 

1432 extraction_source=extraction_source, 

1433 ) 

1434 session.commit() 

1435 logger.info( 

1436 f"✓ SUCCESS: Got text from {extraction_source.upper()} API for '{resource.title[:50]}...'" 

1437 ) 

1438 return True, None 

1439 except Exception as e: 

1440 # Roll back FIRST: the failed commit/flush poisoned the shared 

1441 # session, and even dereferencing resource.id in the log line below 

1442 # can trigger a refresh that re-raises PendingRollbackError. 

1443 # download_as_text reuses this session (_record_retry_attempt) 

1444 # after we return, so it must be clean. 

1445 safe_rollback(session, "_try_api_text_extraction") 

1446 logger.exception(f"Failed to save text for resource {resource.id}") 

1447 # Sanitize error message before returning to API 

1448 safe_error = sanitize_error_for_client( 

1449 f"Failed to save text: {str(e)}" 

1450 ) 

1451 return False, safe_error 

1452 

1453 def _fallback_pdf_extraction( 

1454 self, session, resource 

1455 ) -> Tuple[bool, Optional[str]]: 

1456 """Fallback: Download PDF to memory and extract text.""" 

1457 logger.info( 

1458 f"API text extraction failed, falling back to in-memory PDF download for: {resource.url}" 

1459 ) 

1460 

1461 downloader = self._get_downloader(resource.url) 

1462 if not downloader: 

1463 error_msg = "No compatible downloader found" 

1464 logger.warning( 

1465 f"✗ FAILED: {error_msg} for '{resource.title[:50]}...'" 

1466 ) 

1467 self._record_failed_text_extraction( 

1468 session, resource, error=error_msg 

1469 ) 

1470 session.commit() 

1471 return False, error_msg 

1472 

1473 # Egress policy gate at the network-fire point. 

1474 allowed, reason = self._check_url_against_policy(resource.url) 

1475 if not allowed: 

1476 error_msg = f"egress_policy_denied:{reason}" 

1477 logger.warning( 

1478 f"Skipping fallback PDF download for {resource.url}: " 

1479 f"refused by egress policy ({reason})" 

1480 ) 

1481 self._record_failed_text_extraction( 

1482 session, resource, error=error_msg 

1483 ) 

1484 session.commit() 

1485 return False, error_msg 

1486 

1487 result = downloader.download_with_result(resource.url, ContentType.PDF) 

1488 

1489 if not result.is_success or not result.content: 

1490 error_msg = result.skip_reason or "Failed to download PDF" 

1491 logger.warning( 

1492 f"✗ FAILED: Could not download PDF for '{resource.title[:50]}...' | Error: {error_msg}" 

1493 ) 

1494 self._record_failed_text_extraction( 

1495 session, resource, error=f"PDF download failed: {error_msg}" 

1496 ) 

1497 session.commit() 

1498 return False, f"PDF extraction failed: {error_msg}" 

1499 

1500 # Extract text from PDF 

1501 text = self._extract_text_from_pdf(result.content) 

1502 if not text: 

1503 error_msg = "PDF text extraction returned empty text" 

1504 logger.warning( 

1505 f"Failed to extract text from PDF for: {resource.url}" 

1506 ) 

1507 self._record_failed_text_extraction( 

1508 session, resource, error=error_msg 

1509 ) 

1510 session.commit() 

1511 return False, error_msg 

1512 

1513 try: 

1514 self._save_text_with_db( 

1515 resource, 

1516 text, 

1517 session, 

1518 extraction_method="pdf_extraction", 

1519 extraction_source="pdfplumber_fallback", 

1520 ) 

1521 session.commit() 

1522 logger.info( 

1523 f"✓ SUCCESS: Extracted text from '{resource.title[:50]}...'" 

1524 ) 

1525 return True, None 

1526 except Exception as e: 

1527 # Roll back FIRST (see _try_api_text_extraction): dereferencing 

1528 # resource.id while the session is poisoned would itself re-raise 

1529 # PendingRollbackError. download_as_text reuses this session after 

1530 # we return. 

1531 safe_rollback(session, "_fallback_pdf_extraction") 

1532 logger.exception(f"Failed to save text for resource {resource.id}") 

1533 # Sanitize error message before returning to API 

1534 safe_error = sanitize_error_for_client( 

1535 f"Failed to save text: {str(e)}" 

1536 ) 

1537 return False, safe_error 

1538 

1539 def _get_downloader(self, url: str): 

1540 """ 

1541 Get the appropriate downloader for a URL. 

1542 

1543 Args: 

1544 url: The URL to download from 

1545 

1546 Returns: 

1547 The appropriate downloader instance or None 

1548 """ 

1549 for downloader in self.downloaders: 

1550 if downloader.can_handle(url): 

1551 return downloader 

1552 return None 

1553 

1554 def _download_generic(self, url: str) -> Optional[bytes]: 

1555 """Generic PDF download method.""" 

1556 try: 

1557 headers = { 

1558 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" 

1559 } 

1560 response = safe_get( 

1561 url, headers=headers, timeout=30, allow_redirects=True 

1562 ) 

1563 response.raise_for_status() 

1564 

1565 # Verify it's a PDF 

1566 content_type = response.headers.get("Content-Type", "") 

1567 if ( 

1568 "pdf" not in content_type.lower() 

1569 and not response.content.startswith(b"%PDF") 

1570 ): 

1571 logger.warning(f"Response is not a PDF: {content_type}") 

1572 return None 

1573 

1574 return response.content 

1575 

1576 except Exception: 

1577 logger.exception("Generic download failed") 

1578 return None 

1579 

1580 def _download_arxiv(self, url: str) -> Optional[bytes]: 

1581 """Download from arXiv.""" 

1582 try: 

1583 # Convert abstract URL to PDF URL 

1584 pdf_url = url.replace("abs", "pdf") 

1585 if not pdf_url.endswith(".pdf"): 

1586 pdf_url += ".pdf" 

1587 

1588 return self._download_generic(pdf_url) 

1589 except Exception: 

1590 logger.exception("arXiv download failed") 

1591 return None 

1592 

1593 def _try_europe_pmc(self, pmid: str) -> Optional[bytes]: 

1594 """Try downloading from Europe PMC which often has better PDF availability.""" 

1595 try: 

1596 # Europe PMC API is more reliable for PDFs 

1597 # Check if PDF is available 

1598 api_url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=EXT_ID:{pmid}&format=json" 

1599 response = safe_get(api_url, timeout=10) 

1600 

1601 if response.status_code == 200: 

1602 data = response.json() 

1603 results = data.get("resultList", {}).get("result", []) 

1604 

1605 if results: 

1606 article = results[0] 

1607 # Check if article has open access PDF 

1608 if ( 

1609 article.get("isOpenAccess") == "Y" 

1610 and article.get("hasPDF") == "Y" 

1611 ): 

1612 pmcid = article.get("pmcid") 

1613 if pmcid: 

1614 # Europe PMC PDF URL 

1615 pdf_url = f"https://europepmc.org/backend/ptpmcrender.fcgi?accid={pmcid}&blobtype=pdf" 

1616 logger.info( 

1617 f"Found Europe PMC PDF for PMID {pmid}: {pmcid}" 

1618 ) 

1619 

1620 headers = { 

1621 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" 

1622 } 

1623 

1624 pdf_response = safe_get( 

1625 pdf_url, 

1626 headers=headers, 

1627 timeout=30, 

1628 allow_redirects=True, 

1629 ) 

1630 if pdf_response.status_code == 200: 1630 ↛ 1642line 1630 didn't jump to line 1642 because the condition on line 1630 was always true

1631 content_type = pdf_response.headers.get( 

1632 "content-type", "" 

1633 ) 

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

1635 "pdf" in content_type.lower() 

1636 or len(pdf_response.content) > 1000 

1637 ): 

1638 return pdf_response.content 

1639 except Exception as e: 

1640 logger.debug(f"Europe PMC download failed: {e}") 

1641 

1642 return None 

1643 

1644 def _download_pubmed(self, url: str) -> Optional[bytes]: 

1645 """Download from PubMed/PubMed Central with rate limiting.""" 

1646 try: 

1647 # Apply rate limiting for PubMed requests 

1648 current_time = time.time() 

1649 time_since_last = current_time - self._last_pubmed_request 

1650 if time_since_last < self._pubmed_delay: 

1651 sleep_time = self._pubmed_delay - time_since_last 

1652 logger.debug( 

1653 f"Rate limiting: sleeping {sleep_time:.2f}s before PubMed request" 

1654 ) 

1655 time.sleep(sleep_time) 

1656 self._last_pubmed_request = time.time() 

1657 

1658 # If it's already a PMC article, download directly 

1659 if "/articles/PMC" in url: 

1660 pmc_match = re.search(r"(PMC\d+)", url) 

1661 if pmc_match: 1661 ↛ 1865line 1661 didn't jump to line 1865 because the condition on line 1661 was always true

1662 pmc_id = pmc_match.group(1) 

1663 

1664 # Try Europe PMC (more reliable) 

1665 europe_url = f"https://europepmc.org/backend/ptpmcrender.fcgi?accid={pmc_id}&blobtype=pdf" 

1666 headers = { 

1667 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" 

1668 } 

1669 

1670 try: 

1671 response = safe_get( 

1672 europe_url, 

1673 headers=headers, 

1674 timeout=30, 

1675 allow_redirects=True, 

1676 ) 

1677 if response.status_code == 200: 1677 ↛ 1865line 1677 didn't jump to line 1865 because the condition on line 1677 was always true

1678 content_type = response.headers.get( 

1679 "content-type", "" 

1680 ) 

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

1682 "pdf" in content_type.lower() 

1683 or len(response.content) > 1000 

1684 ): 

1685 logger.info( 

1686 f"Downloaded PDF via Europe PMC for {pmc_id}" 

1687 ) 

1688 return response.content 

1689 except Exception as e: 

1690 logger.debug(f"Direct Europe PMC download failed: {e}") 

1691 return None 

1692 

1693 # If it's a regular PubMed URL, try to find PMC version 

1694 elif urlparse(url).hostname == "pubmed.ncbi.nlm.nih.gov": 

1695 # Extract PMID from URL 

1696 pmid_match = re.search(r"/(\d+)/?", url) 

1697 if pmid_match: 

1698 pmid = pmid_match.group(1) 

1699 logger.info(f"Attempting to download PDF for PMID: {pmid}") 

1700 

1701 # Try Europe PMC first (more reliable) 

1702 pdf_content = self._try_europe_pmc(pmid) 

1703 if pdf_content: 

1704 return pdf_content 

1705 

1706 # First try using NCBI E-utilities API to find PMC ID 

1707 try: 

1708 # Use elink to convert PMID to PMCID 

1709 elink_url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/elink.fcgi" 

1710 params = { 

1711 "dbfrom": "pubmed", 

1712 "db": "pmc", 

1713 "id": pmid, 

1714 "retmode": "json", 

1715 } 

1716 

1717 api_response = safe_get( 

1718 elink_url, params=params, timeout=10 

1719 ) 

1720 if api_response.status_code == 200: 1720 ↛ 1803line 1720 didn't jump to line 1803 because the condition on line 1720 was always true

1721 data = api_response.json() 

1722 # Parse the response to find PMC ID 

1723 link_sets = data.get("linksets", []) 

1724 if link_sets and "linksetdbs" in link_sets[0]: 

1725 for linksetdb in link_sets[0]["linksetdbs"]: 1725 ↛ 1803line 1725 didn't jump to line 1803 because the loop on line 1725 didn't complete

1726 if linksetdb.get( 1726 ↛ 1725line 1726 didn't jump to line 1725 because the condition on line 1726 was always true

1727 "dbto" 

1728 ) == "pmc" and linksetdb.get("links"): 

1729 pmc_id_num = linksetdb["links"][0] 

1730 # Now fetch PMC details to get the correct PMC ID format 

1731 esummary_url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi" 

1732 summary_params = { 

1733 "db": "pmc", 

1734 "id": pmc_id_num, 

1735 "retmode": "json", 

1736 } 

1737 summary_response = safe_get( 

1738 esummary_url, 

1739 params=summary_params, 

1740 timeout=10, 

1741 ) 

1742 if summary_response.status_code == 200: 1742 ↛ 1725line 1742 didn't jump to line 1725 because the condition on line 1742 was always true

1743 summary_data = ( 

1744 summary_response.json() 

1745 ) 

1746 result = summary_data.get( 

1747 "result", {} 

1748 ).get(str(pmc_id_num), {}) 

1749 if result: 1749 ↛ 1725line 1749 didn't jump to line 1725 because the condition on line 1749 was always true

1750 # PMC IDs in the API don't have the "PMC" prefix 

1751 pmc_id = f"PMC{pmc_id_num}" 

1752 logger.info( 

1753 f"Found PMC ID via API: {pmc_id} for PMID: {pmid}" 

1754 ) 

1755 

1756 # Try Europe PMC with the PMC ID 

1757 europe_url = f"https://europepmc.org/backend/ptpmcrender.fcgi?accid={pmc_id}&blobtype=pdf" 

1758 

1759 time.sleep(self._pubmed_delay) 

1760 

1761 headers = { 

1762 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" 

1763 } 

1764 

1765 try: 

1766 response = safe_get( 

1767 europe_url, 

1768 headers=headers, 

1769 timeout=30, 

1770 allow_redirects=True, 

1771 ) 

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

1773 response.status_code 

1774 == 200 

1775 ): 

1776 content_type = response.headers.get( 

1777 "content-type", "" 

1778 ) 

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

1780 "pdf" 

1781 in content_type.lower() 

1782 or len( 

1783 response.content 

1784 ) 

1785 > 1000 

1786 ): 

1787 logger.info( 

1788 f"Downloaded PDF via Europe PMC for {pmc_id}" 

1789 ) 

1790 return ( 

1791 response.content 

1792 ) 

1793 except Exception as e: 

1794 logger.debug( 

1795 f"Europe PMC download with PMC ID failed: {e}" 

1796 ) 

1797 except Exception as e: 

1798 logger.debug( 

1799 f"API lookup failed, trying webpage scraping: {e}" 

1800 ) 

1801 

1802 # Fallback to webpage scraping if API fails 

1803 try: 

1804 headers = { 

1805 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" 

1806 } 

1807 response = safe_get(url, headers=headers, timeout=10) 

1808 if response.status_code == 200: 1808 ↛ 1865line 1808 didn't jump to line 1865 because the condition on line 1808 was always true

1809 # Look for PMC ID in the page 

1810 pmc_match = re.search(r"PMC\d+", response.text) 

1811 if pmc_match: 1811 ↛ 1850line 1811 didn't jump to line 1850 because the condition on line 1811 was always true

1812 pmc_id = pmc_match.group(0) 

1813 logger.info( 

1814 f"Found PMC ID via webpage: {pmc_id} for PMID: {pmid}" 

1815 ) 

1816 

1817 # Add delay before downloading PDF 

1818 time.sleep(self._pubmed_delay) 

1819 

1820 # Try Europe PMC with the PMC ID (more reliable) 

1821 europe_url = f"https://europepmc.org/backend/ptpmcrender.fcgi?accid={pmc_id}&blobtype=pdf" 

1822 headers = { 

1823 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" 

1824 } 

1825 

1826 try: 

1827 response = safe_get( 

1828 europe_url, 

1829 headers=headers, 

1830 timeout=30, 

1831 allow_redirects=True, 

1832 ) 

1833 if response.status_code == 200: 1833 ↛ 1865line 1833 didn't jump to line 1865 because the condition on line 1833 was always true

1834 content_type = response.headers.get( 

1835 "content-type", "" 

1836 ) 

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

1838 "pdf" in content_type.lower() 

1839 or len(response.content) > 1000 

1840 ): 

1841 logger.info( 

1842 f"Downloaded PDF via Europe PMC for {pmc_id}" 

1843 ) 

1844 return response.content 

1845 except Exception as e: 

1846 logger.debug( 

1847 f"Europe PMC download failed: {e}" 

1848 ) 

1849 else: 

1850 logger.info( 

1851 f"No PMC version found for PMID: {pmid}" 

1852 ) 

1853 except requests.exceptions.HTTPError as e: 

1854 if e.response.status_code == 429: 1854 ↛ 1861line 1854 didn't jump to line 1861 because the condition on line 1854 was always true

1855 logger.warning( 

1856 "Rate limited by PubMed, increasing delay" 

1857 ) 

1858 self._pubmed_delay = min( 

1859 self._pubmed_delay * 2, 5.0 

1860 ) # Max 5 seconds 

1861 raise 

1862 except Exception as e: 

1863 logger.debug(f"Could not check for PMC version: {e}") 

1864 

1865 return self._download_generic(url) 

1866 except Exception: 

1867 logger.exception("PubMed download failed") 

1868 return None 

1869 

1870 def _download_semantic_scholar(self, url: str) -> Optional[bytes]: 

1871 """Download from Semantic Scholar.""" 

1872 # Semantic Scholar doesn't host PDFs directly 

1873 # Would need to extract actual PDF URL from page 

1874 return None 

1875 

1876 def _download_biorxiv(self, url: str) -> Optional[bytes]: 

1877 """Download from bioRxiv.""" 

1878 try: 

1879 # Convert to PDF URL 

1880 pdf_url = url.replace(".org/", ".org/content/") 

1881 pdf_url = re.sub(r"v\d+$", "", pdf_url) # Remove version 

1882 pdf_url += ".full.pdf" 

1883 

1884 return self._download_generic(pdf_url) 

1885 except Exception: 

1886 logger.exception("bioRxiv download failed") 

1887 return None 

1888 

1889 def _save_text_with_db( 

1890 self, 

1891 resource: ResearchResource, 

1892 text: str, 

1893 session: Session, 

1894 extraction_method: str, 

1895 extraction_source: str, 

1896 pdf_document_id: Optional[int] = None, 

1897 ) -> Optional[str]: 

1898 """ 

1899 Save extracted text to encrypted database. 

1900 

1901 Args: 

1902 resource: The research resource 

1903 text: Extracted text content 

1904 session: Database session 

1905 extraction_method: How the text was extracted 

1906 extraction_source: Specific tool/API used 

1907 pdf_document_id: ID of PDF document if extracted from PDF 

1908 

1909 Returns: 

1910 None (previously returned text file path, now removed) 

1911 """ 

1912 try: 

1913 # Calculate text metadata for database 

1914 word_count = len(text.split()) 

1915 character_count = len(text) 

1916 

1917 # Find the document by pdf_document_id or resource_id 

1918 doc = None 

1919 if pdf_document_id: 

1920 doc = ( 

1921 session.query(Document) 

1922 .filter_by(id=pdf_document_id) 

1923 .first() 

1924 ) 

1925 else: 

1926 doc = get_document_for_resource(session, resource) 

1927 

1928 if doc: 

1929 # Update existing document with extracted text. Only replace 

1930 # document_hash when transitioning from FAILED — see issue 

1931 # #3827. PDF-bytes hashes set at creation must remain stable; 

1932 # text-content hashes collide far more often (identical 

1933 # extracted text from different PDFs). 

1934 was_failed = doc.status == DocumentStatus.FAILED 

1935 doc.text_content = text 

1936 doc.character_count = character_count 

1937 doc.word_count = word_count 

1938 doc.extraction_method = extraction_method 

1939 doc.extraction_source = extraction_source 

1940 doc.status = DocumentStatus.COMPLETED 

1941 if was_failed: 

1942 doc.document_hash = hashlib.sha256( 

1943 text.encode() 

1944 ).hexdigest() 

1945 doc.processed_at = datetime.now(UTC) 

1946 

1947 # Set quality based on method 

1948 if extraction_method == "native_api": 

1949 doc.extraction_quality = "high" 

1950 elif ( 

1951 extraction_method == "pdf_extraction" 

1952 and extraction_source == "pdfplumber" 

1953 ): 

1954 doc.extraction_quality = "medium" 

1955 else: 

1956 doc.extraction_quality = "low" 

1957 

1958 logger.debug( 

1959 f"Updated document {doc.id} with extracted text ({word_count} words)" 

1960 ) 

1961 else: 

1962 # Create a new Document for text-only extraction 

1963 # Generate hash from text content 

1964 text_hash = hashlib.sha256(text.encode()).hexdigest() 

1965 

1966 # Dedup against existing content hash. If another resource 

1967 # already produced identical extracted text, link this 

1968 # resource to the canonical Document instead of inserting 

1969 # a duplicate that would violate the UNIQUE constraint 

1970 # (issue #3827). Mirrors research_history_indexer.py:322. 

1971 existing_by_hash = ( 

1972 session.query(Document) 

1973 .filter_by(document_hash=text_hash) 

1974 .first() 

1975 ) 

1976 if existing_by_hash: 

1977 resource.document_id = existing_by_hash.id 

1978 library_collection = ( 

1979 session.query(Collection) 

1980 .filter_by(name="Library") 

1981 .first() 

1982 ) 

1983 if library_collection: 1983 ↛ 1990line 1983 didn't jump to line 1990 because the condition on line 1983 was always true

1984 ensure_in_collection( 

1985 session, 

1986 existing_by_hash.id, 

1987 library_collection.id, 

1988 ) 

1989 else: 

1990 logger.warning( 

1991 f"Library collection not found - deduped document {existing_by_hash.id} will not be linked to default collection" 

1992 ) 

1993 logger.info( 

1994 f"Linked resource {resource.id} to existing Document " 

1995 f"{existing_by_hash.id} (matched on content hash)" 

1996 ) 

1997 return None 

1998 

1999 # Get source type for research downloads 

2000 try: 

2001 source_type_id = get_source_type_id( 

2002 self.username, "research_download", self.password 

2003 ) 

2004 except Exception: 

2005 logger.exception( 

2006 "Failed to get source type for text document" 

2007 ) 

2008 raise 

2009 

2010 # Create new document 

2011 doc_id = str(uuid.uuid4()) 

2012 doc = Document( 

2013 id=doc_id, 

2014 source_type_id=source_type_id, 

2015 resource_id=resource.id, 

2016 research_id=resource.research_id, 

2017 document_hash=text_hash, 

2018 original_url=resource.url, 

2019 file_path=FILE_PATH_TEXT_ONLY, 

2020 file_size=character_count, # Use character count as file size for text-only 

2021 file_type="text", 

2022 mime_type="text/plain", 

2023 title=resource.title, 

2024 text_content=text, 

2025 character_count=character_count, 

2026 word_count=word_count, 

2027 extraction_method=extraction_method, 

2028 extraction_source=extraction_source, 

2029 extraction_quality="high" 

2030 if extraction_method == "native_api" 

2031 else "medium", 

2032 status=DocumentStatus.COMPLETED, 

2033 processed_at=datetime.now(UTC), 

2034 ) 

2035 session.add(doc) 

2036 

2037 # Link to default Library collection 

2038 library_collection = ( 

2039 session.query(Collection).filter_by(name="Library").first() 

2040 ) 

2041 if library_collection: 

2042 ensure_in_collection(session, doc_id, library_collection.id) 

2043 else: 

2044 logger.warning( 

2045 f"Library collection not found - document {doc_id} will not be linked to default collection" 

2046 ) 

2047 

2048 logger.info( 

2049 f"Created new document {doc_id} for text-only extraction ({word_count} words)" 

2050 ) 

2051 

2052 logger.info( 

2053 f"Saved text to encrypted database ({word_count} words)" 

2054 ) 

2055 return None 

2056 

2057 except Exception: 

2058 # Rollback BEFORE re-raising so the shared thread-local session 

2059 # is clean by the time the caller's loop reaches its next 

2060 # iteration. Without this, an IntegrityError leaves the session 

2061 # in PendingRollbackError state and every subsequent ORM access 

2062 # cascades (issue #3827). 

2063 safe_rollback(session, "_save_text_with_db") 

2064 logger.exception("Error saving text to encrypted database") 

2065 raise # Re-raise so caller can handle the error 

2066 

2067 def _create_text_document_record( 

2068 self, 

2069 session: Session, 

2070 resource: ResearchResource, 

2071 file_path: Path, 

2072 extraction_method: str, 

2073 extraction_source: str, 

2074 ): 

2075 """Update existing Document with text from file (for legacy text files).""" 

2076 try: 

2077 # Read file to get metadata 

2078 text = file_path.read_text(encoding="utf-8", errors="ignore") 

2079 word_count = len(text.split()) 

2080 character_count = len(text) 

2081 

2082 # Find the Document for this resource 

2083 doc = get_document_for_resource(session, resource) 

2084 

2085 if doc: 

2086 # Update existing document with text content 

2087 doc.text_content = text 

2088 doc.character_count = character_count 

2089 doc.word_count = word_count 

2090 doc.extraction_method = extraction_method 

2091 doc.extraction_source = extraction_source 

2092 doc.extraction_quality = ( 

2093 "low" # Unknown quality for legacy files 

2094 ) 

2095 logger.info( 

2096 f"Updated document {doc.id} with text from file: {file_path.name}" 

2097 ) 

2098 else: 

2099 logger.warning( 

2100 f"No document found to update for resource {resource.id}" 

2101 ) 

2102 

2103 except Exception: 

2104 logger.exception("Error updating document with text from file") 

2105 

2106 def _record_failed_text_extraction( 

2107 self, session: Session, resource: ResearchResource, error: str 

2108 ): 

2109 """Record a failed text extraction attempt in the Document.""" 

2110 try: 

2111 # Find the Document for this resource 

2112 doc = get_document_for_resource(session, resource) 

2113 

2114 if doc: 

2115 # Update document with extraction error 

2116 doc.error_message = error 

2117 doc.extraction_method = "failed" 

2118 doc.extraction_quality = "low" 

2119 doc.status = DocumentStatus.FAILED 

2120 logger.info( 

2121 f"Recorded failed text extraction for document {doc.id}: {error}" 

2122 ) 

2123 else: 

2124 # Create a new Document for failed extraction 

2125 # This enables tracking failures and retry capability 

2126 source_type_id = get_source_type_id( 

2127 self.username, "research_download", self.password 

2128 ) 

2129 

2130 # Deterministic hash so retries update the same record 

2131 failed_hash = hashlib.sha256( 

2132 f"failed:{resource.url}:{resource.id}".encode() 

2133 ).hexdigest() 

2134 

2135 doc_id = str(uuid.uuid4()) 

2136 doc = Document( 

2137 id=doc_id, 

2138 source_type_id=source_type_id, 

2139 resource_id=resource.id, 

2140 research_id=resource.research_id, 

2141 document_hash=failed_hash, 

2142 original_url=resource.url, 

2143 file_path=None, 

2144 file_size=0, 

2145 file_type="unknown", 

2146 title=resource.title, 

2147 status=DocumentStatus.FAILED, 

2148 error_message=error, 

2149 extraction_method="failed", 

2150 extraction_quality="low", 

2151 processed_at=datetime.now(UTC), 

2152 ) 

2153 session.add(doc) 

2154 

2155 logger.info( 

2156 f"Created failed document {doc_id} for resource {resource.id}: {error}" 

2157 ) 

2158 

2159 except Exception: 

2160 logger.exception("Error recording failed text extraction")