File size: 6,466 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
"""The ZeroGPU Space: transformers VLM backend and the Gradio app, driven over its API with gradio_client.

Uses a tiny random Qwen3-VL built on disk (tests/tiny_vlm.py) so nothing is downloaded; set
MRAG_VLM_ID to a real model (e.g. Qwen/Qwen3-VL-2B-Instruct) to run the same tests against it.
"""
import os
import socket
import sys
from pathlib import Path

import pytest

from conftest import ROOT

pytest.importorskip("transformers")


@pytest.fixture(scope="session")
def vlm_id(tmp_path_factory):
    if os.environ.get("MRAG_VLM_ID"):
        return os.environ["MRAG_VLM_ID"]
    try:
        from transformers import Qwen3VLForConditionalGeneration  # noqa: F401
    except ImportError:
        pytest.skip("transformers without Qwen3-VL (needs >=4.57)")
    sys.path.insert(0, str(ROOT / "tests"))
    import tiny_vlm

    return str(tiny_vlm.build(tmp_path_factory.mktemp("tiny_qwen3vl")))


@pytest.fixture(scope="session")
def vlm(vlm_id):
    from mrag.hf_vlm import HFVLM

    return HFVLM(vlm_id)


def test_vlm_image_tokens_match_vision_grid(vlm):
    from PIL import Image

    ims = [Image.new("RGB", (900, 500), "red"), Image.new("RGB", (300, 600), "blue")]
    inp = vlm.build_inputs("system", "Question: what colour?", ims)
    grid = inp["image_grid_thw"]
    merge = vlm.processor.image_processor.merge_size
    n_img_tokens = int((inp["input_ids"] == vlm.model.config.image_token_id).sum())
    assert grid.shape[0] == 2 and n_img_tokens == int(grid.prod(-1).sum()) // merge ** 2


def test_vlm_streams_text(vlm):
    from PIL import Image

    pieces = list(vlm.stream("system", "Question: hi", [Image.new("RGB", (64, 64))], max_new_tokens=8))
    assert pieces and all(isinstance(p, str) for p in pieces)


def test_vlm_errors_are_raised_not_hung(vlm):
    with pytest.raises(Exception):
        list(vlm.stream("system", "x", [], max_new_tokens=-5))


def test_generator_transformers_backend(vlm, tmp_path):
    from PIL import Image

    from mrag.config import Config
    from mrag.generate import Generator, ImageHit, TextHit

    p = tmp_path / "a.png"
    Image.new("RGB", (3000, 1500), "green").save(p)
    g = Generator(Config(llm_backend="transformers", max_new_tokens=6, max_image_side=256), vlm=vlm)
    out = g.answer("q?", [TextHit("1", "fact", "/d/x.pdf", 1, "text", 1.0)],
                   [ImageHit("i", str(p), "/d/x.pdf", 1, "", 0.3)])
    assert isinstance(out, str)


def _free_port():
    s = socket.socket()
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]
    s.close()
    return port


@pytest.fixture(scope="module")
def client(vlm_id, tmp_path_factory):
    pytest.importorskip("gradio_client")
    os.environ["MRAG_VLM_ID"] = vlm_id
    os.environ.setdefault("MRAG_TEXT_BACKEND", "wordllama")
    os.chdir(tmp_path_factory.mktemp("space"))      # demo_docs is built next to app.py; keep tests clean
    sys.path.insert(0, str(ROOT))
    import app
    from gradio_client import Client

    port = _free_port()
    app.demo.queue().launch(server_port=port, prevent_thread_lock=True, quiet=True)
    yield Client(f"http://127.0.0.1:{port}/", verbose=False), app
    app.demo.close()


def test_app_demo_docs_and_streamed_answer(client):
    c, app = client
    status = c.predict(api_name="/load_demo")
    assert "7 file(s)" in status and "report.pdf" in status
    chat, gallery, sources, box, *_ = c.predict(
        "How long is the warranty on the Nimbus R2?", [], 6, 3, True,
        api_name="/prepare_answer")
    assert len(chat) == 2 and chat[0]["role"] == "user"
    assert "36-month" in sources and "report.pdf p.3" in sources
    assert gallery and "report.pdf p.3" in gallery[0]["caption"]
    job = c.submit(64, chat, api_name="/vlm_answer")
    outputs = list(job)
    final = job.result()
    assert outputs and final[-1]["role"] == "assistant" and final[-1]["content"]
    assert box["value"] == "" and box["interactive"] is False
    # gradio_client calls endpoints individually; the browser runs this chained event.
    c.predict(api_name="/finish_answer")


def test_app_upload_and_session_isolation(client, corpus):
    from gradio_client import Client, handle_file

    c, _ = client
    fresh = Client(c.src, verbose=False)
    with pytest.raises(Exception, match="Index some files"):
        fresh.predict("a cat?", [], 6, 3, True, api_name="/prepare_answer")
    status, _ = fresh.predict([handle_file(str(corpus / "images" / "office_cat.jpg"))], api_name="/index_files")
    assert "1 file(s)" in status and "office_cat.jpg" in status
    chat, gallery, sources, *_ = fresh.predict("show me a picture of a cat", [], 6, 3, True, api_name="/prepare_answer")
    assert gallery and "office_cat.jpg" in gallery[0]["caption"]
    assert "report.pdf" not in sources
    final = fresh.predict(64, chat, api_name="/vlm_answer")
    assert final[-1]["role"] == "assistant"
    fresh.predict(api_name="/finish_answer")


def test_app_busy_guard_and_failure_cleanup(client):
    c, app = client
    chat, *_ = c.predict("Who is the CEO?", [], 6, 0, False, api_name="/prepare_answer")
    with pytest.raises(Exception, match="still running"):
        c.predict(api_name="/reset")
    # This handler also runs when ZeroGPU rejects a request or generation fails.
    c.predict(api_name="/finish_answer")
    with pytest.raises(Exception, match="Submit a question"):
        c.predict(64, chat, api_name="/vlm_answer")
    c.predict(api_name="/finish_answer")


def test_app_cumulative_upload_limit(client, tmp_path):
    from gradio_client import Client, handle_file

    c, app = client
    fresh = Client(c.src, verbose=False)
    paths = []
    for i in range(app.MAX_FILES + 1):
        p = tmp_path / f"file{i}.txt"
        p.write_text(f"Document {i}: the robot payload is 25 kilograms.")
        paths.append(handle_file(str(p)))
    status, _ = fresh.predict(paths[:app.MAX_FILES], api_name="/index_files")
    assert f"{app.MAX_FILES} file(s)" in status
    with pytest.raises(Exception, match="indexed files per session"):
        fresh.predict(paths[-1:], api_name="/index_files")
    fresh.predict(api_name="/reset")


def test_app_generation_is_success_only_and_serialized(client):
    _, app = client
    gpu_id = next(i for i, fn in app.demo.fns.items() if fn.fn == app.vlm_answer)
    binding = next(d for d in app.demo.config["dependencies"] if d["id"] == gpu_id)
    assert binding["trigger_only_on_success"] is True
    assert app.demo.fns[gpu_id].concurrency_limit == 1