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,
    )