新增cdsl
This commit is contained in:
@@ -62,11 +62,11 @@ class ParseToolArgumentsTests(unittest.TestCase):
|
||||
self.assertNotIn("extrude", cdsl["$defs"]["feature_atomic_ids"]["enum"])
|
||||
self.assertEqual(cdsl, CDSL_TOOL_SCHEMA)
|
||||
|
||||
def test_strict_tool_schema_is_limited_to_cdsl_generation_arguments(self) -> None:
|
||||
def test_strict_tool_schema_covers_design_intent_and_cdsl_generation_arguments(self) -> None:
|
||||
tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True))
|
||||
strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")]
|
||||
|
||||
self.assertEqual(strict_tools, ["generate_cdsl_model"])
|
||||
self.assertEqual(strict_tools, ["propose_design_intent", "generate_cdsl_model"])
|
||||
generate_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_cdsl_model")
|
||||
self.assertEqual(generate_tool["function"]["parameters"]["properties"]["summary"], {"type": "string", "minLength": 1})
|
||||
self.assertEqual(generate_tool["function"]["parameters"]["properties"]["cdsl"], CDSL_TOOL_SCHEMA)
|
||||
@@ -243,7 +243,7 @@ class ToolArgumentsRetryTests(unittest.TestCase):
|
||||
self.assertTrue(any("已修正工具参数" in str(event.get("text", "")) for event in events))
|
||||
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
|
||||
|
||||
def test_incomplete_cdsl_is_returned_to_the_model_without_creating_a_task(self) -> None:
|
||||
def test_direct_cdsl_generation_is_rejected_without_creating_a_task(self) -> None:
|
||||
class RetryAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -328,8 +328,8 @@ class ToolArgumentsRetryTests(unittest.TestCase):
|
||||
|
||||
tool_result = agent.seen_messages[1][-1]
|
||||
self.assertEqual(tool_result["role"], "tool")
|
||||
self.assertEqual(json.loads(str(tool_result["content"]))["code"], "INVALID_CDSL")
|
||||
self.assertEqual(agent.required_tools, [None, "generate_cdsl_model", None])
|
||||
self.assertEqual(json.loads(str(tool_result["content"]))["code"], "DESIGN_INTENT_REQUIRED")
|
||||
self.assertEqual(agent.required_tools, [None, None, None])
|
||||
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
|
||||
|
||||
def workplane(z: float = 0.0) -> dict[str, list[float]]:
|
||||
return {
|
||||
"origin_mm": [0.0, 0.0, z],
|
||||
"x_dir": [1.0, 0.0, 0.0],
|
||||
"y_dir": [0.0, 1.0, 0.0],
|
||||
"normal": [0.0, 0.0, 1.0],
|
||||
}
|
||||
|
||||
|
||||
def mounting_plate_intent() -> dict:
|
||||
return {
|
||||
"schema": "cad.cdsl.design-intent.v1",
|
||||
"schema_version": "1.0",
|
||||
"mode": "create",
|
||||
"request": "Create a mounting plate with four holes and a center slot",
|
||||
"base_revision_id": "",
|
||||
"structures": [
|
||||
{
|
||||
"id": "base_plate",
|
||||
"cdsl_feature_id": "base_add",
|
||||
"role": "base",
|
||||
"purpose": "Rectangular mounting plate",
|
||||
"depends_on": [],
|
||||
"cdsl_strategy": {
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"profile_type": "rectangle",
|
||||
"parameter_roles": ["width_mm", "height_mm", "thickness_mm"],
|
||||
"selector_roles": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "mounting_holes",
|
||||
"cdsl_feature_id": "hole_cut",
|
||||
"role": "subtractive",
|
||||
"purpose": "Four mounting holes",
|
||||
"depends_on": ["base_plate"],
|
||||
"cdsl_strategy": {
|
||||
"atomic_id": "extrude_cut_blind",
|
||||
"profile_type": "circle_grid",
|
||||
"parameter_roles": ["diameter_mm", "count_x", "count_y"],
|
||||
"selector_roles": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "adjustment_slot",
|
||||
"cdsl_feature_id": "slot_cut",
|
||||
"role": "subtractive",
|
||||
"purpose": "Center adjustment slot",
|
||||
"depends_on": ["mounting_holes"],
|
||||
"cdsl_strategy": {
|
||||
"atomic_id": "extrude_cut_blind",
|
||||
"profile_type": "obround",
|
||||
"parameter_roles": ["length_mm", "width_mm", "depth_mm"],
|
||||
"selector_roles": [],
|
||||
},
|
||||
},
|
||||
],
|
||||
"feature_order": ["base_plate", "mounting_holes", "adjustment_slot"],
|
||||
"assumptions": [],
|
||||
"open_questions": [],
|
||||
"capability_gaps": [],
|
||||
"verification_expectations": [
|
||||
{"type": "feature_count", "feature_id": "mounting_holes", "expected": 4},
|
||||
{"type": "symmetry", "axis": "x"},
|
||||
{"type": "bbox", "expected_mm": {"x": 100, "y": 60, "z": 10}},
|
||||
],
|
||||
"status": "ready",
|
||||
}
|
||||
|
||||
|
||||
def mounting_plate_cdsl() -> dict:
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.0",
|
||||
"kind": "part",
|
||||
"part_id": "mounting-plate",
|
||||
"geometry": {
|
||||
"sketches": [
|
||||
{"id": "base_sketch", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}},
|
||||
{"id": "holes_sketch", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}},
|
||||
{"id": "slot_sketch", "workplane": workplane(10), "profile": {"type": "obround", "center": [0, 0], "length_mm": 32, "width_mm": 10}},
|
||||
],
|
||||
},
|
||||
"features": [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}},
|
||||
{"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}},
|
||||
{"id": "slot_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["hole_cut"], "sketch_id": "slot_sketch", "params": {"distance_mm": 10, "reverse": True}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class DesignIntentValidationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
sys.path.insert(0, str(ROOT / "backend" / "engine"))
|
||||
import cdsl_engine
|
||||
|
||||
cls.engine = cdsl_engine
|
||||
|
||||
def assert_plan_error(self, intent: dict, code: str = "INVALID_DESIGN_INTENT") -> None:
|
||||
with self.assertRaises(self.engine.DesignIntentError) as context:
|
||||
self.engine.validate_design_intent(intent, self.engine)
|
||||
self.assertEqual(context.exception.code, code)
|
||||
|
||||
def test_valid_mounting_plate_plan_and_cdsl_match(self) -> None:
|
||||
intent = mounting_plate_intent()
|
||||
validated = self.engine.validate_design_intent(intent, self.engine)
|
||||
|
||||
self.assertEqual(validated["feature_order"], ["base_plate", "mounting_holes", "adjustment_slot"])
|
||||
self.engine.validate_intent_cdsl(validated, mounting_plate_cdsl(), self.engine)
|
||||
|
||||
def test_revise_requires_current_base_revision(self) -> None:
|
||||
intent = mounting_plate_intent()
|
||||
intent["mode"] = "revise"
|
||||
intent["base_revision_id"] = ""
|
||||
self.assert_plan_error(intent)
|
||||
|
||||
intent["base_revision_id"] = "rev_001"
|
||||
self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_001")
|
||||
with self.assertRaises(self.engine.DesignIntentError) as context:
|
||||
self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_002")
|
||||
self.assertEqual(context.exception.code, "INVALID_DESIGN_INTENT")
|
||||
|
||||
def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None:
|
||||
duplicate = mounting_plate_intent()
|
||||
duplicate["structures"][1]["id"] = "base_plate"
|
||||
self.assert_plan_error(duplicate)
|
||||
|
||||
cyclic = mounting_plate_intent()
|
||||
cyclic["structures"][0]["depends_on"] = ["mounting_holes"]
|
||||
self.assert_plan_error(cyclic)
|
||||
|
||||
unsupported = mounting_plate_intent()
|
||||
unsupported["structures"][0]["cdsl_strategy"]["atomic_id"] = "thread_add"
|
||||
self.assert_plan_error(unsupported)
|
||||
|
||||
incomplete = mounting_plate_intent()
|
||||
incomplete["feature_order"].pop()
|
||||
self.assert_plan_error(incomplete)
|
||||
|
||||
no_profile = mounting_plate_intent()
|
||||
no_profile["structures"][0]["cdsl_strategy"].pop("profile_type")
|
||||
self.assert_plan_error(no_profile)
|
||||
|
||||
unknown_expectation = mounting_plate_intent()
|
||||
unknown_expectation["verification_expectations"][0]["feature_id"] = "missing"
|
||||
self.assert_plan_error(unknown_expectation)
|
||||
|
||||
def test_blockers_and_nonblocking_approximations_are_explicit(self) -> None:
|
||||
clarification = mounting_plate_intent()
|
||||
clarification["open_questions"] = [{"id": "thickness", "question": "What thickness is required?", "blocking": True}]
|
||||
clarification["status"] = "needs_clarification"
|
||||
self.engine.validate_design_intent(clarification, self.engine)
|
||||
with self.assertRaises(self.engine.DesignIntentError) as context:
|
||||
self.engine.validate_intent_cdsl(clarification, mounting_plate_cdsl(), self.engine)
|
||||
self.assertEqual(context.exception.code, "DESIGN_INTENT_BLOCKED")
|
||||
|
||||
approximation = mounting_plate_intent()
|
||||
approximation["capability_gaps"] = [{
|
||||
"code": "unsupported_thread_geometry",
|
||||
"structure_id": "mounting_holes",
|
||||
"message": "Only a cylindrical bore is available.",
|
||||
"blocking": False,
|
||||
}]
|
||||
self.assert_plan_error(approximation)
|
||||
approximation["assumptions"] = ["Thread geometry is approximated by cylindrical bores."]
|
||||
self.engine.validate_design_intent(approximation, self.engine)
|
||||
|
||||
def test_rejects_cdsl_feature_atomic_profile_and_selector_mismatches(self) -> None:
|
||||
missing_feature = mounting_plate_cdsl()
|
||||
missing_feature["features"].pop()
|
||||
with self.assertRaises(self.engine.DesignIntentError) as context:
|
||||
self.engine.validate_intent_cdsl(mounting_plate_intent(), missing_feature, self.engine)
|
||||
self.assertEqual(context.exception.code, "INTENT_CDSL_MISMATCH")
|
||||
|
||||
atomic = mounting_plate_cdsl()
|
||||
atomic["features"][1]["atomic_id"] = "extrude_add_blind"
|
||||
with self.assertRaises(self.engine.DesignIntentError):
|
||||
self.engine.validate_intent_cdsl(mounting_plate_intent(), atomic, self.engine)
|
||||
|
||||
profile = mounting_plate_cdsl()
|
||||
profile["geometry"]["sketches"][1]["profile"]["type"] = "circles"
|
||||
with self.assertRaises(self.engine.DesignIntentError):
|
||||
self.engine.validate_intent_cdsl(mounting_plate_intent(), profile, self.engine)
|
||||
|
||||
selector_plan = copy.deepcopy(mounting_plate_intent())
|
||||
selector_plan["structures"][2]["cdsl_strategy"]["selector_roles"] = ["host_face"]
|
||||
with self.assertRaises(self.engine.DesignIntentError):
|
||||
self.engine.validate_intent_cdsl(selector_plan, mounting_plate_cdsl(), self.engine)
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.services.agent_service import AgentService # noqa: E402
|
||||
from app.services.engine_service import load_engine # noqa: E402
|
||||
from app.services.library import CdslLibrary # noqa: E402
|
||||
from app.services.part_skills import PartSkillLibrary # noqa: E402
|
||||
from app.services.storage import WorkspaceStore # noqa: E402
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
|
||||
from tests.test_design_intent import mounting_plate_cdsl, mounting_plate_intent # noqa: E402
|
||||
|
||||
|
||||
BACKEND = ROOT / "backend"
|
||||
PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills"
|
||||
|
||||
|
||||
class DesignIntentFlowTests(unittest.TestCase):
|
||||
def settings(self, 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,),
|
||||
)
|
||||
|
||||
def test_design_intent_is_required_before_library_or_cdsl(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = self.settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""}
|
||||
searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], intent_state=state))
|
||||
generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"design_intent_id": "intent_aaaaaaaaaaaa", "cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], intent_state=state))
|
||||
|
||||
self.assertEqual(searched["code"], "DESIGN_INTENT_REQUIRED")
|
||||
self.assertEqual(generated["code"], "DESIGN_INTENT_REQUIRED")
|
||||
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
|
||||
|
||||
def test_ready_plan_persists_before_build_and_links_success_revision(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = self.settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
skills = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
agent = AgentService(settings, store, CdslLibrary(settings), skills)
|
||||
request = "Create a mounting plate with four holes and a center slot"
|
||||
state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""}
|
||||
planned, _ = asyncio.run(agent._run_tool(
|
||||
"propose_design_intent",
|
||||
{"intent": mounting_plate_intent(), "summary": "plate plan", "assumptions": []},
|
||||
"", request, [], part_skill_selection=skills.select(request), intent_state=state,
|
||||
))
|
||||
|
||||
self.assertTrue(planned["ok"])
|
||||
task = store.read_task(planned["task_id"])
|
||||
self.assertEqual(task["current_revision"], "")
|
||||
planning_path = store.artifact_path(planned["task_id"], planned["design_intent_path"])
|
||||
self.assertTrue(planning_path.is_file())
|
||||
saved = json.loads(planning_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(saved["part_skill_ids"], skills.select(request)["skill_ids"])
|
||||
|
||||
searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "plate"}, planned["task_id"], request, [], intent_state=state))
|
||||
self.assertTrue(searched["ok"])
|
||||
result, built = asyncio.run(agent._run_tool(
|
||||
"generate_cdsl_model",
|
||||
{"design_intent_id": planned["design_intent_id"], "cdsl": mounting_plate_cdsl(), "summary": "plate", "assumptions": []},
|
||||
planned["task_id"], request, [], part_skill_selection=skills.select(request), intent_state=state,
|
||||
))
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertIsNotNone(built)
|
||||
revision = store.read_task(planned["task_id"])["revisions"][-1]
|
||||
self.assertEqual(revision["design_intent_id"], planned["design_intent_id"])
|
||||
self.assertEqual(revision["design_intent_path"], planned["design_intent_path"])
|
||||
audit = json.loads(store.artifact_path(planned["task_id"], revision["part_skills_path"]).read_text(encoding="utf-8"))
|
||||
self.assertEqual(audit["design_intent_id"], planned["design_intent_id"])
|
||||
self.assertEqual(audit["design_intent_assumptions"], [])
|
||||
|
||||
def test_blocked_plan_is_persisted_without_a_revision_and_new_plan_supersedes_it(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = self.settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
skills = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
agent = AgentService(settings, store, CdslLibrary(settings), skills)
|
||||
request = "Create a mounting plate"
|
||||
state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""}
|
||||
blocked_intent = mounting_plate_intent()
|
||||
blocked_intent["open_questions"] = [{"id": "thickness", "question": "Thickness?", "blocking": True}]
|
||||
blocked_intent["status"] = "needs_clarification"
|
||||
blocked, _ = asyncio.run(agent._run_tool(
|
||||
"propose_design_intent",
|
||||
{"intent": blocked_intent, "summary": "blocked", "assumptions": []},
|
||||
"", request, [], part_skill_selection=skills.select(request), intent_state=state,
|
||||
))
|
||||
self.assertEqual(blocked["code"], "DESIGN_INTENT_BLOCKED")
|
||||
task = store.read_task(blocked["task_id"])
|
||||
self.assertEqual(task["revisions"], [])
|
||||
self.assertEqual(task["design_intents"][0]["status"], "pending")
|
||||
|
||||
ready, _ = asyncio.run(agent._run_tool(
|
||||
"propose_design_intent",
|
||||
{"intent": mounting_plate_intent(), "summary": "ready", "assumptions": []},
|
||||
blocked["task_id"], request, [], part_skill_selection=skills.select(request), intent_state=state,
|
||||
))
|
||||
self.assertTrue(ready["ok"])
|
||||
task = store.read_task(blocked["task_id"])
|
||||
self.assertEqual(task["design_intents"][0]["status"], "superseded")
|
||||
self.assertEqual(task["design_intents"][-1]["status"], "accepted")
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
@@ -9,7 +11,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "json_to_cdsl"))
|
||||
|
||||
from evidence_v2_to_cdsl import StepInspector, convert_evidence
|
||||
from evidence_v2_to_cdsl import StepInspector, batch_convert, convert_evidence
|
||||
|
||||
|
||||
def _identity(stable_id: str, *, kind: str = "feature", geometry: dict | None = None) -> dict:
|
||||
@@ -59,6 +61,42 @@ FIXTURE = {
|
||||
|
||||
|
||||
class EvidenceV2ToCdslTests(unittest.TestCase):
|
||||
def test_batch_conversion_recurses_and_records_relative_source_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
evidence_dir = root / "evidence"
|
||||
source = evidence_dir / "category" / "part.solidworks_evidence_v2.json"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text(json.dumps(FIXTURE), encoding="utf-8")
|
||||
|
||||
output_dir = root / "output"
|
||||
manifest = batch_convert(evidence_dir, output_dir)
|
||||
|
||||
self.assertEqual(manifest["input_count"], 1)
|
||||
self.assertEqual(manifest["converted_count"], 1)
|
||||
self.assertEqual(manifest["results"][0]["source"], "category/part.solidworks_evidence_v2.json")
|
||||
diagnostic = json.loads((output_dir / "part.diagnostic.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(diagnostic["source"], "category/part.solidworks_evidence_v2.json")
|
||||
|
||||
def test_batch_conversion_uses_full_filename_and_disambiguates_normalized_ids(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
evidence_dir = root / "evidence"
|
||||
sources = [
|
||||
evidence_dir / "first" / "14.Rod Nut.solidworks_evidence_v2.json",
|
||||
evidence_dir / "second" / "14 Rod Nut.solidworks_evidence_v2.json",
|
||||
]
|
||||
for source in sources:
|
||||
source.parent.mkdir(parents=True, exist_ok=True)
|
||||
source.write_text(json.dumps(FIXTURE), encoding="utf-8")
|
||||
|
||||
manifest = batch_convert(evidence_dir, root / "output")
|
||||
|
||||
self.assertEqual(manifest["converted_count"], 2)
|
||||
part_ids = {item["part_id"] for item in manifest["results"]}
|
||||
self.assertEqual(len(part_ids), 2)
|
||||
self.assertTrue(all(part_id.startswith("14-Rod-Nut-") for part_id in part_ids))
|
||||
|
||||
def test_converts_fixed_evidence_v2_fixture_to_semantic_cdsl(self) -> None:
|
||||
cdsl, diagnostic = convert_evidence(FIXTURE, source_name="fixture.solidworks_evidence_v2.json")
|
||||
atoms = {feature["atomic_id"] for feature in cdsl["features"]}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.models.contracts import ChatMessage, MessagePart # noqa: E402
|
||||
from app.services.agent_service import AgentService, TOOL_SCHEMAS, system_prompt # noqa: E402
|
||||
from app.services.engine_service import build_revision, load_engine, validate_cdsl # noqa: E402
|
||||
from app.services.library import CdslLibrary # noqa: E402
|
||||
from app.services.part_skills import PartSkillLibrary # noqa: E402
|
||||
from app.services.storage import WorkspaceStore # noqa: E402
|
||||
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 document(part_id: str, sketches: list[dict], features: list[dict]) -> dict:
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.1.0",
|
||||
"kind": "part",
|
||||
"part_id": part_id,
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": sketches},
|
||||
"features": features,
|
||||
}
|
||||
|
||||
|
||||
def mounting_plate_fixture() -> dict:
|
||||
return document("golden-mounting-plate", [
|
||||
{"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 60}},
|
||||
{"id": "grid", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 50, "spacing_y_mm": 30, "center_mm": [0, 0]}},
|
||||
], [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 10}, "sketch_id": "base"},
|
||||
{"id": "mount_pattern", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 10, "reverse": True}, "sketch_id": "grid"},
|
||||
{"id": "counterbores", "atomic_id": "hole_counterbore", "depends_on": ["mount_pattern"], "params": {"diameter_mm": 6, "depth_mm": 10, "counterbore_diameter_mm": 12, "counterbore_depth_mm": 4, "positions": [{"mm": [-15, 0, 0]}, {"mm": [15, 0, 0]}], "host_face": {"frame": workplane(10)}}, "sketch_id": "base"},
|
||||
])
|
||||
|
||||
|
||||
def mounting_bracket_fixture() -> dict:
|
||||
web_plane = {"origin_mm": [0, -20, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1], "normal": [0, 1, 0]}
|
||||
return document("golden-mounting-bracket", [
|
||||
{"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 40}},
|
||||
{"id": "web", "workplane": web_plane, "profile": {"type": "rectangle", "center": [0, -15], "width_mm": 50, "height_mm": 30}},
|
||||
{"id": "slot", "workplane": workplane(6), "profile": {"type": "obround", "center": [0, 0], "length_mm": 24, "width_mm": 8}},
|
||||
{"id": "symmetric_holes", "workplane": workplane(6), "profile": {"type": "circles", "items": [{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}]}},
|
||||
], [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 6}, "sketch_id": "base"},
|
||||
{"id": "web_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 6}, "sketch_id": "web"},
|
||||
{"id": "slot_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["web_add"], "params": {"distance_mm": 6, "reverse": True}, "sketch_id": "slot"},
|
||||
{"id": "symmetric_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["slot_cut"], "params": {"distance_mm": 6, "reverse": True}, "sketch_id": "symmetric_holes"},
|
||||
])
|
||||
|
||||
|
||||
def flange_fixture() -> dict:
|
||||
return document("golden-flange", [
|
||||
{"id": "base", "workplane": workplane(), "profile": {"type": "annulus", "inner_radius_mm": 10, "outer_radius_mm": 40}},
|
||||
{"id": "bolt_circle", "workplane": workplane(12), "profile": {"type": "circles", "items": [{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}]}},
|
||||
], [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 12}, "sketch_id": "base"},
|
||||
{"id": "bolt_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 12, "reverse": True}, "sketch_id": "bolt_circle"},
|
||||
])
|
||||
|
||||
|
||||
def shaft_fixture() -> dict:
|
||||
end_plane = {"origin_mm": [0, 35, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1], "normal": [0, 1, 0]}
|
||||
return document("golden-stepped-shaft", [
|
||||
{"id": "shaft_profile", "workplane": workplane(), "profile": {"type": "polygon", "vertices": [[0, 0], [10, 0], [10, 15], [7, 15], [7, 35], [0, 35]]}},
|
||||
], [
|
||||
{"id": "shaft_add", "atomic_id": "revolve_add", "depends_on": [], "params": {"angle_deg": 360, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 1, 0]}}, "sketch_id": "shaft_profile"},
|
||||
{"id": "coaxial_bore", "atomic_id": "hole_blind", "depends_on": ["shaft_add"], "params": {"diameter_mm": 4, "depth_mm": 35, "positions": [{"mm": [0, 0, 0]}], "host_face": {"frame": end_plane}}, "sketch_id": "shaft_profile"},
|
||||
])
|
||||
|
||||
|
||||
def bearing_housing_fixture() -> dict:
|
||||
return document("golden-bearing-housing", [
|
||||
{"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}},
|
||||
{"id": "housing", "workplane": workplane(8), "profile": {"type": "circle", "radius_mm": 28}},
|
||||
{"id": "base_holes", "workplane": workplane(8), "profile": {"type": "circle_grid", "radius_mm": 4, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}},
|
||||
], [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 8}, "sketch_id": "base"},
|
||||
{"id": "housing_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 25}, "sketch_id": "housing"},
|
||||
{"id": "bearing_seat", "atomic_id": "hole_counterbore", "depends_on": ["housing_add"], "params": {"diameter_mm": 12, "depth_mm": 25, "counterbore_diameter_mm": 30, "counterbore_depth_mm": 10, "positions": [{"mm": [0, 0, 0]}], "host_face": {"frame": workplane(33)}}, "sketch_id": "housing"},
|
||||
{"id": "base_mount_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["bearing_seat"], "params": {"distance_mm": 8, "reverse": True}, "sketch_id": "base_holes"},
|
||||
])
|
||||
|
||||
|
||||
def hex_nut_fixture() -> dict:
|
||||
return document("golden-hex-nut", [
|
||||
{"id": "hex", "workplane": workplane(), "profile": {"type": "polygon", "vertices": [[10, 0], [5, 8.660254], [-5, 8.660254], [-10, 0], [-5, -8.660254], [5, -8.660254]]}},
|
||||
{"id": "thread_bore", "workplane": workplane(10), "profile": {"type": "circle", "radius_mm": 3}},
|
||||
], [
|
||||
{"id": "nut_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 10}, "sketch_id": "hex"},
|
||||
{"id": "m6_bore", "atomic_id": "extrude_cut_blind", "depends_on": ["nut_add"], "params": {"distance_mm": 10, "reverse": True}, "sketch_id": "thread_bore"},
|
||||
])
|
||||
|
||||
|
||||
GOLDEN_CASES = (
|
||||
("带沉孔和孔阵列的安装底板", mounting_plate_fixture, {"planning/mounting-plate", "atomic/counterbored-hole-creation", "atomic/pattern-holes-from-datum"}, {"atomic/pattern-holes-from-datum": "expanded"}),
|
||||
("带槽和对称孔的安装支架", mounting_bracket_fixture, {"planning/mounting-bracket", "functional/slotted-adjustment-feature", "functional/symmetric-feature-layout"}, {"atomic/pattern-holes-from-datum": "expanded"}),
|
||||
("带螺栓圆的法兰", flange_fixture, {"planning/flange", "functional/flange-bolt-circle"}, {"functional/flange-bolt-circle": "expanded"}),
|
||||
("带回转体同轴孔的阶梯轴", shaft_fixture, {"planning/simple-shaft", "functional/axisymmetric-revolve-strategy", "atomic/coaxial-bore-rule"}, {}),
|
||||
("带轴承座孔和底座孔的轴承座", bearing_housing_fixture, {"planning/bearing-housing", "functional/bearing-bore-seat"}, {}),
|
||||
("M6 六角螺母", hex_nut_fixture, {"planning/hexagonal-nut", "atomic/threaded-hole-creation"}, {"atomic/threaded-hole-creation": "approximated"}),
|
||||
)
|
||||
|
||||
|
||||
class PartSkillLibraryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.library = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
|
||||
def test_english_and_chinese_triggers(self) -> None:
|
||||
english = self.library.select("Create a mounting plate with counterbored hole pattern")
|
||||
chinese = self.library.select("带沉孔和孔阵列的安装底板")
|
||||
|
||||
self.assertIn("planning/mounting-plate", english["skill_ids"])
|
||||
self.assertIn("planning/mounting-plate", chinese["skill_ids"])
|
||||
self.assertIn("atomic/counterbored-hole-creation", chinese["skill_ids"])
|
||||
|
||||
def test_exclusions_and_selection_limits(self) -> None:
|
||||
selection = self.library.select("Create a flange nut with an M6 threaded hole")
|
||||
|
||||
self.assertNotIn("planning/flange", selection["skill_ids"])
|
||||
self.assertLessEqual(len(selection["planning_ids"]), 1)
|
||||
self.assertLessEqual(len(selection["support_ids"]), 3)
|
||||
self.assertEqual(len(selection["skill_ids"]), len(set(selection["skill_ids"])))
|
||||
|
||||
def test_inheritance_and_primary_family_conflict(self) -> None:
|
||||
inherited = ["planning/mounting-plate", "atomic/counterbored-hole-creation"]
|
||||
addition = self.library.select("Add one M6 threaded hole", inherited)
|
||||
conflict = self.library.select("Add a flange bolt circle", inherited)
|
||||
replacement = self.library.select("Replace the whole part with a flange bolt circle", inherited)
|
||||
|
||||
self.assertIn("planning/mounting-plate", addition["skill_ids"])
|
||||
self.assertIn("atomic/threaded-hole-creation", addition["skill_ids"])
|
||||
self.assertEqual(conflict["conflict"]["current"], "planning/mounting-plate")
|
||||
self.assertEqual(conflict["conflict"]["matched"], "planning/flange")
|
||||
self.assertEqual(replacement["planning_ids"], ["planning/flange"])
|
||||
self.assertIsNone(replacement["conflict"])
|
||||
|
||||
def test_catalog_and_bridge_files_are_complete(self) -> None:
|
||||
payload = json.loads((PART_SKILL_ROOT / "catalog.json").read_text(encoding="utf-8"))
|
||||
categories = {kind: 0 for kind in ("planning", "functional", "atomic")}
|
||||
for skill in payload["skills"]:
|
||||
categories[skill["kind"]] += 1
|
||||
self.assertTrue((PART_SKILL_ROOT / skill["bridge"]).is_file())
|
||||
self.assertTrue((PART_SKILL_ROOT / skill["source"]).is_file())
|
||||
self.assertTrue(skill["triggers"])
|
||||
self.assertTrue(skill["capability_translation_rules"])
|
||||
self.assertEqual(categories, {"planning": 6, "functional": 7, "atomic": 8})
|
||||
self.assertTrue((PART_SKILL_ROOT / "LICENSE").is_file())
|
||||
self.assertTrue((PART_SKILL_ROOT / "PROVENANCE.md").is_file())
|
||||
|
||||
def test_audit_records_exact_omitted_and_blocked_capability_states(self) -> None:
|
||||
fixture = mounting_plate_fixture()
|
||||
exact = self.library.audit(self.library.select("安装底板"), fixture)
|
||||
finishing = self.library.audit(self.library.select("edge treatment"), fixture)
|
||||
blocked = self.library.audit(self.library.select("flange bolt circle"), mounting_plate_fixture())
|
||||
|
||||
exact_statuses = {item["skill_id"]: item["status"] for item in exact["capability_translations"]}
|
||||
finishing_statuses = {item["skill_id"]: item for item in finishing["capability_translations"]}
|
||||
blocked_statuses = {item["skill_id"]: item["status"] for item in blocked["capability_translations"]}
|
||||
self.assertEqual(exact_statuses["planning/mounting-plate"], "exact")
|
||||
self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["status"], "omitted")
|
||||
self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["translation"], "selector_unavailable")
|
||||
self.assertEqual(blocked_statuses["functional/flange-bolt-circle"], "blocked")
|
||||
|
||||
|
||||
class AgentPartSkillTests(unittest.TestCase):
|
||||
def test_agent_stream_selects_and_injects_part_skill_before_first_model_call(self) -> None:
|
||||
class CaptureAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.first_messages: list[dict[str, object]] = []
|
||||
|
||||
async def _complete(self, messages: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]:
|
||||
self.first_messages = [dict(message) for message in messages]
|
||||
return {"choices": [{"message": {"role": "assistant", "content": "已分析", "tool_calls": []}}]}
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = self._settings(Path(directory))
|
||||
library = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
agent = CaptureAgent(settings, WorkspaceStore(settings), CdslLibrary(settings), library)
|
||||
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="生成一个法兰螺栓圆")])
|
||||
|
||||
async def consume() -> None:
|
||||
async for _ in agent.stream([message], None, None):
|
||||
pass
|
||||
|
||||
asyncio.run(consume())
|
||||
self.assertIn("[planning/flange]", str(agent.first_messages[0]["content"]))
|
||||
self.assertIn("[functional/flange-bolt-circle]", str(agent.first_messages[0]["content"]))
|
||||
|
||||
def test_prompt_contains_bridge_without_adding_tools(self) -> None:
|
||||
library = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
selection = library.select("Create a flange with a bolt circle")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = self._settings(Path(directory))
|
||||
prompt = system_prompt(settings, "Create a flange with a bolt circle", part_skill_context=library.render_context(selection))
|
||||
|
||||
self.assertIn("[planning/flange]", prompt)
|
||||
self.assertIn("circular-pattern atomic", prompt)
|
||||
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["search_cdsl_library", "read_cdsl_reference", "propose_design_intent", "read_current_cdsl", "generate_cdsl_model"])
|
||||
|
||||
def test_generation_persists_assumptions_and_selected_skills(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = self._settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
library = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
agent = AgentService(settings, store, CdslLibrary(settings), library)
|
||||
request = "M6 六角螺母"
|
||||
selection = library.select(request)
|
||||
engine = load_engine(settings)
|
||||
intent = engine.design_intent_from_cdsl(hex_nut_fixture(), request=request)
|
||||
intent_state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""}
|
||||
planned, _ = asyncio.run(agent._run_tool(
|
||||
"propose_design_intent",
|
||||
{"intent": intent, "summary": "M6 nut plan", "assumptions": []},
|
||||
"",
|
||||
request,
|
||||
[],
|
||||
part_skill_selection=selection,
|
||||
intent_state=intent_state,
|
||||
))
|
||||
self.assertTrue(planned["ok"])
|
||||
result, generated = asyncio.run(agent._run_tool(
|
||||
"generate_cdsl_model",
|
||||
{"design_intent_id": planned["design_intent_id"], "cdsl": hex_nut_fixture(), "summary": "M6 nut", "assumptions": ["M6 thread is represented as a cylindrical bore"]},
|
||||
planned["task_id"],
|
||||
request,
|
||||
["reference-fixture"],
|
||||
part_skill_selection=selection,
|
||||
intent_state=intent_state,
|
||||
))
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertIsNotNone(generated)
|
||||
task = store.read_task(str(generated["task_id"]))
|
||||
revision = task["revisions"][0]
|
||||
audit = json.loads(store.artifact_path(task["task_id"], revision["part_skills_path"]).read_text(encoding="utf-8"))
|
||||
report = json.loads(store.artifact_path(task["task_id"], revision["report_path"]).read_text(encoding="utf-8"))
|
||||
self.assertIn("M6 thread is represented as a cylindrical bore", audit["assumptions"])
|
||||
self.assertIn("atomic/threaded-hole-creation", revision["part_skill_ids"])
|
||||
self.assertEqual(report["generation_context"]["cdsl_reference_ids"], ["reference-fixture"])
|
||||
|
||||
@staticmethod
|
||||
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 PartSkillBuildAndGoldenTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.settings = AgentPartSkillTests._settings(Path(tempfile.mkdtemp()))
|
||||
cls.engine = load_engine(cls.settings)
|
||||
cls.library = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
|
||||
def test_success_and_failure_revisions_always_write_part_skill_audit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = AgentPartSkillTests._settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
selection = self.library.select("带螺栓圆的法兰")
|
||||
audit = self.library.audit(selection, flange_fixture(), ["Explicit circle expansion"])
|
||||
success = build_revision(
|
||||
settings=settings, store=store, task_id=None, request="带螺栓圆的法兰", cdsl=flange_fixture(),
|
||||
reference_ids=["flange-reference"], summary="flange", part_skills=audit,
|
||||
generation_assumptions=["Explicit circle expansion"],
|
||||
)
|
||||
success_audit = json.loads(store.artifact_path(success["task_id"], success["part_skills_path"]).read_text(encoding="utf-8"))
|
||||
success_report = json.loads(store.artifact_path(success["task_id"], success["report_path"]).read_text(encoding="utf-8"))
|
||||
self.assertEqual(success_audit["skill_ids"], audit["skill_ids"])
|
||||
self.assertIn("generation_context", success_report)
|
||||
|
||||
invalid = copy.deepcopy(flange_fixture())
|
||||
invalid["features"][0]["atomic_id"] = "not_supported"
|
||||
failed_audit = self.library.audit(selection, invalid, [])
|
||||
with self.assertRaisesRegex(ValueError, "CDSL schema violation"):
|
||||
build_revision(
|
||||
settings=settings, store=store, task_id=success["task_id"], request="bad flange", cdsl=invalid,
|
||||
reference_ids=[], summary="bad", part_skills=failed_audit, generation_assumptions=[],
|
||||
)
|
||||
task = store.read_task(success["task_id"])
|
||||
failed = task["revisions"][-1]
|
||||
self.assertEqual(failed["status"], "failed")
|
||||
self.assertTrue(store.artifact_path(task["task_id"], failed["part_skills_path"]).is_file())
|
||||
report = json.loads(store.artifact_path(task["task_id"], failed["report_path"]).read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["generation_context"]["part_skill_ids"], audit["skill_ids"])
|
||||
legacy_report = {"engine_result": {"engine": "cdsl_only"}}
|
||||
self.assertEqual(legacy_report.get("generation_context", {}), {})
|
||||
|
||||
def test_golden_part_families_validate_preflight_rebuild_and_audit(self) -> None:
|
||||
for request, factory, expected_skills, expected_statuses in GOLDEN_CASES:
|
||||
with self.subTest(request=request):
|
||||
cdsl = factory()
|
||||
selection = self.library.select(request)
|
||||
self.assertTrue(expected_skills.issubset(selection["skill_ids"]))
|
||||
validate_cdsl(cdsl, self.engine)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
step_path = Path(directory) / "model.step"
|
||||
result = self.engine.run_cdsl_only(copy.deepcopy(cdsl), step_path)
|
||||
self.assertEqual(result["engine"], "cdsl_only")
|
||||
self.assertTrue(step_path.is_file())
|
||||
self.assertGreater(step_path.stat().st_size, 0)
|
||||
audit = self.library.audit(selection, cdsl, ["golden fixture"])
|
||||
statuses = {item["skill_id"]: item["status"] for item in audit["capability_translations"]}
|
||||
for skill_id, status in expected_statuses.items():
|
||||
self.assertEqual(statuses[skill_id], status)
|
||||
self.assertTrue(audit["evidence"]["features"])
|
||||
self.assertTrue(audit["evidence"]["profiles"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user