支持直接审查 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 丢图框块,离线侧暂无等价手段),留待后续打磨。
323 lines
13 KiB
Python
323 lines
13 KiB
Python
"""`dwg_review.checks` 单元测试:确定性检查前置。"""
|
||
|
||
from dwg_review import checks
|
||
from dwg_review.checks import (
|
||
check_collisions,
|
||
check_duplicate_entities,
|
||
check_zero_length,
|
||
derive_from_evidence,
|
||
make_issue,
|
||
run_deterministic_checks,
|
||
)
|
||
from dwg_review.mcp_client import McpError, McpToolError
|
||
from tests.fakes import FakeMcpClient, make_ctx, make_evidence, ok_text
|
||
|
||
QUIET = lambda *a: None # noqa: E731
|
||
WINDOW = {"min_x": 0.0, "min_y": 0.0, "max_x": 10.0, "max_y": 10.0}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# make_issue
|
||
# ---------------------------------------------------------------------------
|
||
def test_make_issue_minimal():
|
||
issue = make_issue("几何", "medium", "零长度")
|
||
assert issue == {"category": "几何", "severity": "medium",
|
||
"message": "零长度", "source": "deterministic"}
|
||
|
||
|
||
def test_make_issue_with_handles():
|
||
issue = make_issue("几何", "low", "重复", handle="A", handles=["A", "B"])
|
||
assert issue["handle"] == "A"
|
||
assert issue["handles"] == ["A", "B"]
|
||
|
||
|
||
def test_make_issue_omits_empty_handle():
|
||
assert "handle" not in make_issue("文字", "low", "空文字", handle=None)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# check_zero_length
|
||
# ---------------------------------------------------------------------------
|
||
def test_zero_length_turns_items_into_issues():
|
||
client = FakeMcpClient(responses={"find_zero_length": ok_text({
|
||
"total": 2, "returned": 2, "offset": 0,
|
||
"items": [
|
||
{"handle": "A1", "type": "LINE", "layer": "0", "length": 0.0},
|
||
{"handle": "A2", "type": "ARC", "layer": "0", "length": 0.0001},
|
||
],
|
||
})})
|
||
ctx = make_ctx(client=client)
|
||
issues = check_zero_length(ctx, WINDOW, tolerance=0.001, limit=200, log=QUIET)
|
||
assert [i["handle"] for i in issues] == ["A1", "A2"]
|
||
assert all(i["category"] == "几何" and i["severity"] == "medium" for i in issues)
|
||
|
||
|
||
def test_zero_length_skips_items_without_handle():
|
||
client = FakeMcpClient(responses={"find_zero_length": ok_text(
|
||
{"items": [{"type": "LINE"}, {"handle": "B1"}]}
|
||
)})
|
||
ctx = make_ctx(client=client)
|
||
assert [i["handle"] for i in check_zero_length(ctx, WINDOW, tolerance=0.001,
|
||
limit=200, log=QUIET)] == ["B1"]
|
||
|
||
|
||
def test_zero_length_passes_window_and_params():
|
||
client = FakeMcpClient(responses={"find_zero_length": ok_text({"items": []})})
|
||
ctx = make_ctx(client=client)
|
||
check_zero_length(ctx, WINDOW, tolerance=0.5, limit=77, log=QUIET)
|
||
assert client.arguments_of("find_zero_length") == {
|
||
"window": WINDOW, "tolerance": 0.5, "limit": 77,
|
||
}
|
||
|
||
|
||
def test_zero_length_swallows_tool_error():
|
||
logs = []
|
||
client = FakeMcpClient(responses={"find_zero_length": McpToolError("炸了")})
|
||
ctx = make_ctx(client=client)
|
||
assert check_zero_length(ctx, WINDOW, tolerance=0.001, limit=200, log=logs.append) == []
|
||
assert any("find_zero_length 失败" in line for line in logs)
|
||
|
||
|
||
def test_zero_length_tolerates_non_dict_envelope():
|
||
client = FakeMcpClient(responses={"find_zero_length": ok_text("oops")})
|
||
ctx = make_ctx(client=client)
|
||
assert check_zero_length(ctx, WINDOW, tolerance=0.001, limit=200, log=QUIET) == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# check_duplicate_entities
|
||
# ---------------------------------------------------------------------------
|
||
def test_duplicate_group_becomes_single_issue_with_all_handles():
|
||
client = FakeMcpClient(responses={"find_duplicate_entities": ok_text({
|
||
"count": 1,
|
||
"groups": [{"reason": "identical_geometry", "type": "LINE",
|
||
"handles": ["B1", "B2", "B3"]}],
|
||
})})
|
||
ctx = make_ctx(client=client)
|
||
issues = check_duplicate_entities(ctx, WINDOW, tolerance=0.001, group_limit=50, log=QUIET)
|
||
assert len(issues) == 1 # 一组 = 一个问题
|
||
assert issues[0]["handle"] == "B1"
|
||
assert issues[0]["handles"] == ["B1", "B2", "B3"]
|
||
assert issues[0]["severity"] == "low"
|
||
|
||
|
||
def test_duplicate_skips_empty_groups():
|
||
client = FakeMcpClient(responses={"find_duplicate_entities": ok_text({
|
||
"groups": [{"handles": []}, {"handles": None}, {"handles": ["C1", "C2"]}],
|
||
})})
|
||
ctx = make_ctx(client=client)
|
||
issues = check_duplicate_entities(ctx, WINDOW, tolerance=0.001, group_limit=50, log=QUIET)
|
||
assert [i["handle"] for i in issues] == ["C1"]
|
||
|
||
|
||
def test_duplicate_passes_group_limit():
|
||
client = FakeMcpClient(responses={"find_duplicate_entities": ok_text({"groups": []})})
|
||
ctx = make_ctx(client=client)
|
||
check_duplicate_entities(ctx, WINDOW, tolerance=0.2, group_limit=9, log=QUIET)
|
||
assert client.arguments_of("find_duplicate_entities") == {
|
||
"window": WINDOW, "tolerance": 0.2, "limit": 9,
|
||
}
|
||
|
||
|
||
def test_duplicate_swallows_tool_error():
|
||
logs = []
|
||
client = FakeMcpClient(responses={"find_duplicate_entities": McpError(-32603, "x")})
|
||
ctx = make_ctx(client=client)
|
||
assert check_duplicate_entities(ctx, WINDOW, tolerance=0.001, group_limit=50,
|
||
log=logs.append) == []
|
||
assert any("find_duplicate_entities 失败" in line for line in logs)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# check_collisions
|
||
# ---------------------------------------------------------------------------
|
||
def _collision_item(a="A1", b="B1", ratio=0.8, same_layer=True):
|
||
return {
|
||
"a": {"handle": a, "type": "LINE", "layer": "0", "bbox": {}},
|
||
"b": {"handle": b, "type": "LWPOLYLINE", "layer": "0", "bbox": {}},
|
||
"overlap": {"bbox": {}, "width": 1.0, "height": 1.0, "area": 1.0, "ratio": ratio},
|
||
"same_layer": same_layer,
|
||
"same_type": False,
|
||
}
|
||
|
||
|
||
def test_collisions_turn_items_into_issues():
|
||
client = FakeMcpClient(responses={"find_collisions": ok_text({
|
||
"total": 2, "returned": 2, "offset": 0, "entity_count": 10,
|
||
"items": [_collision_item("A1", "B1", 0.9), _collision_item("A2", "B2", 0.6)],
|
||
})})
|
||
ctx = make_ctx(client=client)
|
||
issues = check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET)
|
||
assert [i["handles"] for i in issues] == [["A1", "B1"], ["A2", "B2"]]
|
||
assert all(i["category"] == "干涉" and i["severity"] == "medium" for i in issues)
|
||
assert all(i["handle"] == i["handles"][0] for i in issues)
|
||
assert "0.90" in issues[0]["message"]
|
||
|
||
|
||
def test_collisions_skips_items_missing_handles():
|
||
client = FakeMcpClient(responses={"find_collisions": ok_text({
|
||
"items": [
|
||
{"a": {"handle": "A1"}, "b": {}},
|
||
{"a": {}, "b": {"handle": "B1"}},
|
||
_collision_item("A2", "B2"),
|
||
],
|
||
})})
|
||
ctx = make_ctx(client=client)
|
||
issues = check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET)
|
||
assert [i["handles"] for i in issues] == [["A2", "B2"]]
|
||
|
||
|
||
def test_collisions_tolerates_missing_ratio():
|
||
client = FakeMcpClient(responses={"find_collisions": ok_text({
|
||
"items": [{"a": {"handle": "A1"}, "b": {"handle": "B1"}, "overlap": {}}],
|
||
})})
|
||
ctx = make_ctx(client=client)
|
||
issues = check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET)
|
||
assert len(issues) == 1
|
||
assert "重叠比 ?" in issues[0]["message"]
|
||
|
||
|
||
def test_collisions_passes_params():
|
||
client = FakeMcpClient(responses={"find_collisions": ok_text({"items": []})})
|
||
ctx = make_ctx(client=client)
|
||
check_collisions(ctx, WINDOW, min_overlap=0.75, limit=33, log=QUIET)
|
||
assert client.arguments_of("find_collisions") == {
|
||
"window": WINDOW, "min_overlap": 0.75, "limit": 33,
|
||
}
|
||
|
||
|
||
def test_collisions_swallows_tool_error():
|
||
logs = []
|
||
client = FakeMcpClient(responses={"find_collisions": McpToolError("炸了")})
|
||
ctx = make_ctx(client=client)
|
||
assert check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=logs.append) == []
|
||
assert any("find_collisions 失败" in line for line in logs)
|
||
|
||
|
||
def test_collisions_tolerates_non_dict_envelope():
|
||
client = FakeMcpClient(responses={"find_collisions": ok_text("oops")})
|
||
ctx = make_ctx(client=client)
|
||
assert check_collisions(ctx, WINDOW, min_overlap=0.5, limit=200, log=QUIET) == []
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# derive_from_evidence
|
||
# ---------------------------------------------------------------------------
|
||
def test_derive_flags_empty_and_whitespace_texts():
|
||
ev = make_evidence(texts={"items": [
|
||
{"handle": "T1", "content": ""},
|
||
{"handle": "T2", "content": " "},
|
||
{"handle": "T3", "content": None},
|
||
{"handle": "T4", "content": "正常文字"},
|
||
]})
|
||
issues = derive_from_evidence(ev)
|
||
assert [i["handle"] for i in issues] == ["T1", "T2", "T3"]
|
||
assert all(i["category"] == "文字" for i in issues)
|
||
|
||
|
||
def test_derive_flags_unclosed_polyline():
|
||
ev = make_evidence(polylines={"items": [
|
||
{"handle": "P1", "vertex_count": 4, "closed": False},
|
||
{"handle": "P2", "vertex_count": 4, "closed": True},
|
||
{"handle": "P3", "vertex_count": 2, "closed": False},
|
||
{"handle": "P4", "closed": False},
|
||
]})
|
||
issues = derive_from_evidence(ev)
|
||
assert [i["handle"] for i in issues] == ["P1"]
|
||
|
||
|
||
def test_derive_can_disable_unclosed_check():
|
||
ev = make_evidence(polylines={"items": [{"handle": "P1", "vertex_count": 4, "closed": False}]})
|
||
assert derive_from_evidence(ev, check_unclosed=False) == []
|
||
|
||
|
||
def test_derive_tolerates_missing_sources():
|
||
assert derive_from_evidence({}) == []
|
||
assert derive_from_evidence(make_evidence()) == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# run_deterministic_checks
|
||
# ---------------------------------------------------------------------------
|
||
def build_ctx():
|
||
client = FakeMcpClient(responses={
|
||
"find_zero_length": ok_text({"items": [{"handle": "Z1", "type": "LINE", "length": 0.0}]}),
|
||
"find_duplicate_entities": ok_text({"groups": [
|
||
{"type": "LINE", "handles": ["D1", "D2"]}]}),
|
||
"find_collisions": ok_text({"items": [_collision_item("C1", "C2", 0.9)]}),
|
||
})
|
||
return make_ctx(client=client)
|
||
|
||
|
||
def test_run_combines_derived_and_mcp():
|
||
ctx = build_ctx()
|
||
ev = make_evidence(
|
||
texts={"items": [{"handle": "T1", "content": ""}]},
|
||
polylines={"items": [{"handle": "P1", "vertex_count": 3, "closed": False}]},
|
||
)
|
||
issues = run_deterministic_checks(ctx, ev, log=QUIET)
|
||
by_handle = {i.get("handle"): i for i in issues}
|
||
assert set(by_handle) == {"T1", "P1", "Z1", "D1"}
|
||
assert by_handle["D1"]["handles"] == ["D1", "D2"]
|
||
|
||
|
||
def test_run_passes_window_to_mcp_tools():
|
||
ctx = build_ctx()
|
||
ev = make_evidence(bbox=(0.0, 0.0, 10.0, 10.0))
|
||
run_deterministic_checks(ctx, ev, log=QUIET)
|
||
assert ctx.client.arguments_of("find_zero_length")["window"] == ev["window"]
|
||
assert ctx.client.arguments_of("find_duplicate_entities")["window"] == ev["window"]
|
||
|
||
|
||
def test_run_without_window_skips_mcp_but_keeps_derived():
|
||
ctx = build_ctx()
|
||
ev = make_evidence()
|
||
ev["window"] = None
|
||
ev["texts"] = {"items": [{"handle": "T1", "content": ""}]}
|
||
issues = run_deterministic_checks(ctx, ev, log=QUIET)
|
||
assert [i["handle"] for i in issues] == ["T1"]
|
||
assert ctx.client.calls == []
|
||
|
||
|
||
def test_run_respects_unclosed_switch(monkeypatch):
|
||
monkeypatch.setattr(checks.config, "CHECK_UNCLOSED_POLYLINES", False)
|
||
ctx = build_ctx()
|
||
ev = make_evidence(polylines={"items": [{"handle": "P1", "vertex_count": 4, "closed": False}]})
|
||
assert [i["handle"] for i in run_deterministic_checks(ctx, ev, log=QUIET)] == ["Z1", "D1"]
|
||
|
||
|
||
def test_run_collisions_off_by_default(monkeypatch):
|
||
# 默认不跑干涉粗筛(AABB 相交噪声大),工具调用里不应出现 find_collisions
|
||
monkeypatch.setattr(checks.config, "CHECK_COLLISIONS", False)
|
||
ctx = build_ctx()
|
||
run_deterministic_checks(ctx, make_evidence(), log=QUIET)
|
||
assert "find_collisions" not in ctx.client.called_names()
|
||
|
||
|
||
def test_run_collisions_when_enabled(monkeypatch):
|
||
monkeypatch.setattr(checks.config, "CHECK_COLLISIONS", True)
|
||
monkeypatch.setattr(checks.config, "COLLISION_MIN_OVERLAP", 0.6)
|
||
ctx = build_ctx()
|
||
issues = run_deterministic_checks(ctx, make_evidence(), log=QUIET)
|
||
by_handle = {i.get("handle"): i for i in issues}
|
||
assert "C1" in by_handle
|
||
assert by_handle["C1"]["category"] == "干涉"
|
||
assert ctx.client.arguments_of("find_collisions")["min_overlap"] == 0.6
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 离线源(tools=None)
|
||
# ---------------------------------------------------------------------------
|
||
def test_run_offline_only_derives_from_evidence():
|
||
"""离线源没有 CAD 工具:只跑纯 Python 派生检查,不调任何 MCP 工具。"""
|
||
ctx = make_ctx(connect_cad=False)
|
||
ev = make_evidence(
|
||
texts={"total": 1, "items": [{"handle": "T1", "content": " "}]},
|
||
polylines={"total": 1, "items": [
|
||
{"handle": "P1", "vertex_count": 4, "closed": False}]},
|
||
)
|
||
issues = run_deterministic_checks(ctx, ev, log=QUIET)
|
||
cats = {i["category"] for i in issues}
|
||
assert "文字" in cats and "几何" in cats
|