Spaces:
Runtime error
Runtime error
File size: 5,849 Bytes
865bc90 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | """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,
)
|