File size: 10,824 Bytes
aad7814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
"""Reference document persistence, delete, and re-ingest."""

from __future__ import annotations

import logging
import shutil
import threading
import time
from datetime import UTC, datetime
from pathlib import Path

from backend.config import settings
from backend.core import ingest
from backend.core.rag_store import TIER_REFERENCE, get_rag_store
from backend.core.report_session import (
    UploadedDocument,
    delete_document,
    get_document,
    list_documents,
    save_document,
)
from backend.core.style_profile import invalidate_style_profile
from backend.utils import tenant_store

logger = logging.getLogger(__name__)

_reingest_lock = threading.Lock()
_reingest_running: set[str] = set()


def is_reingest_running(tenant_id: str) -> bool:
    with _reingest_lock:
        return tenant_id in _reingest_running


def recover_stale_processing_documents(tenant_id: str) -> int:
    """Reset orphaned ``processing`` rows when no re-ingest worker is active."""
    if is_reingest_running(tenant_id):
        return 0
    recovered = 0
    for _doc_id, doc in list_documents(tenant_id).items():
        if doc.status == "processing":
            doc.status = "complete"
            doc.error = None
            save_document(tenant_id, doc)
            recovered += 1
    if recovered:
        logger.info(
            "Recovered %d stale processing document(s) for tenant=%s",
            recovered,
            tenant_id,
        )
    return recovered


def recover_all_tenants_stale_processing() -> int:
    """On startup, clear processing flags left by a killed background worker."""
    tenants_root = settings.data_dir_path / "tenants"
    if not tenants_root.is_dir():
        return 0
    total = 0
    for tenant_dir in sorted(tenants_root.iterdir()):
        if not tenant_dir.is_dir():
            continue
        if not (tenant_dir / "compat_documents.json").is_file():
            continue
        total += recover_stale_processing_documents(tenant_dir.name)
    return total


def reingest_progress(tenant_id: str) -> dict:
    docs = list_documents(tenant_id)
    counts = {"complete": 0, "processing": 0, "failed": 0, "pending": 0}
    for doc in docs.values():
        key = doc.status if doc.status in counts else "pending"
        counts[key] = counts.get(key, 0) + 1
    return {
        "total": len(docs),
        "running": is_reingest_running(tenant_id),
        **counts,
    }


def document_created_at_iso(doc: UploadedDocument) -> str:
    """Serialize ``created_at`` for API responses (ISO-8601 UTC)."""
    ts = doc.created_at
    if isinstance(ts, (int, float)) and ts > 0:
        return datetime.fromtimestamp(ts, tz=UTC).isoformat()
    if isinstance(ts, str) and ts.strip():
        return ts.strip()
    return datetime.now(tz=UTC).isoformat()


def persist_reference_file(
    tenant_id: str,
    document_id: str,
    source_path: Path,
    *,
    original_filename: str,
) -> Path:
    """Copy an uploaded file into tenant storage for later re-ingest."""
    suffix = source_path.suffix.lower() or Path(original_filename).suffix.lower()
    dest = tenant_store.reference_upload_path(tenant_id, document_id, suffix)
    shutil.copy2(source_path, dest)
    return dest


def ingest_and_register(
    tenant_id: str,
    source_path: Path,
    *,
    original_filename: str,
    document_id: str | None = None,
) -> UploadedDocument:
    """Ingest a reference file and record it in the document library."""
    from backend.core.report_session import new_document_id

    doc_id = document_id or new_document_id()
    stored = persist_reference_file(
        tenant_id, doc_id, source_path, original_filename=original_filename
    )
    chunks = ingest.ingest_reference(tenant_id, stored)
    doc = UploadedDocument(
        document_id=doc_id,
        filename=original_filename or stored.name,
        status="complete",
        ingested_chunks=chunks,
        storage_path=str(stored),
        file_size=stored.stat().st_size if stored.is_file() else 0,
        created_at=time.time(),
    )
    save_document(tenant_id, doc)
    invalidate_style_profile(tenant_id)
    return doc


def remove_reference_document(tenant_id: str, document_id: str) -> int:
    """Remove chunks, stored file, and library record. Returns chunks removed."""
    doc = get_document(tenant_id, document_id)
    if doc is None:
        raise KeyError("Document not found")

    removed = get_rag_store().remove_document(
        tenant_id,
        TIER_REFERENCE,
        source_filename=doc.filename,
        doc_id=f"reference:{doc.filename}",
    )
    if doc.storage_path:
        path = Path(doc.storage_path)
        if path.is_file():
            path.unlink(missing_ok=True)
    delete_document(tenant_id, document_id)
    invalidate_style_profile(tenant_id)
    return removed


def reingest_reference_document(tenant_id: str, document_id: str) -> UploadedDocument:
    """Re-process one stored reference file through the current pipeline."""
    doc = get_document(tenant_id, document_id)
    if doc is None:
        raise KeyError("Document not found")

    path = Path(doc.storage_path) if doc.storage_path else None
    if path is None or not path.is_file():
        raise FileNotFoundError("Source file is no longer on disk; cannot re-ingest.")

    doc.status = "processing"
    doc.error = None
    save_document(tenant_id, doc)

    get_rag_store().remove_document(
        tenant_id,
        TIER_REFERENCE,
        source_filename=doc.filename,
        doc_id=f"reference:{doc.filename}",
    )
    chunks = ingest.ingest_reference(tenant_id, path)
    doc.status = "complete"
    doc.error = None
    doc.ingested_chunks = chunks
    doc.file_size = path.stat().st_size
    save_document(tenant_id, doc)
    invalidate_style_profile(tenant_id)
    return doc


def reingest_all_documents(
    tenant_id: str,
    *,
    skip_document_ids: set[str] | None = None,
) -> dict:
    skip = skip_document_ids or set()
    docs = list_documents(tenant_id)
    queued: list[str] = []
    skipped_missing = 0
    skipped_active = 0

    for doc_id, doc in docs.items():
        if doc_id in skip:
            skipped_active += 1
            continue
        try:
            logger.info("Re-ingesting %s for tenant=%s", doc.filename, tenant_id)
            updated = reingest_reference_document(tenant_id, doc_id)
            queued.append(doc_id)
            logger.info(
                "Re-ingested %s (%d chunks)",
                updated.filename,
                updated.ingested_chunks,
            )
        except FileNotFoundError:
            skipped_missing += 1
        except Exception as exc:  # noqa: BLE001
            doc.status = "failed"
            doc.error = str(exc)
            save_document(tenant_id, doc)

    return {
        "queued": len(queued),
        "document_ids": queued,
        "skipped_active": skipped_active,
        "skipped_missing_file": skipped_missing,
        "detail": (
            f"Re-ingested {len(queued)} document(s); "
            f"skipped {skipped_active} blocked and {skipped_missing} missing-file."
        ),
    }


def schedule_reingest_all_documents(
    tenant_id: str,
    *,
    skip_document_ids: set[str] | None = None,
) -> dict:
    """Queue a full-library re-ingest on a background thread (non-blocking HTTP).

    Only the document currently being embedded is marked ``processing`` so a
    server restart cannot strand the whole library in that state.
    """
    skip = skip_document_ids or set()
    docs = list_documents(tenant_id)
    to_queue = [doc_id for doc_id in docs if doc_id not in skip]

    with _reingest_lock:
        if tenant_id in _reingest_running:
            progress = reingest_progress(tenant_id)
            return {
                "queued": 0,
                "document_ids": [],
                "skipped_active": len(skip),
                "skipped_missing_file": 0,
                "reingest_running": True,
                "progress": progress,
                "detail": (
                    f"Re-ingest already running "
                    f"({progress['processing']} processing, "
                    f"{progress['complete']} ready)."
                ),
            }
        _reingest_running.add(tenant_id)

    # Clear orphaned processing flags from a prior killed worker.
    recover_stale_processing_documents(tenant_id)

    def _worker() -> None:
        try:
            logger.info(
                "Background re-ingest started for tenant=%s (%d documents)",
                tenant_id,
                len(to_queue),
            )
            reingest_all_documents(tenant_id, skip_document_ids=skip)
        except Exception:  # noqa: BLE001
            logger.exception("Background re-ingest failed for tenant=%s", tenant_id)
            recover_stale_processing_documents(tenant_id)
        finally:
            with _reingest_lock:
                _reingest_running.discard(tenant_id)
            logger.info("Background re-ingest finished for tenant=%s", tenant_id)

    threading.Thread(
        target=_worker,
        name=f"reingest-{tenant_id}",
        daemon=True,
    ).start()

    progress = reingest_progress(tenant_id)
    return {
        "queued": len(to_queue),
        "document_ids": to_queue,
        "skipped_active": len(skip),
        "skipped_missing_file": 0,
        "reingest_running": True,
        "progress": progress,
        "detail": (
            f"Re-ingest started in the background for {len(to_queue)} document(s). "
            "Status updates every few seconds as each file completes."
        ),
    }


def schedule_reingest_reference_document(tenant_id: str, document_id: str) -> dict:
    """Queue a single-document re-ingest on a background thread."""
    doc = get_document(tenant_id, document_id)
    if doc is None:
        raise KeyError("Document not found")

    path = Path(doc.storage_path) if doc.storage_path else None
    if path is None or not path.is_file():
        raise FileNotFoundError("Source file is no longer on disk; cannot re-ingest.")

    doc.status = "processing"
    doc.error = None
    save_document(tenant_id, doc)

    def _worker() -> None:
        try:
            reingest_reference_document(tenant_id, document_id)
        except Exception as exc:  # noqa: BLE001
            failed = get_document(tenant_id, document_id)
            if failed is not None:
                failed.status = "failed"
                failed.error = str(exc)
                save_document(tenant_id, failed)

    threading.Thread(
        target=_worker,
        name=f"reingest-{tenant_id}-{document_id[:8]}",
        daemon=True,
    ).start()

    return {
        "queued": 1,
        "document_ids": [document_id],
        "detail": f"Re-ingest started in the background for {doc.filename}.",
    }