Files
cad-agent/dwg_review_agent.py
gjm bdd77af041 feat(review): 新增图纸审查工具集与逐图框审查流程
向 MCP 基座注册 15 个 C++ 工具(cad_mcp_plugins.dll):
- 概览: get_drawing_info / get_layers / get_layouts
- 数据: query_entities / get_text_content / get_dimensions /
        get_block_references / get_polylines_info / get_entity_detail
- 图框识别: detect_drawing_frames(块/闭合多段线/直线组三种形态,推算图幅)
- 检查: find_duplicate_entities / find_zero_length
- 视图: zoom_to_entity / zoom_to_window / highlight_entities

其它:
- 抽出 dwg_review_common 公共层(实体遍历 / handle / bbox / 分页 / 错误码)
- 工具返回统一为 MCP content 信封,与既有截图工具保持一致
- 新增 dwg_review_agent.py 逐图框审查脚本(deepseek-flash)
- 新增可配置审查清单 review_checklist.json
- 更新 cad-mcp-frame.json:合并视图工具与审查工具,共 18 个
- cmakelists 加入新增源文件
2026-10-08 15:21:23 +08:00

311 lines
11 KiB
Python
Raw Permalink 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_agent.py
====================
逐图框图纸审查 Agent(联调脚本)
流程:
1. detect_drawing_frames 识别图纸中的所有图框
2. 对每个图框:
a. zoom_to_window 缩放到该图框范围
b. query_entities 取图框内图元摘要(按 window 过滤)
c. get_text_content 取图框内文字
d. get_viewport_screenshot 截取当前视口
e. 交给 LLM 审查,产出该图框的审查意见
3. 汇总所有图框意见,生成总报告
依赖:
pip install openai
环境变量:
DEEPSEEK_API_KEY (不设置时回退到脚本内默认值)
DEEPSEEK_MODEL (默认 deepseek-chat)
使用前提:
CAD 已打开并加载了 cad_mcp_frame.arx,8080 端口在监听。
"""
import os
import sys
import json
import time
import socket
from openai import OpenAI
HOST = "127.0.0.1"
PORT = 8080
# 与仓库既有脚本保持一致;生产环境请改为从环境变量注入
API_KEY = os.environ.get("DEEPSEEK_API_KEY", "sk-420190f448fe41158c4e2ccff90e35ce")
BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
MODEL_NAME = os.environ.get("DEEPSEEK_MODEL", "deepseek-flash") # 该模型已确认支持视觉
# 每个图框审查时,取数据的条数上限
FRAME_ENTITY_LIMIT = int(os.environ.get("FRAME_ENTITY_LIMIT", "200"))
# 最多审查多少个图框(0 = 全部)
MAX_FRAMES = int(os.environ.get("MAX_FRAMES", "0"))
# 审查清单文件路径
CHECKLIST_PATH = os.environ.get("REVIEW_CHECKLIST", "review_checklist.json")
client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
# ---------------------------------------------------------------------------
# MCP / TCP 通信
# ---------------------------------------------------------------------------
def call_tool(tool_name, arguments, recv_timeout=30.0):
"""调用 CAD 基座的 tools/call,返回解析后的 JSON-RPC 响应(失败返回 None)"""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(recv_timeout)
sock.connect((HOST, PORT))
req = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": tool_name, "arguments": arguments},
}
sock.sendall(json.dumps(req).encode("utf-8"))
data = b""
while True:
chunk = sock.recv(65536)
if not chunk:
break
data += chunk
# 基座是单次 send 返回整包;收到后尝试解析即可结束
try:
json.loads(data.decode("utf-8"))
break
except json.JSONDecodeError:
continue
sock.close()
return json.loads(data.decode("utf-8"))
except Exception as e:
print(f"[Error] 调用工具 {tool_name} 失败: {e}")
return None
def mcp_text(resp):
"""从 MCP 响应中取出文本内容;若文本是 JSON 则解析为对象返回"""
if not resp or "result" not in resp:
return None
for item in resp["result"].get("content", []):
if item.get("type") == "text":
text = item.get("text", "")
try:
return json.loads(text)
except (json.JSONDecodeError, TypeError):
return text
return None
def mcp_image(resp):
"""从 MCP 响应中取出图片,返回 (base64, mimeType)"""
if not resp or "result" not in resp:
return None, None
for item in resp["result"].get("content", []):
if item.get("type") == "image":
return item.get("data"), item.get("mimeType", "image/png")
return None, None
def mcp_error(resp):
"""若响应是错误,返回错误信息字符串,否则返回 None"""
if resp and "error" in resp:
err = resp["error"]
return f"code={err.get('code')} message={err.get('message')}"
return None
def bbox_to_window(bbox):
"""[minx,miny,maxx,maxy] -> window 对象"""
return {"min_x": bbox[0], "min_y": bbox[1], "max_x": bbox[2], "max_y": bbox[3]}
# ---------------------------------------------------------------------------
# LLM 审查(审查清单可配置)
# ---------------------------------------------------------------------------
def load_checklist(path):
"""加载审查清单 JSON;失败时回退到内置通用清单"""
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict) and data.get("categories"):
return data
print(f"[Warn] 审查清单 {path} 结构异常,使用内置默认清单。")
except Exception as e:
print(f"[Warn] 无法加载审查清单 {path}: {e},使用内置默认清单。")
return {
"name": "内置通用清单",
"categories": [
{"title": "图层规范性", "severity": "medium",
"checks": ["图元是否在正确图层", "是否存在空图层/命名异常图层"]},
{"title": "文字", "severity": "medium",
"checks": ["空文字", "错别字", "字高异常", "文字重叠"]},
{"title": "标注", "severity": "high",
"checks": ["关键尺寸缺失", "标注值异常"]},
{"title": "几何", "severity": "medium",
"checks": ["零长度/退化实体", "重复重叠实体", "未闭合轮廓"]},
{"title": "图面整体", "severity": "low",
"checks": ["图面完整性", "内容是否超出图框"]},
],
}
def build_system_prompt(checklist):
"""根据审查清单动态生成 LLM 系统提示词"""
lines = [
"你是一名资深建筑与工业厂房图纸审查专家。",
"你将逐张收到某个图框(一张图纸)的:结构化图元数据、文字内容,以及该图框的视口截图。",
"",
f"请依据审查清单《{checklist.get('name', '审查清单')}》逐项审查,"
"指出**具体问题**(尽量引用实体 handle 或文字内容),并标注严重程度:",
"",
]
for cat in checklist.get("categories", []):
lines.append(f"【{cat.get('title', '')}】(严重度:{cat.get('severity', 'medium')})")
for chk in cat.get("checks", []):
lines.append(f" - {chk}")
lines.append("")
lines += [
"要求:",
"- 只报告你**有依据**的问题,不要臆测;数据与截图冲突时以数据为准。",
"- 按清单条目组织结论,未命中的条目可省略。",
"- 若该图框无任何明显问题,明确说“未发现明显问题”。",
"- 不要复述输入数据。",
]
return "\n".join(lines)
def review_frame(idx, frame, entities, texts, img_b64, mime, system_prompt):
"""对单个图框调用 LLM 审查,返回审查意见文本"""
sheet = frame.get("sheet_size") or "未知图幅"
kind = frame.get("kind")
layer = frame.get("layer")
summary = {
"图框序号": idx,
"图幅": sheet,
"形态": kind,
"图层": layer,
"包围盒": frame.get("bbox"),
"图元总数": (entities or {}).get("total") if isinstance(entities, dict) else None,
"图元(截断)": (entities or {}).get("items") if isinstance(entities, dict) else None,
"文字": (texts or {}).get("items") if isinstance(texts, dict) else None,
}
user_content = [
{"type": "text", "text": "以下是该图框的审查输入数据(JSON):\n" +
json.dumps(summary, ensure_ascii=False, indent=2)}
]
if img_b64:
user_content.append({
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{img_b64}"},
})
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
]
try:
resp = client.chat.completions.create(model=MODEL_NAME, messages=messages)
return resp.choices[0].message.content
except Exception as e:
return f"[LLM 审查失败] {e}"
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
def main():
print("=== 🏗️ 逐图框图纸审查 Agent 启动 ===")
# 0. 载入可配置审查清单
checklist = load_checklist(CHECKLIST_PATH)
system_prompt = build_system_prompt(checklist)
print(f"[Init] 审查清单:{checklist.get('name')}"
f"({len(checklist.get('categories', []))} 类),模型:{MODEL_NAME}\n")
# 1. 识别图框
resp = call_tool("detect_drawing_frames", {"min_confidence": 0.3})
err = mcp_error(resp)
if err:
print(f"[Error] 识别图框失败: {err}")
return
frames_data = mcp_text(resp)
items = frames_data.get("items", []) if isinstance(frames_data, dict) else []
if not items:
print("未识别到任何图框,请确认图纸中存在图框,或调整 detect_drawing_frames 参数。")
return
total = len(items)
if MAX_FRAMES > 0:
items = items[:MAX_FRAMES]
print(f"识别到 {total} 个图框,本次审查 {len(items)} 个。\n")
findings = []
# 2. 逐图框审查
for idx, frame in enumerate(items, 1):
bbox = frame.get("bbox")
if not bbox or len(bbox) != 4:
print(f"[跳过] 图框 {idx} 缺少有效包围盒")
continue
print(f"--- 图框 {idx}/{len(items)} kind={frame.get('kind')} "
f"sheet={frame.get('sheet_size')} bbox={bbox}")
win = bbox_to_window(bbox)
# a. 缩放到图框
call_tool("zoom_to_window", dict(win, margin=0.02))
time.sleep(0.6) # 给 CAD 渲染留出时间
# b/c. 取图框内数据
entities = mcp_text(call_tool("query_entities", {
"window": win, "limit": FRAME_ENTITY_LIMIT
}))
texts = mcp_text(call_tool("get_text_content", {
"window": win, "limit": FRAME_ENTITY_LIMIT
}))
# d. 截图
shot = call_tool("get_viewport_screenshot", {})
img_b64, mime = mcp_image(shot)
print(f" 图元 {(entities or {}).get('total')} 个,"
f"文字 {(texts or {}).get('total')} 条,"
f"截图 {'已获取' if img_b64 else '无'}")
# e. LLM 审查
finding = review_frame(idx, frame, entities, texts, img_b64, mime, system_prompt)
findings.append({"frame": idx, "sheet": frame.get("sheet_size"), "finding": finding})
print(f" 审查结论:{finding}\n")
# 3. 汇总总报告
print("=== 📋 汇总总报告 ===")
report_input = "\n\n".join(
f"【图框 {f['frame']}(图幅 {f['sheet']})】\n{f['finding']}" for f in findings
)
try:
resp = client.chat.completions.create(
model=MODEL_NAME,
messages=[
{"role": "system", "content":
"你是图纸审查负责人。请把下面各图框的审查意见汇总为一份"
"结构化的审查报告:先给整体结论,再按图框列出问题,最后给出整改建议。"
"语言简洁、条目化。"},
{"role": "user", "content": report_input},
],
)
print(resp.choices[0].message.content)
except Exception as e:
print(f"[汇总失败] {e}")
print(report_input)
if __name__ == "__main__":
main()