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
+1502
-20
No files matched your search
@@ -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 ""
|
||||
Reference in new issue
Block a user