test(review): 新增 pytest 离线回归网,锁定 dwg_review 既有行为

在改动审查流水线(确定性检查前置 / 结论结构化 / 证据交叉校验)之前,
先立一张离线、确定性、秒级的回归网。

- tests/fakes.py:假 LLM + 假 MCP 基座 + 响应构造器(ok_text/ok_image/err)
  + make_ctx/make_evidence,绝不触网、不读密钥
- 10 个模块共 134 用例:
  mcp_client / llm_client / cad_tools / checklist / config /
  planning / evidence / review / report / annotator
- pytest.ini(testpaths=tests)+ requirements-dev.txt + 根 conftest.py
  (sys.path + RENDER_WAIT=0 / REPORT_DIR=tmp fixture)
This commit is contained in:
gjm committed 2026-10-14 19:07:27 +08:00
1 parent 8c07c7cad6
commit 0656397a68
15 files changed
+1710

No files matched your search

+337
View File
@@ -0,0 +1,337 @@
"""离线测试假件:假 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.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()` 专用队列(便于同时喂 summarize 与 extract_issues)。
"""
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("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,
**over,
):
"""构造 collect_frame 的返回值(evidence)。"""
ev = {
"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": []},
"image_b64": image_b64,
"image_mime": image_mime,
}
ev.update(over)
return ev