RICS / app /services /document_library.py
StormShadow308's picture
Ship production RAG hardening: citation extraction, full-library retrieval, auth.
865bc90
Raw
History Blame Contribute Delete
5.85 kB
"""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,
)