C++(cad_mcp_plugins):
- 新增 bvh.h:header-only 2D BVH(质心中位分割,叶<=4,SelfPairs 交叉对枚举)
- 新增 find_collisions.cpp:粗筛 AABB 相交实体对,附重叠盒 + 逐轴重叠比 ratio
(= 逐轴重叠长/较短长 之积,退化轴取 1.0),按 ratio 降序 + 分页;
narrow=true 只对返回页跑 intersectWith 细筛(附 exact)
- 容器过滤(关键):图框块/分区边界会与一切相交,用重叠【度】判定
deg >= max(exclude_container_deg=8, exclude_container_frac=0.25 * 其他实体数),
与图幅/缩放无关;被剔除者在 excluded_containers 里如实报告
(初版用面积判据对多图框并排失效:3 框只占并集 27/13/7% 全部漏过,
全模型 163 对噪声;改度判据后图框 deg=35/25/23 vs 其余 deg<=3,分离干净)
- cmakelists.txt + 两个 mcp_config.json 注册(28 工具)
agent 侧(Python):
- cad_tools.READ_ONLY_TOOLS += find_collisions(受控子循环可调用)
- checks.check_collisions + run_deterministic_checks(受 CHECK_COLLISIONS 开关,默认关)
- config += CHECK_COLLISIONS=False / COLLISION_MIN_OVERLAP=0.5
- checklist.py / review_checklist.json += 「干涉/碰撞」类别(high)+ 提示词协议
(先 find_collisions 拿候选 → inspect_region 目视确认;AABB 相交 != 真干涉)
验证:
- verify_collisions.py 17/17 PASS:全模型候选 163→80(全部 ratio=0.000),
min_overlap=0.5 → 0 对;逐图框 0 对;narrow top-5 中 4 条 exact=true;
--inject 注入深重叠矩形报出 ratio=0.902 并清理 2/2
- pytest 355 passed(新增 12 项)
- 端到端:LLM 主动调用 find_collisions{min_overlap:0.2,narrow:true,window:<图框>},未误报
352 lines
12 KiB
Python
352 lines
12 KiB
Python
"""离线测试假件:假 LLM、假 MCP 基座、响应构造器。
|
||
|
||
设计约束(踩过的坑,务必遵守):
|
||
- 绝不构造真实的 `LlmClient`——没有 API Key 时它的 `__init__` 直接抛 `LlmError`。
|
||
- 绝不无参构造 `CadTools()` / `ReviewContext.create()`——二者会立刻调用
|
||
`list_tools()` 去连 127.0.0.1:8080。一律用 `make_ctx()` 直接拼装。
|
||
|
||
本文件名不带 `test_` 前缀,故不会被 pytest 收集为用例。
|
||
"""
|
||
|
||
import json
|
||
|
||
from dwg_review import evidence_model
|
||
from dwg_review.cad_tools import CadTools
|
||
from dwg_review.checklist import DEFAULT_CHECKLIST
|
||
from dwg_review.context import ReviewContext
|
||
from dwg_review.mcp_client import McpClient, McpError, McpToolError
|
||
|
||
__all__ = [
|
||
"FakeLlmClient",
|
||
"FakeMcpClient",
|
||
"McpError",
|
||
"McpToolError",
|
||
"STANDARD_TOOLS",
|
||
"err",
|
||
"frame",
|
||
"llm_calls",
|
||
"llm_text",
|
||
"make_ctx",
|
||
"make_evidence",
|
||
"ok_image",
|
||
"ok_mixed",
|
||
"ok_text",
|
||
"rpc_error",
|
||
"tool_schema",
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# OpenAI 风格响应对象(模仿 openai SDK 的 choices[0].message 结构)
|
||
# ---------------------------------------------------------------------------
|
||
class _Fn:
|
||
"""tool_call.function"""
|
||
|
||
def __init__(self, name, arguments):
|
||
self.name = name
|
||
self.arguments = arguments
|
||
|
||
|
||
class _TC:
|
||
"""一个 tool_call。"""
|
||
|
||
def __init__(self, call_id, name, args):
|
||
self.id = call_id
|
||
self.type = "function"
|
||
self.function = _Fn(
|
||
name, args if isinstance(args, str) else json.dumps(args, ensure_ascii=False)
|
||
)
|
||
|
||
|
||
class _Msg:
|
||
"""assistant 消息。"""
|
||
|
||
def __init__(self, content=None, tool_calls=None):
|
||
self.role = "assistant"
|
||
self.content = content
|
||
self.tool_calls = tool_calls or []
|
||
|
||
|
||
class _Choice:
|
||
def __init__(self, message):
|
||
self.message = message
|
||
|
||
|
||
class _Resp:
|
||
def __init__(self, message):
|
||
self.choices = [_Choice(message)]
|
||
|
||
|
||
def llm_text(text):
|
||
"""构造一个「纯文本回复」的 LLM 响应。"""
|
||
return _Resp(_Msg(content=text))
|
||
|
||
|
||
def llm_calls(pairs, content=None):
|
||
"""构造一个「请求调用工具」的 LLM 响应。
|
||
|
||
pairs: [(工具名, 参数字典), ...]
|
||
content: 可选的附带文本。
|
||
"""
|
||
tool_calls = [
|
||
_TC(str(i + 1), name, args) for i, (name, args) in enumerate(pairs)
|
||
]
|
||
return _Resp(_Msg(content=content, tool_calls=tool_calls))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 假 LLM 客户端
|
||
# ---------------------------------------------------------------------------
|
||
class FakeLlmClient:
|
||
"""按脚本回放 LLM 响应,并记录每次调用。
|
||
|
||
- `script` :`chat()` 依次弹出的响应队列;弹空后回落到 `text`。
|
||
- `text` :`chat()` / `chat_text()` 的兜底文本。
|
||
- `text_script` :`chat_text()` 专用队列(便于连续喂多次 `chat_text` 调用)。
|
||
"""
|
||
|
||
def __init__(self, script=None, text="", text_script=None, model="fake-model"):
|
||
self.script = list(script or [])
|
||
self.text = text
|
||
self.text_script = list(text_script or [])
|
||
self.model = model
|
||
self.calls = []
|
||
|
||
def chat(self, messages, *, tools=None, timeout=None, retries=None):
|
||
self.calls.append({"messages": list(messages), "tools": tools})
|
||
if self.script:
|
||
return self.script.pop(0)
|
||
return llm_text(self.text)
|
||
|
||
def chat_text(self, system, user, **kwargs):
|
||
self.calls.append({"system": system, "user": user})
|
||
if self.text_script:
|
||
return self.text_script.pop(0)
|
||
return self.text
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# JSON-RPC / MCP 响应构造器
|
||
# ---------------------------------------------------------------------------
|
||
def ok_text(obj, *, req_id=1):
|
||
"""成功响应,content 为一段文本(非字符串会先 JSON 序列化)。"""
|
||
text = obj if isinstance(obj, str) else json.dumps(obj, ensure_ascii=False)
|
||
return {
|
||
"jsonrpc": "2.0",
|
||
"id": req_id,
|
||
"result": {"content": [{"type": "text", "text": text}]},
|
||
}
|
||
|
||
|
||
def ok_image(b64, mime="image/png", *, req_id=1):
|
||
"""成功响应,content 为一张图片。"""
|
||
return {
|
||
"jsonrpc": "2.0",
|
||
"id": req_id,
|
||
"result": {"content": [{"type": "image", "data": b64, "mimeType": mime}]},
|
||
}
|
||
|
||
|
||
def ok_mixed(objs, images=(), *, req_id=1):
|
||
"""成功响应,content 同时含文本与图片。"""
|
||
content = []
|
||
for obj in objs:
|
||
text = obj if isinstance(obj, str) else json.dumps(obj, ensure_ascii=False)
|
||
content.append({"type": "text", "text": text})
|
||
for b64, mime in images:
|
||
content.append({"type": "image", "data": b64, "mimeType": mime})
|
||
return {"jsonrpc": "2.0", "id": req_id, "result": {"content": content}}
|
||
|
||
|
||
def err(text, *, req_id=1):
|
||
"""工具执行错误:result.isError == true。"""
|
||
return {
|
||
"jsonrpc": "2.0",
|
||
"id": req_id,
|
||
"result": {"content": [{"type": "text", "text": text}], "isError": True},
|
||
}
|
||
|
||
|
||
def rpc_error(code, message, *, req_id=1):
|
||
"""协议级错误:JSON-RPC 顶层 error。"""
|
||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 工具 schema
|
||
# ---------------------------------------------------------------------------
|
||
def tool_schema(name, description="", properties=None, required=None):
|
||
"""构造一条 tools/list 条目。"""
|
||
return {
|
||
"name": name,
|
||
"description": description,
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": properties or {},
|
||
"required": required or [],
|
||
},
|
||
}
|
||
|
||
|
||
STANDARD_TOOLS = [
|
||
tool_schema("get_drawing_info", "图纸信息"),
|
||
tool_schema("get_layers", "图层列表"),
|
||
tool_schema("get_layouts", "布局列表"),
|
||
tool_schema("detect_drawing_frames", "识别图框"),
|
||
tool_schema("zoom_to_window", "缩放到窗口"),
|
||
tool_schema("zoom_to_entity", "缩放到实体"),
|
||
tool_schema("query_entities", "查询图元"),
|
||
tool_schema("get_text_content", "文字内容"),
|
||
tool_schema("get_dimensions", "标注"),
|
||
tool_schema("get_block_references", "块参照"),
|
||
tool_schema("get_polylines_info", "多段线"),
|
||
tool_schema("get_viewport_screenshot", "视口截图"),
|
||
tool_schema("get_entity_detail", "实体详情"),
|
||
tool_schema("find_duplicate_entities", "重复实体"),
|
||
tool_schema("find_zero_length", "零长度实体"),
|
||
tool_schema("find_collisions", "干涉候选"),
|
||
tool_schema("add_leader_annotation", "添加引线批注"),
|
||
tool_schema("highlight_entities", "高亮实体"),
|
||
tool_schema("delete_entities", "删除实体"),
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 假 MCP 客户端
|
||
# ---------------------------------------------------------------------------
|
||
def _as_result(value, arguments):
|
||
"""把注入值解释为「客户端返回值」或「异常」。"""
|
||
if isinstance(value, Exception):
|
||
raise value
|
||
if callable(value):
|
||
value = value(arguments)
|
||
if isinstance(value, Exception):
|
||
raise value
|
||
# 原始 JSON-RPC 响应 → 走真实客户端的归一化(error → McpError,isError → McpToolError)
|
||
if isinstance(value, dict) and ("result" in value or "error" in value):
|
||
return McpClient._normalize(value)
|
||
return value
|
||
|
||
|
||
class FakeMcpClient:
|
||
"""假 MCP 基座客户端。
|
||
|
||
- `tools` :tools/list 返回的工具列表(None = STANDARD_TOOLS)。
|
||
- `responses` :工具名 → 注入值。注入值可以是
|
||
* 原始响应 dict(用 ok_text/ok_image/err/rpc_error 构造)——按真实客户端语义归一化;
|
||
* 异常实例(McpError/McpToolError)——直接抛出;
|
||
* 可调用对象 f(arguments)——返回上面任意一种。
|
||
未命中的工具默认返回 `ok_text({})`。
|
||
"""
|
||
|
||
def __init__(self, tools=None, responses=None, available=True):
|
||
self._tools = list(STANDARD_TOOLS if tools is None else tools)
|
||
self.responses = dict(responses or {})
|
||
self.available = available
|
||
self.calls = [] # call_tool 调用日志
|
||
self.raw_calls = [] # raw_rpc 调用日志
|
||
|
||
# -- 客户端 API --------------------------------------------------------
|
||
def list_tools(self, *, use_cache=True):
|
||
return list(self._tools)
|
||
|
||
def call_tool(self, name, arguments=None, *, timeout=None, retries=None, is_write=False):
|
||
self.calls.append({"name": name, "arguments": arguments, "is_write": is_write})
|
||
if name not in self.responses:
|
||
return ok_text({})
|
||
return _as_result(self.responses[name], arguments)
|
||
|
||
def raw_rpc(self, method, params=None, *, timeout=None, retries=None):
|
||
self.raw_calls.append({"method": method, "params": params})
|
||
if method in self.responses:
|
||
return _as_result(self.responses[method], params)
|
||
return {"jsonrpc": "2.0", "id": 1, "result": {}}
|
||
|
||
def is_available(self, timeout=1.5):
|
||
return self.available
|
||
|
||
# -- 断言辅助 ----------------------------------------------------------
|
||
def called(self, name):
|
||
return [c for c in self.calls if c["name"] == name]
|
||
|
||
def called_names(self):
|
||
return [c["name"] for c in self.calls]
|
||
|
||
def arguments_of(self, name):
|
||
"""返回某工具**最后一次**调用的参数。"""
|
||
hits = self.called(name)
|
||
return hits[-1]["arguments"] if hits else None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 组合辅助
|
||
# ---------------------------------------------------------------------------
|
||
def make_ctx(client=None, llm=None, checklist=None, **over):
|
||
"""直接拼装一个 ReviewContext(不触碰网络、不读密钥)。"""
|
||
client = client if client is not None else FakeMcpClient()
|
||
params = dict(
|
||
client=client,
|
||
tools=CadTools(client),
|
||
llm=llm if llm is not None else FakeLlmClient(),
|
||
checklist=checklist if checklist is not None else DEFAULT_CHECKLIST,
|
||
max_frames=0,
|
||
frame_limit=200,
|
||
max_iters=4,
|
||
)
|
||
params.update(over)
|
||
return ReviewContext(**params)
|
||
|
||
|
||
def frame(bbox, kind="title_block", sheet_size="A1", layer="0", confidence=0.9, **extra):
|
||
"""构造一个图框描述(detect_drawing_frames 返回的 items 元素)。"""
|
||
data = {
|
||
"bbox": list(bbox),
|
||
"kind": kind,
|
||
"sheet_size": sheet_size,
|
||
"layer": layer,
|
||
"confidence": confidence,
|
||
}
|
||
data.update(extra)
|
||
return data
|
||
|
||
|
||
def make_evidence(
|
||
index=1,
|
||
bbox=(0.0, 0.0, 100.0, 50.0),
|
||
image_b64=None,
|
||
image_mime=None,
|
||
images=None,
|
||
source_type=None,
|
||
**over,
|
||
):
|
||
"""构造 collect_frame 的返回值(evidence)。
|
||
|
||
兼容旧的 `image_b64`/`image_mime` 单图入参:若给了 image_b64 而未给 images,
|
||
则归一进 `images` 列表(与 `evidence_model.evidence_images` 的归一保持一致)。
|
||
"""
|
||
if images is None:
|
||
images = []
|
||
if image_b64:
|
||
images.append(
|
||
evidence_model.make_image(image_b64, image_mime or "image/png")
|
||
)
|
||
ev = {
|
||
"source_type": source_type or evidence_model.SOURCE_CAD,
|
||
"index": index,
|
||
"frame": frame(bbox),
|
||
"window": {
|
||
"min_x": float(bbox[0]),
|
||
"min_y": float(bbox[1]),
|
||
"max_x": float(bbox[2]),
|
||
"max_y": float(bbox[3]),
|
||
},
|
||
"entities": {"total": 0, "items": []},
|
||
"texts": {"total": 0, "items": []},
|
||
"dimensions": {"total": 0, "items": []},
|
||
"blocks": {"total": 0, "items": []},
|
||
"polylines": {"total": 0, "items": []},
|
||
"images": images,
|
||
}
|
||
ev.update(over)
|
||
return ev
|