airayven7 commited on
Commit
3f92f69
·
verified ·
1 Parent(s): b6e489c

Sync from GitHub 8a93941

Browse files
app.py CHANGED
@@ -57,6 +57,7 @@ from huggingface_hub import HfApi, snapshot_download
57
 
58
  from core.constants import (
59
  DEFAULT_TOP_K,
 
60
  LIBRARY_DATASET_ID,
61
  MAX_TOP_K,
62
  MOCK_MODELS,
@@ -291,6 +292,7 @@ def api_find(
291
  section: str = "",
292
  pages: list = None,
293
  history: list = None,
 
294
  ) -> dict: # the per-yield type: Server.api infers outputs from this annotation
295
  """One agent turn (one ZeroGPU call), streamed as events (see
296
  pipelines/agent_ask.py for the protocol). page/section are what the viewer
@@ -324,13 +326,13 @@ def api_find(
324
  viewer = {"page": int(page or 0), "section": str(section or ""), "pages": shown}
325
  options = _router_options(manual, request)
326
  log.info(
327
- "find: manual=%s k=%s viewer=%s hist=%d opts=%d q=%r",
328
- manual, k, viewer, len(history or []), len(options), request[:200],
329
  )
330
  try:
331
  events = PIPELINE.run_find(
332
  VISUAL_STORE, PARSED_STORE, request, [manual], int(k), options,
333
- viewer, history,
334
  )
335
  for ev in events:
336
  if ev.get("type") == "tool_result" and "gallery" in ev:
@@ -365,12 +367,15 @@ _FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "frontend")
365
  @app.get("/")
366
  def index():
367
  """Serve the single-page UI, injecting the small bit of server config the
368
- frontend needs (default/max k) so it needs no extra round-trip on load."""
 
369
  with open(os.path.join(_FRONTEND_DIR, "index.html")) as f:
370
  html = f.read()
371
  html = (
372
  html.replace("__DEFAULT_K__", str(DEFAULT_TOP_K))
373
  .replace("__MAX_K__", str(MAX_TOP_K))
 
 
374
  # Cache-bust key for /page images: changing the render DPI changes the
375
  # served page size, so it must change the URL too — otherwise a browser
376
  # could keep an old-resolution page (cached up to a day) under the same
 
57
 
58
  from core.constants import (
59
  DEFAULT_TOP_K,
60
+ GROUND_ENABLE_THINKING,
61
  LIBRARY_DATASET_ID,
62
  MAX_TOP_K,
63
  MOCK_MODELS,
 
292
  section: str = "",
293
  pages: list = None,
294
  history: list = None,
295
+ think: bool = GROUND_ENABLE_THINKING,
296
  ) -> dict: # the per-yield type: Server.api infers outputs from this annotation
297
  """One agent turn (one ZeroGPU call), streamed as events (see
298
  pipelines/agent_ask.py for the protocol). page/section are what the viewer
 
326
  viewer = {"page": int(page or 0), "section": str(section or ""), "pages": shown}
327
  options = _router_options(manual, request)
328
  log.info(
329
+ "find: manual=%s k=%s think=%s viewer=%s hist=%d opts=%d q=%r",
330
+ manual, k, bool(think), viewer, len(history or []), len(options), request[:200],
331
  )
332
  try:
333
  events = PIPELINE.run_find(
334
  VISUAL_STORE, PARSED_STORE, request, [manual], int(k), options,
335
+ viewer, history, bool(think),
336
  )
337
  for ev in events:
338
  if ev.get("type") == "tool_result" and "gallery" in ev:
 
367
  @app.get("/")
368
  def index():
369
  """Serve the single-page UI, injecting the small bit of server config the
370
+ frontend needs (default/max k, default grounding-thinking) so it needs no
371
+ extra round-trip on load."""
372
  with open(os.path.join(_FRONTEND_DIR, "index.html")) as f:
373
  html = f.read()
374
  html = (
375
  html.replace("__DEFAULT_K__", str(DEFAULT_TOP_K))
376
  .replace("__MAX_K__", str(MAX_TOP_K))
377
+ # Initial state of the settings-panel "thinking" toggle (a JS bool).
378
+ .replace("__GROUND_THINK__", "true" if GROUND_ENABLE_THINKING else "false")
379
  # Cache-bust key for /page images: changing the render DPI changes the
380
  # served page size, so it must change the URL too — otherwise a browser
381
  # could keep an old-resolution page (cached up to a day) under the same
core/constants.py CHANGED
@@ -16,6 +16,23 @@ MINICPM_REVISION = os.environ.get(
16
  )
17
  ANSWER_MAX_NEW_TOKENS = 2048
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  # Agent brain: MiniCPM5-1B — a standard LlamaForCausalLM (no trust_remote_code),
20
  # 131k context. The small TEXT model that drives the find-and-point loop: each
21
  # step it picks ONE tool from the conversation so far, the manual's table of
 
16
  )
17
  ANSWER_MAX_NEW_TOKENS = 2048
18
 
19
+ # Let MiniCPM-V reason (legend → callout-number → leader-line → part) before
20
+ # committing to a grounding box for "circle the <thing>". This is only the
21
+ # DEFAULT — the UI settings panel sends a per-request override (see api_find's
22
+ # `think`). Off by default: it roughly multiplies grounding latency (64 → ~512
23
+ # generated tokens) and mainly helps exploded-diagram callouts, not the
24
+ # dense-table wrong-row misses.
25
+ GROUND_ENABLE_THINKING = os.environ.get("GROUND_ENABLE_THINKING", "").lower() in (
26
+ "1",
27
+ "true",
28
+ "yes",
29
+ )
30
+ # Token budgets for one grounding generation. A bare <box> fits in 64; a think
31
+ # trace does not (it gets cut off before the box), so the budget tracks whether
32
+ # thinking is on for that call.
33
+ GROUND_BOX_MAX_NEW_TOKENS = 64
34
+ GROUND_THINK_MAX_NEW_TOKENS = 512
35
+
36
  # Agent brain: MiniCPM5-1B — a standard LlamaForCausalLM (no trust_remote_code),
37
  # 131k context. The small TEXT model that drives the find-and-point loop: each
38
  # step it picks ONE tool from the conversation so far, the manual's table of
frontend/index.html CHANGED
@@ -345,6 +345,20 @@
345
  class="w-full accent-brand-600">
346
  </div>
347
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  <div class="flex gap-2 mt-6">
349
  <button @click="refresh()" class="flex-1 rounded-xl border border-brand-200 px-4 py-2.5 text-sm font-medium text-navy hover:bg-brand-50 transition flex items-center justify-center gap-1.5">
350
  <i data-lucide="refresh-cw" class="w-4 h-4"></i>
@@ -378,6 +392,10 @@ function repairGuy() {
378
  client:null, ready:false,
379
  manuals:[], manual:'',
380
  k:__DEFAULT_K__, maxK:__MAX_K__,
 
 
 
 
381
  input:'', loading:false, status:'',
382
  settingsOpen:false,
383
  ICONS,
@@ -627,7 +645,7 @@ function repairGuy() {
627
  mono:this.history.map((h,i)=>`${i+1}. asked: ${h.request}\n did: ${h.action}`).join('\n')});
628
  try{
629
  const job = this.client.submit('/find', {
630
- request:q, manual:this.manual, k:this.k,
631
  page: this.viewDoc ? this.viewPage : 0, section: this.sectionTitle(this.viewPage),
632
  pages: this.spreadPages, // every page currently on screen — the agent may circle on any
633
  history: this.history,
 
345
  class="w-full accent-brand-600">
346
  </div>
347
 
348
+ <!-- think: let the VLM reason before committing to a circle box -->
349
+ <div class="mt-5 flex items-start justify-between gap-3">
350
+ <div>
351
+ <label class="text-xs font-semibold uppercase tracking-wide text-brand-500/80">Careful pointing</label>
352
+ <p class="mt-0.5 text-xs text-brand-400">VLM reasons before circling — more accurate on diagrams, but slower.</p>
353
+ </div>
354
+ <button type="button" role="switch" :aria-checked="think" @click="think=!think"
355
+ :class="think ? 'bg-brand-600' : 'bg-brand-200'"
356
+ class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition mt-0.5">
357
+ <span :class="think ? 'translate-x-5' : 'translate-x-1'"
358
+ class="inline-block h-4 w-4 rounded-full bg-white shadow transition"></span>
359
+ </button>
360
+ </div>
361
+
362
  <div class="flex gap-2 mt-6">
363
  <button @click="refresh()" class="flex-1 rounded-xl border border-brand-200 px-4 py-2.5 text-sm font-medium text-navy hover:bg-brand-50 transition flex items-center justify-center gap-1.5">
364
  <i data-lucide="refresh-cw" class="w-4 h-4"></i>
 
392
  client:null, ready:false,
393
  manuals:[], manual:'',
394
  k:__DEFAULT_K__, maxK:__MAX_K__,
395
+ // When on, MiniCPM-V reasons before committing to a circle box (slower,
396
+ // helps diagram-callout targets). Sent as `think` with each /find turn;
397
+ // initialized from the server default.
398
+ think:__GROUND_THINK__,
399
  input:'', loading:false, status:'',
400
  settingsOpen:false,
401
  ICONS,
 
645
  mono:this.history.map((h,i)=>`${i+1}. asked: ${h.request}\n did: ${h.action}`).join('\n')});
646
  try{
647
  const job = this.client.submit('/find', {
648
+ request:q, manual:this.manual, k:this.k, think:this.think,
649
  page: this.viewDoc ? this.viewPage : 0, section: this.sectionTitle(this.viewPage),
650
  pages: this.spreadPages, // every page currently on screen — the agent may circle on any
651
  history: this.history,
models/minicpm.py CHANGED
@@ -28,6 +28,9 @@ from transformers import AutoModel, AutoProcessor, AutoTokenizer
28
  from core.constants import (
29
  ANSWER_MAX_NEW_TOKENS,
30
  DESCRIBE_MAX_NEW_TOKENS,
 
 
 
31
  MINICPM_MODEL_ID,
32
  MINICPM_REVISION,
33
  )
@@ -155,11 +158,17 @@ def generate_answer(question: str, pages: list[tuple[str, Image.Image]]) -> str:
155
 
156
 
157
  def ground_box(
158
- image: Image.Image, query: str
159
  ) -> tuple[tuple[float, float, float, float] | None, str]:
160
  """(bbox, raw reply): the bounding box of the described object on a page
161
  image, in that image's pixel coordinates — or None when the model can't
162
- place it (no box in the reply, or a degenerate one). Must run on GPU."""
 
 
 
 
 
 
163
  with torch.no_grad():
164
  out = _MODEL.chat(
165
  msgs=[
@@ -169,17 +178,28 @@ def ground_box(
169
  }
170
  ],
171
  tokenizer=_TOKENIZER,
172
- enable_thinking=False,
173
- max_new_tokens=64,
 
 
 
 
 
174
  )
175
  raw = str(out).strip()
176
- if "NOT FOUND" in raw.upper():
 
 
 
 
177
  return None, raw
178
  # Read the coordinates from the <box>…</box> tag specifically. MiniCPM-V
179
  # may prefix a <ref>…</ref> (e.g. echoing "5. Rod"); that digit would
180
  # otherwise be grabbed as the first coordinate and shift the whole box.
181
- m = re.search(r"<box>(.*?)</box>", raw, re.IGNORECASE | re.DOTALL)
182
- nums = re.findall(r"\d+(?:\.\d+)?", m.group(1) if m else raw)
 
 
183
  if len(nums) < 4:
184
  return None, raw
185
  x1, y1, x2, y2 = (min(1000.0, max(0.0, float(n))) for n in nums[:4])
 
28
  from core.constants import (
29
  ANSWER_MAX_NEW_TOKENS,
30
  DESCRIBE_MAX_NEW_TOKENS,
31
+ GROUND_BOX_MAX_NEW_TOKENS,
32
+ GROUND_ENABLE_THINKING,
33
+ GROUND_THINK_MAX_NEW_TOKENS,
34
  MINICPM_MODEL_ID,
35
  MINICPM_REVISION,
36
  )
 
158
 
159
 
160
  def ground_box(
161
+ image: Image.Image, query: str, enable_thinking: bool | None = None
162
  ) -> tuple[tuple[float, float, float, float] | None, str]:
163
  """(bbox, raw reply): the bounding box of the described object on a page
164
  image, in that image's pixel coordinates — or None when the model can't
165
+ place it (no box in the reply, or a degenerate one). Must run on GPU.
166
+
167
+ enable_thinking lets the model reason (legend → callout-number →
168
+ leader-line → part) before committing to a box. None defers to the
169
+ GROUND_ENABLE_THINKING default; the UI settings panel passes an explicit
170
+ bool per request."""
171
+ think = GROUND_ENABLE_THINKING if enable_thinking is None else enable_thinking
172
  with torch.no_grad():
173
  out = _MODEL.chat(
174
  msgs=[
 
178
  }
179
  ],
180
  tokenizer=_TOKENIZER,
181
+ # The think trace needs room (a bare box fits in 64 tokens; a think
182
+ # trace does not) or it gets cut off before emitting the box — so
183
+ # the token budget tracks the flag.
184
+ enable_thinking=think,
185
+ max_new_tokens=(
186
+ GROUND_THINK_MAX_NEW_TOKENS if think else GROUND_BOX_MAX_NEW_TOKENS
187
+ ),
188
  )
189
  raw = str(out).strip()
190
+ # The thinking trace can mention "not found" mid-reasoning ("at first this
191
+ # looked not found, but…"), so test only the FINAL answer after </think>,
192
+ # not the whole reply.
193
+ final = raw.rsplit("</think>", 1)[-1]
194
+ if "NOT FOUND" in final.upper():
195
  return None, raw
196
  # Read the coordinates from the <box>…</box> tag specifically. MiniCPM-V
197
  # may prefix a <ref>…</ref> (e.g. echoing "5. Rod"); that digit would
198
  # otherwise be grabbed as the first coordinate and shift the whole box.
199
+ # Search `final` so digits inside the reasoning can't be mistaken for
200
+ # coordinates.
201
+ m = re.search(r"<box>(.*?)</box>", final, re.IGNORECASE | re.DOTALL)
202
+ nums = re.findall(r"\d+(?:\.\d+)?", m.group(1) if m else final)
203
  if len(nums) < 4:
204
  return None, raw
205
  x1, y1, x2, y2 = (min(1000.0, max(0.0, float(n))) for n in nums[:4])
pipelines/agent_ask.py CHANGED
@@ -87,10 +87,12 @@ def agent_events(
87
  sections: list[dict],
88
  viewer: dict | None = None,
89
  history: list | None = None,
 
90
  ):
91
  """Yield the events of one agent turn (see module docstring). sections is the
92
  numbered table of contents shown to the agent ([{title, page}]); the agent's
93
- go_to_section index is 1-based into it."""
 
94
  doc_id = doc_ids[0]
95
  manual = names[doc_id]
96
  viewer = viewer or {}
@@ -269,7 +271,7 @@ def agent_events(
269
  yield {"type": "step", "tool": "circle", "target": target, "page": page}
270
  yield {"type": "status", "text": "Pinning it down…"}
271
  img = render_page(visual_store.pdf_path(doc_id), page)
272
- box, braw = minicpm.ground_box(img, target)
273
  log.info("ground_box(%r) on p.%d → %s | raw=%r",
274
  target, page, box, braw[:200])
275
  # The VLM couldn't find the target on this page — almost always
@@ -332,6 +334,7 @@ class AgentPipeline:
332
  sections: list[dict],
333
  viewer: dict | None = None,
334
  history: list | None = None,
 
335
  ):
336
  """One streamed agent turn (the event generator of agent_events)."""
337
  request = (request or "").strip()
@@ -343,5 +346,5 @@ class AgentPipeline:
343
  names = {d["doc_id"]: d["name"] for d in docs}
344
  return agent_events(
345
  request, visual_store, parsed_store, doc_ids or list(names),
346
- int(top_k), names, sections, viewer, history,
347
  )
 
87
  sections: list[dict],
88
  viewer: dict | None = None,
89
  history: list | None = None,
90
+ ground_thinking: bool | None = None,
91
  ):
92
  """Yield the events of one agent turn (see module docstring). sections is the
93
  numbered table of contents shown to the agent ([{title, page}]); the agent's
94
+ go_to_section index is 1-based into it. ground_thinking toggles MiniCPM-V's
95
+ reasoning for the circle grounding (None → server default)."""
96
  doc_id = doc_ids[0]
97
  manual = names[doc_id]
98
  viewer = viewer or {}
 
271
  yield {"type": "step", "tool": "circle", "target": target, "page": page}
272
  yield {"type": "status", "text": "Pinning it down…"}
273
  img = render_page(visual_store.pdf_path(doc_id), page)
274
+ box, braw = minicpm.ground_box(img, target, enable_thinking=ground_thinking)
275
  log.info("ground_box(%r) on p.%d → %s | raw=%r",
276
  target, page, box, braw[:200])
277
  # The VLM couldn't find the target on this page — almost always
 
334
  sections: list[dict],
335
  viewer: dict | None = None,
336
  history: list | None = None,
337
+ ground_thinking: bool | None = None,
338
  ):
339
  """One streamed agent turn (the event generator of agent_events)."""
340
  request = (request or "").strip()
 
346
  names = {d["doc_id"]: d["name"] for d in docs}
347
  return agent_events(
348
  request, visual_store, parsed_store, doc_ids or list(names),
349
+ int(top_k), names, sections, viewer, history, ground_thinking,
350
  )
pipelines/mock_ask.py CHANGED
@@ -143,10 +143,12 @@ class MockAskPipeline:
143
  sections: list[dict],
144
  viewer: dict | None = None,
145
  history: list | None = None,
 
146
  ):
147
  """Yield the same event sequence as pipelines/agent_ask.py, with a
148
  keyword-driven stand-in for the agent's tool choice. Both stores are the
149
- one MockStore; history is ignored. MOCK_DELAY (seconds) paces events."""
 
150
  request = (request or "").strip()
151
  if not request:
152
  raise ValueError("Tell me what to find.")
 
143
  sections: list[dict],
144
  viewer: dict | None = None,
145
  history: list | None = None,
146
+ ground_thinking: bool | None = None,
147
  ):
148
  """Yield the same event sequence as pipelines/agent_ask.py, with a
149
  keyword-driven stand-in for the agent's tool choice. Both stores are the
150
+ one MockStore; history and ground_thinking are ignored. MOCK_DELAY
151
+ (seconds) paces events."""
152
  request = (request or "").strip()
153
  if not request:
154
  raise ValueError("Tell me what to find.")