Files
cad-agent/tests/fakes.py
T
gjm d4a9442e30 feat(review): 几何干涉检查 find_collisions(BVH 粗筛 + 容器过滤)
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:<图框>},未误报
2026-10-16 19:46:44 +08:00

352 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""离线测试假件:假 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