Sync from GitHub 80886c2
Browse files- app.py +14 -5
- frontend/index.html +15 -6
- models/minicpm_agent.py +20 -2
- 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 =
|
| 349 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 379 |
-
|
| 380 |
-
|
|
|
|
|
|
|
| 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:${
|
| 280 |
-
:viewBox="`0 0 ${
|
| 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.
|
| 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 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 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],
|