Spaces:
Runtime error
Runtime error
| import os | |
| import re | |
| import hashlib | |
| import threading | |
| from pathlib import Path | |
| from typing import Optional | |
| # PDF Extraction | |
| try: | |
| from pypdf import PdfReader # preferred | |
| except ImportError: | |
| from PyPDF2 import PdfReader # fallback | |
| # DOCUMENT STORAGE | |
| # Stores extracted documents: { doc_id: { "filename", "chunks", "metadata" } } | |
| documents_db = {} | |
| doc_lock = threading.Lock() | |
| # Chunking configuration | |
| CHUNK_SIZE = 500 # characters per chunk | |
| CHUNK_OVERLAP = 100 # overlap between consecutive chunks to preserve context | |
| # Internal helpers | |
| def _generate_doc_id(filename: str, content: str) -> str: | |
| """Create a stable, unique ID from filename + content hash.""" | |
| digest = hashlib.md5((filename + content[:200]).encode()).hexdigest()[:10] | |
| return f"{Path(filename).stem}_{digest}" | |
| def _chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[dict]: | |
| """ | |
| Split *text* into overlapping fixed-size chunks, preserving the source | |
| page number for each chunk. | |
| Each chunk is a dict: { "text": str, "page": int } | |
| Strategy | |
| -------- | |
| 1. Split the raw text on [Page N] markers produced by extract_text_from_pdf. | |
| 2. For each page block, slide a fixed-size window (with overlap) to | |
| produce sub-chunks, all tagged with the page number they came from. | |
| """ | |
| chunks = [] | |
| # re.split with a capturing group keeps the delimiters in the result list: | |
| # ['', '1', 'page1 text...', '2', 'page2 text...', ...] | |
| page_blocks = re.split(r'\[Page (\d+)\]', text) | |
| for i in range(1, len(page_blocks) - 1, 2): | |
| page_num = int(page_blocks[i]) | |
| page_text = page_blocks[i + 1].strip() | |
| start = 0 | |
| while start < len(page_text): | |
| end = start + chunk_size | |
| chunk_text = page_text[start:end].strip() | |
| if chunk_text: | |
| chunks.append({"text": chunk_text, "page": page_num}) | |
| start += chunk_size - overlap | |
| return chunks | |
| # Public API | |
| def extract_text_from_pdf(file_path: str) -> str: | |
| """ | |
| Extract all text from a PDF file using pypdf (or PyPDF2 as fallback). | |
| Each page is prefixed with a [Page N] marker so that downstream | |
| chunking can recover the source page number. | |
| Returns the concatenated text of all pages, or an error string if | |
| extraction fails. | |
| """ | |
| path = Path(file_path) | |
| if not path.exists(): | |
| return f"Error: File not found — {file_path}" | |
| if path.suffix.lower() != ".pdf": | |
| return f"Error: Expected a .pdf file, got '{path.suffix}'." | |
| try: | |
| reader = PdfReader(str(path)) | |
| pages_text = [] | |
| for i, page in enumerate(reader.pages): | |
| page_text = page.extract_text() or "" | |
| if page_text.strip(): | |
| # [Page N] marker is parsed by _chunk_text — keep format stable | |
| pages_text.append(f"[Page {i + 1}]\n{page_text.strip()}") | |
| if not pages_text: | |
| return "Warning: No extractable text found. The PDF may be scanned/image-based." | |
| return "\n\n".join(pages_text) | |
| except Exception as e: | |
| return f"Error extracting PDF: {str(e)}" | |
| def process_document(file_path: str, filename: Optional[str] = None) -> dict: | |
| """ | |
| Full ingestion pipeline: extract → chunk (with page metadata) → store. | |
| Parameters | |
| ---------- | |
| file_path : str | |
| Absolute or relative path to the PDF file. | |
| filename : str, optional | |
| Display name for the document. Defaults to the file's basename. | |
| Returns | |
| ------- | |
| dict with keys: status, doc_id, filename, num_chunks | |
| """ | |
| filename = filename or Path(file_path).name | |
| # 1. Extract raw text (with [Page N] markers) | |
| raw_text = extract_text_from_pdf(file_path) | |
| if raw_text.startswith("Error"): | |
| return {"status": "error", "message": raw_text} | |
| # 2. Chunk into page-aware dicts | |
| chunks = _chunk_text(raw_text) | |
| # 3. Stable document ID | |
| doc_id = _generate_doc_id(filename, raw_text) | |
| # 4. Thread-safe storage | |
| with doc_lock: | |
| documents_db[doc_id] = { | |
| "filename": filename, | |
| "raw_text": raw_text, | |
| "chunks": chunks, # list[dict] {"text", "page"} | |
| "metadata": { | |
| "file_path": file_path, | |
| "num_pages": raw_text.count("[Page "), | |
| "num_chunks": len(chunks), | |
| "chunk_size": CHUNK_SIZE, | |
| "chunk_overlap": CHUNK_OVERLAP, | |
| }, | |
| } | |
| print(f"[DocumentHub] Processed '{filename}' → {len(chunks)} chunks (doc_id={doc_id})") | |
| return { | |
| "status": "success", | |
| "doc_id": doc_id, | |
| "filename": filename, | |
| "num_chunks": len(chunks), | |
| } | |
| def get_document(doc_id: str) -> Optional[dict]: | |
| """Return the stored document record, or None if not found.""" | |
| with doc_lock: | |
| return documents_db.get(doc_id) | |
| def list_documents() -> list[dict]: | |
| """Return a summary list of all processed documents.""" | |
| with doc_lock: | |
| return [ | |
| { | |
| "doc_id": doc_id, | |
| "filename": doc["filename"], | |
| "num_chunks": doc["metadata"]["num_chunks"], | |
| } | |
| for doc_id, doc in documents_db.items() | |
| ] | |
| def delete_document(doc_id: str) -> bool: | |
| """Remove a document from the store. Returns True if it existed.""" | |
| with doc_lock: | |
| if doc_id in documents_db: | |
| del documents_db[doc_id] | |
| return True | |
| return False | |