Files
cdsl-cad/backend/tests/test_direct_cdsl_pipeline.py
T
2026-08-26 14:13:11 +08:00

590 lines
27 KiB
Python

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, _validate_snapshot_selectors # 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_feature_bbox_excludes_inherited_runtime_records(self) -> None:
document = fixture()
document["features"].append({
"id": "boss",
"atomic_id": "extrude_add_blind",
"depends_on": ["base_add"],
"sketch_id": "base",
"params": {"distance_mm": 8},
})
rules = validate_verification({"rules": [
{"id": "boss_range", "type": "bbox", "feature": "boss", "expected": [8, 58, 58]},
]}, document)
report = evaluate_quality(rules, document, {
"bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]},
"topology_records": [
{
"feature_id": "boss", "owner_feature_ids": ["base"],
"geometry": {"bbox_mm": [0, 0, 0, 128, 70, 90]},
},
{
"feature_id": "boss", "owner_feature_ids": ["boss"],
"geometry": {"bbox_mm": [60, -29, 16, 68, 29, 74]},
},
],
})
self.assertEqual(report["status"], "passed")
self.assertEqual(report["results"][0]["actual"]["dimensions"], [8.0, 58.0, 58.0])
def test_feature_diameter_uses_target_circle(self) -> None:
document = fixture()
document["features"][0]["id"] = "boss"
document["features"][0]["sketch_id"] = "boss_sketch"
document["geometry"]["sketches"][0]["id"] = "boss_sketch"
document["geometry"]["sketches"][0]["profile"] = {"type": "circle", "center": [0, 0], "radius_mm": 29}
rules = validate_verification({"rules": [
{"id": "boss_dia", "type": "overall_diameter", "feature": "boss", "expected": 58},
]}, document)
report = evaluate_quality(rules, document, {
"bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]},
})
self.assertEqual(report["status"], "passed")
self.assertEqual(report["results"][0]["actual"], 58.0)
def test_overall_width_and_height_are_supported(self) -> None:
rules = validate_verification({"rules": [
{"id": "width", "type": "overall_width", "expected": 70},
{"id": "height", "type": "overall_height", "expected": 90},
]}, fixture())
report = evaluate_quality(rules, fixture(), {
"bbox_mm": {"min": [-64, -35, 0], "max": [64, 35, 90]},
})
self.assertEqual(report["status"], "passed")
self.assertEqual([item["actual"] for item in report["results"]], [70.0, 90.0])
def test_feature_scoped_verification_requires_feature(self) -> None:
with self.assertRaisesRegex(ValueError, "feature is required for hole_count"):
validate_verification({"rules": [
{"id": "holes", "type": "hole_count", "expected": 1},
]}, fixture())
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_completed_feature_can_keep_selector_from_historical_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
task_id, parent = self._seed_revision(store)
parent_dir = store.task_dir(task_id) / "revisions" / parent
parent_snapshot_id = f"{task_id}/{parent}"
write_json(parent_dir / "model.topology.json", {
"schema_version": "cad.topology.v1",
"task_id": task_id,
"revision_id": parent,
"snapshot_id": parent_snapshot_id,
"records": [{"record_id": "body:base:edge:0", "kind": "edge", "executable": True, "geometry": {}}],
})
task = store.read_task(task_id)
task["revisions"][0]["topology_path"] = f"revisions/{parent}/model.topology.json"
write_json(store.task_path(task_id), task)
child = "rev_002"
child_dir = store.task_dir(task_id) / "revisions" / child
child_dir.mkdir(parents=True)
write_json(child_dir / "model.cdsl.json", fixture())
write_json(child_dir / "model.topology.json", {
"schema_version": "cad.topology.v1",
"task_id": task_id,
"revision_id": child,
"snapshot_id": f"{task_id}/{child}",
"records": [{"record_id": "body:base:edge:1", "kind": "edge", "executable": True, "geometry": {}}],
})
store.update_task(task_id, {
"revision_id": child,
"status": "success",
"cdsl_path": f"revisions/{child}/model.cdsl.json",
"topology_path": f"revisions/{child}/model.topology.json",
})
document = fixture()
document["features"][0]["selectors"] = [{
"kind": "edge",
"stable_id": "body:base:edge:0",
"source": "runtime_snapshot",
"snapshot_id": parent_snapshot_id,
"confidence": 1.0,
}]
_validate_snapshot_selectors(store, task_id, document)
document["features"][0]["selectors"][0]["snapshot_id"] = f"{task_id}/rev_999"
with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"):
_validate_snapshot_selectors(store, task_id, document)
def test_topology_inspection_unlocks_waiting_plan_nodes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
task_id, revision_id = self._seed_revision(store)
revision_dir = store.task_dir(task_id) / "revisions" / revision_id
snapshot_id = f"{task_id}/{revision_id}"
write_json(revision_dir / "model.topology.json", {
"schema_version": "cad.topology.v1",
"task_id": task_id,
"revision_id": revision_id,
"snapshot_id": snapshot_id,
"records": [{
"record_id": "body:base_add",
"kind": "body",
"body_id": "body:base_add",
"feature_id": "base_add",
"owner_feature_ids": ["base_add"],
"geometry": {},
"executable": True,
}],
})
task = store.read_task(task_id)
task["revisions"][0]["topology_path"] = f"revisions/{revision_id}/model.topology.json"
write_json(store.task_path(task_id), task)
state = {
"phase": "TOPOLOGY_READY",
"feature_plan": {
"schema_version": "cad.feature-plan.v1",
"plan_id": "plan_1",
"task_id": task_id,
"nodes": [
{"id": "base", "atomic_id": "extrude_add_blind", "depends_on": [], "cdsl_feature_ids": ["base_add"]},
{"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "cdsl_feature_ids": ["round"]},
],
},
}
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
result, _ = asyncio.run(agent._run_tool(
"inspect_current_topology", {}, task_id, "Round the edges", [], planning_state=state,
))
self.assertTrue(result["ok"])
self.assertEqual(state["feature_plan"]["topology_snapshot_id"], snapshot_id)
self.assertEqual(state["feature_plan"]["ready_nodes"], ["round"])
def test_successful_build_automatically_binds_topology_for_waiting_nodes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
state = {
"phase": "PLAN_READY",
"feature_plan": {
"schema_version": "cad.feature-plan.v1",
"plan_id": "plan_1",
"task_id": "",
"nodes": [
{
"id": "base",
"atomic_id": "extrude_add_blind",
"depends_on": [],
"cdsl_feature_ids": ["base_add"],
"status": "ready",
},
{
"id": "round",
"atomic_id": "fillet",
"depends_on": ["base"],
"cdsl_feature_ids": ["round"],
},
],
},
}
result, _ = asyncio.run(agent._run_tool(
"generate_cdsl_model",
{"cdsl": fixture(), "summary": "Base feature", "assumptions": []},
None,
"Create a base with a later fillet",
[],
planning_state=state,
))
self.assertTrue(result["ok"])
self.assertEqual(result["required_action"], "patch_cdsl_model")
self.assertEqual(result["plan_status"]["ready_nodes"], ["round"])
self.assertEqual(result["plan_status"]["waiting_nodes"], [])
self.assertEqual(state["feature_plan"]["topology_snapshot_id"], "{}/{}".format(result["task_id"], result["revision_id"]))
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_patch_rejects_non_current_parent_revision(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
task_id, parent = self._seed_revision(store)
child = "rev_002"
child_dir = store.task_dir(task_id) / "revisions" / child
child_dir.mkdir(parents=True)
write_json(child_dir / "model.cdsl.json", fixture())
store.update_task(task_id, {
"revision_id": child,
"status": "success",
"cdsl_path": f"revisions/{child}/model.cdsl.json",
})
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"):
asyncio.run(agent._run_tool(
"patch_cdsl_model",
{"base_revision_id": parent, "patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}], "summary": "stale", "assumptions": []},
task_id,
"stale patch",
[],
planning_state={"phase": "PLANNED"},
))
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)