Spaces:
Runtime error
Runtime error
| #!/usr/bin/env python3 | |
| """ | |
| FinBot Ingestion Script | |
| ======================== | |
| Runs the full ingestion pipeline on a PDF document: | |
| 1. Parse PDF β text + tables (PyMuPDF + pdfplumber) | |
| 2. Chunk text β semantic chunks | |
| 3. Embed chunks β local BGE embeddings β ChromaDB | |
| 4. Index tables β JSON table store | |
| 5. Render pages β high-quality slide PNG images for display + ColPali | |
| 6. ColPali indexing β multi-vector patch embeddings per page | |
| 7. Save all metadata | |
| Usage: | |
| python ingest.py --pdf "../IR Chatbot/emiratesnbd_investor_presentation_2026_q1.pdf" | |
| python ingest.py --pdf path/to/doc.pdf --doc-id my_doc_id | |
| ColPali note: | |
| First run downloads vidore/colpali-v1.2-merged (~7GB). | |
| Subsequent runs are fast (model cached by HuggingFace). | |
| Runs on Mac MPS (M-series) or CPU. | |
| """ | |
| import argparse | |
| import asyncio | |
| import logging | |
| import json | |
| import sys | |
| import time | |
| from pathlib import Path | |
| # Add backend to path | |
| sys.path.insert(0, str(Path(__file__).parent)) | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s %(levelname)-8s %(name)s %(message)s", | |
| datefmt="%H:%M:%S", | |
| ) | |
| logger = logging.getLogger("finbot.ingest") | |
| BASE_DIR = Path(__file__).parent | |
| DATA_DIR = BASE_DIR / "data" | |
| PAGES_DIR = DATA_DIR / "pages" | |
| COLPALI_DIR = DATA_DIR / "colpali_index" | |
| CHROMA_DIR = DATA_DIR / "chroma" | |
| TABLES_FILE = DATA_DIR / "tables.json" | |
| def ingest_document_sync(doc_id: str, pdf_path: str): | |
| """ | |
| Synchronous ingestion pipeline. | |
| Called from CLI or background task. | |
| """ | |
| pdf_path = Path(pdf_path) | |
| if not pdf_path.exists(): | |
| raise FileNotFoundError(f"PDF not found: {pdf_path}") | |
| doc_name = pdf_path.stem.replace("_", " ").replace("-", " ").title() | |
| total_start = time.time() | |
| logger.info("=" * 60) | |
| logger.info(f"FinBot Ingestion Pipeline") | |
| logger.info(f"Document : {pdf_path.name}") | |
| logger.info(f"Doc ID : {doc_id}") | |
| logger.info("=" * 60) | |
| # ββ Step 1: Parse PDF ββββββββββββββββββββββββββββββββββββββββββββ | |
| logger.info("\n[1/6] Parsing PDF (text + tables)β¦") | |
| from services.ingestion.pdf_parser import parse_pdf, save_extraction | |
| t0 = time.time() | |
| extracted = parse_pdf(pdf_path, doc_id, doc_name) | |
| logger.info( | |
| f" β {extracted.total_pages} pages parsed in {time.time()-t0:.1f}s" | |
| f" | Tables on {sum(1 for p in extracted.pages if p.has_table)} pages" | |
| ) | |
| # Save extraction JSON for debugging | |
| save_extraction(extracted, DATA_DIR / "processed") | |
| # ββ Step 2: Chunk text βββββββββββββββββββββββββββββββββββββββββββ | |
| logger.info("\n[2/6] Chunking textβ¦") | |
| from services.ingestion.text_chunker import TextChunker | |
| t0 = time.time() | |
| chunker = TextChunker(chunk_size=300, chunk_overlap=60) | |
| page_texts = [ | |
| { | |
| "page_number": p.page_number, | |
| "text": p.text, | |
| "section_heading": getattr(p, "section_heading", ""), | |
| "slide_number": getattr(p, "slide_number", p.page_number), | |
| } | |
| for p in extracted.pages if p.text.strip() | |
| ] | |
| chunks = chunker.chunk_document(page_texts, doc_id) | |
| logger.info(f" β {len(chunks)} chunks in {time.time()-t0:.1f}s") | |
| # ββ Step 3: Embed + index text in ChromaDB βββββββββββββββββββββββ | |
| logger.info("\n[3/6] Embedding text chunks (local BGE model)β¦") | |
| from services.retrieval.text_retriever import TextRetriever | |
| t0 = time.time() | |
| chunks = chunker.embed_chunks(chunks) | |
| text_retriever = TextRetriever(persist_dir=CHROMA_DIR) | |
| n_indexed = text_retriever.index_chunks(chunks) | |
| logger.info(f" β {n_indexed} chunks indexed in ChromaDB | {time.time()-t0:.1f}s") | |
| # ββ Step 4: Index tables βββββββββββββββββββββββββββββββββββββββββ | |
| logger.info("\n[4/6] Indexing tablesβ¦") | |
| from services.retrieval.table_retriever import TableRetriever | |
| t0 = time.time() | |
| table_retriever = TableRetriever(TABLES_FILE) | |
| n_tables = table_retriever.index_tables(extracted) | |
| logger.info(f" β {n_tables} tables indexed in {time.time()-t0:.1f}s") | |
| # ββ Step 5: Render PDF pages to images βββββββββββββββββββββββββββ | |
| logger.info("\n[5/6] Rendering PDF pages as high-quality slide imagesβ¦") | |
| from services.ingestion.page_renderer import render_pdf_pages | |
| t0 = time.time() | |
| PAGES_DIR.mkdir(parents=True, exist_ok=True) | |
| page_records = render_pdf_pages( | |
| pdf_path=pdf_path, | |
| output_dir=PAGES_DIR, | |
| doc_id=doc_id, | |
| ) | |
| logger.info( | |
| f" β {len(page_records)} pages rendered in {time.time()-t0:.1f}s" | |
| f" β {PAGES_DIR / doc_id / 'pages'}" | |
| ) | |
| # ββ Step 6: ColPali indexing ββββββββββββββββββββββββββββββββββββββββββ | |
| logger.info("\n[6/6] Generating ColPali patch embeddingsβ¦") | |
| logger.info(" (First run downloads ~7GB model β cached after that)") | |
| logger.info(" Running on: Mac MPS or CPU (batch_size=1 to avoid OOM)") | |
| import os, torch | |
| if torch.backends.mps.is_available(): | |
| os.environ["PYTORCH_MPS_HIGH_WATERMARK_RATIO"] = "0.0" | |
| logger.info(" MPS detected β disabled memory watermark limit") | |
| from services.ingestion.colpali_indexer import ColPaliIndexer | |
| t0 = time.time() | |
| colpali_indexer = ColPaliIndexer(store_dir=COLPALI_DIR) | |
| page_records = colpali_indexer.index_pages( | |
| page_records=page_records, | |
| doc_id=doc_id, | |
| batch_size=1, # Always 1 on MPS β OOM otherwise | |
| ) | |
| elapsed = time.time() - t0 | |
| logger.info( | |
| f" β {len(page_records)} pages ColPali-indexed in {elapsed:.1f}s" | |
| f" (~{elapsed/len(page_records):.1f}s/page)" | |
| ) | |
| # ββ Save document metadata ββββββββββββββββββββββββββββββββββββββββ | |
| docs_file = DATA_DIR / "documents.json" | |
| if docs_file.exists(): | |
| with open(docs_file) as f: | |
| docs = json.load(f) | |
| else: | |
| docs = [] | |
| # Remove old entry for this doc_id | |
| docs = [d for d in docs if d["doc_id"] != doc_id] | |
| docs.append({ | |
| "doc_id": doc_id, | |
| "name": doc_name, | |
| "doc_type": extracted.metadata.get("doc_type", "Financial Document"), | |
| "period": extracted.metadata.get("period", "Unknown"), | |
| "institution": extracted.metadata.get("institution", "Emirates NBD"), | |
| "total_pages": extracted.total_pages, | |
| "status": "indexed", | |
| "filename": pdf_path.name, | |
| "chunks_indexed": n_indexed, | |
| "tables_indexed": n_tables, | |
| "colpali_pages": len(page_records), | |
| }) | |
| DATA_DIR.mkdir(parents=True, exist_ok=True) | |
| with open(docs_file, "w") as f: | |
| json.dump(docs, f, indent=2) | |
| total_elapsed = time.time() - total_start | |
| logger.info("\n" + "=" * 60) | |
| logger.info(f"β Ingestion complete in {total_elapsed:.1f}s") | |
| logger.info(f" Text chunks : {n_indexed}") | |
| logger.info(f" Tables : {n_tables}") | |
| logger.info(f" Pages imaged : {len(page_records)}") | |
| logger.info(f" ColPali pages: {len(page_records)}") | |
| logger.info("=" * 60) | |
| logger.info("\nStart the backend: uvicorn app.main:app --reload --port 8000") | |
| logger.info("Start the frontend: npm run dev (in finbot-ir-platform/)") | |
| async def ingest_document(doc_id: str, pdf_path: str): | |
| """Async wrapper for background task usage.""" | |
| loop = asyncio.get_event_loop() | |
| await loop.run_in_executor(None, ingest_document_sync, doc_id, pdf_path) | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser( | |
| description="IRIS PDF Ingestion β text + tables + ColPali visual indexing" | |
| ) | |
| parser.add_argument( | |
| "--pdf", | |
| default=None, | |
| help="Path to the PDF file to ingest (if omitted, scans the documents/ folder)", | |
| ) | |
| parser.add_argument( | |
| "--doc-id", | |
| default=None, | |
| help="Document ID (default: derived from filename)", | |
| ) | |
| parser.add_argument( | |
| "--skip-colpali", | |
| action="store_true", | |
| help="Skip ColPali indexing (faster, but no visual retrieval)", | |
| ) | |
| args = parser.parse_args() | |
| if args.pdf: | |
| pdf = Path(args.pdf) | |
| doc_id = args.doc_id or pdf.stem.lower().replace(" ", "_").replace("-", "_") | |
| logger.info(f"Ingesting specific file: {pdf.name} with doc_id: {doc_id}") | |
| ingest_document_sync(doc_id, str(pdf)) | |
| else: | |
| # Scan the documents/ directory for PDF files | |
| documents_dir = BASE_DIR.parent / "documents" | |
| if not documents_dir.exists(): | |
| documents_dir.mkdir(parents=True, exist_ok=True) | |
| logger.info(f"Created documents/ folder. Please place PDF files in: {documents_dir}") | |
| sys.exit(0) | |
| pdf_files = list(documents_dir.glob("*.pdf")) | |
| if not pdf_files: | |
| logger.info(f"No PDF files found in documents/ folder: {documents_dir}") | |
| sys.exit(0) | |
| # Check existing metadata to skip already indexed files | |
| docs_file = DATA_DIR / "documents.json" | |
| indexed_filenames = set() | |
| if docs_file.exists(): | |
| try: | |
| with open(docs_file) as f: | |
| docs = json.load(f) | |
| for d in docs: | |
| if d.get("status") == "indexed": | |
| indexed_filenames.add(d.get("filename")) | |
| except Exception as e: | |
| logger.warning(f"Could not load documents.json: {e}") | |
| logger.info(f"Scanning documents/ folder. Found {len(pdf_files)} PDF files.") | |
| new_files = [f for f in pdf_files if f.name not in indexed_filenames] | |
| if not new_files: | |
| logger.info("All PDF files in documents/ are already indexed. Nothing to do!") | |
| sys.exit(0) | |
| logger.info(f"Found {len(new_files)} new files to ingest.") | |
| for pdf in new_files: | |
| doc_id = pdf.stem.lower().replace(" ", "_").replace("-", "_") | |
| logger.info(f"\n>>> Starting ingestion for new file: {pdf.name} (ID: {doc_id})") | |
| try: | |
| ingest_document_sync(doc_id, str(pdf)) | |
| except Exception as e: | |
| logger.error(f"β Failed to ingest {pdf.name}: {e}") | |