Sync from GitHub 3e78bca
Browse files
app.py
CHANGED
|
@@ -12,8 +12,9 @@ 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
|
| 16 |
-
|
|
|
|
| 17 |
|
| 18 |
Module layout:
|
| 19 |
models/colembed.py ColEmbed — visual: page embeddings + MaxSim
|
|
@@ -27,6 +28,7 @@ Module layout:
|
|
| 27 |
|
| 28 |
import os
|
| 29 |
import shutil
|
|
|
|
| 30 |
|
| 31 |
import gradio as gr
|
| 32 |
from huggingface_hub import snapshot_download
|
|
@@ -56,11 +58,6 @@ LIBRARIES = {
|
|
| 56 |
),
|
| 57 |
}
|
| 58 |
|
| 59 |
-
METHOD_CHOICES = [
|
| 60 |
-
("Visual — ColEmbed page embeddings + MaxSim", "visual"),
|
| 61 |
-
("Parsed — Nemotron Parse chunks + dense retrieval", "parsed"),
|
| 62 |
-
]
|
| 63 |
-
|
| 64 |
|
| 65 |
def sync_library() -> None:
|
| 66 |
"""Pull pre-indexed manuals from the library dataset into /data.
|
|
@@ -87,82 +84,151 @@ def sync_library() -> None:
|
|
| 87 |
sync_library()
|
| 88 |
|
| 89 |
|
| 90 |
-
def
|
| 91 |
-
"""
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
"""Re-pull the library dataset (incremental) and refresh the dropdown,
|
| 105 |
so manuals indexed after the Space booted show up without a restart."""
|
| 106 |
sync_library()
|
| 107 |
-
|
|
|
|
|
|
|
| 108 |
|
| 109 |
|
| 110 |
-
def
|
|
|
|
|
|
|
|
|
|
| 111 |
if not doc_id:
|
| 112 |
-
|
| 113 |
store, pipeline = LIBRARIES[method]
|
|
|
|
|
|
|
|
|
|
| 114 |
try:
|
| 115 |
-
|
| 116 |
except ValueError as e:
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
|
| 120 |
with gr.Blocks(title="Repair Guy") as demo:
|
| 121 |
gr.Markdown(
|
| 122 |
"# 🔧 Repair Guy\n"
|
| 123 |
-
"
|
| 124 |
-
"
|
| 125 |
-
"-
|
| 126 |
-
"[Nemotron ColEmbed v2](https://huggingface.co/nvidia/nemotron-colembed-vl-4b-v2) "
|
| 127 |
-
"(late interaction, no parsing).\n"
|
| 128 |
-
"- **Parsed** — pages parsed with "
|
| 129 |
-
"[Nemotron Parse](https://huggingface.co/nvidia/NVIDIA-Nemotron-Parse-v1.2), "
|
| 130 |
-
"figures/tables described by MiniCPM-V, section chunks retrieved with "
|
| 131 |
-
"[Llama Nemotron Embed](https://huggingface.co/nvidia/llama-nemotron-embed-vl-1b-v2) "
|
| 132 |
-
"and mapped back to their pages.\n\n"
|
| 133 |
-
"Either way, MiniCPM-V reads the retrieved pages and answers."
|
| 134 |
)
|
| 135 |
-
|
|
|
|
| 136 |
with gr.Column(scale=1):
|
| 137 |
-
method_in = gr.Radio(
|
| 138 |
-
METHOD_CHOICES, value="visual", label="Retrieval approach"
|
| 139 |
-
)
|
| 140 |
manual_in = gr.Dropdown(label="Manual", choices=[])
|
| 141 |
-
|
| 142 |
-
|
|
|
|
| 143 |
label="Question",
|
| 144 |
lines=2,
|
| 145 |
placeholder="e.g. What is the tightening torque for the universal joint flange bolts?",
|
| 146 |
)
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
if __name__ == "__main__":
|
| 168 |
-
demo.launch()
|
|
|
|
| 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
|
|
|
|
| 28 |
|
| 29 |
import os
|
| 30 |
import shutil
|
| 31 |
+
import time
|
| 32 |
|
| 33 |
import gradio as gr
|
| 34 |
from huggingface_hub import snapshot_download
|
|
|
|
| 58 |
),
|
| 59 |
}
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
def sync_library() -> None:
|
| 63 |
"""Pull pre-indexed manuals from the library dataset into /data.
|
|
|
|
| 84 |
sync_library()
|
| 85 |
|
| 86 |
|
| 87 |
+
def _manual_choices() -> list[tuple[str, str]]:
|
| 88 |
+
"""One shared dropdown across both libraries (doc ids are name slugs, so
|
| 89 |
+
the same manual lands on the same id in both); manuals indexed under only
|
| 90 |
+
one approach are labeled with it."""
|
| 91 |
+
docs: dict[str, dict] = {}
|
| 92 |
+
for method, (store, _) in LIBRARIES.items():
|
| 93 |
+
for d in store.list_docs():
|
| 94 |
+
entry = docs.setdefault(d["doc_id"], {"name": d["name"], "methods": []})
|
| 95 |
+
entry["methods"].append(method)
|
| 96 |
+
choices = []
|
| 97 |
+
for doc_id, info in sorted(docs.items(), key=lambda kv: kv[1]["name"].lower()):
|
| 98 |
+
label = info["name"]
|
| 99 |
+
if len(info["methods"]) < len(LIBRARIES):
|
| 100 |
+
label += f" — {info['methods'][0]} only"
|
| 101 |
+
choices.append((label, doc_id))
|
| 102 |
+
return choices
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def refresh_library(doc_id):
|
| 106 |
"""Re-pull the library dataset (incremental) and refresh the dropdown,
|
| 107 |
so manuals indexed after the Space booted show up without a restart."""
|
| 108 |
sync_library()
|
| 109 |
+
choices = _manual_choices()
|
| 110 |
+
ids = [v for _, v in choices]
|
| 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)
|