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

389 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +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 ...security import PathValidator 

32from ...config.paths import get_library_directory 

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

34from ..utils import ( 

35 get_absolute_path_from_settings, 

36 get_url_hash, 

37 open_file_location, 

38) 

39 

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

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

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

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

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

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

46_DROPDOWN_RESEARCH_LIMIT = 1000 

47 

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

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

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

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

52_DOMAIN_SCAN_BATCH_SIZE = 1000 

53 

54 

55class LibraryService: 

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

57 

58 def __init__(self, username: str): 

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

60 self.username = username 

61 

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

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

64 return ( 

65 session.query(DocumentBlob.document_id) 

66 .filter_by(document_id=document_id) 

67 .first() 

68 is not None 

69 ) 

70 

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

72 """ 

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

74 

75 Args: 

76 file_path: Relative file path from library root 

77 

78 Returns: 

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

80 """ 

81 if not file_path or file_path in FILE_PATH_SENTINELS: 

82 return None 

83 abs_path = get_absolute_path_from_settings(file_path) 

84 return str(abs_path) if abs_path else None 

85 

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

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

88 try: 

89 hostname = urlparse(url).hostname 

90 return bool( 

91 hostname 

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

93 ) 

94 except Exception: 

95 return False 

96 

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

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

99 try: 

100 parsed = urlparse(url) 

101 hostname = parsed.hostname 

102 if not hostname: 

103 return False 

104 

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

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

107 return True 

108 

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

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

111 return True 

112 

113 # Check for pubmed in subdomain 

114 if "pubmed" in hostname: 

115 return True 

116 

117 return False 

118 except Exception: 

119 return False 

120 

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

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

123 from datetime import datetime, timedelta, timezone 

124 

125 now = datetime.now(timezone.utc) 

126 if date_filter == "today": 

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

128 elif date_filter == "week": 

129 cutoff = now - timedelta(days=7) 

130 elif date_filter == "month": 

131 cutoff = now - timedelta(days=30) 

132 else: 

133 return query 

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

135 

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

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

138 

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

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

141 """ 

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

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

144 

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

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

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

148 return query.filter( 

149 or_( 

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

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

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

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

154 ) 

155 ) 

156 

157 def get_library_stats(self) -> Dict: 

158 """Get overall library statistics.""" 

159 with get_user_db_session(self.username) as session: 

160 # Get document counts 

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

162 total_pdfs = ( 

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

164 ) 

165 

166 # Get size stats 

167 size_result = session.query( 

168 func.sum(Document.file_size), 

169 func.avg(Document.file_size), 

170 ).first() 

171 

172 total_size = size_result[0] or 0 

173 avg_size = size_result[1] or 0 

174 

175 # Get research stats 

176 research_count = session.query( 

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

178 ).scalar() 

179 

180 # Get domain stats - count unique domains from URLs 

181 # Extract domain from original_url using SQL functions 

182 from sqlalchemy import case, func as sql_func 

183 

184 # Count unique domains by extracting them from URLs 

185 domain_subquery = session.query( 

186 sql_func.distinct( 

187 case( 

188 ( 

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

190 "arxiv.org", 

191 ), 

192 ( 

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

194 "pubmed", 

195 ), 

196 ( 

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

198 "pubmed", 

199 ), 

200 else_="other", 

201 ) 

202 ) 

203 ).subquery() 

204 

205 domain_count = ( 

206 session.query(sql_func.count()) 

207 .select_from(domain_subquery) 

208 .scalar() 

209 ) 

210 

211 # Get download tracker stats 

212 pending_downloads = ( 

213 session.query(DownloadTracker) 

214 .filter_by(is_downloaded=False) 

215 .count() 

216 ) 

217 

218 return { 

219 "total_documents": total_docs, 

220 "total_pdfs": total_pdfs, 

221 "total_size_bytes": total_size, 

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

223 if total_size 

224 else 0, 

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

226 "research_sessions": research_count, 

227 "unique_domains": domain_count, 

228 "pending_downloads": pending_downloads, 

229 "storage_path": self._get_storage_path(), 

230 } 

231 

232 def count_documents( 

233 self, 

234 research_id: Optional[str] = None, 

235 domain: Optional[str] = None, 

236 collection_id: Optional[str] = None, 

237 date_filter: Optional[str] = None, 

238 ) -> int: 

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

240 with get_user_db_session(self.username) as session: 

241 from ...database.library_init import get_default_library_id 

242 

243 if not collection_id: 

244 collection_id = get_default_library_id(self.username) 

245 

246 q = ( 

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

248 .join( 

249 DocumentCollection, 

250 Document.id == DocumentCollection.document_id, 

251 ) 

252 .filter(DocumentCollection.collection_id == collection_id) 

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

254 ) 

255 

256 if research_id: 

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

258 if domain: 

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

260 if date_filter: 

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

262 

263 return q.scalar() or 0 

264 

265 def get_documents( 

266 self, 

267 research_id: Optional[str] = None, 

268 domain: Optional[str] = None, 

269 file_type: Optional[str] = None, 

270 favorites_only: bool = False, 

271 search_query: Optional[str] = None, 

272 collection_id: Optional[str] = None, 

273 date_filter: Optional[str] = None, 

274 limit: int = 100, 

275 offset: int = 0, 

276 ) -> List[Dict]: 

277 """ 

278 Get documents with filtering options. 

279 

280 Returns enriched document information with research details. 

281 """ 

282 with get_user_db_session(self.username) as session: 

283 # Get default Library collection ID if not specified 

284 from ...database.library_init import get_default_library_id 

285 

286 if not collection_id: 

287 collection_id = get_default_library_id(self.username) 

288 

289 logger.info( 

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

291 ) 

292 

293 all_documents = [] 

294 

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

296 # Pagination and sorting happen here on Document alone, 

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

298 doc_subq = ( 

299 session.query(Document.id) 

300 .join( 

301 DocumentCollection, 

302 Document.id == DocumentCollection.document_id, 

303 ) 

304 .filter(DocumentCollection.collection_id == collection_id) 

305 ) 

306 

307 # Apply filters 

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

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

310 

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

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

313 

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

315 doc_subq = self._apply_date_filter( 

316 doc_subq, Document, date_filter 

317 ) 

318 

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

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

321 

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

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

324 

325 if search_query: 

326 # _apply_search_filter references ResearchResource.title, 

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

328 # from the outerjoin duplicating Document IDs. 

329 doc_subq = doc_subq.outerjoin( 

330 ResearchResource, 

331 (Document.resource_id == ResearchResource.id) 

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

333 ).distinct() 

334 doc_subq = self._apply_search_filter( 

335 doc_subq, Document, search_query 

336 ) 

337 

338 # Filter to only completed documents 

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

340 

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

342 doc_subq = doc_subq.order_by( 

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

344 Document.processed_at.desc(), 

345 ) 

346 

347 # Apply SQL-level pagination 

348 doc_subq = doc_subq.offset(offset).limit(limit) 

349 doc_id_subq = doc_subq.subquery() 

350 

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

352 # Use two separate outer joins for ResearchResource to avoid 

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

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

355 # - ResourceByDoc: matched via ResearchResource.document_id 

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

357 ResourceByFK = aliased(ResearchResource) 

358 ResourceByDoc = aliased(ResearchResource) 

359 

360 query = ( 

361 session.query( 

362 Document, 

363 ResourceByFK, 

364 ResourceByDoc, 

365 ResearchHistory, 

366 DocumentCollection, 

367 ) 

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

369 .join( 

370 DocumentCollection, 

371 Document.id == DocumentCollection.document_id, 

372 ) 

373 .outerjoin( 

374 ResourceByFK, 

375 Document.resource_id == ResourceByFK.id, 

376 ) 

377 .outerjoin( 

378 ResourceByDoc, 

379 ResourceByDoc.document_id == Document.id, 

380 ) 

381 .outerjoin( 

382 ResearchHistory, 

383 Document.research_id == ResearchHistory.id, 

384 ) 

385 .filter(DocumentCollection.collection_id == collection_id) 

386 # Re-apply sort so final results are ordered 

387 .order_by( 

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

389 Document.processed_at.desc(), 

390 ) 

391 ) 

392 

393 # Execute query 

394 results = query.all() 

395 logger.info( 

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

397 ) 

398 

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

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

401 blob_ids = set() 

402 if doc_ids: 

403 blob_ids = { 

404 r[0] 

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

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

407 .all() 

408 } 

409 

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

411 # outer join can fan out when multiple ResearchResource rows 

412 # point to the same document via document_id. 

413 seen_doc_ids = set() 

414 for doc, res_by_fk, res_by_doc, research, doc_collection in results: 

415 if doc.id in seen_doc_ids: 

416 continue 

417 seen_doc_ids.add(doc.id) 

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

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

420 resource = res_by_fk or res_by_doc 

421 # Determine availability flags - use Document.file_path directly 

422 file_absolute_path = None 

423 if doc.file_path and doc.file_path not in FILE_PATH_SENTINELS: 

424 abs_path = get_absolute_path_from_settings(doc.file_path) 

425 if abs_path: 425 ↛ 426line 425 didn't jump to line 426 because the condition on line 425 was never true

426 file_absolute_path = str(abs_path) 

427 

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

429 has_pdf = bool(file_absolute_path) 

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

431 has_pdf = doc.id in blob_ids 

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

433 

434 # Use DocumentCollection from query results 

435 has_rag_indexed = ( 

436 doc_collection.indexed if doc_collection else False 

437 ) 

438 rag_chunk_count = ( 

439 doc_collection.chunk_count if doc_collection else 0 

440 ) 

441 

442 all_documents.append( 

443 { 

444 "id": doc.id, 

445 "resource_id": doc.resource_id, 

446 "research_id": doc.research_id, 

447 # Document info 

448 "document_title": doc.title 

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

450 "authors": doc.authors, 

451 "published_date": doc.published_date, 

452 "doi": doc.doi, 

453 "arxiv_id": doc.arxiv_id, 

454 "pmid": doc.pmid, 

455 # File info 

456 "file_path": doc.file_path, 

457 "file_absolute_path": file_absolute_path, 

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

459 if doc.file_path 

460 and doc.file_path not in FILE_PATH_SENTINELS 

461 else doc.filename, 

462 "file_size": doc.file_size, 

463 "file_type": doc.file_type, 

464 # URLs 

465 "original_url": doc.original_url, 

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

467 if doc.original_url 

468 else "User Upload", 

469 # Status 

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

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

472 if doc.processed_at 

473 else ( 

474 doc.uploaded_at.isoformat() 

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

476 else None 

477 ), 

478 "favorite": doc.favorite 

479 if hasattr(doc, "favorite") 

480 else False, 

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

482 # Research info (None for user uploads) 

483 "research_title": research.title or research.query[:80] 

484 if research 

485 else "User Upload", 

486 "research_query": research.query if research else None, 

487 "research_mode": research.mode if research else None, 

488 "research_date": research.created_at 

489 if research 

490 else None, 

491 # Classification flags 

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

493 if doc.original_url 

494 else False, 

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

496 if doc.original_url 

497 else False, 

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

499 # Availability flags 

500 "has_pdf": has_pdf, 

501 "has_text_db": has_text_db, 

502 "has_rag_indexed": has_rag_indexed, 

503 "rag_chunk_count": rag_chunk_count, 

504 } 

505 ) 

506 

507 # Sorting and pagination are now handled at SQL level 

508 return all_documents 

509 

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

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

512 with get_user_db_session(self.username) as session: 

513 # Query collections with document counts and indexed counts 

514 results = ( 

515 session.query( 

516 Collection, 

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

518 "document_count" 

519 ), 

520 func.count( 

521 case( 

522 ( 

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

524 DocumentCollection.document_id, 

525 ), 

526 else_=None, 

527 ) 

528 ).label("indexed_document_count"), 

529 ) 

530 .outerjoin( 

531 DocumentCollection, 

532 Collection.id == DocumentCollection.collection_id, 

533 ) 

534 .group_by(Collection.id) 

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

536 .all() 

537 ) 

538 

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

540 

541 collections = [] 

542 for collection, doc_count, indexed_count in results: 

543 logger.debug( 

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

545 ) 

546 collections.append( 

547 { 

548 "id": collection.id, 

549 "name": collection.name, 

550 "description": collection.description, 

551 "is_default": collection.is_default, 

552 "document_count": doc_count or 0, 

553 "indexed_document_count": indexed_count or 0, 

554 } 

555 ) 

556 

557 return collections 

558 

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

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

561 

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

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

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

565 """ 

566 with get_user_db_session(self.username) as session: 

567 results = ( 

568 session.query( 

569 ResearchHistory.id, 

570 ResearchHistory.title, 

571 ResearchHistory.query, 

572 ) 

573 .order_by(ResearchHistory.created_at.desc()) 

574 .limit(_DROPDOWN_RESEARCH_LIMIT) 

575 .all() 

576 ) 

577 return [ 

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

579 for r in results 

580 ] 

581 

582 def get_research_list_with_stats( 

583 self, 

584 limit: int = 0, 

585 offset: int = 0, 

586 ) -> List[Dict]: 

587 """Get research sessions with download statistics. 

588 

589 Args: 

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

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

592 """ 

593 with get_user_db_session(self.username) as session: 

594 # Query research sessions with resource counts 

595 query = ( 

596 session.query( 

597 ResearchHistory, 

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

599 "total_resources" 

600 ), 

601 func.count( 

602 func.distinct( 

603 case( 

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

605 else_=None, 

606 ) 

607 ) 

608 ).label("downloaded_count"), 

609 func.count( 

610 func.distinct( 

611 case( 

612 ( 

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

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

615 | ResearchResource.url.like( 

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

617 ), 

618 ResearchResource.id, 

619 ), 

620 else_=None, 

621 ) 

622 ) 

623 ).label("downloadable_count"), 

624 ) 

625 .outerjoin( 

626 ResearchResource, 

627 ResearchHistory.id == ResearchResource.research_id, 

628 ) 

629 .outerjoin( 

630 Document, 

631 (ResearchResource.id == Document.resource_id) 

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

633 ) 

634 .group_by(ResearchHistory.id) 

635 .order_by(ResearchHistory.created_at.desc()) 

636 ) 

637 

638 # Apply SQL-level pagination when limit is set 

639 if limit > 0: 

640 query = query.offset(offset).limit(limit) 

641 

642 results = query.all() 

643 

644 # Preload all ratings to avoid N+1 queries 

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

646 all_ratings = ( 

647 session.query(ResearchRating) 

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

649 .all() 

650 if research_ids 

651 else [] 

652 ) 

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

654 

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

656 domain_case = case( 

657 ( 

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

659 "arxiv.org", 

660 ), 

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

662 ( 

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

664 "pubmed", 

665 ), 

666 else_="other", 

667 ) 

668 all_domains = ( 

669 session.query( 

670 ResearchResource.research_id, 

671 domain_case.label("domain"), 

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

673 ) 

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

675 .group_by(ResearchResource.research_id, domain_case) 

676 .all() 

677 if research_ids 

678 else [] 

679 ) 

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

681 for rid, domain, count in all_domains: 

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

683 

684 research_list = [] 

685 for ( 

686 research, 

687 total_resources, 

688 downloaded_count, 

689 downloadable_count, 

690 ) in results: 

691 # Get rating from preloaded dict 

692 rating = ratings_by_research.get(research.id) 

693 

694 # Get domain breakdown from preloaded dict 

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

696 

697 research_list.append( 

698 { 

699 "id": research.id, 

700 "title": research.title, 

701 "query": research.query, 

702 "mode": research.mode, 

703 "status": research.status, 

704 "created_at": research.created_at, 

705 "duration_seconds": research.duration_seconds, 

706 "total_resources": total_resources or 0, 

707 "downloaded_count": downloaded_count or 0, 

708 "downloadable_count": downloadable_count or 0, 

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

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

711 } 

712 ) 

713 

714 return research_list 

715 

716 def get_download_manager_summary_stats(self) -> Dict: 

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

718 

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

720 the download-manager header so stats remain accurate regardless 

721 of which page the user is viewing. 

722 """ 

723 with get_user_db_session(self.username) as session: 

724 row = ( 

725 session.query( 

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

727 "total_researches" 

728 ), 

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

730 "total_resources" 

731 ), 

732 func.count( 

733 func.distinct( 

734 case( 

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

736 else_=None, 

737 ) 

738 ) 

739 ).label("downloaded_count"), 

740 func.count( 

741 func.distinct( 

742 case( 

743 ( 

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

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

746 | ResearchResource.url.like( 

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

748 ), 

749 ResearchResource.id, 

750 ), 

751 else_=None, 

752 ) 

753 ) 

754 ).label("downloadable_count"), 

755 ) 

756 .select_from(ResearchHistory) 

757 .outerjoin( 

758 ResearchResource, 

759 ResearchHistory.id == ResearchResource.research_id, 

760 ) 

761 .outerjoin( 

762 Document, 

763 (ResearchResource.id == Document.resource_id) 

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

765 ) 

766 .one() 

767 ) 

768 

769 total_researches = row.total_researches or 0 

770 total_resources = row.total_resources or 0 

771 downloaded = row.downloaded_count or 0 

772 downloadable = row.downloadable_count or 0 

773 

774 return { 

775 "total_researches": total_researches, 

776 "total_resources": total_resources, 

777 "already_downloaded": downloaded, 

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

779 } 

780 

781 def get_pdf_previews_batch( 

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

783 ) -> Dict[str, Dict]: 

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

785 

786 Returns a dict keyed by research_id with: 

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

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

789 """ 

790 if not research_ids: 

791 return {} 

792 

793 with get_user_db_session(self.username) as session: 

794 results = ( 

795 session.query(Document, ResearchResource) 

796 .outerjoin( 

797 ResearchResource, 

798 (Document.resource_id == ResearchResource.id) 

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

800 ) 

801 .filter( 

802 Document.research_id.in_(research_ids), 

803 Document.file_type == "pdf", 

804 ) 

805 .order_by(Document.processed_at.desc()) 

806 .limit(limit_per_research * len(research_ids)) 

807 .all() 

808 ) 

809 

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

811 seen_doc_ids: set = set() 

812 for doc, resource in results: 

813 if doc.id in seen_doc_ids: 

814 continue 

815 seen_doc_ids.add(doc.id) 

816 

817 rid = doc.research_id 

818 if rid not in previews: 

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

820 

821 entry = previews[rid] 

822 

823 # Domain breakdown (within the SQL LIMIT budget) 

824 domain = "unknown" 

825 if resource and resource.url: 

826 try: 

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

828 except Exception: 

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

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

831 try: 

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

833 except Exception: 

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

835 

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

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

838 "total": 0, 

839 "pdfs": 0, 

840 "downloaded": 0, 

841 } 

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

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

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

845 if doc.status == "completed": 

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

847 

848 # PDF sources preview (capped) 

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

850 title = "Untitled" 

851 if resource and resource.title: 

852 title = resource.title 

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

854 title = doc.filename 

855 

856 entry["pdf_sources"].append( 

857 { 

858 "document_title": title, 

859 "domain": domain, 

860 "file_type": doc.file_type, 

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

862 } 

863 ) 

864 

865 return previews 

866 

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

868 """ 

869 Get a specific document by its ID. 

870 

871 Returns document information with file path. 

872 """ 

873 with get_user_db_session(self.username) as session: 

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

875 result = ( 

876 session.query(Document, ResearchResource, ResearchHistory) 

877 .outerjoin( 

878 ResearchResource, 

879 (Document.resource_id == ResearchResource.id) 

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

881 ) 

882 .outerjoin( 

883 ResearchHistory, 

884 Document.research_id == ResearchHistory.id, 

885 ) 

886 .filter(Document.id == doc_id) 

887 .first() 

888 ) 

889 

890 if result: 

891 # Found document 

892 doc, resource, research = result 

893 

894 # Get RAG indexing status across all collections 

895 doc_collections = ( 

896 session.query(DocumentCollection, Collection) 

897 .join(Collection) 

898 .filter(DocumentCollection.document_id == doc_id) 

899 .all() 

900 ) 

901 

902 # Check if indexed in any collection 

903 has_rag_indexed = any( 

904 dc.indexed for dc, coll in doc_collections 

905 ) 

906 total_chunks = sum( 

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

908 ) 

909 

910 # Build collections list 

911 collections_list = [ 

912 { 

913 "id": coll.id, 

914 "name": coll.name, 

915 "indexed": dc.indexed, 

916 "chunk_count": dc.chunk_count, 

917 } 

918 for dc, coll in doc_collections 

919 ] 

920 

921 # Calculate word count from text content 

922 word_count = ( 

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

924 ) 

925 

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

927 has_pdf = bool( 

928 doc.file_path and doc.file_path not in FILE_PATH_SENTINELS 

929 ) 

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

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

932 

933 return { 

934 "id": doc.id, 

935 "resource_id": doc.resource_id, 

936 "research_id": doc.research_id, 

937 "document_title": doc.title 

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

939 "original_url": doc.original_url 

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

941 "file_path": doc.file_path, 

942 "file_absolute_path": self._get_safe_absolute_path( 

943 doc.file_path 

944 ), 

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

946 if doc.file_path 

947 and doc.file_path not in FILE_PATH_SENTINELS 

948 else doc.filename, 

949 "file_size": doc.file_size, 

950 "file_type": doc.file_type, 

951 "mime_type": doc.mime_type, 

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

953 if resource 

954 else "User Upload", 

955 "download_status": doc.status, 

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

957 if doc.processed_at 

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

959 else str(doc.processed_at) 

960 if doc.processed_at 

961 else ( 

962 doc.uploaded_at.isoformat() 

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

964 else None 

965 ), 

966 "favorite": doc.favorite 

967 if hasattr(doc, "favorite") 

968 else False, 

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

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

971 if research 

972 else "User Upload", 

973 "research_created_at": research.created_at 

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

975 else research.created_at.isoformat() 

976 if research and research.created_at 

977 else None, 

978 # Document fields 

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

980 "has_pdf": has_pdf, 

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

982 "has_rag_indexed": has_rag_indexed, 

983 "rag_chunk_count": total_chunks, 

984 "word_count": word_count, 

985 "collections": collections_list, 

986 } 

987 

988 # Not found 

989 return None 

990 

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

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

993 with get_user_db_session(self.username) as session: 

994 doc = session.get(Document, document_id) 

995 if doc: 

996 doc.favorite = not doc.favorite 

997 session.commit() 

998 return doc.favorite 

999 return False 

1000 

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

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

1003 

1004 Refuses notes: they have their own deletion API 

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

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

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

1008 guarded DocumentDeletionService, but this is a public method. 

1009 """ 

1010 from ...database.models.library import SourceType 

1011 

1012 with get_user_db_session(self.username) as session: 

1013 doc = session.get(Document, document_id) 

1014 if not doc: 

1015 return False 

1016 

1017 note_source = ( 

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

1019 ) 

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

1021 logger.warning( 

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

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

1024 ) 

1025 return False 

1026 

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

1028 tracker = None 

1029 if doc.original_url: 

1030 tracker = ( 

1031 session.query(DownloadTracker) 

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

1033 .first() 

1034 ) 

1035 

1036 # Delete physical file 

1037 if tracker and tracker.file_path: 

1038 try: 

1039 file_path = get_absolute_path_from_settings( 

1040 tracker.file_path 

1041 ) 

1042 if file_path and file_path.is_file(): 

1043 file_path.unlink() 

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

1045 except Exception: 

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

1047 

1048 # Update tracker 

1049 if tracker: 

1050 tracker.is_downloaded = False 

1051 tracker.file_path = None 

1052 

1053 # Delete document and all related records 

1054 from ..deletion.utils.cascade_helper import CascadeHelper 

1055 

1056 CascadeHelper.delete_document_completely(session, document_id) 

1057 session.commit() 

1058 

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

1060 # (delete_document_completely leaves DocumentChunk rows, so without this 

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

1062 # the DB delete already committed. Mirrors sync_library_with_filesystem. 

1063 try: 

1064 from ..deletion.services.document_deletion import ( 

1065 DocumentDeletionService, 

1066 ) 

1067 from ...database.models.library import DocumentChunk 

1068 

1069 with get_user_db_session(self.username) as purge_session: 

1070 coll_rows = ( 

1071 purge_session.query(DocumentChunk.collection_name) 

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

1073 .distinct() 

1074 .all() 

1075 ) 

1076 collection_ids = [ 

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

1078 for row in coll_rows 

1079 if row[0] 

1080 ] 

1081 DocumentDeletionService(self.username)._purge_document_rag( 

1082 document_id, collection_ids, full_delete=True 

1083 ) 

1084 except Exception: 

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

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

1087 document_id, 

1088 ) 

1089 return True 

1090 

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

1092 """Open the folder containing the document.""" 

1093 with get_user_db_session(self.username) as session: 

1094 doc = session.get(Document, document_id) 

1095 if not doc: 

1096 return False 

1097 

1098 tracker = None 

1099 if doc.original_url: 

1100 tracker = ( 

1101 session.query(DownloadTracker) 

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

1103 .first() 

1104 ) 

1105 

1106 if tracker and tracker.file_path: 

1107 # Validate path is within library root to prevent traversal attacks 

1108 library_root = get_absolute_path_from_settings("") 

1109 if not library_root: 

1110 logger.warning("Could not determine library root") 

1111 return False 

1112 try: 

1113 validated_path = PathValidator.validate_safe_path( 

1114 tracker.file_path, library_root, allow_absolute=False 

1115 ) 

1116 if validated_path and validated_path.is_file(): 1116 ↛ 1122line 1116 didn't jump to line 1122 because the condition on line 1116 was always true

1117 return open_file_location(str(validated_path)) 

1118 except ValueError: 

1119 logger.warning("Path validation failed") 

1120 return False 

1121 

1122 return False 

1123 

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

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

1126 

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

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

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

1130 the single ``original_url`` column. 

1131 """ 

1132 with get_user_db_session(self.username) as session: 

1133 netlocs = set() 

1134 rows = ( 

1135 session.query(Document.original_url) 

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

1137 .yield_per(_DOMAIN_SCAN_BATCH_SIZE) 

1138 ) 

1139 for (url,) in rows: 

1140 domain = self._extract_domain(url) 

1141 if domain: 

1142 netlocs.add(domain) 

1143 return sorted(netlocs) 

1144 

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

1146 """Extract domain from URL.""" 

1147 from urllib.parse import urlparse 

1148 

1149 try: 

1150 return urlparse(url).netloc 

1151 except (ValueError, AttributeError): 

1152 return "" 

1153 

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

1155 """Generate hash for URL.""" 

1156 import re 

1157 

1158 # Normalize URL 

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

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

1161 url = url.rstrip("/") 

1162 

1163 return get_url_hash(url) 

1164 

1165 def _get_storage_path(self) -> str: 

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

1167 from ...utilities.db_utils import get_settings_manager 

1168 

1169 settings = get_settings_manager() 

1170 return str( 

1171 Path( 

1172 settings.get_setting( 

1173 "research_library.storage_path", 

1174 str(get_library_directory()), 

1175 ) 

1176 ) 

1177 .expanduser() 

1178 .resolve() 

1179 ) 

1180 

1181 def sync_library_with_filesystem(self) -> Dict: 

1182 """ 

1183 Sync library database with filesystem. 

1184 Check which PDF files exist and update database accordingly. 

1185 

1186 Returns: 

1187 Statistics about the sync operation 

1188 """ 

1189 with get_user_db_session(self.username) as session: 

1190 # Sync only research downloads — uploads have no original_url 

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

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

1193 # silently delete every uploaded document). 

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

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

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

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

1198 documents = ( 

1199 session.query(Document) 

1200 .filter_by(status=DocumentStatus.COMPLETED) 

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

1202 .options(defer(Document.text_content)) 

1203 .all() 

1204 ) 

1205 

1206 stats = { 

1207 "total_documents": len(documents), 

1208 "files_found": 0, 

1209 "files_missing": 0, 

1210 "trackers_updated": 0, 

1211 "missing_files": [], 

1212 } 

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

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

1215 removed_doc_ids: List[str] = [] 

1216 

1217 # Sync documents with filesystem 

1218 for doc in documents: 

1219 # Get download tracker 

1220 tracker = ( 

1221 session.query(DownloadTracker) 

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

1223 .first() 

1224 ) 

1225 

1226 if tracker and tracker.file_path: 

1227 # Check if file exists 

1228 file_path = get_absolute_path_from_settings( 

1229 tracker.file_path 

1230 ) 

1231 if file_path and file_path.is_file(): 

1232 stats["files_found"] += 1 

1233 else: 

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

1235 stats["files_missing"] += 1 

1236 stats["missing_files"].append( 

1237 { 

1238 "id": doc.id, 

1239 "title": doc.title, 

1240 "path": str(file_path) 

1241 if file_path 

1242 else "invalid", 

1243 "url": doc.original_url, 

1244 } 

1245 ) 

1246 

1247 # Reset tracker 

1248 tracker.is_downloaded = False 

1249 tracker.file_path = None 

1250 

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

1252 from ..deletion.utils.cascade_helper import ( 

1253 CascadeHelper, 

1254 ) 

1255 

1256 CascadeHelper.delete_document_completely( 

1257 session, doc.id 

1258 ) 

1259 removed_doc_ids.append(doc.id) 

1260 stats["trackers_updated"] += 1 

1261 else: 

1262 # No tracker or path - delete the document entry 

1263 stats["files_missing"] += 1 

1264 from ..deletion.utils.cascade_helper import CascadeHelper 

1265 

1266 CascadeHelper.delete_document_completely(session, doc.id) 

1267 removed_doc_ids.append(doc.id) 

1268 

1269 session.commit() 

1270 logger.info( 

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

1272 ) 

1273 

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

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

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

1277 # surfacing in collection search. Reuse DocumentDeletionService's 

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

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

1280 if removed_doc_ids: 

1281 from ..deletion.services.document_deletion import ( 

1282 DocumentDeletionService, 

1283 ) 

1284 from ...database.models.library import DocumentChunk 

1285 

1286 deleter = DocumentDeletionService(self.username) 

1287 for doc_id in removed_doc_ids: 

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

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

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

1291 # abort the remaining documents' purges. 

1292 try: 

1293 with get_user_db_session(self.username) as purge_session: 

1294 coll_rows = ( 

1295 purge_session.query(DocumentChunk.collection_name) 

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

1297 .distinct() 

1298 .all() 

1299 ) 

1300 collection_ids = [ 

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

1302 for row in coll_rows 

1303 if row[0] 

1304 ] 

1305 deleter._purge_document_rag( 

1306 doc_id, collection_ids, full_delete=True 

1307 ) 

1308 except Exception: 

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

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

1311 "(delete already committed)", 

1312 doc_id, 

1313 ) 

1314 

1315 return stats 

1316 

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

1318 """ 

1319 Mark specific documents for re-download. 

1320 

1321 Args: 

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

1323 

1324 Returns: 

1325 Number of documents marked 

1326 """ 

1327 with get_user_db_session(self.username) as session: 

1328 count = 0 

1329 for doc_id in document_ids: 

1330 doc = session.get(Document, doc_id) 

1331 if doc: 

1332 if not doc.original_url: 

1333 # Uploads have no source URL and no DownloadTracker, 

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

1335 logger.warning( 

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

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

1338 ) 

1339 continue 

1340 # Get tracker and reset it 

1341 tracker = ( 

1342 session.query(DownloadTracker) 

1343 .filter_by( 

1344 url_hash=self._get_url_hash(doc.original_url) 

1345 ) 

1346 .first() 

1347 ) 

1348 

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

1350 tracker.is_downloaded = False 

1351 tracker.file_path = None 

1352 

1353 # Mark document as pending 

1354 doc.status = DocumentStatus.PENDING 

1355 count += 1 

1356 

1357 session.commit() 

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

1359 return count