integrate ganjihong engine refactor
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
|
||||
from app.cad_agent.domain.state import transition
|
||||
from app.services.agent_service import AgentService
|
||||
|
||||
|
||||
def test_result_payload_contains_all_published_artifacts() -> None:
|
||||
payload = AgentService._result_payload("cad_abcdefghijkl", {
|
||||
"lifecycle": "completed",
|
||||
"published_revision": "rev_prefix",
|
||||
"revisions": [{
|
||||
"revision_id": "rev_prefix",
|
||||
"cdsl_path": "revisions/rev_prefix/model.cdsl.json",
|
||||
"step_path": "revisions/rev_prefix/model.step",
|
||||
"glb_path": "revisions/rev_prefix/model.glb",
|
||||
"report_path": "revisions/rev_prefix/rebuild-report.json",
|
||||
}],
|
||||
})
|
||||
|
||||
assert payload == {
|
||||
"taskId": "cad_abcdefghijkl",
|
||||
"revisionId": "rev_prefix",
|
||||
"cdslPath": "revisions/rev_prefix/model.cdsl.json",
|
||||
"stepPath": "revisions/rev_prefix/model.step",
|
||||
"glbPath": "revisions/rev_prefix/model.glb",
|
||||
"reportPath": "revisions/rev_prefix/rebuild-report.json",
|
||||
"summary": "CDSL CAD model",
|
||||
"referenceIds": [],
|
||||
"engine": "cdsl_only",
|
||||
"lifecycle": "completed",
|
||||
}
|
||||
|
||||
|
||||
def test_best_effort_projection_retains_prefix_artifact_paths() -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3")
|
||||
state = repository.create_task("cad_abcdefghijkl", "make a plate")
|
||||
state = transition(state, "analysis_written", requirements_path="documents/requirements-analysis.json")
|
||||
assert repository.compare_and_swap(state)
|
||||
state = transition(state, "authoring_written", authoring_path="documents/authoring-cdsl-attempt-01.json")
|
||||
assert repository.compare_and_swap(state)
|
||||
state = transition(state, "compiled", runtime_cdsl_path="documents/runtime-cdsl-attempt-01.json")
|
||||
assert repository.compare_and_swap(state)
|
||||
state = transition(state, "repair_required", active_revision="rev_prefix")
|
||||
assert repository.compare_and_swap(state, events=[{
|
||||
"event": "build_failed",
|
||||
"revision_id": "rev_prefix",
|
||||
"paths": {
|
||||
"model.cdsl.json": "revisions/rev_prefix/model.cdsl.json",
|
||||
"model.step": "revisions/rev_prefix/model.step",
|
||||
"model.glb": "revisions/rev_prefix/model.glb",
|
||||
"rebuild-report.json": "revisions/rev_prefix/rebuild-report.json",
|
||||
},
|
||||
}])
|
||||
state = transition(state, "repair_started", repair_count=1)
|
||||
assert repository.compare_and_swap(state)
|
||||
state = transition(state, "repair_required")
|
||||
assert repository.compare_and_swap(state)
|
||||
state = transition(state, "repair_started", repair_count=2)
|
||||
assert repository.compare_and_swap(state)
|
||||
state = transition(state, "publish_best_effort")
|
||||
assert repository.compare_and_swap(state, events=[{"event": "repair_budget_exhausted"}])
|
||||
state = transition(state, "published")
|
||||
assert repository.compare_and_swap(state)
|
||||
|
||||
projection = repository.get_task_projection("cad_abcdefghijkl")
|
||||
assert projection is not None
|
||||
result = AgentService._result_payload("cad_abcdefghijkl", projection)
|
||||
assert result is not None
|
||||
assert result["revisionId"] == "rev_prefix"
|
||||
assert result["stepPath"] == "revisions/rev_prefix/model.step"
|
||||
|
||||
|
||||
def test_result_payload_rejects_incomplete_artifact_sets() -> None:
|
||||
assert AgentService._result_payload("cad_abcdefghijkl", {
|
||||
"published_revision": "rev_prefix",
|
||||
"revisions": [{"revision_id": "rev_prefix", "step_path": "revisions/rev_prefix/model.step"}],
|
||||
}) is None
|
||||
@@ -1,158 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.cad_agent.adapters.author_guidance import FileAuthorGuidance # noqa: E402
|
||||
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator # noqa: E402
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError # noqa: E402
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState # noqa: E402
|
||||
|
||||
|
||||
GUIDANCE_ROOT = ROOT / "backend" / "agent" / "skills" / "cdsl-author-guidance"
|
||||
PROFILE = ROOT / "backend" / "engine" / "cdsl_engine" / "profile_schema.json"
|
||||
|
||||
|
||||
def atomic_ids() -> tuple[str, ...]:
|
||||
return tuple(json.loads(PROFILE.read_text(encoding="utf-8"))["operation_contracts"])
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self, state: TaskState) -> None:
|
||||
self.state = state
|
||||
self.usage_records: list[dict] = []
|
||||
|
||||
def get_state(self, _task_id: str) -> TaskState:
|
||||
return self.state
|
||||
|
||||
def ledger_events(self, _task_id: str) -> list[dict]:
|
||||
return []
|
||||
|
||||
def record_usage(self, _task_id: str, payload: dict) -> None:
|
||||
self.usage_records.append(payload)
|
||||
|
||||
def record_tool_audit(self, _task_id: str, _payload: dict) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _Artifacts:
|
||||
def read_source_requirements(self, _task_id: str) -> str:
|
||||
return "Create a symmetric mounting plate."
|
||||
|
||||
def read_json(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Runtime:
|
||||
def supported_atomic_ids(self) -> tuple[str, ...]:
|
||||
return atomic_ids()
|
||||
|
||||
|
||||
class AuthorGuidanceTests(unittest.TestCase):
|
||||
def test_manifest_covers_every_runtime_atomic_and_keeps_coordinate_core_at_minimum_budget(self) -> None:
|
||||
guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=1_200)
|
||||
covered: set[str] = set()
|
||||
for atomic_id in atomic_ids():
|
||||
selection = guidance.select(
|
||||
phase=TaskPhase.FEATURE_PENDING,
|
||||
atomic_id=atomic_id,
|
||||
repair_required=False,
|
||||
supported_atomic_ids=atomic_ids(),
|
||||
)
|
||||
self.assertTrue(selection.enabled, selection.fallback_reason)
|
||||
self.assertIn("00-author-contract", selection.section_ids)
|
||||
self.assertIn("03-coordinate-system-and-datums", selection.section_ids)
|
||||
self.assertLessEqual(len(selection.content), 1_200)
|
||||
self.assertIn("世界坐标", selection.content)
|
||||
covered.update(section_id for section_id in selection.section_ids if section_id.startswith("op-"))
|
||||
self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-loft", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere", "op-primitives", "op-thread", "op-bend", "op-gear"})
|
||||
|
||||
def test_phase_repair_and_budget_selection_are_stable(self) -> None:
|
||||
guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600)
|
||||
planning = guidance.select(
|
||||
phase=TaskPhase.COMPILING_FEATURE_PLAN,
|
||||
atomic_id="",
|
||||
repair_required=False,
|
||||
supported_atomic_ids=atomic_ids(),
|
||||
)
|
||||
repair = guidance.select(
|
||||
phase=TaskPhase.AWAITING_ACTION,
|
||||
atomic_id="fillet",
|
||||
repair_required=True,
|
||||
supported_atomic_ids=atomic_ids(),
|
||||
)
|
||||
self.assertEqual(planning.section_ids[:2], ("00-author-contract", "03-coordinate-system-and-datums"))
|
||||
self.assertIn("02-parameters-and-derived-dimensions", planning.section_ids)
|
||||
self.assertEqual(repair.section_ids[:3], ("00-author-contract", "03-coordinate-system-and-datums", "op-finish"))
|
||||
self.assertIn("10-repair-and-best-effort", repair.section_ids)
|
||||
|
||||
def test_disabled_missing_and_invalid_corpus_fall_back_without_authoring_failure(self) -> None:
|
||||
common = {
|
||||
"phase": TaskPhase.FEATURE_PENDING,
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"repair_required": False,
|
||||
"supported_atomic_ids": atomic_ids(),
|
||||
}
|
||||
self.assertEqual(FileAuthorGuidance(GUIDANCE_ROOT, enabled=False).select(**common).fallback_reason, "guidance_disabled")
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:FileNotFoundError")
|
||||
(root / "manifest.json").write_text("{}", encoding="utf-8")
|
||||
self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:ValueError")
|
||||
|
||||
def test_author_context_receives_guidance_but_keeps_the_existing_tool_instruction(self) -> None:
|
||||
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1)
|
||||
workflow = WorkflowCoordinator(
|
||||
WorkflowConfig(max_turns=8, format_error_limit=2),
|
||||
_Repository(state),
|
||||
_Artifacts(),
|
||||
_Runtime(),
|
||||
object(),
|
||||
object(),
|
||||
object(),
|
||||
object(),
|
||||
FileAuthorGuidance(GUIDANCE_ROOT),
|
||||
)
|
||||
messages, selection = workflow._author_context(state.task_id, [])
|
||||
system = str(messages[0]["content"])
|
||||
self.assertTrue(selection.enabled)
|
||||
self.assertIn("Use exactly one offered structured tool call", system)
|
||||
self.assertIn("Coordinate System And Datums", system)
|
||||
self.assertIn("世界坐标", system)
|
||||
|
||||
def test_invalid_author_tool_call_retains_guidance_usage_metadata(self) -> None:
|
||||
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1)
|
||||
repository = _Repository(state)
|
||||
class _Models:
|
||||
async def call_tool(self, **_kwargs: object) -> dict:
|
||||
return {"tool_calls": [], "usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4}}
|
||||
workflow = WorkflowCoordinator(
|
||||
WorkflowConfig(max_turns=8, format_error_limit=2),
|
||||
repository,
|
||||
_Artifacts(),
|
||||
_Runtime(),
|
||||
_Models(),
|
||||
object(),
|
||||
object(),
|
||||
object(),
|
||||
FileAuthorGuidance(GUIDANCE_ROOT),
|
||||
)
|
||||
tool = {"type": "function", "function": {"name": "write_requirements_document", "parameters": {"type": "object"}}}
|
||||
result = asyncio.run(workflow._author_turn(state.task_id, ModelIdentity("provider", "model"), [tool], []))
|
||||
self.assertIsInstance(result, WorkflowError)
|
||||
self.assertEqual(result.code, ErrorCode.AUTHOR_FORMAT_INVALID)
|
||||
self.assertEqual(repository.usage_records[0]["guidance_enabled"], True)
|
||||
self.assertIn("03-coordinate-system-and-datums", repository.usage_records[0]["guidance_section_ids"])
|
||||
self.assertEqual(repository.usage_records[0]["retry_reason"], "invalid_tool_call")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,81 @@
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from app.cad_agent.application.authoring_contract import AuthoringDocument
|
||||
from app.cad_agent.application.authoring_compiler import AuthoringCompiler, AuthoringCompileError
|
||||
|
||||
|
||||
def test_model_cannot_submit_runtime_identity():
|
||||
with pytest.raises(ValueError, match="AUTHOR_FORBIDDEN_FIELD"):
|
||||
AuthoringDocument.model_validate({"feature_id": "feature_001", "bodies": []})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["task_id", "revision_id", "stable_id", "selector_tokens", "selector token", "host_face", "mirror_plane"])
|
||||
def test_model_cannot_submit_any_internal_identity_variant(field):
|
||||
with pytest.raises(ValueError, match="AUTHOR_FORBIDDEN_FIELD"):
|
||||
AuthoringDocument.model_validate({field: "internal", "bodies": []})
|
||||
|
||||
|
||||
def test_authoring_rejects_non_mm_and_non_finite_values():
|
||||
with pytest.raises(ValueError):
|
||||
AuthoringDocument.model_validate({"units": "in", "bodies": []})
|
||||
with pytest.raises(ValueError, match="non-finite"):
|
||||
AuthoringDocument.model_validate({"bodies": [{"name": "main", "features": [{"name": "base", "operation": "box_add", "params": {"length_mm": math.nan}}]}]})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sketch", [
|
||||
{
|
||||
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
|
||||
"profiles": [{"type": "circle", "diameter_mm": 20}],
|
||||
},
|
||||
{
|
||||
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
|
||||
"profile": {"type": "circle", "radius_mm": 10},
|
||||
},
|
||||
])
|
||||
def test_authoring_rejects_runtime_or_ambiguous_sketch_shapes(sketch):
|
||||
with pytest.raises(ValueError):
|
||||
AuthoringDocument.model_validate({
|
||||
"bodies": [{"name": "main", "features": [{
|
||||
"name": "base", "operation": "extrude_add_blind", "params": {"distance_mm": 8}, "sketch": sketch,
|
||||
}]}],
|
||||
})
|
||||
|
||||
|
||||
def test_authoring_rejects_selector_fields_that_are_not_declarative_source_intent():
|
||||
with pytest.raises(ValueError):
|
||||
AuthoringDocument.model_validate({
|
||||
"bodies": [{"name": "main", "features": [{
|
||||
"name": "base", "operation": "cylinder_add", "params": {},
|
||||
"selectors": [{"kind": "face", "source": "other.top_planar_face", "role": "host_face"}],
|
||||
}]}],
|
||||
})
|
||||
|
||||
|
||||
def test_compiler_assigns_deterministic_ids_and_orders_dependencies():
|
||||
document = {"bodies": [{"name": "main", "features": [
|
||||
{"name": "hole", "operation": "hole_blind", "depends_on": ["base"]},
|
||||
{"name": "base", "operation": "extrude_add_blind"},
|
||||
]}]}
|
||||
runtime, audit = AuthoringCompiler(lambda operation: {"atomic_id": operation}).compile(document)
|
||||
assert [item["id"] for item in runtime["features"]] == ["feature_002", "feature_001"]
|
||||
assert runtime["features"][1]["depends_on"] == ["feature_002"]
|
||||
assert audit["feature_ids"] == {"hole": "feature_001", "base": "feature_002"}
|
||||
|
||||
|
||||
def test_compiler_rejects_unknown_operation():
|
||||
with pytest.raises(AuthoringCompileError) as error:
|
||||
AuthoringCompiler(lambda _: (_ for _ in ()).throw(ValueError("unknown"))).compile({
|
||||
"bodies": [{"name": "main", "features": [{"name": "base", "operation": "bad"}]}]
|
||||
})
|
||||
assert error.value.code == "OPERATION_UNSUPPORTED"
|
||||
|
||||
|
||||
def test_compiler_preserves_the_forbidden_field_error_code():
|
||||
with pytest.raises(AuthoringCompileError) as error:
|
||||
AuthoringCompiler(lambda operation: {"atomic_id": operation}).compile({
|
||||
"bodies": [{"name": "main", "features": [{"name": "base", "operation": "box_add", "params": {}}]}],
|
||||
"feature_id": "feature_001",
|
||||
})
|
||||
assert error.value.code == "AUTHOR_FORBIDDEN_FIELD"
|
||||
@@ -0,0 +1,219 @@
|
||||
import math
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
||||
from app.cad_agent.adapters.runtime import ProfileCadRuntime
|
||||
from app.cad_agent.ports import AdapterUnavailable
|
||||
from app.cad_agent.application.single_stage import SingleStageExecutor
|
||||
from app.services.engine_service import validate_cdsl, validate_cdsl_shape
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
def _base_feature() -> dict:
|
||||
return {
|
||||
"name": "base",
|
||||
"operation": "extrude_add_blind",
|
||||
"params": {"distance_mm": 8, "result_mode": "new_body"},
|
||||
"sketch": {
|
||||
"workplane": {
|
||||
"origin_mm": [0, 0, 0],
|
||||
"x_dir": [1, 0, 0],
|
||||
"normal": [0, 0, 1],
|
||||
},
|
||||
"profile": {
|
||||
"type": "polygon",
|
||||
"vertices": [[-40, -25], [40, -25], [40, 25], [-40, 25]],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _authoring(*features: dict) -> dict:
|
||||
return {
|
||||
"schema_version": "cad.author.v1",
|
||||
"units": "mm",
|
||||
"bodies": [{"name": "main", "features": list(features)}],
|
||||
}
|
||||
|
||||
|
||||
def test_authoring_compiles_to_runtime_cdsl_and_publishes_one_revision() -> None:
|
||||
runtime = ProfileCadRuntime(get_settings())
|
||||
authoring = _authoring(_base_feature())
|
||||
runtime_cdsl, audit = runtime.compile_authoring(authoring)
|
||||
|
||||
assert runtime_cdsl["schema"] == "cad.runtime.v1"
|
||||
assert runtime_cdsl["bodies"] == [{"id": "body_001", "name": "main"}]
|
||||
assert runtime_cdsl["features"][0]["id"] == "feature_001"
|
||||
assert audit["feature_ids"] == {"base": "feature_001"}
|
||||
validate_cdsl_shape(runtime_cdsl, runtime.engine)
|
||||
validate_cdsl(runtime_cdsl, runtime.engine)
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
artifacts = FileArtifactStore(Path(temporary) / "artifacts")
|
||||
task_id = "cad_abcdefghijkl"
|
||||
artifacts.initialize_task(task_id, "make a rectangular plate")
|
||||
result = SingleStageExecutor(None, artifacts, runtime).execute(task_id, authoring)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
revision = str(result["revision_id"])
|
||||
assert result["executed_feature_ids"] == ["feature_001"]
|
||||
for relative in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json", "renders/render-manifest.json"):
|
||||
assert artifacts.artifact_path(task_id, f"revisions/{revision}/{relative}").is_file()
|
||||
|
||||
|
||||
def test_selector_failure_keeps_the_successful_prefix() -> None:
|
||||
runtime = ProfileCadRuntime(get_settings())
|
||||
invalid_fillet = {
|
||||
"name": "bad_fillet",
|
||||
"operation": "fillet",
|
||||
"depends_on": ["base"],
|
||||
"params": {"radius_mm": 1},
|
||||
# extrude.end is a face output. Using it for an edge-only operation
|
||||
# is a deliberate selector-kind failure, not a geometry fallback.
|
||||
"selectors": [{"kind": "edge", "source": "base.top_planar_face"}],
|
||||
}
|
||||
runtime_cdsl, _audit = runtime.compile_authoring(_authoring(_base_feature(), invalid_fillet))
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
built, diagnostics = runtime.rebuild_best_effort(
|
||||
runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_selector_failure",
|
||||
)
|
||||
|
||||
assert built["executed_feature_ids"] == ["feature_001"]
|
||||
assert diagnostics[0]["feature_id"] == "feature_002"
|
||||
assert diagnostics[0]["code"] == "SELECTOR_KIND_MISMATCH"
|
||||
|
||||
|
||||
def test_cylinder_cap_selector_is_compiled_into_hole_host_face_and_executes() -> None:
|
||||
runtime = ProfileCadRuntime(get_settings())
|
||||
base = {
|
||||
"name": "base_flange",
|
||||
"operation": "cylinder_add",
|
||||
"params": {
|
||||
"radius_mm": 30,
|
||||
"height_mm": 10,
|
||||
"axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]},
|
||||
},
|
||||
}
|
||||
bore = {
|
||||
"name": "center_bore",
|
||||
"operation": "hole_wizard",
|
||||
"params": {
|
||||
"hole_type": "simple",
|
||||
"diameter_mm": 12,
|
||||
"depth_mm": 10,
|
||||
"end_condition": {"type": "through_all", "solidworks_code": 1},
|
||||
"positions": [{"mm": [0, 0, 10]}],
|
||||
},
|
||||
"selectors": [{"kind": "face", "source": "base_flange.top_planar_face", "match": "unique"}],
|
||||
}
|
||||
runtime_cdsl, _audit = runtime.compile_authoring(_authoring(base, bore))
|
||||
compiled_bore = runtime_cdsl["features"][1]
|
||||
assert compiled_bore["depends_on"] == ["feature_001"]
|
||||
assert "selectors" not in compiled_bore
|
||||
assert compiled_bore["params"]["host_face"] == {
|
||||
"kind": "face",
|
||||
"output_role": "cylinder.end",
|
||||
"owner_feature_id": "feature_001",
|
||||
"source": "runtime_snapshot",
|
||||
"confidence": 1.0,
|
||||
"match_mode": "unique",
|
||||
}
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
built, diagnostics = runtime.rebuild_best_effort(
|
||||
runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_cylinder_host",
|
||||
)
|
||||
|
||||
assert diagnostics == []
|
||||
assert built["executed_feature_ids"] == ["feature_001", "feature_002"]
|
||||
|
||||
|
||||
def test_authoring_circle_diameter_is_lowered_to_runtime_radius() -> None:
|
||||
runtime = ProfileCadRuntime(get_settings())
|
||||
authoring = _authoring({
|
||||
"name": "round_base",
|
||||
"operation": "extrude_add_blind",
|
||||
"params": {"distance_mm": 8, "result_mode": "new_body"},
|
||||
"sketch": {
|
||||
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
|
||||
"profile": {"type": "circle", "diameter_mm": 20, "center_mm": [0, 0]},
|
||||
},
|
||||
})
|
||||
compiled, _audit = runtime.compile_authoring(authoring)
|
||||
assert compiled["geometry"]["sketches"][0]["profile"] == {
|
||||
"type": "circle", "center": [0.0, 0.0], "radius_mm": 10.0,
|
||||
}
|
||||
|
||||
|
||||
def test_flange_bolt_host_is_built_before_source_topology_is_replaced() -> None:
|
||||
"""An exposed base cap remains a valid bolt host before later fusions/cuts."""
|
||||
runtime = ProfileCadRuntime(get_settings())
|
||||
circle_sketch = lambda z, normal, diameter: {
|
||||
"workplane": {"origin_mm": [0, 0, z], "x_dir": [1, 0, 0], "normal": normal},
|
||||
"profile": {"type": "circle", "diameter_mm": diameter, "center_mm": [0, 0]},
|
||||
}
|
||||
bolt_positions = [
|
||||
{"mm": [43 * math.cos(math.radians(angle)), 43 * math.sin(math.radians(angle)), 12]}
|
||||
for angle in range(0, 360, 45)
|
||||
]
|
||||
authoring = _authoring(
|
||||
{
|
||||
"name": "base_flange", "operation": "cylinder_add",
|
||||
"params": {"radius_mm": 60, "height_mm": 12, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}},
|
||||
},
|
||||
{
|
||||
"name": "rear_shallow_pad", "operation": "extrude_add_blind", "depends_on": ["bolt_holes"],
|
||||
"params": {"distance_mm": 4, "result_mode": "fuse", "reverse": False},
|
||||
"sketch": circle_sketch(0, [0, 0, -1], 105),
|
||||
},
|
||||
{
|
||||
"name": "rear_guide_boss", "operation": "extrude_add_blind", "depends_on": ["rear_shallow_pad"],
|
||||
"params": {"distance_mm": 12, "result_mode": "fuse", "reverse": False},
|
||||
"sketch": circle_sketch(0, [0, 0, -1], 38),
|
||||
},
|
||||
{
|
||||
"name": "bolt_holes", "operation": "hole_wizard",
|
||||
"params": {
|
||||
"hole_type": "counterbore", "diameter_mm": 8, "depth_mm": 12,
|
||||
"end_condition": {"type": "through_all_both", "solidworks_code": 7},
|
||||
"counterbore": {"diameter_mm": 10, "depth_mm": 4}, "positions": bolt_positions,
|
||||
},
|
||||
"selectors": [{"kind": "face", "source": "base_flange.top_planar_face", "match": "unique"}],
|
||||
},
|
||||
{
|
||||
"name": "front_hub_boss", "operation": "extrude_add_blind", "depends_on": ["bolt_holes"],
|
||||
"params": {"distance_mm": 14, "result_mode": "fuse", "reverse": False},
|
||||
"sketch": circle_sketch(12, [0, 0, 1], 56),
|
||||
},
|
||||
{
|
||||
"name": "center_through_cut", "operation": "extrude_cut_through", "depends_on": ["front_hub_boss", "rear_guide_boss"],
|
||||
"params": {"end_condition": {"type": "through_all_both", "solidworks_code": 7}},
|
||||
"sketch": circle_sketch(0, [0, 0, 1], 32),
|
||||
},
|
||||
)
|
||||
compiled, audit = runtime.compile_authoring(authoring)
|
||||
assert audit["implicit_selector_dependencies"] == {"bolt_holes": ["base_flange"]}
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
rebuilt = runtime.rebuild(compiled, temporary, "cad_abcdefghijkl", "flange_source_order")
|
||||
|
||||
assert rebuilt["health"]["feature_count"] == 6
|
||||
|
||||
|
||||
def test_service_failure_during_prefix_export_is_not_reclassified_as_a_model_error(monkeypatch) -> None:
|
||||
runtime = ProfileCadRuntime(get_settings())
|
||||
runtime_cdsl, _audit = runtime.compile_authoring(_authoring(_base_feature()))
|
||||
|
||||
def unavailable(*_args, **_kwargs):
|
||||
raise AdapterUnavailable("renderer unavailable")
|
||||
|
||||
monkeypatch.setattr(runtime, "rebuild", unavailable)
|
||||
with TemporaryDirectory() as temporary:
|
||||
try:
|
||||
runtime.rebuild_best_effort(runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_service_failure")
|
||||
except AdapterUnavailable as error:
|
||||
assert "renderer unavailable" in str(error)
|
||||
else:
|
||||
raise AssertionError("service failure was incorrectly converted into a model repair diagnostic")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,41 +1,17 @@
|
||||
"""#2 draft 假接受陷阱:extrudeParams.draft 必须被显式拒绝,而不是静默忽略。
|
||||
"""Extrude draft contracts distinguish executable and unsupported extents.
|
||||
|
||||
中文说明
|
||||
--------
|
||||
这个文件在测试什么(issue #2「draft 被 schema 接受但 runtime 未执行」的回归测试):
|
||||
|
||||
1. 背景:三方合同错位——
|
||||
- 机器契约 cdsl_schema.json(extrudeParams,约 101 行)允许
|
||||
"draft": {"type": "object"},且是空 object(无任何子字段约束),
|
||||
即"文档格式假接受";
|
||||
- 人读契约 profile_schema.json 的 extrude_add_blind /
|
||||
extrude_add_two_sided / extrude_cut_blind 均**未**声明 draft
|
||||
为 optional_params;
|
||||
- runtime(build123d_adapter.extrude 仅调 Solid.extrude,无锥形
|
||||
拉伸)也完全没有 draft 实现,遇到 draft 就静默忽略。
|
||||
- 隐患:importer(translator.py 已解析 SolidWorks 的
|
||||
draft_angle_rad / reverse_draft_angle_rad)一旦把拔模角写进
|
||||
CDSL params,runtime 会静默产出**无拔模角的直壁实体**——
|
||||
注塑件/压铸件丢失脱模斜度,脱模卡死、分型面配合错误,且全程
|
||||
无警告(与 #1 y_dir / #3 revolve.reverse 同族的静默错误)。
|
||||
CADFS lowering now maps a single-sided blind draft to
|
||||
``Build123dGeometryAdapter.extrude_taper``. The CDSL machine schema uses
|
||||
``angle_deg`` plus ``pull_direction`` for that executable contract.
|
||||
|
||||
2. 修复策略:因为 build123d 内核没有锥形拉伸能力、且人读契约未声明
|
||||
draft,正确的合同是"显式拒绝"而不是"实现拔模"——
|
||||
capabilities.py 对携带 draft 的 extrude 特征报 unsupported_draft
|
||||
blocker(与 unsupported_extent 同模式)。schema 字段保留(文档格式
|
||||
契约,importer 未来可能产出),能力层明确划界。
|
||||
|
||||
3. 本测试套件把"draft 必须显式拒绝"固定下来:
|
||||
- 主契约:带 draft 的 extrude 特征 → analyze 报 unsupported_draft
|
||||
blocker,runtime_eligible=False;
|
||||
- 回归护栏:不带 draft 的 extrude 特征 → 仍 runtime_eligible;
|
||||
- 文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json(拒绝
|
||||
发生在能力层,不是 schema 层);
|
||||
- 覆盖:extrude_add_blind / extrude_add_two_sided / extrude_cut_blind
|
||||
三种原子都报同一 blocker(同一检查全类生效);
|
||||
- 端到端:rebuild_cdsl(strict)带 draft → 抛 ValueError(大声失败),
|
||||
analyze_document → runtime_eligible=False 且不 built(批量重建
|
||||
不被静默污染)。
|
||||
A two-sided or non-blind draft has no defined neutral-plane semantics in
|
||||
the current CDSL runtime. It must remain in the input document but stop
|
||||
with ``unsupported_draft_extent`` before execution. Non-canonical draft
|
||||
fields are protocol errors and are rejected by the machine schema.
|
||||
|
||||
4. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import
|
||||
cdsl_engine 包做端到端测试(与 test_engine_revolve_reverse.py 风格
|
||||
@@ -97,15 +73,16 @@ def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
|
||||
def _extrude_cdsl(*, atomic_id: str = "extrude_add_blind", with_draft: bool = True) -> dict:
|
||||
"""构造最小 extrude 文档。
|
||||
|
||||
- with_draft=True 时 params 携带 draft 对象(任意非空 object 即可,
|
||||
因为 cdsl_schema.json 对 draft 没有子字段约束);
|
||||
- with_draft=True 时 params 携带可执行的单侧盲向 draft 对象;
|
||||
- with_draft=False 时完全不写 draft 字段(回归护栏用)。
|
||||
- atomic_id 可切换 extrude_add_blind / extrude_add_two_sided /
|
||||
extrude_cut_blind 三种原子,验证同一 blocker 检查对全类生效。
|
||||
"""
|
||||
params: dict = {"distance_mm": 10.0}
|
||||
if atomic_id == "extrude_add_two_sided":
|
||||
params["reverse_distance_mm"] = 10.0
|
||||
if with_draft:
|
||||
params["draft"] = {"angle_deg": 5.0, "direction": "toward_sketch"}
|
||||
params["draft"] = {"angle_deg": 5.0, "pull_direction": True}
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "draft-contract", "meta": {"unit": "mm"},
|
||||
@@ -116,7 +93,7 @@ def _extrude_cdsl(*, atomic_id: str = "extrude_add_blind", with_draft: bool = Tr
|
||||
}]},
|
||||
"features": [{
|
||||
"id": "base_add", "atomic_id": atomic_id, "depends_on": [], "sketch_id": "base",
|
||||
"params": params,
|
||||
"params": params, "execution_status": "supported",
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -134,20 +111,13 @@ def _validate_against_cdsl_schema(doc: dict) -> None:
|
||||
class ExtrudeDraftContractTests(unittest.TestCase):
|
||||
"""draft 假接受陷阱「文档格式-能力边界-运行时」三方合同的回归测试。"""
|
||||
|
||||
def test_draft_extrude_is_explicitly_blocked(self) -> None:
|
||||
"""主契约:带 draft 的 extrude 特征必须被显式拒绝,而不是静默通过。
|
||||
|
||||
修复前(当前):capabilities 对 extrude 只检查 end_condition,
|
||||
draft 字段完全无人过问 → analyze 报 runtime_eligible=True,
|
||||
rebuild 静默产出直壁实体 → 本测试红灯。
|
||||
修复后:capabilities 报 unsupported_draft blocker →
|
||||
runtime_eligible=False → 绿灯。
|
||||
"""
|
||||
def test_blind_draft_extrude_is_runtime_eligible(self) -> None:
|
||||
"""A canonical single-sided blind draft reaches the taper executor."""
|
||||
analysis = analyze_cdsl(_extrude_cdsl(with_draft=True))
|
||||
|
||||
self.assertFalse(analysis.runtime_eligible)
|
||||
self.assertTrue(analysis.runtime_eligible)
|
||||
result = next(item for item in analysis.feature_results if item.feature_id == "base_add")
|
||||
self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers])
|
||||
self.assertNotIn("unsupported_draft_extent", [blocker.code for blocker in result.blockers])
|
||||
|
||||
def test_draft_free_extrude_stays_eligible(self) -> None:
|
||||
"""回归护栏:不带 draft 的 extrude 特征仍必须 runtime_eligible。
|
||||
@@ -160,52 +130,38 @@ class ExtrudeDraftContractTests(unittest.TestCase):
|
||||
self.assertTrue(analysis.runtime_eligible)
|
||||
|
||||
def test_draft_passes_machine_schema(self) -> None:
|
||||
"""文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json 校验。
|
||||
|
||||
cdsl_schema.json(extrudeParams)保留 draft 字段,拒绝发生在
|
||||
能力层(capabilities),不是 schema 层。这条测试锁死"schema 允许
|
||||
+ 能力拒绝"的分层职责,防止未来把 schema 改过头(删掉字段后
|
||||
importer 未来产出 draft 会直接被 schema 打回,失去可诊断性)。
|
||||
"""
|
||||
"""Canonical draft input is a valid CDSL document for each extrude form."""
|
||||
_validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_blind", with_draft=True))
|
||||
_validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_two_sided", with_draft=True))
|
||||
_validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_cut_blind", with_draft=True))
|
||||
|
||||
def test_draft_blocks_every_extrude_atomic(self) -> None:
|
||||
"""覆盖:三种 extrude 原子都报同一个 unsupported_draft blocker。
|
||||
def test_two_sided_draft_is_explicitly_blocked(self) -> None:
|
||||
analysis = analyze_cdsl(_extrude_cdsl(atomic_id="extrude_add_two_sided", with_draft=True))
|
||||
|
||||
draft 检查挂在 _SKETCH_ATOM_PREFIXES(extrude_/revolve_)公共入口,
|
||||
必须对 extrude_add_blind / extrude_add_two_sided / extrude_cut_blind
|
||||
同时生效,而不是只修了某一个。
|
||||
"""
|
||||
for atomic_id in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"):
|
||||
with self.subTest(atomic_id=atomic_id):
|
||||
analysis = analyze_cdsl(_extrude_cdsl(atomic_id=atomic_id, with_draft=True))
|
||||
result = next(item for item in analysis.feature_results if item.feature_id == "base_add")
|
||||
self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers])
|
||||
self.assertFalse(analysis.runtime_eligible)
|
||||
result = next(item for item in analysis.feature_results if item.feature_id == "base_add")
|
||||
self.assertIn("unsupported_draft_extent", [blocker.code for blocker in result.blockers])
|
||||
|
||||
def test_draft_rebuild_fails_loudly_not_silently(self) -> None:
|
||||
"""端到端:draft 文档的重建必须大声失败,而不是静默产出直壁实体。
|
||||
def test_blind_draft_rebuilds_and_batch_reports_an_artifact(self) -> None:
|
||||
"""The supported branch builds a STEP artifact through both entry points."""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
out_step = Path(directory) / "part.step"
|
||||
rebuild_cdsl(_extrude_cdsl(with_draft=True), out_step)
|
||||
self.assertTrue(out_step.exists())
|
||||
|
||||
修复前:rebuild_cdsl(strict)对 draft 视而不见 → 正常返回实体,
|
||||
volume > 0,但几何是**没有拔模角的直壁**——静默错误。
|
||||
修复后:rebuild_cdsl(strict)因 runtime_eligible=False 抛
|
||||
ValueError(feature is not runtime eligible: unsupported_draft);
|
||||
analyze_document 报告 runtime_eligible=False 且不 built——
|
||||
批量重建不会被静默污染。
|
||||
"""
|
||||
# 1) strict 重建直接抛错(大声失败)。
|
||||
with self.assertRaisesRegex(ValueError, "unsupported_draft"):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
rebuild_cdsl(_extrude_cdsl(with_draft=True), Path(directory) / "part.step")
|
||||
|
||||
# 2) 批量层 analyze_document 报告不可执行且不产出 STEP。
|
||||
cdsl = _extrude_cdsl(with_draft=True)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
out_step = Path(directory) / "part.step"
|
||||
report = _analyze_inline(cdsl, out_step)
|
||||
self.assertFalse(report["runtime_eligible"])
|
||||
self.assertFalse(report.get("built", False))
|
||||
self.assertTrue(out_step.exists())
|
||||
self.assertTrue(report["runtime_eligible"])
|
||||
self.assertTrue(report["built"])
|
||||
|
||||
def test_noncanonical_draft_is_rejected_by_machine_schema(self) -> None:
|
||||
cdsl = _extrude_cdsl(with_draft=True)
|
||||
cdsl["features"][0]["params"]["draft"] = {"angle_deg": 5.0, "direction": "toward_sketch"}
|
||||
with self.assertRaises(jsonschema.ValidationError):
|
||||
_validate_against_cdsl_schema(cdsl)
|
||||
|
||||
|
||||
def _analyze_inline(cdsl: dict, out_step: Path) -> dict:
|
||||
|
||||
@@ -916,7 +916,6 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
self.assertEqual(resolved.record.record_id, "sweep:end")
|
||||
self.assertEqual(resolved.record.output_roles, ("sweep.end",))
|
||||
self.assertEqual(evidence["output_role_status"], "unique_result_snapshot")
|
||||
|
||||
registry.replace_body_topology("later", "body:later", [
|
||||
TopologyRecord("later:end", "face", "later", "body:later", {"center_mm": [0, 0, 10]}, object()),
|
||||
])
|
||||
@@ -926,6 +925,103 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
self.assertEqual(stale.status, "not_found")
|
||||
self.assertEqual(stale.diagnostic.code, "selector_output_role_not_found")
|
||||
|
||||
def test_provenance_selector_uses_exact_lineage_not_geometry_successors(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
source = object()
|
||||
exact_result = object()
|
||||
geometrically_similar = object()
|
||||
registry.replace_body_topology("base", "body:base", [
|
||||
TopologyRecord("base:face", "face", "base", "body:base", {"center_mm": [0, 0, 0]}, source),
|
||||
])
|
||||
registry.replace_body_topology("later", "body:later", [
|
||||
TopologyRecord("later:exact", "face", "later", "body:later", {"center_mm": [10, 0, 0]}, exact_result),
|
||||
TopologyRecord("later:similar", "face", "later", "body:later", {"center_mm": [0, 0, 0]}, geometrically_similar),
|
||||
], topology_delta=TopologyDelta("transform", (
|
||||
TopologyDeltaRelation("modified", "face", source, (exact_result,)),
|
||||
)))
|
||||
selector = {
|
||||
"kind": "face", "owner_feature_id": "base", "stable_id": "base:face",
|
||||
"source": "runtime_snapshot", "confidence": 1.0,
|
||||
"geometry": {"center_mm": [0, 0, 0]},
|
||||
"selector_intent": {
|
||||
"version": "1.0", "kind": "face", "query_family": "SWEPT_FACE",
|
||||
"source_query": {"ast": {}, "featurescript_version": "1511"},
|
||||
"derivation_policy": {"allowed": ["continuation"], "multiplicity": "one"},
|
||||
"evidence": "kernel_history",
|
||||
},
|
||||
}
|
||||
resolution = registry.resolve(selector, active_body_id="body:later")
|
||||
self.assertEqual(resolution.status, "resolved")
|
||||
self.assertEqual(resolution.record.record_id, "later:exact")
|
||||
self.assertEqual(registry.lineage()[0].derivation, "continuation")
|
||||
|
||||
def test_provenance_selector_rejects_non_unique_fragment(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
source = object()
|
||||
first = object()
|
||||
second = object()
|
||||
registry.replace_body_topology("base", "body:base", [
|
||||
TopologyRecord("base:edge", "edge", "base", "body:base", {"center_mm": [0, 0, 0]}, source),
|
||||
])
|
||||
registry.replace_body_topology("fillet", "body:fillet", [
|
||||
TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {"center_mm": [0, 0, 0]}, first),
|
||||
TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {"center_mm": [1, 0, 0]}, second),
|
||||
], topology_delta=TopologyDelta("fillet", (
|
||||
TopologyDeltaRelation("modified", "edge", source, (first, second)),
|
||||
)))
|
||||
resolution = registry.resolve({
|
||||
"kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge",
|
||||
"source": "runtime_snapshot", "confidence": 1.0,
|
||||
"selector_intent": {
|
||||
"version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE",
|
||||
"source_query": {"ast": {}, "featurescript_version": "1511"},
|
||||
"derivation_policy": {"allowed": ["fragment"], "multiplicity": "one"},
|
||||
"evidence": "kernel_history",
|
||||
},
|
||||
}, active_body_id="body:fillet")
|
||||
self.assertEqual(resolution.status, "ambiguous")
|
||||
self.assertEqual(resolution.diagnostic.code, "selector_relation_non_unique")
|
||||
|
||||
def test_provenance_selector_returns_all_proven_fragments(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
source = object()
|
||||
first = object()
|
||||
second = object()
|
||||
registry.replace_body_topology("base", "body:base", [
|
||||
TopologyRecord("base:edge", "edge", "base", "body:base", {}, source),
|
||||
])
|
||||
registry.replace_body_topology("fillet", "body:fillet", [
|
||||
TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {}, first),
|
||||
TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {}, second),
|
||||
], topology_delta=TopologyDelta("fillet", (
|
||||
TopologyDeltaRelation("modified", "edge", source, (first, second)),
|
||||
)))
|
||||
resolution = registry.resolve({
|
||||
"kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge",
|
||||
"source": "runtime_snapshot", "confidence": 1.0,
|
||||
"selector_intent": {
|
||||
"version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE",
|
||||
"source_query": {"ast": {}, "featurescript_version": "1511"},
|
||||
"derivation_policy": {"allowed": ["fragment"], "multiplicity": "all_fragments"},
|
||||
"evidence": "kernel_history",
|
||||
},
|
||||
}, active_body_id="body:fillet")
|
||||
self.assertEqual(resolution.status, "resolved")
|
||||
self.assertIsNone(resolution.record)
|
||||
self.assertEqual([record.record_id for record in resolution.records], ["fillet:first", "fillet:second"])
|
||||
|
||||
def test_boolean_section_edges_are_recorded_as_intersection_lineage(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
section_edge = object()
|
||||
registry.replace_body_topology("boolean", "body:boolean", [
|
||||
TopologyRecord("boolean:section", "edge", "boolean", "body:boolean", {}, section_edge),
|
||||
], topology_delta=TopologyDelta("intersect", section_values=(section_edge,)))
|
||||
delta = registry.topology_deltas()[0]
|
||||
lineage = delta["lineage"][0]
|
||||
self.assertEqual(lineage["derivation"], "intersection")
|
||||
self.assertEqual(lineage["result_record_ids"], ["boolean:section"])
|
||||
self.assertTrue(delta["relations"][0]["section_edge"])
|
||||
|
||||
def test_shell_offset_role_source_selects_one_exact_builder_relation(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
extrude_start = object()
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
|
||||
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
||||
from app.cad_agent.adapters.runtime import ProfileCadRuntime
|
||||
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
|
||||
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
|
||||
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
||||
from app.cad_agent.application.llm_contracts import CompiledRequirementsSpec, MarkdownDocument
|
||||
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.results import Accepted, Rejected
|
||||
from app.cad_agent.domain.state import TaskPhase, transition
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
|
||||
|
||||
def runtime_settings(root: Path) -> Settings:
|
||||
provider = ProviderConfig("test", "Test", "https://test.invalid/v1", "key", (ProviderModel("test-model"),))
|
||||
return Settings(
|
||||
task_root=root / "tasks", conversation_root=root / "conversations",
|
||||
library_root=ROOT / "backend" / "cdsl_library", engine_root=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,),
|
||||
)
|
||||
|
||||
|
||||
def contract() -> dict[str, object]:
|
||||
return {
|
||||
"requirements": [
|
||||
{
|
||||
"requirement_id": "req_001",
|
||||
"acceptance_claims": [
|
||||
{"claim_id": "claim_base", "verification_mode": "deterministic"},
|
||||
{"claim_id": "claim_visual", "verification_mode": "visual"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"requirement_id": "req_002",
|
||||
"acceptance_claims": [{"claim_id": "claim_bore", "verification_mode": "deterministic"}],
|
||||
},
|
||||
{
|
||||
"requirement_id": "req_003",
|
||||
"acceptance_claims": [{"claim_id": "claim_pattern", "verification_mode": "deterministic"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def initial_plan() -> FeaturePlan:
|
||||
return FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1",
|
||||
"parent_plan_hash": "",
|
||||
"replaces_node_ids": [],
|
||||
"nodes": [
|
||||
{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_base"], "expected_change": "One base solid."},
|
||||
{"node_id": "bore", "priority": 20, "intent": "Cut the bore.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_bore"], "expected_change": "One through bore."},
|
||||
{"node_id": "pattern", "priority": 30, "intent": "Add the pattern.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_pattern"], "expected_change": "Mounting holes."},
|
||||
],
|
||||
"final_claim_ids": ["claim_visual"],
|
||||
})
|
||||
|
||||
|
||||
class FeaturePlanTests(unittest.TestCase):
|
||||
def test_plan_requires_exact_claim_ownership(self) -> None:
|
||||
plan = initial_plan()
|
||||
self.assertEqual(validate_feature_plan(plan, contract(), {"extrude_add_blind", "hole_blind"}), [])
|
||||
broken = plan.model_copy(deep=True)
|
||||
broken.nodes[1].claim_ids = ["claim_base"]
|
||||
messages = [item["message"] for item in validate_feature_plan(broken, contract(), {"extrude_add_blind", "hole_blind"})]
|
||||
self.assertTrue(any("already owned" in message for message in messages))
|
||||
self.assertTrue(any("has no owner" in message for message in messages))
|
||||
|
||||
def test_visual_claim_repeated_on_a_node_is_removed_before_persisting_the_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
handler = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=lambda: ("extrude_add_blind", "hole_blind"))
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a part.")
|
||||
artifacts.initialize_task(task_id, "Create a part.")
|
||||
self.assertIsInstance(handler.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a part."), invocation_id="requirements"), Accepted)
|
||||
self.assertIsInstance(handler.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] A base and a visual edge treatment."), invocation_id="target"), Accepted)
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "visual", "expected": {"description": "A visible edge treatment."}},
|
||||
]}]})
|
||||
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
|
||||
submitted = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One base solid."}],
|
||||
"final_claim_ids": ["claim_002"],
|
||||
})
|
||||
accepted = handler.submit_feature_plan(task_id, submitted, invocation_id="plan")
|
||||
self.assertIsInstance(accepted, Accepted)
|
||||
state = repository.get_state(task_id)
|
||||
persisted = artifacts.read_json(task_id, state.feature_plan_path)
|
||||
self.assertEqual(persisted["nodes"][0]["claim_ids"], ["claim_001"])
|
||||
self.assertEqual(persisted["final_claim_ids"], ["claim_002"])
|
||||
|
||||
def test_unowned_global_health_claim_is_bound_to_the_unique_root_add_feature(self) -> None:
|
||||
plan = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [
|
||||
{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_bore"], "expected_change": "One base solid."},
|
||||
{"node_id": "finish", "priority": 20, "intent": "Finish the part.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_pattern"], "expected_change": "One bore."},
|
||||
],
|
||||
"final_claim_ids": ["claim_visual"],
|
||||
})
|
||||
health_contract = contract()
|
||||
health_contract["requirements"][0]["acceptance_claims"][0]["claim_kind"] = "single_connected_body"
|
||||
normalized = RequirementsCommandHandler._assign_unowned_global_health_claims(plan, health_contract)
|
||||
self.assertEqual(normalized.nodes[0].claim_ids, ["claim_bore", "claim_base"])
|
||||
self.assertEqual(validate_feature_plan(normalized, health_contract, {"extrude_add_blind", "hole_blind"}), [])
|
||||
|
||||
def test_scheduler_uses_ready_nodes_and_fixed_priority(self) -> None:
|
||||
plan = initial_plan()
|
||||
scheduler = FeatureScheduler(plan, [])
|
||||
self.assertEqual(scheduler.next_ready().node_id, "base")
|
||||
base = plan.nodes[0]
|
||||
events = [{
|
||||
"event": "feature_node_verified", "node_id": "base", "node_hash": node_hash(base),
|
||||
"feature_id": "feature_001", "revision_id": "rev_001",
|
||||
}]
|
||||
scheduler = FeatureScheduler(plan, events)
|
||||
self.assertEqual(scheduler.statuses()["base"], "done")
|
||||
# Both bore and pattern are ready; priority decides deterministically.
|
||||
self.assertEqual(scheduler.next_ready().node_id, "bore")
|
||||
self.assertEqual(scheduler.feature_ids(), {"base": "feature_001"})
|
||||
|
||||
def test_revision_cannot_change_done_node_and_replaces_failed_subgraph(self) -> None:
|
||||
previous = initial_plan()
|
||||
base = previous.nodes[0]
|
||||
bore = previous.nodes[1]
|
||||
events = [
|
||||
{"event": "feature_node_verified", "node_id": "base", "node_hash": node_hash(base), "feature_id": "feature_001", "revision_id": "rev_001"},
|
||||
{"event": "feature_node_failed", "node_id": "bore", "node_hash": node_hash(bore), "failure_class": "engine_build", "attempt": 2, "terminal": True},
|
||||
]
|
||||
completed = FeatureScheduler(previous, events).completed_node_hashes()
|
||||
revision = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1",
|
||||
"parent_plan_hash": plan_hash(previous),
|
||||
"replaces_node_ids": ["bore"],
|
||||
"nodes": [
|
||||
base.model_dump(mode="json"),
|
||||
{"node_id": "bore_revised", "priority": 20, "intent": "Cut the bore with revised operation.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_bore"], "expected_change": "One through bore."},
|
||||
previous.nodes[2].model_dump(mode="json"),
|
||||
],
|
||||
"final_claim_ids": ["claim_visual"],
|
||||
})
|
||||
self.assertEqual(
|
||||
validate_feature_plan(revision, contract(), {"extrude_add_blind", "hole_blind"}, previous_plan=previous, completed_node_hashes=completed, required_replacements={"bore"}),
|
||||
[],
|
||||
)
|
||||
changed = revision.model_copy(deep=True)
|
||||
changed.nodes[0].intent = "Changed completed node."
|
||||
self.assertTrue(any("completed node 'base' was modified" in item["message"] for item in validate_feature_plan(changed, contract(), {"extrude_add_blind", "hole_blind"}, previous_plan=previous, completed_node_hashes=completed, required_replacements={"bore"})))
|
||||
|
||||
def test_materialized_feature_uses_direct_dag_dependencies_not_previous_history_item(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(runtime_settings(Path(temporary)))
|
||||
base = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "dag_test",
|
||||
"geometry": {"sketches": [{
|
||||
"id": "sketch_001", "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
|
||||
}]},
|
||||
"features": [
|
||||
{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"},
|
||||
{"id": "feature_002", "atomic_id": "reference_plane", "params": {"plane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}}, "depends_on": ["feature_001"]},
|
||||
],
|
||||
}
|
||||
contract = runtime.operation_contract("reference_axis")
|
||||
document, audit = runtime.materialize_fragment(
|
||||
base,
|
||||
{"feature": {"atomic_id": "reference_axis", "params": {"axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}}}},
|
||||
contract,
|
||||
{},
|
||||
runtime.reference_tokens(base),
|
||||
depends_on_feature_ids=("feature_001",),
|
||||
)
|
||||
self.assertEqual(document["features"][-1]["depends_on"], ["feature_001"])
|
||||
self.assertEqual(audit["depends_on_feature_ids"], ["feature_001"])
|
||||
|
||||
def test_required_through_cut_gets_a_server_recorded_exit_allowance(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(runtime_settings(Path(temporary)))
|
||||
base = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "through_cut",
|
||||
"geometry": {"sketches": [{
|
||||
"id": "sketch_001",
|
||||
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
|
||||
}]},
|
||||
"features": [{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"}],
|
||||
}
|
||||
document, audit = runtime.materialize_fragment(
|
||||
base,
|
||||
{
|
||||
"sketch": {
|
||||
"workplane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 2},
|
||||
},
|
||||
"feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 5, "reverse": True}},
|
||||
},
|
||||
runtime.operation_contract("extrude_cut_blind"),
|
||||
{"body": {"kind": "body", "geometry": {"bbox_mm": [-10, -10, 0, 10, 10, 5]}}},
|
||||
runtime.reference_tokens(base),
|
||||
require_through=True,
|
||||
depends_on_feature_ids=("feature_001",),
|
||||
)
|
||||
self.assertAlmostEqual(document["features"][-1]["params"]["distance_mm"], 5.01)
|
||||
self.assertEqual(audit["server_normalizations"][0]["submitted_mm"], 5.0)
|
||||
self.assertEqual(audit["server_normalizations"][0]["reason"], "required through-cut exit allowance")
|
||||
|
||||
def test_verified_node_publishes_without_a_render_bundle_or_candidate_review(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
registry = default_registry()
|
||||
runtime = ProfileCadRuntime(runtime_settings(root))
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids)
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(registry))
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a disk.")
|
||||
artifacts.initialize_task(task_id, "Create a disk.")
|
||||
self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted)
|
||||
self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One connected disk with 20 mm diameter and 5 mm thickness."), invocation_id="target"), Accepted)
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "axial_span_mm": 5, "tolerance_mm": 0.1}},
|
||||
]}]})
|
||||
self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
|
||||
plan = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One disk solid."}],
|
||||
"final_claim_ids": [],
|
||||
})
|
||||
self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.SCHEDULING_FEATURE)
|
||||
self.assertIsInstance(actions.schedule_next_feature(task_id), Accepted)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FEATURE_PENDING)
|
||||
result = actions.submit_feature_fragment(task_id, {
|
||||
"sketch": {"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}},
|
||||
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}},
|
||||
}, invocation_id="fragment")
|
||||
self.assertIsInstance(result, Accepted)
|
||||
state = repository.get_state(task_id)
|
||||
self.assertEqual(state.phase, TaskPhase.SCHEDULING_FEATURE)
|
||||
self.assertEqual(state.active_revision, "rev_001")
|
||||
self.assertFalse((artifacts.task_dir(task_id) / "revisions" / "rev_001" / "renders").exists())
|
||||
events = repository.ledger_events(task_id)
|
||||
self.assertTrue(any(event.get("event") == "feature_node_verified" for event in events))
|
||||
self.assertFalse(any(event.get("event") == "candidate_built" for event in events))
|
||||
final_gate = actions.schedule_next_feature(task_id)
|
||||
self.assertIsInstance(final_gate, Accepted)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FINAL_VALIDATION)
|
||||
self.assertTrue(any(event.get("event") == "feature_plan_complete" for event in repository.ledger_events(task_id)))
|
||||
|
||||
def test_feature_plan_schema_binds_plan_revision_lineage_to_state(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
runtime = ProfileCadRuntime(runtime_settings(root))
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=runtime.supported_atomic_ids)
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a disk.")
|
||||
artifacts.initialize_task(task_id, "Create a disk.")
|
||||
self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted)
|
||||
self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One disk."), invocation_id="target"), Accepted)
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
]}]})
|
||||
self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
|
||||
plan = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001"], "expected_change": "One disk solid."}],
|
||||
"final_claim_ids": [],
|
||||
})
|
||||
self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted)
|
||||
scheduled = transition(repository.get_state(task_id), "feature_scheduled")
|
||||
self.assertTrue(repository.compare_and_swap(scheduled))
|
||||
replanning = transition(repository.get_state(task_id), "feature_replan")
|
||||
self.assertTrue(repository.compare_and_swap(replanning))
|
||||
schema = requirements.feature_plan_schema(task_id)
|
||||
properties = schema["properties"]
|
||||
self.assertEqual(properties["parent_plan_hash"]["enum"], [plan_hash(plan)])
|
||||
self.assertEqual(properties["replaces_node_ids"]["minItems"], 0)
|
||||
self.assertEqual(properties["replaces_node_ids"]["maxItems"], 0)
|
||||
node_schema = schema["$defs"]["FeatureNode"]["properties"]
|
||||
self.assertEqual(node_schema["claim_ids"]["items"], {"enum": ["claim_001"]})
|
||||
self.assertEqual(properties["final_claim_ids"]["items"], {"enum": []})
|
||||
|
||||
def test_pending_assigned_claim_rejects_the_node_checkpoint(self) -> None:
|
||||
class PendingAssignedClaimVerifier:
|
||||
def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"claim_id": str(claim.get("claim_id") or ""),
|
||||
"claim_kind": str(claim.get("claim_kind") or ""),
|
||||
"deterministic": True,
|
||||
"status": "pending" if claim.get("claim_id") == "claim_002" else "pass",
|
||||
"evidence": {"reason": "target geometry has not been introduced"},
|
||||
}
|
||||
for claim in claims
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
runtime = ProfileCadRuntime(runtime_settings(root))
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=runtime.supported_atomic_ids)
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, PendingAssignedClaimVerifier())
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a disk.")
|
||||
artifacts.initialize_task(task_id, "Create a disk.")
|
||||
self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted)
|
||||
self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One disk."), invocation_id="target"), Accepted)
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "tolerance_mm": 0.1}},
|
||||
]}]})
|
||||
self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
|
||||
plan = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One disk solid."}],
|
||||
"final_claim_ids": [],
|
||||
})
|
||||
self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted)
|
||||
self.assertIsInstance(actions.schedule_next_feature(task_id), Accepted)
|
||||
result = actions.submit_feature_fragment(task_id, {
|
||||
"sketch": {"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}},
|
||||
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}},
|
||||
}, invocation_id="fragment")
|
||||
self.assertIsInstance(result, Rejected)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FEATURE_PENDING)
|
||||
events = repository.ledger_events(task_id)
|
||||
failure = next(event for event in reversed(events) if event.get("event") == "feature_node_failed")
|
||||
self.assertEqual(failure["failure_class"], "node_validation")
|
||||
self.assertTrue(any(item.get("claim_id") == "claim_002" and item.get("status") == "pending" for item in failure["blockers"]))
|
||||
self.assertFalse(any(event.get("event") == "feature_node_verified" for event in events))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,71 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.cad_agent.evals.live import _fixture, compare_guidance_reports # noqa: E402
|
||||
|
||||
|
||||
def _result(*, scenario: str, repetition: int, author_calls: int, context_chars: int, schema_rejections: int, failure_layer: str = "") -> dict:
|
||||
return {
|
||||
"scenario": scenario,
|
||||
"repetition": repetition,
|
||||
"outcome": "passed",
|
||||
"revision_ids": ["revision_001"],
|
||||
"projection": {"phase": "COMPLETED"},
|
||||
"checks": {"deterministic_claims_pass": True},
|
||||
"schema_rejection_count": schema_rejections,
|
||||
"failure_attribution": {"layer": failure_layer} if failure_layer else None,
|
||||
"scenario_budget": {"max_author_turns": 8, "max_reviewer_turns": 2, "max_total_calls": 10, "max_total_tokens": 1000},
|
||||
"usage": {"records": [{"context_chars": context_chars} for _ in range(author_calls)]},
|
||||
}
|
||||
|
||||
|
||||
def _report(results: list[dict]) -> dict:
|
||||
return {
|
||||
"author": {"provider": "author", "model": "model"},
|
||||
"reviewer": {"provider": "reviewer", "model": "review"},
|
||||
"runtime_profile_sha256": "a" * 64,
|
||||
"operation_contracts": [{"atomic_id": "extrude_add_blind", "contract_hash": "b" * 64}],
|
||||
"author_guidance": {"enabled": False, "max_chars": 3600},
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
class LiveGuidanceComparisonTests(unittest.TestCase):
|
||||
def test_fixture_accepts_multiple_stable_scenarios_in_fixture_order(self) -> None:
|
||||
selected = _fixture("comprehensive", ["l_bracket", "circular_flange_pcd"])
|
||||
self.assertEqual([item["id"] for item in selected], ["circular_flange_pcd", "l_bracket"])
|
||||
|
||||
def test_comparison_enforces_matched_budget_and_quality_gates(self) -> None:
|
||||
control = _report([
|
||||
_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=2, failure_layer="cdsl_expression"),
|
||||
_result(scenario="part_a", repetition=2, author_calls=10, context_chars=1000, schema_rejections=1),
|
||||
])
|
||||
treatment = _report([
|
||||
_result(scenario="part_a", repetition=1, author_calls=11, context_chars=1300, schema_rejections=0),
|
||||
_result(scenario="part_a", repetition=2, author_calls=11, context_chars=1300, schema_rejections=0),
|
||||
])
|
||||
treatment["author_guidance"]["enabled"] = True
|
||||
comparison = compare_guidance_reports(control, treatment)
|
||||
self.assertEqual(comparison["status"], "passed")
|
||||
self.assertTrue(comparison["gates"]["median_author_calls_within_ten_percent"])
|
||||
self.assertTrue(comparison["gates"]["model_or_cdsl_failure_improved"])
|
||||
|
||||
def test_comparison_excludes_explicit_unsupported_runtime_capability(self) -> None:
|
||||
control = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=1)])
|
||||
treatment = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1200, schema_rejections=0)])
|
||||
treatment["author_guidance"]["enabled"] = True
|
||||
treatment["results"][0]["ledger"] = [{"operation_failures": [{"message": "unsupported_draft"}]}]
|
||||
comparison = compare_guidance_reports(control, treatment)
|
||||
self.assertEqual(comparison["treatment"]["eligible_runs"], 0)
|
||||
self.assertEqual(comparison["excluded_capability_gaps"]["treatment"], [{"scenario": "part_a", "repetition": 1}])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.services.review_renderer import render_section # noqa: E402
|
||||
from app.services.render_bundle import render_section # noqa: E402
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ def settings(root: Path) -> Settings:
|
||||
)
|
||||
|
||||
|
||||
class ReviewRendererTests(unittest.TestCase):
|
||||
class RenderBundleTests(unittest.TestCase):
|
||||
def test_section_uses_build123d_part_operation_and_emits_a_png(self) -> None:
|
||||
import build123d as b3d
|
||||
|
||||
@@ -58,24 +58,3 @@ class SettingsModelSelectionTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(resolved_provider.id, "alternate")
|
||||
self.assertEqual(resolved_model.id, "alternate-first")
|
||||
|
||||
def test_reviewer_cannot_be_the_same_author_model(self) -> None:
|
||||
provider = ProviderConfig("openai", "OpenAI", "https://example.invalid/v1", "key", (ProviderModel("gpt-5.5", vision=True),))
|
||||
settings = Settings(
|
||||
task_root=ROOT / "tmp-tasks",
|
||||
conversation_root=ROOT / "tmp-conversations",
|
||||
library_root=ROOT / "backend" / "cdsl_library",
|
||||
engine_root=ROOT / "backend" / "engine" / "cdsl_engine",
|
||||
llm_base_url=provider.base_url,
|
||||
llm_api_key=provider.api_key,
|
||||
llm_model="gpt-5.5",
|
||||
llm_timeout_s=1,
|
||||
default_provider_id="openai",
|
||||
providers=(provider,),
|
||||
review_provider_id="openai",
|
||||
review_model_id="gpt-5.5",
|
||||
)
|
||||
|
||||
author_provider, author_model = settings.resolve_model(None, None)
|
||||
with self.assertRaisesRegex(ValueError, "must differ"):
|
||||
settings.resolve_independent_review_model(author_provider, author_model)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from app.cad_agent.application.single_stage import SingleStageExecutor
|
||||
|
||||
|
||||
class Artifacts:
|
||||
def __init__(self): self.writes = []
|
||||
def write_json_once(self, *_args): self.writes.append(_args); return "x"
|
||||
def start_staging_revision(self, *_args):
|
||||
return type("Stage", (), {"stage_id": "stage", "output_dir": "/tmp"})()
|
||||
def write_stage_json(self, *_args): return "x"
|
||||
def publish_staging_revision(self, *_args): return {"model.step": "x.step"}
|
||||
|
||||
|
||||
class Runtime:
|
||||
def compile_authoring(self, value): return ({"features": [{"id": "feature_001"}]}, {"feature_ids": {"base": "feature_001"}})
|
||||
def rebuild_best_effort(self, *_args): return ({"executed_feature_ids": ["feature_001"]}, [])
|
||||
|
||||
|
||||
def test_single_stage_publishes_compiled_document():
|
||||
result = SingleStageExecutor(None, Artifacts(), Runtime()).execute("cad_abcdefghijkl", {"bodies": []})
|
||||
assert result["status"] == "completed"
|
||||
assert result["executed_feature_ids"] == ["feature_001"]
|
||||
@@ -0,0 +1,27 @@
|
||||
from app.cad_agent.evals.single_stage import summarize
|
||||
|
||||
|
||||
def test_single_stage_summary_reports_protocol_quality_and_cost() -> None:
|
||||
report = summarize([
|
||||
{
|
||||
"attempts": [{"schema_valid": True, "executable": False}],
|
||||
"published_revision": "rev_1",
|
||||
"requirement_targets": [{"status": "pass"}, {"status": "pending"}],
|
||||
"usage": {"records": [{"prompt_tokens": 10, "completion_tokens": 2}]},
|
||||
"duration_ms": 15,
|
||||
},
|
||||
{
|
||||
"attempts": [{"schema_valid": False, "executable": False}],
|
||||
"requirement_targets": [{"status": "fail"}],
|
||||
"usage": {"records": [{"prompt_tokens": 3, "completion_tokens": 1}]},
|
||||
"duration_ms": 5,
|
||||
},
|
||||
])
|
||||
assert report["first_pass_schema_rate"] == 0.5
|
||||
assert report["first_pass_executable_rate"] == 0.0
|
||||
assert report["final_executable_rate"] == 0.5
|
||||
assert report["requirement_targets"] == {"pass": 1, "fail": 1, "pending": 1, "not_applicable": 0}
|
||||
assert report["calls"] == 2
|
||||
assert report["prompt_tokens"] == 13
|
||||
assert report["completion_tokens"] == 3
|
||||
assert report["duration_ms"] == 20
|
||||
@@ -0,0 +1,180 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
||||
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
|
||||
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
|
||||
from app.cad_agent.application.authoring_compiler import AuthoringCompileError
|
||||
from app.cad_agent.domain.errors import ErrorCode
|
||||
from app.cad_agent.domain.state import TaskPhase, transition
|
||||
|
||||
|
||||
class Runtime:
|
||||
def supported_atomic_ids(self): return ("extrude_add_blind",)
|
||||
def operation_contract(self, _operation):
|
||||
return {"atomic_id": "extrude_add_blind", "fragment_shape": {"sketch": "required", "selector_tokens": "forbidden"}, "selector_policy": {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0}, "reference_policy": {"mode": "none"}, "author_params_schema": {"type": "object", "properties": {"distance_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["distance_mm"], "additionalProperties": False}}
|
||||
|
||||
|
||||
class Executor:
|
||||
def __init__(self, artifacts): self.artifacts = artifacts
|
||||
def compile(self, _task, _authoring, *, repair_count):
|
||||
runtime, audit = {"features": []}, {}
|
||||
runtime_path, audit_path = "documents/runtime.json", "documents/audit.json"
|
||||
self.artifacts.write_json_once(_task, runtime_path, runtime)
|
||||
self.artifacts.write_json_once(_task, audit_path, audit)
|
||||
return {"runtime": runtime, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": "x"}
|
||||
def build(self, _task, _authoring, _runtime, _audit, *, repair_count, digest=""):
|
||||
return {"status": "completed", "revision_id": "rev_1", "paths": {"model.step": "revisions/rev_1/model.step"}, "executed_feature_ids": ["feature_001"], "diagnostics": []}
|
||||
|
||||
|
||||
class Models:
|
||||
def __init__(self): self.calls = 0
|
||||
async def call_tool(self, **kwargs):
|
||||
self.calls += 1
|
||||
if kwargs["required_tool_name"] == "analyze_requirements":
|
||||
payload = {"explicit_requirements": ["plate"], "assumptions": [], "acceptance_targets": [], "manual_targets": []}
|
||||
else:
|
||||
payload = {"bodies": [{"name": "main", "features": [{"name": "base", "operation": "extrude_add_blind", "params": {"distance_mm": 8}, "sketch": {"workplane": {"origin_mm": [0,0,0], "x_dir": [1,0,0], "normal": [0,0,1]}, "profile": {"type": "circle", "diameter_mm": 20}}}]}]}
|
||||
import json
|
||||
return {"tool_calls": [{"function": {"name": kwargs["required_tool_name"], "arguments": json.dumps(payload)}}], "usage": {}}
|
||||
|
||||
|
||||
class InvalidAuthorModels(Models):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.author_calls = 0
|
||||
|
||||
async def call_tool(self, **kwargs):
|
||||
if kwargs["required_tool_name"] == "analyze_requirements":
|
||||
return await super().call_tool(**kwargs)
|
||||
self.calls += 1
|
||||
self.author_calls += 1
|
||||
import json
|
||||
return {
|
||||
"tool_calls": [{"function": {
|
||||
"name": "write_authoring_cdsl",
|
||||
"arguments": json.dumps({"feature_id": "feature_001", "bodies": []}),
|
||||
}}],
|
||||
"usage": {},
|
||||
}
|
||||
|
||||
|
||||
def test_workflow_uses_two_author_calls_and_publishes():
|
||||
with TemporaryDirectory() as temporary:
|
||||
repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(Path(temporary) / "artifacts")
|
||||
models = Models()
|
||||
workflow = WorkflowCoordinator(WorkflowConfig(), repository, artifacts, Runtime(), models, Executor(artifacts))
|
||||
workflow.create_task("cad_abcdefghijkl", "make a plate")
|
||||
events = asyncio.run(_collect(workflow))
|
||||
assert models.calls == 2
|
||||
assert events[-1][0] == "task_terminal"
|
||||
assert repository.get_state("cad_abcdefghijkl").phase.value == "COMPLETED"
|
||||
|
||||
|
||||
def test_service_retry_returns_to_the_persisted_stage_without_new_authoring_call():
|
||||
with TemporaryDirectory() as temporary:
|
||||
repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(Path(temporary) / "artifacts")
|
||||
workflow = WorkflowCoordinator(WorkflowConfig(), repository, artifacts, Runtime(), Models(), Executor(artifacts))
|
||||
workflow.create_task("cad_abcdefghijkl", "make a plate")
|
||||
created = repository.get_state("cad_abcdefghijkl")
|
||||
assert created is not None
|
||||
authoring = transition(created, "analysis_written", requirements_path="documents/requirements-analysis.json")
|
||||
assert repository.compare_and_swap(authoring)
|
||||
failed = transition(authoring, "failed", error=ErrorCode.STORAGE_FAILURE)
|
||||
assert failed.phase == TaskPhase.FAILED
|
||||
assert failed.retry_from_phase == TaskPhase.AUTHORING_CDSL
|
||||
assert repository.compare_and_swap(failed)
|
||||
|
||||
assert workflow.resume("cad_abcdefghijkl")
|
||||
resumed = repository.get_state("cad_abcdefghijkl")
|
||||
assert resumed is not None
|
||||
assert resumed.phase == TaskPhase.AUTHORING_CDSL
|
||||
assert resumed.retry_from_phase is None
|
||||
|
||||
|
||||
def test_authoring_schema_failures_use_at_most_two_repairs():
|
||||
with TemporaryDirectory() as temporary:
|
||||
repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(Path(temporary) / "artifacts")
|
||||
models = InvalidAuthorModels()
|
||||
workflow = WorkflowCoordinator(WorkflowConfig(), repository, artifacts, Runtime(), models, Executor(artifacts))
|
||||
workflow.create_task("cad_abcdefghijkl", "make a plate")
|
||||
|
||||
asyncio.run(_collect(workflow))
|
||||
|
||||
state = repository.get_state("cad_abcdefghijkl")
|
||||
assert state is not None
|
||||
assert state.phase == TaskPhase.FAILED
|
||||
assert state.repair_count == 2
|
||||
assert models.author_calls == 3
|
||||
|
||||
|
||||
def test_author_operation_context_exposes_server_injected_selector_contract():
|
||||
context = WorkflowCoordinator._author_operation_contract({
|
||||
"fragment_shape": {"sketch": "forbidden", "selector_tokens": "required"},
|
||||
"selector_policy": {
|
||||
"slot": "params.host_face", "token_kind": "face", "min_items": 1, "max_items": 1,
|
||||
},
|
||||
"author_params_schema": {"type": "object", "properties": {}, "additionalProperties": False},
|
||||
})
|
||||
|
||||
assert context["sketch"] == "forbidden"
|
||||
assert context["selector"] == {
|
||||
"required": True,
|
||||
"kind": "face",
|
||||
"min_items": 1,
|
||||
"max_items": 1,
|
||||
"destination": "params.host_face",
|
||||
"source_syntax": "<local_feature_name>.<output_role>",
|
||||
}
|
||||
assert context["authoring_sketch_template"] is None
|
||||
|
||||
|
||||
def test_schema_repair_hint_contains_the_exact_authoring_sketch_form():
|
||||
assert "diameter_mm" in WorkflowCoordinator._repair_hint(
|
||||
"AUTHOR_SCHEMA_INVALID", "bodies.0.features.0.sketch.profile",
|
||||
)
|
||||
|
||||
|
||||
def test_repair_can_change_unexecuted_features_after_compile_failure():
|
||||
previous = {"bodies": [{"name": "main", "features": [
|
||||
{"name": "base", "operation": "box_add", "params": {}},
|
||||
{"name": "bad_hole", "operation": "hole_wizard", "params": {}},
|
||||
]}]}
|
||||
replacement = {"bodies": [{"name": "main", "features": [
|
||||
{"name": "base", "operation": "box_add", "params": {}},
|
||||
{"name": "bad_hole", "operation": "hole_wizard", "params": {"diameter_mm": 8}},
|
||||
]}]}
|
||||
WorkflowCoordinator._validate_repair_document(
|
||||
previous,
|
||||
replacement,
|
||||
{"diagnostics": [{"code": "SELECTOR_NOT_FOUND", "path": "features.bad_hole.selectors"}]},
|
||||
{"feature_ids": {"base": "feature_001", "bad_hole": "feature_002"}},
|
||||
)
|
||||
|
||||
|
||||
def test_repair_cannot_change_executed_feature_without_targeted_diagnostic():
|
||||
previous = {"bodies": [{"name": "main", "features": [
|
||||
{"name": "base", "operation": "box_add", "params": {"height_mm": 8}},
|
||||
]}]}
|
||||
replacement = {"bodies": [{"name": "main", "features": [
|
||||
{"name": "base", "operation": "box_add", "params": {"height_mm": 9}},
|
||||
]}]}
|
||||
try:
|
||||
WorkflowCoordinator._validate_repair_document(
|
||||
previous,
|
||||
replacement,
|
||||
{"diagnostics": [], "executed_feature_ids": ["feature_001"]},
|
||||
{"feature_ids": {"base": "feature_001"}},
|
||||
)
|
||||
except AuthoringCompileError as error:
|
||||
assert error.code == "AUTHOR_SCHEMA_INVALID"
|
||||
else:
|
||||
raise AssertionError("an executed feature was changed without a diagnostic target")
|
||||
|
||||
|
||||
async def _collect(workflow):
|
||||
return [item async for item in workflow.run(task_id="cad_abcdefghijkl", author=ModelIdentity("p", "m"))]
|
||||
Reference in New Issue
Block a user