Claude commited on
Commit
00a4ce4
·
unverified ·
1 Parent(s): 3be5539

Add MiniCPM-V 4.6 vision call (screenshot -> observation)

Browse files

Implement Call 1 of the pipeline (docs/plans/04):

- prompts.py: VISION_PROMPT (perception only; bans guessing browser/OS/device/
role/environment/backend) and vision_user_text() to append the tester note.
- vision.py: build_vision_messages() (PIL image or URL), observe() running
apply_chat_template + generate + decode with downsample_mode="16x" and
max_slice_nums=36, and load_model() that imports transformers lazily so the
module stays importable on the CPU frontend (the model runs on Modal).
- tests: prompt composition, message shape, and observe() orchestration via
fakes (no model download). Verified the module imports without transformers.

https://claude.ai/code/session_01JE4YVLBWnk188Yu5sib59y

Files changed (4) hide show
  1. buglens/prompts.py +44 -1
  2. buglens/vision.py +89 -1
  3. pyproject.toml +2 -1
  4. tests/test_vision.py +70 -0
buglens/prompts.py CHANGED
@@ -1,2 +1,45 @@
1
- """Prompt templates for screenshot observation and report structuring."""
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Versioned prompts for the BugLens model pipeline.
2
 
3
+ Kept in one place so prompt changes are reviewable and the vision/structuring
4
+ calls never drift from the behavior described in docs/plans/04.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ # Call 1 — perception only. The model must describe what is visible and must
10
+ # never guess context it cannot see. This honesty is the whole product.
11
+ VISION_PROMPT = """You are a QA assistant inspecting a UI screenshot.
12
+
13
+ Describe only what is visible in the screenshot and what the tester note explicitly says.
14
+
15
+ Include:
16
+ - screen or page type if visible
17
+ - visible buttons and whether they appear enabled, disabled, loading, or selected
18
+ - visible error text, empty states, warnings, labels, amounts, or table states
19
+ - visible layout issues, overlap, truncation, missing content, or broken states
20
+ - the tester's note as user-provided context
21
+
22
+ Do not infer causes.
23
+ Do not guess browser, OS, device, user role, environment, backend state, API status, account status, or network state.
24
+ If something is ambiguous, say it is ambiguous.
25
+
26
+ Respond using exactly these sections:
27
+ Visible facts:
28
+ - ...
29
+
30
+ Tester note:
31
+ - ...
32
+
33
+ Ambiguities:
34
+ - ...
35
+
36
+ Not visible:
37
+ - ..."""
38
+
39
+
40
+ def vision_user_text(note: str | None) -> str:
41
+ """Compose the vision instructions plus the tester note (or a clear absence)."""
42
+
43
+ note = (note or "").strip()
44
+ suffix = note if note else "(none provided)"
45
+ return f"{VISION_PROMPT}\n\nTester note: {suffix}"
buglens/vision.py CHANGED
@@ -1,2 +1,90 @@
1
- """Screenshot observation model call."""
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Call 1 of the pipeline: screenshot -> factual UI observation.
2
 
3
+ `transformers` is imported lazily inside `load_model`, so this module stays
4
+ importable on the lightweight Gradio frontend (which has no ML deps); the model
5
+ itself is loaded and run on the Modal GPU backend. `observe()` takes the model
6
+ and processor as arguments so the orchestration is testable without downloading
7
+ the ~1.3B model.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from buglens.config import DEFAULT_MODEL_ID
15
+ from buglens.prompts import vision_user_text
16
+
17
+ DEFAULT_DOWNSAMPLE = "16x" # "4x" only for tiny OCR-heavy text (slower)
18
+ DEFAULT_MAX_NEW_TOKENS = 512
19
+ DEFAULT_MAX_SLICE_NUMS = 36 # handles high-resolution screenshots
20
+
21
+
22
+ def build_vision_messages(image: Any, note: str | None = None) -> list[dict]:
23
+ """Build the chat messages for the vision call.
24
+
25
+ `image` may be a PIL.Image (an uploaded screenshot) or a URL/path string.
26
+ """
27
+
28
+ if isinstance(image, str):
29
+ image_part = {"type": "image", "url": image}
30
+ else:
31
+ image_part = {"type": "image", "image": image}
32
+ return [
33
+ {
34
+ "role": "user",
35
+ "content": [image_part, {"type": "text", "text": vision_user_text(note)}],
36
+ }
37
+ ]
38
+
39
+
40
+ def observe(
41
+ model: Any,
42
+ processor: Any,
43
+ image: Any,
44
+ note: str | None = None,
45
+ *,
46
+ downsample_mode: str = DEFAULT_DOWNSAMPLE,
47
+ max_new_tokens: int = DEFAULT_MAX_NEW_TOKENS,
48
+ max_slice_nums: int = DEFAULT_MAX_SLICE_NUMS,
49
+ ) -> str:
50
+ """Run MiniCPM-V 4.6 to produce a factual observation of the screenshot.
51
+
52
+ `downsample_mode` must be passed to both `apply_chat_template` and `generate`
53
+ per the model card.
54
+ """
55
+
56
+ messages = build_vision_messages(image, note)
57
+ inputs = processor.apply_chat_template(
58
+ messages,
59
+ tokenize=True,
60
+ add_generation_prompt=True,
61
+ return_dict=True,
62
+ return_tensors="pt",
63
+ downsample_mode=downsample_mode,
64
+ max_slice_nums=max_slice_nums,
65
+ ).to(model.device)
66
+ generated = model.generate(
67
+ **inputs,
68
+ downsample_mode=downsample_mode,
69
+ max_new_tokens=max_new_tokens,
70
+ )
71
+ prompt_len = inputs["input_ids"].shape[-1]
72
+ text = processor.decode(generated[0][prompt_len:], skip_special_tokens=True)
73
+ return text.strip()
74
+
75
+
76
+ def load_model(model_id: str = DEFAULT_MODEL_ID):
77
+ """Load MiniCPM-V 4.6 and its processor (imports transformers lazily).
78
+
79
+ Install with ``transformers[torch]>=5.7.0 torchvision av`` — ``av`` instead
80
+ of ``torchcodec`` avoids the CUDA-version conflict noted on the model card.
81
+ Called by the Modal GPU backend, not by the CPU frontend.
82
+ """
83
+
84
+ from transformers import AutoModelForImageTextToText, AutoProcessor # noqa: PLC0415
85
+
86
+ processor = AutoProcessor.from_pretrained(model_id)
87
+ model = AutoModelForImageTextToText.from_pretrained(
88
+ model_id, torch_dtype="auto", device_map="auto"
89
+ )
90
+ return model, processor
pyproject.toml CHANGED
@@ -36,5 +36,6 @@ target-version = "py311"
36
  select = ["E", "F", "I", "UP", "B"]
37
 
38
  [tool.ruff.lint.per-file-ignores]
39
- # Verbatim example copy reads better unwrapped than split across lines.
40
  "buglens/examples.py" = ["E501"]
 
 
36
  select = ["E", "F", "I", "UP", "B"]
37
 
38
  [tool.ruff.lint.per-file-ignores]
39
+ # Verbatim example/prompt copy reads better unwrapped than split across lines.
40
  "buglens/examples.py" = ["E501"]
41
+ "buglens/prompts.py" = ["E501"]
tests/test_vision.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the vision call orchestration (no real model download)."""
2
+
3
+ from buglens import vision
4
+ from buglens.prompts import VISION_PROMPT, vision_user_text
5
+
6
+
7
+ def test_vision_user_text_includes_prompt_and_note() -> None:
8
+ text = vision_user_text("Pay button does nothing")
9
+ assert VISION_PROMPT in text
10
+ assert text.endswith("Tester note: Pay button does nothing")
11
+
12
+
13
+ def test_vision_user_text_handles_missing_note() -> None:
14
+ assert vision_user_text(None).endswith("Tester note: (none provided)")
15
+ assert vision_user_text(" ").endswith("Tester note: (none provided)")
16
+
17
+
18
+ def test_build_messages_url_vs_image() -> None:
19
+ url_msg = vision.build_vision_messages("https://x/y.png", "note")
20
+ assert url_msg[0]["content"][0] == {"type": "image", "url": "https://x/y.png"}
21
+
22
+ sentinel = object() # stands in for a PIL.Image
23
+ img_msg = vision.build_vision_messages(sentinel)
24
+ assert img_msg[0]["content"][0] == {"type": "image", "image": sentinel}
25
+ # The instruction text always bans guessing.
26
+ assert "Do not guess browser" in img_msg[0]["content"][1]["text"]
27
+
28
+
29
+ class _FakeTensor:
30
+ def __init__(self, length: int) -> None:
31
+ self.shape = (1, length)
32
+
33
+
34
+ class _FakeInputs(dict):
35
+ def to(self, _device):
36
+ return self
37
+
38
+
39
+ class _FakeProcessor:
40
+ def apply_chat_template(self, messages, **kwargs):
41
+ self.template_kwargs = kwargs
42
+ self.messages = messages
43
+ return _FakeInputs(input_ids=_FakeTensor(3))
44
+
45
+ def decode(self, ids, **kwargs):
46
+ self.decoded_ids = ids
47
+ return " Visible facts:\n- A disabled Pay button "
48
+
49
+
50
+ class _FakeModel:
51
+ device = "cpu"
52
+
53
+ def generate(self, **kwargs):
54
+ self.generate_kwargs = kwargs
55
+ return [[10, 11, 12, 13, 14, 15]]
56
+
57
+
58
+ def test_observe_passes_params_and_strips_output() -> None:
59
+ model, processor = _FakeModel(), _FakeProcessor()
60
+
61
+ out = vision.observe(model, processor, "shot.png", "note", downsample_mode="16x")
62
+
63
+ assert out == "Visible facts:\n- A disabled Pay button"
64
+ # downsample_mode + slice nums reach the template; downsample + tokens reach generate.
65
+ assert processor.template_kwargs["downsample_mode"] == "16x"
66
+ assert processor.template_kwargs["max_slice_nums"] == 36
67
+ assert model.generate_kwargs["downsample_mode"] == "16x"
68
+ assert model.generate_kwargs["max_new_tokens"] == 512
69
+ # Only the newly generated tokens (after the prompt length of 3) are decoded.
70
+ assert processor.decoded_ids == [13, 14, 15]