Sync from GitHub 8a93941
Browse files- app.py +9 -4
- core/constants.py +17 -0
- frontend/index.html +19 -1
- models/minicpm.py +27 -7
- pipelines/agent_ask.py +6 -3
- pipelines/mock_ask.py +3 -1
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
|
|
|
|
| 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 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
)
|
| 175 |
raw = str(out).strip()
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 182 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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.")
|