airayven7 commited on
Commit
3dfd032
·
verified ·
1 Parent(s): 1022e34

Sync from GitHub c06d2c6

Browse files
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
- viewer = {"page": int(page or 0), "section": str(section or "")}
 
 
 
 
 
 
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
- <div class="flex-1 min-h-0 overflow-auto scroll-thin bg-brand-50/40 flex justify-center items-start p-4">
254
- <template x-if="viewDoc">
255
- <!-- fit the whole page to the available HEIGHT so it isn't blown up
256
- past the viewport (the pane width is fine; portrait pages were
257
- overflowing vertically). aspect-ratio keeps this box exactly the
258
- image's shape, so the SVG circle overlay stays aligned 1:1 -->
259
- <div class="relative h-full max-w-full shrink-0" :style="`aspect-ratio:${imgW}/${imgH}`">
260
- <img :src="pageUrl" @load="imgW=$event.target.naturalWidth; imgH=$event.target.naturalHeight"
261
- class="block h-full w-full select-none rounded-lg shadow-sm ring-1 ring-navy/10" draggable="false" alt="manual page">
262
- <!-- circle overlay: bbox coordinates are in the rendered page's
263
- pixel space, identical to the image's natural size. NB: no
264
- <template x-if> in here HTML templates aren't valid SVG
265
- children, so the ellipse always exists and every binding
266
- guards against circle being null -->
267
- <svg id="circle-overlay" x-show="circle && circle.page===viewPage" :viewBox="`0 0 ${imgW} ${imgH}`"
268
- class="absolute inset-0 w-full h-full pointer-events-none">
269
- <ellipse :cx="circle ? (circle.bbox[0]+circle.bbox[2])/2 : 0"
270
- :cy="circle ? (circle.bbox[1]+circle.bbox[3])/2 : 0"
271
- :rx="circle ? (circle.bbox[2]-circle.bbox[0])/2+16 : 0"
272
- :ry="circle ? (circle.bbox[3]-circle.bbox[1])/2+16 : 0"
273
- fill="none" stroke="#f59e0b" stroke-width="9" opacity="0.85"/>
 
 
 
 
 
 
274
  </svg>
275
  </div>
276
  </template>
277
- <div x-show="!viewDoc" class="chat-grid h-full grid place-items-center text-center px-8">
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, // replaced by the real natural size on image load
 
 
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 the CURRENT page (its full text is given to you):\n"
63
- ' {"tool": "circle", "target": "<short name of the thing to circle>"}\n'
 
 
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 the CURRENT page → circle it. The target MUST "
73
- "be what the mechanic asked for — match it to the page's exact wording if it "
74
- "appears there; NEVER circle a different component.\n"
75
- "- Otherwise → search. After a search shows a page, that page becomes the "
76
- "CURRENT page, so circle on it or search again.\n"
 
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 this page) → '
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
- page: int | None,
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 being viewed. page_text is the parsed page rendered to text (figures
104
- and tables as their descriptions) empty when no page is open or the manual
105
- has no parse."""
 
 
106
  toc_lines = "\n".join(
107
  f"{i + 1}. {s['title']} (p.{s['page']})" for i, s in enumerate(toc)
108
  ) or "(none)"
109
- where = f"p.{page}" + (f', section "{section}"' if section else "") if page else "(no page open)"
110
- page_block = (
111
- f"CURRENT PAGE ({where}) full text:\n{page_text}"
112
- if page_text
113
- else f"CURRENT PAGE: {where} (no text available)"
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
- return {"tool": "circle", "target": target} if _real(target) else None
 
 
 
 
 
 
 
 
 
 
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 = cur # the page circle acts on; moves when search shows one
 
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
- yield {"type": "step", "tool": "circle", "target": target}
 
 
 
 
 
 
 
213
  yield {"type": "status", "text": "Pinning it down…"}
214
- img = render_page(visual_store.pdf_path(doc_id), current_page)
215
  box, braw = minicpm.ground_box(img, target)
216
  log.info("ground_box(%r) on p.%d → %s | raw=%r",
217
- target, current_page, box, braw[:200])
218
  yield {
219
  "type": "done",
220
  "kind": "point",
221
  "found": True,
222
  "target": target,
223
- "page": current_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
- prompt = self._mock_prompt(request, sections, cur)
 
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], page: int) -> str:
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 + TOC + request)."""
 
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\nCURRENT PAGE: p.{page} (mock text omitted)\n\n"
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."