Spaces:
Sleeping
Sleeping
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)
|