Files
ragflow/internal/agent/dsl/testdata/dfx_picture_parser.json
Zhichang Yu e45659868a feat(agent): ship the Go agent canvas port — eino interrupt/resume + Redis check-pointing (#16035)
Replaces the Python agent canvas runtime with a Go implementation that
runs inside `cmd/server_main`.

The canvas compiles into an eino Workflow that pauses on wait-for-user
via native Interrupt/Resume (no sentinel flag) and resumes from a
Redis-backed CheckPointStore.

All 21 Python agent components and ~35 tools are ported with functional
parity.

Sandbox providers now read their JSON config from the admin-panel
system_settings table with env fallback.

234 files / +35,413 / -6,111. All Go files are gofmt-clean (CI gate
added); drops the v2 DSL E2E step and the gap-analysis plan (both
redundant after the port ships).

## Type of change

- [x] Refactoring
- [x] New feature
- [x] Bug fix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-17 13:24:03 +08:00

143 lines
74 KiB
JSON
Raw Permalink 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.
{
"globals": {
"sys.conversation_turns": 0,
"sys.date": "",
"sys.files": [],
"sys.history": [],
"sys.query": "",
"sys.user_id": ""
},
"graph": {
"edges": [
{
"data": {
"isHovered": false
},
"id": "xy-edge__beginstart-CodeExec:VioletMemesJoinend",
"source": "begin",
"sourceHandle": "start",
"target": "CodeExec:VioletMemesJoin",
"targetHandle": "end"
},
{
"data": {
"isHovered": false
},
"id": "xy-edge__CodeExec:VioletMemesJoinstart-Message:WackyWeeksTravelend",
"source": "CodeExec:VioletMemesJoin",
"sourceHandle": "start",
"target": "Message:WackyWeeksTravel",
"targetHandle": "end"
}
],
"nodes": [
{
"data": {
"form": {
"inputs": {
"tz": {
"key": "tz",
"name": "tz",
"optional": false,
"options": [],
"type": "file",
"value": [
{
"created_at": 1779691169.3897872,
"created_by": "861828e42cc111f1b540c1d623001acf",
"extension": "dxf",
"id": "7b1c92f4580411f1b35d9d889eb78c1a",
"mime_type": "application/octet-stream",
"name": "DIE-5.dxf",
"preview_url": null,
"size": 365963
}
]
}
},
"mode": "conversational",
"outputs": {},
"prologue": "Hi! I'm your assistant. What can I do for you?"
},
"label": "Begin",
"name": "begin"
},
"dragging": false,
"id": "begin",
"measured": {
"height": 109,
"width": 200
},
"position": {
"x": -30,
"y": 56
},
"selected": false,
"sourcePosition": "left",
"targetPosition": "right",
"type": "beginNode"
},
{
"data": {
"form": {
"arguments": {
"tz": "begin@tz"
},
"lang": "python",
"outputs": {
"result": {
"type": "String",
"value": null
}
},
"script": "import os\nimport re\nimport math\nimport json\nimport base64\nimport hashlib\nimport io\nimport tempfile\nimport subprocess\nimport sys\nimport xml.etree.ElementTree as ET\nfrom typing import Any, Optional\n\n\n# ============================================================\n# 0. 依赖处理\n# ============================================================\n\ntry:\n import ezdxf\nexcept ModuleNotFoundError:\n subprocess.check_call(\n [sys.executable, \"-m\", \"pip\", \"install\", \"ezdxf\", \"--quiet\"]\n )\n import ezdxf\n\n\n# ============================================================\n# 1. 全局配置\n# ============================================================\n\nCAD_SYMBOL_MAP = {\n \"%%c\": \"⌀\",\n \"%%C\": \"⌀\",\n \"%%p\": \"±\",\n \"%%P\": \"±\",\n \"%%d\": \"°\",\n \"%%D\": \"°\",\n \"φ\": \"⌀\",\n \"Φ\": \"⌀\",\n \"Ø\": \"⌀\",\n \"ø\": \"⌀\",\n}\n\nFEATURE_RULES = {\n \"A\": {\n \"type\": \"thread_hole\",\n \"label\": \"A 螺纹孔/定位孔\",\n },\n \"E\": {\n \"type\": \"wire_cut\",\n \"label\": \"E 线割孔/割孔\",\n },\n \"F\": {\n \"type\": \"insert_fixing_hole\",\n \"label\": \"F 镶件固定孔\",\n },\n \"G\": {\n \"type\": \"wire_cut\",\n \"label\": \"G 冲切刀口/线割\",\n },\n \"J\": {\n \"type\": \"counterbore\",\n \"label\": \"J 贯穿沉孔\",\n },\n \"L1\": {\n \"type\": \"slot_or_wire_cut\",\n \"label\": \"L1 槽/线切割\",\n },\n \"S\": {\n \"type\": \"screw_hole\",\n \"label\": \"S 螺丝孔/特殊孔\",\n },\n \"T\": {\n \"type\": \"thread_hole\",\n \"label\": \"T 攻牙孔\",\n },\n \"X\": {\n \"type\": \"special_process\",\n \"label\": \"X 特殊加工\",\n },\n}\n\nEXPECTED_FEATURE_CODES = set(FEATURE_RULES.keys())\n\nIGNORE_FEATURE_LAYERS = {\n \"图框线\",\n \"图框\",\n \"标题栏\",\n \"TITLE\",\n \"BORDER\",\n \"FRAME\",\n \"DIM\",\n \"DIMENSION\",\n \"CENTER\",\n \"公差\",\n}\n\nTECH_KEYWORDS = [\n \"加工说明\",\n \"技术要求\",\n \"割\",\n \"线割\",\n \"孔\",\n \"沉头\",\n \"沉孔\",\n \"贯穿\",\n \"攻\",\n \"攻牙\",\n \"镶件\",\n \"固定孔\",\n \"深\",\n \"M12\",\n \"M10\",\n \"M8\",\n \"M6\",\n \"M5\",\n \"M4\",\n \"刀口\",\n \"国产\",\n \"SKD61\",\n \"热处理\",\n \"硬度\",\n \"倒角\",\n \"去毛刺\",\n \"粗糙度\",\n \"公差\",\n \"穿孔机\",\n \"基准孔\",\n \"刃口\",\n \"零件名称\",\n]\n\n\n# ============================================================\n# 2. 基础工具函数\n# ============================================================\n\ndef safe_float(value: Any, default: Optional[float] = None) -> Optional[float]:\n try:\n return float(value)\n except Exception:\n return default\n\n\ndef round_num(value: Any, ndigits: int = 3) -> Optional[float]:\n value = safe_float(value)\n if value is None:\n return None\n return round(value, ndigits)\n\n\ndef distance(p1: dict, p2: dict) -> float:\n return math.sqrt((p1[\"x\"] - p2[\"x\"]) ** 2 + (p1[\"y\"] - p2[\"y\"]) ** 2)\n\n\ndef clean_cad_text(text: Any) -> str:\n if text is None:\n return \"\"\n\n text = str(text)\n\n for src, dst in CAD_SYMBOL_MAP.items():\n text = text.replace(src, dst)\n\n text = text.replace(\"\\\\P\", \"\\n\").replace(\"\\\\p\", \"\\n\")\n\n text = re.sub(r\"\\\\S([^;^]+)\\^([^;]+);\", r\"\\1/\\2\", text)\n text = re.sub(r\"\\\\S([^;/]+)/([^;]+);\", r\"\\1/\\2\", text)\n\n text = re.sub(r\"\\\\[AaCcFfHhLlOoQqTtWw][^;]*;\", \"\", text)\n text = text.replace(\"{\", \"\").replace(\"}\", \"\")\n text = re.sub(r\"\\\\[A-Za-z]+\", \"\", text)\n text = re.sub(r\"\\s*[:]\\s*\", \" : \", text)\n text = re.sub(r\"[ \\t\\r\\f\\v]+\", \" \", text)\n text = re.sub(r\"\\n\\s*\\n+\", \"\\n\", text)\n\n return text.strip()\n\n\ndef unique_keep_order(items: list[str]) -> list[str]:\n result = []\n seen = set()\n\n for item in items:\n item = clean_cad_text(item)\n if not item:\n continue\n\n if item not in seen:\n seen.add(item)\n result.append(item)\n\n return result\n\n\ndef is_title_or_border_layer(layer: str) -> bool:\n if not layer:\n return False\n\n layer_upper = str(layer).upper()\n\n for key in IGNORE_FEATURE_LAYERS:\n if key.upper() in layer_upper:\n return True\n\n return False\n\n\ndef is_valid_feature_label_text(text: str) -> bool:\n \"\"\"\n 只允许独立代号参与几何绑定。\n 例如 A、G、J、L1 可以;\n X.±0.2、.X±0.1、A : xxx 不可以。\n \"\"\"\n if not text:\n return False\n\n t = clean_cad_text(text).strip().upper().replace(\" \", \"\")\n return t in FEATURE_RULES\n\n\ndef is_process_definition_text(text: str) -> bool:\n \"\"\"\n A : xxx、G : xxx 这类是加工说明定义,\n 不能直接作为图上的点位。\n \"\"\"\n if not text:\n return False\n\n t = clean_cad_text(text).strip().upper()\n return bool(re.match(r\"^(L1|A|E|F|G|J|S|T|X)\\s*[:]\", t))\n\n\ndef is_valid_process_note(text: str) -> bool:\n if not text:\n return False\n\n t = clean_cad_text(text).strip().upper()\n\n if is_process_definition_text(t):\n return True\n\n for keyword in TECH_KEYWORDS:\n if keyword.upper() in t:\n return True\n\n return False\n\n\ndef svg_escape(text: Any) -> str:\n text = \"\" if text is None else str(text)\n return (\n text.replace(\"&\", \"&amp;\")\n .replace(\"<\", \"&lt;\")\n .replace(\">\", \"&gt;\")\n .replace('\"', \"&quot;\")\n )\n\n\ndef _svg_float(value: Any, default: float = 0.0) -> float:\n if value is None:\n return default\n match = re.search(r\"-?\\d+(?:\\.\\d+)?\", str(value))\n if not match:\n return default\n return float(match.group(0))\n\n\ndef _svg_color(value: Any, default: tuple[int, int, int, int]) -> tuple[int, int, int, int]:\n value = (\"\" if value is None else str(value)).strip()\n if not value or value == \"none\":\n return default\n if value.startswith(\"#\"):\n value = value[1:]\n if len(value) == 3:\n value = \"\".join(ch * 2 for ch in value)\n if len(value) == 6:\n return (\n int(value[0:2], 16),\n int(value[2:4], 16),\n int(value[4:6], 16),\n 255,\n )\n return default\n\n\ndef _svg_font(size: int, bold: bool = False):\n try:\n from PIL import ImageFont\n except ModuleNotFoundError:\n return None\n\n candidates = [\n r\"C:\\Windows\\Fonts\\msyhbd.ttc\" if bold else r\"C:\\Windows\\Fonts\\msyh.ttc\",\n r\"C:\\Windows\\Fonts\\simhei.ttf\",\n r\"C:\\Windows\\Fonts\\arialbd.ttf\" if bold else r\"C:\\Windows\\Fonts\\arial.ttf\",\n ]\n\n for path in candidates:\n if os.path.exists(path):\n try:\n return ImageFont.truetype(path, size)\n except Exception:\n pass\n\n return ImageFont.load_default()\n\n\ndef svg_to_png_bytes(svg: str) -> bytes:\n try:\n from PIL import Image, ImageDraw\n except ModuleNotFoundError:\n subprocess.check_call(\n [sys.executable, \"-m\", \"pip\", \"install\", \"pillow\", \"--quiet\"]\n )\n from PIL import Image, ImageDraw\n\n root = ET.fromstring(svg)\n width = int(_svg_float(root.attrib.get(\"width\"), 900))\n height = int(_svg_float(root.attrib.get(\"height\"), 700))\n image = Image.new(\"RGBA\", (width, height), (255, 255, 255, 0))\n draw = ImageDraw.Draw(image)\n\n def tag_name(element):\n return element.tag.rsplit(\"}\", 1)[-1]\n\n for element in root.iter():\n tag = tag_name(element)\n if tag in {\"svg\", \"g\", \"title\"}:\n continue\n\n fill_raw = element.attrib.get(\"fill\")\n stroke_raw = element.attrib.get(\"stroke\")\n fill = None if fill_raw == \"none\" else _svg_color(fill_raw, (0, 0, 0, 255))\n stroke = None if stroke_raw in (None, \"none\") else _svg_color(stroke_raw, (0, 0, 0, 255))\n stroke_width = max(int(round(_svg_float(element.attrib.get(\"stroke-width\"), 1))), 1)\n\n if tag == \"rect\":\n x = _svg_float(element.attrib.get(\"x\"))\n y = _svg_float(element.attrib.get(\"y\"))\n w = _svg_float(element.attrib.get(\"width\"))\n h = _svg_float(element.attrib.get(\"height\"))\n rx = _svg_float(element.attrib.get(\"rx\"))\n box = [x, y, x + w, y + h]\n if rx > 0:\n draw.rounded_rectangle(box, radius=rx, fill=fill, outline=stroke, width=stroke_width)\n else:\n draw.rectangle(box, fill=fill, outline=stroke, width=stroke_width)\n\n elif tag == \"circle\":\n cx = _svg_float(element.attrib.get(\"cx\"))\n cy = _svg_float(element.attrib.get(\"cy\"))\n r = _svg_float(element.attrib.get(\"r\"))\n draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=fill, outline=stroke, width=stroke_width)\n\n elif tag == \"line\":\n x1 = _svg_float(element.attrib.get(\"x1\"))\n y1 = _svg_float(element.attrib.get(\"y1\"))\n x2 = _svg_float(element.attrib.get(\"x2\"))\n y2 = _svg_float(element.attrib.get(\"y2\"))\n draw.line([x1, y1, x2, y2], fill=stroke or fill, width=stroke_width)\n\n elif tag == \"text\":\n x = _svg_float(element.attrib.get(\"x\"))\n y = _svg_float(element.attrib.get(\"y\"))\n size = int(_svg_float(element.attrib.get(\"font-size\"), 12))\n bold = str(element.attrib.get(\"font-weight\", \"\")).lower() in {\"700\", \"bold\"}\n text = \"\".join(element.itertext()).strip()\n draw.text((x, y - size), text, fill=fill, font=_svg_font(size, bold))\n\n buffer = io.BytesIO()\n image.convert(\"RGB\").save(buffer, format=\"PNG\")\n return buffer.getvalue()\n\n\ndef save_board_png(svg: str) -> tuple[str, Optional[str]]:\n png = svg_to_png_bytes(svg)\n digest = hashlib.sha1(png).hexdigest()[:16]\n filename = f\"die_feature_board_{digest}.png\"\n\n output_dir = os.environ.get(\n \"RAGFLOW_REPORT_IMAGE_DIR\",\n os.path.join(tempfile.gettempdir(), \"ragflow_report_images\"),\n )\n os.makedirs(output_dir, exist_ok=True)\n\n image_path = os.path.abspath(os.path.join(output_dir, filename))\n with open(image_path, \"wb\") as f:\n f.write(png)\n\n base_url = os.environ.get(\"RAGFLOW_REPORT_IMAGE_BASE_URL\", \"\").strip()\n if not base_url:\n return image_path, None\n\n image_url = base_url.rstrip(\"/\") + \"/\" + filename\n return image_path, image_url\n\n\ndef save_board_artifact(svg: str) -> str:\n png = svg_to_png_bytes(svg)\n output_dir = os.path.abspath(\"artifacts\")\n os.makedirs(output_dir, exist_ok=True)\n image_path = os.path.join(output_dir, \"die_feature_board.png\")\n with open(image_path, \"wb\") as f:\n f.write(png)\n\n encoded = base64.b64encode(png).decode(\"ascii\")\n html_path = os.path.join(output_dir, \"die_feature_board.html\")\n html = f\"\"\"<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>DIE feature board</title>\n <style>\n body {{ margin: 0; padding: 16px; background: #f8fafc; }}\n img {{ display: block; max-width: 100%; height: auto; margin: 0 auto; }}\n </style>\n</head>\n<body>\n <img src=\"data:image/png;base64,{encoded}\" alt=\"DIE feature board\" />\n</body>\n</html>\n\"\"\"\n with open(html_path, \"w\", encoding=\"utf-8\") as f:\n f.write(html)\n\n return image_path\n\n\ndef svg_to_markdown_image(svg: str) -> str:\n \"\"\"\n 把 SVG 转成 base64 图片,避免 Markdown/RAGFlow 把 SVG 标签当普通文本显示。\n \"\"\"\n try:\n image_path = save_board_artifact(svg)\n html_path = os.path.join(os.path.dirname(image_path), \"die_feature_board.html\")\n return (\n \"图片已生成到 artifacts 目录:\\n\"\n f\"- PNG: `{image_path}`\\n\"\n f\"- HTML: `{html_path}`\\n\\n\"\n \"当前 Markdown 页面会清空 data:image因此不能在正文内直接显示。\"\n \"请在流程外层使用 artifact/附件展示,或打开 HTML artifact。\"\n )\n\n return (\n \"> 当前 Markdown 渲染器会清空 data:image 的 src图片已改为生成 PNG 文件。\\n\\n\"\n f\"- PNG 文件: `{image_path}`\\n\"\n \"- 要在报告中直接显示,请把该目录映射为静态资源,并设置环境变量 \"\n \"`RAGFLOW_REPORT_IMAGE_BASE_URL` 为可访问的 http(s) 地址。\"\n )\n except Exception as exc:\n return f\"> 图片生成失败: {exc}\"\n\n\n# ============================================================\n# 3. Base64 与 DXF 读取\n# ============================================================\n\ndef svg_to_image_src(svg: str) -> str:\n png = svg_to_png_bytes(svg)\n encoded = base64.b64encode(png).decode(\"ascii\")\n return f\"data:image/png;base64,{encoded}\"\n\n\ndef render_feature_html_board(model: dict, raw: dict, debug: bool = False) -> str:\n canvas_w = 900\n canvas_h = 700\n header_h = 120\n padding = 70\n\n bounds = get_feature_bounds(model, raw)\n base_map_xy, scale = make_mapper(bounds, canvas_w, canvas_h - header_h, padding)\n\n def map_xy(x: float, y: float) -> tuple[float, float]:\n sx, sy = base_map_xy(x, y)\n return sx, round(sy + header_h, 3)\n\n part = model.get(\"part\", {})\n features = model.get(\"features\", [])\n found_codes = \", \".join(model.get(\"found_feature_codes\", [])) or \"无\"\n positioned_codes = \", \".join(model.get(\"positioned_feature_codes\", [])) or \"无\"\n missing_codes = \", \".join(model.get(\"missing_feature_codes\", [])) or \"无\"\n\n plate_x, plate_y = map_xy(bounds[\"min_x\"], bounds[\"max_y\"])\n plate_w = bounds[\"width\"] * scale\n plate_h = bounds[\"height\"] * scale\n\n def esc(value: Any) -> str:\n return svg_escape(\"\" if value is None else value)\n\n def layer_style(**items):\n return \"; \".join(f\"{k.replace('_', '-')}: {v}\" for k, v in items.items() if v is not None)\n\n parts = [\n f'<div style=\"{layer_style(position=\"relative\", width=str(canvas_w)+\"px\", height=str(canvas_h)+\"px\", max_width=\"100%\", overflow=\"auto\", background=\"#f8fafc\", border=\"1px solid #cbd5e1\")}\">',\n '<div style=\"position:absolute; left:30px; top:16px; font-size:22px; font-weight:700; color:#0f172a;\">DIE 模板动态解析看板</div>',\n f'<div style=\"position:absolute; left:30px; top:50px; font-size:13px; color:#334155;\">零件: {esc(part.get(\"name\") or \"未知\")} 材料: {esc(part.get(\"material\") or \"未知\")} 尺寸: {esc(part.get(\"width\"))} x {esc(part.get(\"height\"))} x {esc(part.get(\"thickness\"))}</div>',\n f'<div style=\"position:absolute; left:30px; top:74px; font-size:12px; color:#475569;\">已识别代号: {esc(found_codes)} 已绑定坐标: {esc(positioned_codes)} 疑似缺失: {esc(missing_codes)}</div>',\n f'<div style=\"{layer_style(position=\"absolute\", left=str(plate_x)+\"px\", top=str(plate_y)+\"px\", width=str(round(plate_w, 3))+\"px\", height=str(round(plate_h, 3))+\"px\", background=\"#ffffff\", border=\"2px solid #1e293b\", border_radius=\"8px\")}\"></div>',\n f'<div style=\"position:absolute; left:{plate_x}px; top:{plate_y - 24}px; font-size:12px; color:#0f172a;\">图纸边界: X {round(bounds[\"min_x\"], 2)} ~ {round(bounds[\"max_x\"], 2)}, Y {round(bounds[\"min_y\"], 2)} ~ {round(bounds[\"max_y\"], 2)} 来源: {esc(bounds[\"source\"])}</div>',\n ]\n\n if debug:\n for c in raw.get(\"circles\", []):\n if is_title_or_border_layer(c.get(\"layer\", \"\")):\n continue\n sx, sy = map_xy(c[\"x\"], c[\"y\"])\n r = max(c[\"radius\"] * scale, 3.0)\n parts.append(\n f'<div title=\"孔\" style=\"{layer_style(position=\"absolute\", left=str(sx-r)+\"px\", top=str(sy-r)+\"px\", width=str(2*r)+\"px\", height=str(2*r)+\"px\", background=\"#fee2e2\", border=\"2px solid #ef4444\", border_radius=\"50%\", box_sizing=\"border-box\")}\"></div>'\n )\n\n for f in features:\n x = f.get(\"x\")\n y = f.get(\"y\")\n if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):\n continue\n\n sx, sy = map_xy(x, y)\n code = esc(f.get(\"code\", \"?\"))\n ftype = f.get(\"type\", \"unknown\")\n diameter = f.get(\"diameter\") or 8.0\n r = max((diameter / 2) * scale, 4.0)\n\n color = \"#2563eb\"\n fill = \"#eff6ff\"\n border_style = \"solid\"\n if ftype == \"counterbore\":\n color = \"#7c3aed\"\n fill = \"transparent\"\n elif ftype == \"thread_hole\":\n color = \"#0891b2\"\n fill = \"#ecfeff\"\n elif ftype == \"wire_cut\":\n color = \"#ea580c\"\n fill = \"#fff7ed\"\n border_style = \"dashed\"\n elif ftype == \"special_process\":\n color = \"#dc2626\"\n fill = \"transparent\"\n\n parts.append(\n f'<div title=\"{esc(f.get(\"description\", \"\"))}\" style=\"{layer_style(position=\"absolute\", left=str(sx-r)+\"px\", top=str(sy-r)+\"px\", width=str(2*r)+\"px\", height=str(2*r)+\"px\", background=fill, border=f\"2px {border_style} {color}\", border_radius=\"50%\", box_sizing=\"border-box\")}\"></div>'\n )\n if ftype == \"special_process\":\n parts.append(f'<div style=\"position:absolute; left:{sx-r}px; top:{sy}px; width:{2*r}px; height:2px; background:{color}; transform:rotate(45deg);\"></div>')\n parts.append(f'<div style=\"position:absolute; left:{sx-r}px; top:{sy}px; width:{2*r}px; height:2px; background:{color}; transform:rotate(-45deg);\"></div>')\n\n parts.append(f'<span style=\"position:absolute; left:{sx + 7}px; top:{sy - 20}px; font-size:12px; font-weight:700; color:{color};\">{code}</span>')\n\n legend_x = 680\n legend_y = 130\n parts.extend([\n f'<div style=\"position:absolute; left:{legend_x}px; top:{legend_y}px; width:185px; height:180px; background:#fff; border:1px solid #cbd5e1; border-radius:10px;\"></div>',\n f'<div style=\"position:absolute; left:{legend_x + 14}px; top:{legend_y + 12}px; font-size:14px; font-weight:700;\">图例</div>',\n f'<div style=\"position:absolute; left:{legend_x + 14}px; top:{legend_y + 42}px; width:14px; height:14px; border-radius:50%; background:#eff6ff; border:2px solid #2563eb;\"></div><span style=\"position:absolute; left:{legend_x + 40}px; top:{legend_y + 40}px; font-size:12px;\">普通孔 / 定位孔</span>',\n f'<div style=\"position:absolute; left:{legend_x + 14}px; top:{legend_y + 70}px; width:14px; height:14px; border-radius:50%; background:#fff7ed; border:2px dashed #ea580c;\"></div><span style=\"position:absolute; left:{legend_x + 40}px; top:{legend_y + 68}px; font-size:12px;\">线割 / 割孔</span>',\n f'<div style=\"position:absolute; left:{legend_x + 11}px; top:{legend_y + 95}px; width:20px; height:20px; border-radius:50%; background:#fff; border:2px solid #7c3aed;\"></div><span style=\"position:absolute; left:{legend_x + 40}px; top:{legend_y + 96}px; font-size:12px;\">沉孔 / 贯穿沉孔</span>',\n f'<div style=\"position:absolute; left:{legend_x + 13}px; top:{legend_y + 128}px; width:20px; height:10px; border-radius:5px; background:#fefce8; border:2px solid #ca8a04;\"></div><span style=\"position:absolute; left:{legend_x + 40}px; top:{legend_y + 124}px; font-size:12px;\">槽 / 线割轮廓</span>',\n f'<span style=\"position:absolute; left:{legend_x + 40}px; top:{legend_y + 154}px; font-size:12px;\">特殊加工 X</span>',\n \"</div>\",\n ])\n\n return \"\\n\".join(parts)\n\n\ndef build_html_report(model: dict, raw: dict, svg: str) -> str:\n part = model.get(\"part\", {})\n counts = model.get(\"raw_counts\", {})\n\n def esc(value: Any) -> str:\n return svg_escape(\"\" if value is None else value)\n\n feature_rows = []\n for f in model.get(\"features\", []):\n feature_rows.append(\n \"<tr>\"\n f\"<td>{esc(f.get('code'))}</td>\"\n f\"<td>{esc(f.get('type'))}</td>\"\n f\"<td>({esc(f.get('x'))}, {esc(f.get('y'))})</td>\"\n f\"<td>{esc(f.get('diameter'))}</td>\"\n f\"<td>{esc(f.get('description'))}</td>\"\n \"</tr>\"\n )\n\n notes = \"\".join(f\"<li>{esc(note)}</li>\" for note in model.get(\"process_notes\", []))\n warnings = \"\".join(f\"<li>{esc(w)}</li>\" for w in model.get(\"warnings\", [])) or \"<li>无</li>\"\n stats = \"\".join(\n f\"<li>{esc(key)}: {esc(value)}</li>\"\n for key, value in sorted(raw.get(\"entity_stats\", {}).items())\n ) or \"<li>无</li>\"\n\n return f\"\"\"<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>机械模具图纸矢量数据智能解析报告</title>\n <style>\n body {{ margin: 0; padding: 24px; font-family: Arial, \"Microsoft YaHei\", sans-serif; color: #0f172a; background: #f8fafc; }}\n main {{ max-width: 1120px; margin: 0 auto; }}\n section {{ margin: 0 0 28px; }}\n h1 {{ font-size: 28px; margin: 0 0 22px; }}\n h2 {{ font-size: 20px; margin: 0 0 12px; }}\n table {{ width: 100%; border-collapse: collapse; background: #fff; }}\n th, td {{ border: 1px solid #cbd5e1; padding: 8px 10px; text-align: left; vertical-align: top; }}\n th {{ background: #e2e8f0; }}\n pre {{ padding: 14px; overflow: auto; background: #0f172a; color: #e2e8f0; }}\n </style>\n</head>\n<body>\n<main>\n <h1>机械模具图纸矢量数据智能解析报告</h1>\n <section><h2>1. 零件基础信息</h2><ul>\n <li>零件名称: {esc(part.get(\"name\") or \"未识别\")}</li>\n <li>材料: {esc(part.get(\"material\") or \"未识别\")}</li>\n <li>宽度: {esc(part.get(\"width\"))}</li>\n <li>高度: {esc(part.get(\"height\"))}</li>\n <li>厚度: {esc(part.get(\"thickness\"))}</li>\n </ul></section>\n <section><h2>2. 解析统计</h2><ul>\n <li>TEXT / MTEXT / ATTRIB 文本数量: {esc(counts.get(\"texts\", 0))}</li>\n <li>DIMENSION 尺寸数量: {esc(counts.get(\"dimensions\", 0))}</li>\n <li>CIRCLE 圆/孔数量: {esc(counts.get(\"circles\", 0))}</li>\n <li>ARC 圆弧数量: {esc(counts.get(\"arcs\", 0))}</li>\n <li>LINE 线段数量: {esc(counts.get(\"lines\", 0))}</li>\n <li>POLYLINE 多段线数量: {esc(counts.get(\"polylines\", 0))}</li>\n </ul></section>\n <section><h2>3. 特征识别结果</h2><table>\n <thead><tr><th>代号</th><th>类型</th><th>坐标</th><th>直径</th><th>说明</th></tr></thead>\n <tbody>{''.join(feature_rows)}</tbody>\n </table></section>\n <section><h2>4. 加工说明</h2><ul>{notes}</ul></section>\n <section><h2>5. 动态看板</h2><div class=\"board\">{svg}</div></section>\n <section><h2>6. 结构化 JSON</h2><pre>{esc(json.dumps(model, ensure_ascii=False, indent=2))}</pre></section>\n <section><h2>7. 解析告警</h2><ul>{warnings}</ul></section>\n <section><h2>8. DXF 实体统计</h2><ul>{stats}</ul></section>\n</main>\n</body>\n</html>\"\"\"\n\n\ndef render_feature_markdown_board(model: dict) -> list[str]:\n rows = []\n rows.append(\"当前页面会拆坏 SVG/HTML 图片,因此这里使用稳定的 Markdown 表格看板。\")\n rows.append(\"\")\n rows.append(\"| 代号 | 类型 | X | Y | 直径 | 图层 | 说明 |\")\n rows.append(\"|---|---|---:|---:|---:|---|---|\")\n\n for f in model.get(\"features\", []):\n rows.append(\n \"| \"\n + \" | \".join(\n [\n str(f.get(\"code\", \"\")),\n str(f.get(\"type\", \"\")),\n str(f.get(\"x\", \"\")),\n str(f.get(\"y\", \"\")),\n str(f.get(\"diameter\", \"\")),\n str(f.get(\"layer\", \"\")),\n str(f.get(\"description\", \"\")).replace(\"|\", \"/\"),\n ]\n )\n + \" |\"\n )\n\n if len(rows) == 2:\n rows.append(\"| - | - | - | - | - | - | 暂无已绑定坐标的特征 |\")\n\n rows.append(\"\")\n rows.append(\"**图例**\")\n rows.append(\"\")\n rows.append(\"| 类型 | 含义 |\")\n rows.append(\"|---|---|\")\n rows.append(\"| counterbore | 沉孔 / 贯穿沉孔 |\")\n rows.append(\"| thread_hole | 攻牙孔 |\")\n rows.append(\"| wire_cut | 线割 / 割孔 |\")\n rows.append(\"| slot_or_wire_cut | 槽 / 线割轮廓 |\")\n rows.append(\"| special_process | 特殊加工 |\")\n rows.append(\"| insert_fixing_hole | 镶件固定孔 |\")\n\n return rows\n\n\ndef decode_base64_dxf(raw_content: str) -> str:\n if not raw_content or not raw_content.strip():\n raise ValueError(\"输入内容为空\")\n\n content = raw_content.strip()\n\n if \"base64,\" in content:\n content = content.split(\"base64,\", 1)[1]\n\n content = re.sub(r\"\\s+\", \"\", content)\n decoded_bytes = base64.b64decode(content, validate=False)\n\n for encoding in [\"utf-8\", \"gbk\", \"gb2312\", \"big5\", \"latin1\"]:\n try:\n return decoded_bytes.decode(encoding, errors=\"ignore\")\n except Exception:\n pass\n\n return decoded_bytes.decode(\"utf-8\", errors=\"ignore\")\n\n\ndef parse_dxf_document(dxf_text: str):\n temp_path = None\n\n try:\n with tempfile.NamedTemporaryFile(\n mode=\"w\",\n suffix=\".dxf\",\n delete=False,\n encoding=\"utf-8\",\n errors=\"ignore\",\n ) as f:\n f.write(dxf_text)\n temp_path = f.name\n\n return ezdxf.readfile(temp_path)\n\n finally:\n if temp_path and os.path.exists(temp_path):\n os.remove(temp_path)\n\n\n# ============================================================\n# 4. DXF 实体提取\n# ============================================================\n\ndef get_point(entity: Any, attr_name: str) -> Optional[dict]:\n try:\n point = getattr(entity.dxf, attr_name)\n return {\n \"x\": round(float(point.x), 3),\n \"y\": round(float(point.y), 3),\n }\n except Exception:\n return None\n\n\ndef get_entity_layer(entity: Any) -> str:\n try:\n return str(entity.dxf.layer)\n except Exception:\n return \"\"\n\n\ndef get_entity_text(entity: Any) -> str:\n try:\n dxftype = entity.dxftype()\n\n if dxftype == \"TEXT\":\n return clean_cad_text(entity.dxf.text)\n\n if dxftype == \"MTEXT\":\n return clean_cad_text(entity.plain_text())\n\n if dxftype == \"ATTRIB\":\n return clean_cad_text(entity.dxf.text)\n\n if dxftype == \"ATTDEF\":\n return clean_cad_text(entity.dxf.text)\n\n if hasattr(entity, \"plain_text\"):\n return clean_cad_text(entity.plain_text())\n\n if hasattr(entity.dxf, \"text\"):\n return clean_cad_text(entity.dxf.text)\n\n except Exception:\n pass\n\n return \"\"\n\n\ndef extract_dimension_text(entity: Any) -> list[str]:\n result = []\n\n try:\n raw_text = clean_cad_text(entity.dxf.text)\n\n if raw_text and raw_text != \"<>\":\n result.append(raw_text)\n else:\n try:\n measurement = entity.get_measurement()\n if measurement is not None:\n result.append(f\"{measurement:g}\")\n except Exception:\n pass\n\n try:\n for v in entity.virtual_entities():\n text = get_entity_text(v)\n if text and text != \"<>\":\n result.append(text)\n except Exception:\n pass\n\n try:\n g_block = entity.get_geometry_block()\n if g_block:\n for e in g_block:\n text = get_entity_text(e)\n if text and text != \"<>\":\n result.append(text)\n except Exception:\n pass\n\n except Exception:\n pass\n\n return result\n\n\ndef extract_lwpolyline_points(entity: Any) -> list[dict]:\n points = []\n\n try:\n if entity.dxftype() == \"LWPOLYLINE\":\n for p in entity.get_points():\n points.append({\n \"x\": round(float(p[0]), 3),\n \"y\": round(float(p[1]), 3),\n })\n\n elif entity.dxftype() == \"POLYLINE\":\n for vertex in entity.vertices:\n loc = vertex.dxf.location\n points.append({\n \"x\": round(float(loc.x), 3),\n \"y\": round(float(loc.y), 3),\n })\n\n except Exception:\n pass\n\n return points\n\n\ndef extract_insert_entities(insert_entity: Any, layout_name: str) -> dict:\n result = {\n \"texts\": [],\n \"circles\": [],\n \"lines\": [],\n \"arcs\": [],\n \"polylines\": [],\n }\n\n block_name = \"\"\n insert_point = get_point(insert_entity, \"insert\")\n\n try:\n block_name = clean_cad_text(insert_entity.dxf.name)\n except Exception:\n pass\n\n try:\n for attrib in insert_entity.attribs:\n tag = clean_cad_text(getattr(attrib.dxf, \"tag\", \"\"))\n value = clean_cad_text(attrib.dxf.text)\n point = get_point(attrib, \"insert\") or insert_point\n\n if tag and value:\n text = f\"{tag}: {value}\"\n else:\n text = value\n\n if text:\n result[\"texts\"].append({\n \"text\": text,\n \"x\": point[\"x\"] if point else None,\n \"y\": point[\"y\"] if point else None,\n \"layer\": get_entity_layer(attrib),\n \"layout\": layout_name,\n \"source\": \"ATTRIB\",\n \"block\": block_name,\n })\n except Exception:\n pass\n\n try:\n virtual_entities = insert_entity.virtual_entities()\n except Exception:\n virtual_entities = []\n\n for v in virtual_entities:\n try:\n dxftype = v.dxftype()\n layer = get_entity_layer(v)\n\n if dxftype in [\"TEXT\", \"MTEXT\", \"ATTRIB\", \"ATTDEF\"]:\n text = get_entity_text(v)\n point = get_point(v, \"insert\") or insert_point\n\n if text:\n result[\"texts\"].append({\n \"text\": text,\n \"x\": point[\"x\"] if point else None,\n \"y\": point[\"y\"] if point else None,\n \"layer\": layer,\n \"layout\": layout_name,\n \"source\": f\"INSERT.{dxftype}\",\n \"block\": block_name,\n })\n\n elif dxftype == \"CIRCLE\":\n center = get_point(v, \"center\")\n radius = round_num(v.dxf.radius)\n\n if center and radius is not None:\n result[\"circles\"].append({\n \"x\": center[\"x\"],\n \"y\": center[\"y\"],\n \"radius\": radius,\n \"diameter\": round(radius * 2, 3),\n \"layer\": layer,\n \"layout\": layout_name,\n \"source\": \"INSERT.CIRCLE\",\n \"block\": block_name,\n })\n\n elif dxftype == \"ARC\":\n center = get_point(v, \"center\")\n radius = round_num(v.dxf.radius)\n\n if center and radius is not None:\n result[\"arcs\"].append({\n \"x\": center[\"x\"],\n \"y\": center[\"y\"],\n \"radius\": radius,\n \"diameter\": round(radius * 2, 3),\n \"layer\": layer,\n \"layout\": layout_name,\n \"source\": \"INSERT.ARC\",\n \"block\": block_name,\n })\n\n elif dxftype == \"LINE\":\n start = get_point(v, \"start\")\n end = get_point(v, \"end\")\n\n if start and end:\n result[\"lines\"].append({\n \"start\": start,\n \"end\": end,\n \"layer\": layer,\n \"layout\": layout_name,\n \"source\": \"INSERT.LINE\",\n \"block\": block_name,\n })\n\n elif dxftype in [\"LWPOLYLINE\", \"POLYLINE\"]:\n points = extract_lwpolyline_points(v)\n result[\"polylines\"].append({\n \"points\": points,\n \"layer\": layer,\n \"layout\": layout_name,\n \"source\": f\"INSERT.{dxftype}\",\n \"block\": block_name,\n })\n\n except Exception:\n continue\n\n return result\n\n\ndef extract_raw_entities(doc: Any) -> dict:\n raw = {\n \"texts\": [],\n \"dimensions\": [],\n \"circles\": [],\n \"arcs\": [],\n \"lines\": [],\n \"polylines\": [],\n \"entity_stats\": {},\n \"warnings\": [],\n }\n\n layouts = []\n\n try:\n layouts.append(doc.modelspace())\n except Exception:\n pass\n\n try:\n for layout in doc.layouts:\n if layout.name != \"Model\":\n layouts.append(layout)\n except Exception:\n pass\n\n for layout in layouts:\n layout_name = getattr(layout, \"name\", \"Model\")\n\n for entity in layout:\n try:\n dxftype = entity.dxftype()\n stat_key = f\"{layout_name}.{dxftype}\"\n raw[\"entity_stats\"][stat_key] = raw[\"entity_stats\"].get(stat_key, 0) + 1\n\n layer = get_entity_layer(entity)\n\n if dxftype in [\"TEXT\", \"MTEXT\", \"ATTRIB\", \"ATTDEF\"]:\n text = get_entity_text(entity)\n point = get_point(entity, \"insert\")\n\n if text:\n raw[\"texts\"].append({\n \"text\": text,\n \"x\": point[\"x\"] if point else None,\n \"y\": point[\"y\"] if point else None,\n \"layer\": layer,\n \"layout\": layout_name,\n \"source\": dxftype,\n \"block\": None,\n })\n\n elif dxftype == \"DIMENSION\":\n dim_texts = extract_dimension_text(entity)\n\n for dim in dim_texts:\n raw[\"dimensions\"].append({\n \"text\": dim,\n \"layer\": layer,\n \"layout\": layout_name,\n \"source\": \"DIMENSION\",\n })\n\n elif dxftype == \"CIRCLE\":\n center = get_point(entity, \"center\")\n radius = round_num(entity.dxf.radius)\n\n if center and radius is not None:\n raw[\"circles\"].append({\n \"x\": center[\"x\"],\n \"y\": center[\"y\"],\n \"radius\": radius,\n \"diameter\": round(radius * 2, 3),\n \"layer\": layer,\n \"layout\": layout_name,\n \"source\": \"CIRCLE\",\n \"block\": None,\n })\n\n elif dxftype == \"ARC\":\n center = get_point(entity, \"center\")\n radius = round_num(entity.dxf.radius)\n\n if center and radius is not None:\n raw[\"arcs\"].append({\n \"x\": center[\"x\"],\n \"y\": center[\"y\"],\n \"radius\": radius,\n \"diameter\": round(radius * 2, 3),\n \"layer\": layer,\n \"layout\": layout_name,\n \"source\": \"ARC\",\n \"block\": None,\n })\n\n elif dxftype == \"LINE\":\n start = get_point(entity, \"start\")\n end = get_point(entity, \"end\")\n\n if start and end:\n raw[\"lines\"].append({\n \"start\": start,\n \"end\": end,\n \"layer\": layer,\n \"layout\": layout_name,\n \"source\": \"LINE\",\n \"block\": None,\n })\n\n elif dxftype in [\"LWPOLYLINE\", \"POLYLINE\"]:\n points = extract_lwpolyline_points(entity)\n raw[\"polylines\"].append({\n \"points\": points,\n \"layer\": layer,\n \"layout\": layout_name,\n \"source\": dxftype,\n \"block\": None,\n })\n\n elif dxftype == \"INSERT\":\n insert_data = extract_insert_entities(entity, layout_name)\n\n for key in [\"texts\", \"circles\", \"arcs\", \"lines\", \"polylines\"]:\n raw[key].extend(insert_data.get(key, []))\n\n except Exception as exc:\n try:\n raw[\"warnings\"].append(\n f\"{layout_name} 中 {entity.dxftype()} 解析失败: {exc}\"\n )\n except Exception:\n raw[\"warnings\"].append(f\"{layout_name} 中未知实体解析失败: {exc}\")\n\n return raw\n\n\n# ============================================================\n# 5. 零件信息识别\n# ============================================================\n\ndef extract_part_info(raw: dict) -> dict:\n all_text = \"\\n\".join(\n [t[\"text\"] for t in raw.get(\"texts\", []) if t.get(\"text\")]\n )\n all_dim_text = \"\\n\".join(\n [d[\"text\"] for d in raw.get(\"dimensions\", []) if d.get(\"text\")]\n )\n merged = all_text + \"\\n\" + all_dim_text\n\n part = {\n \"name\": None,\n \"material\": None,\n \"width\": None,\n \"height\": None,\n \"thickness\": None,\n }\n\n for t in raw.get(\"texts\", []):\n text = t.get(\"text\", \"\")\n if \"零件名称\" in text:\n value = text.split(\":\", 1)[-1].strip() if \":\" in text else text\n part[\"name\"] = clean_cad_text(value)\n break\n\n if not part[\"name\"]:\n name_patterns = [\n r\"\\bDIE[-_ ]?\\d+\\b\",\n r\"下模板镶件\",\n r\"模板\",\n ]\n\n for pattern in name_patterns:\n match = re.search(pattern, merged, flags=re.IGNORECASE)\n if match:\n part[\"name\"] = clean_cad_text(match.group(0))\n break\n\n material_patterns = [\n r\"国产\\s*SKD61\",\n r\"SKD61\",\n r\"SKD11\",\n r\"Cr12MoV\",\n r\"NAK80\",\n r\"S136\",\n r\"718H\",\n ]\n\n for pattern in material_patterns:\n match = re.search(pattern, merged, flags=re.IGNORECASE)\n if match:\n part[\"material\"] = clean_cad_text(match.group(0))\n break\n\n size_patterns = [\n r\"(\\d+(?:\\.\\d+)?)\\s*[xX×*]\\s*(\\d+(?:\\.\\d+)?)\\s*[xX×*]\\s*(\\d+(?:\\.\\d+)?)\",\n r\"(\\d+(?:\\.\\d+)?)\\s*[xX×*]\\s*(\\d+(?:\\.\\d+)?)\",\n ]\n\n for pattern in size_patterns:\n match = re.search(pattern, merged)\n if match:\n nums = [float(v) for v in match.groups()]\n if len(nums) >= 2:\n part[\"width\"] = nums[0]\n part[\"height\"] = nums[1]\n if len(nums) >= 3:\n part[\"thickness\"] = nums[2]\n break\n\n xs = []\n ys = []\n\n for c in raw.get(\"circles\", []):\n if is_title_or_border_layer(c.get(\"layer\", \"\")):\n continue\n xs.extend([c[\"x\"] - c[\"radius\"], c[\"x\"] + c[\"radius\"]])\n ys.extend([c[\"y\"] - c[\"radius\"], c[\"y\"] + c[\"radius\"]])\n\n for line in raw.get(\"lines\", []):\n if is_title_or_border_layer(line.get(\"layer\", \"\")):\n continue\n xs.extend([line[\"start\"][\"x\"], line[\"end\"][\"x\"]])\n ys.extend([line[\"start\"][\"y\"], line[\"end\"][\"y\"]])\n\n for poly in raw.get(\"polylines\", []):\n if is_title_or_border_layer(poly.get(\"layer\", \"\")):\n continue\n for p in poly.get(\"points\", []):\n xs.append(p[\"x\"])\n ys.append(p[\"y\"])\n\n for t in raw.get(\"texts\", []):\n if is_title_or_border_layer(t.get(\"layer\", \"\")):\n continue\n if t.get(\"x\") is not None and t.get(\"y\") is not None:\n xs.append(t[\"x\"])\n ys.append(t[\"y\"])\n\n if xs and ys:\n min_x = min(xs)\n max_x = max(xs)\n min_y = min(ys)\n max_y = max(ys)\n\n estimated_width = round(max_x - min_x, 3)\n estimated_height = round(max_y - min_y, 3)\n\n if not part[\"width\"]:\n part[\"width\"] = estimated_width\n if not part[\"height\"]:\n part[\"height\"] = estimated_height\n\n part[\"estimated_bounds\"] = {\n \"min_x\": round(min_x, 3),\n \"max_x\": round(max_x, 3),\n \"min_y\": round(min_y, 3),\n \"max_y\": round(max_y, 3),\n }\n\n return part\n\n\n# ============================================================\n# 6. 特征识别\n# ============================================================\n\ndef extract_feature_code_from_text(text: str, strict: bool = False) -> Optional[str]:\n if not text:\n return None\n\n raw_text = clean_cad_text(text).strip()\n normalized = raw_text.upper().replace(\" \", \"\")\n\n if strict:\n if normalized in FEATURE_RULES:\n return normalized\n return None\n\n match = re.match(r\"^(L1|A|E|F|G|J|S|T|X)\\s*[:]\", raw_text.upper())\n if match:\n return match.group(1)\n\n if normalized in FEATURE_RULES:\n return normalized\n\n return None\n\n\ndef extract_diameter_from_text(text: str) -> Optional[float]:\n if not text:\n return None\n\n normalized = clean_cad_text(text)\n\n patterns = [\n r\"⌀\\s*(\\d+(?:\\.\\d+)?)\",\n r\"直径\\s*(\\d+(?:\\.\\d+)?)\",\n r\"\\bD\\s*(\\d+(?:\\.\\d+)?)\",\n ]\n\n for pattern in patterns:\n match = re.search(pattern, normalized, flags=re.IGNORECASE)\n if match:\n return round(float(match.group(1)), 3)\n\n return None\n\n\ndef classify_feature_type(code: str, text: str) -> str:\n text = text or \"\"\n\n if \"沉孔\" in text or \"沉头\" in text:\n return \"counterbore\"\n\n if \"攻牙\" in text or \"攻\" in text or re.search(r\"\\bM\\d+\", text, flags=re.IGNORECASE):\n return \"thread_hole\"\n\n if \"割\" in text or \"线割\" in text or \"刀口\" in text:\n return \"wire_cut\"\n\n if \"槽\" in text:\n return \"slot_or_wire_cut\"\n\n if code in FEATURE_RULES:\n return FEATURE_RULES[code][\"type\"]\n\n return \"unknown\"\n\n\ndef build_process_notes(raw: dict) -> list[str]:\n notes = []\n\n for t in raw.get(\"texts\", []):\n text = t.get(\"text\", \"\")\n layer = t.get(\"layer\", \"\")\n\n if is_title_or_border_layer(layer):\n continue\n\n if is_valid_process_note(text):\n notes.append(text)\n\n return unique_keep_order(notes)\n\n\ndef build_code_description_map(raw: dict) -> dict:\n code_descriptions = {}\n\n for t in raw.get(\"texts\", []):\n text = t.get(\"text\", \"\")\n layer = t.get(\"layer\", \"\")\n\n if is_title_or_border_layer(layer):\n continue\n\n code = extract_feature_code_from_text(text, strict=False)\n\n if not code:\n continue\n\n if not is_valid_process_note(text):\n continue\n\n code_descriptions.setdefault(code, [])\n code_descriptions[code].append(text)\n\n return {\n code: unique_keep_order(values)\n for code, values in code_descriptions.items()\n }\n\n\ndef associate_texts_to_circles(raw: dict, max_distance: float = 60.0) -> list[dict]:\n \"\"\"\n 将附近文字代号和圆孔实体关联。\n\n 注意:\n - 不再简单排除 51NOTE。\n - 只排除 A : xxx / G : xxx 这种加工说明定义。\n - 独立 A、G、J、L1 等代号可以参与绑定。\n \"\"\"\n features = []\n circles = raw.get(\"circles\", [])\n texts = raw.get(\"texts\", [])\n code_descriptions = build_code_description_map(raw)\n candidates = []\n\n for t in texts:\n text = t.get(\"text\", \"\")\n layer = t.get(\"layer\", \"\")\n\n if is_title_or_border_layer(layer):\n continue\n\n if is_process_definition_text(text):\n continue\n\n if not is_valid_feature_label_text(text):\n continue\n\n code = extract_feature_code_from_text(text, strict=True)\n\n if not code:\n continue\n\n tx = t.get(\"x\")\n ty = t.get(\"y\")\n\n if tx is None or ty is None:\n continue\n\n text_point = {\n \"x\": tx,\n \"y\": ty,\n }\n\n for idx, circle in enumerate(circles):\n if is_title_or_border_layer(circle.get(\"layer\", \"\")):\n continue\n\n circle_point = {\n \"x\": circle[\"x\"],\n \"y\": circle[\"y\"],\n }\n\n d = distance(text_point, circle_point)\n\n if d <= max_distance:\n candidates.append({\n \"circle_index\": idx,\n \"code\": code,\n \"text\": text,\n \"text_x\": tx,\n \"text_y\": ty,\n \"distance\": d,\n \"circle\": circle,\n \"text_entity\": t,\n })\n\n candidates.sort(key=lambda item: item[\"distance\"])\n\n used_circles = set()\n used_labels = set()\n\n for item in candidates:\n circle_index = item[\"circle_index\"]\n label_key = (\n item[\"code\"],\n round(item[\"text_x\"], 3),\n round(item[\"text_y\"], 3),\n )\n\n if circle_index in used_circles:\n continue\n\n if label_key in used_labels:\n continue\n\n used_circles.add(circle_index)\n used_labels.add(label_key)\n\n code = item[\"code\"]\n circle = item[\"circle\"]\n\n descriptions = code_descriptions.get(code, [])\n description = \"\".join(descriptions[:3]) if descriptions else code\n\n diameter_from_text = extract_diameter_from_text(description)\n diameter = diameter_from_text or circle.get(\"diameter\")\n\n features.append({\n \"code\": code,\n \"type\": classify_feature_type(code, description),\n \"label\": FEATURE_RULES.get(code, {}).get(\"label\", code),\n \"description\": description,\n \"x\": circle[\"x\"],\n \"y\": circle[\"y\"],\n \"radius\": round(diameter / 2, 3) if diameter else circle.get(\"radius\"),\n \"diameter\": diameter,\n \"source\": \"text_near_circle\",\n \"text_x\": item[\"text_x\"],\n \"text_y\": item[\"text_y\"],\n \"distance_to_label\": round(item[\"distance\"], 3),\n \"layer\": circle.get(\"layer\"),\n \"layout\": circle.get(\"layout\"),\n })\n\n return features\n\n\ndef infer_features_from_process_notes(raw: dict) -> list[dict]:\n definitions = []\n code_descriptions = build_code_description_map(raw)\n\n for code, descriptions in code_descriptions.items():\n description = \"\".join(descriptions[:3])\n diameter = extract_diameter_from_text(description)\n\n definitions.append({\n \"code\": code,\n \"type\": classify_feature_type(code, description),\n \"label\": FEATURE_RULES.get(code, {}).get(\"label\", code),\n \"description\": description,\n \"diameter\": diameter,\n \"radius\": round(diameter / 2, 3) if diameter else None,\n \"source\": \"process_definition\",\n \"has_position\": False,\n })\n\n return definitions\n\n\ndef merge_duplicate_features(features: list[dict]) -> list[dict]:\n merged = []\n seen = set()\n\n for f in features:\n code = f.get(\"code\")\n x = f.get(\"x\")\n y = f.get(\"y\")\n diameter = f.get(\"diameter\")\n\n key = (\n code,\n round(x, 2) if isinstance(x, (int, float)) else None,\n round(y, 2) if isinstance(y, (int, float)) else None,\n round(diameter, 2) if isinstance(diameter, (int, float)) else None,\n )\n\n if key in seen:\n continue\n\n seen.add(key)\n merged.append(f)\n\n return merged\n\n\ndef build_feature_model(raw: dict) -> dict:\n part = extract_part_info(raw)\n process_notes = build_process_notes(raw)\n\n positioned_features = associate_texts_to_circles(raw, max_distance=60.0)\n feature_definitions = infer_features_from_process_notes(raw)\n positioned_features = merge_duplicate_features(positioned_features)\n\n found_codes = {\n f[\"code\"]\n for f in positioned_features + feature_definitions\n if f.get(\"code\")\n }\n\n positioned_codes = {\n f[\"code\"]\n for f in positioned_features\n if f.get(\"code\")\n }\n\n missing_codes = sorted(EXPECTED_FEATURE_CODES - found_codes)\n definition_only_codes = sorted(found_codes - positioned_codes)\n\n warnings = []\n\n if missing_codes:\n warnings.append(\n \"加工说明或图纸中未识别到以下特征代号: \"\n + \", \".join(missing_codes)\n )\n\n if definition_only_codes:\n warnings.append(\n \"以下特征只有加工说明,暂未绑定到具体孔位/几何: \"\n + \", \".join(definition_only_codes)\n )\n\n if not positioned_features:\n warnings.append(\n \"未识别到可渲染的坐标特征。当前会显示红色调试孔位;请检查文字代号是否转曲、代号与孔位距离是否过远,或是否需要线割轮廓识别。\"\n )\n\n return {\n \"part\": part,\n \"features\": positioned_features,\n \"feature_definitions\": feature_definitions,\n \"process_notes\": process_notes,\n \"found_feature_codes\": sorted(found_codes),\n \"positioned_feature_codes\": sorted(positioned_codes),\n \"missing_feature_codes\": missing_codes,\n \"raw_counts\": {\n \"texts\": len(raw.get(\"texts\", [])),\n \"dimensions\": len(raw.get(\"dimensions\", [])),\n \"circles\": len(raw.get(\"circles\", [])),\n \"arcs\": len(raw.get(\"arcs\", [])),\n \"lines\": len(raw.get(\"lines\", [])),\n \"polylines\": len(raw.get(\"polylines\", [])),\n },\n \"warnings\": warnings + raw.get(\"warnings\", []),\n }\n\n\n# ============================================================\n# 7. SVG 渲染\n# ============================================================\n\ndef get_feature_bounds(model: dict, raw: dict) -> dict:\n part = model.get(\"part\", {})\n estimated_bounds = part.get(\"estimated_bounds\")\n\n feature_xs = []\n feature_ys = []\n\n for f in model.get(\"features\", []):\n x = f.get(\"x\")\n y = f.get(\"y\")\n diameter = f.get(\"diameter\") or 8.0\n radius = max(float(diameter) / 2, 4.0) if isinstance(diameter, (int, float)) else 4.0\n if isinstance(x, (int, float)) and isinstance(y, (int, float)):\n feature_xs.extend([x - radius, x + radius])\n feature_ys.extend([y - radius, y + radius])\n\n # The DXF entity bounds often include title blocks or far-away construction\n # entities. The visual board should focus on the recognized machining\n # features first, otherwise the real holes collapse into one corner.\n if feature_xs and feature_ys:\n min_x = min(feature_xs)\n max_x = max(feature_xs)\n min_y = min(feature_ys)\n max_y = max(feature_ys)\n margin = max(max_x - min_x, max_y - min_y, 1.0) * 0.18\n\n return {\n \"min_x\": min_x - margin,\n \"max_x\": max_x + margin,\n \"min_y\": min_y - margin,\n \"max_y\": max_y + margin,\n \"width\": max_x - min_x + margin * 2,\n \"height\": max_y - min_y + margin * 2,\n \"source\": \"feature_bounds\",\n }\n\n if estimated_bounds:\n min_x = estimated_bounds.get(\"min_x\")\n max_x = estimated_bounds.get(\"max_x\")\n min_y = estimated_bounds.get(\"min_y\")\n max_y = estimated_bounds.get(\"max_y\")\n\n if None not in [min_x, max_x, min_y, max_y]:\n return {\n \"min_x\": float(min_x),\n \"max_x\": float(max_x),\n \"min_y\": float(min_y),\n \"max_y\": float(max_y),\n \"width\": float(max_x - min_x),\n \"height\": float(max_y - min_y),\n \"source\": \"estimated_bounds\",\n }\n\n xs = []\n ys = []\n\n for f in model.get(\"features\", []):\n if isinstance(f.get(\"x\"), (int, float)) and isinstance(f.get(\"y\"), (int, float)):\n xs.append(f[\"x\"])\n ys.append(f[\"y\"])\n\n for c in raw.get(\"circles\", []):\n if is_title_or_border_layer(c.get(\"layer\", \"\")):\n continue\n xs.extend([c[\"x\"] - c[\"radius\"], c[\"x\"] + c[\"radius\"]])\n ys.extend([c[\"y\"] - c[\"radius\"], c[\"y\"] + c[\"radius\"]])\n\n for line in raw.get(\"lines\", []):\n if is_title_or_border_layer(line.get(\"layer\", \"\")):\n continue\n xs.extend([line[\"start\"][\"x\"], line[\"end\"][\"x\"]])\n ys.extend([line[\"start\"][\"y\"], line[\"end\"][\"y\"]])\n\n for poly in raw.get(\"polylines\", []):\n if is_title_or_border_layer(poly.get(\"layer\", \"\")):\n continue\n for p in poly.get(\"points\", []):\n xs.append(p[\"x\"])\n ys.append(p[\"y\"])\n\n if xs and ys:\n min_x = min(xs)\n max_x = max(xs)\n min_y = min(ys)\n max_y = max(ys)\n\n return {\n \"min_x\": min_x,\n \"max_x\": max_x,\n \"min_y\": min_y,\n \"max_y\": max_y,\n \"width\": max_x - min_x,\n \"height\": max_y - min_y,\n \"source\": \"geometry_bounds\",\n }\n\n width = part.get(\"width\") or 400.0\n height = part.get(\"height\") or 300.0\n\n return {\n \"min_x\": 0.0,\n \"max_x\": float(width),\n \"min_y\": 0.0,\n \"max_y\": float(height),\n \"width\": float(width),\n \"height\": float(height),\n \"source\": \"fallback_part_size\",\n }\n\n\ndef make_mapper(bounds: dict, canvas_w: int, canvas_h: int, padding: int):\n real_w = max(bounds[\"width\"], 1.0)\n real_h = max(bounds[\"height\"], 1.0)\n\n scale = min(\n (canvas_w - 2 * padding) / real_w,\n (canvas_h - 2 * padding) / real_h,\n )\n\n def mapper(x: float, y: float) -> tuple[float, float]:\n sx = padding + (x - bounds[\"min_x\"]) * scale\n sy = padding + (bounds[\"max_y\"] - y) * scale\n return round(sx, 3), round(sy, 3)\n\n return mapper, scale\n\n\ndef render_feature_svg(model: dict, raw: dict, debug: bool = False) -> str:\n canvas_w = 900\n canvas_h = 700\n header_h = 120\n padding = 70\n\n bounds = get_feature_bounds(model, raw)\n base_map_xy, scale = make_mapper(bounds, canvas_w, canvas_h - header_h, padding)\n\n def map_xy(x: float, y: float) -> tuple[float, float]:\n sx, sy = base_map_xy(x, y)\n return sx, round(sy + header_h, 3)\n\n part = model.get(\"part\", {})\n features = model.get(\"features\", [])\n\n plate_x, plate_y = map_xy(bounds[\"min_x\"], bounds[\"max_y\"])\n plate_w = bounds[\"width\"] * scale\n plate_h = bounds[\"height\"] * scale\n\n found_codes = \", \".join(model.get(\"found_feature_codes\", [])) or \"无\"\n positioned_codes = \", \".join(model.get(\"positioned_feature_codes\", [])) or \"无\"\n missing_codes = \", \".join(model.get(\"missing_feature_codes\", [])) or \"无\"\n\n min_x = round(bounds[\"min_x\"], 2)\n max_x = round(bounds[\"max_x\"], 2)\n min_y = round(bounds[\"min_y\"], 2)\n max_y = round(bounds[\"max_y\"], 2)\n source = svg_escape(bounds[\"source\"])\n\n svg = []\n\n svg.append(f'''\n<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{canvas_w}\" height=\"{canvas_h}\" viewBox=\"0 0 {canvas_w} {canvas_h}\">\n <rect x=\"0\" y=\"0\" width=\"{canvas_w}\" height=\"{canvas_h}\" fill=\"#f8fafc\"/>\n\n <text x=\"30\" y=\"36\" font-size=\"22\" font-family=\"Arial\" font-weight=\"700\" fill=\"#0f172a\">\n DIE 模板动态解析看板\n </text>\n\n <text x=\"30\" y=\"62\" font-size=\"13\" font-family=\"Arial\" fill=\"#334155\">\n 零件: {svg_escape(part.get(\"name\") or \"未知\")} 材料: {svg_escape(part.get(\"material\") or \"未知\")} 尺寸: {svg_escape(part.get(\"width\"))} × {svg_escape(part.get(\"height\"))} × {svg_escape(part.get(\"thickness\"))}\n </text>\n\n <text x=\"30\" y=\"84\" font-size=\"12\" font-family=\"Arial\" fill=\"#475569\">\n 已识别代号: {svg_escape(found_codes)} 已绑定坐标: {svg_escape(positioned_codes)} 疑似缺失: {svg_escape(missing_codes)}\n </text>\n\n <rect\n x=\"{plate_x}\"\n y=\"{plate_y}\"\n width=\"{round(plate_w, 3)}\"\n height=\"{round(plate_h, 3)}\"\n rx=\"8\"\n fill=\"#ffffff\"\n stroke=\"#1e293b\"\n stroke-width=\"2\"\n />\n\n <text x=\"{plate_x}\" y=\"{plate_y - 10}\" font-size=\"12\" font-family=\"Arial\" fill=\"#0f172a\">\n 图纸边界: X {min_x} ~ {max_x}, Y {min_y} ~ {max_y} 来源: {source}\n </text>\n''')\n\n # 调试层:所有有效圆孔,使用明显红色\n if debug:\n for c in raw.get(\"circles\", []):\n if is_title_or_border_layer(c.get(\"layer\", \"\")):\n continue\n\n sx, sy = map_xy(c[\"x\"], c[\"y\"])\n r = max(c[\"radius\"] * scale, 3.0)\n\n svg.append(f'''\n <circle cx=\"{sx}\" cy=\"{sy}\" r=\"{round(r, 3)}\" fill=\"#fee2e2\" stroke=\"#ef4444\" stroke-width=\"2.5\"/>\n <circle cx=\"{sx}\" cy=\"{sy}\" r=\"3\" fill=\"#dc2626\"/>\n <text x=\"{sx + 8}\" y=\"{sy - 8}\" font-size=\"12\" font-family=\"Arial\" font-weight=\"700\" fill=\"#dc2626\">孔</text>\n''')\n\n # 渲染已绑定坐标的加工特征\n for f in features:\n x = f.get(\"x\")\n y = f.get(\"y\")\n\n if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):\n continue\n\n sx, sy = map_xy(x, y)\n\n code = f.get(\"code\", \"?\")\n ftype = f.get(\"type\", \"unknown\")\n diameter = f.get(\"diameter\") or 8.0\n r = max((diameter / 2) * scale, 4.0)\n\n label = svg_escape(code)\n title = svg_escape(f.get(\"description\", \"\"))\n\n if ftype == \"counterbore\":\n svg.append(f'''\n <g>\n <title>{title}</title>\n <circle cx=\"{sx}\" cy=\"{sy}\" r=\"{round(r * 1.45, 3)}\" fill=\"none\" stroke=\"#7c3aed\" stroke-width=\"1.6\"/>\n <circle cx=\"{sx}\" cy=\"{sy}\" r=\"{round(r, 3)}\" fill=\"none\" stroke=\"#7c3aed\" stroke-width=\"2.2\"/>\n <text x=\"{sx + 7}\" y=\"{sy - 7}\" font-size=\"12\" font-family=\"Arial\" font-weight=\"700\" fill=\"#7c3aed\">{label}</text>\n </g>\n''')\n\n elif ftype == \"thread_hole\":\n svg.append(f'''\n <g>\n <title>{title}</title>\n <circle cx=\"{sx}\" cy=\"{sy}\" r=\"{round(r, 3)}\" fill=\"#ecfeff\" stroke=\"#0891b2\" stroke-width=\"2\"/>\n <line x1=\"{sx - r}\" y1=\"{sy}\" x2=\"{sx + r}\" y2=\"{sy}\" stroke=\"#0891b2\" stroke-width=\"1\"/>\n <line x1=\"{sx}\" y1=\"{sy - r}\" x2=\"{sx}\" y2=\"{sy + r}\" stroke=\"#0891b2\" stroke-width=\"1\"/>\n <text x=\"{sx + 7}\" y=\"{sy - 7}\" font-size=\"12\" font-family=\"Arial\" font-weight=\"700\" fill=\"#0e7490\">{label}</text>\n </g>\n''')\n\n elif ftype == \"wire_cut\":\n svg.append(f'''\n <g>\n <title>{title}</title>\n <circle cx=\"{sx}\" cy=\"{sy}\" r=\"{round(r, 3)}\" fill=\"#fff7ed\" stroke=\"#ea580c\" stroke-width=\"2\" stroke-dasharray=\"5 3\"/>\n <circle cx=\"{sx}\" cy=\"{sy}\" r=\"2.2\" fill=\"#ea580c\"/>\n <text x=\"{sx + 7}\" y=\"{sy - 7}\" font-size=\"12\" font-family=\"Arial\" font-weight=\"700\" fill=\"#c2410c\">{label}</text>\n </g>\n''')\n\n elif ftype == \"slot_or_wire_cut\":\n slot_w = max(r * 2.6, 18)\n slot_h = max(r * 1.2, 8)\n\n svg.append(f'''\n <g>\n <title>{title}</title>\n <rect x=\"{sx - slot_w / 2}\" y=\"{sy - slot_h / 2}\" width=\"{slot_w}\" height=\"{slot_h}\" rx=\"{slot_h / 2}\" fill=\"#fefce8\" stroke=\"#ca8a04\" stroke-width=\"2\"/>\n <text x=\"{sx + 8}\" y=\"{sy - 8}\" font-size=\"12\" font-family=\"Arial\" font-weight=\"700\" fill=\"#a16207\">{label}</text>\n </g>\n''')\n\n elif ftype == \"special_process\":\n cross = max(r, 8)\n\n svg.append(f'''\n <g>\n <title>{title}</title>\n <line x1=\"{sx - cross}\" y1=\"{sy - cross}\" x2=\"{sx + cross}\" y2=\"{sy + cross}\" stroke=\"#dc2626\" stroke-width=\"2\"/>\n <line x1=\"{sx + cross}\" y1=\"{sy - cross}\" x2=\"{sx - cross}\" y2=\"{sy + cross}\" stroke=\"#dc2626\" stroke-width=\"2\"/>\n <text x=\"{sx + 7}\" y=\"{sy - 7}\" font-size=\"12\" font-family=\"Arial\" font-weight=\"700\" fill=\"#b91c1c\">{label}</text>\n </g>\n''')\n\n else:\n svg.append(f'''\n <g>\n <title>{title}</title>\n <circle cx=\"{sx}\" cy=\"{sy}\" r=\"{round(r, 3)}\" fill=\"#eff6ff\" stroke=\"#2563eb\" stroke-width=\"2\"/>\n <circle cx=\"{sx}\" cy=\"{sy}\" r=\"2\" fill=\"#2563eb\"/>\n <text x=\"{sx + 7}\" y=\"{sy - 7}\" font-size=\"12\" font-family=\"Arial\" font-weight=\"700\" fill=\"#1d4ed8\">{label}</text>\n </g>\n''')\n\n legend_x = 680\n legend_y = 130\n\n svg.append(f'''\n <g>\n <rect x=\"{legend_x}\" y=\"{legend_y}\" width=\"185\" height=\"180\" rx=\"10\" fill=\"#ffffff\" stroke=\"#cbd5e1\"/>\n <text x=\"{legend_x + 14}\" y=\"{legend_y + 24}\" font-size=\"14\" font-family=\"Arial\" font-weight=\"700\" fill=\"#0f172a\">图例</text>\n\n <circle cx=\"{legend_x + 22}\" cy=\"{legend_y + 50}\" r=\"7\" fill=\"#eff6ff\" stroke=\"#2563eb\" stroke-width=\"2\"/>\n <text x=\"{legend_x + 40}\" y=\"{legend_y + 55}\" font-size=\"12\" font-family=\"Arial\" fill=\"#334155\">普通孔 / 定位孔</text>\n\n <circle cx=\"{legend_x + 22}\" cy=\"{legend_y + 78}\" r=\"7\" fill=\"#fff7ed\" stroke=\"#ea580c\" stroke-width=\"2\" stroke-dasharray=\"5 3\"/>\n <text x=\"{legend_x + 40}\" y=\"{legend_y + 83}\" font-size=\"12\" font-family=\"Arial\" fill=\"#334155\">线割 / 割孔</text>\n\n <circle cx=\"{legend_x + 22}\" cy=\"{legend_y + 106}\" r=\"10\" fill=\"none\" stroke=\"#7c3aed\" stroke-width=\"1.5\"/>\n <circle cx=\"{legend_x + 22}\" cy=\"{legend_y + 106}\" r=\"6\" fill=\"none\" stroke=\"#7c3aed\" stroke-width=\"2\"/>\n <text x=\"{legend_x + 40}\" y=\"{legend_y + 111}\" font-size=\"12\" font-family=\"Arial\" fill=\"#334155\">沉孔 / 贯穿沉孔</text>\n\n <rect x=\"{legend_x + 13}\" y=\"{legend_y + 128}\" width=\"20\" height=\"10\" rx=\"5\" fill=\"#fefce8\" stroke=\"#ca8a04\" stroke-width=\"2\"/>\n <text x=\"{legend_x + 40}\" y=\"{legend_y + 138}\" font-size=\"12\" font-family=\"Arial\" fill=\"#334155\">槽 / 线割轮廓</text>\n\n <line x1=\"{legend_x + 15}\" y1=\"{legend_y + 158}\" x2=\"{legend_x + 30}\" y2=\"{legend_y + 173}\" stroke=\"#dc2626\" stroke-width=\"2\"/>\n <line x1=\"{legend_x + 30}\" y1=\"{legend_y + 158}\" x2=\"{legend_x + 15}\" y2=\"{legend_y + 173}\" stroke=\"#dc2626\" stroke-width=\"2\"/>\n <text x=\"{legend_x + 40}\" y=\"{legend_y + 170}\" font-size=\"12\" font-family=\"Arial\" fill=\"#334155\">特殊加工 X</text>\n </g>\n''')\n\n svg.append(\"</svg>\")\n\n return \"\\n\".join(svg)\n\n\n# ============================================================\n# 8. Markdown 输出\n# ============================================================\n\ndef append_feature_section(output: list[str], model: dict) -> None:\n output.append(\"## 3. 特征识别结果\")\n output.append(\"\")\n\n features = model.get(\"features\", [])\n definitions = model.get(\"feature_definitions\", [])\n\n output.append(\"### 3.1 已绑定坐标的实体特征\")\n output.append(\"\")\n\n if features:\n for f in features:\n output.append(\n f\"- {f.get('code', '?')} | \"\n f\"类型: {f.get('type', 'unknown')} | \"\n f\"坐标: ({f.get('x')}, {f.get('y')}) | \"\n f\"直径: {f.get('diameter')} | \"\n f\"说明: {f.get('description', '')}\"\n )\n else:\n output.append(\"- 暂无已绑定坐标的实体特征。\")\n\n output.append(\"\")\n output.append(\"### 3.2 仅在加工说明中出现的特征定义\")\n output.append(\"\")\n\n if definitions:\n for f in definitions:\n output.append(\n f\"- {f.get('code', '?')} | \"\n f\"类型: {f.get('type', 'unknown')} | \"\n f\"直径: {f.get('diameter')} | \"\n f\"说明: {f.get('description', '')}\"\n )\n else:\n output.append(\"- 暂无说明型特征定义。\")\n\n output.append(\"\")\n\n\ndef build_markdown_report(model: dict, raw: dict, svg: str) -> str:\n output = []\n part = model.get(\"part\", {})\n\n output.append(\"# 机械模具图纸矢量数据智能解析报告\")\n output.append(\"\")\n\n output.append(\"## 1. 零件基础信息\")\n output.append(\"\")\n output.append(f\"- 零件名称: {part.get('name') or '未识别'}\")\n output.append(f\"- 材料: {part.get('material') or '未识别'}\")\n output.append(f\"- 宽度: {part.get('width') or '未识别'}\")\n output.append(f\"- 高度: {part.get('height') or '未识别'}\")\n output.append(f\"- 厚度: {part.get('thickness') or '未识别'}\")\n\n if part.get(\"estimated_bounds\"):\n b = part[\"estimated_bounds\"]\n output.append(\n f\"- 实体估算边界: X {b.get('min_x')} ~ {b.get('max_x')}, \"\n f\"Y {b.get('min_y')} ~ {b.get('max_y')}\"\n )\n\n output.append(\"\")\n\n output.append(\"## 2. 解析统计\")\n output.append(\"\")\n\n counts = model.get(\"raw_counts\", {})\n output.append(f\"- TEXT / MTEXT / ATTRIB 文本数量: {counts.get('texts', 0)}\")\n output.append(f\"- DIMENSION 尺寸数量: {counts.get('dimensions', 0)}\")\n output.append(f\"- CIRCLE 圆/孔数量: {counts.get('circles', 0)}\")\n output.append(f\"- ARC 圆弧数量: {counts.get('arcs', 0)}\")\n output.append(f\"- LINE 线段数量: {counts.get('lines', 0)}\")\n output.append(f\"- POLYLINE 多段线数量: {counts.get('polylines', 0)}\")\n output.append(\"\")\n\n append_feature_section(output, model)\n\n output.append(\"## 4. 加工说明\")\n output.append(\"\")\n\n process_notes = model.get(\"process_notes\", [])\n\n if process_notes:\n for note in process_notes:\n output.append(f\"- {note}\")\n else:\n output.append(\"- 未识别到加工说明。\")\n\n output.append(\"\")\n\n output.append(\"## 5. 动态看板\")\n output.append(\"\")\n output.extend(render_feature_markdown_board(model))\n output.append(\"\")\n\n output.append(\"## 6. 结构化 JSON\")\n output.append(\"\")\n output.append(\"```json\")\n output.append(json.dumps(model, ensure_ascii=False, indent=2))\n output.append(\"```\")\n output.append(\"\")\n\n output.append(\"## 7. 解析告警\")\n output.append(\"\")\n\n warnings = model.get(\"warnings\", [])\n\n if warnings:\n for w in warnings[:80]:\n output.append(f\"- {w}\")\n else:\n output.append(\"- 无明显告警。\")\n\n output.append(\"\")\n\n output.append(\"## 8. DXF 实体统计\")\n output.append(\"\")\n\n stats = raw.get(\"entity_stats\", {})\n\n if stats:\n for key, value in sorted(stats.items()):\n output.append(f\"- {key}: {value}\")\n else:\n output.append(\"- 无实体统计。\")\n\n return \"\\n\".join(output).strip()\n\n\n# ============================================================\n# 9. RAGFlow 入口\n# ============================================================\n\ndef main(tz: list[Any]) -> str:\n if not tz:\n return \"RAGFlow提示未接收到上游传入的图纸数据 tz\"\n\n try:\n raw_content = str(tz[0]).strip()\n dxf_text = decode_base64_dxf(raw_content)\n except Exception as exc:\n return f\"Base64解码失败: {exc}\"\n\n try:\n doc = parse_dxf_document(dxf_text)\n except Exception as exc:\n return f\"DXF矢量解析失败: {exc}\"\n\n try:\n raw = extract_raw_entities(doc)\n model = build_feature_model(raw)\n\n # debug=True即使没有成功绑定 A/G也会用红色显示解析出的真实圆孔。\n # 正式上线后可以改成 False。\n svg = render_feature_svg(model, raw, debug=True)\n\n return build_markdown_report(model, raw, svg)\n\n except Exception as exc:\n return f\"DXF内容解析失败: {exc}\"\n"
},
"label": "CodeExec",
"name": "代码_0"
},
"dragging": false,
"id": "CodeExec:VioletMemesJoin",
"measured": {
"height": 49,
"width": 200
},
"position": {
"x": 131,
"y": 241.75
},
"selected": true,
"sourcePosition": "right",
"targetPosition": "left",
"type": "ragNode"
},
{
"data": {
"form": {
"content": [
"{CodeExec:VioletMemesJoin@result}"
],
"output_format": "md"
},
"label": "Message",
"name": "回复消息_0"
},
"dragging": false,
"id": "Message:WackyWeeksTravel",
"measured": {
"height": 85,
"width": 200
},
"position": {
"x": 309.5,
"y": 67.75
},
"selected": false,
"sourcePosition": "right",
"targetPosition": "left",
"type": "messageNode"
}
]
},
"variables": []
}