Coverage for src/local_deep_research/research_library/deletion/services/collection_deletion.py: 99%

113 statements  

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

1""" 

2Collection deletion service. 

3 

4Handles: 

5- Full collection deletion with proper cleanup 

6- Documents are preserved but unlinked 

7- RAG index and chunks are deleted 

8""" 

9 

10from typing import Dict, List, Any 

11 

12from loguru import logger 

13 

14from ....database.models.library import ( 

15 Collection, 

16 Document, 

17 DocumentCollection, 

18 DocumentChunk, 

19 CollectionFolder, 

20 RAGIndex, 

21 RagDocumentStatus, 

22 SourceType, 

23) 

24from ....database.session_context import get_user_db_session 

25from ..utils.cascade_helper import CascadeHelper 

26 

27# System collection types that must never be deleted via the public API. 

28# These collections hold first-class user data whose lifecycle is owned by 

29# other subsystems (Library, Research History, Notes); allowing them to be 

30# deleted via the generic collection-delete path triggers cascade orphan 

31# deletion of every document inside, which for Notes means total data loss 

32# of notes, versions, links, and synthesis sources. 

33# NOTE: 'zotero' is deliberately NOT protected — unlike the singleton system 

34# collections above, users create/remove Zotero-synced collections and the 

35# generic delete path is their only UI for it. Whether deleting a Zotero 

36# collection should also purge its synced documents is a separate question 

37# for the Zotero sync owner to decide, not something to block wholesale here. 

38PROTECTED_COLLECTION_TYPES = frozenset( 

39 {"default_library", "research_history", "notes"} 

40) 

41 

42 

43class CollectionDeletionService: 

44 """Service for collection deletion operations.""" 

45 

46 def __init__(self, username: str): 

47 """ 

48 Initialize collection deletion service. 

49 

50 Args: 

51 username: Username for database session 

52 """ 

53 self.username = username 

54 

55 def delete_collection( 

56 self, collection_id: str, delete_orphaned_documents: bool = True 

57 ) -> Dict[str, Any]: 

58 """ 

59 Delete a collection and clean up all related data. 

60 

61 By default, orphaned documents (not in any other collection) are deleted. 

62 Set delete_orphaned_documents=False to preserve all documents. 

63 

64 The following are deleted: 

65 - DocumentChunks for this collection 

66 - FAISS index files 

67 - RAGIndex records 

68 - CollectionFolder records (CASCADE) 

69 - DocumentCollection links (CASCADE) 

70 - RagDocumentStatus records (CASCADE) 

71 - Orphaned documents (if delete_orphaned_documents=True) 

72 

73 Args: 

74 collection_id: ID of the collection to delete 

75 delete_orphaned_documents: If True, delete documents not in any 

76 other collection after unlinking 

77 

78 Returns: 

79 Dict with deletion details: 

80 { 

81 "deleted": True/False, 

82 "collection_id": str, 

83 "collection_name": str, 

84 "chunks_deleted": int, 

85 "documents_unlinked": int, 

86 "indices_deleted": int, 

87 "folders_deleted": int, 

88 "orphaned_documents_deleted": int, 

89 "error": str (if failed) 

90 } 

91 """ 

92 with get_user_db_session(self.username) as session: 

93 try: 

94 # Get collection 

95 collection = session.get(Collection, collection_id) 

96 if not collection: 

97 return { 

98 "deleted": False, 

99 "collection_id": collection_id, 

100 "error": "Collection not found", 

101 } 

102 

103 if collection.collection_type in PROTECTED_COLLECTION_TYPES: 

104 logger.warning( 

105 "Refused to delete protected system collection " 

106 f"{collection_id[:8]}... (type={collection.collection_type})" 

107 ) 

108 return { 

109 "deleted": False, 

110 "collection_id": collection_id, 

111 "collection_name": collection.name, 

112 "collection_type": collection.collection_type, 

113 "error": ( 

114 "Cannot delete system collection " 

115 f"'{collection.name}' (type={collection.collection_type}). " 

116 "This collection holds first-class user data." 

117 ), 

118 } 

119 

120 collection_name = f"collection_{collection_id}" 

121 result = { 

122 "deleted": False, 

123 "collection_id": collection_id, 

124 "collection_name": collection.name, 

125 "chunks_deleted": 0, 

126 "documents_unlinked": 0, 

127 "indices_deleted": 0, 

128 "folders_deleted": 0, 

129 "orphaned_documents_deleted": 0, 

130 } 

131 

132 # 1. Get document IDs BEFORE deleting links (for orphan check) 

133 doc_ids_in_collection = [ 

134 dc.document_id 

135 for dc in session.query(DocumentCollection) 

136 .filter_by(collection_id=collection_id) 

137 .all() 

138 ] 

139 result["documents_unlinked"] = len(doc_ids_in_collection) 

140 

141 # 2. Delete DocumentChunks for this collection 

142 result["chunks_deleted"] = ( 

143 CascadeHelper.delete_collection_chunks( 

144 session, collection_name 

145 ) 

146 ) 

147 

148 # 3. Delete RAGIndex records (DB only). The on-disk FAISS files 

149 # are unlinked AFTER the commit below — steps 4-8 still do DB 

150 # work that could raise and roll back, and a rollback restores 

151 # the RAGIndex rows but not files already deleted from disk. 

152 rag_result = CascadeHelper.delete_rag_indices_for_collection( 

153 session, collection_name, unlink_files=False 

154 ) 

155 result["indices_deleted"] = rag_result["deleted_indices"] 

156 faiss_paths_to_unlink = rag_result["index_paths"] 

157 # Orphaned documents hard-deleted below may still have chunk 

158 # rows/vectors in OTHER collections (a partial reindex can strand 

159 # them). Collect them here and purge those after the commit — 

160 # delete_document_completely does not touch DocumentChunk. 

161 orphaned_doc_ids: List[str] = [] 

162 

163 # 4. Count folders before deletion 

164 result["folders_deleted"] = ( 

165 session.query(CollectionFolder) 

166 .filter_by(collection_id=collection_id) 

167 .count() 

168 ) 

169 

170 # 5. Delete DocumentCollection links explicitly before collection 

171 session.query(DocumentCollection).filter_by( 

172 collection_id=collection_id 

173 ).delete(synchronize_session=False) 

174 

175 # 6. Delete linked folders explicitly 

176 session.query(CollectionFolder).filter_by( 

177 collection_id=collection_id 

178 ).delete(synchronize_session=False) 

179 

180 # 7. Delete the collection itself 

181 session.delete(collection) 

182 

183 # 8. Delete orphaned documents if requested 

184 if delete_orphaned_documents: 

185 # Notes must never be hard-deleted via the orphan 

186 # cascade: they live behind their own deletion API 

187 # (DELETE /api/notes/<id>) and remain discoverable 

188 # via list_notes(), which filters by source_type_id 

189 # rather than collection membership. This mirrors the 

190 # note-skip in document_deletion.py's orphan path. The 

191 # check is kept local (a single SourceType lookup) to 

192 # avoid importing the notes-services package into the 

193 # deletion package, matching document_deletion's own 

194 # local _is_note_document. 

195 note_source = ( 

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

197 ) 

198 for doc_id in doc_ids_in_collection: 

199 # Check if document is in any other collection 

200 remaining = ( 

201 session.query(DocumentCollection) 

202 .filter_by(document_id=doc_id) 

203 .count() 

204 ) 

205 if remaining == 0: 

206 if note_source is not None: 

207 document = session.get(Document, doc_id) 

208 if ( 

209 document is not None 

210 and document.source_type_id 

211 == note_source.id 

212 ): 

213 logger.info( 

214 f"Note {doc_id[:8]}... orphaned by " 

215 "collection deletion — skipping orphan " 

216 "delete; use DELETE /api/notes/<id> " 

217 "to remove." 

218 ) 

219 continue 

220 # Document is orphaned - delete it 

221 CascadeHelper.delete_document_completely( 

222 session, doc_id 

223 ) 

224 orphaned_doc_ids.append(doc_id) 

225 result["orphaned_documents_deleted"] += 1 

226 logger.info( 

227 f"Deleted orphaned document {doc_id[:8]}..." 

228 ) 

229 

230 session.commit() 

231 

232 result["deleted"] = True 

233 logger.info( 

234 f"Deleted collection {collection_id[:8]}... " 

235 f"({result['collection_name']}): {result['chunks_deleted']} chunks, " 

236 f"{result['documents_unlinked']} documents unlinked, " 

237 f"{result['orphaned_documents_deleted']} orphaned deleted" 

238 ) 

239 

240 except Exception: 

241 logger.exception(f"Failed to delete collection {collection_id}") 

242 session.rollback() 

243 return { 

244 "deleted": False, 

245 "collection_id": collection_id, 

246 "error": "Failed to delete collection", 

247 } 

248 

249 # Post-commit best-effort cleanup, OUTSIDE the deletion transaction and 

250 # its try/except: the DB delete is already durable, so a failure here 

251 # must NOT be reported as a failed collection delete — it isn't one. It 

252 # would only leave orphaned files/vectors, logged for ops visibility. 

253 if faiss_paths_to_unlink: 

254 from ....config.paths import get_cache_directory 

255 

256 # NOTE: allowed_root is the COARSE shared rag_indices/ root, not 

257 # the tighter per-user rag_indices/<sha256(user)>/ subdir. It 

258 # cannot be narrowed to the per-user subdir without risking 

259 # refused deletes: pre-per-user-scoping (legacy) indexes live 

260 # DIRECTLY in this shared root (see 

261 # library_rag_service._migrate_legacy_index_files), so a per-user 

262 # allowed_root would reject a legacy-layout RAGIndex.index_path 

263 # and orphan its files. The containment check still blocks 

264 # symlink / ancestor escapes outside rag_indices/ entirely. 

265 rag_indices_root = get_cache_directory() / "rag_indices" 

266 for faiss_path in faiss_paths_to_unlink: 

267 CascadeHelper.delete_faiss_index_files( 

268 faiss_path, allowed_root=rag_indices_root 

269 ) 

270 

271 # Purge each hard-deleted orphan's vectors + chunk rows in its OTHER 

272 # collections (this collection's chunks were removed in step 2). Reuses 

273 # DocumentDeletionService's per-collection vector purge + chunk backstop. 

274 if orphaned_doc_ids: 

275 from .document_deletion import DocumentDeletionService 

276 from ....database.models.library import DocumentChunk 

277 

278 deleter = DocumentDeletionService(self.username) 

279 for doc_id in orphaned_doc_ids: 

280 try: 

281 with get_user_db_session(self.username) as purge_session: 

282 coll_rows = ( 

283 purge_session.query(DocumentChunk.collection_name) 

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

285 .distinct() 

286 .all() 

287 ) 

288 collection_ids = [ 

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

290 for row in coll_rows 

291 if row[0] 

292 ] 

293 deleter._purge_document_rag( 

294 doc_id, collection_ids, full_delete=True 

295 ) 

296 except Exception: 

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

298 "Failed to purge orphaned document {} after collection " 

299 "delete (delete already committed)", 

300 doc_id, 

301 ) 

302 

303 return result 

304 

305 def delete_collection_index_only( 

306 self, collection_id: str 

307 ) -> Dict[str, Any]: 

308 """ 

309 Delete only the RAG index for a collection, keeping the collection itself. 

310 

311 This is useful for rebuilding an index from scratch. 

312 

313 Args: 

314 collection_id: ID of the collection 

315 

316 Returns: 

317 Dict with deletion details 

318 """ 

319 with get_user_db_session(self.username) as session: 

320 try: 

321 # Verify collection exists 

322 collection = session.get(Collection, collection_id) 

323 if not collection: 

324 return { 

325 "deleted": False, 

326 "collection_id": collection_id, 

327 "error": "Collection not found", 

328 } 

329 

330 collection_name = f"collection_{collection_id}" 

331 result = { 

332 "deleted": False, 

333 "collection_id": collection_id, 

334 "chunks_deleted": 0, 

335 "indices_deleted": 0, 

336 "documents_reset": 0, 

337 } 

338 

339 # 1. Delete DocumentChunks 

340 result["chunks_deleted"] = ( 

341 CascadeHelper.delete_collection_chunks( 

342 session, collection_name 

343 ) 

344 ) 

345 

346 # 2. Delete RAGIndex records (DB only). Files are unlinked after 

347 # the commit — steps 3-5 do more DB work that could roll back, 

348 # and a rollback can't restore files already removed from disk. 

349 rag_result = CascadeHelper.delete_rag_indices_for_collection( 

350 session, collection_name, unlink_files=False 

351 ) 

352 result["indices_deleted"] = rag_result["deleted_indices"] 

353 faiss_paths_to_unlink = rag_result["index_paths"] 

354 

355 # 3. Reset DocumentCollection indexed status 

356 result["documents_reset"] = ( 

357 session.query(DocumentCollection) 

358 .filter_by(collection_id=collection_id) 

359 .update({"indexed": False, "chunk_count": 0}) 

360 ) 

361 

362 # 4. Delete RagDocumentStatus for this collection 

363 session.query(RagDocumentStatus).filter_by( 

364 collection_id=collection_id 

365 ).delete(synchronize_session=False) 

366 

367 # 5. Reset collection embedding info 

368 collection.embedding_model = None 

369 collection.embedding_model_type = None 

370 collection.embedding_dimension = None 

371 collection.chunk_size = None 

372 collection.chunk_overlap = None 

373 

374 session.commit() 

375 

376 # DB delete durable — now unlink the FAISS files (best-effort). 

377 if faiss_paths_to_unlink: 

378 from ....config.paths import get_cache_directory 

379 

380 # NOTE: allowed_root is the COARSE shared rag_indices/ 

381 # root, not the per-user rag_indices/<sha256(user)>/ 

382 # subdir -- see delete_collection above for why it can't 

383 # be narrowed (legacy indexes live directly in the shared 

384 # root, so a per-user scope would orphan them). 

385 rag_indices_root = get_cache_directory() / "rag_indices" 

386 for faiss_path in faiss_paths_to_unlink: 

387 CascadeHelper.delete_faiss_index_files( 

388 faiss_path, allowed_root=rag_indices_root 

389 ) 

390 

391 result["deleted"] = True 

392 

393 logger.info( 

394 f"Deleted index for collection {collection_id[:8]}...: " 

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

396 f"{result['documents_reset']} documents reset" 

397 ) 

398 

399 return result 

400 

401 except Exception: 

402 logger.exception( 

403 f"Failed to delete index for collection {collection_id}" 

404 ) 

405 session.rollback() 

406 return { 

407 "deleted": False, 

408 "collection_id": collection_id, 

409 "error": "Failed to delete collection index", 

410 } 

411 

412 def get_deletion_preview(self, collection_id: str) -> Dict[str, Any]: 

413 """ 

414 Get a preview of what will be deleted. 

415 

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

417 

418 Args: 

419 collection_id: ID of the collection 

420 

421 Returns: 

422 Dict with preview information 

423 """ 

424 with get_user_db_session(self.username) as session: 

425 collection = session.get(Collection, collection_id) 

426 if not collection: 

427 return {"found": False, "collection_id": collection_id} 

428 

429 collection_name = f"collection_{collection_id}" 

430 

431 # Count documents 

432 documents_count = ( 

433 session.query(DocumentCollection) 

434 .filter_by(collection_id=collection_id) 

435 .count() 

436 ) 

437 

438 # Count chunks 

439 chunks_count = ( 

440 session.query(DocumentChunk) 

441 .filter_by(collection_name=collection_name) 

442 .count() 

443 ) 

444 

445 # Count folders 

446 folders_count = ( 

447 session.query(CollectionFolder) 

448 .filter_by(collection_id=collection_id) 

449 .count() 

450 ) 

451 

452 # Check for RAG index 

453 has_index = ( 

454 session.query(RAGIndex) 

455 .filter_by(collection_name=collection_name) 

456 .first() 

457 is not None 

458 ) 

459 

460 return { 

461 "found": True, 

462 "collection_id": collection_id, 

463 "name": collection.name, 

464 "description": collection.description, 

465 "is_default": collection.is_default, 

466 "documents_count": documents_count, 

467 "chunks_count": chunks_count, 

468 "folders_count": folders_count, 

469 "has_rag_index": has_index, 

470 "embedding_model": collection.embedding_model, 

471 }