| """Visual ask pipeline: question -> MaxSim retrieval over page embeddings -> |
| top-K page images -> MiniCPM answer grounded in those pages. |
| |
| The whole question runs in ONE @spaces.GPU call (query embedding + MaxSim + |
| page rendering + answer generation), so each question pays the ZeroGPU |
| allocation wait once. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import spaces |
|
|
| from core.constants import ASK_GPU_DURATION |
| from core.pdf import render_page |
| from core.visual_store import VisualStore |
| from models.colembed import maxsim_search |
| from models.minicpm import generate_answer |
|
|
|
|
| @spaces.GPU(duration=ASK_GPU_DURATION) |
| def _ask_on_gpu( |
| question: str, |
| store: VisualStore, |
| doc_ids: list[str] | None, |
| top_k: int, |
| names: dict[str, str], |
| ): |
| hits = maxsim_search(question, store, doc_ids, top_k) |
| pages = [ |
| (f"{names[doc_id]} — p.{page}", render_page(store.pdf_path(doc_id), page), score) |
| for doc_id, page, score in hits |
| ] |
| answer = generate_answer(question, [(label, img) for label, img, _ in pages]) |
| gallery = [(img, f"{label} (score {score:.1f})") for label, img, score in pages] |
| page_refs = [(doc_id, page) for doc_id, page, _ in hits] |
| return answer, gallery, page_refs |
|
|
|
|
| class VisualAskPipeline: |
| """Stateless: the store is passed per call.""" |
|
|
| def run(self, store: VisualStore, question: str, doc_ids: list[str] | None, top_k: int): |
| """Return (answer markdown, gallery items [(image, caption)], page_refs |
| [(doc_id, page_num)] for the retrieved pages, in answer order).""" |
| question = (question or "").strip() |
| if not question: |
| raise ValueError("Please enter a question.") |
| docs = 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 _ask_on_gpu(question, store, doc_ids or None, int(top_k), names) |
|
|