2026-09-08 00:09:22 +08:00

1117 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 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
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 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 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/1",
"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"]}],
"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":"hardware_part_id","assembly_id":"明确所属总成ID","placement":{"x":{"mode":"absolute","value_mm":0},"y":{"mode":"absolute","value_mm":0},"z":{"mode":"absolute","value_mm":0}},"mountings":[{"host_instance_id":"实际安装板件ID","kind":"surface","face":"y-","embed_depth_mm":0,"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的物理实例必须声明mountings,指向真实安装的非五金宿主实例。把手通常一个宿主,铰链可有门板和柜体两个宿主;不得只靠中文名称表示安装关系。
mountings.kind只允许surface/mortised/through;face是宿主朝外安装面x-/x+/y-/y+/z-/z+;embed_depth_mm是实际嵌入宿主的深度,surface必须为0。安装件在该面投影上必须与宿主有正面积重叠,法向必须接触或严格符合嵌入深度。五金可经过验证后外凸于结构总成边界,但不能藉此掩盖把手装到错误门板、悬空或非宣称宿主的穿插。
总成bounds_mm只由门板、抽屉板、柜体板等结构实体的紧包围盒确定,不应把把手、铰链的外凸纳入边界。五金与宿主的正体积交只有在mortised/through关系且嵌入深度与解算几何一致时才允许。
所有运动连接必须引用真实实例和真实总成,不能用大公差掩盖错位。
只画现有图像支持的结构;不要根据常见柜子想象层板数量。未标注内部尺寸先由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 []:
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 {}
mounting_clearances = diagnostic_mounting_clearance_cuts(compiled)["cuts_by_host"]
for index, inst in enumerate(compiled["instances"]):
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 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.",
)
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 {}
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,
}, 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 = copy.deepcopy(best["graph"])
refreshed_compiled = compile_graph(
graph, source_image_ids=image_ids, dimension_ledger=dimension_ledger,
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 [])
)
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 []
),
}
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 + 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/<ID>/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实例必须声明mountings的host_instance_id/kind/face/embed_depth_mm/evidence_ids;"
"surface安装嵌入深度为0,mortised/through必须与实际穿入宿主的几何深度一致。"
"铰链等跨越两个结构件的五金可声明两个mountings;修复五金时不得改大嵌入深度来掩盖错位。"
"hardware_mounting_geometry反馈中若有compatible_mounting_values,必须优先逐字采用其中kind/face/embed_depth_mm;"
"该候选由解算几何生成,不得继续猜测其他轴面。候选为空时才修正宿主或placement。"
"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、assemblies、parts、instances、connections、assembly_connections、uncertainties、placement_solver、orientation_solver、contact_derivation;"
"数组追加使用/-。修改后仍需符合furniture-assembly-graph/1。\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)
row["patch_audit"] = patch_audit
candidate_compiled = compile_graph(
candidate_graph, source_image_ids=image_ids, dimension_ledger=dimension_ledger,
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
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 = response
if not row.get("patch_error"):
row["graph"] = graph
compiled = compile_graph(
graph, source_image_ids=image_ids, dimension_ledger=dimension_ledger,
topology_contract=topology_contract,
)
row["compiled"] = compiled
accepted_compile_issue_count = len(compiled.get("issues") or [])
feedback = list(compiled["issues"])
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视图都不能消解的隐蔽信息。"
"只返回JSON {ok:boolean,issues:[{code,component,source_image_id,evidence,correction}],uncertain:[]}。"
"每个问题引用一张能证明问题的原图ID;没有原图证据的推测放uncertain。\n编译器问题:"
+ json.dumps(compiled["issues"], 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=1, timeout_seconds=150, decoder=lambda text: json.loads(text))
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}"
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 report["status"].startswith("candidate_visual_pass"):
break
if row.get("patch_error") and has_minimum_axis_packing_dead_end(round_base_feedback):
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
reports.append(report)
(out / "report.json").write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()