优化agent
This commit is contained in:
@@ -5,14 +5,59 @@ import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.models.contracts import ChatMessage, MessagePart
|
||||
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, parse_tool_arguments, response_language_instruction, tools_for_model, user_visible_error_message
|
||||
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, normalize_image_analysis, parse_tool_arguments, response_language_instruction, tools_for_model, use_flange_sleeve_template, user_visible_error_message
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.storage import WorkspaceStore
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
|
||||
|
||||
class ToolChoiceCompatibilityTests(unittest.TestCase):
|
||||
def test_retries_without_tool_choice_when_thinking_mode_rejects_it(self) -> None:
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code: int, text: str, body: dict[str, object]) -> None:
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
self._body = body
|
||||
|
||||
def json(self) -> dict[str, object]:
|
||||
return self._body
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self) -> None:
|
||||
self.requests: list[dict[str, object]] = []
|
||||
self.responses = [
|
||||
FakeResponse(400, '{"error":{"message":"Thinking mode does not support this tool_choice"}}', {}),
|
||||
FakeResponse(200, "", {"choices": [{"message": {"role": "assistant", "content": "ok"}}]}),
|
||||
]
|
||||
|
||||
async def __aenter__(self) -> "FakeClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
async def post(self, _url: str, *, headers: dict[str, str], json: dict[str, object]) -> FakeResponse:
|
||||
self.requests.append(dict(json))
|
||||
return self.responses.pop(0)
|
||||
|
||||
agent = object.__new__(AgentService)
|
||||
agent.settings = SimpleNamespace(llm_timeout_s=1)
|
||||
client = FakeClient()
|
||||
provider = ProviderConfig("deepseek", "DeepSeek", "https://example.invalid/v1", "test-key", (ProviderModel("deepseek-v4-flash-vision-exp", vision=True),))
|
||||
model = provider.models[0]
|
||||
|
||||
with patch("app.services.agent_service.httpx.AsyncClient", return_value=client):
|
||||
response = asyncio.run(agent._complete([], [], provider, model, "analyze_image_reference"))
|
||||
|
||||
self.assertEqual(response["choices"][0]["message"]["content"], "ok")
|
||||
self.assertEqual(client.requests[0]["tool_choice"], {"type": "function", "function": {"name": "analyze_image_reference"}})
|
||||
self.assertNotIn("tool_choice", client.requests[1])
|
||||
|
||||
|
||||
class ParseToolArgumentsTests(unittest.TestCase):
|
||||
def test_accepts_one_json_object(self) -> None:
|
||||
payload = parse_tool_arguments(' {"summary":"water cup","cdsl":{"parts":[]}} ')
|
||||
@@ -62,11 +107,13 @@ class ParseToolArgumentsTests(unittest.TestCase):
|
||||
self.assertNotIn("extrude", cdsl["$defs"]["feature_atomic_ids"]["enum"])
|
||||
self.assertEqual(cdsl, CDSL_TOOL_SCHEMA)
|
||||
|
||||
def test_strict_tool_schema_covers_design_intent_and_cdsl_generation_arguments(self) -> None:
|
||||
def test_strict_tool_schema_covers_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, ["propose_design_intent", "generate_cdsl_model"])
|
||||
self.assertEqual(strict_tools, ["generate_flange_sleeve_model", "generate_cdsl_model"])
|
||||
template_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_flange_sleeve_model")
|
||||
self.assertEqual(template_tool["function"]["parameters"]["properties"]["plan"]["required"], ["template"])
|
||||
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)
|
||||
@@ -77,6 +124,38 @@ class ParseToolArgumentsTests(unittest.TestCase):
|
||||
|
||||
self.assertFalse(any(tool["function"].get("strict") for tool in tools))
|
||||
|
||||
def test_flange_sleeve_image_route_exposes_only_the_compact_plan_generator(self) -> None:
|
||||
analysis = {
|
||||
"partType": "带方形法兰的圆筒管座",
|
||||
"visibleFeatures": ["同轴中空管", "四个安装孔"],
|
||||
}
|
||||
|
||||
self.assertTrue(use_flange_sleeve_template(analysis, ""))
|
||||
self.assertFalse(use_flange_sleeve_template(analysis, "cad_existing"))
|
||||
tools = tools_for_model(ProviderModel("default-model"), flange_sleeve_only=True)
|
||||
names = [tool["function"]["name"] for tool in tools]
|
||||
self.assertIn("generate_flange_sleeve_model", names)
|
||||
self.assertNotIn("search_cdsl_library", names)
|
||||
self.assertNotIn("read_cdsl_reference", names)
|
||||
self.assertNotIn("read_current_cdsl", names)
|
||||
self.assertNotIn("generate_cdsl_model", names)
|
||||
|
||||
def test_recorded_image_analysis_is_not_exposed_as_a_tool(self) -> None:
|
||||
tools = tools_for_model(ProviderModel("vision-model", vision=True), include_image_analysis=False)
|
||||
|
||||
self.assertNotIn("analyze_image_reference", [tool["function"]["name"] for tool in tools])
|
||||
|
||||
def test_image_analysis_allows_no_dimension_candidates(self) -> None:
|
||||
analysis_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "analyze_image_reference")
|
||||
self.assertNotIn("dimension_candidates", analysis_tool["function"]["parameters"]["required"])
|
||||
|
||||
result = normalize_image_analysis({
|
||||
"part_type": "压铸外壳",
|
||||
"visible_features": ["圆角矩形外轮廓"],
|
||||
"uncertain_features": [],
|
||||
})
|
||||
self.assertEqual(result["dimension_candidates"], [])
|
||||
|
||||
def test_strict_schema_rejection_is_localized_for_chinese_requests(self) -> None:
|
||||
message = user_visible_error_message(
|
||||
StrictToolSchemaError("provider rejected strict schema"),
|
||||
@@ -243,6 +322,332 @@ 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_persists_every_cdsl_attempt_and_validation_failure(self) -> None:
|
||||
class InvalidCdslAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
plan_call = {
|
||||
"id": "design_brief",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "describe_design_intent",
|
||||
"arguments": json.dumps({"plan": "建立一个法兰。", "assumptions": []}),
|
||||
},
|
||||
}
|
||||
invalid_call = {
|
||||
"id": "invalid_cdsl",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_cdsl_model",
|
||||
"arguments": json.dumps({"cdsl": {}, "summary": "无效法兰", "assumptions": []}),
|
||||
},
|
||||
}
|
||||
self.responses = [
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [plan_call]}}]},
|
||||
*[
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [invalid_call]}}]}
|
||||
for _ in range(7)
|
||||
],
|
||||
]
|
||||
|
||||
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
|
||||
return self.responses.pop(0)
|
||||
|
||||
backend_root = Path(__file__).resolve().parents[1]
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
temporary_root = Path(temporary_directory)
|
||||
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
|
||||
settings = Settings(
|
||||
task_root=temporary_root / "tasks",
|
||||
conversation_root=temporary_root / "conversations",
|
||||
library_root=backend_root / "cdsl_library",
|
||||
engine_root=backend_root / "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)
|
||||
agent = InvalidCdslAgent(settings, store, CdslLibrary(settings))
|
||||
conversation_id = "conv_000000000004"
|
||||
message = ChatMessage(id="user_invalid_cdsl", role="user", parts=[MessagePart(type="text", text="生成一个法兰")])
|
||||
|
||||
async def collect_events() -> list[dict[str, object]]:
|
||||
events: list[dict[str, object]] = []
|
||||
async for chunk in agent.stream([message], conversation_id, None):
|
||||
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect_events())
|
||||
|
||||
diagnostics = settings.conversation_root / conversation_id / "diagnostics"
|
||||
attempts = sorted(diagnostics.glob("cdsl_attempt_*.json"))
|
||||
failures = sorted(diagnostics.glob("cdsl_validation_*.json"))
|
||||
self.assertEqual(len(attempts), 7)
|
||||
self.assertEqual(len(failures), 7)
|
||||
self.assertTrue(all(json.loads(path.read_text(encoding="utf-8")) == {} for path in attempts))
|
||||
|
||||
records = sorted(
|
||||
(json.loads(path.read_text(encoding="utf-8")) for path in failures),
|
||||
key=lambda record: int(record["iteration"]),
|
||||
)
|
||||
self.assertEqual([record["iteration"] for record in records], list(range(2, 9)))
|
||||
self.assertTrue(all(record["kind"] == "cdsl_validation_failure" for record in records))
|
||||
self.assertTrue(all(record["validation_error_type"] == "ValueError" for record in records))
|
||||
self.assertTrue(all(record["validation_error"] for record in records))
|
||||
self.assertEqual(
|
||||
{Path(record["cdsl_attempt_path"]).name for record in records},
|
||||
{path.name for path in attempts},
|
||||
)
|
||||
self.assertTrue(any("每次 CDSL 校验失败的诊断已保存到" in str(event.get("message", "")) for event in events))
|
||||
self.assertEqual(agent.responses, [])
|
||||
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
|
||||
|
||||
|
||||
class ImageReferenceIntakeTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _settings(temporary_root: Path, *, vision: bool = True) -> Settings:
|
||||
backend_root = Path(__file__).resolve().parents[1]
|
||||
provider = ProviderConfig(
|
||||
"test",
|
||||
"Test",
|
||||
"https://example.invalid/v1",
|
||||
"test-key",
|
||||
(ProviderModel("vision-model", vision=vision),),
|
||||
)
|
||||
return Settings(
|
||||
task_root=temporary_root / "tasks",
|
||||
conversation_root=temporary_root / "conversations",
|
||||
library_root=backend_root / "cdsl_library",
|
||||
engine_root=backend_root / "engine" / "cdsl_engine",
|
||||
llm_base_url=provider.base_url,
|
||||
llm_api_key=provider.api_key,
|
||||
llm_model="vision-model",
|
||||
llm_timeout_s=1,
|
||||
default_provider_id="test",
|
||||
providers=(provider,),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_image_attachment(store: WorkspaceStore, conversation_id: str) -> str:
|
||||
store.ensure_conversation(conversation_id)
|
||||
relative_path, _ = store.write_conversation_upload(conversation_id, "flange.png", b"image-bytes")
|
||||
store.add_conversation_attachment(conversation_id, {
|
||||
"id": "upload_flange",
|
||||
"conversation_id": conversation_id,
|
||||
"name": "flange.png",
|
||||
"kind": "image",
|
||||
"path": relative_path,
|
||||
"mime": "image/png",
|
||||
})
|
||||
return "upload_flange"
|
||||
|
||||
@staticmethod
|
||||
def _analysis_arguments() -> dict[str, object]:
|
||||
return {
|
||||
"part_type": "四孔法兰套筒",
|
||||
"visible_features": ["中空圆筒", "四孔法兰", "螺栓孔"],
|
||||
"uncertain_features": ["法兰背面可能有沉孔"],
|
||||
"dimension_candidates": [
|
||||
{"id": "bore_diameter", "label": "中心孔直径", "reason": "图片没有标注内径"},
|
||||
{"id": "bolt_circle", "label": "螺栓孔中心距", "reason": "透视图无法确定孔距"},
|
||||
],
|
||||
}
|
||||
|
||||
def test_image_request_keeps_structured_analysis_when_model_asks_a_question(self) -> None:
|
||||
class ImageIntakeAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.required_tools: list[str | None] = []
|
||||
self.responses = [
|
||||
{"choices": [{"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "image_analysis",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "analyze_image_reference",
|
||||
"arguments": json.dumps(ImageReferenceIntakeTests._analysis_arguments()),
|
||||
},
|
||||
}],
|
||||
}}]},
|
||||
{"choices": [{"message": {
|
||||
"role": "assistant",
|
||||
"content": "中心孔直径会显著影响零件用途,请确认这个尺寸。",
|
||||
"tool_calls": [],
|
||||
}}]},
|
||||
]
|
||||
|
||||
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
|
||||
self.required_tools.append(kwargs.get("required_tool_name") if "required_tool_name" in kwargs else args[4] if len(args) > 4 else None)
|
||||
return self.responses.pop(0)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
temporary_root = Path(temporary_directory)
|
||||
settings = self._settings(temporary_root)
|
||||
store = WorkspaceStore(settings)
|
||||
conversation_id = "conv_000000000001"
|
||||
self._add_image_attachment(store, conversation_id)
|
||||
agent = ImageIntakeAgent(settings, store, CdslLibrary(settings))
|
||||
message = ChatMessage(id="user_image", role="user", parts=[MessagePart(type="text", text="生成图片中的模型")])
|
||||
|
||||
async def collect_events() -> list[dict[str, object]]:
|
||||
events: list[dict[str, object]] = []
|
||||
async for chunk in agent.stream([message], conversation_id, None):
|
||||
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect_events())
|
||||
conversation = store.read_conversation(conversation_id)
|
||||
assistant_parts = conversation["messages"][-1]["parts"]
|
||||
|
||||
self.assertEqual(agent.required_tools, ["analyze_image_reference", None])
|
||||
self.assertEqual(agent.responses, [])
|
||||
self.assertTrue(any(event.get("partType") == "四孔法兰套筒" for event in events))
|
||||
self.assertTrue(any("中心孔直径" in str(event.get("text", "")) for event in events))
|
||||
self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-image-analysis", "text"])
|
||||
self.assertEqual(assistant_parts[0]["data"]["attachmentIds"], ["upload_flange"])
|
||||
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
|
||||
|
||||
def test_model_can_continue_to_generation_after_initial_analysis(self) -> None:
|
||||
class EstimateAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.tool_sets: list[list[str]] = []
|
||||
self.tool_calls: list[str] = []
|
||||
self.responses = [
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "image_analysis", "type": "function", "function": {
|
||||
"name": "analyze_image_reference",
|
||||
"arguments": json.dumps(ImageReferenceIntakeTests._analysis_arguments()),
|
||||
},
|
||||
}]}}]},
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "design_brief", "type": "function", "function": {
|
||||
"name": "describe_design_intent",
|
||||
"arguments": json.dumps({
|
||||
"plan": "按图片比例建立法兰套筒。",
|
||||
"assumptions": ["所有未标注尺寸按图片比例估算,单位为 mm。"],
|
||||
}),
|
||||
},
|
||||
}]}}]},
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "build_cdsl", "type": "function", "function": {
|
||||
"name": "generate_cdsl_model",
|
||||
"arguments": json.dumps({"cdsl": {}, "summary": "估算尺寸的法兰套筒", "assumptions": ["尺寸按比例估算"]}),
|
||||
},
|
||||
}]}}]},
|
||||
{"choices": [{"message": {"role": "assistant", "content": "已按图片比例估算尺寸并生成模型。", "tool_calls": []}}]},
|
||||
]
|
||||
|
||||
async def _complete(self, messages: list[dict[str, object]], tools: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]:
|
||||
self.tool_sets.append([str(tool["function"]["name"]) for tool in tools])
|
||||
return self.responses.pop(0)
|
||||
|
||||
async def _run_tool(self, name: str, arguments: dict[str, object], *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]:
|
||||
self.tool_calls.append(name)
|
||||
if name == "generate_cdsl_model":
|
||||
return {"ok": True, "summary": "估算尺寸的法兰套筒"}, None
|
||||
return await super()._run_tool(name, arguments, *args, **kwargs)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
settings = self._settings(Path(temporary_directory))
|
||||
store = WorkspaceStore(settings)
|
||||
conversation_id = "conv_000000000002"
|
||||
self._add_image_attachment(store, conversation_id)
|
||||
agent = EstimateAgent(settings, store, CdslLibrary(settings))
|
||||
message = ChatMessage(id="user_estimate", role="user", parts=[MessagePart(type="text", text="根据图片直接推进建模,比例上的不确定性按合理工程判断处理。")])
|
||||
|
||||
async def collect_events() -> list[dict[str, object]]:
|
||||
events: list[dict[str, object]] = []
|
||||
async for chunk in agent.stream([message], conversation_id, None):
|
||||
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect_events())
|
||||
assistant_parts = store.read_conversation(conversation_id)["messages"][-1]["parts"]
|
||||
|
||||
self.assertEqual(agent.tool_calls, ["analyze_image_reference", "describe_design_intent", "generate_cdsl_model"])
|
||||
self.assertIn("analyze_image_reference", agent.tool_sets[0])
|
||||
self.assertTrue(all("analyze_image_reference" not in tool_set for tool_set in agent.tool_sets[1:]))
|
||||
self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-image-analysis", "text"])
|
||||
self.assertFalse(any("请补充以下尺寸" in str(event.get("text", "")) for event in events))
|
||||
|
||||
def test_recorded_analysis_reuses_context_without_reanalyzing(self) -> None:
|
||||
class RecordedEstimateAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.tool_sets: list[list[str]] = []
|
||||
self.tool_calls: list[str] = []
|
||||
self.responses = [
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "design_brief", "type": "function", "function": {
|
||||
"name": "describe_design_intent",
|
||||
"arguments": json.dumps({"plan": "按既有识别结果建立法兰套筒。", "assumptions": ["尺寸按图片比例估算"]}),
|
||||
},
|
||||
}]}}]},
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "build_cdsl", "type": "function", "function": {
|
||||
"name": "generate_cdsl_model",
|
||||
"arguments": json.dumps({"cdsl": {}, "summary": "估算尺寸的法兰套筒", "assumptions": ["尺寸按比例估算"]}),
|
||||
},
|
||||
}]}}]},
|
||||
{"choices": [{"message": {"role": "assistant", "content": "已按已有识别结果继续生成模型。", "tool_calls": []}}]},
|
||||
]
|
||||
|
||||
async def _complete(self, messages: list[dict[str, object]], tools: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]:
|
||||
self.tool_sets.append([str(tool["function"]["name"]) for tool in tools])
|
||||
return self.responses.pop(0)
|
||||
|
||||
async def _run_tool(self, name: str, arguments: dict[str, object], *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]:
|
||||
self.tool_calls.append(name)
|
||||
if name == "generate_cdsl_model":
|
||||
return {"ok": True, "summary": "估算尺寸的法兰套筒"}, None
|
||||
return await super()._run_tool(name, arguments, *args, **kwargs)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
settings = self._settings(Path(temporary_directory))
|
||||
store = WorkspaceStore(settings)
|
||||
conversation_id = "conv_000000000003"
|
||||
attachment_id = self._add_image_attachment(store, conversation_id)
|
||||
analysis = self._analysis_arguments()
|
||||
store.append_conversation_message(conversation_id, {
|
||||
"id": "assistant_previous_analysis",
|
||||
"role": "assistant",
|
||||
"parts": [{"type": "data-cad-image-analysis", "data": {
|
||||
"attachmentIds": [attachment_id],
|
||||
"partType": analysis["part_type"],
|
||||
"visibleFeatures": analysis["visible_features"],
|
||||
"uncertainFeatures": analysis["uncertain_features"],
|
||||
"dimensionCandidates": analysis["dimension_candidates"],
|
||||
}}],
|
||||
})
|
||||
agent = RecordedEstimateAgent(settings, store, CdslLibrary(settings))
|
||||
message = ChatMessage(id="user_estimate_again", role="user", parts=[MessagePart(type="text", text="请继续,未标注处按你的工程判断处理。")])
|
||||
|
||||
async def collect_events() -> list[dict[str, object]]:
|
||||
events: list[dict[str, object]] = []
|
||||
async for chunk in agent.stream([message], conversation_id, None):
|
||||
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect_events())
|
||||
conversation = store.read_conversation(conversation_id)
|
||||
analysis_parts = [
|
||||
part
|
||||
for item in conversation["messages"]
|
||||
for part in item["parts"]
|
||||
if part["type"] == "data-cad-image-analysis"
|
||||
]
|
||||
|
||||
self.assertEqual(agent.tool_calls, ["describe_design_intent", "generate_cdsl_model"])
|
||||
self.assertTrue(all("analyze_image_reference" not in tool_set for tool_set in agent.tool_sets))
|
||||
self.assertEqual(len(analysis_parts), 1)
|
||||
self.assertFalse(any("请补充以下尺寸" in str(event.get("text", "")) for event in events))
|
||||
|
||||
def test_direct_cdsl_generation_is_rejected_without_creating_a_task(self) -> None:
|
||||
class RetryAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
@@ -328,10 +733,94 @@ 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"], "DESIGN_INTENT_REQUIRED")
|
||||
self.assertEqual(json.loads(str(tool_result["content"]))["code"], "DESIGN_BRIEF_REQUIRED")
|
||||
self.assertEqual(agent.required_tools, [None, None, None])
|
||||
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
|
||||
|
||||
|
||||
class StructuredResultResponseTests(unittest.TestCase):
|
||||
def test_structured_result_does_not_add_a_duplicate_success_message(self) -> None:
|
||||
class StructuredResultAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.responses = [
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "design_brief",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "describe_design_intent",
|
||||
"arguments": json.dumps({"plan": "建立带中心孔的法兰。", "assumptions": []}),
|
||||
},
|
||||
}]}}]},
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "build_cdsl",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_cdsl_model",
|
||||
"arguments": json.dumps({"cdsl": {}, "summary": "带中心孔的法兰", "assumptions": []}),
|
||||
},
|
||||
}]}}]},
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": []}}]},
|
||||
]
|
||||
|
||||
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
|
||||
return self.responses.pop(0)
|
||||
|
||||
async def _run_tool(self, name: str, *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]:
|
||||
if name == "describe_design_intent":
|
||||
return {"ok": True, "summary": "设计说明已记录"}, None
|
||||
if name == "generate_cdsl_model":
|
||||
return {"ok": True, "summary": "带中心孔的法兰"}, {
|
||||
"task_id": "cad_000000000001",
|
||||
"revision_id": "rev_001",
|
||||
"cdsl_path": "model.cdsl.json",
|
||||
"step_path": "model.step",
|
||||
"glb_path": "model.glb",
|
||||
"report_path": "report.json",
|
||||
"summary": "带中心孔的法兰",
|
||||
"reference_ids": [],
|
||||
"engine": "cdsl_only",
|
||||
}
|
||||
raise AssertionError(f"unexpected tool: {name}")
|
||||
|
||||
backend_root = Path(__file__).resolve().parents[1]
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
temporary_root = Path(temporary_directory)
|
||||
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
|
||||
settings = Settings(
|
||||
task_root=temporary_root / "tasks",
|
||||
conversation_root=temporary_root / "conversations",
|
||||
library_root=backend_root / "cdsl_library",
|
||||
engine_root=backend_root / "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)
|
||||
agent = StructuredResultAgent(settings, store, CdslLibrary(settings))
|
||||
message = ChatMessage(id="user_result", role="user", parts=[MessagePart(type="text", text="生成一个带中心孔的法兰")])
|
||||
|
||||
async def collect_events() -> list[dict[str, object]]:
|
||||
events: list[dict[str, object]] = []
|
||||
async for chunk in agent.stream([message], None, None):
|
||||
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect_events())
|
||||
conversation_id = store.read_conversation(next(settings.conversation_root.iterdir()).name)["conversation_id"]
|
||||
assistant_parts = store.read_conversation(conversation_id)["messages"][-1]["parts"]
|
||||
diagnostics = settings.conversation_root / conversation_id / "diagnostics"
|
||||
attempts = list(diagnostics.glob("cdsl_attempt_*.json"))
|
||||
|
||||
self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-result"])
|
||||
self.assertTrue(any(event.get("taskId") == "cad_000000000001" for event in events))
|
||||
self.assertFalse(any("已生成:" in str(event.get("text", "")) for event in events))
|
||||
self.assertEqual(len(attempts), 1)
|
||||
self.assertEqual(json.loads(attempts[0].read_text(encoding="utf-8")), {})
|
||||
self.assertEqual(list(diagnostics.glob("cdsl_validation_*.json")), [])
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.attachments import attachment_record
|
||||
from app.services.storage import WorkspaceStore
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
|
||||
|
||||
class ConversationAttachmentStorageTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
temporary_root = Path(self.temporary_directory.name)
|
||||
backend_root = Path(__file__).resolve().parents[1]
|
||||
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
|
||||
self.settings = Settings(
|
||||
task_root=temporary_root / "tasks",
|
||||
conversation_root=temporary_root / "conversations",
|
||||
library_root=backend_root / "cdsl_library",
|
||||
engine_root=backend_root / "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,),
|
||||
)
|
||||
self.store = WorkspaceStore(self.settings)
|
||||
self.conversation_id = "conv_000000000001"
|
||||
self.store.ensure_conversation(self.conversation_id)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def test_upload_is_owned_by_conversation_without_creating_a_task(self) -> None:
|
||||
relative_path, target = self.store.write_conversation_upload(self.conversation_id, "reference.png", b"reference-bytes")
|
||||
attachment = attachment_record(self.conversation_id, "reference.png", "image/png", relative_path, b"reference-bytes", "image")
|
||||
conversation = self.store.add_conversation_attachment(self.conversation_id, attachment)
|
||||
|
||||
self.assertEqual(target, (self.settings.conversation_root / self.conversation_id / relative_path).resolve())
|
||||
self.assertTrue(target.is_file())
|
||||
self.assertEqual(conversation["attachments"], [attachment])
|
||||
self.assertEqual(list(self.settings.task_root.glob("cad_*")), [])
|
||||
|
||||
def test_attachment_cannot_be_linked_to_another_conversation(self) -> None:
|
||||
relative_path, _ = self.store.write_conversation_upload(self.conversation_id, "reference.png", b"reference-bytes")
|
||||
attachment = attachment_record("conv_000000000002", "reference.png", "image/png", relative_path, b"reference-bytes", "image")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "does not belong"):
|
||||
self.store.add_conversation_attachment(self.conversation_id, attachment)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -132,7 +132,8 @@ class DesignIntentValidationTests(unittest.TestCase):
|
||||
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")
|
||||
self.assertEqual(context.exception.code, "DESIGN_INTENT_BASE_REVISION_MISMATCH")
|
||||
self.assertIn("rev_002", str(context.exception))
|
||||
|
||||
def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None:
|
||||
duplicate = mounting_plate_intent()
|
||||
|
||||
@@ -6,18 +6,21 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
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 # noqa: E402
|
||||
from app.services.engine_service import load_engine # noqa: E402
|
||||
from app.services.flange_sleeve_template import TEMPLATE_ID # 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
|
||||
from tests.test_design_intent import mounting_plate_cdsl # noqa: E402
|
||||
|
||||
|
||||
BACKEND = ROOT / "backend"
|
||||
@@ -40,85 +43,274 @@ class DesignIntentFlowTests(unittest.TestCase):
|
||||
providers=(provider,),
|
||||
)
|
||||
|
||||
def test_design_intent_is_required_before_library_or_cdsl(self) -> None:
|
||||
def test_design_brief_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))
|
||||
state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
|
||||
searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], planning_state=state))
|
||||
generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], planning_state=state))
|
||||
|
||||
self.assertEqual(searched["code"], "DESIGN_INTENT_REQUIRED")
|
||||
self.assertEqual(generated["code"], "DESIGN_INTENT_REQUIRED")
|
||||
self.assertEqual(searched["code"], "DESIGN_BRIEF_REQUIRED")
|
||||
self.assertEqual(generated["code"], "DESIGN_BRIEF_REQUIRED")
|
||||
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
|
||||
|
||||
def test_ready_plan_persists_before_build_and_links_success_revision(self) -> None:
|
||||
def test_generation_normalizes_legacy_llm_cdsl_before_building(self) -> None:
|
||||
legacy_cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"part_id": "legacy-flange-base",
|
||||
"geometry": {"sketches": [{
|
||||
"id": "base_sketch",
|
||||
"plane": "XY",
|
||||
"offset_mm": 12,
|
||||
"profile": {"type": "circle", "radius_mm": 20},
|
||||
}]},
|
||||
"features": [{
|
||||
"id": "base_add",
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"sketch": "base_sketch",
|
||||
"params": {"distance_mm": 8},
|
||||
}],
|
||||
}
|
||||
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": "PLAN_RECORDED", "design_brief": "Create a flange base."}
|
||||
captured_build: dict[str, object] = {}
|
||||
|
||||
def fake_build_revision(**kwargs: object) -> dict[str, object]:
|
||||
captured_build.update(kwargs)
|
||||
return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"}
|
||||
|
||||
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
result, _ = asyncio.run(agent._run_tool(
|
||||
"generate_cdsl_model",
|
||||
{"cdsl": legacy_cdsl, "summary": "flange base", "assumptions": []},
|
||||
"", "Create a flange base", [], planning_state=state,
|
||||
))
|
||||
|
||||
built_cdsl = captured_build["cdsl"]
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(built_cdsl["features"][0]["sketch_id"], "base_sketch")
|
||||
self.assertEqual(built_cdsl["features"][0]["depends_on"], [])
|
||||
self.assertNotIn("sketch", built_cdsl["features"][0])
|
||||
self.assertIn("workplane", built_cdsl["geometry"]["sketches"][0])
|
||||
self.assertEqual(len(result["normalization_repairs"]), 3)
|
||||
|
||||
def test_flange_sleeve_tool_builds_cdsl_from_a_compact_semantic_plan(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": "PLAN_RECORDED", "design_brief": "Create a flange sleeve."}
|
||||
captured_build: dict[str, object] = {}
|
||||
|
||||
def fake_build_revision(**kwargs: object) -> dict[str, object]:
|
||||
captured_build.update(kwargs)
|
||||
return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"}
|
||||
|
||||
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
result, _ = asyncio.run(agent._run_tool(
|
||||
"generate_flange_sleeve_model",
|
||||
{
|
||||
"plan": {
|
||||
"template": TEMPLATE_ID,
|
||||
"name": "Image flange sleeve",
|
||||
"flange_width_mm": 140,
|
||||
"flange_height_mm": 120,
|
||||
"mount_hole_u_mm": 48,
|
||||
"mount_hole_v_mm": 38,
|
||||
},
|
||||
"summary": "flange sleeve",
|
||||
"assumptions": ["Dimensions are approximate."],
|
||||
},
|
||||
"", "Create an image flange sleeve", [], planning_state=state,
|
||||
))
|
||||
|
||||
built_cdsl = captured_build["cdsl"]
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["template_id"], TEMPLATE_ID)
|
||||
self.assertEqual(captured_build["operation"]["template_id"], TEMPLATE_ID)
|
||||
self.assertEqual(built_cdsl["meta"]["name"], "Image flange sleeve")
|
||||
self.assertEqual(built_cdsl["geometry"]["sketches"][0]["workplane"]["normal"], [1.0, 0.0, 0.0])
|
||||
self.assertEqual(built_cdsl["features"][-1]["depends_on"], ["mount_hole_cuts"])
|
||||
self.assertNotIn("cdsl", result["template_plan"])
|
||||
|
||||
def test_successful_generation_ends_the_agent_tool_loop(self) -> None:
|
||||
class CaptureAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.responses = [
|
||||
{
|
||||
"choices": [{"message": {
|
||||
"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "brief", "type": "function", "function": {
|
||||
"name": "describe_design_intent",
|
||||
"arguments": json.dumps({"plan": "Create a flange sleeve.", "assumptions": []}),
|
||||
},
|
||||
}],
|
||||
}}],
|
||||
},
|
||||
{
|
||||
"choices": [{"message": {
|
||||
"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "template", "type": "function", "function": {
|
||||
"name": "generate_flange_sleeve_model",
|
||||
"arguments": json.dumps({
|
||||
"plan": {"template": TEMPLATE_ID},
|
||||
"summary": "flange sleeve",
|
||||
"assumptions": [],
|
||||
}),
|
||||
},
|
||||
}],
|
||||
}}],
|
||||
},
|
||||
]
|
||||
|
||||
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
|
||||
return self.responses.pop(0)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = self.settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
agent = CaptureAgent(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
|
||||
def fake_build_revision(**kwargs: object) -> dict[str, object]:
|
||||
return {
|
||||
"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001",
|
||||
"cdsl_path": "revisions/rev_001/model.cdsl.json",
|
||||
"step_path": "revisions/rev_001/model.step",
|
||||
"glb_path": "revisions/rev_001/model.glb",
|
||||
"report_path": "revisions/rev_001/rebuild-report.json",
|
||||
"summary": str(kwargs["summary"]), "reference_ids": [], "engine": "cdsl_only",
|
||||
}
|
||||
|
||||
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
async def consume() -> None:
|
||||
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a flange sleeve")])
|
||||
async for _ in agent.stream([message], None, None):
|
||||
pass
|
||||
|
||||
asyncio.run(consume())
|
||||
|
||||
saved = store.read_conversation(next(item.name for item in settings.conversation_root.iterdir()))
|
||||
parts = saved["messages"][-1]["parts"]
|
||||
self.assertEqual(agent.responses, [])
|
||||
self.assertTrue(any(part["type"] == "data-cad-result" for part in parts))
|
||||
self.assertFalse(any(part["type"] == "data-cad-error" for part in parts))
|
||||
|
||||
def test_text_brief_is_returned_to_model_and_cdsl_is_the_only_contract(self) -> None:
|
||||
class CaptureAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.seen_messages: list[list[dict[str, object]]] = []
|
||||
self.responses = [
|
||||
{
|
||||
"choices": [{"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "brief",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "describe_design_intent",
|
||||
"arguments": json.dumps({
|
||||
"plan": "Create a rectangular mounting plate, then cut four mounting holes and a center slot.",
|
||||
"assumptions": ["Use millimetres."],
|
||||
}),
|
||||
},
|
||||
}],
|
||||
}}],
|
||||
},
|
||||
{
|
||||
"choices": [{"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "generate",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_cdsl_model",
|
||||
"arguments": json.dumps({
|
||||
"cdsl": mounting_plate_cdsl(),
|
||||
"summary": "mounting plate",
|
||||
"assumptions": ["Use millimetres."],
|
||||
}),
|
||||
},
|
||||
}],
|
||||
}}],
|
||||
},
|
||||
{"choices": [{"message": {"role": "assistant", "content": "已生成。", "tool_calls": []}}]},
|
||||
]
|
||||
|
||||
async def _complete(self, messages: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]:
|
||||
self.seen_messages.append([dict(message) for message in messages])
|
||||
return self.responses.pop(0)
|
||||
|
||||
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,
|
||||
))
|
||||
agent = CaptureAgent(settings, store, CdslLibrary(settings), skills)
|
||||
captured_build: dict[str, object] = {}
|
||||
|
||||
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"])
|
||||
def fake_build_revision(**kwargs: object) -> dict[str, object]:
|
||||
captured_build.update(kwargs)
|
||||
return {
|
||||
"task_id": "cad_aaaaaaaaaaaa",
|
||||
"revision_id": "rev_001",
|
||||
"cdsl_path": "revisions/rev_001/model.cdsl.json",
|
||||
"step_path": "revisions/rev_001/model.step",
|
||||
"glb_path": "revisions/rev_001/model.glb",
|
||||
"report_path": "revisions/rev_001/rebuild-report.json",
|
||||
"summary": str(kwargs["summary"]),
|
||||
"reference_ids": list(kwargs["reference_ids"]),
|
||||
"engine": "cdsl_only",
|
||||
}
|
||||
|
||||
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,
|
||||
engine = load_engine(settings)
|
||||
with patch.object(engine, "validate_intent_cdsl", side_effect=AssertionError("legacy intent validation must not run")) as legacy_validation, patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
async def consume() -> None:
|
||||
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")])
|
||||
async for _ in agent.stream([message], None, None):
|
||||
pass
|
||||
|
||||
asyncio.run(consume())
|
||||
|
||||
brief_result = json.loads(str(agent.seen_messages[1][-1]["content"]))
|
||||
self.assertEqual(brief_result["plan"], "Create a rectangular mounting plate, then cut four mounting holes and a center slot.")
|
||||
self.assertNotIn("design_intent_id", brief_result)
|
||||
self.assertNotIn("design_intent_id", captured_build)
|
||||
self.assertNotIn("design_intent", captured_build)
|
||||
self.assertEqual(captured_build["parent_revision_id"], "")
|
||||
legacy_validation.assert_not_called()
|
||||
|
||||
def test_revision_parent_is_taken_from_the_current_successful_cdsl_revision(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_PLAN", "design_brief": ""}
|
||||
asyncio.run(agent._run_tool(
|
||||
"describe_design_intent",
|
||||
{"plan": "Increase the plate thickness and preserve the existing hole layout.", "assumptions": []},
|
||||
"cad_aaaaaaaaaaaa", "Revise the plate", [], planning_state=state,
|
||||
))
|
||||
store.read_task = lambda _task_id: {"current_revision": "rev_007"} # type: ignore[method-assign]
|
||||
captured_build: dict[str, object] = {}
|
||||
|
||||
def fake_build_revision(**kwargs: object) -> dict[str, object]:
|
||||
captured_build.update(kwargs)
|
||||
return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_008"}
|
||||
|
||||
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
result, _ = asyncio.run(agent._run_tool(
|
||||
"generate_cdsl_model",
|
||||
{"cdsl": mounting_plate_cdsl(), "summary": "revised plate", "assumptions": []},
|
||||
"cad_aaaaaaaaaaaa", "Revise the plate", [], planning_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")
|
||||
self.assertEqual(captured_build["parent_revision_id"], "rev_007")
|
||||
|
||||
@@ -211,7 +211,7 @@ class AgentPartSkillTests(unittest.TestCase):
|
||||
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:
|
||||
def test_prompt_contains_bridge_and_template_tool(self) -> None:
|
||||
library = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
selection = library.select("Create a flange with a bolt circle")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -220,7 +220,7 @@ class AgentPartSkillTests(unittest.TestCase):
|
||||
|
||||
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"])
|
||||
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_flange_sleeve_model", "generate_cdsl_model"])
|
||||
|
||||
def test_generation_persists_assumptions_and_selected_skills(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -230,27 +230,25 @@ class AgentPartSkillTests(unittest.TestCase):
|
||||
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": ""}
|
||||
planning_state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
|
||||
planned, _ = asyncio.run(agent._run_tool(
|
||||
"propose_design_intent",
|
||||
{"intent": intent, "summary": "M6 nut plan", "assumptions": []},
|
||||
"describe_design_intent",
|
||||
{"plan": "Create an M6 hex nut with a centered cylindrical bore representing the thread.", "assumptions": []},
|
||||
"",
|
||||
request,
|
||||
[],
|
||||
part_skill_selection=selection,
|
||||
intent_state=intent_state,
|
||||
planning_state=planning_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"],
|
||||
{"cdsl": hex_nut_fixture(), "summary": "M6 nut", "assumptions": ["M6 thread is represented as a cylindrical bore"]},
|
||||
"",
|
||||
request,
|
||||
["reference-fixture"],
|
||||
part_skill_selection=selection,
|
||||
intent_state=intent_state,
|
||||
planning_state=planning_state,
|
||||
))
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
|
||||
@@ -5,7 +5,8 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.engine_service import load_engine, validate_cdsl
|
||||
from app.services.engine_service import load_engine, normalize_cdsl_for_engine, validate_cdsl
|
||||
from app.services.flange_sleeve_template import TEMPLATE_ID, build_flange_sleeve_cdsl
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
@@ -88,6 +89,85 @@ class ProfileSchemaTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "positions\\[0\\].*not of type 'object'"):
|
||||
validate_cdsl(cdsl, self.engine)
|
||||
|
||||
def test_normalizes_unambiguous_legacy_llm_field_names_before_validation(self) -> None:
|
||||
legacy_cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"part_id": "legacy-flange-base",
|
||||
"geometry": {"sketches": [{
|
||||
"id": "base_sketch",
|
||||
"plane": "XY",
|
||||
"offset_mm": 12,
|
||||
"profile": {"type": "circle", "radius_mm": 20},
|
||||
}]},
|
||||
"features": [{
|
||||
"id": "base_add",
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"sketch": "base_sketch",
|
||||
"params": {"distance_mm": 8},
|
||||
}],
|
||||
}
|
||||
|
||||
normalized, repairs = normalize_cdsl_for_engine(legacy_cdsl)
|
||||
|
||||
self.assertEqual(legacy_cdsl["geometry"]["sketches"][0]["plane"], "XY")
|
||||
self.assertEqual(normalized["features"][0]["sketch_id"], "base_sketch")
|
||||
self.assertNotIn("sketch", normalized["features"][0])
|
||||
self.assertEqual(normalized["features"][0]["depends_on"], [])
|
||||
self.assertEqual(normalized["geometry"]["sketches"][0]["workplane"], {
|
||||
"origin_mm": [0.0, 0.0, 12.0],
|
||||
"x_dir": [1.0, 0.0, 0.0],
|
||||
"normal": [0.0, 0.0, 1.0],
|
||||
})
|
||||
self.assertEqual(len(repairs), 3)
|
||||
validate_cdsl(normalized, self.engine)
|
||||
|
||||
def test_normalizer_rewrites_the_legacy_revolve_axis_point_name(self) -> None:
|
||||
normalized, repairs = normalize_cdsl_for_engine({
|
||||
"features": [{
|
||||
"id": "turn",
|
||||
"atomic_id": "revolve_add",
|
||||
"params": {"axis": {"point_mm": [0, 0, 0], "direction": [0, 0, 1]}},
|
||||
}],
|
||||
})
|
||||
|
||||
axis = normalized["features"][0]["params"]["axis"]
|
||||
self.assertEqual(axis["origin_mm"], [0, 0, 0])
|
||||
self.assertNotIn("point_mm", axis)
|
||||
self.assertIn("features[0].params.axis: point_mm -> origin_mm", repairs)
|
||||
|
||||
def test_flange_sleeve_template_is_schema_valid_and_runtime_buildable(self) -> None:
|
||||
cdsl, plan = build_flange_sleeve_cdsl({
|
||||
"template": TEMPLATE_ID,
|
||||
"name": "Template flange sleeve",
|
||||
"flange_width_mm": 120,
|
||||
"flange_height_mm": 120,
|
||||
"flange_thickness_mm": 14,
|
||||
"corner_chamfer_mm": 10,
|
||||
"tube_outer_diameter_mm": 70,
|
||||
"tube_straight_length_mm": 95,
|
||||
"tip_outer_diameter_mm": 62,
|
||||
"tip_length_mm": 28,
|
||||
"bore_diameter_mm": 46,
|
||||
"boss_outer_diameter_mm": 82,
|
||||
"boss_height_mm": 5,
|
||||
"mount_hole_diameter_mm": 12,
|
||||
"mount_counterbore_diameter_mm": 24,
|
||||
"mount_counterbore_depth_mm": 5,
|
||||
"mount_hole_u_mm": 42,
|
||||
"mount_hole_v_mm": 42,
|
||||
})
|
||||
|
||||
self.assertEqual(plan["name"], "Template flange sleeve")
|
||||
self.assertEqual(cdsl["features"][1]["depends_on"], ["flange_plate"])
|
||||
self.assertEqual(cdsl["geometry"]["sketches"][0]["workplane"]["x_dir"], [0.0, 1.0, 0.0])
|
||||
validate_cdsl(cdsl, self.engine)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
step_path = Path(directory) / "flange-sleeve.step"
|
||||
result = self.engine.run_cdsl_only(cdsl, step_path)
|
||||
self.assertEqual(result["engine"], "cdsl_only")
|
||||
self.assertTrue(step_path.is_file())
|
||||
self.assertGreater(step_path.stat().st_size, 0)
|
||||
|
||||
def test_cdsl_only_rebuild_preserves_its_actual_failure(self) -> None:
|
||||
cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
|
||||
Reference in New Issue
Block a user