"""Shared helpers for tenant RAG document list and delete operations.""" from __future__ import annotations import logging from datetime import datetime from pathlib import Path from fastapi import HTTPException from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Document, DocumentPurpose, IngestStatus, Report from app.models.schemas import DocumentDeleteResponse, DocumentListItem logger = logging.getLogger(__name__) def ingest_status_to_display(status: IngestStatus | str) -> str: """Map DB ingest status to UI-friendly labels.""" raw = status.value if isinstance(status, IngestStatus) else str(status) if raw in ("pending", "processing"): return "processing" if raw == "complete": return "ready" if raw == "failed": return "failed" return raw def file_size_bytes(file_path: str | None) -> int | None: """Return on-disk byte size when the upload file still exists.""" if not file_path: return None try: p = Path(file_path) if p.is_file(): return int(p.stat().st_size) except OSError: return None return None def document_list_item( doc: Document, *, chunk_count: int, linked_report_count: int, ) -> DocumentListItem: purpose = doc.document_purpose purpose_val = purpose.value if isinstance(purpose, DocumentPurpose) else str(purpose) ingest = doc.status.value if isinstance(doc.status, IngestStatus) else str(doc.status) return DocumentListItem( document_id=doc.id, filename=doc.filename, file_size_bytes=file_size_bytes(doc.file_path), upload_timestamp=doc.created_at, updated_at=doc.updated_at, status=ingest_status_to_display(doc.status), ingest_status=ingest, document_purpose=purpose_val, chunk_count=int(chunk_count), survey_level=doc.survey_level, error_message=doc.error_message, storage_path=doc.file_path, linked_report_count=int(linked_report_count), ) async def delete_tenant_document( db: AsyncSession, *, tenant_id: str, document_id: str, expected_purpose: DocumentPurpose | None = DocumentPurpose.report_source, ) -> DocumentDeleteResponse: """Remove a document from vector index, disk, and database. Args: expected_purpose: When set, reject deletes for other purposes (e.g. style_corpus must use the style-library delete route). """ doc = await db.get(Document, document_id) if doc is None or doc.tenant_id != tenant_id: raise HTTPException(status_code=404, detail="Document not found") if expected_purpose is not None and doc.document_purpose != expected_purpose: raise HTTPException( status_code=400, detail=( "This document is not a RAG report-source upload " "(use DELETE /style-library/{id} for style-library items)." ), ) cnt = await db.execute( select(func.count()).select_from(Report).where(Report.document_id == document_id) ) if int(cnt.scalar_one() or 0) > 0: raise HTTPException( status_code=409, detail=( "This file is still linked to one or more reports. " "Finish or abandon those jobs first, or upload replacements under a new document." ), ) warnings: list[str] = [] chunks_before = 0 chunks_after = 0 vector_deleted = False try: from app.vectorstore.factory import get_vectorstore vs = get_vectorstore() chunks_before = int(vs.count_for_doc(document_id)) try: vs.delete_document(document_id) vector_deleted = True chunks_after = int(vs.count_for_doc(document_id)) if chunks_after > 0: warnings.append( f"Vector index still reports {chunks_after} chunk(s) for this document." ) except Exception as exc: # noqa: BLE001 logger.warning("Vector store delete failed for doc=%s: %s", document_id, exc) warnings.append(f"Vector store deletion error: {exc}") try: chunks_after = int(vs.count_for_doc(document_id)) except Exception: # noqa: BLE001 chunks_after = chunks_before except Exception as exc: # noqa: BLE001 logger.warning("Vector store unavailable during delete doc=%s: %s", document_id, exc) warnings.append(f"Vector store unavailable: {exc}") file_removed = False fp = Path(doc.file_path) try: if fp.is_file(): fp.unlink() file_removed = True except OSError as exc: logger.warning("Could not remove file %s: %s", fp, exc) warnings.append(f"Could not remove file from disk: {exc}") await db.execute(delete(Document).where(Document.id == document_id)) await db.commit() from app.retrieval.semantic_cache import invalidate_semantic_cache_for_tenant from app.services.photo_policy import invalidate_tenant_photo_policy_cache await invalidate_semantic_cache_for_tenant(tenant_id) invalidate_tenant_photo_policy_cache(tenant_id) removed = vector_deleted and (chunks_after == 0) and file_removed detail = "Document removed from disk, database, and search index." if warnings: detail = "Document removed with warnings: " + "; ".join(warnings) return DocumentDeleteResponse( document_id=document_id, removed=removed or not warnings, detail=detail, vector_chunks_removed=max(0, chunks_before - chunks_after), file_removed=file_removed, database_removed=True, warnings=warnings, )