#!/usr/bin/env python3 """ Offline CDMS index builder — the reproducible source of truth. Ingests every PDF in data/pdfs/cdms/, extracts + chunks + BATCH-embeds them, writes the SQLite metadata DB (data/cdms_metadata.db) and the Qdrant vector index, then exports a portable Qdrant snapshot (data/qdrant_snapshots/) that is committed and restored into the served Docker Qdrant at deploy. This replaces the ad-hoc reprocess_pdfs.py. Unlike the old script it: * points at the real CDMS folder (data/pdfs/cdms, not data/pdfs), * injects the filename->cdms.net URL manifest so citations keep the source link, * validates that PDFs == documents == distinct Qdrant docs and points == chunks, * exports a server-format snapshot for the two-process Docker deploy. Usage: # Build into a running Docker Qdrant (recommended) and export a snapshot: docker run -d -p 6333:6333 -v "$PWD/data/qdrant_snapshots:/qdrant/snapshots" qdrant/qdrant python scripts/build_index.py --force # Build into the on-disk local store instead (single-process only, no snapshot): python scripts/build_index.py --force --local Flags: --force reprocess every PDF from scratch (REQUIRED for the first real build, because the shipped index is stale/partial: 26 of 53 PDFs indexed). --local build into the on-disk Qdrant (QDRANT_FORCE_LOCAL) instead of Docker. No portable snapshot is produced in this mode. """ import argparse import json import sqlite3 import sys from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(PROJECT_ROOT)) def load_url_manifest(manifest_path: Path) -> dict: """Return {filename: cdms.net URL}; empty (with a warning) if absent.""" if not manifest_path.exists(): print(f"āš ļø No URL manifest at {manifest_path}") print(" Citations will fall back to filename + page (no source link).") return {} try: data = json.loads(manifest_path.read_text()) print(f"šŸ”— Loaded {len(data)} URL mapping(s) from {manifest_path.name}") return data except Exception as e: print(f"āš ļø Could not read URL manifest ({e}); continuing without URLs.") return {} def build(force: bool, local: bool): import os # A build must never trip the served-mode fail-fast. os.environ.pop("QDRANT_REQUIRE_DOCKER", None) if local: os.environ["QDRANT_FORCE_LOCAL"] = "1" from src.config.paths import PDF_DIR, URL_MANIFEST_PATH from src.cdms.document_loader import DocumentLoader if not PDF_DIR.exists(): sys.exit(f"āŒ PDF folder not found: {PDF_DIR}") pdfs = sorted(PDF_DIR.glob("*.pdf")) if not pdfs: sys.exit(f"āŒ No PDFs in {PDF_DIR}") urls = load_url_manifest(URL_MANIFEST_PATH) missing_urls = [p.name for p in pdfs if p.name not in urls] loader = DocumentLoader(pdf_folder=str(PDF_DIR)) if loader.embedding_service is None: sys.exit("āŒ OpenAI embeddings unavailable (check OPENAI_API_KEY in .env).") if loader.vector_store is None: sys.exit("āŒ Qdrant vector store unavailable.") print(f"\nšŸ“š Building index from {len(pdfs)} PDF(s) in {PDF_DIR}") print(f" Mode: {'on-disk (local)' if local else 'Docker Qdrant'} | force={force}") print("-" * 70) results = [] for pdf in pdfs: res = loader.load_pdf(str(pdf), force_reprocess=force, pdf_url=urls.get(pdf.name)) results.append((pdf.name, res)) if res.get("skipped"): print(f"ā­ļø {pdf.name}: already processed (use --force to rebuild)") elif res.get("success"): print(f"āœ… {pdf.name}: {res.get('chunks_stored', 0)} chunks, " f"{res.get('embeddings_generated', 0)} embeddings") else: print(f"āŒ {pdf.name}: {res.get('error', 'failed')}") if missing_urls: print(f"\nāš ļø {len(missing_urls)} PDF(s) have no URL in the manifest " f"(citations degrade to filename+page): {', '.join(missing_urls[:8])}" f"{' ...' if len(missing_urls) > 8 else ''}") return loader def validate(loader) -> bool: """Assert PDFs == docs == distinct-Qdrant-docs and points == chunks.""" from src.config.paths import PDF_DIR, DB_PATH from src.cdms.product_catalog import ProductCatalog print("\n" + "=" * 70) print("VALIDATION") print("=" * 70) n_pdfs = len(list(PDF_DIR.glob("*.pdf"))) conn = sqlite3.connect(str(DB_PATH)) n_docs = conn.execute("SELECT COUNT(*) FROM documents WHERE processed=1").fetchone()[0] n_chunks = conn.execute("SELECT COUNT(*) FROM document_chunks").fetchone()[0] per_pdf = conn.execute( """SELECT d.filename, COUNT(dc.id) FROM documents d LEFT JOIN document_chunks dc ON dc.document_id = d.id GROUP BY d.id ORDER BY d.filename""" ).fetchall() conn.close() zero_chunk = [fn for fn, n in per_pdf if n == 0] info = loader.vector_store.get_collection_info() points = info.get("cdms_documents", {}).get("points_count", None) print(f" PDFs on disk .............. {n_pdfs}") print(f" Documents (processed) ..... {n_docs}") print(f" Chunks (SQLite) ........... {n_chunks}") print(f" Points (Qdrant) ........... {points}") ok = True if n_docs != n_pdfs: print(f" āš ļø documents ({n_docs}) != PDFs ({n_pdfs}) — run with --force.") ok = False if points is not None and points != n_chunks: print(f" āŒ Qdrant points ({points}) != SQLite chunks ({n_chunks}).") ok = False if zero_chunk: # Known-empty (corrupted / scanned) PDFs are expected, not failures. expected_empty = {"ACQUIT.pdf", "asenra_g.pdf"} unexpected = [z for z in zero_chunk if z not in expected_empty] print(f" ā„¹ļø 0-chunk PDFs ({len(zero_chunk)}): {', '.join(zero_chunk)}") print(f" (expected-empty: {', '.join(sorted(expected_empty))} — corrupted/scanned)") if unexpected: print(f" āš ļø Unexpected 0-chunk PDFs (investigate): {', '.join(unexpected)}") products = sorted(ProductCatalog().available_products()) print(f"\n 🟢 Answerable products ({len(products)}):") print(" " + ", ".join(products)) print("\n " + ("āœ… VALIDATION PASSED" if ok else "āŒ VALIDATION FAILED")) return ok def export_snapshot(loader) -> bool: """Create + download a portable Qdrant snapshot into data/qdrant_snapshots/.""" from src.config.paths import SNAPSHOT_DIR store = loader.vector_store if not store.using_docker: print("\nāš ļø Snapshot export skipped: build ran against the on-disk store, " "not Docker.\n The server-format snapshot needs a Docker Qdrant. " "For a single-process deploy you may commit data/qdrant_local instead.") return False print("\n" + "=" * 70) print("SNAPSHOT EXPORT") print("=" * 70) try: snap = store.client.create_snapshot(collection_name="cdms_documents") name = snap.name print(f" Created snapshot on server: {name}") import requests url = f"http://{store.host}:{store.port}/collections/cdms_documents/snapshots/{name}" resp = requests.get(url, timeout=120) resp.raise_for_status() SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) out = SNAPSHOT_DIR / "cdms_documents.snapshot" out.write_bytes(resp.content) mb = len(resp.content) / (1024 * 1024) print(f" šŸ“¦ Saved {mb:.1f} MB -> {out}") print(" Commit this file (Git LFS). Restore it at deploy with " "scripts/restore_snapshot.py.") return True except Exception as e: print(f" āš ļø Snapshot export failed ({e}).") print(" If the Qdrant container was started with " "'-v \"$PWD/data/qdrant_snapshots:/qdrant/snapshots\"', the .snapshot " "file is already on disk under data/qdrant_snapshots/.") return False def main(): ap = argparse.ArgumentParser(description="Build the committed CDMS index.") ap.add_argument("--force", action="store_true", help="reprocess every PDF (required for the first real build)") ap.add_argument("--local", action="store_true", help="build into the on-disk Qdrant instead of Docker (no snapshot)") ap.add_argument("--no-snapshot", action="store_true", help="skip snapshot export") args = ap.parse_args() loader = build(force=args.force, local=args.local) valid = validate(loader) if not args.local and not args.no_snapshot: export_snapshot(loader) sys.exit(0 if valid else 1) if __name__ == "__main__": main()