优化生成流程
This commit is contained in:
@@ -9,10 +9,12 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.models.contracts import ChatMessage, MessagePart
|
||||
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, normalize_image_analysis, parse_tool_arguments, response_language_instruction, tools_for_model, use_flange_sleeve_template, user_visible_error_message
|
||||
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message
|
||||
from app.services.engine_service import load_engine
|
||||
from app.services.quality import QUALITY_RULE_TYPES
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.storage import WorkspaceStore
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings, get_settings
|
||||
|
||||
|
||||
class ToolChoiceCompatibilityTests(unittest.TestCase):
|
||||
@@ -107,16 +109,31 @@ class ParseToolArgumentsTests(unittest.TestCase):
|
||||
self.assertNotIn("extrude", cdsl["$defs"]["feature_atomic_ids"]["enum"])
|
||||
self.assertEqual(cdsl, CDSL_TOOL_SCHEMA)
|
||||
|
||||
def test_generation_schema_exposes_only_runtime_atomic_ids(self) -> None:
|
||||
settings = get_settings()
|
||||
engine = load_engine(settings)
|
||||
self.assertEqual(
|
||||
set(CDSL_TOOL_SCHEMA["$defs"]["feature_atomic_ids"]["enum"]),
|
||||
set(engine.SUPPORTED_ATOMIC_IDS),
|
||||
)
|
||||
|
||||
def test_verification_schema_exposes_only_implemented_rule_types(self) -> None:
|
||||
generate_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "generate_cdsl_model")
|
||||
verification = generate_tool["function"]["parameters"]["properties"]["verification"]
|
||||
rule_type = verification["properties"]["rules"]["items"]["properties"]["type"]
|
||||
self.assertEqual(set(rule_type["enum"]), set(QUALITY_RULE_TYPES))
|
||||
|
||||
def test_strict_tool_schema_covers_generation_arguments(self) -> None:
|
||||
tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True))
|
||||
strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")]
|
||||
|
||||
self.assertEqual(strict_tools, ["generate_flange_sleeve_model", "generate_cdsl_model"])
|
||||
template_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_flange_sleeve_model")
|
||||
self.assertEqual(template_tool["function"]["parameters"]["properties"]["plan"]["required"], ["template"])
|
||||
self.assertEqual(strict_tools, ["generate_cdsl_model", "patch_cdsl_model"])
|
||||
generate_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_cdsl_model")
|
||||
self.assertEqual(generate_tool["function"]["parameters"]["properties"]["summary"], {"type": "string", "minLength": 1})
|
||||
self.assertEqual(generate_tool["function"]["parameters"]["properties"]["cdsl"], CDSL_TOOL_SCHEMA)
|
||||
self.assertIn("verification", generate_tool["function"]["parameters"]["properties"])
|
||||
patch_tool = next(tool for tool in tools if tool["function"]["name"] == "patch_cdsl_model")
|
||||
self.assertIn("base_revision_id", patch_tool["function"]["parameters"]["required"])
|
||||
self.assertFalse(generate_tool["function"]["parameters"]["additionalProperties"])
|
||||
|
||||
def test_default_model_does_not_receive_strict_tool_schema(self) -> None:
|
||||
@@ -124,21 +141,14 @@ class ParseToolArgumentsTests(unittest.TestCase):
|
||||
|
||||
self.assertFalse(any(tool["function"].get("strict") for tool in tools))
|
||||
|
||||
def test_flange_sleeve_image_route_exposes_only_the_compact_plan_generator(self) -> None:
|
||||
analysis = {
|
||||
"partType": "带方形法兰的圆筒管座",
|
||||
"visibleFeatures": ["同轴中空管", "四个安装孔"],
|
||||
}
|
||||
|
||||
self.assertTrue(use_flange_sleeve_template(analysis, ""))
|
||||
self.assertFalse(use_flange_sleeve_template(analysis, "cad_existing"))
|
||||
tools = tools_for_model(ProviderModel("default-model"), flange_sleeve_only=True)
|
||||
names = [tool["function"]["name"] for tool in tools]
|
||||
self.assertIn("generate_flange_sleeve_model", names)
|
||||
self.assertNotIn("search_cdsl_library", names)
|
||||
self.assertNotIn("read_cdsl_reference", names)
|
||||
self.assertNotIn("read_current_cdsl", names)
|
||||
self.assertNotIn("generate_cdsl_model", names)
|
||||
def test_direct_cdsl_tools_exclude_spec_and_template_generators(self) -> None:
|
||||
names = [tool["function"]["name"] for tool in tools_for_model(ProviderModel("default-model"))]
|
||||
self.assertIn("generate_cdsl_model", names)
|
||||
self.assertIn("patch_cdsl_model", names)
|
||||
self.assertNotIn("create_generation_spec", names)
|
||||
self.assertNotIn("author_cdsl_from_generation_spec", names)
|
||||
self.assertNotIn("patch_generation_spec", names)
|
||||
self.assertNotIn("generate_flange_sleeve_model", names)
|
||||
|
||||
def test_recorded_image_analysis_is_not_exposed_as_a_tool(self) -> None:
|
||||
tools = tools_for_model(ProviderModel("vision-model", vision=True), include_image_analysis=False)
|
||||
@@ -385,15 +395,15 @@ class ToolArgumentsRetryTests(unittest.TestCase):
|
||||
diagnostics = settings.conversation_root / conversation_id / "diagnostics"
|
||||
attempts = sorted(diagnostics.glob("cdsl_attempt_*.json"))
|
||||
failures = sorted(diagnostics.glob("cdsl_validation_*.json"))
|
||||
self.assertEqual(len(attempts), 7)
|
||||
self.assertEqual(len(failures), 7)
|
||||
self.assertEqual(len(attempts), 5)
|
||||
self.assertEqual(len(failures), 5)
|
||||
self.assertTrue(all(json.loads(path.read_text(encoding="utf-8")) == {} for path in attempts))
|
||||
|
||||
records = sorted(
|
||||
(json.loads(path.read_text(encoding="utf-8")) for path in failures),
|
||||
key=lambda record: int(record["iteration"]),
|
||||
)
|
||||
self.assertEqual([record["iteration"] for record in records], list(range(2, 9)))
|
||||
self.assertEqual([record["iteration"] for record in records], list(range(2, 7)))
|
||||
self.assertTrue(all(record["kind"] == "cdsl_validation_failure" for record in records))
|
||||
self.assertTrue(all(record["validation_error_type"] == "ValueError" for record in records))
|
||||
self.assertTrue(all(record["validation_error"] for record in records))
|
||||
@@ -402,7 +412,7 @@ class ToolArgumentsRetryTests(unittest.TestCase):
|
||||
{path.name for path in attempts},
|
||||
)
|
||||
self.assertTrue(any("每次 CDSL 校验失败的诊断已保存到" in str(event.get("message", "")) for event in events))
|
||||
self.assertEqual(agent.responses, [])
|
||||
self.assertEqual(len(agent.responses), 2)
|
||||
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
|
||||
|
||||
|
||||
@@ -822,5 +832,6 @@ class StructuredResultResponseTests(unittest.TestCase):
|
||||
self.assertEqual(json.loads(attempts[0].read_text(encoding="utf-8")), {})
|
||||
self.assertEqual(list(diagnostics.glob("cdsl_validation_*.json")), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
|
||||
|
||||
def workplane(z: float = 0.0) -> dict[str, list[float]]:
|
||||
return {
|
||||
"origin_mm": [0.0, 0.0, z],
|
||||
"x_dir": [1.0, 0.0, 0.0],
|
||||
"y_dir": [0.0, 1.0, 0.0],
|
||||
"normal": [0.0, 0.0, 1.0],
|
||||
}
|
||||
|
||||
|
||||
def mounting_plate_intent() -> dict:
|
||||
return {
|
||||
"schema": "cad.cdsl.design-intent.v1",
|
||||
"schema_version": "1.0",
|
||||
"mode": "create",
|
||||
"request": "Create a mounting plate with four holes and a center slot",
|
||||
"base_revision_id": "",
|
||||
"structures": [
|
||||
{
|
||||
"id": "base_plate",
|
||||
"cdsl_feature_id": "base_add",
|
||||
"role": "base",
|
||||
"purpose": "Rectangular mounting plate",
|
||||
"depends_on": [],
|
||||
"cdsl_strategy": {
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"profile_type": "rectangle",
|
||||
"parameter_roles": ["width_mm", "height_mm", "thickness_mm"],
|
||||
"selector_roles": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "mounting_holes",
|
||||
"cdsl_feature_id": "hole_cut",
|
||||
"role": "subtractive",
|
||||
"purpose": "Four mounting holes",
|
||||
"depends_on": ["base_plate"],
|
||||
"cdsl_strategy": {
|
||||
"atomic_id": "extrude_cut_blind",
|
||||
"profile_type": "circle_grid",
|
||||
"parameter_roles": ["diameter_mm", "count_x", "count_y"],
|
||||
"selector_roles": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "adjustment_slot",
|
||||
"cdsl_feature_id": "slot_cut",
|
||||
"role": "subtractive",
|
||||
"purpose": "Center adjustment slot",
|
||||
"depends_on": ["mounting_holes"],
|
||||
"cdsl_strategy": {
|
||||
"atomic_id": "extrude_cut_blind",
|
||||
"profile_type": "obround",
|
||||
"parameter_roles": ["length_mm", "width_mm", "depth_mm"],
|
||||
"selector_roles": [],
|
||||
},
|
||||
},
|
||||
],
|
||||
"feature_order": ["base_plate", "mounting_holes", "adjustment_slot"],
|
||||
"assumptions": [],
|
||||
"open_questions": [],
|
||||
"capability_gaps": [],
|
||||
"verification_expectations": [
|
||||
{"type": "feature_count", "feature_id": "mounting_holes", "expected": 4},
|
||||
{"type": "symmetry", "axis": "x"},
|
||||
{"type": "bbox", "expected_mm": {"x": 100, "y": 60, "z": 10}},
|
||||
],
|
||||
"status": "ready",
|
||||
}
|
||||
|
||||
|
||||
def mounting_plate_cdsl() -> dict:
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.0",
|
||||
"kind": "part",
|
||||
"part_id": "mounting-plate",
|
||||
"geometry": {
|
||||
"sketches": [
|
||||
{"id": "base_sketch", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}},
|
||||
{"id": "holes_sketch", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}},
|
||||
{"id": "slot_sketch", "workplane": workplane(10), "profile": {"type": "obround", "center": [0, 0], "length_mm": 32, "width_mm": 10}},
|
||||
],
|
||||
},
|
||||
"features": [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}},
|
||||
{"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}},
|
||||
{"id": "slot_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["hole_cut"], "sketch_id": "slot_sketch", "params": {"distance_mm": 10, "reverse": True}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class DesignIntentValidationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
sys.path.insert(0, str(ROOT / "backend" / "engine"))
|
||||
import cdsl_engine
|
||||
|
||||
cls.engine = cdsl_engine
|
||||
|
||||
def assert_plan_error(self, intent: dict, code: str = "INVALID_DESIGN_INTENT") -> None:
|
||||
with self.assertRaises(self.engine.DesignIntentError) as context:
|
||||
self.engine.validate_design_intent(intent, self.engine)
|
||||
self.assertEqual(context.exception.code, code)
|
||||
|
||||
def test_valid_mounting_plate_plan_and_cdsl_match(self) -> None:
|
||||
intent = mounting_plate_intent()
|
||||
validated = self.engine.validate_design_intent(intent, self.engine)
|
||||
|
||||
self.assertEqual(validated["feature_order"], ["base_plate", "mounting_holes", "adjustment_slot"])
|
||||
self.engine.validate_intent_cdsl(validated, mounting_plate_cdsl(), self.engine)
|
||||
|
||||
def test_revise_requires_current_base_revision(self) -> None:
|
||||
intent = mounting_plate_intent()
|
||||
intent["mode"] = "revise"
|
||||
intent["base_revision_id"] = ""
|
||||
self.assert_plan_error(intent)
|
||||
|
||||
intent["base_revision_id"] = "rev_001"
|
||||
self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_001")
|
||||
with self.assertRaises(self.engine.DesignIntentError) as context:
|
||||
self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_002")
|
||||
self.assertEqual(context.exception.code, "DESIGN_INTENT_BASE_REVISION_MISMATCH")
|
||||
self.assertIn("rev_002", str(context.exception))
|
||||
|
||||
def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None:
|
||||
duplicate = mounting_plate_intent()
|
||||
duplicate["structures"][1]["id"] = "base_plate"
|
||||
self.assert_plan_error(duplicate)
|
||||
|
||||
cyclic = mounting_plate_intent()
|
||||
cyclic["structures"][0]["depends_on"] = ["mounting_holes"]
|
||||
self.assert_plan_error(cyclic)
|
||||
|
||||
unsupported = mounting_plate_intent()
|
||||
unsupported["structures"][0]["cdsl_strategy"]["atomic_id"] = "thread_add"
|
||||
self.assert_plan_error(unsupported)
|
||||
|
||||
incomplete = mounting_plate_intent()
|
||||
incomplete["feature_order"].pop()
|
||||
self.assert_plan_error(incomplete)
|
||||
|
||||
no_profile = mounting_plate_intent()
|
||||
no_profile["structures"][0]["cdsl_strategy"].pop("profile_type")
|
||||
self.assert_plan_error(no_profile)
|
||||
|
||||
unknown_expectation = mounting_plate_intent()
|
||||
unknown_expectation["verification_expectations"][0]["feature_id"] = "missing"
|
||||
self.assert_plan_error(unknown_expectation)
|
||||
|
||||
def test_blockers_and_nonblocking_approximations_are_explicit(self) -> None:
|
||||
clarification = mounting_plate_intent()
|
||||
clarification["open_questions"] = [{"id": "thickness", "question": "What thickness is required?", "blocking": True}]
|
||||
clarification["status"] = "needs_clarification"
|
||||
self.engine.validate_design_intent(clarification, self.engine)
|
||||
with self.assertRaises(self.engine.DesignIntentError) as context:
|
||||
self.engine.validate_intent_cdsl(clarification, mounting_plate_cdsl(), self.engine)
|
||||
self.assertEqual(context.exception.code, "DESIGN_INTENT_BLOCKED")
|
||||
|
||||
approximation = mounting_plate_intent()
|
||||
approximation["capability_gaps"] = [{
|
||||
"code": "unsupported_thread_geometry",
|
||||
"structure_id": "mounting_holes",
|
||||
"message": "Only a cylindrical bore is available.",
|
||||
"blocking": False,
|
||||
}]
|
||||
self.assert_plan_error(approximation)
|
||||
approximation["assumptions"] = ["Thread geometry is approximated by cylindrical bores."]
|
||||
self.engine.validate_design_intent(approximation, self.engine)
|
||||
|
||||
def test_rejects_cdsl_feature_atomic_profile_and_selector_mismatches(self) -> None:
|
||||
missing_feature = mounting_plate_cdsl()
|
||||
missing_feature["features"].pop()
|
||||
with self.assertRaises(self.engine.DesignIntentError) as context:
|
||||
self.engine.validate_intent_cdsl(mounting_plate_intent(), missing_feature, self.engine)
|
||||
self.assertEqual(context.exception.code, "INTENT_CDSL_MISMATCH")
|
||||
|
||||
atomic = mounting_plate_cdsl()
|
||||
atomic["features"][1]["atomic_id"] = "extrude_add_blind"
|
||||
with self.assertRaises(self.engine.DesignIntentError):
|
||||
self.engine.validate_intent_cdsl(mounting_plate_intent(), atomic, self.engine)
|
||||
|
||||
profile = mounting_plate_cdsl()
|
||||
profile["geometry"]["sketches"][1]["profile"]["type"] = "circles"
|
||||
with self.assertRaises(self.engine.DesignIntentError):
|
||||
self.engine.validate_intent_cdsl(mounting_plate_intent(), profile, self.engine)
|
||||
|
||||
selector_plan = copy.deepcopy(mounting_plate_intent())
|
||||
selector_plan["structures"][2]["cdsl_strategy"]["selector_roles"] = ["host_face"]
|
||||
with self.assertRaises(self.engine.DesignIntentError):
|
||||
self.engine.validate_intent_cdsl(selector_plan, mounting_plate_cdsl(), self.engine)
|
||||
@@ -14,20 +14,42 @@ sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.models.contracts import ChatMessage, MessagePart # noqa: E402
|
||||
from app.services.agent_service import AgentService # noqa: E402
|
||||
from app.services.engine_service import load_engine # noqa: E402
|
||||
from app.services.flange_sleeve_template import TEMPLATE_ID # noqa: E402
|
||||
from app.services.library import CdslLibrary # noqa: E402
|
||||
from app.services.part_skills import PartSkillLibrary # noqa: E402
|
||||
from app.services.storage import WorkspaceStore # noqa: E402
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
|
||||
from tests.test_design_intent import mounting_plate_cdsl # noqa: E402
|
||||
|
||||
|
||||
BACKEND = ROOT / "backend"
|
||||
PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills"
|
||||
|
||||
|
||||
class DesignIntentFlowTests(unittest.TestCase):
|
||||
def _workplane(z: float = 0.0) -> dict[str, list[float]]:
|
||||
return {"origin_mm": [0.0, 0.0, z], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]}
|
||||
|
||||
|
||||
def mounting_plate_cdsl() -> dict:
|
||||
holes = [
|
||||
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": center, "radius_mm": 3}]}
|
||||
for center in [[-40, -20], [40, -20], [-40, 20], [40, 20]]
|
||||
]
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.0",
|
||||
"kind": "part",
|
||||
"part_id": "mounting-plate",
|
||||
"geometry": {"sketches": [
|
||||
{"id": "base_sketch", "workplane": _workplane(), "profile": {"type": "polygon", "vertices": [[-50, -30], [50, -30], [50, 30], [-50, 30]]}},
|
||||
{"id": "holes_sketch", "workplane": _workplane(10), "profile": {"type": "analytic_contours", "contours": holes}},
|
||||
]},
|
||||
"features": [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}},
|
||||
{"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class DirectCdslFlowTests(unittest.TestCase):
|
||||
def settings(self, root: Path) -> Settings:
|
||||
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
|
||||
return Settings(
|
||||
@@ -48,7 +70,7 @@ class DesignIntentFlowTests(unittest.TestCase):
|
||||
settings = self.settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
|
||||
state = {"phase": "INTAKE", "design_brief": ""}
|
||||
searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], planning_state=state))
|
||||
generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], planning_state=state))
|
||||
|
||||
@@ -77,7 +99,7 @@ class DesignIntentFlowTests(unittest.TestCase):
|
||||
settings = self.settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange base."}
|
||||
state = {"phase": "PLANNED", "design_brief": "Create a flange base."}
|
||||
captured_build: dict[str, object] = {}
|
||||
|
||||
def fake_build_revision(**kwargs: object) -> dict[str, object]:
|
||||
@@ -99,45 +121,6 @@ class DesignIntentFlowTests(unittest.TestCase):
|
||||
self.assertIn("workplane", built_cdsl["geometry"]["sketches"][0])
|
||||
self.assertEqual(len(result["normalization_repairs"]), 3)
|
||||
|
||||
def test_flange_sleeve_tool_builds_cdsl_from_a_compact_semantic_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = self.settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange sleeve."}
|
||||
captured_build: dict[str, object] = {}
|
||||
|
||||
def fake_build_revision(**kwargs: object) -> dict[str, object]:
|
||||
captured_build.update(kwargs)
|
||||
return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"}
|
||||
|
||||
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
result, _ = asyncio.run(agent._run_tool(
|
||||
"generate_flange_sleeve_model",
|
||||
{
|
||||
"plan": {
|
||||
"template": TEMPLATE_ID,
|
||||
"name": "Image flange sleeve",
|
||||
"flange_width_mm": 140,
|
||||
"flange_height_mm": 120,
|
||||
"mount_hole_u_mm": 48,
|
||||
"mount_hole_v_mm": 38,
|
||||
},
|
||||
"summary": "flange sleeve",
|
||||
"assumptions": ["Dimensions are approximate."],
|
||||
},
|
||||
"", "Create an image flange sleeve", [], planning_state=state,
|
||||
))
|
||||
|
||||
built_cdsl = captured_build["cdsl"]
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["template_id"], TEMPLATE_ID)
|
||||
self.assertEqual(captured_build["operation"]["template_id"], TEMPLATE_ID)
|
||||
self.assertEqual(built_cdsl["meta"]["name"], "Image flange sleeve")
|
||||
self.assertEqual(built_cdsl["geometry"]["sketches"][0]["workplane"]["normal"], [1.0, 0.0, 0.0])
|
||||
self.assertEqual(built_cdsl["features"][-1]["depends_on"], ["mount_hole_cuts"])
|
||||
self.assertNotIn("cdsl", result["template_plan"])
|
||||
|
||||
def test_successful_generation_ends_the_agent_tool_loop(self) -> None:
|
||||
class CaptureAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
@@ -148,7 +131,7 @@ class DesignIntentFlowTests(unittest.TestCase):
|
||||
"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "brief", "type": "function", "function": {
|
||||
"name": "describe_design_intent",
|
||||
"arguments": json.dumps({"plan": "Create a flange sleeve.", "assumptions": []}),
|
||||
"arguments": json.dumps({"plan": "Create a cylindrical part.", "assumptions": []}),
|
||||
},
|
||||
}],
|
||||
}}],
|
||||
@@ -156,11 +139,11 @@ class DesignIntentFlowTests(unittest.TestCase):
|
||||
{
|
||||
"choices": [{"message": {
|
||||
"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "template", "type": "function", "function": {
|
||||
"name": "generate_flange_sleeve_model",
|
||||
"id": "generate", "type": "function", "function": {
|
||||
"name": "generate_cdsl_model",
|
||||
"arguments": json.dumps({
|
||||
"plan": {"template": TEMPLATE_ID},
|
||||
"summary": "flange sleeve",
|
||||
"cdsl": mounting_plate_cdsl(),
|
||||
"summary": "mounting plate",
|
||||
"assumptions": [],
|
||||
}),
|
||||
},
|
||||
@@ -189,7 +172,7 @@ class DesignIntentFlowTests(unittest.TestCase):
|
||||
|
||||
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
async def consume() -> None:
|
||||
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a flange sleeve")])
|
||||
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")])
|
||||
async for _ in agent.stream([message], None, None):
|
||||
pass
|
||||
|
||||
@@ -270,8 +253,7 @@ class DesignIntentFlowTests(unittest.TestCase):
|
||||
"engine": "cdsl_only",
|
||||
}
|
||||
|
||||
engine = load_engine(settings)
|
||||
with patch.object(engine, "validate_intent_cdsl", side_effect=AssertionError("legacy intent validation must not run")) as legacy_validation, patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
async def consume() -> None:
|
||||
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")])
|
||||
async for _ in agent.stream([message], None, None):
|
||||
@@ -281,18 +263,16 @@ class DesignIntentFlowTests(unittest.TestCase):
|
||||
|
||||
brief_result = json.loads(str(agent.seen_messages[1][-1]["content"]))
|
||||
self.assertEqual(brief_result["plan"], "Create a rectangular mounting plate, then cut four mounting holes and a center slot.")
|
||||
self.assertNotIn("design_intent_id", brief_result)
|
||||
self.assertNotIn("design_intent_id", captured_build)
|
||||
self.assertNotIn("structures", brief_result)
|
||||
self.assertNotIn("design_intent", captured_build)
|
||||
self.assertEqual(captured_build["parent_revision_id"], "")
|
||||
legacy_validation.assert_not_called()
|
||||
|
||||
def test_revision_parent_is_taken_from_the_current_successful_cdsl_revision(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = self.settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
|
||||
state = {"phase": "INTAKE", "design_brief": "", "current_model_read": True}
|
||||
asyncio.run(agent._run_tool(
|
||||
"describe_design_intent",
|
||||
{"plan": "Increase the plate thickness and preserve the existing hole layout.", "assumptions": []},
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.services.agent_service import AgentService # noqa: E402
|
||||
from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch # noqa: E402
|
||||
from app.services.engine_service import QualityVerificationError, build_revision # noqa: E402
|
||||
from app.services.library import CdslLibrary # noqa: E402
|
||||
from app.services.part_skills import PartSkillLibrary # noqa: E402
|
||||
from app.services.quality import evaluate_quality, validate_verification # noqa: E402
|
||||
from app.services.storage import WorkspaceStore, write_json # noqa: E402
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
|
||||
|
||||
|
||||
BACKEND = ROOT / "backend"
|
||||
PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills"
|
||||
|
||||
|
||||
def workplane(z: float = 0.0) -> dict[str, list[float]]:
|
||||
return {
|
||||
"origin_mm": [0.0, 0.0, z],
|
||||
"x_dir": [1.0, 0.0, 0.0],
|
||||
"y_dir": [0.0, 1.0, 0.0],
|
||||
"normal": [0.0, 0.0, 1.0],
|
||||
}
|
||||
|
||||
|
||||
def fixture() -> dict:
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.0",
|
||||
"kind": "part",
|
||||
"part_id": "direct-cdsl-fixture",
|
||||
"geometry": {
|
||||
"sketches": [{
|
||||
"id": "base",
|
||||
"workplane": workplane(),
|
||||
"profile": {"type": "polygon", "vertices": [[-10, -5], [10, -5], [10, 5], [-10, 5]]},
|
||||
}],
|
||||
},
|
||||
"features": [{
|
||||
"id": "base_add",
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"depends_on": [],
|
||||
"sketch_id": "base",
|
||||
"params": {"distance_mm": 4},
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def settings(root: Path) -> Settings:
|
||||
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
|
||||
return Settings(
|
||||
task_root=root / "tasks",
|
||||
conversation_root=root / "conversations",
|
||||
library_root=BACKEND / "cdsl_library",
|
||||
engine_root=BACKEND / "engine" / "cdsl_engine",
|
||||
llm_base_url=provider.base_url,
|
||||
llm_api_key=provider.api_key,
|
||||
llm_model="test-model",
|
||||
llm_timeout_s=1,
|
||||
default_provider_id="test",
|
||||
providers=(provider,),
|
||||
)
|
||||
|
||||
|
||||
class PatchTests(unittest.TestCase):
|
||||
def test_rfc6902_patch_updates_cdsl_without_replacing_its_root(self) -> None:
|
||||
document = fixture()
|
||||
patched = apply_cdsl_patch(document, [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}])
|
||||
|
||||
self.assertEqual(patched["features"][0]["params"]["distance_mm"], 8)
|
||||
self.assertEqual(document["features"][0]["params"]["distance_mm"], 4)
|
||||
with self.assertRaisesRegex(CdslPatchError, "complete CDSL"):
|
||||
apply_cdsl_patch(document, [{"op": "replace", "path": "", "value": {}}])
|
||||
|
||||
def test_invalid_patch_path_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(CdslPatchError, "out of range"):
|
||||
apply_cdsl_patch(fixture(), [{"op": "replace", "path": "/features/3/id", "value": "x"}])
|
||||
|
||||
|
||||
class VerificationTests(unittest.TestCase):
|
||||
def test_bbox_accepts_dimension_vector(self) -> None:
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "size", "type": "bbox", "expected": [20, 10, 4]},
|
||||
]}, fixture())
|
||||
report = evaluate_quality(rules, fixture(), {
|
||||
"bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]},
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual(report["results"][0]["actual"]["dimensions"], [20.0, 10.0, 4.0])
|
||||
|
||||
def test_bbox_accepts_coordinate_ranges(self) -> None:
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "range", "type": "bbox", "expected": {
|
||||
"x_min": -10, "x_max": 10, "y_min": -5, "y_max": 5, "z_min": 0, "z_max": 4,
|
||||
}},
|
||||
]}, fixture())
|
||||
report = evaluate_quality(rules, fixture(), {
|
||||
"bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]},
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
|
||||
def test_feature_bbox_uses_owned_topology_records(self) -> None:
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "base_range", "type": "bbox", "feature": "base_add", "expected": [10, 8, 2]},
|
||||
]}, fixture())
|
||||
report = evaluate_quality(rules, fixture(), {
|
||||
"bbox_mm": {"min": [0, 0, 0], "max": [20, 20, 20]},
|
||||
"topology_records": [{
|
||||
"feature_id": "base_add", "owner_feature_ids": ["base_add"],
|
||||
"geometry": {"bbox_mm": [0, 0, 0, 10, 8, 2]},
|
||||
}],
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual(report["results"][0]["source"], "runtime.topology_records[base_add].bbox_mm")
|
||||
|
||||
def test_bbox_shape_is_rejected_before_execution(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "expected for bbox"):
|
||||
validate_verification({"rules": [
|
||||
{"id": "bad", "type": "bbox", "expected": {"width": 10}},
|
||||
]}, fixture())
|
||||
|
||||
def test_unknown_feature_reference_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "must reference a CDSL feature ID"):
|
||||
validate_verification({"rules": [{"id": "holes", "type": "hole_count", "feature": "missing", "expected": 4}]}, fixture())
|
||||
|
||||
def test_invalid_expected_value_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "expected for bbox"):
|
||||
validate_verification({"rules": [{"id": "size", "type": "bbox", "expected": [10, 5]}]}, fixture())
|
||||
|
||||
def test_warning_does_not_block_generic_quality(self) -> None:
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "solids", "type": "solid_count", "expected": 1, "severity": "blocking"},
|
||||
{"id": "length", "type": "overall_length", "expected": 999, "severity": "warning"},
|
||||
]}, fixture())
|
||||
report = evaluate_quality(rules, fixture(), {
|
||||
"solid_count": 1,
|
||||
"bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]},
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual(len(report["blocking_failures"]), 0)
|
||||
self.assertEqual(len(report["warnings"]), 1)
|
||||
|
||||
def test_blocking_failure_requires_repair(self) -> None:
|
||||
rules = validate_verification({"rules": [{"id": "solids", "type": "solid_count", "expected": 2}]}, fixture())
|
||||
report = evaluate_quality(rules, fixture(), {"solid_count": 1, "bbox_mm": {"min": [0, 0, 0], "max": [1, 1, 1]}})
|
||||
|
||||
self.assertEqual(report["status"], "failed")
|
||||
self.assertEqual(report["blocking_failures"][0]["id"], "solids")
|
||||
|
||||
|
||||
class AgentPatchFlowTests(unittest.TestCase):
|
||||
def _seed_revision(self, store: WorkspaceStore) -> tuple[str, str]:
|
||||
task = store.ensure_task(None, "Create a direct CDSL fixture")
|
||||
revision_id = "rev_001"
|
||||
revision_dir = store.task_dir(task["task_id"]) / "revisions" / revision_id
|
||||
revision_dir.mkdir(parents=True)
|
||||
write_json(revision_dir / "model.cdsl.json", fixture())
|
||||
store.update_task(task["task_id"], {
|
||||
"revision_id": revision_id,
|
||||
"status": "success",
|
||||
"cdsl_path": f"revisions/{revision_id}/model.cdsl.json",
|
||||
})
|
||||
return task["task_id"], revision_id
|
||||
|
||||
def test_patch_creates_a_child_revision_from_explicit_parent(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
task_id, parent = self._seed_revision(store)
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_build_revision(**kwargs: object) -> dict[str, object]:
|
||||
captured.update(kwargs)
|
||||
return {"task_id": task_id, "revision_id": "rev_002"}
|
||||
|
||||
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
result, _ = asyncio.run(agent._run_tool(
|
||||
"patch_cdsl_model",
|
||||
{
|
||||
"base_revision_id": parent,
|
||||
"patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}],
|
||||
"summary": "Increase thickness",
|
||||
"assumptions": [],
|
||||
},
|
||||
task_id,
|
||||
"Increase thickness",
|
||||
[],
|
||||
planning_state={"phase": "PLANNED", "current_model_read": True},
|
||||
))
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(captured["parent_revision_id"], parent)
|
||||
self.assertEqual(captured["operation"]["type"], "cdsl_patch")
|
||||
self.assertEqual(captured["cdsl"]["features"][0]["params"]["distance_mm"], 8)
|
||||
|
||||
def test_bad_patch_creates_no_revision(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
task_id, parent = self._seed_revision(store)
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
with self.assertRaisesRegex(ValueError, "INVALID_CDSL_PATCH"):
|
||||
asyncio.run(agent._run_tool(
|
||||
"patch_cdsl_model",
|
||||
{"base_revision_id": parent, "patches": [{"op": "replace", "path": "/missing", "value": 8}], "summary": "bad", "assumptions": []},
|
||||
task_id,
|
||||
"bad patch",
|
||||
[],
|
||||
planning_state={"phase": "PLANNED"},
|
||||
))
|
||||
|
||||
task = store.read_task(task_id)
|
||||
self.assertEqual([item["revision_id"] for item in task["revisions"]], [parent])
|
||||
|
||||
def test_complete_replacement_uses_current_revision_as_parent(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
task_id, parent = self._seed_revision(store)
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_build_revision(**kwargs: object) -> dict[str, object]:
|
||||
captured.update(kwargs)
|
||||
return {"task_id": task_id, "revision_id": "rev_002"}
|
||||
|
||||
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
result, _ = asyncio.run(agent._run_tool(
|
||||
"generate_cdsl_model",
|
||||
{"cdsl": fixture(), "summary": "Replacement", "assumptions": []},
|
||||
task_id,
|
||||
"replace geometry",
|
||||
[],
|
||||
planning_state={"phase": "PLANNED", "current_model_read": True},
|
||||
))
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(captured["parent_revision_id"], parent)
|
||||
self.assertEqual(captured["operation"]["type"], "cdsl_replacement")
|
||||
|
||||
def test_blocking_quality_failure_is_repairable_and_readable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
with self.assertRaises(QualityVerificationError) as context:
|
||||
build_revision(
|
||||
settings=config,
|
||||
store=store,
|
||||
task_id=None,
|
||||
request="Create fixture",
|
||||
cdsl=fixture(),
|
||||
reference_ids=[],
|
||||
summary="fixture",
|
||||
verification={"rules": [{"id": "wrong-solid-count", "type": "solid_count", "expected": 2}]},
|
||||
)
|
||||
|
||||
error = context.exception
|
||||
self.assertTrue(error.task_id)
|
||||
self.assertEqual(error.revision_id, "rev_001")
|
||||
task = store.read_task(error.task_id)
|
||||
self.assertEqual(task["revisions"][0]["status"], "needs_repair")
|
||||
self.assertEqual(task["revisions"][0]["quality_status"], "needs_repair")
|
||||
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
read, _ = asyncio.run(agent._run_tool(
|
||||
"read_current_cdsl", {}, error.task_id, "Repair fixture", [], planning_state={"phase": "INTAKE"}
|
||||
))
|
||||
self.assertTrue(read["ok"])
|
||||
self.assertEqual(read["revision_id"], error.revision_id)
|
||||
|
||||
def test_runtime_build_failure_does_not_leave_a_revision(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
|
||||
class BrokenEngine:
|
||||
def run_cdsl_only(self, _cdsl: dict, _step: Path) -> dict:
|
||||
raise RuntimeError("intentional runtime failure")
|
||||
|
||||
with patch("app.services.engine_service.load_engine", return_value=BrokenEngine()), patch(
|
||||
"app.services.engine_service.validate_cdsl", return_value=None
|
||||
), self.assertRaisesRegex(RuntimeError, "intentional runtime failure"):
|
||||
build_revision(
|
||||
settings=config,
|
||||
store=store,
|
||||
task_id=None,
|
||||
request="broken fixture",
|
||||
cdsl=fixture(),
|
||||
reference_ids=[],
|
||||
summary="broken",
|
||||
)
|
||||
|
||||
tasks = list(config.task_root.glob("cad_*"))
|
||||
self.assertEqual(len(tasks), 1)
|
||||
task = store.read_task(tasks[0].name)
|
||||
self.assertEqual(task["revisions"], [])
|
||||
|
||||
|
||||
class CleanupAndBoundaryTests(unittest.TestCase):
|
||||
def test_cleanup_is_dry_run_then_removes_only_retired_artifacts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
data_root = Path(directory) / "data"
|
||||
task_dir = data_root / "tasks" / "cad_aaaaaaaaaaaa" / "revisions" / "rev_001"
|
||||
conversation_dir = data_root / "conversations" / "conv_aaaaaaaaaaaa" / "diagnostics"
|
||||
task_dir.mkdir(parents=True)
|
||||
conversation_dir.mkdir(parents=True)
|
||||
write_json(task_dir / "generation-spec.json", {"schema": "cad.generation-spec.v1"})
|
||||
write_json(task_dir / "model.cdsl.json", fixture())
|
||||
(task_dir / "model.step").write_bytes(b"step")
|
||||
(task_dir / "model.glb").write_bytes(b"glb")
|
||||
write_json(data_root / "tasks" / "cad_aaaaaaaaaaaa" / "task.json", {
|
||||
"generation_spec_path": "revisions/rev_001/generation-spec.json",
|
||||
"current_design_intent_id": "intent_aaaaaaaaaaaa",
|
||||
"revisions": [{"acceptance_path": "revisions/rev_001/acceptance-report.json"}],
|
||||
})
|
||||
write_json(conversation_dir / "generation_spec_validation_deadbeef.json", {"kind": "old"})
|
||||
|
||||
script = BACKEND / "scripts" / "remove_generation_spec_artifacts.py"
|
||||
dry = subprocess.run([sys.executable, str(script), "--data-root", str(data_root)], check=True, capture_output=True, text=True)
|
||||
self.assertIn("Dry run only", dry.stdout)
|
||||
self.assertTrue((task_dir / "generation-spec.json").is_file())
|
||||
|
||||
subprocess.run([sys.executable, str(script), "--data-root", str(data_root), "--apply"], check=True, capture_output=True, text=True)
|
||||
self.assertFalse((task_dir / "generation-spec.json").exists())
|
||||
self.assertFalse((conversation_dir / "generation_spec_validation_deadbeef.json").exists())
|
||||
self.assertTrue((task_dir / "model.cdsl.json").is_file())
|
||||
self.assertTrue((task_dir / "model.step").is_file())
|
||||
self.assertTrue((task_dir / "model.glb").is_file())
|
||||
cleaned = json.loads((data_root / "tasks" / "cad_aaaaaaaaaaaa" / "task.json").read_text(encoding="utf-8"))
|
||||
self.assertNotIn("generation_spec_path", cleaned)
|
||||
self.assertNotIn("current_design_intent_id", cleaned)
|
||||
self.assertNotIn("acceptance_path", cleaned["revisions"][0])
|
||||
|
||||
def test_engine_does_not_import_application_or_dispatch_part_families(self) -> None:
|
||||
engine_root = BACKEND / "engine" / "cdsl_engine"
|
||||
source = "\n".join(path.read_text(encoding="utf-8") for path in engine_root.rglob("*.py"))
|
||||
lowered = source.casefold()
|
||||
|
||||
self.assertNotIn("from app", lowered)
|
||||
self.assertNotIn("import app", lowered)
|
||||
for business_family in ("mounting_bracket", "mounting_plate", "flange_sleeve", "bearing_housing", "hex_nut"):
|
||||
self.assertNotIn(business_family, lowered)
|
||||
@@ -21,6 +21,13 @@ def _workplane() -> dict:
|
||||
return {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
|
||||
|
||||
|
||||
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
|
||||
return {"type": "polygon", "vertices": [
|
||||
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
|
||||
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
|
||||
]}
|
||||
|
||||
|
||||
class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
def _base_block(self) -> dict:
|
||||
return {
|
||||
@@ -28,7 +35,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "base", "workplane": _workplane(),
|
||||
"profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10},
|
||||
"profile": _rectangle([-5, -5], [5, 5]),
|
||||
}]},
|
||||
"features": [{
|
||||
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
@@ -490,7 +497,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "mirror-test",
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [
|
||||
{"id": "base", "workplane": _workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}},
|
||||
{"id": "base", "workplane": _workplane(), "profile": _rectangle([-5, -5], [5, 5])},
|
||||
{"id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "center": [2, 0], "radius_mm": 1}},
|
||||
]},
|
||||
"features": [
|
||||
@@ -518,7 +525,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"geometry": {"sketches": [{
|
||||
"id": "circles", "workplane": _workplane(),
|
||||
"profile": {"type": "circles", "items": [{"center": [2, 3], "radius_mm": 1}]},
|
||||
"profile": {"type": "circle", "center": [2, 3], "radius_mm": 1},
|
||||
}]},
|
||||
"features": [],
|
||||
}
|
||||
@@ -761,7 +768,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "profile", "workplane": _workplane(),
|
||||
"profile": {"type": "rectangle", "min_mm": [2, 1], "max_mm": [4, 2]},
|
||||
"profile": _rectangle([2, 1], [4, 2]),
|
||||
}]},
|
||||
"features": [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -14,7 +13,6 @@ sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.models.contracts import ChatMessage, MessagePart # noqa: E402
|
||||
from app.services.agent_service import AgentService, TOOL_SCHEMAS, system_prompt # noqa: E402
|
||||
from app.services.engine_service import build_revision, load_engine, validate_cdsl # noqa: E402
|
||||
from app.services.library import CdslLibrary # noqa: E402
|
||||
from app.services.part_skills import PartSkillLibrary # noqa: E402
|
||||
from app.services.storage import WorkspaceStore # noqa: E402
|
||||
@@ -46,10 +44,49 @@ def document(part_id: str, sketches: list[dict], features: list[dict]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def rectangle(center: list[float], width: float, height: float) -> dict:
|
||||
half_width, half_height = width / 2, height / 2
|
||||
return {"type": "polygon", "vertices": [
|
||||
[center[0] - half_width, center[1] - half_height], [center[0] + half_width, center[1] - half_height],
|
||||
[center[0] + half_width, center[1] + half_height], [center[0] - half_width, center[1] + half_height],
|
||||
]}
|
||||
|
||||
|
||||
def circles(items: list[dict]) -> dict:
|
||||
return {"type": "analytic_contours", "contours": [
|
||||
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]}
|
||||
for item in items
|
||||
]}
|
||||
|
||||
|
||||
def circle_grid(radius: float, count_x: int, count_y: int, spacing_x: float, spacing_y: float) -> dict:
|
||||
return circles([
|
||||
{"center": [(column - (count_x - 1) / 2) * spacing_x, (row - (count_y - 1) / 2) * spacing_y], "radius_mm": radius}
|
||||
for row in range(count_y) for column in range(count_x)
|
||||
])
|
||||
|
||||
|
||||
def obround(length: float, width: float) -> dict:
|
||||
radius, left, right = width / 2, -length / 2 + width / 2, length / 2 - width / 2
|
||||
return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [
|
||||
{"type": "line", "start": [right, radius], "end": [left, radius]},
|
||||
{"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0], "radius_mm": radius},
|
||||
{"type": "line", "start": [left, -radius], "end": [right, -radius]},
|
||||
{"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0], "radius_mm": radius},
|
||||
]}]}
|
||||
|
||||
|
||||
def annulus(inner_radius: float, outer_radius: float) -> dict:
|
||||
return circles([
|
||||
{"center": [0, 0], "radius_mm": outer_radius},
|
||||
{"center": [0, 0], "radius_mm": inner_radius},
|
||||
])
|
||||
|
||||
|
||||
def mounting_plate_fixture() -> dict:
|
||||
return document("golden-mounting-plate", [
|
||||
{"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 60}},
|
||||
{"id": "grid", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 50, "spacing_y_mm": 30, "center_mm": [0, 0]}},
|
||||
{"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 80, 60)},
|
||||
{"id": "grid", "workplane": workplane(10), "profile": circle_grid(3, 2, 2, 50, 30)},
|
||||
], [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 10}, "sketch_id": "base"},
|
||||
{"id": "mount_pattern", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 10, "reverse": True}, "sketch_id": "grid"},
|
||||
@@ -60,10 +97,10 @@ def mounting_plate_fixture() -> dict:
|
||||
def mounting_bracket_fixture() -> dict:
|
||||
web_plane = {"origin_mm": [0, -20, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1], "normal": [0, 1, 0]}
|
||||
return document("golden-mounting-bracket", [
|
||||
{"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 40}},
|
||||
{"id": "web", "workplane": web_plane, "profile": {"type": "rectangle", "center": [0, -15], "width_mm": 50, "height_mm": 30}},
|
||||
{"id": "slot", "workplane": workplane(6), "profile": {"type": "obround", "center": [0, 0], "length_mm": 24, "width_mm": 8}},
|
||||
{"id": "symmetric_holes", "workplane": workplane(6), "profile": {"type": "circles", "items": [{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}]}},
|
||||
{"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 80, 40)},
|
||||
{"id": "web", "workplane": web_plane, "profile": rectangle([0, -15], 50, 30)},
|
||||
{"id": "slot", "workplane": workplane(6), "profile": obround(24, 8)},
|
||||
{"id": "symmetric_holes", "workplane": workplane(6), "profile": circles([{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}])},
|
||||
], [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 6}, "sketch_id": "base"},
|
||||
{"id": "web_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 6}, "sketch_id": "web"},
|
||||
@@ -74,8 +111,8 @@ def mounting_bracket_fixture() -> dict:
|
||||
|
||||
def flange_fixture() -> dict:
|
||||
return document("golden-flange", [
|
||||
{"id": "base", "workplane": workplane(), "profile": {"type": "annulus", "inner_radius_mm": 10, "outer_radius_mm": 40}},
|
||||
{"id": "bolt_circle", "workplane": workplane(12), "profile": {"type": "circles", "items": [{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}]}},
|
||||
{"id": "base", "workplane": workplane(), "profile": annulus(10, 40)},
|
||||
{"id": "bolt_circle", "workplane": workplane(12), "profile": circles([{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}])},
|
||||
], [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 12}, "sketch_id": "base"},
|
||||
{"id": "bolt_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 12, "reverse": True}, "sketch_id": "bolt_circle"},
|
||||
@@ -94,9 +131,9 @@ def shaft_fixture() -> dict:
|
||||
|
||||
def bearing_housing_fixture() -> dict:
|
||||
return document("golden-bearing-housing", [
|
||||
{"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}},
|
||||
{"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 100, 60)},
|
||||
{"id": "housing", "workplane": workplane(8), "profile": {"type": "circle", "radius_mm": 28}},
|
||||
{"id": "base_holes", "workplane": workplane(8), "profile": {"type": "circle_grid", "radius_mm": 4, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}},
|
||||
{"id": "base_holes", "workplane": workplane(8), "profile": circle_grid(4, 2, 2, 80, 40)},
|
||||
], [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 8}, "sketch_id": "base"},
|
||||
{"id": "housing_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 25}, "sketch_id": "housing"},
|
||||
@@ -115,16 +152,6 @@ def hex_nut_fixture() -> dict:
|
||||
])
|
||||
|
||||
|
||||
GOLDEN_CASES = (
|
||||
("带沉孔和孔阵列的安装底板", mounting_plate_fixture, {"planning/mounting-plate", "atomic/counterbored-hole-creation", "atomic/pattern-holes-from-datum"}, {"atomic/pattern-holes-from-datum": "expanded"}),
|
||||
("带槽和对称孔的安装支架", mounting_bracket_fixture, {"planning/mounting-bracket", "functional/slotted-adjustment-feature", "functional/symmetric-feature-layout"}, {"atomic/pattern-holes-from-datum": "expanded"}),
|
||||
("带螺栓圆的法兰", flange_fixture, {"planning/flange", "functional/flange-bolt-circle"}, {"functional/flange-bolt-circle": "expanded"}),
|
||||
("带回转体同轴孔的阶梯轴", shaft_fixture, {"planning/simple-shaft", "functional/axisymmetric-revolve-strategy", "atomic/coaxial-bore-rule"}, {}),
|
||||
("带轴承座孔和底座孔的轴承座", bearing_housing_fixture, {"planning/bearing-housing", "functional/bearing-bore-seat"}, {}),
|
||||
("M6 六角螺母", hex_nut_fixture, {"planning/hexagonal-nut", "atomic/threaded-hole-creation"}, {"atomic/threaded-hole-creation": "approximated"}),
|
||||
)
|
||||
|
||||
|
||||
class PartSkillLibraryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.library = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
@@ -166,24 +193,19 @@ class PartSkillLibraryTests(unittest.TestCase):
|
||||
self.assertTrue((PART_SKILL_ROOT / skill["bridge"]).is_file())
|
||||
self.assertTrue((PART_SKILL_ROOT / skill["source"]).is_file())
|
||||
self.assertTrue(skill["triggers"])
|
||||
self.assertTrue(skill["capability_translation_rules"])
|
||||
self.assertNotIn("capability_translation_rules", skill)
|
||||
self.assertEqual(categories, {"planning": 6, "functional": 7, "atomic": 8})
|
||||
self.assertTrue((PART_SKILL_ROOT / "LICENSE").is_file())
|
||||
self.assertTrue((PART_SKILL_ROOT / "PROVENANCE.md").is_file())
|
||||
|
||||
def test_audit_records_exact_omitted_and_blocked_capability_states(self) -> None:
|
||||
def test_audit_records_selection_and_assumptions_without_interpreting_geometry(self) -> None:
|
||||
fixture = mounting_plate_fixture()
|
||||
exact = self.library.audit(self.library.select("安装底板"), fixture)
|
||||
finishing = self.library.audit(self.library.select("edge treatment"), fixture)
|
||||
blocked = self.library.audit(self.library.select("flange bolt circle"), mounting_plate_fixture())
|
||||
|
||||
exact_statuses = {item["skill_id"]: item["status"] for item in exact["capability_translations"]}
|
||||
finishing_statuses = {item["skill_id"]: item for item in finishing["capability_translations"]}
|
||||
blocked_statuses = {item["skill_id"]: item["status"] for item in blocked["capability_translations"]}
|
||||
self.assertEqual(exact_statuses["planning/mounting-plate"], "exact")
|
||||
self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["status"], "omitted")
|
||||
self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["translation"], "selector_unavailable")
|
||||
self.assertEqual(blocked_statuses["functional/flange-bolt-circle"], "blocked")
|
||||
audit = self.library.audit(self.library.select("安装底板"), fixture, ["孔位置由用户确认"])
|
||||
self.assertIn("planning/mounting-plate", audit["skill_ids"])
|
||||
self.assertEqual(audit["assumptions"], ["孔位置由用户确认"])
|
||||
self.assertTrue(all(item.get("version") and item.get("summary") for item in audit["skills"]))
|
||||
self.assertNotIn("capability_translations", audit)
|
||||
self.assertNotIn("evidence", audit)
|
||||
|
||||
|
||||
class AgentPartSkillTests(unittest.TestCase):
|
||||
@@ -211,7 +233,7 @@ class AgentPartSkillTests(unittest.TestCase):
|
||||
self.assertIn("[planning/flange]", str(agent.first_messages[0]["content"]))
|
||||
self.assertIn("[functional/flange-bolt-circle]", str(agent.first_messages[0]["content"]))
|
||||
|
||||
def test_prompt_contains_bridge_and_template_tool(self) -> None:
|
||||
def test_prompt_contains_bridge_and_direct_cdsl_tools(self) -> None:
|
||||
library = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
selection = library.select("Create a flange with a bolt circle")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -220,7 +242,7 @@ class AgentPartSkillTests(unittest.TestCase):
|
||||
|
||||
self.assertIn("[planning/flange]", prompt)
|
||||
self.assertIn("circular-pattern atomic", prompt)
|
||||
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_flange_sleeve_model", "generate_cdsl_model"])
|
||||
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"])
|
||||
|
||||
def test_generation_persists_assumptions_and_selected_skills(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -230,7 +252,7 @@ class AgentPartSkillTests(unittest.TestCase):
|
||||
agent = AgentService(settings, store, CdslLibrary(settings), library)
|
||||
request = "M6 六角螺母"
|
||||
selection = library.select(request)
|
||||
planning_state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
|
||||
planning_state = {"phase": "INTAKE", "design_brief": ""}
|
||||
planned, _ = asyncio.run(agent._run_tool(
|
||||
"describe_design_intent",
|
||||
{"plan": "Create an M6 hex nut with a centered cylindrical bore representing the thread.", "assumptions": []},
|
||||
@@ -278,66 +300,5 @@ class AgentPartSkillTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class PartSkillBuildAndGoldenTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.settings = AgentPartSkillTests._settings(Path(tempfile.mkdtemp()))
|
||||
cls.engine = load_engine(cls.settings)
|
||||
cls.library = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
|
||||
def test_success_and_failure_revisions_always_write_part_skill_audit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = AgentPartSkillTests._settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
selection = self.library.select("带螺栓圆的法兰")
|
||||
audit = self.library.audit(selection, flange_fixture(), ["Explicit circle expansion"])
|
||||
success = build_revision(
|
||||
settings=settings, store=store, task_id=None, request="带螺栓圆的法兰", cdsl=flange_fixture(),
|
||||
reference_ids=["flange-reference"], summary="flange", part_skills=audit,
|
||||
generation_assumptions=["Explicit circle expansion"],
|
||||
)
|
||||
success_audit = json.loads(store.artifact_path(success["task_id"], success["part_skills_path"]).read_text(encoding="utf-8"))
|
||||
success_report = json.loads(store.artifact_path(success["task_id"], success["report_path"]).read_text(encoding="utf-8"))
|
||||
self.assertEqual(success_audit["skill_ids"], audit["skill_ids"])
|
||||
self.assertIn("generation_context", success_report)
|
||||
|
||||
invalid = copy.deepcopy(flange_fixture())
|
||||
invalid["features"][0]["atomic_id"] = "not_supported"
|
||||
failed_audit = self.library.audit(selection, invalid, [])
|
||||
with self.assertRaisesRegex(ValueError, "CDSL schema violation"):
|
||||
build_revision(
|
||||
settings=settings, store=store, task_id=success["task_id"], request="bad flange", cdsl=invalid,
|
||||
reference_ids=[], summary="bad", part_skills=failed_audit, generation_assumptions=[],
|
||||
)
|
||||
task = store.read_task(success["task_id"])
|
||||
failed = task["revisions"][-1]
|
||||
self.assertEqual(failed["status"], "failed")
|
||||
self.assertTrue(store.artifact_path(task["task_id"], failed["part_skills_path"]).is_file())
|
||||
report = json.loads(store.artifact_path(task["task_id"], failed["report_path"]).read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["generation_context"]["part_skill_ids"], audit["skill_ids"])
|
||||
legacy_report = {"engine_result": {"engine": "cdsl_only"}}
|
||||
self.assertEqual(legacy_report.get("generation_context", {}), {})
|
||||
|
||||
def test_golden_part_families_validate_preflight_rebuild_and_audit(self) -> None:
|
||||
for request, factory, expected_skills, expected_statuses in GOLDEN_CASES:
|
||||
with self.subTest(request=request):
|
||||
cdsl = factory()
|
||||
selection = self.library.select(request)
|
||||
self.assertTrue(expected_skills.issubset(selection["skill_ids"]))
|
||||
validate_cdsl(cdsl, self.engine)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
step_path = Path(directory) / "model.step"
|
||||
result = self.engine.run_cdsl_only(copy.deepcopy(cdsl), step_path)
|
||||
self.assertEqual(result["engine"], "cdsl_only")
|
||||
self.assertTrue(step_path.is_file())
|
||||
self.assertGreater(step_path.stat().st_size, 0)
|
||||
audit = self.library.audit(selection, cdsl, ["golden fixture"])
|
||||
statuses = {item["skill_id"]: item["status"] for item in audit["capability_translations"]}
|
||||
for skill_id, status in expected_statuses.items():
|
||||
self.assertEqual(statuses[skill_id], status)
|
||||
self.assertTrue(audit["evidence"]["features"])
|
||||
self.assertTrue(audit["evidence"]["profiles"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -6,7 +6,6 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.engine_service import load_engine, normalize_cdsl_for_engine, validate_cdsl
|
||||
from app.services.flange_sleeve_template import TEMPLATE_ID, build_flange_sleeve_cdsl
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
@@ -23,21 +22,38 @@ class ProfileSchemaTests(unittest.TestCase):
|
||||
def test_schema_and_registered_profiles_stay_in_sync(self) -> None:
|
||||
self.assertEqual(set(self.schema["runtime_supported_profiles"]), set(self.engine.SHAPE_GENERATORS))
|
||||
|
||||
def test_legacy_profile_macros_require_explicit_adapter_lowering(self) -> None:
|
||||
legacy = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "legacy-rectangle",
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "base", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
|
||||
"profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10},
|
||||
}]},
|
||||
"features": [{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base", "params": {"distance_mm": 2}}],
|
||||
}
|
||||
with self.assertRaisesRegex(ValueError, "CDSL schema violation"):
|
||||
validate_cdsl(legacy, self.engine)
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
|
||||
lowered = lower_legacy_profiles(legacy)
|
||||
self.assertEqual(lowered["geometry"]["sketches"][0]["profile"]["type"], "analytic_contours")
|
||||
validate_cdsl(lowered, self.engine)
|
||||
|
||||
def test_schema_and_executable_atomic_operations_stay_in_sync(self) -> None:
|
||||
self.assertEqual(set(self.schema["runtime_supported_atomic_ids"]), set(self.engine.SUPPORTED_ATOMIC_IDS))
|
||||
self.assertTrue(set(self.engine.SUPPORTED_ATOMIC_IDS).issubset(self.schema["feature_atomic_ids"]))
|
||||
self.assertEqual(set(self.engine.SUPPORTED_ATOMIC_IDS), set(self.schema["feature_atomic_ids"]))
|
||||
self.assertEqual(
|
||||
set(self.cdsl_schema["$defs"]["feature_atomic_ids"]["enum"]),
|
||||
set(self.schema["feature_atomic_ids"]),
|
||||
set(self.engine.SUPPORTED_ATOMIC_IDS),
|
||||
)
|
||||
|
||||
def test_machine_schema_and_human_contract_stay_in_sync(self) -> None:
|
||||
self.assertEqual(
|
||||
set(self.cdsl_schema["$defs"]["profile_type"]["enum"]),
|
||||
set(self.schema["profiles"]) - {"complex_arc_shape", "unknown_shape"},
|
||||
set(self.schema["runtime_supported_profiles"]),
|
||||
)
|
||||
self.assertEqual(set(self.cdsl_schema["$defs"]["motif_type"]["enum"]), set(self.schema["motif_types"]))
|
||||
self.assertEqual(set(self.cdsl_schema["$defs"]["layout_type"]["enum"]), set(self.schema["layout_types"]))
|
||||
self.assertEqual(set(self.schema["profiles"]), set(self.schema["runtime_supported_profiles"]))
|
||||
|
||||
def test_rejects_unsupported_atomic_operation_before_rebuild(self) -> None:
|
||||
cdsl = {
|
||||
@@ -135,39 +151,6 @@ class ProfileSchemaTests(unittest.TestCase):
|
||||
self.assertNotIn("point_mm", axis)
|
||||
self.assertIn("features[0].params.axis: point_mm -> origin_mm", repairs)
|
||||
|
||||
def test_flange_sleeve_template_is_schema_valid_and_runtime_buildable(self) -> None:
|
||||
cdsl, plan = build_flange_sleeve_cdsl({
|
||||
"template": TEMPLATE_ID,
|
||||
"name": "Template flange sleeve",
|
||||
"flange_width_mm": 120,
|
||||
"flange_height_mm": 120,
|
||||
"flange_thickness_mm": 14,
|
||||
"corner_chamfer_mm": 10,
|
||||
"tube_outer_diameter_mm": 70,
|
||||
"tube_straight_length_mm": 95,
|
||||
"tip_outer_diameter_mm": 62,
|
||||
"tip_length_mm": 28,
|
||||
"bore_diameter_mm": 46,
|
||||
"boss_outer_diameter_mm": 82,
|
||||
"boss_height_mm": 5,
|
||||
"mount_hole_diameter_mm": 12,
|
||||
"mount_counterbore_diameter_mm": 24,
|
||||
"mount_counterbore_depth_mm": 5,
|
||||
"mount_hole_u_mm": 42,
|
||||
"mount_hole_v_mm": 42,
|
||||
})
|
||||
|
||||
self.assertEqual(plan["name"], "Template flange sleeve")
|
||||
self.assertEqual(cdsl["features"][1]["depends_on"], ["flange_plate"])
|
||||
self.assertEqual(cdsl["geometry"]["sketches"][0]["workplane"]["x_dir"], [0.0, 1.0, 0.0])
|
||||
validate_cdsl(cdsl, self.engine)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
step_path = Path(directory) / "flange-sleeve.step"
|
||||
result = self.engine.run_cdsl_only(cdsl, step_path)
|
||||
self.assertEqual(result["engine"], "cdsl_only")
|
||||
self.assertTrue(step_path.is_file())
|
||||
self.assertGreater(step_path.stat().st_size, 0)
|
||||
|
||||
def test_cdsl_only_rebuild_preserves_its_actual_failure(self) -> None:
|
||||
cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
@@ -192,11 +175,16 @@ class ProfileSchemaTests(unittest.TestCase):
|
||||
self.assertNotIn("engine.run_rebuild(cdsl_copy, step_path)", build_revision_source)
|
||||
|
||||
def test_all_official_samples_match_the_engine_schema(self) -> None:
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
|
||||
samples = sorted(self.settings.library_root.glob("samples/**/model.cdsl.json"))
|
||||
self.assertGreater(len(samples), 0)
|
||||
for sample_path in samples:
|
||||
with self.subTest(sample=sample_path.parent.name):
|
||||
validate_cdsl(json.loads(sample_path.read_text(encoding="utf-8")), self.engine)
|
||||
legacy_sample = json.loads(sample_path.read_text(encoding="utf-8"))
|
||||
lowered = lower_legacy_profiles(legacy_sample)
|
||||
validate_cdsl(lowered, self.engine)
|
||||
self.assertTrue(self.engine.analyze_cdsl(lowered).runtime_eligible)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user