feat(review): 审查阶段对齐绘图区分辨率 + 放大取证闭环

发现的缺口:maximized_drawing_area 原先只包住取证循环,审查阶段没被包住 →
LLM 在受控子循环里 zoom + 截图拿到的是未最大化的 2110x462(正是上一轮刚解决的
低分辨率问题,在最需要放大看细节的审查阶段又出现了);且审查中的 zoom 不复位视口,
跑完把用户的 CAD 留在最后一次放大处。

改动:
1. __main__.run_review:把 maximized_drawing_area 从「只包取证」扩到「包取证 + 审查」
   (两者都要截图);report/annotate 留在 with 之外。
2. phases/review.py:新增 _VIEW_TOOLS;review_frame 拆成外壳 + _review_frame,
   外壳 try/finally 里仅当确实动过视口才复位到该图框整幅视图
   (zoom_to_window(win, margin=0.02))。纯查结构化数据的路径不受影响。
3. checklist.build_system_prompt:把「可调用只读工具」改写成明确的放大取证协议——
   截图看不清时用 zoom_to_window(坐标由图框/实体 bbox 算)或 zoom_to_entity,
   再 get_viewport_screenshot 复核;并约束不要为看图而反复放大。

次生问题(同轮修复):_MAX_OBS_IMAGES 原为硬编码 3,而模型一轮内会发 8 次截图 →
拍了 8 张只回灌 3 张。改为配置项 REVIEW_OBS_IMAGES(默认 6),并把预算耗尽的占位
文案改成明确告知模型「不要再请求截图,请给出结论」。

实测(真实图纸 1 图框,新增 verify_review_maximized.py):取证与审查两阶段窗口均为
[-4,-2,3444,1442](最大化态),结束精确还原 [68,0,2316,960],三项全 PASS;
子循环里 LLM 连发 8 组 zoom_to_window + get_viewport_screenshot,提示词生效。
pytest 304 passed。
This commit is contained in:
gjm committed 2026-10-16 18:55:31 +08:00
1 parent 931a430ba4
commit e8140f2bc5
11 files changed
+320 -40

No files matched your search

+8
View File
@@ -65,6 +65,14 @@ def test_build_system_prompt_tool_hint_toggle():
assert "只读" not in without
def test_build_system_prompt_instructs_zoom_when_unreadable():
prompt = build_system_prompt(DEFAULT_CHECKLIST, allow_tools=True)
assert "看不清" in prompt
assert "zoom_to_window" in prompt
assert "zoom_to_entity" in prompt
assert "get_viewport_screenshot" in prompt
def test_build_system_prompt_requires_structured_json_output():
# allow_tools=False 以隔离「工具提示块」里的「只读」字样
prompt = build_system_prompt(DEFAULT_CHECKLIST, allow_tools=False)
+5
View File
@@ -101,6 +101,11 @@ def test_tiling_defaults_are_sane():
assert config.HEATMAP_CELL_DIV > 0
def test_review_obs_images_default_is_sane():
# 审查子循环回灌截图上限:须为正,且不低于旧硬编码值 3
assert config.REVIEW_OBS_IMAGES >= 3
def test_drawing_area_constants_have_expected_types():
assert isinstance(config.DRAWING_AREA_MAXIMIZE, bool)
assert isinstance(config.DRAWING_AREA_ENTER_WAIT, float)
+79
View File
@@ -260,3 +260,82 @@ def test_review_frame_includes_multiple_image_blocks():
assert len(image_blocks) == 2
assert any(b.get("text") == "(截图:overall)" for b in user_content)
assert any(b.get("text") == "(截图:tile-1)" for b in user_content)
# ---------------------------------------------------------------------------
# 视口复位(放大取证不残留给用户)
# ---------------------------------------------------------------------------
def test_review_frame_resets_viewport_after_zoom():
llm = FakeLlmClient(
script=[
llm_calls([("zoom_to_window", {"min_x": 1, "min_y": 2, "max_x": 3, "max_y": 4})]),
llm_text("done"),
]
)
client = FakeMcpClient()
ctx = make_ctx(client=client, llm=llm)
review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
zooms = client.called("zoom_to_window")
assert len(zooms) == 2 # LLM 放大一次 + 结束时复位一次
reset = zooms[-1]["arguments"]
assert (reset["min_x"], reset["min_y"]) == (0.0, 0.0) # 复位到图框整幅视图
assert (reset["max_x"], reset["max_y"]) == (100.0, 50.0)
assert reset["margin"] == 0.02
def test_review_frame_resets_viewport_after_zoom_to_entity():
llm = FakeLlmClient(
script=[llm_calls([("zoom_to_entity", {"handle": "A1"})]), llm_text("done")]
)
client = FakeMcpClient()
ctx = make_ctx(client=client, llm=llm)
review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
assert client.called_names().count("zoom_to_window") == 1
def test_review_frame_resets_viewport_at_iteration_limit():
llm = FakeLlmClient(
script=[llm_calls([("zoom_to_window", {})]), llm_text("收口结论")]
)
client = FakeMcpClient()
ctx = make_ctx(client=client, llm=llm, max_iters=1)
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
assert out["summary"] == "收口结论"
assert client.called_names().count("zoom_to_window") == 2 # 强制收口路径也复位
def test_review_frame_does_not_touch_viewport_without_zoom():
llm = FakeLlmClient(script=[llm_calls([("get_layers", {})]), llm_text("x")])
client = FakeMcpClient()
ctx = make_ctx(client=client, llm=llm)
review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
assert "zoom_to_window" not in client.called_names()
def test_review_frame_viewport_reset_failure_is_swallowed():
seen = {"n": 0}
def zoom(_args):
seen["n"] += 1
if seen["n"] > 1:
raise McpToolError("复位失败")
return ok_text({})
llm = FakeLlmClient(
script=[llm_calls([("zoom_to_window", {})]), llm_text("结论")] # noqa: E501
)
client = FakeMcpClient(responses={"zoom_to_window": zoom})
ctx = make_ctx(client=client, llm=llm)
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
assert out["summary"] == "结论" # 复位失败不影响结论
assert seen["n"] == 2
def test_review_frame_no_viewport_reset_without_window():
llm = FakeLlmClient(script=[llm_calls([("zoom_to_window", {})]), llm_text("x")])
client = FakeMcpClient()
ctx = make_ctx(client=client, llm=llm)
ev = make_evidence()
ev["window"] = None # 非 CAD 源(无窗口)→ 无从复位
review_frame(ctx, ev, "sys", log=lambda *a: None)
assert client.called_names().count("zoom_to_window") == 1