File size: 16,413 Bytes
14a9b91 a86d9d6 14a9b91 1022e34 14a9b91 a86d9d6 14a9b91 3dfd032 14a9b91 3dfd032 14a9b91 3dfd032 bae10b5 f303c41 14a9b91 a86d9d6 14a9b91 1022e34 14a9b91 1022e34 14a9b91 a1d3dec bae10b5 a1d3dec 14a9b91 1022e34 14a9b91 1022e34 14a9b91 a86d9d6 14a9b91 a86d9d6 14a9b91 3dfd032 14a9b91 3dfd032 14a9b91 3dfd032 f303c41 14a9b91 3dfd032 14a9b91 a86d9d6 2205fd2 14a9b91 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 | """Agent find-and-point pipeline: one user message is one streamed turn driven
by MiniCPM5-1B (the text "brain"), which calls tools in a loop until the turn
ends with a page shown β usually a circle drawn β never a generated answer.
Flow (one @spaces.GPU call, streamed as events):
Build the running conversation β system rules, the compact history of past
turns (memory, for "circle the other one" / "go back"), and the live state:
the request, the manual's table of contents, and the WHOLE text of the page
being viewed (parsed page β text, figures/tables as their descriptions). Then
loop, up to AGENT_MAX_STEPS:
decide β ONE tool:
go_to_section(n) land at that section's first page (terminal)
search(query) ColEmbed top-N β 1B rerank by page text β show
the best page; its text is fed back so the agent
can then circle on it (continues)
find_answer(query) dense TEXT retrieval over the parsed chunks β show
the page that STATES the answer (where a visual
search would miss the plain specs page); its text
is fed back so the agent can circle it (continues)
circle(target) ground the target on the CURRENT page (VLM) and
circle it (terminal)
done(message) nothing to do / not in the manual (terminal)
Retrieval is FUSED: ColEmbed (visual store) supplies the shortlist, the parsed
store supplies the page text the 1B reranks with and the agent reasons over β so
a manual must be indexed both ways.
History is used only to resolve references, never to restate answers. Each turn
is otherwise grounded in the viewer state the client sends (current page +
section) and the history it accumulates.
Events yielded (PIL images included; app.py converts them for the wire):
{"type": "status", "text"} progress for the UI
{"type": "step", "tool": "search"|"go_to_section"|"circle", ...}
the tool just chosen
{"type": "tool_result", "tool": "search_docs",
"gallery": [(img, caption)], "page_refs"} search candidates
{"type": "found", "page"} show this page now
{"type": "done", "kind": "navigate"|"point"|"reply", ...} terminal; point may
carry bbox=null (page
shown, not pinpointed)
"""
from __future__ import annotations
import logging
import spaces
from core.constants import (
AGENT_HISTORY_TURNS,
AGENT_MAX_STEPS,
FIND_GPU_DURATION,
)
from core.page_context import index_pages, page_to_text
from core.pdf import page_count, render_page
from models import minicpm, minicpm_agent
from models.colembed import maxsim_search
from pipelines.parsed_ask import retrieve_pages
log = logging.getLogger("repairguy.agent")
def _history_messages(history: list | None) -> list[dict]:
"""The compact memory of past turns as plain user/assistant turns: what the
mechanic asked and what we did. The client sends [{request, action}]; only
the last AGENT_HISTORY_TURNS are kept."""
msgs = []
for turn in (history or [])[-AGENT_HISTORY_TURNS:]:
request = str((turn or {}).get("request") or "").strip()
action = str((turn or {}).get("action") or "").strip()
if request:
msgs.append({"role": "user", "content": request})
if action:
msgs.append({"role": "assistant", "content": action})
return msgs
@spaces.GPU(duration=FIND_GPU_DURATION)
def agent_events(
request: str,
visual_store,
parsed_store,
doc_ids: list[str],
top_k: int,
names: dict[str, str],
sections: list[dict],
viewer: dict | None = None,
history: list | None = None,
):
"""Yield the events of one agent turn (see module docstring). sections is the
numbered table of contents shown to the agent ([{title, page}]); the agent's
go_to_section index is 1-based into it."""
doc_id = doc_ids[0]
manual = names[doc_id]
viewer = viewer or {}
cur = max(1, int(viewer.get("page") or 1))
section = str(viewer.get("section") or "").strip()
# Parsed pages read once; page_text(p) is the whole-page text for the agent
# (and the reranker). Empty for a page with no parse.
page_elements = index_pages(parsed_store.parsed_pages(doc_id))
def page_text(p: int) -> str:
return page_to_text(page_elements.get(p, []))
# The page(s) on the viewer β a two-page spread shows the active page plus
# the next. The agent sees the text of all of them and may circle on any;
# the active page stays first. Falls back to the single current page.
shown_pages = [int(p) for p in (viewer.get("pages") or []) if int(p) >= 1] or [cur]
if cur in shown_pages:
shown_pages = [cur] + [p for p in shown_pages if p != cur]
shown_pages = shown_pages[:2]
shown = [{"page": p, "text": page_text(p)} for p in shown_pages]
messages = [minicpm_agent.system_message()]
messages += _history_messages(history)
messages.append(minicpm_agent.state_message(request, sections, shown, section))
current_page = shown_pages[0] # the active page circle defaults to
circleable = set(shown_pages) # pages the agent may circle on right now
seen_pages = set(shown_pages) # pages already put on screen this turn
tried_queries = set() # normalized search queries already issued this turn
ground_failed = set() # (page, normalized target) the VLM already missed
yield {"type": "status", "text": "Thinkingβ¦"}
def present_hits(hits, qkey):
"""Shared tail for the two retrieval tools (search / find_answer): show
the shortlist, land on the top page, and feed its text back β FORCING a
decision when the landing is a no-op (the same query again, or a page
already shown this turn), so a greedy 1B can't loop the identical lookup
forever. The only thing that differs between the tools is the retriever
that produced `hits`; everything downstream is identical."""
nonlocal current_page, circleable
rendered = [
(p, render_page(visual_store.pdf_path(doc_id), p)) for _, p, _ in hits
]
yield {
"type": "tool_result",
"tool": "search_docs",
"gallery": [
(img, f"{manual} β p.{p} (score {s:.3g})")
for (p, img), (_, _, s) in zip(rendered, hits)
],
"page_refs": [(doc_id, p) for _, p, _ in hits],
}
best_page = hits[0][1]
yield {"type": "found", "page": best_page}
current_page = best_page
circleable = {best_page} # the lookup landed here β circle on this page
stuck = qkey in tried_queries or best_page in seen_pages
tried_queries.add(qkey)
seen_pages.add(best_page)
messages.append(
minicpm_agent.tool_result_message(
minicpm_agent.search_result_message(
request, best_page, page_text(best_page), stuck
)
)
)
for step in range(AGENT_MAX_STEPS):
# Render the exact prompt BEFORE deciding so the trace can show what the
# brain was asked, not just what it answered.
prompt = minicpm_agent.render_prompt(messages)
tool, raw = minicpm_agent.decide(messages)
log.info("step %d: tool=%s | raw=%r", step, tool, raw[:200])
# Diagnostic event: the prompt fed in, the raw 1B reply, and the parsed
# tool for this step, so the UI's trace view shows exactly what the brain
# was asked and decided (and why a reply was rejected). Not used by the
# normal chip flow.
yield {"type": "trace", "step": step, "tool": tool, "raw": raw,
"prompt": prompt}
if tool is None:
# Unusable reply (bad JSON, or an echoed placeholder target). Correct
# it and let the agent try again rather than abandon the turn.
messages.append(
minicpm_agent.tool_result_message(
"Your last reply was not one complete JSON object. Reply with "
"ONE complete JSON object and nothing else, e.g. "
'{"tool": "search", "query": "fuel filter"}. If you circle, the '
"target MUST be copied from the page text above β never invent "
"a part that is not printed there."
)
)
continue
messages.append(minicpm_agent.assistant_action_message(tool))
if tool["tool"] == "go_to_section":
idx = tool["section"] - 1
if not 0 <= idx < len(sections):
messages.append(
minicpm_agent.tool_result_message(
f"There is no section {tool['section']}. Pick a number from "
"the table of contents, or use search."
)
)
continue
opt = sections[idx]
yield {"type": "step", "tool": "go_to_section",
"title": opt["title"], "page": int(opt["page"])}
yield {"type": "done", "kind": "navigate", "nav": "section",
"page": int(opt["page"]), "title": opt["title"]}
return
if tool["tool"] == "go_to_page":
page = tool["page"]
n = page_count(visual_store.pdf_path(doc_id))
if not 1 <= page <= n:
messages.append(
minicpm_agent.tool_result_message(
f"There is no page {page}; this manual has pages 1β{n}. "
"Pick a page in range, search, or go to a section."
)
)
continue
yield {"type": "step", "tool": "go_to_page", "page": page}
yield {"type": "done", "kind": "navigate", "nav": "page",
"page": page, "title": f"Page {page}"}
return
if tool["tool"] == "search":
query = tool["query"]
yield {"type": "step", "tool": "search", "query": query}
yield {"type": "status", "text": f"Searching for β{query}ββ¦"}
# k (the viewer's slider) is the shortlist size; ColEmbed's top page
# is the one shown. A 1B text rerank measured WORSE than raw ColEmbed
# top-1 (0.68 vs 0.84 hit@1) β visual late interaction already ranks
# these (figure-heavy) pages better than re-judging from page text.
hits = maxsim_search(query, visual_store, doc_ids, top_k)
log.info("search(%r) β %s", query, [(p, round(s, 3)) for _, p, s in hits])
if not hits:
messages.append(
minicpm_agent.tool_result_message(f"Search for {query!r} found nothing.")
)
continue
yield from present_hits(hits, "search:" + " ".join(query.lower().split()))
continue
if tool["tool"] == "find_answer":
query = tool["query"]
yield {"type": "step", "tool": "find_answer", "query": query}
yield {"type": "status", "text": f"Looking up β{query}ββ¦"}
# Dense retrieval over the PARSED chunks (text/semantic) β the index
# the parsed store was built for. A fact lookup ("what fuel does it
# take") is a TEXT match: ColEmbed ranks pages by VISUAL similarity
# and misses the plain specs page, so fact questions route here. Same
# (doc_id, page, score) shape as maxsim_search; the agent then circles
# the answering line on the page shown.
hits = retrieve_pages(query, parsed_store, doc_ids, top_k)
log.info("find_answer(%r) β %s", query, [(p, round(s, 3)) for _, p, s in hits])
if not hits:
messages.append(
minicpm_agent.tool_result_message(f"Looking up {query!r} found nothing.")
)
continue
yield from present_hits(hits, "answer:" + " ".join(query.lower().split()))
continue
if tool["tool"] == "circle":
target = tool["target"]
# The agent says which shown page the target is on (it has both pages'
# text). Default to the active page when it's unspecified or not one of
# the pages on screen β so the box is grounded on, and drawn over, the
# RIGHT page.
page = tool.get("page")
if page not in circleable:
page = current_page
yield {"type": "step", "tool": "circle", "target": target, "page": page}
yield {"type": "status", "text": "Pinning it downβ¦"}
img = render_page(visual_store.pdf_path(doc_id), page)
box, braw = minicpm.ground_box(img, target)
log.info("ground_box(%r) on p.%d β %s | raw=%r",
target, page, box, braw[:200])
# The VLM couldn't find the target on this page β almost always
# because it's on a DIFFERENT page (the agent circled too early).
# Don't end the turn with an empty pin: push it to relocate and try
# again. Only fall through to showing the page un-pinned once we've
# already missed this exact (page, target) β a repeat means retrying
# here won't help, same guard as the no-op search.
tkey = (page, " ".join(target.lower().split()))
if box is None and tkey not in ground_failed:
ground_failed.add(tkey)
messages.append(
minicpm_agent.tool_result_message(
minicpm_agent.ground_failed_message(request, target, page)
)
)
continue
yield {
"type": "done",
"kind": "point",
"found": True,
"target": target,
"page": page,
"bbox": [round(v) for v in box] if box is not None else None,
# The pixel size of the image the box was GROUNDED on β the bbox
# is in this coordinate space. The frontend sizes its SVG viewBox
# from this (not the browser-loaded <img>), so the circle lands
# correctly even if the displayed page PNG is served at a
# different/stale resolution than this grounding render.
"dims": [img.width, img.height],
# the VLM's raw grounding reply β diagnostic only (helps explain
# where/why a box landed); shown in the trace view.
"ground_raw": braw[:300],
}
return
if tool["tool"] == "done":
yield {"type": "done", "kind": "reply",
"message": tool.get("message") or "Done."}
return
yield {
"type": "done",
"kind": "reply",
"message": "I went in circles on that one β try rephrasing?",
}
class AgentPipeline:
"""Stateless: the stores are passed per call (fused β visual for retrieval,
parsed for page text)."""
def run_find(
self,
visual_store,
parsed_store,
request: str,
doc_ids: list[str] | None,
top_k: int,
sections: list[dict],
viewer: dict | None = None,
history: list | None = None,
):
"""One streamed agent turn (the event generator of agent_events)."""
request = (request or "").strip()
if not request:
raise ValueError("Tell me what to find.")
docs = visual_store.list_docs()
if not docs:
raise ValueError("No manuals in this library yet.")
names = {d["doc_id"]: d["name"] for d in docs}
return agent_events(
request, visual_store, parsed_store, doc_ids or list(names),
int(top_k), names, sections, viewer, history,
)
|