| """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() |
|
|
| |
| |
| page_elements = index_pages(parsed_store.parsed_pages(doc_id)) |
|
|
| def page_text(p: int) -> str: |
| return page_to_text(page_elements.get(p, [])) |
|
|
| |
| |
| |
| 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] |
| circleable = set(shown_pages) |
| seen_pages = set(shown_pages) |
| tried_queries = set() |
| ground_failed = set() |
| 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} |
| 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): |
| |
| |
| prompt = minicpm_agent.render_prompt(messages) |
| tool, raw = minicpm_agent.decide(messages) |
| log.info("step %d: tool=%s | raw=%r", step, tool, raw[:200]) |
| |
| |
| |
| |
| yield {"type": "trace", "step": step, "tool": tool, "raw": raw, |
| "prompt": prompt} |
| if tool is None: |
| |
| |
| 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}ββ¦"} |
| |
| |
| |
| |
| 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}ββ¦"} |
| |
| |
| |
| |
| |
| |
| 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"] |
| |
| |
| |
| |
| 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]) |
| |
| |
| |
| |
| |
| |
| 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, |
| |
| |
| |
| |
| |
| "dims": [img.width, img.height], |
| |
| |
| "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, |
| ) |
|
|