Sync from GitHub 7d9b758
Browse files- README.md +22 -3
- app.py +73 -158
- core/constants.py +34 -0
- core/pdf.py +35 -0
- core/store.py +142 -0
- models/colembed.py +82 -0
- models/minicpm.py +97 -0
- pipelines/ask.py +35 -0
- pipelines/ingest.py +49 -0
- 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/
|
| 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
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
-
import
|
| 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 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
try:
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 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="
|
| 138 |
gr.Markdown(
|
| 139 |
-
"# 🔧
|
| 140 |
-
"Upload a
|
| 141 |
-
"
|
| 142 |
-
"on
|
| 143 |
-
"annotated page image."
|
| 144 |
)
|
|
|
|
| 145 |
with gr.Row():
|
| 146 |
with gr.Column(scale=1):
|
| 147 |
pdf_in = gr.File(
|
| 148 |
-
label="
|
| 149 |
-
file_types=[".pdf", ".png", ".jpg", ".jpeg", ".webp"],
|
| 150 |
-
type="filepath",
|
| 151 |
)
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
| 154 |
)
|
| 155 |
-
|
| 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 |
-
|
| 169 |
-
|
| 170 |
-
md_out = gr.Markdown()
|
| 171 |
-
with gr.Tab("Structured JSON"):
|
| 172 |
-
json_out = gr.Code(language="json")
|
| 173 |
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
inputs=[
|
| 177 |
-
|
|
|
|
|
|
|
| 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=
|
| 4 |
accelerate
|
| 5 |
-
|
| 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
|