| """Mock store + find-and-point pipeline for local UI iteration (MOCK_MODELS=1). |
| |
| Drop any PDF into MOCK_PDF_DIR (default app/data/mock_pdfs/) and the whole |
| find-and-point UX runs over real, rendered pages of that PDF — no GPU, no |
| model downloads, no HF library sync. The same MockStore instance backs both |
| approaches, so the manual dropdown, the page viewer, navigation, the three |
| router branches (go_to_section / point_here / search→classify→circle), the |
| circle overlay and the candidate-pages strip all exercise the real wiring; |
| only the router's tool choice, the page/bbox picks and the page classification |
| are faked (by simple keyword heuristics, so the branches are predictable in |
| tests). |
| |
| Nothing here imports torch / spaces / the model modules (those load CUDA at |
| import), so this module is safe to load on a laptop. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import glob |
| import hashlib |
| import os |
| import re |
| import time |
|
|
| from core.constants import MOCK_PDF_DIR |
| from core.pdf import page_count, page_size, render_pages |
| from core.sections import match_section |
| from core.store import slugify |
|
|
| |
| |
| MOCK_SECTION_TITLES = [ |
| "General Information", |
| "Engine Mechanical System", |
| "Engine Electrical System", |
| "Fuel System", |
| "Cooling System", |
| "Transmission System", |
| "Brake System — Bleeding and Adjustment", |
| "Steering System", |
| "Suspension System", |
| "Body Electrical System", |
| ] |
|
|
| MOCK_ANSWER = ( |
| "🧪 **Mock mode** — canned answer for UI iteration, not a real model " |
| "response.\n\n" |
| "1. Loosen the three retaining bolts in a star pattern (reassembly torque: " |
| "24 N·m / 18 ft-lb).\n" |
| "2. Withdraw the assembly and inspect the seal lip for wear or scoring.\n" |
| "3. Refit in reverse order, checking clearances against the spec table.\n\n" |
| "The pages this answer *would* be grounded in are shown on the right.\n\n" |
| "({label})" |
| ) |
|
|
|
|
| class MockStore: |
| """A folder of PDFs exposed through the slice of the DocStore API that |
| app.py and MockAskPipeline use. doc_id is the slug of the file name.""" |
|
|
| def __init__(self, pdf_dir: str = MOCK_PDF_DIR): |
| self.pdf_dir = os.path.abspath(pdf_dir) |
| os.makedirs(self.pdf_dir, exist_ok=True) |
|
|
| def _docs(self) -> dict[str, dict]: |
| """Re-scanned on every call so PDFs dropped in while the app is running |
| appear after a 🔄 Sync library click (which just rebuilds the dropdown).""" |
| docs: dict[str, dict] = {} |
| for path in sorted(glob.glob(os.path.join(self.pdf_dir, "*.pdf"))): |
| name = os.path.splitext(os.path.basename(path))[0] |
| docs[slugify(name)] = {"name": name, "path": path} |
| return docs |
|
|
| def list_docs(self) -> list[dict]: |
| return [ |
| { |
| "doc_id": doc_id, |
| "name": info["name"], |
| "pages": page_count(info["path"]), |
| "size_mb": os.path.getsize(info["path"]) / 1e6, |
| } |
| for doc_id, info in self._docs().items() |
| ] |
|
|
| def exists(self, doc_id: str) -> bool: |
| return doc_id in self._docs() |
|
|
| def pdf_path(self, doc_id: str) -> str | None: |
| info = self._docs().get(doc_id) |
| return info["path"] if info else None |
|
|
| def sections(self, doc_id: str) -> list[dict]: |
| """Canned sections spread evenly over the PDF's real pages — same |
| shape as core.sections.sections_from_chunks.""" |
| info = self._docs().get(doc_id) |
| if not info: |
| return [] |
| n = page_count(info["path"]) |
| k = min(len(MOCK_SECTION_TITLES), n) |
| starts = [round(i * n / k) + 1 for i in range(k)] |
| return [ |
| { |
| "title": MOCK_SECTION_TITLES[i], |
| "page_start": starts[i], |
| "page_end": (starts[i + 1] - 1) if i + 1 < k else n, |
| } |
| for i in range(k) |
| ] |
|
|
|
|
|
|
| class MockAskPipeline: |
| """Stateless mock matching the real ask pipelines' contracts: picks a |
| deterministic spread of real pages and returns a canned answer.""" |
|
|
| def run(self, store: MockStore, question: str, doc_ids: list[str] | None, top_k: int): |
| """Return (answer markdown, gallery [(image, caption)], page_refs |
| [(doc_id, page)]) — same shape as the real ask pipelines.""" |
| question = (question or "").strip() |
| if not question: |
| raise ValueError("Please enter a question.") |
| |
| |
| |
| time.sleep(float(os.environ.get("MOCK_DELAY", "0"))) |
| doc_id, info = self._pick_doc(store, doc_ids) |
| pages = self._pick_pages(question, info["pages"], int(top_k)) |
| images = render_pages(store.pdf_path(doc_id), pages) |
| labels = [f"{info['name']} — p.{p}" for p in pages] |
| answer = MOCK_ANSWER.format(label=labels[0]) |
| gallery = [(img, f"{label} (mock)") for label, img in zip(labels, images)] |
| page_refs = [(doc_id, p) for p in pages] |
| return answer, gallery, page_refs |
|
|
| def run_find( |
| self, |
| store: MockStore, |
| request: str, |
| doc_ids: list[str] | None, |
| top_k: int, |
| sections: list[dict], |
| viewer: dict | None = None, |
| ): |
| """Yield the same event sequence as pipelines/find_ask.py, with a |
| keyword-driven stand-in for the router. MOCK_DELAY (seconds) paces the |
| events.""" |
| request = (request or "").strip() |
| if not request: |
| raise ValueError("Tell me what to find.") |
| doc_id, info = self._pick_doc(store, doc_ids) |
| return self._find_events( |
| store, request, doc_id, info, int(top_k), sections or [], viewer or {} |
| ) |
|
|
| def _find_events(self, store, request, doc_id, info, top_k, sections, viewer): |
| delay = float(os.environ.get("MOCK_DELAY", "0")) |
| cur = max(1, int(viewer.get("page") or 1)) |
| yield {"type": "status", "text": "Looking at your page…"} |
| time.sleep(delay) |
|
|
| kind, a, b = self._mock_route(request, sections) |
|
|
| if kind == "go_to_section": |
| yield {"type": "route", "route": "section", "title": b, "page": a} |
| yield {"type": "done", "kind": "navigate", "page": a, "title": b} |
| return |
|
|
| if kind == "point_here": |
| target = a |
| yield {"type": "route", "route": "local", "target": target} |
| yield {"type": "status", "text": "Pinning it down…"} |
| time.sleep(delay) |
| box = self._mock_box(store, doc_id, cur, target) |
| if box is not None: |
| yield {"type": "done", "kind": "point", "found": True, |
| "target": target, "page": cur, "bbox": box} |
| return |
| query = target |
| else: |
| target, query = a, b |
|
|
| yield {"type": "route", "route": "search", "target": target, "query": query} |
| pages = self._pick_pages(request, info["pages"], top_k) |
| images = render_pages(store.pdf_path(doc_id), pages) |
| yield { |
| "type": "tool_result", |
| "tool": "search_docs", |
| "gallery": [ |
| (img, f"{info['name']} — p.{p} (mock)") |
| for p, img in zip(pages, images) |
| ], |
| "page_refs": [(doc_id, p) for p in pages], |
| } |
| yield {"type": "status", "text": f"Checking {len(pages)} pages…"} |
| time.sleep(delay) |
|
|
| |
| give_up = any(w in target.lower() for w in ("xyzzy", "flux capacitor", "nonexistent")) |
| flags = [False] * len(pages) |
| if not give_up and flags: |
| flags[0] = True |
| yield { |
| "type": "classified", |
| "results": [[p, f] for p, f in zip(pages, flags)], |
| } |
| for p, img, f in zip(pages, images, flags): |
| if not f: |
| continue |
| yield {"type": "found", "page": p} |
| yield {"type": "status", "text": "Pinning it down…"} |
| time.sleep(delay) |
| box = self._mock_box(store, doc_id, p, target) |
| yield {"type": "done", "kind": "point", "found": True, |
| "target": target, "page": p, "bbox": box} |
| return |
| yield {"type": "done", "kind": "point", "found": False, "target": target} |
|
|
| @staticmethod |
| def _mock_route(request: str, sections: list[dict]): |
| """Fake the LLM router with keyword heuristics. Returns one of: |
| ("go_to_section", page, title) / ("point_here", target, None) / |
| ("search", target, query).""" |
| r = request.lower() |
| nav_verb = any( |
| v in r for v in ("go to", "take me", "open", "bring up", "pull up", |
| "navigate", "jump to") |
| ) |
| is_circle = any( |
| v in r for v in ("circle", "point", "highlight", "mark", "show me where") |
| ) |
| here = any(v in r for v in ("here", "this page", "this", "current")) |
| secs = [{"title": o["title"], "page_start": o["page"]} for o in sections] |
| best = match_section(request, secs) if secs else None |
|
|
| if (nav_verb or "section" in r or "chapter" in r) and best and best["score"] >= 0.4: |
| return "go_to_section", best["page"], best["title"] |
| target = MockAskPipeline._clean_target(request) |
| if is_circle and here: |
| return "point_here", target, None |
| if is_circle: |
| return "search", target, request |
| if best and best["score"] >= 0.6: |
| return "go_to_section", best["page"], best["title"] |
| return "search", target, request |
|
|
| @staticmethod |
| def _clean_target(request: str) -> str: |
| r = re.sub( |
| r"^(?:can you |please )?(?:circle|point (?:at|to)|highlight|mark|" |
| r"show me where)\s+", |
| "", request.strip(), flags=re.I, |
| ) |
| r = re.sub(r"^(the |a |an )", "", r, flags=re.I) |
| r = re.sub(r"\b(?:on |in )?(?:this|the current) page\b", "", r, flags=re.I) |
| r = re.sub(r"\bhere\b", "", r, flags=re.I) |
| r = re.sub(r"\s+(?:is|are)(?:\s+located)?$", "", r, flags=re.I) |
| return " ".join(r.split()) or request |
|
|
| @staticmethod |
| def _mock_box(store: MockStore, doc_id: str, page: int, target: str): |
| """A deterministic, target-dependent bbox in rendered-page pixels; |
| None for ~1 in 8 targets so the "located but not pinpointed" path is |
| exercised too.""" |
| path = store.pdf_path(doc_id) |
| if not path: |
| return None |
| seed = int(hashlib.sha1(target.encode()).hexdigest(), 16) |
| if seed % 8 == 7: |
| return None |
| w, h = page_size(path, page) |
| x1 = w * (0.1 + (seed % 5) * 0.1) |
| y1 = h * (0.15 + (seed // 5 % 5) * 0.12) |
| return [round(x1), round(y1), round(x1 + w * 0.3), round(y1 + h * 0.18)] |
|
|
| @staticmethod |
| def _pick_doc(store: MockStore, doc_ids: list[str] | None): |
| docs = {d["doc_id"]: d for d in store.list_docs()} |
| if not docs: |
| raise ValueError( |
| f"No PDFs in the mock library yet — drop one into {store.pdf_dir}." |
| ) |
| doc_id = (doc_ids or list(docs))[0] |
| if doc_id not in docs: |
| raise ValueError("That manual isn't in the mock library.") |
| return doc_id, docs[doc_id] |
|
|
| @staticmethod |
| def _pick_pages(question: str, n_pages: int, top_k: int) -> list[int]: |
| """A deterministic, question-dependent spread of 1-based pages, so |
| different questions cite different pages (nice for clicking through the |
| viewer) while the same question is stable across reloads.""" |
| k = max(1, min(top_k, n_pages)) |
| seed = int(hashlib.sha1(question.encode()).hexdigest(), 16) |
| start = seed % n_pages |
| step = max(1, n_pages // k) |
| out: list[int] = [] |
| for i in range(k): |
| page = (start + i * step) % n_pages + 1 |
| if page not in out: |
| out.append(page) |
| return out |
|
|