向 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 加入新增源文件
311 lines
11 KiB
Python
311 lines
11 KiB
Python
"""
|
||
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()
|