File size: 12,693 Bytes
c42f4ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Hugging Face Space (Gradio, ZeroGPU): multimodal RAG over PDFs, Word files and images.

CPU: parsing, OCR, BGE + CLIP embeddings, Qdrant (one in-memory index per browser session).
GPU (ZeroGPU, only while answering): Qwen3-VL reads the retrieved text and images and answers with citations.
"""
from __future__ import annotations

import spaces  # noqa: I001  (import before torch on ZeroGPU)

import logging
import os
import shutil
import sys
import tempfile
from dataclasses import replace
from pathlib import Path

import gradio as gr

ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT / "examples"))

from mrag.config import Config  # noqa: E402
from mrag.generate import Generator, SYSTEM_PROMPT, build_prompt, load_image  # noqa: E402
from mrag.models import Models  # noqa: E402
from mrag.pipeline import MultimodalRAG  # noqa: E402

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("space")

MAX_FILES = int(os.environ.get("MRAG_MAX_FILES", 10))
MAX_FILE_MB = int(os.environ.get("MRAG_MAX_FILE_MB", 25))

BASE_CFG = Config.load(
    qdrant_url=":memory:",
    llm_backend=os.environ.get("MRAG_LLM_BACKEND", "transformers"),
    max_pdf_pages=int(os.environ.get("MRAG_MAX_PDF_PAGES", 40)),
    device="cpu",  # retrieval callbacks never have a ZeroGPU allocation
)
if BASE_CFG.llm_backend not in {"transformers", "extractive"}:
    raise ValueError("This Space supports MRAG_LLM_BACKEND=transformers or extractive.")

# ---- models: loaded once per process -------------------------------------------------
MODELS = Models(BASE_CFG)
_ = MODELS.text, MODELS.clip, MODELS.ocr          # CPU models, warm at startup

VLM = None
if BASE_CFG.llm_backend == "transformers":
    from mrag.hf_vlm import HFVLM

    VLM = HFVLM(BASE_CFG.vlm_id)                  # device "cuda" on ZeroGPU (attached per call)
    log.info("VLM %s on %s", BASE_CFG.vlm_id, VLM.device)

DEMO_DIR = ROOT / "demo_docs"
if not DEMO_DIR.exists():
    import make_demo_corpus

    make_demo_corpus.build(DEMO_DIR)


# ---- per-session state ------------------------------------------------------------------
class Session:
    def __init__(self):
        self.dir = tempfile.mkdtemp(prefix="mrag_session_")
        self.rag = MultimodalRAG(replace(BASE_CFG, data_dir=self.dir), models=MODELS, vlm=VLM)
        self.busy = False

    def close(self):
        self.rag.close()
        shutil.rmtree(self.dir, ignore_errors=True)


def _cleanup(s):
    if isinstance(s, Session):
        s.close()


def _session(s):
    return s if isinstance(s, Session) else Session()


def _idle(s):
    if isinstance(s, Session) and s.busy:
        raise gr.Error("An answer is still running. Wait for it to finish.")


def finish_answer(s):
    """Runs in the web process, including after a GPU error or quota rejection."""
    if isinstance(s, Session):
        s.busy = False
    return s, "", [], "", *[gr.update(interactive=True) for _ in range(4)]


def _status(s: Session) -> str:
    st = s.rag.stats()
    names = sorted(Path(p).name for p in s.rag._manifest())
    return (f"**Indexed:** {st['files']} file(s) · {st['mrag_text']} text chunks · {st['mrag_image']} images"
            + (f"\n\n{', '.join(names)}" if names else ""))


# ---- GPU part -------------------------------------------------------------------------
def _gpu_seconds(prompt, image_paths, max_new_tokens):
    return int(min(120, 20 + 4 * len(image_paths) + max_new_tokens / 12))


@spaces.GPU(duration=lambda prompt, image_paths, max_new_tokens, history:
            _gpu_seconds(prompt, image_paths, max_new_tokens))
def vlm_answer(prompt: str, image_paths: list[str], max_new_tokens: int, history: list[dict]):
    """Gradio-bound GPU callback: stream an answer directly into the chat history.

    Hugging Face ZeroGPU must see @spaces.GPU on the ACTUAL registered event
    handler (the second step in the Gradio event chain below), not on a helper
    invoked by an undecorated event handler.
    """
    if not prompt or not history or history[-1].get("role") != "assistant":
        raise gr.Error("Submit a question before requesting an answer.")
    images = [load_image(p, BASE_CFG.max_image_side) for p in image_paths]
    messages = [dict(item) for item in (history or [])]
    output = ""
    for piece in VLM.stream(SYSTEM_PROMPT, prompt, images, int(max_new_tokens), BASE_CFG.llm_temperature):
        output += piece
        messages[-1] = {"role": "assistant", "content": output}
        yield messages
    if not output.strip():
        messages[-1] = {"role": "assistant", "content": "The model returned an empty response."}
        yield messages


# ---- handlers -------------------------------------------------------------------------
def index_files(files, s, progress=gr.Progress()):
    _idle(s)
    files = files or []
    if len(files) > MAX_FILES:
        raise gr.Error(f"At most {MAX_FILES} files per session.")
    paths = []
    for f in files:
        p = Path(f if isinstance(f, str) else f.name)
        if p.stat().st_size > MAX_FILE_MB * 2**20:
            raise gr.Error(f"{p.name} is larger than {MAX_FILE_MB} MB.")
        paths.append(p)
    if not paths:
        raise gr.Error("Choose some files first.")
    existing = set(s.rag._manifest()) if isinstance(s, Session) else set()
    if len(existing | {str(p.resolve()) for p in paths}) > MAX_FILES:
        raise gr.Error(f"At most {MAX_FILES} indexed files per session. Clear the index first.")
    s = _session(s)
    progress(0, desc="Parsing, OCR and embedding")
    stats = s.rag.ingest(paths)
    if stats["errors"]:
        gr.Warning("; ".join(stats["errors"])[:500])
    return s, _status(s), None


def load_demo(s):
    _idle(s)
    from mrag.ingest import iter_files

    paths = list(iter_files([DEMO_DIR]))
    existing = set(s.rag._manifest()) if isinstance(s, Session) else set()
    if len(existing | {str(p.resolve()) for p in paths}) > MAX_FILES:
        raise gr.Error(f"Demo would exceed {MAX_FILES} indexed files. Clear the index first.")
    s = _session(s)
    stats = s.rag.ingest(paths)
    if stats["errors"]:
        gr.Warning("; ".join(stats["errors"])[:500])
    return s, _status(s)


def reset(s):
    _idle(s)
    _cleanup(s)
    s = Session()
    return s, _status(s), [], [], "", "", [], ""


def prepare_answer(question, history, s, top_k_text, top_k_images, send_images):
    """CPU-only retrieval. Returns inputs for the separate ZeroGPU event."""
    _idle(s)
    if not question or not question.strip():
        raise gr.Error("Enter a question first.")
    if len(question) > 4000:
        raise gr.Error("Keep the question within 4000 characters.")
    if not isinstance(s, Session):
        raise gr.Error("Index some files first (or click 'Load demo documents').")
    if s.rag.stats()["files"] == 0:
        raise gr.Error("Index some files first (or click 'Load demo documents').")
    s.rag.cfg.top_k_text, s.rag.cfg.top_k_images = int(top_k_text), int(top_k_images)
    texts, images = s.rag.retrieve(question)
    selected_images = images if send_images else []
    gallery = [(im.path, f"[Img {i}] {Path(im.source).name}" + (f" p.{im.page}" if im.page else ""))
               for i, im in enumerate(images, 1)]
    sources = "\n\n".join(
        f"**[{i}] {Path(t.source).name}{f' p.{t.page}' if t.page else ''}** "
        f"<small>({t.kind})</small>\n\n{t.text[:700]}" for i, t in enumerate(texts, 1)) or "_none_"
    history = list(history or []) + [
        {"role": "user", "content": question}, {"role": "assistant", "content": ""},
    ]
    s.busy = True
    return (s, history, gallery, sources, gr.update(value="", interactive=False), build_prompt(question, texts, selected_images),
            [im.path for im in selected_images], question,
            *[gr.update(interactive=False) for _ in range(3)])


def cpu_answer(prompt, image_paths, max_new_tokens, history, s, question):
    """Optional non-GPU local fallback. Not used for a standard ZeroGPU Space."""
    messages = [dict(item) for item in history]
    output = ""
    texts, images = s.rag.retrieve(question)
    if not image_paths:
        images = []
    cfg = replace(s.rag.cfg, max_new_tokens=int(max_new_tokens))
    for piece in Generator(cfg, MODELS.text).answer(question, texts, images, stream=True):
        output += piece
        messages[-1] = {"role": "assistant", "content": output}
        yield messages
    if not output:
        messages[-1] = {"role": "assistant", "content": "No evidence found."}
        yield messages


# ---- UI -------------------------------------------------------------------------------
EXAMPLES = [
    "How long is the warranty on the Nimbus R2?",
    "Which region had the highest revenue in FY2025, and how much?",
    "What is the maximum load of the electric forklift?",
    "When is the server room door code changed?",
    "Show me the photo of the office cat.",
]

with gr.Blocks(title="Multimodal RAG", delete_cache=(3600, 10800)) as demo:
    gr.Markdown(
        "# Multimodal RAG\n"
        "Ask questions over **PDFs, Word files, images, charts and scanned pages**. "
        "Retrieval: BGE-small + BM25 for text, OpenCLIP for images, RapidOCR for text inside images, Qdrant. "
        f"Answering: **{Path(BASE_CFG.vlm_id).name}** on ZeroGPU, which reads the retrieved text *and* images. "
        "Each browser session gets a separate temporary index. Avoid uploading confidential data.")
    state = gr.State(None, delete_callback=_cleanup, time_to_live=3 * 3600)
    pending_prompt = gr.State("")
    pending_images = gr.State([])
    pending_question = gr.State("")
    with gr.Row():
        with gr.Column(scale=1, min_width=300):
            files = gr.File(label=f"Upload (≤{MAX_FILES} files, ≤{MAX_FILE_MB} MB each)", file_count="multiple",
                            file_types=[".pdf", ".docx", ".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif",
                                        ".tif", ".tiff", ".txt", ".md"])
            btn_index = gr.Button("Index files", variant="primary")
            btn_demo = gr.Button("Load demo documents")
            btn_reset = gr.Button("Clear index")
            status = gr.Markdown("**Indexed:** nothing yet")
            with gr.Accordion("Settings", open=False):
                k_text = gr.Slider(1, 12, BASE_CFG.top_k_text, step=1, label="Text passages")
                k_img = gr.Slider(0, 6, BASE_CFG.top_k_images, step=1, label="Images")
                max_tokens = gr.Slider(64, 1024, BASE_CFG.max_new_tokens, step=32, label="Max new tokens")
                send_imgs = gr.Checkbox(True, label="Send retrieved images to the VLM")
        with gr.Column(scale=2):
            chat = gr.Chatbot(label="Answer", height=420)
            q = gr.Textbox(label="Question", placeholder="Ask about your documents", submit_btn=True)
            gr.Examples(EXAMPLES, inputs=q)
            gallery = gr.Gallery(label="Retrieved images", columns=3, height=260)
            with gr.Accordion("Retrieved text", open=False):
                src = gr.Markdown()

    cpu_queue = dict(concurrency_id="retrieval", concurrency_limit=1)
    btn_index.click(index_files, [files, state], [state, status, files], **cpu_queue)
    btn_demo.click(load_demo, [state], [state, status], **cpu_queue)
    btn_reset.click(reset, [state], [state, status, chat, gallery, src,
                                  pending_prompt, pending_images, pending_question], **cpu_queue)
    # A two-step Gradio event chain is necessary for ZeroGPU detection.
    # Only successful retrieval can trigger generation; .then also runs on failure.
    prep = q.submit(prepare_answer,
                    inputs=[q, chat, state, k_text, k_img, send_imgs],
                    outputs=[state, chat, gallery, src, q, pending_prompt, pending_images, pending_question,
                             btn_index, btn_demo, btn_reset], **cpu_queue)
    if BASE_CFG.llm_backend == "transformers":
        answer = prep.success(fn=vlm_answer,
                  inputs=[pending_prompt, pending_images, max_tokens, chat],
                  outputs=chat, concurrency_id="vlm", concurrency_limit=1)
    else:
        answer = prep.success(fn=cpu_answer,
                  inputs=[pending_prompt, pending_images, max_tokens, chat, state, pending_question],
                  outputs=chat, **cpu_queue)

    answer.then(finish_answer, inputs=state,
                outputs=[state, pending_prompt, pending_images, pending_question,
                         q, btn_index, btn_demo, btn_reset], **cpu_queue)

if __name__ == "__main__":
    demo.queue(default_concurrency_limit=1, max_size=20).launch(max_file_size=f"{MAX_FILE_MB}mb")