Files
cad-agent/tests/test_review.py
T
gjm 6143bb9ab2 feat(review): PDF/图片离线源适配(C1)—— 无需 CAD 的整条审查链路
支持直接审查 PDF 与位图图纸,全链路离线(不连 CAD、不连 8080 端口)。
渲染在内存内完成,复用既有墨迹热力图 + 自适应瓦片规划。

- 新增 dwg_review/sources.py
  - detect_source / plan_file / collect 统一入口
  - PdfReader:pypdfium2 惰性导入,页序即图框序,处理 /Rotate 与 crop 边距
  - ImageReader:世界单位 = 源像素,snap_box 整数对齐保证窗口与像素严格一致
  - ink_grid:纯 PIL 灰度阈值 → 占用网格(不引入 numpy)
- ReviewContext.create(connect_cad=False):离线时 client/tools 均为 None
- 全链路 ctx.tools is None 守卫:review 只读子循环 / inspect_region / annotator /
  checks 的窗口类检查 / 绘图区最大化
- checklist.build_system_prompt(allow_tools=False, offline=True):离线提示词
- __main__:位置参数 source,按扩展名自动判定源类型并决定是否连 CAD
- config 新增 8 个常量(PDF_MAX_PAGES / 文本最小字数 / 各源缩放上限 / 墨迹阈值等)
- tiling.content_predicate 与 heatmap.cell_box 提升为公开函数

验证:
- pytest 457 passed
- verify_pdf_source.py:PDF 23/23、图片 9/9(不需 CAD),含实测瓦片字高像素与
  旋转映射自检(文本区墨迹 0.311 vs 忽略 /Rotate 反向对照 0.145)
- 真实 LLM 端到端:20 页 HVAC 图纸前 3 页跑通,结论具体可核验且未编造 handle

已知局限:页边线在墨迹网格里会把整页连成单簇,瓦片常退化为整页象限(CAD 侧
靠 drop_oversized 丢图框块,离线侧暂无等价手段),留待后续打磨。
2026-10-16 23:07:23 +08:00

446 lines
17 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.phases.review` 单元测试。"""
import json
import pytest
from dwg_review import config
from dwg_review.cad_tools import READ_ONLY_TOOLS
from dwg_review.mcp_client import McpToolError
from dwg_review.phases.review import (
_review_tools,
_run_readonly_tool,
evidence_payload,
review_frame,
)
from tests.fakes import (
FakeLlmClient,
FakeMcpClient,
llm_calls,
llm_text,
make_ctx,
make_evidence,
ok_image,
ok_mixed,
ok_text,
)
# ---------------------------------------------------------------------------
# evidence_payload
# ---------------------------------------------------------------------------
def test_evidence_payload_strips_image():
ev = make_evidence(image_b64="QUJD", image_mime="image/png")
payload = evidence_payload(ev)
assert "images" not in payload
assert "image_b64" not in payload
assert payload["frame_index"] == 1
def test_evidence_payload_keeps_only_frame_metadata():
ev = make_evidence()
ev["frame"]["secret"] = "should-not-leak"
payload = evidence_payload(ev)
assert "secret" not in payload["frame"]
assert set(payload["frame"]) == {"sheet_size", "kind", "layer", "bbox", "confidence"}
def test_evidence_payload_carries_structured_data():
ev = make_evidence(entities={"total": 9, "items": []}, texts={"total": 2})
payload = evidence_payload(ev)
assert payload["entities"] == {"total": 9, "items": []}
assert payload["texts"] == {"total": 2}
# ---------------------------------------------------------------------------
# _run_readonly_tool
# ---------------------------------------------------------------------------
def test_readonly_rejects_non_whitelisted_tool():
client = FakeMcpClient()
ctx = make_ctx(client=client)
content, budget = _run_readonly_tool(ctx, "get_some_unknown_tool", {}, [3])
assert "不在只读取证白名单内" in content[0]["text"]
assert client.calls == []
assert budget == [3]
def test_readonly_rejects_write_tool():
client = FakeMcpClient()
ctx = make_ctx(client=client)
content, _ = _run_readonly_tool(ctx, "add_leader_annotation", {}, [3])
assert "不在只读取证白名单内" in content[0]["text"]
assert client.calls == []
def test_readonly_whitelist_covers_expected_tools():
assert {"query_entities", "get_entity_detail", "zoom_to_window"} <= READ_ONLY_TOOLS
def test_readonly_handles_tool_error():
client = FakeMcpClient(responses={"query_entities": McpToolError("炸了")})
ctx = make_ctx(client=client)
content, _ = _run_readonly_tool(ctx, "query_entities", {"window": {}}, [3])
assert "执行失败" in content[0]["text"]
def test_readonly_text_and_image_content():
client = FakeMcpClient(
responses={"query_entities": ok_mixed(["hi"], [("QUJD", "image/png")])}
)
ctx = make_ctx(client=client)
content, budget = _run_readonly_tool(ctx, "query_entities", {}, [3])
assert content[0] == {"type": "text", "text": "hi"}
assert content[1]["type"] == "image_url"
assert budget == [2]
def test_readonly_image_budget_exhausted():
client = FakeMcpClient(
responses={"query_entities": ok_mixed(["t"], [("QUJD", "image/png")])}
)
ctx = make_ctx(client=client)
content, budget = _run_readonly_tool(ctx, "query_entities", {}, [0])
assert budget == [0]
assert any("已省略" in c.get("text", "") for c in content)
def test_readonly_empty_response_marker():
empty = {"jsonrpc": "2.0", "id": 1, "result": {"content": []}}
client = FakeMcpClient(responses={"query_entities": empty})
ctx = make_ctx(client=client)
content, _ = _run_readonly_tool(ctx, "query_entities", {}, [3])
assert content == [{"type": "text", "text": "(无返回内容)"}]
# ---------------------------------------------------------------------------
# review_frame
# ---------------------------------------------------------------------------
def test_review_frame_returns_summary_when_no_tools():
llm = FakeLlmClient(script=[llm_text("结论A")])
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
out = review_frame(ctx, make_evidence(), "系统提示", log=lambda *a: None)
assert out["summary"] == "结论A" # 非 JSON → 降级为纯文本 summary
assert out["issues"] == []
assert len(llm.calls) == 1
assert llm.calls[0]["tools"] is not None # 传了只读工具 schema
def test_review_frame_includes_image_block_when_present():
llm = FakeLlmClient(script=[llm_text("ok")])
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
ev = make_evidence(image_b64="QUJD", image_mime="image/png")
review_frame(ctx, ev, "sys", log=lambda *a: None)
user_content = llm.calls[0]["messages"][1]["content"]
assert any(b.get("type") == "image_url" for b in user_content)
def test_review_frame_omits_image_block_when_absent():
llm = FakeLlmClient(script=[llm_text("ok")])
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
user_content = llm.calls[0]["messages"][1]["content"]
assert all(b.get("type") == "text" for b in user_content)
def test_review_frame_runs_tool_then_concludes():
llm = FakeLlmClient(
script=[
llm_calls([("query_entities", {"window": {}})]),
llm_text("结论B"),
]
)
client = FakeMcpClient(responses={"query_entities": ok_text({"total": 1})})
ctx = make_ctx(client=client, llm=llm)
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
assert out["summary"] == "结论B"
assert len(llm.calls) == 2
assert client.called_names() == ["query_entities"]
def test_review_frame_feeds_observation_back_as_tool_message():
llm = FakeLlmClient(script=[llm_calls([("query_entities", {})]), llm_text("done")])
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
tool_msgs = [m for m in llm.calls[1]["messages"] if isinstance(m, dict) and m.get("role") == "tool"]
assert tool_msgs and tool_msgs[0]["tool_call_id"] == "1"
def test_review_frame_invalid_tool_arguments_become_empty():
llm = FakeLlmClient(
script=[llm_calls([("query_entities", "not-json")]), llm_text("done")]
)
client = FakeMcpClient()
ctx = make_ctx(client=client, llm=llm)
review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
assert client.arguments_of("query_entities") == {}
def test_review_frame_force_closes_at_iteration_limit():
llm = FakeLlmClient(
script=[
llm_calls([("query_entities", {})]),
llm_text("收口结论"),
]
)
ctx = make_ctx(client=FakeMcpClient(), llm=llm, max_iters=1)
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
assert out["summary"] == "收口结论"
last_msg = llm.calls[-1]["messages"][-1]
assert last_msg["role"] == "user"
assert "上限" in last_msg["content"]
def test_review_frame_with_zero_iters_forces_immediate_close():
llm = FakeLlmClient(script=[llm_text("直接结论")])
ctx = make_ctx(client=FakeMcpClient(), llm=llm, max_iters=0)
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
assert out["summary"] == "直接结论"
assert len(llm.calls) == 1
def test_review_frame_logs_tool_usage():
logs = []
llm = FakeLlmClient(script=[llm_calls([("get_layers", {})]), llm_text("x")])
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
review_frame(ctx, make_evidence(), "sys", log=logs.append)
assert any("取证" in line and "get_layers" in line for line in logs)
def test_review_frame_payload_is_json_serializable():
llm = FakeLlmClient(script=[llm_text("ok")])
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
text = llm.calls[0]["messages"][1]["content"][0]["text"]
json.loads(text.split(":\n", 1)[1]) # 不抛异常即通过
def test_review_frame_parses_structured_json_conclusion():
text = (
'{"summary": "整体尚可", "issues": ['
'{"category": "几何", "severity": "medium", "message": "零长度", "handle": "A1"}]}'
)
llm = FakeLlmClient(script=[llm_text(text)])
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
assert out["summary"] == "整体尚可"
assert out["issues"] == [
{"category": "几何", "severity": "medium", "message": "零长度", "handle": "A1"}
]
def test_review_frame_parses_fenced_json():
llm = FakeLlmClient(script=[llm_text('```json\n{"summary": "s", "issues": []}\n```')])
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
assert out["summary"] == "s"
assert out["issues"] == []
def test_review_frame_intermediate_tool_turn_json_not_parsed():
# 中间工具回合的 content 里即使塞了 JSON,也不能被当成最终结论
intermediate = '{"summary": "这不是最终结论", "issues": [{"message": "x"}]}'
llm = FakeLlmClient(script=[
llm_calls([("query_entities", {})], content=intermediate),
llm_text('{"summary": "最终结论", "issues": []}'),
])
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
assert out["summary"] == "最终结论"
assert out["issues"] == []
def test_review_frame_includes_multiple_image_blocks():
llm = FakeLlmClient(script=[llm_text("ok")])
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
ev = make_evidence(
images=[
{"b64": "A", "mime": "image/png", "label": "overall"},
{"b64": "B", "mime": "image/png", "label": "tile-1"},
]
)
review_frame(ctx, ev, "sys", log=lambda *a: None)
user_content = llm.calls[0]["messages"][1]["content"]
image_blocks = [b for b in user_content if b.get("type") == "image_url"]
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
# ---------------------------------------------------------------------------
# 虚拟工具 inspect_region 的接入
# ---------------------------------------------------------------------------
def _tool_names(ctx):
return {t["function"]["name"] for t in _review_tools(ctx)}
def test_review_tools_swap_raw_view_tools_for_inspect_region(monkeypatch):
monkeypatch.setattr(config, "INSPECT_ENABLED", True)
names = _tool_names(make_ctx())
assert "inspect_region" in names
assert "query_entities" in names # 其余只读工具照旧
assert "find_collisions" in names # 干涉粗筛对 LLM 可见
assert "zoom_to_window" not in names
assert "zoom_to_entity" not in names
assert "get_viewport_screenshot" not in names
def test_review_tools_keep_raw_view_tools_when_disabled(monkeypatch):
monkeypatch.setattr(config, "INSPECT_ENABLED", False)
names = _tool_names(make_ctx())
assert "inspect_region" not in names
assert {"zoom_to_window", "zoom_to_entity", "get_viewport_screenshot"} <= names
def test_readonly_intercepts_inspect_region_before_whitelist(monkeypatch):
# inspect_region 不在 READ_ONLY_TOOLS 里,但必须被**先**拦截(不能判成越权)
monkeypatch.setattr(config, "INSPECT_ENABLED", True)
ctx = make_ctx(client=FakeMcpClient())
ev = make_evidence()
from dwg_review.inspect import InspectState
content, budget = _run_readonly_tool(
ctx, "inspect_region", {"target": {}}, [6], evidence=ev, state=InspectState(ev)
)
assert "不在只读取证白名单内" not in content[0]["text"]
assert "未执行" in content[0]["text"] # 空 target → 明确的用法错误
assert budget == [6]
def test_readonly_inspect_region_unavailable_when_disabled(monkeypatch):
monkeypatch.setattr(config, "INSPECT_ENABLED", False)
ctx = make_ctx(client=FakeMcpClient())
ev = make_evidence()
from dwg_review.inspect import InspectState
content, _ = _run_readonly_tool(
ctx, "inspect_region", {}, [6], evidence=ev, state=InspectState(ev)
)
assert "不可用" in content[0]["text"]
def test_readonly_inspect_region_unavailable_without_evidence():
ctx = make_ctx(client=FakeMcpClient())
content, _ = _run_readonly_tool(ctx, "inspect_region", {}, [6])
assert "不可用" in content[0]["text"]
# ---------------------------------------------------------------------------
# 离线源(tools=None)
# ---------------------------------------------------------------------------
def test_review_tools_offline_returns_empty():
ctx = make_ctx(connect_cad=False)
assert ctx.tools is None
assert _review_tools(ctx) == []
def test_readonly_tool_offline_rejected():
ctx = make_ctx(connect_cad=False)
content, budget = _run_readonly_tool(ctx, "query_entities", {}, [3])
assert "离线" in content[0]["text"]
assert budget == [3]
def test_readonly_tool_offline_rejects_inspect_region():
"""离线源连 inspect_region 虚拟工具也不可用(它依赖 CAD 截图)。"""
ctx = make_ctx(connect_cad=False)
content, _ = _run_readonly_tool(ctx, "inspect_region", {}, [3])
assert "离线" in content[0]["text"]
def test_review_frame_offline_never_calls_cad():
"""离线源审查:LLM 即使请求工具也不会碰 CAD,直接收口给出结论。"""
llm = FakeLlmClient(script=[
llm_calls([("query_entities", {})]),
llm_text(json.dumps({"summary": "s", "issues": []})),
])
ctx = make_ctx(llm=llm, connect_cad=False)
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
assert out["summary"] == "s"
def test_reset_viewport_offline_noop():
from dwg_review.phases.review import _reset_viewport
ctx = make_ctx(connect_cad=False)
ev = make_evidence()
_reset_viewport(ctx, ev) # 不应抛异常