import importlib.util
import sys
from pathlib import Path
from types import ModuleType
import pytest
def _load_mistral_parser(monkeypatch):
"""Load mistral_parser.py directly, bypassing deepdoc/__init__.py's
beartype_this_package() and the heavy deepdoc dependency chain.
Mirrors test_somark_parser.py."""
repo_root = Path(__file__).resolve().parents[4]
deepdoc_mod = ModuleType("deepdoc")
deepdoc_mod.__path__ = [str(repo_root / "deepdoc")]
monkeypatch.setitem(sys.modules, "deepdoc", deepdoc_mod)
parser_mod = ModuleType("deepdoc.parser")
parser_mod.__path__ = [str(repo_root / "deepdoc" / "parser")]
monkeypatch.setitem(sys.modules, "deepdoc.parser", parser_mod)
pdf_parser_mod = ModuleType("deepdoc.parser.pdf_parser")
class _RAGFlowPdfParser:
pass
pdf_parser_mod.RAGFlowPdfParser = _RAGFlowPdfParser
pdf_parser_mod.MAXIMUM_PAGE_NUMBER = 100000
monkeypatch.setitem(sys.modules, "deepdoc.parser.pdf_parser", pdf_parser_mod)
utils_mod = ModuleType("deepdoc.parser.utils")
utils_mod.extract_pdf_outlines = lambda *_a, **_k: []
monkeypatch.setitem(sys.modules, "deepdoc.parser.utils", utils_mod)
module_name = "test_mistral_parser_unit_module"
module_path = repo_root / "deepdoc" / "parser" / "mistral_parser.py"
spec = importlib.util.spec_from_file_location(module_name, module_path)
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, module_name, module)
spec.loader.exec_module(module)
return module
def _make_parser(m, api_key="", **kwargs):
"""Build a MistralParser without any network call. __init__ only sets attributes."""
return m.MistralParser(base_url="https://api.mistral.ai/v1", api_key=api_key, **kwargs)
def test_resolve_internal_type_maps_known_types(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
assert p._resolve_internal_type("text") == "text"
assert p._resolve_internal_type("title") == "text"
assert p._resolve_internal_type("list") == "text"
assert p._resolve_internal_type("image") == "image"
assert p._resolve_internal_type("table") == "table"
def test_resolve_internal_type_drops_header_footer_by_default(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
assert p._resolve_internal_type("header") is None
assert p._resolve_internal_type("footer") is None
def test_resolve_internal_type_keeps_header_footer_when_flagged(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, keep_header_footer=True)
assert p._resolve_internal_type("header") == "text"
assert p._resolve_internal_type("footer") == "text"
def test_resolve_internal_type_unknown_falls_back_to_text(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
assert p._resolve_internal_type("brand_new_type") == "text"
def test_resolve_internal_type_none_falls_back_to_text(monkeypatch):
# Regression: a block with missing/null "type" yields None here; the
# annotation must accept it (beartype runs in production) and fall back
# to text instead of aborting the whole parse.
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
assert p._resolve_internal_type(None) == "text"
def test_block_text_image_returns_empty(monkeypatch):
m = _load_mistral_parser(monkeypatch)
assert m.MistralParser._block_text({"content": "x"}, "image") == ""
def test_block_text_strips_and_returns_content(monkeypatch):
m = _load_mistral_parser(monkeypatch)
assert m.MistralParser._block_text({"content": " hi "}, "text") == "hi"
def _ocr_response():
"""A real-shaped /v1/ocr response (from a live probe): two pages, a header
(dropped), text, title, an image with bbox, and a table block. dimensions
differ per page to guard against a constant rescale factor."""
return {
"model": "mistral-ocr-latest",
"usage_info": {"pages_processed": 2, "doc_size_bytes": 211291},
"pages": [
{
"index": 0,
"dimensions": {"dpi": 87, "width": 720, "height": 1018},
"markdown": "# Title\n\nhello",
"images": [{"id": "img-0.jpeg", "top_left_x": 251, "top_left_y": 72, "bottom_right_x": 311, "bottom_right_y": 145}],
"tables": [],
"blocks": [
{"type": "header", "content": "Cofinanziato", "top_left_x": 135, "top_left_y": 72, "bottom_right_x": 213, "bottom_right_y": 145},
{"type": "title", "content": "Title", "top_left_x": 40, "top_left_y": 160, "bottom_right_x": 400, "bottom_right_y": 190},
{"type": "text", "content": "hello world", "top_left_x": 40, "top_left_y": 200, "bottom_right_x": 500, "bottom_right_y": 230},
{"type": "image", "content": "", "top_left_x": 251, "top_left_y": 72, "bottom_right_x": 311, "bottom_right_y": 145},
],
},
{
"index": 1,
"dimensions": {"dpi": 144, "width": 1021, "height": 681},
"markdown": "|a|b|",
"images": [],
"tables": [{"id": "tbl-0.md", "content": "
", "format": "html", "word_confidence_scores": None}],
"blocks": [
{"type": "table", "content": "", "table_id": "tbl-0.md", "top_left_x": 49, "top_left_y": 103, "bottom_right_x": 960, "bottom_right_y": 597},
],
},
],
}
def test_normalize_pages_maps_bbox_and_page_size(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
pages = p._normalize_pages(_ocr_response())
assert [pg["page_num"] for pages_ in [pages] for pg in pages_] == [0, 1]
assert pages[0]["page_size"] == {"w": 720, "h": 1018}
assert pages[1]["page_size"] == {"w": 1021, "h": 681}
first_text = pages[0]["blocks"][2]
assert first_text["bbox"] == [40, 200, 500, 230] # [x0, top, x1, bott]
def test_normalize_pages_bbox_none_when_no_coords(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
resp = {"pages": [{"index": 0, "dimensions": {"width": 100, "height": 100}, "blocks": [{"type": "image", "content": ""}]}]}
pages = p._normalize_pages(resp)
assert pages[0]["blocks"][0]["bbox"] is None
# geometry-less image must be skipped, not emitted as a zero-area crop
assert p._transfer_to_sections(pages) == []
def test_normalize_pages_null_index_coerced_to_zero(monkeypatch):
# Regression: a present-but-null "index" must coerce to 0, not None
# (dict.get's default only applies when the key is absent), so the
# downstream page_idx + 1 in _line_tag does not raise TypeError.
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
resp = {
"pages": [
{
"index": None,
"dimensions": {"width": 100, "height": 100},
"blocks": [{"type": "text", "content": "hi", "top_left_x": 1, "top_left_y": 2, "bottom_right_x": 3, "bottom_right_y": 4}],
}
]
}
pages = p._normalize_pages(resp)
assert pages[0]["page_num"] == 0
secs = p._transfer_to_sections(pages) # must not raise
# naive path: text block -> (text, line_tag); tag stamped page 1 (1-based)
assert any(s[0] == "hi" and s[1].startswith("@@1\t") for s in secs)
def test_transfer_naive_path_returns_2_tuples_without_header(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
pages = p._normalize_pages(_ocr_response())
secs = p._transfer_to_sections(pages) # parse_method None -> naive
assert all(isinstance(s, tuple) and len(s) == 2 for s in secs)
joined = " ".join(s[0] for s in secs)
assert "Cofinanziato" not in joined # header dropped
assert "hello world" in joined
assert "" in joined # table inlined as text
def test_transfer_pipeline_path_returns_typed_3_tuples(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
pages = p._normalize_pages(_ocr_response())
secs = p._transfer_to_sections(pages, parse_method="pipeline")
assert all(isinstance(s, tuple) and len(s) == 3 for s in secs)
layout_types = {s[1] for s in secs}
assert {"text", "image", "table"} <= layout_types
def test_transfer_naive_image_carries_caption_and_tag(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
pages = p._normalize_pages(_ocr_response())
secs = p._transfer_to_sections(pages)
img = [s for s in secs if "@@" in s[0] and "##" in s[0]]
assert len(img) == 1 # exactly the image block, tag embedded in text
def test_line_tag_rescales_per_page_dimensions(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
class _Img:
def __init__(self, size):
self.size = size
# Distinct scale factors per page and per axis so a swapped-axis or
# wrong-page-index bug cannot pass: page 0 scales x by 3.0, y by 4.0;
# page 1 scales x by 4.0, y by 6.0.
p.page_images = [_Img((300, 800)), _Img((200, 600))]
tag0 = p._line_tag({"page_idx": 0, "bbox": [10, 20, 40, 60], "page_size": {"w": 100, "h": 200}})
assert tag0 == "@@1\t30.0\t120.0\t80.0\t240.0##"
tag1 = p._line_tag({"page_idx": 1, "bbox": [5, 10, 25, 40], "page_size": {"w": 50, "h": 100}})
assert tag1 == "@@2\t20.0\t100.0\t60.0\t240.0##"
def test_line_tag_clamps_negative_coords_and_roundtrips(monkeypatch):
# Regression: a bbox bleeding past the page origin rescales to negative
# coords; extract_positions' regex excludes '-', so an unclamped tag is
# silently dropped (crop returns None). Clamp to >= 0 so the tag survives.
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
class _Img:
def __init__(self, size):
self.size = size
p.page_images = [_Img((300, 800))]
tag = p._line_tag({"page_idx": 0, "bbox": [-10, -20, 40, 60], "page_size": {"w": 100, "h": 200}})
assert tag == "@@1\t0.0\t120.0\t0.0\t240.0##" # negatives clamped to 0.0
poss = m.MistralParser.extract_positions(tag)
assert len(poss) == 1 # the regex matches; block is not dropped
_pn, left, _right, top, _bottom = poss[0]
assert left == 0.0 and top == 0.0
def test_transfer_to_tables_is_empty(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
pages = p._normalize_pages(_ocr_response())
assert p._transfer_to_tables(pages) == []
def test_crop_returns_none_without_positions(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
assert p.crop("plain text with no tag") is None
def test_crop_reads_page_images_for_tagged_text(monkeypatch):
from PIL import Image
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
p.page_images = [Image.new("RGB", (200, 300), "white")]
tag = "@@1\t10.0\t100.0\t20.0\t60.0##"
out = p.crop("caption" + tag, need_position=True)
assert isinstance(out, tuple) and len(out) == 2
class _Resp:
def __init__(self, status, payload=None, text=""):
self.status_code = status
self._payload = payload or {}
self.text = text
def json(self):
return self._payload
def test_check_installation_requires_api_key(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="")
ok, reason = p.check_installation()
assert ok is False and "key" in reason.lower()
def test_call_ocr_inline_posts_pages_selector(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test")
captured = {}
def fake_post(url, headers=None, json=None, timeout=None, **kw):
captured["url"] = url
captured["json"] = json
return _Resp(200, {"pages": [{"index": 5, "markdown": "x", "blocks": []}], "usage_info": {"pages_processed": 1}})
monkeypatch.setattr(m.requests, "post", fake_post)
out = p._call_ocr(b"%PDF-1.4 fake", "f.pdf", pages=[5])
assert captured["url"].endswith("/ocr")
assert captured["json"]["pages"] == [5]
assert captured["json"]["include_blocks"] is True
assert out["pages"][0]["index"] == 5
def test_call_ocr_omits_pages_when_none(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test")
captured = {}
monkeypatch.setattr(m.requests, "post", lambda url, headers=None, json=None, timeout=None, **kw: captured.update(json=json) or _Resp(200, {"pages": []}))
p._call_ocr(b"%PDF fake", "f.pdf", pages=None)
assert "pages" not in captured["json"]
def test_call_ocr_raises_on_http_error(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-bad")
monkeypatch.setattr(m.requests, "post", lambda *a, **k: _Resp(401, text="Unauthorized"))
with pytest.raises(RuntimeError, match="401"):
p._call_ocr(b"%PDF fake", "f.pdf", pages=None)
def test_call_ocr_uploads_when_over_inline_limit(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test", inline_max_bytes=4)
calls = {"post": [], "get": [], "delete": []}
def fake_post(url, headers=None, json=None, data=None, files=None, timeout=None, **kw):
calls["post"].append(url)
if url.endswith("/files"):
return _Resp(200, {"id": "file-1"})
return _Resp(200, {"pages": [{"index": 0, "blocks": []}]})
monkeypatch.setattr(m.requests, "post", fake_post)
monkeypatch.setattr(m.requests, "get", lambda url, headers=None, params=None, timeout=None, **kw: calls["get"].append(url) or _Resp(200, {"url": "https://signed"}))
monkeypatch.setattr(m.requests, "delete", lambda url, headers=None, timeout=None, **kw: calls["delete"].append(url) or _Resp(200, {}))
out = p._call_ocr(b"%PDF-too-big", "big.pdf", pages=None)
assert any(u.endswith("/files") for u in calls["post"])
assert calls["get"] and calls["delete"] # signed-url fetch + cleanup
assert out["pages"][0]["index"] == 0
def test_call_ocr_upload_files_error_no_delete(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test", inline_max_bytes=4)
calls = {"delete": []}
monkeypatch.setattr(m.requests, "post", lambda url, headers=None, json=None, data=None, files=None, timeout=None, **kw: _Resp(500, text="boom"))
monkeypatch.setattr(m.requests, "delete", lambda url, headers=None, timeout=None, **kw: calls["delete"].append(url) or _Resp(200, {}))
with pytest.raises(RuntimeError, match="500"):
p._call_ocr(b"%PDF-too-big", "big.pdf", pages=None)
assert calls["delete"] == [] # file_id never set -> nothing to clean up
def test_call_ocr_signed_url_error_triggers_cleanup(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test", inline_max_bytes=4)
calls = {"delete": []}
monkeypatch.setattr(m.requests, "post", lambda url, headers=None, json=None, data=None, files=None, timeout=None, **kw: _Resp(200, {"id": "file-1"}))
monkeypatch.setattr(m.requests, "get", lambda url, headers=None, params=None, timeout=None, **kw: _Resp(403, text="nope"))
monkeypatch.setattr(m.requests, "delete", lambda url, headers=None, timeout=None, **kw: calls["delete"].append(url) or _Resp(200, {}))
with pytest.raises(RuntimeError, match="403"):
p._call_ocr(b"%PDF-too-big", "big.pdf", pages=None)
assert any("file-1" in u for u in calls["delete"]) # cleanup ran
def test_call_ocr_post_upload_ocr_error_triggers_cleanup(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test", inline_max_bytes=4)
calls = {"delete": []}
def fake_post(url, headers=None, json=None, data=None, files=None, timeout=None, **kw):
if url.endswith("/files"):
return _Resp(200, {"id": "file-9"})
return _Resp(422, text="bad ocr")
monkeypatch.setattr(m.requests, "post", fake_post)
monkeypatch.setattr(m.requests, "get", lambda url, headers=None, params=None, timeout=None, **kw: _Resp(200, {"url": "https://signed"}))
monkeypatch.setattr(m.requests, "delete", lambda url, headers=None, timeout=None, **kw: calls["delete"].append(url) or _Resp(200, {}))
with pytest.raises(RuntimeError, match="422"):
p._call_ocr(b"%PDF-too-big", "big.pdf", pages=None)
assert any("file-9" in u for u in calls["delete"]) # finally cleanup ran despite OCR failure
def test_call_ocr_delete_failure_does_not_mask_error(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test", inline_max_bytes=4)
def fake_post(url, headers=None, json=None, data=None, files=None, timeout=None, **kw):
if url.endswith("/files"):
return _Resp(200, {"id": "file-x"})
return _Resp(422, text="bad ocr")
def boom_delete(url, headers=None, timeout=None, **kw):
raise RuntimeError("delete network error")
monkeypatch.setattr(m.requests, "post", fake_post)
monkeypatch.setattr(m.requests, "get", lambda url, headers=None, params=None, timeout=None, **kw: _Resp(200, {"url": "https://signed"}))
monkeypatch.setattr(m.requests, "delete", boom_delete)
# the real OCR error, not the swallowed delete error
with pytest.raises(RuntimeError, match="422"):
p._call_ocr(b"%PDF-too-big", "big.pdf", pages=None)
def _patch_render(m, p, n_pages):
from PIL import Image
p.page_images = [Image.new("RGB", (100, 140), "white") for _ in range(n_pages)]
# neuter __images__ so parse_pdf doesn't touch pdfplumber
p.__images__ = lambda *a, **k: None
def test_parse_pdf_whole_document_omits_pages(monkeypatch, tmp_path):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test")
seen = {}
p._call_ocr = lambda pdf_bytes, filename, pages, callback=None: seen.update(pages=pages) or _ocr_response()
_patch_render(m, p, 2)
pdf = tmp_path / "x.pdf"
pdf.write_bytes(b"%PDF-1.4 minimal")
secs, tables = p.parse_pdf(str(pdf))
assert seen["pages"] is None # whole doc -> no selector
assert tables == []
assert any("hello world" in s[0] for s in secs)
def test_parse_pdf_restricted_range_sends_absolute_pages(monkeypatch, tmp_path):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test")
seen = {}
p._call_ocr = lambda pdf_bytes, filename, pages, callback=None: seen.update(pages=pages) or {"pages": []}
_patch_render(m, p, 10)
pdf = tmp_path / "x.pdf"
pdf.write_bytes(b"%PDF-1.4 minimal")
p.parse_pdf(str(pdf), from_page=4, to_page=8)
assert seen["pages"] == [4, 5, 6, 7] # 0-based, half-open, absolute
def test_parse_pdf_pipeline_returns_3_tuples(monkeypatch, tmp_path):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test")
p._call_ocr = lambda *a, **k: _ocr_response()
_patch_render(m, p, 2)
pdf = tmp_path / "x.pdf"
pdf.write_bytes(b"%PDF-1.4 minimal")
secs, _ = p.parse_pdf(str(pdf), parse_method="pipeline")
assert all(len(s) == 3 for s in secs)
def test_parse_pdf_empty_range_short_circuits(monkeypatch, tmp_path):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test")
called = {"ocr": 0}
def fake_ocr(pdf_bytes, filename, pages, callback=None):
called["ocr"] += 1
return {"pages": []}
p._call_ocr = fake_ocr
_patch_render(m, p, 3) # only 3 pages rendered
pdf = tmp_path / "x.pdf"
pdf.write_bytes(b"%PDF-1.4 minimal")
# from_page beyond the rendered page count -> empty selector after clamp
secs, tables = p.parse_pdf(str(pdf), from_page=5, to_page=10)
assert (secs, tables) == ([], [])
assert called["ocr"] == 0 # short-circuited, no OCR call made
def test_parse_pdf_binary_bytes_path(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test")
p._call_ocr = lambda pdf_bytes, filename, pages, callback=None: _ocr_response()
_patch_render(m, p, 2)
secs, tables = p.parse_pdf("x.pdf", binary=b"%PDF-1.4 minimal")
assert any("hello world" in s[0] for s in secs)
assert tables == []
def test_parse_pdf_binary_stream_normalized(monkeypatch):
from io import BytesIO
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test")
seen = {}
p._call_ocr = lambda pdf_bytes, filename, pages, callback=None: seen.update(pdf_bytes=pdf_bytes) or _ocr_response()
_patch_render(m, p, 2)
p.parse_pdf("x.pdf", binary=BytesIO(b"%PDF-1.4 stream"))
assert seen["pdf_bytes"] == b"%PDF-1.4 stream" # BytesIO normalized to raw bytes
def test_image_description_injected_when_vision_model_present(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
p.vision_model = object() # truthy -> enrichment path taken
p._describe_image = lambda line_tag: "A scatter plot of X versus Y"
pages = p._normalize_pages(_ocr_response())
secs = p._transfer_to_sections(pages)
img = [s for s in secs if "@@" in s[0] and "scatter plot" in s[0]]
assert len(img) == 1 # VLM caption injected into the image chunk text
def test_image_description_skipped_without_vision_model(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
assert p.vision_model is None # default: no enrichment
calls = {"n": 0}
def _boom(line_tag):
calls["n"] += 1
return "SHOULD NOT APPEAR"
p._describe_image = _boom
pages = p._normalize_pages(_ocr_response())
secs = p._transfer_to_sections(pages)
assert calls["n"] == 0 # _describe_image not called when vision_model is None
assert not any("SHOULD NOT APPEAR" in s[0] for s in secs)
# the image chunk still exists, labelled by fallback (empty caption -> "image N")
assert any("@@" in s[0] and "image 1" in s[0] for s in secs)
def test_describe_image_returns_empty_when_crop_none(monkeypatch):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
p.vision_model = object()
p.crop = lambda *a, **k: None # no image to crop
assert p._describe_image("@@1\t0\t0\t0\t0##") == ""
def test_parse_pdf_consumes_vision_model_kwarg(monkeypatch, tmp_path):
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m, api_key="sk-test")
seen = {}
p._call_ocr = lambda pdf_bytes, filename, pages, callback=None: seen.setdefault("pages", pages) or _ocr_response()
_patch_render(m, p, 2)
pdf = tmp_path / "x.pdf"
pdf.write_bytes(b"%PDF-1.4 minimal")
p.parse_pdf(str(pdf), vision_model="VM")
assert p.vision_model == "VM" # popped from kwargs into self, not forwarded to _call_ocr
def test_describe_image_passes_pil_image_not_bytes(monkeypatch):
# Regression: vision_llm_chunk expects a PIL Image (it calls img.size/img.save),
# so _describe_image must pass the crop directly, not its bytes.
import sys as _sys
from types import ModuleType
from PIL import Image
m = _load_mistral_parser(monkeypatch)
p = _make_parser(m)
p.vision_model = object()
p.crop = lambda *a, **k: Image.new("RGB", (40, 40), "white")
captured = {}
for name in ("rag", "rag.app", "rag.prompts"):
if name not in _sys.modules:
monkeypatch.setitem(_sys.modules, name, ModuleType(name))
pic = ModuleType("rag.app.picture")
pic.vision_llm_chunk = lambda binary, vision_model, prompt=None, callback=None: (captured.update(kind=type(binary).__name__), "a white square")[1]
gen = ModuleType("rag.prompts.generator")
gen.vision_llm_figure_describe_prompt = lambda: "describe"
monkeypatch.setitem(_sys.modules, "rag.app.picture", pic)
monkeypatch.setitem(_sys.modules, "rag.prompts.generator", gen)
out = p._describe_image("@@1\t0\t0\t40\t40##")
assert out == "a white square"
assert captured["kind"] == "Image" # PIL Image, not 'bytes'