Spaces:
Runtime error
Runtime error
| """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, | |
| ) | |