feat(mcp): 基座换行分帧与 isError 映射;新增 dwg_review 审查 Agent

基座(cad_mcp_frame):
- 新增 RecvOneFrame: 以 '\n' 为消息边界分帧,去掉单次 recv(2048) 限制(上限 8MB);
  用 select 短探测兼容不发 '\n' 的旧客户端;clientSocket 设 SO_RCVTIMEO 防卸载卡死
- JSON-RPC id 支持 string/number;无 id 的通知不回包
- isError 映射: 协议级错误保留顶层 error,工具执行错误转 result.isError=true
- 响应补 '\n' 作为消息边界

Agent(新增 dwg_review 包):
- mcp_client: 唯一 TCP/JSON-RPC 封装(单飞锁/分帧/重试/兼容 error 与 isError)
- cad_tools: 从 tools/list 动态建表,含 15 个只读白名单
- phases: planning -> evidence -> review(受控只读取证子循环)-> report
- annotator: 批注回写 AI_REVIEW 层,支持 dry-run 与撤销
- config: 配置全部走环境变量;Windows 下回退读注册表以读到新设的系统变量

其它: 新增 verify_mcp_frame.py 验证脚本;.gitignore 补 Python/AutoCAD 锁文件规则;
ai.memory 记录架构决策与实测结论
This commit is contained in:
gjm committed 2026-10-13 23:44:11 +08:00
1 parent 3b1cc88262
commit ec5292debe
20 files changed
+1500 -18

No files matched your search

+9
View File
@@ -11,3 +11,12 @@ lib/
*.pch
*.tlog
*.tmp
# Python
__pycache__/
*.pyc
# 审查报告输出目录
reports/
# AutoCAD 锁文件
*.dwl
*.dwl2
+1
View File
@@ -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 路径、测试脚本
+66
View File
@@ -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 个点**(首个为箭头)。
+11
View File
@@ -0,0 +1,11 @@
"""dwg_review —— CAD 图纸审查 Agent(基于 cad_mcp_frame MCP 基座)。
混合式架构:
- 确定性流水线骨架(规划 → 逐图框取证 → 审查 → 汇总)
- 审查阶段给 LLM 一个受控的只读取证工具子循环
入口:
python -m dwg_review --help
"""
__version__ = "0.1.0"
+176
View File
@@ -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())
+116
View File
@@ -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}")
+80
View File
@@ -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)
+75
View File
@@ -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) + ")"
+100
View File
@@ -0,0 +1,100 @@
"""集中配置:全部来自环境变量,禁止硬编码密钥。
用法:所有模块统一 `from . import config` 后读取 config.XXX。
"""
import os
from pathlib import Path
# 仓库根目录(本文件位于 <root>/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())
+40
View File
@@ -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)
+101
View File
@@ -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}"},
}
+176
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""审查流水线的各阶段。"""
+63
View File
@@ -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,
}
+39
View File
@@ -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,
}
+84
View File
@@ -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": "<high|medium|low>", "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
+112
View File
@@ -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 ""
+6 -3
View File
@@ -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 }
} }
};
}
+117 -15
View File
@@ -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 &params)
// 处理工具调用请求(msgId 允许 string 或 number)
std::string HandleToolsCall(const nlohmann::json &msgId, const nlohmann::json &params)
{
nlohmann::json resp;
resp["jsonrpc"] = "2.0";
@@ -68,8 +68,34 @@ namespace
// 让工具集去执行,返回 payload 可能是 {"result": ...} 也可能是 {"error": ...}
nlohmann::json payload = g_mcpTools.Call(toolName, args);
// 将 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
{
+127
View File
@@ -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())