This commit is contained in:
2026-08-31 14:16:08 +08:00
parent b1b226593a
commit 7533b298f1
30 changed files with 6976 additions and 253 deletions
+5 -5
View File
@@ -2,7 +2,7 @@
# CDSL_DEFAULT_PROVIDER=deepseek # CDSL_DEFAULT_PROVIDER=deepseek
# CDSL_DEFAULT_MODEL=deepseek-v4-flash # CDSL_DEFAULT_MODEL=deepseek-v4-flash
CDSL_DEFAULT_PROVIDER=openai 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. # DeepSeek. Fill in your own API key below.
CDSL_LLM_BASE_URL=https://api.deepseek.com/v1 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 CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
# Final autonomous-task publication uses this independent vision reviewer. # Final autonomous-task publication uses this independent vision reviewer.
CDSL_REVIEW_PROVIDER=deepseek CDSL_REVIEW_PROVIDER=openai
CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp CDSL_REVIEW_MODEL=gpt-5.5
# Optional OpenAI provider. Comma-separate enabled models; list vision models # Optional OpenAI provider. Comma-separate enabled models; list vision models
# separately so image attachments can be routed safely. # separately so image attachments can be routed safely.
CDSL_OPENAI_BASE_URL=https://api.vip1129.cc/v1 CDSL_OPENAI_BASE_URL=https://api.vip1129.cc/v1
CDSL_OPENAI_API_KEY=sk-6586c229d77de8c421ba98e7eb0d9c6bb10f08ebc796de946ed17cf8d0d7a229 CDSL_OPENAI_API_KEY=sk-6586c229d77de8c421ba98e7eb0d9c6bb10f08ebc796de946ed17cf8d0d7a229
CDSL_OPENAI_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 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. # Supported values are model- and endpoint-dependent: low, medium, high.
# gpt-5.5 defaults to medium, but setting it explicitly keeps requests stable. # gpt-5.5 defaults to medium, but setting it explicitly keeps requests stable.
CDSL_OPENAI_REASONING_EFFORT=medium CDSL_OPENAI_REASONING_EFFORT=medium
+15
View File
@@ -156,10 +156,25 @@ async def read_task(task_id: str) -> JSONResponse:
task["requirements_markdown"] = requirements or None task["requirements_markdown"] = requirements or None
completion_checklist = store.read_completion_checklist(task["task_id"]) completion_checklist = store.read_completion_checklist(task["task_id"])
task["completion_checklist_markdown"] = completion_checklist or None 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"]) task["agent_state"] = store.read_agent_state(task["task_id"])
return JSONResponse(task) 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}") @app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}")
async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse: async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
+213 -11
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
from collections.abc import AsyncIterator from collections.abc import AsyncIterator, Awaitable, Callable
import json import json
import secrets import secrets
from pathlib import Path 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.library import CdslLibrary
from app.services.review_renderer import renderer_status from app.services.review_renderer import renderer_status
from app.services.sse import event 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 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() 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]]: def conversation_user_context(conversation: dict[str, Any]) -> list[dict[str, str]]:
"""Return durable user intent from the whole conversation. """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}") 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( async def stream(
self, self,
messages: list[ChatMessage], messages: list[ChatMessage],
@@ -214,34 +289,67 @@ class AgentService:
if isinstance(attachment, dict) and str(attachment.get("id") or "") if isinstance(attachment, dict) and str(attachment.get("id") or "")
] ]
self.store.start_generation(task_id, request=user_text) 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() 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)}" assistant_id = f"assistant_{secrets.token_hex(8)}"
async def consume() -> None: async def consume() -> None:
assistant_parts: list[dict[str, Any]] = [] nonlocal sequence
terminal = "" terminal = ""
try: try:
async for name, payload in runner.run( async for name, payload in runner.run(
task_id=task_id, request=user_text, conversation_id=conversation["conversation_id"], provider=provider, 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, 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": 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": elif name == "agent_thinking":
visible = str(payload.get("message") or "") visible = str(payload.get("message") or "")
if visible: 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": elif name == "task_terminal":
terminal = str(payload.get("lifecycle") or "") terminal = str(payload.get("lifecycle") or "")
if terminal == "failed": if terminal == "failed":
assistant_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}}) _upsert_data_part(assistant_parts, {"type": "data-cad-error", "id": decorated["eventId"], "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}})
await queue.put((name, payload)) 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: except Exception as error:
self.store.finish_generation(task_id, lifecycle="failed", failure={"schema_version": "cad.autonomous-failure.v1", "stage": "worker", "message": str(error)}) self.store.finish_generation(task_id, lifecycle="failed", failure={"schema_version": "cad.autonomous-failure.v1", "stage": "worker", "message": str(error)})
terminal = "failed" terminal = "failed"
payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)} 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)) await queue.put(("task_terminal", payload))
finally: finally:
if terminal == "completed" and not assistant_parts: 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}") self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cdsl-{task_id}")
while True: 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: if item is None:
break break
name, payload = item name, payload = item
if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed": if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed":
yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}) yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")})
elif name == "agent_thinking": 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: else:
yield event(name, payload) yield event(name, payload)
yield event("done", {}) yield event("done", {})
@@ -278,6 +392,94 @@ class AgentService:
provider: ProviderConfig, provider: ProviderConfig,
model: ProviderModel, model: ProviderModel,
required_tool_name: str | None = None, 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]: ) -> dict[str, Any]:
tool_choice: str | dict[str, Any] = "auto" tool_choice: str | dict[str, Any] = "auto"
if required_tool_name: 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
+329 -22
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from hashlib import sha256 from hashlib import sha256
import json import json
import math
from typing import Any 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: 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 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 list. Tool-call models commonly emit either a direct feature with
feature. That representation is losslessly transformable only when every a local ``sketch`` or a wrapper shaped as ``{sketch, feature}``. Both are
feature supplies exactly one sketch and no root sketch collection exists. 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")): if any(key in fragment for key in ("sketch", "sketches", "add_sketches")):
return 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): if not isinstance(features, list) or not features or not all(isinstance(item, dict) for item in features):
return return
lifted: list[dict[str, Any]] = [] lifted: list[dict[str, Any]] = []
for index, feature in enumerate(features): normalized_features: list[dict[str, Any]] = []
nested = feature.get("sketches", feature.get("sketch")) 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): if isinstance(nested, dict):
sketches = [nested] sketches = [nested]
elif isinstance(nested, list): elif isinstance(nested, list):
sketches = nested sketches = nested
else: else:
return normalized_features.append(feature)
continue
if len(sketches) != 1 or not isinstance(sketches[0], dict): if len(sketches) != 1 or not isinstance(sketches[0], dict):
return return
feature.pop("sketch", None) feature.pop("sketch", None)
feature.pop("sketches", None) feature.pop("sketches", None)
lifted.append(sketches[0]) lifted.append(sketches[0])
normalized_features.append(feature)
fixes.append({"path": f"features[{index}]", "from": "feature-local sketch", "to": "sketches[]", "action": "lifted_equivalent"}) 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]]]: 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) normalized = deepcopy(fragment)
fixes: list[dict[str, str]] = [] fixes: list[dict[str, str]] = []
_lift_feature_local_sketches(normalized, fixes=fixes) _lift_feature_local_sketches(normalized, fixes=fixes)
_lift_param_embedded_sketches(normalized, fixes=fixes)
feature_values: list[tuple[dict[str, Any], str]] = [] feature_values: list[tuple[dict[str, Any], str]] = []
feature = normalized.get("feature") feature = normalized.get("feature")
if isinstance(feature, dict): if isinstance(feature, dict):
@@ -310,6 +519,7 @@ def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, A
location=f"{location}.params", location=f"{location}.params",
fixes=fixes, fixes=fixes,
) )
_normalize_angle_radians(params, location=f"{location}.params", fixes=fixes)
axis = params.get("axis") axis = params.get("axis")
if axis is None: if axis is None:
axis = {} 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")) sketches = normalized.get("sketches", normalized.get("add_sketches"))
if isinstance(sketches, list): if isinstance(sketches, list):
sketch_values.extend((item, f"sketches[{index}]") for index, item in enumerate(sketches) if isinstance(item, dict)) 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: for current_sketch, location in sketch_values:
profile = current_sketch.get("profile") profile = current_sketch.get("profile")
if isinstance(profile, dict) and profile.get("type") == "polygon": if isinstance(profile, dict) and profile.get("type") == "polygon":
_move_equivalent_field(profile, source="points", target="vertices", location=f"{location}.profile", fixes=fixes) _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 return normalized, fixes
@@ -363,6 +604,9 @@ def materialize_autonomous_fragment(
engine: Any, engine: Any,
selector_tokens: dict[str, dict[str, Any]], selector_tokens: dict[str, dict[str, Any]],
max_features: int, max_features: int,
source: str = "legacy_restore",
allow_legacy_aliases: bool = True,
expected_atomic_id: str = "",
) -> tuple[dict[str, Any], dict[str, Any]]: ) -> tuple[dict[str, Any], dict[str, Any]]:
"""Append authored geometry while assigning only server-owned metadata. """Append authored geometry while assigning only server-owned metadata.
@@ -374,7 +618,25 @@ def materialize_autonomous_fragment(
# becoming an import cycle. # becoming an import cycle.
from app.services.engine_service import feature_atomic_contract 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) 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) sketches, features = _fragment_lists(normalized_fragment)
if len(features) > max_features: if len(features) > max_features:
raise AutonomousFragmentError(f"A fragment may add at most {max_features} feature(s)") 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_sketches: list[dict[str, Any]] = []
materialized_features: 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) forbidden = {"id", "depends_on", "sketch_id", "selectors"} & set(source_feature)
if forbidden: if forbidden:
raise AutonomousFragmentError("Feature identity, dependencies, sketch_id and raw selectors are server-owned: " + ", ".join(sorted(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") params = source_feature.get("params")
if not isinstance(params, dict): if not isinstance(params, dict):
raise AutonomousFragmentError("Each fragment feature must contain a params object") 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( raise AutonomousFragmentError(
f"{atomic_id} host_face is server-owned: put exactly one face token in selector_tokens and use " f"{atomic_id} requires params.angle_deg; revolve_axis (including shared_revolve_axis) "
"positions as [{\"mm\":[x_mm,y_mm,z_mm]}], not raw host_face or bare coordinate arrays" "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", []) 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") raise AutonomousFragmentError("selector_tokens must be a unique array of opaque tokens")
if not token_list_is_valid:
tokens = []
selected: list[dict[str, Any]] = [] selected: list[dict[str, Any]] = []
invalid_tokens: list[str] = []
for token in tokens: for token in tokens:
candidate = selector_tokens.get(token) candidate = selector_tokens.get(token)
if candidate is None: 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") raise AutonomousFragmentError("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
selected.append(deepcopy(candidate["selector"])) selected.append(deepcopy(candidate["selector"]))
slot = contract.get("selector_slot") 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: if not slot and tokens and not token_backed_param:
raise AutonomousFragmentError(f"{atomic_id} does not accept selector tokens") raise AutonomousFragmentError(f"{atomic_id} does not accept selector tokens")
if isinstance(slot, dict): if isinstance(slot, dict):
minimum, maximum = int(slot.get("min_items") or 0), int(slot.get("max_items") or 0) minimum, maximum = int(slot.get("min_items") or 0), int(slot.get("max_items") or 0)
if not minimum <= len(selected) <= maximum: if not minimum <= len(selected) <= maximum:
raise AutonomousFragmentError(f"{atomic_id} requires {minimum}..{maximum} selector token(s)") raise AutonomousFragmentError(f"{atomic_id} requires {minimum}..{maximum} selector token(s)")
elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard": elif token_backed_param:
if len(selected) != 1 or str(selected[0].get("kind") or "") != "face": # Hole token validation above deliberately aggregates every
raise AutonomousFragmentError(f"{atomic_id} requires exactly one face selector token for its host face") # actionable error before this materialization boundary.
pass
elif atomic_id.startswith("revolve_"): elif atomic_id.startswith("revolve_"):
axis = params.get("axis") axis = params.get("axis")
if not isinstance(axis, dict) or "origin_mm" not in axis or "direction" not in 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["id"] = feature_id
output["depends_on"] = [last_feature_id] if last_feature_id else [] output["depends_on"] = [last_feature_id] if last_feature_id else []
if contract["requires_sketch"]: 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") 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: if "id" in sketch or "attachment" in sketch or "profile_from" in sketch:
raise AutonomousFragmentError("Sketch identity and topology attachment are server-owned") raise AutonomousFragmentError("Sketch identity and topology attachment are server-owned")
sketch_id = _autonomous_id("sketch", used_sketch_ids) sketch_id = _autonomous_id("sketch", used_sketch_ids)
sketch["id"] = sketch_id sketch["id"] = sketch_id
if atomic_id.startswith("revolve_"):
_validate_revolve_axis_in_sketch_plane(atomic_id, params, sketch)
materialized_sketches.append(sketch) materialized_sketches.append(sketch)
output["sketch_id"] = sketch_id 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 atomic_id.startswith("pattern_") and "source_feature_ids" not in params:
if not last_feature_id: if not last_feature_id:
raise AutonomousFragmentError(f"{atomic_id} needs a committed source feature") raise AutonomousFragmentError(f"{atomic_id} needs a committed source feature")
@@ -467,7 +772,7 @@ def materialize_autonomous_fragment(
output["selectors"] = [] output["selectors"] = []
materialized_features.append(output) materialized_features.append(output)
last_feature_id = feature_id 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") 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}) document = materialize_fragment(document, {"add_sketches": materialized_sketches, "add_features": materialized_features})
return document, { return document, {
@@ -475,6 +780,8 @@ def materialize_autonomous_fragment(
"source_fragment": deepcopy(fragment), "source_fragment": deepcopy(fragment),
"normalized_fragment": deepcopy(normalized_fragment) if compatibility_fixes else None, "normalized_fragment": deepcopy(normalized_fragment) if compatibility_fixes else None,
"compatibility_fixes": compatibility_fixes, "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_sketch_ids": [item["id"] for item in materialized_sketches],
"assigned_feature_ids": [item["id"] for item in materialized_features], "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", [])], "selector_candidate_ids": [token for feature in features for token in feature.get("selector_tokens", [])],
+2 -2
View File
@@ -148,8 +148,8 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
raise ValueError(f"Training-unsafe CDSL field: {key}") raise ValueError(f"Training-unsafe CDSL field: {key}")
features = cdsl.get("features") features = cdsl.get("features")
sketches = cdsl.get("geometry", {}).get("sketches") sketches = cdsl.get("geometry", {}).get("sketches")
if not isinstance(features, list) or not features or not isinstance(sketches, list) or not sketches: if not isinstance(features, list) or not features or not isinstance(sketches, list):
raise ValueError("CDSL requires features and parameterized sketches") raise ValueError("CDSL requires a feature list and a geometry.sketches array")
sketch_ids = {str(sketch.get("id")) for sketch in sketches} sketch_ids = {str(sketch.get("id")) for sketch in sketches}
semantic_contract = _engine_schema(engine) semantic_contract = _engine_schema(engine)
atomic_contracts = semantic_contract["feature_atomic_ids"] atomic_contracts = semantic_contract["feature_atomic_ids"]
+66 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import re import re
import secrets import secrets
from copy import deepcopy
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -192,6 +193,10 @@ class WorkspaceStore:
"lifecycle": "completed", "lifecycle": "completed",
"run_id": "", "run_id": "",
"requirements_path": "", "requirements_path": "",
"completion_checklist_path": "",
"modeling_plan_path": "",
"modeling_plan_review_path": "",
"modeling_plan_version": 0,
"agent_state_path": "", "agent_state_path": "",
"active_candidate_id": "", "active_candidate_id": "",
"active_branch_id": "main", "active_branch_id": "main",
@@ -222,6 +227,9 @@ class WorkspaceStore:
"requirements_path": "", "requirements_path": "",
"source_requirements_path": "", "source_requirements_path": "",
"completion_checklist_path": "", "completion_checklist_path": "",
"modeling_plan_path": "",
"modeling_plan_review_path": "",
"modeling_plan_version": 0,
"agent_state_path": "", "agent_state_path": "",
"active_candidate_id": "", "active_candidate_id": "",
"active_branch_id": "main", "active_branch_id": "main",
@@ -271,6 +279,15 @@ class WorkspaceStore:
write_json(self.task_path(task_id), task) write_json(self.task_path(task_id), task)
return 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: def read_task(self, task_id: str) -> dict[str, Any] | None:
task = read_json(self.task_path(task_id)) task = read_json(self.task_path(task_id))
return self._migrate_task(task, self.task_path(task_id)) if isinstance(task, dict) else None return self._migrate_task(task, self.task_path(task_id)) if isinstance(task, dict) else None
@@ -293,8 +310,8 @@ class WorkspaceStore:
return task return task
def finish_generation(self, task_id: str, *, lifecycle: str, failure: dict[str, Any] | None = None) -> dict[str, Any]: def finish_generation(self, task_id: str, *, lifecycle: str, failure: dict[str, Any] | None = None) -> dict[str, Any]:
if lifecycle not in {"completed", "failed"}: if lifecycle not in {"completed", "failed", "cancelled", "waiting_review", "failed_review_service"}:
raise ValueError("Generation lifecycle must be completed or failed") raise ValueError("Generation lifecycle must be completed, failed, cancelled, waiting_review, or failed_review_service")
task = self.ensure_task(task_id, "") task = self.ensure_task(task_id, "")
failure_path = "" failure_path = ""
if failure: if failure:
@@ -421,6 +438,53 @@ class WorkspaceStore:
path = self.artifact_path(task_id, relative) path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else "" 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]: def new_candidate(self, task_id: str) -> tuple[str, Path]:
task = self.ensure_task(task_id, "") task = self.ensure_task(task_id, "")
candidate_id = f"candidate_{secrets.token_hex(8)}" candidate_id = f"candidate_{secrets.token_hex(8)}"
+249 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import base64 import base64
import copy import copy
import json import json
import re
from pathlib import Path from pathlib import Path
from typing import Any 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): class VisualReviewError(RuntimeError):
pass pass
@@ -75,6 +98,51 @@ class _CandidateReviewFormatError(VisualReviewError):
"""A locally detected invalid reviewer tool result, eligible for one retry.""" """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: def _thinking_tool_choice_rejected(response: httpx.Response) -> bool:
"""Recognize the only compatibility error for which retrying is sound. """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)) 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]: def _image_part(path: Path) -> dict[str, Any]:
encoded = base64.b64encode(path.read_bytes()).decode("ascii") encoded = base64.b64encode(path.read_bytes()).decode("ascii")
media = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png" 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, batch_goal: str,
deterministic_report: dict[str, Any], deterministic_report: dict[str, Any],
node_id: str, 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]: ) -> dict[str, Any]:
"""Review a staged batch against its local goal and the frozen checklist.""" """Review a staged batch against its local goal and the frozen checklist."""
provider, model = settings.resolve_review_model() provider, model = settings.resolve_review_model()
@@ -237,6 +312,9 @@ async def review_candidate_batch(
"frozen_requirements": requirements, "frozen_requirements": requirements,
"completion_checklist": checklist, "completion_checklist": checklist,
"batch_goal": batch_goal, "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, "deterministic_report": deterministic_report,
"instruction": ( "instruction": (
"Assess this staged batch, not overall task completion. Accept only if the batch goal is achieved " "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 " "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 " "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 " "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": { "render_manifest": {
"renderer": manifest.get("renderer"), "renderer": manifest.get("renderer"),
@@ -341,3 +421,171 @@ async def review_candidate_batch(
"batch_goal": batch_goal, "batch_goal": batch_goal,
**result, **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}
+4
View File
@@ -65,6 +65,8 @@ class Settings:
agent_max_features_per_fragment: int = 6 agent_max_features_per_fragment: int = 6
agent_context_char_limit: int = 14000 agent_context_char_limit: int = 14000
agent_render_cache: bool = True agent_render_cache: bool = True
modeling_plan_enabled: bool = True
modeling_plan_max_revisions: int = 2
autonomous_generation: bool = True autonomous_generation: bool = True
resume_running_tasks_on_startup: 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_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_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))),
agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True), 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, autonomous_generation=True,
# Production instances recover durable runs by default. Test workers # Production instances recover durable runs by default. Test workers
# can disable this before startup to guarantee they touch only tasks # can disable this before startup to guarantee they touch only tasks
+1 -1
View File
@@ -13,7 +13,7 @@
"geometry": { "geometry": {
"type": "object", "type": "object",
"properties": { "properties": {
"sketches": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/sketch"}} "sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}}
}, },
"required": ["sketches"], "required": ["sketches"],
"additionalProperties": false "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_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_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}, "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}}, "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}}, "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}, "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},
+4 -3
View File
@@ -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. # macro profiles and therefore never invoke this adapter.
cdsl = lower_legacy_profiles(cdsl) cdsl = lower_legacy_profiles(cdsl)
sketches = cdsl.get("geometry", {}).get("sketches", []) sketches = cdsl.get("geometry", {}).get("sketches", [])
all_drawable = bool(sketches) and all( # Sketchless parameterized features (for example sphere_add) are fully
_sketch_is_cdsl_drawable(s) for s in sketches # 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 cdsl_only_error: Exception | None = None
if all_drawable and not force_exact: if all_drawable and not force_exact:
+18
View File
@@ -373,6 +373,23 @@ def _revolve_axis(node: FeaturePlanNode, session: ExecutionSession) -> AxisSpec:
return resolution.record.value 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: 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: else:
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。 # 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
axis = _revolve_axis(node, session) axis = _revolve_axis(node, session)
_validate_revolve_axis_in_sketch_plane(axis, selected_sketch)
angle = float(node.params.get("angle_deg") or 0.0) angle = float(node.params.get("angle_deg") or 0.0)
if angle <= 0: if angle <= 0:
raise ValueError("revolve requires angle_deg > 0") raise ValueError("revolve requires angle_deg > 0")
File diff suppressed because it is too large Load Diff
+225
View File
@@ -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"]
+1 -12
View File
@@ -22,24 +22,13 @@ class SphereAddTests(unittest.TestCase):
"kind": "part", "kind": "part",
"part_id": "sphere_test", "part_id": "sphere_test",
"meta": {"unit": "mm"}, "meta": {"unit": "mm"},
"geometry": { "geometry": {"sketches": []},
"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},
}],
},
"features": [{ "features": [{
"id": "sphere", "id": "sphere",
"atomic_id": "sphere_add", "atomic_id": "sphere_add",
"depends_on": [], "depends_on": [],
"name": "Test sphere", "name": "Test sphere",
"params": {"radius_mm": 2.5, "center_mm": [3.0, -4.0, 5.0]}, "params": {"radius_mm": 2.5, "center_mm": [3.0, -4.0, 5.0]},
"sketch_id": "sphere_locator",
}], }],
} }
+94 -1
View File
@@ -15,7 +15,7 @@ import httpx
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend")) 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 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): 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]: def _review(self, root: Path, client: _Client, *, source_requirements: str = "") -> dict[str, object]:
image = root / "iso.png" image = root / "iso.png"
image.write_bytes(b"png") image.write_bytes(b"png")
+1644 -2
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -16,6 +16,7 @@
"@radix-ui/react-collapsible": "^1.1.20", "@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-slider": "^1.4.7", "@radix-ui/react-slider": "^1.4.7",
"@types/react-syntax-highlighter": "^15.5.13",
"ai": "7.0.37", "ai": "7.0.37",
"animejs": "^4.5.0", "animejs": "^4.5.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -23,6 +24,10 @@
"next": "16.2.6", "next": "16.2.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "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", "tailwind-merge": "^3.6.0",
"three": "0.160.0", "three": "0.160.0",
"three-mesh-bvh": "^0.8.0" "three-mesh-bvh": "^0.8.0"
+22 -5
View File
@@ -56,14 +56,31 @@ export async function POST(request: NextRequest) {
} }
const stream = createUIMessageStream({ const stream = createUIMessageStream({
execute: async ({ writer }) => { execute: async ({ writer }) => {
const textId = `assistant_${Date.now()}`; const messageId = `assistant_${Date.now()}`;
writer.write({ type: "start", messageId: textId }); writer.write({ type: "start", messageId });
writer.write({ type: "text-start", id: textId }); let textPartIndex = 0;
let textId: string | null = null;
let sequence = 0;
for await (const item of parseSse(upstream)) { 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); 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" }); 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 }); if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json()); 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());
}
+23 -11
View File
@@ -395,17 +395,6 @@ button:disabled {
.studio-main { display: flex; min-height: 0; flex: 1; } .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); } .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); } .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, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; }
.agent-thread-shell { position: relative; } .agent-thread-shell { position: relative; }
.spin { animation: ui-spin 900ms linear infinite; } .spin { animation: ui-spin 900ms linear infinite; }
@@ -421,6 +410,7 @@ button:disabled {
.assistant-row .message-role svg { color: var(--ui-accent); } .assistant-row .message-role svg { color: var(--ui-accent); }
.message-content { min-width: 0; color: var(--ui-text); } .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 { 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 { 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; } .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; } .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 { 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-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-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-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-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 { 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; } .cad-result-title { margin-left: 21px; color: var(--ui-accent-text); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; }
+28 -23
View File
@@ -165,6 +165,30 @@ export function AgentStudio() {
if (error.stage === "generation") setTaskRunning(false); 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 handleUpload = useCallback(async (files: FileList | null) => {
const selectedFiles = Array.from(files || []); const selectedFiles = Array.from(files || []);
if (!selectedFiles.length) return; if (!selectedFiles.length) return;
@@ -247,6 +271,7 @@ export function AgentStudio() {
uploading={uploading} uploading={uploading}
uploadError={uploadError} uploadError={uploadError}
onUpload={handleUpload} onUpload={handleUpload}
onCancel={handleCancel}
theme={theme} theme={theme}
onToggleTheme={toggleTheme} onToggleTheme={toggleTheme}
providerId={providerId} providerId={providerId}
@@ -257,7 +282,6 @@ export function AgentStudio() {
onCadError={handleError} onCadError={handleError}
onSelectionChange={setViewerSelection} onSelectionChange={setViewerSelection}
taskRunning={taskRunning} taskRunning={taskRunning}
taskRecord={taskRecord}
/> />
</AgentRuntime> </AgentRuntime>
); );
@@ -478,6 +502,7 @@ function StudioShell({
uploading, uploading,
uploadError, uploadError,
onUpload, onUpload,
onCancel,
theme, theme,
onToggleTheme, onToggleTheme,
providerId, providerId,
@@ -488,7 +513,6 @@ function StudioShell({
onCadError, onCadError,
onSelectionChange, onSelectionChange,
taskRunning, taskRunning,
taskRecord,
}: { }: {
config: BackendConfig | null; config: BackendConfig | null;
cadResult: CadResult | null; cadResult: CadResult | null;
@@ -497,6 +521,7 @@ function StudioShell({
uploading: boolean; uploading: boolean;
uploadError: string; uploadError: string;
onUpload: (files: FileList | null) => void; onUpload: (files: FileList | null) => void;
onCancel: () => void;
theme: "light" | "dark"; theme: "light" | "dark";
onToggleTheme: () => void; onToggleTheme: () => void;
providerId: string; providerId: string;
@@ -507,7 +532,6 @@ function StudioShell({
onCadError: (error: CadError) => void; onCadError: (error: CadError) => void;
onSelectionChange: (selection: ViewerSelectionContext | null) => void; onSelectionChange: (selection: ViewerSelectionContext | null) => void;
taskRunning: boolean; taskRunning: boolean;
taskRecord: TaskRecord | null;
}) { }) {
const running = useAuiState((state) => state.thread.isRunning) || taskRunning; const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
const provider = config?.providers.find((item) => item.id === providerId); 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?.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} {config?.autonomous_generation && !config.review_configured ? <div className="config-warning"><AlertCircle size={16} /><span>最终视觉复核未配置,任务在最终发布前会停止:{config.review_error || "请配置独立视觉模型。"}</span></div> : null}
<div className="studio-main"> <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"> <section className="preview-pane">
<GenerationStatus task={taskRecord} />
<CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onError={handleViewerError} onSelectionChange={onSelectionChange} /> <CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onError={handleViewerError} onSelectionChange={onSelectionChange} />
</section> </section>
</div> </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() { function StudioLoading() {
return ( return (
<main className="boot-screen"> <main className="boot-screen">
+13 -6
View File
@@ -2,16 +2,17 @@
import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Upload } from "lucide-react"; 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 { 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 type { CadAttachment } from "@/lib/cad-types";
import { CadErrorPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts"; 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[]; attachments: CadAttachment[];
uploading: boolean; uploading: boolean;
uploadError: string; uploadError: string;
taskRunning?: boolean; taskRunning?: boolean;
onUpload: (files: FileList | null) => void; onUpload: (files: FileList | null) => void;
onCancel?: () => void;
}) { }) {
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const dragDepth = useRef(0); const dragDepth = useRef(0);
@@ -65,7 +66,7 @@ export function AgentThread({ attachments, uploading, uploadError, taskRunning =
</ThreadPrimitive.Empty> </ThreadPrimitive.Empty>
<div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div> <div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div>
</ThreadPrimitive.Viewport> </ThreadPrimitive.Viewport>
<Composer fileInput={fileInput} uploading={uploading} taskRunning={taskRunning} onUpload={onUpload} /> <Composer fileInput={fileInput} uploading={uploading} taskRunning={taskRunning} onUpload={onUpload} onCancel={onCancel} />
</ThreadPrimitive.Root> </ThreadPrimitive.Root>
{isDraggingFiles ? <div className="file-drop-overlay" role="status" aria-live="polite"><Upload size={24} /><span>拖放文件上传</span></div> : null} {isDraggingFiles ? <div className="file-drop-overlay" role="status" aria-live="polite"><Upload size={24} /><span>拖放文件上传</span></div> : null}
</div> </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 }) { 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 running = useAuiState((state) => state.thread.isRunning) || taskRunning; 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>) => { const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.currentTarget.files?.length) onUpload(event.currentTarget.files); if (event.currentTarget.files?.length) onUpload(event.currentTarget.files);
event.currentTarget.value = ""; 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> <span><Check size={14} aria-hidden="true" /> Enter 发送,Shift + Enter 换行</span>
<div className="composer-actions"> <div className="composer-actions">
{running ? ( {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> <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>
+20 -4
View File
@@ -1,32 +1,48 @@
"use client"; "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 { encodeArtifactUrl } from "@/lib/cad-artifacts";
import type { CadError, CadProgress, CadResult } from "@/lib/cad-types"; import type { CadError, CadProgress, CadResult } from "@/lib/cad-types";
import { JsonTree, MarkdownDocument } from "./rich-content";
export function TextPart({ text }: { text: string }) { export function TextPart({ text }: { text: string }) {
if (!text.trim()) return null; 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 }) { export function CadProgressPart({ data }: { data: CadProgress }) {
if (data.step === "agent_stream") return null;
const status = String(data.status || "").toLowerCase(); const status = String(data.status || "").toLowerCase();
const isRunning = status === "running"; const isRunning = status === "running";
const isError = status === "error"; const isError = status === "error";
const statusLabel = isRunning ? "进行中" : isError ? "失败" : status === "success" ? "完成" : data.status; 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 ( return (
<div className={`cad-message cad-progress${isError ? " is-error" : ""}`} role="status" aria-live="polite"> <div className={`cad-message cad-progress${isError ? " is-error" : ""}`} role="status" aria-live="polite">
<div className="cad-message-heading"> <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> <span>{data.label || data.step}</span>
{data.tool ? <code className="cad-tool-name">{data.tool}</code> : null}
<span className="cad-status-label">{statusLabel}</span> <span className="cad-status-label">{statusLabel}</span>
</div> </div>
{data.message ? <div className="cad-message-copy">{data.message}</div> : null} {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> </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 }) { export function CadResultPart({ data }: { data: CadResult }) {
const downloads: Array<[string, string]> = data.checkpoint ? [] : [ const downloads: Array<[string, string]> = data.checkpoint ? [] : [
["STEP", data.stepPath], ["STEP", data.stepPath],
+57
View File
@@ -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");
}
+50
View File
@@ -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", () => { test("restores the latest successful task revision for the viewer", () => {
const result = latestSuccessfulResult({ const result = latestSuccessfulResult({
task_id: "cad_abc", task_id: "cad_abc",
+30 -10
View File
@@ -8,38 +8,58 @@ export type BackendSseEvent = {
export function backendEventToUiChunk( export function backendEventToUiChunk(
item: BackendSseEvent, item: BackendSseEvent,
textId: string, textId: string,
): UIMessageChunk | null { sequence = 0,
): (UIMessageChunk & { id?: string }) | null {
if (item.event === "text_delta") { if (item.event === "text_delta") {
return { type: "text-delta", id: textId, delta: String(item.data.text || "") }; return { type: "text-delta", id: textId, delta: String(item.data.text || "") };
} }
if (item.event === "progress") { if (item.event === "progress") {
return { return {
type: "data-cad-progress", type: "data-cad-progress",
id: `progress_${String(item.data.step || Date.now())}`, id: `progress_${String(item.data.taskId || "task")}_${sequence}`,
data: item.data, 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" const review = item.data.review && typeof item.data.review === "object"
? item.data.review as Record<string, unknown> ? item.data.review as Record<string, unknown>
: null; : null;
const status = item.event === "task_terminal" const status = item.event === "task_terminal"
? (String(item.data.lifecycle || "") === "failed" ? "error" : "success") ? (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) || (item.event === "final_review" && String(review?.verdict || "") === "repair" && Number(review?.confidence || 0) >= 0.85)
? "error" ? "error"
: String(item.data.status || "running"); : 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 { return {
type: "data-cad-progress", 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: ({ 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: "生成任务", 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, message: String( } as Record<string, string>)[item.event], status, ...metadata, message: String(
item.data.message || item.data.reason || (review?.evidence instanceof Array ? review.evidence.join(";") : ""), 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.nodeId ? { nodeId: String(item.data.nodeId) } : {}),
...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}), ...(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) } : {}),
}, },
}; };
} }
+18
View File
@@ -4,9 +4,19 @@ export type CadProgress = {
step: string; step: string;
label: string; label: string;
status: "running" | "success" | "error" | string; status: "running" | "success" | "error" | string;
eventId?: string;
sequence?: number;
timestamp?: string;
message?: string; message?: string;
markdown?: string;
taskId?: string; taskId?: string;
nodeId?: string; nodeId?: string;
tool?: string;
invocationId?: string;
arguments?: Record<string, unknown>;
result?: unknown;
evidence?: string[];
review?: Record<string, unknown>;
lifecycle?: "running" | "completed" | "failed" | string; lifecycle?: "running" | "completed" | "failed" | string;
attempt?: number; attempt?: number;
maxAttempts?: number; maxAttempts?: number;
@@ -96,12 +106,20 @@ export type TaskRecord = {
requirements_markdown?: string | null; requirements_markdown?: string | null;
completion_checklist_path?: string; completion_checklist_path?: string;
completion_checklist_markdown?: string | null; 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?: { agent_state?: {
no_progress?: number; no_progress?: number;
cycle_tool_calls?: number; cycle_tool_calls?: number;
last_diagnostic?: string; last_diagnostic?: string;
last_review?: { candidate_id?: string; path?: string; decision?: string; recorded_at?: 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 }; 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?: { completion_ledger?: {
verified_revision?: string; verified_revision?: string;
items?: Array<{ item?: string; status?: "complete" | "missing" | "uncertain" | string; evidence?: string }>; items?: Array<{ item?: string; status?: "complete" | "missing" | "uncertain" | string; evidence?: string }>;