mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-01 00:05:43 +08:00
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>
143 lines
74 KiB
JSON
143 lines
74 KiB
JSON
{
|
||
"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(\"&\", \"&\")\n .replace(\"<\", \"<\")\n .replace(\">\", \">\")\n .replace('\"', \""\")\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": []
|
||
}
|