airayven7 commited on
Commit
436dbdd
·
verified ·
1 Parent(s): 335025a

Sync from GitHub 7d9b758

Browse files
Files changed (10) hide show
  1. README.md +22 -3
  2. app.py +73 -158
  3. core/constants.py +34 -0
  4. core/pdf.py +35 -0
  5. core/store.py +142 -0
  6. models/colembed.py +82 -0
  7. models/minicpm.py +97 -0
  8. pipelines/ask.py +35 -0
  9. pipelines/ingest.py +49 -0
  10. requirements.txt +3 -7
README.md CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
  title: Repair Guy
3
- emoji:
4
  colorFrom: purple
5
  colorTo: red
6
  sdk: gradio
@@ -9,9 +9,28 @@ python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  preload_from_hub:
12
- - nvidia/NVIDIA-Nemotron-Parse-v1.2
13
- - nvidia/C-RADIOv2-H
14
  license: mit
15
  ---
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
  title: Repair Guy
3
+ emoji: 🔧
4
  colorFrom: purple
5
  colorTo: red
6
  sdk: gradio
 
9
  app_file: app.py
10
  pinned: false
11
  preload_from_hub:
12
+ - nvidia/nemotron-colembed-vl-4b-v2
 
13
  license: mit
14
  ---
15
 
16
+ # Repair Guy — visual RAG over repair manuals
17
+
18
+ No parsing, no chunking, no figure descriptions: every PDF page is embedded
19
+ as an image with [Nemotron ColEmbed v2](https://huggingface.co/nvidia/nemotron-colembed-vl-4b-v2)
20
+ (multi-vector, late interaction). At question time the query is embedded and
21
+ scored against every page with MaxSim (batched torch matmuls on ZeroGPU,
22
+ pages streamed from disk via numpy memmap), and the top-K page images are
23
+ handed to a MiniCPM-V endpoint to produce a grounded answer.
24
+
25
+ ## Space setup
26
+
27
+ - **Persistent storage** must be enabled (embeddings + PDFs live under
28
+ `/data/library`). Budget roughly 5–12 MB per page of float16 token
29
+ embeddings; a 300-page manual is ~2–3.5 GB.
30
+ - **Secret `MINICPM_API_KEY`** — bearer token for the MiniCPM endpoint
31
+ (`MINICPM_BASE_URL` / `MINICPM_MODEL` are env-overridable).
32
+ - Optional: `COLEMBED_MODEL_ID` (defaults to the 4B model),
33
+ `COLEMBED_ATTN` (defaults to `sdpa`; set `flash_attention_2` if flash-attn
34
+ is installed).
35
+
36
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py CHANGED
@@ -1,180 +1,95 @@
1
- """Gradio + ZeroGPU Space for NVIDIA Nemotron Parse v1.2.
2
-
3
- Upload a PDF, pick a page, and get back the parsed markdown, a structured JSON of
4
- elements, and the page image annotated with bounding boxes.
5
-
6
- Runs on ZeroGPU: the model is loaded onto cuda at module level (ZeroGPU emulates
7
- CUDA at startup) and inference runs inside an @spaces.GPU-decorated function.
8
-
9
- This file targets the Space (cuda/bfloat16). For local CPU testing use
10
- parse_page.py in the repo root instead.
 
 
 
 
 
11
  """
12
 
13
- import json
14
- import sys
15
 
16
- import fitz # pymupdf
17
  import gradio as gr
18
- import spaces
19
- import torch
20
- from huggingface_hub import snapshot_download
21
- from PIL import Image, ImageDraw
22
- from transformers import AutoModel, AutoProcessor, GenerationConfig
23
-
24
- MODEL_ID = "nvidia/NVIDIA-Nemotron-Parse-v1.2"
25
- DEVICE = "cuda"
26
- DTYPE = torch.bfloat16
27
- MAX_PROMPT_DURATION = 120 # seconds of GPU time per page
28
-
29
- # ---------------------------------------------------------------------------
30
- # Load helpers + model once at module level (ZeroGPU loads cuda weights here).
31
- # ---------------------------------------------------------------------------
32
-
33
-
34
- def load_postprocessing():
35
- """Download the repo's .py helpers and import postprocessing.
36
-
37
- postprocessing.py imports sibling modules (latex2html, ...), so we pull all
38
- top-level .py files into one dir and put it on sys.path before importing.
39
- """
40
- repo_dir = snapshot_download(repo_id=MODEL_ID, allow_patterns=["*.py"])
41
- if repo_dir not in sys.path:
42
- sys.path.insert(0, repo_dir)
43
- import postprocessing # noqa: E402 (resolved via sys.path above)
44
-
45
- return postprocessing
46
-
47
-
48
- pp = load_postprocessing()
49
-
50
- # Every load passes trust_remote_code=True so the nested C-RADIO encoder code is
51
- # accepted non-interactively (no [y/N] prompt to hang the Space build).
52
- model = (
53
- AutoModel.from_pretrained(MODEL_ID, trust_remote_code=True, dtype=DTYPE)
54
- .to(DEVICE)
55
- .eval()
56
- )
57
- processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
58
- generation_config = GenerationConfig.from_pretrained(MODEL_ID, trust_remote_code=True)
59
-
60
-
61
- @spaces.GPU(duration=MAX_PROMPT_DURATION)
62
- def run_model(image: Image.Image, task_prompt: str) -> str:
63
- """GPU-only step: preprocess + generate + decode. Returns raw model text."""
64
- inputs = processor(
65
- images=[image], text=task_prompt, return_tensors="pt", add_special_tokens=False
66
- )
67
- # Move to GPU; cast float tensors (pixel_values) to the model dtype.
68
- inputs = {
69
- k: (v.to(DEVICE, DTYPE) if torch.is_floating_point(v) else v.to(DEVICE))
70
- for k, v in inputs.items()
71
- }
72
- with torch.no_grad():
73
- outputs = model.generate(**inputs, generation_config=generation_config)
74
- return processor.batch_decode(outputs, skip_special_tokens=True)[0]
75
-
76
-
77
- # ---------------------------------------------------------------------------
78
- # CPU-side orchestration: render page, call GPU, postprocess, annotate.
79
- # ---------------------------------------------------------------------------
80
 
81
-
82
- def render_page(pdf_path: str, page_num: int, dpi: int) -> Image.Image:
83
- doc = fitz.open(pdf_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  try:
85
- if page_num < 1 or page_num > doc.page_count:
86
- raise gr.Error(
87
- f"Page {page_num} out of range — this PDF has {doc.page_count} pages."
88
- )
89
- pix = doc.load_page(page_num - 1).get_pixmap(dpi=dpi)
90
- return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
91
- finally:
92
- doc.close()
93
-
94
-
95
- def load_input(file_path: str, page_num: int, dpi: int) -> Image.Image:
96
- """Return an RGB image from either a PDF page or an image file."""
97
- if file_path.lower().endswith(".pdf"):
98
- return render_page(file_path, page_num, dpi)
99
- return Image.open(file_path).convert("RGB")
100
-
101
-
102
- def parse(input_file, page_num, dpi, text_in_pic, table_format):
103
- if input_file is None:
104
- raise gr.Error("Please upload a PDF or image first.")
105
-
106
- image = load_input(input_file, int(page_num), int(dpi))
107
-
108
- fourth = "<predict_text_in_pic>" if text_in_pic else "<predict_no_text_in_pic>"
109
- task_prompt = f"</s><s><predict_bbox><predict_classes><output_markdown>{fourth}"
110
-
111
- generated_text = run_model(image, task_prompt)
112
 
113
- classes, bboxes, texts = pp.extract_classes_bboxes(generated_text)
114
- bboxes = [pp.transform_bbox_to_original(b, image.width, image.height) for b in bboxes]
115
- texts = [
116
- pp.postprocess_text(t, cls=c, table_format=table_format, text_format="markdown")
117
- for t, c in zip(texts, classes)
118
- ]
119
-
120
- markdown = "\n\n".join(texts)
121
- elements = [
122
- {"class": c, "bbox": b, "text": t} for c, b, t in zip(classes, bboxes, texts)
123
- ]
124
-
125
- annotated = image.copy()
126
- draw = ImageDraw.Draw(annotated)
127
- for b in bboxes:
128
- draw.rectangle((b[0], b[1], b[2], b[3]), outline="red", width=2)
129
-
130
- return annotated, markdown, json.dumps(elements, indent=2)
131
 
 
 
 
 
 
 
 
132
 
133
- # ---------------------------------------------------------------------------
134
- # UI
135
- # ---------------------------------------------------------------------------
136
 
137
- with gr.Blocks(title="Nemotron Parse — Repair Manuals") as demo:
138
  gr.Markdown(
139
- "# 🔧 Nemotron Parse v1.2 — Repair Manual Explorer\n"
140
- "Upload a PDF (choose a page) or an image, and parse it with "
141
- "[NVIDIA Nemotron Parse v1.2](https://huggingface.co/nvidia/NVIDIA-Nemotron-Parse-v1.2) "
142
- "on ZeroGPU. Returns structured markdown, a JSON of elements, and an "
143
- "annotated page image."
144
  )
 
145
  with gr.Row():
146
  with gr.Column(scale=1):
147
  pdf_in = gr.File(
148
- label="PDF or image",
149
- file_types=[".pdf", ".png", ".jpg", ".jpeg", ".webp"],
150
- type="filepath",
151
  )
152
- page_in = gr.Number(
153
- label="Page (PDF only)", value=1, precision=0, minimum=1
 
 
 
154
  )
155
- dpi_in = gr.Slider(
156
- label="Render DPI (PDF only)", minimum=72, maximum=300, value=150, step=10
157
- )
158
- text_in_pic_in = gr.Checkbox(
159
- label="Extract text inside pictures/diagrams", value=False
160
- )
161
- table_format_in = gr.Dropdown(
162
- label="Table format",
163
- choices=["markdown", "latex", "HTML", "json", "csv"],
164
- value="markdown",
165
- )
166
- run_btn = gr.Button("Parse page", variant="primary")
167
  with gr.Column(scale=2):
168
- img_out = gr.Image(label="Annotated page", type="pil")
169
- with gr.Tab("Rendered markdown"):
170
- md_out = gr.Markdown()
171
- with gr.Tab("Structured JSON"):
172
- json_out = gr.Code(language="json")
173
 
174
- run_btn.click(
175
- parse,
176
- inputs=[pdf_in, page_in, dpi_in, text_in_pic_in, table_format_in],
177
- outputs=[img_out, md_out, json_out],
 
 
178
  )
179
 
180
 
 
1
+ """Gradio + ZeroGPU Space: upload a repair manual, ask questions.
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) when the manual is
5
+ uploaded. A question is answered by embedding the query, running MaxSim over
6
+ the page embeddings streamed from disk, and handing the top pages to MiniCPM-V.
7
+
8
+ Module layout:
9
+ models/colembed.py ColEmbed — embedding model + GPU embed/search
10
+ models/minicpm.py MiniCPM — remote VLM that answers over page images
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 DEFAULT_TOP_K
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 model loads onto cuda here, in the main process).
30
+ store = Store()
31
+ embedder = ColEmbed()
32
+ ingest_pipeline = IngestPipeline(embedder, store)
33
+ ask_pipeline = AskPipeline(embedder, store, MiniCPM())
34
+
35
+
36
+ def index_manual(pdf_file, progress=gr.Progress()):
37
+ """Runs on upload: embed the manual's pages (or reuse a previous index)."""
38
+ if not pdf_file:
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
+ doc = ingest_pipeline.run(
47
+ pdf_file, name, lambda frac, desc: progress(frac, desc=desc)
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 answer_question(question, doc_id):
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
+ "Upload a repair manual (PDF) and ask it questions. Pages are embedded "
67
+ "with [Nemotron ColEmbed v2](https://huggingface.co/nvidia/nemotron-colembed-vl-4b-v2) "
68
+ "on upload; answers come from MiniCPM-V reading the most relevant pages."
 
69
  )
70
+ doc_state = gr.State(None)
71
  with gr.Row():
72
  with gr.Column(scale=1):
73
  pdf_in = gr.File(
74
+ label="Repair manual (PDF)", file_types=[".pdf"], type="filepath"
 
 
75
  )
76
+ status_out = gr.Markdown()
77
+ question_in = gr.Textbox(
78
+ label="Question",
79
+ lines=2,
80
+ placeholder="e.g. What is the tightening torque for the universal joint flange bolts?",
81
  )
82
+ ask_btn = gr.Button("Ask", variant="primary")
 
 
 
 
 
 
 
 
 
 
 
83
  with gr.Column(scale=2):
84
+ answer_out = gr.Markdown(label="Answer")
85
+ pages_out = gr.Gallery(label="Pages used", columns=3, height=420)
 
 
 
86
 
87
+ pdf_in.upload(index_manual, inputs=[pdf_in], outputs=[doc_state, status_out])
88
+ ask_btn.click(
89
+ answer_question, inputs=[question_in, doc_state], outputs=[answer_out, pages_out]
90
+ )
91
+ question_in.submit(
92
+ answer_question, inputs=[question_in, doc_state], outputs=[answer_out, pages_out]
93
  )
94
 
95
 
core/constants.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")
10
+
11
+ # Page rendering (both for embedding at index time and for the answering model).
12
+ 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
+ EMBED_PAGES_PER_CALL = 16
18
+ EMBED_BATCH_SIZE = 4
19
+ EMBED_GPU_DURATION = 120
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
+ # Embedding store. HF Spaces persistent storage mounts at /data; fall back to a
29
+ # local directory for development.
30
+ STORE_DIR = os.environ.get("STORE_DIR") or (
31
+ "/data/library"
32
+ if os.path.isdir("/data")
33
+ else os.path.join(os.path.dirname(os.path.dirname(__file__)), "library")
34
+ )
core/pdf.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fitz
2
+ from PIL import Image
3
+
4
+ from core.constants import RENDER_DPI
5
+
6
+
7
+ def page_count(pdf_path: str) -> int:
8
+ doc = fitz.open(pdf_path)
9
+ try:
10
+ return doc.page_count
11
+ finally:
12
+ doc.close()
13
+
14
+
15
+ def render_pages(
16
+ pdf_path: str, page_nums: list[int], dpi: int = RENDER_DPI
17
+ ) -> list[Image.Image]:
18
+ """Render 1-based pages of a PDF to RGB images (document opened once)."""
19
+ doc = fitz.open(pdf_path)
20
+ try:
21
+ images = []
22
+ for num in page_nums:
23
+ if num < 1 or num > doc.page_count:
24
+ raise ValueError(
25
+ f"Page {num} out of range — this PDF has {doc.page_count} pages."
26
+ )
27
+ pix = doc.load_page(num - 1).get_pixmap(dpi=dpi)
28
+ images.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
29
+ return images
30
+ finally:
31
+ doc.close()
32
+
33
+
34
+ def render_page(pdf_path: str, page_num: int, dpi: int = RENDER_DPI) -> Image.Image:
35
+ return render_pages(pdf_path, [page_num], dpi)[0]
core/store.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """On-disk store of per-page ColEmbed token embeddings.
2
+
3
+ Layout: STORE_DIR/<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)
7
+
8
+ embeddings.bin is read back as a numpy memmap, so scoring streams pages from
9
+ disk in batches without ever loading a whole document into memory. index.json
10
+ is written last, so a directory without it is an interrupted ingest and is
11
+ ignored (and overwritten on the next attempt).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import shutil
20
+
21
+ import numpy as np
22
+
23
+ from core.constants import STORE_DIR
24
+
25
+ EMB_DTYPE = np.float16
26
+
27
+
28
+ def slugify(name: str) -> str:
29
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
30
+ return slug or "doc"
31
+
32
+
33
+ class DocWriter:
34
+ """Streams one document's page embeddings to disk during ingest."""
35
+
36
+ def __init__(self, doc_dir: str, name: str, pdf_path: str, dpi: int, model_id: str):
37
+ self.doc_dir = doc_dir
38
+ os.makedirs(doc_dir, exist_ok=True)
39
+ shutil.copyfile(pdf_path, os.path.join(doc_dir, "doc.pdf"))
40
+ self._bin = open(os.path.join(doc_dir, "embeddings.bin"), "wb")
41
+ self._meta = {
42
+ "name": name,
43
+ "dpi": dpi,
44
+ "model_id": model_id,
45
+ "dim": None,
46
+ "pages": [],
47
+ }
48
+ self._offset = 0
49
+
50
+ def add_page(self, page_num: int, emb: np.ndarray) -> None:
51
+ """emb: [n_tokens, dim] for one page, padding rows already removed."""
52
+ emb = np.ascontiguousarray(emb, dtype=EMB_DTYPE)
53
+ if self._meta["dim"] is None:
54
+ self._meta["dim"] = int(emb.shape[1])
55
+ self._bin.write(emb.tobytes())
56
+ self._meta["pages"].append(
57
+ {"page": page_num, "offset": self._offset, "count": int(emb.shape[0])}
58
+ )
59
+ self._offset += int(emb.shape[0])
60
+
61
+ def finalize(self) -> None:
62
+ self._bin.close()
63
+ with open(os.path.join(self.doc_dir, "index.json"), "w") as f:
64
+ json.dump(self._meta, f)
65
+
66
+ def abort(self) -> None:
67
+ self._bin.close()
68
+ shutil.rmtree(self.doc_dir, ignore_errors=True)
69
+
70
+
71
+ class Store:
72
+ def __init__(self, root: str = STORE_DIR):
73
+ self.root = os.path.abspath(root)
74
+ os.makedirs(self.root, exist_ok=True)
75
+
76
+ def _dir(self, doc_id: str) -> str:
77
+ return os.path.join(self.root, doc_id)
78
+
79
+ def exists(self, doc_id: str) -> bool:
80
+ return os.path.isfile(os.path.join(self._dir(doc_id), "index.json"))
81
+
82
+ def meta(self, doc_id: str) -> dict:
83
+ with open(os.path.join(self._dir(doc_id), "index.json")) as f:
84
+ return json.load(f)
85
+
86
+ def pdf_path(self, doc_id: str) -> str:
87
+ return os.path.join(self._dir(doc_id), "doc.pdf")
88
+
89
+ def list_docs(self) -> list[dict]:
90
+ docs = []
91
+ for doc_id in sorted(os.listdir(self.root)):
92
+ if not self.exists(doc_id):
93
+ continue
94
+ meta = self.meta(doc_id)
95
+ size = os.path.getsize(os.path.join(self._dir(doc_id), "embeddings.bin"))
96
+ docs.append(
97
+ {
98
+ "doc_id": doc_id,
99
+ "name": meta["name"],
100
+ "pages": len(meta["pages"]),
101
+ "size_mb": size / 1e6,
102
+ }
103
+ )
104
+ return docs
105
+
106
+ def create(self, doc_id: str, name: str, pdf_path: str, dpi: int, model_id: str) -> DocWriter:
107
+ self.delete(doc_id)
108
+ return DocWriter(self._dir(doc_id), name, pdf_path, dpi, model_id)
109
+
110
+ def delete(self, doc_id: str) -> None:
111
+ shutil.rmtree(self._dir(doc_id), ignore_errors=True)
112
+
113
+ def iter_page_batches(self, doc_ids: list[str] | None = None, pages_per_batch: int = 32):
114
+ """Yield (refs, embs): refs is [(doc_id, page_num)] and embs is a
115
+ zero-padded float16 array [batch, max_tokens, dim].
116
+
117
+ Zero rows are inert under MaxSim (the model zeroes padding before
118
+ L2-normalizing real tokens), matching the reference scorer.
119
+ """
120
+ if doc_ids is None:
121
+ doc_ids = [d["doc_id"] for d in self.list_docs()]
122
+ for doc_id in doc_ids:
123
+ if not self.exists(doc_id): # e.g. deleted while still selected in the UI
124
+ continue
125
+ meta = self.meta(doc_id)
126
+ pages, dim = meta["pages"], meta["dim"]
127
+ if not pages:
128
+ continue
129
+ total = pages[-1]["offset"] + pages[-1]["count"]
130
+ mm = np.memmap(
131
+ os.path.join(self._dir(doc_id), "embeddings.bin"),
132
+ dtype=EMB_DTYPE,
133
+ mode="r",
134
+ shape=(total, dim),
135
+ )
136
+ for i in range(0, len(pages), pages_per_batch):
137
+ chunk = pages[i : i + pages_per_batch]
138
+ t_max = max(p["count"] for p in chunk)
139
+ out = np.zeros((len(chunk), t_max, dim), dtype=EMB_DTYPE)
140
+ for j, p in enumerate(chunk):
141
+ out[j, : p["count"]] = mm[p["offset"] : p["offset"] + p["count"]]
142
+ yield [(doc_id, p["page"]) for p in chunk], out
models/colembed.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ forward_images/forward_queries return zero-padded [batch, tokens, dim] tensors
9
+ with real tokens L2-normalized, so padding rows are exactly zero. We strip them
10
+ before storing and rely on the same property when scoring zero-padded batches.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+ import spaces
17
+ import torch
18
+ from PIL import Image
19
+ from transformers import AutoModel
20
+
21
+ from core.constants import (
22
+ COLEMBED_ATTN,
23
+ COLEMBED_MODEL_ID,
24
+ EMBED_BATCH_SIZE,
25
+ EMBED_GPU_DURATION,
26
+ SCORE_PAGES_PER_BATCH,
27
+ SEARCH_GPU_DURATION,
28
+ )
29
+
30
+
31
+ @spaces.GPU(duration=EMBED_GPU_DURATION)
32
+ def _embed_pages_on_gpu(model, images: list[Image.Image]) -> list[np.ndarray]:
33
+ with torch.no_grad():
34
+ embs = model.forward_images(images, batch_size=EMBED_BATCH_SIZE)
35
+ out = []
36
+ for emb in embs: # [tokens, dim]; zero rows are padding
37
+ mask = emb.abs().sum(dim=-1) > 0
38
+ out.append(emb[mask].to(torch.float16).cpu().numpy())
39
+ return out
40
+
41
+
42
+ @spaces.GPU(duration=SEARCH_GPU_DURATION)
43
+ def _search_on_gpu(model, question: str, store, doc_ids, top_k: int):
44
+ results = []
45
+ with torch.no_grad():
46
+ q = model.forward_queries([question], batch_size=1)[0].to(torch.float16)
47
+ for refs, batch in store.iter_page_batches(doc_ids, SCORE_PAGES_PER_BATCH):
48
+ emb = torch.from_numpy(batch).to(q.device) # [B, T, D] float16
49
+ sim = torch.einsum("qd,btd->bqt", q, emb).float()
50
+ scores = sim.amax(dim=2).sum(dim=1) # MaxSim: max over doc tokens, sum over query tokens
51
+ results.extend(
52
+ (doc_id, page, s)
53
+ for (doc_id, page), s in zip(refs, scores.tolist())
54
+ )
55
+ results.sort(key=lambda r: r[2], reverse=True)
56
+ return results[:top_k]
57
+
58
+
59
+ class ColEmbed:
60
+ MODEL_ID = COLEMBED_MODEL_ID
61
+
62
+ def __init__(self, device: str = "cuda", dtype: torch.dtype = torch.bfloat16):
63
+ self.model = (
64
+ AutoModel.from_pretrained(
65
+ self.MODEL_ID,
66
+ trust_remote_code=True,
67
+ torch_dtype=dtype,
68
+ attn_implementation=COLEMBED_ATTN,
69
+ )
70
+ .to(device)
71
+ .eval()
72
+ )
73
+
74
+ def embed_pages(self, images: list[Image.Image]) -> list[np.ndarray]:
75
+ """Embed page images -> list of [n_tokens, dim] float16 arrays."""
76
+ return _embed_pages_on_gpu(self.model, images)
77
+
78
+ def search(
79
+ self, question: str, store, doc_ids: list[str] | None, top_k: int
80
+ ) -> list[tuple[str, int, float]]:
81
+ """Return the top_k (doc_id, page_num, score) across the given docs."""
82
+ return _search_on_gpu(self.model, question, store, doc_ids, top_k)
models/minicpm.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Client for a MiniCPM-V OpenAI-compatible vision endpoint: answers a question
2
+ grounded in the retrieved repair-manual pages."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import io
8
+ import json
9
+ import os
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+
14
+ from PIL import Image
15
+
16
+ BASE_URL = os.environ.get("MINICPM_BASE_URL", "http://35.203.155.71:8003").rstrip("/")
17
+ MODEL = os.environ.get("MINICPM_MODEL", "MiniCPM-V-4.6")
18
+ API_KEY = os.environ.get("MINICPM_API_KEY", "")
19
+ MAX_EDGE = 1024 # downscale images; endpoint max_model_len is only 8192 and load-sensitive
20
+ RETRIES = 3
21
+
22
+ PROMPT = (
23
+ "You are a repair-manual assistant. The images are the manual pages most "
24
+ "relevant to the user's question, each preceded by its label (manual name "
25
+ "and page number).\n\n"
26
+ "Answer the question using ONLY these pages. Quote exact values (torques, "
27
+ "clearances, part numbers, capacities) as printed, and cite the page label "
28
+ "for each fact. If the pages do not contain the answer, say so instead of "
29
+ "guessing.\n\nQuestion: {question}"
30
+ )
31
+
32
+
33
+ class MiniCPM:
34
+ def __init__(
35
+ self,
36
+ base_url: str = BASE_URL,
37
+ model: str = MODEL,
38
+ api_key: str = API_KEY,
39
+ max_edge: int = MAX_EDGE,
40
+ retries: int = RETRIES,
41
+ ):
42
+ self.base_url = base_url.rstrip("/")
43
+ self.model = model
44
+ self.api_key = api_key
45
+ self.max_edge = max_edge
46
+ self.retries = retries
47
+
48
+ def answer(self, question: str, pages: list[tuple[str, Image.Image]]) -> str:
49
+ """pages: [(label, page image)] in retrieval order."""
50
+ if not self.api_key:
51
+ raise ValueError(
52
+ "MINICPM_API_KEY is not set — add it as a secret on the Space."
53
+ )
54
+ content = [{"type": "text", "text": PROMPT.format(question=question)}]
55
+ for label, img in pages:
56
+ content.append({"type": "text", "text": f"\n[{label}]"})
57
+ content.append(
58
+ {"type": "image_url", "image_url": {"url": self._data_uri(img)}}
59
+ )
60
+ return self._chat(content, max_tokens=900).strip()
61
+
62
+ def _data_uri(self, img: Image.Image) -> str:
63
+ im = img.convert("RGB")
64
+ w, h = im.size
65
+ if max(w, h) > self.max_edge:
66
+ s = self.max_edge / max(w, h)
67
+ im = im.resize((int(w * s), int(h * s)))
68
+ buf = io.BytesIO()
69
+ im.save(buf, format="JPEG", quality=88)
70
+ return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
71
+
72
+ def _chat(self, content: list[dict], max_tokens: int = 512) -> str:
73
+ body = json.dumps(
74
+ {
75
+ "model": self.model,
76
+ "temperature": 0,
77
+ "max_tokens": max_tokens,
78
+ "messages": [{"role": "user", "content": content}],
79
+ }
80
+ ).encode()
81
+ req = urllib.request.Request(
82
+ f"{self.base_url}/v1/chat/completions",
83
+ data=body,
84
+ headers={
85
+ "Authorization": f"Bearer {self.api_key}",
86
+ "Content-Type": "application/json",
87
+ },
88
+ )
89
+ last = None
90
+ for attempt in range(self.retries):
91
+ try:
92
+ with urllib.request.urlopen(req, timeout=120) as r:
93
+ return json.loads(r.read())["choices"][0]["message"]["content"]
94
+ except (urllib.error.URLError, TimeoutError, OSError) as ex:
95
+ last = ex
96
+ time.sleep(2 * (attempt + 1)) # 2s, 4s backoff between retries
97
+ raise last
pipelines/ask.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ColEmbed
9
+ from models.minicpm import MiniCPM
10
+
11
+
12
+ class AskPipeline:
13
+ def __init__(self, embedder: ColEmbed, store: Store, llm: MiniCPM):
14
+ self.embedder = embedder
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 = self.store.list_docs()
24
+ if not docs:
25
+ raise ValueError("No manuals indexed yet — add one in the Library tab.")
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
+ pages = [
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
pipelines/ingest.py ADDED
@@ -0,0 +1,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 is reported between chunks.
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
13
+ from core.store import Store, slugify
14
+ from models.colembed import ColEmbed
15
+
16
+
17
+ class IngestPipeline:
18
+ def __init__(self, embedder: ColEmbed, store: Store):
19
+ self.embedder = embedder
20
+ self.store = store
21
+
22
+ def run(self, pdf_path: str | None, doc_name: str = "", progress=None) -> dict:
23
+ """Index one PDF; returns the stored doc's summary. Re-indexing a manual
24
+ with the same name overwrites it."""
25
+ if not pdf_path:
26
+ raise ValueError("Please upload a PDF first.")
27
+ if not pdf_path.lower().endswith(".pdf"):
28
+ raise ValueError("Only PDFs can be indexed.")
29
+
30
+ name = doc_name.strip() or (
31
+ os.path.splitext(os.path.basename(pdf_path))[0].replace("_", " ")
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:
38
+ for start in range(1, total + 1, EMBED_PAGES_PER_CALL):
39
+ nums = list(range(start, min(start + EMBED_PAGES_PER_CALL, total + 1)))
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
+ if progress:
44
+ progress(nums[-1] / total, f"Embedded {nums[-1]}/{total} pages")
45
+ writer.finalize()
46
+ except BaseException:
47
+ writer.abort()
48
+ raise
49
+ return {"doc_id": doc_id, "name": name, "pages": total}
requirements.txt CHANGED
@@ -1,12 +1,8 @@
1
  spaces
2
  gradio
3
- transformers==5.6.1
4
  accelerate
5
- albumentations
6
- timm
7
- open_clip_torch
8
- einops
9
- beautifulsoup4
10
- lxml
11
  pymupdf
12
  pillow
 
 
1
  spaces
2
  gradio
3
+ transformers>=4.57.2,<5
4
  accelerate
5
+ torchvision
 
 
 
 
 
6
  pymupdf
7
  pillow
8
+ numpy