2552 lines
149 KiB
Python
2552 lines
149 KiB
Python
"""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 (
|
||
ALLOWED_ASSEMBLY_ROLES, ALLOWED_PART_ROLES, 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
|
||
|
||
|
||
class PartitionedGraphError(GraphError):
|
||
"""A bounded generation pass failed before an unsafe graph was merged."""
|
||
|
||
def __init__(self, message: str, audit: list[dict]):
|
||
super().__init__(message)
|
||
self.audit = copy.deepcopy(audit)
|
||
|
||
|
||
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,
|
||
}
|
||
if any(token in message for token in ("模型鉴权失败", "HTTP Error 401", "HTTP Error 403", "invalid_api_key")):
|
||
return {
|
||
"code": "model_credentials_invalid",
|
||
"severity": "blocking",
|
||
"detail": "读图模型已拒绝当前鉴权凭据",
|
||
"next_owner": "runtime_configuration",
|
||
"retryable": False,
|
||
}
|
||
if "读图模型输出被截断" in message:
|
||
return {
|
||
"code": "model_output_truncated",
|
||
"severity": "blocking",
|
||
"detail": "读图服务的实际输出上限不足以返回完整结构化结果",
|
||
"next_owner": "model_gateway_capacity",
|
||
"retryable": False,
|
||
}
|
||
if any(token in message.lower() for token in ("read operation timed out", "timed out", "timeout")):
|
||
return {
|
||
"code": "model_request_timeout",
|
||
"severity": "blocking",
|
||
"detail": "模型请求在当前结构任务时间窗内未返回;禁止原样重复同一大请求",
|
||
"next_owner": "request_partitioning_or_model_routing",
|
||
"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 blockers at the currently authoritative validation stage.
|
||
|
||
B-rep and visual diagnostics generated from a graph that still fails its
|
||
structural compiler are useful artifacts, but they are not stable repair
|
||
targets: moving a part to address one such clash can multiply topology
|
||
errors. Until compilation passes, compare compiler blockers only. After it
|
||
passes, require monotonic improvement across the later gates as before.
|
||
"""
|
||
compiled = row.get("compiled") or {}
|
||
total = len(compiled.get("issues") or [])
|
||
if not compiled.get("ok"):
|
||
return total
|
||
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 qualified_occupied_scope_adjudication(dimension_ledger: dict, axis: str) -> dict | None:
|
||
"""Return an already-qualified overall-arrow fact for one axis.
|
||
|
||
Dimension qualification is independent from graph generation. When it has
|
||
accepted an arrow explicitly scoped as ``overall``, a later collision is
|
||
evidence that the graph is wrong, not that the drawing became ambiguous.
|
||
"""
|
||
fact = (dimension_ledger or {}).get(axis) or {}
|
||
if fact.get("ledger_scope") != "occupied_overall":
|
||
return None
|
||
source_ids = sorted(set(fact.get("source_image_ids") or []))
|
||
if not source_ids:
|
||
return None
|
||
return {
|
||
"decision": "occupied_overall_dimension",
|
||
"axis": axis,
|
||
"confidence": round(float(fact.get("confidence") or 1.0), 4),
|
||
"evidence_image_ids": source_ids,
|
||
"evidence": (
|
||
f"尺寸资格门已将{axis.upper()}方向箭头验收为成品整体外形:"
|
||
f"{fact.get('text') or fact.get('value_mm')}"
|
||
)[:1000],
|
||
"requires_user_question": False,
|
||
"question_focus": None,
|
||
"single_user_question": None,
|
||
"question_options": [],
|
||
"source": "qualified_overall_dimension_ledger",
|
||
}
|
||
|
||
|
||
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 find_resume_checkpoint(rows: list[dict]) -> dict | None:
|
||
"""Find an accepted compiled checkpoint, or allow a source/stage-only resume."""
|
||
candidates = [
|
||
row for row in rows
|
||
if (
|
||
isinstance(row.get("graph"), dict)
|
||
and isinstance(row.get("compiled"), dict)
|
||
and not row.get("patch_error")
|
||
and round_partition_contract_is_current(row)
|
||
)
|
||
]
|
||
return (
|
||
min(candidates, key=lambda row: (round_blocker_count(row), -int(row.get("round") or 0)))
|
||
if candidates else None
|
||
)
|
||
|
||
|
||
def select_resume_checkpoint(rows: list[dict]) -> dict:
|
||
"""Select only an accepted, internally consistent audited checkpoint."""
|
||
checkpoint = find_resume_checkpoint(rows)
|
||
if checkpoint is None:
|
||
raise RuntimeError("resume report has no audited compiled graph checkpoint")
|
||
return checkpoint
|
||
|
||
|
||
def partition_response_is_reusable(stage: str, response) -> bool:
|
||
"""Reject stale stage checkpoints that no longer satisfy the active contract."""
|
||
if not isinstance(response, dict):
|
||
return False
|
||
if stage == "core":
|
||
observations = response.get("observations")
|
||
parameters = response.get("parameters")
|
||
envelope = response.get("envelope_mm")
|
||
parts = response.get("parts")
|
||
return (
|
||
isinstance(observations, list)
|
||
and bool(observations)
|
||
and all(isinstance(item, dict) for item in observations)
|
||
and isinstance(parameters, dict)
|
||
and all(isinstance(item, dict) for item in parameters.values())
|
||
and isinstance(envelope, list)
|
||
and len(envelope) == 3
|
||
and isinstance(response.get("assemblies"), list)
|
||
and isinstance(parts, list)
|
||
and all(
|
||
isinstance(item, dict)
|
||
and (
|
||
item.get("functional_role") != "hardware"
|
||
or (
|
||
bool(item.get("hardware_family"))
|
||
and bool(item.get("hardware_template_id"))
|
||
)
|
||
)
|
||
for item in parts
|
||
)
|
||
)
|
||
if stage in {"structural_instances", "hardware_instances"}:
|
||
return isinstance(response.get("instances"), list)
|
||
if stage == "assembly_connections":
|
||
return isinstance(response.get("assembly_connections"), list)
|
||
return False
|
||
|
||
|
||
def round_partition_contract_is_current(row: dict) -> bool:
|
||
"""Do not resume a compiled graph built from a now-invalid stage schema."""
|
||
audit = row.get("initial_graph_partition_audit") if isinstance(row, dict) else None
|
||
if not isinstance(audit, list):
|
||
return True
|
||
for item in audit:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
if item.get("status") not in {"accepted", "reused_audited_stage"}:
|
||
continue
|
||
stage = str(item.get("stage") or "")
|
||
response = item.get("response")
|
||
if stage and response is not None and not partition_response_is_reusable(stage, response):
|
||
return False
|
||
return True
|
||
|
||
|
||
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 generate_partitioned_initial_graph(
|
||
*, engine, model: str, images: list[dict], evidence_context: str,
|
||
hardware_contract: dict, resume_stages: dict[str, dict] | None = None,
|
||
) -> tuple[dict, list[dict]]:
|
||
"""Generate one graph through bounded, independently validated AI passes.
|
||
|
||
The source images are shown to every semantic pass. Deterministic merge
|
||
gates reject cross-assembly placement references, missing physical
|
||
instances and invalid motion endpoints before the compiler sees them.
|
||
Company BOM data is deliberately absent from this interface.
|
||
"""
|
||
audit = []
|
||
resume_stages = resume_stages or {}
|
||
|
||
def request(stage: str, instruction: str, *, max_tokens: int, timeout_seconds: int):
|
||
row = {
|
||
"stage": stage, "status": "running", "max_tokens": max_tokens,
|
||
"timeout_seconds": timeout_seconds, "source_images_included": True,
|
||
"company_bom_used_in_inference": False,
|
||
}
|
||
audit.append(row)
|
||
resumed = resume_stages.get(stage)
|
||
if isinstance(resumed, dict):
|
||
result = copy.deepcopy(resumed)
|
||
row.update({
|
||
"status": "reused_audited_stage", "elapsed_seconds": 0.0,
|
||
"response": copy.deepcopy(result),
|
||
})
|
||
print(json.dumps({
|
||
"phase": "partitioned_graph_stage", "stage": stage,
|
||
"status": "reused_audited_stage",
|
||
}, ensure_ascii=False), flush=True)
|
||
return result, row
|
||
print(json.dumps({
|
||
"phase": "partitioned_graph_stage", "stage": stage, "status": "started",
|
||
}, ensure_ascii=False), flush=True)
|
||
started = time.time()
|
||
try:
|
||
result = engine._request_model_json({
|
||
"model": model,
|
||
"messages": [{"role": "user", "content": images + [{"type": "text", "text": instruction}]}],
|
||
"response_format": {"type": "json_object"},
|
||
"temperature": 0.0,
|
||
"enable_thinking": False,
|
||
"vl_high_resolution_images": True,
|
||
"max_tokens": max_tokens,
|
||
}, f"分阶段结构图谱生成失败({stage})", max_attempts=1,
|
||
timeout_seconds=timeout_seconds, decoder=lambda text: json.loads(text))
|
||
except Exception as exc:
|
||
row.update({
|
||
"status": "failed", "elapsed_seconds": round(time.time() - started, 3),
|
||
"error": f"{type(exc).__name__}: {exc}",
|
||
})
|
||
print(json.dumps({
|
||
"phase": "partitioned_graph_stage", "stage": stage, "status": "failed",
|
||
"elapsed_seconds": row["elapsed_seconds"],
|
||
}, ensure_ascii=False), flush=True)
|
||
raise PartitionedGraphError(f"{stage}: {exc}", audit) from exc
|
||
row.update({"status": "received", "elapsed_seconds": round(time.time() - started, 3)})
|
||
print(json.dumps({
|
||
"phase": "partitioned_graph_stage", "stage": stage, "status": "received",
|
||
"elapsed_seconds": row["elapsed_seconds"],
|
||
}, ensure_ascii=False), flush=True)
|
||
if not isinstance(result, dict):
|
||
row["status"] = "rejected"
|
||
row["error"] = "response_not_object"
|
||
raise PartitionedGraphError(f"{stage}: 模型返回必须是JSON对象", audit)
|
||
row["response"] = copy.deepcopy(result)
|
||
return result, row
|
||
|
||
def fail(stage: str, row: dict, detail: str):
|
||
row.update({"status": "rejected", "error": detail})
|
||
print(json.dumps({
|
||
"phase": "partitioned_graph_stage", "stage": stage,
|
||
"status": "rejected", "detail": detail,
|
||
}, ensure_ascii=False), flush=True)
|
||
raise PartitionedGraphError(f"{stage}: {detail}", audit)
|
||
|
||
core_prompt = (
|
||
"你正在执行家具CAD结构图谱的第1/4段:只建立证据、参数、整体包围、总成和零件定义。"
|
||
"必须重新查看全部原图;不得输出instances、connections、assembly_connections,也不得输出成本。"
|
||
"每个part的observed_quantity必须是正整数,表示原图支持的物理件数量;不确定结构写入uncertainties,"
|
||
"不能用常见家具经验补件。固定schema_version=furniture-assembly-graph/2、"
|
||
"placement_solver=anchored_axes_v1、orientation_solver=role_axis_templates_v1、"
|
||
"contact_derivation=aabb_within_assembly_v1。零件size_mm直接使用产品X/Y/Z坐标;"
|
||
"parameters必须是JSON对象,每个长度参数必须是完整节点:"
|
||
"观测值使用{\"kind\":\"length\",\"origin\":\"observed\",\"value\":数值,"
|
||
"\"unit\":\"mm\",\"evidence_ids\":[\"证据ID\"],\"confidence\":0到1数值};"
|
||
"派生值使用{\"kind\":\"length\",\"origin\":\"derived\",\"expression\":\"已声明参数表达式\"}。"
|
||
"禁止把裸数值直接放入parameters。envelope_mm必须是恰3项JSON数组,"
|
||
"例如[\"overall_width_mm\",\"overall_depth_mm\",\"overall_height_mm\"],禁止输出{x:[],y:[],z:[]}对象。"
|
||
"assemblies每项必须完整包含id、role、bounds_mm={x:[min,max],y:[min,max],z:[min,max]}、"
|
||
"evidence_ids;role只允许carcass/drawer/door/module/frame/hardware_group/other。"
|
||
"parts每项必须完整包含id、name、shape='box'、functional_role、thickness_axis、"
|
||
"material_family、size_mm=[x,y,z]、observed_quantity、evidence_ids;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,非板件可为null;material_family只允许"
|
||
"engineered_panel/solid_wood/metal/plastic/glass/other。"
|
||
"每个产品必须至少建立一个非运动主assembly,role只能是carcass、frame、module或other;"
|
||
"每个独立运动门或抽屉另建独立assembly,assembly边界必须紧贴其结构实体。"
|
||
"所有铰链、拉手、滑轨、旋钮等采购五金的functional_role必须是hardware并绑定给定模板,"
|
||
"绝不能作为other或普通结构件。每个hardware part必须逐字复制"
|
||
"合同中同一条的hardware_family与hardware_template_id,size_mm固定为[1,1,1]占位;"
|
||
"禁止根据外观自造五金尺寸或省略模板ID。"
|
||
"只返回一份图谱核心JSON,字段为schema_version、placement_solver、orientation_solver、"
|
||
"contact_derivation、observations、parameters、envelope_mm、可选reference_bounds_mm、assemblies、"
|
||
"parts、uncertainties。observations必须是JSON数组,禁止输出对象或image_ids/view_evidence容器;"
|
||
"每项必须为{\"id\":\"obs_front\",\"source_type\":\"image\",\"image_id\":\"image1\","
|
||
"\"text\":\"本条观察\",\"confidence\":0到1之间数值};id必须稳定唯一,image_id必须引用本轮原图ID。"
|
||
"不得引用公司BOM。\n可选五金模板合同:"
|
||
+ json.dumps(hardware_contract, ensure_ascii=False, separators=(",", ":"))
|
||
+ evidence_context
|
||
)
|
||
core, core_audit = request("core", core_prompt, max_tokens=6000, timeout_seconds=180)
|
||
required = {
|
||
"schema_version", "placement_solver", "orientation_solver", "contact_derivation",
|
||
"observations", "parameters", "envelope_mm", "assemblies", "parts", "uncertainties",
|
||
}
|
||
missing = sorted(required - set(core))
|
||
if missing:
|
||
fail("core", core_audit, f"缺少核心字段: {','.join(missing)}")
|
||
if core.get("schema_version") != SCHEMA_VERSION:
|
||
fail("core", core_audit, "schema_version不匹配")
|
||
for forbidden in ("instances", "connections", "assembly_connections"):
|
||
if core.get(forbidden) not in (None, []):
|
||
fail("core", core_audit, f"第1段越权输出{forbidden}")
|
||
if not isinstance(core.get("assemblies"), list) or not isinstance(core.get("parts"), list):
|
||
fail("core", core_audit, "assemblies和parts必须是数组")
|
||
parameters = core.get("parameters")
|
||
if (
|
||
not isinstance(parameters, dict)
|
||
or any(not isinstance(item, dict) for item in parameters.values())
|
||
):
|
||
fail("core", core_audit, "parameters每个参数必须是带来源的JSON节点,不能是裸数值")
|
||
if not isinstance(core.get("envelope_mm"), list) or len(core["envelope_mm"]) != 3:
|
||
fail("core", core_audit, "envelope_mm必须是X/Y/Z顺序的三项数组")
|
||
observations = core.get("observations")
|
||
if (
|
||
not isinstance(observations, list) or not observations
|
||
or any(not isinstance(item, dict) for item in observations)
|
||
):
|
||
fail("core", core_audit, "observations必须是非空证据对象数组")
|
||
observation_ids = []
|
||
for observation in observations:
|
||
observation_id = str(observation.get("id") or "")
|
||
confidence = observation.get("confidence")
|
||
if (
|
||
not observation_id
|
||
or observation.get("source_type") != "image"
|
||
or not str(observation.get("image_id") or "")
|
||
or isinstance(confidence, bool)
|
||
or not isinstance(confidence, (int, float))
|
||
or not 0 <= float(confidence) <= 1
|
||
):
|
||
fail("core", core_audit, f"证据{observation_id or '?'}缺少完整图像字段或置信度非法")
|
||
observation_ids.append(observation_id)
|
||
if len(set(observation_ids)) != len(observation_ids):
|
||
fail("core", core_audit, "observation ID重复")
|
||
assembly_ids = [str(row.get("id") or "") for row in core["assemblies"] if isinstance(row, dict)]
|
||
part_ids = [str(row.get("id") or "") for row in core["parts"] if isinstance(row, dict)]
|
||
if not assembly_ids or "" in assembly_ids or len(set(assembly_ids)) != len(assembly_ids):
|
||
fail("core", core_audit, "总成ID缺失或重复")
|
||
if not part_ids or "" in part_ids or len(set(part_ids)) != len(part_ids):
|
||
fail("core", core_audit, "零件ID缺失或重复")
|
||
invalid_assembly_roles = {
|
||
str(row.get("id") or ""): row.get("role")
|
||
for row in core["assemblies"] if isinstance(row, dict)
|
||
and row.get("role") not in ALLOWED_ASSEMBLY_ROLES
|
||
}
|
||
if invalid_assembly_roles:
|
||
fail("core", core_audit, f"总成角色不在固定枚举中: {invalid_assembly_roles!r}")
|
||
stationary_assembly_ids = [
|
||
str(row.get("id") or "") for row in core["assemblies"] if isinstance(row, dict)
|
||
and row.get("role") not in {"door", "drawer", "hardware_group"}
|
||
]
|
||
if not stationary_assembly_ids:
|
||
fail("core", core_audit, "缺少承载非运动结构件的主总成(carcass/frame/module/other)")
|
||
allowed_hardware_pairs = {
|
||
(
|
||
str(item.get("hardware_family") or ""),
|
||
str(item.get("hardware_template_id") or ""),
|
||
)
|
||
for item in hardware_contract if isinstance(item, dict)
|
||
}
|
||
for part in core["parts"]:
|
||
role = part.get("functional_role") if isinstance(part, dict) else None
|
||
if role not in ALLOWED_PART_ROLES:
|
||
fail("core", core_audit, f"零件{part.get('id') if isinstance(part, dict) else '?'}的functional_role不合法: {role!r}")
|
||
if isinstance(part, dict) and role != "hardware" and (
|
||
part.get("hardware_family") or part.get("hardware_template_id")
|
||
):
|
||
fail("core", core_audit, f"零件{part.get('id')}声明五金模板但functional_role不是hardware")
|
||
if isinstance(part, dict) and role == "hardware":
|
||
pair = (
|
||
str(part.get("hardware_family") or ""),
|
||
str(part.get("hardware_template_id") or ""),
|
||
)
|
||
if not all(pair) or (allowed_hardware_pairs and pair not in allowed_hardware_pairs):
|
||
fail("core", core_audit, f"五金零件{part.get('id')}未绑定合法模板: {pair!r}")
|
||
if part.get("size_mm") != [1, 1, 1]:
|
||
fail("core", core_audit, f"五金零件{part.get('id')}的size_mm必须是[1,1,1]不计价占位")
|
||
quantity = part.get("observed_quantity") if isinstance(part, dict) else None
|
||
if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity < 1:
|
||
fail("core", core_audit, f"零件{part.get('id') if isinstance(part, dict) else '?'}缺少正整数observed_quantity")
|
||
core_audit.update({
|
||
"status": "accepted", "assembly_count": len(assembly_ids), "part_count": len(part_ids),
|
||
})
|
||
|
||
part_by_id = {row["id"]: row for row in core["parts"]}
|
||
structural_parts = {
|
||
part_id: row for part_id, row in part_by_id.items()
|
||
if row.get("functional_role") != "hardware"
|
||
}
|
||
hardware_parts = {
|
||
part_id: row for part_id, row in part_by_id.items()
|
||
if row.get("functional_role") == "hardware"
|
||
}
|
||
structural_prompt = (
|
||
"你正在执行家具CAD结构图谱的第2/4段:根据全部原图和已锁定核心,只输出每个非五金物理实例。"
|
||
"只返回JSON对象{\"instances\":[...]},数量必须逐part精确等于observed_quantity。每个实例必须有唯一id、"
|
||
"part_id、assembly_id和x/y/z三轴placement。每轴只能使用两种精确JSON结构:"
|
||
"{\"mode\":\"absolute\",\"value_mm\":数值},或"
|
||
"{\"mode\":\"align\",\"self_anchor\":\"min|center|max\",\"target\":\"envelope或合法assembly_id\","
|
||
"\"target_anchor\":\"min|center|max\",\"offset_mm\":数值}。"
|
||
"三轴必须放在单一placement字段内,例如"
|
||
"{\"id\":\"inst_top\",\"part_id\":\"part_top\",\"assembly_id\":\"carcass\","
|
||
"\"placement\":{\"x\":{...},\"y\":{...},\"z\":{...}}};禁止把x/y/z直接放在实例顶层。"
|
||
"为消除跨总成歧义,本段align的target"
|
||
"只允许envelope、字面量assembly或该实例自己的assembly_id;禁止引用其他总成或其他实例。"
|
||
"门板/抽屉部件必须在自己的紧边界assembly内定位;不得输出五金、mountings、connections或成本。"
|
||
"不得引用公司BOM。part_id与assembly_id必须逐字符复制下列合法ID,不得翻译、加前缀或另造ID。"
|
||
"\n合法非五金part_id:" + json.dumps(sorted(structural_parts), ensure_ascii=False)
|
||
+ "\n合法assembly_id:" + json.dumps(sorted(assembly_ids), ensure_ascii=False)
|
||
+ "\n已锁定核心:" + json.dumps(core, ensure_ascii=False, separators=(",", ":"))
|
||
+ evidence_context
|
||
)
|
||
structural_response, structural_audit = request(
|
||
"structural_instances", structural_prompt, max_tokens=5000, timeout_seconds=180,
|
||
)
|
||
structural_instances = structural_response.get("instances")
|
||
if not isinstance(structural_instances, list):
|
||
fail("structural_instances", structural_audit, "缺少instances数组")
|
||
seen_instance_ids = set()
|
||
structural_counts = {}
|
||
structural_normalizations = []
|
||
for item in structural_instances:
|
||
if not isinstance(item, dict):
|
||
fail("structural_instances", structural_audit, "结构实例必须是对象")
|
||
instance_id = str(item.get("id") or "")
|
||
part_id = str(item.get("part_id") or "")
|
||
assembly_id = str(item.get("assembly_id") or "")
|
||
if not instance_id or instance_id in seen_instance_ids:
|
||
fail("structural_instances", structural_audit, "结构实例ID缺失或重复")
|
||
if part_id not in structural_parts or assembly_id not in assembly_ids:
|
||
fail(
|
||
"structural_instances", structural_audit,
|
||
f"实例{instance_id}引用非法ID:part_id={part_id!r}, assembly_id={assembly_id!r}; "
|
||
f"合法part_id={sorted(structural_parts)!r}, 合法assembly_id={sorted(assembly_ids)!r}",
|
||
)
|
||
placement = item.get("placement")
|
||
if placement is None and all(isinstance(item.get(axis), dict) for axis in "xyz"):
|
||
placement = {axis: item.pop(axis) for axis in "xyz"}
|
||
item["placement"] = placement
|
||
structural_normalizations.append({
|
||
"instance_id": instance_id,
|
||
"from": "top_level_axes",
|
||
"to": "nested_placement",
|
||
})
|
||
if not isinstance(placement, dict) or set(placement) != {"x", "y", "z"}:
|
||
fail("structural_instances", structural_audit, f"实例{instance_id}必须声明三轴placement")
|
||
for axis in "xyz":
|
||
rule = placement[axis]
|
||
if not isinstance(rule, dict) or rule.get("mode") not in {"absolute", "align"}:
|
||
fail(
|
||
"structural_instances", structural_audit,
|
||
f"实例{instance_id}的{axis}轴定位无效: {rule!r}",
|
||
)
|
||
if rule.get("mode") == "align" and rule.get("target") not in {"envelope", "assembly", assembly_id}:
|
||
fail("structural_instances", structural_audit, f"实例{instance_id}的{axis}轴发生跨总成或实例引用")
|
||
seen_instance_ids.add(instance_id)
|
||
structural_counts[part_id] = structural_counts.get(part_id, 0) + 1
|
||
for part_id, part in structural_parts.items():
|
||
if structural_counts.get(part_id, 0) != part["observed_quantity"]:
|
||
fail(
|
||
"structural_instances", structural_audit,
|
||
f"零件{part_id}实例数{structural_counts.get(part_id, 0)}不等于证据数量{part['observed_quantity']}",
|
||
)
|
||
structural_audit.update({
|
||
"status": "accepted", "instance_count": len(structural_instances),
|
||
"deterministic_normalizations": structural_normalizations,
|
||
})
|
||
|
||
hardware_instances = []
|
||
if hardware_parts:
|
||
hardware_prompt = (
|
||
"你正在执行家具CAD结构图谱的第3/4段:只生成五金实例和安装关系。必须回看全部原图。"
|
||
"只返回JSON对象{\"instances\":[...]},每个hardware part的实例数必须等于observed_quantity。"
|
||
"每个instance必须完整包含id、part_id、assembly_id、placement和mountings;"
|
||
"part_id和assembly_id必须逐字符复制下方合法ID,不得省略、翻译或自造。"
|
||
"placement仅为1×1×1占位,必须逐字输出"
|
||
"{\"x\":{\"mode\":\"absolute\",\"value_mm\":0},\"y\":{\"mode\":\"absolute\","
|
||
"\"value_mm\":0},\"z\":{\"mode\":\"absolute\",\"value_mm\":0}};"
|
||
"真实几何完全由模板mountings决定。"
|
||
"每个mounting的host_instance_id只能引用给出的结构实例ID,slot、kind、face、"
|
||
"embed_depth_mm、mounting_center_mm和evidence_ids必须齐全并服从模板合同。"
|
||
"没有图像证据不得增加五金。不得输出结构实例、总成连接或成本,不得引用公司BOM。\n核心:"
|
||
+ json.dumps(core, ensure_ascii=False, separators=(",", ":"))
|
||
+ "\n合法五金part_id:" + json.dumps(sorted(hardware_parts), ensure_ascii=False)
|
||
+ "\n合法assembly_id:" + json.dumps(sorted(assembly_ids), ensure_ascii=False)
|
||
+ "\n合法结构实例ID:" + json.dumps(sorted(seen_instance_ids), ensure_ascii=False)
|
||
+ "\n五金模板合同:" + json.dumps(hardware_contract, ensure_ascii=False, separators=(",", ":"))
|
||
+ evidence_context
|
||
)
|
||
hardware_response, hardware_audit = request(
|
||
"hardware_instances", hardware_prompt, max_tokens=3500, timeout_seconds=150,
|
||
)
|
||
hardware_instances = hardware_response.get("instances")
|
||
if not isinstance(hardware_instances, list):
|
||
fail("hardware_instances", hardware_audit, "缺少instances数组")
|
||
hardware_counts = {}
|
||
hardware_normalizations = []
|
||
for item in hardware_instances:
|
||
if not isinstance(item, dict):
|
||
fail("hardware_instances", hardware_audit, "五金实例必须是对象")
|
||
instance_id = str(item.get("id") or "")
|
||
part_id = str(item.get("part_id") or "")
|
||
assembly_id = str(item.get("assembly_id") or "")
|
||
if not instance_id or instance_id in seen_instance_ids:
|
||
fail("hardware_instances", hardware_audit, "五金实例ID缺失或与结构实例重复")
|
||
if part_id not in hardware_parts or assembly_id not in assembly_ids:
|
||
fail(
|
||
"hardware_instances", hardware_audit,
|
||
f"五金实例{instance_id}引用了非法零件或总成: "
|
||
f"part_id={part_id!r}, assembly_id={assembly_id!r}",
|
||
)
|
||
placement = item.get("placement")
|
||
if isinstance(placement, dict) and set(placement) == {"x", "y", "z"}:
|
||
for axis in "xyz":
|
||
scalar = placement.get(axis)
|
||
if isinstance(scalar, (int, float)) and not isinstance(scalar, bool):
|
||
placement[axis] = {"mode": "absolute", "value_mm": float(scalar)}
|
||
hardware_normalizations.append({
|
||
"instance_id": instance_id,
|
||
"axis": axis,
|
||
"from": "numeric_placeholder",
|
||
"to": "absolute_placeholder",
|
||
})
|
||
if not isinstance(placement, dict) or set(placement) != {"x", "y", "z"} or any(
|
||
not isinstance(placement[axis], dict) or placement[axis].get("mode") != "absolute"
|
||
for axis in "xyz"
|
||
):
|
||
fail("hardware_instances", hardware_audit, f"五金实例{instance_id}占位定位必须为三轴absolute")
|
||
mountings = item.get("mountings")
|
||
if not isinstance(mountings, list) or not mountings:
|
||
fail("hardware_instances", hardware_audit, f"五金实例{instance_id}缺少模板安装位")
|
||
if any(str(mount.get("host_instance_id") or "") not in seen_instance_ids for mount in mountings if isinstance(mount, dict)):
|
||
fail("hardware_instances", hardware_audit, f"五金实例{instance_id}安装宿主不在结构实例中")
|
||
seen_instance_ids.add(instance_id)
|
||
hardware_counts[part_id] = hardware_counts.get(part_id, 0) + 1
|
||
for part_id, part in hardware_parts.items():
|
||
if hardware_counts.get(part_id, 0) != part["observed_quantity"]:
|
||
fail(
|
||
"hardware_instances", hardware_audit,
|
||
f"五金{part_id}实例数{hardware_counts.get(part_id, 0)}不等于证据数量{part['observed_quantity']}",
|
||
)
|
||
hardware_audit.update({
|
||
"status": "accepted", "instance_count": len(hardware_instances),
|
||
"deterministic_normalizations": hardware_normalizations,
|
||
})
|
||
else:
|
||
audit.append({
|
||
"stage": "hardware_instances", "status": "skipped_no_hardware_parts",
|
||
"source_images_included": False, "company_bom_used_in_inference": False,
|
||
})
|
||
|
||
assembly_connections = []
|
||
if len(assembly_ids) > 1:
|
||
relation_context = [{
|
||
"id": row["id"], "role": row.get("role"), "bounds_mm": row.get("bounds_mm"),
|
||
"member_instance_ids": sorted(
|
||
item["id"] for item in structural_instances + hardware_instances
|
||
if item.get("assembly_id") == row["id"]
|
||
),
|
||
} for row in core["assemblies"]]
|
||
relation_prompt = (
|
||
"你正在执行家具CAD结构图谱的第4/4段:只生成总成之间的真实装配/运动关系。"
|
||
"必须回看全部原图,只返回JSON对象{\"assembly_connections\":[...]}。a和b只能引用给出的assembly ID;"
|
||
"每项必须完整包含id、kind、a、b和evidence_ids,禁止用type代替kind。"
|
||
"每个door或drawer运动总成必须且只能出现一次并放在a端,按图像证据选择hinged或sliding;"
|
||
"hinged必须精确使用{\"id\":\"稳定关系ID\",\"kind\":\"hinged\",\"a\":\"运动总成ID\","
|
||
"\"b\":\"承载总成ID\",\"axis\":\"x|y|z\",\"angle_limits_deg\":[最小,最大],"
|
||
"\"evidence_ids\":[\"原图ID\"]};axis只是字符串x/y/z,禁止输出坐标点。"
|
||
"sliding同样必须给字符串axis和travel_limits_mm。固定模块仅在真实接触时用fixed。"
|
||
"不能引用实例ID,不能输出零件、定位或成本,不得引用公司BOM。\n总成摘要:"
|
||
+ json.dumps(relation_context, ensure_ascii=False, separators=(",", ":")) + evidence_context
|
||
)
|
||
relation_response, relation_audit = request(
|
||
"assembly_connections", relation_prompt, max_tokens=2500, timeout_seconds=120,
|
||
)
|
||
assembly_connections = relation_response.get("assembly_connections")
|
||
if not isinstance(assembly_connections, list):
|
||
fail("assembly_connections", relation_audit, "缺少assembly_connections数组")
|
||
connection_ids = set()
|
||
moving_counts = {row["id"]: 0 for row in core["assemblies"] if row.get("role") in {"door", "drawer"}}
|
||
for item in assembly_connections:
|
||
if not isinstance(item, dict):
|
||
fail("assembly_connections", relation_audit, "总成连接必须是对象")
|
||
connection_id = str(item.get("id") or "")
|
||
if not connection_id or connection_id in connection_ids:
|
||
fail(
|
||
"assembly_connections", relation_audit,
|
||
f"总成连接ID缺失或重复: id={connection_id!r}",
|
||
)
|
||
a_id, b_id = str(item.get("a") or ""), str(item.get("b") or "")
|
||
if a_id not in assembly_ids or b_id not in assembly_ids or a_id == b_id:
|
||
fail("assembly_connections", relation_audit, f"总成连接{connection_id}端点非法")
|
||
if a_id in moving_counts:
|
||
moving_counts[a_id] += 1
|
||
if item.get("kind") not in {"hinged", "sliding"}:
|
||
fail("assembly_connections", relation_audit, f"运动总成{a_id}缺少hinged/sliding关系")
|
||
if item.get("axis") not in {"x", "y", "z"}:
|
||
fail(
|
||
"assembly_connections", relation_audit,
|
||
f"运动总成{a_id}的axis必须是x/y/z字符串: {item.get('axis')!r}",
|
||
)
|
||
limit_field = (
|
||
"angle_limits_deg" if item.get("kind") == "hinged"
|
||
else "travel_limits_mm"
|
||
)
|
||
limits = item.get(limit_field)
|
||
if not isinstance(limits, list) or len(limits) != 2:
|
||
fail(
|
||
"assembly_connections", relation_audit,
|
||
f"运动总成{a_id}缺少两项{limit_field}",
|
||
)
|
||
elif b_id in moving_counts:
|
||
fail("assembly_connections", relation_audit, f"运动总成{b_id}必须位于关系a端")
|
||
connection_ids.add(connection_id)
|
||
invalid_moving = {key: count for key, count in moving_counts.items() if count != 1}
|
||
if invalid_moving:
|
||
fail("assembly_connections", relation_audit, f"运动总成关系数量不等于1: {invalid_moving}")
|
||
relation_audit.update({"status": "accepted", "connection_count": len(assembly_connections)})
|
||
else:
|
||
audit.append({
|
||
"stage": "assembly_connections", "status": "skipped_single_assembly",
|
||
"source_images_included": False, "company_bom_used_in_inference": False,
|
||
})
|
||
|
||
graph = copy.deepcopy(core)
|
||
graph["instances"] = copy.deepcopy(structural_instances + hardware_instances)
|
||
graph["connections"] = []
|
||
graph["assembly_connections"] = copy.deepcopy(assembly_connections)
|
||
return graph, audit
|
||
|
||
|
||
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
|
||
resume_partition_stages = {}
|
||
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 {})
|
||
for prior_round in reversed(resume_payload.get("rounds") or []):
|
||
prior_audit = (
|
||
prior_round.get("initial_graph_partition_audit")
|
||
if isinstance(prior_round, dict) else None
|
||
)
|
||
if not isinstance(prior_audit, list):
|
||
continue
|
||
indexed = {
|
||
str(item.get("stage") or ""): item
|
||
for item in prior_audit if isinstance(item, dict)
|
||
}
|
||
for expected_stage in (
|
||
"core", "structural_instances", "hardware_instances",
|
||
"assembly_connections",
|
||
):
|
||
item = indexed.get(expected_stage) or {}
|
||
response = item.get("response")
|
||
if (
|
||
item.get("status") not in {"accepted", "reused_audited_stage"}
|
||
or not partition_response_is_reusable(expected_stage, response)
|
||
):
|
||
break
|
||
resume_partition_stages[expected_stage] = copy.deepcopy(response)
|
||
if resume_partition_stages:
|
||
break
|
||
if resume_partition_stages:
|
||
report["resumed_partition_stage_names"] = list(resume_partition_stages)
|
||
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 = {}, {}
|
||
source_dependency = None
|
||
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}"}
|
||
source_dependency = terminal_dependency_feedback(exc)
|
||
if source_dependency is None:
|
||
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}"}
|
||
source_dependency = terminal_dependency_feedback(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复核;只有不可见且成本敏感的残余冲突才询问用户",
|
||
}
|
||
if source_dependency:
|
||
report["status"] = "external_dependency_failed"
|
||
report["source_qualification"]["blocking"] = source_dependency
|
||
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": source_dependency,
|
||
}, ensure_ascii=False), flush=True)
|
||
continue
|
||
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
|
||
compact_source_structure = {
|
||
key: source_structure.get(key)
|
||
for key in (
|
||
"drawers", "doors", "back_panel", "parts", "genuinely_invisible",
|
||
"source_completeness_issues",
|
||
)
|
||
if source_structure.get(key) not in (None, [], {})
|
||
}
|
||
compact_source_structure["accepted_cross_view_facts"] = (
|
||
(source_structure.get("view_adjudication") or {}).get("accepted") or {}
|
||
)
|
||
evidence_context = (
|
||
"\n以下是同一批原图经过两个独立AI专项读取后的候选证据。它们不是公司BOM,也不是最终答案;"
|
||
"你必须回看原图。dimension_ledger中存在的X/Y/Z是已通过范围、方向和置信度门的整体尺寸,必须精确采用;"
|
||
"structure.view_adjudication.accepted是跨视图成立的结构约束;conflicts和machine_followups应由本轮图像复核解决,"
|
||
"不能直接转嫁给用户。\n" + json.dumps({
|
||
"structure": compact_source_structure,
|
||
"dimension_ledger": dimension_ledger,
|
||
"topology_contract": topology_contract,
|
||
"verified_scope_facts": scope_facts,
|
||
}, ensure_ascii=False, separators=(",", ":"))
|
||
)
|
||
graph, feedback = {}, []
|
||
accepted_compile_issue_count = None
|
||
accepted_total_blocker_count = None
|
||
round_number_base = 0
|
||
resume_graph_payload = None
|
||
if resume_payload:
|
||
if find_resume_checkpoint(resume_payload.get("rounds") or []) is None:
|
||
report["resumed_from"] = {
|
||
"selection": "source_and_partition_stage_checkpoint_only",
|
||
"compiler_feedback_refreshed": False,
|
||
"kernel_feedback_refreshed": False,
|
||
}
|
||
else:
|
||
resume_graph_payload = resume_payload
|
||
if resume_graph_payload:
|
||
best = select_resume_checkpoint(resume_graph_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:
|
||
compile_gate_instruction = ""
|
||
if accepted_compile_issue_count not in (None, 0):
|
||
compile_gate_instruction = (
|
||
f"当前结构编译器仍有{accepted_compile_issue_count}项阻塞;本轮只能修改"
|
||
"编译器反馈中明确列出的稳定ID和路径,不得为了诊断草图的"
|
||
"B-rep碰撞或外观问题改动其他零件、总成、尺寸或定位。"
|
||
"一次只修一个反馈优先级的问题族,修复后必须保留其他已通过约束。"
|
||
)
|
||
prompt = (
|
||
"你在修正一份已存在的参数化家具结构图谱。必须回看本轮所有原图,并只返回局部JSON修改,不能返回整份图谱。"
|
||
+ compile_gate_instruction
|
||
+ "每条操作为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 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_partitioned_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:
|
||
if graph:
|
||
request_body = {
|
||
"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": 6000,
|
||
}
|
||
response = engine._request_model_json(
|
||
request_body, "原图到参数化结构图谱失败", max_attempts=1,
|
||
timeout_seconds=180, decoder=lambda text: json.loads(text),
|
||
)
|
||
else:
|
||
response, partition_audit = generate_partitioned_initial_graph(
|
||
engine=engine, model=args.model, images=images,
|
||
evidence_context=evidence_context,
|
||
hardware_contract=supported_hardware_contract(),
|
||
resume_stages=resume_partition_stages,
|
||
)
|
||
row["initial_graph_partition_audit"] = partition_audit
|
||
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}"
|
||
if isinstance(exc, PartitionedGraphError):
|
||
row["initial_graph_partition_audit"] = exc.audit
|
||
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 row["compiled"].get("ok") is not True:
|
||
# Keep diagnostic kernel/render artifacts in the report,
|
||
# but do not feed their secondary failures into the next
|
||
# graph repair while the structural contract is invalid.
|
||
feedback = enrich_hardware_feedback_with_verified_placements(
|
||
graph, row["compiled"], list(row["compiled"].get("issues") or []),
|
||
compile_candidate=compile_candidate_graph,
|
||
)
|
||
row["repair_feedback_stage"] = "compiler_only"
|
||
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 round_base_compile_issue_count == 0
|
||
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)
|
||
qualified_overall = (
|
||
qualified_occupied_scope_adjudication(dimension_ledger, dead_end_axis)
|
||
if required_question_focus == "overall_dimension_scope" else None
|
||
)
|
||
try:
|
||
adjudication_response = qualified_overall or 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"
|
||
scope_fact = normalize_scope_facts({
|
||
"id": f"qualified-scope-{focused['axis']}",
|
||
"source_type": "independent_source_adjudication",
|
||
"axis": focused["axis"],
|
||
"ledger_scope": (
|
||
"occupied_overall"
|
||
if focused["decision"] == "occupied_overall_dimension" else "reference"
|
||
),
|
||
"confidence": focused["confidence"],
|
||
"source_image_ids": focused["evidence_image_ids"],
|
||
"text": focused["evidence"],
|
||
}, image_ids=image_ids, dimension_ledger=dimension_ledger)[0]
|
||
scope_facts = [
|
||
item for item in scope_facts if item["axis"] != scope_fact["axis"]
|
||
] + [scope_fact]
|
||
graph, bound_user_evidence, bound_scope_adjudications, binding_audit = bind_scope_facts(
|
||
graph, scope_facts,
|
||
)
|
||
report["focused_scope_binding_audit"] = binding_audit
|
||
feedback = [{
|
||
"code": "qualified_overall_scope_preserved",
|
||
"axis": focused["axis"],
|
||
"detail": "整体尺寸已由原图资格门确认为成品外包围,禁止再询问或改为柜体参考尺寸。",
|
||
"correction": "保持该整体尺寸,仅修正门板/部件厚度、内嵌关系、局部位置或有原图依据的避让加工。",
|
||
}] + dead_end_feedback
|
||
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"
|
||
)
|
||
if report["status"] == "focused_source_adjudication_ready_for_graph_repair":
|
||
continue
|
||
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()
|