Compare commits
16 Commits
main
...
codex/genc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2471105cba | ||
|
|
6ca9e69062 | ||
|
|
61995674e9 | ||
|
|
d0d0eb0294 | ||
|
|
c05c3c525c | ||
|
|
aa412fb893 | ||
|
|
1f1e878b75 | ||
|
|
cbdddb4eb4 | ||
|
|
c6a8953e24 | ||
|
|
88cd1dc427 | ||
|
|
47ef7bbed9 | ||
|
|
695090d05a | ||
|
|
809507820a | ||
|
|
68766258b9 | ||
|
|
272a9aa29f | ||
|
|
ca4c84a6e4 |
@ -12,9 +12,7 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import uuid
|
import uuid
|
||||||
@ -153,17 +151,6 @@ DALONGXIA_ANALYTICS_HINT_KEYWORDS = (
|
|||||||
"analytics",
|
"analytics",
|
||||||
)
|
)
|
||||||
|
|
||||||
EXCEL_IMAGE_LINK_HINT_KEYWORDS = (
|
|
||||||
"图片链接",
|
|
||||||
"链接转图片",
|
|
||||||
"转成图片",
|
|
||||||
"回填",
|
|
||||||
"excel",
|
|
||||||
"xlsx",
|
|
||||||
"表格",
|
|
||||||
"文件",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
URL_RE = re.compile(
|
URL_RE = re.compile(
|
||||||
r"https?://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+",
|
r"https?://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+",
|
||||||
@ -309,37 +296,6 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
|||||||
self.video_analysis_timeout = max(15, min(int(os.getenv("VIDEO_ANALYSIS_TIMEOUT", "360")), 900))
|
self.video_analysis_timeout = max(15, min(int(os.getenv("VIDEO_ANALYSIS_TIMEOUT", "360")), 900))
|
||||||
self.video_analysis_max_tokens = max(256, min(int(os.getenv("VIDEO_ANALYSIS_MAX_TOKENS", "1800")), 8192))
|
self.video_analysis_max_tokens = max(256, min(int(os.getenv("VIDEO_ANALYSIS_MAX_TOKENS", "1800")), 8192))
|
||||||
self.video_analysis_max_urls = max(1, min(int(os.getenv("VIDEO_ANALYSIS_MAX_URLS", "2")), 3))
|
self.video_analysis_max_urls = max(1, min(int(os.getenv("VIDEO_ANALYSIS_MAX_URLS", "2")), 3))
|
||||||
self.excel_link_feature_enabled = bool_env("EXCEL_IMAGE_LINK_FEATURE_ENABLED", True)
|
|
||||||
self.excel_converter_script = (
|
|
||||||
os.getenv(
|
|
||||||
"EXCEL_IMAGE_CONVERTER_SCRIPT",
|
|
||||||
"/opt/openclaw-dingtalk-bridge/skills/dingtalk-excel-image-return/convert_excel_image_links.py",
|
|
||||||
).strip()
|
|
||||||
or "/opt/openclaw-dingtalk-bridge/skills/dingtalk-excel-image-return/convert_excel_image_links.py"
|
|
||||||
)
|
|
||||||
self.excel_converter_timeout = max(60, min(int(os.getenv("EXCEL_IMAGE_CONVERTER_TIMEOUT", "480")), 3600))
|
|
||||||
self.excel_link_column = os.getenv("EXCEL_IMAGE_LINK_COLUMN", "图片链接").strip() or "图片链接"
|
|
||||||
self.excel_image_column_header = os.getenv("EXCEL_IMAGE_COLUMN_HEADER", "图片").strip() or "图片"
|
|
||||||
self.excel_image_max_width = max(80, min(int(os.getenv("EXCEL_IMAGE_MAX_WIDTH", "140")), 1200))
|
|
||||||
self.excel_image_max_height = max(80, min(int(os.getenv("EXCEL_IMAGE_MAX_HEIGHT", "140")), 1200))
|
|
||||||
self.excel_image_download_timeout = max(3, min(int(os.getenv("EXCEL_IMAGE_DOWNLOAD_TIMEOUT", "20")), 120))
|
|
||||||
self.excel_file_download_timeout = max(5, min(int(os.getenv("EXCEL_FILE_DOWNLOAD_TIMEOUT", "60")), 300))
|
|
||||||
self.excel_skip_tls_verify = bool_env("EXCEL_IMAGE_SKIP_TLS_VERIFY", False)
|
|
||||||
self.excel_text_prompt_cooldown_seconds = max(
|
|
||||||
10,
|
|
||||||
min(int(os.getenv("EXCEL_TEXT_PROMPT_COOLDOWN_SECONDS", "120")), 600),
|
|
||||||
)
|
|
||||||
self.excel_alert_log_enabled = bool_env("EXCEL_ALERT_LOG_ENABLED", True)
|
|
||||||
self.excel_alert_log_path = (
|
|
||||||
os.getenv(
|
|
||||||
"EXCEL_ALERT_LOG_PATH",
|
|
||||||
"/opt/openclaw-dingtalk-bridge/logs/excel_converter_alerts.log",
|
|
||||||
).strip()
|
|
||||||
or "/opt/openclaw-dingtalk-bridge/logs/excel_converter_alerts.log"
|
|
||||||
)
|
|
||||||
self.excel_alert_detail_limit = max(200, min(int(os.getenv("EXCEL_ALERT_DETAIL_LIMIT", "1600")), 10000))
|
|
||||||
self._excel_alert_log_lock = threading.Lock()
|
|
||||||
self._recent_excel_success_ts = {}
|
|
||||||
|
|
||||||
def _should_handle(self, msg: ChatbotMessage, text: str) -> bool:
|
def _should_handle(self, msg: ChatbotMessage, text: str) -> bool:
|
||||||
if text.startswith(self.prefix):
|
if text.startswith(self.prefix):
|
||||||
@ -1445,154 +1401,6 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
|||||||
break
|
break
|
||||||
return codes
|
return codes
|
||||||
|
|
||||||
def _normalize_file_name(self, raw_name: str) -> str:
|
|
||||||
raw = str(raw_name or "").strip().replace("\\", "_").replace("/", "_")
|
|
||||||
raw = re.sub(r"\s+", "_", raw)
|
|
||||||
stem, ext = os.path.splitext(raw)
|
|
||||||
safe_stem = re.sub(r"[^0-9A-Za-z._-]+", "_", stem).strip("._-")
|
|
||||||
safe_ext = re.sub(r"[^0-9A-Za-z.]+", "", ext).lower()
|
|
||||||
if safe_ext and not safe_ext.startswith("."):
|
|
||||||
safe_ext = f".{safe_ext}"
|
|
||||||
if not safe_stem:
|
|
||||||
safe_stem = f"upload_{int(time.time())}"
|
|
||||||
normalized = f"{safe_stem}{safe_ext}" if safe_ext else safe_stem
|
|
||||||
return normalized[:120]
|
|
||||||
|
|
||||||
def _is_supported_excel_file(self, file_name: str) -> bool:
|
|
||||||
return str(file_name or "").strip().lower().endswith(".xlsx")
|
|
||||||
|
|
||||||
def _is_supported_excel_payload(self, file_name: str, file_type: str) -> bool:
|
|
||||||
if self._is_supported_excel_file(file_name):
|
|
||||||
return True
|
|
||||||
normalized = str(file_type or "").strip().lower()
|
|
||||||
if not normalized:
|
|
||||||
return False
|
|
||||||
normalized = normalized.split(";")[0].strip()
|
|
||||||
if normalized in {
|
|
||||||
"xlsx",
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
}:
|
|
||||||
return True
|
|
||||||
if "spreadsheetml.sheet" in normalized:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _is_excel_link_prompt(self, prompt: str) -> bool:
|
|
||||||
raw = str(prompt or "").strip().lower()
|
|
||||||
if not raw:
|
|
||||||
return False
|
|
||||||
return any(keyword in raw for keyword in EXCEL_IMAGE_LINK_HINT_KEYWORDS)
|
|
||||||
|
|
||||||
def _find_download_code_payload(self, value):
|
|
||||||
stack = [value]
|
|
||||||
while stack:
|
|
||||||
current = stack.pop()
|
|
||||||
if current is None:
|
|
||||||
continue
|
|
||||||
if isinstance(current, str):
|
|
||||||
content = current.strip()
|
|
||||||
if not content:
|
|
||||||
continue
|
|
||||||
if content.startswith("{") or content.startswith("["):
|
|
||||||
try:
|
|
||||||
stack.append(json.loads(content))
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if isinstance(current, dict):
|
|
||||||
lowered = {str(k).lower(): k for k in current.keys()}
|
|
||||||
code = ""
|
|
||||||
if "downloadcode" in lowered:
|
|
||||||
raw_code = current.get(lowered["downloadcode"])
|
|
||||||
code = str(raw_code or "").strip()
|
|
||||||
|
|
||||||
download_url = ""
|
|
||||||
for key_name in (
|
|
||||||
"downloadurl",
|
|
||||||
"download_url",
|
|
||||||
"privatedownloadurl",
|
|
||||||
"private_download_url",
|
|
||||||
"privateurl",
|
|
||||||
):
|
|
||||||
if key_name in lowered:
|
|
||||||
raw_url = current.get(lowered[key_name])
|
|
||||||
candidate_url = str(raw_url or "").strip()
|
|
||||||
if candidate_url.lower().startswith(("http://", "https://")):
|
|
||||||
download_url = candidate_url
|
|
||||||
break
|
|
||||||
|
|
||||||
file_name = ""
|
|
||||||
for candidate in ("fileName", "filename", "file_name", "name", "title"):
|
|
||||||
if candidate in current and str(current.get(candidate) or "").strip():
|
|
||||||
file_name = str(current.get(candidate)).strip()
|
|
||||||
break
|
|
||||||
|
|
||||||
file_type = ""
|
|
||||||
for candidate in ("fileType", "filetype", "mimeType", "mimetype", "suffix", "ext", "extension"):
|
|
||||||
if candidate in current and str(current.get(candidate) or "").strip():
|
|
||||||
file_type = str(current.get(candidate)).strip()
|
|
||||||
break
|
|
||||||
|
|
||||||
if code or download_url:
|
|
||||||
return {
|
|
||||||
"download_code": code,
|
|
||||||
"download_url": download_url,
|
|
||||||
"file_name": file_name,
|
|
||||||
"file_type": file_type,
|
|
||||||
}
|
|
||||||
for child in current.values():
|
|
||||||
stack.append(child)
|
|
||||||
continue
|
|
||||||
if isinstance(current, list):
|
|
||||||
for child in current:
|
|
||||||
stack.append(child)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _extract_incoming_file_payload(self, callback_data: dict, msg: ChatbotMessage) -> dict:
|
|
||||||
payload = self._find_download_code_payload(callback_data)
|
|
||||||
if not payload:
|
|
||||||
payload = self._find_download_code_payload(getattr(msg, "extensions", {}))
|
|
||||||
if not payload:
|
|
||||||
try:
|
|
||||||
payload = self._find_download_code_payload(msg.to_dict())
|
|
||||||
except Exception:
|
|
||||||
payload = None
|
|
||||||
if not payload:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
download_code = str(payload.get("download_code") or "").strip()
|
|
||||||
download_url = str(payload.get("download_url") or "").strip()
|
|
||||||
if download_url and not download_url.lower().startswith(("http://", "https://")):
|
|
||||||
download_url = ""
|
|
||||||
if not download_code and not download_url:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
file_name = payload.get("file_name") or ""
|
|
||||||
file_type = payload.get("file_type") or ""
|
|
||||||
if not file_name and isinstance(callback_data, dict):
|
|
||||||
for key in ("fileName", "filename", "name", "title"):
|
|
||||||
raw = callback_data.get(key)
|
|
||||||
if raw:
|
|
||||||
file_name = str(raw).strip()
|
|
||||||
if file_name:
|
|
||||||
break
|
|
||||||
if not file_type and isinstance(callback_data, dict):
|
|
||||||
for key in ("fileType", "filetype", "mimeType", "mimetype", "suffix", "ext", "extension"):
|
|
||||||
raw = callback_data.get(key)
|
|
||||||
if raw:
|
|
||||||
file_type = str(raw).strip()
|
|
||||||
if file_type:
|
|
||||||
break
|
|
||||||
normalized_file_name = self._normalize_file_name(file_name or "upload.xlsx")
|
|
||||||
normalized_file_type = str(file_type or "").strip().lower()
|
|
||||||
if not self._is_supported_excel_payload(normalized_file_name, normalized_file_type):
|
|
||||||
return {}
|
|
||||||
payload["download_code"] = download_code
|
|
||||||
payload["download_url"] = download_url
|
|
||||||
payload["file_name"] = normalized_file_name
|
|
||||||
payload["file_type"] = normalized_file_type
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def _guess_image_ext(self, content_type: str, source_url: str) -> str:
|
def _guess_image_ext(self, content_type: str, source_url: str) -> str:
|
||||||
ctype = (content_type or "").split(";")[0].strip().lower()
|
ctype = (content_type or "").split(";")[0].strip().lower()
|
||||||
ext = ""
|
ext = ""
|
||||||
@ -2271,259 +2079,6 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
|||||||
raw = (proc.stdout or "").strip()
|
raw = (proc.stdout or "").strip()
|
||||||
return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}"
|
return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}"
|
||||||
|
|
||||||
def _reply_file_by_media_id(self, msg: ChatbotMessage, media_id: str, file_name: str = "") -> bool:
|
|
||||||
if not media_id:
|
|
||||||
return False
|
|
||||||
safe_name = self._normalize_file_name(file_name or f"excel_{int(time.time())}.xlsx")
|
|
||||||
file_ext = os.path.splitext(safe_name)[1].lower().lstrip(".") or "file"
|
|
||||||
request_headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "*/*",
|
|
||||||
}
|
|
||||||
values = {
|
|
||||||
"msgtype": "file",
|
|
||||||
"file": {
|
|
||||||
"mediaId": media_id,
|
|
||||||
"fileName": safe_name,
|
|
||||||
"fileType": file_ext,
|
|
||||||
},
|
|
||||||
"at": {
|
|
||||||
"atUserIds": [msg.sender_staff_id] if msg.sender_staff_id else [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
response_text = ""
|
|
||||||
response = requests.post(
|
|
||||||
msg.session_webhook,
|
|
||||||
headers=request_headers,
|
|
||||||
data=json.dumps(values),
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
response_text = response.text
|
|
||||||
response.raise_for_status()
|
|
||||||
payload = {}
|
|
||||||
try:
|
|
||||||
payload = response.json()
|
|
||||||
except Exception:
|
|
||||||
payload = {}
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
errcode = payload.get("errcode")
|
|
||||||
success = payload.get("success")
|
|
||||||
if (errcode is not None and str(errcode) not in {"0", ""}) or success is False:
|
|
||||||
self.logger.error(
|
|
||||||
"reply file business failed, errcode=%s payload=%s",
|
|
||||||
errcode,
|
|
||||||
str(payload)[:500],
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.error("reply file failed, error=%s, response.text=%s", exc, response_text)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _upload_excel_output_to_s3(self, file_path: str, filename: str) -> str:
|
|
||||||
if boto3 is None or BotoConfig is None:
|
|
||||||
return ""
|
|
||||||
if not (self.s3_bucket and self.s3_access_key and self.s3_secret_key):
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
safe_name = self._normalize_file_name(filename or f"excel_{int(time.time())}.xlsx")
|
|
||||||
object_key = f"{self.s3_key_prefix}excel-output/{safe_name}"
|
|
||||||
self._upload_to_s3(
|
|
||||||
file_path,
|
|
||||||
object_key,
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
)
|
|
||||||
return self._build_public_s3_url(object_key)
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.exception("upload excel output to s3 failed: %s", exc)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _excel_error_summary(self, detail: str) -> str:
|
|
||||||
raw = str(detail or "").strip()
|
|
||||||
if not raw:
|
|
||||||
return "unknown_error"
|
|
||||||
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
|
||||||
if not lines:
|
|
||||||
return raw[:300]
|
|
||||||
for line in reversed(lines):
|
|
||||||
lower_line = line.lower()
|
|
||||||
if lower_line.startswith("traceback"):
|
|
||||||
continue
|
|
||||||
if lower_line.startswith("file "):
|
|
||||||
continue
|
|
||||||
if lower_line in {"^^^^^^", "^^^^^"}:
|
|
||||||
continue
|
|
||||||
return line[:300]
|
|
||||||
return lines[-1][:300]
|
|
||||||
|
|
||||||
def _record_excel_failure_alert(
|
|
||||||
self,
|
|
||||||
msg: ChatbotMessage,
|
|
||||||
file_name: str,
|
|
||||||
stage: str,
|
|
||||||
detail: str,
|
|
||||||
return_code: int | None = None,
|
|
||||||
stdout_text: str = "",
|
|
||||||
stderr_text: str = "",
|
|
||||||
) -> None:
|
|
||||||
normalized_detail = str(detail or "").strip() or "unknown_error"
|
|
||||||
summary = self._excel_error_summary(normalized_detail)
|
|
||||||
digest = hashlib.sha1(normalized_detail.encode("utf-8", errors="ignore")).hexdigest()[:12]
|
|
||||||
log_record = {
|
|
||||||
"ts": dt.datetime.now().isoformat(timespec="seconds"),
|
|
||||||
"event": "excel_convert_failed",
|
|
||||||
"stage": stage,
|
|
||||||
"digest": digest,
|
|
||||||
"summary": summary,
|
|
||||||
"return_code": return_code,
|
|
||||||
"file_name": str(file_name or ""),
|
|
||||||
"conversation_id": str(getattr(msg, "conversation_id", "") or ""),
|
|
||||||
"session_webhook": str(getattr(msg, "session_webhook", "") or ""),
|
|
||||||
"sender_staff_id": str(getattr(msg, "sender_staff_id", "") or ""),
|
|
||||||
"detail": normalized_detail[: self.excel_alert_detail_limit],
|
|
||||||
"stderr": str(stderr_text or "")[: self.excel_alert_detail_limit],
|
|
||||||
"stdout": str(stdout_text or "")[: self.excel_alert_detail_limit],
|
|
||||||
}
|
|
||||||
self.logger.warning(
|
|
||||||
"excel failure alert stage=%s digest=%s file=%s summary=%s",
|
|
||||||
stage,
|
|
||||||
digest,
|
|
||||||
file_name,
|
|
||||||
summary,
|
|
||||||
)
|
|
||||||
if not self.excel_alert_log_enabled:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
parent_dir = os.path.dirname(self.excel_alert_log_path) or "."
|
|
||||||
os.makedirs(parent_dir, exist_ok=True)
|
|
||||||
line = json.dumps(log_record, ensure_ascii=False)
|
|
||||||
with self._excel_alert_log_lock:
|
|
||||||
with open(self.excel_alert_log_path, "a", encoding="utf-8") as file_obj:
|
|
||||||
file_obj.write(line + "\n")
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("write excel alert log failed")
|
|
||||||
|
|
||||||
def _run_excel_image_link_job(self, msg: ChatbotMessage, file_payload: dict) -> tuple[str, bool]:
|
|
||||||
if not self.excel_link_feature_enabled:
|
|
||||||
return "Excel 图片链接转图片功能未启用。", False
|
|
||||||
script_path = self.excel_converter_script
|
|
||||||
if not os.path.exists(script_path):
|
|
||||||
return f"转换脚本不存在:{script_path}", False
|
|
||||||
|
|
||||||
download_code = str(file_payload.get("download_code") or "").strip()
|
|
||||||
download_url = str(file_payload.get("download_url") or "").strip()
|
|
||||||
file_name = self._normalize_file_name(file_payload.get("file_name") or "upload.xlsx")
|
|
||||||
if not download_code and not download_url:
|
|
||||||
return "未获取到文件下载地址,请重试上传。", False
|
|
||||||
if not self._is_supported_excel_file(file_name):
|
|
||||||
return "当前仅支持 .xlsx 文件,请上传Excel后重试。", False
|
|
||||||
|
|
||||||
if not download_url:
|
|
||||||
download_url = self.get_image_download_url(download_code)
|
|
||||||
if not download_url:
|
|
||||||
return "文件下载地址获取失败,请重试。", False
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(prefix="dingtalk_excel_link_") as tmpdir:
|
|
||||||
input_path = os.path.join(tmpdir, file_name)
|
|
||||||
output_name = f"{os.path.splitext(file_name)[0]}_图片回填.xlsx"
|
|
||||||
output_path = os.path.join(tmpdir, output_name)
|
|
||||||
try:
|
|
||||||
response = requests.get(download_url, timeout=self.excel_file_download_timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
with open(input_path, "wb") as fh:
|
|
||||||
fh.write(response.content)
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.exception("download incoming excel failed")
|
|
||||||
self._record_excel_failure_alert(
|
|
||||||
msg,
|
|
||||||
file_name,
|
|
||||||
stage="download_source_excel",
|
|
||||||
detail=f"{type(exc).__name__}: {exc}",
|
|
||||||
)
|
|
||||||
return f"下载源文件失败:{exc}", False
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
sys.executable,
|
|
||||||
script_path,
|
|
||||||
"--input",
|
|
||||||
input_path,
|
|
||||||
"--output",
|
|
||||||
output_path,
|
|
||||||
"--link-column",
|
|
||||||
self.excel_link_column,
|
|
||||||
"--image-column-header",
|
|
||||||
self.excel_image_column_header,
|
|
||||||
"--max-width",
|
|
||||||
str(self.excel_image_max_width),
|
|
||||||
"--max-height",
|
|
||||||
str(self.excel_image_max_height),
|
|
||||||
"--timeout",
|
|
||||||
str(self.excel_image_download_timeout),
|
|
||||||
]
|
|
||||||
if self.excel_skip_tls_verify:
|
|
||||||
cmd.append("--skip-tls-verify")
|
|
||||||
try:
|
|
||||||
proc = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=self.excel_converter_timeout,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
self._record_excel_failure_alert(
|
|
||||||
msg,
|
|
||||||
file_name,
|
|
||||||
stage="excel_converter_timeout",
|
|
||||||
detail=f"timeout>{self.excel_converter_timeout}s",
|
|
||||||
)
|
|
||||||
return f"Excel 转换超时(>{self.excel_converter_timeout}秒),请稍后重试。", False
|
|
||||||
|
|
||||||
stdout_text = (proc.stdout or "").strip()
|
|
||||||
stderr_text = (proc.stderr or "").strip()
|
|
||||||
if proc.returncode != 0 or not os.path.exists(output_path):
|
|
||||||
detail = stderr_text or stdout_text or "未知错误"
|
|
||||||
self.logger.error("excel converter failed code=%s detail=%s", proc.returncode, detail[:2500])
|
|
||||||
self._record_excel_failure_alert(
|
|
||||||
msg,
|
|
||||||
file_name,
|
|
||||||
stage="excel_converter_failed",
|
|
||||||
detail=detail,
|
|
||||||
return_code=proc.returncode,
|
|
||||||
stdout_text=stdout_text,
|
|
||||||
stderr_text=stderr_text,
|
|
||||||
)
|
|
||||||
return f"Excel 转换失败:{detail[:1200]}", False
|
|
||||||
|
|
||||||
summary_line = ""
|
|
||||||
for line in stdout_text.splitlines():
|
|
||||||
if line.startswith("SUMMARY:"):
|
|
||||||
summary_line = line.replace("SUMMARY:", "").strip()
|
|
||||||
break
|
|
||||||
|
|
||||||
media_id = ""
|
|
||||||
try:
|
|
||||||
with open(output_path, "rb") as fh:
|
|
||||||
media_content = fh.read()
|
|
||||||
media_id = self.dingtalk_client.upload_to_dingtalk(
|
|
||||||
media_content,
|
|
||||||
filetype="file",
|
|
||||||
filename=output_name,
|
|
||||||
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
) or ""
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.exception("upload converted excel failed")
|
|
||||||
|
|
||||||
sent = self._reply_file_by_media_id(msg, media_id, output_name) if media_id else False
|
|
||||||
if not sent:
|
|
||||||
s3_url = self._upload_excel_output_to_s3(output_path, output_name)
|
|
||||||
if s3_url:
|
|
||||||
return f"钉钉文件回传受限,已生成下载链接:{s3_url}", True
|
|
||||||
return "处理完成但回传文件失败,请稍后重试。", False
|
|
||||||
|
|
||||||
summary_text = summary_line or "已完成图片回填。"
|
|
||||||
return f"文件已回传:{output_name}\n处理结果:{summary_text}", True
|
|
||||||
|
|
||||||
def _is_image_url(self, raw_url: str) -> bool:
|
def _is_image_url(self, raw_url: str) -> bool:
|
||||||
try:
|
try:
|
||||||
parsed = urllib.parse.urlparse(raw_url)
|
parsed = urllib.parse.urlparse(raw_url)
|
||||||
@ -2655,37 +2210,16 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
|||||||
text = text_list[0].strip() if text_list else ""
|
text = text_list[0].strip() if text_list else ""
|
||||||
|
|
||||||
incoming_image_codes = self._extract_incoming_download_codes(msg)
|
incoming_image_codes = self._extract_incoming_download_codes(msg)
|
||||||
incoming_file_payload = self._extract_incoming_file_payload(callback.data, msg)
|
|
||||||
has_excel_file = bool(incoming_file_payload)
|
|
||||||
conversation_key = re.sub(r"[^A-Za-z0-9_-]+", "-", str(getattr(msg, "conversation_id", "") or "default"))
|
|
||||||
self.logger.info(
|
|
||||||
"incoming message: type=%s has_excel_file=%s has_image=%s text_len=%s",
|
|
||||||
str(getattr(msg, "message_type", "")),
|
|
||||||
has_excel_file,
|
|
||||||
bool(incoming_image_codes),
|
|
||||||
len(text or ""),
|
|
||||||
)
|
|
||||||
if str(getattr(msg, "message_type", "")).lower() == "file" and not has_excel_file:
|
|
||||||
callback_keys = []
|
|
||||||
if isinstance(callback.data, dict):
|
|
||||||
callback_keys = list(callback.data.keys())[:15]
|
|
||||||
self.logger.warning(
|
|
||||||
"file message without excel payload: callback_keys=%s extensions_keys=%s",
|
|
||||||
callback_keys,
|
|
||||||
list((getattr(msg, "extensions", {}) or {}).keys())[:15],
|
|
||||||
)
|
|
||||||
|
|
||||||
if not text and not incoming_image_codes and not has_excel_file:
|
if not text and not incoming_image_codes:
|
||||||
return AckMessage.STATUS_OK, "ignored"
|
return AckMessage.STATUS_OK, "ignored"
|
||||||
|
|
||||||
if not has_excel_file and not self._should_handle(msg, text):
|
if not self._should_handle(msg, text):
|
||||||
return AckMessage.STATUS_OK, "ignored"
|
return AckMessage.STATUS_OK, "ignored"
|
||||||
|
|
||||||
prompt = self._normalize_prompt(text)
|
prompt = self._normalize_prompt(text)
|
||||||
if not prompt:
|
if not prompt:
|
||||||
if has_excel_file:
|
if incoming_image_codes:
|
||||||
prompt = "请处理我上传的Excel文件,把图片链接转成图片并回传。"
|
|
||||||
elif incoming_image_codes:
|
|
||||||
prompt = "请识别并分析我上传的图片内容,输出结论和建议。"
|
prompt = "请识别并分析我上传的图片内容,输出结论和建议。"
|
||||||
else:
|
else:
|
||||||
self._reply_message(
|
self._reply_message(
|
||||||
@ -2694,14 +2228,6 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
|||||||
prefer_card=False,
|
prefer_card=False,
|
||||||
)
|
)
|
||||||
return AckMessage.STATUS_OK, "ok"
|
return AckMessage.STATUS_OK, "ok"
|
||||||
elif not has_excel_file and self.excel_link_feature_enabled and self._is_excel_link_prompt(prompt):
|
|
||||||
now_ts = time.time()
|
|
||||||
last_ts = self._recent_excel_success_ts.get(conversation_key, 0)
|
|
||||||
if last_ts and now_ts - last_ts <= self.excel_text_prompt_cooldown_seconds:
|
|
||||||
self._reply_message(msg, "文件刚处理完成,请查看上一条回传文件。", prefer_card=False)
|
|
||||||
else:
|
|
||||||
self._reply_message(msg, "请直接上传 .xlsx 文件(包含“图片链接”列),我会自动回填并回传。", prefer_card=False)
|
|
||||||
return AckMessage.STATUS_OK, "excel_text_prompt_hint"
|
|
||||||
|
|
||||||
event_key = self._event_key(callback, msg, prompt)
|
event_key = self._event_key(callback, msg, prompt)
|
||||||
if self._seen_or_mark_event(event_key):
|
if self._seen_or_mark_event(event_key):
|
||||||
@ -2727,6 +2253,7 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to send processing hint")
|
self.logger.exception("failed to send processing hint")
|
||||||
|
|
||||||
|
conversation_key = re.sub(r"[^A-Za-z0-9_-]+", "-", str(getattr(msg, "conversation_id", "") or "default"))
|
||||||
conv_lock = self._conversation_locks.setdefault(conversation_key, asyncio.Lock())
|
conv_lock = self._conversation_locks.setdefault(conversation_key, asyncio.Lock())
|
||||||
|
|
||||||
async with conv_lock:
|
async with conv_lock:
|
||||||
@ -2741,23 +2268,7 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
|||||||
conversation_key,
|
conversation_key,
|
||||||
self.max_parallel_jobs,
|
self.max_parallel_jobs,
|
||||||
)
|
)
|
||||||
if has_excel_file:
|
if incoming_image_codes and not self._is_install_media_prompt(prompt):
|
||||||
file_answer, file_sent = await asyncio.to_thread(
|
|
||||||
self._run_excel_image_link_job,
|
|
||||||
msg,
|
|
||||||
incoming_file_payload,
|
|
||||||
)
|
|
||||||
if file_sent:
|
|
||||||
self._recent_excel_success_ts[conversation_key] = time.time()
|
|
||||||
self._reply_message(msg, file_answer, prefer_card=False)
|
|
||||||
self.logger.info(
|
|
||||||
"excel file processed: session=%s file=%s",
|
|
||||||
session_id,
|
|
||||||
incoming_file_payload.get("file_name", ""),
|
|
||||||
)
|
|
||||||
return AckMessage.STATUS_OK, "excel_file_ok"
|
|
||||||
answer = file_answer
|
|
||||||
elif incoming_image_codes and not self._is_install_media_prompt(prompt):
|
|
||||||
image_context_result = await asyncio.to_thread(
|
image_context_result = await asyncio.to_thread(
|
||||||
self._build_image_context_from_dingtalk,
|
self._build_image_context_from_dingtalk,
|
||||||
msg,
|
msg,
|
||||||
@ -2767,18 +2278,17 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
|||||||
context_text = image_context_result.get("context_text", "")
|
context_text = image_context_result.get("context_text", "")
|
||||||
if context_text:
|
if context_text:
|
||||||
prompt_for_exec = self._merge_prompt_with_image_context(prompt, context_text)
|
prompt_for_exec = self._merge_prompt_with_image_context(prompt, context_text)
|
||||||
if not has_excel_file:
|
if self._is_install_media_prompt(prompt):
|
||||||
if self._is_install_media_prompt(prompt):
|
answer = await asyncio.to_thread(self._run_install_media_lookup, prompt)
|
||||||
answer = await asyncio.to_thread(self._run_install_media_lookup, prompt)
|
elif self._is_dalongxia_analytics_prompt(prompt):
|
||||||
elif self._is_dalongxia_analytics_prompt(prompt):
|
answer = await asyncio.to_thread(self._run_dalongxia_analytics, prompt)
|
||||||
answer = await asyncio.to_thread(self._run_dalongxia_analytics, prompt)
|
elif self._is_video_analysis_prompt(prompt):
|
||||||
elif self._is_video_analysis_prompt(prompt):
|
answer = await asyncio.to_thread(self._run_video_analysis, prompt)
|
||||||
answer = await asyncio.to_thread(self._run_video_analysis, prompt)
|
elif self._is_market_prompt(prompt):
|
||||||
elif self._is_market_prompt(prompt):
|
subject = self._extract_market_subject(prompt)
|
||||||
subject = self._extract_market_subject(prompt)
|
answer = await asyncio.to_thread(self._run_market_brief, subject)
|
||||||
answer = await asyncio.to_thread(self._run_market_brief, subject)
|
else:
|
||||||
else:
|
answer = await asyncio.to_thread(self._run_openclaw, session_id, prompt_for_exec)
|
||||||
answer = await asyncio.to_thread(self._run_openclaw, session_id, prompt_for_exec)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("worker execution failed: session=%s", session_id)
|
self.logger.exception("worker execution failed: session=%s", session_id)
|
||||||
answer = "系统执行异常,请稍后重试。"
|
answer = "系统执行异常,请稍后重试。"
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
---
|
|
||||||
name: dingtalk-excel-image-return
|
|
||||||
description: 在钉钉应用机器人(推荐 Stream 模式)场景处理用户上传的 Excel(.xlsx)文件:识别图片链接列,下载链接图片并插入新增图片列,再上传并回传结果文件。用户提到“钉钉机器人”“上传表格”“图片链接转图片”“新增图片列”“回传文件”“批量插图”“Excel 图片列”时使用此技能;涉及钉钉文件下载、Excel 图片写入、文件回传时优先使用此技能。
|
|
||||||
---
|
|
||||||
|
|
||||||
# dingtalk-excel-image-return
|
|
||||||
|
|
||||||
## 触发关键词
|
|
||||||
|
|
||||||
优先匹配以下关键词或等价表达:
|
|
||||||
|
|
||||||
- `钉钉机器人`
|
|
||||||
- `上传表格`
|
|
||||||
- `Excel 图片链接`
|
|
||||||
- `图片链接转图片`
|
|
||||||
- `新增图片列`
|
|
||||||
- `回传文件`
|
|
||||||
- `批量插图`
|
|
||||||
|
|
||||||
## 执行流程(最稳方案)
|
|
||||||
|
|
||||||
1. 使用钉钉应用机器人接收消息,优先使用 Stream 模式。
|
|
||||||
2. 在用户与机器人单聊中接收 `file` 消息,提取 `downloadCode`。
|
|
||||||
3. 调用钉钉文件下载接口获取原始 `.xlsx`。
|
|
||||||
4. 运行 `scripts/convert_excel_image_links.py` 处理表格。
|
|
||||||
5. 上传结果文件到钉钉文件存储。
|
|
||||||
6. 调用会话文件发送接口回传结果。
|
|
||||||
|
|
||||||
如果用户要求“群里 @ 机器人并直接传文件”,先提示群聊文件接收限制,再引导改为单聊上传,或提供上传入口卡片。
|
|
||||||
|
|
||||||
## Excel 处理命令
|
|
||||||
|
|
||||||
执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/convert_excel_image_links.py \
|
|
||||||
--input /tmp/input.xlsx \
|
|
||||||
--output /tmp/output.xlsx \
|
|
||||||
--link-column 图片链接 \
|
|
||||||
--image-column-header 图片
|
|
||||||
```
|
|
||||||
|
|
||||||
常用参数:
|
|
||||||
|
|
||||||
- `--link-column`:链接列(表头名或列字母,如 `C`)
|
|
||||||
- `--header-row`:表头行号(默认 `1`)
|
|
||||||
- `--sheet`:只处理指定工作表(可重复)
|
|
||||||
- `--max-width` / `--max-height`:限制图片尺寸
|
|
||||||
- `--skip-tls-verify`:图片链接为自签名证书时临时使用
|
|
||||||
|
|
||||||
## 钉钉接口编排
|
|
||||||
|
|
||||||
按顺序调用:
|
|
||||||
|
|
||||||
1. 获取用户上传文件(通过 `downloadCode`)。
|
|
||||||
2. 调用下载接口拉取原表格。
|
|
||||||
3. 本地转换并生成新文件。
|
|
||||||
4. 上传新文件。
|
|
||||||
5. 发送文件消息回会话。
|
|
||||||
|
|
||||||
接口详情、字段和最小实现建议见 `references/dingtalk-workflow.md`。
|
|
||||||
|
|
||||||
## 失败兜底
|
|
||||||
|
|
||||||
- 下载图片失败时不中断整表,记录失败行并继续。
|
|
||||||
- 非 `http/https` 链接标记为失败。
|
|
||||||
- 处理完成后始终输出结果文件;失败行通过日志返回。
|
|
||||||
- 涉及认证或证书错误时先打印明确错误,再提示运维修复证书/权限。
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "钉钉表格图片回填"
|
|
||||||
short_description: "把钉钉Excel里的图片链接批量转成嵌入图片并自动回传"
|
|
||||||
default_prompt: "Use $dingtalk-excel-image-return to convert image URLs in an uploaded .xlsx file into embedded images and return the processed file."
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
# DingTalk workflow for Excel image-link conversion
|
|
||||||
|
|
||||||
## 1) Recommended architecture
|
|
||||||
|
|
||||||
- Use an **app bot** (not a group custom bot).
|
|
||||||
- Use **Stream mode** to receive messages.
|
|
||||||
- Prefer **1:1 chat** for file uploads.
|
|
||||||
|
|
||||||
## 2) End-to-end API sequence
|
|
||||||
|
|
||||||
1. Receive message event (`msgtype=file`) and parse:
|
|
||||||
- `conversationId`
|
|
||||||
- `senderStaffId`
|
|
||||||
- `downloadCode`
|
|
||||||
- original `fileName`
|
|
||||||
2. Download source file:
|
|
||||||
- `POST /v1.0/robot/messageFiles/download`
|
|
||||||
- Body includes `downloadCode`.
|
|
||||||
3. Run local conversion script:
|
|
||||||
- `scripts/convert_excel_image_links.py`
|
|
||||||
4. Upload output file to DingTalk file service (get file storage identifiers).
|
|
||||||
5. Send output file back:
|
|
||||||
- `POST /v1.0/convFile/conversations/files/send`
|
|
||||||
- Include `conversationId` and uploaded file info.
|
|
||||||
|
|
||||||
## 3) Message handling policy
|
|
||||||
|
|
||||||
- Reject non-`.xlsx` files with clear error text.
|
|
||||||
- If file size exceeds service limit, return size-limit guidance.
|
|
||||||
- Keep idempotency key (`conversationId + messageId`) to avoid duplicate processing.
|
|
||||||
- Return progress checkpoints in long jobs: received -> processing -> done/failed.
|
|
||||||
|
|
||||||
## 4) Security and reliability
|
|
||||||
|
|
||||||
- Keep app secrets in env vars; never hardcode credentials.
|
|
||||||
- Restrict outbound image downloads with timeout and max size.
|
|
||||||
- Allow partial success for broken image URLs.
|
|
||||||
- Record each failed row with reason for troubleshooting.
|
|
||||||
|
|
||||||
## 5) Suggested runtime contract
|
|
||||||
|
|
||||||
Input contract:
|
|
||||||
- `.xlsx` file
|
|
||||||
- URL column header default: `图片链接`
|
|
||||||
|
|
||||||
Output contract:
|
|
||||||
- New `.xlsx` with inserted column header default: `图片`
|
|
||||||
- Same sheet names and original data preserved
|
|
||||||
- Failed URL rows logged
|
|
||||||
@ -1,505 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import io
|
|
||||||
import mimetypes
|
|
||||||
import re
|
|
||||||
import ssl
|
|
||||||
import tempfile
|
|
||||||
import zipfile
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
from openpyxl import load_workbook
|
|
||||||
from openpyxl.drawing.image import Image as XLImage
|
|
||||||
from openpyxl.packaging import manifest as openpyxl_manifest
|
|
||||||
from openpyxl.utils import column_index_from_string, get_column_letter
|
|
||||||
try:
|
|
||||||
from PIL import Image as PILImage
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
PILImage = None
|
|
||||||
|
|
||||||
|
|
||||||
CONTENT_TYPE_TO_EXT = {
|
|
||||||
"image/png": ".png",
|
|
||||||
"image/jpeg": ".jpg",
|
|
||||||
"image/jpg": ".jpg",
|
|
||||||
"image/webp": ".webp",
|
|
||||||
"image/bmp": ".bmp",
|
|
||||||
"image/gif": ".gif",
|
|
||||||
}
|
|
||||||
|
|
||||||
SUPPORTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}
|
|
||||||
EXCEL_SAFE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".gif"}
|
|
||||||
UNSAFE_OR_UNKNOWN_EXTENSIONS = {".webp", ".tif", ".tiff", ".heic", ".avif"}
|
|
||||||
IMAGE_HINT_HEADERS = (
|
|
||||||
"图片链接",
|
|
||||||
"商品图片",
|
|
||||||
"主图链接",
|
|
||||||
"图片url",
|
|
||||||
"image_url",
|
|
||||||
"image url",
|
|
||||||
"图片",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_mimetype_modules():
|
|
||||||
yield mimetypes
|
|
||||||
compat = getattr(openpyxl_manifest, "mimetypes", None)
|
|
||||||
if compat is not None:
|
|
||||||
yield compat
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_type_maps(module) -> tuple[dict, dict]:
|
|
||||||
type_maps = getattr(module, "types_map", {})
|
|
||||||
if isinstance(type_maps, tuple) and len(type_maps) >= 2:
|
|
||||||
loose_map, strict_map = type_maps[0], type_maps[1]
|
|
||||||
if isinstance(strict_map, dict) and isinstance(loose_map, dict):
|
|
||||||
return strict_map, loose_map
|
|
||||||
if (
|
|
||||||
isinstance(type_maps, dict)
|
|
||||||
and isinstance(type_maps.get(True), dict)
|
|
||||||
and isinstance(type_maps.get(False), dict)
|
|
||||||
):
|
|
||||||
return type_maps[True], type_maps[False]
|
|
||||||
if isinstance(type_maps, dict):
|
|
||||||
loose_map = getattr(module, "common_types", {})
|
|
||||||
if not isinstance(loose_map, dict):
|
|
||||||
loose_map = type_maps
|
|
||||||
return type_maps, loose_map
|
|
||||||
return {}, {}
|
|
||||||
|
|
||||||
|
|
||||||
def _register_type(module, extension: str, mime_type: str) -> None:
|
|
||||||
add_type = getattr(module, "add_type", None)
|
|
||||||
if callable(add_type):
|
|
||||||
try:
|
|
||||||
add_type(mime_type, extension, strict=True)
|
|
||||||
except TypeError:
|
|
||||||
try:
|
|
||||||
add_type(mime_type, extension)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
add_type(mime_type, extension, strict=False)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
strict_map, loose_map = _resolve_type_maps(module)
|
|
||||||
strict_map[extension] = mime_type
|
|
||||||
loose_map.setdefault(extension, mime_type)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_mimetype_for_ext(ext: str) -> None:
|
|
||||||
normalized_raw = str(ext or "").strip()
|
|
||||||
if not normalized_raw:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not normalized_raw.startswith("."):
|
|
||||||
normalized_raw = f".{normalized_raw}"
|
|
||||||
normalized = normalized_raw.lower()
|
|
||||||
|
|
||||||
guessed = mimetypes.types_map.get(normalized)
|
|
||||||
if not guessed:
|
|
||||||
guessed = mimetypes.common_types.get(normalized)
|
|
||||||
if not guessed:
|
|
||||||
guessed, _ = mimetypes.guess_type(f"x{normalized}", strict=False)
|
|
||||||
if not guessed and normalized in {".webp", ".WEBP"}:
|
|
||||||
guessed = "image/webp"
|
|
||||||
if not guessed:
|
|
||||||
guessed = "application/octet-stream"
|
|
||||||
|
|
||||||
variants = {normalized}
|
|
||||||
variants.add(normalized_raw)
|
|
||||||
if normalized_raw != normalized:
|
|
||||||
variants.add(normalized_raw.upper())
|
|
||||||
|
|
||||||
for module in _iter_mimetype_modules():
|
|
||||||
for variant in variants:
|
|
||||||
_register_type(module, variant, guessed)
|
|
||||||
|
|
||||||
|
|
||||||
ensure_mimetype_for_ext(".webp")
|
|
||||||
|
|
||||||
|
|
||||||
def register_mimetypes_from_archive(path: Path) -> None:
|
|
||||||
try:
|
|
||||||
with zipfile.ZipFile(path, "r") as archive:
|
|
||||||
for name in archive.namelist():
|
|
||||||
suffix = Path(name).suffix
|
|
||||||
if suffix:
|
|
||||||
ensure_mimetype_for_ext(suffix)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RowResult:
|
|
||||||
row: int
|
|
||||||
url: str
|
|
||||||
status: str
|
|
||||||
detail: str
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Download image URLs from an Excel column and insert images into a new column."
|
|
||||||
)
|
|
||||||
parser.add_argument("--input", required=True, help="Input .xlsx path")
|
|
||||||
parser.add_argument("--output", required=True, help="Output .xlsx path")
|
|
||||||
parser.add_argument(
|
|
||||||
"--link-column",
|
|
||||||
default="图片链接",
|
|
||||||
help="URL column by header text or column letter (default: 图片链接)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--image-column-header",
|
|
||||||
default="图片",
|
|
||||||
help="Header value for inserted image column",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--header-row",
|
|
||||||
type=int,
|
|
||||||
default=1,
|
|
||||||
help="Header row number (default: 1)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--start-row",
|
|
||||||
type=int,
|
|
||||||
default=0,
|
|
||||||
help="Start processing row (default: header_row + 1)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--sheet",
|
|
||||||
action="append",
|
|
||||||
default=[],
|
|
||||||
help="Sheet name to process (repeatable). Default: process all sheets",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--max-width",
|
|
||||||
type=int,
|
|
||||||
default=140,
|
|
||||||
help="Max image width in pixels (default: 140)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--max-height",
|
|
||||||
type=int,
|
|
||||||
default=140,
|
|
||||||
help="Max image height in pixels (default: 140)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--timeout",
|
|
||||||
type=int,
|
|
||||||
default=15,
|
|
||||||
help="Image download timeout in seconds (default: 15)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--skip-tls-verify",
|
|
||||||
action="store_true",
|
|
||||||
help="Disable TLS certificate verification for image URLs",
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_link_column(sheet, header_row: int, link_column: str) -> int:
|
|
||||||
column_spec = link_column.strip()
|
|
||||||
if re.fullmatch(r"[A-Za-z]{1,3}", column_spec):
|
|
||||||
return column_index_from_string(column_spec.upper())
|
|
||||||
|
|
||||||
for col_idx in range(1, sheet.max_column + 1):
|
|
||||||
value = sheet.cell(row=header_row, column=col_idx).value
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if str(value).strip() == column_spec:
|
|
||||||
return col_idx
|
|
||||||
|
|
||||||
guessed = guess_link_column(sheet, header_row)
|
|
||||||
if guessed is not None:
|
|
||||||
return guessed
|
|
||||||
|
|
||||||
raise ValueError(
|
|
||||||
f"Sheet '{sheet.title}' cannot find link column '{link_column}' in header row {header_row}."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def looks_like_http_url(value: str) -> bool:
|
|
||||||
return str(value or "").lower().startswith(("http://", "https://"))
|
|
||||||
|
|
||||||
|
|
||||||
def looks_like_image_url(url: str) -> bool:
|
|
||||||
raw = str(url or "").strip().lower()
|
|
||||||
if not looks_like_http_url(raw):
|
|
||||||
return False
|
|
||||||
parsed = urllib.parse.urlparse(raw)
|
|
||||||
path = parsed.path or ""
|
|
||||||
host = parsed.hostname or ""
|
|
||||||
if any(path.endswith(ext) for ext in SUPPORTED_EXTENSIONS):
|
|
||||||
return True
|
|
||||||
if host.startswith("img.") or "image" in host:
|
|
||||||
return True
|
|
||||||
if any(token in path for token in ("/img", "/image", "/upload")):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def guess_link_column(sheet, header_row: int) -> int | None:
|
|
||||||
max_scan_rows = min(sheet.max_row, header_row + 30)
|
|
||||||
best_col = None
|
|
||||||
best_score = -1
|
|
||||||
|
|
||||||
for col_idx in range(1, sheet.max_column + 1):
|
|
||||||
header_val = str(sheet.cell(row=header_row, column=col_idx).value or "").strip().lower()
|
|
||||||
score = 0
|
|
||||||
if header_val:
|
|
||||||
if header_val in IMAGE_HINT_HEADERS:
|
|
||||||
score += 40
|
|
||||||
if "图片" in header_val:
|
|
||||||
score += 20
|
|
||||||
if "链接" in header_val or "url" in header_val:
|
|
||||||
score += 15
|
|
||||||
|
|
||||||
http_count = 0
|
|
||||||
image_like_count = 0
|
|
||||||
for row in range(header_row + 1, max_scan_rows + 1):
|
|
||||||
cell_value = sheet.cell(row=row, column=col_idx).value
|
|
||||||
if cell_value is None:
|
|
||||||
continue
|
|
||||||
text = str(cell_value).strip()
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
if looks_like_http_url(text):
|
|
||||||
http_count += 1
|
|
||||||
if looks_like_image_url(text):
|
|
||||||
image_like_count += 1
|
|
||||||
|
|
||||||
score += http_count * 3 + image_like_count * 6
|
|
||||||
if image_like_count == 0 and http_count == 0 and score < 20:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if score > best_score:
|
|
||||||
best_score = score
|
|
||||||
best_col = col_idx
|
|
||||||
|
|
||||||
return best_col
|
|
||||||
|
|
||||||
|
|
||||||
def choose_sheets(workbook, names: Iterable[str]):
|
|
||||||
if not names:
|
|
||||||
return list(workbook.worksheets)
|
|
||||||
|
|
||||||
chosen = []
|
|
||||||
existing = {ws.title: ws for ws in workbook.worksheets}
|
|
||||||
for name in names:
|
|
||||||
if name not in existing:
|
|
||||||
raise ValueError(f"Sheet '{name}' does not exist.")
|
|
||||||
chosen.append(existing[name])
|
|
||||||
return chosen
|
|
||||||
|
|
||||||
|
|
||||||
def build_ssl_context(skip_verify: bool):
|
|
||||||
if not skip_verify:
|
|
||||||
return None
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
context.check_hostname = False
|
|
||||||
context.verify_mode = ssl.CERT_NONE
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
def guess_extension(url: str, content_type: str | None) -> str:
|
|
||||||
parsed = urllib.parse.urlparse(url)
|
|
||||||
suffix = Path(parsed.path).suffix.lower()
|
|
||||||
if suffix in SUPPORTED_EXTENSIONS:
|
|
||||||
return suffix
|
|
||||||
|
|
||||||
if content_type:
|
|
||||||
clean_content_type = content_type.split(";")[0].strip().lower()
|
|
||||||
if clean_content_type in CONTENT_TYPE_TO_EXT:
|
|
||||||
return CONTENT_TYPE_TO_EXT[clean_content_type]
|
|
||||||
|
|
||||||
return ".png"
|
|
||||||
|
|
||||||
|
|
||||||
def download_image(url: str, timeout: int, ssl_context):
|
|
||||||
request = urllib.request.Request(
|
|
||||||
url,
|
|
||||||
headers={"User-Agent": "Mozilla/5.0 CodexSkill/1.0"},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(request, timeout=timeout, context=ssl_context) as response:
|
|
||||||
content_type = response.headers.get("Content-Type")
|
|
||||||
data = response.read()
|
|
||||||
return data, guess_extension(url, content_type)
|
|
||||||
|
|
||||||
|
|
||||||
def to_excel_safe_image(payload: bytes, extension: str) -> tuple[bytes, str]:
|
|
||||||
ext = (extension or "").lower()
|
|
||||||
ensure_mimetype_for_ext(ext)
|
|
||||||
if ext in EXCEL_SAFE_EXTENSIONS and ext not in UNSAFE_OR_UNKNOWN_EXTENSIONS:
|
|
||||||
return payload, ext
|
|
||||||
|
|
||||||
if PILImage is None:
|
|
||||||
return payload, ".png"
|
|
||||||
|
|
||||||
with PILImage.open(io.BytesIO(payload)) as image:
|
|
||||||
if image.mode not in {"RGB", "RGBA"}:
|
|
||||||
image = image.convert("RGBA")
|
|
||||||
output = io.BytesIO()
|
|
||||||
image.save(output, format="PNG")
|
|
||||||
return output.getvalue(), ".png"
|
|
||||||
|
|
||||||
|
|
||||||
def pixels_to_excel_width(px: int) -> float:
|
|
||||||
return max(12.0, round((px + 12) / 7, 2))
|
|
||||||
|
|
||||||
|
|
||||||
def pixels_to_points(px: int) -> float:
|
|
||||||
return round(px * 0.75, 2)
|
|
||||||
|
|
||||||
|
|
||||||
def resize_image(image: XLImage, max_width: int, max_height: int) -> None:
|
|
||||||
width = image.width or max_width
|
|
||||||
height = image.height or max_height
|
|
||||||
scale = min(max_width / width, max_height / height, 1.0)
|
|
||||||
image.width = int(width * scale)
|
|
||||||
image.height = int(height * scale)
|
|
||||||
|
|
||||||
|
|
||||||
def process_sheet(sheet, args: argparse.Namespace, ssl_context, temp_files: list[Path]) -> list[RowResult]:
|
|
||||||
results: list[RowResult] = []
|
|
||||||
|
|
||||||
header_row = args.header_row
|
|
||||||
start_row = args.start_row if args.start_row > 0 else header_row + 1
|
|
||||||
|
|
||||||
link_column_idx = resolve_link_column(sheet, header_row, args.link_column)
|
|
||||||
image_column_idx = link_column_idx + 1
|
|
||||||
sheet.insert_cols(image_column_idx)
|
|
||||||
sheet.cell(row=header_row, column=image_column_idx, value=args.image_column_header)
|
|
||||||
|
|
||||||
image_column_letter = get_column_letter(image_column_idx)
|
|
||||||
column_width = pixels_to_excel_width(args.max_width)
|
|
||||||
existing_width = sheet.column_dimensions[image_column_letter].width
|
|
||||||
if existing_width is None or existing_width < column_width:
|
|
||||||
sheet.column_dimensions[image_column_letter].width = column_width
|
|
||||||
|
|
||||||
for row in range(start_row, sheet.max_row + 1):
|
|
||||||
source = sheet.cell(row=row, column=link_column_idx).value
|
|
||||||
if source is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
url = str(source).strip()
|
|
||||||
if not url:
|
|
||||||
continue
|
|
||||||
if not url.lower().startswith(("http://", "https://")):
|
|
||||||
results.append(RowResult(row=row, url=url, status="failed", detail="invalid_url"))
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload, extension = download_image(url, args.timeout, ssl_context)
|
|
||||||
payload, extension = to_excel_safe_image(payload, extension)
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=extension) as temp_file:
|
|
||||||
temp_file.write(payload)
|
|
||||||
temp_path = Path(temp_file.name)
|
|
||||||
temp_files.append(temp_path)
|
|
||||||
|
|
||||||
image = XLImage(str(temp_path))
|
|
||||||
resize_image(image, args.max_width, args.max_height)
|
|
||||||
|
|
||||||
anchor = f"{image_column_letter}{row}"
|
|
||||||
sheet.add_image(image, anchor)
|
|
||||||
|
|
||||||
needed_height = pixels_to_points(image.height + 8)
|
|
||||||
current_height = sheet.row_dimensions[row].height
|
|
||||||
if current_height is None or current_height < needed_height:
|
|
||||||
sheet.row_dimensions[row].height = needed_height
|
|
||||||
|
|
||||||
results.append(RowResult(row=row, url=url, status="ok", detail="inserted"))
|
|
||||||
except urllib.error.URLError as error:
|
|
||||||
results.append(RowResult(row=row, url=url, status="failed", detail=f"download_error:{error.reason}"))
|
|
||||||
except Exception as error: # noqa: BLE001
|
|
||||||
results.append(RowResult(row=row, url=url, status="failed", detail=f"image_error:{error}"))
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def drop_broken_images(workbook) -> int:
|
|
||||||
removed = 0
|
|
||||||
for sheet in workbook.worksheets:
|
|
||||||
existing = list(getattr(sheet, "_images", []))
|
|
||||||
if not existing:
|
|
||||||
continue
|
|
||||||
kept = []
|
|
||||||
removed_in_sheet = 0
|
|
||||||
for image in existing:
|
|
||||||
try:
|
|
||||||
image._data()
|
|
||||||
kept.append(image)
|
|
||||||
except Exception:
|
|
||||||
removed += 1
|
|
||||||
removed_in_sheet += 1
|
|
||||||
if removed_in_sheet:
|
|
||||||
sheet._images = kept
|
|
||||||
return removed
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
|
|
||||||
input_path = Path(args.input)
|
|
||||||
output_path = Path(args.output)
|
|
||||||
|
|
||||||
if input_path.suffix.lower() != ".xlsx":
|
|
||||||
print("ERROR: input must be .xlsx")
|
|
||||||
return 1
|
|
||||||
if not input_path.exists():
|
|
||||||
print(f"ERROR: input does not exist: {input_path}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
register_mimetypes_from_archive(input_path)
|
|
||||||
ssl_context = build_ssl_context(args.skip_tls_verify)
|
|
||||||
|
|
||||||
workbook = load_workbook(input_path)
|
|
||||||
sheets = choose_sheets(workbook, args.sheet)
|
|
||||||
temp_files: list[Path] = []
|
|
||||||
|
|
||||||
all_results: list[tuple[str, RowResult]] = []
|
|
||||||
for sheet in sheets:
|
|
||||||
sheet_results = process_sheet(sheet, args, ssl_context, temp_files)
|
|
||||||
all_results.extend((sheet.title, item) for item in sheet_results)
|
|
||||||
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
workbook.save(output_path)
|
|
||||||
except Exception as save_error:
|
|
||||||
removed = drop_broken_images(workbook)
|
|
||||||
if removed <= 0:
|
|
||||||
raise
|
|
||||||
print(f"WARN: dropped_broken_images={removed} reason={save_error}")
|
|
||||||
workbook.save(output_path)
|
|
||||||
finally:
|
|
||||||
for file_path in temp_files:
|
|
||||||
file_path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
success_count = sum(1 for _, item in all_results if item.status == "ok")
|
|
||||||
fail_count = sum(1 for _, item in all_results if item.status == "failed")
|
|
||||||
|
|
||||||
print(f"DONE: output={output_path}")
|
|
||||||
print(f"SUMMARY: success={success_count} failed={fail_count}")
|
|
||||||
|
|
||||||
for sheet_name, result in all_results:
|
|
||||||
if result.status == "failed":
|
|
||||||
print(
|
|
||||||
f"FAILED: sheet={sheet_name} row={result.row} url={result.url} reason={result.detail}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
# CAD-Coder 对产品成本评估 CAD 链路的适用性评估
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
CAD-Coder 可以加强“单个已隔离部件的参数化几何候选生成”,不能直接代替整件家具的多视图理解、装配图谱、五金/孔槽规则和生产验证。线上主链路不应直接使用其单次输出。
|
||||||
|
|
||||||
|
## 论文中已证明的能力
|
||||||
|
|
||||||
|
- 训练目标是从一张渲染图输出可执行的 CadQuery Python,训练集为 163,671 个图像-代码配对。
|
||||||
|
- 数据中的几何操作主要是直线、圆弧、圆和拉伸;训练图为理想化灰度等距渲染。
|
||||||
|
- 对 100 个同分布测试样本,论文报告可执行率 100%、尺度和位移归一化后的最佳对齐 IoU 为 0.675。
|
||||||
|
- 论文只用 5 个简单 3D 打印物体检查真实照片泛化,结果显示可生成大致形状,但比渲染图明显更差。
|
||||||
|
|
||||||
|
## 不能证明的能力
|
||||||
|
|
||||||
|
- 没有家具总成、抽屉、移门、背板、五金、连接孔、槽位和 BOM 语义评测。
|
||||||
|
- 没有同时输入我们要求的正面、侧面、内部/开门和尺寸图并做一致性求解。
|
||||||
|
- IoU 评测先归一化尺度并选最佳主轴对齐,因此不能证明实际长宽高和方向正确。
|
||||||
|
- 论文明确报告真实照片中会出现长宽比例错误,多次拉伸的复杂实体会漏特征,未训练操作的成功很依赖提示词。
|
||||||
|
- 该代码库尚未提供简化的单请求在线推理接口;模型权重约 26.7GB,官方推理代码依赖 CUDA/LLaVA 环境。
|
||||||
|
|
||||||
|
## 正确的接入位置
|
||||||
|
|
||||||
|
1. 现有多视图理解器先产生带证据的家具装配图谱:总成、物理实例、尺寸参数、运动副和不确定项。
|
||||||
|
2. 确定性约束编译器解决坐标、边界、接触、干涉和数量,不接受模型心算 XYZ。
|
||||||
|
3. 只将已隔离且普通 box 表达不足的单个异形件裁剪图交给 CAD-Coder,让它生成候选 CadQuery 实体。
|
||||||
|
4. 生成代码在无网络、无宿主文件权限、有超时和资源额度的沙箱执行。
|
||||||
|
5. 每个候选必须通过尺寸包围盒、体积、面数、连通体、与相邻件接触/干涉、四向轮廓和孔槽清单检验。
|
||||||
|
6. 只有确定性检验通过才将候选合并到主图谱;失败则回到局部重读/重生成,不会交付给用户。
|
||||||
|
7. 连接孔、背板槽、抽底槽、螺钉和滑轨仍由“材料 + 板厚 + 接触类型 + 运动副 + 载荷”的工艺模板生成,禁止 CAD-Coder 自由猜测。
|
||||||
|
|
||||||
|
## 训练和评测改造
|
||||||
|
|
||||||
|
- 学习论文的领域专项微调,但目标不应只是最终单实体 CadQuery,而是“装配图谱 + 可执行局部几何程序”。
|
||||||
|
- 用现有参数化家具图谱自动生成正视/侧视/俯视/轴测/尺寸图,并保留完整部件、接触、孔槽和材料真值。
|
||||||
|
- 在渲染图基础上增加真实商品背景、透视、遮挡、纹理、弱标注和多视图缺失,不重复 CAD-Coder 的理想灰度等距训练分布。
|
||||||
|
- 评测不使用归一化后最佳方向 IoU 作唯一指标;必须同时验收绝对 X/Y/Z、部件数量、结构图谱、连接模板、孔槽和冻结后成本偏差。
|
||||||
|
- 公司 BOM 只在估算冻结后做盲测答案键,不进入识图、生成或自修正上下文。
|
||||||
|
|
||||||
|
## 资源与上线边界
|
||||||
|
|
||||||
|
- 权重已发布,并非纸面方案。但现有代码默认 `.half().cuda()`,当前 Apple M1 Max 开发机不能按官方路径原样运行。
|
||||||
|
- 不在 Chat API 服务主机内下载 26.7GB 权重或与在线任务抢占显存/内存。先在独立 GPU 工作节点做隔离 A/B 评测。
|
||||||
|
- 首批验收不是“代码可运行”,而是同一原图下相对现有候选生成器在部件完整率、绝对尺寸、接触干涉、孔槽和最终成本上确实提升。
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- CAD-Coder 论文:<https://arxiv.org/abs/2505.14646>
|
||||||
|
- CAD-Coder 官方代码:<https://github.com/anniedoris/CAD-Coder>
|
||||||
|
- CAD-Coder 官方权重:<https://huggingface.co/CADCODER/CAD-Coder/tree/main>
|
||||||
|
- 2026 CADBench:<https://arxiv.org/abs/2605.10873>
|
||||||
|
- Ortho2CAD:<https://arxiv.org/abs/2607.08891>
|
||||||
|
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
# GenCAD 项目接入评估
|
||||||
|
|
||||||
|
## 2026-09-07源码复核快照
|
||||||
|
|
||||||
|
- MIT研究仓库复核提交:`f5484cf90e30d15b980313006a1424f8949b3496`(2025-07-14)。
|
||||||
|
- `gurul/gencad`工具链复核提交:`364770c8f09792064389e7abc85240813622d120`(2026-08-27)。
|
||||||
|
- MIT仓库的正式`inference_gencad.py`逐个遍历PNG,每张图片独立编码并生成一个CAD;它没有调用仓库中仅定义、未被其他代码引用的`MultiViewResNetImageEncoder`。因此不能把“代码里有多视图类”理解成已实现家具多视角融合。
|
||||||
|
- MIT仓库的命令词汇和实现常量共同限定为直线、圆弧、圆、草图循环和拉伸,`MAX_TOTAL_LEN=60`、`MAX_N_EXT=10`。没有装配体、零件实例、运动副、五金和制造特征语义。
|
||||||
|
- MIT仓库README的Evaluation仍标记为`Coming soon`;论文指标来自作者论文,仓库没有现成的家具实拍、多视图、BOM或生产孔槽复现实验。
|
||||||
|
- MIT仓库根目录没有LICENSE文件。论文公开和代码可下载不等于允许商用;授权未明确前只能隔离研究,不进入生产依赖和训练资产。
|
||||||
|
- `gurul/gencad`根仓库为MIT,但其中包含AGPL子模块和独立许可的vendored代码,使用时必须逐组件合规,不能把整个依赖树视为MIT。
|
||||||
|
|
||||||
|
## 同名项目必须区分
|
||||||
|
|
||||||
|
### MIT GenCAD 研究
|
||||||
|
|
||||||
|
项目是图像条件的参数化 CAD 程序生成模型,组合了:
|
||||||
|
|
||||||
|
1. 自回归 CAD 命令编码/解码;
|
||||||
|
2. 图片与 CAD 程序的对比学习共享表征;
|
||||||
|
3. 图片条件的潜空间扩散;
|
||||||
|
4. CAD 命令序列解码。
|
||||||
|
|
||||||
|
论文使用过滤后的 DeepCAD 数据:168,674 个有效 CAD,152,530/8,515/7,629 作为训练、验证和测试,生成 845,105 张灰度图片。五个版本主要是 X/Y/Z 缩放变体,不是产品多视角证据。
|
||||||
|
|
||||||
|
它的命令词汇只有直线、圆弧、圆、循环起点、序列终点和拉伸,最大 10 次拉伸、总序列长度 60。没有装配语义、家具部件实例、五金、连接孔槽规则、材料和工艺成本。
|
||||||
|
|
||||||
|
论文明确列出的限制包括:工业 CAD 任务更复杂;缺少旋转、圆角、倒角等操作;不能保证输出有效;训练图主要是干净等轴 CAD 图,真实照片、噪声背景和非等轴视角需要后续研究。论文失败样例含相交面、不规则实体和不连通实体。
|
||||||
|
|
||||||
|
研究仓库当前没有可见的 LICENSE 文件,代码或权重用于商业产品前必须先明确授权。
|
||||||
|
|
||||||
|
### gurul/gencad 工具链
|
||||||
|
|
||||||
|
这是另一个项目:把无头 FreeCAD 暴露给编程代理,并提供截面渲染和等轴渲染。最值得借鉴的是:
|
||||||
|
|
||||||
|
- build → render → inspect → repair 的闭环;
|
||||||
|
- 用真实几何内核生成截面图,检查孔、槽、壁厚和间隙;
|
||||||
|
- 用对象位移生成炸开视图;
|
||||||
|
- 最终保留 FCStd/STEP 供人工测量和复核。
|
||||||
|
|
||||||
|
不能原样接入生产。其 freecad_run 接受任意绝对 Python 脚本路径,以完整宿主环境执行;没有允许目录、环境变量清理、容器隔离或文件/网络能力限制,只有超时。截面工具也允许调用方指定任意输入输出绝对路径。
|
||||||
|
|
||||||
|
主仓库为 MIT,但包含独立许可的子模块;不能把整个目录一概按 MIT 处理。
|
||||||
|
|
||||||
|
## 对当前产品成本评估的价值
|
||||||
|
|
||||||
|
### 值得采用
|
||||||
|
|
||||||
|
1. 图片—CAD 共享向量检索:用产品局部图从经过审核的历史零件库检索几何模板,只作为候选,不读取对应公司成本答案。
|
||||||
|
2. 多候选生成:同一低置信零件生成多个参数化候选,由尺寸、连接、干涉和视觉一致性共同排序。
|
||||||
|
3. 截面复核:在背板槽、抽屉底板槽、排钻孔、偏心件孔和金属管壁位置自动切片,直接验证实体内部结构。
|
||||||
|
4. 闭环修复:失败反馈必须定位到零件、轴、孔槽或连接面,只允许局部补丁。
|
||||||
|
5. 炸开视图:继续使用结构图谱中的稳定零件 ID 和位移向量,生成可复核的装配/炸开/包装三态。
|
||||||
|
|
||||||
|
## 应该如何落到我们的架构
|
||||||
|
|
||||||
|
GenCAD不应成为“原图直接输出最终家具CAD”的黑盒。可复用的思想应拆成四层,并由现有结构图谱保持唯一事实源:
|
||||||
|
|
||||||
|
1. **候选检索层**:按局部图像、轮廓和尺寸约束从经过人工验收的零件模板库检索top-k;只返回候选几何/连接模板及相似度,不返回公司同款BOM数量或成本答案。
|
||||||
|
2. **多候选图谱层**:对低置信结构生成2—4个完整装配图谱候选,不直接生成互不关联的实体;每个候选都必须满足原图证据、整体尺寸、角色数量契约和材料工艺约束。
|
||||||
|
3. **确定性几何裁判层**:统一执行参数依赖重算、装配边界、真实接触、B-rep闭合、正体积干涉、孔槽边界、连接件唯一计数和关键截面检查。硬门禁失败的候选不能进入视觉评分。
|
||||||
|
4. **独立视觉裁判层**:把原始多视图与CAD正面/侧面/俯视/等轴及关键截面同时给复核模型;视觉模型只输出定位到稳定零件ID的差异,不直接改成本或宣告可生产。
|
||||||
|
|
||||||
|
候选排序建议使用可审计的分层门禁,而不是一个加权总分掩盖致命错误:
|
||||||
|
|
||||||
|
`证据/拓扑硬门 → 毫米尺寸硬门 → B-rep/孔槽/干涉硬门 → 多视图一致性 → 不确定性数量 → 模型置信度`
|
||||||
|
|
||||||
|
只有前三级全部通过的候选才允许进入后两级;否则即使渲染外观很像也必须失败。
|
||||||
|
|
||||||
|
## 不能直接接入生产的原因
|
||||||
|
|
||||||
|
| 能力 | MIT GenCAD | 家具成本评估需要 | 判断 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 输入 | 单张干净等轴CAD图/草图 | 3张实拍+尺寸图,跨视图同件追踪 | 不满足 |
|
||||||
|
| 输出对象 | 单个CAD程序/实体 | 多零件装配图谱+BOM+运动关系 | 不满足 |
|
||||||
|
| 几何词汇 | 草图+拉伸为主 | 板件、管件、孔槽、封边、焊缝、五金 | 不满足 |
|
||||||
|
| 正确性 | 论文明确不能保证有效CAD | 任一硬门失败不得报价完成 | 不满足 |
|
||||||
|
| 评价 | 分布级COV/MMD/JSD/FID | 每件产品毫米误差、BOM精确率、连接可制造性 | 指标不等价 |
|
||||||
|
| 商用许可 | 仓库未见LICENSE | 生产依赖必须有明确授权 | 当前阻塞 |
|
||||||
|
|
||||||
|
因此接入策略是:**借鉴方法,不直接部署MIT模型;借用`gurul/gencad`的截面复核思路,但使用我们已有的隔离CAD工作者和类型化接口重写,禁止任意脚本路径执行。**
|
||||||
|
|
||||||
|
### 不应采用
|
||||||
|
|
||||||
|
- 不把 MIT GenCAD 当成完整家具图像到 CAD 的主模型。
|
||||||
|
- 不用它决定 BOM 数量、抽屉排列、移门/铰链、材料和工艺。
|
||||||
|
- 不直接把 gurul/gencad 的任意脚本执行 MCP 暴露给 Chat API。
|
||||||
|
- 不以“渲染像”或“STEP 可打开”代替毫米尺寸和可制造性门禁。
|
||||||
|
|
||||||
|
## 推荐优先级
|
||||||
|
|
||||||
|
1. 现有结构图谱继续作为装配、BOM 和连接关系的唯一权威中间表示。
|
||||||
|
2. Zero-to-CAD 作为孔槽/圆角/异形等单零件几何候选器。
|
||||||
|
3. 在隔离的可信 FreeCAD/OCCT 工作者中增加截面几何复核器。
|
||||||
|
4. 采用 GenCAD 的对比检索思路建设公司历史零件模板检索,但在生成结果冻结后才做成本对照。
|
||||||
|
5. CAD-Coder 和 MIT GenCAD 仅作为额外候选与离线基准。
|
||||||
|
|
||||||
|
## 安全接入边界
|
||||||
|
|
||||||
|
- 只允许服务端生成的受信脚本进入几何工作者。
|
||||||
|
- 工作者运行于容器/沙箱,禁网、只读基镜像、限定 CPU/内存/超时和输出目录。
|
||||||
|
- 所有输入、输出和图谱哈希进入审计记录。
|
||||||
|
- 截面图与原图/CAD 多视图一起交给独立复核模型;任何硬门禁失败都不得生成报价完成状态。
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
# 通用多视图到家具CAD算法升级(候选,未上线)
|
||||||
|
|
||||||
|
## 为什么停止继续堆中文名称修正规则
|
||||||
|
|
||||||
|
2026-09-07同一批原图的v78隔离试验说明,仅追加“先识别结构”提示仍不够:
|
||||||
|
|
||||||
|
- 书架专项读图仍把侧板认成连续整板;总体宽度复读更正后,基于旧宽度算出的板件没有联动重算。
|
||||||
|
- 中文尺寸证据“总宽减去两侧板厚”被旧规范化器同时当成宽度和厚度。
|
||||||
|
- 餐柜中的“抽屉中竖板、抽屉下横档”又被宽泛的中文抽屉判断当成面板,真正面板可能被数量截断器丢掉。
|
||||||
|
|
||||||
|
这些不是两个SKU的例外,而是数据表示缺陷:自由文本承担了几何语义、结构身份和公式依赖,多个后处理器反复猜测并覆盖信息。不能继续仅按案例名加条件。
|
||||||
|
|
||||||
|
## 新的唯一结构来源
|
||||||
|
|
||||||
|
`structure_graph.py`候选编译器使用明确的数据:
|
||||||
|
|
||||||
|
1. **观察证据**:原图ID、局部区域、观察文字、来源类型、置信度。来源必须与本轮真实原图集合一致;用户确认只能来自会话事实库,模型无权自行声明。
|
||||||
|
2. **参数依赖图**:尺寸类型、单位、来源、证据;派生值保存算式,不保存一句“减去板厚”作为可执行规则。支持依赖重算、量纲检查、成环检查。未知值不补成默认18mm。
|
||||||
|
3. **零件定义**:稳定ID、局部尺寸、几何类型。中文名仅用于显示,不决定轴向和结构身份。
|
||||||
|
4. **物理实例**:每件板的独立ID、所属总成和明确轴映射。每个总成先声明由尺寸证据或派生参数定义的局部`bounds_mm`;AI不再直接输出XYZ,而是给出相对所属总成、产品外形或其他实例的min/center/max锚点约束,由编译器求解坐标。BOM数量从实例生成,不由模型再给一套可独立漂移的数量。
|
||||||
|
5. **连接关系**:同一总成内固定/支承接触从最终几何的正面积相交自动推导,不再让AI猜面。抽屉、门等跨总成的滑动/铰接关系显式声明,并由后续五金模板复核。
|
||||||
|
6. **几何反馈**:越界、重复实体、无实例部件、数量冲突、连接错位产生可定位到图谱路径的错误,交还AI修改;不能直接翻译成让用户修图的问卷。
|
||||||
|
|
||||||
|
同一图谱编译结果生成CAD草案和BOM。候选`manufacturing_oracle.py`已把编译后的真实接触面转换为孔、槽、焊缝和唯一五金实例;孔槽、五金模板、成品下料尺寸与后续工时台账都以同一`connection_id`、`instance_id`和`hardware_instance_id`追溯,不再按中文名称独立猜数量。
|
||||||
|
|
||||||
|
## 当前已实现的边界
|
||||||
|
|
||||||
|
- 已有标准库实现的参数解析器、量纲校验、实例/BOM生成、固定接触校验、包围尺寸校验、重复实体拦截。
|
||||||
|
- 已有独立试验脚本`probe_structure_graph.py`:原始四图→AI图谱→编译→实际STEP/GLB/预览→AI对照原图复核→最多两轮反馈修正。使用隔离状态目录,不碰真实用户会话,也不读公司BOM。
|
||||||
|
- 随机尺寸/板厚依赖重算、单位等价、中文名称不改变几何等变换测试已加入。它们只验证编译规则,不证明AI已正确识图。
|
||||||
|
- 当前只支持正交box板件;已增加AABB实体穿插、总成局部边界和滑动总成非运动轴范围检查。候选制造层已支持人造板确认螺钉、层板托、捕获槽、木螺钉、混合材料螺栓和金属焊缝的基础几何驱动,但供应商专用孔模板、铰链/滑轨运动包络、曲面、金属型材、任意角度和连续运动扫掠碰撞仍未迁移。对不支持的形状或连接报错,不偷偷换成长方体或虚构孔位。
|
||||||
|
- 背板和抽屉底板的捕获槽同时生成承载件槽体和嵌入板下料余量;制造BOM采用“净接触几何+两端入槽深度”,CAD用调整后的板件真实伸入槽内,再由B-rep布尔干涉检查验证,不再出现“槽切了但板停在槽口”的假连接。
|
||||||
|
- 通用CAD构建链在单体闭合性检查后增加零件两两正体积干涉门,未通过时禁止预览和成功状态。服务器现有检查器使用`--format json`,不能使用不存在的`--json`参数。
|
||||||
|
- 所有输出均为drawing_draft;编译通过也必须原图视觉复核和制造验证,不能直接下料。
|
||||||
|
- **当前尚未替换生产Chat API主链路,也没有达到公司成本误差小于5%的验收目标。**
|
||||||
|
|
||||||
|
## v85隔离内核证据(2026-09-07)
|
||||||
|
|
||||||
|
- 本地完整成本/图谱/制造回归172项通过(253.707秒);加入捕获槽成品尺寸和证据文件语义角色后,图谱、制造与CAD管线39项专项回归通过。
|
||||||
|
- 服务器`/tmp`隔离运行确认螺钉样例:3个实体、12个真实孔、6个唯一连接件,STEP为101927字节;切孔前后面数18→36、总体积21340800→21335663.496mm³,减少量与孔模板理论切削体积一致;3对实体B-rep干涉检查0冲突。
|
||||||
|
- 捕获槽样例:两侧各6mm入槽,抽屉底板成品尺寸由764×400×18变为776×400×18mm,CAD为3个实体、26个面、总体积21338400mm³;2个槽的0.5mm装配余量真实保留,3对实体0冲突。
|
||||||
|
- 包装爆炸模型用当前11个包装节点实测55对候选,其中4对进入布尔检查、51对由包围盒排除、0冲突,说明新增干涉门没有误伤当前包装模型。
|
||||||
|
- 上述只是候选文件和服务器临时目录的真实几何证据;没有覆盖生产文件、没有重启服务、没有完成原图识别或成本5%盲测。
|
||||||
|
|
||||||
|
## v87 GenCAD方法落地与隔离失败证据(2026-09-07)
|
||||||
|
|
||||||
|
- MIT GenCAD 与同名 `gurul/gencad` 的源码级审计见 `GENCAD_INTEGRATION_EVALUATION.md`。本项目只吸收多候选、几何执行、截面/多视图复核和有界修复方法;不把任一仓库直接暴露给 Chat API,也不把单零件生成器当作家具装配真值。
|
||||||
|
- 编译器允许实例把锚点写成“自身所属总成的稳定 ID”,内部仍归一为总成局部坐标;引用其他总成不会被静默接受。运动连接误用零件实例时返回实例→所属总成映射及独立运动总成修复要求。
|
||||||
|
- CAD 内核碰撞与非法 JSON Patch 已转换为稳定实例 ID、重叠体积/范围和合法替换路径,失败不会吞成笼统异常,也不会无限重试。
|
||||||
|
- 本地结构图谱、制造层与 CAD 管线专项回归 44 项通过;最终候选代码的成本、结构图谱、制造层与 CAD 管线完整回归 180 项通过(253.928 秒)。这些测试证明门禁和既有计算未回退,不代表真实读图已经验收通过。
|
||||||
|
- 服务器 `/tmp/dalongxia-cad-v85-eI8HeF/probe-sideboard4` 隔离试验真实读取 4 张原图,独立固定 top=1、bottom=1、side=2、door=2、shelf=1、leg=4 及 1200×400×800mm 外形,且未读取公司 BOM。三轮图谱 SHA 均变化,证明模型确实处理了反馈;但编译问题为 60→55→59,B-rep 冲突为 3→6→5,最终 `graph_and_cad_kernel_not_passed`。
|
||||||
|
- 本次失败的主要新证据是板件局部轴/总成边界修复仍不稳定:门板的宽高厚轴被错置,背板沿深度轴穿越柜体,第三轮又给多块板统一加入 20mm 正偏移。门、背板与侧板因此超界并发生正体积相交。门禁已正确拒绝交付,但 GenCAD 风格的自由补丁尚不能收敛为制造模型。
|
||||||
|
- 上述隔离进程已结束,未覆盖生产文件、未重启服务、未改变真实用户会话;因此当前仍是候选能力,不能标记上线或完成成本误差≤5%目标。
|
||||||
|
|
||||||
|
## 下一阶段交付门禁
|
||||||
|
|
||||||
|
- 给尺寸和接缝定位增加局部证据复读,AI必须引用对应区域,避免整图一句“连续无缝”高置信覆盖局部事实。
|
||||||
|
- 默认由AI完成低置信复读:先逐张读图、再独立读尺寸箭头、再用CAD正面/侧面/俯视/等轴四视图回看原图。用户确认只保留给完全不可见且成本敏感的残余冲突,不得把每个置信度不足字段做成问卷。
|
||||||
|
- 图谱修改应局部、版本化;每轮保存图谱哈希、修改路径、编译反馈、渲染及视觉差异。错误相同且两轮未改善时停止自动循环,只询问真正缺少的关键事实,不重复整页。
|
||||||
|
- 把材质、板厚等交互转成参数变更,完整重算图谱,再编译CAD、孔槽、五金和成本。不能在图谱编译后再调用旧名称启发式修改几何。
|
||||||
|
- 扩充不同结构的留出集:开放架、抽屉柜、移门柜、桌架组合、板金属组合等;变更板厚、整体尺寸、抽屉排列、层板数量都做一致性检验。两款用户案例不等于覆盖所有家具。
|
||||||
|
- 冻结图谱/CAD/BOM和报价后,再读取公司同款成本评价。公司数据用于基准单价和事后偏差审计,不给视觉模型提供目标BOM。
|
||||||
|
- 真实Chat API验收需明确实际运行版本、全程中文进度、无重复提问/计费、可修改前序参数,并核对每个交付文件;不能把隔离模型请求当作完整API验收。
|
||||||
|
|
||||||
|
## v90 修复事务规范化与收敛保护(2026-09-07)
|
||||||
|
|
||||||
|
- 模型输出不是直接写入权威图谱。响应先统一为有界 patch 列表;直接数组和 `patches` 对象都可解析,但非法成员、空变化和超过上限的修改会被拒绝。
|
||||||
|
- 整段实例 placement 只在同时含有且仅含 x/y/z 时可进入规范化,并拆为实际变化的单轴替换;原始路径、展开路径和响应形状都进入审计。底层补丁器继续拒绝整段 placement 写入,形成两层保护。
|
||||||
|
- 局部修改按事务应用到候选副本并重新编译;编译问题数不能高于上一已接受版本。回归候选被完整丢弃,下一轮收到问题数和代码分布,而不是在损坏状态上继续修。
|
||||||
|
- sideboard7 隔离证据显示该机制使编译问题从 57 项总体降到 15 项,并成功消费一次直接数组响应;但剩余总成边界和穿插仍是阻断项。这里的“收敛”只描述验证器问题计数,不等于图片理解正确或可制造。
|
||||||
|
|
||||||
|
## v96 五金安装约束、端到端评分与可恢复探针(2026-09-07)
|
||||||
|
|
||||||
|
- 五金实例不再依赖“碰到某块板就算安装”。`mountings`显式绑定宿主实例、宿主板面、表装/埋入/贯穿类型、嵌入深度和证据;同一铰链可绑定门板与侧板两个宿主。求解器按板厚轴限制可安装板面,并验证法向距离、切向重叠和深度。
|
||||||
|
- 结构总成边界从非五金结构成员计算;已验证安装的五金可形成实际外形突出,但不能反过来把结构总成边界放大。合法埋入只豁免对应五金—宿主这一对碰撞,未声明的碰撞和错误位置仍阻断。
|
||||||
|
- 编译错误返回精确可采用的安装值和最小平移候选,不让模型自由猜板面。若移动实例是其总成唯一成员,候选同时包含紧包围总成边界修改,两个补丁按原子事务应用。
|
||||||
|
- 隔离修复不再只优化编译问题数。每轮以编译、制造、CAD内核和视觉问题总数为唯一接受分数,候选必须严格下降;`--resume-report`会选历史最佳检查点并用当前编译器刷新反馈后续跑,保留原图资格与不读公司BOM的审计链。
|
||||||
|
- 续跑选择器排除任何带`patch_error`的拒绝轮次;编译阶段只有在当前问题数作为下界已经不可能优于上一端到端总分时才提前回滚,避免用旧编译问题数误杀可能显著减少B-rep冲突的候选。专项60项、完整196项回归通过。
|
||||||
|
- 当前仍缺少安装特征实体化:B-rep应依据安装模板在宿主生成孔、杯槽、沉孔或开口,同时把五金拆成可见体和嵌入体,不能永久靠允许碰撞对掩盖。下一阶段还要增加“参考尺寸包络/实际占用包络”语义,识别覆盖门、嵌入背板与净深尺寸,先做尺寸链可行性裁决再进入AI修复。
|
||||||
|
|
||||||
|
## v101 最小分离轴可行区间与防循环门禁(2026-09-07)
|
||||||
|
|
||||||
|
- 诊断B-rep会在已验证的埋入/贯穿安装宿主上减去五金嵌入包围盒,但这些减材显式标记为非生产特征。生产孔槽必须继续由供应商五金模板、连接面和实际规格生成,两者不可混用。
|
||||||
|
- 内核干涉复核始终针对刚生成的STEP绝对路径直接执行,并解析stdout JSON。引擎封装层的进度stderr、旧结果字段或进程退出码不再能覆盖真实碰撞对。
|
||||||
|
- 每个实体冲突会取重叠最小的分离轴,在零件所属总成或产品边界中构造允许的下坐标域,再逐个扣除与其它实体发生正体积相交的禁止区间。输出包含允许域、所有阻塞实体、禁止区间和剩余可行区间,可被人和AI独立复算。
|
||||||
|
- 若冲突两侧实体在最小分离轴上都没有任何可行区间,该问题标记`minimum_separation_axis_infeasible=true`。AI不得继续往返移件,只能依证据修正尺寸/外形口径/覆盖与内嵌语义,或建立显式槽口、避让等加工几何。
|
||||||
|
- 无用户参与的隔离探针在一轮原图修复未使端到端阻塞严格下降时立即停止,输出`geometry_dead_end_requires_focused_source_adjudication`。正式交互应先调用AI聚焦原图复读;只有高置信尺寸仍相互矛盾时,向用户集中询问一个能决定结构的问题,不重启全流程。
|
||||||
|
|
||||||
|
## v102-v104 实际占用包络、参考尺寸口径与单问题兜底(2026-09-08)
|
||||||
|
|
||||||
|
- `envelope_mm`固定表示包含门板、拉手等凸出件的实际占用外形;图纸若明确只标柜体、开口或框架,可另建`reference_bounds_mm`。结构图谱不能自行把原图“整体尺寸”改成参考尺寸,必须引用真实用户确认,或引用独立聚焦原图裁决且置信度不低于0.8。
|
||||||
|
- 编译结果同时输出`occupied_envelope_mm`、`reference_bounds_mm`和`dimension_scope_audit`;参考包络必须完全落在实际占用包络内。这样可表达“柜体深400mm、门板外覆后成品深420mm”,而不是通过移动门板或缩短层板伪造无碰撞。
|
||||||
|
- v102聚焦复读虽正确判断原图尺寸冲突,但生成的问题暴露内部几何过程,并给出“把背板加厚”等会扩大占用空间的错误选项;该轮判定失败。v103增加长度和术语门禁后,模型又跳过柜门,直接追问层板与背板尺寸;仍判定失败。
|
||||||
|
- v104由结构化冲突中的门板角色、板厚轴和最短分离轴确定首问必须是`overall_dimension_scope`。多模态模型连续两次仍返回过长选项,被输出校验器拒绝;系统没有重试循环,而是生成受控兜底问题:“尺寸图标注的总深 400mm,是否包含柜门厚度?”,只给“包含柜门(成品最外尺寸)/不包含柜门(只算柜体/框架)”两个选项。
|
||||||
|
- v104证明的是防循环和用户提问边界生效,不是AI图像裁决成功:报告明确保存模型两次格式失败和`deterministic_dead_end_question_fallback`来源。它仍停在单一事实待确认状态,没有交付CAD、BOM或成本,也未部署生产。
|
||||||
|
|
||||||
|
## 同行公开资料的可用结论
|
||||||
|
|
||||||
|
Zoo公开说明其代理会操作可编辑的参数化模型,执行几何代码,读取质量/体积等模型信息,并用多视图快照反复检查。我们借鉴“结构表示+几何执行反馈+视觉反馈”机制,不能据其宣传推断多张家具图片已经可以稳定得到生产级完整BOM,也不能据此承诺成本误差5%。
|
||||||
|
|
||||||
|
- [Zoo Zookeeper技术说明](https://docs.zoo.dev/research/zookeeper)
|
||||||
|
- [Zoo Zookeeper使用说明](https://docs.zoo.dev/docs/zoo-design-studio/zookeeper)
|
||||||
360
server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md
Normal file
360
server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
# 产品成本评估:CAD / BOM / 成本盲测台账
|
||||||
|
|
||||||
|
## 不可违反的验收原则
|
||||||
|
|
||||||
|
- 估价器只读取用户提供的图片、尺寸、材料与工艺确认;被测公司的 SKU、BOM、单价和总成本在报价冻结前不可读取。
|
||||||
|
- 公司 BOM 只作为报价后的答案键和公共物料价格统计,不可用同款 SKU 反向覆盖本次部件数量、尺寸或总价。
|
||||||
|
- 数据划分必须以 SPU 为组,避免同一产品不同 SKU 分别落入校准集和盲测集。
|
||||||
|
- 每次模型或规则升级都必须重放固定盲测集,并新增至少一个此前未见的结构族样本。
|
||||||
|
- 目标同时满足:必需部件数量完全一致、不得出现不存在的部件、关键尺寸在确认公差内、连接工艺与材料相容、总成本误差绝对值不超过 5%。总价接近但 BOM 错误仍判失败。
|
||||||
|
|
||||||
|
## 统一评测指标
|
||||||
|
|
||||||
|
| 维度 | 硬性指标 | 失败示例 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 输入质量 | 每张图有角色、质量、置信度、替换关系;缺图只补传该角色 | 用附件数量代替角色验收 |
|
||||||
|
| 结构 BOM | 必需部件 role + side + instance 数量完全一致 | 两个抽屉只有一组左右帮板 |
|
||||||
|
| 几何 | 整体外包围盒按确认尺寸;板件厚度和轴向语义一致 | 门板宽高互换、薄背板被统一加厚 |
|
||||||
|
| 拓扑 | 无非法重叠、悬空、越界;门/抽屉/层板与开口匹配 | 移门门扇不覆盖门洞 |
|
||||||
|
| 制造特征 | 孔、槽、封边、焊点均有几何证据和唯一成本驱动 | 零孔槽仍计 CNC;孔位和人工重复计费 |
|
||||||
|
| 五金 | 由连接图和运动副反推规格、数量及孔模板 | 移门误加铰链;五金按部件数套默认包 |
|
||||||
|
| 成本 | 总成本相对公司答案键绝对误差 ≤5%,并报告分类误差 | 用公司同款总价或 BOM 行直接修正输出 |
|
||||||
|
| 图纸 | 特征表可分页且无截断;低置信特征必须阻止正式发布 | 特征 24 条但图纸只显示 23 条 |
|
||||||
|
|
||||||
|
## 已完成且不得回退的通用修复
|
||||||
|
|
||||||
|
- 图片按正视、侧视、开门/内部、尺寸图逐张验收,支持单张补传和替换。
|
||||||
|
- CAD 后再次使用原始多视图与组装/炸开渲染图复核;AI 意见必须经过尺寸账本和确定性几何门裁决。
|
||||||
|
- 用户确认的拓扑和尺寸在统一板厚后重新应用,防止通用柜体重排覆盖分段侧板。
|
||||||
|
- 移门采用产品轴向尺寸账本,禁止根据整体比例猜测并交换门板宽高。
|
||||||
|
- 公司历史 BOM 在盲测估价中关闭;盲测完成后再加载答案键。
|
||||||
|
- 零孔槽不计 CNC、成品饰面板不重复计贴面/油漆、包装材料损耗固定为 2%,包材与人工使用不同成本行。
|
||||||
|
|
||||||
|
## 固定回归样本
|
||||||
|
|
||||||
|
| 样本 | 已验证事实 | 当前结果 | 尚未证明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 花城 1200 推拉餐柜 | 1200×400×850,双移门、双抽屉;其他结构仍须按原始资料核验 | 旧显式结构回放曾有23个实例;不等于首次读图或盲测通过 | 与该款完整公司 BOM 的逐行结构/成本误差 ≤5% |
|
||||||
|
| 花城模块书架(旧称480,宽度答案键待核实) | 高1600、下柜深300可由参考图验证;线稿48是下模块竖向高度,不是整体宽 | 旧“480×300×1600、6分段侧板已通过”仅属显式约束回放,不能作为盲测正确证据 | 图中真正整体外宽、合成参考图与线稿的背板/侧板接缝一致性、逐件结构与成本 |
|
||||||
|
| SZ001514N886V01 板木金属办公桌 | 公司答案键 123.51 元 | 2026-09-04 旧盲测为 311.48 元(+152.19%) | 必须用当前版本重新跑;旧结果不可作为已修复证据 |
|
||||||
|
|
||||||
|
## 已否决方案(避免重复)
|
||||||
|
|
||||||
|
- 只依赖单次多模态模型直接输出 CAD/BOM:缺少尺寸和拓扑约束,结果不可稳定制造。
|
||||||
|
- 只比较渲染图外观:可以掩盖重复板、缺失抽屉帮板、错误孔槽和错误五金。
|
||||||
|
- 为单一产品硬编码数量/尺寸:只能让回归样本通过,无法证明未来产品泛化。
|
||||||
|
- 为达到 5% 直接读取公司同款 BOM 或总成本:这是答案泄漏,不是成本评估能力。
|
||||||
|
- 只以总价误差验收:不同类别高低相互抵消时会产生“价格正确、结构错误”的假通过。
|
||||||
|
|
||||||
|
## 下一阶段顺序
|
||||||
|
|
||||||
|
1. 从 21,695 个公司 SKU 中按结构族抽取有完整图片、尺寸图和成本答案键的 SPU 级盲测集。
|
||||||
|
2. 将识别输出固定为参数化形状程序:部件、轴向尺寸、装配关系、运动副、连接面和制造特征。
|
||||||
|
3. 增加二维投影差、轮廓差、遮挡次序和多视图一致性评分;低分先由AI针对原图疑点重读、修正并复核,确实无资料或仍冲突时才集中询问用户。
|
||||||
|
4. 补齐五金供应商模板与生产图分页,只有通过自动门和人工签批才能从 `drawing_draft` 升级。
|
||||||
|
5. 每轮输出逐样本指标、宏平均、最差样本与失败原因;未达到全部门槛不得标记完成。
|
||||||
|
|
||||||
|
## 2026-09-07 v62 新增通用规则
|
||||||
|
|
||||||
|
- 当前用户消息中明确标注的整体/外形/成品尺寸,是本轮产品外包围的最高优先级证据;尺寸图二次转录不得用相邻规格或多规格表覆盖。
|
||||||
|
- “桌面、台面、tabletop、desktop、顶板”统一映射为顶面角色,尺寸表只允许更新同一制造部件,不得额外生成重复顶板。
|
||||||
|
- 只有用户明确外包围契约生效时,才对越界板件执行硬约束;不得把这一约束错误施加到独立型材下料长度等通用 BOM 数据。
|
||||||
|
- 生产工艺特征表超过单页容量时必须续页,不能截断,也不能因版面溢出把已完整的工艺数据判为生产阻塞。
|
||||||
|
|
||||||
|
## 2026-09-07 v63 新增通用规则
|
||||||
|
|
||||||
|
- “左右各 N 块层板/搁板”必须解释为总数 `2×N`;后续纠正语句里的单侧数量不得把已经确认的总数降为 N,只有“共/合计/改为/实际只有”等明确总量修正可以覆盖。
|
||||||
|
- 抽屉后帮板必须封闭在左右帮板的尾端,不得简单贴到柜体后沿;抽屉深度小于柜深时仍要保持箱体闭合。
|
||||||
|
- 多模态复核对“抽屉穿出柜体”的判断必须与CAD实例的六面边界逐轴核验;只有所有抽屉箱体实例都处于产品外包围内才允许反证该说法。
|
||||||
|
- 仅凭抽屉部件深度小于柜深,不能反证位置错位、装配冲突、干涉或重叠;这类空间问题必须保留给确定性几何门或用户确认。
|
||||||
|
|
||||||
|
## 2026-09-07 v64 新增通用规则
|
||||||
|
|
||||||
|
- 报价完成后必须把“实际用于生成CAD和计价”的分析版本写回项目;不得让会话检查点继续保存统一板厚前的旧BOM,造成已交付CAD为12mm而查询结果仍显示10mm。
|
||||||
|
- 抽屉面板高度在多视图重读后发生变化时,抽屉间竖隔板必须随面板区高度重新计算;旧高度不得把隔板推到产品外包围之外并反复触发CAD复核。
|
||||||
|
- “底板承托/前板位置”类AI复核项只有在五件抽屉箱体逐轴闭合、全部处于产品边界内且前/左/右/后四面均存在有效底板槽时才可确定性反证;缺任一条件都必须继续阻断。
|
||||||
|
|
||||||
|
## 2026-09-07 v65 新增通用规则
|
||||||
|
|
||||||
|
- 带尺寸箭头的侧面/三分之四视图,只要模型确认展示产品前后深度,不得仅因存在标注而改判为尺寸图;否则会占用尺寸角色并错误索要用户重复补传侧视图。
|
||||||
|
- 用户同轮已明确完整外包围时,详细图若提供了至少两类内部/抽屉/门片尺寸,不得只因缺少某个重复的整体尺寸标签而判废。
|
||||||
|
- 真实流程验收必须绑定本次附件创建的项目,且最终响应和项目阶段都为成功;禁止按产品名回退读取历史已完成项目制造假阳性。
|
||||||
|
- 已确认的模块化侧板在CAD复核重识别后必须按段恢复,不能退化成两块整高侧板;带R角的侧板轮廓不得再额外生成“圆角装饰条”重复部件。
|
||||||
|
- 上部层板比下柜窄的阶梯式书架,分段侧板的中/上段必须按层板内宽居中内收;不得把所有分段仍放在下柜最外侧。
|
||||||
|
|
||||||
|
## 2026-09-07 v67-v68 新增通用规则
|
||||||
|
|
||||||
|
- 中文结构约束不能依赖单一书面句式:“每侧分3段”“每侧分为三段”“每侧分成3段”必须归一到同一拓扑;“底部仅2个上下抽屉”优先于同句中“上部5块层板”,不得被邻近的“左右侧板”误判成顶部并排抽屉。
|
||||||
|
- 未给出每段高度但已有下部功能区高度和产品总高时,可按已知下段高度与剩余净高等分生成带来源的CAD估算;这些数值必须保留估算标签并进入多视图复核,不能伪装为图片实测。
|
||||||
|
- 上传图片时一次性授权“未标注内部尺寸按CAD装配合理值预估”后,系统应自动补齐最小承重外壳并进入CAD复核;不得再次逐项索要抽屉帮板或底板尺寸。整体长宽高、门型等仍无法从图片确定且显著影响成本时才询问。
|
||||||
|
- 花城样品改为盲测:上传文件名中不写产品类型、尺寸、门型,提示词不提供标准BOM;标准结构只在完成后评分。人工补充答案和AI/CAD自修正必须分别计数,禁止把用户补充后的结果冒充首次读图准确率。
|
||||||
|
- Zoo/Zookeeper公开方法的可复用核心不是“让通用模型一次猜CAD”,而是严格上下文的代理循环:计划→执行参数化几何→调用CAD内核→计算/多视图快照观察→修改→再次验证。当前项目对应实现必须把部件图、装配图、爆炸图、尺寸账本和确定性几何检查放在同一复核循环内。
|
||||||
|
- 同行家具拆单软件的共同做法是参数化模块/板件、连接件自动生成孔位、三维爆炸检查、孔槽和工艺规则审单。图片识别输出因此必须升级为装配图谱:部件实例、接触面、断开边、运动副、连接方法、孔槽模板和证据置信度;仅有板件长宽厚列表不足以生成生产CAD。
|
||||||
|
|
||||||
|
## 2026-09-07 v69-v71 新增通用规则
|
||||||
|
|
||||||
|
- CAD复核发现高置信数量、无门/有门、抽屉排列、层板布局、侧板分段或R角差异时,只允许将多视角一致结论转换为可审计的拓扑补丁;禁止丢弃当前BOM后让模型整份自由重画。
|
||||||
|
- 带尺寸标注的轴测/透视图仍可承担第三视角。若宽泛分类器把两张轴测图都判成尺寸图,必须再由只看像素的AI裁决哪张证明了三维深度,并把另一张保留为尺寸图,不得要求用户重复上传已有证据。
|
||||||
|
- CAD复核输入采用带asset_id的六图拼图,并只额外发送一张高分辨率尺寸图;禁止把4张原图、2张CAD和拼图全部重复发送。
|
||||||
|
- CAD复核单次模型请求默认硬超时180秒,格式重试由外层统一控制;禁止“内层3次×外层2次×360秒”的嵌套等待。
|
||||||
|
- “请AI根据本轮复核自行修复”属于执行指令,不属于用户提供的结构事实;不得仅因更新了来源字段就跳过新的AI差异补丁。只有包含明确数量、尺寸、门型或有无部件的消息才可写成用户拓扑确认。
|
||||||
|
|
||||||
|
## 2026-09-07 v77 审计纠正与当前真实状态
|
||||||
|
|
||||||
|
### 本轮用户交互原则
|
||||||
|
|
||||||
|
用户强调提升AI自身读图理解能力,而不是将大量判断丢给用户。因此流程应为:原图多视角理解 → 独立尺寸箭头复读 → CAD/BOM几何检查 → 原图与CAD差异复核 → 有证据的自动修复(有界重试)。仍不可见且影响成本的关键条件才集中询问一次;获得预估授权的隐蔽尺寸保留估算来源,不得伪装实测。
|
||||||
|
|
||||||
|
### 必须纠正的历史验收错误
|
||||||
|
|
||||||
|
- 生产 `ai_solid_wood_cost_engine()` 缓存实例,覆盖磁盘引擎不意味着运行时已更新。2026-09-07本轮核查主进程仍为PID3902140,启动时间09:20:37;v72–v76在磁盘的版本不能当作Chat API实际执行版本。
|
||||||
|
- 已去除读图提示中与书架样本完全相同的 `[480,554,554]`、`[300,200,200]` 和餐柜门片数值。这种提示包含答案的测试不能证明泛化。
|
||||||
|
- 测试脚本不再自动替用户回复“柜深40cm、移门54cm、无背板”等产品事实;附件名称附加本次会话唯一标记,禁止误取历史项目。运行时版本从实际报价summary读取,不能取脚本从磁盘导入的常量。
|
||||||
|
- 书架“宽480mm”答案键被撤销并标记待核实,不以另一个猜测替代。完整成本5%目标仍未通过。
|
||||||
|
|
||||||
|
### 通用代码修正(候选代码,尚不能声称线上验收完成)
|
||||||
|
|
||||||
|
- 尺寸独立转录增加数值、单位、整体/局部/模块范围和标注两端;没有BOM表的尺寸图也可复读箭头。不接受无证据的第二次整体三元组覆盖前次数据。
|
||||||
|
- 表格读图遗漏尺寸箭头字段时,发起更窄的AI复读,仍提供原始多视角作方向/单位参考;额外复读失败不丢掉已经成功的零件表。
|
||||||
|
- 尺寸值与证据原子恢复;没有拓扑约束也保留明确用户尺寸或独立标注依据,不再将旧证据套在新数值上。
|
||||||
|
- 去除“宽深刚好互换就直接忽略问题”的捷径;必须有明确轴向证据。读图同时声称某轴已标注和未标注时按冲突处理,不盲目接受。
|
||||||
|
- AI修复只采信原始图,CAD渲染图不得给自身投票,同一asset_id不得重复计票;AI拓扑不再屏蔽之后的反证。
|
||||||
|
- AI分段/装配坐标不再标作用户确认。几何规范化保存结构化 dimension_kind,防止把“整体深度减主体板厚”这条说明里的“厚”误当成厚度值,导致多轮规范化将20mm板厚变成480mm。
|
||||||
|
- 当用户同轮明确了外形尺寸,尺寸表行先保留原数值,等用户外形约束生效后再做几何修正,避免中间态先按旧外形错误裁切。
|
||||||
|
|
||||||
|
### 实测记录:不得把成功响应当作识别正确
|
||||||
|
|
||||||
|
- 118项完整单测通过(213.460秒);v77专项检查扩展到11项并通过,其中两项随后追加验证原始四视图传递与附加读图失败保留有效表格。不将重叠的专项测试累加成额外全流程次数。
|
||||||
|
- `/tmp/cost-source-probe-v77.json`:qwen-vl-plus表格模式对两款图都缺少新尺寸箭头字段,并出现不合理的板件/五金转录;不能作为合格读图。
|
||||||
|
- `/tmp/cost-source-arrows-v77-plus.json`:缩小任务为尺寸箭头后,两款均未返回完整可解析JSON。
|
||||||
|
- `/tmp/cost-source-arrows-v77-qwen3.json`:qwen3-vl-plus输出格式合格,但书架把模块48错误写为整体深、30写为宽且毫米/厘米错;餐柜仍有“已给高度又称缺高度”的矛盾。
|
||||||
|
- `/tmp/cost-source-arrows-v77-max.json`:qwen-vl-max仍混淆局部方向和单位,不足以直接替换生产模型。
|
||||||
|
- `/tmp/cost-source-arrows-v77-qwen3-multiview.json`:四张原图都送入后可读出侧图1600mm和300mm,但仍自相矛盾地输出160mm整体高和错误整体宽;不能据此宣称多图已解决。
|
||||||
|
- 上述均为隔离目录中的直接模型探针,不是完整Chat API验收,没有修改真实用户会话、没有录入业务单据。
|
||||||
|
- `/tmp/cost-source-arrows-v77-qwen3-thinking.json`:qwen3-vl-plus四图+高分辨率+思考4096仍把书架高/宽报160mm,失败,不能以增加思考预算代替检验。
|
||||||
|
- `/tmp/cost-source-arrows-v77-qwen35-thinking.json`:qwen3.5-plus四图+高分辨率+思考4096读出整体高160cm、深30cm、48为下模块高度,比此前改善;仍将抽屉面37.6误作深度并把39.8直接认作整体外宽,暂不作为生产模型切换依据。
|
||||||
|
- `/tmp/cost-source-arrows-v77-qwen35-frame.json`:增加“先以正面长边、侧边、竖边建立投影方向,再分类尺寸线”的通用步骤后,qwen3.5-plus将书架37.6正确分类为抽屉面宽X,48/55.4分类模块高度Z,160cm/30cm分类整体高深;餐柜120/40/85cm均正确。书架39.8是否属于成品最大外宽仍待独立资料核验;这只是标注子任务改善,整份BOM测试继续。
|
||||||
|
- `/tmp/cost-full-source-analysis-v77-qwen35.json`:直接让强模型执行原有整份BOM长提示,书架首个请求180秒超时(不以加大超时继续掩盖任务过载)。转测独立“结构与数量”识别,再与尺寸证据合并的方案。
|
||||||
|
- 同一完整读图探针的餐柜返回成功,但错误把跨越两个抽屉的1164mm承托隔板命名为单个“抽屉底板”,只再补一个544mm底板,并遗漏内部层板。因此“返回成功/structure_questions=[]”绝不等于BOM正确。
|
||||||
|
- `/tmp/cost-structure-v77-qwen35.json`:只问结构与数量时,餐柜识别出2抽屉、2移门、2内部层板,并区分drawer_base(柜体横隔板)与两个drawer_bottom;但对背板仍与既定答案键冲突。书架能识别两只上下抽屉及完整左右后底,但侧板仅识别为上下两段而非尺寸链中的三段,仍失败。
|
||||||
|
- 下一轮需修复的确定性代码问题:`_ensure_complete_drawer_boxes`无条件按抽屉总数分摊柜宽,没有传入横向/纵向排列;正确的上下抽屉面宽可能被程序缩窄。需要将图像结构证据的drawer_layout贯通初次规范化、抽屉补件和实例装配,不能靠后续用户纠正才恢复。
|
||||||
|
- 上述报告已下载至仓库`outputs/product-cost-v77/`保存(最后两个报告以最终完成版本为准)。本轮没有变更生产模型配置,没有重启生产服务,没有宣称两款Chat API/CAD/成本全流程已通过。
|
||||||
|
- 高分辨率和视觉推理模式继续作隔离对比。参考阿里云官方参数说明:[视觉理解](https://help.aliyun.com/zh/model-studio/vision)、[视觉推理](https://help.aliyun.com/zh/model-studio/visual-reasoning)。仅文档宣称更强或格式通过不能决定上线。
|
||||||
|
|
||||||
|
### 下一步验收条件
|
||||||
|
|
||||||
|
先使原图标注读数、方向、单位和范围在无答案提示下稳定一致;必要时增加标注端点/产品投影轴的像素几何验证。确认原始资料和答案键相互匹配,再在真实运行时版本上做两款Chat API闭环,冻结报价后才查公司BOM对比。服务器有用户运行时不重启,不启动无人值守的重启守候命令。
|
||||||
|
|
||||||
|
## 2026-09-07 v78及结构图谱算法转向
|
||||||
|
|
||||||
|
- 用户再次明确:不要做单品/单测试补丁,要提高未来产品的通用识别、结构与连接能力。后续不继续扩大依赖中文名字的后处理链;新架构与边界见`STRUCTURE_GRAPH_UPGRADE.md`。
|
||||||
|
- v78候选修正抽屉排列贯通、保留真实抽面宽厚高、区分有结构用途证据的固定承托板与移动抽底、箱体跟随抽面位置。另修初读尺寸证据中的“总宽减板厚”被同时当成厚度的词匹配错误。均未部署到生产。
|
||||||
|
- 124项完整回归在最后的抽面跟随、首次语义匹配修正之前通过,223.882秒;随后6项v78专项回归通过,11.301秒。不得混写为最终全量通过。
|
||||||
|
- `outputs/product-cost-v77/cost-full-source-analysis-v78-qwen35.json`两款实图模型探针完成,但两款均不合格。书架278.32秒:侧板仍连续整板、部分板件基于错误旧宽300mm推算;餐柜310.61秒:抽屉中竖板/下横档被旧名称分类误当抽面并挤掉真正抽面,内部层板也缺失。不能因请求completed就标记验收通过。
|
||||||
|
- v78探针在最后两个本地语义/定位补丁之前启动,远程临时代码未中途更换;报告不能代表这些后续修改已经通过实图验证。
|
||||||
|
- 新增独立`structure_graph.py`:类型化参数/单位/派生算式、显式实例与总成、连接面、图谱哈希、实例生成BOM、依赖联动、重复实体/越界/接触错误。没有SKU或产品名称分支。7项测试通过,其中对100组随机尺寸及修改后依赖做验证;只是编译器基础验证,不是100款家具验收。
|
||||||
|
- `probe_structure_graph.py`开始隔离原图→图谱→CAD→多模态复核试验,最多两轮。不调用公司BOM、不修改业务会话;试验运行结果必须单独记录,不预先宣称通过。当前只支持正交板件,完整连接孔槽与第三方Chat API仍未迁移。
|
||||||
|
- 本轮生产检查PID3902140、active,最近只读探针各忙碌计数均为0;没有重启生产服务、没有修改线上模型配置。
|
||||||
|
- 首轮新图谱试验结果已保存`outputs/product-cost-graph-v1/report.json`:书架两轮74.47/69.73秒,仍有8项量纲/引用错误;餐柜两轮81.88/72秒,仍有11项连接几何错误,第二轮图谱SHA256完全未变。两款都在编译门禁停下,**未生成本轮CAD预览,更未完成Chat API/报价验收**。不能宣称自我修正已有效。
|
||||||
|
- 核查 `_request_model_json` 会完整序列化本轮body发送,未见本地按首轮内容缓存响应的机制。图谱第二轮确实携带上一图谱及完整feedback;是否远端网关/模型有效利用反馈尚未独立证明,不能把相同响应直接归咎第三方。
|
||||||
|
- 追加通用连接连通性检查:模型不能通过删光连接关系让漂浮板件逃过检查;8项图谱测试通过,其中100组参数变换仍通过。该追加检查在远端首轮试验启动后本地完成,未混记进原报告。
|
||||||
|
- 最新局部回归:20项抽屉相关检查通过(36.714秒);11项v77检查通过(20.142秒)。最终当前全量尚未重跑。
|
||||||
|
- 下一步不原样重复整图生成:先增强反馈为具体算式/轴/接触面;把能构建的失败图谱渲染为明确标记的诊断草图供AI看错位(当前脚本只有编译全通过才渲染,导致几何失败时反而缺少视觉反馈),再实施局部图谱修改/约束求解。诊断模型不能交付为通过产品。独立箭头转录应输入参数证据图,避免主生成模型再次自由猜整个外形。仍不得引用公司答案矫正生成。
|
||||||
|
|
||||||
|
## 2026-09-07 v79 AI优先原图裁决(候选,未上线)
|
||||||
|
|
||||||
|
- 用户要求AI承担主要图像理解,不把低置信字段逐项转给用户。新策略固定为:逐图结构读数、尺寸箭头专项读数、跨图裁决、CAD四视图反向复核和局部图谱修补;只有所有原图确实不可见且会显著改变制造/成本的残余项,才集中询问一次。
|
||||||
|
- `view_findings`现在逐张记录完整产品可见性、抽屉/门/层板计数、抽屉排列、侧边可见度、侧板段数和归一化接缝位置。单张图声称“侧板连续”不再能直接定案;两张有效原图的段数和接缝位置需相互支持,否则进入AI局部复读,而不是立即要求用户判断。
|
||||||
|
- 尺寸箭头专项读取会生成外部`dimension_ledger`。编译器直接比较CAD的X/Y/Z外包围与账本,阻止把侧视图300mm深度静默写成总宽。冲突或低置信箭头不进入强制账本,不会伪装成已确认尺寸。
|
||||||
|
- 结构图谱诊断CAD新增正面、右侧、俯视、等轴四个视图供多模态AI复核;不再只给单张等轴图。第二轮仍只允许局部JSON Patch,并保存每条修改审计。
|
||||||
|
- v2书架图谱实测未通过:第一轮14项编译问题,第二轮降为6项;AI已自动补齐层板连接并识别抽屉需有4块侧帮、4块前后帮、2块底板,但只增加了零件定义,没有增加对应物理实例,CAD仍缺抽屉箱体。更严重的是将300mm深度误作宽度。此报告保存于`outputs/product-cost-graph-v2/book-report.json`,不能上线。
|
||||||
|
- 新增14项图谱单测通过(0.041秒),另有2项源视图裁决专项通过;这只证明门禁/裁决代码,不代表实图识别通过。v79完整回归和新实图探针仍需继续。
|
||||||
|
|
||||||
|
### v79诊断失败与约束求解转向
|
||||||
|
|
||||||
|
- `outputs/product-cost-graph-v3b/book-report.json`确认旧路线不可接受:尺寸专项复读超时导致账本为空,CAD仍为300×300×1600mm;第一轮23项问题,第二轮反而增加到30项。虽补了抽屉实例,但坐标、连接面和总成约束更差;四视图已直接暴露出“有零件但不是产品”。该版未部署。
|
||||||
|
- 根因改判为表示层错误:不再让多模态模型直接猜整套XYZ坐标与固定连接面。同一总成内的固定/支撑连接改为根据实体AABB正面积接触自动生成;抽屉、门等有间隙的跨总成运动关系仍须显式声明。
|
||||||
|
- 新增`anchored_axes_v1`定位求解:AI只能输出每轴的靠边/居中/对齐锚点与偏移,程序计算最终坐标。缺轴、依赖成环、越界、悬空、重复实体都阻断,不做自动挪动遮盖。
|
||||||
|
- 候选编译器当前19项单测通过;包含100组随机尺寸依赖变换、锚点定位参数联动、依赖成环、真实接触和跨总成隔离。当前全量148项回归通过(235.761秒)。这仍只是编译器回归,新实图闭环未通过前不上线。
|
||||||
|
|
||||||
|
## 2026-09-07 v80 总成局部坐标与实体干涉门(候选,未上线)
|
||||||
|
|
||||||
|
- v7隔离STEP现场证明旧锚点仍不够:上部侧板虽然属于上部模块,却从产品Z=0开始,与下部侧板重叠;部分层板按产品总宽生成并穿过侧板;抽屉后帮、底板和侧帮也存在正体积交叠。该模型不可制造,未部署。
|
||||||
|
- 每个总成新增可参数化`bounds_mm`,实例可把X/Y/Z锚定到所属总成。总成边界必须落在产品外形内,并与成员实体并集的实际包围盒一致;上层模块不能再复制整件产品Z范围。
|
||||||
|
- 新增三类硬阻断:部件超出所属总成、两个实体发生正体积穿插、滑动总成在非运动轴上超出导向总成。共享面接触仍允许,不能用大公差或虚大总成隐藏错误。
|
||||||
|
- 图谱修复提示删除了`anchored_axes_v1`下修改不存在`position_mm`的错误示例;局部修复只能改总成边界、零件尺寸、锚点或偏移。
|
||||||
|
- 当前结构图谱24项测试、全链路156项回归全部通过(237.383秒)。这些测试证明错误门禁和既有功能未回退,不代表花城实图已经通过。
|
||||||
|
- 首次v8隔离执行因临时SSH未继承模型密钥,在尺寸证据门停止且未生成CAD;第二次携带现有服务环境后SSH连接中断,未形成阶段检查点。随后改用独立临时systemd探针保证模型请求不随SSH断线,不触碰生产服务。
|
||||||
|
|
||||||
|
## 2026-09-07 v83 尺寸端点裁决与 CAD-Coder 论文复核(候选,未上线)
|
||||||
|
|
||||||
|
- 旧生产任务 `wood_20260907103319_7449b0d8` 的源尺寸图明确存在 39.8cm 整体宽、30cm 深、160cm 高;旧CAD却以 300×480×1600mm 生成,且以全高侧板代替三段模块侧板。源图文件未被修改,是旧识图/几何结果错误和“误判复核通过”。
|
||||||
|
- 增加尺寸标注范围裁决:只允许从已转录的数值候选按稳定索引选择,必须回看箭头/引出线两端并证明跨越产品外轮廓,不允许新造数值。v79/v80/v82/v83四项尺寸专项回归全部通过。
|
||||||
|
- 部件图谱新增 `functional_role` 和局部 `thickness_axis`,编译器根据 `axis_order` 映射实际板面,再从实体接触分类面贴面/边贴面/边贴边,禁止从中文名称猜连接。27项图谱回归全部通过。
|
||||||
|
- CAD-Coder 论文和官方代码/权重已核对,适用边界记录在 `CAD_CODER_INTEGRATION_EVALUATION.md`。它只作为单部件几何候选器,不作为家具装配/BOM真值来源。
|
||||||
|
- 该模型权重约 26.7GB,官方代码默认 CUDA;未将权重下载到正在承载 Chat API 的服务器,未部署,也未把“代码可执行”当成家具制造验收通过。
|
||||||
|
|
||||||
|
## 2026-09-07 v84 Zero-to-CAD 与 GenCAD 路线评估(候选,未上线)
|
||||||
|
|
||||||
|
- Zero-to-CAD、MIT GenCAD 及同名 gurul/gencad 已按论文、官方仓库和关键执行代码分别核对,边界记录在 ZERO_TO_CAD_INTEGRATION_EVALUATION.md 与 GENCAD_INTEGRATION_EVALUATION.md。
|
||||||
|
- Zero-to-CAD 适合作为单零件孔槽、圆角、倒角和异形特征候选器;它的公开数据明确是单一连通零件,不是家具装配体,不能决定 BOM、五金或连接关系。
|
||||||
|
- MIT GenCAD 的可借鉴点是图片—CAD 对比检索和潜扩散多候选;其训练词汇仅含直线、圆弧、圆和拉伸,且作者明确报告无效、相交和不连通输出,不作为当前主模型。
|
||||||
|
- gurul/gencad 的截面渲染适合补足孔槽、壁厚和间隙的几何复核;其任意绝对路径 Python 执行接口不具备生产隔离边界,禁止直接接入第三方 Chat API。
|
||||||
|
- 推荐顺序固定为:结构图谱权威层 → Zero-to-CAD 单件候选 → 受限 FreeCAD/OCCT 截面复核 → GenCAD 风格检索/多候选排序。没有一个开源项目能替代装配约束和工艺门禁。
|
||||||
|
- 本轮只做源码/论文审计与方案归档,没有部署模型、没有重启生产服务,也没有触碰用户任务。
|
||||||
|
|
||||||
|
## 2026-09-07 v85 接触面到真实孔槽的制造层(候选,未上线)
|
||||||
|
|
||||||
|
- 新增通用`manufacturing_oracle.py`。它只读取结构图谱编译器给出的物理实例、材料族、功能角色和真实AABB接触;中文显示名称变化不会改变连接方法或数量。当前基础策略覆盖人造板确认螺钉、层板托、背板/抽屉底板捕获槽、实木木螺钉、木/金属螺栓和金属焊缝,无法可靠处理的边贴边或不相容榫卯直接阻断。
|
||||||
|
- 每个孔、槽、焊缝和连接件都有稳定的`manufacturing_connection_id`;连接件按唯一`hardware_instance_id`计数,禁止同一孔位重复加工和重复计费。孔圆周、孔深、加工面中心、槽体边界和材料兼容均有确定性检查。
|
||||||
|
- 捕获槽不再只有“承载板切槽”这一半。制造层给嵌入板同步增加逐边入槽余量,输出`manufacturing_geometry_by_instance`和使用成品下料尺寸的`manufacturing_bom`;CAD用调整后的板件伸入槽内,并以实际布尔结果检查干涉。
|
||||||
|
- 通用CAD构建链在单体有效性之后新增`inspect interfere --format json`。第一次服务器隔离测试发现运行时不支持`--json`并会导致所有构建失败,已改成服务器实际支持的`--format json`;这一失败尝试没有计为通过。
|
||||||
|
- 本地完整回归172项通过(253.707秒);制造/图谱/CAD专项在捕获槽修正和证据文件语义角色后39项通过。技能结构校验通过,语法检查通过。
|
||||||
|
- 服务器只读检查显示Chat API服务active且活动任务、等待任务和持久队列均为0。随后仅在`/tmp/dalongxia-cad-v85-eI8HeF`隔离验证,没有覆盖生产文件或重启服务。
|
||||||
|
- 确认螺钉样例真实结果:3实体、12孔、6连接件、STEP 101927字节;切孔前后面数18→36,总体积21340800→21335663.496mm³,差值与孔模板切削体积一致;B-rep检查3对实体0冲突。
|
||||||
|
- 捕获槽样例真实结果:左右各6mm入槽,板件下料尺寸764×400×18→776×400×18mm;3实体、26面、总体积21338400mm³,0.5mm槽宽余量对应净减2400mm³,3对实体0冲突。当前包装模型11节点、55对候选也实测0冲突,新增装配门没有误伤该模型。
|
||||||
|
- 尚未完成:供应商专用确认螺钉/三合一/铰链/滑轨模板、运动扫掠碰撞、正交截面自动判读、真实原图到图谱首次识别、冻结后公司BOM成本误差≤5%盲测。因此v85仍为候选,不能称生产级CAD或目标完成。
|
||||||
|
|
||||||
|
## 2026-09-07 v87 GenCAD审计与生成—验证—修复实测(候选,未上线)
|
||||||
|
|
||||||
|
- MIT GenCAD 的官方推理代码逐张处理图片,未调用仓库中定义的多视图编码器;其草图词汇限于直线、圆弧、圆与拉伸,论文也明确保留无效、相交和不连通输出。它可用于图片—CAD检索和多候选排序,不能直接产生家具装配/BOM真值。
|
||||||
|
- 同名 `gurul/gencad` 的 FreeCAD 代理循环可借鉴截面渲染、执行后检查和局部修复,但其 MCP 可执行任意绝对路径 Python,不能直接接入第三方 Chat API。两仓库许可边界和审计提交已记录在 `GENCAD_INTEGRATION_EVALUATION.md`。
|
||||||
|
- 候选结构编译器新增自身总成 ID 锚点别名、跨总成误用阻断、运动副实例端点的可执行修复映射;探针新增结构化 B-rep 冲突反馈和非法补丁反馈。专项 44 项回归通过;最终候选完整回归 180 项通过(253.928 秒)。
|
||||||
|
- 服务器隔离样例在原始四图上正确建立数量合同与 1200×400×800mm 尺寸账本,但三轮仍未收敛:编译问题 60→55→59,B-rep 冲突 3→6→5,最终状态 `graph_and_cad_kernel_not_passed`。失败报告位于服务器临时目录 `/tmp/dalongxia-cad-v85-eI8HeF/probe-sideboard4/sideboard/report.json`。
|
||||||
|
- 本轮确认的新根因是轴语义仍由模型以 `axis_order` 自由表达,局部修复可能把门板宽高厚重新映射到错误产品轴;同时修复提示不足以阻止对多个实例盲加统一偏移。下一步应把面板厚度轴和功能角色编译成受限放置模板,再让模型只选择开口、靠边关系和间隙参数。
|
||||||
|
- 未修改生产文件、未重启 Chat API、未改变用户任务;真实多产品盲测、公司 BOM 事后对比和总成本误差≤5%仍未完成。
|
||||||
|
|
||||||
|
## 2026-09-07 v90 受控修复响应与单调收敛(候选,未上线)
|
||||||
|
|
||||||
|
- v89 的“发现整段 placement 就整批拒绝”虽然阻止了坐标污染,但真实模型经常把有效单轴修复与整段覆盖混在同一批,导致全部有效修改也被丢弃;另一次模型直接返回 patch 数组,旧调用对数组执行 `.get()` 后异常。该策略已记录为失败,避免以后重复采用。
|
||||||
|
- 新增通用修复响应规范化:既接受 `{"patches": [...]}`,也接受直接 patch 数组;格式正确的完整 x/y/z placement 仅展开为与当前图谱不同的单轴 replace,并记录原路径与展开路径。底层 `apply_graph_patch` 仍禁止整段覆盖,因此规范化不能绕过安全边界。
|
||||||
|
- 每轮候选图谱先重新编译;编译问题数高于上一已接受版本时保留旧图谱并返回结构化回归反馈,不让问题数反弹的修复继续污染后续轮次。
|
||||||
|
- 本地最终完整回归 187 项通过(236.066 秒),其中新增列表响应、稳定 ID/数字索引、完整 placement 单轴展开、畸形与无变化响应拒绝等覆盖。
|
||||||
|
- 服务器确认生产 Chat API 活动任务、等待任务、持久队列均为 0 后,仅在 `/tmp/dalongxia-cad-v85-eI8HeF/probe-sideboard7` 运行隔离原图试验;未修改生产文件、未重启服务、未读取公司 BOM。
|
||||||
|
- 同一组四张原图仍正确固定 1200×400×800mm 和 top=1、bottom=1、side=2、door=2、shelf=1、leg=4。五轮编译问题 57→52→35→30→15,CAD B-rep 冲突 11→16→9→12→3;最终仍有门/五金总成边界、一个门把手断联、背板与层板以及左侧板与铰链穿插,状态仍为 `graph_and_cad_kernel_not_passed`。
|
||||||
|
- 该结果证明修复链可消费反馈并总体收敛,但没有通过制造门禁、没有生成可交付报价,也没有完成不同公司产品盲测或冻结后总成本误差≤5%。
|
||||||
|
|
||||||
|
## 2026-09-07 v91-v96 五金安装语义与端到端防回退(候选,未上线)
|
||||||
|
|
||||||
|
- v90仍把旋钮、铰链等五金当作普通长方体。它们只要伸出总成边界就会被判越界,移进板内又会被判穿插;编译器也无法区分“合法安装嵌入”和“误碰撞”。本轮为所有`functional_role=hardware`实例增加显式`mountings`,每条安装声明宿主、安装方式(表装/埋入/贯穿)、合法板面、嵌入深度和原图证据。没有安装关系、宿主不存在、选了板件窄边、切入深度不符或切向没有正面积重叠时均阻断。
|
||||||
|
- 五金安装通过后才进入装配连通图;只有已验证的表装五金允许伸出结构件紧包围盒,只有已验证的埋入/贯穿安装允许与对应宿主发生体积交叠。其他零件碰撞仍按原规则阻断,不以“五金”身份整体豁免。
|
||||||
|
- v91隔离试验暴露“提示模型自己猜安装面”不可行:首轮10条安装关系仅2条有效,之后编译问题50→39后反复回退。该提示路线判定失败并停止。
|
||||||
|
- 编译反馈现按已求解几何给出`compatible_mounting_values`,只列宿主真实板面及对应安装方式/深度;实体冲突反馈给出按最小位移排序的`compatible_separation_candidates`。单成员总成的位移候选会同时给出总成`bounds_mm`补丁,避免只挪零件后制造新的边界错误。
|
||||||
|
- v92隔离试验使10条五金安装全部有效,编译问题48→29→7;但第4轮虽然编译问题继续降到5,B-rep冲突却从11增到15,证明只按编译问题数选修复会接受总体更差的CAD。该“编译单指标单调”路线判定不足。
|
||||||
|
- 修复接受条件改为编译、制造、B-rep与视觉阻塞项的端到端总数必须严格下降。新增`--resume-report`从历史报告选择总阻塞最少且轮次最新的检查点,并用当前编译器重新生成反馈,避免重新读图、继承旧反馈或从错误轮次继续。
|
||||||
|
- v93因早期回归提示错误引用候选问题而停止;v94因续跑检查点沿用旧编译反馈而停止;v95因位移候选先按碰撞数排序,首选出现578mm不合理侧板平移而停止。三次均为隔离systemd单元,修正相应通用机制后才继续,没有覆盖生产文件或用户任务。
|
||||||
|
- v96从v92第3轮检查点恢复,不重新读取原图且保持`company_bom_used_in_inference=false`。端到端阻塞18→12→10;后续模型试图一次改货架、4个铰链和2个旋钮,编译问题由3反弹到20,被事务门拒绝并保留第5轮图谱。报告位于`/tmp/dalongxia-cad-v85-eI8HeF/probe-sideboard13/sideboard/report.json`,最终仍未通过。
|
||||||
|
- v96还暴露两个未解决的真实边界:一是表装/埋入五金在结构编译器合法后,CAD实体仍是完整盒体,B-rep层尚未生成宿主安装孔/槽;二是样例深度链存在语义歧义,门厚20mm、层板深380mm、背板厚9mm与400mm总深无法在当前“所有实体必须包含于同一外包络”的假设下同时无穿插放置。必须先增加参考外形与实际外形/覆盖门语义或尺寸可行性裁决,不能靠移动板件伪造通过。
|
||||||
|
- 最终候选代码专项60项通过(0.063秒),完整回归196项通过(236.111秒);续跑会跳过所有带`patch_error`的拒绝轮次,编译早退仅在“编译问题下界已不可能优于已接受端到端总分”时触发。测试通过只证明防回退和既有功能未回退,不改变本次真实样例仍失败的结论。
|
||||||
|
- 本轮保持候选状态:五金挂载和修复事务更可靠,但生产级连接特征、真实多产品盲测以及冻结后成本误差≤5%均未完成,禁止标记成功或部署。
|
||||||
|
|
||||||
|
## 2026-09-07 v97-v101 安装碰撞去噪与尺寸可行性门禁(候选,未上线)
|
||||||
|
|
||||||
|
- v96第5轮图谱的B-rep原始复核有8个碰撞,其中6个是已经结构编译器验证的铰链/拉手埋入安装,不应指令AI把五金移离宿主。候选CAD构建现只从宿主减去这些已验证安装的诊断包围盒;其`production_feature=false`,不冒充真实铰杯孔、沉孔或孔位模板。
|
||||||
|
- 服务器隔离目录`mount-clearance-v97`实体检验证明:6个合法安装干涉被排除后,STEP仍保留18个实体且单体有效,内核只返回层板/背板1个真实冲突,体积208800mm³。这证明去噪没有吞掉无关结构错误。
|
||||||
|
- v97首次续跑暴露引擎封装层可能保留旧干涉结果,且相对STEP路径在切换`cwd`后被重复拼接。修复后对当前STEP直接执行`inspect interfere --format json`,优先解析stdout机器结果,现场稳定返回`['shelf_inst','back_panel_inst']`,不再降级为泛化内核错误。
|
||||||
|
- v100续跑复现了往返摆动:层板向前移开背板后立即与左右门板冲突,端到端阻塞仍为4,候选被事务门禁拒绝。根因是当前图纸同时给出总深400mm、门厚20mm、层板深380mm、背板厚9mm,在单一实体外包络假设下无法仅靠前后平移无冲突放置。
|
||||||
|
- 编译器新增精确单轴区间扣减:在所属总成/产品允许边界内,计算移动件不与所有切向相交实体碰撞的全部下坐标区间。该样例层板Y轴允许区间[0,20]被门板与背板完全覆盖;背板Y轴[0,391]也无可行子区间,因此返回`minimum_separation_axis_infeasible=true`,不再把局部位移候选误当成解。
|
||||||
|
- v101从同一已审计检查点隔离续跑,刷新基线为3个编译阻塞+1个真实内核碰撞。AI一轮修复产生11个编译问题,被拒绝后立即停止;最终状态`geometry_dead_end_requires_focused_source_adjudication`,仅执行1轮,没有再循环。下一交互节点必须先由AI聚焦复核外形口径、覆盖/内嵌关系、零件深度或明确避让加工;仍无法消解时只问用户一个聚焦问题。
|
||||||
|
- 最终完整回归205项全部通过(239.624秒)。以上现场运行均为`/tmp`隔离试验,每次启动前确认真实Chat API活动、等待、持久队列均为0;未覆盖生产文件、未重启生产服务。真实多产品盲测、生产级孔位模板和冻结后成本误差≤5%仍未完成。
|
||||||
|
|
||||||
|
## 2026-09-08 v102-v104 尺寸口径证据门与用户问题收敛(候选,未上线)
|
||||||
|
|
||||||
|
- 新增实际占用包络与参考包络双层语义。原图账本仍默认核对实际占用外形;把某一轴改判为柜体/开口参考尺寸时,必须由会话事实或本轮原图独立裁决授权,不能由待修复图谱自证。参考包络越出实际占用外形会被阻断。
|
||||||
|
- v102隔离运行正确识别尺寸源冲突,但向用户暴露过多内部技术描述并给出会扩大占用空间的错误方案;v103虽拦截内部术语,仍把问题降到层板/背板局部尺寸,遗漏更上游的“总深是否包含柜门”口径。两轮均作为失败证据保留,未交付用户。
|
||||||
|
- v104启动前服务器只读检查为`active=0 waiting=0 dewu_busy=0 dewu_queue=0 durable_inflight=0`。从同一已审计v96检查点恢复,只执行一轮:基线3个编译阻塞和1个真实B-rep冲突;AI候选造成11个编译问题被拒绝;随后进入一次聚焦原图裁决,没有继续前后移动循环。
|
||||||
|
- v104多模态模型连续2次仍因选项过长未通过输出校验,系统明确记录`focused_source_adjudication_error`,并使用`deterministic_dead_end_question_fallback`安全收口为一个问题:“尺寸图标注的总深 400mm,是否包含柜门厚度?”,选项仅为“包含柜门(成品最外尺寸)”与“不包含柜门(只算柜体/框架)”。报告位于服务器隔离目录`/tmp/dalongxia-cad-v85-eI8HeF/probe-sideboard19/sideboard/report.json`。
|
||||||
|
- 本地新增参考尺寸证据门、问题优先级、焦点不匹配拒绝和确定性兜底专项检查后,结构图谱/制造层74项通过;最终候选完整回归210项通过(235.977秒)。v104仍不等于图像裁决或制造模型通过,生产服务未改、未重启。
|
||||||
|
|
||||||
|
## 2026-09-08 v105-v112 用户事实续跑、视觉复核净化与五金安装原子修复(候选,未上线)
|
||||||
|
|
||||||
|
- 续跑接口新增结构化 `scope_facts`。用户已经回答“总深不包含柜门”后,该事实绑定到原尺寸证据的 Y 轴,程序把 400mm 柜体参考范围投影为门前沿 0mm、柜体 20–420mm、实际占用深度 420mm;只有端到端阻塞总数严格下降才接受,重复续跑保持幂等,不再重复询问同一个口径问题。
|
||||||
|
- v105 暴露旧续跑仍会重新询问已回答问题;修复后,阻塞清空时直接进入制造编译、真实 STEP 构建和视觉复核,不再发起没有待修项的模型“修复”。
|
||||||
|
- v106 在尺寸口径解除后收敛到 3 个问题:层板与背板的非结构接触被误生成连接,以及背板先被左右侧板加宽后,顶/底板槽仍使用旧净宽,造成两处真实 B-rep 干涉。后续发散轮次被事务门拒绝,没有覆盖较好检查点。
|
||||||
|
- 制造规划改为只在被捕获薄板的边真正进入另一板件宽面时生成槽;层板/隔板边仅抵到背板宽面时记为 `non_structural_abutment`,不产生虚构螺钉、槽或成本。所有入槽余量累计完成后,再按最终嵌入板制造尺寸重算每个承载槽,消除迭代顺序导致的槽宽不足。v107 对 v106 最佳检查点重新编译后,结构、制造和 B-rep 阻塞均为 0。
|
||||||
|
- v108 隔离进程未继承视觉模型凭据,旧逻辑把配置缺失误当几何错误并进入修复循环。现在将其分类为不可重试的 `model_credentials_missing`,交给运行时配置层处理,不污染图谱,也不重复请求模型。
|
||||||
|
- v109 的视觉模型输出自相矛盾:一边声称层板、铰链、旋钮无需修改,一边把它们列为问题;还试图用透视照片估算覆盖尺寸图明确给出的 40mm 腿高。视觉复核现仅接受枚举问题码、有效原图证据、可执行修正;尺寸差异必须同时给出 `expected_mm` 和 `actual_mm` 且真实不一致。声明“无需修改”的行、超长推理文本和无数值尺寸意见全部丢弃并记录审计,精确尺寸账本优先于照片透视感受。
|
||||||
|
- 人工查看 v109 四视图发现右门两只铰链仍位于中缝,尽管编译器把它们声明为安装在右侧板。根因是旧 `actual_embed` 使用硬件到安装面的无界距离,远离宿主 528mm 的铰链也能伪装成贯穿安装。现改为硬件与宿主厚度区间的真实交集并限制最大嵌入深度;只有存在实际空间接触的安装面才兼容。v110 因此稳定阻断两只错误铰链,未被 B-rep“无冲突”假通过。
|
||||||
|
- v111 证明只给模型安装方式候选仍不足:模型只改 `through/embed/face` 声明而不移动实体,事务门正确拒绝。系统随后为无简单候选的五金生成原子候选集,把单轴位置、安装方式、安装面和嵌入深度绑成一组;每组必须先经完整图谱重编译并严格减少阻塞,视觉模型只能从原图支持的整组候选中选择,不能仅改声明绕过校验。
|
||||||
|
- v112 从同一审计检查点选择右侧板候选后,结构、制造、B-rep和净化后的视觉阻塞均为 0;报告状态为 `candidate_visual_pass_requires_independent_acceptance`,没有自动宣告成功。人工独立查看最终正视和等轴视图后仍判定不合格:铰链虽从中缝移到左右外侧,但被画成横跨门板/侧板的长方体“贯穿件”,与家具暗铰链的铰杯、底座及孔位工艺不符。这证明几何合法不等于五金工艺语义正确。
|
||||||
|
- 本轮明确记录的失败路线:让模型只修改五金安装元数据;用通用长方体代替具体铰链家族;把视觉模型无证据的透视估算当尺寸修正;把运行时凭据缺失交给几何重试。后续不得重复这些做法,应增加有证据的五金家族/安装模板(例如铰杯直径与深度、门板杯孔、侧板底座孔距)并用原图或用户确认选择具体模板。
|
||||||
|
- v112 仍只是一组人工生成样例的隔离验证,未使用公司 BOM 参与推理、未修改生产服务。不同公司产品盲测、组装/零件外观独立验收、生产级五金孔位与冻结报价后总成本误差不超过 5%仍未完成,接口状态必须保持测试失败/继续优化。
|
||||||
|
|
||||||
|
## 2026-09-08 v113 五金家族模板与生产孔型门禁(候选,未上线)
|
||||||
|
|
||||||
|
- v112 人工验收失败的直接原因不是铰链位置单轴偏差,而是表示层仍把暗铰链和旋钮当作任意长方体。移动长方体直到包围盒接触宿主只能让几何门变绿,不能得到铰杯、底座、连接臂、螺钉孔或可采购件数量;该路线正式停止,不再生成旧式 `compatible_placement_patches` 修复模板五金。
|
||||||
|
- 新增版本化 `hardware_catalog.py`,首批模板为 35mm 暗铰链(TH52、系统32公开参考)与 M4 单孔旋钮。模型只能选择稳定 `hardware_family` / `hardware_template_id`,并为每个模板槽声明真实宿主、安装广面、模板规定的安装方式和嵌入深度、产品坐标孔型中心及原图证据。缺模板、模板家族不匹配、槽缺失/重复、旋钮整件贯穿、铰杯边距越界、底座不在37mm系统线均阻断。
|
||||||
|
- 五金可视几何不再使用占位盒:暗铰链由铰杯圆柱、安装底座和两段正交连接臂组成,旋钮由表装圆柱组成;`1×1×1` 只保留为不可计价声明占位。模板挂载对宿主的合法相交由槽位门单独证明,未挂载的其他实体仍经过碰撞门。
|
||||||
|
- 制造层按模板在宿主板上生成真实孔型。每只暗铰链恰好生成 1 个 35mm 铰杯孔、2 个 TH52 固定孔和 2 个系统32底座孔;每只旋钮恰好生成 1 个 4.5mm 螺钉通孔。采购五金每个物理实例只计一次,五金可视包围盒不会进入板材下料 BOM、接触推导或再次生成确认螺钉/木螺钉。
|
||||||
|
- 公开模板保留 `public_reference_draft` 警告,未映射公司实际采购 SKU 前,制造结果状态只能是 `manufacturing_draft_unapproved_hardware`,禁止冒充公司已批准生产孔型。
|
||||||
|
- 本地新增/改写 12 项五金专项验证并通过;最终完整回归 226 项全部通过(253.672 秒),Python 语法、`git diff --check` 与技能结构校验均通过。四个运行文件已同步到服务器 `/tmp/dalongxia-cad-v85-eI8HeF` 隔离目录,SHA-256 与本机完全一致且服务器静态编译通过。
|
||||||
|
- 同步时服务器只读状态为 `active=0 waiting=0`,但另有 1 个得物1688持久任务执行中;因此本轮没有启动模型推理、没有覆盖生产文件、没有重启服务。真实 STEP/GLB 与 B-rep 五金模板构建仍须在服务器完全空闲后验证。
|
||||||
|
- 真实多产品盲测、原图与组装/拆件独立验收、公司实际五金 SKU 批准,以及冻结估价后逐项和总成本误差不超过 5%仍未完成;候选不能部署或标记成功。
|
||||||
|
|
||||||
|
## 2026-09-08 v114 五金复合实体 B-rep 验收(候选,未上线)
|
||||||
|
|
||||||
|
- 服务器等待门严格等到真实 Chat API 活动、排队、得物1688未终态批次和持久任务全为 0;两个残留浏览器 busy 标记经连续闲置检查被确认为 stale。之后只在 `/tmp/dalongxia-cad-v85-eI8HeF` 运行隔离验证,没有修改生产目录、重启或部署服务。
|
||||||
|
- 第一次真实 STEP 验收失败:结构编译和单测都通过,但导出器把同一只铰链的铰杯、底座和两段连接臂作为四个独立实体;OCCT 因此将连接臂与自身底座的正常搭接报为 2 组干涉(511.9999mm³ 与 319.9999mm³)。这一失败没有被算作通过,也没有通过放宽干涉容差规避。
|
||||||
|
- 导出器现在会先将一个采购五金模板内的几何基元布尔合并为单一实体,再与门板、侧板等家具实体做权威干涉检查。结构层同时将铰链臂放在门板 Y=18mm 和侧板 X=18mm 的安装面外侧,仅允许铰杯进入已生成的 35mm 铰杯孔。
|
||||||
|
- 第二次服务器真实内核验收通过:铰链生成 113053 字节 STEP 和 61632 字节 GLB,孔工序数为 5;旋钮生成 30274 字节 STEP 和 21168 字节 GLB,孔工序数为 1。两个样例的 B-rep 都为零干涉,报告在服务器 `hardware-template-brep-v113-fused/report.json`。
|
||||||
|
- 本地结构图谱与制造层 91 项专项回归通过;最终完整回归 227 项全部通过(233.019秒,其中 1 项真实内核用例按设计仅在服务器开关启用时执行),Python 语法和 `git diff --check` 通过。
|
||||||
|
- 仍未完成公司实际五金 SKU 模板批准、原始多图到 v2 图谱的新识别、多产品冻结后 BOM/成本盲测和总成本误差不超过 5%。本版仍是未部署候选,不得用以上样例通过替代真实多产品验收。
|
||||||
|
|
||||||
|
## 2026-09-08 v115-v116 原图重跑与模型鉴权终止门(候选,未上线)
|
||||||
|
|
||||||
|
- v115 启动前持续只读检查服务器真实任务状态;先等待正在执行的得物1688任务完成,确认 Chat API 活动、等待队列和持久任务均为 0 后,才在 `/tmp/dalongxia-cad-v85-eI8HeF` 使用四张原始产品图启动全新识别。没有复用公司 BOM、旧报告或历史模型结果,也没有修改生产目录、重启或部署服务。
|
||||||
|
- v115 在进入 CAD 前被阿里云模型接口以 HTTP 401 `invalid_api_key` 拒绝。服务器密钥文件存在、权限为 600,且其内容指纹与用户最近提供的密钥完全一致;对同一接口的最小请求也稳定返回 401,证明这是远端鉴权凭据失效,不是任务缺图、缺尺寸或本地环境未加载密钥。
|
||||||
|
- 旧异常分支把结构读图的 401 吞成普通机器重读失败,随后继续做尺寸完整性判断,最终误报为缺少 x/y/z 轴并可能引导用户补传素材。这条“鉴权失败后继续资格判断”的路线判定错误,已停止使用。
|
||||||
|
- v116 将 401/403 单独归类为不可重试的 `model_credentials_invalid`。模型请求只执行一次,源资格层立即以 `external_dependency_failed` 终止,不再重复请求模型,也不再向用户虚构缺图、缺尺寸或 CAD 问题。服务器隔离现场耗时 3.753 秒,返回中文阻塞信息“读图模型已拒绝当前鉴权凭据”。
|
||||||
|
- 候选完整回归 229 项通过(246.290 秒),1 项真实 CAD 内核用例按设计因本机未启用服务器依赖而跳过;新增专项测试验证 401 只请求一次、鉴权失败不进入读图/CAD循环。v115 因外部鉴权阻塞,尚未实际验证上一轮分阶段 CAD 修复门能否在这组原图上完成收敛,不能宣称原图重跑成功。
|
||||||
|
- 当前仍需新的有效阿里云模型密钥才能继续多视图识别、CAD/BOM冻结盲测和公司成本误差不超过 5% 的目标。本轮候选未上线、未重启生产服务,接口状态必须保持测试失败/继续优化。
|
||||||
|
|
||||||
|
## 2026-09-08 v117 本地多模态输出上限审计与截断终止门(候选,未上线)
|
||||||
|
|
||||||
|
- 在生产 Chat API 活动、等待、得物任务与持久队列连续为空后,服务器仅在 `/tmp/dalongxia-cad-v85-eI8HeF` 使用原始四图启动全新隔离试验;没有读取公司 BOM、旧图谱或旧模型响应,`company_bom_used_in_inference=false`,没有修改生产目录或重启服务。
|
||||||
|
- 本地 Gemma4 多模态服务正确复读出整体宽 1200mm、深 400mm、高 800mm,三轴均有两次独立证据且置信度为 1.0;但结构专项响应在第 72 行被截断为未闭合字符串,五轮图谱请求也全部返回空或不可解析 JSON,故没有生成可验收 CAD/BOM。
|
||||||
|
- 现场核对服务端实现后确认根因:调用方请求 3072–8192 tokens,服务却用 `GEMMA_LOCAL_MAX_NEW_TOKENS=512` 静默钳制输出,并且无论是否触顶都返回 `finish_reason=stop`。因此旧逻辑把确定的容量不足误判为偶发 JSON 格式问题,重复提交同一个过大的结构任务;这条“原样重试五轮”路线正式判定失败。
|
||||||
|
- 通用请求层现在同时识别显式 `finish_reason=length` 与“未闭合 JSON + 完成 token 正好落在常见隐藏上限 + 请求量至少为其两倍”的静默截断证据。命中后立即抛出 `model_output_truncated`,责任层标记为 `model_gateway_capacity` 且不可原样重试;普通短小畸形 JSON 仍保留最多三次格式重试,不会把所有格式错误都误判成容量问题。
|
||||||
|
- 新增测试覆盖 401 终止、512-token 假 `stop` 只请求一次、短畸形 JSON 仍执行有界重试,以及探针把截断归类为外部模型网关容量问题。最终完整回归 231 项通过(234.508 秒),1 项真实 CAD 内核用例按设计因本机未启用服务器依赖而跳过。
|
||||||
|
- 该修复只根治截断后的无效循环和错误归因,不代表 Gemma4 已具备长结构图谱生成能力,更不代表原图结构、CAD、BOM或成本通过。下一步必须把高层视觉识别拆成可验证的小型结构事实,再由确定性参数/装配编译器生成 CAD;或者接入可返回完整长 JSON 的有效多模态网关。不同公司产品冻结后盲测与总成本误差不超过 5% 仍未完成。
|
||||||
|
|
||||||
|
## 2026-09-08 v119 阿里云新凭据与真实截断终止验收(候选,未上线)
|
||||||
|
|
||||||
|
- 用户提供的新阿里云密钥已原子写入服务器受限密钥文件,文件权限保持 `600`、运行账户归属不变;未回显密钥。对阿里云兼容接口的最小 `qwen-plus` 请求返回 HTTP 200,证明 v115 的 `invalid_api_key` 阻塞已经解除。引擎按请求动态读取密钥文件,因此没有重启生产服务,也没有中断正在执行的业务任务。
|
||||||
|
- 随后仅在 `/tmp/dalongxia-cad-v85-eI8HeF` 使用同一组四张原始产品图运行一轮 `qwen-vl-plus` 隔离资格审查;没有调用公司 BOM,`company_bom_used_in_inference=false`,也没有写入生产目录。
|
||||||
|
- 真实响应在结构事实 JSON 第 193 行附近未闭合,尺寸专项响应返回 2300 tokens 后同样未闭合。候选请求层将其归类为 `model_output_truncated`,责任层为 `model_gateway_capacity`、`retryable=false`;报告 `rounds=[]`,证明没有再进入图谱轮次或重复调用。
|
||||||
|
- 该现场结果验证了“截断即终止、不循环、不误生成 CAD/BOM”的通用修复,但也证明当前大而全的结构事实提示仍超过模型有效输出边界。下一步不再提升原样重试次数,而是把多视图识别拆成装配概览、柜体板件、门抽/五金和尺寸账本等小型独立事实,再由确定性参数化编译器合并并执行跨事实一致性门禁。
|
||||||
|
- 本次仍未生成可验收 CAD/BOM,未部署候选;不同公司产品冻结后盲测、部件/孔槽/五金完整性以及总成本误差不超过 5% 均未完成,测试状态继续保持失败。
|
||||||
|
|
||||||
|
## 2026-09-08 v122-v123 分阶段图谱生成与局部定位失败隔离(候选,未上线)
|
||||||
|
|
||||||
|
- 新阿里云凭据以受限文件方式更新后,使用 `qwen3-vl-plus` 的真实最小请求返回 HTTP 200;生产服务没有重启。随后在服务器 `/tmp/dalongxia-cad-v85-eI8HeF` 继续四张原始图隔离试验,未读取公司 BOM,报告明确记录 `company_bom_used_in_inference=false`。
|
||||||
|
- v122 的源证据资格审查通过,整体尺寸固定为 1200×400×800mm,模型不再发生请求超时:首轮图谱约 118.42 秒返回,第二轮局部修复约 90.94 秒返回。但两轮编译分别有 37、35 项问题且物理实例均为 0,最终 `graph_not_passed`,不能作为成功模型。
|
||||||
|
- 现场报告确认首个真实错误是门总成实例把 `carcass` 当作跨总成定位目标。旧定位求解器遇到这一个非法引用后会丢弃全图所有已合法求解实例,进而伪造空总成、无实例零件、数量冲突和源拓扑冲突等数十项级联问题。该全局失败行为已停止使用。
|
||||||
|
- 定位求解现按实例依赖树隔离失败:非法实例及依赖它的实例返回稳定 ID 路径,其余合法坐标、审计和 BOM 实例继续保留;对已知定位失败的零件、角色和总成暂缓派生数量/边界级联检查。新增测试证明一个跨总成引用只阻断受影响实例,不再把 `/instances` 整体清空。
|
||||||
|
- 初始图谱由一次大请求改为四个有边界的多模态阶段:核心证据/参数/总成/零件、非五金物理实例、五金模板安装、总成运动关系。每一段都重新获得全部原图;程序在合并前强制校验稳定 ID、逐零件实例数量、三轴定位结构、五金宿主和总成连接端点。初始结构实例只允许锚定整体或自身总成,禁止跨总成和实例依赖;失败阶段会保存审计并停止合并,不把半份图谱送入 CAD。
|
||||||
|
- 本地完整回归 236 项通过(238.562 秒),其中 1 项真实 OCCT 内核测试按设计仅在服务器开关启用时跳过;Python 语法检查通过。分阶段通用算法尚未做新的服务器原图实跑,也未部署生产;不同公司产品冻结后 BOM/成本盲测与总成本误差不超过 5% 仍未完成。
|
||||||
|
|
||||||
|
## 2026-09-08 v124-v131 凭据热更新与分区合同收紧(生产基础设施已修复,CAD 候选未上线)
|
||||||
|
|
||||||
|
- 阿里云新密钥的本地文件与服务器受保护密钥文件 SHA-256 指纹一致,直接 `qwen3-vl-plus` 请求返回 HTTP 200。旧故障的真正原因是 Chat API 主进程和持久 Codex 子进程在启动时缓存了旧值,即使受保护文件已替换,请求仍使用被云端封锁的旧 Key,日志为 HTTP 401 `InvalidApiKey` / `API-key is blocked`。
|
||||||
|
- 生产 Chat API 现在在每次请求时优先重读 `*_API_KEY_FILE`;持久 Codex 运行时在无在途请求时检查密钥指纹,变化时只替换子进程并保留会话恢复信息。以后旋转 Key 不再依赖主服务重启。
|
||||||
|
- 部署前实际检查过去 5 分钟真实请求为 0、持久队列中运行/排队任务为 0;备份后仅在空闲窗口重启主服务一次。服务自 2026-09-08 15:40:20 CST 持续 active,健康端点 HTTP 200、NRestarts=0。部署后紧接的两条真实 Chat API Codex shadow 记录为 `completed`,部署前紧邻三条均因 `CodexAppServerError` 失败;新日志中未再出现密钥被锁错误。
|
||||||
|
- 凭据热更新本地回归 15 项通过,包括“文件新值覆盖旧环境变量”和“只替换持久子进程”;已部署文件哈希与本地候选一致。Git 补丁仅包含受保护文件的读取与指纹机制,不包含密钥明文。
|
||||||
|
- v124 首次真实分区请求被阿里云以 HTTP 400 拒绝:使用 `response_format=json_object` 时提示未字面包含 `json`。所有分区提示现已显式要求 JSON,并为非鉴权 HTTP 错误保留不含凭据的安全现场信息;该 400 后未再出现。
|
||||||
|
- v126-v129 逐段暴露并阻断了非法稳定 ID、只生成门总成却没有主柜体总成、缺少 `functional_role` 以及不符合受限定位模式的 x 轴结构。核心响应现强制主总成、固定角色枚举和完整必填字段;结构实例只接受明确的 absolute/align JSON 形状。这些失败都在合并/CAD 前终止,没有伪造输出。
|
||||||
|
- v130 暴露旧续跑只接受“已完整编译图”,因此无法从“素材已验收、某个分区已通过”的失败报告继续。续跑现分为素材、分区阶段和完整编译图三级检查点;只复用连续且状态为 accepted 的完整响应,失败阶段仍会重跑。
|
||||||
|
- v131 从原图审计报告续跑:核心阶段 83.383 秒通过,非五金结构实例 36.849 秒通过;五金阶段 36.302 秒返回后因所有铰链/拉手实例都缺少 `assembly_id` 而被拒绝。现已将五金实例的完整字段合同、合法五金 part ID 和合法 assembly ID 显式写入提示,错误也会返回精确的实际引用值。
|
||||||
|
- 当前完整回归 240 项通过(246.140 秒,1 项服务器 OCCT 依赖用例在本机按设计跳过),最新断点与五金合同专项回归通过。v131 仍未生成可验收 CAD;不同公司产品盲测、冻结后与公司 BOM 对比、总成本误差不超过 5% 仍未完成,因此产品成本评估状态必须继续保持测试失败/继续优化。
|
||||||
|
|
||||||
|
## 2026-09-08 v132-v138 分区续跑与当前合同失效门(候选,未上线)
|
||||||
|
|
||||||
|
- v132 从已接受的核心和结构分区续跑,五金阶段补齐了总成 ID,但把定位轴返回为标量占位,未满足受限三轴定位合同;该响应在合并前被拒绝,没有进入 CAD。
|
||||||
|
- v133 对仅缺少语义的标量五金定位做确定性绝对坐标归一化,并保留审计;五金阶段通过,关系阶段因缺少稳定关系 ID、使用未允许的 `type` 字段以及点坐标形式的轴而被拒绝。
|
||||||
|
- v134 收紧关系合同后关系阶段通过,但核心分区把 `observations` 返回为对象而非证据列表,绑定证据时触发类型异常。核心分区现要求非空对象列表;旧对象形检查点会失效并重跑,不能进入图谱/CAD。
|
||||||
|
- v135 使用新核心合同重新读取原图;核心阶段通过,结构实例将 x/y/z 放在实例顶层而不是 `placement` 下。只对字段层级错误执行无语义变化的确定性嵌套并记录审计,不修改轴值、总成或角色。
|
||||||
|
- v136 完成四个分区后由编译器发现核心仍有三个裸数值参数、对象形包围盒,以及五金零件缺少模板族/模板 ID。核心合同现强制每个参数为带证据状态的节点、包围盒必须为三元素数组、每个五金零件必须从允许模板对中选择。
|
||||||
|
- v137 暴露完整图谱续跑选择器仍可能拾取旧分区合同生成的已编译图。续跑选择器现逐个验证核心、结构、五金和关系分区均符合当前合同;旧合同图谱不再进入局部修复循环。
|
||||||
|
- v138 在服务器 `/tmp/dalongxia-cad-v85-eI8HeF/probe-sideboard46-v138-current-core` 以当前合同从原图重新生成四个分区:核心、结构、五金和关系阶段均一次返回并通过分区格式门,总耗时约 229 秒;随后制造编译器正确阻断缺少允许转轴方向的四只铰链、旋钮安装槽位不一致,以及落在门总成边界外的铰链定位。最终状态为 `graph_and_cad_kernel_not_passed`,没有误标成功、没有输出错误生产图。
|
||||||
|
- 最终候选完整回归 243 项通过(258.547 秒),其中 1 项真实 OCCT 内核用例按设计仅在服务器依赖启用时跳过;新增断点续跑、旧合同失效、分区形状和安全归一化检查均通过。
|
||||||
|
- 本轮明确证明凭据热更新与分区检查点机制已恢复真实模型调用,并把错误从格式/断点层推进到具体五金装配语义;但 v138 仍未生成可独立验收的 CAD。不同公司产品盲测、冻结后成本对比和总成本误差不超过 5% 仍未完成,产品成本评估继续保持测试失败/继续优化。
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
# Zero-to-CAD 接入评估
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
Zero-to-CAD 比 CAD-Coder 和 GenCAD 更适合承担单个零件的几何候选生成,尤其是孔、槽、沉头孔、圆角、倒角、壳体、阵列等局部特征;它仍不能直接作为家具装配、BOM 数量、连接方式或生产工艺的真值来源。
|
||||||
|
|
||||||
|
## 已核对事实
|
||||||
|
|
||||||
|
- Zero-To-CAD-1m 含 999,633 条可执行 CadQuery 程序;100K 是筛选后的多样化子集。
|
||||||
|
- 每条样本提供 8 个 256×256 渲染视角、CadQuery 代码、操作列表、STL 和 STEP。
|
||||||
|
- 操作覆盖草图、拉伸、切除、旋转、放样、扫掠、圆角、倒角、抽壳、偏移、布尔、阵列、镜像、通孔、盲孔、沉头孔、螺纹和加强筋。
|
||||||
|
- 数据生成采用“模型写代码—执行—校验—修复”的代理循环;但校验只保证可执行、拓扑有效、单一连通实体、最少面数、正体积及导出成功,不等于完整可制造性检查。
|
||||||
|
- 官方提示明确把每条样本限定为一个自包含零件,不是装配体。
|
||||||
|
- 2B 模型要求 8 张干净 CAD 渲染图;官方说明真实照片、噪声背景和复杂多部件装配仍可能失败。
|
||||||
|
- 论文在合成测试集上执行成功率 82.1%、平均 IoU 0.747;在 ABC 人工设计分布外数据上降至 61.0% 和 0.377。IoU 使用归一化和旋转最佳匹配,不证明绝对尺寸、朝向或制造关系正确。
|
||||||
|
|
||||||
|
## 推荐接法
|
||||||
|
|
||||||
|
1. 首先试验 100K 数据和 2B 模型,不直接把 1M 全量导入生产。
|
||||||
|
2. 现有多模态模块先完成产品总成、部件实例、尺寸证据和连接图谱。
|
||||||
|
3. 对需要孔槽、异形、圆角等特征的单个零件,程序生成 8 个规范视角,再让 Zero-to-CAD 输出多个局部几何候选。
|
||||||
|
4. 每个候选必须通过绝对尺寸、实体有效性、孔槽数量/位置、部件边界、连接面、装配干涉和材料工艺门禁。
|
||||||
|
5. 候选失败时只修复该零件,不允许模型重新解释整个产品或重写完整 BOM。
|
||||||
|
|
||||||
|
## 禁止事项
|
||||||
|
|
||||||
|
- 不以归一化 IoU 代替毫米尺寸验收。
|
||||||
|
- 不把单零件模型用于决定抽屉数量、门型、五金或总成关系。
|
||||||
|
- 不在当前承载 Chat API 的生产进程内直接加载模型权重。
|
||||||
|
- 不把能够导出 STEP 当作工厂可生产证明。
|
||||||
|
|
||||||
121
server_patch/ai_3d_model/ai-solid-wood-cost/hardware_catalog.py
Normal file
121
server_patch/ai_3d_model/ai-solid-wood-cost/hardware_catalog.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
"""Stable furniture-hardware templates used by the assembly compiler.
|
||||||
|
|
||||||
|
The vision model may identify a hardware *family*, but it is not allowed to
|
||||||
|
invent production drilling dimensions. A manufacturing-capable graph must
|
||||||
|
reference one explicit template from this catalog (or a future company
|
||||||
|
catalog with the same contract). Public-reference templates remain visibly
|
||||||
|
marked as such until the company approves a purchasable fitting/SKU.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
|
||||||
|
|
||||||
|
CATALOG_SCHEMA_VERSION = "dalongxia.hardware-catalog/1"
|
||||||
|
|
||||||
|
|
||||||
|
HARDWARE_TEMPLATES = {
|
||||||
|
"euro_hinge_35_th52_system32_v1": {
|
||||||
|
"family": "concealed_cup_hinge",
|
||||||
|
"display_name": "35mm暗铰链(TH52/系统32参考模板)",
|
||||||
|
"approval_status": "public_reference_draft",
|
||||||
|
"source_urls": [
|
||||||
|
"https://shop.hettich.com/ro_EN/Hinges/Sensys-hinge-series/Hinges/"
|
||||||
|
"Sensys-110%C2%B0-hinge-without-integrated-silent-system-%28Sensys-8645%29%2C-"
|
||||||
|
"nickel-plated%2C-overlay%2C-Opening-angle-110%C2%B0%2C-TH-drilling-pattern-"
|
||||||
|
"52-x-5-5-mm%2C-for-screwing-on-%28-%29/p/9073638",
|
||||||
|
"https://mediacenter.grass.eu/Produktkataloge/Hinges/EN/179/",
|
||||||
|
],
|
||||||
|
"hardware_axes": ["x", "z"],
|
||||||
|
"mounting_slots": {
|
||||||
|
"cup": {
|
||||||
|
"host_roles": ["door"],
|
||||||
|
"kind": "mortised",
|
||||||
|
"embed_depth_mm": 12.8,
|
||||||
|
"footprint_radius_mm": 17.6,
|
||||||
|
},
|
||||||
|
"plate": {
|
||||||
|
"host_roles": ["side", "stile", "frame_member"],
|
||||||
|
"kind": "surface",
|
||||||
|
"embed_depth_mm": 0.0,
|
||||||
|
"footprint_half_width_mm": 16.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"manufacturing": {
|
||||||
|
"cup_diameter_mm": 35.0,
|
||||||
|
"cup_depth_mm": 12.8,
|
||||||
|
"cup_fixing_spacing_mm": 52.0,
|
||||||
|
"cup_fixing_diameter_mm": 5.5,
|
||||||
|
"cup_fixing_depth_mm": 12.0,
|
||||||
|
"cup_edge_distance_range_mm": [3.0, 7.0],
|
||||||
|
"plate_system_line_mm": 37.0,
|
||||||
|
"plate_hole_spacing_mm": 32.0,
|
||||||
|
"plate_hole_diameter_mm": 5.0,
|
||||||
|
"plate_hole_depth_mm": 12.0,
|
||||||
|
},
|
||||||
|
"visual": {
|
||||||
|
"cup_diameter_mm": 35.0,
|
||||||
|
"cup_depth_mm": 12.8,
|
||||||
|
"plate_size_mm": [32.0, 2.0, 40.0],
|
||||||
|
"arm_thickness_mm": 8.0,
|
||||||
|
},
|
||||||
|
"purchased_unit": "套",
|
||||||
|
},
|
||||||
|
"knob_m4_surface_v1": {
|
||||||
|
"family": "knob",
|
||||||
|
"display_name": "单孔旋钮(M4参考模板)",
|
||||||
|
"approval_status": "public_reference_draft",
|
||||||
|
"source_urls": [],
|
||||||
|
"mounting_slots": {
|
||||||
|
"body": {
|
||||||
|
"host_roles": ["door", "drawer_front"],
|
||||||
|
"kind": "surface",
|
||||||
|
"embed_depth_mm": 0.0,
|
||||||
|
"footprint_radius_mm": 10.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"manufacturing": {
|
||||||
|
"through_hole_diameter_mm": 4.5,
|
||||||
|
},
|
||||||
|
"visual": {
|
||||||
|
"body_diameter_mm": 20.0,
|
||||||
|
"body_projection_mm": 24.0,
|
||||||
|
},
|
||||||
|
"purchased_unit": "个",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_hardware_template(template_id: str) -> dict | None:
|
||||||
|
"""Return an isolated template value so callers cannot mutate the catalog."""
|
||||||
|
value = HARDWARE_TEMPLATES.get(str(template_id or ""))
|
||||||
|
return copy.deepcopy(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def supported_hardware_contract() -> list[dict]:
|
||||||
|
"""Compact schema exposed to the graph-generating model."""
|
||||||
|
contracts = []
|
||||||
|
for template_id, template in sorted(HARDWARE_TEMPLATES.items()):
|
||||||
|
manufacturing = template.get("manufacturing") or {}
|
||||||
|
contracts.append({
|
||||||
|
"hardware_template_id": template_id,
|
||||||
|
"hardware_family": template["family"],
|
||||||
|
"hardware_axes": list(template.get("hardware_axes") or []),
|
||||||
|
"mounting_slots": {
|
||||||
|
slot: {
|
||||||
|
"host_roles": list(rule["host_roles"]),
|
||||||
|
"kind": rule["kind"],
|
||||||
|
"embed_depth_mm": rule["embed_depth_mm"],
|
||||||
|
}
|
||||||
|
for slot, rule in sorted(template["mounting_slots"].items())
|
||||||
|
},
|
||||||
|
"placement_constraints": {
|
||||||
|
key: copy.deepcopy(manufacturing[key])
|
||||||
|
for key in (
|
||||||
|
"cup_edge_distance_range_mm", "plate_system_line_mm",
|
||||||
|
)
|
||||||
|
if key in manufacturing
|
||||||
|
},
|
||||||
|
"approval_status": template["approval_status"],
|
||||||
|
})
|
||||||
|
return contracts
|
||||||
@ -0,0 +1,641 @@
|
|||||||
|
"""Geometry-driven manufacturing feature planner for compiled assemblies.
|
||||||
|
|
||||||
|
The image model identifies physical parts and assembly membership. This
|
||||||
|
module turns verified geometric contacts into holes, grooves or weld seams.
|
||||||
|
It never infers a joint from a Chinese display name and never silently clamps
|
||||||
|
an invalid feature into the part. The output remains a manufacturing draft;
|
||||||
|
an independent B-rep/section inspection is still required before release.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from hardware_catalog import get_hardware_template
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = "dalongxia.manufacturing-feature-plan/1"
|
||||||
|
|
||||||
|
PANEL_ROLES = {
|
||||||
|
"top", "bottom", "side", "shelf", "door", "back", "drawer_front",
|
||||||
|
"drawer_left", "drawer_right", "drawer_back", "drawer_bottom",
|
||||||
|
"drawer_support", "divider",
|
||||||
|
}
|
||||||
|
CAPTURED_ROLES = {"back", "drawer_bottom"}
|
||||||
|
ALLOWED_MATERIALS = {"engineered_panel", "solid_wood", "metal", "plastic", "glass", "other"}
|
||||||
|
|
||||||
|
DEFAULT_POLICY = {
|
||||||
|
"default_material_family": "engineered_panel",
|
||||||
|
"assembly_mode": "knock_down",
|
||||||
|
"engineered_panel_joint": "confirmat",
|
||||||
|
"solid_wood_joint": "wood_screw",
|
||||||
|
"mixed_joint": "bolt",
|
||||||
|
"metal_joint": "weld",
|
||||||
|
"shelf_joint": "shelf_pin",
|
||||||
|
"captured_panel_joint": "captured_groove",
|
||||||
|
"max_connector_spacing_mm": 240.0,
|
||||||
|
"connector_edge_offset_mm": 45.0,
|
||||||
|
"groove_clearance_mm": 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _finite_positive(value, label: str) -> float:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
|
||||||
|
raise ValueError(f"{label}必须为有限正数")
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _axis_index(face: str) -> int:
|
||||||
|
if not isinstance(face, str) or len(face) != 2 or face[0] not in "xyz" or face[1] not in "+-":
|
||||||
|
raise ValueError("连接面必须为x-/x+/y-/y+/z-/z+")
|
||||||
|
return "xyz".index(face[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _face_plane(instance: dict, face: str) -> float:
|
||||||
|
axis = _axis_index(face)
|
||||||
|
return float(instance["position_mm"][axis]) + (float(instance["size_mm"][axis]) if face[1] == "+" else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _inside_center(instance: dict, face: str, depth: float) -> list[float]:
|
||||||
|
axis = _axis_index(face)
|
||||||
|
center = [float(instance["position_mm"][i]) + float(instance["size_mm"][i]) / 2.0 for i in range(3)]
|
||||||
|
plane = _face_plane(instance, face)
|
||||||
|
center[axis] = plane - depth / 2.0 if face[1] == "+" else plane + depth / 2.0
|
||||||
|
return center
|
||||||
|
|
||||||
|
|
||||||
|
def _local_center(instance: dict, world_center: list[float]) -> dict:
|
||||||
|
axis_order = list(instance.get("axis_order") or [0, 1, 2])
|
||||||
|
local = [0.0, 0.0, 0.0]
|
||||||
|
for product_axis in range(3):
|
||||||
|
local_axis = axis_order[product_axis]
|
||||||
|
local[local_axis] = float(world_center[product_axis]) - float(instance["position_mm"][product_axis])
|
||||||
|
return {"x": round(local[0], 4), "y": round(local[1], 4), "z": round(local[2], 4)}
|
||||||
|
|
||||||
|
|
||||||
|
def _world_dict(values: list[float]) -> dict:
|
||||||
|
return {axis: round(float(values[index]), 4) for index, axis in enumerate("xyz")}
|
||||||
|
|
||||||
|
|
||||||
|
def _overlap_ranges(a: dict, b: dict, normal_axis: int) -> dict[int, tuple[float, float]]:
|
||||||
|
ranges = {}
|
||||||
|
for axis in range(3):
|
||||||
|
if axis == normal_axis:
|
||||||
|
continue
|
||||||
|
low = max(float(a["position_mm"][axis]), float(b["position_mm"][axis]))
|
||||||
|
high = min(
|
||||||
|
float(a["position_mm"][axis]) + float(a["size_mm"][axis]),
|
||||||
|
float(b["position_mm"][axis]) + float(b["size_mm"][axis]),
|
||||||
|
)
|
||||||
|
ranges[axis] = (low, high)
|
||||||
|
return ranges
|
||||||
|
|
||||||
|
|
||||||
|
def _line_points(
|
||||||
|
a: dict,
|
||||||
|
b: dict,
|
||||||
|
normal_axis: int,
|
||||||
|
policy: dict,
|
||||||
|
*,
|
||||||
|
face_a: str,
|
||||||
|
face_b: str,
|
||||||
|
fixed_count: int | None = None,
|
||||||
|
) -> list[list[float]]:
|
||||||
|
ranges = _overlap_ranges(a, b, normal_axis)
|
||||||
|
if any(high - low <= 0.01 for low, high in ranges.values()):
|
||||||
|
return []
|
||||||
|
run_axis = max(ranges, key=lambda axis: ranges[axis][1] - ranges[axis][0])
|
||||||
|
cross_axis = next(axis for axis in ranges if axis != run_axis)
|
||||||
|
low, high = ranges[run_axis]
|
||||||
|
length = high - low
|
||||||
|
edge = min(float(policy["connector_edge_offset_mm"]), max(0.0, length / 3.0))
|
||||||
|
usable = max(0.0, length - 2 * edge)
|
||||||
|
if fixed_count is not None:
|
||||||
|
count = max(1, int(fixed_count))
|
||||||
|
else:
|
||||||
|
spacing = _finite_positive(policy["max_connector_spacing_mm"], "最大连接件间距")
|
||||||
|
count = max(2, int(math.ceil(usable / spacing)) + 1)
|
||||||
|
positions = [low + length / 2.0] if count == 1 else [low + edge + usable * i / (count - 1) for i in range(count)]
|
||||||
|
plane = (_face_plane(a, face_a) + _face_plane(b, face_b)) / 2.0
|
||||||
|
points = []
|
||||||
|
for coordinate in positions:
|
||||||
|
point = [0.0, 0.0, 0.0]
|
||||||
|
point[normal_axis] = plane
|
||||||
|
point[run_axis] = coordinate
|
||||||
|
point[cross_axis] = sum(ranges[cross_axis]) / 2.0
|
||||||
|
points.append(point)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_hole(instance: dict, feature: dict) -> list[str]:
|
||||||
|
messages = []
|
||||||
|
axis = "xyz".index(feature["axis"])
|
||||||
|
center = [feature["world_center_mm"][name] for name in "xyz"]
|
||||||
|
radius = float(feature["diameter_mm"]) / 2.0
|
||||||
|
depth = float(feature["depth_mm"])
|
||||||
|
for tangent in range(3):
|
||||||
|
low = float(instance["position_mm"][tangent])
|
||||||
|
high = low + float(instance["size_mm"][tangent])
|
||||||
|
margin = 0.0 if tangent == axis else radius
|
||||||
|
if center[tangent] - margin < low - 0.01 or center[tangent] + margin > high + 0.01:
|
||||||
|
messages.append(f"孔圆周超出零件{'xyz'[tangent].upper()}轴边界")
|
||||||
|
if depth > float(instance["size_mm"][axis]) + 0.01:
|
||||||
|
messages.append("孔深超过零件厚度/轴向尺寸")
|
||||||
|
expected = _inside_center(instance, feature["face"], depth)
|
||||||
|
if abs(center[axis] - expected[axis]) > 0.05:
|
||||||
|
messages.append("孔中心与声明加工面、孔深不一致")
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def _method_for(contact: dict, instances: dict, policy: dict) -> str:
|
||||||
|
a, b = instances[contact["a"]], instances[contact["b"]]
|
||||||
|
roles = {a.get("functional_role"), b.get("functional_role")}
|
||||||
|
materials = {a.get("material_family"), b.get("material_family")}
|
||||||
|
if roles & CAPTURED_ROLES:
|
||||||
|
# A back/drawer-bottom is captured only when *its edge* enters the
|
||||||
|
# broad face of another panel. When a shelf/divider edge merely
|
||||||
|
# terminates against the broad face of a back, that contact does not
|
||||||
|
# imply another groove, screw or machining charge.
|
||||||
|
if contact.get("interface_type") == "edge_to_face":
|
||||||
|
edge = instances.get(str(contact.get("edge_instance_id") or ""))
|
||||||
|
broad = instances.get(str(contact.get("broad_face_instance_id") or ""))
|
||||||
|
if edge and edge.get("functional_role") in CAPTURED_ROLES:
|
||||||
|
return str(policy["captured_panel_joint"])
|
||||||
|
if (
|
||||||
|
broad and broad.get("functional_role") in CAPTURED_ROLES
|
||||||
|
and edge and edge.get("functional_role") in {"shelf", "divider"}
|
||||||
|
):
|
||||||
|
return "non_structural_abutment"
|
||||||
|
return str(policy["captured_panel_joint"])
|
||||||
|
if "shelf" in roles:
|
||||||
|
return str(policy["shelf_joint"])
|
||||||
|
if materials == {"metal"}:
|
||||||
|
return str(policy["metal_joint"])
|
||||||
|
if "metal" in materials:
|
||||||
|
return str(policy["mixed_joint"])
|
||||||
|
if "engineered_panel" in materials:
|
||||||
|
return str(policy["engineered_panel_joint"])
|
||||||
|
if materials == {"solid_wood"}:
|
||||||
|
return str(policy["solid_wood_joint"])
|
||||||
|
return "unresolved"
|
||||||
|
|
||||||
|
|
||||||
|
def plan_manufacturing_features(compiled: dict, *, policy: dict | None = None) -> dict:
|
||||||
|
"""Return a deterministic manufacturing draft from a compiled graph."""
|
||||||
|
active_policy = {**DEFAULT_POLICY, **dict(policy or {})}
|
||||||
|
issues: list[dict] = []
|
||||||
|
|
||||||
|
def issue(code: str, detail: str, **context) -> None:
|
||||||
|
issues.append({"code": code, "detail": detail, "severity": "blocking", **context})
|
||||||
|
|
||||||
|
if not isinstance(compiled, dict) or not compiled.get("ok"):
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"ok": False,
|
||||||
|
"release_status": "blocked_by_assembly_graph",
|
||||||
|
"issues": [{"code": "assembly_graph_not_valid", "detail": "装配图谱未通过,禁止生成孔槽", "severity": "blocking"}],
|
||||||
|
"features_by_instance": {}, "connection_plans": [], "hardware_lines": [],
|
||||||
|
}
|
||||||
|
default_material = str(active_policy.get("default_material_family") or "")
|
||||||
|
if default_material not in ALLOWED_MATERIALS:
|
||||||
|
raise ValueError("默认材料族无效")
|
||||||
|
instances = {str(row["id"]): dict(row) for row in compiled.get("instances") or []}
|
||||||
|
for row in instances.values():
|
||||||
|
material = str(row.get("material_family") or default_material)
|
||||||
|
if material not in ALLOWED_MATERIALS:
|
||||||
|
issue("material_family", "实例材料族无效", instance_id=row["id"], material_family=material)
|
||||||
|
row["material_family"] = material
|
||||||
|
features = {key: {"holes": [], "grooves": [], "welds": []} for key in instances}
|
||||||
|
# The assembly graph describes visible/net contact geometry. A captured
|
||||||
|
# back or drawer bottom must extend into the receiver after its groove is
|
||||||
|
# cut. Keep this manufacturing geometry separate and auditable instead
|
||||||
|
# of either leaving the insert at the groove mouth or hiding a positive
|
||||||
|
# AABB overlap in the source graph.
|
||||||
|
manufacturing_geometry = {
|
||||||
|
key: {
|
||||||
|
"instance_id": key,
|
||||||
|
"part_id": row.get("part_id"),
|
||||||
|
"axis_order": list(row.get("axis_order") or [0, 1, 2]),
|
||||||
|
"position_mm": [float(value) for value in row["position_mm"]],
|
||||||
|
"size_mm": [float(value) for value in row["size_mm"]],
|
||||||
|
"capture_allowances": [],
|
||||||
|
}
|
||||||
|
for key, row in instances.items()
|
||||||
|
if row.get("functional_role") != "hardware"
|
||||||
|
}
|
||||||
|
connection_plans = []
|
||||||
|
hardware = Counter()
|
||||||
|
hardware_template_warnings = []
|
||||||
|
hardware_names = {
|
||||||
|
"confirmat": ("确认螺钉", "7×50mm"),
|
||||||
|
"wood_screw": ("木螺钉", "4×40mm"),
|
||||||
|
"shelf_pin": ("层板托", "5mm"),
|
||||||
|
"bolt": ("连接螺栓", "M6"),
|
||||||
|
}
|
||||||
|
sequence = 0
|
||||||
|
|
||||||
|
def add_hole(instance: dict, *, point: list[float], face: str, diameter: float, depth: float,
|
||||||
|
connection_id: str, hardware_id: str, hole_type: str,
|
||||||
|
source: str = "verified_geometric_contact") -> None:
|
||||||
|
nonlocal sequence
|
||||||
|
sequence += 1
|
||||||
|
axis_index = _axis_index(face)
|
||||||
|
center = _inside_center(instance, face, depth)
|
||||||
|
for tangent in range(3):
|
||||||
|
if tangent != axis_index:
|
||||||
|
center[tangent] = point[tangent]
|
||||||
|
feature = {
|
||||||
|
"feature_id": f"HOLE-{sequence:05d}", "kind": "hole",
|
||||||
|
"manufacturing_connection_id": connection_id, "hardware_instance_id": hardware_id,
|
||||||
|
"instance_id": instance["id"], "axis": "xyz"[axis_index], "face": face,
|
||||||
|
"world_center_mm": _world_dict(center), "local_center_mm": _local_center(instance, center),
|
||||||
|
"diameter_mm": round(float(diameter), 3), "depth_mm": round(float(depth), 3),
|
||||||
|
"hole_type": hole_type, "source": source,
|
||||||
|
}
|
||||||
|
errors = _validate_hole(instance, feature)
|
||||||
|
feature["validation_status"] = "blocked" if errors else "valid"
|
||||||
|
if errors:
|
||||||
|
issue("hole_geometry", ";".join(errors), instance_id=instance["id"], feature_id=feature["feature_id"])
|
||||||
|
features[instance["id"]]["holes"].append(feature)
|
||||||
|
|
||||||
|
# Purchased fittings have their own selected drilling template. Count
|
||||||
|
# each physical fitting once, machine only its hosts, and never put the
|
||||||
|
# fitting envelope into the board/material cut list.
|
||||||
|
mountings_by_hardware: dict[str, dict[str, dict]] = {}
|
||||||
|
for row in compiled.get("mounting_audit") or []:
|
||||||
|
if row.get("template_geometry"):
|
||||||
|
mountings_by_hardware.setdefault(str(row["instance_id"]), {})[str(row["slot"])] = row
|
||||||
|
for hardware_id, slots in sorted(mountings_by_hardware.items()):
|
||||||
|
fitting = instances.get(hardware_id)
|
||||||
|
if not fitting:
|
||||||
|
issue("hardware_template_instance", "五金模板引用了不存在的实例", hardware_instance_id=hardware_id)
|
||||||
|
continue
|
||||||
|
template_id = str(fitting.get("hardware_template_id") or "")
|
||||||
|
template = get_hardware_template(template_id)
|
||||||
|
if not template or template.get("family") != fitting.get("hardware_family"):
|
||||||
|
issue(
|
||||||
|
"hardware_template_instance", "编译后的五金实例没有匹配模板",
|
||||||
|
hardware_instance_id=hardware_id, hardware_template_id=template_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if set(slots) != set(template["mounting_slots"]):
|
||||||
|
issue(
|
||||||
|
"hardware_template_mountings", "五金模板的宿主安装槽不完整",
|
||||||
|
hardware_instance_id=hardware_id, actual_slots=sorted(slots),
|
||||||
|
required_slots=sorted(template["mounting_slots"]),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if template.get("approval_status") != "company_approved":
|
||||||
|
hardware_template_warnings.append({
|
||||||
|
"code": "hardware_template_not_company_approved",
|
||||||
|
"hardware_instance_id": hardware_id,
|
||||||
|
"hardware_template_id": template_id,
|
||||||
|
"approval_status": template.get("approval_status"),
|
||||||
|
"detail": "孔型来自公开参考模板;投产前必须映射到公司实际采购五金SKU并批准",
|
||||||
|
})
|
||||||
|
hardware[template_id] += 1
|
||||||
|
hardware_names[template_id] = (template["display_name"], template_id)
|
||||||
|
generated_before = sequence
|
||||||
|
if template["family"] == "concealed_cup_hinge":
|
||||||
|
manufacturing = template["manufacturing"]
|
||||||
|
hardware_axis = "xyz".index(str(fitting.get("hardware_axis") or ""))
|
||||||
|
cup = slots["cup"]
|
||||||
|
cup_host = instances[cup["host_instance_id"]]
|
||||||
|
cup_point = [float(value) for value in cup["mounting_center_mm"]]
|
||||||
|
cup_connection = f"mount:{hardware_id}:cup"
|
||||||
|
add_hole(
|
||||||
|
cup_host, point=cup_point, face=cup["face"],
|
||||||
|
diameter=float(manufacturing["cup_diameter_mm"]),
|
||||||
|
depth=float(manufacturing["cup_depth_mm"]),
|
||||||
|
connection_id=cup_connection, hardware_id=hardware_id,
|
||||||
|
hole_type="hinge_cup", source="selected_hardware_template",
|
||||||
|
)
|
||||||
|
for sign in (-1.0, 1.0):
|
||||||
|
point = list(cup_point)
|
||||||
|
point[hardware_axis] += sign * float(manufacturing["cup_fixing_spacing_mm"]) / 2.0
|
||||||
|
add_hole(
|
||||||
|
cup_host, point=point, face=cup["face"],
|
||||||
|
diameter=float(manufacturing["cup_fixing_diameter_mm"]),
|
||||||
|
depth=min(float(manufacturing["cup_fixing_depth_mm"]), float(cup_host["size_mm"][_axis_index(cup["face"])])),
|
||||||
|
connection_id=cup_connection, hardware_id=hardware_id,
|
||||||
|
hole_type="hinge_cup_fixing", source="selected_hardware_template",
|
||||||
|
)
|
||||||
|
plate = slots["plate"]
|
||||||
|
plate_host = instances[plate["host_instance_id"]]
|
||||||
|
plate_point = [float(value) for value in plate["mounting_center_mm"]]
|
||||||
|
plate_connection = f"mount:{hardware_id}:plate"
|
||||||
|
for sign in (-1.0, 1.0):
|
||||||
|
point = list(plate_point)
|
||||||
|
point[hardware_axis] += sign * float(manufacturing["plate_hole_spacing_mm"]) / 2.0
|
||||||
|
add_hole(
|
||||||
|
plate_host, point=point, face=plate["face"],
|
||||||
|
diameter=float(manufacturing["plate_hole_diameter_mm"]),
|
||||||
|
depth=min(float(manufacturing["plate_hole_depth_mm"]), float(plate_host["size_mm"][_axis_index(plate["face"])])),
|
||||||
|
connection_id=plate_connection, hardware_id=hardware_id,
|
||||||
|
hole_type="hinge_plate_fixing", source="selected_hardware_template",
|
||||||
|
)
|
||||||
|
elif template["family"] == "knob":
|
||||||
|
body = slots["body"]
|
||||||
|
host = instances[body["host_instance_id"]]
|
||||||
|
normal_axis = _axis_index(body["face"])
|
||||||
|
connection_id = f"mount:{hardware_id}:body"
|
||||||
|
add_hole(
|
||||||
|
host, point=[float(value) for value in body["mounting_center_mm"]],
|
||||||
|
face=body["face"],
|
||||||
|
diameter=float(template["manufacturing"]["through_hole_diameter_mm"]),
|
||||||
|
depth=float(host["size_mm"][normal_axis]),
|
||||||
|
connection_id=connection_id, hardware_id=hardware_id,
|
||||||
|
hole_type="handle_fastener_through", source="selected_hardware_template",
|
||||||
|
)
|
||||||
|
generated_ids = [
|
||||||
|
feature["feature_id"]
|
||||||
|
for rows in features.values() for feature in rows["holes"]
|
||||||
|
if feature.get("hardware_instance_id") == hardware_id
|
||||||
|
]
|
||||||
|
connection_plans.append({
|
||||||
|
"connection_id": f"hardware-template:{hardware_id}",
|
||||||
|
"method": "selected_hardware_template",
|
||||||
|
"hardware_instance_ids": [hardware_id],
|
||||||
|
"hardware_template_id": template_id,
|
||||||
|
"feature_ids": generated_ids,
|
||||||
|
"feature_count": sequence - generated_before,
|
||||||
|
"source": "verified_template_mounting_slots",
|
||||||
|
})
|
||||||
|
|
||||||
|
for contact in compiled.get("derived_contacts") or []:
|
||||||
|
a, b = instances.get(str(contact.get("a"))), instances.get(str(contact.get("b")))
|
||||||
|
if not a or not b:
|
||||||
|
issue("contact_target", "制造接触引用了不存在的实例", contact_id=contact.get("id"))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
normal_axis = _axis_index(str(contact.get("face_a") or ""))
|
||||||
|
except ValueError as exc:
|
||||||
|
issue("contact_face", str(exc), contact_id=contact.get("id"))
|
||||||
|
continue
|
||||||
|
method = _method_for(contact, instances, active_policy)
|
||||||
|
connection_id = str(contact.get("id") or f"contact:{a['id']}:{b['id']}")
|
||||||
|
plan = {"connection_id": connection_id, "a": a["id"], "b": b["id"], "method": method,
|
||||||
|
"interface_type": contact.get("interface_type"), "feature_ids": [], "hardware_instance_ids": []}
|
||||||
|
if method == "mortise_tenon" and "engineered_panel" in {a["material_family"], b["material_family"]}:
|
||||||
|
issue("material_process_incompatible", "人造板接触不得使用传统承力榫卯", contact_id=connection_id)
|
||||||
|
connection_plans.append(plan)
|
||||||
|
continue
|
||||||
|
if method == "unresolved" or contact.get("interface_type") == "edge_to_edge" and method not in {"weld"}:
|
||||||
|
issue("joint_method_unresolved", "该接触界面没有可靠的量产连接方式", contact_id=connection_id,
|
||||||
|
interface_type=contact.get("interface_type"), material_families=[a["material_family"], b["material_family"]])
|
||||||
|
connection_plans.append(plan)
|
||||||
|
continue
|
||||||
|
if method == "non_structural_abutment":
|
||||||
|
plan["feature_source"] = "verified_contact_requires_no_independent_connector"
|
||||||
|
connection_plans.append(plan)
|
||||||
|
continue
|
||||||
|
if method == "captured_groove":
|
||||||
|
insert = a if a.get("functional_role") in CAPTURED_ROLES else b if b.get("functional_role") in CAPTURED_ROLES else None
|
||||||
|
receiver_id = contact.get("broad_face_instance_id")
|
||||||
|
receiver = instances.get(str(receiver_id)) if receiver_id else None
|
||||||
|
if not insert or not receiver or insert["id"] == receiver["id"]:
|
||||||
|
issue("groove_receiver_unresolved", "嵌入薄板接触无法确定开槽承载件", contact_id=connection_id)
|
||||||
|
connection_plans.append(plan)
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
contact.get("interface_type") != "edge_to_face"
|
||||||
|
or str(contact.get("edge_instance_id") or "") != insert["id"]
|
||||||
|
):
|
||||||
|
issue(
|
||||||
|
"captured_interface_incompatible",
|
||||||
|
"嵌入薄板必须以板边进入承载件宽面;宽面对宽面接触不能自动生成捕获槽",
|
||||||
|
contact_id=connection_id,
|
||||||
|
insert_instance_id=insert["id"],
|
||||||
|
interface_type=contact.get("interface_type"),
|
||||||
|
)
|
||||||
|
connection_plans.append(plan)
|
||||||
|
continue
|
||||||
|
insert_thickness_axis = insert.get("thickness_axis")
|
||||||
|
if insert_thickness_axis not in "xyz":
|
||||||
|
issue("insert_thickness_axis", "嵌入板未声明产品坐标厚度轴", contact_id=connection_id)
|
||||||
|
connection_plans.append(plan)
|
||||||
|
continue
|
||||||
|
insert_thickness = float(insert["size_mm"]["xyz".index(insert_thickness_axis)])
|
||||||
|
clearance = float(active_policy["groove_clearance_mm"])
|
||||||
|
receiver_face = contact["face_a"] if contact["a"] == receiver["id"] else contact["face_b"]
|
||||||
|
depth = min(6.0, float(receiver["size_mm"][normal_axis]) * 0.4)
|
||||||
|
ranges = _overlap_ranges(a, b, normal_axis)
|
||||||
|
run_axis = max(ranges, key=lambda axis: ranges[axis][1] - ranges[axis][0])
|
||||||
|
cross_axis = next(axis for axis in ranges if axis != run_axis)
|
||||||
|
world_size = [depth if axis == normal_axis else 0.0 for axis in range(3)]
|
||||||
|
world_size[run_axis] = ranges[run_axis][1] - ranges[run_axis][0]
|
||||||
|
world_size[cross_axis] = insert_thickness + clearance
|
||||||
|
center = _inside_center(receiver, receiver_face, depth)
|
||||||
|
center[run_axis] = sum(ranges[run_axis]) / 2.0
|
||||||
|
center[cross_axis] = float(insert["position_mm"][cross_axis]) + float(insert["size_mm"][cross_axis]) / 2.0
|
||||||
|
sequence += 1
|
||||||
|
groove = {
|
||||||
|
"feature_id": f"GROOVE-{sequence:05d}", "kind": "groove",
|
||||||
|
"manufacturing_connection_id": connection_id, "instance_id": receiver["id"],
|
||||||
|
"face": receiver_face, "world_center_mm": _world_dict(center),
|
||||||
|
"world_size_mm": _world_dict(world_size), "insert_instance_id": insert["id"],
|
||||||
|
"insert_thickness_mm": round(insert_thickness, 3), "clearance_mm": round(clearance, 3),
|
||||||
|
"source": "verified_geometric_contact", "validation_status": "valid",
|
||||||
|
}
|
||||||
|
receiver_low = receiver["position_mm"]
|
||||||
|
for axis in range(3):
|
||||||
|
if center[axis] - world_size[axis] / 2 < receiver_low[axis] - 0.01 or center[axis] + world_size[axis] / 2 > receiver_low[axis] + receiver["size_mm"][axis] + 0.01:
|
||||||
|
groove["validation_status"] = "blocked"
|
||||||
|
issue("groove_geometry", "槽体超出承载零件边界", contact_id=connection_id, instance_id=receiver["id"])
|
||||||
|
break
|
||||||
|
features[receiver["id"]]["grooves"].append(groove)
|
||||||
|
plan["feature_ids"].append(groove["feature_id"])
|
||||||
|
insert_face = contact["face_a"] if contact["a"] == insert["id"] else contact["face_b"]
|
||||||
|
insert_axis = _axis_index(insert_face)
|
||||||
|
adjusted = manufacturing_geometry[insert["id"]]
|
||||||
|
if insert_face[1] == "-":
|
||||||
|
adjusted["position_mm"][insert_axis] -= depth
|
||||||
|
adjusted["size_mm"][insert_axis] += depth
|
||||||
|
allowance = {
|
||||||
|
"connection_id": connection_id,
|
||||||
|
"axis": "xyz"[insert_axis],
|
||||||
|
"face": insert_face,
|
||||||
|
"extension_mm": round(depth, 4),
|
||||||
|
"receiver_instance_id": receiver["id"],
|
||||||
|
"groove_feature_id": groove["feature_id"],
|
||||||
|
}
|
||||||
|
adjusted["capture_allowances"].append(allowance)
|
||||||
|
plan["insert_allowance"] = allowance
|
||||||
|
elif method == "weld":
|
||||||
|
ranges = _overlap_ranges(a, b, normal_axis)
|
||||||
|
length = max(high - low for low, high in ranges.values())
|
||||||
|
sequence += 1
|
||||||
|
weld = {"feature_id": f"WELD-{sequence:05d}", "kind": "weld_seam",
|
||||||
|
"manufacturing_connection_id": connection_id, "length_mm": round(length, 3),
|
||||||
|
"process": "fillet_weld", "source": "verified_metal_contact"}
|
||||||
|
features[a["id"]]["welds"].append(weld)
|
||||||
|
plan["feature_ids"].append(weld["feature_id"])
|
||||||
|
else:
|
||||||
|
if contact.get("interface_type") != "edge_to_face":
|
||||||
|
issue("connector_interface", "螺钉/层板托/螺栓模板只允许边对面接触", contact_id=connection_id,
|
||||||
|
interface_type=contact.get("interface_type"))
|
||||||
|
connection_plans.append(plan)
|
||||||
|
continue
|
||||||
|
broad = instances[str(contact["broad_face_instance_id"])]
|
||||||
|
edge = instances[str(contact["edge_instance_id"])]
|
||||||
|
broad_face = contact["face_a"] if contact["a"] == broad["id"] else contact["face_b"]
|
||||||
|
edge_face = contact["face_a"] if contact["a"] == edge["id"] else contact["face_b"]
|
||||||
|
fixed_count = 2 if method == "shelf_pin" else None
|
||||||
|
points = _line_points(
|
||||||
|
a, b, normal_axis, active_policy,
|
||||||
|
face_a=contact["face_a"], face_b=contact["face_b"], fixed_count=fixed_count,
|
||||||
|
)
|
||||||
|
if not points:
|
||||||
|
issue("connector_span", "接触面没有足够的正面积布置连接件", contact_id=connection_id)
|
||||||
|
connection_plans.append(plan)
|
||||||
|
continue
|
||||||
|
diameter = 5.0 if method == "shelf_pin" else 6.0 if method == "bolt" else 5.0 if method == "confirmat" else 3.0
|
||||||
|
for ordinal, point in enumerate(points, 1):
|
||||||
|
hardware_id = f"HW:{connection_id}:{ordinal}"
|
||||||
|
broad_depth = float(broad["size_mm"][normal_axis])
|
||||||
|
add_hole(broad, point=point, face=broad_face, diameter=diameter, depth=broad_depth,
|
||||||
|
connection_id=connection_id, hardware_id=hardware_id, hole_type="through")
|
||||||
|
if method != "shelf_pin":
|
||||||
|
edge_depth = min(40.0 if method == "confirmat" else 30.0, float(edge["size_mm"][normal_axis]) - 1.0)
|
||||||
|
if edge_depth <= 0:
|
||||||
|
issue("connector_depth", "边部件轴向尺寸不足以容纳盲孔", contact_id=connection_id, instance_id=edge["id"])
|
||||||
|
else:
|
||||||
|
add_hole(edge, point=point, face=edge_face, diameter=max(2.5, diameter - 1.0), depth=edge_depth,
|
||||||
|
connection_id=connection_id, hardware_id=hardware_id, hole_type="blind")
|
||||||
|
hardware[method] += 1
|
||||||
|
plan["hardware_instance_ids"].append(hardware_id)
|
||||||
|
plan["feature_ids"] = [
|
||||||
|
row["feature_id"] for instance_features in features.values()
|
||||||
|
for row in instance_features["holes"] + instance_features["grooves"] + instance_features["welds"]
|
||||||
|
if row.get("manufacturing_connection_id") == connection_id
|
||||||
|
]
|
||||||
|
connection_plans.append(plan)
|
||||||
|
|
||||||
|
# Capture allowances accumulate in more than one axis. For example, a
|
||||||
|
# back captured by the two sides is wider than its net visible width when
|
||||||
|
# it subsequently enters the top/bottom grooves. Refit every groove to
|
||||||
|
# the *final* manufacturing insert geometry so the cut fully clears the
|
||||||
|
# same body exported to STEP. This is a second pass deliberately: the
|
||||||
|
# result must not depend on derived-contact iteration order.
|
||||||
|
for receiver_id, instance_features in features.items():
|
||||||
|
receiver = instances[receiver_id]
|
||||||
|
receiver_low = [float(value) for value in receiver["position_mm"]]
|
||||||
|
receiver_high = [
|
||||||
|
receiver_low[axis] + float(receiver["size_mm"][axis]) for axis in range(3)
|
||||||
|
]
|
||||||
|
for groove in instance_features["grooves"]:
|
||||||
|
if groove.get("validation_status") != "valid":
|
||||||
|
continue
|
||||||
|
insert = instances.get(str(groove.get("insert_instance_id") or ""))
|
||||||
|
adjusted = manufacturing_geometry.get(str(groove.get("insert_instance_id") or ""))
|
||||||
|
if not insert or not adjusted:
|
||||||
|
groove["validation_status"] = "blocked"
|
||||||
|
issue(
|
||||||
|
"groove_insert_geometry",
|
||||||
|
"槽口无法取得最终嵌入板制造尺寸",
|
||||||
|
instance_id=receiver_id,
|
||||||
|
feature_id=groove["feature_id"],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
normal_axis = _axis_index(str(groove["face"]))
|
||||||
|
thickness_axis = "xyz".index(str(insert["thickness_axis"]))
|
||||||
|
center = [float(groove["world_center_mm"][name]) for name in "xyz"]
|
||||||
|
size = [float(groove["world_size_mm"][name]) for name in "xyz"]
|
||||||
|
insert_low = [float(value) for value in adjusted["position_mm"]]
|
||||||
|
insert_high = [
|
||||||
|
insert_low[axis] + float(adjusted["size_mm"][axis]) for axis in range(3)
|
||||||
|
]
|
||||||
|
clearance = float(groove.get("clearance_mm") or 0.0)
|
||||||
|
valid = True
|
||||||
|
for axis in range(3):
|
||||||
|
if axis == normal_axis:
|
||||||
|
continue
|
||||||
|
extra = clearance / 2.0 if axis == thickness_axis else 0.0
|
||||||
|
low = max(receiver_low[axis], insert_low[axis] - extra)
|
||||||
|
high = min(receiver_high[axis], insert_high[axis] + extra)
|
||||||
|
if high - low <= 0.01:
|
||||||
|
valid = False
|
||||||
|
break
|
||||||
|
center[axis] = (low + high) / 2.0
|
||||||
|
size[axis] = high - low
|
||||||
|
if not valid:
|
||||||
|
groove["validation_status"] = "blocked"
|
||||||
|
issue(
|
||||||
|
"groove_final_coverage",
|
||||||
|
"最终嵌入板与承载件没有可加工的槽口交集",
|
||||||
|
instance_id=receiver_id,
|
||||||
|
feature_id=groove["feature_id"],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
groove["world_center_mm"] = _world_dict(center)
|
||||||
|
groove["world_size_mm"] = _world_dict(size)
|
||||||
|
groove["coverage_source"] = "final_insert_geometry_after_all_capture_allowances"
|
||||||
|
|
||||||
|
# A duplicated operation would be machined and charged twice. Detect it
|
||||||
|
# by geometry and instance, independent of feature names.
|
||||||
|
for instance_id, rows in features.items():
|
||||||
|
seen = {}
|
||||||
|
for feature in rows["holes"]:
|
||||||
|
signature = (feature["axis"], tuple(feature["world_center_mm"][a] for a in "xyz"), feature["diameter_mm"], feature["depth_mm"])
|
||||||
|
if signature in seen:
|
||||||
|
issue("duplicate_feature", "同一孔位被重复生成,禁止重复加工和计费", instance_id=instance_id,
|
||||||
|
feature_ids=[seen[signature], feature["feature_id"]])
|
||||||
|
seen[signature] = feature["feature_id"]
|
||||||
|
|
||||||
|
hardware_lines = [
|
||||||
|
{"hardware_code": method, "name": hardware_names[method][0], "specification": hardware_names[method][1],
|
||||||
|
"quantity": quantity, "unit": "个", "quantity_source": "unique_hardware_instance_id"}
|
||||||
|
for method, quantity in sorted(hardware.items()) if quantity and method in hardware_names
|
||||||
|
]
|
||||||
|
hole_count = sum(len(row["holes"]) for row in features.values())
|
||||||
|
groove_count = sum(len(row["grooves"]) for row in features.values())
|
||||||
|
weld_length = sum(float(feature["length_mm"]) for row in features.values() for feature in row["welds"])
|
||||||
|
bom_names = {str(row.get("part_id")): str(row.get("name") or row.get("part_id")) for row in compiled.get("bom") or []}
|
||||||
|
manufacturing_bom_groups: dict[tuple, dict] = {}
|
||||||
|
for instance_id, geometry in manufacturing_geometry.items():
|
||||||
|
axis_order = geometry["axis_order"]
|
||||||
|
local_size = [0.0, 0.0, 0.0]
|
||||||
|
for product_axis, local_axis in enumerate(axis_order):
|
||||||
|
local_size[local_axis] = float(geometry["size_mm"][product_axis])
|
||||||
|
local_size = [round(value, 4) for value in local_size]
|
||||||
|
signature = (str(geometry.get("part_id") or ""), tuple(local_size))
|
||||||
|
if signature not in manufacturing_bom_groups:
|
||||||
|
part_id = signature[0]
|
||||||
|
manufacturing_bom_groups[signature] = {
|
||||||
|
"part_id": part_id,
|
||||||
|
"name": bom_names.get(part_id, part_id),
|
||||||
|
"manufacturing_size_mm": local_size,
|
||||||
|
"quantity": 0,
|
||||||
|
"instance_ids": [],
|
||||||
|
"size_source": "compiled_net_geometry_plus_verified_capture_allowances",
|
||||||
|
}
|
||||||
|
manufacturing_bom_groups[signature]["quantity"] += 1
|
||||||
|
manufacturing_bom_groups[signature]["instance_ids"].append(instance_id)
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"ok": not issues,
|
||||||
|
"release_status": (
|
||||||
|
"blocked" if issues else
|
||||||
|
"manufacturing_draft_unapproved_hardware" if hardware_template_warnings else
|
||||||
|
"manufacturing_draft"
|
||||||
|
),
|
||||||
|
"policy": active_policy,
|
||||||
|
"issues": issues,
|
||||||
|
"features_by_instance": features,
|
||||||
|
"manufacturing_geometry_by_instance": manufacturing_geometry,
|
||||||
|
"manufacturing_bom": list(manufacturing_bom_groups.values()),
|
||||||
|
"connection_plans": connection_plans,
|
||||||
|
"hardware_lines": hardware_lines,
|
||||||
|
"hardware_template_warnings": hardware_template_warnings,
|
||||||
|
"cost_drivers": {
|
||||||
|
"hole_operations": hole_count,
|
||||||
|
"groove_operations": groove_count,
|
||||||
|
"weld_length_m": round(weld_length / 1000.0, 4),
|
||||||
|
"hardware_instances": sum(hardware.values()),
|
||||||
|
"hardware_template_instances": sum(
|
||||||
|
quantity for code, quantity in hardware.items()
|
||||||
|
if get_hardware_template(code)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"validation_required": ["brep_boolean", "section_oracle", "assembly_interference", "visual_source_comparison"],
|
||||||
|
}
|
||||||
2551
server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py
Normal file
2551
server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,109 @@
|
|||||||
|
# 家具生产工艺图与材料连接规则
|
||||||
|
|
||||||
|
## 目标与边界
|
||||||
|
|
||||||
|
这一子系统把已确认的材料、板厚、BOM角色、五金模板与CAD几何统一成一份制造特征图。估价CAD不等于已发布生产图;只有完成硬校验和结构工程师签批后才可下料。
|
||||||
|
|
||||||
|
## 材料到连接工艺的硬约束
|
||||||
|
|
||||||
|
| 材料类型 | 允许的主连接 | 禁止/限制 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 纯实木 | 传统榫卯、定位木销、木螺钉、五金连接 | 传统榫卯必须明确榫头/榫眼尺寸、纤维方向和配合公差;仅有图像疑似不能发布 |
|
||||||
|
| 密度板/刨花板/实木多层板 | 确认螺钉、三合一、定位木销、五金件、背板/抽底槽 | 禁止传统承力榫卯;木销只是定位件,不得在图纸或费用中标为榫卯 |
|
||||||
|
| 金属 | 焊接、螺栓、螺母、铆钉、铆螺母 | 不生成木工榫卯;焊缝未有符号、焊脚/焊透尺寸、长度和夹具基准时只能出草案 |
|
||||||
|
| 木/板材+金属 | 预埋螺母、连接片、角码、穿透螺栓 | 材料界面不允许默认传统榫卯,必须标注金属件与孔位模板 |
|
||||||
|
|
||||||
|
输入中一旦出现不兼容工艺,系统必须将它放入 `rejected_incompatible_features`,对应数量和成本归零,不能默默保留。
|
||||||
|
|
||||||
|
背板、抽屉底板等捕获式薄板不能只在承载板上画槽。每个入槽接触必须同时生成:承载件槽深、槽宽/装配余量、薄板对应边的入槽延伸量,以及使用延伸后成品尺寸的制造BOM。组装CAD必须以该成品尺寸伸入已切槽实体;若仍有正体积干涉、薄板只停在槽口或槽宽小于板厚加余量,自动发布检查失败。
|
||||||
|
|
||||||
|
## 制造特征数据结构
|
||||||
|
|
||||||
|
每个零件使用“左-前-下角”为局部原点,单位固定为mm。不可用零件图片的像素坐标替代制造坐标。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"part_id": "SIDE-L-001",
|
||||||
|
"datum": "左-前-下角",
|
||||||
|
"dimensions_mm": {"x": 12, "y": 400, "z": 850},
|
||||||
|
"holes": [{
|
||||||
|
"feature_id": "HOLE-0001",
|
||||||
|
"face": "X-",
|
||||||
|
"axis": "x",
|
||||||
|
"center_mm": {"x": 6, "y": 100, "z": 12},
|
||||||
|
"diameter_mm": 4,
|
||||||
|
"depth_mm": 13,
|
||||||
|
"hole_type": "through",
|
||||||
|
"dimension_tolerance_mm": 0.10,
|
||||||
|
"position_tolerance_mm": 0.15,
|
||||||
|
"hardware_template_id": "DLX-CONFIRMAT-5X30-V1"
|
||||||
|
}],
|
||||||
|
"grooves": [{
|
||||||
|
"feature_id": "GROOVE-0002",
|
||||||
|
"center_mm": {"x": 6, "y": 390.5, "z": 425},
|
||||||
|
"size_mm": {"x": 3.5, "y": 3, "z": 834},
|
||||||
|
"dimension_tolerance_mm": 0.15,
|
||||||
|
"bottom_radius_max_mm": 1.5
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
生成坐标超界时可为了CAD预览记录安全显示坐标,但必须同时保留 `requested_center_mm`、标记 `validation_status=blocked`并阻断生产发布;不允许静默夹取坐标后冒充合格。
|
||||||
|
|
||||||
|
## 图纸交付与发布状态
|
||||||
|
|
||||||
|
- PDF:首页是发布状态与阻断项,后续每个零件一页,显示材料、数量、总尺寸、基准、主视图和孔槽表。
|
||||||
|
- DXF ZIP:每个零件一个R12 DXF,分 `OUTLINE` / `HOLE` / `GROOVE` / `DIMENSION` 图层。DXF用于交换,机台NC代码必须再经设备专用后处理器生成。
|
||||||
|
- JSON:包含完整制造特征、规则版本、阻断项和发布状态,是第三方界面判断是否显示“可下料”的唯一依据。
|
||||||
|
|
||||||
|
状态定义:
|
||||||
|
|
||||||
|
1. `drawing_draft`:可用于成本估算和技术评审,不得下料。
|
||||||
|
2. `production_ready`:自动材料、尺寸、孔槽、五金模板、BOM/CAD一致性检查通过,但仍待签批。
|
||||||
|
3. `production_released`:结构工程师签批后的不可变更版本,只有该状态允许工厂下料。
|
||||||
|
|
||||||
|
当前引擎交付的自动图包不伪造人工签批,因此默认 `release_status=drawing_draft`;当自动检查全部通过时另外返回 `automated_validation_status=production_ready`。
|
||||||
|
|
||||||
|
自动检查的最低顺序固定为:图谱尺寸/总成边界 → 材料与工艺兼容 → 孔槽边界 → 实际布尔切削 → 零件两两B-rep正体积干涉 → BOM/五金/工时唯一ID一致 → 正交截面与多视图复核。仅验证每个单体闭合,不能替代装配干涉检查;仅在JSON中存在孔槽,也不能替代切削后面数/体积或截面证据。
|
||||||
|
|
||||||
|
## Chat API 第三方前端支持
|
||||||
|
|
||||||
|
`response.files[]` 新增三个文件角色:
|
||||||
|
|
||||||
|
| artifact_role | MIME | 前端行为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `production_drawing_manifest` | `application/json` | 读取发布状态、阻断项、零件与孔槽;不平铺内部JSON |
|
||||||
|
| `production_drawing_pdf` | `application/pdf` | 显示“预览/下载生产图” |
|
||||||
|
| `production_drawing_dxf_bundle` | `application/zip` | 显示“下载DXF图包”,不尝试在对话中渲染为普通文本 |
|
||||||
|
|
||||||
|
前端必须按JSON中状态渲染:
|
||||||
|
|
||||||
|
- `release_status != production_released`:显示红色“未批准不得下料”,列出 `release_blockers`,不显示“发送工厂”。
|
||||||
|
- `automated_validation_status = production_ready`且待签批:显示黄色“自动检查通过,待工程审批”。
|
||||||
|
- `release_status = production_released`:显示绿色已发布标识,同时显示版本号、签批人和文件SHA-256。
|
||||||
|
|
||||||
|
第三方不得仅根据PDF/DXF文件存在就判定可生产。
|
||||||
|
|
||||||
|
## 正式依据
|
||||||
|
|
||||||
|
- 家具制图:QB/T 1338-2012
|
||||||
|
https://std.samr.gov.cn/hb/search/stdHBDetailed?id=8B1827F1BE7ABB19E05397BE0A0AB44A
|
||||||
|
- 木家具通用技术条件:GB/T 3324-2024
|
||||||
|
https://std.samr.gov.cn//gb/search/gbDetailed?id=25940C3CEF158A9AE06397BE0A0A525A
|
||||||
|
- 家具结构安全技术规范:GB 28008-2024
|
||||||
|
https://std.samr.gov.cn/gb/search/gbDetailed?id=1BD5B9878E612FCEE06397BE0A0A10CC
|
||||||
|
- 图纸幅面和格式:GB/T 14689-2008
|
||||||
|
https://std.samr.gov.cn/search/stdPage?q=GBT14689
|
||||||
|
- 机械制图 尺寸注法:GB/T 4458.4-2003
|
||||||
|
https://std.samr.gov.cn/gb/search/gbDetailed?id=cea9cUOkNBU%3D&mode=p
|
||||||
|
- 未注一般公差:GB/T 1804-2000
|
||||||
|
https://std.samr.gov.cn/gb/search/gbDetailed?id=71F772D783B2D3A7E05397BE0A0AB82A
|
||||||
|
- 焊缝符号:GB/T 324-2008
|
||||||
|
https://std.samr.gov.cn/gb/search/gbDetailedCNF?id=71F772D77B8DD3A7E05397BE0A0AB82A
|
||||||
|
- 焊接结构一般公差:GB/T 19804-2005
|
||||||
|
https://std.samr.gov.cn/gb/search/gbDetailed?id=71F772D7835FD3A7E05397BE0A0AB82A
|
||||||
|
- Blum官方目录说明BXF/CAD数据包含加工图、裁切尺寸和钻孔位置:
|
||||||
|
https://publications.blum.com/2022/catalogue/zh/600/
|
||||||
|
- Hettich System 32排钻模板和五金DXF/DWG下载页:
|
||||||
|
https://shop.hettich.com/us_EN/Further-products/Assembly-machinery/Drilling-jigs/Drilling-jigs-for-hole-lines/Accura-for-hole-lines/Accura-1000-mm-drilling-template%2C-aluminium-with-3-stop-marking-pins/p/10573
|
||||||
|
https://shop.hettich.com/bh_EN/Hinges/Concealed-hinges/Intermat-fast-assembly-concealed-hinges/Hinges/110%C2%B0-Intermat-hinge-without-self-closing-feature-%28Intermat-9973%29%2C-overlay%2C-Opening-angle-110%C2%B0%2C-TH-drilling-pattern-52-x-5-5-mm%2C-for-screwing-on-%28-%29/p/9043361
|
||||||
16865
server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py
Normal file
16865
server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py
Normal file
File diff suppressed because it is too large
Load Diff
2151
server_patch/ai_3d_model/ai-solid-wood-cost/structure_graph.py
Normal file
2151
server_patch/ai_3d_model/ai-solid-wood-cost/structure_graph.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,970 @@
|
|||||||
|
"""Metamorphic assembly tests. No SKU or company-BOM answer constants."""
|
||||||
|
import copy
|
||||||
|
import random
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from structure_graph import (
|
||||||
|
GraphError, ParameterResolver, SCHEMA_VERSION, _axis_packing_feasibility, apply_graph_patch,
|
||||||
|
build_dimension_ledger, build_source_topology_contract, compile_graph,
|
||||||
|
normalize_graph_patch_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def param(value, unit="mm"):
|
||||||
|
return {"kind": "length", "origin": "observed", "value": value, "unit": unit,
|
||||||
|
"evidence_ids": ["view"], "confidence": 0.95}
|
||||||
|
|
||||||
|
|
||||||
|
def frame(width, depth, height, thickness):
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"uncertainties": [],
|
||||||
|
"observations": [{"id": "view", "source_type": "image", "image_id": "original-front", "confidence": 0.95}],
|
||||||
|
"parameters": {"w": param(width), "d": param(depth), "h": param(height), "t": param(thickness),
|
||||||
|
"inner": {"kind": "length", "origin": "derived", "expression": "w - 2*t"}},
|
||||||
|
"envelope_mm": ["w", "d", "h"],
|
||||||
|
"parts": [{"id": "A", "name": "任意名称A", "shape": "box", "functional_role": "side", "thickness_axis": "x", "size_mm": ["t", "d", "h"], "observed_quantity": 2},
|
||||||
|
{"id": "B", "name": "任意名称B", "shape": "box", "functional_role": "shelf", "thickness_axis": "z", "size_mm": ["inner", "d", "t"], "observed_quantity": 1}],
|
||||||
|
"instances": [{"id": "i1", "part_id": "A", "assembly_id": "root", "position_mm": [0, 0, 0]},
|
||||||
|
{"id": "i2", "part_id": "A", "assembly_id": "root", "position_mm": ["w-t", 0, 0]},
|
||||||
|
{"id": "i3", "part_id": "B", "assembly_id": "root", "position_mm": ["t", 0, "h-t"]}],
|
||||||
|
"connections": [{"id": "join1", "kind": "fixed", "a": "i1", "b": "i3", "face_a": "x+", "face_b": "x-"},
|
||||||
|
{"id": "join2", "kind": "fixed", "a": "i3", "b": "i2", "face_a": "x+", "face_b": "x-"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compile_case(graph):
|
||||||
|
return compile_graph(graph, source_image_ids={"original-front"})
|
||||||
|
|
||||||
|
|
||||||
|
def anchored_frame(width=800, depth=400, height=1100, thickness=18):
|
||||||
|
graph = frame(width, depth, height, thickness)
|
||||||
|
graph["placement_solver"] = "anchored_axes_v1"
|
||||||
|
graph["contact_derivation"] = "aabb_within_assembly_v1"
|
||||||
|
graph["connections"] = []
|
||||||
|
placements = {
|
||||||
|
"i1": {
|
||||||
|
"x": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"},
|
||||||
|
"y": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"},
|
||||||
|
"z": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"},
|
||||||
|
},
|
||||||
|
"i2": {
|
||||||
|
"x": {"mode": "align", "self_anchor": "max", "target": "envelope", "target_anchor": "max"},
|
||||||
|
"y": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"},
|
||||||
|
"z": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"},
|
||||||
|
},
|
||||||
|
"i3": {
|
||||||
|
"x": {"mode": "align", "self_anchor": "min", "target": "i1", "target_anchor": "max"},
|
||||||
|
"y": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"},
|
||||||
|
"z": {"mode": "align", "self_anchor": "max", "target": "envelope", "target_anchor": "max"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for instance in graph["instances"]:
|
||||||
|
instance.pop("position_mm")
|
||||||
|
instance["placement"] = placements[instance["id"]]
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
def anchored_frame_with_drawer_assembly():
|
||||||
|
graph = anchored_frame()
|
||||||
|
graph["assemblies"] = [
|
||||||
|
{"id": "root", "role": "carcass", "evidence_ids": ["view"],
|
||||||
|
"bounds_mm": {"x": [0, "w"], "y": [0, "d"], "z": [0, "h"]}},
|
||||||
|
{"id": "drawer-1", "role": "drawer", "evidence_ids": ["view"],
|
||||||
|
"bounds_mm": {"x": ["t+10", "w-t-10"], "y": [20, 320], "z": [500, 600]}},
|
||||||
|
]
|
||||||
|
graph["parts"].append({
|
||||||
|
"id": "D", "name": "独立运动总成代表体", "shape": "box",
|
||||||
|
"functional_role": "other", "thickness_axis": None,
|
||||||
|
"size_mm": ["inner - 20", 300, 100], "observed_quantity": 1, "evidence_ids": ["view"],
|
||||||
|
})
|
||||||
|
graph["instances"].append({
|
||||||
|
"id": "drawer-solid", "part_id": "D", "assembly_id": "drawer-1",
|
||||||
|
"axis_order": [0, 1, 2],
|
||||||
|
"placement": {
|
||||||
|
"x": {"mode": "align", "self_anchor": "min", "target": "assembly", "target_anchor": "min"},
|
||||||
|
"y": {"mode": "align", "self_anchor": "min", "target": "assembly", "target_anchor": "min"},
|
||||||
|
"z": {"mode": "align", "self_anchor": "min", "target": "assembly", "target_anchor": "min"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
graph["assembly_connections"] = [{
|
||||||
|
"id": "drawer-slide", "kind": "sliding", "a": "drawer-1", "b": "root",
|
||||||
|
"axis": "y", "travel_limits_mm": [0, 250], "evidence_ids": ["view"],
|
||||||
|
}]
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
def anchored_door_with_hardware(*, kind="surface", embed_depth_mm=0, hardware_x=300):
|
||||||
|
"""A product-independent moving panel with one template-backed knob."""
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"placement_solver": "anchored_axes_v1",
|
||||||
|
"orientation_solver": "role_axis_templates_v1",
|
||||||
|
"contact_derivation": "aabb_within_assembly_v1",
|
||||||
|
"observations": [{
|
||||||
|
"id": "view", "source_type": "image", "image_id": "original-front",
|
||||||
|
"confidence": 0.95,
|
||||||
|
}],
|
||||||
|
"parameters": {},
|
||||||
|
"envelope_mm": [700, 100, 700],
|
||||||
|
"assemblies": [{
|
||||||
|
"id": "moving-panel", "role": "door", "evidence_ids": ["view"],
|
||||||
|
"bounds_mm": {"x": [0, 600], "y": [20, 38], "z": [0, 700]},
|
||||||
|
}],
|
||||||
|
"parts": [
|
||||||
|
{"id": "panel", "name": "任意运动板", "shape": "box", "functional_role": "door",
|
||||||
|
"thickness_axis": "y", "material_family": "engineered_panel",
|
||||||
|
"size_mm": [600, 18, 700], "observed_quantity": 1},
|
||||||
|
{"id": "fitting", "name": "单孔旋钮", "shape": "box", "functional_role": "hardware",
|
||||||
|
"thickness_axis": None, "hardware_family": "knob",
|
||||||
|
"hardware_template_id": "knob_m4_surface_v1",
|
||||||
|
"size_mm": [1, 1, 1], "observed_quantity": 1},
|
||||||
|
],
|
||||||
|
"instances": [
|
||||||
|
{"id": "panel-instance", "part_id": "panel", "assembly_id": "moving-panel",
|
||||||
|
"placement": {
|
||||||
|
"x": {"mode": "absolute", "value_mm": 0},
|
||||||
|
"y": {"mode": "absolute", "value_mm": 20},
|
||||||
|
"z": {"mode": "absolute", "value_mm": 0},
|
||||||
|
}},
|
||||||
|
{"id": "fitting-instance", "part_id": "fitting", "assembly_id": "moving-panel",
|
||||||
|
"placement": {
|
||||||
|
"x": {"mode": "absolute", "value_mm": 0},
|
||||||
|
"y": {"mode": "absolute", "value_mm": 0},
|
||||||
|
"z": {"mode": "absolute", "value_mm": 0},
|
||||||
|
},
|
||||||
|
"mountings": [{
|
||||||
|
"slot": "body",
|
||||||
|
"host_instance_id": "panel-instance", "kind": kind, "face": "y-",
|
||||||
|
"embed_depth_mm": embed_depth_mm,
|
||||||
|
"mounting_center_mm": [hardware_x, 20, 350], "evidence_ids": ["view"],
|
||||||
|
}]},
|
||||||
|
],
|
||||||
|
"connections": [],
|
||||||
|
"assembly_connections": [],
|
||||||
|
"uncertainties": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def anchored_hinged_door_with_template(*, cup_edge_mm=5, plate_system_line_mm=37):
|
||||||
|
"""One generic inset door and side using a selected 35 mm hinge template."""
|
||||||
|
cup_x = 18 + 17.5 + cup_edge_mm
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"placement_solver": "anchored_axes_v1",
|
||||||
|
"orientation_solver": "role_axis_templates_v1",
|
||||||
|
"contact_derivation": "aabb_within_assembly_v1",
|
||||||
|
"observations": [{
|
||||||
|
"id": "view", "source_type": "image", "image_id": "original-front",
|
||||||
|
"confidence": 0.95,
|
||||||
|
}],
|
||||||
|
"parameters": {},
|
||||||
|
"envelope_mm": [618, 400, 700],
|
||||||
|
"assemblies": [
|
||||||
|
{"id": "door-group", "role": "door", "evidence_ids": ["view"],
|
||||||
|
"bounds_mm": {"x": [18, 618], "y": [0, 18], "z": [0, 700]}},
|
||||||
|
{"id": "carcass", "role": "carcass", "evidence_ids": ["view"],
|
||||||
|
"bounds_mm": {"x": [0, 18], "y": [18, 400], "z": [0, 700]}},
|
||||||
|
],
|
||||||
|
"parts": [
|
||||||
|
{"id": "door", "name": "门板", "shape": "box", "functional_role": "door",
|
||||||
|
"thickness_axis": "y", "material_family": "engineered_panel",
|
||||||
|
"size_mm": [600, 18, 700], "observed_quantity": 1},
|
||||||
|
{"id": "side", "name": "侧板", "shape": "box", "functional_role": "side",
|
||||||
|
"thickness_axis": "x", "material_family": "engineered_panel",
|
||||||
|
"size_mm": [18, 382, 700], "observed_quantity": 1},
|
||||||
|
{"id": "hinge", "name": "35mm暗铰链", "shape": "box", "functional_role": "hardware",
|
||||||
|
"thickness_axis": None, "hardware_family": "concealed_cup_hinge",
|
||||||
|
"hardware_template_id": "euro_hinge_35_th52_system32_v1",
|
||||||
|
"size_mm": [1, 1, 1], "observed_quantity": 1},
|
||||||
|
],
|
||||||
|
"instances": [
|
||||||
|
{"id": "door-instance", "part_id": "door", "assembly_id": "door-group",
|
||||||
|
"placement": {
|
||||||
|
"x": {"mode": "absolute", "value_mm": 18},
|
||||||
|
"y": {"mode": "absolute", "value_mm": 0},
|
||||||
|
"z": {"mode": "absolute", "value_mm": 0},
|
||||||
|
}},
|
||||||
|
{"id": "side-instance", "part_id": "side", "assembly_id": "carcass",
|
||||||
|
"placement": {
|
||||||
|
"x": {"mode": "absolute", "value_mm": 0},
|
||||||
|
"y": {"mode": "absolute", "value_mm": 18},
|
||||||
|
"z": {"mode": "absolute", "value_mm": 0},
|
||||||
|
}},
|
||||||
|
{"id": "hinge-instance", "part_id": "hinge", "assembly_id": "door-group",
|
||||||
|
"hardware_axis": "z",
|
||||||
|
"placement": {
|
||||||
|
"x": {"mode": "absolute", "value_mm": 0},
|
||||||
|
"y": {"mode": "absolute", "value_mm": 0},
|
||||||
|
"z": {"mode": "absolute", "value_mm": 0},
|
||||||
|
},
|
||||||
|
"mountings": [
|
||||||
|
{"slot": "cup", "host_instance_id": "door-instance", "kind": "mortised",
|
||||||
|
"face": "y+", "embed_depth_mm": 12.8,
|
||||||
|
"mounting_center_mm": [cup_x, 18, 100], "evidence_ids": ["view"]},
|
||||||
|
{"slot": "plate", "host_instance_id": "side-instance", "kind": "surface",
|
||||||
|
"face": "x+", "embed_depth_mm": 0,
|
||||||
|
"mounting_center_mm": [18, 18 + plate_system_line_mm, 100], "evidence_ids": ["view"]},
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
"connections": [],
|
||||||
|
"assembly_connections": [{
|
||||||
|
"id": "door-hinge", "kind": "hinged", "a": "door-group", "b": "carcass",
|
||||||
|
"axis": "z", "angle_limits_deg": [0, 110], "evidence_ids": ["view"],
|
||||||
|
}],
|
||||||
|
"uncertainties": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StructureGraphTest(unittest.TestCase):
|
||||||
|
def test_template_knob_can_protrude_beyond_structural_assembly_bounds(self):
|
||||||
|
result = compile_case(anchored_door_with_hardware())
|
||||||
|
self.assertTrue(result["ok"], result["issues"])
|
||||||
|
self.assertEqual(1, len(result["mounting_audit"]))
|
||||||
|
self.assertEqual("surface", result["mounting_audit"][0]["kind"])
|
||||||
|
self.assertEqual(0, result["mounting_audit"][0]["actual_embed_depth_mm"])
|
||||||
|
geometry = result["hardware_geometry_by_instance"]["fitting-instance"]
|
||||||
|
self.assertEqual("knob", geometry["hardware_family"])
|
||||||
|
self.assertEqual(["knob_body"], [row["role"] for row in geometry["primitives"]])
|
||||||
|
fitting = next(row for row in result["instances"] if row["id"] == "fitting-instance")
|
||||||
|
self.assertEqual([1.0, 1.0, 1.0], fitting["declared_placeholder_size_mm"])
|
||||||
|
self.assertEqual([20.0, 24.0, 20.0], fitting["size_mm"])
|
||||||
|
self.assertNotIn(
|
||||||
|
"assembly_bounds_not_tight", {row["code"] for row in result["issues"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hardware_on_wrong_panel_position_is_still_blocking(self):
|
||||||
|
result = compile_case(anchored_door_with_hardware(hardware_x=650))
|
||||||
|
codes = {row["code"] for row in result["issues"]}
|
||||||
|
self.assertIn("hardware_mounting_geometry", codes)
|
||||||
|
self.assertIn("assembly_internal_disconnected", codes)
|
||||||
|
self.assertIn("disconnected_instance", codes)
|
||||||
|
self.assertFalse(result["mounting_audit"])
|
||||||
|
|
||||||
|
def test_surface_knob_cannot_fake_whole_body_through_mounting(self):
|
||||||
|
graph = anchored_door_with_hardware(kind="through", embed_depth_mm=18)
|
||||||
|
result = compile_case(graph)
|
||||||
|
problem = next(row for row in result["issues"] if row["code"] == "hardware_mounting_kind")
|
||||||
|
self.assertEqual("surface", problem["required_kind"])
|
||||||
|
self.assertEqual("through", problem["actual_kind"])
|
||||||
|
self.assertFalse(result["mounting_audit"])
|
||||||
|
|
||||||
|
def test_hardware_family_and_template_are_required_and_must_match(self):
|
||||||
|
missing = anchored_door_with_hardware()
|
||||||
|
missing["parts"][1].pop("hardware_template_id")
|
||||||
|
self.assertIn("part_geometry", {row["code"] for row in compile_case(missing)["issues"]})
|
||||||
|
mismatch = anchored_door_with_hardware()
|
||||||
|
mismatch["parts"][1]["hardware_family"] = "concealed_cup_hinge"
|
||||||
|
self.assertIn("part_geometry", {row["code"] for row in compile_case(mismatch)["issues"]})
|
||||||
|
|
||||||
|
def test_hardware_mounting_is_required_and_non_hardware_cannot_claim_it(self):
|
||||||
|
missing = anchored_door_with_hardware()
|
||||||
|
missing["instances"][1].pop("mountings")
|
||||||
|
self.assertIn("hardware_mounting_missing", {row["code"] for row in compile_case(missing)["issues"]})
|
||||||
|
|
||||||
|
invalid = anchored_door_with_hardware()
|
||||||
|
invalid["instances"][0]["mountings"] = copy.deepcopy(invalid["instances"][1]["mountings"])
|
||||||
|
self.assertIn("mounting_on_non_hardware", {row["code"] for row in compile_case(invalid)["issues"]})
|
||||||
|
|
||||||
|
def test_template_hardware_feedback_requires_real_broad_face_not_box_movement(self):
|
||||||
|
graph = anchored_door_with_hardware()
|
||||||
|
graph["instances"][1]["mountings"][0]["face"] = "x+"
|
||||||
|
result = compile_case(graph)
|
||||||
|
problem = next(
|
||||||
|
row for row in result["issues"] if row["code"] == "hardware_mounting_geometry"
|
||||||
|
)
|
||||||
|
self.assertIn("厚度轴y的广面", problem["detail"])
|
||||||
|
self.assertNotIn("compatible_mounting_values", problem)
|
||||||
|
|
||||||
|
def test_hinge_template_validates_slots_system_lines_and_non_box_geometry(self):
|
||||||
|
result = compile_case(anchored_hinged_door_with_template())
|
||||||
|
self.assertTrue(result["ok"], result["issues"])
|
||||||
|
self.assertEqual({"cup", "plate"}, {row["slot"] for row in result["mounting_audit"]})
|
||||||
|
geometry = result["hardware_geometry_by_instance"]["hinge-instance"]
|
||||||
|
self.assertEqual(
|
||||||
|
{"hinge_cup", "hinge_mounting_plate", "hinge_arm_from_cup", "hinge_arm_to_plate"},
|
||||||
|
{row["role"] for row in geometry["primitives"]},
|
||||||
|
)
|
||||||
|
self.assertIn("cylinder", {row["kind"] for row in geometry["primitives"]})
|
||||||
|
arms = [row for row in geometry["primitives"] if row["role"].startswith("hinge_arm_")]
|
||||||
|
# Door interior face is Y=18 and side interior face is X=18. The arm
|
||||||
|
# may touch those planes, but only the cup cylinder may enter a board.
|
||||||
|
self.assertTrue(all(
|
||||||
|
row["center_mm"][1] - row["size_mm"][1] / 2 >= 18 for row in arms
|
||||||
|
))
|
||||||
|
self.assertTrue(all(
|
||||||
|
row["center_mm"][0] - row["size_mm"][0] / 2 >= 18 for row in arms
|
||||||
|
))
|
||||||
|
self.assertTrue(all(row.get("accepted_as_mounting") for row in result["collision_audit"]))
|
||||||
|
self.assertEqual([], result["derived_contacts"])
|
||||||
|
|
||||||
|
def test_hinge_template_blocks_wrong_cup_edge_and_plate_system_line(self):
|
||||||
|
cup = compile_case(anchored_hinged_door_with_template(cup_edge_mm=15))
|
||||||
|
self.assertIn("hinge_cup_edge_distance", {row["code"] for row in cup["issues"]})
|
||||||
|
plate = compile_case(anchored_hinged_door_with_template(plate_system_line_mm=55))
|
||||||
|
self.assertIn("hinge_plate_system_line", {row["code"] for row in plate["issues"]})
|
||||||
|
|
||||||
|
def test_hinge_template_requires_every_mounting_slot_and_template_kind(self):
|
||||||
|
missing = anchored_hinged_door_with_template()
|
||||||
|
missing["instances"][2]["mountings"].pop()
|
||||||
|
self.assertIn("hardware_mounting_slots_missing", {row["code"] for row in compile_case(missing)["issues"]})
|
||||||
|
wrong = anchored_hinged_door_with_template()
|
||||||
|
wrong["instances"][2]["mountings"][0]["kind"] = "through"
|
||||||
|
self.assertIn("hardware_mounting_kind", {row["code"] for row in compile_case(wrong)["issues"]})
|
||||||
|
|
||||||
|
def test_role_axis_mode_accepts_direct_product_axes_without_instance_mapping(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
graph["orientation_solver"] = "role_axis_templates_v1"
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertTrue(result["ok"], result["issues"])
|
||||||
|
self.assertEqual("role_axis_templates_v1", result["orientation_solver"])
|
||||||
|
self.assertTrue(all(row["accepted"] for row in result["orientation_audit"]))
|
||||||
|
sizes = {row["id"]: row["size_mm"] for row in result["instances"]}
|
||||||
|
self.assertEqual([18.0, 400.0, 1100.0], sizes["i1"])
|
||||||
|
self.assertEqual([764.0, 400.0, 18.0], sizes["i3"])
|
||||||
|
|
||||||
|
def test_role_axis_mode_blocks_role_thickness_mismatch(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
graph["orientation_solver"] = "role_axis_templates_v1"
|
||||||
|
graph["parts"][0]["functional_role"] = "door"
|
||||||
|
result = compile_case(graph)
|
||||||
|
issue = next(row for row in result["issues"] if row["code"] == "role_axis_template")
|
||||||
|
self.assertEqual("x", issue["actual"])
|
||||||
|
self.assertEqual(["y"], issue["expected"])
|
||||||
|
|
||||||
|
def test_role_axis_mode_blocks_second_instance_axis_swap(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
graph["orientation_solver"] = "role_axis_templates_v1"
|
||||||
|
graph["instances"][0]["axis_order"] = [2, 1, 0]
|
||||||
|
result = compile_case(graph)
|
||||||
|
issue = next(
|
||||||
|
row for row in result["issues"]
|
||||||
|
if row["code"] == "role_axis_template" and row["path"].endswith("/axis_order")
|
||||||
|
)
|
||||||
|
self.assertEqual([0, 1, 2], issue["expected"])
|
||||||
|
|
||||||
|
def test_role_axis_mode_is_invariant_to_display_names(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
graph["orientation_solver"] = "role_axis_templates_v1"
|
||||||
|
first = compile_case(graph)
|
||||||
|
graph["parts"][0]["name"] = "unrelated-display-name"
|
||||||
|
graph["parts"][1]["name"] = "另一个完全无关的显示名"
|
||||||
|
second = compile_case(graph)
|
||||||
|
self.assertEqual(first["orientation_audit"], second["orientation_audit"])
|
||||||
|
structural = lambda rows: [
|
||||||
|
{key: value for key, value in row.items() if key != "name"}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
self.assertEqual(structural(first["bom"]), structural(second["bom"]))
|
||||||
|
|
||||||
|
def test_independent_multiview_role_count_blocks_silent_graph_omission(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
source_structure = {
|
||||||
|
"parts": [
|
||||||
|
{"source_structure_id": "S1", "component_role": "side", "quantity": 2,
|
||||||
|
"confidence": 0.96, "image_ids": [1, 2]},
|
||||||
|
{"source_structure_id": "S2", "component_role": "shelf", "quantity": 2,
|
||||||
|
"confidence": 0.91, "image_ids": [1, 3]},
|
||||||
|
{"source_structure_id": "S3", "component_role": "door", "quantity": 5,
|
||||||
|
"confidence": 0.51, "image_ids": [1]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
contract = build_source_topology_contract(source_structure)
|
||||||
|
self.assertEqual({"shelf": 2, "side": 2}, contract["role_counts"])
|
||||||
|
result = compile_graph(
|
||||||
|
graph, source_image_ids={"original-front"}, topology_contract=contract
|
||||||
|
)
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
mismatch = next(
|
||||||
|
row for row in result["issues"]
|
||||||
|
if row["code"] == "source_topology_count_conflict"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
("shelf", 2, 1),
|
||||||
|
(mismatch["role"], mismatch["expected"], mismatch["actual"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_random_dimensions_and_thickness_recompute_all_dependencies(self):
|
||||||
|
rng = random.Random(80721)
|
||||||
|
for _ in range(100):
|
||||||
|
w, d, h, t = rng.randint(360, 2200), rng.randint(180, 800), rng.randint(500, 2200), rng.randint(9, 35)
|
||||||
|
graph = frame(w, d, h, t)
|
||||||
|
first = compile_case(graph)
|
||||||
|
self.assertTrue(first["ok"], first["issues"])
|
||||||
|
new_w, new_t = w + rng.randint(5, 180), t + 3
|
||||||
|
graph["parameters"]["w"]["value"] = new_w
|
||||||
|
graph["parameters"]["t"]["value"] = new_t
|
||||||
|
second = compile_case(graph)
|
||||||
|
self.assertTrue(second["ok"], second["issues"])
|
||||||
|
self.assertEqual(new_w - 2 * new_t, second["bom"][1]["size_mm"][0])
|
||||||
|
self.assertEqual([2, 1], [row["quantity"] for row in second["bom"]])
|
||||||
|
self.assertNotEqual(first["graph_sha256"], second["graph_sha256"])
|
||||||
|
|
||||||
|
def test_names_do_not_change_dimensions_role_or_count(self):
|
||||||
|
graph = frame(840, 420, 1150, 16)
|
||||||
|
before = compile_case(graph)
|
||||||
|
graph["parts"][0]["name"] = "抽屉面板中竖板"
|
||||||
|
graph["parts"][1]["name"] = "抽屉底板封边减板厚"
|
||||||
|
after = compile_case(graph)
|
||||||
|
self.assertTrue(after["ok"])
|
||||||
|
self.assertEqual(before["instances"], after["instances"])
|
||||||
|
|
||||||
|
def test_cm_and_mm_evidence_resolve_to_same_geometry(self):
|
||||||
|
graph = frame(800, 400, 1100, 18)
|
||||||
|
original = compile_case(graph)
|
||||||
|
for value in graph["parameters"].values():
|
||||||
|
if value.get("origin") == "observed":
|
||||||
|
value["value"] /= 10
|
||||||
|
value["unit"] = "cm"
|
||||||
|
self.assertEqual(original["instances"], compile_case(graph)["instances"])
|
||||||
|
|
||||||
|
def test_connection_gap_returned_to_ai_not_silently_clamped(self):
|
||||||
|
graph = frame(800, 400, 1100, 18)
|
||||||
|
graph["instances"][2]["position_mm"][0] = "t*2"
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertIn("connection_gap", {row["code"] for row in result["issues"]})
|
||||||
|
self.assertEqual(36, result["instances"][2]["position_mm"][0])
|
||||||
|
|
||||||
|
def test_duplicate_solids_and_missing_instances_are_not_hidden(self):
|
||||||
|
graph = frame(800, 400, 1100, 18)
|
||||||
|
graph["instances"][1]["position_mm"] = [0, 0, 0]
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertIn("duplicate_solid", {row["code"] for row in result["issues"]})
|
||||||
|
graph["instances"].pop()
|
||||||
|
self.assertIn("uninstantiated_part", {row["code"] for row in compile_case(graph)["issues"]})
|
||||||
|
|
||||||
|
def test_dimension_errors_cycles_and_code_execution_are_rejected(self):
|
||||||
|
graph = frame(800, 400, 1100, 18)
|
||||||
|
for expression in ("w + 2", "w*w", "inner + t", "__import__('os').system('true')"):
|
||||||
|
candidate = copy.deepcopy(graph)
|
||||||
|
candidate["parameters"]["inner"]["expression"] = expression
|
||||||
|
self.assertFalse(compile_case(candidate)["ok"], expression)
|
||||||
|
|
||||||
|
def test_model_cannot_invent_original_image_or_user_confirmation(self):
|
||||||
|
graph = frame(800, 400, 1100, 18)
|
||||||
|
graph["observations"][0]["image_id"] = "invented"
|
||||||
|
self.assertIn("evidence_image", {row["code"] for row in compile_case(graph)["issues"]})
|
||||||
|
graph["observations"][0]["source_type"] = "user"
|
||||||
|
self.assertIn("evidence_user", {row["code"] for row in compile_case(graph)["issues"]})
|
||||||
|
|
||||||
|
def test_deleting_connections_cannot_hide_a_floating_part(self):
|
||||||
|
graph = frame(800, 400, 1100, 18)
|
||||||
|
graph["connections"] = []
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertIn("disconnected_instance", {row["code"] for row in result["issues"]})
|
||||||
|
|
||||||
|
def test_geometry_context_accepts_mm_literals_but_derived_parameters_remain_dimension_safe(self):
|
||||||
|
resolver = ParameterResolver({"w": param(800), "t": param(18)},
|
||||||
|
{"view": {"source_type": "image", "confidence": 0.95}})
|
||||||
|
self.assertEqual(5, resolver.length("5"))
|
||||||
|
self.assertEqual(343, resolver.length("(w - 3*t) / 2 - 30"))
|
||||||
|
with self.assertRaises(GraphError):
|
||||||
|
resolver.expression("w - 30")
|
||||||
|
|
||||||
|
def test_contact_solver_recomputes_normal_positions_and_keeps_audit(self):
|
||||||
|
graph = frame(800, 400, 1100, 18)
|
||||||
|
graph["constraint_solver"] = "orthogonal_contacts_v1"
|
||||||
|
graph["instances"][1]["position_mm"] = [760, 0, 0]
|
||||||
|
graph["instances"][2]["position_mm"] = [30, 0, "h-t"]
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertTrue(result["ok"], result["issues"])
|
||||||
|
positions = {row["id"]: row["position_mm"] for row in result["instances"]}
|
||||||
|
self.assertEqual(782, positions["i2"][0])
|
||||||
|
self.assertEqual(18, positions["i3"][0])
|
||||||
|
self.assertGreaterEqual(len(result["solver_adjustments"]), 2)
|
||||||
|
|
||||||
|
def test_contact_solver_blocks_contradictory_cycle(self):
|
||||||
|
graph = frame(800, 400, 1100, 18)
|
||||||
|
graph["constraint_solver"] = "orthogonal_contacts_v1"
|
||||||
|
graph["connections"].append({"id": "bad", "kind": "fixed", "a": "i1", "b": "i2",
|
||||||
|
"face_a": "x-", "face_b": "x+"})
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertIn("constraint_cycle_conflict", {row["code"] for row in result["issues"]})
|
||||||
|
|
||||||
|
def test_local_graph_patch_is_audited_and_cannot_escape_schema(self):
|
||||||
|
graph = frame(800, 400, 1100, 18)
|
||||||
|
updated, audit = apply_graph_patch(graph, [
|
||||||
|
{"op": "replace", "path": "/parameters/w/value", "value": 920},
|
||||||
|
{"op": "add", "path": "/uncertainties/-", "value": "隐藏连接件待制造模板复核"},
|
||||||
|
])
|
||||||
|
self.assertEqual(920, updated["parameters"]["w"]["value"])
|
||||||
|
self.assertEqual(800, graph["parameters"]["w"]["value"])
|
||||||
|
self.assertEqual(2, len(audit))
|
||||||
|
with self.assertRaises(GraphError):
|
||||||
|
apply_graph_patch(graph, [{"op": "replace", "path": "/schema_version", "value": "evil"}])
|
||||||
|
with self.assertRaises(GraphError):
|
||||||
|
apply_graph_patch(graph, [{"op": "remove", "path": "/parameters/missing"}])
|
||||||
|
|
||||||
|
def test_local_graph_patch_resolves_stable_ids_in_arrays(self):
|
||||||
|
graph = anchored_frame_with_drawer_assembly()
|
||||||
|
updated, audit = apply_graph_patch(graph, [
|
||||||
|
{"op": "replace", "path": "/assemblies/drawer-1/bounds_mm/z", "value": [510, 610]},
|
||||||
|
{"op": "add", "path": "/instances/drawer-solid/placement/z/offset_mm", "value": 5},
|
||||||
|
])
|
||||||
|
self.assertEqual([510, 610], updated["assemblies"][1]["bounds_mm"]["z"])
|
||||||
|
self.assertEqual(5, updated["instances"][-1]["placement"]["z"]["offset_mm"])
|
||||||
|
self.assertEqual(2, len(audit))
|
||||||
|
with self.assertRaises(GraphError):
|
||||||
|
apply_graph_patch(graph, [
|
||||||
|
{"op": "replace", "path": "/assemblies/missing/bounds_mm/z", "value": [0, 1]},
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_local_graph_patch_rejects_whole_placement_overwrite(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
with self.assertRaisesRegex(GraphError, "禁止整段覆盖实例placement"):
|
||||||
|
apply_graph_patch(graph, [{
|
||||||
|
"op": "replace", "path": "/instances/i1/placement",
|
||||||
|
"value": copy.deepcopy(graph["instances"][1]["placement"]),
|
||||||
|
}])
|
||||||
|
|
||||||
|
def test_patch_payload_expands_whole_placement_to_changed_axes(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
replacement = copy.deepcopy(graph["instances"][0]["placement"])
|
||||||
|
replacement["x"] = {"mode": "absolute", "value_mm": 12}
|
||||||
|
patches, audit = normalize_graph_patch_payload(graph, {"patches": [{
|
||||||
|
"op": "replace", "path": "/instances/i1/placement", "value": replacement,
|
||||||
|
}]})
|
||||||
|
self.assertEqual([{
|
||||||
|
"op": "replace", "path": "/instances/i1/placement/x",
|
||||||
|
"value": {"mode": "absolute", "value_mm": 12},
|
||||||
|
}], patches)
|
||||||
|
self.assertEqual(["/instances/i1/placement/x"], audit[1]["expanded_paths"])
|
||||||
|
updated, patch_audit = apply_graph_patch(graph, patches)
|
||||||
|
self.assertEqual(12, updated["instances"][0]["placement"]["x"]["value_mm"])
|
||||||
|
self.assertEqual("/instances/i1/placement/x", patch_audit[0]["path"])
|
||||||
|
|
||||||
|
def test_patch_payload_accepts_direct_list_and_numeric_instance(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
replacement = copy.deepcopy(graph["instances"][1]["placement"])
|
||||||
|
replacement["z"] = {"mode": "absolute", "value_mm": 19}
|
||||||
|
patches, audit = normalize_graph_patch_payload(graph, [{
|
||||||
|
"op": "replace", "path": "/instances/1/placement", "value": replacement,
|
||||||
|
}])
|
||||||
|
self.assertEqual("patch_list", audit[0]["source"])
|
||||||
|
self.assertEqual("/instances/1/placement/z", patches[0]["path"])
|
||||||
|
|
||||||
|
def test_patch_payload_rejects_malformed_or_noop_whole_placement(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
with self.assertRaisesRegex(GraphError, "恰好包含x、y、z"):
|
||||||
|
normalize_graph_patch_payload(graph, {"patches": [{
|
||||||
|
"op": "replace", "path": "/instances/i1/placement", "value": {"x": {}},
|
||||||
|
}]})
|
||||||
|
with self.assertRaisesRegex(GraphError, "没有产生任何有效变化"):
|
||||||
|
normalize_graph_patch_payload(graph, {"patches": [{
|
||||||
|
"op": "replace", "path": "/instances/i1/placement",
|
||||||
|
"value": copy.deepcopy(graph["instances"][0]["placement"]),
|
||||||
|
}]})
|
||||||
|
|
||||||
|
def test_invalid_assembly_role_feedback_contains_legal_enum_and_stable_path(self):
|
||||||
|
graph = anchored_frame_with_drawer_assembly()
|
||||||
|
graph["assemblies"][1]["role"] = "drawer_assembly"
|
||||||
|
result = compile_case(graph)
|
||||||
|
issue = next(row for row in result["issues"] if row["code"] == "assembly_role")
|
||||||
|
self.assertEqual("/assemblies/drawer-1/role", issue["legal_repair"]["path"])
|
||||||
|
self.assertIn("drawer", issue["allowed"])
|
||||||
|
self.assertNotIn("drawer_assembly", issue["allowed"])
|
||||||
|
|
||||||
|
def test_direct_source_dimension_ledger_blocks_axis_swap(self):
|
||||||
|
graph = frame(800, 400, 1100, 18)
|
||||||
|
valid = compile_graph(
|
||||||
|
graph,
|
||||||
|
source_image_ids={"original-front"},
|
||||||
|
dimension_ledger={
|
||||||
|
"x": {"value_mm": 800, "source_image_ids": ["original-front"]},
|
||||||
|
"y": {"value_mm": 400, "source_image_ids": ["original-front"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertTrue(valid["ok"], valid["issues"])
|
||||||
|
graph["envelope_mm"] = ["d", "w", "h"]
|
||||||
|
swapped = compile_graph(
|
||||||
|
graph,
|
||||||
|
source_image_ids={"original-front"},
|
||||||
|
dimension_ledger={
|
||||||
|
"x": {"value_mm": 800, "source_image_ids": ["original-front"]},
|
||||||
|
"y": {"value_mm": 400, "source_image_ids": ["original-front"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
conflicts = [row for row in swapped["issues"] if row["code"] == "overall_dimension_conflict"]
|
||||||
|
self.assertEqual({"x", "y"}, {row["axis"] for row in conflicts})
|
||||||
|
|
||||||
|
def test_dimension_ledger_rejects_local_low_confidence_and_conflicting_arrows(self):
|
||||||
|
ledger = build_dimension_ledger([
|
||||||
|
{"axis": "x", "value": 80, "unit": "cm", "scope": "overall", "confidence": 0.95,
|
||||||
|
"text": "80", "location": "外轮廓左右"},
|
||||||
|
{"axis": "y", "value": 40, "unit": "cm", "scope": "part", "confidence": 0.99},
|
||||||
|
{"axis": "z", "value": 1100, "unit": "mm", "scope": "overall", "confidence": 0.72},
|
||||||
|
], source_image_id="dimension")
|
||||||
|
self.assertEqual(800, ledger["x"]["value_mm"])
|
||||||
|
self.assertEqual("occupied_overall", ledger["x"]["ledger_scope"])
|
||||||
|
self.assertEqual("qualified_overall_arrow", ledger["x"]["scope_source"])
|
||||||
|
self.assertNotIn("y", ledger)
|
||||||
|
self.assertNotIn("z", ledger)
|
||||||
|
conflicted = build_dimension_ledger([
|
||||||
|
{"axis": "x", "value": 800, "unit": "mm", "scope": "overall", "confidence": 0.95},
|
||||||
|
{"axis": "x", "value": 900, "unit": "mm", "scope": "overall", "confidence": 0.95},
|
||||||
|
], source_image_id="dimension")
|
||||||
|
self.assertNotIn("x", conflicted)
|
||||||
|
self.assertEqual("x", conflicted["conflicts"][0]["axis"])
|
||||||
|
|
||||||
|
def test_contacts_are_derived_from_geometry_within_declared_assembly(self):
|
||||||
|
graph = frame(800, 400, 1100, 18)
|
||||||
|
graph["connections"] = []
|
||||||
|
graph["contact_derivation"] = "aabb_within_assembly_v1"
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertTrue(result["ok"], result["issues"])
|
||||||
|
self.assertEqual(2, len(result["derived_contacts"]))
|
||||||
|
self.assertEqual({"edge_to_face"}, {row["interface_type"] for row in result["derived_contacts"]})
|
||||||
|
self.assertEqual({"i3"}, {row["edge_instance_id"] for row in result["derived_contacts"]})
|
||||||
|
graph["instances"][2]["position_mm"][0] = "t+5"
|
||||||
|
broken = compile_case(graph)
|
||||||
|
self.assertFalse(broken["ok"])
|
||||||
|
self.assertIn("disconnected_instance", {row["code"] for row in broken["issues"]})
|
||||||
|
|
||||||
|
def test_contacts_do_not_fabricate_cross_assembly_drawer_joint(self):
|
||||||
|
graph = frame(800, 400, 1100, 18)
|
||||||
|
graph["connections"] = []
|
||||||
|
graph["contact_derivation"] = "aabb_within_assembly_v1"
|
||||||
|
graph["instances"][2]["assembly_id"] = "drawer-1"
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertFalse(result["derived_contacts"])
|
||||||
|
graph["connections"] = [
|
||||||
|
{"id": "slide-left", "kind": "sliding", "a": "i1", "b": "i3"},
|
||||||
|
{"id": "slide-right", "kind": "sliding", "a": "i2", "b": "i3"},
|
||||||
|
]
|
||||||
|
self.assertTrue(compile_case(graph)["ok"])
|
||||||
|
|
||||||
|
def test_anchor_constraints_compute_coordinates_instead_of_trusting_ai_xyz(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertTrue(result["ok"], result["issues"])
|
||||||
|
positions = {row["id"]: row["position_mm"] for row in result["instances"]}
|
||||||
|
self.assertEqual([0, 0, 0], positions["i1"])
|
||||||
|
self.assertEqual([782, 0, 0], positions["i2"])
|
||||||
|
self.assertEqual([18, 0, 1082], positions["i3"])
|
||||||
|
self.assertEqual(9, len(result["placement_audit"]))
|
||||||
|
self.assertEqual(2, len(result["derived_contacts"]))
|
||||||
|
|
||||||
|
def test_own_assembly_stable_id_is_a_safe_anchor_alias(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
for instance in graph["instances"]:
|
||||||
|
for axis in "xyz":
|
||||||
|
rule = instance["placement"][axis]
|
||||||
|
if rule.get("target") == "assembly":
|
||||||
|
rule["target"] = instance["assembly_id"]
|
||||||
|
aliased = compile_case(graph)
|
||||||
|
canonical = compile_case(anchored_frame())
|
||||||
|
self.assertEqual(
|
||||||
|
[row["position_mm"] for row in canonical["instances"]],
|
||||||
|
[row["position_mm"] for row in aliased["instances"]],
|
||||||
|
)
|
||||||
|
self.assertTrue(all(
|
||||||
|
row["target_kind"] == "assembly"
|
||||||
|
for row in aliased["placement_audit"] if row["target"] == "root"
|
||||||
|
))
|
||||||
|
|
||||||
|
invalid = anchored_frame()
|
||||||
|
invalid["instances"][0]["placement"]["x"]["target"] = "other-assembly"
|
||||||
|
self.assertIn("placement_constraint", {row["code"] for row in compile_case(invalid)["issues"]})
|
||||||
|
|
||||||
|
def test_invalid_anchor_is_localized_and_preserves_unrelated_geometry(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
graph["instances"][0]["placement"]["x"]["target"] = "other-assembly"
|
||||||
|
|
||||||
|
result = compile_case(graph)
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertEqual(["i2"], [row["id"] for row in result["instances"]])
|
||||||
|
self.assertEqual(3, len(result["placement_audit"]))
|
||||||
|
placement_issues = [
|
||||||
|
row for row in result["issues"] if row["code"] == "placement_constraint"
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
{"/instances/i1/placement", "/instances/i3/placement"},
|
||||||
|
{row["path"] for row in placement_issues},
|
||||||
|
)
|
||||||
|
self.assertNotIn("/instances", {row["path"] for row in placement_issues})
|
||||||
|
cascade_codes = {"empty_assembly", "uninstantiated_part", "quantity_conflict"}
|
||||||
|
self.assertFalse(cascade_codes & {row["code"] for row in result["issues"]})
|
||||||
|
|
||||||
|
def test_anchor_constraints_follow_parameter_changes(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
first = compile_case(graph)
|
||||||
|
graph["parameters"]["w"]["value"] = 920
|
||||||
|
graph["parameters"]["h"]["value"] = 1250
|
||||||
|
second = compile_case(graph)
|
||||||
|
self.assertTrue(second["ok"], second["issues"])
|
||||||
|
positions = {row["id"]: row["position_mm"] for row in second["instances"]}
|
||||||
|
self.assertEqual([902, 0, 0], positions["i2"])
|
||||||
|
self.assertEqual([18, 0, 1232], positions["i3"])
|
||||||
|
self.assertNotEqual(first["graph_sha256"], second["graph_sha256"])
|
||||||
|
|
||||||
|
def test_anchor_constraints_reject_missing_axis_and_dependency_cycle(self):
|
||||||
|
missing = anchored_frame()
|
||||||
|
del missing["instances"][0]["placement"]["z"]
|
||||||
|
self.assertIn("placement_constraint", {row["code"] for row in compile_case(missing)["issues"]})
|
||||||
|
cyclic = anchored_frame()
|
||||||
|
cyclic["instances"][0]["placement"]["x"] = {
|
||||||
|
"mode": "align", "self_anchor": "min", "target": "i2", "target_anchor": "min"
|
||||||
|
}
|
||||||
|
cyclic["instances"][1]["placement"]["x"] = {
|
||||||
|
"mode": "align", "self_anchor": "min", "target": "i1", "target_anchor": "min"
|
||||||
|
}
|
||||||
|
self.assertIn("placement_constraint", {row["code"] for row in compile_case(cyclic)["issues"]})
|
||||||
|
|
||||||
|
def test_assembly_motion_connects_whole_subassembly_not_arbitrary_board(self):
|
||||||
|
graph = anchored_frame_with_drawer_assembly()
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertTrue(result["ok"], result["issues"])
|
||||||
|
self.assertEqual("sliding", result["assembly_connection_audit"][0]["kind"])
|
||||||
|
self.assertEqual("y", result["assembly_connection_audit"][0]["axis"])
|
||||||
|
graph["assembly_connections"] = []
|
||||||
|
broken = compile_case(graph)
|
||||||
|
self.assertIn("disconnected_assembly", {row["code"] for row in broken["issues"]})
|
||||||
|
|
||||||
|
def test_assembly_motion_rejects_instance_endpoints_with_actionable_mapping(self):
|
||||||
|
graph = anchored_frame_with_drawer_assembly()
|
||||||
|
graph["assembly_connections"][0]["a"] = "drawer-solid"
|
||||||
|
graph["assembly_connections"][0]["b"] = "i1"
|
||||||
|
result = compile_case(graph)
|
||||||
|
problem = next(
|
||||||
|
row for row in result["issues"]
|
||||||
|
if row["code"] == "assembly_connection_uses_instance"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"drawer-solid": "drawer-1", "i1": "root"},
|
||||||
|
problem["instance_endpoint_to_current_assembly"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"drawer-solid", problem["moving_endpoint_repair"]["instance_id"]
|
||||||
|
)
|
||||||
|
self.assertIn("root", problem["stationary_endpoint_repair"])
|
||||||
|
|
||||||
|
def test_fixed_assembly_relation_requires_real_geometric_contact(self):
|
||||||
|
graph = anchored_frame_with_drawer_assembly()
|
||||||
|
graph["assembly_connections"][0] = {
|
||||||
|
"id": "not-really-fixed", "kind": "fixed", "a": "drawer-1", "b": "root",
|
||||||
|
"evidence_ids": ["view"],
|
||||||
|
}
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertIn("fixed_assemblies_not_contacting", {row["code"] for row in result["issues"]})
|
||||||
|
|
||||||
|
def test_assembly_joint_cannot_hide_loose_member(self):
|
||||||
|
graph = anchored_frame_with_drawer_assembly()
|
||||||
|
graph["parts"][-1]["observed_quantity"] = 2
|
||||||
|
loose = copy.deepcopy(graph["instances"][-1])
|
||||||
|
loose["id"] = "drawer-loose"
|
||||||
|
loose["placement"]["z"] = {"mode": "absolute", "value_mm": 800}
|
||||||
|
graph["instances"].append(loose)
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertIn("assembly_internal_disconnected", {row["code"] for row in result["issues"]})
|
||||||
|
|
||||||
|
def test_module_local_bounds_prevent_upper_module_from_falling_to_product_floor(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
graph["parameters"]["lower_h"] = param(420)
|
||||||
|
graph["parts"][0]["size_mm"][2] = "h-lower_h"
|
||||||
|
graph["assemblies"] = [{
|
||||||
|
"id": "root", "role": "carcass", "evidence_ids": ["view"],
|
||||||
|
"bounds_mm": {"x": [0, "w"], "y": [0, "d"], "z": ["lower_h", "h"]},
|
||||||
|
}]
|
||||||
|
for instance in graph["instances"][:2]:
|
||||||
|
instance["placement"]["x"]["target"] = "assembly"
|
||||||
|
instance["placement"]["y"]["target"] = "assembly"
|
||||||
|
instance["placement"]["z"]["target"] = "assembly"
|
||||||
|
graph["instances"][2]["placement"]["y"]["target"] = "assembly"
|
||||||
|
graph["instances"][2]["placement"]["z"]["target"] = "assembly"
|
||||||
|
result = compile_case(graph)
|
||||||
|
self.assertTrue(result["ok"], result["issues"])
|
||||||
|
positions = {row["id"]: row["position_mm"] for row in result["instances"]}
|
||||||
|
self.assertEqual(420, positions["i1"][2])
|
||||||
|
self.assertEqual(420, positions["i2"][2])
|
||||||
|
graph["parameters"]["lower_h"]["value"] = 510
|
||||||
|
moved = compile_case(graph)
|
||||||
|
self.assertTrue(moved["ok"], moved["issues"])
|
||||||
|
self.assertEqual(510, {row["id"]: row["position_mm"] for row in moved["instances"]}["i1"][2])
|
||||||
|
|
||||||
|
def test_part_outside_assembly_and_solid_interference_are_blocking(self):
|
||||||
|
outside = anchored_frame_with_drawer_assembly()
|
||||||
|
outside["instances"][-1]["placement"]["z"] = {"mode": "absolute", "value_mm": 650}
|
||||||
|
result = compile_case(outside)
|
||||||
|
self.assertIn("outside_assembly_bounds", {row["code"] for row in result["issues"]})
|
||||||
|
issue = next(row for row in result["issues"] if row["code"] == "outside_assembly_bounds")
|
||||||
|
self.assertEqual("drawer-solid", issue["instance_id"])
|
||||||
|
self.assertEqual(2, len(issue["legal_alignment_replacements"]))
|
||||||
|
self.assertEqual(0, issue["legal_alignment_replacements"][0]["value"]["offset_mm"])
|
||||||
|
|
||||||
|
collision = anchored_frame()
|
||||||
|
collision["instances"][2]["placement"]["x"] = {"mode": "absolute", "value_mm": 10}
|
||||||
|
result = compile_case(collision)
|
||||||
|
self.assertIn("solid_interference", {row["code"] for row in result["issues"]})
|
||||||
|
conflict = next(row for row in result["issues"] if row["code"] == "solid_interference")
|
||||||
|
self.assertTrue(conflict["compatible_separation_candidates"])
|
||||||
|
self.assertTrue(all(row["patches"] for row in conflict["compatible_separation_candidates"]))
|
||||||
|
|
||||||
|
def test_collision_candidate_moves_single_member_assembly_and_its_tight_bounds_together(self):
|
||||||
|
graph = anchored_door_with_hardware()
|
||||||
|
graph["assemblies"].append({
|
||||||
|
"id": "stationary", "role": "carcass", "evidence_ids": ["view"],
|
||||||
|
"bounds_mm": {"x": [0, 18], "y": [20, 100], "z": [0, 700]},
|
||||||
|
})
|
||||||
|
graph["parts"].append({
|
||||||
|
"id": "stationary-panel", "name": "静止结构件", "shape": "box",
|
||||||
|
"functional_role": "side", "thickness_axis": "x",
|
||||||
|
"size_mm": [18, 80, 700], "observed_quantity": 1,
|
||||||
|
})
|
||||||
|
graph["instances"].append({
|
||||||
|
"id": "stationary-instance", "part_id": "stationary-panel", "assembly_id": "stationary",
|
||||||
|
"placement": {
|
||||||
|
"x": {"mode": "absolute", "value_mm": 0},
|
||||||
|
"y": {"mode": "absolute", "value_mm": 20},
|
||||||
|
"z": {"mode": "absolute", "value_mm": 0},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
result = compile_case(graph)
|
||||||
|
conflict = next(
|
||||||
|
row for row in result["issues"]
|
||||||
|
if row["code"] == "solid_interference"
|
||||||
|
and {row["a"], row["b"]} == {"panel-instance", "stationary-instance"}
|
||||||
|
)
|
||||||
|
candidate = next(
|
||||||
|
row for row in conflict["compatible_separation_candidates"]
|
||||||
|
if row["moving_instance_id"] == "panel-instance"
|
||||||
|
and row["axis"] == "x" and row["translation_mm"] == 18
|
||||||
|
)
|
||||||
|
self.assertEqual(0, candidate["remaining_collision_count"])
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"/instances/panel-instance/placement/x",
|
||||||
|
"/assemblies/moving-panel/bounds_mm/x",
|
||||||
|
],
|
||||||
|
[row["path"] for row in candidate["patches"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_axis_packing_proves_when_front_member_shelf_and_back_cannot_fit(self):
|
||||||
|
shelf = {
|
||||||
|
"id": "moving", "position_mm": [0, 20, 40],
|
||||||
|
"size_mm": [100, 380, 20],
|
||||||
|
}
|
||||||
|
rows = [
|
||||||
|
shelf,
|
||||||
|
{"id": "front-left", "position_mm": [0, 0, 0], "size_mm": [50, 20, 100]},
|
||||||
|
{"id": "front-right", "position_mm": [50, 0, 0], "size_mm": [50, 20, 100]},
|
||||||
|
{"id": "back", "position_mm": [0, 382, 0], "size_mm": [100, 9, 100]},
|
||||||
|
]
|
||||||
|
result = _axis_packing_feasibility(
|
||||||
|
shelf, rows, [[0, 100], [0, 400], [0, 100]], axes=[1]
|
||||||
|
)[0]
|
||||||
|
self.assertFalse(result["feasible_without_dimension_or_bounds_change"])
|
||||||
|
self.assertEqual([], result["feasible_lower_position_intervals_mm"])
|
||||||
|
self.assertEqual(
|
||||||
|
{"front-left", "front-right", "back"},
|
||||||
|
{row["instance_id"] for row in result["blocking_instances"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
shorter = copy.deepcopy(shelf)
|
||||||
|
shorter["size_mm"][1] = 360
|
||||||
|
rows[0] = shorter
|
||||||
|
feasible = _axis_packing_feasibility(
|
||||||
|
shorter, rows, [[0, 100], [0, 400], [0, 100]], axes=[1]
|
||||||
|
)[0]
|
||||||
|
self.assertTrue(feasible["feasible_without_dimension_or_bounds_change"])
|
||||||
|
self.assertTrue(feasible["feasible_lower_position_intervals_mm"])
|
||||||
|
|
||||||
|
def test_reference_envelope_scope_requires_verified_user_or_independent_adjudication(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
graph["parameters"]["occupied_depth"] = param(420)
|
||||||
|
graph["envelope_mm"][1] = "occupied_depth"
|
||||||
|
graph["reference_bounds_mm"] = {
|
||||||
|
"x": [0, "w"], "y": [20, "occupied_depth"], "z": [0, "h"],
|
||||||
|
}
|
||||||
|
graph["dimension_scope_overrides"] = {
|
||||||
|
"y": {"ledger_scope": "reference", "adjudication_id": "depth-scope"},
|
||||||
|
}
|
||||||
|
ledger = {"y": {"value_mm": 400, "tolerance_mm": 1, "source_image_ids": ["original-front"]}}
|
||||||
|
|
||||||
|
unverified = compile_graph(
|
||||||
|
graph, source_image_ids={"original-front"}, dimension_ledger=ledger
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"dimension_scope_override_unverified",
|
||||||
|
{row["code"] for row in unverified["issues"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
verified = compile_graph(
|
||||||
|
graph,
|
||||||
|
source_image_ids={"original-front"},
|
||||||
|
dimension_ledger=ledger,
|
||||||
|
dimension_scope_adjudications={"depth-scope": {
|
||||||
|
"axis": "y", "ledger_scope": "reference", "confidence": 0.92,
|
||||||
|
"source_image_ids": ["original-front"],
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
codes = {row["code"] for row in verified["issues"]}
|
||||||
|
self.assertNotIn("dimension_scope_override_unverified", codes)
|
||||||
|
self.assertNotIn("overall_dimension_conflict", codes)
|
||||||
|
self.assertEqual("independent_source_adjudication", verified["dimension_scope_audit"][0]["verification_source"])
|
||||||
|
self.assertEqual(400, verified["dimension_scope_audit"][0]["comparison_mm"])
|
||||||
|
|
||||||
|
def test_reference_envelope_scope_accepts_exact_conversation_fact(self):
|
||||||
|
graph = anchored_frame()
|
||||||
|
graph["parameters"]["occupied_depth"] = param(420)
|
||||||
|
graph["envelope_mm"][1] = "occupied_depth"
|
||||||
|
graph["reference_bounds_mm"] = {
|
||||||
|
"x": [0, "w"], "y": [20, "occupied_depth"], "z": [0, "h"],
|
||||||
|
}
|
||||||
|
fact = {
|
||||||
|
"id": "confirmed-depth-scope", "source_type": "user",
|
||||||
|
"text": "400mm是柜体深度,不包含覆盖门", "confidence": 1.0,
|
||||||
|
}
|
||||||
|
graph["observations"].append(fact)
|
||||||
|
graph["dimension_scope_overrides"] = {
|
||||||
|
"y": {"ledger_scope": "reference", "evidence_id": fact["id"]},
|
||||||
|
}
|
||||||
|
ledger = {"y": {"value_mm": 400, "tolerance_mm": 1, "source_image_ids": ["original-front"]}}
|
||||||
|
result = compile_graph(
|
||||||
|
graph,
|
||||||
|
source_image_ids={"original-front"},
|
||||||
|
user_evidence={fact["id"]: fact},
|
||||||
|
dimension_ledger=ledger,
|
||||||
|
)
|
||||||
|
codes = {row["code"] for row in result["issues"]}
|
||||||
|
self.assertNotIn("dimension_scope_override_unverified", codes)
|
||||||
|
self.assertNotIn("overall_dimension_conflict", codes)
|
||||||
|
self.assertEqual("user", result["dimension_scope_audit"][0]["verification_source"])
|
||||||
|
|
||||||
|
outside = copy.deepcopy(graph)
|
||||||
|
outside["reference_bounds_mm"]["y"] = [-20, 400]
|
||||||
|
rejected = compile_graph(
|
||||||
|
outside,
|
||||||
|
source_image_ids={"original-front"},
|
||||||
|
user_evidence={fact["id"]: fact},
|
||||||
|
dimension_ledger=ledger,
|
||||||
|
)
|
||||||
|
self.assertIn("reference_bounds", {row["code"] for row in rejected["issues"]})
|
||||||
|
|
||||||
|
def test_anchored_duplicate_instance_id_and_loose_assembly_bounds_are_blocking(self):
|
||||||
|
duplicate = anchored_frame()
|
||||||
|
duplicate["instances"].append(copy.deepcopy(duplicate["instances"][0]))
|
||||||
|
result = compile_case(duplicate)
|
||||||
|
self.assertIn("instance_geometry", {row["code"] for row in result["issues"]})
|
||||||
|
self.assertEqual(3, len(result["instances"]))
|
||||||
|
|
||||||
|
loose_bounds = anchored_frame_with_drawer_assembly()
|
||||||
|
loose_bounds["assemblies"][1]["bounds_mm"]["z"][1] = 650
|
||||||
|
result = compile_case(loose_bounds)
|
||||||
|
self.assertIn("assembly_bounds_not_tight", {row["code"] for row in result["issues"]})
|
||||||
|
|
||||||
|
def test_panel_role_requires_a_valid_local_thickness_axis(self):
|
||||||
|
missing = frame(800, 400, 1100, 18)
|
||||||
|
missing["parts"][0].pop("thickness_axis")
|
||||||
|
self.assertIn("part_geometry", {row["code"] for row in compile_case(missing)["issues"]})
|
||||||
|
wrong = frame(800, 400, 1100, 18)
|
||||||
|
wrong["parts"][0]["thickness_axis"] = "z"
|
||||||
|
self.assertIn("part_geometry", {row["code"] for row in compile_case(wrong)["issues"]})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
609
server_patch/ai_3d_model/ai_3d_model_engine.py
Normal file
609
server_patch/ai_3d_model/ai_3d_model_engine.py
Normal file
@ -0,0 +1,609 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import resource
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
ProgressCallback = Callable[[str, str, int, int], None]
|
||||||
|
|
||||||
|
|
||||||
|
class AI3DModelError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AI3DModelEngine:
|
||||||
|
"""STEP-first text/image-to-CAD runner with an untrusted-code sandbox."""
|
||||||
|
|
||||||
|
TERMINAL_STAGES = {"completed", "failed"}
|
||||||
|
ALLOWED_IMPORT_ROOTS = {"build123d", "math"}
|
||||||
|
BANNED_CALLS = {
|
||||||
|
"compile", "eval", "exec", "getattr", "globals", "input", "locals",
|
||||||
|
"open", "setattr", "vars", "__import__",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.state_dir = pathlib.Path(
|
||||||
|
os.environ.get("DALONGXIA_AI_3D_STATE_DIR", "/var/lib/dalongxia-chat/ai-3d-model")
|
||||||
|
)
|
||||||
|
self.runtime_dir = pathlib.Path(
|
||||||
|
os.environ.get("DALONGXIA_AI_3D_RUNTIME_DIR", "/opt/dalongxia-cad-runtime")
|
||||||
|
)
|
||||||
|
self.skill_dir = pathlib.Path(
|
||||||
|
os.environ.get("DALONGXIA_AI_3D_SKILL_DIR", "/opt/dalongxia-chat-api/ai-3d-model/cad")
|
||||||
|
)
|
||||||
|
self.model = os.environ.get("DALONGXIA_AI_3D_MODEL", "qwen3-coder-plus")
|
||||||
|
self.vision_model = os.environ.get("DALONGXIA_AI_3D_VISION_MODEL", "qwen-vl-plus")
|
||||||
|
self.api_base = os.environ.get(
|
||||||
|
"DALONGXIA_AI_3D_API_BASE",
|
||||||
|
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
).rstrip("/")
|
||||||
|
self.timeout = max(60, int(os.environ.get("DALONGXIA_AI_3D_TIMEOUT", "300")))
|
||||||
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(self.state_dir / "jobs").mkdir(parents=True, exist_ok=True)
|
||||||
|
self.db_path = self.state_dir / "jobs.sqlite3"
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(self.db_path, timeout=20)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _init_db(self) -> None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
job_id TEXT PRIMARY KEY,
|
||||||
|
context_key TEXT NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
display_text TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
result_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
error TEXT NOT NULL DEFAULT ''
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_ai3d_context_updated ON jobs(context_key, updated_at DESC)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def context_key(conversation_id: str, user_id: str, session_key: str) -> str:
|
||||||
|
raw = "|".join([str(user_id or ""), str(conversation_id or ""), str(session_key or "")])
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def _insert_job(self, context_key: str, prompt: str) -> dict:
|
||||||
|
job_id = f"cad_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
||||||
|
now = self._now()
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO jobs(job_id,context_key,prompt,stage,display_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
|
||||||
|
(job_id, context_key, prompt, "queued", "任务已进入CAD生成队列", now, now),
|
||||||
|
)
|
||||||
|
return self.get_job(job_id) or {}
|
||||||
|
|
||||||
|
def _update_job(self, job_id: str, stage: str, display_text: str, *, result=None, error="") -> dict:
|
||||||
|
now = self._now()
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE jobs SET stage=?,display_text=?,updated_at=?,result_json=?,error=? WHERE job_id=?",
|
||||||
|
(stage, display_text, now, json.dumps(result or {}, ensure_ascii=False), str(error or "")[:4000], job_id),
|
||||||
|
)
|
||||||
|
return self.get_job(job_id) or {}
|
||||||
|
|
||||||
|
def get_job(self, job_id: str) -> dict | None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
value = dict(row)
|
||||||
|
try:
|
||||||
|
value["result"] = json.loads(value.pop("result_json") or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
value["result"] = {}
|
||||||
|
return value
|
||||||
|
|
||||||
|
def latest_job(self, context_key: str) -> dict | None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT job_id FROM jobs WHERE context_key=? ORDER BY updated_at DESC LIMIT 1",
|
||||||
|
(context_key,),
|
||||||
|
).fetchone()
|
||||||
|
return self.get_job(str(row["job_id"])) if row else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_entry_only(message: str) -> bool:
|
||||||
|
compact = re.sub(r"[\s,。!?!?、::]+", "", str(message or "").lower())
|
||||||
|
compact = re.sub(r"【技能路由[::]ai-3d-model】", "", compact)
|
||||||
|
return compact in {"", "ai画3d模型", "ai画3d", "生成3d模型", "文字生成cad"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_progress_request(message: str) -> bool:
|
||||||
|
text = str(message or "").strip()
|
||||||
|
return bool(re.fullmatch(r"(?:继续|进度|查看进度|现在怎么样|完成了吗|结果呢)[??。\s]*", text))
|
||||||
|
|
||||||
|
def _api_key(self) -> str:
|
||||||
|
direct = os.environ.get("DASHSCOPE_API_KEY", "").strip()
|
||||||
|
if direct:
|
||||||
|
return direct
|
||||||
|
key_path = os.environ.get("DASHSCOPE_API_KEY_FILE", "").strip()
|
||||||
|
if key_path:
|
||||||
|
try:
|
||||||
|
return pathlib.Path(key_path).read_text(encoding="utf-8").strip()
|
||||||
|
except OSError as exc:
|
||||||
|
raise AI3DModelError("CAD模型服务密钥文件不可读") from exc
|
||||||
|
raise AI3DModelError("CAD模型服务尚未配置密钥")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_image_intent(prompt: str) -> bool:
|
||||||
|
text = re.sub(r"\s+", "", str(prompt or "")).lower()
|
||||||
|
return any(
|
||||||
|
marker in text
|
||||||
|
for marker in (
|
||||||
|
"根据图片", "根据上传", "按图", "参考图", "示意图", "工程图",
|
||||||
|
"图中标注", "照着图", "图片创建", "图片生成", "image-to-cad",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _vision_content(image_inputs: list[dict], prompt: str) -> list[dict]:
|
||||||
|
content: list[dict] = []
|
||||||
|
for item in image_inputs[:4]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
kind = str(item.get("kind") or "")
|
||||||
|
value = str(item.get("value") or "").strip()
|
||||||
|
if kind == "image_url" and re.match(r"^https?://", value, re.I):
|
||||||
|
content.append({"type": "image_url", "image_url": {"url": value}})
|
||||||
|
elif kind == "image_base64" and value.startswith("data:image/"):
|
||||||
|
content.append({"type": "image_url", "image_url": {"url": value}})
|
||||||
|
content.append(
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": (
|
||||||
|
"请作为机械CAD读图工程师,只提取图像中有证据的几何与尺寸。"
|
||||||
|
"识别外形、孔/槽/凸台、对称关系、截面、标注数值与单位。"
|
||||||
|
"不要凭空添加图中没有的结构;看不清的内容必须放入uncertainties。"
|
||||||
|
"返回一个JSON对象,字段为summary、geometry、dimensions、uncertainties、modeling_instruction。\n"
|
||||||
|
f"用户要求:{prompt.strip()}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
|
||||||
|
def _visual_spec(self, prompt: str, image_inputs: list[dict]) -> dict:
|
||||||
|
content = self._vision_content(image_inputs, prompt)
|
||||||
|
if not any(item.get("type") == "image_url" for item in content):
|
||||||
|
raise AI3DModelError("图片内容不完整,请上传可读取的PNG/JPG/WebP图片")
|
||||||
|
body = {
|
||||||
|
"model": self.vision_model,
|
||||||
|
"messages": [{"role": "user", "content": content}],
|
||||||
|
"temperature": 0.0,
|
||||||
|
}
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.api_base}/chat/completions",
|
||||||
|
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
||||||
|
headers={"Authorization": f"Bearer {self._api_key()}", "Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
content_value = payload["choices"][0]["message"]["content"]
|
||||||
|
if isinstance(content_value, list):
|
||||||
|
content_value = "".join(
|
||||||
|
str(item.get("text") or "") for item in content_value if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
raw = str(content_value or "").strip()
|
||||||
|
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.S)
|
||||||
|
match = re.search(r"\{.*\}", raw, flags=re.S)
|
||||||
|
spec = json.loads(match.group(0) if match else raw)
|
||||||
|
except (urllib.error.URLError, TimeoutError, KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
|
||||||
|
raise AI3DModelError(f"CAD图片理解失败:{exc}") from exc
|
||||||
|
if not isinstance(spec, dict) or not str(spec.get("modeling_instruction") or "").strip():
|
||||||
|
raise AI3DModelError("CAD图片理解未返回可建模的结构和尺寸")
|
||||||
|
return spec
|
||||||
|
|
||||||
|
def _model_source(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
repair_error: str = "",
|
||||||
|
previous_source: str = "",
|
||||||
|
visual_spec: dict | None = None,
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
system = """你是参数化机械CAD工程师。把中文需求转换成安全的 build123d Python。
|
||||||
|
只返回一个JSON对象:{"title":"简短中文名","assumptions":["..."],"source":"..."}。
|
||||||
|
source必须:1) 只允许 import math 或 from build123d import ...;2) 定义无参数函数 gen_step();
|
||||||
|
3) 返回一个闭合的正体积 Part、Solid、Compound 或带标签的装配;4) 单位毫米;5) 使用明确命名参数;
|
||||||
|
6) 不读写文件、不访问网络、环境变量或系统,不使用动态执行;7) 不调用 show_object;8) 简单需求也要完整建模。
|
||||||
|
优先使用 Box、Cylinder、Sphere、Cone、BuildPart、BuildSketch、Locations、extrude、fillet、chamfer、Hole。
|
||||||
|
不要返回Markdown代码围栏,不要臆造工程认证或承载能力。"""
|
||||||
|
user = f"用户CAD需求:\n{prompt.strip()}"
|
||||||
|
if visual_spec:
|
||||||
|
user += (
|
||||||
|
"\n\n以下是视觉模型从用户图片提取的读图结果。"
|
||||||
|
"建模必须以其中可见标注为准,不得用常见尺寸替换:\n"
|
||||||
|
+ json.dumps(visual_spec, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
if repair_error:
|
||||||
|
user += f"\n\n上一次源码构建失败,请仅修复导致失败的部分。错误:\n{repair_error[-3000:]}"
|
||||||
|
user += f"\n\n上一次源码:\n{previous_source[-10000:]}"
|
||||||
|
body = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||||||
|
"temperature": 0.1,
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
}
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.api_base}/chat/completions",
|
||||||
|
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
||||||
|
headers={"Authorization": f"Bearer {self._api_key()}", "Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||||
|
raise AI3DModelError(f"CAD模型服务调用失败:{exc}") from exc
|
||||||
|
try:
|
||||||
|
content = payload["choices"][0]["message"]["content"]
|
||||||
|
if isinstance(content, list):
|
||||||
|
content = "".join(str(x.get("text") or "") for x in content if isinstance(x, dict))
|
||||||
|
text = str(content).strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.S)
|
||||||
|
result = json.loads(text)
|
||||||
|
source = str(result.get("source") or "").strip()
|
||||||
|
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
|
||||||
|
raise AI3DModelError("CAD模型服务未返回有效源码") from exc
|
||||||
|
self._validate_source(source)
|
||||||
|
return source, result
|
||||||
|
|
||||||
|
def _validate_source(self, source: str) -> None:
|
||||||
|
if not source or len(source) > 50_000:
|
||||||
|
raise AI3DModelError("生成的CAD源码为空或过长")
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source)
|
||||||
|
except SyntaxError as exc:
|
||||||
|
raise AI3DModelError(f"生成源码语法错误:{exc.msg}") from exc
|
||||||
|
has_gen_step = False
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||||
|
names = [alias.name.split(".")[0] for alias in node.names] if isinstance(node, ast.Import) else [(node.module or "").split(".")[0]]
|
||||||
|
if any(name not in self.ALLOWED_IMPORT_ROOTS for name in names):
|
||||||
|
raise AI3DModelError("生成源码包含不允许的模块")
|
||||||
|
elif isinstance(node, ast.FunctionDef) and node.name == "gen_step" and not node.args.args:
|
||||||
|
has_gen_step = True
|
||||||
|
elif isinstance(node, ast.Call):
|
||||||
|
name = node.func.id if isinstance(node.func, ast.Name) else ""
|
||||||
|
if name in self.BANNED_CALLS:
|
||||||
|
raise AI3DModelError("生成源码包含不安全调用")
|
||||||
|
elif isinstance(node, ast.Attribute) and str(node.attr).startswith("__"):
|
||||||
|
raise AI3DModelError("生成源码包含不安全属性访问")
|
||||||
|
if not has_gen_step:
|
||||||
|
raise AI3DModelError("生成源码缺少无参数 gen_step()")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _limits() -> None:
|
||||||
|
resource.setrlimit(resource.RLIMIT_CPU, (180, 180))
|
||||||
|
resource.setrlimit(resource.RLIMIT_AS, (5 * 1024**3, 5 * 1024**3))
|
||||||
|
resource.setrlimit(resource.RLIMIT_FSIZE, (512 * 1024**2, 512 * 1024**2))
|
||||||
|
resource.setrlimit(resource.RLIMIT_NOFILE, (256, 256))
|
||||||
|
# RLIMIT_NPROC is per Linux uid, and the shared Chat API uid already owns
|
||||||
|
# browser workers. Keep a finite ceiling without blocking namespace setup.
|
||||||
|
resource.setrlimit(resource.RLIMIT_NPROC, (2048, 2048))
|
||||||
|
|
||||||
|
def _sandbox_command(self, job_dir: pathlib.Path, argv: list[str]) -> list[str]:
|
||||||
|
command = [
|
||||||
|
"bwrap", "--unshare-net", "--unshare-pid", "--unshare-ipc", "--unshare-uts",
|
||||||
|
"--die-with-parent", "--new-session",
|
||||||
|
"--tmpfs", "/", "--ro-bind", "/proc", "/proc", "--dev", "/dev", "--tmpfs", "/dev/shm",
|
||||||
|
"--dir", "/opt", "--dir", "/opt/dalongxia-cad-runtime",
|
||||||
|
"--dir", "/opt/dalongxia-chat-api", "--dir", "/opt/dalongxia-chat-api/ai-3d-model",
|
||||||
|
"--dir", "/work", "--tmpfs", "/tmp",
|
||||||
|
"--ro-bind", "/usr", "/usr", "--ro-bind", "/lib", "/lib",
|
||||||
|
"--ro-bind", "/etc", "/etc", "--ro-bind", "/sys", "/sys",
|
||||||
|
]
|
||||||
|
if pathlib.Path("/lib64").exists():
|
||||||
|
command += ["--ro-bind", "/lib64", "/lib64"]
|
||||||
|
command += [
|
||||||
|
"--ro-bind", str(self.runtime_dir), str(self.runtime_dir),
|
||||||
|
"--ro-bind", str(self.skill_dir), str(self.skill_dir),
|
||||||
|
"--bind", str(job_dir), "/work", "--chdir", "/work",
|
||||||
|
"--setenv", "HOME", "/tmp", "--setenv", "TMPDIR", "/tmp",
|
||||||
|
"--setenv", "PATH", f"{self.runtime_dir}/venv/bin:/usr/bin:/bin",
|
||||||
|
"--clearenv",
|
||||||
|
]
|
||||||
|
# Environment is rebuilt after clearenv by bwrap in command order.
|
||||||
|
command += [
|
||||||
|
"--setenv", "HOME", "/tmp", "--setenv", "TMPDIR", "/tmp",
|
||||||
|
"--setenv", "PATH", f"{self.runtime_dir}/venv/bin:/usr/bin:/bin",
|
||||||
|
"--setenv", "PLAYWRIGHT_BROWSERS_PATH", f"{self.runtime_dir}/ms-playwright",
|
||||||
|
]
|
||||||
|
return command + argv
|
||||||
|
|
||||||
|
def _run(self, job_dir: pathlib.Path, argv: list[str], *, stdin: str = "", timeout: int = 240) -> subprocess.CompletedProcess:
|
||||||
|
proc = subprocess.run(
|
||||||
|
self._sandbox_command(job_dir, argv), input=stdin, text=True,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout,
|
||||||
|
preexec_fn=self._limits, env={"PATH": "/usr/bin:/bin"},
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = (proc.stderr or proc.stdout or "CAD工具执行失败")[-5000:]
|
||||||
|
raise AI3DModelError(detail)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
def _run_trusted_snapshot(
|
||||||
|
self,
|
||||||
|
job_dir: pathlib.Path,
|
||||||
|
argv: list[str],
|
||||||
|
*,
|
||||||
|
stdin: str,
|
||||||
|
timeout: int,
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
"""Render an already-validated STEP outside bwrap.
|
||||||
|
|
||||||
|
Playwright cannot spawn Chromium from this host's unprivileged user
|
||||||
|
namespace. The renderer receives only the inert STEP artifact, never the
|
||||||
|
generated Python source, and gets a minimal environment.
|
||||||
|
"""
|
||||||
|
env = {
|
||||||
|
"HOME": "/tmp",
|
||||||
|
"TMPDIR": "/tmp",
|
||||||
|
"PATH": f"{self.runtime_dir}/venv/bin:/usr/bin:/bin",
|
||||||
|
"PLAYWRIGHT_BROWSERS_PATH": str(self.runtime_dir / "ms-playwright"),
|
||||||
|
}
|
||||||
|
proc = subprocess.run(
|
||||||
|
argv, input=stdin, text=True, cwd=job_dir,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = (proc.stderr or proc.stdout or "CAD预览执行失败")[-5000:]
|
||||||
|
raise AI3DModelError(detail)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _requested_exports(prompt: str) -> list[str]:
|
||||||
|
upper = str(prompt or "").upper()
|
||||||
|
return [fmt for fmt in ("STL", "3MF", "GLB") if fmt in upper]
|
||||||
|
|
||||||
|
def _build_and_validate(
|
||||||
|
self,
|
||||||
|
job_dir: pathlib.Path,
|
||||||
|
source_name: str,
|
||||||
|
requested_exports: list[str] | None = None,
|
||||||
|
progress: ProgressCallback | None = None,
|
||||||
|
) -> dict:
|
||||||
|
python = str(self.runtime_dir / "venv/bin/python")
|
||||||
|
gen = str(self.skill_dir / "scripts/gen")
|
||||||
|
inspect_tool = str(self.skill_dir / "scripts/inspect")
|
||||||
|
snapshot = str(self.skill_dir / "scripts/snapshot")
|
||||||
|
self._run(job_dir, [python, gen, source_name, "--write", "--json"], timeout=self.timeout)
|
||||||
|
step_name = source_name[:-3]
|
||||||
|
step_path = job_dir / step_name
|
||||||
|
if not step_path.is_file() or step_path.stat().st_size < 100:
|
||||||
|
raise AI3DModelError("STEP文件没有生成")
|
||||||
|
if progress:
|
||||||
|
progress("validating", "STEP几何已生成,正在复核闭合性、体积和尺寸边界", 4, 5)
|
||||||
|
validation = self._run(job_dir, [python, inspect_tool, "validate", step_name], timeout=self.timeout)
|
||||||
|
# Per-solid validity does not detect two individually valid parts
|
||||||
|
# occupying the same volume. Run the geometry kernel's pairwise
|
||||||
|
# boolean-intersection audit before producing a preview or claiming a
|
||||||
|
# usable assembly. The inspect command exits non-zero on a clash, so
|
||||||
|
# _run turns it into a repairable CAD failure instead of a false pass.
|
||||||
|
interference = self._run(
|
||||||
|
job_dir,
|
||||||
|
# The bundled inspect CLI exposes structured output through
|
||||||
|
# ``--format json`` (and already defaults to JSON on newer
|
||||||
|
# versions). ``--json`` is not accepted by the runtime currently
|
||||||
|
# installed on the server.
|
||||||
|
[python, inspect_tool, "interfere", step_name, "--format", "json"],
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
refs = self._run(
|
||||||
|
job_dir,
|
||||||
|
[python, inspect_tool, "refs", step_name, "--facts", "--planes", "--positioning"],
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
preview_base = str(job_dir / "preview.png")
|
||||||
|
snapshot_job = {
|
||||||
|
"input": str(step_path), "mode": "view",
|
||||||
|
"outputs": [{"path": preview_base, "camera": "iso"}],
|
||||||
|
"render": {"viewLabels": True, "padding": 0.12, "sizeProfile": "diagnostic"},
|
||||||
|
}
|
||||||
|
if progress:
|
||||||
|
progress("rendering", "几何校验通过,正在生成等轴预览图", 4, 5)
|
||||||
|
self._run_trusted_snapshot(
|
||||||
|
job_dir,
|
||||||
|
[python, snapshot, "--job", "-", "--json"],
|
||||||
|
stdin=json.dumps(snapshot_job),
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
previews = sorted(job_dir.glob("preview*.png"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
if not previews:
|
||||||
|
raise AI3DModelError("模型预览图没有生成")
|
||||||
|
result = {
|
||||||
|
"step_path": str(step_path), "source_path": str(job_dir / source_name),
|
||||||
|
"preview_path": str(previews[0]), "validation": validation.stdout.strip()[-5000:],
|
||||||
|
"interference": interference.stdout.strip()[-12000:],
|
||||||
|
"facts": refs.stdout.strip()[-12000:],
|
||||||
|
}
|
||||||
|
formats = [str(item).upper() for item in (requested_exports or [])]
|
||||||
|
if formats:
|
||||||
|
export_tool = str(self.skill_dir / "scripts/export")
|
||||||
|
flags = [f"--{fmt.lower()}" for fmt in formats]
|
||||||
|
self._run(job_dir, [python, export_tool, step_name, *flags, "--json"], timeout=self.timeout)
|
||||||
|
for fmt in formats:
|
||||||
|
export_path = step_path.with_suffix(f".{fmt.lower()}")
|
||||||
|
if not export_path.is_file() or export_path.stat().st_size < 100:
|
||||||
|
raise AI3DModelError(f"{fmt}文件没有生成")
|
||||||
|
result[f"{fmt.lower()}_path"] = str(export_path)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
conversation_id: str,
|
||||||
|
user_id: str,
|
||||||
|
session_key: str,
|
||||||
|
progress: ProgressCallback | None = None,
|
||||||
|
image_inputs: list[dict] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
image_inputs = [item for item in (image_inputs or []) if isinstance(item, dict)][:4]
|
||||||
|
context_key = self.context_key(conversation_id, user_id, session_key)
|
||||||
|
if self._is_entry_only(message):
|
||||||
|
return {
|
||||||
|
"status": "waiting",
|
||||||
|
"answer": "### ai画3d模型\n\n请直接在对话框描述要画的模型和关键尺寸。默认单位为毫米,主文件为 STEP。\n\n例如:`画一个80×50×4毫米的L形安装支架,两边各有2个直径5毫米安装孔,圆角2毫米,同时导出STL。`",
|
||||||
|
"files": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._is_progress_request(message):
|
||||||
|
latest = self.latest_job(context_key)
|
||||||
|
if not latest:
|
||||||
|
return {"status": "waiting", "answer": "### ai画3d模型\n\n当前会话还没有CAD任务,请直接描述模型和尺寸。", "files": []}
|
||||||
|
result = latest.get("result") or {}
|
||||||
|
if latest["stage"] == "completed":
|
||||||
|
return {"status": "completed", "answer": self._completed_answer(latest), "files": result.get("files") or []}
|
||||||
|
if latest["stage"] == "failed":
|
||||||
|
return {"status": "failed", "answer": f"### ai画3d模型未完成\n\n{latest['display_text']}\n\n失败原因:{latest['error']}", "files": []}
|
||||||
|
return {"status": "waiting", "answer": f"### ai画3d模型进度\n\n{latest['display_text']}\n\n任务号:`{latest['job_id']}`", "files": []}
|
||||||
|
|
||||||
|
if self._has_image_intent(message) and not image_inputs:
|
||||||
|
return {
|
||||||
|
"status": "waiting",
|
||||||
|
"answer": (
|
||||||
|
"### ai画3d模型\n\n我收到了按图建模的要求,但当前请求没有可读取的图片内容。"
|
||||||
|
"请直接上传PNG、JPG或WebP图片,不会在没有读图证据时自行猜测尺寸。"
|
||||||
|
),
|
||||||
|
"files": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
job = self._insert_job(context_key, message)
|
||||||
|
job_id = str(job["job_id"])
|
||||||
|
job_dir = self.state_dir / "jobs" / job_id
|
||||||
|
job_dir.mkdir(parents=True, exist_ok=False)
|
||||||
|
source_name = "model.step.py"
|
||||||
|
assumptions: list[str] = []
|
||||||
|
|
||||||
|
def emit(stage: str, text: str, current: int) -> None:
|
||||||
|
self._update_job(job_id, stage, text)
|
||||||
|
if progress:
|
||||||
|
progress(stage, text, current, 5)
|
||||||
|
|
||||||
|
def build_progress(stage: str, text: str, current: int, total: int) -> None:
|
||||||
|
self._update_job(job_id, stage, text)
|
||||||
|
if progress:
|
||||||
|
progress(stage, text, current, total)
|
||||||
|
|
||||||
|
try:
|
||||||
|
visual_spec: dict | None = None
|
||||||
|
if image_inputs:
|
||||||
|
emit("vision", "正在识别图片中的轮廓、孔位、尺寸标注和建模约束", 1)
|
||||||
|
visual_spec = self._visual_spec(message, image_inputs)
|
||||||
|
emit("specifying", "图片读图完成,正在把可见标注整理为参数化建模要求", 2)
|
||||||
|
else:
|
||||||
|
emit("specifying", "正在把文字需求整理为尺寸、特征和建模约束", 1)
|
||||||
|
source = ""
|
||||||
|
last_error = ""
|
||||||
|
meta: dict = {}
|
||||||
|
for attempt in range(1, 3):
|
||||||
|
emit("coding", "正在生成参数化CAD源码" if attempt == 1 else "首次构建未通过,正在自动修复源码", 2)
|
||||||
|
source, meta = self._model_source(
|
||||||
|
message,
|
||||||
|
last_error,
|
||||||
|
source,
|
||||||
|
visual_spec=visual_spec,
|
||||||
|
)
|
||||||
|
(job_dir / source_name).write_text(source + "\n", encoding="utf-8")
|
||||||
|
assumptions = [str(x) for x in (meta.get("assumptions") or []) if str(x).strip()][:10]
|
||||||
|
try:
|
||||||
|
emit("building", "正在无网络隔离沙箱中构建STEP几何", 3)
|
||||||
|
built = self._build_and_validate(
|
||||||
|
job_dir,
|
||||||
|
source_name,
|
||||||
|
requested_exports=self._requested_exports(message),
|
||||||
|
progress=build_progress,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = str(exc)
|
||||||
|
if attempt >= 2:
|
||||||
|
raise
|
||||||
|
result = {
|
||||||
|
**built, "assumptions": assumptions, "title": str(meta.get("title") or "3D模型"),
|
||||||
|
"files": [],
|
||||||
|
}
|
||||||
|
emit("delivering", "模型、源码和预览已生成,正在上传交付文件", 4)
|
||||||
|
self._update_job(job_id, "delivering", "模型已完成并通过几何校验,正在上传交付文件", result=result)
|
||||||
|
completed = self.get_job(job_id) or {}
|
||||||
|
return {
|
||||||
|
"status": "completed", "answer": self._completed_answer(completed),
|
||||||
|
"files": [], "local_files": built, "job_id": job_id,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
friendly = re.sub(r"\s+", " ", str(exc)).strip()[-1000:] or "CAD生成失败"
|
||||||
|
self._update_job(job_id, "failed", "模型生成未完成", error=friendly)
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"answer": f"### ai画3d模型未完成\n\n任务没有被错误标记为成功。系统已自动生成并修复两轮,但几何校验仍未通过。\n\n失败原因:{friendly}",
|
||||||
|
"files": [], "job_id": job_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def attach_files(self, job_id: str, files: list[dict]) -> dict | None:
|
||||||
|
job = self.get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
result = dict(job.get("result") or {})
|
||||||
|
result["files"] = [dict(item) for item in files if isinstance(item, dict)]
|
||||||
|
return self._update_job(
|
||||||
|
job_id,
|
||||||
|
"completed",
|
||||||
|
"模型已完成并通过几何校验,交付文件已上传",
|
||||||
|
result=result,
|
||||||
|
error=str(job.get("error") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_delivery_failed(self, job_id: str, error: str) -> dict | None:
|
||||||
|
job = self.get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
return self._update_job(
|
||||||
|
job_id,
|
||||||
|
"failed",
|
||||||
|
"模型已生成,但交付文件上传失败",
|
||||||
|
result=dict(job.get("result") or {}),
|
||||||
|
error=str(error or "文件上传失败")[:1000],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _completed_answer(job: dict) -> str:
|
||||||
|
result = job.get("result") or {}
|
||||||
|
assumptions = result.get("assumptions") or []
|
||||||
|
assumptions_text = "\n".join(f"- {x}" for x in assumptions) or "- 未使用额外假设"
|
||||||
|
return (
|
||||||
|
f"### ai画3d模型完成\n\n模型已生成,并完成 STEP 几何校验与预览复核。\n\n"
|
||||||
|
f"- 任务号:`{job.get('job_id')}`\n- 模型:{result.get('title') or '3D模型'}\n"
|
||||||
|
f"- 主交付:STEP\n- 可编辑源码:build123d Python\n\n使用的建模假设:\n{assumptions_text}"
|
||||||
|
)
|
||||||
73
server_patch/ai_3d_model/test_ai_3d_model_engine.py
Normal file
73
server_patch/ai_3d_model/test_ai_3d_model_engine.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from ai_3d_model_engine import AI3DModelEngine, AI3DModelError
|
||||||
|
|
||||||
|
|
||||||
|
class BuildValidationPipelineTest(unittest.TestCase):
|
||||||
|
def engine(self):
|
||||||
|
value = AI3DModelEngine.__new__(AI3DModelEngine)
|
||||||
|
value.runtime_dir = pathlib.Path("/runtime")
|
||||||
|
value.skill_dir = pathlib.Path("/skill")
|
||||||
|
value.timeout = 30
|
||||||
|
return value
|
||||||
|
|
||||||
|
def test_interference_runs_before_preview_and_is_returned(self):
|
||||||
|
engine = self.engine()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run(job_dir, argv, *, stdin="", timeout=0):
|
||||||
|
calls.append(("run", list(argv)))
|
||||||
|
if "gen" in argv[1]:
|
||||||
|
(job_dir / "model.step").write_bytes(b"STEP" * 40)
|
||||||
|
command = argv[2] if len(argv) > 2 else ""
|
||||||
|
return subprocess.CompletedProcess(argv, 0, stdout=json.dumps({"ok": True, "command": command}), stderr="")
|
||||||
|
|
||||||
|
def fake_snapshot(job_dir, argv, *, stdin, timeout):
|
||||||
|
calls.append(("snapshot", list(argv)))
|
||||||
|
payload = json.loads(stdin)
|
||||||
|
pathlib.Path(payload["outputs"][0]["path"]).write_bytes(b"PNG")
|
||||||
|
return subprocess.CompletedProcess(argv, 0, stdout="{}", stderr="")
|
||||||
|
|
||||||
|
engine._run = fake_run
|
||||||
|
engine._run_trusted_snapshot = fake_snapshot
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
result = engine._build_and_validate(pathlib.Path(folder), "model.step.py")
|
||||||
|
commands = [argv[2] for kind, argv in calls if kind == "run" and len(argv) > 2]
|
||||||
|
self.assertLess(commands.index("validate"), commands.index("interfere"))
|
||||||
|
self.assertLess(commands.index("interfere"), commands.index("refs"))
|
||||||
|
interfere_call = next(
|
||||||
|
argv for kind, argv in calls if kind == "run" and len(argv) > 2 and argv[2] == "interfere"
|
||||||
|
)
|
||||||
|
self.assertEqual(["--format", "json"], interfere_call[-2:])
|
||||||
|
self.assertEqual("interfere", json.loads(result["interference"])["command"])
|
||||||
|
|
||||||
|
def test_interference_failure_blocks_preview(self):
|
||||||
|
engine = self.engine()
|
||||||
|
snapshot_called = False
|
||||||
|
|
||||||
|
def fake_run(job_dir, argv, *, stdin="", timeout=0):
|
||||||
|
if "gen" in argv[1]:
|
||||||
|
(job_dir / "model.step").write_bytes(b"STEP" * 40)
|
||||||
|
if len(argv) > 2 and argv[2] == "interfere":
|
||||||
|
raise AI3DModelError("two parts overlap")
|
||||||
|
return subprocess.CompletedProcess(argv, 0, stdout="{}", stderr="")
|
||||||
|
|
||||||
|
def fake_snapshot(job_dir, argv, *, stdin, timeout):
|
||||||
|
nonlocal snapshot_called
|
||||||
|
snapshot_called = True
|
||||||
|
return subprocess.CompletedProcess(argv, 0, stdout="{}", stderr="")
|
||||||
|
|
||||||
|
engine._run = fake_run
|
||||||
|
engine._run_trusted_snapshot = fake_snapshot
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
with self.assertRaisesRegex(AI3DModelError, "overlap"):
|
||||||
|
engine._build_and_validate(pathlib.Path(folder), "model.step.py")
|
||||||
|
self.assertFalse(snapshot_called)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
34
server_patch/aliyun_key_hot_reload/README.md
Normal file
34
server_patch/aliyun_key_hot_reload/README.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Aliyun key hot reload
|
||||||
|
|
||||||
|
This patch closes two key-rotation gaps in the production Chat API without
|
||||||
|
storing any credential in Git:
|
||||||
|
|
||||||
|
1. `video_gateway_api_key()` prefers a protected `*_API_KEY_FILE` on every
|
||||||
|
call, so the AI skill router cannot keep using the startup-cached value.
|
||||||
|
2. `CodexAppServerProcess` fingerprints the protected provider key before
|
||||||
|
every request. When the file changes and no request is pending, it replaces
|
||||||
|
only the persistent Codex child and resumes the existing thread.
|
||||||
|
|
||||||
|
Production base hashes used by this patch:
|
||||||
|
|
||||||
|
- `chat_api.py`: `de81004c50a96fdf2e0064340edab213139da6ef304014c1586a4059871a8a1b`
|
||||||
|
- `codex_app_server_runtime.py`: `13c4463c6226840ae1a751279b4ec830796fb8cb46e2491f245bbdfebc71dfb9`
|
||||||
|
|
||||||
|
Deployed hashes on 2026-09-08:
|
||||||
|
|
||||||
|
- `chat_api.py`: `878e4e0d4c7e5b91190f53b64dccca3154fa3a189ea006d2182d0930a9079c4f`
|
||||||
|
- `codex_app_server_runtime.py`: `1ba04cbd15842114c77e89e71c7f36a43a24bc77bac22fd88eeb5d714b573cb3`
|
||||||
|
|
||||||
|
The deployment was gated on zero queued/running durable tasks and zero real
|
||||||
|
Chat API requests in the previous five minutes. A backup was created at
|
||||||
|
`/opt/dalongxia-chat-api/backups/aliyun-key-hot-reload-mLOvGl` before the
|
||||||
|
service restart.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- the supplied key file fingerprint matched the protected server key file;
|
||||||
|
- a direct `qwen3-vl-plus` request returned HTTP 200;
|
||||||
|
- the production health endpoint returned HTTP 200 after restart;
|
||||||
|
- the next two Codex shadow records completed successfully, while the three
|
||||||
|
immediately preceding records had failed with `CodexAppServerError`;
|
||||||
|
- no post-deployment `InvalidApiKey` or `API-key is blocked` error appeared.
|
||||||
42
server_patch/aliyun_key_hot_reload/chat_api.patch
Normal file
42
server_patch/aliyun_key_hot_reload/chat_api.patch
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
--- chat_api.py
|
||||||
|
+++ chat_api.py
|
||||||
|
@@ -2157,7 +2157,12 @@
|
||||||
|
def video_gateway_api_key() -> str:
|
||||||
|
env = build_tool_env()
|
||||||
|
for key in ("DALONGXIA_VIDEO_GATEWAY_API_KEY", "DASHSCOPE_API_KEY", "BAILIAN_API_KEY", "QWEN_API_KEY"):
|
||||||
|
- value = (os.getenv(key) or env.get(key) or "").strip()
|
||||||
|
+ file_name = f"{key}_FILE"
|
||||||
|
+ configured_path = (os.getenv(file_name) or env.get(file_name) or "").strip()
|
||||||
|
+ if configured_path:
|
||||||
|
+ value = read_protected_secret_file(file_name, configured_path).strip()
|
||||||
|
+ else:
|
||||||
|
+ value = (os.getenv(key) or env.get(key) or "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
@@ -2165,11 +2170,22 @@
|
||||||
|
|
||||||
|
def ai_skill_router_api_config() -> dict[str, str]:
|
||||||
|
env = build_tool_env()
|
||||||
|
- explicit_key = (
|
||||||
|
- os.getenv("DALONGXIA_AI_SKILL_ROUTER_API_KEY")
|
||||||
|
- or env.get("DALONGXIA_AI_SKILL_ROUTER_API_KEY")
|
||||||
|
+ explicit_key_file = (
|
||||||
|
+ os.getenv("DALONGXIA_AI_SKILL_ROUTER_API_KEY_FILE")
|
||||||
|
+ or env.get("DALONGXIA_AI_SKILL_ROUTER_API_KEY_FILE")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
+ explicit_key = (
|
||||||
|
+ read_protected_secret_file(
|
||||||
|
+ "DALONGXIA_AI_SKILL_ROUTER_API_KEY_FILE", explicit_key_file,
|
||||||
|
+ ).strip()
|
||||||
|
+ if explicit_key_file
|
||||||
|
+ else (
|
||||||
|
+ os.getenv("DALONGXIA_AI_SKILL_ROUTER_API_KEY")
|
||||||
|
+ or env.get("DALONGXIA_AI_SKILL_ROUTER_API_KEY")
|
||||||
|
+ or ""
|
||||||
|
+ ).strip()
|
||||||
|
+ )
|
||||||
|
if explicit_key:
|
||||||
|
return {
|
||||||
|
"api_key": explicit_key,
|
||||||
@ -0,0 +1,221 @@
|
|||||||
|
--- codex_app_server_runtime.py
|
||||||
|
+++ codex_app_server_runtime.py
|
||||||
|
@@ -546,12 +546,36 @@
|
||||||
|
self._notifications: deque[tuple[int, dict[str, Any]]] = deque(maxlen=10000)
|
||||||
|
self._notification_sequence = 0
|
||||||
|
self._condition = threading.Condition()
|
||||||
|
+ self._lifecycle_lock = threading.RLock()
|
||||||
|
self._closed = False
|
||||||
|
self._stderr_tail: deque[str] = deque(maxlen=30)
|
||||||
|
+ self._started_provider_key_fingerprint = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return bool(self._process is not None and self._process.poll() is None)
|
||||||
|
+
|
||||||
|
+ def _provider_key_value(self) -> str:
|
||||||
|
+ if self.config.provider_api_key_file:
|
||||||
|
+ key_path = Path(self.config.provider_api_key_file).expanduser()
|
||||||
|
+ try:
|
||||||
|
+ key_value = key_path.read_text(encoding="utf-8").strip()
|
||||||
|
+ except OSError as exc:
|
||||||
|
+ raise CodexAppServerUnavailable(
|
||||||
|
+ "Codex provider API key file is unavailable"
|
||||||
|
+ ) from exc
|
||||||
|
+ if not key_value:
|
||||||
|
+ raise CodexAppServerUnavailable(
|
||||||
|
+ "Codex provider API key file is empty"
|
||||||
|
+ )
|
||||||
|
+ return key_value
|
||||||
|
+ return str(
|
||||||
|
+ os.environ.get(self.config.provider_api_key_env or "OPENAI_API_KEY") or ""
|
||||||
|
+ ).strip()
|
||||||
|
+
|
||||||
|
+ def _provider_key_fingerprint(self) -> str:
|
||||||
|
+ value = self._provider_key_value()
|
||||||
|
+ return hashlib.sha256(value.encode("utf-8")).hexdigest() if value else ""
|
||||||
|
|
||||||
|
def _process_env(self) -> dict[str, str]:
|
||||||
|
allowed_names = {
|
||||||
|
@@ -573,81 +597,79 @@
|
||||||
|
allowed_names.update(self.config.forward_env)
|
||||||
|
env = {name: value for name, value in os.environ.items() if name in allowed_names}
|
||||||
|
if self.config.provider_api_key_file:
|
||||||
|
- key_path = Path(self.config.provider_api_key_file).expanduser()
|
||||||
|
- try:
|
||||||
|
- key_value = key_path.read_text(encoding="utf-8").strip()
|
||||||
|
- except OSError as exc:
|
||||||
|
- raise CodexAppServerUnavailable(
|
||||||
|
- "Codex provider API key file is unavailable"
|
||||||
|
- ) from exc
|
||||||
|
- if not key_value:
|
||||||
|
- raise CodexAppServerUnavailable(
|
||||||
|
- "Codex provider API key file is empty"
|
||||||
|
- )
|
||||||
|
+ key_value = self._provider_key_value()
|
||||||
|
env[self.config.provider_api_key_env or "OPENAI_API_KEY"] = key_value
|
||||||
|
env["CODEX_HOME"] = self.config.codex_home
|
||||||
|
env["CODEX_ANALYTICS_ENABLED"] = "0"
|
||||||
|
return env
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
- if self.running:
|
||||||
|
- return
|
||||||
|
- if self._closed:
|
||||||
|
- raise CodexAppServerUnavailable("Codex App Server process is closed")
|
||||||
|
- Path(self.config.codex_home).mkdir(parents=True, exist_ok=True)
|
||||||
|
- Path(self.config.cwd).mkdir(parents=True, exist_ok=True)
|
||||||
|
- try:
|
||||||
|
- process = self.popen_factory(
|
||||||
|
- self.config.command,
|
||||||
|
- stdin=subprocess.PIPE,
|
||||||
|
- stdout=subprocess.PIPE,
|
||||||
|
- stderr=subprocess.PIPE,
|
||||||
|
- text=True,
|
||||||
|
- encoding="utf-8",
|
||||||
|
- errors="replace",
|
||||||
|
- bufsize=1,
|
||||||
|
- cwd=self.config.cwd,
|
||||||
|
- env=self._process_env(),
|
||||||
|
- )
|
||||||
|
- except (FileNotFoundError, PermissionError, OSError) as exc:
|
||||||
|
- raise CodexAppServerUnavailable(
|
||||||
|
- f"unable to start Codex App Server: {type(exc).__name__}"
|
||||||
|
- ) from exc
|
||||||
|
- self._process = process
|
||||||
|
- self._reader = threading.Thread(
|
||||||
|
- target=self._read_stdout,
|
||||||
|
- name="codex-app-server-stdout",
|
||||||
|
- daemon=True,
|
||||||
|
- )
|
||||||
|
- self._stderr_reader = threading.Thread(
|
||||||
|
- target=self._read_stderr,
|
||||||
|
- name="codex-app-server-stderr",
|
||||||
|
- daemon=True,
|
||||||
|
- )
|
||||||
|
- self._reader.start()
|
||||||
|
- self._stderr_reader.start()
|
||||||
|
- try:
|
||||||
|
- self.request(
|
||||||
|
- "initialize",
|
||||||
|
- {
|
||||||
|
- "clientInfo": {
|
||||||
|
- "name": "dalongxia_claw",
|
||||||
|
- "title": "Dalongxia Claw",
|
||||||
|
- "version": "1.0.0",
|
||||||
|
- },
|
||||||
|
- "capabilities": {
|
||||||
|
- "optOutNotificationMethods": [
|
||||||
|
- "item/reasoning/textDelta",
|
||||||
|
- "rawResponse/completed",
|
||||||
|
- ]
|
||||||
|
- },
|
||||||
|
- },
|
||||||
|
- timeout=self.config.request_timeout_seconds,
|
||||||
|
+ with self._lifecycle_lock:
|
||||||
|
+ current_fingerprint = self._provider_key_fingerprint()
|
||||||
|
+ if self.running and current_fingerprint == self._started_provider_key_fingerprint:
|
||||||
|
+ return
|
||||||
|
+ if self.running:
|
||||||
|
+ with self._pending_lock:
|
||||||
|
+ if self._pending:
|
||||||
|
+ return
|
||||||
|
+ self._terminate_process()
|
||||||
|
+ if self._closed:
|
||||||
|
+ raise CodexAppServerUnavailable("Codex App Server process is closed")
|
||||||
|
+ Path(self.config.codex_home).mkdir(parents=True, exist_ok=True)
|
||||||
|
+ Path(self.config.cwd).mkdir(parents=True, exist_ok=True)
|
||||||
|
+ try:
|
||||||
|
+ process = self.popen_factory(
|
||||||
|
+ self.config.command,
|
||||||
|
+ stdin=subprocess.PIPE,
|
||||||
|
+ stdout=subprocess.PIPE,
|
||||||
|
+ stderr=subprocess.PIPE,
|
||||||
|
+ text=True,
|
||||||
|
+ encoding="utf-8",
|
||||||
|
+ errors="replace",
|
||||||
|
+ bufsize=1,
|
||||||
|
+ cwd=self.config.cwd,
|
||||||
|
+ env=self._process_env(),
|
||||||
|
+ )
|
||||||
|
+ except (FileNotFoundError, PermissionError, OSError) as exc:
|
||||||
|
+ raise CodexAppServerUnavailable(
|
||||||
|
+ f"unable to start Codex App Server: {type(exc).__name__}"
|
||||||
|
+ ) from exc
|
||||||
|
+ self._process = process
|
||||||
|
+ self._started_provider_key_fingerprint = current_fingerprint
|
||||||
|
+ self._reader = threading.Thread(
|
||||||
|
+ target=self._read_stdout,
|
||||||
|
+ name="codex-app-server-stdout",
|
||||||
|
+ daemon=True,
|
||||||
|
)
|
||||||
|
- self.notify("initialized", {})
|
||||||
|
- except Exception:
|
||||||
|
- self.close()
|
||||||
|
- raise
|
||||||
|
+ self._stderr_reader = threading.Thread(
|
||||||
|
+ target=self._read_stderr,
|
||||||
|
+ name="codex-app-server-stderr",
|
||||||
|
+ daemon=True,
|
||||||
|
+ )
|
||||||
|
+ self._reader.start()
|
||||||
|
+ self._stderr_reader.start()
|
||||||
|
+ try:
|
||||||
|
+ self.request(
|
||||||
|
+ "initialize",
|
||||||
|
+ {
|
||||||
|
+ "clientInfo": {
|
||||||
|
+ "name": "dalongxia_claw",
|
||||||
|
+ "title": "Dalongxia Claw",
|
||||||
|
+ "version": "1.0.0",
|
||||||
|
+ },
|
||||||
|
+ "capabilities": {
|
||||||
|
+ "optOutNotificationMethods": [
|
||||||
|
+ "item/reasoning/textDelta",
|
||||||
|
+ "rawResponse/completed",
|
||||||
|
+ ]
|
||||||
|
+ },
|
||||||
|
+ },
|
||||||
|
+ timeout=self.config.request_timeout_seconds,
|
||||||
|
+ )
|
||||||
|
+ self.notify("initialized", {})
|
||||||
|
+ except Exception:
|
||||||
|
+ self.close()
|
||||||
|
+ raise
|
||||||
|
|
||||||
|
def _read_stdout(self) -> None:
|
||||||
|
process = self._process
|
||||||
|
@@ -731,7 +753,7 @@
|
||||||
|
*,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
- if not self.running and method != "initialize":
|
||||||
|
+ if method != "initialize":
|
||||||
|
self.start()
|
||||||
|
with self._pending_lock:
|
||||||
|
self._request_id += 1
|
||||||
|
@@ -885,10 +907,10 @@
|
||||||
|
duration_ms=int((time.monotonic() - started_at) * 1000),
|
||||||
|
)
|
||||||
|
|
||||||
|
- def close(self) -> None:
|
||||||
|
- self._closed = True
|
||||||
|
+ def _terminate_process(self) -> None:
|
||||||
|
process = self._process
|
||||||
|
self._process = None
|
||||||
|
+ self._started_provider_key_fingerprint = ""
|
||||||
|
if process is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
@@ -911,7 +933,12 @@
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
+ def close(self) -> None:
|
||||||
|
+ with self._lifecycle_lock:
|
||||||
|
+ self._closed = True
|
||||||
|
+ self._terminate_process()
|
||||||
|
|
||||||
|
+
|
||||||
|
def codex_plan_output_schema() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
Loading…
x
Reference in New Issue
Block a user