优化
This commit is contained in:
+5
-5
@@ -2,7 +2,7 @@
|
||||
# CDSL_DEFAULT_PROVIDER=deepseek
|
||||
# CDSL_DEFAULT_MODEL=deepseek-v4-flash
|
||||
CDSL_DEFAULT_PROVIDER=openai
|
||||
CDSL_DEFAULT_MODEL=gpt-5.5
|
||||
CDSL_DEFAULT_MODEL=gpt-5.4-mini
|
||||
|
||||
# DeepSeek. Fill in your own API key below.
|
||||
CDSL_LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
@@ -12,15 +12,15 @@ CDSL_LLM_TIMEOUT_S=90
|
||||
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
|
||||
|
||||
# Final autonomous-task publication uses this independent vision reviewer.
|
||||
CDSL_REVIEW_PROVIDER=deepseek
|
||||
CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp
|
||||
CDSL_REVIEW_PROVIDER=openai
|
||||
CDSL_REVIEW_MODEL=gpt-5.5
|
||||
|
||||
# Optional OpenAI provider. Comma-separate enabled models; list vision models
|
||||
# separately so image attachments can be routed safely.
|
||||
CDSL_OPENAI_BASE_URL=https://api.vip1129.cc/v1
|
||||
CDSL_OPENAI_API_KEY=sk-6586c229d77de8c421ba98e7eb0d9c6bb10f08ebc796de946ed17cf8d0d7a229
|
||||
CDSL_OPENAI_MODELS=gpt-5.5,gpt-5.6-luna
|
||||
CDSL_OPENAI_VISION_MODELS=gpt-5.5,gpt-5.6-luna
|
||||
CDSL_OPENAI_MODELS=gpt-5.5,gpt-5.6-luna,gpt-5.4-mini
|
||||
CDSL_OPENAI_VISION_MODELS=gpt-5.5,gpt-5.6-luna,gpt-5.4-mini
|
||||
# Supported values are model- and endpoint-dependent: low, medium, high.
|
||||
# gpt-5.5 defaults to medium, but setting it explicitly keeps requests stable.
|
||||
CDSL_OPENAI_REASONING_EFFORT=medium
|
||||
|
||||
@@ -156,10 +156,25 @@ async def read_task(task_id: str) -> JSONResponse:
|
||||
task["requirements_markdown"] = requirements or None
|
||||
completion_checklist = store.read_completion_checklist(task["task_id"])
|
||||
task["completion_checklist_markdown"] = completion_checklist or None
|
||||
modeling_plan = store.read_modeling_plan(task["task_id"])
|
||||
task["modeling_plan_markdown"] = modeling_plan or None
|
||||
task["modeling_plan_review"] = store.read_modeling_plan_review(task["task_id"])
|
||||
task["agent_state"] = store.read_agent_state(task["task_id"])
|
||||
return JSONResponse(task)
|
||||
|
||||
|
||||
@app.delete("/v1/tasks/{task_id}")
|
||||
async def cancel_task(task_id: str) -> JSONResponse:
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
task = await agent.cancel(safe_id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return JSONResponse(task)
|
||||
|
||||
|
||||
@app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}")
|
||||
async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
@@ -17,7 +17,7 @@ from app.services.autonomous_cdsl_generation import AutonomousCdslGenerationRunn
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.review_renderer import renderer_status
|
||||
from app.services.sse import event
|
||||
from app.services.storage import WorkspaceStore
|
||||
from app.services.storage import WorkspaceStore, now_iso
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
|
||||
|
||||
@@ -25,6 +25,68 @@ def text_from_message(message: ChatMessage) -> str:
|
||||
return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip()
|
||||
|
||||
|
||||
def _response_language(text: str) -> str:
|
||||
cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff")
|
||||
latin = sum(1 for char in text if char.isascii() and char.isalpha())
|
||||
return "Chinese" if cjk >= 2 and cjk >= latin * 0.15 else "English"
|
||||
|
||||
|
||||
_EVENT_LABELS = {
|
||||
"requirements_document": "冻结需求", "completion_checklist": "完成清单", "completion_audit": "完成审计",
|
||||
"modeling_plan": "建模计划", "modeling_plan_review": "计划独立复核",
|
||||
"tool_call": "建模工具", "candidate_result": "候选构建", "candidate_review": "候选独立复核",
|
||||
"geometry_diagnostic": "几何诊断", "geometry_conclusion": "几何结论", "step_review": "步骤审查",
|
||||
"checkpoint": "构建检查点", "rollback": "回滚检查点", "final_review": "最终视觉复核", "task_terminal": "生成任务",
|
||||
}
|
||||
|
||||
|
||||
def _event_status(name: str, payload: dict[str, Any]) -> str:
|
||||
if name == "task_terminal":
|
||||
lifecycle = str(payload.get("lifecycle") or "")
|
||||
if lifecycle == "failed":
|
||||
return "error"
|
||||
if lifecycle == "cancelled":
|
||||
return "cancelled"
|
||||
return "success"
|
||||
review = payload.get("review") if isinstance(payload.get("review"), dict) else {}
|
||||
if (name == "modeling_plan_review" and str(review.get("verdict") or "") == "revise") or (
|
||||
name == "candidate_review" and str(review.get("verdict") or "") == "reject"
|
||||
) or (
|
||||
name == "final_review" and str(review.get("verdict") or "") == "repair"
|
||||
):
|
||||
return "error"
|
||||
return str(payload.get("status") or "running")
|
||||
|
||||
|
||||
def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
data.update({"step": name, "label": _EVENT_LABELS.get(name, name), "status": _event_status(name, payload)})
|
||||
return data
|
||||
|
||||
|
||||
def _append_text_part(parts: list[dict[str, Any]], text: str) -> None:
|
||||
if not text:
|
||||
return
|
||||
if parts and parts[-1].get("type") == "text":
|
||||
parts[-1]["text"] = str(parts[-1].get("text") or "") + text
|
||||
else:
|
||||
parts.append({"type": "text", "text": text})
|
||||
|
||||
|
||||
def _upsert_data_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None:
|
||||
part_id = str(part.get("id") or "")
|
||||
if part_id:
|
||||
for index, existing in enumerate(parts):
|
||||
if str(existing.get("id") or "") == part_id:
|
||||
parts[index] = part
|
||||
return
|
||||
parts.append(part)
|
||||
|
||||
|
||||
class _StreamingUnsupported(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def conversation_user_context(conversation: dict[str, Any]) -> list[dict[str, str]]:
|
||||
"""Return durable user intent from the whole conversation.
|
||||
|
||||
@@ -131,6 +193,19 @@ class AgentService:
|
||||
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"resume-autonomous-cdsl-{task_id}")
|
||||
|
||||
async def cancel(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.store.read_task(task_id)
|
||||
if task is None:
|
||||
return None
|
||||
running = self._autonomous_runs.pop(task_id, None)
|
||||
if running and not running.done():
|
||||
running.cancel()
|
||||
if str(task.get("lifecycle") or "") == "running":
|
||||
task = self.store.finish_generation(task_id, lifecycle="cancelled", failure={
|
||||
"schema_version": "cad.autonomous-cancelled.v1", "stage": "cancelled", "message": "CAD generation was cancelled by the user.",
|
||||
})
|
||||
return task
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
@@ -214,34 +289,67 @@ class AgentService:
|
||||
if isinstance(attachment, dict) and str(attachment.get("id") or "")
|
||||
]
|
||||
self.store.start_generation(task_id, request=user_text)
|
||||
yield event("progress", {
|
||||
"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running",
|
||||
"message": "CAD 任务已启动。" if _response_language(user_text) == "Chinese" else "CAD task started.",
|
||||
})
|
||||
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
|
||||
runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete)
|
||||
assistant_parts: list[dict[str, Any]] = []
|
||||
sequence = 0
|
||||
|
||||
async def on_text_delta(text: str) -> None:
|
||||
# Text is persisted and streamed through the same ordered queue as
|
||||
# tool events, so a refresh produces the exact same transcript.
|
||||
nonlocal sequence
|
||||
sequence += 1
|
||||
_append_text_part(assistant_parts, text)
|
||||
await queue.put(("text_delta", {
|
||||
"text": text, "taskId": task_id, "eventId": f"{task_id}_{sequence}_text_delta",
|
||||
"sequence": sequence, "timestamp": now_iso(),
|
||||
}))
|
||||
|
||||
runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete, on_text_delta=on_text_delta)
|
||||
assistant_id = f"assistant_{secrets.token_hex(8)}"
|
||||
|
||||
async def consume() -> None:
|
||||
assistant_parts: list[dict[str, Any]] = []
|
||||
nonlocal sequence
|
||||
terminal = ""
|
||||
try:
|
||||
async for name, payload in runner.run(
|
||||
task_id=task_id, request=user_text, conversation_id=conversation["conversation_id"], provider=provider,
|
||||
model=model, initial_messages=initial_messages, frozen_attachment_ids=frozen_attachment_ids, already_started=True,
|
||||
):
|
||||
sequence += 1
|
||||
decorated = dict(payload)
|
||||
decorated.setdefault("taskId", task_id)
|
||||
decorated.setdefault("eventId", f"{task_id}_{sequence}_{name}")
|
||||
decorated.update({
|
||||
"sequence": sequence,
|
||||
"timestamp": now_iso(),
|
||||
})
|
||||
if name == "cad_result":
|
||||
assistant_parts.append({"type": "data-cad-result", "data": payload})
|
||||
_upsert_data_part(assistant_parts, {"type": "data-cad-result", "id": decorated["eventId"], "data": decorated})
|
||||
elif name == "agent_thinking":
|
||||
visible = str(payload.get("message") or "")
|
||||
if visible:
|
||||
assistant_parts.append({"type": "text", "text": visible})
|
||||
# Streaming completions already delivered this
|
||||
# content through on_text_delta; only persist the
|
||||
# full response for the compatibility path.
|
||||
if not assistant_parts or assistant_parts[-1].get("type") != "text" or visible not in str(assistant_parts[-1].get("text") or ""):
|
||||
_append_text_part(assistant_parts, visible + "\n\n")
|
||||
elif name == "task_terminal":
|
||||
terminal = str(payload.get("lifecycle") or "")
|
||||
if terminal == "failed":
|
||||
assistant_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}})
|
||||
await queue.put((name, payload))
|
||||
_upsert_data_part(assistant_parts, {"type": "data-cad-error", "id": decorated["eventId"], "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}})
|
||||
elif name != "text_delta":
|
||||
_upsert_data_part(assistant_parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)})
|
||||
await queue.put((name, decorated))
|
||||
except Exception as error:
|
||||
self.store.finish_generation(task_id, lifecycle="failed", failure={"schema_version": "cad.autonomous-failure.v1", "stage": "worker", "message": str(error)})
|
||||
terminal = "failed"
|
||||
payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)}
|
||||
assistant_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}})
|
||||
error_id = f"{task_id}_{sequence + 1}_cad_error"
|
||||
assistant_parts.append({"type": "data-cad-error", "id": error_id, "data": {"stage": "generation", "message": str(error)}})
|
||||
await queue.put(("task_terminal", payload))
|
||||
finally:
|
||||
if terminal == "completed" and not assistant_parts:
|
||||
@@ -252,14 +360,20 @@ class AgentService:
|
||||
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cdsl-{task_id}")
|
||||
while True:
|
||||
item = await queue.get()
|
||||
try:
|
||||
item = await asyncio.wait_for(queue.get(), timeout=15)
|
||||
except asyncio.TimeoutError:
|
||||
yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()})
|
||||
continue
|
||||
if item is None:
|
||||
break
|
||||
name, payload = item
|
||||
if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed":
|
||||
yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")})
|
||||
elif name == "agent_thinking":
|
||||
yield event("text_delta", {"text": str(payload.get("message") or "")})
|
||||
message = str(payload.get("message") or "")
|
||||
if message:
|
||||
yield event("text_delta", {"text": message + "\n\n"})
|
||||
else:
|
||||
yield event(name, payload)
|
||||
yield event("done", {})
|
||||
@@ -278,6 +392,94 @@ class AgentService:
|
||||
provider: ProviderConfig,
|
||||
model: ProviderModel,
|
||||
required_tool_name: str | None = None,
|
||||
*,
|
||||
on_text_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if on_text_delta is None:
|
||||
return await self._complete_once(messages, tools, provider, model, required_tool_name)
|
||||
try:
|
||||
return await self._complete_stream(messages, tools, provider, model, required_tool_name, on_text_delta)
|
||||
except _StreamingUnsupported:
|
||||
await on_text_delta("当前模型不支持工具流式,已切换为兼容模式。\n\n")
|
||||
response = await self._complete_once(messages, tools, provider, model, required_tool_name)
|
||||
content = str((((response.get("choices") or [{}])[0] or {}).get("message") or {}).get("content") or "")
|
||||
if content:
|
||||
await on_text_delta(content + "\n\n")
|
||||
# Prevent the runner from emitting this same full response again.
|
||||
choice = ((response.get("choices") or [{}])[0] or {}).get("message") or {}
|
||||
return {"choices": [{"message": {**choice, "content": ""}}]}
|
||||
|
||||
async def _complete_stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
provider: ProviderConfig,
|
||||
model: ProviderModel,
|
||||
required_tool_name: str | None,
|
||||
on_text_delta: Callable[[str], Awaitable[None]],
|
||||
) -> dict[str, Any]:
|
||||
tool_choice: str | dict[str, Any] = "auto"
|
||||
if required_tool_name:
|
||||
tool_choice = {"type": "function", "function": {"name": required_tool_name}}
|
||||
payload: dict[str, Any] = {
|
||||
"model": model.id, "messages": messages, "tools": tools,
|
||||
"tool_choice": tool_choice, "temperature": 0.1, "stream": True,
|
||||
}
|
||||
payload.update(provider.chat_completion_options)
|
||||
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client:
|
||||
async with client.stream("POST", f"{provider.base_url}/chat/completions", headers=headers, json=payload) as response:
|
||||
if response.status_code in {400, 404, 405, 415, 422}:
|
||||
detail = (await response.aread()).decode(errors="replace")[:400]
|
||||
raise _StreamingUnsupported(detail)
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"LLM request failed ({response.status_code}): {(await response.aread()).decode(errors='replace')[:800]}")
|
||||
if "text/event-stream" not in str(response.headers.get("content-type") or ""):
|
||||
raise _StreamingUnsupported("provider returned a non-stream response")
|
||||
tool_calls: dict[int, dict[str, Any]] = {}
|
||||
async for line in response.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
raw = line[5:].strip()
|
||||
if raw == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = chunk.get("choices") if isinstance(chunk, dict) else None
|
||||
delta = choices[0].get("delta") if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
|
||||
if not isinstance(delta, dict):
|
||||
continue
|
||||
text = str(delta.get("content") or delta.get("reasoning_content") or "")
|
||||
if text:
|
||||
await on_text_delta(text)
|
||||
for call in delta.get("tool_calls") or []:
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
index = int(call.get("index") or 0)
|
||||
current = tool_calls.setdefault(index, {"id": str(call.get("id") or f"call_{index}"), "type": "function", "function": {"name": "", "arguments": ""}})
|
||||
if call.get("id"):
|
||||
current["id"] = str(call["id"])
|
||||
function = call.get("function") if isinstance(call.get("function"), dict) else {}
|
||||
if function.get("name"):
|
||||
current["function"]["name"] += str(function["name"])
|
||||
if function.get("arguments"):
|
||||
current["function"]["arguments"] += str(function["arguments"])
|
||||
except _StreamingUnsupported:
|
||||
raise
|
||||
except (httpx.HTTPError, asyncio.TimeoutError) as error:
|
||||
raise RuntimeError(f"LLM streaming connection failed: {error}") from error
|
||||
return {"choices": [{"message": {"content": "", "tool_calls": [tool_calls[key] for key in sorted(tool_calls)]}}]}
|
||||
|
||||
async def _complete_once(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
provider: ProviderConfig,
|
||||
model: ProviderModel,
|
||||
required_tool_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tool_choice: str | dict[str, Any] = "auto"
|
||||
if required_tool_name:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from app.services.engine_service import feature_atomic_contract
|
||||
|
||||
|
||||
class CanonicalFragmentError(ValueError):
|
||||
def __init__(self, message: str, *, path: str = "fragment", code: str = "CDSL_SCHEMA_INVALID") -> None:
|
||||
super().__init__(message)
|
||||
self.path = path
|
||||
self.code = code
|
||||
|
||||
|
||||
def _point(size: int) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"minItems": size,
|
||||
"maxItems": size,
|
||||
}
|
||||
|
||||
|
||||
def _workplane_schema() -> dict[str, Any]:
|
||||
point3 = _point(3)
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"origin_mm": deepcopy(point3),
|
||||
"x_dir": deepcopy(point3),
|
||||
"normal": deepcopy(point3),
|
||||
},
|
||||
"required": ["origin_mm", "x_dir", "normal"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def _analytic_segment_schema() -> dict[str, Any]:
|
||||
point2 = _point(2)
|
||||
return {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "line"},
|
||||
"start": deepcopy(point2),
|
||||
"end": deepcopy(point2),
|
||||
},
|
||||
"required": ["type", "start", "end"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "arc"},
|
||||
"start": deepcopy(point2),
|
||||
"end": deepcopy(point2),
|
||||
"center": deepcopy(point2),
|
||||
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
|
||||
"clockwise": {"type": "boolean"},
|
||||
},
|
||||
"required": ["type", "start", "end", "center", "radius_mm"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "circle"},
|
||||
"center": deepcopy(point2),
|
||||
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
|
||||
},
|
||||
"required": ["type", "center", "radius_mm"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def canonical_profile_schema() -> dict[str, Any]:
|
||||
point2 = _point(2)
|
||||
segment = _analytic_segment_schema()
|
||||
contour = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {"enum": ["outer", "inner"]},
|
||||
"closed": {"const": True},
|
||||
"segments": {"type": "array", "minItems": 1, "items": segment},
|
||||
},
|
||||
"required": ["role", "closed", "segments"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
return {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "circle"},
|
||||
"center": deepcopy(point2),
|
||||
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
|
||||
},
|
||||
"required": ["type", "radius_mm"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "polygon"},
|
||||
"vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)},
|
||||
},
|
||||
"required": ["type", "vertices"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "analytic_contours"},
|
||||
"contours": {"type": "array", "minItems": 1, "maxItems": 2, "items": contour},
|
||||
},
|
||||
"required": ["type", "contours"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _end_condition_schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"type": "string", "minLength": 1},
|
||||
"solidworks_code": {"type": "integer"},
|
||||
},
|
||||
"required": ["type", "solidworks_code"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def _axis_schema() -> dict[str, Any]:
|
||||
point3 = _point(3)
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {"origin_mm": deepcopy(point3), "direction": deepcopy(point3)},
|
||||
"required": ["origin_mm", "direction"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def _hole_positions_schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"mm": _point(3)},
|
||||
"required": ["mm"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _parameter_schema(atomic_id: str, contract: dict[str, Any]) -> dict[str, Any]:
|
||||
positive = {"type": "number", "exclusiveMinimum": 0}
|
||||
number = {"type": "number"}
|
||||
integer = {"type": "integer", "minimum": 1}
|
||||
point3 = _point(3)
|
||||
properties: dict[str, Any] = {}
|
||||
required = [name for name in contract["required_params"] if name not in {"host_face", "source_feature_ids", "mirror_plane"}]
|
||||
|
||||
if atomic_id.startswith("extrude_"):
|
||||
properties = {
|
||||
"distance_mm": deepcopy(number),
|
||||
"reverse_distance_mm": deepcopy(number),
|
||||
"reverse": {"type": "boolean"},
|
||||
"end_condition": _end_condition_schema(),
|
||||
"reverse_end_condition": _end_condition_schema(),
|
||||
}
|
||||
elif atomic_id.startswith("revolve_"):
|
||||
properties = {
|
||||
"angle_deg": {"type": "number", "exclusiveMinimum": 0, "maximum": 360},
|
||||
"axis": _axis_schema(),
|
||||
"reverse": {"type": "boolean"},
|
||||
}
|
||||
elif atomic_id.startswith("hole_"):
|
||||
properties = {
|
||||
"diameter_mm": deepcopy(positive),
|
||||
"depth_mm": deepcopy(positive),
|
||||
"positions": _hole_positions_schema(),
|
||||
"drill_angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793},
|
||||
"countersink_diameter_mm": deepcopy(positive),
|
||||
"countersink_angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793},
|
||||
"counterbore_diameter_mm": deepcopy(positive),
|
||||
"counterbore_depth_mm": deepcopy(positive),
|
||||
}
|
||||
elif atomic_id == "sphere_add":
|
||||
properties = {"radius_mm": deepcopy(positive), "center_mm": deepcopy(point3)}
|
||||
elif atomic_id == "fillet":
|
||||
properties = {"radius_mm": deepcopy(positive), "tangent_propagation": {"type": "boolean"}}
|
||||
elif atomic_id == "chamfer":
|
||||
properties = {
|
||||
"distance_mm": deepcopy(positive),
|
||||
"distance_2_mm": deepcopy(positive),
|
||||
"angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793},
|
||||
}
|
||||
elif atomic_id == "pattern_linear":
|
||||
properties = {
|
||||
"direction_1": deepcopy(point3),
|
||||
"spacing_1_mm": deepcopy(positive),
|
||||
"pattern_count_1": deepcopy(integer),
|
||||
"direction_2": deepcopy(point3),
|
||||
"spacing_2_mm": deepcopy(positive),
|
||||
"pattern_count_2": deepcopy(integer),
|
||||
}
|
||||
elif atomic_id == "pattern_mirror":
|
||||
properties = {}
|
||||
elif atomic_id == "reference_axis":
|
||||
properties = {"axis": _axis_schema()}
|
||||
elif atomic_id == "reference_plane":
|
||||
properties = {"plane": _workplane_schema()}
|
||||
elif atomic_id == "hole_wizard":
|
||||
required = ["hole_type", "diameter_mm", "depth_mm"]
|
||||
properties = {
|
||||
"hole_type": {"type": "string", "minLength": 1},
|
||||
"diameter_mm": deepcopy(positive),
|
||||
"depth_mm": deepcopy(positive),
|
||||
"positions": _hole_positions_schema(),
|
||||
"thread": {"type": "object"},
|
||||
"countersink": {"type": "object"},
|
||||
"counterbore": {"type": "object"},
|
||||
}
|
||||
else:
|
||||
for name in [*contract["required_params"], *contract["optional_params"]]:
|
||||
if name not in {"host_face", "source_feature_ids", "mirror_plane"}:
|
||||
properties[name] = {}
|
||||
|
||||
allowed = set(required) | {
|
||||
name for name in contract["optional_params"]
|
||||
if name not in {"host_face", "source_feature_ids", "mirror_plane"}
|
||||
}
|
||||
properties = {name: value for name, value in properties.items() if name in allowed}
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def _selector_limits(atomic_id: str, contract: dict[str, Any]) -> tuple[int, int] | None:
|
||||
if atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
|
||||
return 1, 1
|
||||
slot = contract.get("selector_slot")
|
||||
if not isinstance(slot, dict):
|
||||
return None
|
||||
return int(slot.get("min_items") or 1), int(slot.get("max_items") or 1)
|
||||
|
||||
|
||||
def build_operation_fragment_schema(
|
||||
engine: Any,
|
||||
atomic_id: str,
|
||||
*,
|
||||
correction: dict[str, Any] | None = None,
|
||||
allow_multi_feature: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
del correction
|
||||
if allow_multi_feature:
|
||||
raise ValueError("Canonical autonomous authoring currently permits exactly one feature")
|
||||
contract = feature_atomic_contract(engine, atomic_id)
|
||||
feature_properties: dict[str, Any] = {
|
||||
"atomic_id": {"const": atomic_id},
|
||||
"params": _parameter_schema(atomic_id, contract),
|
||||
}
|
||||
feature_required = ["atomic_id", "params"]
|
||||
selector_limits = _selector_limits(atomic_id, contract)
|
||||
if selector_limits is not None:
|
||||
minimum, maximum = selector_limits
|
||||
feature_properties["selector_tokens"] = {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1},
|
||||
"minItems": minimum,
|
||||
"maxItems": maximum,
|
||||
"uniqueItems": True,
|
||||
}
|
||||
feature_required.append("selector_tokens")
|
||||
feature_schema = {
|
||||
"type": "object",
|
||||
"properties": feature_properties,
|
||||
"required": feature_required,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
properties: dict[str, Any] = {"feature": feature_schema}
|
||||
required = ["feature"]
|
||||
if contract["requires_sketch"]:
|
||||
properties["sketch"] = {
|
||||
"type": "object",
|
||||
"properties": {"workplane": _workplane_schema(), "profile": canonical_profile_schema()},
|
||||
"required": ["workplane", "profile"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
required.insert(0, "sketch")
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def canonical_fragment_example(engine: Any, atomic_id: str) -> dict[str, Any]:
|
||||
contract = feature_atomic_contract(engine, atomic_id)
|
||||
feature: dict[str, Any] = {"atomic_id": atomic_id, "params": {}}
|
||||
params = feature["params"]
|
||||
if atomic_id.startswith("extrude_"):
|
||||
params["distance_mm"] = 10
|
||||
if "reverse_distance_mm" in contract["required_params"]:
|
||||
params["reverse_distance_mm"] = 5
|
||||
elif atomic_id.startswith("revolve_"):
|
||||
params.update({"angle_deg": 360, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}})
|
||||
elif atomic_id.startswith("hole_"):
|
||||
params.update({"diameter_mm": 10, "depth_mm": 20, "positions": [{"mm": [0, 0, 0]}]})
|
||||
if atomic_id == "hole_countersink":
|
||||
params.update({"countersink_diameter_mm": 16, "countersink_angle_rad": 1.5707963267948966})
|
||||
if atomic_id == "hole_counterbore":
|
||||
params.update({"counterbore_diameter_mm": 16, "counterbore_depth_mm": 4})
|
||||
feature["selector_tokens"] = ["face-token-from-current-topology"]
|
||||
elif atomic_id == "hole_wizard":
|
||||
params.update({"hole_type": "simple", "diameter_mm": 10, "depth_mm": 20})
|
||||
feature["selector_tokens"] = ["face-token-from-current-topology"]
|
||||
elif atomic_id == "sphere_add":
|
||||
params.update({"radius_mm": 10, "center_mm": [0, 0, 0]})
|
||||
elif atomic_id == "fillet":
|
||||
params["radius_mm"] = 0.5
|
||||
feature["selector_tokens"] = ["edge-token-from-current-topology"]
|
||||
elif atomic_id == "chamfer":
|
||||
params["distance_mm"] = 0.5
|
||||
feature["selector_tokens"] = ["edge-token-from-current-topology"]
|
||||
elif atomic_id == "pattern_linear":
|
||||
params.update({"direction_1": [1, 0, 0], "spacing_1_mm": 10, "pattern_count_1": 2})
|
||||
elif atomic_id == "pattern_mirror":
|
||||
feature["selector_tokens"] = ["plane-token-from-current-topology"]
|
||||
elif atomic_id == "reference_axis":
|
||||
params["axis"] = {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}
|
||||
elif atomic_id == "reference_plane":
|
||||
params["plane"] = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
|
||||
|
||||
fragment: dict[str, Any] = {"feature": feature}
|
||||
if contract["requires_sketch"]:
|
||||
fragment["sketch"] = {
|
||||
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
|
||||
"profile": {"type": "polygon", "vertices": [[-10, -5], [10, -5], [10, 5], [-10, 5]]},
|
||||
}
|
||||
fragment = {"sketch": fragment["sketch"], "feature": feature}
|
||||
return fragment
|
||||
|
||||
|
||||
def operation_contract_hash(atomic_id: str, schema: dict[str, Any]) -> str:
|
||||
encoded = json.dumps({"atomic_id": atomic_id, "schema": schema}, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def validate_canonical_fragment(engine: Any, fragment: dict[str, Any], *, expected_atomic_id: str = "") -> str:
|
||||
feature = fragment.get("feature") if isinstance(fragment, dict) else None
|
||||
atomic_id = str((feature or {}).get("atomic_id") or "") if isinstance(feature, dict) else ""
|
||||
if expected_atomic_id and atomic_id and atomic_id != expected_atomic_id:
|
||||
raise CanonicalFragmentError(
|
||||
f"OPERATION_CONTRACT_MISMATCH: expected {expected_atomic_id}, received {atomic_id}",
|
||||
path="fragment.feature.atomic_id",
|
||||
code="OPERATION_CONTRACT_MISMATCH",
|
||||
)
|
||||
if not atomic_id:
|
||||
raise CanonicalFragmentError(
|
||||
"CDSL_SCHEMA_INVALID: canonical fragment requires fragment.feature.atomic_id",
|
||||
path="fragment.feature.atomic_id",
|
||||
)
|
||||
schema = build_operation_fragment_schema(engine, atomic_id)
|
||||
errors = sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda error: (list(error.absolute_path), error.message))
|
||||
if errors:
|
||||
error = errors[0]
|
||||
path = "fragment" + "".join(
|
||||
f"[{item}]" if isinstance(item, int) else f".{item}"
|
||||
for item in error.absolute_path
|
||||
)
|
||||
raise CanonicalFragmentError(f"CDSL_SCHEMA_INVALID at {path}: {error.message}", path=path)
|
||||
profile = ((fragment.get("sketch") or {}).get("profile") or {}) if isinstance(fragment, dict) else {}
|
||||
if isinstance(profile, dict) and profile.get("type") == "analytic_contours":
|
||||
roles = [str(item.get("role") or "") for item in profile.get("contours") or () if isinstance(item, dict)]
|
||||
if roles.count("outer") != 1 or roles.count("inner") > 1:
|
||||
raise CanonicalFragmentError(
|
||||
"CDSL_PROFILE_MULTIPLE_OUTERS: analytic_contours requires exactly one outer contour and at most one inner contour",
|
||||
path="fragment.sketch.profile.contours",
|
||||
code="CDSL_PROFILE_MULTIPLE_OUTERS",
|
||||
)
|
||||
if roles.count("outer") == 0 and roles.count("inner"):
|
||||
raise CanonicalFragmentError(
|
||||
"CDSL_PROFILE_INNER_WITHOUT_OUTER: analytic_contours cannot contain an inner contour without an outer contour",
|
||||
path="fragment.sketch.profile.contours",
|
||||
code="CDSL_PROFILE_INNER_WITHOUT_OUTER",
|
||||
)
|
||||
return atomic_id
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -231,12 +232,15 @@ def _normalize_axis_mapping(axis: dict[str, Any], *, location: str, fixes: list[
|
||||
|
||||
|
||||
def _lift_feature_local_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None:
|
||||
"""Accept the common one-feature/one-sketch nesting without choosing geometry.
|
||||
"""Accept common feature-local sketch spellings without choosing geometry.
|
||||
|
||||
The public fragment grammar owns one ordered sketch list and one ordered
|
||||
feature list. Some tool-call models naturally nest each sketch below its
|
||||
feature. That representation is losslessly transformable only when every
|
||||
feature supplies exactly one sketch and no root sketch collection exists.
|
||||
feature list. Tool-call models commonly emit either a direct feature with
|
||||
a local ``sketch`` or a wrapper shaped as ``{sketch, feature}``. Both are
|
||||
losslessly transformable when the fragment has no root sketch collection.
|
||||
Sketchless features such as ``sphere_add`` and ``chamfer`` may be mixed in
|
||||
the same batch; the materializer pairs only sketch-requiring features with
|
||||
the lifted sketches.
|
||||
"""
|
||||
if any(key in fragment for key in ("sketch", "sketches", "add_sketches")):
|
||||
return
|
||||
@@ -244,21 +248,225 @@ def _lift_feature_local_sketches(fragment: dict[str, Any], *, fixes: list[dict[s
|
||||
if not isinstance(features, list) or not features or not all(isinstance(item, dict) for item in features):
|
||||
return
|
||||
lifted: list[dict[str, Any]] = []
|
||||
for index, feature in enumerate(features):
|
||||
nested = feature.get("sketches", feature.get("sketch"))
|
||||
normalized_features: list[dict[str, Any]] = []
|
||||
for index, item in enumerate(features):
|
||||
wrapped = item.get("feature")
|
||||
if wrapped is not None:
|
||||
if not isinstance(wrapped, dict):
|
||||
return
|
||||
feature = deepcopy(wrapped)
|
||||
if "selector_tokens" in item:
|
||||
if "selector_tokens" in feature:
|
||||
raise AutonomousFragmentError(
|
||||
f"features[{index}] supplies selector_tokens both on the wrapper and feature"
|
||||
)
|
||||
feature["selector_tokens"] = deepcopy(item["selector_tokens"])
|
||||
nested = item.get("sketches", item.get("sketch"))
|
||||
fixes.append({"path": f"features[{index}]", "from": "{sketch,feature}", "to": "feature", "action": "unwrapped_equivalent"})
|
||||
else:
|
||||
feature = deepcopy(item)
|
||||
nested = feature.get("sketches", feature.get("sketch"))
|
||||
if isinstance(nested, dict):
|
||||
sketches = [nested]
|
||||
elif isinstance(nested, list):
|
||||
sketches = nested
|
||||
else:
|
||||
return
|
||||
normalized_features.append(feature)
|
||||
continue
|
||||
if len(sketches) != 1 or not isinstance(sketches[0], dict):
|
||||
return
|
||||
feature.pop("sketch", None)
|
||||
feature.pop("sketches", None)
|
||||
lifted.append(sketches[0])
|
||||
normalized_features.append(feature)
|
||||
fixes.append({"path": f"features[{index}]", "from": "feature-local sketch", "to": "sketches[]", "action": "lifted_equivalent"})
|
||||
fragment["sketches"] = lifted
|
||||
fragment["features"] = normalized_features
|
||||
if lifted:
|
||||
fragment["sketches"] = lifted
|
||||
|
||||
|
||||
def _lift_param_embedded_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None:
|
||||
"""Lift an exact legacy ``params.workplane/profile`` sketch spelling.
|
||||
|
||||
Some authors place an extrusion's complete sketch inside its params object.
|
||||
The workplane and profile retain their meaning verbatim, so extracting them
|
||||
is safe. Partial shapes remain invalid instead of being guessed.
|
||||
"""
|
||||
if any(key in fragment for key in ("sketch", "sketches", "add_sketches")):
|
||||
return
|
||||
features = fragment.get("features", fragment.get("add_features"))
|
||||
if not isinstance(features, list) or not all(isinstance(item, dict) for item in features):
|
||||
return
|
||||
lifted: list[dict[str, Any]] = []
|
||||
for index, feature in enumerate(features):
|
||||
atomic_id = str(feature.get("atomic_id") or "")
|
||||
params = feature.get("params")
|
||||
if not atomic_id.startswith(("extrude_", "revolve_")) or not isinstance(params, dict):
|
||||
continue
|
||||
workplane = params.get("workplane")
|
||||
profile = params.get("profile")
|
||||
if workplane is None and profile is None:
|
||||
continue
|
||||
if not isinstance(workplane, dict) or not isinstance(profile, dict):
|
||||
return
|
||||
params.pop("workplane")
|
||||
params.pop("profile")
|
||||
lifted.append({"workplane": workplane, "profile": profile})
|
||||
fixes.append({"path": f"features[{index}].params", "from": "workplane/profile", "to": "sketches[]", "action": "lifted_equivalent"})
|
||||
if lifted:
|
||||
fragment["sketches"] = lifted
|
||||
|
||||
|
||||
def _set_reverse_from_direction(params: dict[str, Any], *, reverse: bool, location: str, fixes: list[dict[str, str]]) -> None:
|
||||
if "reverse" in params and params["reverse"] is not reverse:
|
||||
raise AutonomousFragmentError(
|
||||
f"CONFLICTING_PARAMETER_ALIASES at {location}: direction conflicts with reverse"
|
||||
)
|
||||
params["reverse"] = reverse
|
||||
params.pop("direction", None)
|
||||
fixes.append({"path": location, "from": "direction", "to": "reverse", "action": "normalized_equivalent"})
|
||||
|
||||
|
||||
def _normalize_extrude_direction(
|
||||
params: dict[str, Any],
|
||||
sketch: dict[str, Any] | None,
|
||||
*,
|
||||
location: str,
|
||||
fixes: list[dict[str, str]],
|
||||
) -> None:
|
||||
"""Accept an extrusion direction only when it exactly restates the sketch."""
|
||||
direction = params.get("direction")
|
||||
if direction is None:
|
||||
return
|
||||
if isinstance(direction, str):
|
||||
normalized = direction.strip().lower()
|
||||
if normalized in {"negative", "reverse", "-normal"}:
|
||||
_set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes)
|
||||
elif normalized in {"positive", "forward", "+normal"}:
|
||||
_set_reverse_from_direction(params, reverse=False, location=location, fixes=fixes)
|
||||
return
|
||||
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
|
||||
normal = workplane.get("normal") if isinstance(workplane, dict) else None
|
||||
if (
|
||||
not isinstance(direction, list)
|
||||
or not isinstance(normal, list)
|
||||
or len(direction) != 3
|
||||
or len(normal) != 3
|
||||
or not all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in [*direction, *normal])
|
||||
):
|
||||
return
|
||||
direction_norm = math.sqrt(sum(float(value) ** 2 for value in direction))
|
||||
normal_norm = math.sqrt(sum(float(value) ** 2 for value in normal))
|
||||
if direction_norm == 0 or normal_norm == 0:
|
||||
return
|
||||
cosine = sum(float(direction[index]) * float(normal[index]) for index in range(3)) / (direction_norm * normal_norm)
|
||||
if math.isclose(cosine, 1.0, abs_tol=1e-9):
|
||||
params.pop("direction")
|
||||
fixes.append({"path": location, "from": "direction", "to": "workplane.normal", "action": "deduplicated_equivalent"})
|
||||
elif math.isclose(cosine, -1.0, abs_tol=1e-9):
|
||||
_set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes)
|
||||
|
||||
|
||||
def _normalize_angle_radians(params: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
|
||||
"""Convert the explicitly unit-labelled angle_rad alias to angle_deg."""
|
||||
if "angle_rad" not in params:
|
||||
return
|
||||
radians = params["angle_rad"]
|
||||
if not isinstance(radians, (int, float)) or isinstance(radians, bool) or not math.isfinite(float(radians)):
|
||||
return
|
||||
degrees = float(radians) * 180.0 / math.pi
|
||||
if "angle_deg" in params:
|
||||
supplied = params["angle_deg"]
|
||||
if not isinstance(supplied, (int, float)) or isinstance(supplied, bool) or not math.isclose(float(supplied), degrees, rel_tol=0.0, abs_tol=1e-9):
|
||||
raise AutonomousFragmentError(
|
||||
f"CONFLICTING_PARAMETER_ALIASES at {location}: angle_rad conflicts with angle_deg"
|
||||
)
|
||||
params.pop("angle_rad")
|
||||
fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "deduplicated_equivalent"})
|
||||
return
|
||||
params["angle_deg"] = degrees
|
||||
params.pop("angle_rad")
|
||||
fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "converted_unit"})
|
||||
|
||||
|
||||
def _normalize_concentric_circle_contours(profile: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
|
||||
"""Expand a common two-circle annulus shorthand into analytic contours."""
|
||||
if profile.get("type") != "analytic_contours":
|
||||
return
|
||||
contours = profile.get("contours")
|
||||
if not isinstance(contours, list) or len(contours) != 2 or not all(isinstance(item, dict) for item in contours):
|
||||
return
|
||||
if not all(item.get("type") == "circle" and isinstance(item.get("center"), list) and len(item["center"]) == 2 for item in contours):
|
||||
return
|
||||
if contours[0]["center"] != contours[1]["center"]:
|
||||
return
|
||||
try:
|
||||
ordered = sorted(contours, key=lambda item: float(item["radius_mm"]), reverse=True)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return
|
||||
if float(ordered[0]["radius_mm"]) <= float(ordered[1]["radius_mm"]):
|
||||
return
|
||||
profile["contours"] = [
|
||||
{"role": role, "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]}
|
||||
for role, item in zip(("outer", "inner"), ordered)
|
||||
]
|
||||
fixes.append({"path": location, "from": "circle contour shorthand", "to": "analytic_contours.segments", "action": "expanded_equivalent"})
|
||||
|
||||
|
||||
def _finite_vector3(value: Any) -> tuple[float, float, float] | None:
|
||||
"""Return a finite numeric vector when the author supplied one."""
|
||||
if (
|
||||
not isinstance(value, list)
|
||||
or len(value) != 3
|
||||
or not all(isinstance(component, (int, float)) and not isinstance(component, bool) for component in value)
|
||||
):
|
||||
return None
|
||||
result = tuple(float(component) for component in value)
|
||||
return result if all(math.isfinite(component) for component in result) else None
|
||||
|
||||
|
||||
def _validate_revolve_axis_in_sketch_plane(
|
||||
atomic_id: str,
|
||||
params: dict[str, Any],
|
||||
sketch: dict[str, Any],
|
||||
) -> None:
|
||||
"""Reject a revolve axis that cannot be a construction line of its sketch.
|
||||
|
||||
A solid revolve is defined around an axis in the source sketch plane.
|
||||
Letting an out-of-plane axis reach OCC can produce degenerate BReps that
|
||||
fail much later during tessellation, so enforce this geometric invariant
|
||||
before candidate staging. Malformed vectors are left to CDSL schema
|
||||
validation, which can report their field-level shape.
|
||||
"""
|
||||
axis = params.get("axis")
|
||||
workplane = sketch.get("workplane") if isinstance(sketch.get("workplane"), dict) else None
|
||||
if not isinstance(axis, dict) or not isinstance(workplane, dict):
|
||||
return
|
||||
axis_origin = _finite_vector3(axis.get("origin_mm"))
|
||||
axis_direction = _finite_vector3(axis.get("direction"))
|
||||
plane_origin = _finite_vector3(workplane.get("origin_mm"))
|
||||
plane_normal = _finite_vector3(workplane.get("normal"))
|
||||
if None in {axis_origin, axis_direction, plane_origin, plane_normal}:
|
||||
return
|
||||
assert axis_origin is not None and axis_direction is not None and plane_origin is not None and plane_normal is not None
|
||||
direction_length = math.sqrt(sum(component * component for component in axis_direction))
|
||||
normal_length = math.sqrt(sum(component * component for component in plane_normal))
|
||||
if direction_length == 0 or normal_length == 0:
|
||||
return
|
||||
direction_normal_dot = abs(sum(axis_direction[index] * plane_normal[index] for index in range(3)) / (direction_length * normal_length))
|
||||
if direction_normal_dot > 1e-7:
|
||||
raise AutonomousFragmentError(
|
||||
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: "
|
||||
f"{atomic_id} params.axis.direction must be parallel to sketch.workplane; "
|
||||
f"abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}"
|
||||
)
|
||||
origin_plane_offset = abs(sum((axis_origin[index] - plane_origin[index]) * plane_normal[index] for index in range(3)) / normal_length)
|
||||
if origin_plane_offset > 1e-6:
|
||||
raise AutonomousFragmentError(
|
||||
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: "
|
||||
f"{atomic_id} params.axis.origin_mm must lie in sketch.workplane; "
|
||||
f"plane_offset_mm={origin_plane_offset:.3g}"
|
||||
)
|
||||
|
||||
|
||||
def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]]]:
|
||||
@@ -275,6 +483,7 @@ def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, A
|
||||
normalized = deepcopy(fragment)
|
||||
fixes: list[dict[str, str]] = []
|
||||
_lift_feature_local_sketches(normalized, fixes=fixes)
|
||||
_lift_param_embedded_sketches(normalized, fixes=fixes)
|
||||
feature_values: list[tuple[dict[str, Any], str]] = []
|
||||
feature = normalized.get("feature")
|
||||
if isinstance(feature, dict):
|
||||
@@ -310,6 +519,7 @@ def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, A
|
||||
location=f"{location}.params",
|
||||
fixes=fixes,
|
||||
)
|
||||
_normalize_angle_radians(params, location=f"{location}.params", fixes=fixes)
|
||||
axis = params.get("axis")
|
||||
if axis is None:
|
||||
axis = {}
|
||||
@@ -349,10 +559,41 @@ def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, A
|
||||
sketches = normalized.get("sketches", normalized.get("add_sketches"))
|
||||
if isinstance(sketches, list):
|
||||
sketch_values.extend((item, f"sketches[{index}]") for index, item in enumerate(sketches) if isinstance(item, dict))
|
||||
sketch_feature_index = 0
|
||||
for current_feature, feature_location in feature_values:
|
||||
atomic_id = str(current_feature.get("atomic_id") or "")
|
||||
if not atomic_id.startswith(("extrude_", "revolve_")):
|
||||
continue
|
||||
current_sketch = sketch_values[sketch_feature_index][0] if sketch_feature_index < len(sketch_values) else None
|
||||
sketch_feature_index += 1
|
||||
if atomic_id.startswith("extrude_"):
|
||||
params = current_feature.get("params")
|
||||
if isinstance(params, dict):
|
||||
_normalize_extrude_direction(
|
||||
params,
|
||||
sketch=current_sketch,
|
||||
location=f"{feature_location}.params",
|
||||
fixes=fixes,
|
||||
)
|
||||
for current_sketch, location in sketch_values:
|
||||
profile = current_sketch.get("profile")
|
||||
if isinstance(profile, dict) and profile.get("type") == "polygon":
|
||||
_move_equivalent_field(profile, source="points", target="vertices", location=f"{location}.profile", fixes=fixes)
|
||||
if isinstance(profile, dict):
|
||||
_normalize_concentric_circle_contours(profile, location=f"{location}.profile", fixes=fixes)
|
||||
# Earlier versions advertised sphere_add as sketch-backed even though its
|
||||
# executor has always used only radius_mm and center_mm. Preserve that
|
||||
# single-feature spelling without keeping an unused locator sketch in the
|
||||
# immutable CDSL document.
|
||||
if (
|
||||
len(feature_values) == 1
|
||||
and str(feature_values[0][0].get("atomic_id") or "") == "sphere_add"
|
||||
and len(sketch_values) == 1
|
||||
):
|
||||
normalized.pop("sketch", None)
|
||||
normalized.pop("sketches", None)
|
||||
normalized.pop("add_sketches", None)
|
||||
fixes.append({"path": "sketch", "from": "sphere locator sketch", "to": "none", "action": "dropped_unused_legacy_locator"})
|
||||
return normalized, fixes
|
||||
|
||||
|
||||
@@ -363,6 +604,9 @@ def materialize_autonomous_fragment(
|
||||
engine: Any,
|
||||
selector_tokens: dict[str, dict[str, Any]],
|
||||
max_features: int,
|
||||
source: str = "legacy_restore",
|
||||
allow_legacy_aliases: bool = True,
|
||||
expected_atomic_id: str = "",
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Append authored geometry while assigning only server-owned metadata.
|
||||
|
||||
@@ -374,7 +618,25 @@ def materialize_autonomous_fragment(
|
||||
# becoming an import cycle.
|
||||
from app.services.engine_service import feature_atomic_contract
|
||||
|
||||
from app.services.cdsl_authoring_schema import CanonicalFragmentError, validate_canonical_fragment
|
||||
|
||||
normalized_fragment, compatibility_fixes = normalize_autonomous_fragment(fragment)
|
||||
if not allow_legacy_aliases:
|
||||
if compatibility_fixes:
|
||||
first = compatibility_fixes[0]
|
||||
location = str(first.get("path") or "fragment")
|
||||
legacy = str(first.get("from") or "legacy field")
|
||||
canonical = str(first.get("to") or "canonical field")
|
||||
raise AutonomousFragmentError(
|
||||
f"CDSL_CANONICAL_FORMAT_REQUIRED at fragment.{location}: "
|
||||
f"{legacy} is a legacy spelling; use {canonical}"
|
||||
)
|
||||
try:
|
||||
validate_canonical_fragment(engine, fragment, expected_atomic_id=expected_atomic_id)
|
||||
except CanonicalFragmentError as error:
|
||||
raise AutonomousFragmentError(str(error)) from error
|
||||
normalized_fragment = deepcopy(fragment)
|
||||
compatibility_fixes = []
|
||||
sketches, features = _fragment_lists(normalized_fragment)
|
||||
if len(features) > max_features:
|
||||
raise AutonomousFragmentError(f"A fragment may add at most {max_features} feature(s)")
|
||||
@@ -388,7 +650,12 @@ def materialize_autonomous_fragment(
|
||||
materialized_sketches: list[dict[str, Any]] = []
|
||||
materialized_features: list[dict[str, Any]] = []
|
||||
|
||||
for index, source_feature in enumerate(features):
|
||||
sketch_index = 0
|
||||
for source_feature in features:
|
||||
# Materialization injects server-owned ids, host faces and pattern
|
||||
# sources. Work on a private copy so the original tool-call fragment
|
||||
# remains intact in audit records and diagnostic replacement cards.
|
||||
source_feature = deepcopy(source_feature)
|
||||
forbidden = {"id", "depends_on", "sketch_id", "selectors"} & set(source_feature)
|
||||
if forbidden:
|
||||
raise AutonomousFragmentError("Feature identity, dependencies, sketch_id and raw selectors are server-owned: " + ", ".join(sorted(forbidden)))
|
||||
@@ -399,31 +666,68 @@ def materialize_autonomous_fragment(
|
||||
params = source_feature.get("params")
|
||||
if not isinstance(params, dict):
|
||||
raise AutonomousFragmentError("Each fragment feature must contain a params object")
|
||||
if (atomic_id.startswith("hole_") or atomic_id == "hole_wizard") and "host_face" in params:
|
||||
if atomic_id.startswith("revolve_") and params.get("angle_deg") is None:
|
||||
# A shared batch axis is deliberately limited to the axis. A
|
||||
# default revolution angle would silently turn valid partial
|
||||
# revolves into a different solid, so it remains author-owned.
|
||||
raise AutonomousFragmentError(
|
||||
f"{atomic_id} host_face is server-owned: put exactly one face token in selector_tokens and use "
|
||||
"positions as [{\"mm\":[x_mm,y_mm,z_mm]}], not raw host_face or bare coordinate arrays"
|
||||
f"{atomic_id} requires params.angle_deg; revolve_axis (including shared_revolve_axis) "
|
||||
"supplies only params.axis. Declare an explicit angle in degrees."
|
||||
)
|
||||
token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard"
|
||||
tokens = source_feature.pop("selector_tokens", [])
|
||||
if not isinstance(tokens, list) or not all(isinstance(token, str) for token in tokens) or len(set(tokens)) != len(tokens):
|
||||
token_list_is_valid = (
|
||||
isinstance(tokens, list)
|
||||
and all(isinstance(token, str) for token in tokens)
|
||||
and len(set(tokens)) == len(tokens)
|
||||
)
|
||||
if not token_list_is_valid and not token_backed_param:
|
||||
raise AutonomousFragmentError("selector_tokens must be a unique array of opaque tokens")
|
||||
if not token_list_is_valid:
|
||||
tokens = []
|
||||
selected: list[dict[str, Any]] = []
|
||||
invalid_tokens: list[str] = []
|
||||
for token in tokens:
|
||||
candidate = selector_tokens.get(token)
|
||||
if candidate is None:
|
||||
if token_backed_param:
|
||||
invalid_tokens.append(token)
|
||||
continue
|
||||
raise AutonomousFragmentError("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
|
||||
selected.append(deepcopy(candidate["selector"]))
|
||||
slot = contract.get("selector_slot")
|
||||
token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard"
|
||||
if token_backed_param:
|
||||
# Report all author-correctable hole errors at once. A hole is
|
||||
# topology-sensitive, so its host face remains server-owned and
|
||||
# must be injected from one current face token.
|
||||
issues: list[str] = []
|
||||
author_params = (set(contract["required_params"]) | set(contract["optional_params"])) - {"host_face"}
|
||||
if "host_face" in params:
|
||||
issues.append("params.host_face is server-owned; use selector_tokens")
|
||||
missing = [name for name in contract["required_params"] if name != "host_face" and name not in params]
|
||||
if missing:
|
||||
issues.append("missing params: " + ", ".join(missing))
|
||||
unexpected = sorted(name for name in params if name not in author_params and name != "host_face")
|
||||
if unexpected:
|
||||
issues.append("unsupported params: " + ", ".join(unexpected))
|
||||
if not token_list_is_valid:
|
||||
issues.append("selector_tokens must be a unique array of opaque tokens")
|
||||
if invalid_tokens:
|
||||
issues.append("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
|
||||
if len(tokens) != 1 or len(selected) != 1 or str((selected[0] if selected else {}).get("kind") or "") != "face":
|
||||
issues.append(f"{atomic_id} requires exactly one face selector token for its host face")
|
||||
if issues:
|
||||
raise AutonomousFragmentError("HOLE_FRAGMENT_INVALID: " + "; ".join(issues))
|
||||
if not slot and tokens and not token_backed_param:
|
||||
raise AutonomousFragmentError(f"{atomic_id} does not accept selector tokens")
|
||||
if isinstance(slot, dict):
|
||||
minimum, maximum = int(slot.get("min_items") or 0), int(slot.get("max_items") or 0)
|
||||
if not minimum <= len(selected) <= maximum:
|
||||
raise AutonomousFragmentError(f"{atomic_id} requires {minimum}..{maximum} selector token(s)")
|
||||
elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
|
||||
if len(selected) != 1 or str(selected[0].get("kind") or "") != "face":
|
||||
raise AutonomousFragmentError(f"{atomic_id} requires exactly one face selector token for its host face")
|
||||
elif token_backed_param:
|
||||
# Hole token validation above deliberately aggregates every
|
||||
# actionable error before this materialization boundary.
|
||||
pass
|
||||
elif atomic_id.startswith("revolve_"):
|
||||
axis = params.get("axis")
|
||||
if not isinstance(axis, dict) or "origin_mm" not in axis or "direction" not in axis:
|
||||
@@ -437,17 +741,18 @@ def materialize_autonomous_fragment(
|
||||
output["id"] = feature_id
|
||||
output["depends_on"] = [last_feature_id] if last_feature_id else []
|
||||
if contract["requires_sketch"]:
|
||||
if index >= len(sketches):
|
||||
if sketch_index >= len(sketches):
|
||||
raise AutonomousFragmentError(f"{atomic_id} requires one new sketch in the same fragment")
|
||||
sketch = sketches[index]
|
||||
sketch = sketches[sketch_index]
|
||||
sketch_index += 1
|
||||
if "id" in sketch or "attachment" in sketch or "profile_from" in sketch:
|
||||
raise AutonomousFragmentError("Sketch identity and topology attachment are server-owned")
|
||||
sketch_id = _autonomous_id("sketch", used_sketch_ids)
|
||||
sketch["id"] = sketch_id
|
||||
if atomic_id.startswith("revolve_"):
|
||||
_validate_revolve_axis_in_sketch_plane(atomic_id, params, sketch)
|
||||
materialized_sketches.append(sketch)
|
||||
output["sketch_id"] = sketch_id
|
||||
elif index < len(sketches):
|
||||
raise AutonomousFragmentError(f"{atomic_id} does not accept a sketch")
|
||||
if atomic_id.startswith("pattern_") and "source_feature_ids" not in params:
|
||||
if not last_feature_id:
|
||||
raise AutonomousFragmentError(f"{atomic_id} needs a committed source feature")
|
||||
@@ -467,7 +772,7 @@ def materialize_autonomous_fragment(
|
||||
output["selectors"] = []
|
||||
materialized_features.append(output)
|
||||
last_feature_id = feature_id
|
||||
if len(sketches) != len(materialized_sketches):
|
||||
if sketch_index != len(sketches):
|
||||
raise AutonomousFragmentError("Each sketch must be consumed by a feature that requires a sketch")
|
||||
document = materialize_fragment(document, {"add_sketches": materialized_sketches, "add_features": materialized_features})
|
||||
return document, {
|
||||
@@ -475,6 +780,8 @@ def materialize_autonomous_fragment(
|
||||
"source_fragment": deepcopy(fragment),
|
||||
"normalized_fragment": deepcopy(normalized_fragment) if compatibility_fixes else None,
|
||||
"compatibility_fixes": compatibility_fixes,
|
||||
"compatibility_fix_count": len(compatibility_fixes),
|
||||
"legacy_input": source != "tool_call" or bool(compatibility_fixes),
|
||||
"assigned_sketch_ids": [item["id"] for item in materialized_sketches],
|
||||
"assigned_feature_ids": [item["id"] for item in materialized_features],
|
||||
"selector_candidate_ids": [token for feature in features for token in feature.get("selector_tokens", [])],
|
||||
|
||||
@@ -148,8 +148,8 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
raise ValueError(f"Training-unsafe CDSL field: {key}")
|
||||
features = cdsl.get("features")
|
||||
sketches = cdsl.get("geometry", {}).get("sketches")
|
||||
if not isinstance(features, list) or not features or not isinstance(sketches, list) or not sketches:
|
||||
raise ValueError("CDSL requires features and parameterized sketches")
|
||||
if not isinstance(features, list) or not features or not isinstance(sketches, list):
|
||||
raise ValueError("CDSL requires a feature list and a geometry.sketches array")
|
||||
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
|
||||
semantic_contract = _engine_schema(engine)
|
||||
atomic_contracts = semantic_contract["feature_atomic_ids"]
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -192,6 +193,10 @@ class WorkspaceStore:
|
||||
"lifecycle": "completed",
|
||||
"run_id": "",
|
||||
"requirements_path": "",
|
||||
"completion_checklist_path": "",
|
||||
"modeling_plan_path": "",
|
||||
"modeling_plan_review_path": "",
|
||||
"modeling_plan_version": 0,
|
||||
"agent_state_path": "",
|
||||
"active_candidate_id": "",
|
||||
"active_branch_id": "main",
|
||||
@@ -222,6 +227,9 @@ class WorkspaceStore:
|
||||
"requirements_path": "",
|
||||
"source_requirements_path": "",
|
||||
"completion_checklist_path": "",
|
||||
"modeling_plan_path": "",
|
||||
"modeling_plan_review_path": "",
|
||||
"modeling_plan_version": 0,
|
||||
"agent_state_path": "",
|
||||
"active_candidate_id": "",
|
||||
"active_branch_id": "main",
|
||||
@@ -271,6 +279,15 @@ class WorkspaceStore:
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def update_task_fields(self, task_id: str, fields: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update task metadata without appending a synthetic revision."""
|
||||
task = self.ensure_task(task_id, "")
|
||||
for key, value in fields.items():
|
||||
task[str(key)] = deepcopy(value)
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def read_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = read_json(self.task_path(task_id))
|
||||
return self._migrate_task(task, self.task_path(task_id)) if isinstance(task, dict) else None
|
||||
@@ -293,8 +310,8 @@ class WorkspaceStore:
|
||||
return task
|
||||
|
||||
def finish_generation(self, task_id: str, *, lifecycle: str, failure: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
if lifecycle not in {"completed", "failed"}:
|
||||
raise ValueError("Generation lifecycle must be completed or failed")
|
||||
if lifecycle not in {"completed", "failed", "cancelled", "waiting_review", "failed_review_service"}:
|
||||
raise ValueError("Generation lifecycle must be completed, failed, cancelled, waiting_review, or failed_review_service")
|
||||
task = self.ensure_task(task_id, "")
|
||||
failure_path = ""
|
||||
if failure:
|
||||
@@ -421,6 +438,53 @@ class WorkspaceStore:
|
||||
path = self.artifact_path(task_id, relative)
|
||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
|
||||
def write_modeling_plan(self, task_id: str, markdown: str, *, version: int = 1) -> Path:
|
||||
"""Persist one immutable, versioned modeling-plan document."""
|
||||
text = str(markdown or "").strip()
|
||||
if not text:
|
||||
raise ValueError("modeling plan must not be empty")
|
||||
task = self.ensure_task(task_id, "")
|
||||
version = max(1, int(version))
|
||||
relative = Path("plans") / f"modeling-plan-v{version}.md"
|
||||
path = self.task_dir(task_id) / relative
|
||||
if path.exists():
|
||||
raise ValueError("modeling plan version already exists")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text + "\n", encoding="utf-8")
|
||||
task["modeling_plan_path"] = relative.as_posix()
|
||||
task["modeling_plan_version"] = version
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def read_modeling_plan(self, task_id: str) -> str:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("modeling_plan_path") or "")
|
||||
if not relative:
|
||||
return ""
|
||||
path = self.artifact_path(task_id, relative)
|
||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
|
||||
def write_modeling_plan_review(self, task_id: str, review: dict[str, Any], *, version: int) -> Path:
|
||||
task = self.ensure_task(task_id, "")
|
||||
version = max(1, int(version))
|
||||
relative = Path("plans") / f"modeling-plan-v{version}.review.json"
|
||||
path = self.task_dir(task_id) / relative
|
||||
if path.exists():
|
||||
raise ValueError("modeling plan review already exists")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_json(path, review)
|
||||
task["modeling_plan_review_path"] = relative.as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def read_modeling_plan_review(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("modeling_plan_review_path") or "")
|
||||
value = read_json(self.artifact_path(task_id, relative), {}) if relative else None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
def new_candidate(self, task_id: str) -> tuple[str, Path]:
|
||||
task = self.ensure_task(task_id, "")
|
||||
candidate_id = f"candidate_{secrets.token_hex(8)}"
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -67,6 +68,28 @@ CANDIDATE_REVIEW_TOOL = {
|
||||
}
|
||||
|
||||
|
||||
MODELING_PLAN_REVIEW_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "review_modeling_plan",
|
||||
"description": "Independently review a CAD modeling plan for requirement coverage, coherent step grouping, dependencies, and observable evidence. Never generate or modify CDSL.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"verdict": {"enum": ["pass", "revise"]},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"issues": {"type": "array", "items": {"type": "object", "properties": {"type": {"type": "string"}, "step_id": {"type": "string"}, "message": {"type": "string"}}, "required": ["type", "message"], "additionalProperties": False}, "maxItems": 20},
|
||||
"coverage": {"type": "array", "items": {"type": "object", "properties": {"requirement": {"type": "string"}, "step_id": {"type": "string"}, "status": {"enum": ["covered", "missing"]}, "evidence": {"type": "string"}}, "required": ["requirement", "step_id", "status", "evidence"], "additionalProperties": False}},
|
||||
"step_checks": {"type": "array", "items": {"type": "object", "properties": {"step_id": {"type": "string"}, "status": {"enum": ["pass", "fail"]}, "notes": {"type": "string"}}, "required": ["step_id", "status", "notes"], "additionalProperties": False}},
|
||||
"action_checks": {"type": "array", "items": {"type": "object", "properties": {"step_id": {"type": "string"}, "status": {"enum": ["pass", "fail"]}, "notes": {"type": "string"}, "required_action_count": {"type": "integer", "minimum": 1}}, "required": ["step_id", "status", "notes", "required_action_count"], "additionalProperties": False}},
|
||||
},
|
||||
"required": ["verdict", "confidence", "issues", "coverage", "step_checks", "action_checks"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class VisualReviewError(RuntimeError):
|
||||
pass
|
||||
|
||||
@@ -75,6 +98,51 @@ class _CandidateReviewFormatError(VisualReviewError):
|
||||
"""A locally detected invalid reviewer tool result, eligible for one retry."""
|
||||
|
||||
|
||||
class _ModelingPlanReviewFormatError(VisualReviewError):
|
||||
"""A locally detected invalid modeling-plan verdict, eligible for one retry."""
|
||||
|
||||
|
||||
def _checklist_key(value: Any) -> str:
|
||||
return " ".join(str(value or "").strip().split()).casefold()
|
||||
|
||||
|
||||
def _looks_like_operation_id(value: str) -> bool:
|
||||
token = str(value or "").strip().casefold()
|
||||
if not token or "_" not in token:
|
||||
return token in {"fillet", "chamfer"}
|
||||
prefixes = ("extrude_", "revolve_", "hole_", "pattern_", "sphere_", "reference_", "cylinder_", "sweep_")
|
||||
suffixes = ("_add", "_cut", "_blind", "_wizard", "_linear", "_circular", "_mirror")
|
||||
return token.startswith(prefixes) or token.endswith(suffixes)
|
||||
|
||||
|
||||
def _unsupported_operation_mentions(result: dict[str, Any], runtime_operations: list[dict[str, Any]]) -> list[str]:
|
||||
"""Find capability-shaped names in reviewer prose that Runtime cannot execute."""
|
||||
supported = {
|
||||
str(item.get("atomic_id") or "").strip().casefold()
|
||||
for item in runtime_operations
|
||||
if isinstance(item, dict) and str(item.get("atomic_id") or "").strip()
|
||||
}
|
||||
mentions: set[str] = set()
|
||||
for section in (result.get("issues"), result.get("coverage"), result.get("step_checks"), result.get("action_checks")):
|
||||
if not isinstance(section, list):
|
||||
continue
|
||||
for row in section:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
for value in row.values():
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
for token in re.findall(r"(?<![A-Za-z0-9_])([A-Za-z][A-Za-z0-9_]*)(?![A-Za-z0-9_])", value):
|
||||
normalized = token.casefold()
|
||||
if normalized in supported or not _looks_like_operation_id(normalized):
|
||||
continue
|
||||
# Runtime capabilities are exact identifiers. A prefix
|
||||
# or human shorthand (for example extrude_cut for
|
||||
# extrude_cut_blind) is still invalid and must be revised.
|
||||
mentions.add(token)
|
||||
return sorted(mentions, key=str.casefold)
|
||||
|
||||
|
||||
def _thinking_tool_choice_rejected(response: httpx.Response) -> bool:
|
||||
"""Recognize the only compatibility error for which retrying is sound.
|
||||
|
||||
@@ -99,6 +167,10 @@ def candidate_review_tool() -> dict[str, Any]:
|
||||
return json.loads(json.dumps(CANDIDATE_REVIEW_TOOL))
|
||||
|
||||
|
||||
def modeling_plan_review_tool() -> dict[str, Any]:
|
||||
return json.loads(json.dumps(MODELING_PLAN_REVIEW_TOOL))
|
||||
|
||||
|
||||
def _image_part(path: Path) -> dict[str, Any]:
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
media = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
|
||||
@@ -222,6 +294,9 @@ async def review_candidate_batch(
|
||||
batch_goal: str,
|
||||
deterministic_report: dict[str, Any],
|
||||
node_id: str,
|
||||
plan_step: dict[str, Any] | None = None,
|
||||
plan_feature_ids: list[str] | None = None,
|
||||
plan_action: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Review a staged batch against its local goal and the frozen checklist."""
|
||||
provider, model = settings.resolve_review_model()
|
||||
@@ -237,6 +312,9 @@ async def review_candidate_batch(
|
||||
"frozen_requirements": requirements,
|
||||
"completion_checklist": checklist,
|
||||
"batch_goal": batch_goal,
|
||||
"plan_step": plan_step or None,
|
||||
"plan_feature_ids": plan_feature_ids or [],
|
||||
"plan_action": plan_action or None,
|
||||
"deterministic_report": deterministic_report,
|
||||
"instruction": (
|
||||
"Assess this staged batch, not overall task completion. Accept only if the batch goal is achieved "
|
||||
@@ -247,7 +325,9 @@ async def review_candidate_batch(
|
||||
"intermediate model may be accepted when its batch goal is achieved. Return one coverage row for every "
|
||||
"completion checklist item, preserving exact item text. Use pending for future work and regressed when "
|
||||
"this batch breaks previously achieved work. Reject on disconnected geometry when the requirements call "
|
||||
"for one body, wrong orientation, visibly wrong geometry, or a failed batch goal."
|
||||
"for one body, wrong orientation, visibly wrong geometry, or a failed batch goal. "
|
||||
"When plan_step is supplied, reject operations outside that step and confirm its planned feature goal. "
|
||||
"A step may contain multiple related actions; when plan_action is supplied, assess only that action, confirm the candidate changes its target, and reject unrelated or multiple independent profiles."
|
||||
),
|
||||
"render_manifest": {
|
||||
"renderer": manifest.get("renderer"),
|
||||
@@ -341,3 +421,171 @@ async def review_candidate_batch(
|
||||
"batch_goal": batch_goal,
|
||||
**result,
|
||||
}
|
||||
|
||||
|
||||
async def review_modeling_plan(
|
||||
settings: Settings,
|
||||
*,
|
||||
source_requirements: str,
|
||||
requirements: str,
|
||||
checklist: list[str],
|
||||
plan: dict[str, Any],
|
||||
runtime_operations: list[dict[str, Any]],
|
||||
node_id: str = "modeling-plan",
|
||||
) -> dict[str, Any]:
|
||||
"""Ask an independent model to simulate and review a modeling plan."""
|
||||
provider, model = settings.resolve_review_model()
|
||||
payload_context = {
|
||||
"node_id": node_id,
|
||||
"source_requirements": source_requirements,
|
||||
"frozen_requirements": requirements,
|
||||
"completion_checklist": checklist,
|
||||
"modeling_plan": plan,
|
||||
"runtime_operations": runtime_operations,
|
||||
"instruction": (
|
||||
"Review the plan only; do not generate CDSL. Treat modeling_plan.plan_text as semantic "
|
||||
"guidance, not a schema: missing structures/features arrays or machine IDs are not by "
|
||||
"themselves failures. Infer intended structures, ordering, relationships, and evidence "
|
||||
"from the prose. Check every checklist item, dependency order, step cohesion, topology "
|
||||
"preconditions, Runtime feasibility, action granularity, and observable evidence. A semantic "
|
||||
"step may contain related or repeated targets, but each independently located profile "
|
||||
"or Runtime feature must be enumerated as a separate action within that same step. Each action "
|
||||
"must be executable as one Runtime feature. A multi-position hole or supported pattern may remain "
|
||||
"one action when all positions share the same host and operation contract. Treat any supplied "
|
||||
"action records as execution units, and set required_action_count to the minimum number the step's "
|
||||
"semantics require. Return fail when the plan contains fewer actions than that number. Return pass when the plan "
|
||||
"is actionable enough for an LLM to generate CDSL in coherent batches. Do not require "
|
||||
"colors or materials when runtime_operations does not provide them; accept a geometrically "
|
||||
"equivalent ring, groove, or separated feature and mention the limitation only as guidance. "
|
||||
"Only return revise for a real requirement omission, contradictory geometry, unsafe ordering, "
|
||||
"an operation name that is not present in runtime_operations, or an operation that cannot plausibly be implemented. "
|
||||
"Do not downgrade an unsupported operation to vague guidance. If a machine operation is mentioned, it must be an exact ID from runtime_operations; otherwise return revise and describe the semantic intent without inventing an operation name. "
|
||||
"Return exactly one coverage row per checklist item."
|
||||
),
|
||||
}
|
||||
payload = {
|
||||
"model": model.id,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are an independent CAD modeling-plan reviewer. You may only call review_modeling_plan."},
|
||||
{"role": "user", "content": json.dumps(payload_context, ensure_ascii=False)},
|
||||
],
|
||||
"tools": [modeling_plan_review_tool()],
|
||||
"tool_choice": {"type": "function", "function": {"name": "review_modeling_plan"}},
|
||||
"temperature": 0,
|
||||
}
|
||||
payload.update(provider.chat_completion_options)
|
||||
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
|
||||
|
||||
async def request_review(client: httpx.AsyncClient, request_payload: dict[str, Any]) -> httpx.Response:
|
||||
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload)
|
||||
if _thinking_tool_choice_rejected(response):
|
||||
request_payload.pop("tool_choice", None)
|
||||
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload)
|
||||
if response.status_code >= 400:
|
||||
raise VisualReviewError(f"Modeling plan review request failed ({response.status_code}): {response.text[:500]}")
|
||||
return response
|
||||
|
||||
def validate_response(response: httpx.Response) -> dict[str, Any]:
|
||||
try:
|
||||
call = response.json()["choices"][0]["message"]["tool_calls"][0]
|
||||
if call["function"]["name"] != "review_modeling_plan":
|
||||
raise KeyError("wrong tool")
|
||||
result = json.loads(call["function"]["arguments"])
|
||||
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer did not return a valid review tool call") from error
|
||||
allowed = {"verdict", "confidence", "issues", "coverage", "step_checks", "action_checks"}
|
||||
if not isinstance(result, dict) or set(result) != allowed or result.get("verdict") not in {"pass", "revise"}:
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid verdict")
|
||||
try:
|
||||
confidence = float(result.get("confidence"))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid confidence") from error
|
||||
if not 0 <= confidence <= 1:
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer confidence is outside [0, 1]")
|
||||
if not isinstance(result.get("issues"), list) or not all(isinstance(item, dict) for item in result["issues"]):
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned invalid issues")
|
||||
expected = {_checklist_key(item): item for item in checklist}
|
||||
coverage = result.get("coverage")
|
||||
if not isinstance(coverage, list) or len(coverage) != len(checklist):
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return coverage for every checklist item")
|
||||
seen: set[str] = set()
|
||||
for item in coverage:
|
||||
if not isinstance(item, dict) or set(item) != {"requirement", "step_id", "status", "evidence"}:
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid coverage row")
|
||||
key = _checklist_key(item.get("requirement"))
|
||||
if key not in expected or key in seen or item.get("status") not in {"covered", "missing"} or not str(item.get("evidence") or "").strip():
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer coverage does not match the frozen checklist")
|
||||
item["requirement"] = expected[key]
|
||||
seen.add(key)
|
||||
step_records = {
|
||||
str(item.get("step_id") or ""): item
|
||||
for item in plan.get("steps") or () if isinstance(item, dict)
|
||||
}
|
||||
steps = set(step_records)
|
||||
checks = result.get("step_checks")
|
||||
if not isinstance(checks, list) or len(checks) != len(steps):
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return one step check per plan step")
|
||||
checked: set[str] = set()
|
||||
for item in checks:
|
||||
if not isinstance(item, dict) or set(item) != {"step_id", "status", "notes"}:
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid step check")
|
||||
step_id = str(item.get("step_id") or "")
|
||||
if step_id not in steps or step_id in checked or item.get("status") not in {"pass", "fail"}:
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer step checks do not match the plan")
|
||||
checked.add(step_id)
|
||||
action_checks = result.get("action_checks")
|
||||
if not isinstance(action_checks, list) or len(action_checks) != len(steps):
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return one action check per plan step")
|
||||
action_checked: set[str] = set()
|
||||
for item in action_checks:
|
||||
if not isinstance(item, dict) or set(item) != {"step_id", "status", "notes", "required_action_count"}:
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid action check")
|
||||
step_id = str(item.get("step_id") or "")
|
||||
required_count = item.get("required_action_count")
|
||||
if (
|
||||
step_id not in steps
|
||||
or step_id in action_checked
|
||||
or item.get("status") not in {"pass", "fail"}
|
||||
or not isinstance(required_count, int)
|
||||
or isinstance(required_count, bool)
|
||||
or required_count < 1
|
||||
):
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer action checks do not match the plan")
|
||||
planned_actions = [
|
||||
action for action in (step_records[step_id].get("actions") or ())
|
||||
if isinstance(action, dict)
|
||||
]
|
||||
if required_count > len(planned_actions) and item.get("status") != "fail":
|
||||
raise _ModelingPlanReviewFormatError(
|
||||
"Modeling plan reviewer must fail an action check when required_action_count exceeds the plan's action count"
|
||||
)
|
||||
action_checked.add(step_id)
|
||||
if result["verdict"] == "pass" and any(item.get("status") != "covered" for item in coverage):
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every checklist item is covered")
|
||||
if result["verdict"] == "pass" and any(item.get("status") != "pass" for item in checks):
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every step check passes")
|
||||
if result["verdict"] == "pass" and any(item.get("status") != "pass" for item in action_checks):
|
||||
raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every action check passes")
|
||||
unsupported = _unsupported_operation_mentions(result, runtime_operations)
|
||||
if unsupported:
|
||||
# These strings came from the reviewer's explanatory prose, not
|
||||
# from the submitted plan. The plan parser enforces exact IDs for
|
||||
# explicit operation declarations; do not reject an otherwise
|
||||
# valid semantic plan because the reviewer hallucinated a
|
||||
# shorthand while describing an alternative. Keep this warning in
|
||||
# the raw audit record and scrub it before author exposure.
|
||||
result["unsupported_operations"] = unsupported
|
||||
result["review_warnings"] = [
|
||||
"Reviewer mentioned operation name(s) absent from runtime_operations; those names were ignored as guidance."
|
||||
]
|
||||
return result
|
||||
|
||||
async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client:
|
||||
response = await request_review(client, payload)
|
||||
try:
|
||||
result = validate_response(response)
|
||||
except _ModelingPlanReviewFormatError as error:
|
||||
retry_payload = copy.deepcopy(payload)
|
||||
retry_payload["messages"].append({"role": "user", "content": f"Your previous review was rejected locally: {error}. Return only a complete review_modeling_plan tool call with one coverage row per checklist item and one step_checks and action_checks row per plan step."})
|
||||
result = validate_response(await request_review(client, retry_payload))
|
||||
return {"schema_version": "cad.modeling-plan-review.v1", "node_id": node_id, "model": model.id, **result}
|
||||
|
||||
@@ -65,6 +65,8 @@ class Settings:
|
||||
agent_max_features_per_fragment: int = 6
|
||||
agent_context_char_limit: int = 14000
|
||||
agent_render_cache: bool = True
|
||||
modeling_plan_enabled: bool = True
|
||||
modeling_plan_max_revisions: int = 2
|
||||
autonomous_generation: bool = True
|
||||
resume_running_tasks_on_startup: bool = True
|
||||
|
||||
@@ -199,6 +201,8 @@ def get_settings() -> Settings:
|
||||
agent_max_features_per_fragment=max(1, min(6, int(os.getenv("CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT", "6")))),
|
||||
agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))),
|
||||
agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True),
|
||||
modeling_plan_enabled=_env_flag("CDSL_MODELING_PLAN_ENABLED", True),
|
||||
modeling_plan_max_revisions=max(1, int(os.getenv("CDSL_MODELING_PLAN_MAX_REVISIONS", "2"))),
|
||||
autonomous_generation=True,
|
||||
# Production instances recover durable runs by default. Test workers
|
||||
# can disable this before startup to guarantee they touch only tasks
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"geometry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketches": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/sketch"}}
|
||||
"sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}}
|
||||
},
|
||||
"required": ["sketches"],
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"hole_blind": {"summary": "Cut one or more blind cylindrical holes in the current body.", "required_params": ["diameter_mm", "depth_mm", "positions", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face is injected from exactly one face selector token.", "requires_sketch": false},
|
||||
"hole_countersink": {"summary": "Cut one or more blind holes with countersink dimensions.", "required_params": ["diameter_mm", "depth_mm", "positions", "countersink_diameter_mm", "countersink_angle_rad", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face is injected from exactly one face selector token.", "requires_sketch": false},
|
||||
"hole_counterbore": {"summary": "Cut one or more blind holes with counterbore dimensions.", "required_params": ["diameter_mm", "depth_mm", "positions", "counterbore_diameter_mm", "counterbore_depth_mm", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face is injected from exactly one face selector token.", "requires_sketch": false},
|
||||
"sphere_add": {"summary": "Add one spherical solid at an explicit model-space center.", "required_params": ["radius_mm", "center_mm"], "optional_params": [], "requires_sketch": true, "produces_body": true},
|
||||
"sphere_add": {"summary": "Add one spherical solid at an explicit model-space center.", "required_params": ["radius_mm", "center_mm"], "optional_params": [], "requires_sketch": false, "produces_body": true},
|
||||
"fillet": {"summary": "Apply a radius to selected edges or faces.", "required_params": ["radius_mm"], "optional_params": ["tangent_propagation"], "requires_sketch": false, "selector_slot": {"path": "feature.selectors", "min_items": 1, "max_items": 64}},
|
||||
"chamfer": {"summary": "Apply an equal-distance or angle-distance chamfer to selected edges or faces.", "required_params": ["distance_mm"], "optional_params": ["distance_2_mm", "angle_rad"], "requires_sketch": false, "selector_slot": {"path": "feature.selectors", "min_items": 1, "max_items": 64}},
|
||||
"pattern_linear": {"summary": "Repeat source features along one or two directions.", "required_params": ["source_feature_ids", "direction_1", "spacing_1_mm", "pattern_count_1"], "optional_params": ["direction_2", "spacing_2_mm", "pattern_count_2"], "requires_sketch": false},
|
||||
|
||||
@@ -39,9 +39,10 @@ def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = No
|
||||
# macro profiles and therefore never invoke this adapter.
|
||||
cdsl = lower_legacy_profiles(cdsl)
|
||||
sketches = cdsl.get("geometry", {}).get("sketches", [])
|
||||
all_drawable = bool(sketches) and all(
|
||||
_sketch_is_cdsl_drawable(s) for s in sketches
|
||||
)
|
||||
# Sketchless parameterized features (for example sphere_add) are fully
|
||||
# executable by the CDSL-only runtime. ``all([])`` deliberately keeps
|
||||
# that path available rather than forcing an unavailable legacy fallback.
|
||||
all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches)
|
||||
|
||||
cdsl_only_error: Exception | None = None
|
||||
if all_drawable and not force_exact:
|
||||
|
||||
@@ -373,6 +373,23 @@ def _revolve_axis(node: FeaturePlanNode, session: ExecutionSession) -> AxisSpec:
|
||||
return resolution.record.value
|
||||
|
||||
|
||||
def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None:
|
||||
"""Defend direct CDSL execution from an out-of-plane revolve axis."""
|
||||
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
direction_normal_dot = abs(vector_dot(axis.direction, plane.normal))
|
||||
if direction_normal_dot > 1e-7:
|
||||
raise ValueError(
|
||||
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to "
|
||||
f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}"
|
||||
)
|
||||
origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal))
|
||||
if origin_plane_offset > 1e-6:
|
||||
raise ValueError(
|
||||
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in "
|
||||
f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}"
|
||||
)
|
||||
|
||||
|
||||
def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, sketch: dict[str, Any] | None = None) -> FeatureResult:
|
||||
# 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。
|
||||
|
||||
@@ -402,6 +419,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
|
||||
else:
|
||||
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
|
||||
axis = _revolve_axis(node, session)
|
||||
_validate_revolve_axis_in_sketch_plane(axis, selected_sketch)
|
||||
angle = float(node.params.get("angle_deg") or 0.0)
|
||||
if angle <= 0:
|
||||
raise ValueError("revolve requires angle_deg > 0")
|
||||
|
||||
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