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:
1 parent
3b1cc88262
commit
ec5292debe
20 files changed
+1500
-18
No files matched your search
@@ -11,3 +11,12 @@ lib/
|
||||
*.pch
|
||||
*.tlog
|
||||
*.tmp
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
# 审查报告输出目录
|
||||
reports/
|
||||
# AutoCAD 锁文件
|
||||
*.dwl
|
||||
*.dwl2
|
||||
@@ -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 路径、测试脚本
|
||||
@@ -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 个点**(首个为箭头)。
|
||||
@@ -0,0 +1,11 @@
|
||||
"""dwg_review —— CAD 图纸审查 Agent(基于 cad_mcp_frame MCP 基座)。
|
||||
|
||||
混合式架构:
|
||||
- 确定性流水线骨架(规划 → 逐图框取证 → 审查 → 汇总)
|
||||
- 审查阶段给 LLM 一个受控的只读取证工具子循环
|
||||
|
||||
入口:
|
||||
python -m dwg_review --help
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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())
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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) + ")"
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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}"},
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""审查流水线的各阶段。"""
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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 }
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 注入到外层信封中
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user