Spaces:
Runtime error
Runtime error
Download app/ingest.py from capcut8128/docs-qa-backend: direct link, hf CLI and curl.
- Browser
- Download file 5.7 kB
-
https://huggingface.co/spaces/capcut8128/docs-qa-backend/resolve/main/app/ingest.py
- Command line
-
hf download hf://spaces/capcut8128/docs-qa-backend/app/ingest.py
-
curl -L -o ingest.py https://huggingface.co/spaces/capcut8128/docs-qa-backend/resolve/main/app/ingest.py
5.7 kB
| """Upload pipeline: parse -> chunk -> embed -> store, run in the background with progress.""" | |
| import hashlib | |
| import io | |
| import logging | |
| import uuid | |
| from pathlib import Path | |
| from pypdf import PdfReader | |
| from . import store | |
| from .bm25 import bm25 | |
| from .chunking import chunk_text | |
| from .config import settings | |
| from .registry import get_registry | |
| log = logging.getLogger("rag.ingest") | |
| ALLOWED = (".pdf", ".txt", ".md") | |
| MIN_TEXT_CHARS = 20 # a page with less text than this counts as "empty" (blank or scanned image) | |
| def parse_file(filename: str, data: bytes, on_progress=None) -> list[tuple[int, str]]: | |
| """Return [(page_number, text), ...]. Page numbers are 1-based PDF page positions.""" | |
| name = filename.lower() | |
| if name.endswith(".pdf"): | |
| try: | |
| reader = PdfReader(io.BytesIO(data)) | |
| if reader.is_encrypted: | |
| reader.decrypt("") | |
| total = len(reader.pages) | |
| except Exception as e: | |
| raise ValueError(f"Could not open this PDF (password-protected or damaged): {e}") | |
| pages = [] | |
| for i, page in enumerate(reader.pages): | |
| try: | |
| text = page.extract_text() or "" | |
| except Exception: | |
| text = "" | |
| pages.append((i + 1, text)) | |
| if on_progress and (i % 10 == 0 or i == total - 1): | |
| on_progress(i + 1, total) | |
| return pages | |
| if name.endswith((".txt", ".md")): | |
| text = data.decode("utf-8", errors="ignore") | |
| return [(i + 1, t) for i, t in enumerate(text.split("\f"))] # form feed = page break | |
| raise ValueError("Unsupported file type. Use PDF, TXT or MD.") | |
| def submit_document(filename: str, data: bytes) -> tuple[dict, bool]: | |
| """Register an upload. Returns (entry, is_duplicate). Call process_document() afterwards.""" | |
| if not filename.lower().endswith(ALLOWED): | |
| raise ValueError("Unsupported file type. Use PDF, TXT or MD.") | |
| if not data: | |
| raise ValueError("The file is empty.") | |
| reg = get_registry() | |
| sha = hashlib.sha256(data).hexdigest() | |
| existing = reg.find_by_hash(sha) | |
| if existing: | |
| return existing, True | |
| entry = reg.create( | |
| { | |
| "doc_id": str(uuid.uuid4()), | |
| "filename": Path(filename).name, | |
| "sha256": sha, | |
| "status": "processing", | |
| "stage": "Queued", | |
| "progress": 0, | |
| "pages": 0, | |
| "chunks": 0, | |
| "empty_pages": [], | |
| "warning": None, | |
| "error": None, | |
| } | |
| ) | |
| return entry, False | |
| def process_document(doc_id: str, filename: str, data: bytes) -> None: | |
| reg = get_registry() | |
| try: | |
| reg.update(doc_id, stage="Reading pages", progress=1) | |
| def parse_progress(done, total): | |
| reg.update(doc_id, progress=1 + int(14 * done / max(total, 1))) | |
| pages = parse_file(filename, data, parse_progress) | |
| empty = [p for p, t in pages if len(t.strip()) < MIN_TEXT_CHARS] | |
| records = [] # (page, chunk_index, text) | |
| for page_no, text in pages: | |
| for chunk in chunk_text(text, settings.chunk_size, settings.chunk_overlap): | |
| records.append((page_no, len(records), chunk)) | |
| if not records: | |
| raise ValueError("No extractable text found. This looks like a scanned PDF (images), which needs OCR.") | |
| warning = None | |
| if len(empty) > 0.3 * len(pages): | |
| warning = ( | |
| f"{len(empty)} of {len(pages)} pages have no readable text (scanned images?). " | |
| "Questions about those pages cannot be answered without OCR." | |
| ) | |
| reg.update(doc_id, pages=len(pages), empty_pages=empty, warning=warning, stage="Embedding", progress=15) | |
| stem = Path(filename).stem | |
| total = len(records) | |
| for i in range(0, total, settings.embed_batch): | |
| batch = records[i : i + settings.embed_batch] | |
| # the header gives the embedding model the document name and page as context | |
| vectors = store.embed([f"{stem} | page {p}\n{t}" for p, _, t in batch]) | |
| points = [ | |
| store.PointStruct( | |
| id=str(uuid.uuid4()), | |
| vector=vec, | |
| payload={"doc_id": doc_id, "filename": filename, "page": p, "chunk_index": idx, "text": t}, | |
| ) | |
| for (p, idx, t), vec in zip(batch, vectors) | |
| ] | |
| store.upsert(points) | |
| done = min(i + len(batch), total) | |
| reg.update(doc_id, progress=15 + int(84 * done / total), chunks=done) | |
| bm25.mark_dirty() | |
| reg.update(doc_id, status="ready", stage="Ready", progress=100, chunks=total) | |
| log.info("Ingested %s: %d pages, %d chunks", filename, len(pages), total) | |
| except Exception as e: | |
| log.exception("Ingest failed for %s", filename) | |
| try: | |
| store.delete_doc(doc_id) | |
| except Exception: | |
| pass | |
| bm25.mark_dirty() | |
| reg.update(doc_id, status="failed", stage="Failed", error=str(e)) | |
| def delete_document(doc_id: str) -> bool: | |
| reg = get_registry() | |
| if not reg.get(doc_id): | |
| return False | |
| store.delete_doc(doc_id) | |
| reg.delete(doc_id) | |
| bm25.mark_dirty() | |
| return True | |
| def recover_interrupted() -> None: | |
| """If the server stopped mid-upload, clean up the half-finished documents.""" | |
| reg = get_registry() | |
| for d in reg.all(): | |
| if d["status"] == "processing": | |
| store.delete_doc(d["doc_id"]) | |
| reg.update(d["doc_id"], status="failed", stage="Failed", error="Interrupted by a server restart. Delete it and upload again.") | |
| bm25.mark_dirty() | |