1190 lines
47 KiB
Python
Executable File
1190 lines
47 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from importlib.metadata import PackageNotFoundError, version
|
|
from pathlib import Path
|
|
|
|
|
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
REGISTRY_PATH = Path(__file__).with_name("capabilities.json")
|
|
RECONSTRUCTION_ADAPTER = Path(__file__).with_name("reconstruct_step.py")
|
|
|
|
|
|
@dataclass
|
|
class BackendScore:
|
|
name: str
|
|
score: int
|
|
reasons: list[str] = field(default_factory=list)
|
|
|
|
def add(self, points: int, reason: str) -> None:
|
|
self.score += points
|
|
self.reasons.append(f"{points:+d} {reason}")
|
|
|
|
|
|
KEYWORD_GROUPS: dict[str, tuple[str, ...]] = {
|
|
"standard_factory": (
|
|
"gear", "gears", "齿轮", "齿条", "rack", "ring gear", "内齿圈",
|
|
"cycloidal", "摆线", "bearing", "轴承", "planetary", "行星",
|
|
"reducer", "gearbox", "减速器", "减速箱", "joint actuator", "关节执行器",
|
|
"bevel gear", "锥齿轮", "sprocket", "链轮", "roller chain", "滚子链",
|
|
"bolt", "螺栓", "nut", "螺母", "fastener", "紧固件",
|
|
),
|
|
"general_mechanical": (
|
|
"bracket", "支架", "connecting rod", "连杆", "crank", "曲柄",
|
|
"lever", "摇臂", "shaft", "轴", "bushing", "轴套", "flange", "法兰",
|
|
"housing", "壳体", "enclosure", "外壳", "fixture", "夹具", "plate", "安装板",
|
|
),
|
|
"semantic_modeling": (
|
|
"replay", "重放", "operation graph", "model json", "模型图", "semantic tag",
|
|
"语义标签", "topology", "拓扑追踪", "source mapping", "来源映射",
|
|
"unit", "单位", "tolerance", "公差", "公差链", "scene.zip",
|
|
"freecad", "fusion 360", "solidworks",
|
|
),
|
|
"custom_surface": (
|
|
"freeform", "自由曲面", "organic", "有机曲面", "nurbs", "复杂曲面",
|
|
"surface patch", "曲面补片", "multi rail", "多轨扫掠",
|
|
),
|
|
"mechanism": (
|
|
"mechanism", "机构", "motion", "运动关系", "gear constraint", "齿轮约束",
|
|
"belt constraint", "皮带约束", "revolute", "转动副", "prismatic", "移动副",
|
|
),
|
|
"visual_complexity": (
|
|
"image", "photo", "sketch", "图片", "照片", "草图", "复杂", "complex",
|
|
"像", "参考图", "reference",
|
|
),
|
|
}
|
|
|
|
|
|
EXPERIENCE_FAMILY_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
(
|
|
"flanged_hub_adapter",
|
|
("flanged hub", "法兰轮毂", "法兰轴套", "法兰适配器", "带法兰轴套"),
|
|
),
|
|
("connecting_rod", ("connecting rod", "连杆")),
|
|
("gear", ("gear", "齿轮")),
|
|
("shaft", ("shaft", "轴类", "传动轴")),
|
|
("flange", ("flange", "法兰")),
|
|
("bushing", ("bushing", "sleeve", "轴套", "衬套")),
|
|
("plate", ("plate", "mounting plate", "安装板", "平板")),
|
|
("bracket", ("bracket", "mounting bracket", "支架", "托架")),
|
|
("block", ("block", "rectangular body", "方块", "块体")),
|
|
("manifold", ("manifold", "valve body", "housing", "歧管", "阀体", "壳体")),
|
|
("rotational_part", ("roller", "pulley", "cylindrical body", "滚轮", "带轮")),
|
|
)
|
|
|
|
EXPERIENCE_FEATURE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
("base_flange", ("flange", "法兰", "底盘")),
|
|
("hollow_sleeve", ("hub", "sleeve", "bushing", "轴套", "衬套", "轮毂")),
|
|
("central_passage", ("through bore", "center bore", "通孔", "中心孔", "内孔")),
|
|
("counterbore", ("counterbore", "沉孔", "台阶孔")),
|
|
(
|
|
"repeated_axial_hole_pattern",
|
|
("bolt circle", "hole pattern", "螺丝孔", "螺栓孔", "孔阵列"),
|
|
),
|
|
("conical_transition", ("cone", "taper", "锥", "斜面过渡")),
|
|
("gear_teeth", ("gear", "teeth", "齿轮", "齿")),
|
|
("coaxial_cylindrical_stack", ("stepped shaft", "阶梯轴", "同轴", "轴肩")),
|
|
(
|
|
"repeated_axial_hole_pattern",
|
|
(
|
|
"mounting holes",
|
|
"corner holes",
|
|
"four holes",
|
|
"multiple holes",
|
|
"bolt holes",
|
|
"安装孔",
|
|
"四个孔",
|
|
"多个孔",
|
|
),
|
|
),
|
|
("planar_dominant_body", ("plate", "bracket", "flat body", "板", "支架")),
|
|
(
|
|
"multi_level_planar_profile",
|
|
("pocket", "recess", "stepped plate", "台阶面", "凹槽", "沉台"),
|
|
),
|
|
(
|
|
"rotational_body",
|
|
("cylindrical body", "roller", "pulley", "revolved", "圆柱体", "滚轮", "回转体"),
|
|
),
|
|
(
|
|
"stepped_rotational_profile",
|
|
("stepped shaft", "multiple diameters", "shoulder", "阶梯轴", "多段直径", "轴肩"),
|
|
),
|
|
("shaft_like_body", ("shaft", "axle", "rod", "轴", "长杆")),
|
|
(
|
|
"flange_like_rotational_body",
|
|
("flange", "flanged", "法兰", "带法兰"),
|
|
),
|
|
(
|
|
"cylindrical_feature_network",
|
|
("bore", "passage", "drilled holes", "孔系", "流道", "钻孔"),
|
|
),
|
|
(
|
|
"multi_axis_passage",
|
|
("cross hole", "intersecting passage", "multi-axis", "交叉孔", "多轴孔"),
|
|
),
|
|
)
|
|
|
|
# The router vocabulary is request-facing (plate, bracket, flange), while the
|
|
# distilled library vocabulary is geometry-facing (patterned_plate,
|
|
# plate_or_bracket, flanged_rotational_part). Keep the query semantic by
|
|
# expanding a request family into compatible generalized scopes; this never
|
|
# uses held-out case geometry or instance parameters.
|
|
EXPERIENCE_FAMILY_SCOPE_ALIASES: dict[str, set[str]] = {
|
|
"flanged_hub_adapter": {
|
|
"flanged_hub_adapter",
|
|
"flanged_rotational_part",
|
|
"hollow_or_stepped_shaft",
|
|
"rotational_part",
|
|
},
|
|
"connecting_rod": {
|
|
"connecting_rod",
|
|
"patterned_prismatic_part",
|
|
"plate_or_bracket",
|
|
},
|
|
"gear": {"gear", "rotational_part"},
|
|
"shaft": {"shaft", "hollow_or_stepped_shaft", "rotational_part"},
|
|
"flange": {"flange", "flanged_rotational_part", "rotational_part"},
|
|
"bushing": {"bushing", "hollow_or_stepped_shaft", "rotational_part"},
|
|
"plate": {"plate", "patterned_plate", "plate_or_bracket"},
|
|
"bracket": {
|
|
"bracket",
|
|
"plate_or_bracket",
|
|
"patterned_plate",
|
|
"patterned_prismatic_part",
|
|
},
|
|
"block": {"block", "prismatic_block", "patterned_prismatic_part"},
|
|
"manifold": {
|
|
"manifold",
|
|
"multi_axis_manifold",
|
|
"prismatic_block",
|
|
"patterned_prismatic_part",
|
|
},
|
|
"rotational_part": {
|
|
"rotational_part",
|
|
"shaft",
|
|
"hollow_or_stepped_shaft",
|
|
"flanged_rotational_part",
|
|
},
|
|
}
|
|
|
|
|
|
def contains_any(text: str, terms: tuple[str, ...]) -> bool:
|
|
return any(term in text for term in terms)
|
|
|
|
|
|
def simplecadapi_standard_part_match(
|
|
request: str,
|
|
registry: dict[str, object],
|
|
) -> dict[str, object] | None:
|
|
"""Match only a requested *primary* standard part, never a feature word.
|
|
|
|
The catalog is maintained beside the router from the actual SimpleCADAPI
|
|
``simplecadapi.std`` factories. This is intentionally deterministic: an
|
|
unmatched or ambiguous request is a build123d request.
|
|
"""
|
|
catalog = registry.get("routing_catalog", {})
|
|
rules = (
|
|
catalog.get("simplecadapi_standard_part_types", [])
|
|
if isinstance(catalog, dict)
|
|
else []
|
|
)
|
|
text = request.casefold()
|
|
best_match: tuple[int, dict[str, object], list[str]] | None = None
|
|
for raw_rule in rules:
|
|
if not isinstance(raw_rule, dict):
|
|
continue
|
|
aliases = [str(value).casefold() for value in raw_rule.get("aliases", [])]
|
|
exclusions = [str(value).casefold() for value in raw_rule.get("feature_context_exclusions", [])]
|
|
matched_aliases = [alias for alias in aliases if alias and alias in text]
|
|
if not matched_aliases:
|
|
continue
|
|
if any(exclusion and exclusion in text for exclusion in exclusions):
|
|
continue
|
|
# Generic terms such as "gear" must never hide a more specific
|
|
# standard-part type such as "herringbone gear".
|
|
specificity = max(len(alias) for alias in matched_aliases)
|
|
if best_match is None or specificity > best_match[0]:
|
|
best_match = (specificity, raw_rule, matched_aliases)
|
|
if best_match is None:
|
|
return None
|
|
_, rule, matched_aliases = best_match
|
|
return {
|
|
"type": rule.get("type"),
|
|
"factory": rule.get("factory"),
|
|
"matched_aliases": matched_aliases,
|
|
}
|
|
|
|
|
|
def infer_experience_query(request: str) -> tuple[str | None, list[str]]:
|
|
text = request.lower()
|
|
family = next(
|
|
(
|
|
name
|
|
for name, terms in EXPERIENCE_FAMILY_RULES
|
|
if contains_any(text, terms)
|
|
),
|
|
None,
|
|
)
|
|
features = [
|
|
name
|
|
for name, terms in EXPERIENCE_FEATURE_RULES
|
|
if contains_any(text, terms)
|
|
]
|
|
return family, features
|
|
|
|
|
|
def probe_backends() -> dict[str, dict[str, object]]:
|
|
def distribution_version(name: str) -> str | None:
|
|
try:
|
|
return version(name)
|
|
except PackageNotFoundError:
|
|
return None
|
|
|
|
simplecad_version = distribution_version("simplecadapi")
|
|
return {
|
|
"build123d": {
|
|
"available": importlib.util.find_spec("build123d") is not None,
|
|
"version": distribution_version("build123d"),
|
|
"probe": f"python={sys.executable}",
|
|
},
|
|
"simplecadapi": {
|
|
"available": importlib.util.find_spec("simplecadapi") is not None,
|
|
"version": simplecad_version,
|
|
"minimum_version": "2.0.2",
|
|
"compatible": bool(
|
|
simplecad_version
|
|
and tuple(
|
|
int(part)
|
|
for part in re.findall(r"\d+", simplecad_version)[:3]
|
|
)
|
|
>= (2, 0, 2)
|
|
),
|
|
"probe": f"python={sys.executable}",
|
|
},
|
|
}
|
|
|
|
|
|
def _source_backend_hint(edit_source: Path | None) -> str | None:
|
|
"""Read only an explicitly supplied editable source to preserve its backend."""
|
|
if edit_source is None:
|
|
return None
|
|
source = edit_source.expanduser()
|
|
if not source.is_file():
|
|
return None
|
|
if source.name.endswith(".designir.json"):
|
|
try:
|
|
payload = json.loads(source.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
hint = payload.get("backend_hint")
|
|
if hint in {"build123d", "simplecadapi"}:
|
|
return hint
|
|
compiler = payload.get("compiler")
|
|
if isinstance(compiler, dict) and compiler.get("backend") in {
|
|
"build123d",
|
|
"simplecadapi",
|
|
}:
|
|
return str(compiler["backend"])
|
|
if source.suffix.lower() == ".py":
|
|
try:
|
|
prefix = source.read_text(encoding="utf-8")[:100_000].lower()
|
|
except OSError:
|
|
return None
|
|
if "import simplecadapi" in prefix or "from simplecadapi" in prefix:
|
|
return "simplecadapi"
|
|
if "import build123d" in prefix or "from build123d" in prefix:
|
|
return "build123d"
|
|
return None
|
|
|
|
|
|
def _routing_requirements(
|
|
text: str,
|
|
*,
|
|
edit_context: dict[str, object] | None,
|
|
assembly: bool,
|
|
source_backend: str | None,
|
|
) -> list[str]:
|
|
requirements = ["step_export", "editable_designir"]
|
|
if contains_any(text, KEYWORD_GROUPS["standard_factory"]):
|
|
requirements.append("standard_mechanical_factory")
|
|
if contains_any(text, KEYWORD_GROUPS["semantic_modeling"]):
|
|
requirements.append("semantic_graph_and_replay")
|
|
if contains_any(text, KEYWORD_GROUPS["custom_surface"]):
|
|
requirements.append("custom_surface_vocabulary")
|
|
if contains_any(text, KEYWORD_GROUPS["mechanism"]):
|
|
requirements.append("mechanism_constraints")
|
|
if assembly:
|
|
requirements.append("assembly")
|
|
if source_backend:
|
|
requirements.append(f"preserve_{source_backend}_source")
|
|
if edit_context and edit_context.get("modification_mode") == (
|
|
"private_evidence_to_isolated_designir_reconstruction"
|
|
):
|
|
requirements.extend(
|
|
["source_independent_surfaceir_rebuild", "strict_brep_validation"]
|
|
)
|
|
return list(dict.fromkeys(requirements))
|
|
|
|
|
|
EXPERIENCE_FORBIDDEN_KEYS = {
|
|
"parameters",
|
|
"parameter_examples",
|
|
"coordinates",
|
|
"coordinate",
|
|
"center",
|
|
"location",
|
|
"axis_origin",
|
|
"source_sha256",
|
|
"source_path",
|
|
"face_refs",
|
|
"surface_ids",
|
|
"evidence",
|
|
}
|
|
|
|
MAJOR_EDIT_TERMS = (
|
|
"整体重做",
|
|
"完全重做",
|
|
"重新设计",
|
|
"改成另一种",
|
|
"大改",
|
|
"拓扑重建",
|
|
"major redesign",
|
|
"rebuild the whole",
|
|
)
|
|
def _find_forbidden_experience_key(value: object) -> str | None:
|
|
if isinstance(value, dict):
|
|
for key, child in value.items():
|
|
if key in EXPERIENCE_FORBIDDEN_KEYS:
|
|
return key
|
|
found = _find_forbidden_experience_key(child)
|
|
if found:
|
|
return found
|
|
elif isinstance(value, list):
|
|
for child in value:
|
|
found = _find_forbidden_experience_key(child)
|
|
if found:
|
|
return found
|
|
return None
|
|
|
|
|
|
def default_experience_library() -> Path | None:
|
|
configured = os.environ.get("CAD_EXPERIENCE_LIBRARY", "").strip()
|
|
if configured:
|
|
return Path(configured).expanduser()
|
|
published_candidate = (
|
|
SKILL_ROOT.parents[2]
|
|
/ "designir-pipeline"
|
|
/ "output"
|
|
/ "library.json"
|
|
)
|
|
return published_candidate if published_candidate.is_file() else None
|
|
|
|
|
|
def classify_edit_context(
|
|
request: str,
|
|
edit_source: Path | None,
|
|
existing_model: bool,
|
|
) -> dict[str, object] | None:
|
|
if not (edit_source or existing_model):
|
|
return None
|
|
source = edit_source.expanduser().resolve() if edit_source else None
|
|
if source is not None:
|
|
protected_roots = [
|
|
(
|
|
SKILL_ROOT.parents[2]
|
|
/ "designir-pipeline"
|
|
/ "input"
|
|
).resolve(),
|
|
(
|
|
SKILL_ROOT.parents[2]
|
|
/ "designir-pipeline"
|
|
/ "runs"
|
|
).resolve(),
|
|
]
|
|
if any(source == root or root in source.parents for root in protected_roots):
|
|
raise ValueError(
|
|
"cad-router reconstruction cannot read distillation input or run "
|
|
"artifacts; pass an editable DesignIR outside the private pipeline"
|
|
)
|
|
suffix = source.suffix.lower() if source else ""
|
|
text = request.lower()
|
|
|
|
if suffix == ".json" and source and source.name.endswith(".designir.json"):
|
|
mode = "designir_parameter_edit"
|
|
reason = "Executable DesignIR exists; update its named parameters, constraints, or features."
|
|
elif suffix == ".py":
|
|
mode = "native_parameter_edit"
|
|
reason = "An editable generator exists; update its named parameters or feature source."
|
|
elif source and suffix in {".step", ".stp"}:
|
|
mode = "private_evidence_to_isolated_designir_reconstruction"
|
|
reason = (
|
|
"The uploaded STEP is teacher truth: ingestion extracts private "
|
|
"evidence, then reconstruction authors DesignIR without STEP access."
|
|
)
|
|
elif contains_any(text, MAJOR_EDIT_TERMS):
|
|
mode = "experience_guided_semantic_reconstruction"
|
|
reason = "The requested topology change is large; rebuild from the specified source model using only promoted experience."
|
|
else:
|
|
mode = "existing_source_required"
|
|
reason = "An edit was requested without a resolvable editable source or private case."
|
|
|
|
return {
|
|
"operation": "modify",
|
|
"edit_source": str(source) if source else None,
|
|
"modification_mode": mode,
|
|
"reason": reason,
|
|
"allowed_knowledge_sources": (
|
|
[
|
|
"deterministically extracted private teacher evidence",
|
|
"the promoted generalized CAD experience library",
|
|
"the current user request",
|
|
]
|
|
if source and suffix in {".step", ".stp"}
|
|
else [
|
|
"the explicitly supplied DesignIR or editable generator",
|
|
"the promoted generalized CAD experience library",
|
|
"the current user request",
|
|
]
|
|
),
|
|
"forbidden_knowledge_sources": [
|
|
"teacher STEP during reconstruction",
|
|
"acceptance vault",
|
|
"embedded B-Rep or mesh geometry",
|
|
"private cases unrelated to the active teacher evidence",
|
|
],
|
|
"preservation_contract": [
|
|
"Preserve the source coordinate frame unless the request explicitly changes it.",
|
|
"Preserve unmodified interfaces, datums, and feature relationships.",
|
|
"Prefer the per-part DesignIR and native generator when they exist.",
|
|
"Use generalized experience only as method guidance, never as teacher geometry.",
|
|
],
|
|
}
|
|
|
|
|
|
def load_experience_library(
|
|
path: Path | None,
|
|
family: str | None = None,
|
|
features: list[str] | None = None,
|
|
consumer: str = "router",
|
|
) -> dict[str, object] | None:
|
|
"""Load promoted generalized methods; reject case parameters and coordinates."""
|
|
explicit_library = path is not None
|
|
candidate = path or default_experience_library()
|
|
if candidate is None:
|
|
return None
|
|
resolved = candidate.expanduser().resolve()
|
|
try:
|
|
payload = json.loads(resolved.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise ValueError(f"Cannot read generalized CAD experience library: {resolved}: {exc}") from exc
|
|
if payload.get("schema_version") != "2.0":
|
|
raise ValueError(f"Unsupported generalized CAD experience schema in {resolved}")
|
|
if payload.get("library_kind") != "generalized_cad_experience":
|
|
raise ValueError(f"Not a generalized CAD experience library: {resolved}")
|
|
policy = payload.get("policy")
|
|
if not isinstance(policy, dict):
|
|
raise ValueError(f"CAD experience library has no policy: {resolved}")
|
|
if (
|
|
policy.get("instance_parameters_allowed") is not False
|
|
or policy.get("absolute_coordinates_allowed") is not False
|
|
or policy.get("single_case_promotion_allowed") is not False
|
|
):
|
|
raise ValueError("CAD experience library policy permits instance-answer leakage")
|
|
reviewed = (
|
|
policy.get("llm_semantic_review_required") is True
|
|
and policy.get("draft_evidence_verified") is True
|
|
)
|
|
consumer_flag = (
|
|
"surfaceir_consumable"
|
|
if consumer == "surfaceir"
|
|
else "router_consumable"
|
|
)
|
|
if not reviewed:
|
|
raise ValueError(
|
|
"CAD experience library was not produced by LLM semantic review "
|
|
"plus deterministic evidence verification"
|
|
)
|
|
if policy.get(consumer_flag) is not True:
|
|
if not explicit_library:
|
|
return None
|
|
raise ValueError(
|
|
f"CAD experience library is not approved for {consumer}"
|
|
)
|
|
forbidden = _find_forbidden_experience_key(payload)
|
|
if forbidden:
|
|
raise ValueError(f"CAD experience library contains forbidden instance key: {forbidden}")
|
|
requested_features = {
|
|
item.strip().lower().replace(" ", "_")
|
|
for item in (features or [])
|
|
if item.strip()
|
|
}
|
|
normalized_family = family.strip().lower().replace(" ", "_") if family else None
|
|
compatible_scopes = (
|
|
EXPERIENCE_FAMILY_SCOPE_ALIASES.get(
|
|
normalized_family, {normalized_family}
|
|
)
|
|
if normalized_family
|
|
else set()
|
|
)
|
|
selected: list[object] = []
|
|
for item in payload.get("experiences", []):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
scope = set(item.get("scope", []))
|
|
if consumer == "surfaceir":
|
|
if scope.intersection(
|
|
{"surfaceir_reconstruction", "surfaceir_semantic_editing"}
|
|
):
|
|
selected.append(item)
|
|
continue
|
|
if "global" not in scope:
|
|
if not compatible_scopes or scope.isdisjoint(compatible_scopes):
|
|
continue
|
|
required = set(item.get("when", {}).get("features", []))
|
|
if required and not required.issubset(requested_features):
|
|
continue
|
|
selected.append(item)
|
|
return {
|
|
"schema_version": "2.0",
|
|
"context_kind": "generalized_cad_experience_query",
|
|
"family": normalized_family,
|
|
"compatible_scopes": sorted(compatible_scopes),
|
|
"requested_features": sorted(requested_features),
|
|
"experiences": selected,
|
|
"selection_policy": (
|
|
"surfaceir_runtime_conditioned"
|
|
if consumer == "surfaceir"
|
|
else "request_family_and_feature_match"
|
|
),
|
|
"policy": {
|
|
"contains_instance_parameters": False,
|
|
"contains_absolute_coordinates": False,
|
|
},
|
|
}
|
|
|
|
|
|
def route(args: argparse.Namespace) -> dict[str, object]:
|
|
request = args.request.strip()
|
|
text = request.lower()
|
|
inferred_family, inferred_features = infer_experience_query(request)
|
|
experience_family = args.experience_family or inferred_family
|
|
experience_features = args.experience_feature or inferred_features
|
|
edit_context = classify_edit_context(
|
|
request,
|
|
args.edit_source,
|
|
args.existing_model,
|
|
)
|
|
teacher_step_reconstruction = bool(
|
|
edit_context
|
|
and edit_context.get("modification_mode")
|
|
== "private_evidence_to_isolated_designir_reconstruction"
|
|
)
|
|
experience_context = load_experience_library(
|
|
args.experience_library,
|
|
experience_family,
|
|
experience_features,
|
|
"surfaceir" if teacher_step_reconstruction else "router",
|
|
)
|
|
needs_source_classification = bool(edit_context and not experience_family)
|
|
source_backend = _source_backend_hint(args.edit_source)
|
|
requirements = _routing_requirements(
|
|
text,
|
|
edit_context=edit_context,
|
|
assembly=args.assembly,
|
|
source_backend=source_backend,
|
|
)
|
|
if not edit_context and args.backend == "auto":
|
|
registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
|
standard_part = simplecadapi_standard_part_match(request, registry)
|
|
if not standard_part:
|
|
requirements = [
|
|
requirement
|
|
for requirement in requirements
|
|
if requirement != "standard_mechanical_factory"
|
|
]
|
|
agent_experiences = (
|
|
experience_context.get("experiences", [])
|
|
if isinstance(experience_context, dict)
|
|
else []
|
|
)
|
|
agent_generalized_methods = [
|
|
{
|
|
key: item[key]
|
|
for key in (
|
|
"id",
|
|
"kind",
|
|
"guidance",
|
|
"check",
|
|
"repair",
|
|
"confidence",
|
|
"reconstruction_grammar",
|
|
)
|
|
if isinstance(item, dict) and key in item
|
|
}
|
|
for item in agent_experiences
|
|
if isinstance(item, dict)
|
|
]
|
|
agent_reconstruction_grammars = [
|
|
item["reconstruction_grammar"]
|
|
for item in agent_experiences
|
|
if isinstance(item, dict)
|
|
and item.get("kind") == "reconstruction_grammar"
|
|
and "reconstruction_grammar" in item
|
|
]
|
|
selected_name = "simplecadapi" if standard_part else "build123d"
|
|
backend_info = registry["backends"][selected_name]
|
|
source_of_truth = (
|
|
"simplecadapi model graph with native Python source"
|
|
if selected_name == "simplecadapi"
|
|
else "build123d native Python source"
|
|
)
|
|
selection_reason = (
|
|
"matched SimpleCADAPI standard-part catalog entry "
|
|
f"{standard_part['type']} ({standard_part['factory']})"
|
|
if standard_part
|
|
else "no SimpleCADAPI standard-part catalog entry matched; use the general text-to-cad/build123d backend"
|
|
)
|
|
return {
|
|
"schema_version": "1.0",
|
|
"request": request,
|
|
"selected_backend": selected_name,
|
|
"runner_skill": backend_info["runner_skill"],
|
|
"project": backend_info["project"],
|
|
"adapter": None,
|
|
"confidence": "high",
|
|
"routing_policy": {
|
|
"version": "4.0",
|
|
"selection_scope": "new_model_primary_part_type",
|
|
"requirements": requirements,
|
|
"decision_authority": "cad_router_standard_part_catalog",
|
|
"keyword_backend_scoring": True,
|
|
"fallback_policy": "unmatched_requests_use_build123d",
|
|
"standard_part_match": standard_part,
|
|
"server_side_template_generation": False,
|
|
"effective_execution_engine": selected_name,
|
|
"validation_engines": ["backend_geometry_validation"],
|
|
},
|
|
"backend_scores": [
|
|
{
|
|
"backend": selected_name,
|
|
"score": 100,
|
|
"reasons": [selection_reason],
|
|
},
|
|
{
|
|
"backend": "build123d" if selected_name == "simplecadapi" else "simplecadapi",
|
|
"score": 0,
|
|
"reasons": ["not selected by the standard-part catalog"],
|
|
},
|
|
],
|
|
"fallback_order": ["build123d"] if selected_name == "simplecadapi" else [],
|
|
"workflow_profiles": [],
|
|
"artifact_contract": {
|
|
"primary_format": "step",
|
|
"editable_contract": "DesignIR 3.0 normalized contract",
|
|
"editable_contract_role": "contract_after_backend_generation",
|
|
"authoritative_geometry_source": source_of_truth,
|
|
},
|
|
"source_of_truth": source_of_truth,
|
|
"experience_context": experience_context,
|
|
"design_plan": {
|
|
"plan_kind": "deterministic_standard_part_route",
|
|
"operation": "create",
|
|
"part_family": experience_family or "unclassified",
|
|
"requested_feature_roles": sorted(set(experience_features)),
|
|
"selected_backend": selected_name,
|
|
"source_of_truth": source_of_truth,
|
|
"standard_part_match": standard_part,
|
|
"generalized_methods": agent_generalized_methods,
|
|
"reconstruction_grammars": agent_reconstruction_grammars,
|
|
"experience_status": (
|
|
"matched"
|
|
if experience_context and experience_context.get("experiences")
|
|
else "no_promoted_method_matched"
|
|
),
|
|
"execution_steps": [
|
|
"CAD Router selects the backend from the standard-part catalog.",
|
|
"Agent writes backend-native Python source for the full request.",
|
|
"Server executes the submitted source and rejects missing artifacts.",
|
|
"Server normalizes backend output into DesignIR 3.0.",
|
|
],
|
|
},
|
|
"project_contributions": {
|
|
"text-to-cad": "default backend for every request outside the SimpleCADAPI standard-part catalog",
|
|
"SimpleCADAPI": "backend for the catalogued standard mechanical part types only",
|
|
},
|
|
"availability": None,
|
|
}
|
|
scores = {
|
|
"build123d": BackendScore(
|
|
"build123d",
|
|
30,
|
|
["+30 broad custom mechanical geometry coverage"],
|
|
),
|
|
"simplecadapi": BackendScore(
|
|
"simplecadapi",
|
|
30,
|
|
["+30 replayable OCP-native parametric coverage"],
|
|
),
|
|
}
|
|
availability = probe_backends() if args.probe else None
|
|
if availability:
|
|
for backend_name, facts in availability.items():
|
|
if facts.get("available") is not True:
|
|
scores[backend_name].add(
|
|
-10_000, "backend is unavailable in the probed runtime"
|
|
)
|
|
if availability["simplecadapi"].get("compatible") is not True:
|
|
scores["simplecadapi"].add(
|
|
-10_000,
|
|
"installed SimpleCADAPI does not satisfy the 2.0.2 capability contract",
|
|
)
|
|
|
|
if contains_any(text, KEYWORD_GROUPS["standard_factory"]):
|
|
scores["simplecadapi"].add(
|
|
85, "a deterministic standard mechanical factory is available"
|
|
)
|
|
scores["build123d"].add(10, "the family can be modeled generically")
|
|
if contains_any(text, KEYWORD_GROUPS["general_mechanical"]):
|
|
scores["build123d"].add(
|
|
32, "custom mechanical part benefits from broad feature construction"
|
|
)
|
|
scores["simplecadapi"].add(
|
|
24, "general OCP feature modeling and semantic tagging are available"
|
|
)
|
|
if contains_any(text, KEYWORD_GROUPS["semantic_modeling"]):
|
|
scores["simplecadapi"].add(
|
|
70,
|
|
"model replay, semantic lineage, units, tolerances, or translation is required",
|
|
)
|
|
scores["build123d"].add(
|
|
8, "editable Python can preserve parameters but has no equivalent unified graph"
|
|
)
|
|
if contains_any(text, KEYWORD_GROUPS["custom_surface"]):
|
|
scores["build123d"].add(
|
|
65, "custom/freeform surface vocabulary favors the general CAD backend"
|
|
)
|
|
scores["simplecadapi"].add(
|
|
-20, "requested surface vocabulary may exceed deterministic SDK coverage"
|
|
)
|
|
if contains_any(text, KEYWORD_GROUPS["mechanism"]):
|
|
scores["simplecadapi"].add(
|
|
38, "typed mechanism constraints and replayable products are available"
|
|
)
|
|
scores["build123d"].add(
|
|
24, "source-level joints and assemblies are available"
|
|
)
|
|
|
|
if source_backend:
|
|
scores[source_backend].add(
|
|
120, f"preserve the explicitly supplied {source_backend} source of truth"
|
|
)
|
|
other = "simplecadapi" if source_backend == "build123d" else "build123d"
|
|
scores[other].add(
|
|
-35, "backend conversion would risk feature-history and edit-contract loss"
|
|
)
|
|
elif edit_context and not teacher_step_reconstruction:
|
|
scores["build123d"].add(
|
|
20, "general existing-source inspection and local feature repair"
|
|
)
|
|
scores["simplecadapi"].add(
|
|
20, "semantic graph editing is available when the source supports it"
|
|
)
|
|
if teacher_step_reconstruction:
|
|
# Uploaded STEP reconstruction is executed by SurfaceIR. Backend scores
|
|
# describe the preferred semantic authoring backend after reconstruction,
|
|
# not the source-independent rebuild engine itself.
|
|
scores["build123d"].add(
|
|
18, "broad semantic feature vocabulary can author future repairs"
|
|
)
|
|
scores["simplecadapi"].add(
|
|
18, "strict B-Rep comparison and semantic lineage strengthen acceptance"
|
|
)
|
|
if args.assembly:
|
|
scores["build123d"].add(25, "source-level assembly and datum workflow")
|
|
scores["simplecadapi"].add(
|
|
25, "replayable product, connector, and constraint workflow"
|
|
)
|
|
scores["build123d"].add(15, "STEP export and editable Python satisfy the contract")
|
|
scores["simplecadapi"].add(
|
|
15, "STEP export plus model JSON satisfy the contract"
|
|
)
|
|
if experience_context and experience_context["experiences"]:
|
|
scores["build123d"].add(
|
|
18, "generalized experience can guide parametric feature composition"
|
|
)
|
|
scores["simplecadapi"].add(
|
|
24, "generalized experience can bind to semantic roles and operation graphs"
|
|
)
|
|
|
|
forced_backend = args.backend if args.backend != "auto" else None
|
|
if (
|
|
forced_backend
|
|
and availability
|
|
and scores[forced_backend].score < -5_000
|
|
):
|
|
raise ValueError(
|
|
f"Explicit backend '{forced_backend}' is unavailable or incompatible "
|
|
"in the probed runtime"
|
|
)
|
|
ranked = sorted(scores.values(), key=lambda item: (-item.score, item.name))
|
|
if forced_backend:
|
|
selected = scores[forced_backend]
|
|
selected.reasons.append("explicit backend override")
|
|
fallback = [item.name for item in ranked if item.name != forced_backend]
|
|
else:
|
|
selected = ranked[0]
|
|
fallback = [item.name for item in ranked[1:]]
|
|
|
|
profiles: list[str] = []
|
|
if contains_any(text, KEYWORD_GROUPS["visual_complexity"]):
|
|
profiles.extend(["requirement_refinement", "visual_repair"])
|
|
if not any(char.isdigit() for char in request) and contains_any(
|
|
text,
|
|
KEYWORD_GROUPS["general_mechanical"] + KEYWORD_GROUPS["standard_factory"],
|
|
):
|
|
profiles.append("requirement_refinement")
|
|
profiles = list(dict.fromkeys(profiles))
|
|
|
|
registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
|
backend_info = registry["backends"][selected.name]
|
|
editable_contract = "DesignIR 3.0 normalized contract"
|
|
if teacher_step_reconstruction:
|
|
source_of_truth = "surfaceir_designir"
|
|
elif selected.name == "simplecadapi":
|
|
source_of_truth = "simplecadapi model graph with native Python source"
|
|
else:
|
|
source_of_truth = "build123d native Python source"
|
|
gap = selected.score - max((scores[name].score for name in scores if name != selected.name), default=0)
|
|
confidence = "high" if gap >= 40 else "medium" if gap >= 15 else "low"
|
|
result: dict[str, object] = {
|
|
"schema_version": "1.0",
|
|
"request": request,
|
|
"selected_backend": selected.name,
|
|
"runner_skill": backend_info["runner_skill"],
|
|
"project": backend_info["project"],
|
|
"adapter": backend_info.get("adapter"),
|
|
"confidence": confidence,
|
|
"routing_policy": {
|
|
"version": "2.0",
|
|
"default_backend": None,
|
|
"selection_scope": (
|
|
"post_reconstruction_semantic_backend"
|
|
if teacher_step_reconstruction
|
|
else "authoring_backend"
|
|
),
|
|
"requirements": requirements,
|
|
"native_source_backend": source_backend,
|
|
"score_margin": gap,
|
|
"effective_execution_engine": (
|
|
"surfaceir" if teacher_step_reconstruction else selected.name
|
|
),
|
|
"validation_engines": (
|
|
[
|
|
"surfaceir_geometry_acceptance",
|
|
"simplecadapi_brep_compare",
|
|
]
|
|
if teacher_step_reconstruction
|
|
else ["backend_geometry_validation"]
|
|
),
|
|
},
|
|
"backend_scores": [
|
|
{"backend": item.name, "score": item.score, "reasons": item.reasons}
|
|
for item in sorted(scores.values(), key=lambda item: (-item.score, item.name))
|
|
],
|
|
"fallback_order": fallback,
|
|
"workflow_profiles": profiles,
|
|
"artifact_contract": {
|
|
"primary_format": "step",
|
|
"editable_contract": editable_contract,
|
|
"editable_contract_role": "contract_after_backend_generation",
|
|
"authoritative_geometry_source": source_of_truth,
|
|
},
|
|
"source_of_truth": source_of_truth,
|
|
"experience_context": experience_context,
|
|
"design_plan": {
|
|
"plan_kind": "backend_neutral_parametric_design_plan",
|
|
"operation": "modify" if edit_context else "create",
|
|
"part_family": experience_family or "unclassified",
|
|
"requested_feature_roles": sorted(set(experience_features)),
|
|
"selected_backend": selected.name,
|
|
"source_of_truth": source_of_truth,
|
|
"generalized_methods": [
|
|
{
|
|
key: item[key]
|
|
for key in (
|
|
"id",
|
|
"kind",
|
|
"guidance",
|
|
"check",
|
|
"repair",
|
|
"confidence",
|
|
"reconstruction_grammar",
|
|
)
|
|
if key in item
|
|
}
|
|
for item in (
|
|
experience_context.get("experiences", [])
|
|
if experience_context
|
|
else []
|
|
)
|
|
if isinstance(item, dict)
|
|
],
|
|
"reconstruction_grammars": [
|
|
item["reconstruction_grammar"]
|
|
for item in (
|
|
experience_context.get("experiences", [])
|
|
if experience_context
|
|
else []
|
|
)
|
|
if isinstance(item, dict)
|
|
and item.get("kind") == "reconstruction_grammar"
|
|
and isinstance(item.get("reconstruction_grammar"), dict)
|
|
],
|
|
"experience_status": (
|
|
"pending_source_classification"
|
|
if needs_source_classification
|
|
else (
|
|
"matched"
|
|
if experience_context and experience_context.get("experiences")
|
|
else "no_promoted_method_matched"
|
|
)
|
|
),
|
|
"experience_query_plan": {
|
|
"requires_source_inspection": needs_source_classification,
|
|
"family": experience_family,
|
|
"feature_roles": sorted(set(experience_features)),
|
|
"retry_after_inspection": needs_source_classification,
|
|
"retry_arguments": (
|
|
"--experience-family <inspected-family> "
|
|
"--experience-feature <inspected-feature>"
|
|
if needs_source_classification
|
|
else None
|
|
),
|
|
"knowledge_boundary": (
|
|
"Classify from active-job private evidence only; the "
|
|
"reconstruction agent never reads teacher STEP."
|
|
),
|
|
},
|
|
"edit_context": edit_context,
|
|
"execution_steps": (
|
|
[
|
|
"Run deterministic ingestion in the protected teacher zone.",
|
|
"Give Agent A only the active private evidence packet and promoted experience.",
|
|
"Author complete executable DesignIR with uncertainty and missing-capability declarations.",
|
|
"Run anti-cheat validation and compile in a sandbox without teacher STEP.",
|
|
"Give Agent B the teacher, DesignIR, rebuilt STEP, and edit contract.",
|
|
"Run geometry comparison and parameter perturbation acceptance.",
|
|
"Submit sanitized feedback to deterministic promotion policy.",
|
|
]
|
|
if teacher_step_reconstruction
|
|
else
|
|
[
|
|
"Load only the explicitly supplied DesignIR or native generator.",
|
|
"Map requested changes to named parameters or affected semantic feature roles.",
|
|
"Preserve unchanged interfaces and rebuild only the smallest responsible feature region.",
|
|
"Use promoted generalized experience to guide methods.",
|
|
"Use semantic reconstruction for topology changes that cannot preserve the prior feature structure.",
|
|
"Validate changed dimensions plus invariants inherited from the previous model.",
|
|
]
|
|
if edit_context
|
|
else [
|
|
"Resolve explicit user dimensions and preserve them as authoritative parameters.",
|
|
"Translate requested feature roles and matched generalized methods into the selected backend's native operations.",
|
|
"Generate backend-native source or model graph and export the primary STEP artifact.",
|
|
"Normalize backend metadata into DesignIR 3.0 for parameters, feature tree, validation, and future edit bindings.",
|
|
"Validate topology, dimensions, feature relationships, surface mix, and export integrity against the brief.",
|
|
]
|
|
),
|
|
},
|
|
"project_contributions": {
|
|
"text-to-cad": "backend-neutral orchestrator, DesignIR owner, SurfaceIR reconstruction, refinement, repair, validation, artifacts, and Viewer handoff",
|
|
"SimpleCADAPI": "equal-priority OCP backend for standard factories, replayable graphs, semantic lineage, units, tolerances, translators, and B-Rep diagnostics",
|
|
},
|
|
"availability": availability,
|
|
}
|
|
if teacher_step_reconstruction:
|
|
result["execution_adapter"] = {
|
|
"path": str(RECONSTRUCTION_ADAPTER),
|
|
"runtime": "DesignIR 3.0 / SurfaceIR",
|
|
"automatic_geometry_acceptance": True,
|
|
"automatic_parameter_acceptance": True,
|
|
"supplemental_brep_acceptance": "SimpleCADAPI 2.0.2",
|
|
}
|
|
return result
|
|
|
|
|
|
def execute_reconstruction(
|
|
args: argparse.Namespace, result: dict[str, object]
|
|
) -> dict[str, object]:
|
|
edit_context = result.get("design_plan", {}).get("edit_context")
|
|
if not isinstance(edit_context, dict) or edit_context.get(
|
|
"modification_mode"
|
|
) != "private_evidence_to_isolated_designir_reconstruction":
|
|
raise ValueError(
|
|
"--execute currently requires --edit-source with one STEP/STP file"
|
|
)
|
|
if args.edit_source is None or args.task_dir is None:
|
|
raise ValueError(
|
|
"--execute requires both --edit-source and --task-dir"
|
|
)
|
|
runtime = os.environ.get("CAD_ROUTER_PYTHON", "").strip()
|
|
workspace_python = (
|
|
SKILL_ROOT.parents[2] / "text-to-cad" / ".venv" / "bin" / "python"
|
|
)
|
|
interpreter = (
|
|
Path(runtime).expanduser()
|
|
if runtime
|
|
else workspace_python
|
|
if workspace_python.is_file()
|
|
else Path(sys.executable)
|
|
)
|
|
command = [
|
|
str(interpreter),
|
|
str(RECONSTRUCTION_ADAPTER),
|
|
"--source",
|
|
str(args.edit_source),
|
|
"--task-dir",
|
|
str(args.task_dir),
|
|
"--request",
|
|
args.request,
|
|
"--maximum-parameters",
|
|
str(args.maximum_parameters),
|
|
"--timeout-seconds",
|
|
str(args.timeout_seconds),
|
|
"--semantic-backend",
|
|
str(result["selected_backend"]),
|
|
]
|
|
completed = subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if completed.returncode != 0:
|
|
detail = completed.stderr.strip() or completed.stdout.strip()
|
|
raise RuntimeError(
|
|
"cad-router STEP reconstruction failed: " + detail[-3000:]
|
|
)
|
|
lines = [line for line in completed.stdout.splitlines() if line.strip()]
|
|
if not lines:
|
|
raise RuntimeError("cad-router reconstruction returned no result")
|
|
return json.loads(lines[-1])
|
|
|
|
|
|
def explain(result: dict[str, object]) -> str:
|
|
policy = result.get("routing_policy", {})
|
|
lines = [
|
|
f"Selected backend: {result['selected_backend']}",
|
|
f"Runner skill: ${result['runner_skill']}",
|
|
f"Project: {result['project']}",
|
|
f"Confidence: {result['confidence']}",
|
|
"Effective execution engine: "
|
|
f"{policy.get('effective_execution_engine', result['selected_backend'])}",
|
|
f"Source of truth: {result['source_of_truth']}",
|
|
]
|
|
requirements = policy.get("requirements", [])
|
|
if requirements:
|
|
lines.append(f"Requirements: {', '.join(requirements)}")
|
|
validators = policy.get("validation_engines", [])
|
|
if validators:
|
|
lines.append(f"Validation engines: {', '.join(validators)}")
|
|
profiles = result["workflow_profiles"]
|
|
if profiles:
|
|
lines.append(f"Workflow profiles: {', '.join(profiles)}")
|
|
experience_context = result.get("experience_context")
|
|
if experience_context:
|
|
lines.append(
|
|
"Experience: "
|
|
f"{experience_context.get('family') or 'global'} "
|
|
f"({len(experience_context.get('experiences', []))} generalized methods, "
|
|
"no case parameters or coordinates)"
|
|
)
|
|
lines.append("Ranking:")
|
|
for item in result["backend_scores"]:
|
|
lines.append(f" {item['backend']}: {item['score']}")
|
|
for reason in item["reasons"]:
|
|
lines.append(f" - {reason}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="Route a CAD request to a V1 modeling backend.")
|
|
parser.add_argument("request", help="Natural-language CAD request.")
|
|
parser.add_argument(
|
|
"--backend",
|
|
choices=("auto", "build123d", "simplecadapi"),
|
|
default="auto",
|
|
help="Override automatic routing.",
|
|
)
|
|
parser.add_argument("--existing-model", action="store_true", help="The task modifies or inspects an existing model.")
|
|
parser.add_argument(
|
|
"--edit-source",
|
|
type=Path,
|
|
help="Existing *.designir.json, generator, or explicit STEP/STP teacher to reconstruct; private corpus access is forbidden.",
|
|
)
|
|
parser.add_argument("--assembly", action="store_true", help="The requested result is an assembly.")
|
|
parser.add_argument("--probe", action="store_true", help="Probe current-interpreter backend availability.")
|
|
parser.add_argument(
|
|
"--experience-library",
|
|
type=Path,
|
|
help="Schema 2.0 generalized CAD experience library; case JSON is rejected.",
|
|
)
|
|
parser.add_argument("--experience-family", help="Optional generalized experience family filter.")
|
|
parser.add_argument(
|
|
"--experience-feature",
|
|
action="append",
|
|
default=[],
|
|
help="Requested semantic feature role; repeatable.",
|
|
)
|
|
parser.add_argument("--explain", action="store_true", help="Print a human-readable decision instead of JSON.")
|
|
parser.add_argument("--manifest", type=Path, help="Also write the route decision as JSON to this explicit path.")
|
|
parser.add_argument(
|
|
"--execute",
|
|
action="store_true",
|
|
help="Execute an uploaded STEP reconstruction after routing.",
|
|
)
|
|
parser.add_argument(
|
|
"--task-dir",
|
|
type=Path,
|
|
help="Explicit output directory for --execute.",
|
|
)
|
|
parser.add_argument(
|
|
"--maximum-parameters",
|
|
type=int,
|
|
default=8,
|
|
help="Maximum feature-specific edit candidates to validate; overall_scale is also tested as fallback.",
|
|
)
|
|
parser.add_argument(
|
|
"--timeout-seconds",
|
|
type=int,
|
|
default=180,
|
|
help="Timeout for each reconstruction or acceptance worker.",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
result = route(args)
|
|
if args.execute:
|
|
result["execution"] = execute_reconstruction(args, result)
|
|
payload = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
|
|
if args.manifest:
|
|
args.manifest.parent.mkdir(parents=True, exist_ok=True)
|
|
args.manifest.write_text(payload, encoding="utf-8")
|
|
print(explain(result) if args.explain else payload, end="\n" if args.explain else "")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|