Coverage for src/local_deep_research/research_library/deletion/services/document_deletion.py: 98%

162 statements  

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

1""" 

2Document deletion service. 

3 

4Handles: 

5- Full document deletion with proper cascade cleanup 

6- Blob-only deletion (remove PDF, keep text) 

7- Remove from collection (unlink or delete if orphaned) 

8""" 

9 

10from typing import Dict, List, Any 

11 

12from loguru import logger 

13 

14from ....constants import FILE_PATH_BLOB_DELETED 

15from ....database.models.library import ( 

16 Collection, 

17 Document, 

18 DocumentChunk, 

19 DocumentCollection, 

20 RagDocumentStatus, 

21 SourceType, 

22) 

23from ....database.session_context import get_user_db_session 

24from ....database.session_passwords import capture_request_db_password 

25from ..utils.cascade_helper import CascadeHelper 

26 

27 

28def _is_note_document(session, document) -> bool: 

29 """Return True if the document represents a note (source_type='note'). 

30 

31 Notes have their own deletion API at /api/notes/<id>. Letting them be 

32 deleted via the generic document-deletion endpoints would bypass the 

33 B1 protection that was specifically built to prevent note data loss 

34 (see PROTECTED_COLLECTION_TYPES in collection_deletion.py). 

35 

36 The check is local-by-design — keep this module free of imports from 

37 the notes package to avoid an import cycle. 

38 """ 

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

40 return bool(note_source) and document.source_type_id == note_source.id 

41 

42 

43class DocumentDeletionService: 

44 """Service for document deletion operations.""" 

45 

46 def __init__(self, username: str): 

47 """ 

48 Initialize document deletion service. 

49 

50 Args: 

51 username: Username for database session 

52 """ 

53 self.username = username 

54 

55 def delete_document(self, document_id: str) -> Dict[str, Any]: 

56 """ 

57 Delete a document and ALL related data. 

58 

59 This method ensures complete cleanup: 

60 - DocumentChunks (no FK constraint, manual cleanup required) 

61 - DocumentBlob (CASCADE handles, but we track for stats) 

62 - Filesystem files 

63 - FAISS index entries 

64 - DownloadTracker update 

65 - DocumentCollection links (CASCADE) 

66 - RagDocumentStatus (CASCADE) 

67 

68 Args: 

69 document_id: ID of the document to delete 

70 

71 Returns: 

72 Dict with deletion details: 

73 { 

74 "deleted": True/False, 

75 "document_id": str, 

76 "title": str, 

77 "blob_deleted": bool, 

78 "blob_size": int, 

79 "chunks_deleted": int, 

80 "collections_unlinked": int, 

81 "error": str (if failed) 

82 } 

83 """ 

84 result: Dict[str, Any] = { 

85 "deleted": False, 

86 "document_id": document_id, 

87 "title": None, 

88 "blob_deleted": False, 

89 "blob_size": 0, 

90 "chunks_deleted": 0, 

91 "collections_unlinked": 0, 

92 "file_deleted": False, 

93 } 

94 rag_collection_ids: List[str] = [] 

95 

96 with get_user_db_session(self.username) as session: 

97 try: 

98 # Get document 

99 document = session.get(Document, document_id) 

100 if not document: 

101 return { 

102 "deleted": False, 

103 "document_id": document_id, 

104 "error": "Document not found", 

105 } 

106 

107 # Notes are not deletable via the generic document API. 

108 # The Notes-collection deletion guard only blocks the 

109 # cascade path; without this check a user could wipe an 

110 # individual note by passing its UUID here, and the bulk 

111 # endpoint amplifies the same gap. Route the caller to 

112 # DELETE /api/notes/<id> which goes through NoteService 

113 # with the proper version/link cleanup. 

114 if _is_note_document(session, document): 

115 return { 

116 "deleted": False, 

117 "document_id": document_id, 

118 "title": document.title or "Untitled", 

119 "error": ( 

120 "Notes cannot be deleted via the document API. " 

121 "Use DELETE /api/notes/<id> instead." 

122 ), 

123 "is_note": True, 

124 } 

125 

126 title = document.title or document.filename or "Untitled" 

127 result["title"] = title 

128 

129 # 1. Get collections before deletion for chunk cleanup 

130 collections = CascadeHelper.get_document_collections( 

131 session, document_id 

132 ) 

133 result["collections_unlinked"] = len(collections) 

134 

135 # Every collection that may hold FAISS vectors / chunk rows 

136 # for this document: the UNION of its linked collections and 

137 # the collections that actually have chunk rows. The chunk-row 

138 # half matters because a partial/failed reindex can leave 

139 # chunks in a collection whose join row is gone or was never 

140 # marked indexed. Used by the post-commit RAG purge below. 

141 chunk_collection_rows = ( 

142 session.query(DocumentChunk.collection_name) 

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

144 .distinct() 

145 .all() 

146 ) 

147 rag_collection_ids = list( 

148 {str(c) for c in collections} 

149 | { 

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

151 for row in chunk_collection_rows 

152 if row[0] 

153 } 

154 ) 

155 

156 # 2. Count chunks for stats. The rows themselves (and their 

157 # FAISS vectors) are removed in the post-commit RAG cleanup 

158 # phase below — purge_document_vectors resolves the FAISS 

159 # int ids from these rows, so they must outlive this 

160 # transaction (mirrors NoteService.delete_note). 

161 result["chunks_deleted"] = ( 

162 session.query(DocumentChunk) 

163 .filter( 

164 DocumentChunk.source_id == document_id, 

165 DocumentChunk.source_type == "document", 

166 ) 

167 .count() 

168 ) 

169 

170 # 3. Get blob size before deletion (for stats) 

171 result["blob_size"] = CascadeHelper.get_document_blob_size( 

172 session, document_id 

173 ) 

174 result["blob_deleted"] = result["blob_size"] > 0 

175 

176 # 4. Delete filesystem file if exists 

177 if document.storage_mode == "filesystem" and document.file_path: 

178 from ...utils import get_absolute_path_from_settings 

179 

180 try: 

181 file_path = get_absolute_path_from_settings( 

182 document.file_path 

183 ) 

184 if file_path: 

185 result["file_deleted"] = ( 

186 CascadeHelper.delete_filesystem_file( 

187 str(file_path) 

188 ) 

189 ) 

190 except Exception: 

191 logger.exception("Failed to delete filesystem file") 

192 

193 # 5. Update DownloadTracker 

194 CascadeHelper.update_download_tracker(session, document) 

195 

196 # 6. Delete the document and all related records (leaves the 

197 # DocumentChunk rows for the post-commit cleanup phase). 

198 CascadeHelper.delete_document_completely(session, document_id) 

199 session.commit() 

200 

201 result["deleted"] = True 

202 logger.info( 

203 f"Deleted document {document_id[:8]}... ({title}): " 

204 f"{result['chunks_deleted']} chunks, " 

205 f"{result['blob_size']} bytes blob" 

206 ) 

207 

208 except Exception: 

209 logger.exception(f"Failed to delete document {document_id}") 

210 session.rollback() 

211 return { 

212 "deleted": False, 

213 "document_id": document_id, 

214 "error": "Failed to delete document", 

215 } 

216 

217 # Post-commit FAISS + chunk-row cleanup. Runs after the deletion 

218 # transaction has committed — releasing SQLite's single write lock — 

219 # so the slow vector-store IO never blocks another writer. 

220 if result["deleted"]: 220 ↛ 225line 220 didn't jump to line 225 because the condition on line 220 was always true

221 self._purge_document_rag( 

222 document_id, rag_collection_ids, full_delete=True 

223 ) 

224 

225 return result 

226 

227 def _purge_document_rag( 

228 self, 

229 document_id: str, 

230 collection_ids: List[str], 

231 *, 

232 full_delete: bool, 

233 ) -> None: 

234 """Remove a document's FAISS vectors and chunk rows post-commit. 

235 

236 Called after the deletion/unlink transaction has committed — 

237 releasing SQLite's single write lock — so the slow vector-store IO 

238 here never blocks another writer. For each collection the vectors 

239 are purged BEFORE their chunk rows (``purge_document_vectors`` 

240 resolves the FAISS int ids from those rows), then the rows are 

241 deleted. 

242 

243 A final sweep then guarantees no chunk row survives even when the 

244 per-collection purge could not run — e.g. the DB password is 

245 unavailable on an encrypted-DB install, or a collection has no 

246 current FAISS index. ``full_delete`` selects the sweep scope: True 

247 (the document is gone) sweeps every remaining chunk row for the 

248 document; False (a single unlink) sweeps only the listed 

249 collections, leaving the document's chunks in its other collections 

250 intact. 

251 

252 Best-effort: the DB delete already committed, so failures here are 

253 logged, not surfaced. 

254 """ 

255 if collection_ids: 

256 dbpw = capture_request_db_password(self.username) 

257 from ...services.rag_service_factory import get_rag_service 

258 

259 for collection_id in collection_ids: 

260 try: 

261 # A fresh service per collection so the factory resolves 

262 # THAT collection's stored embedding model/provider — a 

263 # shared instance would compute the wrong index hash and 

264 # purge nothing for non-default-embedding collections. 

265 with get_rag_service( 

266 self.username, 

267 collection_id=collection_id, 

268 db_password=dbpw, 

269 ) as rag: 

270 rag.purge_document_vectors(document_id, collection_id) 

271 rag.purge_document_chunks(document_id, collection_id) 

272 except Exception: 

273 # exception=True keeps the stack trace. dbpw is a live 

274 # local here, but frame-local values only render under 

275 # the opt-in LDR_LOGURU_DIAGNOSE dev flag — every 

276 # persistent sink sets diagnose=False — so the password 

277 # cannot reach a log in normal operation. 

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

279 "Failed to remove RAG entries for document {} " 

280 "from collection {}", 

281 document_id, 

282 collection_id, 

283 ) 

284 

285 # Guaranteed backstop: remove any chunk rows the RAG purge could not 

286 # reach. Their vectors can't be resolved without the rows, but the 

287 # collection-scoped rehydration filter already stops an orphaned 

288 # vector from ever surfacing another document's text, so dropping the 

289 # rows here keeps the deletion complete and safe. 

290 try: 

291 with get_user_db_session(self.username) as session: 

292 if full_delete: 

293 CascadeHelper.delete_document_chunks(session, document_id) 

294 else: 

295 for collection_id in collection_ids: 

296 CascadeHelper.delete_document_chunks( 

297 session, 

298 document_id, 

299 f"collection_{collection_id}", 

300 ) 

301 session.commit() 

302 except Exception: 

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

304 "Failed to sweep residual chunk rows for document {}", 

305 document_id, 

306 ) 

307 

308 def delete_blob_only(self, document_id: str) -> Dict[str, Any]: 

309 """ 

310 Delete PDF binary but keep document metadata and text content. 

311 

312 This saves database space while preserving searchability. 

313 

314 Args: 

315 document_id: ID of the document 

316 

317 Returns: 

318 Dict with deletion details: 

319 { 

320 "deleted": True/False, 

321 "document_id": str, 

322 "bytes_freed": int, 

323 "storage_mode_updated": bool, 

324 "error": str (if failed) 

325 } 

326 """ 

327 with get_user_db_session(self.username) as session: 

328 try: 

329 # Get document 

330 document = session.get(Document, document_id) 

331 if not document: 

332 return { 

333 "deleted": False, 

334 "document_id": document_id, 

335 "bytes_freed": 0, 

336 "error": "Document not found", 

337 } 

338 

339 # Notes are not mutable via the generic blob-delete API. 

340 # Without this guard, a note's storage_mode/file_path can 

341 # be overwritten with the PDF-blob-deletion sentinel via 

342 # either DELETE /library/api/document/<id>/blob OR the 

343 # bulk DELETE /library/api/documents/blobs path (which 

344 # loops over this method per-id). 

345 if _is_note_document(session, document): 

346 return { 

347 "deleted": False, 

348 "document_id": document_id, 

349 "bytes_freed": 0, 

350 "error": ( 

351 "Notes cannot be modified via the document API. " 

352 "Use the notes API instead." 

353 ), 

354 "is_note": True, 

355 } 

356 

357 result = { 

358 "deleted": False, 

359 "document_id": document_id, 

360 "bytes_freed": 0, 

361 "storage_mode_updated": False, 

362 } 

363 

364 # Handle based on storage mode 

365 if document.storage_mode == "database": 

366 # Delete blob from database 

367 result["bytes_freed"] = CascadeHelper.delete_document_blob( 

368 session, document_id 

369 ) 

370 

371 elif document.storage_mode == "filesystem": 

372 # Delete filesystem file 

373 from ...utils import get_absolute_path_from_settings 

374 

375 if document.file_path: 

376 try: 

377 file_path = get_absolute_path_from_settings( 

378 document.file_path 

379 ) 

380 if file_path and file_path.is_file(): 

381 result["bytes_freed"] = file_path.stat().st_size 

382 CascadeHelper.delete_filesystem_file( 

383 str(file_path) 

384 ) 

385 except Exception: 

386 logger.exception("Failed to delete filesystem file") 

387 

388 else: 

389 # No blob to delete 

390 return { 

391 "deleted": False, 

392 "document_id": document_id, 

393 "bytes_freed": 0, 

394 "error": "Document has no stored PDF (storage_mode is 'none')", 

395 } 

396 

397 # Update document to indicate blob is deleted 

398 document.storage_mode = "none" 

399 document.file_path = FILE_PATH_BLOB_DELETED 

400 result["storage_mode_updated"] = True 

401 

402 session.commit() 

403 result["deleted"] = True 

404 

405 logger.info( 

406 f"Deleted blob for document {document_id[:8]}...: " 

407 f"{result['bytes_freed']} bytes freed" 

408 ) 

409 

410 return result 

411 

412 except Exception: 

413 logger.exception( 

414 f"Failed to delete blob for document {document_id}" 

415 ) 

416 session.rollback() 

417 return { 

418 "deleted": False, 

419 "document_id": document_id, 

420 "bytes_freed": 0, 

421 "error": "Failed to delete document blob", 

422 } 

423 

424 def remove_from_collection( 

425 self, 

426 document_id: str, 

427 collection_id: str, 

428 ) -> Dict[str, Any]: 

429 """ 

430 Remove document from a collection. 

431 

432 If the document is not in any other collection after removal, 

433 it will be completely deleted. 

434 

435 Args: 

436 document_id: ID of the document 

437 collection_id: ID of the collection 

438 

439 Returns: 

440 Dict with operation details: 

441 { 

442 "unlinked": True/False, 

443 "document_deleted": bool, 

444 "document_id": str, 

445 "collection_id": str, 

446 "chunks_deleted": int, 

447 "error": str (if failed) 

448 } 

449 """ 

450 with get_user_db_session(self.username) as session: 

451 try: 

452 # Verify document exists 

453 document = session.get(Document, document_id) 

454 if not document: 

455 return { 

456 "unlinked": False, 

457 "document_deleted": False, 

458 "document_id": document_id, 

459 "collection_id": collection_id, 

460 "error": "Document not found", 

461 } 

462 

463 # Verify collection exists and document is in it 

464 doc_collection = ( 

465 session.query(DocumentCollection) 

466 .filter_by( 

467 document_id=document_id, collection_id=collection_id 

468 ) 

469 .first() 

470 ) 

471 

472 if not doc_collection: 

473 return { 

474 "unlinked": False, 

475 "document_deleted": False, 

476 "document_id": document_id, 

477 "collection_id": collection_id, 

478 "error": "Document not in this collection", 

479 } 

480 

481 # Permanent-home guard: the Notes collection is every 

482 # note's home (_get_or_create_notes_collection); nothing 

483 # re-links a note after an unlink, so removal here would 

484 # silently drop the note from semantic search, the 

485 # collection view, and the auto-reindex worker — while 

486 # list_notes still shows it. NoteService.remove_from_collection 

487 # enforces this for the notes API; this mirrors it for the 

488 # generic collection-document API (single + bulk). 

489 collection = session.get(Collection, collection_id) 

490 if ( 

491 collection is not None 

492 and collection.collection_type == "notes" 

493 and _is_note_document(session, document) 

494 ): 

495 return { 

496 "unlinked": False, 

497 "document_deleted": False, 

498 "document_id": document_id, 

499 "collection_id": collection_id, 

500 "protected": True, 

501 "error": ( 

502 "Cannot remove a note from its notes " 

503 "collection — it is the note's permanent " 

504 "home. Use DELETE /api/notes/<id> to delete " 

505 "the note itself." 

506 ), 

507 } 

508 

509 result = { 

510 "unlinked": False, 

511 "document_deleted": False, 

512 "document_id": document_id, 

513 "collection_id": collection_id, 

514 "chunks_deleted": 0, 

515 } 

516 

517 # Count chunks for this document in this collection (for 

518 # stats). Their rows and FAISS vectors are removed in the 

519 # post-commit RAG cleanup phase — purge_document_vectors 

520 # resolves the FAISS int ids from these rows, so they must 

521 # outlive this transaction. 

522 collection_name = f"collection_{collection_id}" 

523 result["chunks_deleted"] = ( 

524 session.query(DocumentChunk) 

525 .filter( 

526 DocumentChunk.source_id == document_id, 

527 DocumentChunk.source_type == "document", 

528 DocumentChunk.collection_name == collection_name, 

529 ) 

530 .count() 

531 ) 

532 

533 # Remove the link 

534 session.delete(doc_collection) 

535 # Clear the RagDocumentStatus row for this (document, collection) 

536 # — the "indexed" marker read by get_rag_stats / the RAG status 

537 # route. Its FK cascades fire only when the Document, Collection, 

538 # or RAGIndex is deleted; a mere unlink deletes none of those, so 

539 # a leftover row reports the just-removed document as still 

540 # indexed in this collection. 

541 session.query(RagDocumentStatus).filter_by( 

542 document_id=document_id, collection_id=collection_id 

543 ).delete(synchronize_session=False) 

544 session.flush() 

545 

546 # Check if document is in any other collection 

547 remaining_count = CascadeHelper.count_document_in_collections( 

548 session, document_id 

549 ) 

550 

551 # Default (unlink, and note-orphan): purge only THIS 

552 # collection's vectors/chunks; the document (or note) and its 

553 # chunks in other collections stay intact. 

554 full_delete = False 

555 rag_collection_ids: List[str] = [collection_id] 

556 

557 if remaining_count == 0 and _is_note_document( 557 ↛ 567line 557 didn't jump to line 567 because the condition on line 557 was never true

558 session, document 

559 ): 

560 # Note that was orphaned by this removal: skip the 

561 # destructive delete_document_completely path. Notes 

562 # live behind their own deletion API (DELETE /api/notes/<id>) 

563 # and are still discoverable via list_notes() which 

564 # filters by source_type_id, not collection membership. 

565 # The Notes collection itself acts as the persistent 

566 # home for every note via _get_or_create_notes_collection. 

567 logger.info( 

568 f"Note {document_id[:8]}... orphaned from collection — " 

569 "skipping orphan delete; use DELETE /api/notes/<id> to remove." 

570 ) 

571 elif remaining_count == 0: 

572 # Document is orphaned - delete it completely 

573 # Note: We're already in a session, so we need to do this 

574 # directly rather than calling delete_document() 

575 logger.info( 

576 f"Document {document_id[:8]}... is orphaned, deleting" 

577 ) 

578 

579 # Full delete: purge vectors/chunks across EVERY collection 

580 # that still holds chunk rows for this document, not just 

581 # the one being removed (a partial reindex can strand 

582 # chunks elsewhere). Rows are deleted in the post-commit 

583 # phase so purge_document_vectors can resolve their ids. 

584 chunk_collection_rows = ( 

585 session.query(DocumentChunk.collection_name) 

586 .filter_by( 

587 source_type="document", source_id=document_id 

588 ) 

589 .distinct() 

590 .all() 

591 ) 

592 full_delete = True 

593 rag_collection_ids = list( 

594 {collection_id} 

595 | { 

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

597 for row in chunk_collection_rows 

598 if row[0] 

599 } 

600 ) 

601 

602 # Update DownloadTracker 

603 CascadeHelper.update_download_tracker(session, document) 

604 

605 # Delete filesystem file if applicable 

606 if ( 

607 document.storage_mode == "filesystem" 

608 and document.file_path 

609 ): 

610 from ...utils import get_absolute_path_from_settings 

611 

612 try: 

613 file_path = get_absolute_path_from_settings( 

614 document.file_path 

615 ) 

616 if file_path: 

617 CascadeHelper.delete_filesystem_file( 

618 str(file_path) 

619 ) 

620 except Exception: 

621 logger.exception("Failed to delete filesystem file") 

622 

623 # Delete document and all related records (leaves the 

624 # DocumentChunk rows for the post-commit cleanup phase). 

625 CascadeHelper.delete_document_completely( 

626 session, document_id 

627 ) 

628 result["document_deleted"] = True 

629 

630 session.commit() 

631 result["unlinked"] = True 

632 

633 logger.info( 

634 f"Removed document {document_id[:8]}... from collection " 

635 f"{collection_id[:8]}... " 

636 f"(deleted={result['document_deleted']})" 

637 ) 

638 

639 except Exception: 

640 logger.exception( 

641 f"Failed to remove document {document_id} " 

642 f"from collection {collection_id}" 

643 ) 

644 session.rollback() 

645 return { 

646 "unlinked": False, 

647 "document_deleted": False, 

648 "document_id": document_id, 

649 "collection_id": collection_id, 

650 "error": "Failed to remove document from collection", 

651 } 

652 

653 # Post-commit FAISS + chunk-row cleanup, after the transaction has 

654 # committed and released SQLite's write lock. 

655 self._purge_document_rag( 

656 document_id, rag_collection_ids, full_delete=full_delete 

657 ) 

658 

659 return result 

660 

661 def get_deletion_preview(self, document_id: str) -> Dict[str, Any]: 

662 """ 

663 Get a preview of what will be deleted. 

664 

665 Useful for showing the user what will happen before confirming. 

666 

667 Args: 

668 document_id: ID of the document 

669 

670 Returns: 

671 Dict with preview information 

672 """ 

673 with get_user_db_session(self.username) as session: 

674 document = session.get(Document, document_id) 

675 if not document: 

676 return {"found": False, "document_id": document_id} 

677 

678 collections = CascadeHelper.get_document_collections( 

679 session, document_id 

680 ) 

681 

682 # Count chunks 

683 total_chunks = ( 

684 session.query(DocumentChunk) 

685 .filter( 

686 DocumentChunk.source_id == document_id, 

687 DocumentChunk.source_type == "document", 

688 ) 

689 .count() 

690 ) 

691 

692 blob_size = CascadeHelper.get_document_blob_size( 

693 session, document_id 

694 ) 

695 

696 return { 

697 "found": True, 

698 "document_id": document_id, 

699 "title": document.title or document.filename or "Untitled", 

700 "file_type": document.file_type, 

701 "storage_mode": document.storage_mode, 

702 "has_blob": blob_size > 0, 

703 "blob_size": blob_size, 

704 "has_text": bool(document.text_content), 

705 "collections_count": len(collections), 

706 "chunks_count": total_chunks, 

707 }