支持直接审查 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 丢图框块,离线侧暂无等价手段),留待后续打磨。
186 lines
6.9 KiB
Python
186 lines
6.9 KiB
Python
"""`dwg_review.annotator` 单元测试。"""
|
||
|
||
import pytest
|
||
|
||
from dwg_review.annotator import _pick_point, annotate, clear
|
||
from dwg_review.mcp_client import McpError, McpToolError
|
||
from tests.fakes import FakeMcpClient, make_ctx, ok_text
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _pick_point
|
||
# ---------------------------------------------------------------------------
|
||
def test_pick_point_from_bbox_center():
|
||
assert _pick_point({"bbox": [0, 0, 10, 20]}) == [5.0, 10.0, 0.0]
|
||
|
||
|
||
def test_pick_point_from_position():
|
||
assert _pick_point({"position": [1, 2, 3]}) == [1.0, 2.0, 3.0]
|
||
|
||
|
||
def test_pick_point_from_position_without_z():
|
||
assert _pick_point({"position": [1, 2]}) == [1.0, 2.0, 0.0]
|
||
|
||
|
||
def test_pick_point_prefers_bbox_over_position():
|
||
assert _pick_point({"bbox": [0, 0, 10, 10], "position": [99, 99]}) == [5.0, 5.0, 0.0]
|
||
|
||
|
||
def test_pick_point_returns_none_when_unlocatable():
|
||
assert _pick_point({}) is None
|
||
assert _pick_point({"bbox": [0, 0, 1]}) is None
|
||
assert _pick_point("not-a-dict") is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# annotate
|
||
# ---------------------------------------------------------------------------
|
||
def build_client(detail=None, created=None, fail_leader=False):
|
||
responses = {"get_entity_detail": detail if detail is not None else ok_text({"bbox": [0, 0, 10, 10]})}
|
||
if fail_leader:
|
||
responses["add_leader_annotation"] = McpToolError("引线失败")
|
||
else:
|
||
responses["add_leader_annotation"] = ok_text(
|
||
created if created is not None else {"created": [{"handle": "NEW1"}]}
|
||
)
|
||
responses["highlight_entities"] = ok_text({})
|
||
return FakeMcpClient(responses=responses)
|
||
|
||
|
||
def test_annotate_dry_run_does_not_call_cad():
|
||
logs = []
|
||
ctx = make_ctx(client=FakeMcpClient())
|
||
created, highlighted = annotate(
|
||
ctx, [{"handle": "A", "severity": "high", "message": "x"}], dry_run=True, log=logs.append
|
||
)
|
||
assert created == [] and highlighted == []
|
||
assert ctx.client.calls == []
|
||
assert any("[dry-run]" in line for line in logs)
|
||
|
||
|
||
def test_annotate_skips_issues_without_handle():
|
||
ctx = make_ctx(client=FakeMcpClient())
|
||
created, highlighted = annotate(
|
||
ctx, [{"severity": "high", "message": "无 handle"}], log=lambda *a: None
|
||
)
|
||
assert created == [] and highlighted == []
|
||
assert ctx.client.calls == []
|
||
|
||
|
||
def test_annotate_creates_leader_and_highlights():
|
||
ctx = make_ctx(client=build_client())
|
||
created, highlighted = annotate(
|
||
ctx, [{"handle": "A", "severity": "high", "message": "重叠"}], log=lambda *a: None
|
||
)
|
||
assert created == ["NEW1"]
|
||
assert highlighted == ["A"]
|
||
names = ctx.client.called_names()
|
||
assert "get_entity_detail" in names
|
||
assert "add_leader_annotation" in names
|
||
assert "highlight_entities" in names
|
||
|
||
|
||
def test_annotate_leader_payload_shape():
|
||
ctx = make_ctx(client=build_client())
|
||
annotate(ctx, [{"handle": "A", "severity": "high", "message": "m"}], log=lambda *a: None)
|
||
args = ctx.client.arguments_of("add_leader_annotation")
|
||
assert args["layer"] == "AI_REVIEW"
|
||
assert len(args["points"]) == 2
|
||
assert args["points"][0] == [5.0, 5.0, 0.0] # 实体定位点
|
||
assert args["text_position"] == args["points"][1]
|
||
assert args["text"].startswith("[high]")
|
||
|
||
|
||
def test_annotate_skips_when_unlocatable():
|
||
logs = []
|
||
ctx = make_ctx(client=build_client(detail=ok_text({})))
|
||
created, highlighted = annotate(
|
||
ctx, [{"handle": "A", "message": "x"}], log=logs.append
|
||
)
|
||
assert created == [] and highlighted == []
|
||
assert "add_leader_annotation" not in ctx.client.called_names()
|
||
assert any("无法定位坐标" in line for line in logs)
|
||
|
||
|
||
def test_annotate_tool_error_does_not_highlight():
|
||
logs = []
|
||
ctx = make_ctx(client=build_client(fail_leader=True))
|
||
created, highlighted = annotate(
|
||
ctx, [{"handle": "A", "message": "x"}], log=logs.append
|
||
)
|
||
assert created == [] and highlighted == []
|
||
assert "highlight_entities" not in ctx.client.called_names()
|
||
assert any("批注失败" in line for line in logs)
|
||
|
||
|
||
def test_annotate_handles_multiple_created_entities():
|
||
client = build_client(created={"created": [{"handle": "N1"}, {"handle": "N2"}]})
|
||
ctx = make_ctx(client=client)
|
||
created, _ = annotate(ctx, [{"handle": "A", "message": "x"}], log=lambda *a: None)
|
||
assert created == ["N1", "N2"]
|
||
|
||
|
||
def test_annotate_dedupes_repeated_handle():
|
||
"""同一 handle 出现两次只批注一次(created 与 highlighted 均去重)。"""
|
||
ctx = make_ctx(client=build_client())
|
||
created, highlighted = annotate(
|
||
ctx,
|
||
[
|
||
{"handle": "A", "severity": "high", "message": "第一次"},
|
||
{"handle": "A", "severity": "high", "message": "第二次"},
|
||
],
|
||
log=lambda *a: None,
|
||
)
|
||
assert created == ["NEW1"]
|
||
assert highlighted == ["A"]
|
||
assert len(ctx.client.called("add_leader_annotation")) == 1
|
||
|
||
|
||
def test_annotate_propagates_protocol_error_on_detail():
|
||
ctx = make_ctx(client=FakeMcpClient(responses={"get_entity_detail": McpError(-32602, "bad")}))
|
||
# annotate 只兜 add_leader/highlight 的错;detail 的协议错误会向上冒泡(现状行为)
|
||
with pytest.raises(McpError):
|
||
annotate(ctx, [{"handle": "A", "message": "x"}], log=lambda *a: None)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# clear
|
||
# ---------------------------------------------------------------------------
|
||
def test_clear_deletes_and_restores():
|
||
ctx = make_ctx(client=FakeMcpClient())
|
||
clear(ctx, handles=["N1"], highlighted=["A"], log=lambda *a: None)
|
||
assert ctx.client.arguments_of("delete_entities") == {"handles": ["N1"]}
|
||
assert ctx.client.arguments_of("highlight_entities") == {"handles": ["A"], "restore": True}
|
||
|
||
|
||
def test_clear_noop_without_arguments():
|
||
ctx = make_ctx(client=FakeMcpClient())
|
||
clear(ctx, log=lambda *a: None)
|
||
assert ctx.client.calls == []
|
||
|
||
|
||
def test_clear_swallows_tool_errors():
|
||
logs = []
|
||
client = FakeMcpClient(responses={"delete_entities": McpToolError("删不掉")})
|
||
ctx = make_ctx(client=client)
|
||
clear(ctx, handles=["N1"], log=logs.append)
|
||
assert any("撤销失败" in line for line in logs)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 离线源(tools=None)
|
||
# ---------------------------------------------------------------------------
|
||
def test_annotate_offline_returns_empty_without_touching_cad():
|
||
logs = []
|
||
ctx = make_ctx(connect_cad=False)
|
||
created, highlighted = annotate(
|
||
ctx, [{"handle": "H1", "severity": "high", "message": "x"}], log=logs.append
|
||
)
|
||
assert created == [] and highlighted == []
|
||
assert any("离线" in line for line in logs)
|
||
|
||
|
||
def test_clear_offline_noop():
|
||
ctx = make_ctx(connect_cad=False)
|
||
clear(ctx, handles=["N1"], highlighted=["A"], log=lambda *a: None) # 不应抛异常
|