airayven7 commited on
Commit
bac8db7
·
verified ·
1 Parent(s): 4e22592

Sync from GitHub 468b7a2

Browse files
Files changed (3) hide show
  1. app.py +228 -94
  2. pipelines/parsed_ask.py +4 -2
  3. pipelines/visual_ask.py +4 -2
app.py CHANGED
@@ -12,9 +12,10 @@ the pre-indexed library and answers questions):
12
  retrieval is dense cosine over chunks with parent-page lookup.
13
 
14
  Both hand the retrieved page images to MiniCPM-V for the grounded answer,
15
- in one ZeroGPU call per question. The UI is a side-by-side comparison: one
16
- manual, one question, and each approach answers in its own column (its own
17
- GPU call) so retrieval quality and latency can be compared directly.
 
18
 
19
  Module layout:
20
  models/colembed.py ColEmbed — visual: page embeddings + MaxSim
@@ -36,6 +37,7 @@ from huggingface_hub import snapshot_download
36
  from core.constants import (
37
  DEFAULT_TOP_K,
38
  LIBRARY_DATASET_ID,
 
39
  PARSED_SUBDIR,
40
  PREINDEXED_DIR,
41
  VISUAL_SUBDIR,
@@ -111,124 +113,256 @@ def refresh_library(doc_id):
111
  return gr.update(choices=choices, value=doc_id if doc_id in ids else None)
112
 
113
 
114
- def _ask(method: str, question, doc_id):
115
- """One approach's column: (timing line, answer markdown, gallery).
116
- Soft in-column messages instead of gr.Error so that when both columns run
117
- off one click, one failing doesn't kill the other."""
118
- if not doc_id:
119
- return "", "*Pick a manual first.*", []
120
- store, pipeline = LIBRARIES[method]
121
- if not store.exists(doc_id):
122
- return "", f"*This manual isn't indexed with the {method} approach yet.*", []
123
- start = time.monotonic()
124
- try:
125
- answer, gallery = pipeline.run(store, question, [doc_id], DEFAULT_TOP_K)
126
- except ValueError as e:
127
- return "", f"*{e}*", []
128
- return f"⏱️ answered in {time.monotonic() - start:.1f}s", answer, gallery
129
 
 
 
 
 
130
 
131
- def ask_visual(question, doc_id):
132
- return _ask("visual", question, doc_id)
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
- def ask_parsed(question, doc_id):
136
- return _ask("parsed", question, doc_id)
137
 
 
 
 
138
 
139
- CSS = """
140
- .app-header { text-align: center; margin: 0.5em 0 0.2em; }
141
- .app-header p { color: var(--body-text-color-subdued); margin-top: 0.3em; }
142
- .approach-card {
143
- border-radius: 14px !important;
144
- border-top: 4px solid var(--card-accent) !important;
145
- }
146
- .visual-card { --card-accent: #e8590c; }
147
- .parsed-card { --card-accent: #0c8599; }
148
- .approach-card h3 { margin: 0.1em 0 0; }
149
- .pipeline-steps {
150
- color: var(--body-text-color-subdued);
151
- font-size: 0.85em;
152
- line-height: 1.7;
153
- }
154
- .pipeline-steps code { font-size: 0.95em; }
155
- .timing { color: var(--body-text-color-subdued); font-size: 0.9em; min-height: 1.2em; }
156
- """
157
 
158
- VISUAL_CARD = """### 🖼️ Visual
159
- **ColEmbed late interaction** — pages stay images; nothing is parsed or chunked.
160
 
161
- <div class="pipeline-steps">
 
 
 
 
 
 
162
 
163
- `page image` `multi-vector embedding` `MaxSim vs. query` → `top pages` → `MiniCPM‑V answers`
 
 
 
 
164
 
165
- **Index:** heavy (5–12 MB/page) · **Strengths:** immune to parsing errors, sees layout and diagrams natively
166
- </div>"""
167
 
168
- PARSED_CARD = """### 📄 Parsed
169
- **Parse + dense chunks** pages become structured text; figures and tables become descriptions.
 
 
 
170
 
171
- <div class="pipeline-steps">
172
 
173
- `Nemotron Parse` → `MiniCPM‑V describes figures/tables` → `section chunks` → `dense cosine` → `parent pages` → `MiniCPM‑V answers`
 
 
174
 
175
- **Index:** tiny (a few MB/manual) · **Strengths:** inspectable chunks, precise hits on one table or diagram
176
- </div>"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
 
179
  with gr.Blocks(title="Repair Guy") as demo:
180
- gr.Markdown(
181
- "# 🔧 Repair Guy\n"
182
- "Two local-only ways to search a repair manual, side by side — pick a "
183
- "manual, ask once, compare what each approach retrieves and answers.",
184
- elem_classes="app-header",
185
- )
 
 
 
 
 
186
 
187
  with gr.Row(equal_height=True):
188
- with gr.Column(scale=1):
189
- manual_in = gr.Dropdown(label="Manual", choices=[])
190
- refresh_btn = gr.Button("🔄 Sync library", size="sm")
191
- with gr.Column(scale=2):
192
- question_in = gr.Textbox(
193
- label="Question",
194
- lines=2,
195
- placeholder="e.g. What is the tightening torque for the universal joint flange bolts?",
 
 
 
 
 
196
  )
197
  with gr.Row():
198
- both_btn = gr.Button("⚡ Ask both", variant="primary")
199
-
200
- with gr.Row(equal_height=False):
201
- with gr.Column(variant="panel", elem_classes="approach-card visual-card"):
202
- gr.Markdown(VISUAL_CARD)
203
- vis_btn = gr.Button("Ask with Visual", size="sm")
204
- vis_time = gr.Markdown(elem_classes="timing")
205
- vis_answer = gr.Markdown(label="Answer")
206
- vis_pages = gr.Gallery(label="Pages used", columns=3, height=300)
207
- with gr.Column(variant="panel", elem_classes="approach-card parsed-card"):
208
- gr.Markdown(PARSED_CARD)
209
- par_btn = gr.Button("Ask with Parsed", size="sm")
210
- par_time = gr.Markdown(elem_classes="timing")
211
- par_answer = gr.Markdown(label="Answer")
212
- par_pages = gr.Gallery(label="Pages used", columns=3, height=300)
213
-
214
- inputs = [question_in, manual_in]
215
- vis_outputs = [vis_time, vis_answer, vis_pages]
216
- par_outputs = [par_time, par_answer, par_pages]
217
-
218
- vis_btn.click(ask_visual, inputs=inputs, outputs=vis_outputs)
219
- par_btn.click(ask_parsed, inputs=inputs, outputs=par_outputs)
220
- # Two listeners on one event: both columns run off a single click/submit.
221
- both_btn.click(ask_visual, inputs=inputs, outputs=vis_outputs)
222
- both_btn.click(ask_parsed, inputs=inputs, outputs=par_outputs)
223
- question_in.submit(ask_visual, inputs=inputs, outputs=vis_outputs)
224
- question_in.submit(ask_parsed, inputs=inputs, outputs=par_outputs)
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  refresh_btn.click(refresh_library, inputs=[manual_in], outputs=[manual_in])
 
227
  demo.load(lambda: gr.update(choices=_manual_choices()), outputs=[manual_in])
228
 
229
 
230
  # Gradio 6 takes theme/css at launch(), not in the Blocks constructor.
231
- LAUNCH_KWARGS = dict(theme=gr.themes.Soft(primary_hue="orange"), css=CSS)
 
 
 
 
 
 
 
 
 
232
 
233
  if __name__ == "__main__":
234
  demo.launch(**LAUNCH_KWARGS)
 
12
  retrieval is dense cosine over chunks with parent-page lookup.
13
 
14
  Both hand the retrieved page images to MiniCPM-V for the grounded answer,
15
+ in one ZeroGPU call per question. The UI is a two-panel assistant: a chat on
16
+ the left, and on the right a viewer of the source PDF with a strip of the
17
+ pages the answer was grounded in. Which approach answers (and how many pages
18
+ it retrieves, k) is chosen in the settings modal.
19
 
20
  Module layout:
21
  models/colembed.py ColEmbed — visual: page embeddings + MaxSim
 
37
  from core.constants import (
38
  DEFAULT_TOP_K,
39
  LIBRARY_DATASET_ID,
40
+ MAX_TOP_K,
41
  PARSED_SUBDIR,
42
  PREINDEXED_DIR,
43
  VISUAL_SUBDIR,
 
113
  return gr.update(choices=choices, value=doc_id if doc_id in ids else None)
114
 
115
 
116
+ # Labels for the approach picker: value -> (label, one-line description).
117
+ APPROACHES = {
118
+ "visual": "🖼️ Visual ColEmbed late interaction (pages stay images)",
119
+ "parsed": "📄 Parsed dense chunks over parsed text + figure/table descriptions",
120
+ }
 
 
 
 
 
 
 
 
 
 
121
 
122
+ EMPTY_PDF = (
123
+ "<div class='pdf-empty'>📄<br>The cited manual page will appear here "
124
+ "once you ask a question.</div>"
125
+ )
126
 
 
 
127
 
128
+ def _pdf_path(doc_id: str) -> str | None:
129
+ """The source PDF for a manual (kept identically in whichever store indexed
130
+ it — both copy doc.pdf at ingest)."""
131
+ for store, _ in LIBRARIES.values():
132
+ if store.exists(doc_id):
133
+ return store.pdf_path(doc_id)
134
+ return None
135
+
136
+
137
+ def _pdf_viewer(doc_id: str, page: int = 1) -> str:
138
+ """An <iframe> of the manual's PDF, opened at `page`. Gradio serves files
139
+ under allowed_paths at /gradio_api/file=<abs path>; the #page/view fragment
140
+ is honoured by the browser's built-in PDF viewer."""
141
+ path = _pdf_path(doc_id)
142
+ if not path:
143
+ return EMPTY_PDF
144
+ src = f"/gradio_api/file={path}#page={page}&view=FitH"
145
+ return f"<iframe class='pdf-frame' src='{src}'></iframe>"
146
+
147
+
148
+ def add_user(question, history):
149
+ """Show the question immediately and clear the input. Returns ('', history)
150
+ so the textbox empties while the answer is generated by `bot`."""
151
+ question = (question or "").strip()
152
+ history = history or []
153
+ if not question:
154
+ return "", history
155
+ return "", history + [{"role": "user", "content": question}]
156
+
157
+
158
+ def bot(history, manual, approach, k, pages):
159
+ """Answer the last user message with the chosen approach, streaming a
160
+ 'searching' state while the single ZeroGPU call runs. `pages` is the
161
+ current citation state, echoed on intermediate yields so it isn't disturbed.
162
+
163
+ Yields (chatbot, pdf_html, pages_gallery, page_state)."""
164
+ history = history or []
165
+ hold = (gr.update(), gr.update(), pages) # pdf, gallery, state: unchanged
166
+ if not history or history[-1]["role"] != "user":
167
+ yield history, *hold
168
+ return
169
+ question = history[-1]["content"]
170
 
171
+ def assistant_says(text):
172
+ return history + [{"role": "assistant", "content": text}]
173
 
174
+ if not manual:
175
+ yield assistant_says("Pick a manual first ☝️"), *hold
176
+ return
177
 
178
+ store, pipeline = LIBRARIES[approach]
179
+ if not store.exists(manual):
180
+ other = "parsed" if approach == "visual" else "visual"
181
+ yield assistant_says(
182
+ f"This manual isn't indexed with the **{approach}** approach yet — "
183
+ f"switch to **{other}** in ⚙️ settings, or pick another manual."
184
+ ), *hold
185
+ return
 
 
 
 
 
 
 
 
 
 
186
 
187
+ history = history + [{"role": "assistant", "content": "_Searching the manual…_"}]
188
+ yield history, *hold
189
 
190
+ start = time.monotonic()
191
+ try:
192
+ answer, gallery, page_refs = pipeline.run(store, question, [manual], int(k))
193
+ except ValueError as e:
194
+ history[-1]["content"] = f"⚠️ {e}"
195
+ yield history, *hold
196
+ return
197
 
198
+ new_pages = [p for _, p in page_refs]
199
+ footer = f"\n\n<sub>⏱️ {time.monotonic() - start:.1f}s · {approach} · k={int(k)}</sub>"
200
+ history[-1]["content"] = answer + footer
201
+ pdf_html = _pdf_viewer(manual, new_pages[0] if new_pages else 1)
202
+ yield history, pdf_html, gallery, new_pages
203
 
 
 
204
 
205
+ def jump_to_page(evt: gr.SelectData, manual, pages):
206
+ """Clicking a cited-page thumbnail re-opens the PDF at that page."""
207
+ if not manual or not pages or evt.index >= len(pages):
208
+ return gr.update()
209
+ return _pdf_viewer(manual, pages[evt.index])
210
 
 
211
 
212
+ def reset_pdf_on_manual_change(manual):
213
+ """Switching manuals opens that manual at page 1 and clears stale citations."""
214
+ return _pdf_viewer(manual, 1) if manual else EMPTY_PDF, [], []
215
 
216
+
217
+ CSS = """
218
+ :root { --rg-radius: 16px; }
219
+ .gradio-container { max-width: 1500px !important; }
220
+
221
+ /* Header ----------------------------------------------------------------- */
222
+ #rg-header { align-items: center; margin: 0.4em 0 0.8em; }
223
+ #rg-title h1 { margin: 0; font-weight: 700; letter-spacing: -0.01em; }
224
+ #rg-title p { margin: 0.15em 0 0; color: var(--body-text-color-subdued); font-size: 0.95em; }
225
+ #rg-cog { display: flex; justify-content: flex-end; }
226
+ .icon-btn {
227
+ max-width: 46px; min-width: 46px !important; height: 46px;
228
+ border-radius: 50% !important; font-size: 1.25em !important;
229
+ padding: 0 !important; box-shadow: none !important;
230
+ }
231
+
232
+ /* Panels ----------------------------------------------------------------- */
233
+ .rg-panel {
234
+ border-radius: var(--rg-radius) !important;
235
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.05) !important;
236
+ border: 1px solid var(--border-color-primary) !important;
237
+ padding: 14px !important;
238
+ }
239
+ .rg-panel .chatbot, #rg-chat .bubble-wrap { border: none !important; }
240
+
241
+ /* PDF viewer ------------------------------------------------------------- */
242
+ .pdf-frame {
243
+ width: 100%; height: 620px; border: none;
244
+ border-radius: 12px; background: var(--background-fill-secondary);
245
+ }
246
+ .pdf-empty {
247
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
248
+ height: 620px; gap: 12px; border-radius: 12px; text-align: center;
249
+ font-size: 1.05em; line-height: 1.5; color: var(--body-text-color-subdued);
250
+ background: var(--background-fill-secondary);
251
+ }
252
+ .pdf-empty::first-line { font-size: 2.4em; }
253
+ .cited-label { font-size: 0.85em; color: var(--body-text-color-subdued); margin: 10px 2px 4px; }
254
+
255
+ /* Settings modal (native overlay, no extra deps) ------------------------- */
256
+ #rg-settings {
257
+ position: fixed; inset: 0; z-index: 1000;
258
+ background: rgba(15, 23, 42, 0.45); backdrop-filter: blur(2px);
259
+ display: flex; align-items: center; justify-content: center;
260
+ }
261
+ #rg-settings .rg-settings-card {
262
+ width: min(440px, 92vw); background: var(--background-fill-primary);
263
+ border-radius: var(--rg-radius); padding: 22px 24px;
264
+ box-shadow: 0 24px 60px rgba(15, 23, 42, 0.35);
265
+ }
266
+ .rg-settings-card h3 { margin-top: 0; }
267
+ """
268
 
269
 
270
  with gr.Blocks(title="Repair Guy") as demo:
271
+ pages_state = gr.State([]) # page numbers of the current citations, gallery-aligned
272
+
273
+ with gr.Row(elem_id="rg-header"):
274
+ with gr.Column(scale=8, elem_id="rg-title"):
275
+ gr.Markdown(
276
+ "# 🔧 Repair Guy\n"
277
+ "Your AI repair assistant — ask about a manual, get a grounded "
278
+ "answer with the exact pages it came from."
279
+ )
280
+ with gr.Column(scale=1, elem_id="rg-cog", min_width=60):
281
+ cog_btn = gr.Button("⚙️", elem_classes="icon-btn", variant="secondary")
282
 
283
  with gr.Row(equal_height=True):
284
+ # Left: chat
285
+ with gr.Column(scale=5, elem_classes="rg-panel"):
286
+ manual_in = gr.Dropdown(
287
+ label="Manual", choices=[], filterable=True, container=True
288
+ )
289
+ chatbot = gr.Chatbot(
290
+ type="messages",
291
+ height=540,
292
+ show_label=False,
293
+ elem_id="rg-chat",
294
+ avatar_images=(None, None),
295
+ placeholder="### 🔧 Repair Guy\nPick a manual, then describe the issue "
296
+ "or ask a question about it.",
297
  )
298
  with gr.Row():
299
+ question_in = gr.Textbox(
300
+ show_label=False, scale=8, container=False,
301
+ placeholder="Describe the issue you're facing…",
302
+ )
303
+ submit_btn = gr.Button("Send", scale=1, variant="primary", min_width=90)
304
+
305
+ # Right: source PDF + cited pages
306
+ with gr.Column(scale=5, elem_classes="rg-panel"):
307
+ pdf_view = gr.HTML(EMPTY_PDF)
308
+ gr.Markdown("Cited pages", elem_classes="cited-label")
309
+ pages_gallery = gr.Gallery(
310
+ show_label=False, columns=4, height=140, object_fit="contain",
311
+ preview=False, allow_preview=False,
312
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
+ # Settings modal -----------------------------------------------------------
315
+ with gr.Column(elem_id="rg-settings", visible=False) as settings_modal:
316
+ with gr.Column(elem_classes="rg-settings-card"):
317
+ gr.Markdown("### ⚙️ Settings")
318
+ approach_in = gr.Radio(
319
+ choices=[(label, key) for key, label in APPROACHES.items()],
320
+ value="visual",
321
+ label="Retrieval approach",
322
+ )
323
+ k_in = gr.Slider(
324
+ 1, MAX_TOP_K, value=DEFAULT_TOP_K, step=1,
325
+ label="Pages retrieved per question (k)",
326
+ )
327
+ with gr.Row():
328
+ refresh_btn = gr.Button("🔄 Sync library", size="sm")
329
+ close_btn = gr.Button("Done", variant="primary", size="sm")
330
+
331
+ # Wiring -------------------------------------------------------------------
332
+ bot_inputs = [chatbot, manual_in, approach_in, k_in, pages_state]
333
+ chat_outputs = [chatbot, pdf_view, pages_gallery, pages_state]
334
+
335
+ for trigger in (question_in.submit, submit_btn.click):
336
+ trigger(
337
+ add_user, [question_in, chatbot], [question_in, chatbot], queue=False
338
+ ).then(bot, bot_inputs, chat_outputs)
339
+
340
+ pages_gallery.select(
341
+ jump_to_page, inputs=[manual_in, pages_state], outputs=pdf_view
342
+ )
343
+ manual_in.change(
344
+ reset_pdf_on_manual_change, inputs=manual_in,
345
+ outputs=[pdf_view, pages_gallery, pages_state],
346
+ )
347
+
348
+ cog_btn.click(lambda: gr.update(visible=True), outputs=settings_modal)
349
+ close_btn.click(lambda: gr.update(visible=False), outputs=settings_modal)
350
  refresh_btn.click(refresh_library, inputs=[manual_in], outputs=[manual_in])
351
+
352
  demo.load(lambda: gr.update(choices=_manual_choices()), outputs=[manual_in])
353
 
354
 
355
  # Gradio 6 takes theme/css at launch(), not in the Blocks constructor.
356
+ LAUNCH_KWARGS = dict(
357
+ theme=gr.themes.Soft(
358
+ primary_hue="orange",
359
+ neutral_hue="slate",
360
+ font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
361
+ ),
362
+ css=CSS,
363
+ # PDFs live under the pre-indexed library; allow the file route to serve them.
364
+ allowed_paths=[PREINDEXED_DIR],
365
+ )
366
 
367
  if __name__ == "__main__":
368
  demo.launch(**LAUNCH_KWARGS)
pipelines/parsed_ask.py CHANGED
@@ -76,14 +76,16 @@ def _ask_on_gpu(
76
  (img, f"{label} (cosine {score:.3f})")
77
  for (label, img), (_, _, score) in zip(pages, refs)
78
  ]
79
- return answer, gallery
 
80
 
81
 
82
  class ParsedAskPipeline:
83
  """Stateless: the store is passed per call."""
84
 
85
  def run(self, store: ParsedStore, question: str, doc_ids: list[str] | None, top_k: int):
86
- """Return (answer markdown, gallery items [(image, caption)])."""
 
87
  question = (question or "").strip()
88
  if not question:
89
  raise ValueError("Please enter a question.")
 
76
  (img, f"{label} (cosine {score:.3f})")
77
  for (label, img), (_, _, score) in zip(pages, refs)
78
  ]
79
+ page_refs = [(doc_id, page) for doc_id, page, _ in refs]
80
+ return answer, gallery, page_refs
81
 
82
 
83
  class ParsedAskPipeline:
84
  """Stateless: the store is passed per call."""
85
 
86
  def run(self, store: ParsedStore, question: str, doc_ids: list[str] | None, top_k: int):
87
+ """Return (answer markdown, gallery items [(image, caption)], page_refs
88
+ [(doc_id, page_num)] for the retrieved pages, in answer order)."""
89
  question = (question or "").strip()
90
  if not question:
91
  raise ValueError("Please enter a question.")
pipelines/visual_ask.py CHANGED
@@ -32,14 +32,16 @@ def _ask_on_gpu(
32
  ]
33
  answer = generate_answer(question, [(label, img) for label, img, _ in pages])
34
  gallery = [(img, f"{label} (score {score:.1f})") for label, img, score in pages]
35
- return answer, gallery
 
36
 
37
 
38
  class VisualAskPipeline:
39
  """Stateless: the store is passed per call."""
40
 
41
  def run(self, store: VisualStore, question: str, doc_ids: list[str] | None, top_k: int):
42
- """Return (answer markdown, gallery items [(image, caption)])."""
 
43
  question = (question or "").strip()
44
  if not question:
45
  raise ValueError("Please enter a question.")
 
32
  ]
33
  answer = generate_answer(question, [(label, img) for label, img, _ in pages])
34
  gallery = [(img, f"{label} (score {score:.1f})") for label, img, score in pages]
35
+ page_refs = [(doc_id, page) for doc_id, page, _ in hits]
36
+ return answer, gallery, page_refs
37
 
38
 
39
  class VisualAskPipeline:
40
  """Stateless: the store is passed per call."""
41
 
42
  def run(self, store: VisualStore, question: str, doc_ids: list[str] | None, top_k: int):
43
+ """Return (answer markdown, gallery items [(image, caption)], page_refs
44
+ [(doc_id, page_num)] for the retrieved pages, in answer order)."""
45
  question = (question or "").strip()
46
  if not question:
47
  raise ValueError("Please enter a question.")