Sync from GitHub 4fcc227
Browse files- README.md +17 -6
- app.py +123 -50
- core/constants.py +32 -11
- core/store.py +2 -4
- models/colembed.py +17 -15
- models/minicpm.py +18 -14
- pipelines/ask.py +35 -19
- pipelines/ingest.py +16 -7
README.md
CHANGED
|
@@ -24,13 +24,24 @@ pages streamed from disk via numpy memmap), and the top-K page images are
|
|
| 24 |
read by MiniCPM-V 4.5 — also on ZeroGPU — to produce a grounded answer.
|
| 25 |
Everything runs inside the Space; no external endpoints.
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
## Space setup
|
| 28 |
|
| 29 |
-
- **Persistent storage** must be enabled (
|
| 30 |
-
`/data
|
| 31 |
-
embeddings; a
|
| 32 |
-
- Optional env vars: `
|
| 33 |
-
`
|
| 34 |
-
(defaults to `
|
|
|
|
| 35 |
|
| 36 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 24 |
read by MiniCPM-V 4.5 — also on ZeroGPU — to produce a grounded answer.
|
| 25 |
Everything runs inside the Space; no external endpoints.
|
| 26 |
|
| 27 |
+
## Two tabs
|
| 28 |
+
|
| 29 |
+
- **Library** — large manuals indexed offline (`scripts/index_modal.py` in the
|
| 30 |
+
GitHub repo runs it on a Modal GPU; `index_local.py` if you have a CUDA box)
|
| 31 |
+
and pushed to the
|
| 32 |
+
[library dataset](https://huggingface.co/datasets/build-small-hackathon/repair-guy-library);
|
| 33 |
+
the Space syncs it to `/data/preindexed` at startup.
|
| 34 |
+
- **Upload your own** — indexes on the Space's ZeroGPU, capped at
|
| 35 |
+
`MAX_UPLOAD_PAGES` (default 50) to protect quota.
|
| 36 |
+
|
| 37 |
## Space setup
|
| 38 |
|
| 39 |
+
- **Persistent storage** must be enabled (the library sync and uploads live
|
| 40 |
+
under `/data`). Budget roughly 5–12 MB per page of float16 token
|
| 41 |
+
embeddings; a 1000-page manual is ~6–12 GB — size the tier to the library.
|
| 42 |
+
- Optional env vars: `LIBRARY_DATASET_ID`, `MAX_UPLOAD_PAGES`,
|
| 43 |
+
`COLEMBED_MODEL_ID` (defaults to the 4B model), `MINICPM_MODEL_ID`
|
| 44 |
+
(defaults to `openbmb/MiniCPM-V-4_5`), `COLEMBED_ATTN` (defaults to `sdpa`;
|
| 45 |
+
set `flash_attention_2` if flash-attn is installed).
|
| 46 |
|
| 47 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
app.py
CHANGED
|
@@ -1,96 +1,169 @@
|
|
| 1 |
-
"""Gradio + ZeroGPU Space:
|
| 2 |
|
| 3 |
Visual RAG with no parsing/chunking: every PDF page is embedded as an image
|
| 4 |
-
with Nemotron ColEmbed v2 (multi-vector, late interaction)
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
Module layout:
|
| 9 |
-
models/colembed.py ColEmbed — embedding model + GPU embed/
|
| 10 |
-
models/minicpm.py MiniCPM —
|
| 11 |
core/store.py Store — on-disk per-page token embeddings
|
| 12 |
core/pdf.py render_pages — PDF -> RGB page images (CPU)
|
| 13 |
pipelines/ingest.py IngestPipeline — PDF -> embeddings -> store
|
| 14 |
pipelines/ask.py AskPipeline — question -> retrieve -> answer
|
| 15 |
-
app.py this file: builds the objects + Gradio UI
|
| 16 |
"""
|
| 17 |
|
| 18 |
import os
|
| 19 |
|
| 20 |
import gradio as gr
|
|
|
|
| 21 |
|
| 22 |
-
from core.constants import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
from core.store import Store, slugify
|
| 24 |
from models.colembed import ColEmbed
|
| 25 |
-
from models.minicpm import MiniCPM
|
| 26 |
from pipelines.ask import AskPipeline
|
| 27 |
from pipelines.ingest import IngestPipeline
|
| 28 |
|
| 29 |
-
# Construct once at startup (the
|
| 30 |
-
|
|
|
|
| 31 |
embedder = ColEmbed()
|
| 32 |
-
ingest_pipeline = IngestPipeline(embedder,
|
| 33 |
-
ask_pipeline = AskPipeline(
|
| 34 |
|
| 35 |
|
| 36 |
-
def
|
| 37 |
-
"""
|
| 38 |
-
|
| 39 |
-
return None, ""
|
| 40 |
-
name = os.path.splitext(os.path.basename(pdf_file))[0].replace("_", " ")
|
| 41 |
-
doc_id = slugify(name)
|
| 42 |
-
if store.exists(doc_id):
|
| 43 |
-
pages = len(store.meta(doc_id)["pages"])
|
| 44 |
-
return doc_id, f"**{name}** is already indexed ({pages} pages) — ask away."
|
| 45 |
try:
|
| 46 |
-
|
| 47 |
-
|
| 48 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
except ValueError as e:
|
| 50 |
raise gr.Error(str(e)) from e
|
| 51 |
-
return doc_id, f"Indexed **{doc['name']}** ({doc['pages']} pages) — ask away."
|
| 52 |
|
| 53 |
|
| 54 |
-
def
|
| 55 |
if not doc_id:
|
| 56 |
raise gr.Error("Upload a repair manual first.")
|
| 57 |
try:
|
| 58 |
-
return ask_pipeline.run(question, [doc_id], DEFAULT_TOP_K)
|
| 59 |
except ValueError as e:
|
| 60 |
raise gr.Error(str(e)) from e
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
with gr.Blocks(title="Repair Guy") as demo:
|
| 64 |
gr.Markdown(
|
| 65 |
"# 🔧 Repair Guy\n"
|
| 66 |
-
"
|
| 67 |
-
"
|
| 68 |
-
"
|
|
|
|
| 69 |
)
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
pdf_in.upload(index_manual, inputs=[pdf_in], outputs=[doc_state, status_out])
|
| 88 |
-
|
| 89 |
-
|
|
|
|
| 90 |
)
|
| 91 |
-
|
| 92 |
-
|
|
|
|
| 93 |
)
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
"""Gradio + ZeroGPU Space: ask questions over repair manuals.
|
| 2 |
|
| 3 |
Visual RAG with no parsing/chunking: every PDF page is embedded as an image
|
| 4 |
+
with Nemotron ColEmbed v2 (multi-vector, late interaction). A question is
|
| 5 |
+
answered by embedding the query, running MaxSim over page embeddings streamed
|
| 6 |
+
from disk, and handing the top pages to MiniCPM-V — all in one ZeroGPU call.
|
| 7 |
+
|
| 8 |
+
Two tabs:
|
| 9 |
+
Library — large manuals pre-indexed offline (scripts/index_local.py),
|
| 10 |
+
synced at startup from the library dataset on the Hub.
|
| 11 |
+
Upload — index your own small PDF on ZeroGPU (page-capped to protect
|
| 12 |
+
the Space's GPU quota), then ask it questions.
|
| 13 |
|
| 14 |
Module layout:
|
| 15 |
+
models/colembed.py ColEmbed — embedding model + GPU embed / MaxSim
|
| 16 |
+
models/minicpm.py MiniCPM — local VLM that answers over page images
|
| 17 |
core/store.py Store — on-disk per-page token embeddings
|
| 18 |
core/pdf.py render_pages — PDF -> RGB page images (CPU)
|
| 19 |
pipelines/ingest.py IngestPipeline — PDF -> embeddings -> store
|
| 20 |
pipelines/ask.py AskPipeline — question -> retrieve -> answer
|
|
|
|
| 21 |
"""
|
| 22 |
|
| 23 |
import os
|
| 24 |
|
| 25 |
import gradio as gr
|
| 26 |
+
from huggingface_hub import snapshot_download
|
| 27 |
|
| 28 |
+
from core.constants import (
|
| 29 |
+
DEFAULT_TOP_K,
|
| 30 |
+
LIBRARY_DATASET_ID,
|
| 31 |
+
MAX_UPLOAD_PAGES,
|
| 32 |
+
PREINDEXED_DIR,
|
| 33 |
+
UPLOADS_DIR,
|
| 34 |
+
)
|
| 35 |
from core.store import Store, slugify
|
| 36 |
from models.colembed import ColEmbed
|
|
|
|
| 37 |
from pipelines.ask import AskPipeline
|
| 38 |
from pipelines.ingest import IngestPipeline
|
| 39 |
|
| 40 |
+
# Construct once at startup (the models load onto cuda here, in the main process).
|
| 41 |
+
library = Store(PREINDEXED_DIR)
|
| 42 |
+
uploads = Store(UPLOADS_DIR)
|
| 43 |
embedder = ColEmbed()
|
| 44 |
+
ingest_pipeline = IngestPipeline(embedder, uploads)
|
| 45 |
+
ask_pipeline = AskPipeline()
|
| 46 |
|
| 47 |
|
| 48 |
+
def sync_library() -> None:
|
| 49 |
+
"""Pull pre-indexed manuals from the library dataset into /data.
|
| 50 |
+
A missing or empty dataset just means an empty library tab."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
try:
|
| 52 |
+
snapshot_download(
|
| 53 |
+
LIBRARY_DATASET_ID, repo_type="dataset", local_dir=library.root
|
| 54 |
)
|
| 55 |
+
except Exception as e:
|
| 56 |
+
print(f"Library dataset not synced ({LIBRARY_DATASET_ID}): {e}")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
sync_library()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _choices(store: Store) -> list[tuple[str, str]]:
|
| 63 |
+
return [(d["name"], d["doc_id"]) for d in store.list_docs()]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def ask_library(question, doc_id):
|
| 67 |
+
if not doc_id:
|
| 68 |
+
raise gr.Error("Pick a manual first.")
|
| 69 |
+
try:
|
| 70 |
+
return ask_pipeline.run(library, question, [doc_id], DEFAULT_TOP_K)
|
| 71 |
except ValueError as e:
|
| 72 |
raise gr.Error(str(e)) from e
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
+
def ask_upload(question, doc_id):
|
| 76 |
if not doc_id:
|
| 77 |
raise gr.Error("Upload a repair manual first.")
|
| 78 |
try:
|
| 79 |
+
return ask_pipeline.run(uploads, question, [doc_id], DEFAULT_TOP_K)
|
| 80 |
except ValueError as e:
|
| 81 |
raise gr.Error(str(e)) from e
|
| 82 |
|
| 83 |
|
| 84 |
+
def index_manual(pdf_file, progress=gr.Progress()):
|
| 85 |
+
"""Runs on upload: embed the manual's pages (or reuse a previous index).
|
| 86 |
+
Generator — each yield streams (doc_state, status markdown) to the UI."""
|
| 87 |
+
if not pdf_file:
|
| 88 |
+
yield None, ""
|
| 89 |
+
return
|
| 90 |
+
name = os.path.splitext(os.path.basename(pdf_file))[0].replace("_", " ")
|
| 91 |
+
doc_id = slugify(name)
|
| 92 |
+
if uploads.exists(doc_id):
|
| 93 |
+
pages = len(uploads.meta(doc_id)["pages"])
|
| 94 |
+
yield doc_id, f"**{name}** is already indexed ({pages} pages) — ask away."
|
| 95 |
+
return
|
| 96 |
+
yield None, f"⏳ Indexing **{name}** — preparing…"
|
| 97 |
+
try:
|
| 98 |
+
for event in ingest_pipeline.run(pdf_file, name, max_pages=MAX_UPLOAD_PAGES):
|
| 99 |
+
if event[0] == "progress":
|
| 100 |
+
_, done, total = event
|
| 101 |
+
progress(done / total, desc=f"Embedding pages {done}/{total}")
|
| 102 |
+
yield None, f"⏳ Indexing **{name}** — {done}/{total} pages embedded…"
|
| 103 |
+
else:
|
| 104 |
+
doc = event[1]
|
| 105 |
+
except ValueError as e:
|
| 106 |
+
raise gr.Error(str(e)) from e
|
| 107 |
+
yield doc_id, f"✅ Indexed **{doc['name']}** ({doc['pages']} pages) — ask away."
|
| 108 |
+
|
| 109 |
+
|
| 110 |
with gr.Blocks(title="Repair Guy") as demo:
|
| 111 |
gr.Markdown(
|
| 112 |
"# 🔧 Repair Guy\n"
|
| 113 |
+
"Ask questions over repair manuals. Pages are retrieved visually with "
|
| 114 |
+
"[Nemotron ColEmbed v2](https://huggingface.co/nvidia/nemotron-colembed-vl-4b-v2) "
|
| 115 |
+
"(late interaction, no parsing) and answered by MiniCPM-V reading the "
|
| 116 |
+
"most relevant pages."
|
| 117 |
)
|
| 118 |
+
with gr.Tab("📚 Library"):
|
| 119 |
+
with gr.Row():
|
| 120 |
+
with gr.Column(scale=1):
|
| 121 |
+
manual_in = gr.Dropdown(label="Manual", choices=[])
|
| 122 |
+
lib_question_in = gr.Textbox(
|
| 123 |
+
label="Question",
|
| 124 |
+
lines=2,
|
| 125 |
+
placeholder="e.g. What is the tightening torque for the universal joint flange bolts?",
|
| 126 |
+
)
|
| 127 |
+
lib_ask_btn = gr.Button("Ask", variant="primary")
|
| 128 |
+
with gr.Column(scale=2):
|
| 129 |
+
lib_answer_out = gr.Markdown(label="Answer")
|
| 130 |
+
lib_pages_out = gr.Gallery(label="Pages used", columns=3, height=420)
|
| 131 |
+
with gr.Tab("📄 Upload your own"):
|
| 132 |
+
gr.Markdown(
|
| 133 |
+
f"Upload a PDF of up to **{MAX_UPLOAD_PAGES} pages** (indexing runs "
|
| 134 |
+
"on this Space's GPU quota — big manuals live in the Library tab)."
|
| 135 |
+
)
|
| 136 |
+
doc_state = gr.State(None)
|
| 137 |
+
with gr.Row():
|
| 138 |
+
with gr.Column(scale=1):
|
| 139 |
+
pdf_in = gr.File(
|
| 140 |
+
label="Repair manual (PDF)", file_types=[".pdf"], type="filepath"
|
| 141 |
+
)
|
| 142 |
+
status_out = gr.Markdown()
|
| 143 |
+
up_question_in = gr.Textbox(label="Question", lines=2)
|
| 144 |
+
up_ask_btn = gr.Button("Ask", variant="primary")
|
| 145 |
+
with gr.Column(scale=2):
|
| 146 |
+
up_answer_out = gr.Markdown(label="Answer")
|
| 147 |
+
up_pages_out = gr.Gallery(label="Pages used", columns=3, height=420)
|
| 148 |
|
| 149 |
+
lib_ask_btn.click(
|
| 150 |
+
ask_library, inputs=[lib_question_in, manual_in],
|
| 151 |
+
outputs=[lib_answer_out, lib_pages_out],
|
| 152 |
+
)
|
| 153 |
+
lib_question_in.submit(
|
| 154 |
+
ask_library, inputs=[lib_question_in, manual_in],
|
| 155 |
+
outputs=[lib_answer_out, lib_pages_out],
|
| 156 |
+
)
|
| 157 |
pdf_in.upload(index_manual, inputs=[pdf_in], outputs=[doc_state, status_out])
|
| 158 |
+
up_ask_btn.click(
|
| 159 |
+
ask_upload, inputs=[up_question_in, doc_state],
|
| 160 |
+
outputs=[up_answer_out, up_pages_out],
|
| 161 |
)
|
| 162 |
+
up_question_in.submit(
|
| 163 |
+
ask_upload, inputs=[up_question_in, doc_state],
|
| 164 |
+
outputs=[up_answer_out, up_pages_out],
|
| 165 |
)
|
| 166 |
+
demo.load(lambda: gr.update(choices=_choices(library)), outputs=[manual_in])
|
| 167 |
|
| 168 |
|
| 169 |
if __name__ == "__main__":
|
core/constants.py
CHANGED
|
@@ -1,9 +1,14 @@
|
|
| 1 |
import os
|
| 2 |
|
| 3 |
# Embedding model (late-interaction / ColBERT-style page embeddings).
|
|
|
|
|
|
|
| 4 |
COLEMBED_MODEL_ID = os.environ.get(
|
| 5 |
"COLEMBED_MODEL_ID", "nvidia/nemotron-colembed-vl-4b-v2"
|
| 6 |
)
|
|
|
|
|
|
|
|
|
|
| 7 |
# sdpa works for this model and needs no extra wheels on ZeroGPU; set
|
| 8 |
# COLEMBED_ATTN=flash_attention_2 if flash-attn is installed.
|
| 9 |
COLEMBED_ATTN = os.environ.get("COLEMBED_ATTN", "sdpa")
|
|
@@ -13,27 +18,43 @@ RENDER_DPI = 150
|
|
| 13 |
|
| 14 |
# Indexing: pages embedded per ZeroGPU call, and model batch size within a call.
|
| 15 |
# Chunking keeps each GPU call well under its duration limit; progress is
|
| 16 |
-
# reported between chunks.
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
| 20 |
|
| 21 |
# Retrieval: MaxSim is computed on GPU over fixed-size batches of pages
|
| 22 |
# streamed from the on-disk store.
|
| 23 |
SCORE_PAGES_PER_BATCH = 32
|
| 24 |
-
SEARCH_GPU_DURATION = 60
|
| 25 |
DEFAULT_TOP_K = 3
|
| 26 |
MAX_TOP_K = 5
|
| 27 |
|
| 28 |
# Answering model (runs locally on ZeroGPU).
|
| 29 |
MINICPM_MODEL_ID = os.environ.get("MINICPM_MODEL_ID", "openbmb/MiniCPM-V-4_5")
|
| 30 |
-
|
|
|
|
|
|
|
| 31 |
ANSWER_MAX_NEW_TOKENS = 2048
|
| 32 |
|
| 33 |
-
#
|
| 34 |
-
#
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
if os.path.isdir("/data")
|
| 38 |
-
else os.path.join(os.path.dirname(os.path.dirname(__file__)), "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
)
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
|
| 3 |
# Embedding model (late-interaction / ColBERT-style page embeddings).
|
| 4 |
+
# Revisions are pinned because both models load trust_remote_code; bump
|
| 5 |
+
# deliberately after reviewing upstream changes.
|
| 6 |
COLEMBED_MODEL_ID = os.environ.get(
|
| 7 |
"COLEMBED_MODEL_ID", "nvidia/nemotron-colembed-vl-4b-v2"
|
| 8 |
)
|
| 9 |
+
COLEMBED_REVISION = os.environ.get(
|
| 10 |
+
"COLEMBED_REVISION", "0ed152d91f8ad4c5d48296b51c220f686641a398"
|
| 11 |
+
)
|
| 12 |
# sdpa works for this model and needs no extra wheels on ZeroGPU; set
|
| 13 |
# COLEMBED_ATTN=flash_attention_2 if flash-attn is installed.
|
| 14 |
COLEMBED_ATTN = os.environ.get("COLEMBED_ATTN", "sdpa")
|
|
|
|
| 18 |
|
| 19 |
# Indexing: pages embedded per ZeroGPU call, and model batch size within a call.
|
| 20 |
# Chunking keeps each GPU call well under its duration limit; progress is
|
| 21 |
+
# reported between chunks. Bigger chunks mean fewer ZeroGPU queue waits per
|
| 22 |
+
# manual (~0.5s/page observed, so 64 pages ≈ 35-40s of a 240s budget — a
|
| 23 |
+
# 1000-page manual is ~16 GPU calls).
|
| 24 |
+
EMBED_PAGES_PER_CALL = 64
|
| 25 |
+
EMBED_BATCH_SIZE = 8
|
| 26 |
+
EMBED_GPU_DURATION = 240
|
| 27 |
|
| 28 |
# Retrieval: MaxSim is computed on GPU over fixed-size batches of pages
|
| 29 |
# streamed from the on-disk store.
|
| 30 |
SCORE_PAGES_PER_BATCH = 32
|
|
|
|
| 31 |
DEFAULT_TOP_K = 3
|
| 32 |
MAX_TOP_K = 5
|
| 33 |
|
| 34 |
# Answering model (runs locally on ZeroGPU).
|
| 35 |
MINICPM_MODEL_ID = os.environ.get("MINICPM_MODEL_ID", "openbmb/MiniCPM-V-4_5")
|
| 36 |
+
MINICPM_REVISION = os.environ.get(
|
| 37 |
+
"MINICPM_REVISION", "fd3209b2e0580e346fc33d2c6f85b6e9332eecda"
|
| 38 |
+
)
|
| 39 |
ANSWER_MAX_NEW_TOKENS = 2048
|
| 40 |
|
| 41 |
+
# One ZeroGPU call covers the whole question: query embedding + MaxSim +
|
| 42 |
+
# page rendering + answer generation.
|
| 43 |
+
ASK_GPU_DURATION = 120
|
| 44 |
+
|
| 45 |
+
# Manual stores. HF Spaces persistent storage mounts at /data; fall back to a
|
| 46 |
+
# local directory for development. Pre-indexed manuals (large PDFs embedded
|
| 47 |
+
# offline, see scripts/index_local.py) are synced from the library dataset at
|
| 48 |
+
# startup; in-app uploads are indexed on ZeroGPU and page-capped to protect
|
| 49 |
+
# the Space's quota.
|
| 50 |
+
_DATA_ROOT = os.environ.get("DATA_ROOT") or (
|
| 51 |
+
"/data"
|
| 52 |
if os.path.isdir("/data")
|
| 53 |
+
else os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
|
| 54 |
+
)
|
| 55 |
+
PREINDEXED_DIR = os.path.join(_DATA_ROOT, "preindexed")
|
| 56 |
+
UPLOADS_DIR = os.path.join(_DATA_ROOT, "uploads")
|
| 57 |
+
LIBRARY_DATASET_ID = os.environ.get(
|
| 58 |
+
"LIBRARY_DATASET_ID", "build-small-hackathon/repair-guy-library"
|
| 59 |
)
|
| 60 |
+
MAX_UPLOAD_PAGES = int(os.environ.get("MAX_UPLOAD_PAGES", "50"))
|
core/store.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""On-disk store of per-page ColEmbed token embeddings.
|
| 2 |
|
| 3 |
-
Layout:
|
| 4 |
doc.pdf original PDF, kept to re-render retrieved pages at answer time
|
| 5 |
embeddings.bin raw float16 [total_tokens, dim], pages stored back to back
|
| 6 |
index.json manual name, dim, dpi, model id, per-page (offset, count)
|
|
@@ -20,8 +20,6 @@ import shutil
|
|
| 20 |
|
| 21 |
import numpy as np
|
| 22 |
|
| 23 |
-
from core.constants import STORE_DIR
|
| 24 |
-
|
| 25 |
EMB_DTYPE = np.float16
|
| 26 |
|
| 27 |
|
|
@@ -69,7 +67,7 @@ class DocWriter:
|
|
| 69 |
|
| 70 |
|
| 71 |
class Store:
|
| 72 |
-
def __init__(self, root: str
|
| 73 |
self.root = os.path.abspath(root)
|
| 74 |
os.makedirs(self.root, exist_ok=True)
|
| 75 |
|
|
|
|
| 1 |
"""On-disk store of per-page ColEmbed token embeddings.
|
| 2 |
|
| 3 |
+
Layout: <store root>/<doc_id>/
|
| 4 |
doc.pdf original PDF, kept to re-render retrieved pages at answer time
|
| 5 |
embeddings.bin raw float16 [total_tokens, dim], pages stored back to back
|
| 6 |
index.json manual name, dim, dpi, model id, per-page (offset, count)
|
|
|
|
| 20 |
|
| 21 |
import numpy as np
|
| 22 |
|
|
|
|
|
|
|
| 23 |
EMB_DTYPE = np.float16
|
| 24 |
|
| 25 |
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
class Store:
|
| 70 |
+
def __init__(self, root: str):
|
| 71 |
self.root = os.path.abspath(root)
|
| 72 |
os.makedirs(self.root, exist_ok=True)
|
| 73 |
|
models/colembed.py
CHANGED
|
@@ -1,14 +1,13 @@
|
|
| 1 |
"""Nemotron ColEmbed v2: late-interaction page embeddings + MaxSim retrieval.
|
| 2 |
|
| 3 |
-
Two ZeroGPU entry points:
|
| 4 |
-
_embed_pages_on_gpu page images -> per-page token embeddings (index time)
|
| 5 |
-
_search_on_gpu question -> top-K (doc, page, score) via MaxSim over
|
| 6 |
-
batches of page embeddings streamed from the store
|
| 7 |
-
|
| 8 |
The model is a module-level global: ZeroGPU packs module-level CUDA tensors at
|
| 9 |
startup and shares them with the GPU worker, whereas function arguments are
|
| 10 |
pickled — and the trust_remote_code model class is not picklable.
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
forward_images/forward_queries return zero-padded [batch, tokens, dim] tensors
|
| 13 |
with real tokens L2-normalized, so padding rows are exactly zero. We strip them
|
| 14 |
before storing and rely on the same property when scoring zero-padded batches.
|
|
@@ -22,20 +21,21 @@ import numpy as np
|
|
| 22 |
import spaces
|
| 23 |
import torch
|
| 24 |
from PIL import Image
|
| 25 |
-
from transformers import AutoModel
|
| 26 |
|
| 27 |
from core.constants import (
|
| 28 |
COLEMBED_ATTN,
|
| 29 |
COLEMBED_MODEL_ID,
|
|
|
|
| 30 |
EMBED_BATCH_SIZE,
|
| 31 |
EMBED_GPU_DURATION,
|
| 32 |
SCORE_PAGES_PER_BATCH,
|
| 33 |
-
SEARCH_GPU_DURATION,
|
| 34 |
)
|
| 35 |
|
| 36 |
_MODEL = (
|
| 37 |
AutoModel.from_pretrained(
|
| 38 |
COLEMBED_MODEL_ID,
|
|
|
|
| 39 |
trust_remote_code=True,
|
| 40 |
dtype=torch.bfloat16,
|
| 41 |
attn_implementation=COLEMBED_ATTN,
|
|
@@ -43,6 +43,11 @@ _MODEL = (
|
|
| 43 |
.to("cuda")
|
| 44 |
.eval()
|
| 45 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
# The remote code's forward_documents hardcodes DataLoader(num_workers=8), but
|
| 48 |
# the ZeroGPU worker is a daemonic process and may not spawn children
|
|
@@ -72,8 +77,11 @@ def _embed_pages_on_gpu(images: list[Image.Image]) -> list[np.ndarray]:
|
|
| 72 |
return out
|
| 73 |
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
results = []
|
| 78 |
with torch.no_grad():
|
| 79 |
q = _MODEL.forward_queries([question], batch_size=1)[0].to(torch.float16)
|
|
@@ -95,9 +103,3 @@ class ColEmbed:
|
|
| 95 |
def embed_pages(self, images: list[Image.Image]) -> list[np.ndarray]:
|
| 96 |
"""Embed page images -> list of [n_tokens, dim] float16 arrays."""
|
| 97 |
return _embed_pages_on_gpu(images)
|
| 98 |
-
|
| 99 |
-
def search(
|
| 100 |
-
self, question: str, store, doc_ids: list[str] | None, top_k: int
|
| 101 |
-
) -> list[tuple[str, int, float]]:
|
| 102 |
-
"""Return the top_k (doc_id, page_num, score) across the given docs."""
|
| 103 |
-
return _search_on_gpu(question, store, doc_ids, top_k)
|
|
|
|
| 1 |
"""Nemotron ColEmbed v2: late-interaction page embeddings + MaxSim retrieval.
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
The model is a module-level global: ZeroGPU packs module-level CUDA tensors at
|
| 4 |
startup and shares them with the GPU worker, whereas function arguments are
|
| 5 |
pickled — and the trust_remote_code model class is not picklable.
|
| 6 |
|
| 7 |
+
_embed_pages_on_gpu is a ZeroGPU entry point (used at index time);
|
| 8 |
+
maxsim_search is a plain function so the ask pipeline can run it inside its
|
| 9 |
+
own single GPU call together with answer generation.
|
| 10 |
+
|
| 11 |
forward_images/forward_queries return zero-padded [batch, tokens, dim] tensors
|
| 12 |
with real tokens L2-normalized, so padding rows are exactly zero. We strip them
|
| 13 |
before storing and rely on the same property when scoring zero-padded batches.
|
|
|
|
| 21 |
import spaces
|
| 22 |
import torch
|
| 23 |
from PIL import Image
|
| 24 |
+
from transformers import AutoModel, AutoProcessor
|
| 25 |
|
| 26 |
from core.constants import (
|
| 27 |
COLEMBED_ATTN,
|
| 28 |
COLEMBED_MODEL_ID,
|
| 29 |
+
COLEMBED_REVISION,
|
| 30 |
EMBED_BATCH_SIZE,
|
| 31 |
EMBED_GPU_DURATION,
|
| 32 |
SCORE_PAGES_PER_BATCH,
|
|
|
|
| 33 |
)
|
| 34 |
|
| 35 |
_MODEL = (
|
| 36 |
AutoModel.from_pretrained(
|
| 37 |
COLEMBED_MODEL_ID,
|
| 38 |
+
revision=COLEMBED_REVISION,
|
| 39 |
trust_remote_code=True,
|
| 40 |
dtype=torch.bfloat16,
|
| 41 |
attn_implementation=COLEMBED_ATTN,
|
|
|
|
| 43 |
.to("cuda")
|
| 44 |
.eval()
|
| 45 |
)
|
| 46 |
+
# Pre-build the processor the remote code would otherwise lazily create per
|
| 47 |
+
# GPU worker (it caches on this exact attribute, see _get_processor).
|
| 48 |
+
_MODEL._processor = AutoProcessor.from_pretrained(
|
| 49 |
+
COLEMBED_MODEL_ID, revision=COLEMBED_REVISION, trust_remote_code=True
|
| 50 |
+
)
|
| 51 |
|
| 52 |
# The remote code's forward_documents hardcodes DataLoader(num_workers=8), but
|
| 53 |
# the ZeroGPU worker is a daemonic process and may not spawn children
|
|
|
|
| 77 |
return out
|
| 78 |
|
| 79 |
|
| 80 |
+
def maxsim_search(
|
| 81 |
+
question: str, store, doc_ids: list[str] | None, top_k: int
|
| 82 |
+
) -> list[tuple[str, int, float]]:
|
| 83 |
+
"""Top-K (doc_id, page_num, score) across docs. Must run on GPU (called
|
| 84 |
+
from within a @spaces.GPU context)."""
|
| 85 |
results = []
|
| 86 |
with torch.no_grad():
|
| 87 |
q = _MODEL.forward_queries([question], batch_size=1)[0].to(torch.float16)
|
|
|
|
| 103 |
def embed_pages(self, images: list[Image.Image]) -> list[np.ndarray]:
|
| 104 |
"""Embed page images -> list of [n_tokens, dim] float16 arrays."""
|
| 105 |
return _embed_pages_on_gpu(images)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
models/minicpm.py
CHANGED
|
@@ -1,19 +1,20 @@
|
|
| 1 |
-
"""MiniCPM-V
|
| 2 |
-
repair-manual pages.
|
| 3 |
|
| 4 |
The model and tokenizer are module-level globals: ZeroGPU packs module-level
|
| 5 |
CUDA tensors at startup and shares them with the GPU worker, whereas function
|
| 6 |
arguments are pickled — and trust_remote_code model classes are not picklable.
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
-
import spaces
|
| 12 |
import torch
|
| 13 |
from PIL import Image
|
| 14 |
-
from transformers import AutoModel, AutoTokenizer
|
| 15 |
|
| 16 |
-
from core.constants import
|
| 17 |
|
| 18 |
PROMPT = (
|
| 19 |
"You are a repair-manual assistant. The images are the manual pages most "
|
|
@@ -38,6 +39,7 @@ PROMPT = (
|
|
| 38 |
_MODEL = (
|
| 39 |
AutoModel.from_pretrained(
|
| 40 |
MINICPM_MODEL_ID,
|
|
|
|
| 41 |
trust_remote_code=True,
|
| 42 |
dtype=torch.bfloat16,
|
| 43 |
attn_implementation="sdpa",
|
|
@@ -45,11 +47,19 @@ _MODEL = (
|
|
| 45 |
.to("cuda")
|
| 46 |
.eval()
|
| 47 |
)
|
| 48 |
-
_TOKENIZER = AutoTokenizer.from_pretrained(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
|
|
|
| 53 |
content = []
|
| 54 |
for label, img in pages: # chat() accepts interleaved strings and PIL images
|
| 55 |
content.append(f"[{label}]")
|
|
@@ -63,9 +73,3 @@ def _answer_on_gpu(question: str, pages: list[tuple[str, Image.Image]]) -> str:
|
|
| 63 |
max_new_tokens=ANSWER_MAX_NEW_TOKENS,
|
| 64 |
)
|
| 65 |
return str(answer).strip()
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
class MiniCPM:
|
| 69 |
-
def answer(self, question: str, pages: list[tuple[str, Image.Image]]) -> str:
|
| 70 |
-
"""pages: [(label, page image)] in retrieval order."""
|
| 71 |
-
return _answer_on_gpu(question, pages)
|
|
|
|
| 1 |
+
"""MiniCPM-V: answers a question grounded in the retrieved repair-manual pages.
|
|
|
|
| 2 |
|
| 3 |
The model and tokenizer are module-level globals: ZeroGPU packs module-level
|
| 4 |
CUDA tensors at startup and shares them with the GPU worker, whereas function
|
| 5 |
arguments are pickled — and trust_remote_code model classes are not picklable.
|
| 6 |
+
|
| 7 |
+
generate_answer is a plain function: the ask pipeline calls it inside its own
|
| 8 |
+
single @spaces.GPU call, right after retrieval.
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
|
|
|
| 13 |
import torch
|
| 14 |
from PIL import Image
|
| 15 |
+
from transformers import AutoModel, AutoProcessor, AutoTokenizer
|
| 16 |
|
| 17 |
+
from core.constants import ANSWER_MAX_NEW_TOKENS, MINICPM_MODEL_ID, MINICPM_REVISION
|
| 18 |
|
| 19 |
PROMPT = (
|
| 20 |
"You are a repair-manual assistant. The images are the manual pages most "
|
|
|
|
| 39 |
_MODEL = (
|
| 40 |
AutoModel.from_pretrained(
|
| 41 |
MINICPM_MODEL_ID,
|
| 42 |
+
revision=MINICPM_REVISION,
|
| 43 |
trust_remote_code=True,
|
| 44 |
dtype=torch.bfloat16,
|
| 45 |
attn_implementation="sdpa",
|
|
|
|
| 47 |
.to("cuda")
|
| 48 |
.eval()
|
| 49 |
)
|
| 50 |
+
_TOKENIZER = AutoTokenizer.from_pretrained(
|
| 51 |
+
MINICPM_MODEL_ID, revision=MINICPM_REVISION, trust_remote_code=True
|
| 52 |
+
)
|
| 53 |
+
# Pre-build the processor chat() would otherwise lazily create per GPU worker
|
| 54 |
+
# (it caches on this exact attribute, see modeling_minicpmv.chat).
|
| 55 |
+
_MODEL.processor = AutoProcessor.from_pretrained(
|
| 56 |
+
MINICPM_MODEL_ID, revision=MINICPM_REVISION, trust_remote_code=True
|
| 57 |
+
)
|
| 58 |
|
| 59 |
|
| 60 |
+
def generate_answer(question: str, pages: list[tuple[str, Image.Image]]) -> str:
|
| 61 |
+
"""pages: [(label, page image)] in retrieval order. Must run on GPU
|
| 62 |
+
(called from within a @spaces.GPU context)."""
|
| 63 |
content = []
|
| 64 |
for label, img in pages: # chat() accepts interleaved strings and PIL images
|
| 65 |
content.append(f"[{label}]")
|
|
|
|
| 73 |
max_new_tokens=ANSWER_MAX_NEW_TOKENS,
|
| 74 |
)
|
| 75 |
return str(answer).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pipelines/ask.py
CHANGED
|
@@ -1,35 +1,51 @@
|
|
| 1 |
"""Ask pipeline: question -> MaxSim retrieval over the store -> top-K page
|
| 2 |
-
images -> MiniCPM answer grounded in those pages.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
|
|
|
|
|
|
|
|
| 6 |
from core.pdf import render_page
|
| 7 |
from core.store import Store
|
| 8 |
-
from models.colembed import
|
| 9 |
-
from models.minicpm import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
class AskPipeline:
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
self.store = store
|
| 16 |
-
self.llm = llm
|
| 17 |
|
| 18 |
-
def run(self, question: str, doc_ids: list[str] | None, top_k: int):
|
| 19 |
"""Return (answer markdown, gallery items [(image, caption)])."""
|
| 20 |
question = (question or "").strip()
|
| 21 |
if not question:
|
| 22 |
raise ValueError("Please enter a question.")
|
| 23 |
-
docs =
|
| 24 |
if not docs:
|
| 25 |
-
raise ValueError("No manuals indexed yet —
|
| 26 |
-
|
| 27 |
-
hits = self.embedder.search(question, self.store, doc_ids or None, int(top_k))
|
| 28 |
names = {d["doc_id"]: d["name"] for d in docs}
|
| 29 |
-
|
| 30 |
-
(f"{names[doc_id]} — p.{page}", render_page(self.store.pdf_path(doc_id), page), score)
|
| 31 |
-
for doc_id, page, score in hits
|
| 32 |
-
]
|
| 33 |
-
answer = self.llm.answer(question, [(label, img) for label, img, _ in pages])
|
| 34 |
-
gallery = [(img, f"{label} (score {score:.1f})") for label, img, score in pages]
|
| 35 |
-
return answer, gallery
|
|
|
|
| 1 |
"""Ask pipeline: question -> MaxSim retrieval over the store -> top-K page
|
| 2 |
+
images -> MiniCPM answer grounded in those pages.
|
| 3 |
+
|
| 4 |
+
The whole question runs in ONE @spaces.GPU call (query embedding + MaxSim +
|
| 5 |
+
page rendering + answer generation), so each question pays the ZeroGPU
|
| 6 |
+
allocation wait once. Indexing has its own separate GPU entry point.
|
| 7 |
+
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
+
import spaces
|
| 12 |
+
|
| 13 |
+
from core.constants import ASK_GPU_DURATION
|
| 14 |
from core.pdf import render_page
|
| 15 |
from core.store import Store
|
| 16 |
+
from models.colembed import maxsim_search
|
| 17 |
+
from models.minicpm import generate_answer
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@spaces.GPU(duration=ASK_GPU_DURATION)
|
| 21 |
+
def _ask_on_gpu(
|
| 22 |
+
question: str,
|
| 23 |
+
store: Store,
|
| 24 |
+
doc_ids: list[str] | None,
|
| 25 |
+
top_k: int,
|
| 26 |
+
names: dict[str, str],
|
| 27 |
+
):
|
| 28 |
+
hits = maxsim_search(question, store, doc_ids, top_k)
|
| 29 |
+
pages = [
|
| 30 |
+
(f"{names[doc_id]} — p.{page}", render_page(store.pdf_path(doc_id), page), score)
|
| 31 |
+
for doc_id, page, score in hits
|
| 32 |
+
]
|
| 33 |
+
answer = generate_answer(question, [(label, img) for label, img, _ in pages])
|
| 34 |
+
gallery = [(img, f"{label} (score {score:.1f})") for label, img, score in pages]
|
| 35 |
+
return answer, gallery
|
| 36 |
|
| 37 |
|
| 38 |
class AskPipeline:
|
| 39 |
+
"""Stateless: the store is passed per call, so the same pipeline serves
|
| 40 |
+
both the pre-indexed library and user uploads."""
|
|
|
|
|
|
|
| 41 |
|
| 42 |
+
def run(self, store: Store, question: str, doc_ids: list[str] | None, top_k: int):
|
| 43 |
"""Return (answer markdown, gallery items [(image, caption)])."""
|
| 44 |
question = (question or "").strip()
|
| 45 |
if not question:
|
| 46 |
raise ValueError("Please enter a question.")
|
| 47 |
+
docs = store.list_docs()
|
| 48 |
if not docs:
|
| 49 |
+
raise ValueError("No manuals indexed yet — upload one first.")
|
|
|
|
|
|
|
| 50 |
names = {d["doc_id"]: d["name"] for d in docs}
|
| 51 |
+
return _ask_on_gpu(question, store, doc_ids or None, int(top_k), names)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pipelines/ingest.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
"""Ingest pipeline: PDF -> page images -> ColEmbed embeddings -> on-disk store.
|
| 2 |
|
| 3 |
Pages are embedded in chunks of EMBED_PAGES_PER_CALL so each ZeroGPU call stays
|
| 4 |
-
short; progress
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import os
|
|
|
|
| 10 |
|
| 11 |
from core.constants import EMBED_PAGES_PER_CALL, RENDER_DPI
|
| 12 |
from core.pdf import page_count, render_pages
|
|
@@ -19,9 +20,12 @@ class IngestPipeline:
|
|
| 19 |
self.embedder = embedder
|
| 20 |
self.store = store
|
| 21 |
|
| 22 |
-
def run(
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
| 25 |
if not pdf_path:
|
| 26 |
raise ValueError("Please upload a PDF first.")
|
| 27 |
if not pdf_path.lower().endswith(".pdf"):
|
|
@@ -32,6 +36,12 @@ class IngestPipeline:
|
|
| 32 |
)
|
| 33 |
doc_id = slugify(name)
|
| 34 |
total = page_count(pdf_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
writer = self.store.create(doc_id, name, pdf_path, RENDER_DPI, self.embedder.MODEL_ID)
|
| 37 |
try:
|
|
@@ -40,10 +50,9 @@ class IngestPipeline:
|
|
| 40 |
images = render_pages(pdf_path, nums)
|
| 41 |
for num, emb in zip(nums, self.embedder.embed_pages(images)):
|
| 42 |
writer.add_page(num, emb)
|
| 43 |
-
|
| 44 |
-
progress(nums[-1] / total, f"Embedded {nums[-1]}/{total} pages")
|
| 45 |
writer.finalize()
|
| 46 |
except BaseException:
|
| 47 |
writer.abort()
|
| 48 |
raise
|
| 49 |
-
|
|
|
|
| 1 |
"""Ingest pipeline: PDF -> page images -> ColEmbed embeddings -> on-disk store.
|
| 2 |
|
| 3 |
Pages are embedded in chunks of EMBED_PAGES_PER_CALL so each ZeroGPU call stays
|
| 4 |
+
short; progress events are yielded between chunks so the UI can stream them.
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import os
|
| 10 |
+
from collections.abc import Iterator
|
| 11 |
|
| 12 |
from core.constants import EMBED_PAGES_PER_CALL, RENDER_DPI
|
| 13 |
from core.pdf import page_count, render_pages
|
|
|
|
| 20 |
self.embedder = embedder
|
| 21 |
self.store = store
|
| 22 |
|
| 23 |
+
def run(
|
| 24 |
+
self, pdf_path: str | None, doc_name: str = "", max_pages: int | None = None
|
| 25 |
+
) -> Iterator[tuple]:
|
| 26 |
+
"""Index one PDF. Generator yielding ("progress", pages_done, total)
|
| 27 |
+
after each embedded chunk, then ("done", doc summary dict) last.
|
| 28 |
+
Re-indexing a manual with the same name overwrites it."""
|
| 29 |
if not pdf_path:
|
| 30 |
raise ValueError("Please upload a PDF first.")
|
| 31 |
if not pdf_path.lower().endswith(".pdf"):
|
|
|
|
| 36 |
)
|
| 37 |
doc_id = slugify(name)
|
| 38 |
total = page_count(pdf_path)
|
| 39 |
+
if max_pages and total > max_pages:
|
| 40 |
+
raise ValueError(
|
| 41 |
+
f"This PDF has {total} pages — uploads are capped at {max_pages} "
|
| 42 |
+
"pages to conserve the Space's GPU quota. Large manuals belong "
|
| 43 |
+
"in the pre-indexed library."
|
| 44 |
+
)
|
| 45 |
|
| 46 |
writer = self.store.create(doc_id, name, pdf_path, RENDER_DPI, self.embedder.MODEL_ID)
|
| 47 |
try:
|
|
|
|
| 50 |
images = render_pages(pdf_path, nums)
|
| 51 |
for num, emb in zip(nums, self.embedder.embed_pages(images)):
|
| 52 |
writer.add_page(num, emb)
|
| 53 |
+
yield ("progress", nums[-1], total)
|
|
|
|
| 54 |
writer.finalize()
|
| 55 |
except BaseException:
|
| 56 |
writer.abort()
|
| 57 |
raise
|
| 58 |
+
yield ("done", {"doc_id": doc_id, "name": name, "pages": total})
|