File size: 9,351 Bytes
49f0cfb
dc1b199
49f0cfb
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
3f046da
faa8fb3
b76f199
faa8fb3
dc1b199
 
49f0cfb
 
dc1b199
 
49f0cfb
 
b76f199
dc1b199
 
e561e67
dc1b199
 
 
 
 
 
49f0cfb
dc1b199
49f0cfb
 
 
 
dc1b199
49f0cfb
 
dc1b199
 
 
 
 
 
 
49f0cfb
dc1b199
 
 
49f0cfb
dc1b199
 
 
 
 
 
 
b76f199
dc1b199
3f046da
 
 
 
 
 
 
e561e67
 
 
 
 
 
 
be9fd4a
e561e67
 
 
 
 
 
 
b76f199
 
3f046da
b76f199
 
3f046da
 
 
b76f199
 
 
 
 
 
 
3f046da
b76f199
3f046da
 
 
 
 
 
 
 
 
dc1b199
 
 
 
 
 
 
3f046da
dc1b199
49f0cfb
e561e67
 
 
 
 
 
 
 
 
faa8fb3
3f046da
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
faa8fb3
 
 
 
 
 
 
 
 
 
dc1b199
 
b76f199
 
732b14f
b76f199
 
732b14f
b76f199
dc1b199
 
49f0cfb
 
dc1b199
 
 
 
 
49f0cfb
dc1b199
 
 
 
 
 
 
 
 
 
49f0cfb
 
569bceb
 
 
 
 
 
 
 
 
 
 
 
b76f199
 
49f0cfb
 
b76f199
49f0cfb
b76f199
faa8fb3
b76f199
 
 
49f0cfb
b76f199
49f0cfb
 
 
b76f199
49f0cfb
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Ingestion pipeline: load β†’ split β†’ enrich metadata β†’ embed + index.

LangChain experiment branch β€” replaces the master pipeline with a
streamlined LangChain-native flow:

  file
    β†’ LangChain Loader  (Docx2txtLoader | PyMuPDFLoader)
    β†’ RecursiveCharacterTextSplitter (tiktoken-based)
    β†’ metadata enrichment  (chunk_id, doc_id, tenant_id, section_type)
    β†’ VectorStore.add_documents()  (LangChain store handles embedding)

Key differences from master branch:
- No manual embedding step β€” the LangChain vectorstore embeds internally.
- No ParsedBlock / normalizer intermediary β€” LangChain Documents flow through.
- Chunk metadata is attached in this pipeline (not in the splitter).
"""

import asyncio
import logging
import uuid
from datetime import UTC, datetime
from pathlib import Path

from langchain_core.documents import Document

from app.config import settings
from app.db.database import get_session_factory
from app.db.models import Document as DBDocument
from app.db.models import IngestStatus
from app.ingest.hierarchy import build_hierarchical_documents
from app.ingest.parser_docx import parse_docx
from app.ingest.parser_pdf import parse_pdf
from app.services.document_sanitiser import DocumentSanitisationError
from app.vectorstore.factory import get_vectorstore

logger = logging.getLogger(__name__)


async def ingest_document(doc_id: str, file_path: Path) -> None:
    """Run the full LangChain ingestion pipeline for a single document.

    Loads the file with the appropriate LangChain loader, splits into
    token-bounded chunks, enriches each chunk with provenance metadata,
    then calls ``VectorStore.add_documents()`` which handles embedding
    and indexing in one step.

    Updates ``Document.status`` to ``complete`` on success or ``failed`` on
    error.

    Args:
        doc_id: Primary key of the ``Document`` row to update.
        file_path: Absolute path to the uploaded file on disk.

    Example::

        await ingest_document("abc-123", Path("/uploads/t/abc-123.pdf"))
    """
    factory = get_session_factory()
    async with factory() as db:
        doc: DBDocument | None = await db.get(DBDocument, doc_id)
        if doc is None:
            logger.error("Document %s not found in DB β€” skipping ingestion", doc_id)
            return

        doc.status = IngestStatus.processing
        await db.commit()

        tenant_id_for_photo_cache = doc.tenant_id
        try:
            logger.info("Ingestion start doc=%s tenant=%s file=%s", doc_id, doc.tenant_id, file_path.name)

            def _run_sync_pipeline() -> int:
                # ── 1. Load ───────────────────────────────────────────────────
                raw_docs = _load_file(file_path)
                logger.debug("doc=%s loaded %d raw document(s)", doc_id, len(raw_docs))

                from app.services.document_sanitiser import (
                    sanitise_langchain_documents_sync,
                    should_sanitise_for_rag,
                )

                if should_sanitise_for_rag(doc.tenant_id):
                    logger.info(
                        "Personalised RAG: sanitising doc=%s tenant=%s before private index",
                        doc_id,
                        doc.tenant_id,
                    )
                    raw_docs = sanitise_langchain_documents_sync(
                        raw_docs, tenant_id=doc.tenant_id
                    )

                # ── 2. Hierarchical chunks (document β†’ section β†’ paragraph) ───
                hier = build_hierarchical_documents(
                    raw_docs,
                    doc_id=doc_id,
                    filename=doc.filename or file_path.name,
                    chunk_size=settings.chunk_size,
                    chunk_overlap=settings.chunk_overlap,
                )
                enriched = _stamp_chunk_metadata(
                    hier,
                    doc_id=doc_id,
                    tenant_id=doc.tenant_id,
                    survey_level=doc.survey_level,
                )
                logger.debug("doc=%s produced %d hierarchical rows", doc_id, len(enriched))

                # ── 3. Embed + index ──────────────────────────────────────────
                vs = get_vectorstore()
                vs.add_documents(enriched)
                return len(enriched)

            # Run the heavy pipeline off the event loop and enforce a timeout so
            # the UI doesn't hang forever if embeddings/indexing stalls.
            chunks_count = await asyncio.wait_for(
                asyncio.to_thread(_run_sync_pipeline),
                timeout=float(settings.ingest_timeout_seconds),
            )

            doc.status = IngestStatus.complete
            logger.info(
                "Ingestion complete doc=%s tenant=%s chunks=%d",
                doc_id,
                doc.tenant_id,
                chunks_count,
            )

        except DocumentSanitisationError as exc:
            logger.error(
                "Ingestion blocked by sanitisation doc=%s tenant=%s: %s",
                doc_id,
                doc.tenant_id,
                exc,
            )
            doc.status = IngestStatus.failed
            doc.error_message = f"Document sanitisation failed: {exc}"
        except TimeoutError:
            logger.error(
                "Ingestion timed out doc=%s tenant=%s after %ss",
                doc_id,
                doc.tenant_id,
                settings.ingest_timeout_seconds,
            )
            doc.status = IngestStatus.failed
            doc.error_message = (
                f"Ingestion timed out after {settings.ingest_timeout_seconds}s. "
                "Try a smaller file, or check embedding/indexing configuration."
            )
        except Exception as exc:
            logger.exception("Ingestion failed for doc=%s: %s", doc_id, exc)
            doc.status = IngestStatus.failed
            msg = str(exc)
            # ChromaDB-only wording; this repository uses FAISS only β€” stale process or old install.
            if "Queue enqueue failed" in msg or "Timeout connecting to server" in msg:
                msg += (
                    " [This text is from ChromaDB, which this app no longer uses. "
                    "Stop every uvicorn/python instance, run `pip install -e .` in the project root, "
                    "then start a single fresh server. Open GET /health and check "
                    "vectorstore_backend is \"faiss\".]"
                )
            doc.error_message = msg

        await db.commit()
        if doc.status == IngestStatus.complete:
            # Lazy import avoids a circular import (photo_policy_corpus β†’ knowledge_base β†’ pipeline).
            from app.retrieval.semantic_cache import invalidate_semantic_cache_for_tenant
            from app.services.photo_policy_corpus import invalidate_tenant_photo_policy_cache

            await invalidate_semantic_cache_for_tenant(doc.tenant_id)
            invalidate_tenant_photo_policy_cache(tenant_id_for_photo_cache)


def _load_file(file_path: Path) -> list[Document]:
    """Dispatch to the LangChain loader for the given file type.

    Args:
        file_path: Path to a ``.docx`` or ``.pdf`` file.

    Returns:
        List of raw LangChain :class:`Document` objects.

    Raises:
        ValueError: If the file type is not supported.
    """
    suffix = file_path.suffix.lower()
    if suffix == ".docx":
        return parse_docx(file_path)
    if suffix == ".pdf":
        return parse_pdf(file_path)
    raise ValueError(f"Unsupported file type: '{suffix}'")


def load_raw_documents(file_path: Path) -> list[Document]:
    """Load ``file_path`` with the same loaders as ingestion (canonical apply, tools).

    Args:
        file_path: Path to a supported upload (``.docx`` / ``.pdf``).

    Returns:
        Raw LangChain documents before splitting.
    """
    return _load_file(file_path)


def _stamp_chunk_metadata(
    documents: list[Document],
    doc_id: str,
    tenant_id: str,
    survey_level: int | None = None,
) -> list[Document]:
    """Stamp ``chunk_id``, ``doc_id``, ``tenant_id``, and ``created_at`` on each row."""
    created_at = datetime.now(UTC).isoformat()
    for chunk in documents:
        proposed = chunk.metadata.pop("proposed_chunk_id", None)
        cid = proposed if proposed else f"{doc_id}_{uuid.uuid4().hex[:16]}"
        chunk.metadata.update({
            "chunk_id": cid,
            "doc_id": doc_id,
            "tenant_id": tenant_id,
            "created_at": created_at,
            "section_type": chunk.metadata.get("section_type", "paragraph"),
        })
        if survey_level is not None:
            chunk.metadata["survey_level"] = int(survey_level)
    return documents


def stamp_chunks_for_index(
    documents: list[Document],
    doc_id: str,
    tenant_id: str,
    survey_level: int | None = None,
) -> list[Document]:
    """Stamp provenance metadata before :meth:`VectorStore.add_documents` (ingest or runtime RAG)."""
    return _stamp_chunk_metadata(documents, doc_id=doc_id, tenant_id=tenant_id, survey_level=survey_level)