786 lines
31 KiB
Python
Executable File
786 lines
31 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
|
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
REGISTRY_PATH = Path(__file__).with_name("capabilities.json")
|
|
CADAM_RUNTIME_FILES = (
|
|
Path("src/vendor/openscad-wasm/openscad.js"),
|
|
Path("src/vendor/openscad-wasm/openscad.wasm"),
|
|
)
|
|
|
|
|
|
@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, ...]] = {
|
|
"special_mechanical": (
|
|
"gear", "gears", "齿轮", "齿条", "rack", "ring gear", "内齿圈",
|
|
"cycloidal", "摆线", "bearing", "轴承", "planetary", "行星",
|
|
"reducer", "gearbox", "减速器", "减速箱", "joint actuator", "关节执行器",
|
|
),
|
|
"general_mechanical": (
|
|
"bracket", "支架", "connecting rod", "连杆", "crank", "曲柄",
|
|
"lever", "摇臂", "shaft", "轴", "bushing", "轴套", "flange", "法兰",
|
|
"housing", "壳体", "enclosure", "外壳", "fixture", "夹具", "plate", "安装板",
|
|
),
|
|
"printable": (
|
|
"3d print", "3d-print", "打印", "printable", "vase", "花瓶", "knob", "旋钮",
|
|
"container", "容器", "adapter", "适配器", "honeycomb", "蜂窝", "lattice", "晶格",
|
|
"pattern", "阵列", "decorative", "装饰", "openscad", "scad", "bosl",
|
|
),
|
|
"replay": (
|
|
"replay", "重放", "operation graph", "model json", "模型图", "semantic tag",
|
|
"语义标签", "freecad", "fcstd",
|
|
),
|
|
"visual_complexity": (
|
|
"image", "photo", "sketch", "图片", "照片", "草图", "复杂", "complex",
|
|
"像", "参考图", "reference",
|
|
),
|
|
}
|
|
|
|
|
|
FOCUSED_SKILLS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
("dxf", ("dxf", "laser cut", "激光切割", "waterjet", "水刀", "flat pattern", "展开")),
|
|
("urdf", ("urdf", "robot description", "机器人描述")),
|
|
("srdf", ("srdf", "moveit", "planning group", "规划组")),
|
|
("sdf", ("sdformat", "gazebo", "simulation world", "仿真世界", ".sdf")),
|
|
("gcode", ("g-code", "gcode", "slice", "切片")),
|
|
("sendcutsend", ("sendcutsend",)),
|
|
("bambu-labs", ("bambu", "拓竹")),
|
|
)
|
|
|
|
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 normalize_formats(formats: list[str]) -> list[str]:
|
|
result: list[str] = []
|
|
for value in formats:
|
|
normalized = value.lower().lstrip(".")
|
|
if normalized not in result:
|
|
result.append(normalized)
|
|
return result
|
|
|
|
|
|
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 cadam_root() -> Path:
|
|
configured = os.environ.get("CADAM_ROOT", "").strip()
|
|
if configured:
|
|
return Path(configured).expanduser().resolve()
|
|
return (SKILL_ROOT.parents[2] / "CADAM").resolve()
|
|
|
|
|
|
def cadam_runtime_available(root: Path | None = None) -> bool:
|
|
candidate = root or cadam_root()
|
|
return all((candidate / relative_path).is_file() for relative_path in CADAM_RUNTIME_FILES)
|
|
|
|
|
|
def probe_backends() -> dict[str, dict[str, object]]:
|
|
return {
|
|
"build123d": {
|
|
"available": importlib.util.find_spec("build123d") is not None,
|
|
"probe": f"python={sys.executable}",
|
|
},
|
|
"simplecadapi": {
|
|
"available": importlib.util.find_spec("simplecadapi") is not None,
|
|
"probe": f"python={sys.executable}",
|
|
},
|
|
"cadam": {
|
|
"available": cadam_runtime_available(),
|
|
"probe": str(cadam_root()),
|
|
},
|
|
}
|
|
|
|
|
|
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",
|
|
)
|
|
LOCAL_FEATURE_EDIT_TERMS = (
|
|
"增加孔",
|
|
"添加孔",
|
|
"删除孔",
|
|
"增加槽",
|
|
"添加槽",
|
|
"删除槽",
|
|
"沉孔",
|
|
"倒角",
|
|
"圆角",
|
|
"add hole",
|
|
"remove hole",
|
|
"add slot",
|
|
"counterbore",
|
|
"fillet",
|
|
"chamfer",
|
|
)
|
|
PARAMETER_EDIT_TERMS = (
|
|
"孔径",
|
|
"直径",
|
|
"厚度",
|
|
"长度",
|
|
"宽度",
|
|
"高度",
|
|
"间距",
|
|
"齿数",
|
|
"模数",
|
|
"改为",
|
|
"调整",
|
|
"diameter",
|
|
"thickness",
|
|
"length",
|
|
"width",
|
|
"height",
|
|
"spacing",
|
|
"change",
|
|
"resize",
|
|
)
|
|
|
|
|
|
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()
|
|
workspace_candidate = SKILL_ROOT.parents[2] / "cad-experience-library" / "library.json"
|
|
return workspace_candidate if workspace_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:
|
|
parser_root = (
|
|
SKILL_ROOT.parents[2] / "cad-experience-plugin" / "parser"
|
|
).resolve()
|
|
if source == parser_root or parser_root in source.parents:
|
|
raise ValueError(
|
|
"cad-router cannot use cad-experience-plugin/parser input or "
|
|
"output as an edit source; provide the original model or "
|
|
"editable generator outside the distillation staging area"
|
|
)
|
|
suffix = source.suffix.lower() if source else ""
|
|
text = request.lower()
|
|
|
|
if suffix == ".json" and source and source.name == "model-spec.json":
|
|
mode = "model_spec_parameter_edit"
|
|
reason = "A per-part model specification exists; update its named JSON parameters or modification features."
|
|
elif suffix in {".py", ".scad", ".js", ".mjs"}:
|
|
mode = "native_parameter_edit"
|
|
reason = "An editable generator exists; update its named parameters or feature source."
|
|
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."
|
|
elif source and suffix in {".step", ".stp"} and contains_any(
|
|
text, LOCAL_FEATURE_EDIT_TERMS
|
|
):
|
|
mode = "direct_step_local_feature_edit"
|
|
reason = "Inspect the specified STEP and rebuild only the affected local feature."
|
|
elif source and suffix in {".step", ".stp"} and contains_any(
|
|
text, PARAMETER_EDIT_TERMS
|
|
):
|
|
mode = "direct_step_feature_rebuild"
|
|
reason = "STEP has no native feature history; inspect the source and reconstruct the affected dimensional feature."
|
|
elif source and suffix in {".step", ".stp"}:
|
|
mode = "inspect_existing_step_then_modify"
|
|
reason = "Inspect the explicitly supplied STEP before choosing local editing or reconstruction."
|
|
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": [
|
|
"the explicitly supplied original model or editable generator",
|
|
"the promoted generalized CAD experience library",
|
|
"the current user request",
|
|
],
|
|
"forbidden_knowledge_sources": [
|
|
"cad-experience-plugin/parser/input",
|
|
"cad-experience-plugin/parser/output",
|
|
"private case JSON",
|
|
],
|
|
"preservation_contract": [
|
|
"Preserve the source coordinate frame unless the request explicitly changes it.",
|
|
"Preserve unmodified interfaces, datums, and feature relationships.",
|
|
"Prefer the per-part model-spec.json and native generator over inferred STEP reconstruction when they exist.",
|
|
"Use generalized experience only as method guidance, never as replacement source geometry.",
|
|
],
|
|
}
|
|
|
|
|
|
def load_experience_library(
|
|
path: Path | None,
|
|
family: str | None = None,
|
|
features: list[str] | None = None,
|
|
) -> dict[str, object] | None:
|
|
"""Load promoted generalized methods; reject case parameters and coordinates."""
|
|
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")
|
|
if (
|
|
policy.get("llm_semantic_review_required") is not True
|
|
or policy.get("draft_evidence_verified") is not True
|
|
or policy.get("router_consumable") is not True
|
|
):
|
|
raise ValueError(
|
|
"CAD experience library was not produced by LLM semantic review "
|
|
"plus deterministic evidence verification"
|
|
)
|
|
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 "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,
|
|
"policy": {
|
|
"contains_instance_parameters": False,
|
|
"contains_absolute_coordinates": False,
|
|
},
|
|
}
|
|
|
|
|
|
def route(args: argparse.Namespace) -> dict[str, object]:
|
|
request = args.request.strip()
|
|
text = request.lower()
|
|
outputs = normalize_formats(args.output)
|
|
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,
|
|
)
|
|
experience_context = load_experience_library(
|
|
args.experience_library,
|
|
experience_family,
|
|
experience_features,
|
|
)
|
|
needs_source_classification = bool(edit_context and not experience_family)
|
|
scores = {
|
|
"build123d": BackendScore("build123d", 35, ["+35 general STEP-first default"]),
|
|
"simplecadapi": BackendScore("simplecadapi", 20, ["+20 specialist mechanical baseline"]),
|
|
"cadam": BackendScore("cadam", 10, ["+10 CADAM printable CSG baseline"]),
|
|
}
|
|
|
|
if contains_any(text, KEYWORD_GROUPS["special_mechanical"]):
|
|
scores["simplecadapi"].add(65, "supported standard mechanical family")
|
|
scores["build123d"].add(8, "can model the family generically")
|
|
if contains_any(text, KEYWORD_GROUPS["general_mechanical"]):
|
|
scores["build123d"].add(35, "general mechanical part with direct parametric features")
|
|
scores["simplecadapi"].add(10, "general OCP modeling is available")
|
|
if contains_any(text, KEYWORD_GROUPS["printable"]):
|
|
scores["cadam"].add(55, "printable, pattern-heavy, or SCAD-native request")
|
|
if contains_any(text, KEYWORD_GROUPS["replay"]):
|
|
scores["simplecadapi"].add(60, "replay, semantic graph, or FreeCAD requirement")
|
|
|
|
if edit_context:
|
|
scores["build123d"].add(55, "existing model inspection or source modification")
|
|
scores["cadam"].add(-50, "poor fit for imported editable STEP")
|
|
if args.assembly:
|
|
scores["build123d"].add(15, "source-level assembly workflow")
|
|
scores["simplecadapi"].add(18, "mechanism and constraint workflow")
|
|
scores["cadam"].add(-15, "weak fit for engineering assembly relationships")
|
|
if args.browser_controls:
|
|
scores["cadam"].add(45, "CADAM-style parameter controls requested")
|
|
if args.manufacturing == "machining":
|
|
scores["build123d"].add(25, "machining favors STEP-first B-Rep")
|
|
scores["simplecadapi"].add(18, "OCP STEP geometry suits machining")
|
|
scores["cadam"].add(-25, "mesh/CSG route is not preferred for machining")
|
|
elif args.manufacturing == "printing":
|
|
scores["cadam"].add(30, "printing favors fast parametric CSG")
|
|
scores["build123d"].add(8, "can export validated printable meshes")
|
|
elif args.manufacturing == "laser-cutting":
|
|
scores["build123d"].add(12, "can own projected source geometry")
|
|
|
|
if any(fmt in {"step", "stp"} for fmt in outputs):
|
|
scores["build123d"].add(35, "editable STEP requested")
|
|
scores["simplecadapi"].add(30, "OCP STEP export requested")
|
|
scores["cadam"].add(-70, "no native editable STEP output")
|
|
if "fcstd" in outputs:
|
|
scores["simplecadapi"].add(70, "FreeCAD output requested")
|
|
scores["cadam"].add(-40, "FreeCAD output is unsupported")
|
|
if "scad" in outputs:
|
|
scores["cadam"].add(80, "SCAD source requested")
|
|
if any(fmt in {"stl", "3mf"} for fmt in outputs):
|
|
scores["cadam"].add(12, "print mesh requested")
|
|
scores["build123d"].add(5, "secondary mesh export is available")
|
|
if experience_context and experience_context["experiences"]:
|
|
scores["build123d"].add(18, "generalized experience can guide parametric feature composition")
|
|
scores["simplecadapi"].add(18, "generalized experience can guide semantic feature composition")
|
|
|
|
forced_backend = args.backend if args.backend != "auto" else None
|
|
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["special_mechanical"],
|
|
):
|
|
profiles.append("requirement_refinement")
|
|
profiles = list(dict.fromkeys(profiles))
|
|
|
|
downstream = [name for name, terms in FOCUSED_SKILLS if contains_any(text, terms)]
|
|
if args.manufacturing == "laser-cutting" and "dxf" not in downstream:
|
|
downstream.append("dxf")
|
|
if any(fmt in {"stl", "3mf", "gcode"} for fmt in outputs) and "cad-viewer" not in downstream:
|
|
downstream.append("cad-viewer")
|
|
elif selected.name in {"build123d", "simplecadapi"}:
|
|
downstream.append("cad-viewer")
|
|
|
|
registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
|
backend_info = registry["backends"][selected.name]
|
|
source_of_truth = backend_info["source_of_truth"]
|
|
if (
|
|
edit_context
|
|
and edit_context.get("modification_mode") == "model_spec_parameter_edit"
|
|
):
|
|
source_of_truth = "per-part model-spec.json with a backend generator"
|
|
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,
|
|
"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,
|
|
"downstream_skills": downstream,
|
|
"requested_outputs": outputs,
|
|
"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 only from the explicitly supplied source model; "
|
|
"never from parser input/output or private case JSON."
|
|
),
|
|
},
|
|
"edit_context": edit_context,
|
|
"execution_steps": (
|
|
[
|
|
"Load only the explicitly supplied source model or native generator.",
|
|
"Inspect the source model to classify its family and feature roles when the request does not name them, then re-query promoted experience.",
|
|
"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; never read parser input/output.",
|
|
"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.",
|
|
"Instantiate matched reconstruction grammars as symbolic model-spec.json parameters, datums, and canonical feature stages; never copy teacher dimensions.",
|
|
"Translate requested feature roles and matched generalized methods into backend operations.",
|
|
"Generate the editable source of truth and STEP-first artifact when requested.",
|
|
"Validate topology, dimensions, feature relationships, surface mix, and export integrity against the grammar validation roles.",
|
|
]
|
|
),
|
|
},
|
|
"project_contributions": {
|
|
"text-to-cad": "core Skill host, build123d CAD backend, refinement, visual repair, validation, artifacts, and CAD Viewer handoff",
|
|
"SimpleCADAPI": "specialized mechanical backend with replayable graphs and semantic modeling",
|
|
"CADAM": "OpenSCAD-WASM execution for printable and pattern-heavy SCAD models",
|
|
},
|
|
"availability": probe_backends() if args.probe else None,
|
|
}
|
|
return result
|
|
|
|
|
|
def explain(result: dict[str, object]) -> str:
|
|
lines = [
|
|
f"Selected backend: {result['selected_backend']}",
|
|
f"Runner skill: ${result['runner_skill']}",
|
|
f"Project: {result['project']}",
|
|
f"Confidence: {result['confidence']}",
|
|
f"Source of truth: {result['source_of_truth']}",
|
|
]
|
|
profiles = result["workflow_profiles"]
|
|
if profiles:
|
|
lines.append(f"Workflow profiles: {', '.join(profiles)}")
|
|
downstream = result["downstream_skills"]
|
|
if downstream:
|
|
lines.append(f"Downstream skills: {', '.join('$' + name for name in downstream)}")
|
|
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", "cadam"),
|
|
default="auto",
|
|
help="Override automatic routing.",
|
|
)
|
|
parser.add_argument("--output", action="append", default=[], help="Required output format; repeatable.")
|
|
parser.add_argument(
|
|
"--manufacturing",
|
|
choices=("unspecified", "machining", "printing", "laser-cutting", "concept"),
|
|
default="unspecified",
|
|
)
|
|
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 model-spec.json, generator, or explicit STEP/STP to modify; private case JSON is forbidden.",
|
|
)
|
|
parser.add_argument("--assembly", action="store_true", help="The requested result is an assembly.")
|
|
parser.add_argument("--browser-controls", action="store_true", help="Interactive browser parameter controls are required.")
|
|
parser.add_argument("--probe", action="store_true", help="Probe current-interpreter and OpenSCAD 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.")
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
result = route(args)
|
|
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())
|