Coverage for src/local_deep_research/research_library/services/library_service.py: 90%

393 statements  

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

1""" 

2Library Management Service 

3 

4Handles querying and managing the downloaded document library: 

5- Search and filter documents 

6- Get statistics and analytics 

7- Manage collections and favorites 

8- Handle file operations 

9""" 

10 

11from pathlib import Path 

12from typing import Dict, List, Optional 

13from urllib.parse import urlparse 

14 

15from loguru import logger 

16from sqlalchemy import or_, func, case 

17from sqlalchemy.orm import aliased, defer 

18 

19from ...constants import FILE_PATH_SENTINELS 

20from ...database.models.download_tracker import DownloadTracker 

21from ...database.models.library import ( 

22 Collection, 

23 Document, 

24 DocumentBlob, 

25 DocumentCollection, 

26 DocumentStatus, 

27) 

28from ...database.models.metrics import ResearchRating 

29from ...database.models.research import ResearchHistory, ResearchResource 

30from ...database.session_context import get_user_db_session 

31from ...config.paths import get_library_directory 

32from ...utilities.db_utils import get_settings_manager 

33from ...utilities.resource_utils import safe_close 

34from ...utilities.sql_utils import escape_like as _escape_like 

35from ..utils import ( 

36 get_absolute_path_from_settings, 

37 get_url_hash, 

38 open_file_location, 

39) 

40 

41# Filter dropdowns (library page + ``/api/research-list``) load research 

42# sessions into a client-side ``<select>``. Bound the query so a very large 

43# history cannot pull every row into memory / the DOM on each page load 

44# (#4560). The query already projects to three small columns, so this is a 

45# safety cap rather than a crash fix; realistic histories are far smaller, and 

46# comprehensive large-scale filtering is a server-side-search follow-up. 

47_DROPDOWN_RESEARCH_LIMIT = 1000 

48 

49# ``get_unique_domains`` scans one URL row per downloaded document to build the 

50# domain filter. Stream it in batches (``yield_per``) rather than materializing 

51# every row at once, so a very large library cannot exhaust memory (#4560). The 

52# distinct-domain set it accumulates is small regardless of library size. 

53_DOMAIN_SCAN_BATCH_SIZE = 1000 

54 

55 

56class LibraryService: 

57 """Service for managing and querying the document library.""" 

58 

59 def __init__(self, username: str): 

60 """Initialize library service for a user.""" 

61 self.username = username 

62 

63 def _has_blob_in_db(self, session, document_id: str) -> bool: 

64 """Check if a PDF blob exists in the database for a document.""" 

65 return ( 

66 session.query(DocumentBlob.document_id) 

67 .filter_by(document_id=document_id) 

68 .first() 

69 is not None 

70 ) 

71 

72 def _get_safe_absolute_path(self, file_path: str) -> Optional[str]: 

73 """ 

74 Get the absolute path for a file, safely handling invalid paths. 

75 

76 Args: 

77 file_path: Relative file path from library root 

78 

79 Returns: 

80 Absolute path as string, or None if path is invalid/unsafe 

81 """ 

82 if not file_path or file_path in FILE_PATH_SENTINELS: 

83 return None 

84 abs_path = get_absolute_path_from_settings(file_path, self.username) 

85 return str(abs_path) if abs_path else None 

86 

87 def _is_arxiv_url(self, url: str) -> bool: 

88 """Check if URL is from arXiv domain.""" 

89 try: 

90 hostname = urlparse(url).hostname 

91 return bool( 

92 hostname 

93 and (hostname == "arxiv.org" or hostname.endswith(".arxiv.org")) 

94 ) 

95 except Exception: 

96 return False 

97 

98 def _is_pubmed_url(self, url: str) -> bool: 

99 """Check if URL is from PubMed or NCBI domains.""" 

100 try: 

101 parsed = urlparse(url) 

102 hostname = parsed.hostname 

103 if not hostname: 

104 return False 

105 

106 # Check for pubmed.ncbi.nlm.nih.gov 

107 if hostname == "pubmed.ncbi.nlm.nih.gov": 

108 return True 

109 

110 # Check for ncbi.nlm.nih.gov with PMC path 

111 if hostname == "ncbi.nlm.nih.gov" and "/pmc" in parsed.path: 

112 return True 

113 

114 # Check for pubmed in subdomain 

115 if "pubmed" in hostname: 

116 return True 

117 

118 return False 

119 except Exception: 

120 return False 

121 

122 def _apply_date_filter(self, query, model_class, date_filter: str): 

123 """Apply date range filter based on processed_at timestamp.""" 

124 from datetime import datetime, timedelta, timezone 

125 

126 now = datetime.now(timezone.utc) 

127 if date_filter == "today": 

128 cutoff = now.replace(hour=0, minute=0, second=0, microsecond=0) 

129 elif date_filter == "week": 

130 cutoff = now - timedelta(days=7) 

131 elif date_filter == "month": 

132 cutoff = now - timedelta(days=30) 

133 else: 

134 return query 

135 return query.filter(model_class.processed_at >= cutoff) 

136 

137 def _apply_domain_filter(self, query, model_class, domain: str): 

138 """Apply domain filter to query for Document. 

139 

140 The dropdown is fully data-driven (populated from get_unique_domains), 

141 so the filter is a generic substring match against original_url. 

142 """ 

143 pattern = f"%{_escape_like(domain)}%" 

144 return query.filter(model_class.original_url.like(pattern, escape="\\")) 

145 

146 def _apply_search_filter(self, query, model_class, search_query: str): 

147 """Apply search filter to query for Document.""" 

148 search_pattern = f"%{_escape_like(search_query)}%" 

149 return query.filter( 

150 or_( 

151 model_class.title.ilike(search_pattern, escape="\\"), 

152 model_class.authors.ilike(search_pattern, escape="\\"), 

153 model_class.doi.ilike(search_pattern, escape="\\"), 

154 ResearchResource.title.ilike(search_pattern, escape="\\"), 

155 ) 

156 ) 

157 

158 def get_library_stats(self) -> Dict: 

159 """Get overall library statistics.""" 

160 with get_user_db_session(self.username) as session: 

161 # Get document counts 

162 total_docs = session.query(Document).count() 

163 total_pdfs = ( 

164 session.query(Document).filter_by(file_type="pdf").count() 

165 ) 

166 

167 # Get size stats 

168 size_result = session.query( 

169 func.sum(Document.file_size), 

170 func.avg(Document.file_size), 

171 ).first() 

172 

173 total_size = size_result[0] or 0 

174 avg_size = size_result[1] or 0 

175 

176 # Get research stats 

177 research_count = session.query( 

178 func.count(func.distinct(Document.research_id)) 

179 ).scalar() 

180 

181 # Get domain stats - count unique domains from URLs 

182 # Extract domain from original_url using SQL functions 

183 from sqlalchemy import case, func as sql_func 

184 

185 # Count unique domains by extracting them from URLs 

186 domain_subquery = session.query( 

187 sql_func.distinct( 

188 case( 

189 ( 

190 Document.original_url.like("%arxiv.org%"), 

191 "arxiv.org", 

192 ), 

193 ( 

194 Document.original_url.like("%pubmed%"), 

195 "pubmed", 

196 ), 

197 ( 

198 Document.original_url.like("%ncbi.nlm.nih.gov%"), 

199 "pubmed", 

200 ), 

201 else_="other", 

202 ) 

203 ) 

204 ).subquery() 

205 

206 domain_count = ( 

207 session.query(sql_func.count()) 

208 .select_from(domain_subquery) 

209 .scalar() 

210 ) 

211 

212 # Get download tracker stats 

213 pending_downloads = ( 

214 session.query(DownloadTracker) 

215 .filter_by(is_downloaded=False) 

216 .count() 

217 ) 

218 

219 return { 

220 "total_documents": total_docs, 

221 "total_pdfs": total_pdfs, 

222 "total_size_bytes": total_size, 

223 "total_size_mb": total_size / (1024 * 1024) 

224 if total_size 

225 else 0, 

226 "average_size_mb": avg_size / (1024 * 1024) if avg_size else 0, 

227 "research_sessions": research_count, 

228 "unique_domains": domain_count, 

229 "pending_downloads": pending_downloads, 

230 "storage_path": self._get_storage_path(), 

231 } 

232 

233 def count_documents( 

234 self, 

235 research_id: Optional[str] = None, 

236 domain: Optional[str] = None, 

237 collection_id: Optional[str] = None, 

238 date_filter: Optional[str] = None, 

239 ) -> int: 

240 """Count documents matching the given filters (for pagination).""" 

241 with get_user_db_session(self.username) as session: 

242 from ...database.library_init import get_default_library_id 

243 

244 if not collection_id: 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true

245 collection_id = get_default_library_id(self.username) 

246 

247 q = ( 

248 session.query(func.count(Document.id)) 

249 .join( 

250 DocumentCollection, 

251 Document.id == DocumentCollection.document_id, 

252 ) 

253 .filter(DocumentCollection.collection_id == collection_id) 

254 .filter(Document.status == "completed") 

255 ) 

256 

257 if research_id: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true

258 q = q.filter(Document.research_id == research_id) 

259 if domain: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true

260 q = self._apply_domain_filter(q, Document, domain) 

261 if date_filter: 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true

262 q = self._apply_date_filter(q, Document, date_filter) 

263 

264 return q.scalar() or 0 

265 

266 def get_documents( 

267 self, 

268 research_id: Optional[str] = None, 

269 domain: Optional[str] = None, 

270 file_type: Optional[str] = None, 

271 favorites_only: bool = False, 

272 search_query: Optional[str] = None, 

273 collection_id: Optional[str] = None, 

274 date_filter: Optional[str] = None, 

275 limit: int = 100, 

276 offset: int = 0, 

277 ) -> List[Dict]: 

278 """ 

279 Get documents with filtering options. 

280 

281 Returns enriched document information with research details. 

282 """ 

283 with get_user_db_session(self.username) as session: 

284 # Get default Library collection ID if not specified 

285 from ...database.library_init import get_default_library_id 

286 

287 if not collection_id: 

288 collection_id = get_default_library_id(self.username) 

289 

290 logger.info( 

291 f"[LibraryService] Getting documents for collection_id: {collection_id}, research_id: {research_id}, domain: {domain}" 

292 ) 

293 

294 all_documents = [] 

295 

296 # Step 1: subquery to get paginated document IDs. 

297 # Pagination and sorting happen here on Document alone, 

298 # avoiding non-determinism from outer-joining related tables. 

299 doc_subq = ( 

300 session.query(Document.id) 

301 .join( 

302 DocumentCollection, 

303 Document.id == DocumentCollection.document_id, 

304 ) 

305 .filter(DocumentCollection.collection_id == collection_id) 

306 ) 

307 

308 # Apply filters 

309 if research_id: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true

310 doc_subq = doc_subq.filter(Document.research_id == research_id) 

311 

312 if domain: 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true

313 doc_subq = self._apply_domain_filter(doc_subq, Document, domain) 

314 

315 if date_filter: 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true

316 doc_subq = self._apply_date_filter( 

317 doc_subq, Document, date_filter 

318 ) 

319 

320 if file_type: 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true

321 doc_subq = doc_subq.filter(Document.file_type == file_type) 

322 

323 if favorites_only: 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true

324 doc_subq = doc_subq.filter(Document.favorite.is_(True)) 

325 

326 if search_query: 

327 # _apply_search_filter references ResearchResource.title, 

328 # so we must outerjoin it here. DISTINCT prevents fan-out 

329 # from the outerjoin duplicating Document IDs. 

330 doc_subq = doc_subq.outerjoin( 

331 ResearchResource, 

332 (Document.resource_id == ResearchResource.id) 

333 | (ResearchResource.document_id == Document.id), 

334 ).distinct() 

335 doc_subq = self._apply_search_filter( 

336 doc_subq, Document, search_query 

337 ) 

338 

339 # Filter to only completed documents 

340 doc_subq = doc_subq.filter(Document.status == "completed") 

341 

342 # Sort at SQL level (SQLite-safe NULL handling) 

343 doc_subq = doc_subq.order_by( 

344 case((Document.processed_at.isnot(None), 0), else_=1), 

345 Document.processed_at.desc(), 

346 ) 

347 

348 # Apply SQL-level pagination 

349 doc_subq = doc_subq.offset(offset).limit(limit) 

350 doc_id_subq = doc_subq.subquery() 

351 

352 # Step 2: join the paginated document IDs with related tables. 

353 # Use two separate outer joins for ResearchResource to avoid 

354 # the OR-condition join that can fan out to multiple rows: 

355 # - ResourceByFK: matched via Document.resource_id (primary FK) 

356 # - ResourceByDoc: matched via ResearchResource.document_id 

357 # We prefer ResourceByFK; fall back to ResourceByDoc in Python. 

358 ResourceByFK = aliased(ResearchResource) 

359 ResourceByDoc = aliased(ResearchResource) 

360 

361 query = ( 

362 session.query( 

363 Document, 

364 ResourceByFK, 

365 ResourceByDoc, 

366 ResearchHistory, 

367 DocumentCollection, 

368 ) 

369 .join(doc_id_subq, Document.id == doc_id_subq.c.id) 

370 .join( 

371 DocumentCollection, 

372 Document.id == DocumentCollection.document_id, 

373 ) 

374 .outerjoin( 

375 ResourceByFK, 

376 Document.resource_id == ResourceByFK.id, 

377 ) 

378 .outerjoin( 

379 ResourceByDoc, 

380 ResourceByDoc.document_id == Document.id, 

381 ) 

382 .outerjoin( 

383 ResearchHistory, 

384 Document.research_id == ResearchHistory.id, 

385 ) 

386 .filter(DocumentCollection.collection_id == collection_id) 

387 # Re-apply sort so final results are ordered 

388 .order_by( 

389 case((Document.processed_at.isnot(None), 0), else_=1), 

390 Document.processed_at.desc(), 

391 ) 

392 ) 

393 

394 # Execute query 

395 results = query.all() 

396 logger.info( 

397 f"[LibraryService] Found {len(results)} documents in collection {collection_id}" 

398 ) 

399 

400 # Batch-check blob existence to avoid N+1 queries 

401 doc_ids = [row[0].id for row in results] 

402 blob_ids = set() 

403 if doc_ids: 

404 blob_ids = { 

405 r[0] 

406 for r in session.query(DocumentBlob.document_id) 

407 .filter(DocumentBlob.document_id.in_(doc_ids)) 

408 .all() 

409 } 

410 

411 # Process results — deduplicate by doc.id since the ResourceByDoc 

412 # outer join can fan out when multiple ResearchResource rows 

413 # point to the same document via document_id. 

414 # 

415 # Resolve one settings manager for the whole page (lazily, on the 

416 # first document that actually needs a path resolved) instead of 

417 # letting get_absolute_path_from_settings() fall back to the 

418 # ambient get_settings_manager() per iteration (up to `limit` 

419 # times). Each ambient call opens a DB session via 

420 # get_db_session(), which is now tracked in a per-thread owner 

421 # registry (utilities/db_utils.py) until this request's cleanup 

422 # boundary — so on a busy multi-user instance, enough process-wide 

423 # LRU evictions between iterations can pin one pooled connection 

424 # per evicted-and-recreated session for the rest of the request 

425 # (see changelog/#6095 follow-up). Creating it once here bounds 

426 # that to at most one session for this call, mirroring the 

427 # pattern already used in DownloadService (self.settings, 

428 # threaded through get_absolute_path_from_settings calls). 

429 seen_doc_ids = set() 

430 settings_manager = None 

431 try: 

432 for ( 

433 doc, 

434 res_by_fk, 

435 res_by_doc, 

436 research, 

437 doc_collection, 

438 ) in results: 

439 if doc.id in seen_doc_ids: 

440 continue 

441 seen_doc_ids.add(doc.id) 

442 # Prefer the resource matched via Document.resource_id FK; 

443 # fall back to the one matched via ResearchResource.document_id. 

444 resource = res_by_fk or res_by_doc 

445 # Determine availability flags - use Document.file_path directly 

446 file_absolute_path = None 

447 if ( 

448 doc.file_path 

449 and doc.file_path not in FILE_PATH_SENTINELS 

450 ): 

451 if settings_manager is None: 

452 settings_manager = get_settings_manager( 

453 username=self.username 

454 ) 

455 abs_path = get_absolute_path_from_settings( 

456 doc.file_path, 

457 self.username, 

458 settings_manager=settings_manager, 

459 ) 

460 if abs_path: 

461 file_absolute_path = str(abs_path) 

462 

463 # Check if PDF is available (filesystem OR database) 

464 has_pdf = bool(file_absolute_path) 

465 if not has_pdf and doc.storage_mode == "database": 

466 has_pdf = doc.id in blob_ids 

467 has_text_db = bool(doc.text_content) # Text now in Document 

468 

469 # Use DocumentCollection from query results 

470 has_rag_indexed = ( 

471 doc_collection.indexed if doc_collection else False 

472 ) 

473 rag_chunk_count = ( 

474 doc_collection.chunk_count if doc_collection else 0 

475 ) 

476 

477 all_documents.append( 

478 { 

479 "id": doc.id, 

480 "resource_id": doc.resource_id, 

481 "research_id": doc.research_id, 

482 # Document info 

483 "document_title": doc.title 

484 or (resource.title if resource else doc.filename), 

485 "authors": doc.authors, 

486 "published_date": doc.published_date, 

487 "doi": doc.doi, 

488 "arxiv_id": doc.arxiv_id, 

489 "pmid": doc.pmid, 

490 # File info 

491 "file_path": doc.file_path, 

492 "file_absolute_path": file_absolute_path, 

493 "file_name": Path(doc.file_path).name 

494 if doc.file_path 

495 and doc.file_path not in FILE_PATH_SENTINELS 

496 else doc.filename, 

497 "file_size": doc.file_size, 

498 "file_type": doc.file_type, 

499 # URLs 

500 "original_url": doc.original_url, 

501 "domain": self._extract_domain(doc.original_url) 

502 if doc.original_url 

503 else "User Upload", 

504 # Status 

505 "download_status": doc.status or "completed", 

506 "downloaded_at": doc.processed_at.isoformat() 

507 if doc.processed_at 

508 else ( 

509 doc.uploaded_at.isoformat() 

510 if hasattr(doc, "uploaded_at") 

511 and doc.uploaded_at 

512 else None 

513 ), 

514 "favorite": doc.favorite 

515 if hasattr(doc, "favorite") 

516 else False, 

517 "tags": doc.tags if hasattr(doc, "tags") else [], 

518 # Research info (None for user uploads) 

519 "research_title": research.title 

520 or research.query[:80] 

521 if research 

522 else "User Upload", 

523 "research_query": research.query 

524 if research 

525 else None, 

526 "research_mode": research.mode 

527 if research 

528 else None, 

529 "research_date": research.created_at 

530 if research 

531 else None, 

532 # Classification flags 

533 "is_arxiv": self._is_arxiv_url(doc.original_url) 

534 if doc.original_url 

535 else False, 

536 "is_pubmed": self._is_pubmed_url(doc.original_url) 

537 if doc.original_url 

538 else False, 

539 "is_pdf": doc.file_type == "pdf", 

540 # Availability flags 

541 "has_pdf": has_pdf, 

542 "has_text_db": has_text_db, 

543 "has_rag_indexed": has_rag_indexed, 

544 "rag_chunk_count": rag_chunk_count, 

545 } 

546 ) 

547 

548 # Sorting and pagination are now handled at SQL level 

549 return all_documents 

550 finally: 

551 safe_close( 

552 settings_manager, "settings manager", allow_none=True 

553 ) 

554 

555 def get_all_collections(self) -> List[Dict]: 

556 """Get all collections with document and indexed document counts.""" 

557 with get_user_db_session(self.username) as session: 

558 # Query collections with document counts and indexed counts 

559 results = ( 

560 session.query( 

561 Collection, 

562 func.count(DocumentCollection.document_id).label( 

563 "document_count" 

564 ), 

565 func.count( 

566 case( 

567 ( 

568 DocumentCollection.indexed == True, # noqa: E712 

569 DocumentCollection.document_id, 

570 ), 

571 else_=None, 

572 ) 

573 ).label("indexed_document_count"), 

574 ) 

575 .outerjoin( 

576 DocumentCollection, 

577 Collection.id == DocumentCollection.collection_id, 

578 ) 

579 .group_by(Collection.id) 

580 .order_by(Collection.is_default.desc(), Collection.name) 

581 .all() 

582 ) 

583 

584 logger.info(f"[LibraryService] Found {len(results)} collections") 

585 

586 collections = [] 

587 for collection, doc_count, indexed_count in results: 

588 logger.debug( 

589 f"[LibraryService] Collection: {collection.name} (ID: {collection.id}), documents: {doc_count}, indexed: {indexed_count}" 

590 ) 

591 collections.append( 

592 { 

593 "id": collection.id, 

594 "name": collection.name, 

595 "description": collection.description, 

596 "is_default": collection.is_default, 

597 "document_count": doc_count or 0, 

598 "indexed_document_count": indexed_count or 0, 

599 } 

600 ) 

601 

602 return collections 

603 

604 def get_research_list_for_dropdown(self) -> List[Dict]: 

605 """Get minimal research session info for filter dropdowns. 

606 

607 Returns only id, title, and query — no joins or aggregates — for at 

608 most the ``_DROPDOWN_RESEARCH_LIMIT`` most-recent sessions, so a very 

609 large history cannot load every row into memory / the DOM (#4560). 

610 """ 

611 with get_user_db_session(self.username) as session: 

612 results = ( 

613 session.query( 

614 ResearchHistory.id, 

615 ResearchHistory.title, 

616 ResearchHistory.query, 

617 ) 

618 .order_by(ResearchHistory.created_at.desc()) 

619 .limit(_DROPDOWN_RESEARCH_LIMIT) 

620 .all() 

621 ) 

622 return [ 

623 {"id": r.id, "title": r.title, "query": r.query} 

624 for r in results 

625 ] 

626 

627 def get_research_list_with_stats( 

628 self, 

629 limit: int = 0, 

630 offset: int = 0, 

631 ) -> List[Dict]: 

632 """Get research sessions with download statistics. 

633 

634 Args: 

635 limit: Maximum number of results (0 = no limit, for backwards compat). 

636 offset: Number of rows to skip. Only applied when limit > 0. 

637 """ 

638 with get_user_db_session(self.username) as session: 

639 # Query research sessions with resource counts 

640 query = ( 

641 session.query( 

642 ResearchHistory, 

643 func.count(func.distinct(ResearchResource.id)).label( 

644 "total_resources" 

645 ), 

646 func.count( 

647 func.distinct( 

648 case( 

649 (Document.status == "completed", Document.id), 

650 else_=None, 

651 ) 

652 ) 

653 ).label("downloaded_count"), 

654 func.count( 

655 func.distinct( 

656 case( 

657 ( 

658 ResearchResource.url.like("%.pdf") 

659 | ResearchResource.url.like("%arxiv.org%") 

660 | ResearchResource.url.like( 

661 "%ncbi.nlm.nih.gov/pmc%" 

662 ), 

663 ResearchResource.id, 

664 ), 

665 else_=None, 

666 ) 

667 ) 

668 ).label("downloadable_count"), 

669 ) 

670 .outerjoin( 

671 ResearchResource, 

672 ResearchHistory.id == ResearchResource.research_id, 

673 ) 

674 .outerjoin( 

675 Document, 

676 (ResearchResource.id == Document.resource_id) 

677 | (ResearchResource.document_id == Document.id), 

678 ) 

679 .group_by(ResearchHistory.id) 

680 .order_by(ResearchHistory.created_at.desc()) 

681 ) 

682 

683 # Apply SQL-level pagination when limit is set 

684 if limit > 0: 

685 query = query.offset(offset).limit(limit) 

686 

687 results = query.all() 

688 

689 # Preload all ratings to avoid N+1 queries 

690 research_ids = [r[0].id for r in results] 

691 all_ratings = ( 

692 session.query(ResearchRating) 

693 .filter(ResearchRating.research_id.in_(research_ids)) 

694 .all() 

695 if research_ids 

696 else [] 

697 ) 

698 ratings_by_research = {r.research_id: r for r in all_ratings} 

699 

700 # Batch domain queries to avoid N+1 (same pattern as ratings) 

701 domain_case = case( 

702 ( 

703 ResearchResource.url.like("%arxiv.org%"), 

704 "arxiv.org", 

705 ), 

706 (ResearchResource.url.like("%pubmed%"), "pubmed"), 

707 ( 

708 ResearchResource.url.like("%ncbi.nlm.nih.gov%"), 

709 "pubmed", 

710 ), 

711 else_="other", 

712 ) 

713 all_domains = ( 

714 session.query( 

715 ResearchResource.research_id, 

716 domain_case.label("domain"), 

717 func.count().label("count"), 

718 ) 

719 .filter(ResearchResource.research_id.in_(research_ids)) 

720 .group_by(ResearchResource.research_id, domain_case) 

721 .all() 

722 if research_ids 

723 else [] 

724 ) 

725 domains_by_research: Dict[str, list] = {} 

726 for rid, domain, count in all_domains: 

727 domains_by_research.setdefault(rid, []).append((domain, count)) 

728 

729 research_list = [] 

730 for ( 

731 research, 

732 total_resources, 

733 downloaded_count, 

734 downloadable_count, 

735 ) in results: 

736 # Get rating from preloaded dict 

737 rating = ratings_by_research.get(research.id) 

738 

739 # Get domain breakdown from preloaded dict 

740 domains = domains_by_research.get(research.id, []) 

741 

742 research_list.append( 

743 { 

744 "id": research.id, 

745 "title": research.title, 

746 "query": research.query, 

747 "mode": research.mode, 

748 "status": research.status, 

749 "created_at": research.created_at, 

750 "duration_seconds": research.duration_seconds, 

751 "total_resources": total_resources or 0, 

752 "downloaded_count": downloaded_count or 0, 

753 "downloadable_count": downloadable_count or 0, 

754 "rating": rating.rating if rating else None, 

755 "top_domains": [(d, c) for d, c in domains if d], 

756 } 

757 ) 

758 

759 return research_list 

760 

761 def get_download_manager_summary_stats(self) -> Dict: 

762 """Get aggregate download stats across ALL research sessions. 

763 

764 This is a lightweight query that only returns totals — used for 

765 the download-manager header so stats remain accurate regardless 

766 of which page the user is viewing. 

767 """ 

768 with get_user_db_session(self.username) as session: 

769 row = ( 

770 session.query( 

771 func.count(func.distinct(ResearchHistory.id)).label( 

772 "total_researches" 

773 ), 

774 func.count(func.distinct(ResearchResource.id)).label( 

775 "total_resources" 

776 ), 

777 func.count( 

778 func.distinct( 

779 case( 

780 (Document.status == "completed", Document.id), 

781 else_=None, 

782 ) 

783 ) 

784 ).label("downloaded_count"), 

785 func.count( 

786 func.distinct( 

787 case( 

788 ( 

789 ResearchResource.url.like("%.pdf") 

790 | ResearchResource.url.like("%arxiv.org%") 

791 | ResearchResource.url.like( 

792 "%ncbi.nlm.nih.gov/pmc%" 

793 ), 

794 ResearchResource.id, 

795 ), 

796 else_=None, 

797 ) 

798 ) 

799 ).label("downloadable_count"), 

800 ) 

801 .select_from(ResearchHistory) 

802 .outerjoin( 

803 ResearchResource, 

804 ResearchHistory.id == ResearchResource.research_id, 

805 ) 

806 .outerjoin( 

807 Document, 

808 (ResearchResource.id == Document.resource_id) 

809 | (ResearchResource.document_id == Document.id), 

810 ) 

811 .one() 

812 ) 

813 

814 total_researches = row.total_researches or 0 

815 total_resources = row.total_resources or 0 

816 downloaded = row.downloaded_count or 0 

817 downloadable = row.downloadable_count or 0 

818 

819 return { 

820 "total_researches": total_researches, 

821 "total_resources": total_resources, 

822 "already_downloaded": downloaded, 

823 "available_to_download": max(downloadable - downloaded, 0), 

824 } 

825 

826 def get_pdf_previews_batch( 

827 self, research_ids: List, limit_per_research: int = 10 

828 ) -> Dict[str, Dict]: 

829 """Batch-fetch PDF documents and domain breakdowns for multiple research sessions. 

830 

831 Returns a dict keyed by research_id with: 

832 - "pdf_sources": list of document dicts (capped at limit_per_research) 

833 - "domains": dict of domain -> {total, pdfs, downloaded} 

834 """ 

835 if not research_ids: 

836 return {} 

837 

838 with get_user_db_session(self.username) as session: 

839 results = ( 

840 session.query(Document, ResearchResource) 

841 .outerjoin( 

842 ResearchResource, 

843 (Document.resource_id == ResearchResource.id) 

844 | (ResearchResource.document_id == Document.id), 

845 ) 

846 .filter( 

847 Document.research_id.in_(research_ids), 

848 Document.file_type == "pdf", 

849 ) 

850 .order_by(Document.processed_at.desc()) 

851 .limit(limit_per_research * len(research_ids)) 

852 .all() 

853 ) 

854 

855 previews: Dict[str, Dict] = {} 

856 seen_doc_ids: set = set() 

857 for doc, resource in results: 

858 if doc.id in seen_doc_ids: 

859 continue 

860 seen_doc_ids.add(doc.id) 

861 

862 rid = doc.research_id 

863 if rid not in previews: 

864 previews[rid] = {"pdf_sources": [], "domains": {}} 

865 

866 entry = previews[rid] 

867 

868 # Domain breakdown (within the SQL LIMIT budget) 

869 domain = "unknown" 

870 if resource and resource.url: 

871 try: 

872 domain = urlparse(resource.url).netloc or "unknown" 

873 except Exception: 

874 logger.debug("Failed to parse resource URL for domain") 

875 elif doc.original_url: 875 ↛ 881line 875 didn't jump to line 881 because the condition on line 875 was always true

876 try: 

877 domain = urlparse(doc.original_url).netloc or "unknown" 

878 except Exception: 

879 logger.debug("Failed to parse document URL for domain") 

880 

881 if domain not in entry["domains"]: 

882 entry["domains"][domain] = { 

883 "total": 0, 

884 "pdfs": 0, 

885 "downloaded": 0, 

886 } 

887 entry["domains"][domain]["total"] += 1 

888 if doc.file_type == "pdf": 888 ↛ 890line 888 didn't jump to line 890 because the condition on line 888 was always true

889 entry["domains"][domain]["pdfs"] += 1 

890 if doc.status == "completed": 

891 entry["domains"][domain]["downloaded"] += 1 

892 

893 # PDF sources preview (capped) 

894 if len(entry["pdf_sources"]) < limit_per_research: 

895 title = "Untitled" 

896 if resource and resource.title: 

897 title = resource.title 

898 elif doc.filename: 898 ↛ 901line 898 didn't jump to line 901 because the condition on line 898 was always true

899 title = doc.filename 

900 

901 entry["pdf_sources"].append( 

902 { 

903 "document_title": title, 

904 "domain": domain, 

905 "file_type": doc.file_type, 

906 "download_status": doc.status or "unknown", 

907 } 

908 ) 

909 

910 return previews 

911 

912 def get_document_by_id(self, doc_id: str) -> Optional[Dict]: 

913 """ 

914 Get a specific document by its ID. 

915 

916 Returns document information with file path. 

917 """ 

918 with get_user_db_session(self.username) as session: 

919 # Find document - use outer joins to support both research downloads and user uploads 

920 result = ( 

921 session.query(Document, ResearchResource, ResearchHistory) 

922 .outerjoin( 

923 ResearchResource, 

924 (Document.resource_id == ResearchResource.id) 

925 | (ResearchResource.document_id == Document.id), 

926 ) 

927 .outerjoin( 

928 ResearchHistory, 

929 Document.research_id == ResearchHistory.id, 

930 ) 

931 .filter(Document.id == doc_id) 

932 .first() 

933 ) 

934 

935 if result: 

936 # Found document 

937 doc, resource, research = result 

938 

939 # Get RAG indexing status across all collections 

940 doc_collections = ( 

941 session.query(DocumentCollection, Collection) 

942 .join(Collection) 

943 .filter(DocumentCollection.document_id == doc_id) 

944 .all() 

945 ) 

946 

947 # Check if indexed in any collection 

948 has_rag_indexed = any( 

949 dc.indexed for dc, coll in doc_collections 

950 ) 

951 total_chunks = sum( 

952 dc.chunk_count for dc, coll in doc_collections if dc.indexed 

953 ) 

954 

955 # Build collections list 

956 collections_list = [ 

957 { 

958 "id": coll.id, 

959 "name": coll.name, 

960 "indexed": dc.indexed, 

961 "chunk_count": dc.chunk_count, 

962 } 

963 for dc, coll in doc_collections 

964 ] 

965 

966 # Calculate word count from text content 

967 word_count = ( 

968 len(doc.text_content.split()) if doc.text_content else 0 

969 ) 

970 

971 # Check if PDF is available (database OR filesystem) 

972 has_pdf = bool( 

973 doc.file_path and doc.file_path not in FILE_PATH_SENTINELS 

974 ) 

975 if not has_pdf and doc.storage_mode == "database": 

976 has_pdf = self._has_blob_in_db(session, doc.id) 

977 

978 return { 

979 "id": doc.id, 

980 "resource_id": doc.resource_id, 

981 "research_id": doc.research_id, 

982 "document_title": doc.title 

983 or (resource.title if resource else doc.filename), 

984 "original_url": doc.original_url 

985 or (resource.url if resource else None), 

986 "file_path": doc.file_path, 

987 "file_absolute_path": self._get_safe_absolute_path( 

988 doc.file_path 

989 ), 

990 "file_name": Path(doc.file_path).name 

991 if doc.file_path 

992 and doc.file_path not in FILE_PATH_SENTINELS 

993 else doc.filename, 

994 "file_size": doc.file_size, 

995 "file_type": doc.file_type, 

996 "mime_type": doc.mime_type, 

997 "domain": self._extract_domain(resource.url) 

998 if resource 

999 else "User Upload", 

1000 "download_status": doc.status, 

1001 "downloaded_at": doc.processed_at.isoformat() 

1002 if doc.processed_at 

1003 and hasattr(doc.processed_at, "isoformat") 

1004 else str(doc.processed_at) 

1005 if doc.processed_at 

1006 else ( 

1007 doc.uploaded_at.isoformat() 

1008 if hasattr(doc, "uploaded_at") and doc.uploaded_at 

1009 else None 

1010 ), 

1011 "favorite": doc.favorite 

1012 if hasattr(doc, "favorite") 

1013 else False, 

1014 "tags": doc.tags if hasattr(doc, "tags") else [], 

1015 "research_title": research.query[:100] 

1016 if research 

1017 else "User Upload", 

1018 "research_created_at": research.created_at 

1019 if research and isinstance(research.created_at, str) 

1020 else research.created_at.isoformat() 

1021 if research and research.created_at 

1022 else None, 

1023 # Document fields 

1024 "is_pdf": doc.file_type == "pdf", 

1025 "has_pdf": has_pdf, 

1026 "has_text_db": bool(doc.text_content), 

1027 "has_rag_indexed": has_rag_indexed, 

1028 "rag_chunk_count": total_chunks, 

1029 "word_count": word_count, 

1030 "collections": collections_list, 

1031 } 

1032 

1033 # Not found 

1034 return None 

1035 

1036 def toggle_favorite(self, document_id: str) -> bool: 

1037 """Toggle favorite status of a document.""" 

1038 with get_user_db_session(self.username) as session: 

1039 doc = session.get(Document, document_id) 

1040 if doc: 

1041 doc.favorite = not doc.favorite 

1042 session.commit() 

1043 return doc.favorite 

1044 return False 

1045 

1046 def delete_document(self, document_id: str) -> bool: 

1047 """Delete a document from library (file and database entry). 

1048 

1049 Refuses notes: they have their own deletion API 

1050 (DELETE /api/notes/<id>) that performs version/link/synthesis 

1051 cleanup. Hard-deleting a note here would bypass that and destroy 

1052 the note's history. Defense-in-depth — the routed handler is the 

1053 guarded DocumentDeletionService, but this is a public method. 

1054 """ 

1055 from ...database.models.library import SourceType 

1056 

1057 with get_user_db_session(self.username) as session: 

1058 doc = session.get(Document, document_id) 

1059 if not doc: 

1060 return False 

1061 

1062 note_source = ( 

1063 session.query(SourceType).filter_by(name="note").first() 

1064 ) 

1065 if note_source and doc.source_type_id == note_source.id: 

1066 logger.warning( 

1067 f"Refused to delete note {document_id[:8]}... via the " 

1068 "library document API; use DELETE /api/notes/<id>." 

1069 ) 

1070 return False 

1071 

1072 # Get file path from tracker (only if document has original_url) 

1073 tracker = None 

1074 if doc.original_url: 

1075 tracker = ( 

1076 session.query(DownloadTracker) 

1077 .filter_by(url_hash=self._get_url_hash(doc.original_url)) 

1078 .first() 

1079 ) 

1080 

1081 # Delete physical file. Own-root only: never resolve via the 

1082 # legacy shared root, where a colliding relative path can be 

1083 # another tenant's file (#5521), consistent with the delete 

1084 # sites in document_deletion.py / PDFStorageManager.delete_pdf. 

1085 if tracker and tracker.file_path: 

1086 try: 

1087 file_path = get_absolute_path_from_settings( 

1088 tracker.file_path, 

1089 self.username, 

1090 allow_legacy_fallback=False, 

1091 ) 

1092 if file_path and file_path.is_file(): 

1093 file_path.unlink() 

1094 logger.info(f"Deleted file: {file_path}") 

1095 except Exception: 

1096 logger.exception("Failed to delete file") 

1097 

1098 # Update tracker 

1099 if tracker: 

1100 tracker.is_downloaded = False 

1101 tracker.file_path = None 

1102 

1103 # Delete document and all related records 

1104 from ..deletion.utils.cascade_helper import CascadeHelper 

1105 

1106 CascadeHelper.delete_document_completely(session, document_id) 

1107 session.commit() 

1108 

1109 # Post-commit: purge the removed document's FAISS vectors + chunk rows 

1110 # (delete_document_completely leaves DocumentChunk rows, so without this 

1111 # the deleted content keeps surfacing in collection search). Best-effort; 

1112 # the DB delete already committed. Mirrors sync_library_with_filesystem. 

1113 try: 

1114 from ..deletion.services.document_deletion import ( 

1115 DocumentDeletionService, 

1116 ) 

1117 from ...database.models.library import DocumentChunk 

1118 

1119 with get_user_db_session(self.username) as purge_session: 

1120 coll_rows = ( 

1121 purge_session.query(DocumentChunk.collection_name) 

1122 .filter_by(source_type="document", source_id=document_id) 

1123 .distinct() 

1124 .all() 

1125 ) 

1126 collection_ids = [ 

1127 row[0].removeprefix("collection_") 

1128 for row in coll_rows 

1129 if row[0] 

1130 ] 

1131 DocumentDeletionService(self.username)._purge_document_rag( 

1132 document_id, collection_ids, full_delete=True 

1133 ) 

1134 except Exception: 

1135 logger.opt(exception=True).error( 

1136 "Failed to purge removed document {} (delete already committed)", 

1137 document_id, 

1138 ) 

1139 return True 

1140 

1141 def open_file_location(self, document_id: str) -> bool: 

1142 """Open the folder containing the document.""" 

1143 with get_user_db_session(self.username) as session: 

1144 doc = session.get(Document, document_id) 

1145 if not doc: 

1146 return False 

1147 

1148 tracker = None 

1149 if doc.original_url: 

1150 tracker = ( 

1151 session.query(DownloadTracker) 

1152 .filter_by(url_hash=self._get_url_hash(doc.original_url)) 

1153 .first() 

1154 ) 

1155 

1156 if tracker and tracker.file_path: 

1157 # Resolve within the per-user root (traversal + symlink safe), 

1158 # with legacy-shared fallback for pre-isolation files (#5521). 

1159 validated_path = get_absolute_path_from_settings( 

1160 tracker.file_path, self.username 

1161 ) 

1162 if validated_path and validated_path.is_file(): 

1163 return open_file_location(str(validated_path)) 

1164 logger.warning("Path validation failed or file missing") 

1165 return False 

1166 

1167 return False 

1168 

1169 def get_unique_domains(self) -> List[str]: 

1170 """Get sorted list of unique netlocs from all document URLs. 

1171 

1172 Streams the URL column in batches (``yield_per``) and accumulates 

1173 netlocs into a set, so a very large library is never fully 

1174 materialized in memory at once (#4560). The query already projects to 

1175 the single ``original_url`` column. 

1176 """ 

1177 with get_user_db_session(self.username) as session: 

1178 netlocs = set() 

1179 rows = ( 

1180 session.query(Document.original_url) 

1181 .filter(Document.original_url.isnot(None)) 

1182 .yield_per(_DOMAIN_SCAN_BATCH_SIZE) 

1183 ) 

1184 for (url,) in rows: 

1185 domain = self._extract_domain(url) 

1186 if domain: 

1187 netlocs.add(domain) 

1188 return sorted(netlocs) 

1189 

1190 def _extract_domain(self, url: str) -> str: 

1191 """Extract domain from URL.""" 

1192 from urllib.parse import urlparse 

1193 

1194 try: 

1195 return urlparse(url).netloc 

1196 except (ValueError, AttributeError): 

1197 return "" 

1198 

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

1200 """Generate hash for URL.""" 

1201 import re 

1202 

1203 # Normalize URL 

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

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

1206 url = url.rstrip("/") 

1207 

1208 return get_url_hash(url) 

1209 

1210 def _get_storage_path(self) -> str: 

1211 """Get library storage path from settings (respects LDR_DATA_DIR).""" 

1212 settings = get_settings_manager() 

1213 return str( 

1214 Path( 

1215 settings.get_setting( 

1216 "research_library.storage_path", 

1217 str(get_library_directory()), 

1218 ) 

1219 ) 

1220 .expanduser() 

1221 .resolve() 

1222 ) 

1223 

1224 def sync_library_with_filesystem(self) -> Dict: 

1225 """ 

1226 Sync library database with filesystem. 

1227 Check which PDF files exist and update database accordingly. 

1228 

1229 Returns: 

1230 Statistics about the sync operation 

1231 """ 

1232 with get_user_db_session(self.username) as session: 

1233 # Sync only research downloads — uploads have no original_url 

1234 # and no DownloadTracker, so they don't belong in this routine 

1235 # (and the destructive `else` branch below would otherwise 

1236 # silently delete every uploaded document). 

1237 # Don't load the large text_content body: this sync loop only 

1238 # reads original_url/id/title (and may delete the row), so loading 

1239 # every completed document's full text would needlessly exhaust 

1240 # memory on a large library (#4560). 

1241 documents = ( 

1242 session.query(Document) 

1243 .filter_by(status=DocumentStatus.COMPLETED) 

1244 .filter(Document.original_url.isnot(None)) 

1245 .options(defer(Document.text_content)) 

1246 .all() 

1247 ) 

1248 

1249 stats = { 

1250 "total_documents": len(documents), 

1251 "files_found": 0, 

1252 "files_missing": 0, 

1253 "trackers_updated": 0, 

1254 "missing_files": [], 

1255 } 

1256 # Documents deleted below — their FAISS vectors + chunk rows are 

1257 # purged AFTER this transaction commits (see the post-commit block). 

1258 removed_doc_ids: List[str] = [] 

1259 

1260 # Sync documents with filesystem. 

1261 # 

1262 # Resolve one settings manager for the whole sync (lazily, on the 

1263 # first document that actually needs a path resolved) instead of 

1264 # letting get_absolute_path_from_settings() fall back to the 

1265 # ambient get_settings_manager() per document (this is a sync 

1266 # `def`, so a large library means one iteration per completed 

1267 # document, unbounded). Each ambient call opens a DB session that 

1268 # is now tracked in a per-thread owner registry 

1269 # (utilities/db_utils.py) until this request's cleanup boundary, 

1270 # so enough process-wide LRU evictions between iterations can pin 

1271 # one pooled connection per evicted-and-recreated session for the 

1272 # rest of the request (see changelog/#6095 follow-up). Creating 

1273 # it once here bounds that to at most one session, mirroring 

1274 # DownloadService's self.settings pattern. 

1275 settings_manager = None 

1276 try: 

1277 for doc in documents: 

1278 # Get download tracker 

1279 tracker = ( 

1280 session.query(DownloadTracker) 

1281 .filter_by( 

1282 url_hash=self._get_url_hash(doc.original_url) 

1283 ) 

1284 .first() 

1285 ) 

1286 

1287 if tracker and tracker.file_path: 

1288 # Check if file exists (per-user, legacy-shared fallback) 

1289 if settings_manager is None: 

1290 settings_manager = get_settings_manager( 

1291 username=self.username 

1292 ) 

1293 file_path = get_absolute_path_from_settings( 

1294 tracker.file_path, 

1295 self.username, 

1296 settings_manager=settings_manager, 

1297 ) 

1298 if file_path and file_path.is_file(): 

1299 stats["files_found"] += 1 

1300 else: 

1301 # File missing or path invalid - mark for re-download 

1302 stats["files_missing"] += 1 

1303 stats["missing_files"].append( 

1304 { 

1305 "id": doc.id, 

1306 "title": doc.title, 

1307 "path": str(file_path) 

1308 if file_path 

1309 else "invalid", 

1310 "url": doc.original_url, 

1311 } 

1312 ) 

1313 

1314 # Reset tracker 

1315 tracker.is_downloaded = False 

1316 tracker.file_path = None 

1317 

1318 # Delete the document entry so it can be re-queued 

1319 from ..deletion.utils.cascade_helper import ( 

1320 CascadeHelper, 

1321 ) 

1322 

1323 CascadeHelper.delete_document_completely( 

1324 session, doc.id 

1325 ) 

1326 removed_doc_ids.append(doc.id) 

1327 stats["trackers_updated"] += 1 

1328 else: 

1329 # No tracker or path - delete the document entry 

1330 stats["files_missing"] += 1 

1331 from ..deletion.utils.cascade_helper import ( 

1332 CascadeHelper, 

1333 ) 

1334 

1335 CascadeHelper.delete_document_completely( 

1336 session, doc.id 

1337 ) 

1338 removed_doc_ids.append(doc.id) 

1339 finally: 

1340 safe_close( 

1341 settings_manager, "settings manager", allow_none=True 

1342 ) 

1343 

1344 session.commit() 

1345 logger.info( 

1346 f"Library sync completed: {stats['files_found']} found, {stats['files_missing']} missing" 

1347 ) 

1348 

1349 # Post-commit: purge the removed documents' FAISS vectors + chunk rows. 

1350 # delete_document_completely leaves DocumentChunk rows (no FK), so 

1351 # without this the removed doc's vectors and chunk_text linger and keep 

1352 # surfacing in collection search. Reuse DocumentDeletionService's 

1353 # post-commit purge (per-collection vector removal + guaranteed chunk-row 

1354 # backstop), run after the session above has closed. 

1355 if removed_doc_ids: 

1356 from ..deletion.services.document_deletion import ( 

1357 DocumentDeletionService, 

1358 ) 

1359 from ...database.models.library import DocumentChunk 

1360 

1361 deleter = DocumentDeletionService(self.username) 

1362 for doc_id in removed_doc_ids: 

1363 # Best-effort per document: the sync's DB deletes already 

1364 # committed, so a purge hiccup here must not raise out of the 

1365 # sync (which would report the whole operation as failed) nor 

1366 # abort the remaining documents' purges. 

1367 try: 

1368 with get_user_db_session(self.username) as purge_session: 

1369 coll_rows = ( 

1370 purge_session.query(DocumentChunk.collection_name) 

1371 .filter_by(source_type="document", source_id=doc_id) 

1372 .distinct() 

1373 .all() 

1374 ) 

1375 collection_ids = [ 

1376 row[0].removeprefix("collection_") 

1377 for row in coll_rows 

1378 if row[0] 

1379 ] 

1380 deleter._purge_document_rag( 

1381 doc_id, collection_ids, full_delete=True 

1382 ) 

1383 except Exception: 

1384 logger.opt(exception=True).error( 

1385 "Failed to purge removed document {} after library sync " 

1386 "(delete already committed)", 

1387 doc_id, 

1388 ) 

1389 

1390 return stats 

1391 

1392 def mark_for_redownload(self, document_ids: List[str]) -> int: 

1393 """ 

1394 Mark specific documents for re-download. 

1395 

1396 Args: 

1397 document_ids: List of document IDs to mark for re-download 

1398 

1399 Returns: 

1400 Number of documents marked 

1401 """ 

1402 with get_user_db_session(self.username) as session: 

1403 count = 0 

1404 for doc_id in document_ids: 

1405 doc = session.get(Document, doc_id) 

1406 if doc: 

1407 if not doc.original_url: 

1408 # Uploads have no source URL and no DownloadTracker, 

1409 # so re-download is not meaningful for them. 

1410 logger.warning( 

1411 f"Skipping mark-for-redownload on {doc_id[:8]}…: " 

1412 "document has no original_url (likely user upload)" 

1413 ) 

1414 continue 

1415 # Get tracker and reset it 

1416 tracker = ( 

1417 session.query(DownloadTracker) 

1418 .filter_by( 

1419 url_hash=self._get_url_hash(doc.original_url) 

1420 ) 

1421 .first() 

1422 ) 

1423 

1424 if tracker: 1424 ↛ 1429line 1424 didn't jump to line 1429 because the condition on line 1424 was always true

1425 tracker.is_downloaded = False 

1426 tracker.file_path = None 

1427 

1428 # Mark document as pending 

1429 doc.status = DocumentStatus.PENDING 

1430 count += 1 

1431 

1432 session.commit() 

1433 logger.info(f"Marked {count} documents for re-download") 

1434 return count