Files
cad-agent/tests/test_inspect.py
T
gjm b757892c67 feat(review): agent 侧虚拟工具 inspect_region —— 按可读分辨率放大指定区域
解决三个缺口:① 放大没复用标定/瓦片数学(LLM 一次 zoom 只出一张,宽区域仍读不清);
②「看清没」由 VLM 自评(易过度自信/幻觉);③ 只能按 handle / 手算 bbox 指认,
图上看到但没 handle 的东西指认不了。

核心思路:把「放大」升级成一个语义化工具——LLM 只指认「要看哪里」,由 agent 复用
tiling.py 的标定数学算出该切几张、每张覆盖多大,用构造保证可读。

新增 dwg_review/inspect.py(agent 侧,不编 DLL):
- TOOL_SCHEMA:inspect_region(target, purpose);target 三形态 {handle} / {window} /
  {label,cx,cy,span},purpose = text(8px) / line(6px)。
- InspectState:单图框内「截图标签 → 世界可见范围」登记表,支持逐级下钻
  (返回的 inspect-N 可继续作为 label 指认)。
- resolve_target / _point_box:label+cx+cy → WCS。cy 是图像坐标(上→下),
  世界 y 必须翻转(y = y2 - cy*H)——单测专门钉住。
- plan_tiles:复用 world_per_px + tiles_for_box + subdivide;超 INSPECT_MAX_TILES(4)
  则硬失败并给出「缩小 target / 分多次请求」的指引(不静默降级);拿不到比例/像素
  则退回单张并如实告知。

关键几何:CAD 的 _.ZOOM _W 是等比适配(不拉伸),故「截图上的归一化坐标」必须按
max(W/ew, H/eh) 反算真实可见范围(新增 tiling.visible_box)——直接用送进去的窗口会偏。
zoom_to_window 返回的 view 是「加过 margin 的窗口」,不是可见范围,不能混用。

证据层配套:evidence_model.make_image(..., window=) 记录每张图的可见世界范围;
capture_images 用 view_px(overall 图原始像素)+ visible_box 逐张登记。

子循环接入:_run_readonly_tool 在只读白名单校验之前拦截 inspect_region(它不在
READ_ONLY_TOOLS 里,内部只调白名单内的只读 MCP 工具);_review_tools 启用时用
inspect_region 取代三个裸视口工具;inspect_region 计入 _VIEW_TOOLS → 结束复位视口。
配置:INSPECT_ENABLED(True) / INSPECT_MAX_TILES(4)。

端到端实测(真实图纸 1 图框):取证与审查两阶段均在最大化态、结束精确还原(4 项 PASS);
LLM 的 11 次取证调用里 2 次是 inspect_region,且都显式给 window + purpose:"text"
(x[20000,115000] y[-6500,3000],即 A0 图框底部横条=标题栏所在)。
验收口径达成:报告报出标题栏乱码 ???????????????,而 get_text_content 对全图返回 0 条文字
(本图文字全在块/自定义实体内)→ 块内文字是被视觉放大读出来的。
pytest 343 passed。
2026-10-16 19:15:32 +08:00

349 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""`dwg_review.inspect` 单元测试(agent 侧虚拟工具 `inspect_region`)。"""
import base64
import io
import pytest
from dwg_review import config, inspect
from dwg_review.inspect import (
InspectState,
_point_box,
inspect_region,
plan_tiles,
readable_px,
resolve_target,
)
from dwg_review.phases.review import review_frame
from tests.fakes import (
FakeLlmClient,
FakeMcpClient,
frame,
llm_calls,
llm_text,
make_ctx,
make_evidence,
ok_image,
)
@pytest.fixture
def inspect_env(monkeypatch):
"""钉死 inspect 相关配置,使用例不受外部环境变量影响。"""
for name, value in {
"TEXT_HEIGHT_MM": 2.0,
"TEXT_READABLE_PX": 8,
"LINE_READABLE_PX": 6,
"INSPECT_MAX_TILES": 4,
"INSPECT_ENABLED": True,
"TILE_OVERLAP": 0.0,
"IMAGE_MAX_EDGE": 1280,
"RENDER_WAIT": 0.0,
}.items():
monkeypatch.setattr(config, name, value)
return config
def _png_b64(w=2000, h=1414):
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (w, h), "black").save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
def _evidence(images=None, entities=None, sheet_scale=100.0, sheet_size="A0"):
"""图框 (0,0)-(120000,40000)、1:100、A0 的 evidence(含 overall 截图)。"""
ev = make_evidence()
ev["frame"] = frame([0, 0, 120000, 40000], sheet_size=sheet_size, sheet_scale=sheet_scale)
ev["window"] = {"min_x": 0.0, "min_y": 0.0, "max_x": 120000.0, "max_y": 40000.0}
ev["entities"] = entities or {
"total": 1,
"items": [{"handle": "A1", "type": "LINE", "bbox": [0, 0, 1000, 500]}],
}
if images is None:
images = [
{
"b64": _png_b64(),
"mime": "image/png",
"label": "overall",
"window": (0.0, 0.0, 120000.0, 40000.0),
}
]
ev["images"] = images
return ev
def _client(b64=None):
return FakeMcpClient(
responses={"get_viewport_screenshot": ok_image(b64 or _png_b64())}
)
# ---------------------------------------------------------------------------
# InspectState
# ---------------------------------------------------------------------------
def test_inspect_state_registers_evidence_labels_with_window():
ev = _evidence(images=[
{"b64": "A", "mime": "image/png", "label": "overall", "window": (0, 0, 10, 10)},
{"b64": "B", "mime": "image/png", "label": "tile-1", "window": [0, 0, 5, 5]},
{"b64": "C", "mime": "image/png", "label": "tile-2"}, # 无 window → 不登记
])
state = InspectState(ev)
assert set(state.labels) == {"overall", "tile-1"}
assert state.labels["tile-1"] == (0.0, 0.0, 5.0, 5.0)
def test_inspect_state_register_assigns_incrementing_labels():
state = InspectState(_evidence())
assert state.register((1, 2, 3, 4)) == "inspect-1"
assert state.register((0, 0, 1, 1)) == "inspect-2"
assert state.labels["inspect-1"] == (1, 2, 3, 4)
# ---------------------------------------------------------------------------
# _point_box / resolve_target
# ---------------------------------------------------------------------------
def test_point_box_flips_y_because_cy_is_image_down():
base = (0.0, 0.0, 100.0, 50.0)
top = _point_box(base, 0.5, 0.0, 0.5) # 图像顶部
bottom = _point_box(base, 0.5, 1.0, 0.5) # 图像底部
assert (top[1] + top[3]) / 2 == pytest.approx(50.0) # → 世界 y 大的一侧
assert (bottom[1] + bottom[3]) / 2 == pytest.approx(0.0)
def test_point_box_centers_and_clamps_span():
base = (0.0, 0.0, 100.0, 50.0)
box = _point_box(base, 0.5, 0.5, 0.5)
assert box == pytest.approx((25.0, 12.5, 75.0, 37.5))
assert _point_box(base, 0.5, 0.5, 5.0) == pytest.approx(base) # span 上限 1
tiny = _point_box(base, 0.5, 0.5, 0.0)
assert (tiny[2] - tiny[0]) == pytest.approx(100.0 * 0.02) # span 下限 0.02
def test_resolve_target_by_handle_is_case_insensitive():
ev = _evidence()
box, note = resolve_target(ev, {"handle": "a1"}, InspectState(ev))
assert box == (0.0, 0.0, 1000.0, 500.0)
assert "a1" in note
def test_resolve_target_handle_not_found():
ev = _evidence()
box, note = resolve_target(ev, {"handle": "ZZ"}, InspectState(ev))
assert box is None
assert "ZZ" in note and "window" in note
def test_resolve_target_by_window():
ev = _evidence()
box, note = resolve_target(
ev, {"window": {"min_x": 1, "min_y": 2, "max_x": 3, "max_y": 4}}, InspectState(ev)
)
assert box == (1.0, 2.0, 3.0, 4.0)
assert note
def test_resolve_target_bad_window():
ev = _evidence()
box, note = resolve_target(ev, {"window": {"min_x": 1}}, InspectState(ev))
assert box is None
assert "window 非法" in note
def test_resolve_target_by_label_point():
ev = _evidence()
state = InspectState(ev)
box, note = resolve_target(
ev, {"label": "overall", "cx": 0.5, "cy": 0.5, "span": 0.5}, state
)
# overall 覆盖 (0,0)-(120000,40000) → 中心 (60000, 20000),跨度为半幅
assert box == pytest.approx((30000.0, 10000.0, 90000.0, 30000.0))
assert "overall" in note
def test_resolve_target_unknown_label_lists_known():
ev = _evidence()
box, note = resolve_target(ev, {"label": "tile-9"}, InspectState(ev))
assert box is None
assert "tile-9" in note and "overall" in note
def test_resolve_target_rejects_bad_inputs():
ev = _evidence()
state = InspectState(ev)
assert resolve_target(ev, "nope", state)[0] is None
assert resolve_target(ev, {}, state)[0] is None
assert resolve_target(ev, {"label": "overall", "cx": "x"}, state)[0] is None
# ---------------------------------------------------------------------------
# plan_tiles
# ---------------------------------------------------------------------------
def test_readable_px_by_purpose(inspect_env):
assert readable_px("text") == inspect_env.TEXT_READABLE_PX
assert readable_px("line") == inspect_env.LINE_READABLE_PX
def test_plan_tiles_single_tile_for_small_region(inspect_env):
wins, note = plan_tiles(
(0, 0, 1000, 500), 100.0, "text", (2000, 1414),
max_tiles=4, overlap=0.0, margin=0.02,
)
assert len(wins) == 1
assert "1×1" in note
# 目标框被外扩 5% 留边
assert wins[0]["min_x"] < 0 and wins[0]["max_x"] > 1000
def test_plan_tiles_splits_wide_region(inspect_env):
# 66000 世界单位宽(含 5% 留边)/ 单张 32000 → 3 张
wins, note = plan_tiles(
(0, 0, 60000, 20000), 100.0, "text", (2000, 1414),
max_tiles=9, overlap=0.0, margin=0.02,
)
assert len(wins) == 3
assert "3 张" in note
def test_plan_tiles_fatal_when_over_cap(inspect_env):
wins, note = plan_tiles(
(0, 0, 100000, 100000), 100.0, "text", (2000, 1414),
max_tiles=4, overlap=0.0, margin=0.02,
)
assert wins is None
assert "超过单次上限 4" in note
assert "span" in note # 给出缩小 target 的具体指引
def test_plan_tiles_falls_back_to_single_without_scale(inspect_env):
wins, note = plan_tiles(
(0, 0, 1000, 500), 0.0, "text", (2000, 1414),
max_tiles=4, overlap=0.0, margin=0.02,
)
assert len(wins) == 1
assert "退回单张" in note
def test_plan_tiles_degenerate_box_is_fatal(inspect_env):
wins, note = plan_tiles(
(0, 0, 0, 0), 100.0, "text", (2000, 1414),
max_tiles=4, overlap=0.0, margin=0.02,
)
assert wins is None
assert "退化" in note
# ---------------------------------------------------------------------------
# inspect_region(执行体)
# ---------------------------------------------------------------------------
def test_inspect_region_handle_success(inspect_env):
pytest.importorskip("PIL")
client = _client()
ctx = make_ctx(client=client)
ev = _evidence()
state = InspectState(ev)
content, budget = inspect_region(ctx, ev, {"target": {"handle": "A1"}}, [6], state)
assert budget[0] == 5 # 只用掉 1 张
assert "实体 A1" in content[0]["text"]
assert any(c.get("type") == "image_url" for c in content)
assert "zoom_to_window" in client.called_names()
assert "inspect-1" in state.labels
# 登记的可见范围应**包含**目标框(ZOOM W 等比适配会露出更多)
vis = state.labels["inspect-1"]
assert vis[0] <= 0.0 and vis[2] >= 1000.0
def test_inspect_region_label_target_reports_label_and_range(inspect_env):
pytest.importorskip("PIL")
ctx = make_ctx(client=_client())
ev = _evidence()
state = InspectState(ev)
content, budget = inspect_region(
ctx, ev, {"target": {"label": "overall", "cx": 0.5, "cy": 0.5, "span": 0.5}}, [6], state
)
assert budget[0] == 3 # 中心半幅需 3 张瓦片
assert "overall" in content[0]["text"]
assert any("可见世界范围" in c.get("text", "") for c in content)
def test_inspect_region_purpose_line_is_offered(inspect_env):
pytest.importorskip("PIL")
ctx = make_ctx(client=_client())
ev = _evidence()
content, _ = inspect_region(
ctx, ev, {"target": {"handle": "A1"}, "purpose": "line"}, [6], InspectState(ev)
)
assert "line" in content[0]["text"]
def test_inspect_region_bad_target_makes_no_mcp_calls(inspect_env):
client = _client()
ctx = make_ctx(client=client)
ev = _evidence()
content, budget = inspect_region(
ctx, ev, {"target": {"label": "nope"}}, [6], InspectState(ev)
)
assert budget[0] == 6
assert "未执行" in content[0]["text"]
assert client.called_names() == []
def test_inspect_region_oversized_target_makes_no_mcp_calls(inspect_env):
client = _client()
ctx = make_ctx(client=client)
ev = _evidence()
content, budget = inspect_region(
ctx, ev,
{"target": {"window": {"min_x": 0, "min_y": 0, "max_x": 200000, "max_y": 200000}}},
[6], InspectState(ev),
)
assert budget[0] == 6
assert "超过单次上限" in content[0]["text"]
assert client.called_names() == []
def test_inspect_region_budget_exhausted_returns_no_images(inspect_env):
pytest.importorskip("PIL")
ctx = make_ctx(client=_client())
ev = _evidence()
content, budget = inspect_region(
ctx, ev, {"target": {"handle": "A1"}}, [0], InspectState(ev)
)
assert budget[0] == 0
assert all(c.get("type") == "text" for c in content)
assert any("预算已用完" in c.get("text", "") for c in content)
# ---------------------------------------------------------------------------
# 与审查子循环的集成
# ---------------------------------------------------------------------------
def test_review_frame_can_call_inspect_region(inspect_env):
pytest.importorskip("PIL")
llm = FakeLlmClient(script=[
llm_calls([("inspect_region", {"target": {"handle": "A1"}})]),
llm_text('{"summary": "结论", "issues": []}'),
])
client = _client()
ctx = make_ctx(client=client, llm=llm)
ev = _evidence()
out = review_frame(ctx, ev, "sys", log=lambda *a: None)
assert out["summary"] == "结论"
names = client.called_names()
assert "zoom_to_window" in names # inspect_region 内部真的缩放了
assert names.count("zoom_to_window") == 2 # 放大 1 次 + 结束复位 1 次
def test_review_frame_offers_inspect_region_to_llm(inspect_env):
llm = FakeLlmClient(script=[llm_text("x")])
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
names = {t["function"]["name"] for t in llm.calls[0]["tools"]}
assert "inspect_region" in names
assert "zoom_to_window" not in names