docs-qa-backend / app /ingest.py
capcut8128's picture
Upload 12 files
0a19322 verified
Raw History Blame Contribute Delete
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()