2026-09-07 21:04:02 +08:00

551 lines
37 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 importlib.util
import json
import os
import pathlib
import re
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"),
}
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 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":["width","depth","height"],
"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":"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}}}],
"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。
全图只使用一个产品坐标系: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数量只等于真实实例数,不额外产生总成板。
所有运动连接必须引用真实实例和真实总成,不能用大公差掩盖错位。
只画现有图像支持的结构;不要根据常见柜子想象层板数量。未标注内部尺寸先由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 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 {}
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}",
])
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 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(
"--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"}
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复核;只有不可见且成本敏感的残余冲突才询问用户",
}
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
for turn in range(max(1, min(args.rounds, 5))):
started = time.time()
row = {"round": turn + 1}
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二次旋转。"
"编译器会用这些字段区分面贴面、边贴面与边贴边,不允许用名称代替接口语义。"
"修正定位时优先使用稳定实例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": turn + 1, "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 (
accepted_compile_issue_count is not None
and candidate_issue_count > accepted_compile_issue_count
):
graph = previous_graph
row["patch_error"] = "repair_regression_rejected"
row["patch_regression"] = {
"previous_issue_count": accepted_compile_issue_count,
"candidate_issue_count": 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"本轮局部修改使编译问题从{accepted_compile_issue_count}项增加到"
f"{candidate_issue_count}项,已保留上一版图谱;下一轮必须缩小修改范围。"
),
"correction": "逐条采用编译器给出的稳定ID和单轴路径,禁止整段覆盖placement或引入统一经验偏移。",
}] + list(candidate_compiled.get("issues") or [])[: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-{turn + 1}"
build_dir.mkdir(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())
try:
built = ai3d._build_and_validate(build_dir, source.name, requested_exports=["glb"])
except Exception as exc:
row["cad_kernel_error"] = f"{type(exc).__name__}: {exc}"
kernel_feedback = 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"]}]
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": turn + 1, "status": report["status"], "elapsed": row["elapsed_seconds"], "feedback": feedback[:8]}, ensure_ascii=False), flush=True)
if report["status"].startswith("candidate_visual_pass"):
break
reports.append(report)
(out / "report.json").write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()