ReMe/tests/unit/test_auto_image_steps.py
WQS 5c17874f73
feat(auto_resource): interpret image resources into daily notes (#500)
* feat: add auto_image step for image resource caption notes

* feat: wire image resources into the resource watch loop

* refactor: split auto resource processors behind router

* refactor: align resource processor module names

* refactor: preserve auto resource compatibility

* refactor: clarify auto resource routing structure

* fix: address auto resource review concerns

* test: scope auto resource fixtures

* docs: align auto resource processor wording

* test: cover image resize failures

* fix: harden image resource lifecycle

* fix: preserve resource image detail and linked daily ownership

* style(file-graph): stabilize multiline docstring formatting
2026-09-07 12:00:38 +08:00

1089 lines
44 KiB
Python

"""Tests for AutoImageResourceStep: image resource files become caption daily notes.
The vision model boundary is faked (no network); test images are synthesized
with PIL inside a temporary workspace.
"""
# pylint: disable=protected-access
import base64
import hashlib
import io
import struct
import subprocess
import sys
import tomllib
import zlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import frontmatter
import pytest
import yaml
from PIL import Image, JpegImagePlugin
from reme.components import R
from reme.components.component_registry import ComponentRegistry
from reme.components.job import BaseJob
from reme.components.runtime_context import RuntimeContext
from reme.enumeration import ComponentEnum
from reme.steps.evolve.auto_image_resource import (
AutoImageResourceStep,
DEFAULT_MAX_IMAGE_PIXELS,
_build_image_request_payload,
_normalize_image_bytes,
_parse_caption_json,
)
from reme.steps.evolve.auto_resource import AutoResourceStep
from reme.steps.evolve.auto_text_resource import AutoTextResourceStep
from .auto_resource_test_support import (
FakeAgentWrapper as _FakeAgentWrapper,
FakeAudioResourceStep as _FakeAudioResourceStep,
FakeVisionModel as _FakeVisionModel,
FlakyAgentWrapper as _FlakyAgentWrapper,
FlakyVisionModel as _FlakyVisionModel,
StructuredVisionModel as _StructuredVisionModel,
caption_json as _caption_json,
image_bytes as _img_bytes,
make_app_context as _make_app_context,
png_bytes as _png_bytes,
write_binary as _write_binary,
write_note as _write_note,
)
pytest_plugins = ("unit.auto_resource_test_plugin",)
def _png_bytes_with_header_size(width: int, height: int) -> bytes:
"""Change only a tiny PNG's IHDR dimensions without allocating its pixels."""
data = bytearray(_png_bytes())
data[16:24] = struct.pack(">II", width, height)
data[29:33] = struct.pack(">I", zlib.crc32(data[12:29]) & 0xFFFFFFFF)
return bytes(data)
@pytest.mark.parametrize(
("image_format", "suffix", "source_mime", "request_mime"),
[
("PNG", ".png", "image/png", "image/png"),
("JPEG", ".jpg", "image/jpeg", "image/jpeg"),
("WEBP", ".webp", "image/webp", "image/webp"),
("BMP", ".bmp", "image/bmp", "image/jpeg"),
("TIFF", ".tiff", "image/tiff", "image/jpeg"),
],
)
@pytest.mark.asyncio
async def test_auto_image_supports_core_formats(
image_format,
suffix,
source_mime,
request_mime,
auto_resource_env,
):
"""Core formats preserve source metadata and use a provider-safe request payload."""
env = auto_resource_env
source = env.write_binary(f"resource/2026-01-01/image{suffix}", _img_bytes(image_format))
model = _StructuredVisionModel(
content={"name": "visible-subject", "description": "Visible", "caption": "Visible caption."},
)
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
assert response.success is True
note_path = env.workspace / "daily/2026-01-01/visible-subject.md"
post = frontmatter.loads(note_path.read_text(encoding="utf-8"))
assert post.metadata["source_resource"] == f"[[resource/2026-01-01/image{suffix}]]"
assert post.metadata["kind"] == "image"
assert post.metadata["media_type"] == source_mime
assert post.metadata["name"] == "visible-subject"
assert f"![[resource/2026-01-01/image{suffix}]]" in post.content
assert "Visible caption." in post.content
assert model.structured_calls[0][0].content[1].source.media_type == request_mime
assert (env.workspace / "daily/2026-01-01.md").is_file()
@pytest.mark.parametrize("change", ["added", "modified", "deleted"])
@pytest.mark.asyncio
async def test_auto_image_note_lifecycle(change, auto_resource_env):
"""Added, modified, and deleted image events maintain one source-owned note."""
env = auto_resource_env
source = env.workspace / "resource/2026-01-01/img.png"
note_path = env.workspace / "daily/2026-01-01/red-square.md"
if change != "deleted":
_write_binary(source, _png_bytes())
if change != "added":
_write_note(note_path, "[[resource/2026-01-01/img.png]]")
model = _FakeVisionModel(_caption_json("red-square", "Updated", "The updated caption."))
response = await env.run(env.processor(model), [{"change": change, "path": str(source)}])
result = response.metadata["results"][0]["metadata"]
assert response.success is True
assert result["action"] == change
if change == "deleted":
assert not note_path.exists()
assert not model.calls
else:
assert note_path.is_file()
assert "The updated caption." in frontmatter.loads(note_path.read_text(encoding="utf-8")).content
@pytest.mark.parametrize(
("stem", "plain_text", "note_name", "caption", "raw_json_must_be_absent"),
[
(
"fenced",
"```json\n" + _caption_json("fenced-note", "Fenced", "Fenced caption body.") + "\n```",
"fenced-note",
"Fenced caption body.",
False,
),
("photo", "A plain description.", "photo", "A plain description.", False),
(
"waterfall",
'{"file": "resource/2026-01-01/waterfall.png", "description": "A tall waterfall."}',
"waterfall",
"A tall waterfall.",
True,
),
],
)
@pytest.mark.asyncio
async def test_auto_image_plain_outputs_create_clean_notes(
stem,
plain_text,
note_name,
caption,
raw_json_must_be_absent,
auto_resource_env,
):
"""Fenced JSON, raw text, and description-only JSON remain valid plain fallbacks."""
env = auto_resource_env
source = env.write_binary(f"resource/2026-01-01/{stem}.png", _png_bytes())
response = await env.run(env.processor(_FakeVisionModel(plain_text)), [{"change": "added", "path": str(source)}])
assert response.success is True
content = (env.workspace / f"daily/2026-01-01/{note_name}.md").read_text(encoding="utf-8")
assert caption in content
if raw_json_must_be_absent:
assert '{"file"' not in content
assert '"description"' not in content
@pytest.mark.asyncio
async def test_auto_image_downscales_oversized_image_for_request_only(auto_resource_env):
"""Images beyond the request budget are downscaled in the request; storage is untouched."""
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/huge.png", _png_bytes(width=3000, height=3000))
stored_bytes = source.read_bytes()
model = _FakeVisionModel(_caption_json("huge-image", "Big", "A big image."))
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
assert response.success is True
data_block = model.calls[0][0].content[1]
assert data_block.source.media_type == "image/jpeg"
with Image.open(io.BytesIO(base64.b64decode(data_block.source.data))) as sent:
assert max(sent.size) <= 2048
assert source.read_bytes() == stored_bytes
assert (env.workspace / "daily/2026-01-01/huge-image.md").is_file()
@pytest.mark.asyncio
async def test_auto_image_uses_jpeg_decoder_downsampling_before_load(auto_resource_env):
"""Large JPEGs lower their decoder allocation before full pixel decode."""
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/large.jpg", _img_bytes("JPEG", (4096, 2048)))
stored_bytes = source.read_bytes()
model = _FakeVisionModel(_caption_json("large-jpeg", "Large", "A large JPEG."))
decoded_sizes = []
original_load = JpegImagePlugin.JpegImageFile.load
def record_decoder_size(image, *args, **kwargs):
decoded_sizes.append(image.size)
return original_load(image, *args, **kwargs)
with patch.object(JpegImagePlugin.JpegImageFile, "load", new=record_decoder_size):
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
assert response.success is True
assert decoded_sizes
assert decoded_sizes[0] == (2048, 1024)
data_block = model.calls[0][0].content[1]
with Image.open(io.BytesIO(base64.b64decode(data_block.source.data))) as sent:
assert max(sent.size) <= 2048
assert source.read_bytes() == stored_bytes
def test_image_preprocessing_resizes_16_bit_tiff_before_rgb_conversion():
"""Pillow 10-compatible scaling keeps large I;16 TIFF images memory-bounded."""
image = Image.new("I;16", (2049, 2), 1000)
buffer = io.BytesIO()
image.save(buffer, format="TIFF")
calls = []
original_thumbnail = Image.Image.thumbnail
def record_thumbnail(frame, size, resample, *args, **kwargs):
calls.append((frame.mode, resample))
return original_thumbnail(frame, size, resample, *args, **kwargs)
with patch.object(Image.Image, "thumbnail", new=record_thumbnail):
payload = _build_image_request_payload(buffer.getvalue(), ".tiff")
assert calls == [("I;16", Image.Resampling.NEAREST)]
assert payload["mime"] == "image/jpeg"
assert payload["source_mime"] == "image/tiff"
with Image.open(io.BytesIO(base64.b64decode(payload["data_b64"]))) as sent:
assert max(sent.size) <= 2048
@pytest.mark.parametrize(("mode", "transparent"), [("P", False), ("1", False), ("P", True)])
def test_image_preprocessing_retains_thin_strokes_and_palette_alpha(mode, transparent):
"""Filtered downscaling retains lines that nearest-neighbor sampling drops."""
image = Image.new(mode, (4096, 256), 1 if mode == "1" else 0)
if mode == "P":
image.putpalette([255, 255, 255, 0, 0, 0] + [0] * 762)
if transparent:
image.info["transparency"] = 0
image.paste(0 if mode == "1" else 1, (0, 0, 1, 256))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
image.close()
payload = _build_image_request_payload(buffer.getvalue(), ".png")
assert payload["source_mime"] == "image/png"
with Image.open(io.BytesIO(base64.b64decode(payload["data_b64"]))) as sent:
assert sent.size == (2048, 128)
if transparent:
assert payload["mime"] == "image/png"
assert sent.mode == "RGBA"
alpha_min, alpha_max = sent.getextrema()[3]
assert alpha_min == 0
assert 0 < alpha_max < 255
else:
assert sent.getextrema()[0][0] < 240
assert sent.getpixel((sent.width - 1, 0))[:3] == (255, 255, 255)
@pytest.mark.parametrize("mode", ["P", "1"])
@pytest.mark.parametrize("failing_method", ["thumbnail", "save"])
def test_image_preprocessing_closes_expanded_frames_on_failure(mode, failing_method):
"""Failure after mode expansion releases the temporary resize frame."""
image = Image.new(mode, (2049, 2))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
image.close()
converted_frames = []
original_convert = Image.Image.convert
def record_convert(frame, *args, **kwargs):
converted = original_convert(frame, *args, **kwargs)
if frame.mode == mode:
converted_frames.append(converted)
return converted
with (
patch.object(Image.Image, "convert", new=record_convert),
patch.object(Image.Image, failing_method, side_effect=OSError(f"{failing_method} failed")),
pytest.raises(RuntimeError, match="Failed to convert/resize image"),
):
_build_image_request_payload(buffer.getvalue(), ".png")
assert len(converted_frames) == 1
for frame in converted_frames:
with pytest.raises(ValueError, match="closed image"):
frame.getpixel((0, 0))
@pytest.mark.parametrize(
("source_size", "request_size"),
[((3000, 1000), (683, 2048)), ((4096, 1024), (512, 2048))],
ids=["resize-and-rotate", "decoder-downsample-and-rotate"],
)
@pytest.mark.asyncio
async def test_auto_image_applies_exif_orientation_before_resizing(source_size, request_size, auto_resource_env):
"""A rotated phone JPEG is normalized upright for the VLM without touching its source."""
env = auto_resource_env
image = Image.new("RGB", source_size, (255, 0, 0))
image.paste((0, 0, 255), (image.width // 2, 0, image.width, image.height))
exif = Image.Exif()
exif[274] = 6
buffer = io.BytesIO()
image.save(buffer, format="JPEG", exif=exif)
image.close()
source = env.write_binary("resource/2026-01-01/phone.jpg", buffer.getvalue())
stored_bytes = source.read_bytes()
model = _FakeVisionModel(_caption_json("upright-phone-photo", "Upright", "An upright phone photo."))
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
assert response.success is True
data_block = model.calls[0][0].content[1]
assert data_block.source.media_type == "image/jpeg"
with Image.open(io.BytesIO(base64.b64decode(data_block.source.data))) as sent:
assert sent.size == request_size
assert sent.getexif().get(274) is None
top = sent.getpixel((sent.width // 2, sent.height // 4))
bottom = sent.getpixel((sent.width // 2, 3 * sent.height // 4))
assert top[0] > 240 and top[2] < 15
assert bottom[2] > 240 and bottom[0] < 15
assert source.read_bytes() == stored_bytes
assert (env.workspace / "daily/2026-01-01/upright-phone-photo.md").is_file()
@pytest.mark.parametrize(
("image_format", "suffix", "source_mime", "request_mime"),
[
("JPEG", ".png", "image/jpeg", "image/jpeg"),
("PNG", ".jpg", "image/png", "image/png"),
("BMP", ".png", "image/bmp", "image/jpeg"),
],
)
@pytest.mark.asyncio
async def test_auto_image_uses_decoded_format_when_suffix_is_misleading(
image_format,
suffix,
source_mime,
request_mime,
auto_resource_env,
):
"""Request and note MIME values come from decoded bytes, with conversion when needed."""
env = auto_resource_env
source = env.write_binary(f"resource/2026-01-01/mislabeled{suffix}", _img_bytes(image_format))
stored_bytes = source.read_bytes()
model = _StructuredVisionModel(
content={"name": "actual-format", "description": "Decoded", "caption": "Decoded image content."},
)
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
assert response.success is True
data_block = model.structured_calls[0][0].content[1]
assert data_block.source.media_type == request_mime
with Image.open(io.BytesIO(base64.b64decode(data_block.source.data))) as sent:
assert sent.get_format_mimetype() == request_mime
note = frontmatter.load(env.workspace / "daily/2026-01-01/actual-format.md")
assert note.metadata["media_type"] == source_mime
assert source.read_bytes() == stored_bytes
@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"])
@pytest.mark.asyncio
async def test_auto_image_rejects_pixel_bomb_before_decode_and_isolates_batch(routed, auto_resource_env):
"""A small compressed file with unsafe dimensions fails without stopping the next image."""
env = auto_resource_env
unsafe = env.write_binary(
"resource/2026-01-01/pixel-bomb.png",
_png_bytes_with_header_size(8000, 6000),
)
safe = env.write_binary("resource/2026-01-01/safe.png", _png_bytes())
model = _FakeVisionModel(_caption_json("safe-image", "Safe", "A safe image."))
response = await env.run(
env.processor(model, routed=routed),
[
{"change": "added", "path": str(unsafe)},
{"change": "added", "path": str(safe)},
],
)
results = response.metadata["results"]
assert response.success is False
assert [item["success"] for item in results] == [False, True]
assert results[0]["metadata"]["action"] == "failed"
assert results[0]["metadata"]["modified"] is False
assert f"48000000 > {DEFAULT_MAX_IMAGE_PIXELS}" in results[0]["metadata"]["error"]
assert len(model.calls) == 1
assert not (env.workspace / "daily/2026-01-01/pixel-bomb.md").exists()
assert (env.workspace / "daily/2026-01-01/safe-image.md").is_file()
def test_image_pixel_limit_is_checked_before_full_decode():
"""The explicit pixel budget is enforced from image headers before ``load``."""
with patch("PIL.PngImagePlugin.PngImageFile.load", side_effect=AssertionError("must not decode")) as image_load:
with pytest.raises(RuntimeError, match=r"8x8=64 > 63"):
_normalize_image_bytes(_png_bytes(), ".png", max_image_pixels=63)
image_load.assert_not_called()
def test_image_decompression_bomb_warning_becomes_an_error(monkeypatch):
"""Pillow's warning-only bomb threshold becomes a reportable processor error."""
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 32)
with pytest.raises(RuntimeError, match="decompression-bomb protection"):
_build_image_request_payload(_png_bytes(), ".png")
@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"])
@pytest.mark.asyncio
async def test_decompression_bomb_warning_is_isolated_per_change(routed, auto_resource_env, monkeypatch):
"""A Pillow bomb warning fails one resource while a safe batch peer still completes."""
env = auto_resource_env
warned = env.write_binary("resource/2026-01-01/warned.png", _png_bytes())
safe = env.write_binary("resource/2026-01-01/safe.png", _png_bytes(width=4, height=4))
model = _FakeVisionModel(_caption_json("safe-image", "Safe", "A safe image."))
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 32)
response = await env.run(
env.processor(model, routed=routed),
[
{"change": "added", "path": str(warned)},
{"change": "added", "path": str(safe)},
],
)
results = response.metadata["results"]
assert response.success is False
assert [item["success"] for item in results] == [False, True]
assert "decompression-bomb protection" in results[0]["metadata"]["error"]
assert len(model.calls) == 1
assert not (env.workspace / "daily/2026-01-01/warned.md").exists()
assert (env.workspace / "daily/2026-01-01/safe-image.md").is_file()
def test_auto_image_pixel_limit_cannot_be_raised_by_runtime_context():
"""Request-scoped kwargs cannot relax the processor's deployment safety cap."""
step = AutoImageResourceStep(max_image_pixels=63)
step.context = RuntimeContext(max_image_pixels=DEFAULT_MAX_IMAGE_PIXELS)
assert step._max_image_pixels() == 63
@pytest.mark.asyncio
async def test_auto_image_skips_oversized_file(auto_resource_env):
"""Files beyond max_image_bytes are skipped without a VLM call."""
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/img.png", _png_bytes())
model = _FakeVisionModel(_caption_json("x", "y", "z"))
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}], max_image_bytes=8)
result = response.metadata["results"][0]
assert response.success is True
assert result["metadata"]["reason"] == "file_too_large"
assert result["metadata"]["oversized"] is True
assert not model.calls
assert not (env.workspace / "daily/2026-01-01/img.md").exists()
@pytest.mark.asyncio
async def test_auto_image_skips_without_vision_model(auto_resource_env):
"""Without any resolvable vision model the change is skipped with a reason."""
env = auto_resource_env
env.app_context.components = {}
source = env.write_binary("resource/2026-01-01/img.png", _png_bytes())
step = AutoImageResourceStep(app_context=env.app_context, file_store=env.file_store)
response = await env.run(step, [{"change": "added", "path": str(source)}])
result = response.metadata["results"][0]
assert response.success is True
assert result["metadata"]["reason"] == "vision_model_not_configured"
assert not (env.workspace / "daily/2026-01-01/img.md").exists()
@pytest.mark.asyncio
async def test_auto_resource_router_preserves_mixed_result_order_and_emits_one_hook(auto_resource_env):
"""The unified router sends each suffix to one processor and aggregates once."""
env = auto_resource_env
env.app_context.registry = R.copy()
env.app_context.registry.add("fake_audio_resource_step", _FakeAudioResourceStep, owner=__name__)
image = env.write_binary("resource/2026-01-01/img.png", _png_bytes())
text = env.workspace / "resource/2026-01-01/note.txt"
text.write_text("hello", encoding="utf-8")
wrapper = _FakeAgentWrapper()
model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square."))
hook_calls = []
async def hook(**kwargs):
hook_calls.append(kwargs)
env.app_context.metadata = {"qwenpaw_memory_result_hook": hook}
changes = [
{"change": "added", "path": str(image)},
{"change": "added", "path": str(env.workspace / "resource/2026-01-01/clip.wav")},
{"change": "added", "path": str(text)},
]
step = AutoResourceStep(
app_context=env.app_context,
file_store=env.file_store,
agent_wrapper=wrapper,
as_llm=model,
dispatch_steps=["auto_image_resource_step", "fake_audio_resource_step", "auto_text_resource_step"],
)
context = RuntimeContext(changes=changes)
response = await step(context)
assert response.success is True
assert [item["path"] for item in response.metadata["results"]] == [
"resource/2026-01-01/img.png",
"resource/2026-01-01/clip.wav",
"resource/2026-01-01/note.txt",
]
assert len(model.calls) == 1
assert "hello" in wrapper.inputs
assert context.get("changes") == changes
assert len(hook_calls) == 1
assert hook_calls[0]["kwargs"] == {"changes": changes}
assert len(hook_calls[0]["metadata"]["results"]) == 3
@pytest.mark.asyncio
async def test_auto_resource_router_isolates_text_exception_and_preserves_image_result(auto_resource_env):
"""One text exception cannot discard an earlier image write or stop later resources."""
env = auto_resource_env
env.app_context.registry = R.copy()
image = env.write_binary("resource/2026-01-01/img.png", _png_bytes())
first_text = env.workspace / "resource/2026-01-01/first.txt"
second_text = env.workspace / "resource/2026-01-01/second.txt"
first_text.write_text("first", encoding="utf-8")
second_text.write_text("second", encoding="utf-8")
model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square."))
wrapper = _FlakyAgentWrapper()
hook_calls = []
async def hook(**kwargs):
hook_calls.append(kwargs)
env.app_context.metadata = {"qwenpaw_memory_result_hook": hook}
changes = [
{"change": "added", "path": str(first_text)},
{"change": "added", "path": str(image)},
{"change": "added", "path": str(second_text)},
]
context = RuntimeContext(changes=changes)
step = AutoResourceStep(
app_context=env.app_context,
dispatch_steps=[
{"backend": "auto_image_resource_step", "file_store": env.file_store, "as_llm": model},
{
"backend": "auto_text_resource_step",
"file_store": env.file_store,
"agent_wrapper": wrapper,
},
],
)
response = await step(context)
results = response.metadata["results"]
assert response.success is False
assert response.metadata["processed"] == 3
assert response.metadata["modified"] is True
assert [item["path"] for item in results] == [
"resource/2026-01-01/first.txt",
"resource/2026-01-01/img.png",
"resource/2026-01-01/second.txt",
]
assert [item["success"] for item in results] == [False, True, True]
assert results[0]["metadata"] == {
"path": "resource/2026-01-01/first.txt",
"modified": False,
"action": "failed",
"error": "text provider unavailable",
}
assert results[1]["metadata"]["modified"] is True
assert "error" not in results[2]["metadata"]
assert wrapper.calls == 2
assert (env.workspace / "daily/2026-01-01/red-square.md").is_file()
assert context.get("changes") == changes
assert len(hook_calls) == 1
assert hook_calls[0]["metadata"]["results"] == results
def test_auto_resource_router_inherits_declared_options_with_child_override():
"""Each processor selects inherited router options; explicit child values win."""
file_store = object()
agent_wrapper = object()
vision_model = object()
prompt_dict = {"system_prompt": "legacy text prompt"}
step = AutoResourceStep(
file_store=file_store,
agent_wrapper=agent_wrapper,
as_llm=vision_model,
language="zh",
prompt_dict=prompt_dict,
max_file_bytes=4,
max_image_bytes=8,
max_image_pixels=64,
dispatch_steps=[
{"backend": "auto_image_resource_step", "max_image_bytes": 32, "max_image_pixels": 128},
{"backend": "auto_text_resource_step", "max_file_bytes": 16},
],
)
specs = {spec["backend"]: spec for spec, _, _ in step._processor_routes()}
assert specs["auto_text_resource_step"] == {
"backend": "auto_text_resource_step",
"file_store": file_store,
"agent_wrapper": agent_wrapper,
"language": "zh",
"prompt_dict": prompt_dict,
"max_file_bytes": 16,
}
assert specs["auto_image_resource_step"] == {
"backend": "auto_image_resource_step",
"file_store": file_store,
"as_llm": vision_model,
"language": "zh",
"max_image_bytes": 32,
"max_image_pixels": 128,
}
inherited = AutoResourceStep(
max_image_pixels=64,
dispatch_steps=["auto_image_resource_step", "auto_text_resource_step"],
)
inherited_specs = {spec["backend"]: spec for spec, _, _ in inherited._processor_routes()}
assert inherited_specs["auto_image_resource_step"]["max_image_pixels"] == 64
@pytest.mark.asyncio
async def test_auto_resource_router_accepts_a_registered_third_modality_without_code_changes(auto_resource_env):
"""A new processor only needs registration, a matcher, and dispatch configuration."""
env = auto_resource_env
env.app_context.registry = R.copy()
env.app_context.registry.add("fake_audio_resource_step", _FakeAudioResourceStep, owner=__name__)
step = AutoResourceStep(
app_context=env.app_context,
dispatch_steps=["fake_audio_resource_step", "auto_text_resource_step"],
)
response = await env.run(
step,
[{"change": "added", "path": str(env.workspace / "resource/2026-01-01/clip.WAV")}],
)
assert response.success is True
assert response.metadata["results"][0]["metadata"]["processor"] == "audio"
assert response.metadata["results"][0]["path"] == "resource/2026-01-01/clip.WAV"
unsupported = AutoResourceStep(app_context=env.app_context, dispatch_steps=["fake_audio_resource_step"])
response = await env.run(unsupported, [{"change": "added", "path": "resource/2026-01-01/archive.bin"}])
assert response.success is False
assert response.metadata["results"][0]["metadata"]["reason"] == "unsupported_resource"
def test_auto_resource_router_requires_the_fallback_processor_to_be_last():
"""Processor ordering stays deterministic and first-match routing remains extensible."""
step = AutoResourceStep(
dispatch_steps=["auto_text_resource_step", "auto_image_resource_step"],
)
with pytest.raises(ValueError, match="fallback processor must be last"):
step._processor_routes()
def test_auto_resource_router_discovers_unique_fallback_for_legacy_config():
"""An old Step spec without dispatch_steps keeps its text-resource behavior."""
app_ctx = _make_app_context(Path.cwd())
app_ctx.registry = R.copy()
step = AutoResourceStep(app_context=app_ctx)
routes = step._processor_routes()
assert [(spec, step_cls) for spec, step_cls, _ in routes] == [
({"backend": "auto_text_resource_step"}, AutoTextResourceStep),
]
@pytest.mark.asyncio
async def test_auto_resource_legacy_job_config_dispatches_text_resource(auto_resource_env):
"""A real BaseJob accepts the pre-router auto_resource Step configuration."""
env = auto_resource_env
env.app_context.registry = R.copy()
wrapper = _FakeAgentWrapper()
job = BaseJob(
name="legacy_auto_resource",
app_context=env.app_context,
steps=[
{
"backend": "auto_resource_step",
"file_store": env.file_store,
"agent_wrapper": wrapper,
},
],
)
await job.start()
try:
source = env.workspace / "resource/2026-01-01/legacy.txt"
source.parent.mkdir(parents=True, exist_ok=True)
source.write_text("legacy text resource", encoding="utf-8")
response = await job(changes=[{"change": "added", "path": str(source)}])
assert response.success is True
assert response.metadata["processed"] == 1
assert response.metadata["results"][0]["success"] is True
assert "legacy text resource" in wrapper.inputs
finally:
await job.close()
def test_auto_resource_router_rejects_ambiguous_registered_fallbacks():
"""Legacy discovery fails closed when plugins register multiple fallbacks."""
app_ctx = _make_app_context(Path.cwd())
app_ctx.registry = R.copy()
app_ctx.registry.add("second_text_fallback", AutoTextResourceStep, owner=__name__)
step = AutoResourceStep(app_context=app_ctx)
with pytest.raises(RuntimeError, match="exactly one registered fallback resource processor"):
step._processor_routes()
explicit = AutoResourceStep(app_context=app_ctx, dispatch_steps=["auto_text_resource_step"])
assert len(explicit._processor_routes()) == 1
def test_auto_resource_router_rejects_missing_registered_fallback():
"""Legacy discovery fails clearly when no fallback processor is installed."""
app_ctx = _make_app_context(Path.cwd())
app_ctx.registry = ComponentRegistry()
step = AutoResourceStep(app_context=app_ctx)
with pytest.raises(RuntimeError, match=r"fallback resource processor; found: none"):
step._processor_routes()
def test_resource_processors_have_canonical_registrations_and_isolated_prompts():
"""Each modality owns one backend and loads only its module-local prompts."""
assert R.get(ComponentEnum.STEP, "auto_resource_step") is AutoResourceStep
assert R.get(ComponentEnum.STEP, "auto_text_resource_step") is AutoTextResourceStep
assert R.get(ComponentEnum.STEP, "auto_image_resource_step") is AutoImageResourceStep
assert R.get(ComponentEnum.STEP, "auto_image_step") is None
text_step = AutoTextResourceStep()
image_step = AutoImageResourceStep()
assert text_step.prompt.has_prompt("system_prompt")
assert text_step.prompt.has_prompt("user_message_create")
assert not text_step.prompt.has_prompt("user_message")
assert image_step.prompt.has_prompt("user_message")
assert not image_step.prompt.has_prompt("system_prompt")
assert not image_step.prompt.has_prompt("user_message_create")
def test_auto_image_named_model_uses_standard_ref_resolution():
"""A configured ``as_llm`` component name is honored instead of ignored."""
app_ctx = _make_app_context(Path.cwd())
named = _FakeVisionModel("named")
vision = _FakeVisionModel("vision")
default = _FakeVisionModel("default")
app_ctx.components = {
ComponentEnum.AS_LLM: {
"my_vlm": SimpleNamespace(model=named),
"vision": SimpleNamespace(model=vision),
"default": SimpleNamespace(model=default),
},
}
step = AutoImageResourceStep(app_context=app_ctx, as_llm="my_vlm")
step.context = RuntimeContext()
assert step._vision_model() is named
implicit = AutoImageResourceStep(app_context=app_ctx)
implicit.context = RuntimeContext()
assert implicit._vision_model() is vision
missing = AutoImageResourceStep(app_context=app_ctx, as_llm="missing_vlm")
missing.context = RuntimeContext()
with pytest.raises(KeyError, match="missing_vlm"):
missing._vision_model()
@pytest.mark.parametrize(
("blocked_module", "payload", "suffix", "error_pattern"),
[
("PIL", _png_bytes(), ".png", r"Pillow.*reme-ai\[core\]"),
(
"pillow_heif",
b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1heic",
".heic",
r"pillow-heif.*reme-ai\[image-heif\]",
),
],
)
def test_image_preprocessing_reports_dependency_errors(blocked_module, payload, suffix, error_pattern):
"""Lazy image dependencies produce actionable installation errors."""
real_import = __import__
def import_without_dependency(name, *args, **kwargs):
if name == blocked_module or (blocked_module == "PIL" and name.startswith("PIL.")):
raise ImportError(f"blocked {blocked_module}")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=import_without_dependency):
with pytest.raises(RuntimeError, match=error_pattern):
_normalize_image_bytes(payload, suffix)
def test_misleading_heic_suffix_does_not_load_optional_dependency():
"""A core image named ``.heic`` is decoded by content without loading pillow-heif."""
real_import = __import__
def import_without_heif(name, *args, **kwargs):
if name == "pillow_heif":
raise AssertionError("pillow-heif must not be loaded for PNG bytes")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=import_without_heif):
payload = _build_image_request_payload(_png_bytes(), ".heic")
assert payload["mime"] == "image/png"
assert payload["source_mime"] == "image/png"
assert payload["converted"] is False
@pytest.mark.parametrize(
("payload", "suffix", "failing_method", "error_pattern"),
[
(b"not an image", ".png", None, "Failed to decode image"),
(_png_bytes(width=2049, height=1), ".png", "thumbnail", "Failed to convert/resize image"),
(_img_bytes("BMP"), ".bmp", "save", "Failed to convert/resize image"),
],
ids=["decode", "resize", "conversion"],
)
def test_image_preprocessing_reports_data_errors(payload, suffix, failing_method, error_pattern):
"""Decode, resize, and conversion failures remain explicit."""
if failing_method is None:
with pytest.raises(RuntimeError, match=error_pattern):
_build_image_request_payload(payload, suffix)
return
with patch(
f"PIL.Image.Image.{failing_method}",
side_effect=OSError(f"{failing_method} failed"),
) as failing_call:
with pytest.raises(RuntimeError, match=error_pattern):
_normalize_image_bytes(payload, suffix)
failing_call.assert_called_once()
def test_auto_image_module_imports_without_pillow():
"""Importing the registered image Step does not eagerly require Pillow."""
root = Path(__file__).resolve().parents[2]
script = """
import builtins
real_import = builtins.__import__
def import_without_pillow(name, *args, **kwargs):
if name == "PIL" or name.startswith("PIL."):
raise ImportError("Pillow unavailable")
return real_import(name, *args, **kwargs)
builtins.__import__ = import_without_pillow
import reme.steps.evolve.auto_image_resource
"""
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=root,
check=False,
capture_output=True,
text=True,
)
assert completed.returncode == 0, completed.stderr
@pytest.mark.asyncio
async def test_auto_image_prompt_treats_filename_as_a_weak_hint(auto_resource_env):
"""The VLM prompt separates filename hints from visible image evidence."""
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/cat-at-beach.png", _png_bytes())
model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square."))
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
assert response.success is True
prompt = model.calls[0][0].content[0].text
assert "Filename: cat-at-beach.png" in prompt
assert "Filename stem: cat-at-beach" in prompt
assert "weak hints" in prompt
assert "trust the visible image content" in prompt
@pytest.mark.asyncio
async def test_auto_image_reports_modified_when_index_refresh_fails_after_write(auto_resource_env):
"""A post-write failure keeps the actual on-disk modification state."""
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/img.png", _png_bytes())
model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square."))
async def fail_refresh(*_args, **_kwargs):
raise RuntimeError("index refresh failed")
with patch("reme.steps.evolve.base_auto_resource.refresh_day_index", new=fail_refresh):
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
result = response.metadata["results"][0]
assert response.success is False
assert result["metadata"]["action"] == "failed"
assert result["metadata"]["modified"] is True
assert "index refresh failed" in result["metadata"]["error"]
assert (env.workspace / "daily/2026-01-01/red-square.md").is_file()
def test_default_resource_watcher_dispatches_only_the_unified_router():
"""Both init and live resource producers call one auto-resource router."""
root = Path(__file__).resolve().parents[2]
config = yaml.safe_load((root / "reme" / "config" / "default.yaml").read_text(encoding="utf-8"))
steps = config["jobs"]["resource_watch_loop"]["steps"]
for producer in steps:
backends = [item["backend"] for item in producer["dispatch_steps"]]
assert backends == ["update_catalog_step", "auto_resource_step"]
router = producer["dispatch_steps"][1]
assert router["dispatch_steps"] == ["auto_image_resource_step", "auto_text_resource_step"]
auto_resource = config["jobs"]["auto_resource"]["steps"][0]
assert auto_resource["backend"] == "auto_resource_step"
assert auto_resource["dispatch_steps"] == ["auto_image_resource_step", "auto_text_resource_step"]
assert "auto_image" not in config["jobs"]
def test_image_dependencies_keep_heif_support_optional():
"""Pillow is core, while pillow-heif stays isolated in its opt-in extra."""
root = Path(__file__).resolve().parents[2]
project = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))["project"]
base = [item.lower() for item in project["dependencies"]]
optional = project["optional-dependencies"]
assert not any(item.startswith("pillow") for item in base)
assert any(item.lower().startswith("pillow>") for item in optional["core"])
assert not any(item.lower().startswith("pillow-heif") for item in optional["core"])
assert any(item.lower().startswith("pillow-heif") for item in optional["image-heif"])
assert "reme-ai[image-heif]" in optional["full"]
@pytest.mark.parametrize("failure_stage", ["model", "decode"])
@pytest.mark.asyncio
async def test_auto_image_failure_is_isolated_per_change(failure_stage, auto_resource_env):
"""Model and decode failures do not block the next image in a batch."""
env = auto_resource_env
if failure_stage == "model":
first_data = _png_bytes()
model = _FlakyVisionModel(_caption_json("good-image", "Good", "A valid image."))
expected_error = "vision backend unavailable"
else:
first_data = b"not an image"
model = _FakeVisionModel(_caption_json("good-image", "Good", "A valid image."))
expected_error = "Failed to decode image"
first = env.write_binary("resource/2026-01-01/first.png", first_data)
second = env.write_binary("resource/2026-01-01/second.png", _png_bytes(color=(20, 90, 200)))
response = await env.run(
env.processor(model),
[
{"change": "added", "path": str(first)},
{"change": "added", "path": str(second)},
],
)
results = response.metadata["results"]
assert response.success is False
assert results[0]["success"] is False
assert results[0]["metadata"]["action"] == "failed"
assert expected_error in results[0]["metadata"]["error"]
assert results[1]["success"] is True
assert (env.workspace / "daily/2026-01-01/good-image.md").is_file()
assert first.exists() and second.exists()
@pytest.mark.asyncio
async def test_auto_image_uniquifies_conflicting_note_name(auto_resource_env):
"""A name collision with an unrelated note falls back to the sha1-suffixed path."""
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/img.png", _png_bytes())
env.write_note("daily/2026-01-01/red-square.md", "[[resource/2026-01-01/other.png]]")
model = _FakeVisionModel(_caption_json("red-square", "A red square", "An 8x8 solid red square."))
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
assert response.success is True
suffix = hashlib.sha1(b"resource/2026-01-01/img.png").hexdigest()[:8]
assert (env.workspace / f"daily/2026-01-01/red-square--{suffix}.md").is_file()
@pytest.mark.parametrize(
("text", "expected"),
[
(
'{"description": "Waterfall in Iceland."}',
{"name": "", "description": "Waterfall in Iceland.", "caption": "Waterfall in Iceland."},
),
('{"caption": "A red square."}', {"name": "", "description": "", "caption": "A red square."}),
(
'```json\n{"name": "n", "description": "d", "caption": "c"}\n```',
{"name": "n", "description": "d", "caption": "c"},
),
(
"A plain description without json.",
{"name": "", "description": "", "caption": "A plain description without json."},
),
('{"foo": 1}', {"name": "", "description": "", "caption": ""}),
("```json\n\n```", {"name": "", "description": "", "caption": ""}),
],
)
def test_parse_caption_json(text, expected):
"""Plain fallback parsing normalizes useful fields without leaking unusable JSON."""
assert _parse_caption_json(text) == expected
@pytest.mark.parametrize(
("structured_mode", "note_name", "caption", "expected_plain_calls"),
[
("success", "red-square", "An 8x8 red square.", 0),
("error", "plain-note", "Plain-call caption.", 1),
("empty", "empty-note", "Recovered by plain call.", 1),
],
)
@pytest.mark.asyncio
async def test_auto_image_structured_output_and_plain_retry(
structured_mode,
note_name,
caption,
expected_plain_calls,
auto_resource_env,
):
"""Structured success stays primary; structured errors and empties retry plain once."""
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/img.png", _png_bytes())
if structured_mode == "success":
model = _StructuredVisionModel(
content={"name": note_name, "description": "A red square.", "caption": caption},
)
elif structured_mode == "error":
model = _StructuredVisionModel(
error=RuntimeError("provider rejects tool_choice"),
plain_text=_caption_json(note_name, "Plain", caption),
)
else:
model = _StructuredVisionModel(
content={"name": "", "description": "", "caption": ""},
plain_text=_caption_json(note_name, "Empty", caption),
)
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
assert response.success is True
assert len(model.structured_calls) == 1
assert len(model.plain_calls) == expected_plain_calls
content = (env.workspace / f"daily/2026-01-01/{note_name}.md").read_text(encoding="utf-8")
assert caption in content
@pytest.mark.asyncio
async def test_auto_image_converts_heic_request_when_extra_is_installed(auto_resource_env):
"""HEIC conversion is covered independently when the image-heif extra exists."""
pytest.importorskip("pillow_heif")
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/phone.heic", _img_bytes("HEIF"))
model = _StructuredVisionModel(content={"name": "", "description": "d", "caption": "converted caption"})
response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}])
assert response.success is True
assert model.structured_calls[0][0].content[1].source.media_type in {"image/png", "image/jpeg"}
note = frontmatter.loads((env.workspace / "daily/2026-01-01/phone.md").read_text(encoding="utf-8"))
assert note.metadata["media_type"] == "image/heic"
assert "converted caption" in note.content