diff --git a/.gitignore b/.gitignore index 850720e..28d28f2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,13 @@ lib/ *.idb *.pch *.tlog -*.tmp \ No newline at end of file +*.tmp + +# Python +__pycache__/ +*.pyc +# 审查报告输出目录 +reports/ +# AutoCAD 锁文件 +*.dwl +*.dwl2 \ No newline at end of file diff --git a/ai.memory/MEMORY.md b/ai.memory/MEMORY.md index a6cf1bf..4afbb1f 100644 --- a/ai.memory/MEMORY.md +++ b/ai.memory/MEMORY.md @@ -5,6 +5,7 @@ > 组织方式:本文件是索引(一行一条),各主题内容放在独立文件中。 - [CADProject 图纸审查 Agent 工作](project_dwg_review_agent.md) — 基于 MCP 框架的图纸审查工具集与逐图框审查流程 +- [LLM 识图审图 Agent 架构决策](project_review_agent_impl.md) — 混合式自研 agent、基座 B 最小集、标准化先走 stdio 桥;含 dwg_review 包结构 - [MCP 基座 c++_dll 加载与执行的坑](project_mcp_frame_dll_loader.md) — acrxGetApiVersion 有状态不可比对、写库需文档锁、同一 DLL 只加载一次 - [用户协作偏好](feedback_preferences.md) — 调试用 Debug/R230、模型 deepseek-flash、DLL 集中在 cad_mcp_plugins 等 - [环境与外部资源](reference_environment.md) — 构建/运行要点、CAD SDK 路径、测试脚本 diff --git a/ai.memory/project_review_agent_impl.md b/ai.memory/project_review_agent_impl.md new file mode 100644 index 0000000..12bd6dc --- /dev/null +++ b/ai.memory/project_review_agent_impl.md @@ -0,0 +1,66 @@ +--- +name: LLM 识图审图 Agent —— 架构决策(已定稿) +description: cad_mcp_frame + 自研 Agent 的架构决策与分阶段路线——混合式(流水线骨架+受控只读取证子循环)、基座 B 最小集、标准化路线(先 stdio 桥) +type: project +--- + +# LLM 识图审图 Agent —— 架构决策(2026-10-13 定稿) + +## 决策 + +1. **Agent 形态:混合式自研**(非纯 ReAct、非纯流水线):确定性流水线骨架 + (规划 → 逐图框取证 → 审查 → 汇总)+ 审查阶段给 LLM 一个**受控只读取证工具子循环** + (白名单 15 个只读工具,`add_*/delete_*/set_*/draw_*` 一律禁止)。 +2. **审查依据**:清单驱动(`review_checklist.json`)+ LLM 自主判断。 +3. **输出**:Markdown + JSON 报告 + 可选 CAD 图上批注回写(统一落 `AI_REVIEW` 层)。 +4. **基座改造:只做「B 最小集」**——换行分帧(`\n` 为消息边界)、去掉单次 `recv(2048)` 限制、 + `isError` 映射、`id` 支持 string/number + 通知不回包;**不做** `initialize`/`ping` 握手。 +5. **标准化路线**:先自研 agent 跑通 → 再做 stdio↔TCP 桥 → 远期才考虑 Streamable HTTP。 + +**Why(关键结论,非显而易见)**: + +- 基座是**裸 TCP 自定义传输**。规范虽允许自定义传输,但标准客户端(Claude Desktop / + CodeBuddy / MCP Inspector)**只实现 stdio 与 Streamable HTTP**——因此裸 TCP 无论怎么补 + 生命周期都**无法被标准客户端直连**。"能被标准客户端接入"只有两条路:**stdio 桥** + (省力、CAD 侧零改动)或 **Streamable HTTP**(彻底、要在 CAD 内做 HTTP+SSE、最贵)。 +- 自研 agent 直接连 TCP,传输是否"标准"**不影响**,故**标准化不是前置条件**。 +- `mcp_server.cpp` 原来**单次 `recv` 仅 2048 字节**,请求超 2KB 会被截断成 `-32700`; + 这是**服务端**限制、客户端绕不过去 → 必须先改基座。 +- image 内容块 `{type:image,data,mimeType}` **本就是 MCP 标准**(`source:{type,mediaType}` + 是 Anthropic Messages API 的格式,别混淆),无需改。 + +**How to apply**:新增审图工具仍走 `cad_mcp_plugins` + `mcp_config.json`;agent 侧工具从 +`tools/list` **动态发现**,加工具零代码。日后要做标准客户端接入,**从 stdio 桥起步**。 + +## 新增产物 + +- `dwg_review/`(Python 包):`config.py`(环境变量,无硬编码 Key)、`mcp_client.py` + (唯一 TCP 封装:单飞锁 + 换行分帧 + 重试 + 兼容 `error`/`isError`)、`llm_client.py`、 + `cad_tools.py`(动态工具表 + 只读白名单 `READ_ONLY_TOOLS`)、`checklist.py`、 + `phases/{planning,evidence,review,report}.py`、`annotator.py`、`__main__.py`。 + **取代**根目录散落的 `dwg_review_agent.py` / `agent_loop.py` 等重复脚本。 +- `verify_mcp_frame.py`:基座改动验证脚本(分帧 / 大报文 / string id / 通知 / isError)。 + +## 环境事实 + +- 本机 **未安装 Pillow** → 截图下采样为**可选**(缺失时原样传原图,视觉 token 成本更高)。 + 如需控成本:`pip install pillow`。 +- 视觉模型 `deepseek-flash`。 +- **`DEEPSEEK_API_KEY` 设在 Windows `Machine`(系统)级**。因新设的系统变量**不会传播到已启动 + 的进程**,`dwg_review/config.py` 的 `_read_env()` 在进程环境读不到时**回退读注册表** + (`HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment` → `HKCU\Environment`)。 + 否则 CodeBuddy 内的命令永远读不到新设的 Key。 +- **AutoCAD 2020 自动加载 ARX(用户配置,不在仓库内)**:在 `bin/Debug-R230/` 下建 + `acad.rx`(内容一行 `cad_mcp_frame.arx`),并把该目录加入 CAD 的**搜索路径与信任路径**。 + 这是 AutoCAD 官方 RX 自动加载机制,比 `acaddoc.lsp` + LISP 更直接。 +- AutoCAD 2020 安装于 `C:/Program Files/Autodesk/AutoCAD 2020/`;用户配置目录 + `C:/Users/gjm/AppData/Roaming/Autodesk/AutoCAD 2020/R23.1/chs/`(`acaddoc.lsp` 已用于 PDF2CAD,勿覆盖)。 + +## 验证结论(2026-10-13 实测通过) + +- `verify_mcp_frame.py` 4/4 PASS(换行分帧 / >2KB 报文不截断 / string id 原样回显 / 通知不回包 / isError)。 +- `verify_annotate.py` 9/9 PASS(批注工具无回归)。 +- `dwg_review --list-tools` = 26 个工具;`--plan-only` 取证正常;全流程跑出报告。 +- **受控取证子循环实测生效**:LLM 自主调用了白名单内的 `get_drawing_info`/`get_layers`/ + `find_zero_length`/`find_duplicate_entities`/`get_dimensions` 做二次确认。 +- 批注回写实测通过;注意 `add_leader_annotation` 的 `points` **必须 ≥2 个点**(首个为箭头)。 diff --git a/dwg_review/__init__.py b/dwg_review/__init__.py new file mode 100644 index 0000000..a36afe3 --- /dev/null +++ b/dwg_review/__init__.py @@ -0,0 +1,11 @@ +"""dwg_review —— CAD 图纸审查 Agent(基于 cad_mcp_frame MCP 基座)。 + +混合式架构: + - 确定性流水线骨架(规划 → 逐图框取证 → 审查 → 汇总) + - 审查阶段给 LLM 一个受控的只读取证工具子循环 + +入口: + python -m dwg_review --help +""" + +__version__ = "0.1.0" diff --git a/dwg_review/__main__.py b/dwg_review/__main__.py new file mode 100644 index 0000000..923198a --- /dev/null +++ b/dwg_review/__main__.py @@ -0,0 +1,176 @@ +"""CLI 入口:python -m dwg_review [options]""" + +import argparse +import json +import sys +from pathlib import Path + +from . import config +from .annotator import annotate, clear +from .checklist import build_system_prompt, checklist_summary +from .context import ReviewContext +from .mcp_client import McpError, McpToolError, result_json +from .phases import evidence as evidence_phase +from .phases import planning, report, review + + +def _dump_evidence(evidences): + out_dir = Path(config.REPORT_DIR) + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / "evidence.json" + trimmed = [{k: v for k, v in e.items() if k != "image_b64"} for e in evidences] + path.write_text(json.dumps(trimmed, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + +def cmd_list_tools(ctx): + tools = ctx.tools.refresh() + print(f"共 {len(tools)} 个工具:") + for tool in tools: + print(f" - {tool['name']}") + return 0 + + +def cmd_check(ctx): + if not ctx.client.is_available(): + print(f"[X] {config.HOST}:{config.PORT} 未监听,请先启动已加载插件的 CAD。") + return 1 + print(f"[OK] {config.HOST}:{config.PORT} 在监听") + info = result_json(ctx.tools.call("get_drawing_info", {}), {}) + print("图纸信息:", json.dumps(info, ensure_ascii=False)[:400]) + return 0 + + +def run_review(ctx, args): + print("=== 图纸审查 Agent ===") + print(f"清单:{checklist_summary(ctx.checklist)}") + if ctx.llm is not None: + print(f"模型:{ctx.llm.model}") + print() + + print("[1/4] 规划:识别图框与概览 ...") + plan = planning.plan(ctx) + frames = plan.get("frames", []) + print(f" 识别到 {len(frames)} 个图框") + if not frames: + print("未识别到任何图框,退出。") + return 1 + + system_prompt = build_system_prompt(ctx.checklist, allow_tools=True) + + print("[2/4] 取证:逐图框采集(串行) ...") + evidences = [] + for idx, frame in enumerate(frames, 1): + print( + f" - 图框 {idx}/{len(frames)} kind={frame.get('kind')} " + f"sheet={frame.get('sheet_size')}" + ) + ev = evidence_phase.collect_frame(ctx, idx, frame) + if ev is None: + print(" [跳过] 缺少有效包围盒") + continue + evidences.append(ev) + print( + f" 图元 {(ev.get('entities') or {}).get('total')} 条," + f"截图 {'有' if ev.get('image_b64') else '无'}" + ) + + if args.plan_only: + path = _dump_evidence(evidences) + print(f"[evidence] 已写入 {path}") + return 0 + + print("[3/4] 审查:逐图框 LLM 审查(含受控只读取证子循环) ...") + findings = [] + for ev in evidences: + print(f" - 审查图框 {ev['index']} ...") + text = review.review_frame(ctx, ev, system_prompt) + findings.append( + { + "frame": ev["index"], + "sheet": (ev.get("frame") or {}).get("sheet_size"), + "finding": text, + } + ) + preview = text if len(text) <= 200 else text[:200] + "..." + print(f" 结论:{preview}") + + print("[4/4] 汇总报告 ...") + markdown = report.summarize(ctx, findings) + issues = report.extract_issues(ctx, findings) + report.save_report(markdown, findings, plan, issues) + print() + print(markdown) + + if args.annotate: + print() + print(f"[批注回写] {'(dry-run) ' if args.dry_run else ''}结构化问题 {len(issues)} 条") + created, highlighted = annotate(ctx, issues, dry_run=args.dry_run) + if not args.dry_run and (created or highlighted): + hint = "python -m dwg_review --clear" + if created: + hint += f" {','.join(created)}" + if highlighted: + hint += f" --restore {','.join(highlighted)}" + print(f" 撤销:{hint}") + return 0 + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="dwg_review", description="CAD 图纸审查 Agent(基于 cad_mcp_frame MCP 基座)" + ) + parser.add_argument("--list-tools", action="store_true", help="列出基座注册的全部工具") + parser.add_argument("--check", action="store_true", help="检查基座连通性并打印图纸概览") + parser.add_argument("--plan-only", action="store_true", help="只做规划+取证,落盘 evidence.json") + parser.add_argument("--frames", type=int, default=None, help="最多审查的图框数(0=全部)") + parser.add_argument("--annotate", action="store_true", help="审查后把问题批注回写到图上") + parser.add_argument("--dry-run", action="store_true", help="批注只打印不回写") + parser.add_argument("--clear", default=None, help="删除给定 handle(逗号分隔)以撤销批注") + parser.add_argument("--restore", default=None, help="还原给定 handle(逗号分隔)的原始颜色") + parser.add_argument("--checklist", default=None, help="审查清单 JSON 路径") + return parser + + +def main(argv=None): + args = build_parser().parse_args(argv) + + if args.checklist: + config.CHECKLIST_PATH = Path(args.checklist) + + need_llm = not ( + args.list_tools or args.check or args.plan_only or args.clear or args.restore + ) + try: + ctx = ReviewContext.create(need_llm=need_llm) + except McpError as exc: + print(f"[X] 无法连接 CAD 基座:{exc}") + return 1 + except Exception as exc: # noqa: BLE001 + print(f"[X] 初始化失败:{exc}") + return 1 + + if args.frames is not None: + ctx.max_frames = args.frames + + try: + if args.list_tools: + return cmd_list_tools(ctx) + if args.check: + return cmd_check(ctx) + if args.clear or args.restore: + handles = [h.strip() for h in (args.clear or "").split(",") if h.strip()] + highlighted = [h.strip() for h in (args.restore or "").split(",") if h.strip()] + clear(ctx, handles, highlighted) + return 0 + return run_review(ctx, args) + except (McpError, McpToolError) as exc: + print(f"[X] 调用失败:{exc}") + return 1 + except KeyboardInterrupt: + print("\n[中断]") + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dwg_review/annotator.py b/dwg_review/annotator.py new file mode 100644 index 0000000..8364f43 --- /dev/null +++ b/dwg_review/annotator.py @@ -0,0 +1,116 @@ +"""批注回写:把审查问题标到 CAD 图上(统一落 AI_REVIEW 层)。 + +只在问题带有 handle 时才回写。产出两类可撤销的副作用: + - created :新建的批注实体(引线等)→ 用 delete_entities 删除 + - highlighted :被临时改色的原实体 → 用 highlight_entities(restore=true) 还原 +""" + +from . import config +from .mcp_client import McpError, McpToolError, result_json + +# 引线落点相对实体定位点的偏移(图纸单位) +_LEADER_OFFSET = (30.0, 20.0) + + +def _pick_point(detail): + """从实体详情中挑一个用于放引线的点(WCS)。""" + if not isinstance(detail, dict): + return None + + bbox = detail.get("bbox") + if isinstance(bbox, (list, tuple)) and len(bbox) == 4: + return [ + (float(bbox[0]) + float(bbox[2])) / 2.0, + (float(bbox[1]) + float(bbox[3])) / 2.0, + 0.0, + ] + + for key in ("position", "insert", "center", "start"): + pt = detail.get(key) + if isinstance(pt, (list, tuple)) and len(pt) >= 2: + return [float(pt[0]), float(pt[1]), float(pt[2]) if len(pt) > 2 else 0.0] + return None + + +def annotate(ctx, issues, *, dry_run=False, log=print): + """按问题列表回写批注,返回 (created, highlighted) 两个 handle 列表。""" + created = [] + highlighted = [] + + for issue in issues: + handle = issue.get("handle") + if not handle: + continue + + text = f"[{issue.get('severity', '?')}] {issue.get('message', '')}" + + if dry_run: + log(f"[dry-run] 批注 handle={handle} 层={config.ANNOTATE_LAYER}: {text}") + continue + + # 先定位,拿不到坐标就跳过(避免产生无意义的批注) + detail = result_json(ctx.tools.call("get_entity_detail", {"handle": handle}), {}) + point = _pick_point(detail) + if not point: + log(f"[跳过] handle={handle} 无法定位坐标") + continue + + landing = [ + point[0] + _LEADER_OFFSET[0], + point[1] + _LEADER_OFFSET[1], + 0.0, + ] + try: + res = result_json( + ctx.tools.call( + "add_leader_annotation", + { + "text": text, + "points": [point, landing], # 至少 2 个点,首个为箭头 + "text_position": landing, + "layer": config.ANNOTATE_LAYER, + }, + ), + {}, + ) + for item in (res or {}).get("created", []): + if item.get("handle"): + created.append(item["handle"]) + except (McpError, McpToolError) as exc: + log(f"[批注失败] handle={handle}: {exc}") + continue + + # 引线建好后再高亮原实体(失败不影响已创建的批注) + if handle in highlighted: + continue + try: + ctx.tools.call("highlight_entities", {"handle": handle, "color": 1}) + highlighted.append(handle) + except (McpError, McpToolError) as exc: + log(f"[高亮失败] handle={handle}: {exc}") + + if not dry_run: + log( + f"[批注] 新建 {len(created)} 个批注实体、高亮 {len(highlighted)} 个原实体" + f"(层 {config.ANNOTATE_LAYER})" + ) + return created, highlighted + + +def clear(ctx, handles=None, highlighted=None, *, log=print): + """撤销批注:删除批注实体 + 还原被高亮的实体。""" + if handles: + try: + ctx.tools.call("delete_entities", {"handles": list(handles)}) + log(f"[批注] 已删除 {len(handles)} 个批注实体") + except (McpError, McpToolError) as exc: + log(f"[撤销失败] {exc}") + + if highlighted: + try: + ctx.tools.call( + "highlight_entities", {"handles": list(highlighted), "restore": True} + ) + log(f"[批注] 已还原 {len(highlighted)} 个实体的颜色") + except (McpError, McpToolError) as exc: + log(f"[还原失败] {exc}") diff --git a/dwg_review/cad_tools.py b/dwg_review/cad_tools.py new file mode 100644 index 0000000..e4c7f35 --- /dev/null +++ b/dwg_review/cad_tools.py @@ -0,0 +1,80 @@ +"""工具表:从 tools/list 动态构建,新增工具无需改代码。""" + +from .mcp_client import McpClient + +# 只读工具白名单:审查阶段的受控子循环只允许调用这些(不做任何写库操作) +READ_ONLY_TOOLS = frozenset( + { + "query_entities", + "get_text_content", + "get_dimensions", + "get_block_references", + "get_polylines_info", + "get_entity_detail", + "get_layers", + "get_layouts", + "get_drawing_info", + "detect_drawing_frames", + "find_duplicate_entities", + "find_zero_length", + "zoom_to_window", + "zoom_to_entity", + "get_viewport_screenshot", + } +) + +# 写类工具前缀/名称:调用时不重试,且默认禁止 LLM 在审查阶段触发 +_WRITE_PREFIXES = ("add_", "delete_", "draw_", "set_") +_WRITE_EXACT = {"set_entity_properties", "highlight_entities"} + + +def is_write_tool(name: str) -> bool: + return name.startswith(_WRITE_PREFIXES) or name in _WRITE_EXACT + + +class CadTools: + """工具注册表 + 调用代理。""" + + def __init__(self, client=None): + self.client = client or McpClient() + self._by_name = {} + self.refresh() + + def refresh(self): + tools = self.client.list_tools(use_cache=False) + self._by_name = {t["name"]: t for t in tools} + return tools + + @property + def names(self): + return sorted(self._by_name) + + def has(self, name): + return name in self._by_name + + def describe(self, name): + return self._by_name.get(name) + + def openai_tools(self, names=None): + """转换为 OpenAI function 工具格式;names 为可选的名称白名单集合。""" + out = [] + for name, tool in self._by_name.items(): + if names is not None and name not in names: + continue + out.append( + { + "type": "function", + "function": { + "name": name, + "description": tool.get("description", ""), + "parameters": tool.get("inputSchema") + or {"type": "object", "properties": {}}, + }, + } + ) + return out + + def call(self, name, arguments=None, *, is_write=None): + if is_write is None: + is_write = is_write_tool(name) + return self.client.call_tool(name, arguments, is_write=is_write) diff --git a/dwg_review/checklist.py b/dwg_review/checklist.py new file mode 100644 index 0000000..f4799db --- /dev/null +++ b/dwg_review/checklist.py @@ -0,0 +1,75 @@ +"""审查清单加载 + 系统提示构建(清单驱动)。""" + +import json + +from . import config + +DEFAULT_CHECKLIST = { + "name": "内置通用清单", + "categories": [ + {"title": "图层规范性", "severity": "medium", + "checks": ["图元是否在正确图层", "是否存在空图层/命名异常图层"]}, + {"title": "文字", "severity": "medium", + "checks": ["空文字", "错别字", "字高异常", "文字重叠"]}, + {"title": "标注", "severity": "high", + "checks": ["关键尺寸缺失", "标注值异常"]}, + {"title": "几何", "severity": "medium", + "checks": ["零长度/退化实体", "重复重叠实体", "未闭合轮廓"]}, + {"title": "图面整体", "severity": "low", + "checks": ["图面完整性", "内容是否超出图框"]}, + ], +} + + +def load_checklist(path=None): + """加载审查清单 JSON;失败时回退内置默认清单。""" + path = path or config.CHECKLIST_PATH + 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 exc: # noqa: BLE001 + print(f"[Warn] 无法加载审查清单 {path}: {exc},使用内置默认清单。") + return DEFAULT_CHECKLIST + + +def build_system_prompt(checklist, *, allow_tools=True): + """根据审查清单动态生成 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("") + + if allow_tools: + lines += [ + "你可以调用**只读**取证工具进一步确认细节(例如放大局部、查实体详情、查重复实体)。", + "当结构化数据不足以判断时,先调用工具取证,再下结论。", + "", + ] + + lines += [ + "要求:", + "- 只报告你**有依据**的问题,不要臆测;数据与截图冲突时以数据为准。", + "- 按清单条目组织结论,未命中的条目可省略。", + "- 若该图框无任何明显问题,明确说“未发现明显问题”。", + "- 不要复述输入数据。", + ] + return "\n".join(lines) + + +def checklist_summary(checklist): + """用于规划阶段的一句话摘要。""" + cats = checklist.get("categories", []) + return f"{checklist.get('name', '清单')}({len(cats)} 类:" + \ + "、".join(c.get("title", "") for c in cats) + ")" diff --git a/dwg_review/config.py b/dwg_review/config.py new file mode 100644 index 0000000..c1532c9 --- /dev/null +++ b/dwg_review/config.py @@ -0,0 +1,100 @@ +"""集中配置:全部来自环境变量,禁止硬编码密钥。 + +用法:所有模块统一 `from . import config` 后读取 config.XXX。 +""" + +import os +from pathlib import Path + +# 仓库根目录(本文件位于 /dwg_review/config.py) +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +def _resolve_path(value: str) -> Path: + """相对路径按仓库根目录解析(避免依赖当前工作目录)。""" + p = Path(value) + return p if p.is_absolute() else (PROJECT_ROOT / p) + + +def _read_env(name: str) -> str: + """读取环境变量。 + + Windows 下新设置的系统/用户环境变量不会传播到**已启动**的进程, + 因此当前进程读不到时,回退到注册表中的 Machine / User 级环境变量。 + """ + value = os.environ.get(name) + if value: + return value + if os.name != "nt": + return "" + try: + import winreg + except ImportError: # pragma: no cover + return "" + locations = ( + (winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"), + (winreg.HKEY_CURRENT_USER, r"Environment"), + ) + for hive, subkey in locations: + try: + with winreg.OpenKey(hive, subkey) as key: + value, _ = winreg.QueryValueEx(key, name) + if value: + return str(value) + except OSError: + continue + return "" + + +# --------------------------------------------------------------------------- +# CAD MCP 基座(TCP JSON-RPC) +# --------------------------------------------------------------------------- +HOST = os.environ.get("CAD_MCP_HOST", "127.0.0.1") +PORT = int(os.environ.get("CAD_MCP_PORT", "8080")) + +# 单次 TCP 调用超时(秒) +CALL_TIMEOUT = float(os.environ.get("CAD_MCP_TIMEOUT", "30")) +# 连接/超时类错误的重试次数(写类工具不重试) +CALL_RETRIES = int(os.environ.get("CAD_MCP_RETRIES", "2")) +# 帧大小上限(与基座 8MB 上限对齐,留余量) +MAX_FRAME_BYTES = int(os.environ.get("MAX_FRAME_BYTES", str(4 * 1024 * 1024))) + +# --------------------------------------------------------------------------- +# LLM(默认 DeepSeek,视觉模型 deepseek-flash) +# --------------------------------------------------------------------------- +API_KEY = _read_env("DEEPSEEK_API_KEY") +BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com") +MODEL_NAME = os.environ.get("DEEPSEEK_MODEL", "deepseek-flash") +LLM_TIMEOUT = float(os.environ.get("LLM_TIMEOUT", "120")) +LLM_RETRIES = int(os.environ.get("LLM_RETRIES", "2")) + +# --------------------------------------------------------------------------- +# 审查流程 +# --------------------------------------------------------------------------- +# 审查清单路径 +CHECKLIST_PATH = _resolve_path(os.environ.get("REVIEW_CHECKLIST", "review_checklist.json")) +# 最多审查多少个图框(0 = 全部) +MAX_FRAMES = int(os.environ.get("MAX_FRAMES", "0")) +# 每个图框取数据的条数上限 +FRAME_ENTITY_LIMIT = int(os.environ.get("FRAME_ENTITY_LIMIT", "200")) +# 缩放后等待 CAD 渲染的秒数 +RENDER_WAIT = float(os.environ.get("RENDER_WAIT", "0.6")) +# 审查阶段受控工具子循环的最大回合数 +REVIEW_MAX_ITERS = int(os.environ.get("REVIEW_MAX_ITERS", "4")) +# 报告输出目录 +REPORT_DIR = _resolve_path(os.environ.get("REPORT_DIR", "reports")) +# 批注回写使用的图层 +ANNOTATE_LAYER = os.environ.get("ANNOTATE_LAYER", "AI_REVIEW") + +# --------------------------------------------------------------------------- +# 截图(视觉 token 成本控制) +# --------------------------------------------------------------------------- +# 长边上限(下采样目标),0 表示不下采样 +IMAGE_MAX_EDGE = int(os.environ.get("IMAGE_MAX_EDGE", "1280")) +# JPEG 质量 +IMAGE_JPEG_QUALITY = int(os.environ.get("IMAGE_JPEG_QUALITY", "80")) + + +def has_api_key() -> bool: + return bool(API_KEY.strip()) diff --git a/dwg_review/context.py b/dwg_review/context.py new file mode 100644 index 0000000..347bfff --- /dev/null +++ b/dwg_review/context.py @@ -0,0 +1,40 @@ +"""审查运行的共享上下文。""" + +from dataclasses import dataclass + +from . import config +from .cad_tools import CadTools +from .checklist import load_checklist +from .llm_client import LlmClient +from .mcp_client import McpClient + + +@dataclass +class ReviewContext: + client: McpClient + tools: CadTools + llm: LlmClient + checklist: dict + max_frames: int = 0 + frame_limit: int = 200 + max_iters: int = 4 + annotate: bool = False + dry_run: bool = False + + @classmethod + def create(cls, *, need_llm=True, **overrides): + client = McpClient() + tools = CadTools(client) + llm = LlmClient() if need_llm else None + checklist = load_checklist() + params = dict( + client=client, + tools=tools, + llm=llm, + checklist=checklist, + max_frames=config.MAX_FRAMES, + frame_limit=config.FRAME_ENTITY_LIMIT, + max_iters=config.REVIEW_MAX_ITERS, + ) + params.update(overrides) + return cls(**params) diff --git a/dwg_review/llm_client.py b/dwg_review/llm_client.py new file mode 100644 index 0000000..075cf89 --- /dev/null +++ b/dwg_review/llm_client.py @@ -0,0 +1,101 @@ +"""LLM 封装(文本 + 视觉),统一超时 / 重试 / 截图预处理。 + +默认走 DeepSeek(视觉模型 deepseek-flash)。密钥从环境变量读取,禁止硬编码。 +""" + +import base64 +import io +import time + +from openai import OpenAI + +from . import config + +try: # 可选依赖:仅用于截图下采样 + from PIL import Image + + HAS_PIL = True +except ImportError: # pragma: no cover + HAS_PIL = False + + +class LlmError(Exception): + pass + + +def downsample_image_b64(b64, mime="image/png", max_edge=None, quality=None): + """把 base64 截图下采样并转 JPEG,降低视觉 token 成本。 + + 缺少 Pillow 或参数为 0 时原样返回 (b64, mime)。 + """ + max_edge = config.IMAGE_MAX_EDGE if max_edge is None else max_edge + quality = config.IMAGE_JPEG_QUALITY if quality is None else quality + if not HAS_PIL or max_edge <= 0: + return b64, mime + try: + img = Image.open(io.BytesIO(base64.b64decode(b64))) + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + w, h = img.size + scale = max_edge / float(max(w, h)) + if scale < 1.0: + img = img.resize( + (max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS + ) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=quality) + return base64.b64encode(buf.getvalue()).decode("ascii"), "image/jpeg" + except Exception: + return b64, mime + + +class LlmClient: + """统一的 LLM 调用入口。""" + + def __init__(self, api_key=None, base_url=None, model=None): + key = config.API_KEY if api_key is None else api_key + if not key: + raise LlmError("未配置 DEEPSEEK_API_KEY 环境变量") + self.model = model or config.MODEL_NAME + self._client = OpenAI(api_key=key, base_url=base_url or config.BASE_URL) + + def chat(self, messages, *, tools=None, timeout=None, retries=None): + """发起一次对话补全,返回原始 response。""" + retries = config.LLM_RETRIES if retries is None else retries + last = None + for attempt in range(retries + 1): + try: + kwargs = { + "model": self.model, + "messages": messages, + "timeout": timeout or config.LLM_TIMEOUT, + } + if tools: + kwargs["tools"] = tools + return self._client.chat.completions.create(**kwargs) + except Exception as exc: # noqa: BLE001 - 统一包装为 LlmError + last = exc + if attempt < retries: + time.sleep(0.5 * (2 ** attempt)) + continue + raise LlmError(f"LLM 调用失败: {exc}") from exc + raise LlmError(f"LLM 调用失败: {last}") + + def chat_text(self, system, user, **kwargs): + """纯文本对话,返回助手文本。""" + resp = self.chat( + [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + **kwargs, + ) + return resp.choices[0].message.content + + @staticmethod + def image_content(b64, mime="image/png"): + """构造 OpenAI/DeepSeek 视觉消息里的图片内容块。""" + return { + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{b64}"}, + } diff --git a/dwg_review/mcp_client.py b/dwg_review/mcp_client.py new file mode 100644 index 0000000..f47ecbc --- /dev/null +++ b/dwg_review/mcp_client.py @@ -0,0 +1,176 @@ +"""唯一的 TCP / JSON-RPC 封装。 + +要点: + - 换行分帧:请求与响应均以 '\\n' 结尾(新基座按 '\\n' 分帧;旧基座读到即止,兼容)。 + - 单飞锁:CAD 侧单线程,所有调用串行,避免并发踩踏主线程派发链。 + - 错误归一化:同时识别 JSON-RPC 顶层 error 与 MCP 的 result.isError。 +""" + +import json +import socket +import threading +import time + +from . import config + + +class McpError(Exception): + """协议级错误(JSON-RPC error:-32700/-32600/-32601/-32602 等)。""" + + def __init__(self, code, message): + super().__init__(f"[{code}] {message}") + self.code = code + self.message = message + + +class McpToolError(Exception): + """工具执行错误(MCP result.isError == true)。""" + + def __init__(self, message, code=None): + super().__init__(message) + self.code = code + self.message = message + + +def result_texts(resp): + """取出 result.content 中所有 text 内容。""" + result = (resp or {}).get("result") or {} + return [i.get("text", "") for i in result.get("content", []) if i.get("type") == "text"] + + +def result_images(resp): + """取出 result.content 中所有图片,返回 [(base64, mimeType), ...]。""" + result = (resp or {}).get("result") or {} + return [ + (i.get("data"), i.get("mimeType", "image/png")) + for i in result.get("content", []) + if i.get("type") == "image" + ] + + +def result_json(resp, default=None): + """取第一个 text 内容并尝试按 JSON 解析;解析失败则返回原始字符串。""" + texts = result_texts(resp) + if not texts: + return default + text = texts[0] + try: + return json.loads(text) + except (json.JSONDecodeError, TypeError): + return text + + +class McpClient: + """与 CAD MCP 基座通信的唯一入口。""" + + def __init__(self, host=None, port=None, timeout=None): + self.host = host or config.HOST + self.port = port or config.PORT + self.timeout = timeout if timeout is not None else config.CALL_TIMEOUT + self._lock = threading.Lock() # 单飞:CAD 单线程,必须串行 + self._next_id = 1 + self._tools_cache = None + + # ------------------------------------------------------------------ + # 底层收发 + # ------------------------------------------------------------------ + def _connect(self, timeout=None): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(self.timeout if timeout is None else timeout) + sock.connect((self.host, self.port)) + return sock + + def _send_frame(self, payload, timeout=None): + raw = json.dumps(payload, ensure_ascii=False).encode("utf-8") + if len(raw) > config.MAX_FRAME_BYTES: + raise McpError(-32600, f"请求帧过大: {len(raw)} > {config.MAX_FRAME_BYTES} 字节") + + sock = self._connect(timeout) + try: + sock.sendall(raw + b"\n") + data = b"" + while True: + chunk = sock.recv(65536) + if not chunk: + break + data += chunk + if b"\n" in data: + break + if len(data) > config.MAX_FRAME_BYTES: + raise McpError(-32603, "响应帧过大") + finally: + sock.close() + + text = data.decode("utf-8", errors="replace").strip() + if not text: + raise McpError(-32700, "空响应") + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise McpError(-32700, f"响应解析失败: {exc}") from exc + + # ------------------------------------------------------------------ + # 高层 RPC + # ------------------------------------------------------------------ + def _rpc(self, method, params=None, *, timeout=None, retries=None, is_write=False): + retries = config.CALL_RETRIES if retries is None else retries + if is_write: + retries = 0 # 写类工具不重试,避免重复副作用 + + with self._lock: + last_exc = None + for attempt in range(retries + 1): + req_id = self._next_id + self._next_id += 1 + payload = {"jsonrpc": "2.0", "id": req_id, "method": method} + if params is not None: + payload["params"] = params + try: + return self._normalize(self._send_frame(payload, timeout)) + except (McpError, McpToolError): + raise # 协议/工具错误是确定性的,不重试 + except (OSError, socket.timeout) as exc: + last_exc = exc + if attempt < retries: + time.sleep(0.3 * (2 ** attempt)) + continue + raise McpError(-32603, f"连接 CAD 基座失败: {exc}") from exc + raise McpError(-32603, f"连接 CAD 基座失败: {last_exc}") + + @staticmethod + def _normalize(resp): + if isinstance(resp, dict) and "error" in resp: + err = resp.get("error") or {} + raise McpError(err.get("code", -32603), err.get("message", "unknown error")) + result = resp.get("result") if isinstance(resp, dict) else None + if isinstance(result, dict) and result.get("isError"): + texts = result_texts(resp) + raise McpToolError(texts[0] if texts else "tool error") + return resp + + # ------------------------------------------------------------------ + # 对外 API + # ------------------------------------------------------------------ + def call_tool(self, name, arguments=None, *, timeout=None, retries=None, is_write=False): + """调用一个工具;成功返回归一化后的响应(含 result.content)。""" + params = {"name": name, "arguments": arguments or {}} + return self._rpc("tools/call", params, timeout=timeout, retries=retries, is_write=is_write) + + def list_tools(self, *, use_cache=True): + """返回 tools/list 的 tools 数组。""" + if use_cache and self._tools_cache is not None: + return self._tools_cache + resp = self._rpc("tools/list") + tools = (resp.get("result") or {}).get("tools", []) + self._tools_cache = tools + return tools + + def is_available(self, timeout=1.5): + """探测基座是否在监听(仅建立连接,不发请求)。""" + try: + with self._lock: + sock = self._connect(timeout) + sock.close() + return True + except OSError: + return False diff --git a/dwg_review/phases/__init__.py b/dwg_review/phases/__init__.py new file mode 100644 index 0000000..67aca11 --- /dev/null +++ b/dwg_review/phases/__init__.py @@ -0,0 +1 @@ +"""审查流水线的各阶段。""" diff --git a/dwg_review/phases/evidence.py b/dwg_review/phases/evidence.py new file mode 100644 index 0000000..cf3f6d3 --- /dev/null +++ b/dwg_review/phases/evidence.py @@ -0,0 +1,63 @@ +"""阶段 2:逐图框取证(确定性、串行)。 + +对每个图框:缩放到该框 → 取结构化数据(实体/文字/标注/块/多段线)→ 截取视口。 +结构化数据是权威依据,截图仅作视觉上下文。 +""" + +import time + +from .. import config +from ..mcp_client import result_images, result_json + + +def bbox_to_window(bbox): + """[minx,miny,maxx,maxy] -> window 对象。""" + return { + "min_x": float(bbox[0]), + "min_y": float(bbox[1]), + "max_x": float(bbox[2]), + "max_y": float(bbox[3]), + } + + +def collect_frame(ctx, index, frame): + """采集单个图框的证据;bbox 无效时返回 None。""" + bbox = frame.get("bbox") + if not bbox or len(bbox) != 4: + return None + + win = bbox_to_window(bbox) + limit = ctx.frame_limit + + # a. 缩放到图框(给 CAD 留渲染时间) + ctx.tools.call("zoom_to_window", dict(win, margin=0.02)) + time.sleep(config.RENDER_WAIT) + + # b. 结构化数据(权威) + entities = result_json(ctx.tools.call("query_entities", {"window": win, "limit": limit}), {}) + texts = result_json(ctx.tools.call("get_text_content", {"window": win, "limit": limit}), {}) + dimensions = result_json(ctx.tools.call("get_dimensions", {"window": win, "limit": limit}), {}) + blocks = result_json( + ctx.tools.call("get_block_references", {"window": win, "limit": limit}), {} + ) + polylines = result_json( + ctx.tools.call("get_polylines_info", {"window": win, "limit": limit}), {} + ) + + # c. 视口截图(视觉上下文) + shot = ctx.tools.call("get_viewport_screenshot", {}) + images = result_images(shot) + image_b64, image_mime = images[0] if images else (None, None) + + return { + "index": index, + "frame": frame, + "window": win, + "entities": entities, + "texts": texts, + "dimensions": dimensions, + "blocks": blocks, + "polylines": polylines, + "image_b64": image_b64, + "image_mime": image_mime, + } diff --git a/dwg_review/phases/planning.py b/dwg_review/phases/planning.py new file mode 100644 index 0000000..66387b4 --- /dev/null +++ b/dwg_review/phases/planning.py @@ -0,0 +1,39 @@ +"""阶段 1:全局规划。 + +收集图纸概览 + 识别图框,按阅读顺序排序,产出审查计划。 +""" + +from ..mcp_client import result_json + + +def _sort_frames(frames): + """按阅读顺序排序:先上后下(max_y 降序)、再左后右(min_x 升序)。""" + + def key(frame): + bbox = frame.get("bbox") or [0, 0, 0, 0] + return (-round(float(bbox[3]), 3), round(float(bbox[0]), 3)) + + return sorted(frames, key=key) + + +def plan(ctx): + """返回 {drawing_info, layers, layouts, frames}。""" + drawing_info = result_json(ctx.tools.call("get_drawing_info", {}), {}) + layers = result_json(ctx.tools.call("get_layers", {"count_entities": True}), {}) + layouts = result_json(ctx.tools.call("get_layouts", {}), {}) + + frames_data = result_json( + ctx.tools.call("detect_drawing_frames", {"min_confidence": 0.3}), {} + ) + frames = frames_data.get("items", []) if isinstance(frames_data, dict) else [] + + frames = _sort_frames(frames) + if ctx.max_frames > 0: + frames = frames[: ctx.max_frames] + + return { + "drawing_info": drawing_info, + "layers": layers, + "layouts": layouts, + "frames": frames, + } diff --git a/dwg_review/phases/report.py b/dwg_review/phases/report.py new file mode 100644 index 0000000..fd9c121 --- /dev/null +++ b/dwg_review/phases/report.py @@ -0,0 +1,84 @@ +"""阶段 4:汇总报告 + 结构化问题抽取。""" + +import json +from datetime import datetime +from pathlib import Path + +from .. import config + + +def summarize(ctx, findings, log=print): + """用 LLM 把各图框结论汇总为总报告。""" + if not findings: + return "未识别到可审查的图框,或所有图框均无有效包围盒。" + + report_input = "\n\n".join( + f"【图框 {f['frame']}(图幅 {f.get('sheet')})】\n{f['finding']}" for f in findings + ) + system = ( + "你是图纸审查负责人。请把下面各图框的审查意见汇总为一份结构化的审查报告:" + "先给整体结论,再按图框列出问题,最后给出整改建议。语言简洁、条目化。" + ) + try: + return ctx.llm.chat_text(system, report_input) + except Exception as exc: # noqa: BLE001 + log(f"[汇总失败] {exc}") + return report_input + + +_EXTRACT_SYSTEM = ( + "你是结构化信息抽取器。请把下面的图纸审查意见抽取为 JSON 数组," + "每个元素形如:" + '{"frame": <图框序号>, "handle": "<实体句柄,没有则省略>", ' + '"category": "<图层/文字/标注/几何/图面整体>", ' + '"severity": "", "message": "<问题描述>"}。' + "只输出 JSON 数组,不要输出任何解释、不要用 markdown 代码块包裹。" + "若某条意见无法定位到具体实体,省略 handle 字段。" +) + + +def extract_issues(ctx, findings, log=print): + """把自由文本的审查意见抽取为结构化问题列表(用于批注回写)。""" + if not findings: + return [] + source = "\n\n".join( + f"图框 {f['frame']}:\n{f['finding']}" for f in findings + ) + try: + text = ctx.llm.chat_text(_EXTRACT_SYSTEM, source) + text = text.strip() + if text.startswith("```"): + text = text.strip("`") + text = text.split("\n", 1)[1] if "\n" in text else text + data = json.loads(text) + if isinstance(data, list): + return data + except Exception as exc: # noqa: BLE001 + log(f"[结构化抽取失败] {exc}") + return [] + + +def save_report(markdown, findings, plan, issues=None, log=print): + """落盘 Markdown + JSON 报告,返回两个路径。""" + out_dir = Path(config.REPORT_DIR) + out_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + md_path = out_dir / f"review_{stamp}.md" + json_path = out_dir / f"review_{stamp}.json" + + md_path.write_text(markdown, encoding="utf-8") + json_path.write_text( + json.dumps( + { + "generated_at": stamp, + "findings": findings, + "issues": issues or [], + "frames": plan.get("frames"), + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + log(f"[报告] 已写入 {md_path} 与 {json_path}") + return md_path, json_path diff --git a/dwg_review/phases/review.py b/dwg_review/phases/review.py new file mode 100644 index 0000000..e718fd1 --- /dev/null +++ b/dwg_review/phases/review.py @@ -0,0 +1,112 @@ +"""阶段 3:逐图框审查(LLM + 受控只读取证子循环)。 + +流程: + 1. 用清单生成系统提示,喂入结构化证据 + 截图。 + 2. LLM 若请求只读取证工具 → 执行后把观测回灌,再问(有界循环)。 + 3. LLM 不再请求工具时,其输出即为该图框的审查结论。 +""" + +import json + +from ..cad_tools import READ_ONLY_TOOLS, is_write_tool +from ..llm_client import LlmClient, downsample_image_b64 +from ..mcp_client import McpError, McpToolError, result_images, result_texts + +# 单次审查回灌给 LLM 的图片数上限(控制视觉 token 成本) +_MAX_OBS_IMAGES = 3 + + +def evidence_payload(evidence): + """把证据整理成给 LLM 的 JSON(剔除 base64 图片,避免撑爆上下文)。""" + frame = evidence.get("frame") or {} + return { + "frame_index": evidence.get("index"), + "frame": { + k: frame.get(k) + for k in ("sheet_size", "kind", "layer", "bbox", "confidence") + }, + "entities": evidence.get("entities"), + "texts": evidence.get("texts"), + "dimensions": evidence.get("dimensions"), + "blocks": evidence.get("blocks"), + "polylines": evidence.get("polylines"), + } + + +def _run_readonly_tool(ctx, name, args, image_budget): + """执行一个只读取证工具,返回回灌给 LLM 的 content 列表。""" + if name not in READ_ONLY_TOOLS or is_write_tool(name): + return [{"type": "text", "text": f"工具 {name} 不在只读取证白名单内,已拒绝。"}], image_budget + + try: + resp = ctx.tools.call(name, args) + except (McpError, McpToolError) as exc: + return [{"type": "text", "text": f"工具 {name} 执行失败: {exc}"}], image_budget + + content = [] + for text in result_texts(resp): + content.append({"type": "text", "text": text}) + for b64, mime in result_images(resp): + if image_budget[0] <= 0: + content.append({"type": "text", "text": "(后续截图已省略以控制成本)"}) + break + image_budget[0] -= 1 + nb, nm = downsample_image_b64(b64, mime) + content.append(LlmClient.image_content(nb, nm)) + if not content: + content.append({"type": "text", "text": "(无返回内容)"}) + return content, image_budget + + +def review_frame(ctx, evidence, system_prompt, log=print): + """审查单个图框,返回审查意见文本。""" + payload = evidence_payload(evidence) + user_content = [ + { + "type": "text", + "text": "以下是该图框的审查输入数据(JSON):\n" + + json.dumps(payload, ensure_ascii=False, indent=2), + } + ] + image_b64 = evidence.get("image_b64") + if image_b64: + nb, nm = downsample_image_b64(image_b64, evidence.get("image_mime") or "image/png") + user_content.append(LlmClient.image_content(nb, nm)) + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content}, + ] + + tools_schema = ctx.tools.openai_tools(names=READ_ONLY_TOOLS) + image_budget = [_MAX_OBS_IMAGES] + + for _ in range(max(0, ctx.max_iters)): + resp = ctx.llm.chat(messages, tools=tools_schema) + msg = resp.choices[0].message + messages.append(msg) + + if not msg.tool_calls: + return msg.content or "" + + for tool_call in msg.tool_calls: + name = tool_call.function.name + try: + args = json.loads(tool_call.function.arguments or "{}") + except json.JSONDecodeError: + args = {} + log(f" [取证] {name} {args if args else ''}") + content, image_budget = _run_readonly_tool(ctx, name, args, image_budget) + messages.append( + {"role": "tool", "tool_call_id": tool_call.id, "content": content} + ) + + # 达到回合上限:强制收口 + messages.append( + { + "role": "user", + "content": "已达到取证回合上限,请基于现有信息直接给出该图框的审查结论。", + } + ) + resp = ctx.llm.chat(messages) + return resp.choices[0].message.content or "" diff --git a/inc/mcp_plugin_api.h b/inc/mcp_plugin_api.h index f82d769..2055ba2 100644 --- a/inc/mcp_plugin_api.h +++ b/inc/mcp_plugin_api.h @@ -40,7 +40,8 @@ public: { "result", { { "content", { { { "type", "text" }, { "text", text } } - } } + } }, + { "isError", false } } } }; } @@ -53,7 +54,8 @@ public: { "result", { { "content", { { { "type", "image" }, { "data", base64Data }, { "mimeType", mimeType } } - } } + } }, + { "isError", false } } } }; } @@ -69,7 +71,8 @@ public: { "content", { { { "type", "text" }, { "text", text } }, { { "type", "image" }, { "data", base64Data }, { "mimeType", mimeType } } - } } + } }, + { "isError", false } } } }; } diff --git a/src/cad_mcp_frame/mcp_server.cpp b/src/cad_mcp_frame/mcp_server.cpp index 3745daa..cc9c8bd 100644 --- a/src/cad_mcp_frame/mcp_server.cpp +++ b/src/cad_mcp_frame/mcp_server.cpp @@ -42,8 +42,8 @@ namespace std::string rawJson; }; - // 处理工具查询请求 - std::string HandleToolsList(int msgId) + // 处理工具查询请求(msgId 允许 string 或 number) + std::string HandleToolsList(const nlohmann::json &msgId) { nlohmann::json resp; resp["jsonrpc"] = "2.0"; @@ -55,8 +55,8 @@ namespace return resp.dump(); } - // 处理工具调用请求 - std::string HandleToolsCall(int msgId, const nlohmann::json ¶ms) + // 处理工具调用请求(msgId 允许 string 或 number) + std::string HandleToolsCall(const nlohmann::json &msgId, const nlohmann::json ¶ms) { nlohmann::json resp; resp["jsonrpc"] = "2.0"; @@ -68,8 +68,34 @@ namespace // 让工具集去执行,返回 payload 可能是 {"result": ...} 也可能是 {"error": ...} nlohmann::json payload = g_mcpTools.Call(toolName, args); - // 将 payload 注入到外层信封中 - resp.update(payload); + if (payload.contains("error") && payload["error"].is_object()) + { + int code = payload["error"].value("code", -32603); + std::string message = payload["error"].value("message", ""); + + // 协议级错误(JSON-RPC 保留码:解析/请求/方法/参数)保持为顶层 error; + // 工具执行错误按 MCP 规范改为 result.isError = true,错误信息进 content[0].text。 + if (code == -32700 || code == -32600 || code == -32601 || code == -32602) + { + resp.update(payload); + } + else + { + resp["result"] = { + { "isError", true }, + { "content", { { { "type", "text" }, { "text", message } } } } + }; + } + } + else + { + // 正常结果:将 payload({"result": ...})注入到外层信封中 + resp.update(payload); + + // 显式标注 isError=false(MCP 规范字段) + if (resp.contains("result") && resp["result"].is_object()) + resp["result"]["isError"] = false; + } return resp.dump(); } @@ -92,31 +118,40 @@ namespace else { std::string method = req.value("method", ""); - int msgId = req.value("id", 0); + + // id 允许 string 或 number;缺失 id 表示这是一条"通知",按规范不应产生任何响应 + bool isNotification = !req.contains("id"); + nlohmann::json idVal = isNotification ? nlohmann::json(nullptr) : req["id"]; if (method == "tools/list") { - responseStr = HandleToolsList(msgId); + responseStr = HandleToolsList(idVal); } else if (method == "tools/call") { - responseStr = HandleToolsCall(msgId, req.value("params", nlohmann::json::object())); + responseStr = HandleToolsCall(idVal, req.value("params", nlohmann::json::object())); } else { // 找不到方法 nlohmann::json err; err["jsonrpc"] = "2.0"; - err["id"] = msgId; + err["id"] = idVal; err["error"] = { {"code", -32601}, {"message", "Method not found"} }; responseStr = err.dump(); } + + // 通知不回包(send 处对空串已有保护) + if (isNotification) + responseStr.clear(); } // 通过传递过来的 Socket 将结果发送回大模型端 - if (pTask->clientSocket != INVALID_SOCKET) + // 响应同样以 '\n' 结尾作为消息边界(json.loads 容忍尾部换行,旧客户端不受影响) + if (pTask->clientSocket != INVALID_SOCKET && !responseStr.empty()) { - send(pTask->clientSocket, responseStr.c_str(), responseStr.length(), 0); + responseStr.push_back('\n'); + send(pTask->clientSocket, responseStr.c_str(), (int)responseStr.length(), 0); } } @@ -136,6 +171,63 @@ namespace return DefWindowProc(hWnd, message, wParam, lParam); } + // 从 socket 读取一整帧 JSON-RPC 报文。 + // 消息边界规则:优先以 '\n' 作为帧结束标记(新客户端); + // 若客户端不发 '\n'(旧脚本),则在收到数据后用 select 短暂探测是否还有后续数据, + // 无后续数据即视为整帧,从而保持向后兼容。 + // 返回 true 表示成功取到一帧(内容写入 out);false 表示无数据或出错。 + // 注意:本函数运行在后台 TCP 线程,只做字节累积,不解析 JSON、不触碰任何 ARX API。 + bool RecvOneFrame(SOCKET sock, std::string &out) + { + const size_t kMaxFrameBytes = 8 * 1024 * 1024; // 8MB 上限,防御异常/恶意大报文 + char buf[4096]; + out.clear(); + + for (;;) + { + int n = recv(sock, buf, sizeof(buf), 0); + if (n > 0) + { + out.append(buf, n); + + // 命中换行符即成帧(去掉 '\n' 及之后内容,并兼容 CRLF) + size_t pos = out.find('\n'); + if (pos != std::string::npos) + { + out.resize(pos); + if (!out.empty() && out.back() == '\r') + out.pop_back(); + return true; + } + + if (out.size() > kMaxFrameBytes) + return false; // 超过上限,判为非法报文 + + // 尚未见到 '\n':短暂探测是否还有后续分片。 + // 无后续数据 => 判定为"整帧已到"(兼容不发 '\n' 的旧客户端)。 + fd_set probe; + FD_ZERO(&probe); + FD_SET(sock, &probe); + timeval probeTimeout = { 0, 150000 }; // 150ms + int pr = select(0, &probe, nullptr, nullptr, &probeTimeout); + if (pr <= 0) + return true; + } + else if (n == 0) + { + // 对端正常关闭:若已收到数据则视为整帧 + return !out.empty(); + } + else + { + int err = WSAGetLastError(); + if ((err == WSAETIMEDOUT || err == WSAEWOULDBLOCK) && !out.empty()) + return true; // 超时但已有数据:视为整帧 + return false; + } + } + } + // TCP 监听后台线程 void TcpServerLoop() { @@ -166,14 +258,19 @@ namespace SOCKET clientSocket = accept(g_listenSocket, nullptr, nullptr); if (clientSocket != INVALID_SOCKET) { - char recvBuf[2048] = { 0 }; - int bytesReceived = recv(clientSocket, recvBuf, sizeof(recvBuf) - 1, 0); - if (bytesReceived > 0) + // 设置接收超时:保证插件卸载时后台线程能及时退出,不会永久阻塞在 recv 上 + DWORD recvTimeoutMs = 5000; + setsockopt(clientSocket, SOL_SOCKET, SO_RCVTIMEO, + (const char *)&recvTimeoutMs, sizeof(recvTimeoutMs)); + + // 按消息边界读取一整帧(不再受 2048 字节单次 recv 限制) + std::string frame; + if (RecvOneFrame(clientSocket, frame) && !frame.empty()) { // 将数据拷贝到堆内存,准备派发给主线程 TaskData *pTask = new TaskData; pTask->clientSocket = clientSocket; // 记录 Socket - pTask->rawJson = recvBuf; + pTask->rawJson = frame; // 关键转变:通过 PostMessage 发送给主线程拥有的隐式窗口 if (g_hMsgWnd != NULL) @@ -185,6 +282,11 @@ namespace delete pTask; } } + else + { + // 空帧或读取失败:直接关闭连接 + closesocket(clientSocket); + } } else { diff --git a/verify_mcp_frame.py b/verify_mcp_frame.py new file mode 100644 index 0000000..0e921f1 --- /dev/null +++ b/verify_mcp_frame.py @@ -0,0 +1,127 @@ +"""验证基座 B 最小集改动(换行分帧 / 大报文 / string id / 通知 / isError)。 + +前提:CAD 已打开并加载了重新构建的 cad_mcp_frame.arx,8080 端口在监听。 + +用法: + python verify_mcp_frame.py +""" + +import json +import socket +import sys + +HOST = "127.0.0.1" +PORT = 8080 + + +def raw_send(payload_bytes, *, timeout=10.0): + """发送一帧(已含 '\\n'),读回响应原始字节(读不到则为空)。""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + try: + sock.connect((HOST, PORT)) + sock.sendall(payload_bytes) + data = b"" + while True: + try: + chunk = sock.recv(65536) + except socket.timeout: + break + if not chunk: + break + data += chunk + if b"\n" in data: + break + return data + finally: + sock.close() + + +def _call(payload): + return raw_send(json.dumps(payload).encode("utf-8") + b"\n") + + +def test_string_id(): + resp = json.loads(_call({"jsonrpc": "2.0", "id": "abc-123", "method": "tools/list"}).decode()) + assert resp.get("id") == "abc-123", f"id 未原样回显: {resp.get('id')!r}" + count = len(resp.get("result", {}).get("tools", [])) + print(f"[PASS] string id 原样回显;tools={count}") + + +def test_large_request(): + """构造 >2KB 的 arguments;旧基座会截断成 -32700,新基座应正常返回 -32601。""" + big = {"blob": "x" * 5000} + resp = json.loads( + _call( + { + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": {"name": "__no_such_tool__", "arguments": big}, + } + ).decode() + ) + code = (resp.get("error") or {}).get("code") + assert code == -32601, f"期望 -32601(工具不存在),实际 {resp}" + print("[PASS] >2KB 请求未被截断(返回 -32601 而非 -32700)") + + +def test_notification_no_reply(): + data = raw_send( + json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}).encode() + b"\n", + timeout=2.0, + ) + assert data == b"", f"通知不应回包,却收到 {data[:120]!r}" + print("[PASS] 无 id 的通知未回包") + + +def test_tool_error_shape(): + """用不存在的 handle 触发工具执行错误,应为 result.isError=true。""" + resp = json.loads( + _call( + { + "jsonrpc": "2.0", + "id": 9, + "method": "tools/call", + "params": {"name": "get_entity_detail", "arguments": {"handle": "ZZZZZZ"}}, + } + ).decode() + ) + result = resp.get("result") + if isinstance(result, dict) and result.get("isError") is True: + print("[PASS] 工具执行错误 -> result.isError=true") + elif resp.get("error"): + print(f"[WARN] 仍是顶层 error(code={resp['error'].get('code')}),isError 映射未生效") + else: + print(f"[?] 未预期响应: {resp}") + + +def main(): + # 先探测端口 + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + probe.settimeout(1.5) + try: + probe.connect((HOST, PORT)) + except OSError as exc: + print(f"[X] {HOST}:{PORT} 未监听({exc})。请先启动已加载插件的 CAD。") + return 1 + finally: + probe.close() + + print(f"=== 验证基座 {HOST}:{PORT} ===") + failed = 0 + for test in (test_string_id, test_large_request, test_notification_no_reply, test_tool_error_shape): + try: + test() + except AssertionError as exc: + print(f"[FAIL] {test.__name__}: {exc}") + failed += 1 + except Exception as exc: # noqa: BLE001 + print(f"[ERROR] {test.__name__}: {exc}") + failed += 1 + print("=== 全部通过 ===" if failed == 0 else f"=== {failed} 项未通过 ===") + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main())