Sync from GitHub ea32217
Browse files- README.md +6 -6
- core/constants.py +5 -0
- models/minicpm.py +55 -81
README.md
CHANGED
|
@@ -10,6 +10,7 @@ app_file: app.py
|
|
| 10 |
pinned: false
|
| 11 |
preload_from_hub:
|
| 12 |
- nvidia/nemotron-colembed-vl-4b-v2
|
|
|
|
| 13 |
license: mit
|
| 14 |
---
|
| 15 |
|
|
@@ -20,17 +21,16 @@ as an image with [Nemotron ColEmbed v2](https://huggingface.co/nvidia/nemotron-c
|
|
| 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 |
-
|
|
|
|
| 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 |
-
-
|
| 31 |
-
|
| 32 |
-
|
| 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
|
|
|
|
| 10 |
pinned: false
|
| 11 |
preload_from_hub:
|
| 12 |
- nvidia/nemotron-colembed-vl-4b-v2
|
| 13 |
+
- openbmb/MiniCPM-V-4_5
|
| 14 |
license: mit
|
| 15 |
---
|
| 16 |
|
|
|
|
| 21 |
(multi-vector, late interaction). At question time the query is embedded and
|
| 22 |
scored against every page with MaxSim (batched torch matmuls on ZeroGPU,
|
| 23 |
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 (embeddings + PDFs live under
|
| 30 |
`/data/library`). Budget roughly 5–12 MB per page of float16 token
|
| 31 |
embeddings; a 300-page manual is ~2–3.5 GB.
|
| 32 |
+
- Optional env vars: `COLEMBED_MODEL_ID` (defaults to the 4B model),
|
| 33 |
+
`MINICPM_MODEL_ID` (defaults to `openbmb/MiniCPM-V-4_5`), `COLEMBED_ATTN`
|
| 34 |
+
(defaults to `sdpa`; set `flash_attention_2` if flash-attn is installed).
|
|
|
|
|
|
|
| 35 |
|
| 36 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
core/constants.py
CHANGED
|
@@ -25,6 +25,11 @@ 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 (
|
|
|
|
| 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 |
+
ANSWER_GPU_DURATION = 120
|
| 31 |
+
ANSWER_MAX_NEW_TOKENS = 2048
|
| 32 |
+
|
| 33 |
# Embedding store. HF Spaces persistent storage mounts at /data; fall back to a
|
| 34 |
# local directory for development.
|
| 35 |
STORE_DIR = os.environ.get("STORE_DIR") or (
|
models/minicpm.py
CHANGED
|
@@ -1,97 +1,71 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
import
|
| 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 |
-
|
| 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
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
content
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 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
|
|
|
|
| 1 |
+
"""MiniCPM-V on ZeroGPU: answers a question grounded in the retrieved
|
| 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 ANSWER_GPU_DURATION, ANSWER_MAX_NEW_TOKENS, MINICPM_MODEL_ID
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
PROMPT = (
|
| 19 |
"You are a repair-manual assistant. The images are the manual pages most "
|
| 20 |
"relevant to the user's question, each preceded by its label (manual name "
|
| 21 |
"and page number).\n\n"
|
| 22 |
+
"Answer the question using ONLY what is printed on these pages, following "
|
| 23 |
+
"these rules:\n"
|
| 24 |
+
"1. If the answer is a procedure, reproduce EVERY step in order, numbered "
|
| 25 |
+
"exactly as in the manual. Never skip, merge, or summarize steps. Keep each "
|
| 26 |
+
"step's notes, model exceptions, and specifications (e.g. torque values) "
|
| 27 |
+
"with that step, exactly as printed.\n"
|
| 28 |
+
"2. Quote exact values (torques, clearances, part numbers, capacities) as "
|
| 29 |
+
"printed, including units.\n"
|
| 30 |
+
"3. End with the page label(s) you used, e.g. (Manual — p.238). If a "
|
| 31 |
+
"procedure clearly continues on a page you were not given, say so.\n"
|
| 32 |
+
"4. If the pages do not contain the answer, say so instead of guessing.\n"
|
| 33 |
+
"5. Start directly with the answer — no preamble like 'Based on the "
|
| 34 |
+
"provided pages'.\n\n"
|
| 35 |
+
"Question: {question}"
|
| 36 |
)
|
| 37 |
|
| 38 |
+
_MODEL = (
|
| 39 |
+
AutoModel.from_pretrained(
|
| 40 |
+
MINICPM_MODEL_ID,
|
| 41 |
+
trust_remote_code=True,
|
| 42 |
+
dtype=torch.bfloat16,
|
| 43 |
+
attn_implementation="sdpa",
|
| 44 |
+
)
|
| 45 |
+
.to("cuda")
|
| 46 |
+
.eval()
|
| 47 |
+
)
|
| 48 |
+
_TOKENIZER = AutoTokenizer.from_pretrained(MINICPM_MODEL_ID, trust_remote_code=True)
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
+
@spaces.GPU(duration=ANSWER_GPU_DURATION)
|
| 52 |
+
def _answer_on_gpu(question: str, pages: list[tuple[str, Image.Image]]) -> str:
|
| 53 |
+
content = []
|
| 54 |
+
for label, img in pages: # chat() accepts interleaved strings and PIL images
|
| 55 |
+
content.append(f"[{label}]")
|
| 56 |
+
content.append(img.convert("RGB"))
|
| 57 |
+
content.append(PROMPT.format(question=question))
|
| 58 |
+
with torch.no_grad():
|
| 59 |
+
answer = _MODEL.chat(
|
| 60 |
+
msgs=[{"role": "user", "content": content}],
|
| 61 |
+
tokenizer=_TOKENIZER,
|
| 62 |
+
enable_thinking=False,
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|