Files
cad-agent/dwg_review/checks.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

212 lines
7.6 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.
"""确定性检查:MCP 工具(find_zero_length / find_duplicate_entities / find_collisions)
+ 纯 Python 派生。
产出的 issue 带 `source="deterministic"`,其 handle 来自工具/数据本身,
因此被视为**可信**,可直接进入批注链路,不依赖 LLM 回忆。
注意:`find_zero_length` / `find_duplicate_entities` 的 `window` 参数在 C++ 侧
(`parseCommonQuery`)**实际生效**,只是 JSON schema 未声明——Python 侧显式传参即可。
"""
from . import config
from .mcp_client import McpError, McpToolError, result_json
def make_issue(category, severity, message, *, handle=None, handles=None,
source="deterministic"):
"""构造统一 issue(`verified` 由 findings 层补齐)。"""
issue = {
"category": category,
"severity": severity,
"message": message,
"source": source,
}
if handle:
issue["handle"] = handle
if handles:
issue["handles"] = list(handles)
return issue
def _iter_items(envelope):
"""健壮迭代 items(`result_json` 解析失败会返回原始字符串)。"""
if not isinstance(envelope, dict):
return []
items = envelope.get("items")
if not isinstance(items, list):
return []
return [i for i in items if isinstance(i, dict)]
# ---------------------------------------------------------------------------
# MCP 检查
# ---------------------------------------------------------------------------
def check_zero_length(ctx, window, *, tolerance, limit, log=print):
"""find_zero_length(window) → 逐条零长度实体转 issue。"""
try:
resp = ctx.tools.call(
"find_zero_length",
{"window": window, "tolerance": tolerance, "limit": limit},
)
except (McpError, McpToolError) as exc:
log(f" [确定性] find_zero_length 失败: {exc}")
return []
out = []
for item in _iter_items(result_json(resp, {})):
handle = item.get("handle")
if not handle:
continue
out.append(
make_issue(
"几何", "medium",
f"零长度/退化实体(类型 {item.get('type')},长度 {item.get('length')})",
handle=handle,
)
)
return out
def check_duplicate_entities(ctx, window, *, tolerance, group_limit, log=print):
"""find_duplicate_entities(window) → 每个重复组一条 issue。
`limit` 的语义是「最大重复组数」(非条目数)。一组重复是一个问题,
故只产出一条 issue,主 handle 取组内首个,全部 handle 放进 `handles`。
"""
try:
resp = ctx.tools.call(
"find_duplicate_entities",
{"window": window, "tolerance": tolerance, "limit": group_limit},
)
except (McpError, McpToolError) as exc:
log(f" [确定性] find_duplicate_entities 失败: {exc}")
return []
env = result_json(resp, {})
groups = env.get("groups") if isinstance(env, dict) else None
out = []
for group in groups or []:
if not isinstance(group, dict):
continue
handles = [h for h in (group.get("handles") or []) if h]
if not handles:
continue
out.append(
make_issue(
"几何", "low",
f"重复/重叠实体组(类型 {group.get('type')},共 {len(handles)} 个几何相同)",
handle=handles[0],
handles=handles,
)
)
return out
def check_collisions(ctx, window, *, min_overlap, limit, log=print):
"""find_collisions(window) → 每条「包围盒干涉候选」转 issue。
**候选**语义:AABB 相交不等于真干涉(L 形布局、相邻管段接口、标注引线跨越
图元都会相交),故严重度取 medium、category 单列「干涉」,由报告层 / 人复核。
重叠比 `ratio` 写进 message,便于按可疑程度排序与人工筛选。
"""
try:
resp = ctx.tools.call(
"find_collisions",
{"window": window, "min_overlap": min_overlap, "limit": limit},
)
except (McpError, McpToolError) as exc:
log(f" [确定性] find_collisions 失败: {exc}")
return []
out = []
for item in _iter_items(result_json(resp, {})):
a = item.get("a") if isinstance(item.get("a"), dict) else {}
b = item.get("b") if isinstance(item.get("b"), dict) else {}
ha, hb = a.get("handle"), b.get("handle")
if not ha or not hb:
continue
ov = item.get("overlap") if isinstance(item.get("overlap"), dict) else {}
ratio = ov.get("ratio")
ratio_txt = f"{ratio:.2f}" if isinstance(ratio, (int, float)) else "?"
out.append(
make_issue(
"干涉", "medium",
f"包围盒干涉候选({a.get('type')}/{ha} × {b.get('type')}/{hb},"
f"重叠比 {ratio_txt},同层={item.get('same_layer')})",
handle=ha,
handles=[ha, hb],
)
)
return out
# ---------------------------------------------------------------------------
# 纯 Python 派生(零额外 MCP 往返)
# ---------------------------------------------------------------------------
def _empty_texts(evidence):
out = []
for item in _iter_items(evidence.get("texts")):
content = item.get("content")
if content is None or str(content).strip() == "":
out.append(
make_issue("文字", "medium", "存在空文字或仅含空格的文字",
handle=item.get("handle"))
)
return out
def _unclosed_polylines(evidence):
out = []
for item in _iter_items(evidence.get("polylines")):
if item.get("vertex_count", 0) >= 3 and item.get("closed") is False:
out.append(
make_issue("几何", "low",
"多段线顶点≥3但未闭合,可能是应当闭合的轮廓",
handle=item.get("handle"))
)
return out
def derive_from_evidence(evidence, *, check_unclosed=True):
"""从已采集证据里做零成本派生检查。"""
issues = _empty_texts(evidence)
if check_unclosed:
issues += _unclosed_polylines(evidence)
return issues
# ---------------------------------------------------------------------------
# 入口
# ---------------------------------------------------------------------------
def run_deterministic_checks(ctx, evidence, *, log=print):
"""对单个图框跑全部确定性检查,返回 issue 列表。
任何单项 MCP 失败都只记录并跳过,不抛出(不拖垮该图框)。
"""
issues = derive_from_evidence(
evidence, check_unclosed=config.CHECK_UNCLOSED_POLYLINES
)
window = evidence.get("window")
# 离线源(PDF / 图片)没有 CAD 可查:tools 为 None,只跑纯 Python 派生检查。
if window and ctx.tools is not None:
issues += check_zero_length(
ctx, window,
tolerance=config.ZERO_LENGTH_TOLERANCE,
limit=config.FRAME_ENTITY_LIMIT,
log=log,
)
issues += check_duplicate_entities(
ctx, window,
tolerance=config.DUP_TOLERANCE,
group_limit=config.DUP_GROUP_LIMIT,
log=log,
)
if config.CHECK_COLLISIONS:
issues += check_collisions(
ctx, window,
min_overlap=config.COLLISION_MIN_OVERLAP,
limit=config.FRAME_ENTITY_LIMIT,
log=log,
)
return issues