"""Isolated original-images → parametric graph → CAD → visual feedback trial. No company BOM enters inference. No business sessions or production services are mutated. A successful compile/render is NOT visual or pricing acceptance. """ from __future__ import annotations import argparse import base64 import copy import importlib.util import json import math import os import pathlib import re import subprocess import time from structure_graph import ( GraphError, SCHEMA_VERSION, apply_graph_patch, build_dimension_ledger, build_source_topology_contract, compile_graph, normalize_graph_patch_payload, ) from manufacturing_oracle import plan_manufacturing_features from hardware_catalog import supported_hardware_contract ROLE_FILENAME_ALIASES = { "front": ("front",), "side": ("side",), "third": ("third", "open", "three-quarter", "perspective"), "dimension": ("dimension", "dimensions", "dim", "drawing"), } class AuthoritativeKernelError(RuntimeError): def __init__(self, feedback: list[dict]): super().__init__("CAD实体干涉检查未通过") self.feedback = feedback def terminal_dependency_feedback(error: Exception | str) -> dict | None: """Classify non-geometric configuration failures that retries cannot fix.""" message = str(error or "").strip() if "尚未配置密钥" in message: return { "code": "model_credentials_missing", "severity": "blocking", "detail": "隔离验证进程未取得读图模型凭据", "next_owner": "runtime_configuration", "retryable": False, } return None VISUAL_ISSUE_CODES = { "missing_component", "extra_component", "component_count_mismatch", "geometric_placement_mismatch", "orientation_mismatch", "assembly_relation_mismatch", "silhouette_mismatch", "numeric_dimension_mismatch", "hardware_type_mismatch", "hardware_count_mismatch", "door_type_mismatch", } def normalize_visual_review( value: dict, *, image_ids: set[str], dimension_ledger: dict, ) -> dict: """Reject self-contradictory or non-actionable visual-review prose.""" if not isinstance(value, dict): raise ValueError("视觉复核必须返回JSON对象") audit = [] issues = [] raw_issues = value.get("issues") or [] if not isinstance(raw_issues, list) or len(raw_issues) > 16: raise ValueError("视觉复核问题列表无效") for index, row in enumerate(raw_issues): if not isinstance(row, dict): audit.append({"index": index, "accepted": False, "reason": "issue_not_object"}) continue code = str(row.get("code") or "").strip().lower() if code == "dimension_mismatch": code = "numeric_dimension_mismatch" component = str(row.get("component") or "").strip() image_id = str(row.get("source_image_id") or "").strip() evidence = str(row.get("evidence") or "").strip() correction = str(row.get("correction") or "").strip() combined = f"{evidence}\n{correction}".lower() if code not in VISUAL_ISSUE_CODES: audit.append({"index": index, "accepted": False, "reason": "unsupported_issue_code", "code": code}) continue if not component or image_id not in image_ids or not evidence or not correction: audit.append({"index": index, "accepted": False, "reason": "missing_actionable_evidence", "code": code}) continue if any(phrase in combined for phrase in ( "no correction needed", "no major issue", "无需修改", "不需要修改", "不需修正", )): audit.append({"index": index, "accepted": False, "reason": "self_declared_non_issue", "code": code}) continue if len(evidence) > 600 or len(correction) > 400: audit.append({"index": index, "accepted": False, "reason": "overlong_reasoning_not_diagnostic", "code": code}) continue cleaned = { "code": code, "component": component, "source_image_id": image_id, "evidence": evidence, "correction": correction, } if code == "numeric_dimension_mismatch": try: expected = float(row.get("expected_mm")) actual = float(row.get("actual_mm")) except (TypeError, ValueError): audit.append({"index": index, "accepted": False, "reason": "numeric_values_required", "code": code}) continue if not math.isfinite(expected) or not math.isfinite(actual) or abs(expected - actual) <= 0.5: audit.append({"index": index, "accepted": False, "reason": "numeric_mismatch_not_proven", "code": code}) continue cleaned.update({"expected_mm": expected, "actual_mm": actual}) issues.append(cleaned) audit.append({"index": index, "accepted": True, "code": code}) uncertain = [] raw_uncertain = value.get("uncertain") or [] if not isinstance(raw_uncertain, list) or len(raw_uncertain) > 8: raise ValueError("视觉复核不确定项列表无效") for index, row in enumerate(raw_uncertain): if not isinstance(row, dict): continue image_id = str(row.get("source_image_id") or "").strip() question = str(row.get("single_user_question") or "").strip() evidence = str(row.get("evidence") or "").strip() if image_id in image_ids and question and evidence and len(question) <= 80 and len(evidence) <= 400: uncertain.append({ "code": str(row.get("code") or "visual_uncertainty").strip(), "component": str(row.get("component") or "").strip(), "source_image_id": image_id, "evidence": evidence, "single_user_question": question, }) return { "ok": not issues and not uncertain, "issues": issues, "uncertain": uncertain, "normalization_audit": audit, "dimension_ledger_axes": sorted(axis for axis in "xyz" if axis in (dimension_ledger or {})), } def enrich_hardware_feedback_with_verified_placements( graph: dict, compiled: dict, feedback: list[dict], *, compile_candidate, ) -> list[dict]: """Attach the only legal v2 repair contract to hardware feedback. Graph v2 has no generic fitting geometry. All locations are explicit template mounting centres; moving a placeholder AABB is therefore never a legal repair, even as a fallback. """ del compiled, compile_candidate # Kept in the call signature for probe compatibility. result = copy.deepcopy(feedback) parts = {str(row.get("id") or ""): row for row in graph.get("parts") or []} template_instances = { str(row.get("id") or "") for row in graph.get("instances") or [] if (parts.get(str(row.get("part_id") or "")) or {}).get("hardware_template_id") } for item in result: if item.get("code") != "hardware_mounting_geometry": continue instance_id = str(item.get("instance_id") or "") if instance_id in template_instances: # Template geometry is driven by explicit mounting centres and # fixed slot rules, never by moving an arbitrary placeholder box # until its AABB happens to touch a board. item.pop("compatible_placement_patches", None) item["correction"] = ( "回看原图修正该模板五金的slot、host_instance_id、face与mounting_center_mm;" "kind和embed_depth_mm必须逐字使用模板合同。禁止移动1×1×1占位placement、" "禁止把整个五金改成through来制造接触。" ) item["repair_contract"] = "template_mounting_centers_only" continue item.pop("compatible_placement_patches", None) item["repair_contract"] = "hardware_template_required" item["correction"] = ( "先将五金part映射到允许的hardware_family和hardware_template_id,再按模板补齐全部" "mounting slots与孔型中心;v2禁止任何通用包围盒位置候选。" ) return result def source_for_role(asset_dir: pathlib.Path, case: str, role: str) -> pathlib.Path | None: """Resolve test evidence by semantic role, not one fragile filename.""" aliases = ROLE_FILENAME_ALIASES[role] allowed = {".jpg", ".jpeg", ".png", ".webp"} candidates = [] for alias_index, alias in enumerate(aliases): for path in asset_dir.glob(f"{case}-{alias}.*"): if path.suffix.lower() in allowed: candidates.append((alias_index, path.name, path)) return min(candidates)[2] if candidates else None def _json_object_from_error(error: Exception | str) -> dict | None: """Extract the first structured CAD-kernel payload from an exception.""" text = str(error or "").strip() decoder = json.JSONDecoder() for index, character in enumerate(text): if character != "{": continue try: value, _ = decoder.raw_decode(text[index:]) except json.JSONDecodeError: continue if isinstance(value, dict): return value return None def structured_kernel_feedback(error: Exception | str) -> list[dict]: """Turn B-rep failures into stable-instance repair instructions.""" payload = _json_object_from_error(error) feedback = [] if payload: for clash in payload.get("clashes") or []: if not isinstance(clash, dict): continue first = str((clash.get("a") or {}).get("name") or "").strip() second = str((clash.get("b") or {}).get("name") or "").strip() bounds = clash.get("bounds") or {} minimum, maximum = bounds.get("min") or [], bounds.get("max") or [] overlap = { axis: round(float(maximum[position]) - float(minimum[position]), 4) for position, axis in enumerate("xyz") if position < len(minimum) and position < len(maximum) } feedback.append({ "code": "brep_interference", "severity": "blocking", "instance_ids": [value for value in (first, second) if value], "volume_mm3": clash.get("volume"), "overlap_size_mm": overlap, "bounds_mm": bounds, "correction": ( "回看原图后修改这两个实例的零件尺寸、所属总成或placement整轴约束," "使实体没有正体积重叠;不得删除原图可见零件或缩小到错误尺寸。" ), "legal_patch_roots": [ f"/instances/{instance_id}/placement" for instance_id in (first, second) if instance_id ], }) for detail in payload.get("errors") or []: feedback.append({ "code": "cad_kernel_validation_error", "severity": "blocking", "detail": str(detail), }) if feedback: return feedback return [{ "code": "cad_kernel_error", "severity": "blocking", "detail": str(error or "CAD几何内核未通过")[-1800:], "correction": "只修改导致内核失败的图谱参数、零件几何或定位约束,然后重新构建。", }] def authoritative_interference_feedback(ai3d, build_dir: pathlib.Path, step_path: str, built: dict | None = None) -> list[dict]: """Parse the kernel JSON itself; process exit behaviour is not a contract.""" step_file = pathlib.Path(step_path) built_raw = str((built or {}).get("interference") or "").strip() raw = "" # The engine wrapper may retain an earlier validation payload in # ``built['interference']`` even though the exported STEP is newer. When # the STEP exists, inspect that exact artifact and make it authoritative. if ai3d is not None and (step_file.is_file() or not built_raw): python = str(ai3d.runtime_dir / "venv/bin/python") inspect_tool = str(ai3d.skill_dir / "scripts/inspect") try: inspected = subprocess.run( [ python, inspect_tool, "interfere", str(step_file.resolve()) if step_file.is_file() else str(step_path), "--format", "json", ], cwd=build_dir, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=ai3d.timeout, ) # The CLI writes progress to stderr and the machine result to # stdout, including when it exits non-zero for a real clash. raw = str(inspected.stdout or inspected.stderr or "").strip() except Exception as exc: raw = str(exc) if not raw: raw = built_raw payload = _json_object_from_error(raw) if isinstance(payload, dict) and payload.get("ok") is True: return [] return structured_kernel_feedback(raw or "CAD内核未返回结构化干涉结果") def build_and_authoritatively_validate(ai3d, build_dir: pathlib.Path, source_name: str, requested_exports=None) -> dict: try: built = ai3d._build_and_validate( build_dir, source_name, requested_exports=list(requested_exports or []) ) except Exception: # Older engine wrappers raise from the interference command before # returning its stdout JSON. If STEP exists, recover the authoritative # pair list directly instead of degrading to a generic error. step_path = build_dir / source_name[:-3] if step_path.is_file(): feedback = authoritative_interference_feedback( ai3d, build_dir, str(step_path), {} ) if feedback and not ( len(feedback) == 1 and feedback[0].get("code") == "cad_kernel_error" ): raise AuthoritativeKernelError(feedback) raise feedback = authoritative_interference_feedback( ai3d, build_dir, str(built.get("step_path") or build_dir / source_name[:-3]), built ) if feedback: raise AuthoritativeKernelError(feedback) return built def round_blocker_count(row: dict) -> int: """Count end-to-end blockers for monotonic repair acceptance. Compiler issues alone are insufficient: an edit can remove a loose-bounds warning while creating more exact B-rep clashes. A candidate therefore has to improve the complete compile/manufacturing/kernel/visual chain. """ compiled = row.get("compiled") or {} total = len(compiled.get("issues") or []) if compiled.get("ok"): total += len((row.get("manufacturing_plan") or {}).get("issues") or []) kernel_feedback = row.get("cad_kernel_feedback") or [] total += len(kernel_feedback) if row.get("cad_kernel_error") and not kernel_feedback: total += 1 review = row.get("visual_review") if isinstance(review, dict): review_count = len(review.get("issues") or []) + len(review.get("uncertain") or []) total += review_count if review.get("ok") is not True and review_count == 0: total += 1 return total def compile_lower_bound_can_improve(candidate_compile_issue_count: int, accepted_total_blocker_count) -> bool: """Whether this candidate can still beat the accepted end-to-end score.""" if accepted_total_blocker_count is None: return True return candidate_compile_issue_count < accepted_total_blocker_count def has_minimum_axis_packing_dead_end(feedback: list[dict]) -> bool: """Whether geometry proved that translation cannot repair a collision.""" return any( isinstance(row, dict) and row.get("code") == "solid_interference" and row.get("minimum_separation_axis_infeasible") is True for row in feedback ) def normalize_focused_source_adjudication( value, *, image_ids: set[str], required_question_focus: str | None = None, ) -> dict: """Validate the one-shot AI decision used after a proven geometry dead end.""" if not isinstance(value, dict): raise ValueError("聚焦尺寸裁决必须返回JSON对象") decision = value.get("decision") allowed = { "occupied_overall_dimension", "reference_carcass_or_opening_dimension", "explicit_machining_relief", "source_dimensions_conflict", } if decision not in allowed: raise ValueError("聚焦尺寸裁决decision不在受控枚举中") axis = value.get("axis") confidence = value.get("confidence") evidence_ids = value.get("evidence_image_ids") or [] if axis not in {"x", "y", "z"}: raise ValueError("聚焦尺寸裁决缺少有效轴") if ( isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not 0 <= float(confidence) <= 1 ): raise ValueError("聚焦尺寸裁决置信度无效") if ( not isinstance(evidence_ids, list) or not evidence_ids or not set(evidence_ids).issubset(image_ids) ): raise ValueError("聚焦尺寸裁决必须引用本轮真实原图ID") evidence = str(value.get("evidence") or "").strip() if not evidence: raise ValueError("聚焦尺寸裁决缺少可复核的图像依据") question = str(value.get("single_user_question") or "").strip() options = value.get("question_options") or [] question_focus = str(value.get("question_focus") or "").strip() requires_question = decision == "source_dimensions_conflict" or float(confidence) < 0.8 allowed_focus = { "overall_dimension_scope", "component_dimension", "assembly_relation", "machining_relief", } if requires_question: if ( question_focus not in allowed_focus or not question or not isinstance(options, list) or not 2 <= len(options) <= 3 ): raise ValueError("原图无法消解时必须返回一个明确问题和2至3个互斥选项") if required_question_focus and question_focus != required_question_focus: raise ValueError(f"当前冲突必须先确认{required_question_focus},不能跳到后续技术问题") if len(question) > 100 or any(len(str(item)) > 60 for item in options): raise ValueError("用户确认问题必须短、通俗,不能把技术分析塞进选项") if re.search(r"image\d+|[XYZxyz]轴|instance|AABB|overlap|干涉体积", question): raise ValueError("用户确认问题不得暴露图片ID、坐标轴或几何内部术语") if question_focus == "overall_dimension_scope": joined = " ".join(str(item) for item in options) if "包含" not in joined or "不包含" not in joined: raise ValueError("整体尺寸口径问题必须给出“包含/不包含”互斥选项") if not requires_question: question, options, question_focus = "", [], "" cleaned_options = [ re.sub(r"^[A-Ca-c][\.\u3001]\s*", "", str(item).strip())[:60] for item in options[:3] ] return { "decision": decision, "axis": axis, "confidence": round(float(confidence), 4), "evidence_image_ids": sorted(set(evidence_ids)), "evidence": evidence[:1000], "requires_user_question": requires_question, "question_focus": question_focus or None, "single_user_question": question[:500] or None, "question_options": cleaned_options, "source": "independent_focused_multiview_adjudication", } def dead_end_question_focus(dead_end_feedback: list[dict], graph: dict) -> str: """Choose the first user-decidable fact without exposing CAD internals.""" parts = {str(row.get("id")): row for row in graph.get("parts") or []} instances = { str(row.get("id")): parts.get(str(row.get("part_id"))) or {} for row in graph.get("instances") or [] } for conflict in dead_end_feedback: axes = set(conflict.get("minimum_separation_axes") or []) related_ids = {str(conflict.get("a") or ""), str(conflict.get("b") or "")} for row in conflict.get("minimum_axis_packing_feasibility") or []: related_ids.add(str(row.get("moving_instance_id") or "")) related_ids.update(str(item.get("instance_id") or "") for item in row.get("blocking_instances") or []) for instance_id in related_ids: part = instances.get(instance_id) or {} if ( part.get("functional_role") in {"door", "drawer_front"} and part.get("thickness_axis") in axes ): return "overall_dimension_scope" return "component_dimension" def deterministic_dead_end_question( *, focus: str, axis: str, dimension_ledger: dict, graph: dict, ) -> dict: """Safe UI fallback when the model cannot phrase the one remaining fact.""" axis_names = {"x": "总宽", "y": "总深", "z": "总高"} fact = (dimension_ledger or {}).get(axis) or {} value = fact.get("value_mm") value_text = f"{float(value):g}mm" if isinstance(value, (int, float)) and not isinstance(value, bool) else "标注的整体尺寸" parts = {str(row.get("id")): row for row in graph.get("parts") or []} has_door = any(part.get("functional_role") == "door" for part in parts.values()) front_name = "柜门" if has_door else "前置面板" if focus == "overall_dimension_scope": return { "decision": "source_dimensions_conflict", "axis": axis, "confidence": 1.0, "evidence_image_ids": sorted(set(fact.get("source_image_ids") or [])), "evidence": "几何区间检查证明整体尺寸口径会直接决定前置件与内部板件能否同时放置。", "requires_user_question": True, "question_focus": focus, "single_user_question": f"尺寸图标注的{axis_names.get(axis, '整体尺寸')} {value_text},是否包含{front_name}厚度?", "question_options": [ f"包含{front_name}(成品最外尺寸)", f"不包含{front_name}(只算柜体/框架)", ], "source": "deterministic_dead_end_question_fallback", } return { "decision": "source_dimensions_conflict", "axis": axis, "confidence": 1.0, "evidence_image_ids": sorted(set(fact.get("source_image_ids") or [])), "evidence": "几何区间检查证明当前部件尺寸无法在允许边界内共存。", "requires_user_question": True, "question_focus": focus, "single_user_question": f"请确认{axis_names.get(axis, '该方向')}上标注的部件尺寸或两件的前后关系。", "question_options": ["按尺寸图数值,修正前后关系", "前后关系正确,需修正部件尺寸"], "source": "deterministic_dead_end_question_fallback", } def normalize_scope_facts(value, *, image_ids: set[str], dimension_ledger: dict) -> list[dict]: """Validate externally supplied dimension-scope facts for an isolated resume. These facts represent either an exact user answer already captured by the conversation runtime or an independent source adjudication. They are not model-authored graph content and therefore remain outside the patch loop. """ rows = value if isinstance(value, list) else [value] if not rows or len(rows) > 3: raise ValueError("尺寸口径事实必须包含1至3条记录") cleaned = [] seen_axes = set() for row in rows: if not isinstance(row, dict): raise ValueError("每条尺寸口径事实必须是JSON对象") fact_id = str(row.get("id") or "").strip() source_type = str(row.get("source_type") or "").strip() axis = str(row.get("axis") or "").strip() ledger_scope = str(row.get("ledger_scope") or "").strip() confidence = row.get("confidence") source_ids = row.get("source_image_ids") or [] text = str(row.get("text") or "").strip() if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{2,79}", fact_id): raise ValueError("尺寸口径事实缺少稳定ID") if source_type not in {"user", "independent_source_adjudication"}: raise ValueError("尺寸口径事实来源必须是用户确认或独立原图裁决") if axis not in {"x", "y", "z"} or axis in seen_axes: raise ValueError("每个轴只允许一条尺寸口径事实") if ledger_scope not in {"occupied_overall", "reference"}: raise ValueError("尺寸口径只能是实际占用外形或柜体/开口参考尺寸") if axis not in (dimension_ledger or {}): raise ValueError("尺寸口径事实必须对应已通过资格门的原图尺寸轴") if ( isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not 0.8 <= float(confidence) <= 1.0 ): raise ValueError("尺寸口径事实置信度必须不低于0.8") if source_type == "user" and float(confidence) != 1.0: raise ValueError("用户明确确认的尺寸口径必须按1.0记录") if ( not isinstance(source_ids, list) or not source_ids or not set(source_ids).issubset(image_ids) ): raise ValueError("尺寸口径事实必须引用本轮真实原图ID") if not text or len(text) > 300: raise ValueError("尺寸口径事实必须保存简短原始依据") cleaned.append({ "id": fact_id, "source_type": source_type, "axis": axis, "ledger_scope": ledger_scope, "confidence": round(float(confidence), 4), "source_image_ids": sorted(set(source_ids)), "text": text, }) seen_axes.add(axis) return cleaned def bind_scope_facts(graph: dict, scope_facts: list[dict]) -> tuple[dict, dict, dict, list[dict]]: """Bind trusted facts without allowing the graph model to rewrite them.""" result = copy.deepcopy(graph) observations = list(result.get("observations") or []) user_evidence = {} adjudications = {} audit = [] fact_ids = {row["id"] for row in scope_facts} observations = [row for row in observations if str(row.get("id") or "") not in fact_ids] overrides = copy.deepcopy(result.get("dimension_scope_overrides") or {}) if not isinstance(overrides, dict): overrides = {} for fact in scope_facts: fact_id, axis, scope = fact["id"], fact["axis"], fact["ledger_scope"] if fact["source_type"] == "user": observation = { "id": fact_id, "source_type": "user", "text": fact["text"], "confidence": 1.0, } observations.append(observation) user_evidence[fact_id] = copy.deepcopy(observation) reference = {"ledger_scope": scope, "evidence_id": fact_id} else: adjudications[fact_id] = { "axis": axis, "ledger_scope": scope, "confidence": fact["confidence"], "source_image_ids": list(fact["source_image_ids"]), "evidence": fact["text"], } reference = {"ledger_scope": scope, "adjudication_id": fact_id} if scope == "reference": overrides[axis] = reference else: overrides.pop(axis, None) audit.append({ "fact_id": fact_id, "axis": axis, "ledger_scope": scope, "source_type": fact["source_type"], "binding": "authoritative_outside_model_patch_loop", }) result["observations"] = observations if overrides: result["dimension_scope_overrides"] = overrides else: result.pop("dimension_scope_overrides", None) return result, user_evidence, adjudications, audit def propose_reference_scope_projection( graph: dict, compiled: dict, scope_facts: list[dict], dimension_ledger: dict, ) -> tuple[dict | None, list[dict]]: """Propose a bounded overlay-front coordinate-frame expansion. This is a candidate, never an unconditional repair. It is generated only when a verified fact says an overall label excludes a door/drawer front, the front instances all touch the same current envelope face, and their thickness axis matches that label. The normal end-to-end score and B-rep checks must still improve before the caller accepts it. """ result = copy.deepcopy(graph) envelope = list(compiled.get("occupied_envelope_mm") or []) compiled_instances = list(compiled.get("instances") or []) graph_instances = {str(row.get("id")): row for row in result.get("instances") or []} graph_assemblies = {str(row.get("id")): row for row in result.get("assemblies") or []} if len(envelope) != 3: return None, [{"accepted": False, "reason": "occupied_envelope_unavailable"}] reference_bounds = copy.deepcopy(result.get("reference_bounds_mm") or { axis: [0, envelope[index]] for index, axis in enumerate("xyz") }) audit = [] changed = False for fact in scope_facts: if fact.get("ledger_scope") != "reference": continue axis = fact["axis"] axis_index = "xyz".index(axis) ledger_fact = (dimension_ledger or {}).get(axis) or {} reference_size = ledger_fact.get("value_mm") if isinstance(reference_size, bool) or not isinstance(reference_size, (int, float)): audit.append({"fact_id": fact["id"], "accepted": False, "reason": "qualified_size_missing"}) continue fronts = [ row for row in compiled_instances if row.get("functional_role") in {"door", "drawer_front"} and row.get("thickness_axis") == axis ] if not fronts: audit.append({"fact_id": fact["id"], "accepted": False, "reason": "matching_front_instance_missing"}) continue min_touch = all(abs(float(row["position_mm"][axis_index])) <= 0.5 for row in fronts) max_touch = all( abs(float(row["position_mm"][axis_index]) + float(row["size_mm"][axis_index]) - float(envelope[axis_index])) <= 0.5 for row in fronts ) if min_touch == max_touch: audit.append({"fact_id": fact["id"], "accepted": False, "reason": "front_envelope_face_ambiguous"}) continue projection = max(float(row["size_mm"][axis_index]) for row in fronts) if projection <= 0 or projection > 100: audit.append({"fact_id": fact["id"], "accepted": False, "reason": "front_projection_out_of_range"}) continue actual_size = float(reference_size) + projection expected_reference_bounds = [projection if min_touch else 0.0, actual_size if min_touch else float(reference_size)] existing_reference_bounds = reference_bounds.get(axis) if ( abs(float(envelope[axis_index]) - actual_size) <= 0.01 and isinstance(existing_reference_bounds, list) and len(existing_reference_bounds) == 2 and all( abs(float(existing_reference_bounds[index]) - expected_reference_bounds[index]) <= 0.01 for index in range(2) ) ): audit.append({ "fact_id": fact["id"], "accepted": False, "axis": axis, "reason": "reference_scope_projection_already_bound", "occupied_size_mm": round(actual_size, 4), "reference_bounds_mm": [round(value, 4) for value in expected_reference_bounds], }) continue new_envelope = list(envelope) new_envelope[axis_index] = actual_size result["envelope_mm"] = [round(value, 4) for value in new_envelope] reference_bounds = { name: [0, round(new_envelope[index], 4)] for index, name in enumerate("xyz") } shift = projection if min_touch else 0.0 reference_bounds[axis] = [round(shift, 4), round(shift + float(reference_size), 4)] front_assembly_ids = {str(row.get("assembly_id") or "") for row in fronts} shifted_assembly_ids = set() if min_touch: for assembly_id, assembly in graph_assemblies.items(): if assembly_id in front_assembly_ids or assembly.get("role") in {"door", "hardware_group"}: continue bounds = assembly.get("bounds_mm") or {} values = bounds.get(axis) if not isinstance(values, list) or len(values) != 2: continue try: low, high = (float(values[0]), float(values[1])) except (TypeError, ValueError): audit.append({"fact_id": fact["id"], "accepted": False, "reason": "symbolic_assembly_bounds_require_ai"}) return None, audit bounds[axis] = [round(low + shift, 4), round(high + shift, 4)] shifted_assembly_ids.add(assembly_id) for instance_id, instance in graph_instances.items(): assembly_id = str(instance.get("assembly_id") or "") if assembly_id not in shifted_assembly_ids: continue placement = instance.get("placement") or {} axis_rule = placement.get(axis) if isinstance(axis_rule, dict) and axis_rule.get("target") == "envelope": axis_rule["target"] = assembly_id result["reference_bounds_mm"] = reference_bounds audit.append({ "fact_id": fact["id"], "accepted": True, "axis": axis, "front_face": "min" if min_touch else "max", "front_projection_mm": round(projection, 4), "reference_size_mm": round(float(reference_size), 4), "occupied_size_mm": round(actual_size, 4), "front_instance_ids": sorted(str(row.get("id")) for row in fronts), "shifted_assembly_ids": sorted(shifted_assembly_ids), "status": "candidate_requires_full_compile_kernel_visual_validation", }) envelope = new_envelope changed = True return (result if changed else None), audit def select_resume_checkpoint(rows: list[dict]) -> dict: """Select only an accepted, internally consistent audited checkpoint.""" candidates = [ row for row in rows if ( isinstance(row.get("graph"), dict) and isinstance(row.get("compiled"), dict) and not row.get("patch_error") ) ] if not candidates: raise RuntimeError("resume report has no audited compiled graph checkpoint") return min(candidates, key=lambda row: (round_blocker_count(row), -int(row.get("round") or 0))) def structured_patch_feedback(graph: dict, patches, error: Exception | str) -> list[dict]: """Explain a rejected JSON Patch without silently changing its semantics.""" issues = [] instances = list(graph.get("instances") or []) if isinstance(graph, dict) else [] for patch in patches if isinstance(patches, list) else []: path = str(patch.get("path") or "") if isinstance(patch, dict) else "" match = re.fullmatch(r"/instances/([^/]+)/placement/([xyz])/(value_mm|offset_mm)", path) if not match: continue token, axis, leaf = match.groups() instance = None if token.isdigit() and int(token) < len(instances): instance = instances[int(token)] else: instance = next((row for row in instances if str(row.get("id") or "") == token), None) if not isinstance(instance, dict): continue stable_id = str(instance.get("id") or token) current = ((instance.get("placement") or {}).get(axis) or {}) legal = [{ "op": "replace", "path": f"/instances/{stable_id}/placement/{axis}", "value_shape": {"mode": "absolute", "value_mm": "<毫米值>"}, }] if current.get("mode") == "align": legal.append({ "op": "add" if "offset_mm" not in current else "replace", "path": f"/instances/{stable_id}/placement/{axis}/offset_mm", "value_shape": "<毫米偏移值>", }) issues.append({ "code": "patch_application_error", "severity": "blocking", "attempted_path": path, "instance_id": stable_id, "axis": axis, "current_constraint": current, "legal_repair_examples": legal, "detail": str(error), "correction": ( f"当前{axis.upper()}轴没有可replace的{leaf}字段。使用稳定实例ID并替换整个轴约束;" "如果当前是align且只需平移,则对offset_mm使用add/replace。" ), }) return issues or [{ "code": "patch_application_error", "severity": "blocking", "detail": str(error), "attempted_patches": patches, "correction": "保留上一版图谱,根据现有字段和稳定ID重新生成合法局部修改。", }] GRAPH_PROMPT = """根据全部原始图片构建可编辑、可验证的家具装配图谱,不输出报价。 先理解产品正面、侧面和尺寸线方向,再分清每个物理板件及其所属总成。 不能把抽屉整体算成板件,固定横隔板不属于抽屉箱体;每个物理实例有独立ID。 侧板是否断开需要局部接缝证据,颜色变化不等于断开,层板边缘不等于侧板接缝。 多张图存在矛盾则在uncertainties中记录;未看见不能当作没有。 严格JSON结构: {"schema_version":"furniture-assembly-graph/2", "placement_solver":"anchored_axes_v1", "orientation_solver":"role_axis_templates_v1", "contact_derivation":"aabb_within_assembly_v1", "observations":[{"id":"e1","source_type":"image","image_id":"image1","confidence":0.0,"text":"原图观察,不是经验","region":[0,0,1,1]}], "parameters":{"parameter_name":{"kind":"length/count/scalar","origin":"observed/derived/estimated","value":0,"unit":"mm/cm/m","evidence_ids":["e1"],"confidence":0.0}}, "envelope_mm":["实际占用宽","实际占用深","实际占用高"], "assemblies":[{"id":"lower_module","role":"carcass","bounds_mm":{"x":[0,"width"],"y":[0,"depth"],"z":[0,"lower_height"]},"evidence_ids":["e1"]},{"id":"drawer_1","role":"drawer","bounds_mm":{"x":["side_thickness","width-side_thickness"],"y":[0,"drawer_depth"],"z":["drawer_z_min","drawer_z_max"]},"evidence_ids":["e1"]}], "parts":[{"id":"part_id","name":"中文部件名","shape":"box","functional_role":"side","thickness_axis":"x","size_mm":["参数或算式","参数或算式","参数或算式"],"observed_quantity":1,"evidence_ids":["e1"]},{"id":"hinge_part","name":"暗铰链","shape":"box","functional_role":"hardware","thickness_axis":null,"hardware_family":"concealed_cup_hinge","hardware_template_id":"euro_hinge_35_th52_system32_v1","size_mm":[1,1,1],"observed_quantity":1,"evidence_ids":["e1"]}], "instances":[{"id":"板件物理实例ID","part_id":"panel_part_id","assembly_id":"明确所属总成ID","placement":{"x":{"mode":"align","self_anchor":"min","target":"envelope","target_anchor":"min","offset_mm":0},"y":{"mode":"absolute","value_mm":0},"z":{"mode":"align","self_anchor":"max","target":"另一实例ID","target_anchor":"min","offset_mm":0}}},{"id":"五金物理实例ID","part_id":"hinge_part","assembly_id":"门总成ID","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":"门板实例ID","kind":"mortised","face":"y+","embed_depth_mm":12.8,"mounting_center_mm":[22,20,150],"evidence_ids":["e1"]},{"slot":"plate","host_instance_id":"侧板实例ID","kind":"surface","face":"x+","embed_depth_mm":0,"mounting_center_mm":[20,57,150],"evidence_ids":["e1"]}]}], "connections":[], "assembly_connections":[{"id":"drawer_slide","kind":"sliding","a":"drawer_1","b":"carcass","axis":"y","travel_limits_mm":[0,250],"evidence_ids":["e1"]}], "uncertainties":[]} 示例字段名仅是格式,不是产品答案。不要照抄示例观察。 参数:observed仅用于图上明确可读的标注,必须引用实际图片证据;照片比例、隐蔽板厚等用estimated且confidence<=0.55。 derived参数不写value/unit,改写expression为参数名和加减乘除的算式,如width-2*panel_thickness。 先定义变量,再引用;数字是无量纲系数,不要把mm和件数直接相加。零件size_mm/position_mm中数字字面量单位固定mm。 默认envelope_mm是门板、把手、凸出脚等全部结构安装后的实际占用外形,不是柜体净尺寸。 若尺寸箭头实际量的是柜体、框架或开口参考范围,只有独立端点裁决或真实用户证据验证后,才能增加reference_bounds_mm和dimension_scope_overrides; 图谱模型自己不得把overall改口径来躲避冲突。reference_bounds_mm必须位于实际占用envelope_mm内。 全图只使用一个产品坐标系:X=产品宽、Y=产品深、Z=产品高。固定写orientation_solver=role_axis_templates_v1。 size_mm必须直接按产品[X宽,Y深,Z高]填写;instances禁止输出axis_order,也禁止对零件做第二次轴交换。 固定写placement_solver=anchored_axes_v1,禁止输出position_mm或心算XYZ坐标。 每个part必须声明functional_role,只允许top/bottom/side/shelf/door/back/drawer_front/drawer_left/drawer_right/drawer_back/drawer_bottom/drawer_support/divider/leg/rail/stile/frame_member/hardware/other。 所有板件还必须声明产品坐标中的thickness_axis=x/y/z,它必须是size_mm最薄的一轴;不能根据中文名称猜方向。非板件可写null。 角色朝向规则:top/bottom/shelf/drawer_bottom/drawer_support厚度轴为z;side/drawer_left/drawer_right为x;door/back/drawer_front/drawer_back为y;divider只允许x或z。 例如侧板size_mm=[t,depth,height]且thickness_axis=x;层板size_mm=[inner_width,depth,t]且thickness_axis=z;门板size_mm=[door_width,t,door_height]且thickness_axis=y。 每个实例的placement必须有x/y/z三轴。每轴只允许absolute(value_mm),或align(self_anchor,target,target_anchor,offset_mm)。 anchor只能是min/center/max;target只能是envelope、字面量assembly、当前实例自己的assembly_id,或先定义的实例ID。当前assembly_id与assembly等价,不能用其他总成ID做跨总成定位。每个部件优先在所属assembly局部边界内定位;例如模块右侧板X最大面对齐所属总成X最大面,应写self_anchor=max,target=assembly,target_anchor=max,实际坐标由求解器计算。只有部件确实位于整件产品最外边界时才能直接锚定envelope。 固定写contact_derivation=aabb_within_assembly_v1。AI只声明每个物理件所属总成,同一总成内的固定/支撑接触由编译器根据真实几何正面积相交自动推导,禁止AI猜固定连接面。 每个assembly必须至少有一个实例,不能创建空root。每个assembly必须给出严格bounds_mm,边界等于所属实体并集的真实包围盒,不能把整件产品边界复制给局部模块。尺寸图或多视图显示上下分段时,按真实结构分成上下模块;上部模块的Z最小值必须来自分段尺寸或可追溯派生参数,其侧板不得再次落到产品Z=0。 assembly.role只允许carcass/drawer/door/module/frame/hardware_group/other;门总成写door,禁止写door_assembly、sliding_door等自创角色。滑动或铰接性质写在assembly_connections.kind,不写进role。 若顶板、底板、侧板、背板或腿属于同一carcass,它的bounds_mm必须覆盖这些实体的完整并集;不能只用内部净空作为carcass边界。落地腿的Z最小面通常对齐产品envelope最小面,不能把腿的Z最大面对齐最小面而放到产品外。 抽屉的前/后/左/右帮板和底板属于同一drawer总成;柜体板属于carcass,不能把“抽屉”运动性质挂在某一块帮板上。层板、底板和抽屉帮板必须按相邻侧板之间的净空生成,不能按产品总宽横穿侧板;任何实体正体积穿插都必须修正。 每个会独立运动的抽屉、平开门或移门必须是独立assembly;单块门板也要建立包含一个实例的总成。不同总成之间用assembly_connections,a/b只能引用assemblies中的稳定ID,禁止引用零件实例ID:fixed必须在最终几何中真实接触;sliding必须有axis与travel_limits_mm;hinged必须有axis与angle_limits_deg。connections中不要生成fixed/support。 不同规格独立part;相同规格由多个instances引用,BOM数量只等于真实实例数,不额外产生总成板。 每个functional_role=hardware的part必须声明hardware_family和hardware_template_id;模型只能选系统提供的模板,禁止按中文名称或任意长方体自创孔径、孔距与嵌入深度。part的size_mm仅是1×1×1占位,最终外形由模板编译。 每个五金实例必须为模板的每个安装槽声明mountings。每条含slot、真实宿主实例、模板规定的kind、宿主广面face、模板规定的embed_depth_mm、产品坐标mounting_center_mm和原图证据。暗铰链还必须写hardware_axis;cup槽指向门板,plate槽指向柜体侧板/框条。旋钮body必须是门板外表面的surface安装,穿门螺钉孔由模板生成,禁止把整个旋钮写成through。 mounting_center_mm是孔型中心,必须在宿主安装平面上且完整孔型不能越界。35mm暗铰杯的边距和底座系统线由模板校验;不得修改模板孔径、孔距、嵌入深度或用through绕过。总成bounds_mm只由结构实体紧包围盒确定,不纳入五金外凸。 所有运动连接必须引用真实实例和真实总成,不能用大公差掩盖错位。 只画现有图像支持的结构;不要根据常见柜子想象层板数量。未标注内部尺寸先由AI按多图比例估算并标记estimated,生成评审草案; 只有原图完全不可见且对制造/成本高度敏感、经过局部复读与CAD反向复核仍不能判定的字段,才进入用户确认候选。 不得引用公司BOM或产品名称模板生成几何,不得生成source_type=user。 当前编译器只支持正交box板件;复杂曲面仍须保留uncertainties,不能宣称已精确复刻。 输出紧凑有效JSON,最多40个parts、100个instances,不输出油漆材料、成本或孔位模板。""" def render_multiview(ai3d, build_dir: pathlib.Path, step_path: str) -> list[dict]: """Render orthographic evidence so the reviewer is not trapped in one ISO view.""" python = str(ai3d.runtime_dir / "venv/bin/python") snapshot = str(ai3d.skill_dir / "scripts/snapshot") outputs = [ {"path": str(build_dir / f"cad-{camera}.png"), "camera": camera} for camera in ("front", "right", "top", "iso") ] ai3d._run_trusted_snapshot( build_dir, [python, snapshot, "--job", "-", "--json"], stdin=json.dumps({ "input": step_path, "mode": "view", "outputs": outputs, "render": {"viewLabels": True, "padding": 0.1, "sizeProfile": "diagnostic"}, }), timeout=ai3d.timeout, ) return [{"camera": item["camera"], "path": item["path"]} for item in outputs if pathlib.Path(item["path"]).is_file()] def diagnostic_mounting_clearance_cuts(compiled: dict, *, clearance_mm: float = 0.2) -> dict: """Create diagnostic-only host clearances for validated mounting overlap. Hardware is currently represented by a conservative box envelope. A mortised or through-mounted item therefore overlaps its host by design. Subtracting only the already validated overlap keeps the B-rep collision oracle from telling the repair model to move installed hardware away. These boxes are never manufacturing drill or pocket instructions. """ instances = {str(row.get("id")): row for row in compiled.get("instances") or []} cuts_by_host: dict[str, list[dict]] = {} audit = [] pad = max(0.0, float(clearance_mm)) / 2.0 for mounting in compiled.get("mounting_audit") or []: # Template-backed fittings receive real cup/fastener holes from the # manufacturing planner. Subtracting their composite AABB would cut # away unrelated door/side material and make a false clean B-rep. if mounting.get("template_geometry"): continue if not mounting.get("allows_host_interference"): continue hardware_id = str(mounting.get("instance_id") or "") host_id = str(mounting.get("host_instance_id") or "") hardware, host = instances.get(hardware_id), instances.get(host_id) if not hardware or not host: continue low = [ max(float(hardware["position_mm"][axis]), float(host["position_mm"][axis])) for axis in range(3) ] high = [ min( float(hardware["position_mm"][axis]) + float(hardware["size_mm"][axis]), float(host["position_mm"][axis]) + float(host["size_mm"][axis]), ) for axis in range(3) ] if min(high[axis] - low[axis] for axis in range(3)) <= 0: continue size = [high[axis] - low[axis] + 2 * pad for axis in range(3)] center = [(low[axis] + high[axis]) / 2.0 for axis in range(3)] row = { "host_instance_id": host_id, "hardware_instance_id": hardware_id, "kind": mounting.get("kind"), "world_center_mm": {name: round(center[index], 6) for index, name in enumerate("xyz")}, "world_size_mm": {name: round(size[index], 6) for index, name in enumerate("xyz")}, "clearance_mm": round(float(clearance_mm), 6), "source": "validated_mounting_overlap_envelope", "production_feature": False, } cuts_by_host.setdefault(host_id, []).append(row) audit.append(row) return {"cuts_by_host": cuts_by_host, "audit": audit} def cad_source(compiled, manufacturing_plan=None): lines = [ "from build123d import Align, Axis, Box, Cylinder, Pos, Compound, Color", "", "def gen_step():", " parts = []", ] colors = [(0.72, 0.83, 0.96), (0.96, 0.77, 0.45), (0.8, 0.65, 0.86), (0.5, 0.82, 0.67)] feature_map = (manufacturing_plan or {}).get("features_by_instance") or {} manufacturing_geometry = (manufacturing_plan or {}).get("manufacturing_geometry_by_instance") or {} hardware_geometry = compiled.get("hardware_geometry_by_instance") or {} mounting_clearances = diagnostic_mounting_clearance_cuts(compiled)["cuts_by_host"] for index, inst in enumerate(compiled["instances"]): template_geometry = hardware_geometry.get(str(inst["id"])) if template_geometry: primitive_names = [] for primitive_index, primitive in enumerate(template_geometry["primitives"], 1): variable = f"hardware_{index}_{primitive_index}" primitive_names.append(variable) center = primitive["center_mm"] if primitive["kind"] == "box": size = primitive["size_mm"] lines.append( f" {variable} = Box({size[0]!r}, {size[1]!r}, {size[2]!r}, " f"align=(Align.CENTER, Align.CENTER, Align.CENTER)).moved(" f"Pos({center[0]!r}, {center[1]!r}, {center[2]!r}))" ) else: axis = primitive["axis"] rotate = ".rotate(Axis.Y, 90)" if axis == "x" else ".rotate(Axis.X, 90)" if axis == "y" else "" lines.append( f" {variable} = Cylinder({float(primitive['diameter_mm']) / 2!r}, " f"{float(primitive['length_mm'])!r}, align=(Align.CENTER, Align.CENTER, Align.CENTER))" f"{rotate}.moved(Pos({center[0]!r}, {center[1]!r}, {center[2]!r}))" ) # These primitives are regions of one purchased fitting. Fuse # them before export so the authoritative B-rep oracle compares # the fitting with the furniture, rather than reporting the # hinge arm's intentional overlap with its own mounting plate. lines.append(f" part = {primitive_names[0]}") for primitive_name in primitive_names[1:]: lines.append(f" part = part + {primitive_name}") lines.extend([ f" part.label = {str(inst['id'])!r}", f" part.color = Color{colors[index % len(colors)]!r}", " parts.append(part)", ]) continue adjusted = manufacturing_geometry.get(str(inst["id"])) or {} size = adjusted.get("size_mm") or inst["size_mm"] pos = adjusted.get("position_mm") or inst["position_mm"] center = [pos[axis] + size[axis] / 2 for axis in range(3)] lines.extend([ f" part = Box({size[0]!r}, {size[1]!r}, {size[2]!r}, align=(Align.CENTER, Align.CENTER, Align.CENTER)).moved(Pos({center[0]!r}, {center[1]!r}, {center[2]!r}))", ]) for feature_index, feature in enumerate((feature_map.get(str(inst["id"])) or {}).get("holes") or [], 1): if feature.get("validation_status") != "valid": continue world = feature["world_center_mm"] axis = feature["axis"] rotate = ".rotate(Axis.Y, 90)" if axis == "x" else ".rotate(Axis.X, 90)" if axis == "y" else "" lines.extend([ f" hole_{index}_{feature_index} = Cylinder({float(feature['diameter_mm']) / 2!r}, {float(feature['depth_mm'])!r}, align=(Align.CENTER, Align.CENTER, Align.CENTER)){rotate}.moved(Pos({world['x']!r}, {world['y']!r}, {world['z']!r}))", f" part = part - hole_{index}_{feature_index}", ]) for feature_index, feature in enumerate((feature_map.get(str(inst["id"])) or {}).get("grooves") or [], 1): if feature.get("validation_status") != "valid": continue world, groove_size = feature["world_center_mm"], feature["world_size_mm"] lines.extend([ f" groove_{index}_{feature_index} = Box({groove_size['x']!r}, {groove_size['y']!r}, {groove_size['z']!r}, align=(Align.CENTER, Align.CENTER, Align.CENTER)).moved(Pos({world['x']!r}, {world['y']!r}, {world['z']!r}))", f" part = part - groove_{index}_{feature_index}", ]) for cut_index, cut in enumerate(mounting_clearances.get(str(inst["id"])) or [], 1): world, cut_size = cut["world_center_mm"], cut["world_size_mm"] lines.extend([ " # Diagnostic-only clearance for a validated mounted-hardware envelope.", f" mount_clearance_{index}_{cut_index} = Box({cut_size['x']!r}, {cut_size['y']!r}, {cut_size['z']!r}, align=(Align.CENTER, Align.CENTER, Align.CENTER)).moved(Pos({world['x']!r}, {world['y']!r}, {world['z']!r}))", f" part = part - mount_clearance_{index}_{cut_index}", ]) lines.extend([ f" part.label = {str(inst['id'])!r}", f" part.color = Color{colors[index % len(colors)]!r}", " parts.append(part)", ]) lines.append(" return Compound(children=parts, label='assembly_graph_draft')") return "\n".join(lines) + "\n" def refresh_compiled_kernel_feedback(ai3d, build_dir: pathlib.Path, compiled: dict, manufacturing_plan: dict) -> dict: """Rebuild one audited graph so resumed repair never consumes stale CAD feedback.""" build_dir.mkdir(parents=True, exist_ok=True) source = build_dir / "assembly.step.py" source.write_text(cad_source(compiled, manufacturing_plan), encoding="utf-8") ai3d._validate_source(source.read_text(encoding="utf-8")) try: built = build_and_authoritatively_validate(ai3d, build_dir, source.name) except Exception as exc: return { "feedback": ( exc.feedback if isinstance(exc, AuthoritativeKernelError) else structured_kernel_feedback(exc) ), "error": f"{type(exc).__name__}: {exc}", "diagnostic_mounting_clearance_audit": diagnostic_mounting_clearance_cuts(compiled)["audit"], } return { "feedback": [], "built": built, "diagnostic_mounting_clearance_audit": diagnostic_mounting_clearance_cuts(compiled)["audit"], } def build_render_and_review( *, engine, model: str, images: list[dict], ai3d, build_dir: pathlib.Path, compiled: dict, manufacturing_plan: dict, image_ids: set[str], dimension_ledger: dict, ) -> dict: """Build one blocker-free graph and compare four CAD views to originals.""" build_dir.mkdir(parents=True, exist_ok=True) source = build_dir / "assembly.step.py" source.write_text(cad_source(compiled, manufacturing_plan), encoding="utf-8") ai3d._validate_source(source.read_text(encoding="utf-8")) built = build_and_authoritatively_validate( ai3d, build_dir, source.name, requested_exports=["glb"] ) built["multiview_previews"] = render_multiview(ai3d, build_dir, built["step_path"]) cad_views = [] for item in built["multiview_previews"]: view_path = pathlib.Path(item["path"]) cad_views.extend([ {"type": "text", "text": f"候选CAD {item['camera']}正交视图(不是原图证据):"}, {"type": "image_url", "image_url": { "url": "data:image/png;base64," + base64.b64encode(view_path.read_bytes()).decode() }}, ]) review = engine._request_model_json({ "model": model, "messages": [{"role": "user", "content": images + cad_views + [{ "type": "text", "text": ( "必须分别使用CAD正面、右侧、俯视和等轴视图,对照全部原始图核验CAD侧板分段、抽屉排列、" "所有可见板件数量、门型、部件方向、连接关系和外形比例。" "这是诊断草图,不能因为内核已通过或画面简单就判正确。" "先由AI给出可执行correction;uncertain仅保留所有原图和四个CAD视图都不能消解的隐蔽信息。" "精确尺寸图和已通过资格门的尺寸台账优先于照片透视比例;不能仅因照片看起来更长或更短就否定图纸尺寸。" "只有发现真实错误时才列入issues,已经正确或无需修改的项目严禁列入。" "code只能是missing_component、extra_component、component_count_mismatch、geometric_placement_mismatch、" "orientation_mismatch、assembly_relation_mismatch、silhouette_mismatch、numeric_dimension_mismatch、" "hardware_type_mismatch、hardware_count_mismatch、door_type_mismatch。" "numeric_dimension_mismatch必须同时返回expected_mm和actual_mm。" "evidence不超过600字符,correction不超过400字符;禁止输出思考过程。" "只返回JSON {ok:boolean,issues:[{code,component,source_image_id,evidence,correction,expected_mm?,actual_mm?}]," "uncertain:[{code,component,source_image_id,evidence,single_user_question}]}。" "每个问题引用一张能证明问题的原图ID;没有原图证据的推测放uncertain。\n当前编译器问题:" + json.dumps(compiled.get("issues") or [], ensure_ascii=False) + "\n已通过资格门的尺寸台账:" + json.dumps(dimension_ledger, ensure_ascii=False) + "\n当前由物理实例生成的BOM:" + json.dumps(compiled.get("bom") or [], ensure_ascii=False) + "\n由真实接触面生成的孔槽与连接件计划:" + json.dumps(manufacturing_plan, ensure_ascii=False) ), }]}], "response_format": {"type": "json_object"}, "temperature": 0.0, "enable_thinking": True, "thinking_budget": 2048, "vl_high_resolution_images": True, "max_tokens": 6000, }, "原图与结构图谱CAD复核失败", max_attempts=2, timeout_seconds=150, decoder=lambda text: normalize_visual_review( json.loads(text), image_ids=image_ids, dimension_ledger=dimension_ledger, )) return { "built": built, "visual_review": review, "diagnostic_mounting_clearance_audit": diagnostic_mounting_clearance_cuts(compiled)["audit"], } def main(): parser = argparse.ArgumentParser() parser.add_argument("--engine", required=True) parser.add_argument("--assets", required=True) parser.add_argument("--output", required=True) parser.add_argument("--cases", default="book,slide") parser.add_argument("--model", default="qwen3.5-plus") parser.add_argument("--rounds", type=int, default=2) parser.add_argument( "--resume-report", help="Resume from the lowest-blocker audited graph in a prior isolated report.", ) parser.add_argument( "--material-family", default="engineered_panel", choices=("engineered_panel", "solid_wood", "metal", "plastic", "glass", "other"), help="Authoritative user-selected material family; never inferred from the image.", ) parser.add_argument( "--scope-facts-json", help=( "JSON object/list of already verified user or independent dimension-scope facts. " "The probe validates and binds them outside the model patch loop." ), ) args = parser.parse_args() out = pathlib.Path(args.output) out.mkdir(parents=True, exist_ok=True) os.environ["DALONGXIA_SOLID_WOOD_COST_STATE_DIR"] = str(out / "isolated-cost-state") os.environ["DALONGXIA_AI_3D_STATE_DIR"] = str(out / "isolated-cad-state") os.environ["DALONGXIA_SOLID_WOOD_HISTORY_BOM_ENABLED"] = "0" spec = importlib.util.spec_from_file_location("graph_probe_engine", args.engine) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) engine = mod.SolidWoodCostEngine() engine.model = args.model ai3d = engine._load_ai3d() reports = [] for case in args.cases.split(","): case_dir = out / case case_dir.mkdir(exist_ok=True) images = [] source_assets = [] image_ids = set() for index, role in enumerate(("front", "side", "third", "dimension"), 1): source = source_for_role(pathlib.Path(args.assets), case, role) if source is None: raise RuntimeError(f"missing source view: {case}/{role}") image_id = f"image{index}" image_ids.add(image_id) mime = "image/png" if source.suffix == ".png" else "image/jpeg" data_url = f"data:{mime};base64," + base64.b64encode(source.read_bytes()).decode() images.extend([{"type": "text", "text": f"原始图ID={image_id};候选视角={role},需按图片内容复核。"}, {"type": "image_url", "image_url": {"url": data_url}}]) source_assets.append({ "asset_id": index, "role": role, "kind": "image_base64", "value": data_url, "filename": source.name, "validation_status": "accepted", }) report = {"case": case, "schema_version": SCHEMA_VERSION, "model": args.model, "material_family": args.material_family, "full_chat_api": False, "company_bom_used_in_inference": False, "rounds": [], "status": "not_validated"} resume_payload = None if args.resume_report: resume_path = pathlib.Path(args.resume_report) if resume_path.is_dir(): resume_path = resume_path / case / "report.json" resume_payload = json.loads(resume_path.read_text(encoding="utf-8")) if isinstance(resume_payload, list): matches = [row for row in resume_payload if row.get("case") == case] if len(matches) != 1: raise RuntimeError(f"resume report does not uniquely contain case: {case}") resume_payload = matches[0] if ( not isinstance(resume_payload, dict) or resume_payload.get("case") != case or resume_payload.get("schema_version") != SCHEMA_VERSION or resume_payload.get("company_bom_used_in_inference") is not False ): raise RuntimeError("resume report is not a compatible no-company-BOM isolated checkpoint") report["source_qualification"] = copy.deepcopy(resume_payload.get("source_qualification") or {}) print(json.dumps({ "case": case, "phase": "resume_audited_isolated_checkpoint", "source_report": str(resume_path), }, ensure_ascii=False), flush=True) else: print(json.dumps({"case": case, "phase": "ai_source_evidence_qualification"}), flush=True) source_structure, source_dimensions = {}, {} try: source_structure = engine._read_source_structure(source_assets) except Exception as exc: source_structure = {"status": "machine_reread_failed", "error": f"{type(exc).__name__}: {exc}"} try: source_dimensions = engine._read_source_dimension_labels( source_assets[-1], [source_assets[0], source_assets[1]] ) except Exception as exc: source_dimensions = {"status": "machine_reread_failed", "error": f"{type(exc).__name__}: {exc}"} dimension_ledger = build_dimension_ledger( source_dimensions.get("overall_dimension_labels") or [], source_image_id="image4" ) topology_contract = build_source_topology_contract(source_structure) report["source_qualification"] = { "structure": source_structure, "dimensions": source_dimensions, "dimension_ledger": dimension_ledger, "topology_contract": topology_contract, "policy": "AI先交叉识别和CAD复核;只有不可见且成本敏感的残余冲突才询问用户", } source_qualification = report["source_qualification"] source_structure = source_qualification.get("structure") or {} source_dimensions = source_qualification.get("dimensions") or {} dimension_ledger = source_qualification.get("dimension_ledger") or {} topology_contract = source_qualification.get("topology_contract") or {} scope_facts = [] if args.scope_facts_json: scope_facts = normalize_scope_facts( json.loads(args.scope_facts_json), image_ids=image_ids, dimension_ledger=dimension_ledger, ) report["verified_scope_facts"] = copy.deepcopy(scope_facts) bound_user_evidence = {} bound_scope_adjudications = {} def compile_candidate_graph(candidate_graph: dict) -> dict: rebound, candidate_user_evidence, candidate_adjudications, _ = bind_scope_facts( candidate_graph, scope_facts, ) return compile_graph( rebound, source_image_ids=image_ids, user_evidence=candidate_user_evidence, dimension_ledger=dimension_ledger, dimension_scope_adjudications=candidate_adjudications, topology_contract=topology_contract, ) def checkpoint(current_row=None): snapshot = json.loads(json.dumps(report, ensure_ascii=False)) if current_row is not None: snapshot["rounds"] = list(snapshot.get("rounds") or []) + [current_row] (case_dir / "report.json").write_text( json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8" ) checkpoint() missing_axes = [axis for axis in "xyz" if axis not in dimension_ledger] if missing_axes or dimension_ledger.get("conflicts"): report["status"] = "source_dimensions_not_qualified" report["source_qualification"]["blocking"] = { "code": "overall_dimension_ledger_incomplete", "missing_axes": missing_axes, "conflicts": dimension_ledger.get("conflicts") or [], "next_owner": "ai_focused_source_reread", } reports.append(report) (case_dir / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") (out / "report.json").write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps({ "case": case, "status": report["status"], "blocking": report["source_qualification"]["blocking"], }, ensure_ascii=False), flush=True) continue evidence_context = ( "\n以下是同一批原图经过两个独立AI专项读取后的候选证据。它们不是公司BOM,也不是最终答案;" "你必须回看原图。dimension_ledger中存在的X/Y/Z是已通过范围、方向和置信度门的整体尺寸,必须精确采用;" "structure.view_adjudication.accepted是跨视图成立的结构约束;conflicts和machine_followups应由本轮图像复核解决," "不能直接转嫁给用户。\n" + json.dumps({ "structure": source_structure, "dimension_read": source_dimensions, "dimension_ledger": dimension_ledger, "topology_contract": topology_contract, "verified_scope_facts": scope_facts, }, ensure_ascii=False) ) graph, feedback = {}, [] accepted_compile_issue_count = None accepted_total_blocker_count = None round_number_base = 0 if resume_payload: best = select_resume_checkpoint(resume_payload.get("rounds") or []) graph, bound_user_evidence, bound_scope_adjudications, scope_binding_audit = bind_scope_facts( copy.deepcopy(best["graph"]), scope_facts, ) report["scope_binding_audit"] = scope_binding_audit refreshed_compiled = compile_graph( graph, source_image_ids=image_ids, user_evidence=bound_user_evidence, dimension_ledger=dimension_ledger, dimension_scope_adjudications=bound_scope_adjudications, topology_contract=topology_contract, ) accepted_compile_issue_count = len(refreshed_compiled.get("issues") or []) refreshed_manufacturing_plan = plan_manufacturing_features( refreshed_compiled, policy={"default_material_family": args.material_family}, ) refreshed_kernel = refresh_compiled_kernel_feedback( ai3d, case_dir / "resume-kernel-refresh", refreshed_compiled, refreshed_manufacturing_plan, ) prior_kernel_feedback = list(refreshed_kernel.get("feedback") or []) refreshed_checkpoint = { "compiled": refreshed_compiled, "manufacturing_plan": refreshed_manufacturing_plan, "cad_kernel_feedback": prior_kernel_feedback, "visual_review": best.get("visual_review"), } accepted_total_blocker_count = round_blocker_count(refreshed_checkpoint) feedback = ( list(refreshed_compiled.get("issues") or []) + ( list(refreshed_manufacturing_plan.get("issues") or []) if refreshed_compiled.get("ok") else [] ) + prior_kernel_feedback + list((best.get("visual_review") or {}).get("issues") or []) + list((best.get("visual_review") or {}).get("uncertain") or []) ) feedback = enrich_hardware_feedback_with_verified_placements( graph, refreshed_compiled, feedback, compile_candidate=compile_candidate_graph, ) round_number_base = int(best.get("round") or 0) report["resumed_from"] = { "source_round": round_number_base, "source_graph_sha256": refreshed_compiled.get("graph_sha256"), "source_end_to_end_blocker_count": accepted_total_blocker_count, "selection": "minimum_end_to_end_blocker_count_then_latest_round", "compiler_feedback_refreshed": True, "kernel_feedback_refreshed": True, "refreshed_kernel_blocker_count": len(prior_kernel_feedback), "diagnostic_mounting_clearance_count": len( refreshed_kernel.get("diagnostic_mounting_clearance_audit") or [] ), } if scope_facts: projected_graph, projection_audit = propose_reference_scope_projection( graph, refreshed_compiled, scope_facts, dimension_ledger, ) projection_record = {"audit": projection_audit, "accepted": False} if projected_graph is not None: projected_graph, projected_user_evidence, projected_adjudications, projected_binding = bind_scope_facts( projected_graph, scope_facts, ) projected_compiled = compile_graph( projected_graph, source_image_ids=image_ids, user_evidence=projected_user_evidence, dimension_ledger=dimension_ledger, dimension_scope_adjudications=projected_adjudications, topology_contract=topology_contract, ) projected_manufacturing = plan_manufacturing_features( projected_compiled, policy={"default_material_family": args.material_family}, ) projected_kernel = refresh_compiled_kernel_feedback( ai3d, case_dir / "scope-projection-kernel", projected_compiled, projected_manufacturing, ) projected_checkpoint = { "compiled": projected_compiled, "manufacturing_plan": projected_manufacturing, "cad_kernel_feedback": list(projected_kernel.get("feedback") or []), } projected_score = round_blocker_count(projected_checkpoint) projection_record.update({ "binding_audit": projected_binding, "compiled": projected_compiled, "manufacturing_plan": projected_manufacturing, "cad_kernel_feedback": projected_checkpoint["cad_kernel_feedback"], "end_to_end_blocker_count": projected_score, "previous_end_to_end_blocker_count": accepted_total_blocker_count, }) if ( accepted_total_blocker_count is None or projected_score < accepted_total_blocker_count ): graph = projected_graph bound_user_evidence = projected_user_evidence bound_scope_adjudications = projected_adjudications refreshed_compiled = projected_compiled refreshed_manufacturing_plan = projected_manufacturing accepted_compile_issue_count = len(projected_compiled.get("issues") or []) accepted_total_blocker_count = projected_score feedback = ( list(projected_compiled.get("issues") or []) + ( list(projected_manufacturing.get("issues") or []) if projected_compiled.get("ok") else [] ) + projected_checkpoint["cad_kernel_feedback"] ) feedback = enrich_hardware_feedback_with_verified_placements( graph, projected_compiled, feedback, compile_candidate=compile_candidate_graph, ) projection_record["accepted"] = True projection_record["acceptance_rule"] = "strict_end_to_end_blocker_reduction" else: projection_record["rejection_reason"] = "end_to_end_blocker_count_not_reduced" report["scope_projection"] = projection_record if ( accepted_total_blocker_count == 0 and refreshed_compiled.get("ok") and refreshed_manufacturing_plan.get("ok") ): print(json.dumps({ "case": case, "phase": "resume_blocker_free_visual_review", "source_round": round_number_base, }, ensure_ascii=False), flush=True) try: resumed_review = build_render_and_review( engine=engine, model=args.model, images=images, ai3d=ai3d, build_dir=case_dir / "resume-visual-review", compiled=refreshed_compiled, manufacturing_plan=refreshed_manufacturing_plan, image_ids=image_ids, dimension_ledger=dimension_ledger, ) except Exception as exc: dependency_feedback = terminal_dependency_feedback(exc) if dependency_feedback: report["resume_visual_review"] = { "dependency_error": dependency_feedback, } report["status"] = "visual_review_dependency_failed" reports.append(report) (case_dir / "report.json").write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" ) (out / "report.json").write_text( json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8" ) print(json.dumps({ "case": case, "status": report["status"], "blocking": dependency_feedback, }, ensure_ascii=False), flush=True) continue kernel_feedback = ( exc.feedback if isinstance(exc, AuthoritativeKernelError) else structured_kernel_feedback(exc) ) report["resume_visual_review"] = { "error": f"{type(exc).__name__}: {exc}", "cad_kernel_feedback": kernel_feedback, } feedback = kernel_feedback accepted_total_blocker_count = len(kernel_feedback) report["status"] = "resume_visual_review_build_failed" else: review = resumed_review["visual_review"] report["resume_visual_review"] = resumed_review feedback = list(review.get("issues") or []) + list(review.get("uncertain") or []) accepted_total_blocker_count = len(feedback) if review.get("ok") is True and not feedback and not graph.get("uncertainties"): report["status"] = "candidate_visual_pass_requires_independent_acceptance" reports.append(report) (case_dir / "report.json").write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" ) (out / "report.json").write_text( json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8" ) print(json.dumps({ "case": case, "status": report["status"], "source_round": round_number_base, }, ensure_ascii=False), flush=True) continue report["status"] = "resume_visual_not_passed" for turn in range(max(1, min(args.rounds, 5))): started = time.time() row = {"round": round_number_base + turn + 1} round_base_graph = graph round_base_feedback = list(feedback) round_base_compile_issue_count = accepted_compile_issue_count prompt = ( GRAPH_PROMPT + "\n本次允许使用的五金模板合同:" + json.dumps(supported_hardware_contract(), ensure_ascii=False, separators=(",", ":")) + evidence_context ) if graph: prompt = ( "你在修正一份已存在的参数化家具结构图谱。必须回看本轮所有原图,并只返回局部JSON修改,不能返回整份图谱。" "每条操作为add/replace/remove和JSON Pointer路径;修复引用、参数、实例位置、接触面或遗漏部件。" "不能通过放大公差、删除有原图证据的部件/连接或改低置信度来逃避问题。" "一次最多24条patch,优先级固定为:原图整体尺寸冲突、总成局部边界错误、五金安装宿主/安装面错误、实体穿插、已定义却无实例的部件、总成归属/总成关系错误、几何未真实接触、其他外观差异。" "若零件observed_quantity大于实例数,必须为每个真实物理件补instances,不能只保留零件定义。" "同一总成的固定关系不准手写connections;必须修正部件尺寸、placement锚点或assembly_id,让求解器计算坐标并从真实接触面推导连接。" "anchored_axes_v1下严禁修改不存在的position_mm;必须修改总成bounds_mm、零件size_mm或placement锚点/offset_mm。" "禁止replace或remove整个/instances//placement;只修改反馈明确指出的/placement/x、/placement/y或/placement/z单轴,不能连带改坏已通过的轴。" "若仍返回完整placement,系统只会拆成实际发生变化的单轴修改并记录审计,不会允许绕过底层保护。" "assembly.role只允许carcass/drawer/door/module/frame/hardware_group/other;门总成必须写door,运动类型写assembly_connections.kind。" "图谱固定使用orientation_solver=role_axis_templates_v1;每个板件必须保留functional_role,size_mm直接表示产品X/Y/Z,thickness_axis也是产品轴。" "严禁新增或修改axis_order;若角色朝向冲突,必须修正part的size_mm与thickness_axis,不能对instance二次旋转。" "编译器会用这些字段区分面贴面、边贴面与边贴边,不允许用名称代替接口语义。" "每个hardware part必须声明本轮允许合同中的hardware_family和hardware_template_id," "每个hardware实例必须补齐该模板全部mounting slots;每个slot包含slot、host_instance_id、" "kind、face、embed_depth_mm、mounting_center_mm和evidence_ids。kind、embed_depth_mm、" "宿主角色必须逐字服从模板合同,35mm铰链还要声明允许的hardware_axis,并满足杯孔边距和底座系统线。" "五金part的1×1×1与实例placement只是不可计价占位,禁止移动占位盒制造接触,禁止把旋钮或整只铰链写成through。" "hardware_mounting_geometry只允许回看原图修正slot宿主、广面face与mounting_center_mm;" "不得修改模板孔径、孔距、嵌入深度或把另一处无关板件挪来配合五金。" "solid_interference反馈中若有compatible_separation_candidates,必须回看原图后优先选择" "最小必要位移且符合真实装配方向的候选;位移量相同时再选" "remaining_collision_count更少的候选,逐字采用其patches;" "候选中总成bounds修改与唯一结构件位移是一个原子修复,不得只做一半。" "若minimum_separation_axis_infeasible=true,几何已证明最短分离方向内没有可用位置;" "不得再把同一零件在两个障碍物之间往返移动。必须回看原图,只在有证据时修正" "部件尺寸、外形口径、覆盖/内嵌语义或显式槽口;若原图高置信尺寸本身互相矛盾," "在uncertainties中记录准确冲突和唯一需确认的问题,不得猜一个数值强行通过。" "修正定位时优先使用稳定实例ID。若某轴当前没有value_mm或offset_mm,不得replace不存在的叶子字段;" "必须replace整个轴对象,或对align轴用add新增offset_mm。" "合法示例:{\"patches\":[{\"op\":\"replace\",\"path\":\"/instances/door_left_inst/placement/x\"," "\"value\":{\"mode\":\"absolute\",\"value_mm\":20}}]}。" "路径可修改observations、parameters、envelope_mm、reference_bounds_mm、dimension_scope_overrides、assemblies、parts、instances、connections、assembly_connections、uncertainties、placement_solver、orientation_solver、contact_derivation;" "数组追加使用/-。修改后必须符合furniture-assembly-graph/2。" "\n本轮允许的完整五金模板合同:" + json.dumps(supported_hardware_contract(), ensure_ascii=False, separators=(",", ":")) + "\n上一版图谱:" + json.dumps(graph, ensure_ascii=False) + evidence_context + "\n编译器及原图视觉复核的结构化反馈:" + json.dumps(feedback, ensure_ascii=False) ) phase = "ai_graph_from_original_images" if not graph else "ai_local_graph_repair_from_feedback" print(json.dumps({"case": case, "round": row["round"], "phase": phase}), flush=True) try: response = engine._request_model_json({ "model": args.model, "messages": [{"role": "user", "content": images + [{"type": "text", "text": prompt}]}], "response_format": {"type": "json_object"}, "temperature": 0.0, "enable_thinking": True, "thinking_budget": 2048, "vl_high_resolution_images": True, "max_tokens": 14000, }, "原图到参数化结构图谱失败", max_attempts=1, timeout_seconds=180, decoder=lambda text: json.loads(text)) if graph: row["patch_response"] = response previous_graph = graph try: normalized_patches, normalization_audit = normalize_graph_patch_payload(graph, response) row["patch_normalization_audit"] = normalization_audit row["normalized_patches"] = normalized_patches candidate_graph, patch_audit = apply_graph_patch(graph, normalized_patches) candidate_graph, candidate_user_evidence, candidate_scope_adjudications, binding_audit = bind_scope_facts( candidate_graph, scope_facts, ) row["scope_binding_audit"] = binding_audit row["patch_audit"] = patch_audit candidate_compiled = compile_graph( candidate_graph, source_image_ids=image_ids, user_evidence=candidate_user_evidence, dimension_ledger=dimension_ledger, dimension_scope_adjudications=candidate_scope_adjudications, topology_contract=topology_contract, ) candidate_issue_count = len(candidate_compiled.get("issues") or []) if not compile_lower_bound_can_improve( candidate_issue_count, accepted_total_blocker_count ): graph = previous_graph row["patch_error"] = "repair_regression_rejected" row["patch_regression"] = { "accepted_end_to_end_blocker_count": accepted_total_blocker_count, "candidate_compile_issue_lower_bound": candidate_issue_count, "candidate_issue_codes": dict(__import__("collections").Counter( item.get("code") for item in candidate_compiled.get("issues") or [] )), } feedback = [{ "code": "repair_regression_rejected", "detail": ( f"本轮仅编译问题已有{candidate_issue_count}项,不可能严格优于上一版" f"端到端总阻塞{accepted_total_blocker_count}项,已保留上一版图谱。" ), "correction": "逐条采用编译器给出的稳定ID和单轴路径,禁止整段覆盖placement或引入统一经验偏移。", }] + round_base_feedback[:12] report["status"] = "regressive_patch_rejected" row["checkpoint_phase"] = "regressive_patch_rejected_graph_preserved" checkpoint(row) else: graph = candidate_graph bound_user_evidence = candidate_user_evidence bound_scope_adjudications = candidate_scope_adjudications except GraphError as exc: row["patch_error"] = f"{type(exc).__name__}: {exc}" raw_patches = response.get("patches") if isinstance(response, dict) else response feedback = structured_patch_feedback(graph, raw_patches, exc) row["patch_feedback"] = feedback report["status"] = "patch_not_applied" row["checkpoint_phase"] = "invalid_patch_rejected_graph_preserved" checkpoint(row) else: graph, bound_user_evidence, bound_scope_adjudications, binding_audit = bind_scope_facts( response, scope_facts, ) row["scope_binding_audit"] = binding_audit if not row.get("patch_error"): row["graph"] = graph compiled = compile_graph( graph, source_image_ids=image_ids, user_evidence=bound_user_evidence, dimension_ledger=dimension_ledger, dimension_scope_adjudications=bound_scope_adjudications, topology_contract=topology_contract, ) row["compiled"] = compiled accepted_compile_issue_count = len(compiled.get("issues") or []) feedback = list(compiled["issues"]) feedback = enrich_hardware_feedback_with_verified_placements( graph, compiled, feedback, compile_candidate=compile_candidate_graph, ) manufacturing_plan = plan_manufacturing_features( compiled, policy={"default_material_family": args.material_family}, ) row["manufacturing_plan"] = manufacturing_plan if compiled["ok"]: feedback += list(manufacturing_plan.get("issues") or []) row["checkpoint_phase"] = "graph_compiled" checkpoint(row) if compiled["instances"]: build_dir = case_dir / f"round-{row['round']}" build_dir.mkdir(exist_ok=True) source = build_dir / "assembly.step.py" row["diagnostic_mounting_clearance_audit"] = diagnostic_mounting_clearance_cuts(compiled)["audit"] source.write_text(cad_source(compiled, manufacturing_plan), encoding="utf-8") ai3d._validate_source(source.read_text()) try: built = build_and_authoritatively_validate( ai3d, build_dir, source.name, requested_exports=["glb"] ) except Exception as exc: row["cad_kernel_error"] = f"{type(exc).__name__}: {exc}" kernel_feedback = ( exc.feedback if isinstance(exc, AuthoritativeKernelError) else structured_kernel_feedback(exc) ) row["cad_kernel_feedback"] = kernel_feedback feedback += kernel_feedback report["status"] = ( "cad_kernel_not_passed" if compiled["ok"] else "graph_and_cad_kernel_not_passed" ) row["checkpoint_phase"] = "cad_kernel_rejected" checkpoint(row) else: built["multiview_previews"] = render_multiview(ai3d, build_dir, built["step_path"]) row["built" if compiled["ok"] else "diagnostic_built"] = built row["checkpoint_phase"] = "cad_built_before_visual_review" checkpoint(row) cad_views = [] for item in built["multiview_previews"]: view_path = pathlib.Path(item["path"]) cad_views.extend([ {"type": "text", "text": f"候选CAD {item['camera']}正交视图(不是原图证据):"}, {"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(view_path.read_bytes()).decode()}}, ]) review = engine._request_model_json({ "model": args.model, "messages": [{"role": "user", "content": images + cad_views + [ {"type": "text", "text": ( "必须分别使用CAD正面、右侧、俯视和等轴视图,对照全部原始图核验CAD侧板分段、抽屉排列、" "所有可见板件数量、门型、部件方向、连接关系和外形比例。" "这是诊断草图,编译错误中的零件可能未渲染;不能因为画面简单就判正确。" "先由AI给出可执行correction;uncertain仅保留所有原图和四个CAD视图都不能消解的隐蔽信息。" "精确尺寸图和尺寸台账优先于照片透视比例;已经正确或无需修改的项目严禁列入issues。" "code只能是missing_component、extra_component、component_count_mismatch、geometric_placement_mismatch、" "orientation_mismatch、assembly_relation_mismatch、silhouette_mismatch、numeric_dimension_mismatch、" "hardware_type_mismatch、hardware_count_mismatch、door_type_mismatch。" "numeric_dimension_mismatch必须同时返回expected_mm和actual_mm;禁止输出思考过程。" "只返回JSON {ok:boolean,issues:[{code,component,source_image_id,evidence,correction,expected_mm?,actual_mm?}],uncertain:[]}。" "每个问题引用一张能证明问题的原图ID;没有原图证据的推测放uncertain。\n编译器问题:" + json.dumps(compiled["issues"], ensure_ascii=False) + "\n已通过资格门的尺寸台账:" + json.dumps(dimension_ledger, ensure_ascii=False) + "\n当前由物理实例生成的BOM:" + json.dumps(compiled["bom"], ensure_ascii=False) + "\n由真实接触面生成的孔槽与连接件计划:" + json.dumps(manufacturing_plan, ensure_ascii=False) )}, ]}], "response_format": {"type": "json_object"}, "temperature": 0.0, "enable_thinking": True, "thinking_budget": 2048, "vl_high_resolution_images": True, "max_tokens": 6000, }, "原图与结构图谱CAD复核失败", max_attempts=2, timeout_seconds=150, decoder=lambda text: normalize_visual_review( json.loads(text), image_ids=image_ids, dimension_ledger=dimension_ledger, )) row["visual_review"] = review feedback = list(compiled["issues"]) + list(review.get("issues") or []) + list(review.get("uncertain") or []) if compiled["ok"] and manufacturing_plan.get("ok") and review.get("ok") is True and not feedback and not graph.get("uncertainties"): report["status"] = "candidate_visual_pass_requires_independent_acceptance" else: report["status"] = "visual_not_passed" if compiled["ok"] else "graph_not_passed_with_diagnostic_render" else: report["status"] = "graph_not_passed" except Exception as exc: row["error"] = f"{type(exc).__name__}: {exc}" dependency_feedback = terminal_dependency_feedback(exc) if dependency_feedback: row["terminal_dependency_error"] = dependency_feedback report["status"] = "external_dependency_failed" feedback = [dependency_feedback] else: report["status"] = "failed" feedback = [{"code": "request_or_compile_error", "detail": row["error"]}] if not row.get("patch_error") and isinstance(row.get("compiled"), dict): candidate_blocker_count = round_blocker_count(row) row["end_to_end_blocker_count"] = candidate_blocker_count if ( round_base_graph and accepted_total_blocker_count is not None and candidate_blocker_count >= accepted_total_blocker_count ): candidate_graph = graph graph = round_base_graph accepted_compile_issue_count = round_base_compile_issue_count row["patch_error"] = "end_to_end_repair_regression_rejected" row["candidate_graph"] = candidate_graph row["end_to_end_regression"] = { "previous_blocker_count": accepted_total_blocker_count, "candidate_blocker_count": candidate_blocker_count, "count_basis": "compile+manufacturing_when_compile_ok+kernel+visual", } feedback = [{ "code": "end_to_end_repair_regression_rejected", "detail": ( f"本轮修改后综合阻塞数为{candidate_blocker_count}项,未优于上一版" f"{accepted_total_blocker_count}项,已保留上一版图谱。" ), "correction": ( "同时核对编译器与B-rep内核反馈,只采用不会产生新冲突的单轴候选;" "不得用减少边界提示来换取更多实体穿插。" ), }] + round_base_feedback[:12] report["status"] = "regressive_end_to_end_patch_rejected" else: accepted_total_blocker_count = candidate_blocker_count row["elapsed_seconds"] = round(time.time() - started, 2) row.pop("checkpoint_phase", None) report["rounds"].append(row) (case_dir / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps({"case": case, "round": row["round"], "status": report["status"], "elapsed": row["elapsed_seconds"], "feedback": feedback[:8]}, ensure_ascii=False), flush=True) if row.get("terminal_dependency_error"): break if report["status"].startswith("candidate_visual_pass"): break resolved_scope_axes = {fact["axis"] for fact in scope_facts} dead_end_axes = { axis for item in round_base_feedback if isinstance(item, dict) and item.get("minimum_separation_axis_infeasible") is True for axis in (item.get("minimum_separation_axes") or []) } if ( row.get("patch_error") and has_minimum_axis_packing_dead_end(round_base_feedback) and not (resolved_scope_axes & dead_end_axes) ): report["status"] = "geometry_dead_end_requires_focused_source_adjudication" dead_end_feedback = [ item for item in round_base_feedback if isinstance(item, dict) and item.get("minimum_separation_axis_infeasible") is True ] required_question_focus = dead_end_question_focus(dead_end_feedback, round_base_graph) dead_end_axis = next(iter( axis for item in dead_end_feedback for axis in (item.get("minimum_separation_axes") or []) if axis in {"x", "y", "z"} ), "y") report["blocking"] = { "code": "minimum_axis_packing_dead_end", "detail": ( "最短分离轴没有合法放置区间,且一轮原图修复未使端到端阻塞下降。" "已停止前后循环移动;下一步必须聚焦判定外形口径、覆盖/内嵌关系、" "部件尺寸或明确避让加工。" ), "next_owner": "ai_focused_source_adjudication_then_single_user_question_if_still_ambiguous", } print(json.dumps({ "case": case, "phase": "ai_focused_source_adjudication", "dead_end_count": len(dead_end_feedback), }, ensure_ascii=False), flush=True) try: adjudication_response = engine._request_model_json({ "model": args.model, "messages": [{"role": "user", "content": images + [{ "type": "text", "text": ( "你现在只做一次聚焦的原图几何口径裁决,不重画CAD、不报价、不读公司BOM。" "几何编译器已证明当前冲突在最短分离轴内没有任何合法平移位置。" "必须回看每张原图的尺寸箭头两端、门/背板/层板覆盖关系和可见槽口," "在下列四种decision中只选一个:" "occupied_overall_dimension(箭头是成品实际外形)、" "reference_carcass_or_opening_dimension(箭头仅量柜体/框架/开口)、" "explicit_machining_relief(原图明确有槽口/避让使实体可共存)、" "source_dimensions_conflict(原图本身无法唯一解释)。" "不得从家具常识猜测;不可见就选source_dimensions_conflict。" "只返回JSON:{decision,axis,confidence,evidence_image_ids,evidence," "question_focus,single_user_question,question_options}。只有decision=source_dimensions_conflict或" "confidence<0.8时才能填写一个single_user_question和2至3个互斥选项," "question_focus只能是overall_dimension_scope/component_dimension/assembly_relation/" "machining_relief。优先确认已标“整体”的尺寸是否包含前置门板/面板等凸出件;" "只有这一口径已明确时,才问部件尺寸或槽口。问题不超过100字,选项只能2至3个且每个不超过60字;" "不得出现image1、X/Y/Z轴、AABB、干涉体积等内部术语,不得给出会让占用空间更大的伪修复选项。" f"本轮结构规则确定若需要询问,question_focus必须是{required_question_focus}。" "其余情况留空。\n当前不可平移消解的结构化冲突:" + json.dumps(dead_end_feedback, ensure_ascii=False) ), }]}], "response_format": {"type": "json_object"}, "temperature": 0.0, "enable_thinking": True, "thinking_budget": 1536, "vl_high_resolution_images": True, "max_tokens": 3500, }, "原图尺寸口径聚焦裁决失败", max_attempts=2, timeout_seconds=150, decoder=lambda text: normalize_focused_source_adjudication( json.loads(text), image_ids=image_ids, required_question_focus=required_question_focus, )) focused = adjudication_response report["focused_source_adjudication"] = focused if focused["requires_user_question"]: report["status"] = "geometry_source_conflict_requires_one_user_question" report["blocking"]["next_owner"] = "user_single_focused_question" report["blocking"]["single_user_question"] = focused["single_user_question"] report["blocking"]["question_options"] = focused["question_options"] else: report["status"] = "focused_source_adjudication_ready_for_graph_repair" report["blocking"]["next_owner"] = "graph_repair_with_independent_scope_adjudication" except Exception as exc: report["focused_source_adjudication_error"] = f"{type(exc).__name__}: {exc}" focused = deterministic_dead_end_question( focus=required_question_focus, axis=dead_end_axis, dimension_ledger=dimension_ledger, graph=round_base_graph, ) report["focused_source_adjudication"] = focused report["status"] = "geometry_source_conflict_requires_one_user_question" report["blocking"]["next_owner"] = "user_single_focused_question" report["blocking"]["single_user_question"] = focused["single_user_question"] report["blocking"]["question_options"] = focused["question_options"] (case_dir / "report.json").write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" ) break if ( row.get("patch_error") and has_minimum_axis_packing_dead_end(round_base_feedback) and resolved_scope_axes & dead_end_axes ): report["status"] = "verified_scope_fact_requires_additional_geometry_repair" report["blocking"] = { "code": "verified_scope_fact_not_yet_realized_in_geometry", "resolved_axes": sorted(resolved_scope_axes & dead_end_axes), "detail": "该尺寸口径已有可信答案,禁止再次询问;继续局部修复参考包络、实际占用包络和装配位置。", "next_owner": "bounded_graph_repair_without_reasking_user", } (case_dir / "report.json").write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" ) reports.append(report) (out / "report.json").write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8") if __name__ == "__main__": main()