Spaces:
Sleeping
Sleeping
Claude commited on
Add MiniCPM-V 4.6 vision call (screenshot -> observation)
Browse filesImplement 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
- buglens/prompts.py +44 -1
- buglens/vision.py +89 -1
- pyproject.toml +2 -1
- tests/test_vision.py +70 -0
buglens/prompts.py
CHANGED
|
@@ -1,2 +1,45 @@
|
|
| 1 |
-
"""
|
| 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 |
-
"""
|
| 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]
|