Files
cdsl-cad/backend/tests/test_plan_actions.py
T
2026-08-31 14:16:08 +08:00

226 lines
12 KiB
Python

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"]