优化
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")
|
||||
|
||||
Generated
+1644
-2
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
||||
"@radix-ui/react-collapsible": "^1.1.20",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-slider": "^1.4.7",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"ai": "7.0.37",
|
||||
"animejs": "^4.5.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -23,6 +24,10 @@
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-json-view-lite": "^2.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"three": "0.160.0",
|
||||
"three-mesh-bvh": "^0.8.0"
|
||||
|
||||
@@ -56,14 +56,31 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
const textId = `assistant_${Date.now()}`;
|
||||
writer.write({ type: "start", messageId: textId });
|
||||
writer.write({ type: "text-start", id: textId });
|
||||
const messageId = `assistant_${Date.now()}`;
|
||||
writer.write({ type: "start", messageId });
|
||||
let textPartIndex = 0;
|
||||
let textId: string | null = null;
|
||||
let sequence = 0;
|
||||
for await (const item of parseSse(upstream)) {
|
||||
const chunk = backendEventToUiChunk(item, textId);
|
||||
if (item.event === "done") continue;
|
||||
sequence += 1;
|
||||
if (item.event === "text_delta") {
|
||||
if (!textId) {
|
||||
textId = `${messageId}_text_${textPartIndex++}`;
|
||||
writer.write({ type: "text-start", id: textId });
|
||||
}
|
||||
const chunk = backendEventToUiChunk(item, textId, sequence);
|
||||
if (chunk) writer.write(chunk);
|
||||
continue;
|
||||
}
|
||||
if (textId) {
|
||||
writer.write({ type: "text-end", id: textId });
|
||||
textId = null;
|
||||
}
|
||||
const chunk = backendEventToUiChunk(item, `${messageId}_text_${textPartIndex}`, sequence);
|
||||
if (chunk) writer.write(chunk);
|
||||
}
|
||||
writer.write({ type: "text-end", id: textId });
|
||||
if (textId) writer.write({ type: "text-end", id: textId });
|
||||
writer.write({ type: "finish", finishReason: "stop" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,3 +9,10 @@ export async function GET(_request: NextRequest, context: { params: Promise<{ ta
|
||||
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
|
||||
return NextResponse.json(await response.json());
|
||||
}
|
||||
|
||||
export async function DELETE(_request: NextRequest, context: { params: Promise<{ taskId: string }> }) {
|
||||
const { taskId } = await context.params;
|
||||
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" });
|
||||
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
|
||||
return NextResponse.json(await response.json());
|
||||
}
|
||||
|
||||
@@ -395,17 +395,6 @@ button:disabled {
|
||||
.studio-main { display: flex; min-height: 0; flex: 1; }
|
||||
.agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); }
|
||||
.preview-pane { position: relative; min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
|
||||
.generation-status { position: absolute; z-index: 30; top: 12px; right: 12px; width: min(260px, calc(100% - 24px)); max-height: min(42vh, 360px); overflow: auto; border: 1px solid var(--ui-border); border-radius: 6px; background: var(--ui-glass-popover); box-shadow: var(--ui-shadow-soft); backdrop-filter: blur(12px); color: var(--ui-text); padding: 10px; font-size: 12px; }
|
||||
.generation-status-heading { display: flex; align-items: center; gap: 6px; color: var(--ui-text-strong); font-weight: 650; }
|
||||
.generation-status-active { margin: 6px 0 8px; color: var(--ui-accent-text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
|
||||
.generation-status ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
||||
.generation-status li { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--ui-border-muted); padding-top: 4px; color: var(--ui-text-muted); }
|
||||
.generation-status li[data-status="completed"] small { color: var(--ui-success); }
|
||||
.generation-status li[data-status="planned"] small { color: var(--ui-text-subtle); }
|
||||
.generation-status li[data-status="failed"] small { color: var(--ui-error); }
|
||||
.generation-status details { margin: 8px 0; border-top: 1px solid var(--ui-border-muted); padding-top: 6px; }
|
||||
.generation-status summary { cursor: pointer; color: var(--ui-text-strong); }
|
||||
.generation-status pre { margin: 6px 0 0; max-height: 132px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ui-text-muted); }
|
||||
.agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; }
|
||||
.agent-thread-shell { position: relative; }
|
||||
.spin { animation: ui-spin 900ms linear infinite; }
|
||||
@@ -421,6 +410,7 @@ button:disabled {
|
||||
.assistant-row .message-role svg { color: var(--ui-accent); }
|
||||
.message-content { min-width: 0; color: var(--ui-text); }
|
||||
.message-text { margin: 0; font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.message-text .markdown-document { margin-top: 0; border-left: 0; padding: 0; font-size: inherit; }
|
||||
.message-request-error { display: flex; align-items: flex-start; gap: 7px; margin-top: 8px; color: var(--ui-error-text); font-size: 11px; line-height: 1.45; }
|
||||
.message-request-error svg { flex: 0 0 auto; margin-top: 1px; }
|
||||
.thread-empty { color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 2px 0 14px; }
|
||||
@@ -448,7 +438,29 @@ button:disabled {
|
||||
.cad-message-heading { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text); font-size: 12px; font-weight: 600; }
|
||||
.cad-message-heading svg { flex: 0 0 auto; color: var(--ui-accent); }
|
||||
.cad-status-label { color: var(--ui-text-subtle); font-size: 10px; font-weight: 500; text-transform: uppercase; }
|
||||
.cad-tool-name { overflow: hidden; max-width: 46%; border: 1px solid var(--ui-border-muted); border-radius: 3px; background: var(--ui-control-bg); color: var(--ui-accent-text); font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; padding: 1px 4px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cad-message-copy { margin-left: 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
.cad-event-details { margin: 4px 0 0 21px; color: var(--ui-text-subtle); font-size: 10px; }
|
||||
.cad-event-details summary { cursor: pointer; width: fit-content; }
|
||||
.cad-event-details pre { max-height: 180px; overflow: auto; margin: 4px 0 0; border-left: 2px solid var(--ui-border-muted); padding-left: 8px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ui-text-muted); font: 10px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.cad-event-details ul { display: grid; gap: 3px; margin: 4px 0 0; padding-left: 14px; color: var(--ui-text-muted); line-height: 1.4; }
|
||||
.cad-document-details { margin-top: 7px; }
|
||||
.markdown-document { margin-top: 7px; border-left: 2px solid var(--ui-accent-border); padding: 2px 0 2px 10px; color: var(--ui-text); font-size: 11px; line-height: 1.65; }
|
||||
.markdown-document > :first-child { margin-top: 0; }.markdown-document > :last-child { margin-bottom: 0; }
|
||||
.markdown-document h1, .markdown-document h2, .markdown-document h3 { margin: 12px 0 5px; color: var(--ui-text-strong); line-height: 1.3; }
|
||||
.markdown-document h1 { font-size: 14px; }.markdown-document h2 { font-size: 13px; }.markdown-document h3 { font-size: 12px; }
|
||||
.markdown-document p { margin: 5px 0; }.markdown-document ul, .markdown-document ol { display: block; margin: 5px 0; padding-left: 20px; color: var(--ui-text); }
|
||||
.markdown-document li { margin: 2px 0; }.markdown-document li::marker { color: var(--ui-accent); }
|
||||
.markdown-document input[type="checkbox"] { margin: 0 6px 0 0; accent-color: var(--ui-accent); }
|
||||
.markdown-document blockquote { margin: 7px 0; border-left: 2px solid var(--ui-border-strong); padding-left: 9px; color: var(--ui-text-muted); }
|
||||
.markdown-document a { color: var(--ui-link); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.markdown-document table { width: 100%; margin: 7px 0; border-collapse: collapse; font-size: 10px; }.markdown-document th, .markdown-document td { border: 1px solid var(--ui-border); padding: 5px 7px; text-align: left; }.markdown-document th { background: var(--ui-panel-raised); color: var(--ui-text-strong); }
|
||||
.inline-code { border: 1px solid var(--ui-border-muted); border-radius: 3px; background: var(--ui-control-bg); color: var(--ui-accent-text); padding: 1px 4px; font: 0.92em/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.code-viewer { position: relative; max-width: 100%; margin: 7px 0; overflow: auto; border: 1px solid var(--ui-border-strong); border-radius: 4px; background: #171a1d; color: #e8e8e3; }
|
||||
.code-viewer pre { max-height: 320px; margin: 0 !important; border: 0; white-space: pre; font-size: 10px; line-height: 1.5; }
|
||||
.code-language { position: sticky; left: 100%; top: 0; z-index: 1; display: block; width: max-content; margin: 5px 6px -18px auto; color: #969b9f; font-size: 9px; text-transform: uppercase; }
|
||||
.json-tree { max-height: 260px; overflow: auto; margin-top: 6px; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); padding: 7px; color: var(--ui-text); font: 10px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.json-tree .json-view--property { color: var(--ui-accent-text); }.json-tree .json-view--string { color: var(--ui-success-text); }.json-tree .json-view--number, .json-tree .json-view--boolean { color: var(--ui-secondary-text); }
|
||||
.cad-progress.is-error .cad-message-heading, .cad-progress.is-error .cad-message-heading svg, .cad-error .cad-message-heading, .cad-error .cad-message-heading svg { color: var(--ui-error-text); }
|
||||
.cad-result { margin-top: 12px; }
|
||||
.cad-result-title { margin-left: 21px; color: var(--ui-accent-text); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
|
||||
@@ -165,6 +165,30 @@ export function AgentStudio() {
|
||||
if (error.stage === "generation") setTaskRunning(false);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setTaskRunning(false);
|
||||
setLastError("正在停止 CAD 任务...");
|
||||
void (async () => {
|
||||
let taskId = selectedTaskId;
|
||||
if (!taskId && conversationId) {
|
||||
const conversationResponse = await fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { cache: "no-store" });
|
||||
if (conversationResponse.ok) {
|
||||
const conversation = await conversationResponse.json() as ConversationRecord;
|
||||
taskId = conversation.current_task_id || "";
|
||||
}
|
||||
}
|
||||
if (!taskId) throw new Error("尚未取得运行中的任务编号,请稍后重试");
|
||||
const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(payload.error || "停止 CAD 任务失败");
|
||||
}
|
||||
setLastError("CAD 任务已停止");
|
||||
})().catch((error) => {
|
||||
setLastError(error instanceof Error ? error.message : "停止 CAD 任务失败");
|
||||
});
|
||||
}, [conversationId, selectedTaskId]);
|
||||
|
||||
const handleUpload = useCallback(async (files: FileList | null) => {
|
||||
const selectedFiles = Array.from(files || []);
|
||||
if (!selectedFiles.length) return;
|
||||
@@ -247,6 +271,7 @@ export function AgentStudio() {
|
||||
uploading={uploading}
|
||||
uploadError={uploadError}
|
||||
onUpload={handleUpload}
|
||||
onCancel={handleCancel}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
providerId={providerId}
|
||||
@@ -257,7 +282,6 @@ export function AgentStudio() {
|
||||
onCadError={handleError}
|
||||
onSelectionChange={setViewerSelection}
|
||||
taskRunning={taskRunning}
|
||||
taskRecord={taskRecord}
|
||||
/>
|
||||
</AgentRuntime>
|
||||
);
|
||||
@@ -478,6 +502,7 @@ function StudioShell({
|
||||
uploading,
|
||||
uploadError,
|
||||
onUpload,
|
||||
onCancel,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
providerId,
|
||||
@@ -488,7 +513,6 @@ function StudioShell({
|
||||
onCadError,
|
||||
onSelectionChange,
|
||||
taskRunning,
|
||||
taskRecord,
|
||||
}: {
|
||||
config: BackendConfig | null;
|
||||
cadResult: CadResult | null;
|
||||
@@ -497,6 +521,7 @@ function StudioShell({
|
||||
uploading: boolean;
|
||||
uploadError: string;
|
||||
onUpload: (files: FileList | null) => void;
|
||||
onCancel: () => void;
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
providerId: string;
|
||||
@@ -507,7 +532,6 @@ function StudioShell({
|
||||
onCadError: (error: CadError) => void;
|
||||
onSelectionChange: (selection: ViewerSelectionContext | null) => void;
|
||||
taskRunning: boolean;
|
||||
taskRecord: TaskRecord | null;
|
||||
}) {
|
||||
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
|
||||
const provider = config?.providers.find((item) => item.id === providerId);
|
||||
@@ -531,9 +555,8 @@ function StudioShell({
|
||||
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span>未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。</span></div> : null}
|
||||
{config?.autonomous_generation && !config.review_configured ? <div className="config-warning"><AlertCircle size={16} /><span>最终视觉复核未配置,任务在最终发布前会停止:{config.review_error || "请配置独立视觉模型。"}</span></div> : null}
|
||||
<div className="studio-main">
|
||||
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} /></aside>
|
||||
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} onCancel={onCancel} /></aside>
|
||||
<section className="preview-pane">
|
||||
<GenerationStatus task={taskRecord} />
|
||||
<CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onError={handleViewerError} onSelectionChange={onSelectionChange} />
|
||||
</section>
|
||||
</div>
|
||||
@@ -541,24 +564,6 @@ function StudioShell({
|
||||
);
|
||||
}
|
||||
|
||||
function GenerationStatus({ task }: { task: TaskRecord | null }) {
|
||||
if (task?.lifecycle !== "running") return null;
|
||||
const agent = task.agent_state;
|
||||
const events = agent?.recent_events || [];
|
||||
return (
|
||||
<aside className="generation-status" aria-live="polite">
|
||||
<div className="generation-status-heading"><Loader2 className="spin" size={14} /><span>自主建模</span></div>
|
||||
<div className="generation-status-active">{task.active_candidate_id ? `候选 ${task.active_candidate_id}` : task.active_revision || "正在编写冻结需求"}</div>
|
||||
{task.requirements_markdown ? <details open><summary>需求文档</summary><pre>{task.requirements_markdown}</pre></details> : null}
|
||||
{task.completion_checklist_markdown ? <details open><summary>完成清单</summary><pre>{task.completion_checklist_markdown}</pre></details> : null}
|
||||
{agent?.completion_ledger?.items?.length ? <ul className="completion-ledger">{agent.completion_ledger.items.map((item, index) => <li key={`${item.item || "item"}-${index}`}><span>{item.status === "complete" ? "完成" : item.status === "uncertain" ? "待确认" : "缺失"}</span><small>{item.item}{item.evidence ? `:${item.evidence}` : ""}</small></li>)}</ul> : null}
|
||||
{agent?.last_review ? <div className="generation-status-active">步骤审查:{agent.last_review.decision || "已记录"}</div> : null}
|
||||
{agent?.last_diagnostic ? <div className="generation-status-active">{agent.last_diagnostic}</div> : null}
|
||||
{events.length ? <ul>{events.slice(-6).map((item, index) => <li key={`${item.at || "event"}-${index}`}><span>{item.kind || item.tool || "工具"}</span><small>{item.message || "已更新"}</small></li>)}</ul> : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function StudioLoading() {
|
||||
return (
|
||||
<main className="boot-screen">
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Upload } from "lucide-react";
|
||||
import { useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
|
||||
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAui, useAuiState } from "@assistant-ui/react";
|
||||
import type { CadAttachment } from "@/lib/cad-types";
|
||||
import { CadErrorPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
|
||||
|
||||
export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload }: {
|
||||
export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload, onCancel }: {
|
||||
attachments: CadAttachment[];
|
||||
uploading: boolean;
|
||||
uploadError: string;
|
||||
taskRunning?: boolean;
|
||||
onUpload: (files: FileList | null) => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const dragDepth = useRef(0);
|
||||
@@ -65,7 +66,7 @@ export function AgentThread({ attachments, uploading, uploadError, taskRunning =
|
||||
</ThreadPrimitive.Empty>
|
||||
<div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div>
|
||||
</ThreadPrimitive.Viewport>
|
||||
<Composer fileInput={fileInput} uploading={uploading} taskRunning={taskRunning} onUpload={onUpload} />
|
||||
<Composer fileInput={fileInput} uploading={uploading} taskRunning={taskRunning} onUpload={onUpload} onCancel={onCancel} />
|
||||
</ThreadPrimitive.Root>
|
||||
{isDraggingFiles ? <div className="file-drop-overlay" role="status" aria-live="polite"><Upload size={24} /><span>拖放文件上传</span></div> : null}
|
||||
</div>
|
||||
@@ -104,8 +105,14 @@ function AssistantMessage() {
|
||||
);
|
||||
}
|
||||
|
||||
function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void }) {
|
||||
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
|
||||
function Composer({ fileInput, uploading, taskRunning = false, onUpload, onCancel }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void; onCancel?: () => void }) {
|
||||
const aui = useAui();
|
||||
const chatRunning = useAuiState((state) => state.thread.isRunning);
|
||||
const running = chatRunning || taskRunning;
|
||||
const handleCancel = () => {
|
||||
onCancel?.();
|
||||
if (chatRunning) aui.thread().cancelRun();
|
||||
};
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.currentTarget.files?.length) onUpload(event.currentTarget.files);
|
||||
event.currentTarget.value = "";
|
||||
@@ -119,7 +126,7 @@ function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fil
|
||||
<span><Check size={14} aria-hidden="true" /> Enter 发送,Shift + Enter 换行</span>
|
||||
<div className="composer-actions">
|
||||
{running ? (
|
||||
<span className="text-[11px] text-[var(--ui-text-subtle)]"><Loader2 className="mr-1 inline spin" size={13} aria-hidden="true" />生成中</span>
|
||||
<button type="button" className="composer-command composer-cancel" title="停止生成" aria-label="停止生成" onClick={handleCancel}><Loader2 className="spin" size={14} aria-hidden="true" />停止</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="composer-command composer-upload" title="上传图片或文档" aria-label="上传图片或文档" aria-busy={uploading || undefined} disabled={uploading} onClick={() => fileInput.current?.click()}>{uploading ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Paperclip size={14} aria-hidden="true" />}上传</button>
|
||||
|
||||
@@ -1,32 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Box, Check, Download, Loader2 } from "lucide-react";
|
||||
import { AlertTriangle, Box, Check, Download, Eye, FileCheck, Loader2, RotateCcw, Search, Wrench } from "lucide-react";
|
||||
import { encodeArtifactUrl } from "@/lib/cad-artifacts";
|
||||
import type { CadError, CadProgress, CadResult } from "@/lib/cad-types";
|
||||
import { JsonTree, MarkdownDocument } from "./rich-content";
|
||||
|
||||
export function TextPart({ text }: { text: string }) {
|
||||
if (!text.trim()) return null;
|
||||
return <p className="message-text">{text}</p>;
|
||||
return <div className="message-text"><MarkdownDocument>{text}</MarkdownDocument></div>;
|
||||
}
|
||||
|
||||
export function CadProgressPart({ data }: { data: CadProgress }) {
|
||||
if (data.step === "agent_stream") return null;
|
||||
const status = String(data.status || "").toLowerCase();
|
||||
const isRunning = status === "running";
|
||||
const isError = status === "error";
|
||||
const statusLabel = isRunning ? "进行中" : isError ? "失败" : status === "success" ? "完成" : data.status;
|
||||
const Icon = data.step === "tool_call" ? Wrench : data.step.includes("review") || data.step === "final_review" ? Eye : data.step === "rollback" ? RotateCcw : data.step.includes("requirements") || data.step.includes("checklist") ? FileCheck : data.step.includes("diagnostic") ? Search : isError ? AlertTriangle : Check;
|
||||
const evidence = data.evidence || (Array.isArray(data.review?.evidence) ? data.review.evidence.map(String) : []);
|
||||
const documentTitle = data.step === "requirements_document" ? "冻结需求内容" : data.step === "completion_checklist" ? "完成清单内容" : "";
|
||||
const documentMarkdown = data.step === "requirements_document" ? withoutRequirementsFilename(data.markdown || "") : data.markdown || "";
|
||||
return (
|
||||
<div className={`cad-message cad-progress${isError ? " is-error" : ""}`} role="status" aria-live="polite">
|
||||
<div className="cad-message-heading">
|
||||
{isRunning ? <Loader2 className="spin" size={14} aria-hidden="true" /> : isError ? <AlertTriangle size={14} aria-hidden="true" /> : <Check size={14} aria-hidden="true" />}
|
||||
{isRunning ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Icon size={14} aria-hidden="true" />}
|
||||
<span>{data.label || data.step}</span>
|
||||
{data.tool ? <code className="cad-tool-name">{data.tool}</code> : null}
|
||||
<span className="cad-status-label">{statusLabel}</span>
|
||||
</div>
|
||||
{data.message ? <div className="cad-message-copy">{data.message}</div> : null}
|
||||
{documentMarkdown ? <details className="cad-event-details cad-document-details" open={data.step === "requirements_document"}><summary>{documentTitle || "文档内容"}</summary><MarkdownDocument>{documentMarkdown}</MarkdownDocument></details> : null}
|
||||
{data.arguments ? <details className="cad-event-details"><summary>调用参数</summary><JsonTree data={data.arguments} /></details> : null}
|
||||
{data.result !== undefined ? <details className="cad-event-details"><summary>执行结果</summary><JsonTree data={data.result} /></details> : null}
|
||||
{evidence.length ? <details className="cad-event-details"><summary>证据 ({evidence.length})</summary><ul>{evidence.map((item, index) => <li key={`${item}-${index}`}>{item}</li>)}</ul></details> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function withoutRequirementsFilename(markdown: string) {
|
||||
return markdown
|
||||
.replace(/^\s*#{1,6}\s*`?requirements\.md`?\s*\n+/i, "")
|
||||
.replace(/^\s*`?requirements\.md`?\s*\n+/i, "")
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
export function CadResultPart({ data }: { data: CadResult }) {
|
||||
const downloads: Array<[string, string]> = data.checkpoint ? [] : [
|
||||
["STEP", data.stepPath],
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { JsonView, collapseAllNested } from "react-json-view-lite";
|
||||
import "react-json-view-lite/dist/index.css";
|
||||
import { PrismAsync as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
|
||||
export function MarkdownDocument({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="markdown-document">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ children: label, ...props }) => <a {...props} target="_blank" rel="noreferrer">{label}</a>,
|
||||
code: MarkdownCode,
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownCode({ className, children, ...props }: ComponentPropsWithoutRef<"code"> & { children?: ReactNode }) {
|
||||
const language = /language-([\w-]+)/.exec(className || "")?.[1];
|
||||
const value = String(children || "").replace(/\n$/, "");
|
||||
if (!language && !value.includes("\n")) return <code className="inline-code" {...props}>{children}</code>;
|
||||
return <CodeViewer code={value} language={language || "text"} />;
|
||||
}
|
||||
|
||||
export function CodeViewer({ code, language = "text" }: { code: string; language?: string }) {
|
||||
return (
|
||||
<div className="code-viewer">
|
||||
<span className="code-language">{language}</span>
|
||||
<SyntaxHighlighter language={language} style={oneDark} wrapLongLines customStyle={{ margin: 0, background: "transparent", padding: "12px" }}>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JsonTree({ data }: { data: unknown }) {
|
||||
const value = isJsonContainer(data) ? data : { value: data };
|
||||
return (
|
||||
<div className="json-tree">
|
||||
<JsonView data={value} shouldExpandNode={(level) => level < 1 || collapseAllNested(level)} clickToExpandNode />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isJsonContainer(value: unknown): value is Record<string, unknown> | unknown[] {
|
||||
return Boolean(value && typeof value === "object");
|
||||
}
|
||||
@@ -42,6 +42,56 @@ test("maps a rejected independent candidate review into a blocking progress stat
|
||||
});
|
||||
});
|
||||
|
||||
test("maps a modeling plan revision request into a blocking progress state", () => {
|
||||
const chunk = backendEventToUiChunk({
|
||||
event: "modeling_plan_review",
|
||||
data: { taskId: "cad_abc", review: { verdict: "revise", issues: [{ message: "split unrelated finish" }] } },
|
||||
}, "text_1");
|
||||
assert.equal(chunk?.type, "data-cad-progress");
|
||||
assert.equal("data" in chunk! ? (chunk.data as { label?: string }).label : null, "计划独立复核");
|
||||
assert.equal("data" in chunk! ? (chunk.data as { status?: string }).status : null, "error");
|
||||
});
|
||||
|
||||
test("keeps a server-verified plan skip visible in the timeline", () => {
|
||||
const chunk = backendEventToUiChunk({
|
||||
event: "plan_step_skipped",
|
||||
data: {
|
||||
taskId: "cad_abc",
|
||||
planStepId: "step_6",
|
||||
nextPlanStepId: "",
|
||||
evidenceRef: "completion_ledger:rev_008",
|
||||
status: "success",
|
||||
message: "The current revision already satisfies this plan step.",
|
||||
},
|
||||
}, "text_1");
|
||||
assert.equal(chunk?.type, "data-cad-progress");
|
||||
assert.equal("data" in chunk! ? (chunk.data as { label?: string }).label : null, "计划步骤已满足");
|
||||
assert.equal("data" in chunk! ? (chunk.data as { status?: string }).status : null, "success");
|
||||
});
|
||||
|
||||
test("gives repeated tool events unique ordered parts", () => {
|
||||
const first = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", tool: "inspect_model", status: "running" } }, "text_1", 4);
|
||||
const second = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", tool: "inspect_model", status: "success" } }, "text_1", 5);
|
||||
assert.notEqual(first?.id, second?.id);
|
||||
assert.equal("data" in first! ? (first.data as { sequence?: number }).sequence : null, 4);
|
||||
assert.equal("data" in second! ? (second.data as { sequence?: number }).sequence : null, 5);
|
||||
});
|
||||
|
||||
test("reuses an invocation id so tool completion updates its running card", () => {
|
||||
const running = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", eventId: "call_1", invocationId: "call_1", tool: "submit_cdsl_fragment", status: "running" } }, "text_1", 4);
|
||||
const complete = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", eventId: "call_1", invocationId: "call_1", tool: "submit_cdsl_fragment", status: "success" } }, "text_1", 5);
|
||||
assert.equal(running?.id, complete?.id);
|
||||
});
|
||||
|
||||
test("keeps frozen requirement markdown visible in the timeline", () => {
|
||||
const chunk = backendEventToUiChunk({
|
||||
event: "requirements_document",
|
||||
data: { taskId: "cad_abc", status: "frozen", markdown: "# 冻结需求\n\n- 创建底座" },
|
||||
}, "text_1", 2);
|
||||
assert.equal(chunk?.type, "data-cad-progress");
|
||||
assert.equal("data" in chunk! ? (chunk.data as { markdown?: string }).markdown : null, "# 冻结需求\n\n- 创建底座");
|
||||
});
|
||||
|
||||
test("restores the latest successful task revision for the viewer", () => {
|
||||
const result = latestSuccessfulResult({
|
||||
task_id: "cad_abc",
|
||||
|
||||
@@ -8,38 +8,58 @@ export type BackendSseEvent = {
|
||||
export function backendEventToUiChunk(
|
||||
item: BackendSseEvent,
|
||||
textId: string,
|
||||
): UIMessageChunk | null {
|
||||
sequence = 0,
|
||||
): (UIMessageChunk & { id?: string }) | null {
|
||||
if (item.event === "text_delta") {
|
||||
return { type: "text-delta", id: textId, delta: String(item.data.text || "") };
|
||||
}
|
||||
if (item.event === "progress") {
|
||||
return {
|
||||
type: "data-cad-progress",
|
||||
id: `progress_${String(item.data.step || Date.now())}`,
|
||||
data: item.data,
|
||||
id: `progress_${String(item.data.taskId || "task")}_${sequence}`,
|
||||
data: { ...item.data, sequence },
|
||||
};
|
||||
}
|
||||
if (["requirements_document", "completion_checklist", "completion_audit", "agent_thinking", "tool_call", "candidate_result", "candidate_review", "geometry_diagnostic", "geometry_conclusion", "step_review", "checkpoint", "rollback", "final_review", "task_terminal"].includes(item.event)) {
|
||||
if (["requirements_document", "completion_checklist", "completion_audit", "modeling_plan", "modeling_plan_review", "plan_step_skipped", "agent_thinking", "tool_call", "candidate_result", "candidate_review", "geometry_diagnostic", "geometry_conclusion", "step_review", "checkpoint", "rollback", "final_review", "task_terminal"].includes(item.event)) {
|
||||
const review = item.data.review && typeof item.data.review === "object"
|
||||
? item.data.review as Record<string, unknown>
|
||||
: null;
|
||||
const status = item.event === "task_terminal"
|
||||
? (String(item.data.lifecycle || "") === "failed" ? "error" : "success")
|
||||
: (item.event === "candidate_review" && String(review?.verdict || "") === "reject")
|
||||
: (item.event === "modeling_plan_review" && String(review?.verdict || "") === "revise")
|
||||
|| (item.event === "candidate_review" && String(review?.verdict || "") === "reject")
|
||||
|| (item.event === "final_review" && String(review?.verdict || "") === "repair" && Number(review?.confidence || 0) >= 0.85)
|
||||
? "error"
|
||||
: String(item.data.status || "running");
|
||||
const taskId = String(item.data.taskId || "task");
|
||||
const eventId = String(item.data.eventId || `${taskId}_${sequence}_${item.event}`);
|
||||
const metadata = sequence > 0 || item.data.eventId
|
||||
? {
|
||||
eventId,
|
||||
sequence,
|
||||
...(review ? { review } : {}),
|
||||
}
|
||||
: {};
|
||||
return {
|
||||
type: "data-cad-progress",
|
||||
id: `${item.event}_${String(item.data.taskId || Date.now())}_${String(item.data.nodeId || "")}`,
|
||||
id: `event_${eventId}`,
|
||||
data: { step: item.event, label: ({
|
||||
requirements_document: "冻结需求", completion_checklist: "完成清单", completion_audit: "完成审计", agent_thinking: "建模判断", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", geometry_diagnostic: "几何诊断", geometry_conclusion: "几何结论", step_review: "步骤审查", checkpoint: "构建检查点", rollback: "回滚检查点", final_review: "最终视觉复核", task_terminal: "生成任务",
|
||||
} as Record<string, string>)[item.event], status, message: String(
|
||||
item.data.message || item.data.reason || (review?.evidence instanceof Array ? review.evidence.join(";") : ""),
|
||||
requirements_document: "冻结需求", completion_checklist: "完成清单", completion_audit: "完成审计", modeling_plan: "建模计划", modeling_plan_review: "计划独立复核", plan_step_skipped: "计划步骤已满足", agent_thinking: "建模判断", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", geometry_diagnostic: "几何诊断", geometry_conclusion: "几何结论", step_review: "步骤审查", checkpoint: "构建检查点", rollback: "回滚检查点", final_review: "最终视觉复核", task_terminal: "生成任务",
|
||||
} as Record<string, string>)[item.event], status, ...metadata, message: String(
|
||||
item.data.message || item.data.reason
|
||||
|| (review?.evidence instanceof Array ? review.evidence.join(";") : "")
|
||||
|| (review?.issues instanceof Array ? review.issues.map((issue) => typeof issue === "object" && issue ? String((issue as Record<string, unknown>).message || "") : String(issue)).filter(Boolean).join(";") : ""),
|
||||
),
|
||||
...(item.data.taskId ? { taskId: String(item.data.taskId) } : {}),
|
||||
...(item.data.taskId ? { taskId } : {}),
|
||||
...(item.data.nodeId ? { nodeId: String(item.data.nodeId) } : {}),
|
||||
...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}),
|
||||
...(item.data.timestamp ? { timestamp: String(item.data.timestamp) } : {}),
|
||||
...(item.data.markdown ? { markdown: String(item.data.markdown) } : {}),
|
||||
...(item.data.tool ? { tool: String(item.data.tool) } : {}),
|
||||
...(item.data.invocationId ? { invocationId: String(item.data.invocationId) } : {}),
|
||||
...(item.data.arguments && typeof item.data.arguments === "object" ? { arguments: item.data.arguments as Record<string, unknown> } : {}),
|
||||
...(item.data.result !== undefined ? { result: item.data.result } : {}),
|
||||
...(Array.isArray(item.data.evidence) ? { evidence: item.data.evidence.map(String) } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,9 +4,19 @@ export type CadProgress = {
|
||||
step: string;
|
||||
label: string;
|
||||
status: "running" | "success" | "error" | string;
|
||||
eventId?: string;
|
||||
sequence?: number;
|
||||
timestamp?: string;
|
||||
message?: string;
|
||||
markdown?: string;
|
||||
taskId?: string;
|
||||
nodeId?: string;
|
||||
tool?: string;
|
||||
invocationId?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
evidence?: string[];
|
||||
review?: Record<string, unknown>;
|
||||
lifecycle?: "running" | "completed" | "failed" | string;
|
||||
attempt?: number;
|
||||
maxAttempts?: number;
|
||||
@@ -96,12 +106,20 @@ export type TaskRecord = {
|
||||
requirements_markdown?: string | null;
|
||||
completion_checklist_path?: string;
|
||||
completion_checklist_markdown?: string | null;
|
||||
modeling_plan_path?: string;
|
||||
modeling_plan_review_path?: string;
|
||||
modeling_plan_version?: number;
|
||||
modeling_plan_markdown?: string | null;
|
||||
modeling_plan_review?: Record<string, unknown> | null;
|
||||
agent_state?: {
|
||||
no_progress?: number;
|
||||
cycle_tool_calls?: number;
|
||||
last_diagnostic?: string;
|
||||
last_review?: { candidate_id?: string; path?: string; decision?: string; recorded_at?: string };
|
||||
last_candidate_review?: { verdict?: "accept" | "reject" | string; batch_goal?: string; batch_goal_status?: string; evidence?: string[]; recorded_at?: string };
|
||||
modeling_plan_status?: "missing" | "pending_review" | "revise" | "approved" | "stale" | string;
|
||||
active_plan_step_id?: string;
|
||||
plan_step_status?: Record<string, "pending" | "complete" | string>;
|
||||
completion_ledger?: {
|
||||
verified_revision?: string;
|
||||
items?: Array<{ item?: string; status?: "complete" | "missing" | "uncertain" | string; evidence?: string }>;
|
||||
|
||||
Reference in New Issue
Block a user