airayven7 commited on
Commit
a86d9d6
Β·
verified Β·
1 Parent(s): f303c41

Sync from GitHub 80886c2

Browse files
Files changed (4) hide show
  1. app.py +14 -5
  2. frontend/index.html +15 -6
  3. models/minicpm_agent.py +20 -2
  4. pipelines/agent_ask.py +66 -28
app.py CHANGED
@@ -63,6 +63,7 @@ from core.constants import (
63
  MOCK_PDF_DIR,
64
  PARSED_SUBDIR,
65
  PREINDEXED_DIR,
 
66
  VISUAL_SUBDIR,
67
  )
68
  from core.pdf import pdf_outline, render_page_png
@@ -345,8 +346,14 @@ def index():
345
  frontend needs (default/max k) so it needs no extra round-trip on load."""
346
  with open(os.path.join(_FRONTEND_DIR, "index.html")) as f:
347
  html = f.read()
348
- html = html.replace("__DEFAULT_K__", str(DEFAULT_TOP_K)).replace(
349
- "__MAX_K__", str(MAX_TOP_K)
 
 
 
 
 
 
350
  )
351
  return HTMLResponse(html)
352
 
@@ -375,9 +382,11 @@ def serve_pdf(doc_id: str):
375
  @app.get("/page/{doc_id}/{page}")
376
  def serve_page(doc_id: str, page: int):
377
  """One manual page rendered to PNG at RENDER_DPI β€” the viewer pane's <img>
378
- source. Grounding boxes from the find turn are in this image's pixel
379
- coordinates, so the SVG circle overlay maps 1:1. Immutable content β†’ long
380
- browser cache, which is what makes page flips instant."""
 
 
381
  path = _pdf_path(doc_id)
382
  if not path or not os.path.isfile(path):
383
  return Response(status_code=404)
 
63
  MOCK_PDF_DIR,
64
  PARSED_SUBDIR,
65
  PREINDEXED_DIR,
66
+ RENDER_DPI,
67
  VISUAL_SUBDIR,
68
  )
69
  from core.pdf import pdf_outline, render_page_png
 
346
  frontend needs (default/max k) so it needs no extra round-trip on load."""
347
  with open(os.path.join(_FRONTEND_DIR, "index.html")) as f:
348
  html = f.read()
349
+ html = (
350
+ html.replace("__DEFAULT_K__", str(DEFAULT_TOP_K))
351
+ .replace("__MAX_K__", str(MAX_TOP_K))
352
+ # Cache-bust key for /page images: changing the render DPI changes the
353
+ # served page size, so it must change the URL too β€” otherwise a browser
354
+ # could keep an old-resolution page (cached up to a day) under the same
355
+ # URL while the grounding render uses the new size.
356
+ .replace("__PAGE_V__", str(RENDER_DPI))
357
  )
358
  return HTMLResponse(html)
359
 
 
382
  @app.get("/page/{doc_id}/{page}")
383
  def serve_page(doc_id: str, page: int):
384
  """One manual page rendered to PNG at RENDER_DPI β€” the viewer pane's <img>
385
+ source. Cached long (immutable content β†’ instant page flips); the URL
386
+ carries a ?v=RENDER_DPI cache key so a render-size change can't leave a
387
+ stale-resolution page under the same URL. The circle overlay sizes its
388
+ viewBox from the grounding render's dimensions (sent with the bbox), not
389
+ this image, so it stays aligned even if a cached page is a different size."""
390
  path = _pdf_path(doc_id)
391
  if not path or not os.path.isfile(path):
392
  return Response(status_code=404)
frontend/index.html CHANGED
@@ -264,7 +264,7 @@
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">
@@ -276,8 +276,8 @@
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"
@@ -347,6 +347,7 @@ const ICONS = {
347
  'undo-2': '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11"/>',
348
  'circle-slash': '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
349
  info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
 
350
  };
351
 
352
  function repairGuy() {
@@ -374,17 +375,23 @@ function repairGuy() {
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};
@@ -508,6 +515,7 @@ function repairGuy() {
508
  case 'step':{ // the chosen tool, with its args
509
  let label='';
510
  if(ev.tool==='search') label=`search Β· β€œ${ev.query}”`;
 
511
  else if(ev.tool==='circle') label=`circle Β· β€œ${ev.target}”`;
512
  else if(ev.tool==='go_to_section') label=`go_to_section Β· ${ev.title} (p.${ev.page})`;
513
  else if(ev.tool==='go_to_page') label=`go_to_page Β· p.${ev.page}`;
@@ -629,6 +637,7 @@ function repairGuy() {
629
  else if(ev.tool==='go_to_page'){ find.icon='file-text'; find.label=`Going to page ${ev.page}`; find.detail=''; this.status=`Opening page ${ev.page}…`; }
630
  else if(ev.tool==='circle'){ find.icon='target'; find.label=`Circling β€œ${ev.target}”`; find.detail=''; this.status=`Looking for β€œ${ev.target}”…`; }
631
  else if(ev.tool==='search'){ find.icon='search'; find.label=`Searching: β€œ${ev.query}”`; find.detail=''; this.status=`Searching the manual for β€œ${ev.query}”…`; }
 
632
  break;
633
 
634
  case 'tool_result': // search candidates (no longer shown β€” keep status only)
@@ -659,7 +668,7 @@ function repairGuy() {
659
  this.recordHistory(ev.message||'done');
660
  }else if(ev.found){ // point
661
  this.setPage(ev.page);
662
- if(ev.bbox){ this.circle={page:ev.page, bbox:ev.bbox}; find.icon='target'; find.label=`Circled β€œ${ev.target}”`; this.recordHistory(`circled β€œ${ev.target}” on p.${ev.page}`); }
663
  else { find.icon='file-text'; find.label=`Showed β€œ${ev.target}”`; this.recordHistory(`showed β€œ${ev.target}” on p.${ev.page}`); } // page found, not pinpointed
664
  find.detail=`p.${ev.page}${meta}`;
665
  }else{
 
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}?v=__PAGE_V__`"
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">
 
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:${boxDims(p).w}/${boxDims(p).h}`"
280
+ :viewBox="`0 0 ${boxDims(p).w} ${boxDims(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"
 
347
  'undo-2': '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11"/>',
348
  'circle-slash': '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
349
  info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
350
+ 'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
351
  };
352
 
353
  function repairGuy() {
 
375
  spread:true, // book-style two-page view (active page + the next)
376
  nat:{}, // page number -> {w,h} natural size, set on each image load
377
 
378
+ get pageUrl(){ return this.viewDoc ? `/page/${encodeURIComponent(this.viewDoc)}/${this.viewPage}?v=__PAGE_V__` : ''; },
379
  // the page(s) on screen: the active page, plus the next one as context in
380
  // spread mode (none past the last page). The active page is always LEFT, so
381
  // the agent's target/circle page stays the first element.
382
  get spreadPages(){ if(!this.viewDoc) return []; const p=this.viewPage; return (this.spread && p<this.docPages) ? [p, p+1] : [p]; },
383
  natOf(p){ return this.nat[p] || {w:this.imgW, h:this.imgH}; },
384
+ // The coordinate space the active circle's bbox lives in: the size of the
385
+ // image the VLM GROUNDED on (sent by the backend), NOT the browser-loaded
386
+ // page <img>. Using this for the SVG viewBox keeps the circle aligned even
387
+ // if the served page PNG is a different/stale resolution. Falls back to the
388
+ // loaded image's natural size (older events without dims).
389
+ boxDims(p){ const c=this.circle; return (c && c.page===p && c.dims) ? {w:c.dims[0], h:c.dims[1]} : this.natOf(p); },
390
  // the overlay rectangle for the active circle on page p: bbox + a small pad,
391
  // normalized (x1<x2,y1<y2) and clamped to the page so it never runs off.
392
  boxRect(p){
393
  if(!this.circle || !this.circle.bbox) return {x:0,y:0,w:0,h:0};
394
+ const b=this.circle.bbox, n=this.boxDims(p), pad=14;
395
  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]);
396
  const x=Math.max(0,x1-pad), y=Math.max(0,y1-pad);
397
  return {x, y, w:Math.min(n.w,x2+pad)-x, h:Math.min(n.h,y2+pad)-y};
 
515
  case 'step':{ // the chosen tool, with its args
516
  let label='';
517
  if(ev.tool==='search') label=`search Β· β€œ${ev.query}”`;
518
+ else if(ev.tool==='find_answer') label=`find_answer Β· β€œ${ev.query}”`;
519
  else if(ev.tool==='circle') label=`circle Β· β€œ${ev.target}”`;
520
  else if(ev.tool==='go_to_section') label=`go_to_section Β· ${ev.title} (p.${ev.page})`;
521
  else if(ev.tool==='go_to_page') label=`go_to_page Β· p.${ev.page}`;
 
637
  else if(ev.tool==='go_to_page'){ find.icon='file-text'; find.label=`Going to page ${ev.page}`; find.detail=''; this.status=`Opening page ${ev.page}…`; }
638
  else if(ev.tool==='circle'){ find.icon='target'; find.label=`Circling β€œ${ev.target}”`; find.detail=''; this.status=`Looking for β€œ${ev.target}”…`; }
639
  else if(ev.tool==='search'){ find.icon='search'; find.label=`Searching: β€œ${ev.query}”`; find.detail=''; this.status=`Searching the manual for β€œ${ev.query}”…`; }
640
+ else if(ev.tool==='find_answer'){ find.icon='help-circle'; find.label=`Looking up: β€œ${ev.query}”`; find.detail=''; this.status=`Looking up β€œ${ev.query}” in the manual…`; }
641
  break;
642
 
643
  case 'tool_result': // search candidates (no longer shown β€” keep status only)
 
668
  this.recordHistory(ev.message||'done');
669
  }else if(ev.found){ // point
670
  this.setPage(ev.page);
671
+ if(ev.bbox){ this.circle={page:ev.page, bbox:ev.bbox, dims:ev.dims}; find.icon='target'; find.label=`Circled β€œ${ev.target}”`; this.recordHistory(`circled β€œ${ev.target}” on p.${ev.page}`); }
672
  else { find.icon='file-text'; find.label=`Showed β€œ${ev.target}”`; this.recordHistory(`showed β€œ${ev.target}” on p.${ev.page}`); } // page found, not pinpointed
673
  find.detail=`p.${ev.page}${meta}`;
674
  }else{
models/minicpm_agent.py CHANGED
@@ -8,6 +8,9 @@ tool:
8
 
9
  search(query) semantic-search the manual; the best page is shown
10
  and its text added to the conversation
 
 
 
11
  go_to_section(section) jump to a numbered table-of-contents section
12
  circle(target) circle something on the CURRENT page (its text is
13
  in context); the VLM grounds the box
@@ -43,7 +46,7 @@ from core.constants import (
43
 
44
  # The tools the agent may emit, and the JSON shape of each. Kept here so the
45
  # prompt and the parser can't drift apart.
46
- TOOLS = ("search", "go_to_section", "go_to_page", "circle", "done")
47
 
48
  SYSTEM_PROMPT = (
49
  "You are the assistant for a hands-busy mechanic reading a repair manual on "
@@ -55,6 +58,11 @@ SYSTEM_PROMPT = (
55
  "Tools:\n"
56
  '- Search the manual for a part/topic/procedure:\n'
57
  ' {"tool": "search", "query": "<focused search phrase>"}\n'
 
 
 
 
 
58
  "- Jump to a section β€” use its number from the TABLE OF CONTENTS:\n"
59
  ' {"tool": "go_to_section", "section": <number>}\n'
60
  "- Jump straight to a known PHYSICAL page number:\n"
@@ -81,6 +89,11 @@ SYSTEM_PROMPT = (
81
  'a value, or a line (e.g. "what fuel does it take" answered by "Engine fuel '
82
  '- Gasoline") β†’ circle that spot, using the printed words as the target. '
83
  "Point at the answer; do NOT keep searching for a better page.\n"
 
 
 
 
 
84
  "- Otherwise β†’ search. After a search shows a page, that page is on screen, "
85
  "so circle on it or search again β€” but NEVER repeat a search that did not "
86
  "move you; act on the page instead.\n"
@@ -95,7 +108,9 @@ SYSTEM_PROMPT = (
95
  'Mechanic: "circle the bleeder screw" (it is on p.412, which is on screen) β†’ '
96
  '{"tool": "circle", "target": "bleeder screw", "page": 412}\n'
97
  'Mechanic: "what fuel does it take" (p.5 on screen shows "Engine fuel - '
98
- 'Gasoline") β†’ {"tool": "circle", "target": "Engine fuel - Gasoline", "page": 5}'
 
 
99
  )
100
 
101
 
@@ -286,6 +301,9 @@ def _parse_tool(raw: str) -> dict | None:
286
  if tool == "search":
287
  query = str(obj.get("query") or "").strip()
288
  return {"tool": "search", "query": query} if _real(query) else None
 
 
 
289
  if tool == "go_to_section":
290
  try:
291
  return {"tool": "go_to_section", "section": int(obj.get("section"))}
 
8
 
9
  search(query) semantic-search the manual; the best page is shown
10
  and its text added to the conversation
11
+ find_answer(query) dense TEXT search of the parsed chunks for a
12
+ spec/value/fact; the page that states it is shown,
13
+ its text added so the agent can circle the answer
14
  go_to_section(section) jump to a numbered table-of-contents section
15
  circle(target) circle something on the CURRENT page (its text is
16
  in context); the VLM grounds the box
 
46
 
47
  # The tools the agent may emit, and the JSON shape of each. Kept here so the
48
  # prompt and the parser can't drift apart.
49
+ TOOLS = ("search", "find_answer", "go_to_section", "go_to_page", "circle", "done")
50
 
51
  SYSTEM_PROMPT = (
52
  "You are the assistant for a hands-busy mechanic reading a repair manual on "
 
58
  "Tools:\n"
59
  '- Search the manual for a part/topic/procedure:\n'
60
  ' {"tool": "search", "query": "<focused search phrase>"}\n'
61
+ "- Look up the ANSWER to a question β€” a spec, value, or fact (fuel type, a "
62
+ "torque, an oil/coolant capacity). This searches the manual TEXT, so it "
63
+ "finds the page that STATES the answer when a topic search would miss the "
64
+ "plain specs page:\n"
65
+ ' {"tool": "find_answer", "query": "<the spec or fact being asked for>"}\n'
66
  "- Jump to a section β€” use its number from the TABLE OF CONTENTS:\n"
67
  ' {"tool": "go_to_section", "section": <number>}\n'
68
  "- Jump straight to a known PHYSICAL page number:\n"
 
89
  'a value, or a line (e.g. "what fuel does it take" answered by "Engine fuel '
90
  '- Gasoline") β†’ circle that spot, using the printed words as the target. '
91
  "Point at the answer; do NOT keep searching for a better page.\n"
92
+ "- They ask a QUESTION for a spec/value/fact that is NOT on screen yet "
93
+ '("what fuel does it take", "engine oil capacity", "drain-plug torque") β†’ '
94
+ "find_answer with the fact as the query; it searches the manual text and "
95
+ "shows the page that states it, which you then circle. Use find_answer for "
96
+ "fact questions; use search to find a part, diagram, or procedure.\n"
97
  "- Otherwise β†’ search. After a search shows a page, that page is on screen, "
98
  "so circle on it or search again β€” but NEVER repeat a search that did not "
99
  "move you; act on the page instead.\n"
 
108
  'Mechanic: "circle the bleeder screw" (it is on p.412, which is on screen) β†’ '
109
  '{"tool": "circle", "target": "bleeder screw", "page": 412}\n'
110
  'Mechanic: "what fuel does it take" (p.5 on screen shows "Engine fuel - '
111
+ 'Gasoline") β†’ {"tool": "circle", "target": "Engine fuel - Gasoline", "page": 5}\n'
112
+ 'Mechanic: "what\'s the engine oil capacity" (not on this page) β†’ '
113
+ '{"tool": "find_answer", "query": "engine oil capacity"}'
114
  )
115
 
116
 
 
301
  if tool == "search":
302
  query = str(obj.get("query") or "").strip()
303
  return {"tool": "search", "query": query} if _real(query) else None
304
+ if tool == "find_answer":
305
+ query = str(obj.get("query") or "").strip()
306
+ return {"tool": "find_answer", "query": query} if _real(query) else None
307
  if tool == "go_to_section":
308
  try:
309
  return {"tool": "go_to_section", "section": int(obj.get("section"))}
pipelines/agent_ask.py CHANGED
@@ -13,6 +13,10 @@ Flow (one @spaces.GPU call, streamed as events):
13
  search(query) ColEmbed top-N β†’ 1B rerank by page text β†’ show
14
  the best page; its text is fed back so the agent
15
  can then circle on it (continues)
 
 
 
 
16
  circle(target) ground the target on the CURRENT page (VLM) and
17
  circle it (terminal)
18
  done(message) nothing to do / not in the manual (terminal)
@@ -52,6 +56,7 @@ from core.page_context import index_pages, page_to_text
52
  from core.pdf import page_count, render_page
53
  from models import minicpm, minicpm_agent
54
  from models.colembed import maxsim_search
 
55
 
56
  log = logging.getLogger("repairguy.agent")
57
 
@@ -119,6 +124,41 @@ def agent_events(
119
  ground_failed = set() # (page, normalized target) the VLM already missed
120
  yield {"type": "status", "text": "Thinking…"}
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  for step in range(AGENT_MAX_STEPS):
123
  # Render the exact prompt BEFORE deciding so the trace can show what the
124
  # brain was asked, not just what it answered.
@@ -194,35 +234,27 @@ def agent_events(
194
  minicpm_agent.tool_result_message(f"Search for {query!r} found nothing.")
195
  )
196
  continue
197
- pages = [(p, render_page(visual_store.pdf_path(doc_id), p)) for _, p, _ in hits]
198
- yield {
199
- "type": "tool_result",
200
- "tool": "search_docs",
201
- "gallery": [
202
- (img, f"{manual} β€” p.{p} (score {s:.3g})")
203
- for (p, img), (_, _, s) in zip(pages, hits)
204
- ],
205
- "page_refs": [(doc_id, p) for _, p, _ in hits],
206
- }
207
- best_page = hits[0][1]
208
- yield {"type": "found", "page": best_page}
209
- current_page = best_page
210
- circleable = {best_page} # search landed here β€” circle on this page
211
- # A no-op search β€” the same query again, or it lands on a page already
212
- # shown this turn β€” means the agent is stuck. Feed back a FORCING
213
- # message (no "search again") so a greedy 1B can't loop the identical
214
- # search forever; otherwise the normal "circle or search again" prompt.
215
- qkey = " ".join(query.lower().split())
216
- stuck = qkey in tried_queries or best_page in seen_pages
217
- tried_queries.add(qkey)
218
- seen_pages.add(best_page)
219
- messages.append(
220
- minicpm_agent.tool_result_message(
221
- minicpm_agent.search_result_message(
222
- request, best_page, page_text(best_page), stuck
223
- )
224
  )
225
- )
 
226
  continue
227
 
228
  if tool["tool"] == "circle":
@@ -262,6 +294,12 @@ def agent_events(
262
  "target": target,
263
  "page": page,
264
  "bbox": [round(v) for v in box] if box is not None else None,
 
 
 
 
 
 
265
  # the VLM's raw grounding reply β€” diagnostic only (helps explain
266
  # where/why a box landed); shown in the trace view.
267
  "ground_raw": braw[:300],
 
13
  search(query) ColEmbed top-N β†’ 1B rerank by page text β†’ show
14
  the best page; its text is fed back so the agent
15
  can then circle on it (continues)
16
+ find_answer(query) dense TEXT retrieval over the parsed chunks β†’ show
17
+ the page that STATES the answer (where a visual
18
+ search would miss the plain specs page); its text
19
+ is fed back so the agent can circle it (continues)
20
  circle(target) ground the target on the CURRENT page (VLM) and
21
  circle it (terminal)
22
  done(message) nothing to do / not in the manual (terminal)
 
56
  from core.pdf import page_count, render_page
57
  from models import minicpm, minicpm_agent
58
  from models.colembed import maxsim_search
59
+ from pipelines.parsed_ask import retrieve_pages
60
 
61
  log = logging.getLogger("repairguy.agent")
62
 
 
124
  ground_failed = set() # (page, normalized target) the VLM already missed
125
  yield {"type": "status", "text": "Thinking…"}
126
 
127
+ def present_hits(hits, qkey):
128
+ """Shared tail for the two retrieval tools (search / find_answer): show
129
+ the shortlist, land on the top page, and feed its text back β€” FORCING a
130
+ decision when the landing is a no-op (the same query again, or a page
131
+ already shown this turn), so a greedy 1B can't loop the identical lookup
132
+ forever. The only thing that differs between the tools is the retriever
133
+ that produced `hits`; everything downstream is identical."""
134
+ nonlocal current_page, circleable
135
+ rendered = [
136
+ (p, render_page(visual_store.pdf_path(doc_id), p)) for _, p, _ in hits
137
+ ]
138
+ yield {
139
+ "type": "tool_result",
140
+ "tool": "search_docs",
141
+ "gallery": [
142
+ (img, f"{manual} β€” p.{p} (score {s:.3g})")
143
+ for (p, img), (_, _, s) in zip(rendered, hits)
144
+ ],
145
+ "page_refs": [(doc_id, p) for _, p, _ in hits],
146
+ }
147
+ best_page = hits[0][1]
148
+ yield {"type": "found", "page": best_page}
149
+ current_page = best_page
150
+ circleable = {best_page} # the lookup landed here β€” circle on this page
151
+ stuck = qkey in tried_queries or best_page in seen_pages
152
+ tried_queries.add(qkey)
153
+ seen_pages.add(best_page)
154
+ messages.append(
155
+ minicpm_agent.tool_result_message(
156
+ minicpm_agent.search_result_message(
157
+ request, best_page, page_text(best_page), stuck
158
+ )
159
+ )
160
+ )
161
+
162
  for step in range(AGENT_MAX_STEPS):
163
  # Render the exact prompt BEFORE deciding so the trace can show what the
164
  # brain was asked, not just what it answered.
 
234
  minicpm_agent.tool_result_message(f"Search for {query!r} found nothing.")
235
  )
236
  continue
237
+ yield from present_hits(hits, "search:" + " ".join(query.lower().split()))
238
+ continue
239
+
240
+ if tool["tool"] == "find_answer":
241
+ query = tool["query"]
242
+ yield {"type": "step", "tool": "find_answer", "query": query}
243
+ yield {"type": "status", "text": f"Looking up β€œ{query}”…"}
244
+ # Dense retrieval over the PARSED chunks (text/semantic) β€” the index
245
+ # the parsed store was built for. A fact lookup ("what fuel does it
246
+ # take") is a TEXT match: ColEmbed ranks pages by VISUAL similarity
247
+ # and misses the plain specs page, so fact questions route here. Same
248
+ # (doc_id, page, score) shape as maxsim_search; the agent then circles
249
+ # the answering line on the page shown.
250
+ hits = retrieve_pages(query, parsed_store, doc_ids, top_k)
251
+ log.info("find_answer(%r) β†’ %s", query, [(p, round(s, 3)) for _, p, s in hits])
252
+ if not hits:
253
+ messages.append(
254
+ minicpm_agent.tool_result_message(f"Looking up {query!r} found nothing.")
 
 
 
 
 
 
 
 
 
255
  )
256
+ continue
257
+ yield from present_hits(hits, "answer:" + " ".join(query.lower().split()))
258
  continue
259
 
260
  if tool["tool"] == "circle":
 
294
  "target": target,
295
  "page": page,
296
  "bbox": [round(v) for v in box] if box is not None else None,
297
+ # The pixel size of the image the box was GROUNDED on β€” the bbox
298
+ # is in this coordinate space. The frontend sizes its SVG viewBox
299
+ # from this (not the browser-loaded <img>), so the circle lands
300
+ # correctly even if the displayed page PNG is served at a
301
+ # different/stale resolution than this grounding render.
302
+ "dims": [img.width, img.height],
303
  # the VLM's raw grounding reply β€” diagnostic only (helps explain
304
  # where/why a box landed); shown in the trace view.
305
  "ground_raw": braw[:300],