"""离线测试假件:假 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