Files
cad-agent/tests/fakes.py
T
gjm 6143bb9ab2 feat(review): PDF/图片离线源适配(C1)—— 无需 CAD 的整条审查链路
支持直接审查 PDF 与位图图纸,全链路离线(不连 CAD、不连 8080 端口)。
渲染在内存内完成,复用既有墨迹热力图 + 自适应瓦片规划。

- 新增 dwg_review/sources.py
  - detect_source / plan_file / collect 统一入口
  - PdfReader:pypdfium2 惰性导入,页序即图框序,处理 /Rotate 与 crop 边距
  - ImageReader:世界单位 = 源像素,snap_box 整数对齐保证窗口与像素严格一致
  - ink_grid:纯 PIL 灰度阈值 → 占用网格(不引入 numpy)
- ReviewContext.create(connect_cad=False):离线时 client/tools 均为 None
- 全链路 ctx.tools is None 守卫:review 只读子循环 / inspect_region / annotator /
  checks 的窗口类检查 / 绘图区最大化
- checklist.build_system_prompt(allow_tools=False, offline=True):离线提示词
- __main__:位置参数 source,按扩展名自动判定源类型并决定是否连 CAD
- config 新增 8 个常量(PDF_MAX_PAGES / 文本最小字数 / 各源缩放上限 / 墨迹阈值等)
- tiling.content_predicate 与 heatmap.cell_box 提升为公开函数

验证:
- pytest 457 passed
- verify_pdf_source.py:PDF 23/23、图片 9/9(不需 CAD),含实测瓦片字高像素与
  旋转映射自检(文本区墨迹 0.311 vs 忽略 /Rotate 反向对照 0.145)
- 真实 LLM 端到端:20 页 HVAC 图纸前 3 页跑通,结论具体可核验且未编造 handle

已知局限:页边线在墨迹网格里会把整页连成单簇,瓦片常退化为整页象限(CAD 侧
靠 drop_oversized 丢图框块,离线侧暂无等价手段),留待后续打磨。
2026-10-16 23:07:23 +08:00

355 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, connect_cad=True, **over):
"""直接拼装一个 ReviewContext(不触碰网络、不读密钥)。
`connect_cad=False` → `client` / `tools` 置 None,模拟 PDF / 图片**离线源**上下文。
"""
client = client if client is not None else FakeMcpClient()
params = dict(
client=client if connect_cad else None,
tools=CadTools(client) if connect_cad else None,
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