Files
cadSet/text-to-cad/skills/cad-router/scripts/route.py
T
2026-07-27 17:40:16 +08:00

684 lines
27 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")
@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", "安装板",
),
"replay": (
"replay", "重放", "operation graph", "model json", "模型图", "semantic tag",
"语义标签",
),
"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 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]]:
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}",
},
}
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,
) -> 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()
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,
)
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"]),
}
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["replay"]):
scores["simplecadapi"].add(60, "replay or semantic graph requirement")
if edit_context:
scores["build123d"].add(55, "existing model inspection or source modification")
if args.assembly:
scores["build123d"].add(15, "source-level assembly workflow")
scores["simplecadapi"].add(18, "mechanism and constraint workflow")
scores["build123d"].add(35, "editable STEP is the fixed artifact contract")
scores["simplecadapi"].add(
30, "OCP STEP export satisfies the fixed artifact contract"
)
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))
registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
backend_info = registry["backends"][selected.name]
source_of_truth = "per-part DesignIR 2.0 with an isolated backend compiler"
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,
"artifact_contract": {
"primary_format": "step",
"editable_contract": "DesignIR 2.0",
},
"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.",
"Instantiate matched reconstruction grammars as DesignIR parameters, datums, constraints, and canonical feature stages; never copy teacher geometry.",
"Translate requested feature roles and matched generalized methods into backend operations.",
"Generate the editable source of truth and STEP artifact.",
"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",
},
"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)}")
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.")
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())