This commit is contained in:
2026-08-31 14:16:08 +08:00
parent b1b226593a
commit 7533b298f1
30 changed files with 6976 additions and 253 deletions
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
from app.services.autonomous_cdsl_generation import (
AutonomousCdslGenerationRunner,
parse_modeling_plan,
)
from app.services.storage import WorkspaceStore
from pathlib import Path
from app.settings import ProviderConfig, ProviderModel, Settings
def _plan():
return parse_modeling_plan(
"""
[STEP step_3]
goal: 两端夹紧槽
relationship: 左右槽属于同一夹紧结构,但分别作用于左右轴套
[ACTION left_clamp_slot]
step: step_3
title: 左轴套夹紧槽
target: left_hub
operation: extrude_cut_blind
[ACTION right_clamp_slot]
step: step_3
title: 右轴套夹紧槽
target: right_hub
operation: extrude_cut_blind
depends_on: left_clamp_slot
""",
checklist=["两端夹紧槽"],
runtime_atomic_ids={"extrude_cut_blind"},
)
def test_related_targets_stay_in_one_step_with_independent_actions():
plan = _plan()
assert [item["action_id"] for item in plan["steps"][0]["actions"]] == [
"left_clamp_slot",
"right_clamp_slot",
]
assert plan["steps"][0]["relationship"] == "左右槽属于同一夹紧结构,但分别作用于左右轴套"
assert plan["actions"][1]["depends_on"] == ["left_clamp_slot"]
def test_soft_action_headings_are_indexed_without_forcing_machine_fields():
plan = parse_modeling_plan(
"## Step 3 - 两端夹紧槽\n### Action 3a - 左槽\n切左槽\n### Action 3b - 右槽\n切右槽",
checklist=["两端夹紧槽"],
runtime_atomic_ids={"extrude_cut_blind"},
)
assert [item["title"] for item in plan["steps"][0]["actions"]] == ["左槽", "右槽"]
assert plan["steps"][0]["actions"][1]["depends_on"] == ["step_1_action_1"]
def test_action_progress_advances_within_step_before_next_step(tmp_path):
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
settings = Settings(task_root=tmp_path / "tasks", conversation_root=tmp_path / "conversations", library_root=Path("backend/cdsl_library"), engine_root=Path("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,))
store = WorkspaceStore(settings)
runner = AutonomousCdslGenerationRunner(settings, store, AsyncMock())
state = {
"modeling_plan_enforced": True,
"modeling_plan_status": "approved",
"modeling_plan": _plan(),
"active_plan_step_id": "step_3",
"active_plan_action_id": "left_clamp_slot",
"plan_action_status": {"left_clamp_slot": "pending", "right_clamp_slot": "pending"},
"plan_feature_status": {},
"plan_step_status": {"step_3": "pending"},
}
runner._mark_plan_progress(state, {
"plan_step_id": "step_3",
"plan_action_id": "left_clamp_slot",
"atomic_ids": ["extrude_cut_blind"],
})
assert state["active_plan_step_id"] == "step_3"
assert state["active_plan_action_id"] == "right_clamp_slot"
assert state["plan_action_status"]["left_clamp_slot"] == "complete"
assert state["plan_action_status"]["right_clamp_slot"] == "pending"
assert state["plan_step_status"]["step_3"] == "pending"
def test_action_operation_mismatch_is_explicit():
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
settings = Settings(task_root=Path("/tmp/cdsl-cad-plan-action-test/tasks"), conversation_root=Path("/tmp/cdsl-cad-plan-action-test/conversations"), library_root=Path("backend/cdsl_library"), engine_root=Path("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,))
store = WorkspaceStore(settings)
runner = AutonomousCdslGenerationRunner(settings, store, AsyncMock())
state = {
"modeling_plan_enforced": True,
"modeling_plan_status": "approved",
"modeling_plan": _plan(),
"active_plan_step_id": "step_3",
"active_plan_action_id": "left_clamp_slot",
"plan_action_status": {"left_clamp_slot": "pending", "right_clamp_slot": "pending"},
}
action = runner._plan_current_action(state)
assert action is not None
assert action["operation"] == "extrude_cut_blind"
def test_enforced_submit_schema_exposes_action_id():
from app.services.autonomous_cdsl_generation import _operation_contract_payload
from app.services.cdsl_authoring_schema import operation_contract_hash
from app.services.engine_service import load_engine
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
root = Path("/tmp/cdsl-cad-plan-action-schema")
settings = Settings(task_root=root / "tasks", conversation_root=root / "conversations", library_root=Path("backend/cdsl_library"), engine_root=Path("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,))
store = WorkspaceStore(settings)
task = store.ensure_task(None, "schema")
contract = _operation_contract_payload(load_engine(settings), "extrude_cut_blind")
state = {"modeling_plan_enforced": True, "modeling_plan_mode": "indexed", "active_operation_contract": contract, "pending_operation_revision": "", "pending_operation_contract_hash": operation_contract_hash("extrude_cut_blind", contract["canonical_fragment_schema"])}
tool = AutonomousCdslGenerationRunner(settings, store, AsyncMock())._canonical_submit_tool(task, state)
assert tool is not None
assert "plan_action_id" in tool["function"]["parameters"]["properties"]
assert "plan_action_id" in tool["function"]["parameters"]["required"]
def test_multiple_outer_profiles_request_plan_action_split_without_schema_loop(tmp_path):
from app.services.engine_service import load_engine
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
settings = Settings(task_root=tmp_path / "tasks", conversation_root=tmp_path / "conversations", library_root=Path("backend/cdsl_library"), engine_root=Path("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,))
store = WorkspaceStore(settings)
task = store.ensure_task(None, "left and right slots")
task_id = str(task["task_id"])
store.write_requirements_document(task_id, "# Frozen\nCreate left and right slots.")
runner = AutonomousCdslGenerationRunner(settings, store, AsyncMock())
engine = load_engine(settings)
plan = parse_modeling_plan(
"## Step 1 - Slots\n### Action 1a - Left slot\n### Action 1b - Right slot",
checklist=["left and right slots"],
runtime_atomic_ids={"extrude_cut_blind"},
)
state = {
"modeling_plan_enforced": True,
"modeling_plan_status": "approved",
"modeling_plan": plan,
"active_plan_step_id": "step_1",
"active_plan_action_id": "step_1_action_1",
"plan_step_status": {"step_1": "pending"},
"plan_action_status": {"step_1_action_1": "pending", "step_1_action_2": "pending"},
"candidate_attempts_by_head": {},
}
asyncio.run(runner._execute_tool(
task_id, "left and right slots", state, engine, "get_cdsl_operation_contract",
{"atomic_id": "extrude_cut_blind"},
))
fragment = {
"sketch": {
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": {
"type": "analytic_contours",
"contours": [
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [-10, 0], "radius_mm": 2}]},
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [10, 0], "radius_mm": 2}]},
],
},
},
"feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 5}},
}
events, progressed = asyncio.run(runner._execute_tool(
task_id, "left and right slots", state, engine, "submit_cdsl_fragment",
{
"plan_step_id": "step_1",
"plan_action_id": "step_1_action_1",
"batch_goal": "Create both slots.",
"fragment": fragment,
},
))
assert progressed is False
assert events[0][1]["diagnostic"]["code"] == "CDSL_PROFILE_MULTIPLE_OUTERS"
assert state["modeling_plan_status"] == "revise"
assert state["candidate_action_required"]["reason"] == "plan_action_split_required"
assert state["format_correction"] == {}
assert not state.get("schema_retry_counts")
assert (store.read_task(task_id) or {}).get("active_revision") == ""
def test_reviewer_action_count_can_require_plan_revision_without_server_semantic_rules(tmp_path):
from app.services.engine_service import load_engine
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
settings = Settings(task_root=tmp_path / "tasks", conversation_root=tmp_path / "conversations", library_root=Path("backend/cdsl_library"), engine_root=Path("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,))
store = WorkspaceStore(settings)
task = store.ensure_task(None, "left and right slots")
task_id = str(task["task_id"])
store.write_requirements_document(task_id, "# Frozen\nCreate left and right slots.")
store.write_completion_checklist(task_id, "- [ ] left and right slots")
runner = AutonomousCdslGenerationRunner(settings, store, AsyncMock())
state = {
"modeling_plan_enforced": True,
"modeling_plan_status": "missing",
"modeling_plan_review_attempts": 0,
}
reviewer_revise = {
"schema_version": "cad.modeling-plan-review.v1",
"verdict": "revise",
"confidence": 0.9,
"issues": [{"type": "action_granularity", "step_id": "step_1", "message": "The two independently located slots need separate actions."}],
"coverage": [{"requirement": "left and right slots", "step_id": "step_1", "status": "covered", "evidence": "The step names both slots."}],
"step_checks": [{"step_id": "step_1", "status": "pass", "notes": "The related slots remain together."}],
"action_checks": [{"step_id": "step_1", "status": "fail", "notes": "The plan has one action but requires two.", "required_action_count": 2}],
}
with patch("app.services.autonomous_cdsl_generation.review_modeling_plan", AsyncMock(return_value=reviewer_revise)):
events, progressed = asyncio.run(runner._execute_tool(
task_id,
"left and right slots",
state,
load_engine(settings),
"write_modeling_plan",
{"plan_text": "## Step 1 - Clamp slots\nCreate the left slot and right slot."},
))
assert progressed is True
assert state["modeling_plan_status"] == "revise"
assert "action_granularity_issues" not in state["modeling_plan"]
review_event = next(payload for name, payload in events if name == "modeling_plan_review")
assert review_event["review"]["verdict"] == "revise"
assert review_event["review"]["action_checks"][0]["status"] == "fail"
assert [item["function"]["name"] for item in runner._author_tools(store.read_task(task_id) or {}, requirements_frozen=True, state=state)] == ["write_modeling_plan"]
+1 -12
View File
@@ -22,24 +22,13 @@ class SphereAddTests(unittest.TestCase):
"kind": "part",
"part_id": "sphere_test",
"meta": {"unit": "mm"},
"geometry": {
"sketches": [{
"id": "sphere_locator",
"workplane": {
"origin_mm": [0, 0, 0],
"x_dir": [1, 0, 0],
"normal": [0, 0, 1],
},
"profile": {"type": "circle", "radius_mm": 1.0},
}],
},
"geometry": {"sketches": []},
"features": [{
"id": "sphere",
"atomic_id": "sphere_add",
"depends_on": [],
"name": "Test sphere",
"params": {"radius_mm": 2.5, "center_mm": [3.0, -4.0, 5.0]},
"sketch_id": "sphere_locator",
}],
}
+94 -1
View File
@@ -15,7 +15,7 @@ import httpx
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from app.services.visual_review import VisualReviewError, review_candidate_batch, review_checkpoint # noqa: E402
from app.services.visual_review import VisualReviewError, review_candidate_batch, review_checkpoint, review_modeling_plan, _unsupported_operation_mentions # noqa: E402
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
@@ -109,6 +109,99 @@ def _candidate_response(*, verdict: str = "accept", batch_goal_status: str = "ac
class VisualReviewCompatibilityTests(unittest.TestCase):
def test_plan_review_detects_unsupported_operation_mentions(self) -> None:
result = {
"issues": [{"type": "guidance", "step_id": "step_1", "message": "Prefer extrude_add; pattern_circular is unavailable."}],
"coverage": [],
"step_checks": [],
"action_checks": [],
}
self.assertEqual(
_unsupported_operation_mentions(result, [{"atomic_id": "extrude_add_blind"}, {"atomic_id": "extrude_add_two_sided"}]),
["extrude_add", "pattern_circular"],
)
self.assertEqual(
_unsupported_operation_mentions(
{"issues": [{"message": "A cut can use extrude_cut."}], "coverage": [], "step_checks": [], "action_checks": []},
[{"atomic_id": "extrude_cut_blind"}],
),
["extrude_cut"],
)
def test_plan_review_records_unsupported_operation_without_rejecting_semantic_plan(self) -> None:
arguments = {
"verdict": "pass",
"confidence": 0.9,
"issues": [{"type": "guidance", "step_id": "step_1", "message": "Prefer extrude_add for the boss."}],
"coverage": [{"requirement": "one connected plate", "step_id": "step_1", "status": "covered", "evidence": "The base is described."}],
"step_checks": [{"step_id": "step_1", "status": "pass", "notes": "The step is ordered."}],
"action_checks": [{"step_id": "step_1", "status": "pass", "notes": "One base feature is one action.", "required_action_count": 1}],
}
response = _Response(200, {"choices": [{"message": {"tool_calls": [{"function": {
"name": "review_modeling_plan", "arguments": json.dumps(arguments),
}}]}}]})
with tempfile.TemporaryDirectory() as temporary:
with patch("app.services.visual_review.httpx.AsyncClient", return_value=_Client([response])):
result = asyncio.run(review_modeling_plan(
settings(Path(temporary)),
source_requirements="Build a plate.",
requirements="# Frozen",
checklist=["one connected plate"],
plan={"steps": [{"step_id": "step_1", "actions": [{"action_id": "step_1_action_1"}]}]},
runtime_operations=[{"atomic_id": "extrude_add_blind"}, {"atomic_id": "extrude_add_two_sided"}],
))
self.assertEqual(result["verdict"], "pass")
self.assertEqual(result["unsupported_operations"], ["extrude_add"])
self.assertTrue(result["review_warnings"])
def test_plan_reviewer_cannot_pass_a_failed_action_check(self) -> None:
arguments = {
"verdict": "pass",
"confidence": 0.9,
"issues": [],
"coverage": [{"requirement": "left and right slots", "step_id": "step_1", "status": "covered", "evidence": "Both are named."}],
"step_checks": [{"step_id": "step_1", "status": "pass", "notes": "The related slots remain in one step."}],
"action_checks": [{"step_id": "step_1", "status": "fail", "notes": "Left and right need separate actions.", "required_action_count": 2}],
}
response = _Response(200, {"choices": [{"message": {"tool_calls": [{"function": {
"name": "review_modeling_plan", "arguments": json.dumps(arguments),
}}]}}]})
with tempfile.TemporaryDirectory() as temporary:
with patch("app.services.visual_review.httpx.AsyncClient", return_value=_Client([response, response])):
with self.assertRaisesRegex(VisualReviewError, "every action check passes"):
asyncio.run(review_modeling_plan(
settings(Path(temporary)),
source_requirements="Cut left and right slots.",
requirements="# Frozen",
checklist=["left and right slots"],
plan={"steps": [{"step_id": "step_1", "actions": [{"action_id": "step_1_action_1"}]}]},
runtime_operations=[{"atomic_id": "extrude_cut_blind"}],
))
def test_plan_reviewer_required_action_count_is_checked_against_plan_structure(self) -> None:
arguments = {
"verdict": "pass",
"confidence": 0.9,
"issues": [],
"coverage": [{"requirement": "two slots", "step_id": "step_1", "status": "covered", "evidence": "Both targets are described."}],
"step_checks": [{"step_id": "step_1", "status": "pass", "notes": "Ordering is coherent."}],
"action_checks": [{"step_id": "step_1", "status": "pass", "notes": "Two actions are required.", "required_action_count": 2}],
}
response = _Response(200, {"choices": [{"message": {"tool_calls": [{"function": {
"name": "review_modeling_plan", "arguments": json.dumps(arguments),
}}]}}]})
with tempfile.TemporaryDirectory() as temporary:
with patch("app.services.visual_review.httpx.AsyncClient", return_value=_Client([response, response])):
with self.assertRaisesRegex(VisualReviewError, "required_action_count exceeds"):
asyncio.run(review_modeling_plan(
settings(Path(temporary)),
source_requirements="Create two slots.",
requirements="# Frozen",
checklist=["two slots"],
plan={"steps": [{"step_id": "step_1", "actions": [{"action_id": "only_action"}]}]},
runtime_operations=[{"atomic_id": "extrude_cut_blind"}],
))
def _review(self, root: Path, client: _Client, *, source_requirements: str = "") -> dict[str, object]:
image = root / "iso.png"
image.write_bytes(b"png")