File size: 17,238 Bytes
51720d8
436dbdd
2ad630b
 
 
293ed8c
2ad630b
 
 
 
 
 
 
c982278
89111aa
 
 
 
 
 
 
 
 
 
 
 
 
 
51720d8
 
 
 
 
 
 
 
436dbdd
 
2ad630b
 
 
 
 
89111aa
 
2ad630b
79d9660
51720d8
 
deb5a88
 
51720d8
 
 
a6a1bb9
436dbdd
2ad630b
55adaba
a6a1bb9
deb5a88
c63fd20
51720d8
293ed8c
deb5a88
293ed8c
 
 
bac8db7
51720d8
 
2ad630b
293ed8c
2ad630b
293ed8c
89111aa
 
51720d8
a6a1bb9
 
 
 
 
 
 
 
 
 
 
 
 
51720d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79d9660
436dbdd
293ed8c
 
2ad630b
51720d8
 
deb5a88
293ed8c
2ad630b
436dbdd
a6a1bb9
293ed8c
a6a1bb9
2ad630b
 
 
 
 
 
 
 
 
 
 
293ed8c
 
 
 
 
51720d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c982278
 
55adaba
 
 
c982278
 
 
55adaba
c982278
55adaba
 
 
 
 
c982278
55adaba
 
 
bac8db7
 
 
 
 
 
 
 
 
89111aa
 
 
 
 
 
 
 
 
 
 
 
c982278
 
89111aa
 
c982278
89111aa
 
c982278
89111aa
 
 
c982278
 
89111aa
 
c982278
 
89111aa
 
 
c982278
89111aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c982278
 
51720d8
 
 
 
 
 
 
 
 
bac8db7
 
51720d8
 
 
 
bac8db7
55adaba
51720d8
 
 
 
 
 
 
 
 
 
 
89111aa
 
51720d8
55adaba
 
89111aa
 
 
51720d8
 
 
c982278
 
9c0bd3b
89111aa
 
 
 
 
 
 
 
 
 
 
9c0bd3b
51720d8
9c0bd3b
 
51720d8
 
bac8db7
 
 
9c0bd3b
 
51720d8
9c0bd3b
51720d8
9c0bd3b
bac8db7
c982278
89111aa
a6a1bb9
89111aa
 
a6a1bb9
bac8db7
89111aa
9c0bd3b
 
 
 
 
 
 
 
 
 
 
 
89111aa
a6a1bb9
89111aa
a6a1bb9
9c0bd3b
 
bac8db7
89111aa
9c0bd3b
a6a1bb9
89111aa
a6a1bb9
51720d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bac8db7
51720d8
bac8db7
55adaba
9c0bd3b
 
 
 
 
 
 
 
 
51720d8
 
c982278
 
51720d8
 
 
 
 
 
55adaba
c63fd20
c982278
 
 
89111aa
 
 
c982278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89111aa
 
 
 
c982278
 
deb5a88
51720d8
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
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
"""Gradio Server Mode backend for the Repair Guy Space.

Showcases two local-only indexing approaches over the same manuals (all
ingestion happens offline via scripts/index_modal.py; the Space only syncs
the pre-indexed library and answers questions):

  Visual β€” every page embedded as an image with Nemotron ColEmbed v2
           (multi-vector late interaction, no parsing); retrieval is MaxSim
           over page embeddings streamed from disk.
  Parsed β€” pages parsed with Nemotron Parse, figures/tables described by
           MiniCPM-V, section chunks embedded with Llama Nemotron Embed;
           retrieval is dense cosine over chunks with parent-page lookup.

The product is a hands-busy mechanic's assistant: a page viewer driven by
short requests. It finds and points β€” it never writes answers, and keeps no
chat history (every request stands alone, grounded in the viewer state).
  Obvious nav (client)  next/previous page, "page 412", back, next/previous
                        section: pure frontend state, no server call.
  Everything else       β†’ /find, one ZeroGPU call (pipelines/find_ask.py):
  (ZeroGPU find turn)   MiniCPM-V sees the page being viewed plus a numbered
                        section list and picks one tool β€” circle on this page
                        (visual grounding), jump to a section, or search the
                        manual then classify every candidate page in parallel
                        and circle the first match. Streamed as events; the
                        UI shows tool chips and the resulting page/circle,
                        never model prose. The section list is the manual's
                        clean PDF-bookmark chapters plus a per-request fuzzy
                        shortlist of fine parse headings (core/sections.py).

UI architecture β€” this is NOT a gr.Blocks app. It runs in Gradio *Server Mode*
(`gradio.Server`, a FastAPI server with Gradio's engine: queueing, streaming
and β€” crucially β€” ZeroGPU auth). The Python pipelines below are exposed as
`@app.api` endpoints; the frontend is a fully custom single page (Tailwind +
Alpine, no build step) served from frontend/index.html, which calls those
endpoints with @gradio/client so the HF iframe auth headers ZeroGPU needs are
forwarded. The source PDFs are served straight from FastAPI at /pdf/<doc_id>.

Module layout:
  models/colembed.py        ColEmbed       β€” visual: page embeddings + MaxSim
  models/nemotron_embed.py  NemotronEmbed  β€” parsed: dense chunk/query embeddings
  models/minicpm.py         MiniCPM        β€” shared: answers over page images
  core/visual_store.py      VisualStore    β€” on-disk per-page token embeddings
  core/parsed_store.py      ParsedStore    β€” chunks + dense embedding matrix
  core/sections.py          section index + fuzzy matching (router shortlist)
  pipelines/find_ask.py     find_events β€” the shared find-and-point GPU turn
  pipelines/visual_ask.py   VisualAskPipeline
  pipelines/parsed_ask.py   ParsedAskPipeline
  pipelines/mock_ask.py     MockAskPipeline β€” local UI iteration (MOCK_MODELS=1)
  frontend/index.html       the custom UI
"""

import base64
import io
import json
import logging
import os
import shutil
import time
import warnings

import gradio as gr
from fastapi.responses import FileResponse, HTMLResponse, Response
from huggingface_hub import snapshot_download

from core.constants import (
    DEFAULT_TOP_K,
    LIBRARY_DATASET_ID,
    MAX_TOP_K,
    MOCK_MODELS,
    MOCK_PDF_DIR,
    PARSED_SUBDIR,
    PREINDEXED_DIR,
    VISUAL_SUBDIR,
)
from core.pdf import pdf_outline, render_page_png
from core.sections import sections_from_chunks, top_sections

# gradio 6.17.3 (pinned β€” see README frontmatter) still uses starlette's old
# 422 constant, so every queue join emits a StarletteDeprecationWarning. Not
# ours to fix; silence it so the Space logs stay readable.
warnings.filterwarnings(
    "ignore", message=r"'HTTP_422_UNPROCESSABLE_ENTITY' is deprecated"
)

logging.basicConfig(
    level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s"
)
logging.getLogger("httpx").setLevel(logging.WARNING)  # logs every hub request
log = logging.getLogger("repairguy")


def _build_libraries() -> dict:
    """The {approach: (store, pipeline)} map, constructed once at startup.

    In MOCK_MODELS mode a single PDF-folder mock backs both approaches and no
    GPU/model code is imported (those modules load CUDA at import). Otherwise
    the real stores and pipelines load β€” the models onto cuda here, in the main
    process. Either way both pipelines expose run(store, question, doc_ids, k)."""
    if MOCK_MODELS:
        from pipelines.mock_ask import MockAskPipeline, MockStore

        store, pipeline = MockStore(), MockAskPipeline()
        print(f"⚠️  MOCK_MODELS β€” canned answers over PDFs in {MOCK_PDF_DIR}")
        return {"visual": (store, pipeline), "parsed": (store, pipeline)}

    from core.parsed_store import ParsedStore
    from core.visual_store import VisualStore
    from pipelines.parsed_ask import ParsedAskPipeline
    from pipelines.visual_ask import VisualAskPipeline

    return {
        "visual": (
            VisualStore(os.path.join(PREINDEXED_DIR, VISUAL_SUBDIR)),
            VisualAskPipeline(),
        ),
        "parsed": (
            ParsedStore(os.path.join(PREINDEXED_DIR, PARSED_SUBDIR)),
            ParsedAskPipeline(),
        ),
    }


LIBRARIES = _build_libraries()


def sync_library() -> None:
    """Pull pre-indexed manuals from the library dataset into /data.
    A missing or empty dataset just means an empty library."""
    if MOCK_MODELS:  # mock library is the local PDF folder; nothing to sync
        return
    try:
        snapshot_download(
            LIBRARY_DATASET_ID, repo_type="dataset", local_dir=PREINDEXED_DIR
        )
        log.info("library synced from %s", LIBRARY_DATASET_ID)
    except Exception as e:
        log.warning("library dataset not synced (%s): %s", LIBRARY_DATASET_ID, e)
        return
    # PREINDEXED_DIR mirrors the dataset (one dir per method); prune top-level
    # leftovers from the pre-method-prefix layout, which snapshot_download
    # never deletes.
    for entry in os.listdir(PREINDEXED_DIR):
        path = os.path.join(PREINDEXED_DIR, entry)
        if entry.startswith(".") or entry in (VISUAL_SUBDIR, PARSED_SUBDIR):
            continue
        if os.path.isdir(path) and os.path.isfile(os.path.join(path, "index.json")):
            print(f"Pruning stale pre-migration doc dir: {entry}")
            shutil.rmtree(path, ignore_errors=True)


sync_library()


# Labels for the approach picker shown in the settings panel.
APPROACHES = {
    "visual": {
        "label": "Visual",
        "blurb": "ColEmbed late interaction β€” pages stay images, nothing parsed.",
    },
    "parsed": {
        "label": "Parsed",
        "blurb": "Dense chunks over parsed text + figure / table descriptions.",
    },
}


def _manual_choices() -> list[dict]:
    """The manuals shown in the picker, one shared list across both libraries
    (doc ids are name slugs, so the same manual lands on the same id in both);
    manuals indexed under only one approach are tagged with it. pages drives
    the viewer's page count."""
    docs: dict[str, dict] = {}
    for method, (store, _) in LIBRARIES.items():
        for d in store.list_docs():
            entry = docs.setdefault(
                d["doc_id"], {"name": d["name"], "methods": [], "pages": 0}
            )
            entry["methods"].append(method)
            entry["pages"] = max(entry["pages"], d["pages"])
    choices = []
    for doc_id, info in sorted(docs.items(), key=lambda kv: kv[1]["name"].lower()):
        label = info["name"]
        if len(info["methods"]) < len(LIBRARIES):
            label += f" β€” {info['methods'][0]} only"
        choices.append({"value": doc_id, "label": label, "pages": info["pages"]})
    return choices


def _pdf_path(doc_id: str) -> str | None:
    """The source PDF for a manual (kept identically in whichever store indexed
    it β€” both copy doc.pdf at ingest)."""
    for store, _ in LIBRARIES.values():
        if store.exists(doc_id):
            return store.pdf_path(doc_id)
    return None


# Two section views, both cached per doc and cleared on library re-sync:
#   outline  β€” the manual's own clean bookmark chapters (pdf_outline), the
#              frontend's breadcrumb + next/previous-section navigation, and
#              the always-shown part of the router's section list.
#   headings β€” the fine-grained, noisy parse headings (1000+ for a big
#              manual): far too many for a prompt, but a per-request fuzzy
#              shortlist of them gives the router precise targets like
#              "Brake System Bleeding β€” p.532".
# A visual-only manual with no PDF bookmarks has neither; section navigation
# then no-ops and the router works from the page image alone.
_OUTLINE_CACHE: dict[str, list[dict]] = {}
_HEADINGS_CACHE: dict[str, list[dict]] = {}


def _doc_outline(doc_id: str) -> list[dict]:
    if doc_id not in _OUTLINE_CACHE:
        if MOCK_MODELS:
            store, _ = LIBRARIES["parsed"]
            _OUTLINE_CACHE[doc_id] = store.sections(doc_id)
        else:
            path = _pdf_path(doc_id)
            _OUTLINE_CACHE[doc_id] = pdf_outline(path) if path else []
    return _OUTLINE_CACHE[doc_id]


def _doc_headings(doc_id: str) -> list[dict]:
    if doc_id not in _HEADINGS_CACHE:
        store, _ = LIBRARIES["parsed"]
        if MOCK_MODELS:
            _HEADINGS_CACHE[doc_id] = store.sections(doc_id)
        elif store.exists(doc_id):
            _HEADINGS_CACHE[doc_id] = sections_from_chunks(store.chunks(doc_id))
        else:
            _HEADINGS_CACHE[doc_id] = []
    return _HEADINGS_CACHE[doc_id]


def _router_options(doc_id: str, request: str, max_total: int = 24) -> list[dict]:
    """The numbered section list shown to the router as [{title, page}]: the
    clean chapters always, plus the fine headings best matching this request,
    deduped by page. The router replies with a 1-based index into this list."""
    options = [
        {"title": s["title"], "page": s["page_start"]} for s in _doc_outline(doc_id)
    ]
    seen = {o["page"] for o in options}
    for s in top_sections(request, _doc_headings(doc_id), n=8):
        if s["page"] not in seen:
            options.append(s)
            seen.add(s["page"])
    return options[:max_total]


def _thumb_data_uri(img, width: int = 280) -> str:
    """A downscaled JPEG data URI for a rendered page, small enough to ship in
    the JSON answer (cited-page thumbnails are decorative, not full-res)."""
    img = img.convert("RGB")
    if img.width > width:
        img = img.resize((width, round(img.height * width / img.width)))
    buf = io.BytesIO()
    img.save(buf, format="JPEG", quality=80)
    return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()


# ---------------------------------------------------------------------------
# Server Mode: API engine (ZeroGPU auth, queueing) with a custom frontend.
# ---------------------------------------------------------------------------
app = gr.Server()


@app.api(name="manuals")
def api_manuals() -> list[dict]:
    """The manual picker's options: [{value, label}]."""
    return _manual_choices()


@app.api(name="refresh")
def api_refresh() -> list[dict]:
    """Re-sync the library dataset (incremental) and return the refreshed
    picker options, so manuals indexed after boot show up without a restart."""
    sync_library()
    _OUTLINE_CACHE.clear()
    _HEADINGS_CACHE.clear()
    return _manual_choices()


@app.api(name="find")
def api_find(
    request: str,
    manual: str = "",
    approach: str = "visual",
    k: int = DEFAULT_TOP_K,
    page: int = 0,
    section: str = "",
) -> dict:  # the per-yield type: Server.api infers outputs from this annotation
    """One find-and-point turn (one ZeroGPU call), streamed as events (see
    pipelines/find_ask.py for the protocol). page/section are what the viewer
    currently shows β€” the router needs them to decide "is it on this page?".

    Yields {type: status|route|tool_result|classified|found|done|error, ...};
    tool_result galleries are converted to JSON-able thumbnails here, and the
    terminal `done` carries elapsed/approach/k. Soft errors so the frontend
    renders them as chips."""
    request = (request or "").strip()
    if not request:
        yield {"type": "error", "error": "Tell me what to find."}
        return
    if not manual:
        yield {"type": "error", "error": "Pick a manual first ☝️"}
        return
    if approach not in LIBRARIES:
        approach = "visual"
    store, pipeline = LIBRARIES[approach]
    if not store.exists(manual):
        other = "parsed" if approach == "visual" else "visual"
        yield {
            "type": "error",
            "error": f"This manual isn't indexed with the **{approach}** approach "
            f"yet β€” switch to **{other}** in settings, or pick another manual.",
        }
        return
    start = time.monotonic()
    viewer = {"page": int(page or 0), "section": str(section or "")}
    options = _router_options(manual, request)
    log.info(
        "find: manual=%s approach=%s k=%s viewer=%s opts=%d q=%r",
        manual, approach, k, viewer, len(options), request[:200],
    )
    try:
        events = pipeline.run_find(store, request, [manual], int(k), options, viewer)
        for ev in events:
            if ev.get("type") == "tool_result" and "gallery" in ev:
                pages = [p for _, p in ev["page_refs"]]
                yield {
                    "type": "tool_result",
                    "tool": ev["tool"],
                    "pages": pages,
                    "thumbnails": [
                        {"page": p, "src": _thumb_data_uri(img), "caption": cap}
                        for (img, cap), p in zip(ev["gallery"], pages)
                    ],
                }
            elif ev.get("type") == "done":
                elapsed = round(time.monotonic() - start, 1)
                log.info("find: done in %.1fs (%s)", elapsed, ev.get("kind"))
                yield {**ev, "elapsed": elapsed, "approach": approach, "k": int(k)}
            else:
                yield ev
    except ValueError as e:
        log.warning("find: rejected β€” %s", e)
        yield {"type": "error", "error": f"⚠️ {e}"}
    except Exception as e:
        log.exception("find: failed after %.1fs", time.monotonic() - start)
        yield {"type": "error", "error": f"⚠️ Something went wrong: {e}"}


# --- custom FastAPI routes: serve the SPA and the source PDFs ---------------
_FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "frontend")


_APPROACHES_JS = [{"key": k, **v} for k, v in APPROACHES.items()]


@app.get("/")
def index():
    """Serve the single-page UI, injecting the small bit of server config the
    frontend needs (approach options, default/max k) so it needs no extra
    round-trip on load."""
    with open(os.path.join(_FRONTEND_DIR, "index.html")) as f:
        html = f.read()
    html = (
        html.replace("__APPROACHES__", json.dumps(_APPROACHES_JS))
        .replace("__DEFAULT_K__", str(DEFAULT_TOP_K))
        .replace("__MAX_K__", str(MAX_TOP_K))
    )
    return HTMLResponse(html)


@app.get("/media/{filename}")
def serve_media(filename: str):
    """Static frontend assets (logo etc.) from frontend/assets/."""
    path = os.path.join(_FRONTEND_DIR, "assets", os.path.basename(filename))
    if not os.path.isfile(path):
        return Response(status_code=404)
    return FileResponse(path)


@app.get("/pdf/{doc_id}")
def serve_pdf(doc_id: str):
    """A manual's source PDF (kept for direct download/open-in-tab; the viewer
    pane shows /page images, not the PDF)."""
    path = _pdf_path(doc_id)
    if not path or not os.path.isfile(path):
        return Response(status_code=404)
    return FileResponse(
        path, media_type="application/pdf", content_disposition_type="inline"
    )


@app.get("/page/{doc_id}/{page}")
def serve_page(doc_id: str, page: int):
    """One manual page rendered to PNG at RENDER_DPI β€” the viewer pane's <img>
    source. Grounding boxes from the find turn are in this image's pixel
    coordinates, so the SVG circle overlay maps 1:1. Immutable content β†’ long
    browser cache, which is what makes page flips instant."""
    path = _pdf_path(doc_id)
    if not path or not os.path.isfile(path):
        return Response(status_code=404)
    try:
        data = render_page_png(path, page)
    except ValueError:
        return Response(status_code=404)
    return Response(
        data,
        media_type="image/png",
        headers={"Cache-Control": "public, max-age=86400"},
    )


@app.get("/sections/{doc_id}")
def serve_sections(doc_id: str):
    """The manual's clean chapter outline [{title, page_start, page_end}] β€”
    drives the breadcrumb and next/previous-section navigation in the
    frontend. Empty for a visual-only manual with no PDF bookmarks."""
    return {"sections": _doc_outline(doc_id)}


if __name__ == "__main__":
    app.launch()