airayven7 commited on
Commit
3f08eff
·
verified ·
1 Parent(s): 8025053

Sync from GitHub ea32217

Browse files
Files changed (3) hide show
  1. README.md +6 -6
  2. core/constants.py +5 -0
  3. 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
- 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
 
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
- """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
 
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)