Sync from GitHub c06d2c6
Browse files- app.py +8 -1
- frontend/index.html +53 -25
- models/minicpm.py +4 -0
- models/minicpm_agent.py +48 -21
- pipelines/agent_ask.py +24 -8
- pipelines/mock_ask.py +8 -5
app.py
CHANGED
|
@@ -266,6 +266,7 @@ def api_find(
|
|
| 266 |
k: int = DEFAULT_TOP_K,
|
| 267 |
page: int = 0,
|
| 268 |
section: str = "",
|
|
|
|
| 269 |
history: list = None,
|
| 270 |
) -> dict: # the per-yield type: Server.api infers outputs from this annotation
|
| 271 |
"""One agent turn (one ZeroGPU call), streamed as events (see
|
|
@@ -291,7 +292,13 @@ def api_find(
|
|
| 291 |
}
|
| 292 |
return
|
| 293 |
start = time.monotonic()
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
options = _router_options(manual, request)
|
| 296 |
log.info(
|
| 297 |
"find: manual=%s k=%s viewer=%s hist=%d opts=%d q=%r",
|
|
|
|
| 266 |
k: int = DEFAULT_TOP_K,
|
| 267 |
page: int = 0,
|
| 268 |
section: str = "",
|
| 269 |
+
pages: list = None,
|
| 270 |
history: list = None,
|
| 271 |
) -> dict: # the per-yield type: Server.api infers outputs from this annotation
|
| 272 |
"""One agent turn (one ZeroGPU call), streamed as events (see
|
|
|
|
| 292 |
}
|
| 293 |
return
|
| 294 |
start = time.monotonic()
|
| 295 |
+
# `pages` = every page currently on the viewer (the two-page spread shows
|
| 296 |
+
# two); the agent reads them all and may circle on any. Falls back to the
|
| 297 |
+
# single `page` for older clients.
|
| 298 |
+
shown = [int(p) for p in (pages or []) if str(p).strip().isdigit()] or (
|
| 299 |
+
[int(page)] if page else []
|
| 300 |
+
)
|
| 301 |
+
viewer = {"page": int(page or 0), "section": str(section or ""), "pages": shown}
|
| 302 |
options = _router_options(manual, request)
|
| 303 |
log.info(
|
| 304 |
"find: manual=%s k=%s viewer=%s hist=%d opts=%d q=%r",
|
frontend/index.html
CHANGED
|
@@ -243,38 +243,49 @@
|
|
| 243 |
p. <span x-text="viewPage"></span><span class="text-brand-400 font-medium"> / <span x-text="docPages"></span></span>
|
| 244 |
</div>
|
| 245 |
<div class="flex-1 min-w-0 text-[12px] text-brand-500/90 truncate text-right pr-1" x-text="sectionTitle(viewPage)"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
<button x-show="navStack.length" @click="goBack()" aria-label="Back" title="Back to previous spot"
|
| 247 |
class="grid place-items-center w-11 h-11 rounded-xl text-brand-600 hover:bg-brand-100 transition">
|
| 248 |
<i data-lucide="undo-2" class="w-5 h-5"></i>
|
| 249 |
</button>
|
| 250 |
</div>
|
| 251 |
|
| 252 |
-
<!-- the page -
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
<
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
</svg>
|
| 275 |
</div>
|
| 276 |
</template>
|
| 277 |
-
<div x-show="!viewDoc" class="
|
| 278 |
<div>
|
| 279 |
<i data-lucide="file-text" class="inline-block w-11 h-11 text-brand-300 mb-3"></i>
|
| 280 |
<p class="text-brand-500/80 text-sm max-w-xs">Pick a manual and it opens here — then say things like <em>go to the brakes section</em> or <em>next page</em>.</p>
|
|
@@ -359,9 +370,25 @@ function repairGuy() {
|
|
| 359 |
// viewer state machine: which page is open, the manual's chapter index,
|
| 360 |
// a jump history for "back", and the active circle overlay.
|
| 361 |
viewDoc:'', viewPage:1, docPages:1, sections:[], navStack:[], circle:null,
|
| 362 |
-
imgW:1240, imgH:1754, //
|
|
|
|
|
|
|
| 363 |
|
| 364 |
get pageUrl(){ return this.viewDoc ? `/page/${encodeURIComponent(this.viewDoc)}/${this.viewPage}` : ''; },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
async init(){
|
| 367 |
window.lucide?.createIcons(); // swap static <i data-lucide> -> clean SVG icons
|
|
@@ -392,7 +419,7 @@ function repairGuy() {
|
|
| 392 |
async newChat(){
|
| 393 |
this.lastRequest=''; this.activity=null; this.history=[];
|
| 394 |
this.trace=[]; this.traceSeen=0; this.tab='toc';
|
| 395 |
-
this.viewDoc=''; this.viewPage=1; this.navStack=[]; this.circle=null; this.sections=[];
|
| 396 |
const m = this.manuals.find(x=>x.value===this.manual);
|
| 397 |
if(!m) return;
|
| 398 |
this.docPages = m.pages || 1;
|
|
@@ -553,6 +580,7 @@ function repairGuy() {
|
|
| 553 |
const job = this.client.submit('/find', {
|
| 554 |
request:q, manual:this.manual, k:this.k,
|
| 555 |
page: this.viewDoc ? this.viewPage : 0, section: this.sectionTitle(this.viewPage),
|
|
|
|
| 556 |
history: this.history,
|
| 557 |
});
|
| 558 |
// the client re-delivers the generator's last yield in the completion
|
|
|
|
| 243 |
p. <span x-text="viewPage"></span><span class="text-brand-400 font-medium"> / <span x-text="docPages"></span></span>
|
| 244 |
</div>
|
| 245 |
<div class="flex-1 min-w-0 text-[12px] text-brand-500/90 truncate text-right pr-1" x-text="sectionTitle(viewPage)"></div>
|
| 246 |
+
<button @click="spread=!spread" :aria-pressed="spread" aria-label="Two-page view" title="Two-page view"
|
| 247 |
+
class="grid place-items-center w-11 h-11 rounded-xl transition"
|
| 248 |
+
:class="spread ? 'text-brand-700 bg-brand-100' : 'text-brand-600 hover:bg-brand-100'">
|
| 249 |
+
<i data-lucide="book-open" class="w-5 h-5"></i>
|
| 250 |
+
</button>
|
| 251 |
<button x-show="navStack.length" @click="goBack()" aria-label="Back" title="Back to previous spot"
|
| 252 |
class="grid place-items-center w-11 h-11 rounded-xl text-brand-600 hover:bg-brand-100 transition">
|
| 253 |
<i data-lucide="undo-2" class="w-5 h-5"></i>
|
| 254 |
</button>
|
| 255 |
</div>
|
| 256 |
|
| 257 |
+
<!-- the page(s): a book-style spread — the active page plus the next one
|
| 258 |
+
for context (toggle to single with the book button). Each slot is a
|
| 259 |
+
full-height flex column; the image fits the slot's HEIGHT and (in
|
| 260 |
+
spread) at most half the width, so both facing pages stay fully
|
| 261 |
+
visible without blowing up. img + svg are stacked in one grid cell
|
| 262 |
+
and both carry the page's aspect-ratio + max constraints, so they
|
| 263 |
+
compute the SAME box and the circle overlay stays aligned 1:1. -->
|
| 264 |
+
<div class="flex-1 min-h-0 overflow-auto scroll-thin bg-brand-50/40 flex justify-center items-stretch gap-3 p-4">
|
| 265 |
+
<template x-for="p in spreadPages" :key="p">
|
| 266 |
+
<div class="relative flex-1 min-w-0 grid place-items-center">
|
| 267 |
+
<img :src="`/page/${encodeURIComponent(viewDoc)}/${p}`"
|
| 268 |
+
@load="nat[p]={w:$event.target.naturalWidth,h:$event.target.naturalHeight}; if(p===viewPage){imgW=$event.target.naturalWidth; imgH=$event.target.naturalHeight;}"
|
| 269 |
+
class="col-start-1 row-start-1 block max-h-full max-w-full w-auto h-auto select-none rounded-lg shadow-sm ring-1 ring-navy/10"
|
| 270 |
+
draggable="false" alt="manual page">
|
| 271 |
+
<!-- box overlay: a rounded rectangle hugging the grounded region,
|
| 272 |
+
padded slightly and CLAMPED to the page so it never runs off
|
| 273 |
+
(an ellipse circumscribing the box ballooned wide boxes into a
|
| 274 |
+
page-spanning arc). bbox coords are in this page's rendered
|
| 275 |
+
pixel space (= its natural size); only the page the box is FOR
|
| 276 |
+
shows it. No <template x-if> inside <svg> (HTML templates aren't
|
| 277 |
+
valid SVG children) — the rect always exists, bindings null-guard. -->
|
| 278 |
+
<svg x-show="circle && circle.page===p"
|
| 279 |
+
:style="`aspect-ratio:${natOf(p).w}/${natOf(p).h}`"
|
| 280 |
+
:viewBox="`0 0 ${natOf(p).w} ${natOf(p).h}`"
|
| 281 |
+
class="col-start-1 row-start-1 max-h-full max-w-full w-auto h-auto pointer-events-none">
|
| 282 |
+
<rect :x="boxRect(p).x" :y="boxRect(p).y" :width="boxRect(p).w" :height="boxRect(p).h"
|
| 283 |
+
rx="14" ry="14" fill="#f59e0b" fill-opacity="0.10"
|
| 284 |
+
stroke="#f59e0b" stroke-width="8" opacity="0.95"/>
|
| 285 |
</svg>
|
| 286 |
</div>
|
| 287 |
</template>
|
| 288 |
+
<div x-show="!viewDoc" class="h-full w-full grid place-items-center text-center px-8">
|
| 289 |
<div>
|
| 290 |
<i data-lucide="file-text" class="inline-block w-11 h-11 text-brand-300 mb-3"></i>
|
| 291 |
<p class="text-brand-500/80 text-sm max-w-xs">Pick a manual and it opens here — then say things like <em>go to the brakes section</em> or <em>next page</em>.</p>
|
|
|
|
| 370 |
// viewer state machine: which page is open, the manual's chapter index,
|
| 371 |
// a jump history for "back", and the active circle overlay.
|
| 372 |
viewDoc:'', viewPage:1, docPages:1, sections:[], navStack:[], circle:null,
|
| 373 |
+
imgW:1240, imgH:1754, // the ACTIVE (left) page's natural size; fallback for the overlay
|
| 374 |
+
spread:true, // book-style two-page view (active page + the next)
|
| 375 |
+
nat:{}, // page number -> {w,h} natural size, set on each image load
|
| 376 |
|
| 377 |
get pageUrl(){ return this.viewDoc ? `/page/${encodeURIComponent(this.viewDoc)}/${this.viewPage}` : ''; },
|
| 378 |
+
// the page(s) on screen: the active page, plus the next one as context in
|
| 379 |
+
// spread mode (none past the last page). The active page is always LEFT, so
|
| 380 |
+
// the agent's target/circle page stays the first element.
|
| 381 |
+
get spreadPages(){ if(!this.viewDoc) return []; const p=this.viewPage; return (this.spread && p<this.docPages) ? [p, p+1] : [p]; },
|
| 382 |
+
natOf(p){ return this.nat[p] || {w:this.imgW, h:this.imgH}; },
|
| 383 |
+
// the overlay rectangle for the active circle on page p: bbox + a small pad,
|
| 384 |
+
// normalized (x1<x2,y1<y2) and clamped to the page so it never runs off.
|
| 385 |
+
boxRect(p){
|
| 386 |
+
if(!this.circle || !this.circle.bbox) return {x:0,y:0,w:0,h:0};
|
| 387 |
+
const b=this.circle.bbox, n=this.natOf(p), pad=14;
|
| 388 |
+
const x1=Math.min(b[0],b[2]), y1=Math.min(b[1],b[3]), x2=Math.max(b[0],b[2]), y2=Math.max(b[1],b[3]);
|
| 389 |
+
const x=Math.max(0,x1-pad), y=Math.max(0,y1-pad);
|
| 390 |
+
return {x, y, w:Math.min(n.w,x2+pad)-x, h:Math.min(n.h,y2+pad)-y};
|
| 391 |
+
},
|
| 392 |
|
| 393 |
async init(){
|
| 394 |
window.lucide?.createIcons(); // swap static <i data-lucide> -> clean SVG icons
|
|
|
|
| 419 |
async newChat(){
|
| 420 |
this.lastRequest=''; this.activity=null; this.history=[];
|
| 421 |
this.trace=[]; this.traceSeen=0; this.tab='toc';
|
| 422 |
+
this.viewDoc=''; this.viewPage=1; this.navStack=[]; this.circle=null; this.sections=[]; this.nat={};
|
| 423 |
const m = this.manuals.find(x=>x.value===this.manual);
|
| 424 |
if(!m) return;
|
| 425 |
this.docPages = m.pages || 1;
|
|
|
|
| 580 |
const job = this.client.submit('/find', {
|
| 581 |
request:q, manual:this.manual, k:this.k,
|
| 582 |
page: this.viewDoc ? this.viewPage : 0, section: this.sectionTitle(this.viewPage),
|
| 583 |
+
pages: this.spreadPages, // every page currently on screen — the agent may circle on any
|
| 584 |
history: this.history,
|
| 585 |
});
|
| 586 |
// the client re-delivers the generator's last yield in the completion
|
models/minicpm.py
CHANGED
|
@@ -64,6 +64,10 @@ GROUND_PROMPT = (
|
|
| 64 |
"part in the drawing and box THAT part (not the legend text).\n"
|
| 65 |
"- Otherwise it may be a row in a table, a specification value, or a "
|
| 66 |
"heading — box that.\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
"Reply with ONLY the box, as <box>x1 y1 x2 y2</box>: four integers "
|
| 68 |
"normalized to 0-1000 (x left→right, y top→bottom) over the whole page, "
|
| 69 |
"and nothing else. If it is not on this page, reply exactly: NOT FOUND"
|
|
|
|
| 64 |
"part in the drawing and box THAT part (not the legend text).\n"
|
| 65 |
"- Otherwise it may be a row in a table, a specification value, or a "
|
| 66 |
"heading — box that.\n"
|
| 67 |
+
"Box ONLY that one part, as TIGHTLY as possible — just the part itself. Do "
|
| 68 |
+
"NOT box the whole figure, the whole diagram, a group of parts, or the page; "
|
| 69 |
+
"if the part is small, the box must be small. A box wider than about half "
|
| 70 |
+
"the page is almost always wrong.\n"
|
| 71 |
"Reply with ONLY the box, as <box>x1 y1 x2 y2</box>: four integers "
|
| 72 |
"normalized to 0-1000 (x left→right, y top→bottom) over the whole page, "
|
| 73 |
"and nothing else. If it is not on this page, reply exactly: NOT FOUND"
|
models/minicpm_agent.py
CHANGED
|
@@ -59,8 +59,10 @@ SYSTEM_PROMPT = (
|
|
| 59 |
' {"tool": "go_to_section", "section": <number>}\n'
|
| 60 |
"- Jump straight to a known PHYSICAL page number:\n"
|
| 61 |
' {"tool": "go_to_page", "page": <number>}\n'
|
| 62 |
-
"- Circle something on
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
"- Finish — nothing more to do, or it isn't in the manual:\n"
|
| 65 |
' {"tool": "done", "message": "<one short line for the mechanic>"}\n\n'
|
| 66 |
"How to choose:\n"
|
|
@@ -69,11 +71,12 @@ SYSTEM_PROMPT = (
|
|
| 69 |
'contents that lists the part with a page number ("Actuators .... 855"), or '
|
| 70 |
"history gave one → go_to_page that number. Do NOT circle the index line; "
|
| 71 |
"go to the page it points to.\n"
|
| 72 |
-
"- The thing they want is on
|
| 73 |
-
"be what the mechanic asked for — match it
|
| 74 |
-
"appears there; NEVER circle a different
|
| 75 |
-
"
|
| 76 |
-
"
|
|
|
|
| 77 |
"- Use the conversation history only to resolve what they mean (e.g. "
|
| 78 |
'"circle the other one"); never restate earlier answers.\n\n'
|
| 79 |
"Examples (copy the FORMAT, not the values):\n"
|
|
@@ -82,8 +85,8 @@ SYSTEM_PROMPT = (
|
|
| 82 |
'{"tool": "search", "query": "fuel filter replacement"}\n'
|
| 83 |
'Mechanic: "the wastegate actuator" (current page is an index reading '
|
| 84 |
'"Actuators .... 855") → {"tool": "go_to_page", "page": 855}\n'
|
| 85 |
-
'Mechanic: "circle the bleeder screw" (it is on
|
| 86 |
-
'{"tool": "circle", "target": "bleeder screw"}'
|
| 87 |
)
|
| 88 |
|
| 89 |
|
|
@@ -94,24 +97,38 @@ def system_message() -> dict:
|
|
| 94 |
def state_message(
|
| 95 |
request: str,
|
| 96 |
toc: list[dict],
|
| 97 |
-
|
| 98 |
section: str,
|
| 99 |
-
page_text: str,
|
| 100 |
) -> dict:
|
| 101 |
"""The user message for the current step: what the mechanic just said, the
|
| 102 |
table of contents (numbered, the go_to_section index), and the whole text of
|
| 103 |
-
the page
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
| 106 |
toc_lines = "\n".join(
|
| 107 |
f"{i + 1}. {s['title']} (p.{s['page']})" for i, s in enumerate(toc)
|
| 108 |
) or "(none)"
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
# The request goes LAST (after the long page text) so it stays freshest —
|
| 116 |
# otherwise the page block dominates and the agent acts on the page instead
|
| 117 |
# of what was asked.
|
|
@@ -223,7 +240,17 @@ def _parse_tool(raw: str) -> dict | None:
|
|
| 223 |
return None
|
| 224 |
if tool == "circle":
|
| 225 |
target = str(obj.get("target") or "").strip()
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
if tool == "done":
|
| 228 |
return {"tool": "done", "message": str(obj.get("message") or "").strip()}
|
| 229 |
return None
|
|
|
|
| 59 |
' {"tool": "go_to_section", "section": <number>}\n'
|
| 60 |
"- Jump straight to a known PHYSICAL page number:\n"
|
| 61 |
' {"tool": "go_to_page", "page": <number>}\n'
|
| 62 |
+
"- Circle something on a page CURRENTLY ON SCREEN (their full text is given "
|
| 63 |
+
"to you). When two pages are shown, set page to the one whose text has it:\n"
|
| 64 |
+
' {"tool": "circle", "target": "<short name of the thing to circle>", '
|
| 65 |
+
'"page": <the on-screen page number it is on>}\n'
|
| 66 |
"- Finish — nothing more to do, or it isn't in the manual:\n"
|
| 67 |
' {"tool": "done", "message": "<one short line for the mechanic>"}\n\n'
|
| 68 |
"How to choose:\n"
|
|
|
|
| 71 |
'contents that lists the part with a page number ("Actuators .... 855"), or '
|
| 72 |
"history gave one → go_to_page that number. Do NOT circle the index line; "
|
| 73 |
"go to the page it points to.\n"
|
| 74 |
+
"- The thing they want is on a page ON SCREEN → circle it, and set page to "
|
| 75 |
+
"the one it is on. The target MUST be what the mechanic asked for — match it "
|
| 76 |
+
"to that page's exact wording if it appears there; NEVER circle a different "
|
| 77 |
+
"component.\n"
|
| 78 |
+
"- Otherwise → search. After a search shows a page, that page is on screen, "
|
| 79 |
+
"so circle on it or search again.\n"
|
| 80 |
"- Use the conversation history only to resolve what they mean (e.g. "
|
| 81 |
'"circle the other one"); never restate earlier answers.\n\n'
|
| 82 |
"Examples (copy the FORMAT, not the values):\n"
|
|
|
|
| 85 |
'{"tool": "search", "query": "fuel filter replacement"}\n'
|
| 86 |
'Mechanic: "the wastegate actuator" (current page is an index reading '
|
| 87 |
'"Actuators .... 855") → {"tool": "go_to_page", "page": 855}\n'
|
| 88 |
+
'Mechanic: "circle the bleeder screw" (it is on p.412, which is on screen) → '
|
| 89 |
+
'{"tool": "circle", "target": "bleeder screw", "page": 412}'
|
| 90 |
)
|
| 91 |
|
| 92 |
|
|
|
|
| 97 |
def state_message(
|
| 98 |
request: str,
|
| 99 |
toc: list[dict],
|
| 100 |
+
shown: list[dict],
|
| 101 |
section: str,
|
|
|
|
| 102 |
) -> dict:
|
| 103 |
"""The user message for the current step: what the mechanic just said, the
|
| 104 |
table of contents (numbered, the go_to_section index), and the whole text of
|
| 105 |
+
the page(s) currently on the viewer. The viewer shows a two-page spread, so
|
| 106 |
+
`shown` is [{page, text}] for each page on screen (one or two). Each page's
|
| 107 |
+
text is the parsed page rendered to text (figures/tables as descriptions) —
|
| 108 |
+
empty when the manual has no parse. When the agent circles, it names which of
|
| 109 |
+
these pages the target is on."""
|
| 110 |
toc_lines = "\n".join(
|
| 111 |
f"{i + 1}. {s['title']} (p.{s['page']})" for i, s in enumerate(toc)
|
| 112 |
) or "(none)"
|
| 113 |
+
if shown:
|
| 114 |
+
where = " and ".join(f"p.{s['page']}" for s in shown) + (
|
| 115 |
+
f' (section "{section}")' if section else ""
|
| 116 |
+
)
|
| 117 |
+
blocks = "\n\n".join(
|
| 118 |
+
f"PAGE {s['page']} — full text:\n{s['text'] or '(no text available)'}"
|
| 119 |
+
for s in shown
|
| 120 |
+
)
|
| 121 |
+
page_block = (
|
| 122 |
+
f"CURRENTLY ON SCREEN — {where}. You can circle on "
|
| 123 |
+
+ (
|
| 124 |
+
"either of these pages (say which in the circle call):\n"
|
| 125 |
+
if len(shown) > 1
|
| 126 |
+
else "this page:\n"
|
| 127 |
+
)
|
| 128 |
+
+ blocks
|
| 129 |
+
)
|
| 130 |
+
else:
|
| 131 |
+
page_block = "CURRENTLY ON SCREEN: (no page open)"
|
| 132 |
# The request goes LAST (after the long page text) so it stays freshest —
|
| 133 |
# otherwise the page block dominates and the agent acts on the page instead
|
| 134 |
# of what was asked.
|
|
|
|
| 240 |
return None
|
| 241 |
if tool == "circle":
|
| 242 |
target = str(obj.get("target") or "").strip()
|
| 243 |
+
if not _real(target):
|
| 244 |
+
return None
|
| 245 |
+
out = {"tool": "circle", "target": target}
|
| 246 |
+
# Optional: which on-screen page the target is on (the pipeline validates
|
| 247 |
+
# it against the pages actually shown and defaults to the active page).
|
| 248 |
+
try:
|
| 249 |
+
if obj.get("page") is not None:
|
| 250 |
+
out["page"] = int(obj.get("page"))
|
| 251 |
+
except (TypeError, ValueError):
|
| 252 |
+
pass
|
| 253 |
+
return out
|
| 254 |
if tool == "done":
|
| 255 |
return {"tool": "done", "message": str(obj.get("message") or "").strip()}
|
| 256 |
return None
|
pipelines/agent_ask.py
CHANGED
|
@@ -99,13 +99,21 @@ def agent_events(
|
|
| 99 |
def page_text(p: int) -> str:
|
| 100 |
return page_to_text(page_elements.get(p, []))
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
messages = [minicpm_agent.system_message()]
|
| 103 |
messages += _history_messages(history)
|
| 104 |
-
messages.append(
|
| 105 |
-
minicpm_agent.state_message(request, sections, cur, section, page_text(cur))
|
| 106 |
-
)
|
| 107 |
|
| 108 |
-
current_page =
|
|
|
|
| 109 |
yield {"type": "status", "text": "Thinking…"}
|
| 110 |
|
| 111 |
for step in range(AGENT_MAX_STEPS):
|
|
@@ -194,6 +202,7 @@ def agent_events(
|
|
| 194 |
best_page = hits[0][1]
|
| 195 |
yield {"type": "found", "page": best_page}
|
| 196 |
current_page = best_page
|
|
|
|
| 197 |
messages.append(
|
| 198 |
minicpm_agent.tool_result_message(
|
| 199 |
f"Search showed p.{best_page}. It is now the CURRENT page.\n"
|
|
@@ -209,18 +218,25 @@ def agent_events(
|
|
| 209 |
|
| 210 |
if tool["tool"] == "circle":
|
| 211 |
target = tool["target"]
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
yield {"type": "status", "text": "Pinning it down…"}
|
| 214 |
-
img = render_page(visual_store.pdf_path(doc_id),
|
| 215 |
box, braw = minicpm.ground_box(img, target)
|
| 216 |
log.info("ground_box(%r) on p.%d → %s | raw=%r",
|
| 217 |
-
target,
|
| 218 |
yield {
|
| 219 |
"type": "done",
|
| 220 |
"kind": "point",
|
| 221 |
"found": True,
|
| 222 |
"target": target,
|
| 223 |
-
"page":
|
| 224 |
"bbox": [round(v) for v in box] if box is not None else None,
|
| 225 |
# the VLM's raw grounding reply — diagnostic only (helps explain
|
| 226 |
# where/why a box landed); shown in the trace view.
|
|
|
|
| 99 |
def page_text(p: int) -> str:
|
| 100 |
return page_to_text(page_elements.get(p, []))
|
| 101 |
|
| 102 |
+
# The page(s) on the viewer — a two-page spread shows the active page plus
|
| 103 |
+
# the next. The agent sees the text of all of them and may circle on any;
|
| 104 |
+
# the active page stays first. Falls back to the single current page.
|
| 105 |
+
shown_pages = [int(p) for p in (viewer.get("pages") or []) if int(p) >= 1] or [cur]
|
| 106 |
+
if cur in shown_pages:
|
| 107 |
+
shown_pages = [cur] + [p for p in shown_pages if p != cur]
|
| 108 |
+
shown_pages = shown_pages[:2]
|
| 109 |
+
shown = [{"page": p, "text": page_text(p)} for p in shown_pages]
|
| 110 |
+
|
| 111 |
messages = [minicpm_agent.system_message()]
|
| 112 |
messages += _history_messages(history)
|
| 113 |
+
messages.append(minicpm_agent.state_message(request, sections, shown, section))
|
|
|
|
|
|
|
| 114 |
|
| 115 |
+
current_page = shown_pages[0] # the active page circle defaults to
|
| 116 |
+
circleable = set(shown_pages) # pages the agent may circle on right now
|
| 117 |
yield {"type": "status", "text": "Thinking…"}
|
| 118 |
|
| 119 |
for step in range(AGENT_MAX_STEPS):
|
|
|
|
| 202 |
best_page = hits[0][1]
|
| 203 |
yield {"type": "found", "page": best_page}
|
| 204 |
current_page = best_page
|
| 205 |
+
circleable = {best_page} # search landed here — circle on this page
|
| 206 |
messages.append(
|
| 207 |
minicpm_agent.tool_result_message(
|
| 208 |
f"Search showed p.{best_page}. It is now the CURRENT page.\n"
|
|
|
|
| 218 |
|
| 219 |
if tool["tool"] == "circle":
|
| 220 |
target = tool["target"]
|
| 221 |
+
# The agent says which shown page the target is on (it has both pages'
|
| 222 |
+
# text). Default to the active page when it's unspecified or not one of
|
| 223 |
+
# the pages on screen — so the box is grounded on, and drawn over, the
|
| 224 |
+
# RIGHT page.
|
| 225 |
+
page = tool.get("page")
|
| 226 |
+
if page not in circleable:
|
| 227 |
+
page = current_page
|
| 228 |
+
yield {"type": "step", "tool": "circle", "target": target, "page": page}
|
| 229 |
yield {"type": "status", "text": "Pinning it down…"}
|
| 230 |
+
img = render_page(visual_store.pdf_path(doc_id), page)
|
| 231 |
box, braw = minicpm.ground_box(img, target)
|
| 232 |
log.info("ground_box(%r) on p.%d → %s | raw=%r",
|
| 233 |
+
target, page, box, braw[:200])
|
| 234 |
yield {
|
| 235 |
"type": "done",
|
| 236 |
"kind": "point",
|
| 237 |
"found": True,
|
| 238 |
"target": target,
|
| 239 |
+
"page": page,
|
| 240 |
"bbox": [round(v) for v in box] if box is not None else None,
|
| 241 |
# the VLM's raw grounding reply — diagnostic only (helps explain
|
| 242 |
# where/why a box landed); shown in the trace view.
|
pipelines/mock_ask.py
CHANGED
|
@@ -158,7 +158,8 @@ class MockAskPipeline:
|
|
| 158 |
def _find_events(self, store, request, doc_id, info, top_k, sections, viewer):
|
| 159 |
delay = float(os.environ.get("MOCK_DELAY", "0"))
|
| 160 |
cur = max(1, int(viewer.get("page") or 1))
|
| 161 |
-
|
|
|
|
| 162 |
yield {"type": "status", "text": "Thinking…"}
|
| 163 |
time.sleep(delay)
|
| 164 |
|
|
@@ -216,7 +217,7 @@ class MockAskPipeline:
|
|
| 216 |
|
| 217 |
def _circle(self, store, doc_id, page, target, delay):
|
| 218 |
"""Emit the circle step + terminal point event for a target on a page."""
|
| 219 |
-
yield {"type": "step", "tool": "circle", "target": target}
|
| 220 |
yield {"type": "status", "text": "Pinning it down…"}
|
| 221 |
time.sleep(delay)
|
| 222 |
box = self._mock_box(store, doc_id, page, target)
|
|
@@ -233,18 +234,20 @@ class MockAskPipeline:
|
|
| 233 |
"raw": json.dumps(tool, separators=(",", ":")), "prompt": prompt}
|
| 234 |
|
| 235 |
@staticmethod
|
| 236 |
-
def _mock_prompt(request: str, sections: list[dict],
|
| 237 |
"""A representative stand-in for the rendered chat prompt, so the
|
| 238 |
Diagnostics 'prompt' view is exercisable in MOCK_MODELS=1. Not the real
|
| 239 |
-
template — just the same shape (system rules +
|
|
|
|
| 240 |
toc = "\n".join(
|
| 241 |
f"{i + 1}. {s['title']} (p.{s.get('page') or s.get('page_start')})"
|
| 242 |
for i, s in enumerate(sections)
|
| 243 |
) or "(none)"
|
|
|
|
| 244 |
return (
|
| 245 |
"<|im_start|>system\n(mock) You FIND the right page and POINT at "
|
| 246 |
"things — reply with ONE tool JSON, no prose.<|im_end|>\n"
|
| 247 |
-
f"<|im_start|>user\
|
| 248 |
f"TABLE OF CONTENTS:\n{toc}\n\n"
|
| 249 |
f"The mechanic said: {request!r}\n"
|
| 250 |
"Choose ONE tool and reply with ONLY its JSON object."
|
|
|
|
| 158 |
def _find_events(self, store, request, doc_id, info, top_k, sections, viewer):
|
| 159 |
delay = float(os.environ.get("MOCK_DELAY", "0"))
|
| 160 |
cur = max(1, int(viewer.get("page") or 1))
|
| 161 |
+
shown = [int(p) for p in (viewer.get("pages") or []) if str(p).isdigit()][:2] or [cur]
|
| 162 |
+
prompt = self._mock_prompt(request, sections, shown)
|
| 163 |
yield {"type": "status", "text": "Thinking…"}
|
| 164 |
time.sleep(delay)
|
| 165 |
|
|
|
|
| 217 |
|
| 218 |
def _circle(self, store, doc_id, page, target, delay):
|
| 219 |
"""Emit the circle step + terminal point event for a target on a page."""
|
| 220 |
+
yield {"type": "step", "tool": "circle", "target": target, "page": page}
|
| 221 |
yield {"type": "status", "text": "Pinning it down…"}
|
| 222 |
time.sleep(delay)
|
| 223 |
box = self._mock_box(store, doc_id, page, target)
|
|
|
|
| 234 |
"raw": json.dumps(tool, separators=(",", ":")), "prompt": prompt}
|
| 235 |
|
| 236 |
@staticmethod
|
| 237 |
+
def _mock_prompt(request: str, sections: list[dict], shown: list[int]) -> str:
|
| 238 |
"""A representative stand-in for the rendered chat prompt, so the
|
| 239 |
Diagnostics 'prompt' view is exercisable in MOCK_MODELS=1. Not the real
|
| 240 |
+
template — just the same shape (system rules + the on-screen pages +
|
| 241 |
+
TOC + request)."""
|
| 242 |
toc = "\n".join(
|
| 243 |
f"{i + 1}. {s['title']} (p.{s.get('page') or s.get('page_start')})"
|
| 244 |
for i, s in enumerate(sections)
|
| 245 |
) or "(none)"
|
| 246 |
+
where = " and ".join(f"p.{p}" for p in shown) or "(no page open)"
|
| 247 |
return (
|
| 248 |
"<|im_start|>system\n(mock) You FIND the right page and POINT at "
|
| 249 |
"things — reply with ONE tool JSON, no prose.<|im_end|>\n"
|
| 250 |
+
f"<|im_start|>user\nCURRENTLY ON SCREEN — {where} (mock text omitted)\n\n"
|
| 251 |
f"TABLE OF CONTENTS:\n{toc}\n\n"
|
| 252 |
f"The mechanic said: {request!r}\n"
|
| 253 |
"Choose ONE tool and reply with ONLY its JSON object."
|