398 lines
16 KiB
Python
398 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
ROUTE_PATH = Path(__file__).resolve().parents[4] / "skills" / "cad-router" / "scripts" / "route.py"
|
|
SPEC = importlib.util.spec_from_file_location("cad_router_route", ROUTE_PATH)
|
|
assert SPEC is not None and SPEC.loader is not None
|
|
route_module = importlib.util.module_from_spec(SPEC)
|
|
sys.modules[SPEC.name] = route_module
|
|
SPEC.loader.exec_module(route_module)
|
|
|
|
|
|
class CadRouterTests(unittest.TestCase):
|
|
def parse(self, *args: str):
|
|
return route_module.build_parser().parse_args(list(args))
|
|
|
|
def test_routes_gear_to_simplecadapi(self) -> None:
|
|
result = route_module.route(self.parse("生成一个模数 2 的人字齿轮"))
|
|
self.assertEqual("simplecadapi", result["selected_backend"])
|
|
|
|
def test_routes_connecting_rod_to_build123d(self) -> None:
|
|
result = route_module.route(self.parse("生成中心距 120mm 的发动机连杆"))
|
|
self.assertEqual("build123d", result["selected_backend"])
|
|
|
|
def test_existing_step_stays_step_first(self) -> None:
|
|
result = route_module.route(self.parse("修改现有零件的孔径", "--existing-model"))
|
|
self.assertEqual("build123d", result["selected_backend"])
|
|
|
|
def test_every_route_has_fixed_step_artifact_contract(self) -> None:
|
|
result = route_module.route(self.parse("做一个参数化旋钮"))
|
|
self.assertEqual(
|
|
{
|
|
"primary_format": "step",
|
|
"editable_contract": "DesignIR 2.0",
|
|
},
|
|
result["artifact_contract"],
|
|
)
|
|
|
|
def test_every_route_reports_only_runtime_project_roles(self) -> None:
|
|
result = route_module.route(self.parse("生成一个 40mm 法兰"))
|
|
self.assertEqual(
|
|
{"text-to-cad", "SimpleCADAPI"},
|
|
set(result["project_contributions"]),
|
|
)
|
|
|
|
def test_explicit_override_is_respected(self) -> None:
|
|
result = route_module.route(self.parse("生成一个齿轮", "--backend", "build123d"))
|
|
self.assertEqual("build123d", result["selected_backend"])
|
|
selected = next(item for item in result["backend_scores"] if item["backend"] == "build123d")
|
|
self.assertIn("explicit backend override", selected["reasons"])
|
|
|
|
def test_generalized_experience_is_loaded_without_case_geometry(self) -> None:
|
|
payload = {
|
|
"schema_version": "2.0",
|
|
"library_kind": "generalized_cad_experience",
|
|
"policy": {
|
|
"instance_parameters_allowed": False,
|
|
"absolute_coordinates_allowed": False,
|
|
"single_case_promotion_allowed": False,
|
|
"llm_semantic_review_required": True,
|
|
"draft_evidence_verified": True,
|
|
"router_consumable": True,
|
|
},
|
|
"experiences": [
|
|
{
|
|
"id": "constraint.coaxial_stack",
|
|
"kind": "constraint",
|
|
"scope": ["flanged_hub_adapter"],
|
|
"when": {"relation": "coaxial_stack"},
|
|
"guidance": "Preserve the semantic relationship.",
|
|
"support": 40,
|
|
"confidence": 0.95,
|
|
}
|
|
],
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
library = Path(directory) / "library.json"
|
|
library.write_text(json.dumps(payload), encoding="utf-8")
|
|
result = route_module.route(
|
|
self.parse(
|
|
"根据经验生成一个法兰轴套",
|
|
"--experience-library",
|
|
str(library),
|
|
"--experience-family",
|
|
"flanged_hub_adapter",
|
|
)
|
|
)
|
|
context = result["experience_context"]
|
|
self.assertEqual("flanged_hub_adapter", context["family"])
|
|
self.assertEqual(1, len(context["experiences"]))
|
|
self.assertFalse(context["policy"]["contains_instance_parameters"])
|
|
|
|
def test_case_parameters_are_rejected_as_experience(self) -> None:
|
|
payload = {
|
|
"schema_version": "2.0",
|
|
"library_kind": "generalized_cad_experience",
|
|
"policy": {
|
|
"instance_parameters_allowed": False,
|
|
"absolute_coordinates_allowed": False,
|
|
"single_case_promotion_allowed": False,
|
|
"llm_semantic_review_required": True,
|
|
"draft_evidence_verified": True,
|
|
"router_consumable": True,
|
|
},
|
|
"parameters": {"base_outer_diameter": 91.0},
|
|
"experiences": [],
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
library = Path(directory) / "library.json"
|
|
library.write_text(json.dumps(payload), encoding="utf-8")
|
|
with self.assertRaisesRegex(ValueError, "forbidden instance key"):
|
|
route_module.load_experience_library(library)
|
|
|
|
def test_candidate_experience_is_visible_but_not_consumed(self) -> None:
|
|
payload = {
|
|
"schema_version": "2.0",
|
|
"library_kind": "generalized_cad_experience",
|
|
"policy": {
|
|
"instance_parameters_allowed": False,
|
|
"absolute_coordinates_allowed": False,
|
|
"single_case_promotion_allowed": False,
|
|
"llm_semantic_review_required": True,
|
|
"draft_evidence_verified": True,
|
|
"router_consumable": True,
|
|
},
|
|
"experiences": [],
|
|
"candidate_experiences": [
|
|
{
|
|
"id": "motif.flange.base_flange+hollow_sleeve",
|
|
"kind": "feature_motif",
|
|
"scope": ["flange"],
|
|
"when": {"features": ["base_flange", "hollow_sleeve"]},
|
|
"guidance": "Candidate only.",
|
|
"support": 1,
|
|
"confidence": 1.0,
|
|
"promotion_state": "candidate",
|
|
}
|
|
],
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
library = Path(directory) / "library.json"
|
|
library.write_text(json.dumps(payload), encoding="utf-8")
|
|
context = route_module.load_experience_library(
|
|
library, "flange", ["base_flange", "hollow_sleeve"]
|
|
)
|
|
self.assertEqual([], context["experiences"])
|
|
|
|
def test_unreviewed_statistical_library_is_rejected(self) -> None:
|
|
payload = {
|
|
"schema_version": "2.0",
|
|
"library_kind": "generalized_cad_experience",
|
|
"policy": {
|
|
"instance_parameters_allowed": False,
|
|
"absolute_coordinates_allowed": False,
|
|
"single_case_promotion_allowed": False,
|
|
},
|
|
"experiences": [],
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
library = Path(directory) / "library.json"
|
|
library.write_text(json.dumps(payload), encoding="utf-8")
|
|
with self.assertRaisesRegex(ValueError, "LLM semantic review"):
|
|
route_module.load_experience_library(library)
|
|
|
|
def test_request_automatically_builds_experience_query_and_design_plan(self) -> None:
|
|
payload = {
|
|
"schema_version": "2.0",
|
|
"library_kind": "generalized_cad_experience",
|
|
"policy": {
|
|
"instance_parameters_allowed": False,
|
|
"absolute_coordinates_allowed": False,
|
|
"single_case_promotion_allowed": False,
|
|
"llm_semantic_review_required": True,
|
|
"draft_evidence_verified": True,
|
|
"router_consumable": True,
|
|
},
|
|
"experiences": [
|
|
{
|
|
"id": "motif.base_flange+hollow_sleeve",
|
|
"kind": "feature_motif",
|
|
"scope": ["flanged_hub_adapter"],
|
|
"when": {"features": ["base_flange", "hollow_sleeve"]},
|
|
"guidance": "Treat these feature roles as a reusable composition.",
|
|
"support": 24,
|
|
"confidence": 0.9,
|
|
}
|
|
],
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
library = Path(directory) / "library.json"
|
|
library.write_text(json.dumps(payload), encoding="utf-8")
|
|
result = route_module.route(
|
|
self.parse(
|
|
"生成一个带法兰轴套和中心通孔",
|
|
"--experience-library",
|
|
str(library),
|
|
)
|
|
)
|
|
context = result["experience_context"]
|
|
plan = result["design_plan"]
|
|
self.assertEqual("flanged_hub_adapter", context["family"])
|
|
self.assertIn("base_flange", context["requested_features"])
|
|
self.assertIn("hollow_sleeve", context["requested_features"])
|
|
self.assertEqual(1, len(context["experiences"]))
|
|
self.assertEqual("matched", plan["experience_status"])
|
|
self.assertEqual(
|
|
"motif.base_flange+hollow_sleeve",
|
|
plan["generalized_methods"][0]["id"],
|
|
)
|
|
|
|
def test_design_plan_exposes_promoted_reconstruction_grammar(self) -> None:
|
|
payload = {
|
|
"schema_version": "2.0",
|
|
"library_kind": "generalized_cad_experience",
|
|
"policy": {
|
|
"instance_parameters_allowed": False,
|
|
"absolute_coordinates_allowed": False,
|
|
"single_case_promotion_allowed": False,
|
|
"llm_semantic_review_required": True,
|
|
"draft_evidence_verified": True,
|
|
"router_consumable": True,
|
|
},
|
|
"experiences": [
|
|
{
|
|
"id": "reconstruction.flanged_hub_adapter.primary",
|
|
"kind": "reconstruction_grammar",
|
|
"scope": ["flanged_hub_adapter"],
|
|
"when": {"features": ["base_flange", "hollow_sleeve"]},
|
|
"guidance": "Establish shared datums before composing the envelope.",
|
|
"reconstruction_grammar": {
|
|
"parameter_roles": ["overall_long_span"],
|
|
"datum_roles": ["part_center", "primary_axis"],
|
|
"feature_roles": ["base_flange", "hollow_sleeve"],
|
|
"relation_roles": [],
|
|
"canonical_stages": [
|
|
{
|
|
"id": "establish_reference_frame",
|
|
"operation": "define_datums",
|
|
"feature_roles": [],
|
|
"reference_roles": ["part_center", "primary_axis"],
|
|
}
|
|
],
|
|
"cardinality_classes": ["cylinder_dense"],
|
|
"validation_roles": ["closed_solid"],
|
|
},
|
|
"support": 30,
|
|
"confidence": 0.9,
|
|
}
|
|
],
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
library = Path(directory) / "library.json"
|
|
library.write_text(json.dumps(payload), encoding="utf-8")
|
|
result = route_module.route(
|
|
self.parse(
|
|
"生成一个带法兰轴套",
|
|
"--experience-library",
|
|
str(library),
|
|
)
|
|
)
|
|
grammars = result["design_plan"]["reconstruction_grammars"]
|
|
self.assertEqual(1, len(grammars))
|
|
self.assertEqual(["overall_long_span"], grammars[0]["parameter_roles"])
|
|
|
|
def test_request_family_matches_geometry_facing_distilled_scope(self) -> None:
|
|
payload = {
|
|
"schema_version": "2.0",
|
|
"library_kind": "generalized_cad_experience",
|
|
"policy": {
|
|
"instance_parameters_allowed": False,
|
|
"absolute_coordinates_allowed": False,
|
|
"single_case_promotion_allowed": False,
|
|
"llm_semantic_review_required": True,
|
|
"draft_evidence_verified": True,
|
|
"router_consumable": True,
|
|
},
|
|
"experiences": [
|
|
{
|
|
"id": "motif.patterned_plate.holes",
|
|
"kind": "feature_motif",
|
|
"scope": ["patterned_plate"],
|
|
"when": {
|
|
"features": [
|
|
"planar_dominant_body",
|
|
"repeated_axial_hole_pattern",
|
|
]
|
|
},
|
|
"guidance": "Build the plate first, then place holes from symmetry.",
|
|
"support": 40,
|
|
"confidence": 0.94,
|
|
}
|
|
],
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
library = Path(directory) / "library.json"
|
|
library.write_text(json.dumps(payload), encoding="utf-8")
|
|
result = route_module.route(
|
|
self.parse(
|
|
"Generate a mounting plate with four corner holes",
|
|
"--experience-library",
|
|
str(library),
|
|
)
|
|
)
|
|
context = result["experience_context"]
|
|
self.assertEqual("plate", context["family"])
|
|
self.assertIn("patterned_plate", context["compatible_scopes"])
|
|
self.assertIn("planar_dominant_body", context["requested_features"])
|
|
self.assertIn(
|
|
"repeated_axial_hole_pattern", context["requested_features"]
|
|
)
|
|
self.assertEqual(
|
|
["motif.patterned_plate.holes"],
|
|
[item["id"] for item in context["experiences"]],
|
|
)
|
|
|
|
def test_step_edit_uses_private_evidence_and_isolated_designir(self) -> None:
|
|
result = route_module.route(
|
|
self.parse(
|
|
"把中心孔径调整大一些",
|
|
"--edit-source",
|
|
"/tmp/source.step",
|
|
)
|
|
)
|
|
plan = result["design_plan"]
|
|
self.assertEqual("modify", plan["operation"])
|
|
self.assertEqual(
|
|
"private_evidence_to_isolated_designir_reconstruction",
|
|
plan["edit_context"]["modification_mode"],
|
|
)
|
|
forbidden = plan["edit_context"]["forbidden_knowledge_sources"]
|
|
self.assertIn("teacher STEP during reconstruction", forbidden)
|
|
self.assertIn(
|
|
"deterministically extracted private teacher evidence",
|
|
plan["edit_context"]["allowed_knowledge_sources"],
|
|
)
|
|
self.assertEqual(
|
|
"pending_source_classification", plan["experience_status"]
|
|
)
|
|
self.assertTrue(
|
|
plan["experience_query_plan"]["requires_source_inspection"]
|
|
)
|
|
|
|
def test_native_generator_is_preferred_for_modification(self) -> None:
|
|
result = route_module.route(
|
|
self.parse(
|
|
"把孔径改为 12mm",
|
|
"--edit-source",
|
|
"/tmp/model.py",
|
|
)
|
|
)
|
|
self.assertEqual(
|
|
"native_parameter_edit",
|
|
result["design_plan"]["edit_context"]["modification_mode"],
|
|
)
|
|
|
|
def test_teacher_vault_is_rejected_as_reconstruction_source(self) -> None:
|
|
teacher_source = (
|
|
Path(__file__).resolve().parents[5]
|
|
/ "designir-pipeline"
|
|
/ "input"
|
|
/ "part.step"
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "cannot read distillation input"):
|
|
route_module.route(
|
|
self.parse(
|
|
"修改这个零件",
|
|
"--edit-source",
|
|
str(teacher_source),
|
|
)
|
|
)
|
|
|
|
def test_private_run_artifact_is_rejected_as_edit_source(self) -> None:
|
|
run_source = (
|
|
Path(__file__).resolve().parents[5]
|
|
/ "designir-pipeline"
|
|
/ "runs"
|
|
/ "designir"
|
|
/ "part.designir.json"
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "cannot read distillation input"):
|
|
route_module.route(
|
|
self.parse(
|
|
"修改这个零件",
|
|
"--edit-source",
|
|
str(run_source),
|
|
)
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|