优化
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -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",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user