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_MODEL=deepseek-v4-flash
CDSL_DEFAULT_PROVIDER=openai
CDSL_DEFAULT_MODEL=gpt-5.5
CDSL_DEFAULT_MODEL=gpt-5.4-mini
# DeepSeek. Fill in your own API key below.
CDSL_LLM_BASE_URL=https://api.deepseek.com/v1
@@ -12,15 +12,15 @@ CDSL_LLM_TIMEOUT_S=90
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
# Final autonomous-task publication uses this independent vision reviewer.
CDSL_REVIEW_PROVIDER=deepseek
CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp
CDSL_REVIEW_PROVIDER=openai
CDSL_REVIEW_MODEL=gpt-5.5
# Optional OpenAI provider. Comma-separate enabled models; list vision models
# separately so image attachments can be routed safely.
CDSL_OPENAI_BASE_URL=https://api.vip1129.cc/v1
CDSL_OPENAI_API_KEY=sk-6586c229d77de8c421ba98e7eb0d9c6bb10f08ebc796de946ed17cf8d0d7a229
CDSL_OPENAI_MODELS=gpt-5.5,gpt-5.6-luna
CDSL_OPENAI_VISION_MODELS=gpt-5.5,gpt-5.6-luna
CDSL_OPENAI_MODELS=gpt-5.5,gpt-5.6-luna,gpt-5.4-mini
CDSL_OPENAI_VISION_MODELS=gpt-5.5,gpt-5.6-luna,gpt-5.4-mini
# Supported values are model- and endpoint-dependent: low, medium, high.
# gpt-5.5 defaults to medium, but setting it explicitly keeps requests stable.
CDSL_OPENAI_REASONING_EFFORT=medium
+15
View File
@@ -156,10 +156,25 @@ async def read_task(task_id: str) -> JSONResponse:
task["requirements_markdown"] = requirements or None
completion_checklist = store.read_completion_checklist(task["task_id"])
task["completion_checklist_markdown"] = completion_checklist or None
modeling_plan = store.read_modeling_plan(task["task_id"])
task["modeling_plan_markdown"] = modeling_plan or None
task["modeling_plan_review"] = store.read_modeling_plan_review(task["task_id"])
task["agent_state"] = store.read_agent_state(task["task_id"])
return JSONResponse(task)
@app.delete("/v1/tasks/{task_id}")
async def cancel_task(task_id: str) -> JSONResponse:
try:
safe_id = safe_task_id(task_id)
task = await agent.cancel(safe_id)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return JSONResponse(task)
@app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}")
async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
from fastapi.responses import FileResponse
+213 -11
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
import base64
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Awaitable, Callable
import json
import secrets
from pathlib import Path
@@ -17,7 +17,7 @@ from app.services.autonomous_cdsl_generation import AutonomousCdslGenerationRunn
from app.services.library import CdslLibrary
from app.services.review_renderer import renderer_status
from app.services.sse import event
from app.services.storage import WorkspaceStore
from app.services.storage import WorkspaceStore, now_iso
from app.settings import ProviderConfig, ProviderModel, Settings
@@ -25,6 +25,68 @@ def text_from_message(message: ChatMessage) -> str:
return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip()
def _response_language(text: str) -> str:
cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff")
latin = sum(1 for char in text if char.isascii() and char.isalpha())
return "Chinese" if cjk >= 2 and cjk >= latin * 0.15 else "English"
_EVENT_LABELS = {
"requirements_document": "冻结需求", "completion_checklist": "完成清单", "completion_audit": "完成审计",
"modeling_plan": "建模计划", "modeling_plan_review": "计划独立复核",
"tool_call": "建模工具", "candidate_result": "候选构建", "candidate_review": "候选独立复核",
"geometry_diagnostic": "几何诊断", "geometry_conclusion": "几何结论", "step_review": "步骤审查",
"checkpoint": "构建检查点", "rollback": "回滚检查点", "final_review": "最终视觉复核", "task_terminal": "生成任务",
}
def _event_status(name: str, payload: dict[str, Any]) -> str:
if name == "task_terminal":
lifecycle = str(payload.get("lifecycle") or "")
if lifecycle == "failed":
return "error"
if lifecycle == "cancelled":
return "cancelled"
return "success"
review = payload.get("review") if isinstance(payload.get("review"), dict) else {}
if (name == "modeling_plan_review" and str(review.get("verdict") or "") == "revise") or (
name == "candidate_review" and str(review.get("verdict") or "") == "reject"
) or (
name == "final_review" and str(review.get("verdict") or "") == "repair"
):
return "error"
return str(payload.get("status") or "running")
def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
data.update({"step": name, "label": _EVENT_LABELS.get(name, name), "status": _event_status(name, payload)})
return data
def _append_text_part(parts: list[dict[str, Any]], text: str) -> None:
if not text:
return
if parts and parts[-1].get("type") == "text":
parts[-1]["text"] = str(parts[-1].get("text") or "") + text
else:
parts.append({"type": "text", "text": text})
def _upsert_data_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None:
part_id = str(part.get("id") or "")
if part_id:
for index, existing in enumerate(parts):
if str(existing.get("id") or "") == part_id:
parts[index] = part
return
parts.append(part)
class _StreamingUnsupported(RuntimeError):
pass
def conversation_user_context(conversation: dict[str, Any]) -> list[dict[str, str]]:
"""Return durable user intent from the whole conversation.
@@ -131,6 +193,19 @@ class AgentService:
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"resume-autonomous-cdsl-{task_id}")
async def cancel(self, task_id: str) -> dict[str, Any] | None:
task = self.store.read_task(task_id)
if task is None:
return None
running = self._autonomous_runs.pop(task_id, None)
if running and not running.done():
running.cancel()
if str(task.get("lifecycle") or "") == "running":
task = self.store.finish_generation(task_id, lifecycle="cancelled", failure={
"schema_version": "cad.autonomous-cancelled.v1", "stage": "cancelled", "message": "CAD generation was cancelled by the user.",
})
return task
async def stream(
self,
messages: list[ChatMessage],
@@ -214,34 +289,67 @@ class AgentService:
if isinstance(attachment, dict) and str(attachment.get("id") or "")
]
self.store.start_generation(task_id, request=user_text)
yield event("progress", {
"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running",
"message": "CAD 任务已启动。" if _response_language(user_text) == "Chinese" else "CAD task started.",
})
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete)
assistant_parts: list[dict[str, Any]] = []
sequence = 0
async def on_text_delta(text: str) -> None:
# Text is persisted and streamed through the same ordered queue as
# tool events, so a refresh produces the exact same transcript.
nonlocal sequence
sequence += 1
_append_text_part(assistant_parts, text)
await queue.put(("text_delta", {
"text": text, "taskId": task_id, "eventId": f"{task_id}_{sequence}_text_delta",
"sequence": sequence, "timestamp": now_iso(),
}))
runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete, on_text_delta=on_text_delta)
assistant_id = f"assistant_{secrets.token_hex(8)}"
async def consume() -> None:
assistant_parts: list[dict[str, Any]] = []
nonlocal sequence
terminal = ""
try:
async for name, payload in runner.run(
task_id=task_id, request=user_text, conversation_id=conversation["conversation_id"], provider=provider,
model=model, initial_messages=initial_messages, frozen_attachment_ids=frozen_attachment_ids, already_started=True,
):
sequence += 1
decorated = dict(payload)
decorated.setdefault("taskId", task_id)
decorated.setdefault("eventId", f"{task_id}_{sequence}_{name}")
decorated.update({
"sequence": sequence,
"timestamp": now_iso(),
})
if name == "cad_result":
assistant_parts.append({"type": "data-cad-result", "data": payload})
_upsert_data_part(assistant_parts, {"type": "data-cad-result", "id": decorated["eventId"], "data": decorated})
elif name == "agent_thinking":
visible = str(payload.get("message") or "")
if visible:
assistant_parts.append({"type": "text", "text": visible})
# Streaming completions already delivered this
# content through on_text_delta; only persist the
# full response for the compatibility path.
if not assistant_parts or assistant_parts[-1].get("type") != "text" or visible not in str(assistant_parts[-1].get("text") or ""):
_append_text_part(assistant_parts, visible + "\n\n")
elif name == "task_terminal":
terminal = str(payload.get("lifecycle") or "")
if terminal == "failed":
assistant_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}})
await queue.put((name, payload))
_upsert_data_part(assistant_parts, {"type": "data-cad-error", "id": decorated["eventId"], "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}})
elif name != "text_delta":
_upsert_data_part(assistant_parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)})
await queue.put((name, decorated))
except Exception as error:
self.store.finish_generation(task_id, lifecycle="failed", failure={"schema_version": "cad.autonomous-failure.v1", "stage": "worker", "message": str(error)})
terminal = "failed"
payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)}
assistant_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}})
error_id = f"{task_id}_{sequence + 1}_cad_error"
assistant_parts.append({"type": "data-cad-error", "id": error_id, "data": {"stage": "generation", "message": str(error)}})
await queue.put(("task_terminal", payload))
finally:
if terminal == "completed" and not assistant_parts:
@@ -252,14 +360,20 @@ class AgentService:
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cdsl-{task_id}")
while True:
item = await queue.get()
try:
item = await asyncio.wait_for(queue.get(), timeout=15)
except asyncio.TimeoutError:
yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()})
continue
if item is None:
break
name, payload = item
if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed":
yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")})
elif name == "agent_thinking":
yield event("text_delta", {"text": str(payload.get("message") or "")})
message = str(payload.get("message") or "")
if message:
yield event("text_delta", {"text": message + "\n\n"})
else:
yield event(name, payload)
yield event("done", {})
@@ -278,6 +392,94 @@ class AgentService:
provider: ProviderConfig,
model: ProviderModel,
required_tool_name: str | None = None,
*,
on_text_delta: Callable[[str], Awaitable[None]] | None = None,
) -> dict[str, Any]:
if on_text_delta is None:
return await self._complete_once(messages, tools, provider, model, required_tool_name)
try:
return await self._complete_stream(messages, tools, provider, model, required_tool_name, on_text_delta)
except _StreamingUnsupported:
await on_text_delta("当前模型不支持工具流式,已切换为兼容模式。\n\n")
response = await self._complete_once(messages, tools, provider, model, required_tool_name)
content = str((((response.get("choices") or [{}])[0] or {}).get("message") or {}).get("content") or "")
if content:
await on_text_delta(content + "\n\n")
# Prevent the runner from emitting this same full response again.
choice = ((response.get("choices") or [{}])[0] or {}).get("message") or {}
return {"choices": [{"message": {**choice, "content": ""}}]}
async def _complete_stream(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
provider: ProviderConfig,
model: ProviderModel,
required_tool_name: str | None,
on_text_delta: Callable[[str], Awaitable[None]],
) -> dict[str, Any]:
tool_choice: str | dict[str, Any] = "auto"
if required_tool_name:
tool_choice = {"type": "function", "function": {"name": required_tool_name}}
payload: dict[str, Any] = {
"model": model.id, "messages": messages, "tools": tools,
"tool_choice": tool_choice, "temperature": 0.1, "stream": True,
}
payload.update(provider.chat_completion_options)
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
try:
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client:
async with client.stream("POST", f"{provider.base_url}/chat/completions", headers=headers, json=payload) as response:
if response.status_code in {400, 404, 405, 415, 422}:
detail = (await response.aread()).decode(errors="replace")[:400]
raise _StreamingUnsupported(detail)
if response.status_code >= 400:
raise RuntimeError(f"LLM request failed ({response.status_code}): {(await response.aread()).decode(errors='replace')[:800]}")
if "text/event-stream" not in str(response.headers.get("content-type") or ""):
raise _StreamingUnsupported("provider returned a non-stream response")
tool_calls: dict[int, dict[str, Any]] = {}
async for line in response.aiter_lines():
if not line.startswith("data:"):
continue
raw = line[5:].strip()
if raw == "[DONE]":
break
try:
chunk = json.loads(raw)
except json.JSONDecodeError:
continue
choices = chunk.get("choices") if isinstance(chunk, dict) else None
delta = choices[0].get("delta") if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
if not isinstance(delta, dict):
continue
text = str(delta.get("content") or delta.get("reasoning_content") or "")
if text:
await on_text_delta(text)
for call in delta.get("tool_calls") or []:
if not isinstance(call, dict):
continue
index = int(call.get("index") or 0)
current = tool_calls.setdefault(index, {"id": str(call.get("id") or f"call_{index}"), "type": "function", "function": {"name": "", "arguments": ""}})
if call.get("id"):
current["id"] = str(call["id"])
function = call.get("function") if isinstance(call.get("function"), dict) else {}
if function.get("name"):
current["function"]["name"] += str(function["name"])
if function.get("arguments"):
current["function"]["arguments"] += str(function["arguments"])
except _StreamingUnsupported:
raise
except (httpx.HTTPError, asyncio.TimeoutError) as error:
raise RuntimeError(f"LLM streaming connection failed: {error}") from error
return {"choices": [{"message": {"content": "", "tool_calls": [tool_calls[key] for key in sorted(tool_calls)]}}]}
async def _complete_once(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
provider: ProviderConfig,
model: ProviderModel,
required_tool_name: str | None = None,
) -> dict[str, Any]:
tool_choice: str | dict[str, Any] = "auto"
if required_tool_name:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,403 @@
from __future__ import annotations
import hashlib
import json
from copy import deepcopy
from typing import Any
from jsonschema import Draft202012Validator
from app.services.engine_service import feature_atomic_contract
class CanonicalFragmentError(ValueError):
def __init__(self, message: str, *, path: str = "fragment", code: str = "CDSL_SCHEMA_INVALID") -> None:
super().__init__(message)
self.path = path
self.code = code
def _point(size: int) -> dict[str, Any]:
return {
"type": "array",
"items": {"type": "number"},
"minItems": size,
"maxItems": size,
}
def _workplane_schema() -> dict[str, Any]:
point3 = _point(3)
return {
"type": "object",
"properties": {
"origin_mm": deepcopy(point3),
"x_dir": deepcopy(point3),
"normal": deepcopy(point3),
},
"required": ["origin_mm", "x_dir", "normal"],
"additionalProperties": False,
}
def _analytic_segment_schema() -> dict[str, Any]:
point2 = _point(2)
return {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "line"},
"start": deepcopy(point2),
"end": deepcopy(point2),
},
"required": ["type", "start", "end"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"type": {"const": "arc"},
"start": deepcopy(point2),
"end": deepcopy(point2),
"center": deepcopy(point2),
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
"clockwise": {"type": "boolean"},
},
"required": ["type", "start", "end", "center", "radius_mm"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"center": deepcopy(point2),
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
},
"required": ["type", "center", "radius_mm"],
"additionalProperties": False,
},
]
}
def canonical_profile_schema() -> dict[str, Any]:
point2 = _point(2)
segment = _analytic_segment_schema()
contour = {
"type": "object",
"properties": {
"role": {"enum": ["outer", "inner"]},
"closed": {"const": True},
"segments": {"type": "array", "minItems": 1, "items": segment},
},
"required": ["role", "closed", "segments"],
"additionalProperties": False,
}
return {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"center": deepcopy(point2),
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
},
"required": ["type", "radius_mm"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"type": {"const": "polygon"},
"vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)},
},
"required": ["type", "vertices"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"type": {"const": "analytic_contours"},
"contours": {"type": "array", "minItems": 1, "maxItems": 2, "items": contour},
},
"required": ["type", "contours"],
"additionalProperties": False,
},
]
}
def _end_condition_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {
"type": {"type": "string", "minLength": 1},
"solidworks_code": {"type": "integer"},
},
"required": ["type", "solidworks_code"],
"additionalProperties": False,
}
def _axis_schema() -> dict[str, Any]:
point3 = _point(3)
return {
"type": "object",
"properties": {"origin_mm": deepcopy(point3), "direction": deepcopy(point3)},
"required": ["origin_mm", "direction"],
"additionalProperties": False,
}
def _hole_positions_schema() -> dict[str, Any]:
return {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {"mm": _point(3)},
"required": ["mm"],
"additionalProperties": False,
},
}
def _parameter_schema(atomic_id: str, contract: dict[str, Any]) -> dict[str, Any]:
positive = {"type": "number", "exclusiveMinimum": 0}
number = {"type": "number"}
integer = {"type": "integer", "minimum": 1}
point3 = _point(3)
properties: dict[str, Any] = {}
required = [name for name in contract["required_params"] if name not in {"host_face", "source_feature_ids", "mirror_plane"}]
if atomic_id.startswith("extrude_"):
properties = {
"distance_mm": deepcopy(number),
"reverse_distance_mm": deepcopy(number),
"reverse": {"type": "boolean"},
"end_condition": _end_condition_schema(),
"reverse_end_condition": _end_condition_schema(),
}
elif atomic_id.startswith("revolve_"):
properties = {
"angle_deg": {"type": "number", "exclusiveMinimum": 0, "maximum": 360},
"axis": _axis_schema(),
"reverse": {"type": "boolean"},
}
elif atomic_id.startswith("hole_"):
properties = {
"diameter_mm": deepcopy(positive),
"depth_mm": deepcopy(positive),
"positions": _hole_positions_schema(),
"drill_angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793},
"countersink_diameter_mm": deepcopy(positive),
"countersink_angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793},
"counterbore_diameter_mm": deepcopy(positive),
"counterbore_depth_mm": deepcopy(positive),
}
elif atomic_id == "sphere_add":
properties = {"radius_mm": deepcopy(positive), "center_mm": deepcopy(point3)}
elif atomic_id == "fillet":
properties = {"radius_mm": deepcopy(positive), "tangent_propagation": {"type": "boolean"}}
elif atomic_id == "chamfer":
properties = {
"distance_mm": deepcopy(positive),
"distance_2_mm": deepcopy(positive),
"angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793},
}
elif atomic_id == "pattern_linear":
properties = {
"direction_1": deepcopy(point3),
"spacing_1_mm": deepcopy(positive),
"pattern_count_1": deepcopy(integer),
"direction_2": deepcopy(point3),
"spacing_2_mm": deepcopy(positive),
"pattern_count_2": deepcopy(integer),
}
elif atomic_id == "pattern_mirror":
properties = {}
elif atomic_id == "reference_axis":
properties = {"axis": _axis_schema()}
elif atomic_id == "reference_plane":
properties = {"plane": _workplane_schema()}
elif atomic_id == "hole_wizard":
required = ["hole_type", "diameter_mm", "depth_mm"]
properties = {
"hole_type": {"type": "string", "minLength": 1},
"diameter_mm": deepcopy(positive),
"depth_mm": deepcopy(positive),
"positions": _hole_positions_schema(),
"thread": {"type": "object"},
"countersink": {"type": "object"},
"counterbore": {"type": "object"},
}
else:
for name in [*contract["required_params"], *contract["optional_params"]]:
if name not in {"host_face", "source_feature_ids", "mirror_plane"}:
properties[name] = {}
allowed = set(required) | {
name for name in contract["optional_params"]
if name not in {"host_face", "source_feature_ids", "mirror_plane"}
}
properties = {name: value for name, value in properties.items() if name in allowed}
return {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
}
def _selector_limits(atomic_id: str, contract: dict[str, Any]) -> tuple[int, int] | None:
if atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
return 1, 1
slot = contract.get("selector_slot")
if not isinstance(slot, dict):
return None
return int(slot.get("min_items") or 1), int(slot.get("max_items") or 1)
def build_operation_fragment_schema(
engine: Any,
atomic_id: str,
*,
correction: dict[str, Any] | None = None,
allow_multi_feature: bool = False,
) -> dict[str, Any]:
del correction
if allow_multi_feature:
raise ValueError("Canonical autonomous authoring currently permits exactly one feature")
contract = feature_atomic_contract(engine, atomic_id)
feature_properties: dict[str, Any] = {
"atomic_id": {"const": atomic_id},
"params": _parameter_schema(atomic_id, contract),
}
feature_required = ["atomic_id", "params"]
selector_limits = _selector_limits(atomic_id, contract)
if selector_limits is not None:
minimum, maximum = selector_limits
feature_properties["selector_tokens"] = {
"type": "array",
"items": {"type": "string", "minLength": 1},
"minItems": minimum,
"maxItems": maximum,
"uniqueItems": True,
}
feature_required.append("selector_tokens")
feature_schema = {
"type": "object",
"properties": feature_properties,
"required": feature_required,
"additionalProperties": False,
}
properties: dict[str, Any] = {"feature": feature_schema}
required = ["feature"]
if contract["requires_sketch"]:
properties["sketch"] = {
"type": "object",
"properties": {"workplane": _workplane_schema(), "profile": canonical_profile_schema()},
"required": ["workplane", "profile"],
"additionalProperties": False,
}
required.insert(0, "sketch")
return {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
}
def canonical_fragment_example(engine: Any, atomic_id: str) -> dict[str, Any]:
contract = feature_atomic_contract(engine, atomic_id)
feature: dict[str, Any] = {"atomic_id": atomic_id, "params": {}}
params = feature["params"]
if atomic_id.startswith("extrude_"):
params["distance_mm"] = 10
if "reverse_distance_mm" in contract["required_params"]:
params["reverse_distance_mm"] = 5
elif atomic_id.startswith("revolve_"):
params.update({"angle_deg": 360, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}})
elif atomic_id.startswith("hole_"):
params.update({"diameter_mm": 10, "depth_mm": 20, "positions": [{"mm": [0, 0, 0]}]})
if atomic_id == "hole_countersink":
params.update({"countersink_diameter_mm": 16, "countersink_angle_rad": 1.5707963267948966})
if atomic_id == "hole_counterbore":
params.update({"counterbore_diameter_mm": 16, "counterbore_depth_mm": 4})
feature["selector_tokens"] = ["face-token-from-current-topology"]
elif atomic_id == "hole_wizard":
params.update({"hole_type": "simple", "diameter_mm": 10, "depth_mm": 20})
feature["selector_tokens"] = ["face-token-from-current-topology"]
elif atomic_id == "sphere_add":
params.update({"radius_mm": 10, "center_mm": [0, 0, 0]})
elif atomic_id == "fillet":
params["radius_mm"] = 0.5
feature["selector_tokens"] = ["edge-token-from-current-topology"]
elif atomic_id == "chamfer":
params["distance_mm"] = 0.5
feature["selector_tokens"] = ["edge-token-from-current-topology"]
elif atomic_id == "pattern_linear":
params.update({"direction_1": [1, 0, 0], "spacing_1_mm": 10, "pattern_count_1": 2})
elif atomic_id == "pattern_mirror":
feature["selector_tokens"] = ["plane-token-from-current-topology"]
elif atomic_id == "reference_axis":
params["axis"] = {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}
elif atomic_id == "reference_plane":
params["plane"] = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
fragment: dict[str, Any] = {"feature": feature}
if contract["requires_sketch"]:
fragment["sketch"] = {
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": {"type": "polygon", "vertices": [[-10, -5], [10, -5], [10, 5], [-10, 5]]},
}
fragment = {"sketch": fragment["sketch"], "feature": feature}
return fragment
def operation_contract_hash(atomic_id: str, schema: dict[str, Any]) -> str:
encoded = json.dumps({"atomic_id": atomic_id, "schema": schema}, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def validate_canonical_fragment(engine: Any, fragment: dict[str, Any], *, expected_atomic_id: str = "") -> str:
feature = fragment.get("feature") if isinstance(fragment, dict) else None
atomic_id = str((feature or {}).get("atomic_id") or "") if isinstance(feature, dict) else ""
if expected_atomic_id and atomic_id and atomic_id != expected_atomic_id:
raise CanonicalFragmentError(
f"OPERATION_CONTRACT_MISMATCH: expected {expected_atomic_id}, received {atomic_id}",
path="fragment.feature.atomic_id",
code="OPERATION_CONTRACT_MISMATCH",
)
if not atomic_id:
raise CanonicalFragmentError(
"CDSL_SCHEMA_INVALID: canonical fragment requires fragment.feature.atomic_id",
path="fragment.feature.atomic_id",
)
schema = build_operation_fragment_schema(engine, atomic_id)
errors = sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda error: (list(error.absolute_path), error.message))
if errors:
error = errors[0]
path = "fragment" + "".join(
f"[{item}]" if isinstance(item, int) else f".{item}"
for item in error.absolute_path
)
raise CanonicalFragmentError(f"CDSL_SCHEMA_INVALID at {path}: {error.message}", path=path)
profile = ((fragment.get("sketch") or {}).get("profile") or {}) if isinstance(fragment, dict) else {}
if isinstance(profile, dict) and profile.get("type") == "analytic_contours":
roles = [str(item.get("role") or "") for item in profile.get("contours") or () if isinstance(item, dict)]
if roles.count("outer") != 1 or roles.count("inner") > 1:
raise CanonicalFragmentError(
"CDSL_PROFILE_MULTIPLE_OUTERS: analytic_contours requires exactly one outer contour and at most one inner contour",
path="fragment.sketch.profile.contours",
code="CDSL_PROFILE_MULTIPLE_OUTERS",
)
if roles.count("outer") == 0 and roles.count("inner"):
raise CanonicalFragmentError(
"CDSL_PROFILE_INNER_WITHOUT_OUTER: analytic_contours cannot contain an inner contour without an outer contour",
path="fragment.sketch.profile.contours",
code="CDSL_PROFILE_INNER_WITHOUT_OUTER",
)
return atomic_id
+329 -22
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
import math
from typing import Any
@@ -231,12 +232,15 @@ def _normalize_axis_mapping(axis: dict[str, Any], *, location: str, fixes: list[
def _lift_feature_local_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None:
"""Accept the common one-feature/one-sketch nesting without choosing geometry.
"""Accept common feature-local sketch spellings without choosing geometry.
The public fragment grammar owns one ordered sketch list and one ordered
feature list. Some tool-call models naturally nest each sketch below its
feature. That representation is losslessly transformable only when every
feature supplies exactly one sketch and no root sketch collection exists.
feature list. Tool-call models commonly emit either a direct feature with
a local ``sketch`` or a wrapper shaped as ``{sketch, feature}``. Both are
losslessly transformable when the fragment has no root sketch collection.
Sketchless features such as ``sphere_add`` and ``chamfer`` may be mixed in
the same batch; the materializer pairs only sketch-requiring features with
the lifted sketches.
"""
if any(key in fragment for key in ("sketch", "sketches", "add_sketches")):
return
@@ -244,21 +248,225 @@ def _lift_feature_local_sketches(fragment: dict[str, Any], *, fixes: list[dict[s
if not isinstance(features, list) or not features or not all(isinstance(item, dict) for item in features):
return
lifted: list[dict[str, Any]] = []
for index, feature in enumerate(features):
nested = feature.get("sketches", feature.get("sketch"))
normalized_features: list[dict[str, Any]] = []
for index, item in enumerate(features):
wrapped = item.get("feature")
if wrapped is not None:
if not isinstance(wrapped, dict):
return
feature = deepcopy(wrapped)
if "selector_tokens" in item:
if "selector_tokens" in feature:
raise AutonomousFragmentError(
f"features[{index}] supplies selector_tokens both on the wrapper and feature"
)
feature["selector_tokens"] = deepcopy(item["selector_tokens"])
nested = item.get("sketches", item.get("sketch"))
fixes.append({"path": f"features[{index}]", "from": "{sketch,feature}", "to": "feature", "action": "unwrapped_equivalent"})
else:
feature = deepcopy(item)
nested = feature.get("sketches", feature.get("sketch"))
if isinstance(nested, dict):
sketches = [nested]
elif isinstance(nested, list):
sketches = nested
else:
return
normalized_features.append(feature)
continue
if len(sketches) != 1 or not isinstance(sketches[0], dict):
return
feature.pop("sketch", None)
feature.pop("sketches", None)
lifted.append(sketches[0])
normalized_features.append(feature)
fixes.append({"path": f"features[{index}]", "from": "feature-local sketch", "to": "sketches[]", "action": "lifted_equivalent"})
fragment["sketches"] = lifted
fragment["features"] = normalized_features
if lifted:
fragment["sketches"] = lifted
def _lift_param_embedded_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None:
"""Lift an exact legacy ``params.workplane/profile`` sketch spelling.
Some authors place an extrusion's complete sketch inside its params object.
The workplane and profile retain their meaning verbatim, so extracting them
is safe. Partial shapes remain invalid instead of being guessed.
"""
if any(key in fragment for key in ("sketch", "sketches", "add_sketches")):
return
features = fragment.get("features", fragment.get("add_features"))
if not isinstance(features, list) or not all(isinstance(item, dict) for item in features):
return
lifted: list[dict[str, Any]] = []
for index, feature in enumerate(features):
atomic_id = str(feature.get("atomic_id") or "")
params = feature.get("params")
if not atomic_id.startswith(("extrude_", "revolve_")) or not isinstance(params, dict):
continue
workplane = params.get("workplane")
profile = params.get("profile")
if workplane is None and profile is None:
continue
if not isinstance(workplane, dict) or not isinstance(profile, dict):
return
params.pop("workplane")
params.pop("profile")
lifted.append({"workplane": workplane, "profile": profile})
fixes.append({"path": f"features[{index}].params", "from": "workplane/profile", "to": "sketches[]", "action": "lifted_equivalent"})
if lifted:
fragment["sketches"] = lifted
def _set_reverse_from_direction(params: dict[str, Any], *, reverse: bool, location: str, fixes: list[dict[str, str]]) -> None:
if "reverse" in params and params["reverse"] is not reverse:
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: direction conflicts with reverse"
)
params["reverse"] = reverse
params.pop("direction", None)
fixes.append({"path": location, "from": "direction", "to": "reverse", "action": "normalized_equivalent"})
def _normalize_extrude_direction(
params: dict[str, Any],
sketch: dict[str, Any] | None,
*,
location: str,
fixes: list[dict[str, str]],
) -> None:
"""Accept an extrusion direction only when it exactly restates the sketch."""
direction = params.get("direction")
if direction is None:
return
if isinstance(direction, str):
normalized = direction.strip().lower()
if normalized in {"negative", "reverse", "-normal"}:
_set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes)
elif normalized in {"positive", "forward", "+normal"}:
_set_reverse_from_direction(params, reverse=False, location=location, fixes=fixes)
return
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
normal = workplane.get("normal") if isinstance(workplane, dict) else None
if (
not isinstance(direction, list)
or not isinstance(normal, list)
or len(direction) != 3
or len(normal) != 3
or not all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in [*direction, *normal])
):
return
direction_norm = math.sqrt(sum(float(value) ** 2 for value in direction))
normal_norm = math.sqrt(sum(float(value) ** 2 for value in normal))
if direction_norm == 0 or normal_norm == 0:
return
cosine = sum(float(direction[index]) * float(normal[index]) for index in range(3)) / (direction_norm * normal_norm)
if math.isclose(cosine, 1.0, abs_tol=1e-9):
params.pop("direction")
fixes.append({"path": location, "from": "direction", "to": "workplane.normal", "action": "deduplicated_equivalent"})
elif math.isclose(cosine, -1.0, abs_tol=1e-9):
_set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes)
def _normalize_angle_radians(params: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
"""Convert the explicitly unit-labelled angle_rad alias to angle_deg."""
if "angle_rad" not in params:
return
radians = params["angle_rad"]
if not isinstance(radians, (int, float)) or isinstance(radians, bool) or not math.isfinite(float(radians)):
return
degrees = float(radians) * 180.0 / math.pi
if "angle_deg" in params:
supplied = params["angle_deg"]
if not isinstance(supplied, (int, float)) or isinstance(supplied, bool) or not math.isclose(float(supplied), degrees, rel_tol=0.0, abs_tol=1e-9):
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: angle_rad conflicts with angle_deg"
)
params.pop("angle_rad")
fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "deduplicated_equivalent"})
return
params["angle_deg"] = degrees
params.pop("angle_rad")
fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "converted_unit"})
def _normalize_concentric_circle_contours(profile: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
"""Expand a common two-circle annulus shorthand into analytic contours."""
if profile.get("type") != "analytic_contours":
return
contours = profile.get("contours")
if not isinstance(contours, list) or len(contours) != 2 or not all(isinstance(item, dict) for item in contours):
return
if not all(item.get("type") == "circle" and isinstance(item.get("center"), list) and len(item["center"]) == 2 for item in contours):
return
if contours[0]["center"] != contours[1]["center"]:
return
try:
ordered = sorted(contours, key=lambda item: float(item["radius_mm"]), reverse=True)
except (KeyError, TypeError, ValueError):
return
if float(ordered[0]["radius_mm"]) <= float(ordered[1]["radius_mm"]):
return
profile["contours"] = [
{"role": role, "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]}
for role, item in zip(("outer", "inner"), ordered)
]
fixes.append({"path": location, "from": "circle contour shorthand", "to": "analytic_contours.segments", "action": "expanded_equivalent"})
def _finite_vector3(value: Any) -> tuple[float, float, float] | None:
"""Return a finite numeric vector when the author supplied one."""
if (
not isinstance(value, list)
or len(value) != 3
or not all(isinstance(component, (int, float)) and not isinstance(component, bool) for component in value)
):
return None
result = tuple(float(component) for component in value)
return result if all(math.isfinite(component) for component in result) else None
def _validate_revolve_axis_in_sketch_plane(
atomic_id: str,
params: dict[str, Any],
sketch: dict[str, Any],
) -> None:
"""Reject a revolve axis that cannot be a construction line of its sketch.
A solid revolve is defined around an axis in the source sketch plane.
Letting an out-of-plane axis reach OCC can produce degenerate BReps that
fail much later during tessellation, so enforce this geometric invariant
before candidate staging. Malformed vectors are left to CDSL schema
validation, which can report their field-level shape.
"""
axis = params.get("axis")
workplane = sketch.get("workplane") if isinstance(sketch.get("workplane"), dict) else None
if not isinstance(axis, dict) or not isinstance(workplane, dict):
return
axis_origin = _finite_vector3(axis.get("origin_mm"))
axis_direction = _finite_vector3(axis.get("direction"))
plane_origin = _finite_vector3(workplane.get("origin_mm"))
plane_normal = _finite_vector3(workplane.get("normal"))
if None in {axis_origin, axis_direction, plane_origin, plane_normal}:
return
assert axis_origin is not None and axis_direction is not None and plane_origin is not None and plane_normal is not None
direction_length = math.sqrt(sum(component * component for component in axis_direction))
normal_length = math.sqrt(sum(component * component for component in plane_normal))
if direction_length == 0 or normal_length == 0:
return
direction_normal_dot = abs(sum(axis_direction[index] * plane_normal[index] for index in range(3)) / (direction_length * normal_length))
if direction_normal_dot > 1e-7:
raise AutonomousFragmentError(
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: "
f"{atomic_id} params.axis.direction must be parallel to sketch.workplane; "
f"abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}"
)
origin_plane_offset = abs(sum((axis_origin[index] - plane_origin[index]) * plane_normal[index] for index in range(3)) / normal_length)
if origin_plane_offset > 1e-6:
raise AutonomousFragmentError(
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: "
f"{atomic_id} params.axis.origin_mm must lie in sketch.workplane; "
f"plane_offset_mm={origin_plane_offset:.3g}"
)
def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]]]:
@@ -275,6 +483,7 @@ def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, A
normalized = deepcopy(fragment)
fixes: list[dict[str, str]] = []
_lift_feature_local_sketches(normalized, fixes=fixes)
_lift_param_embedded_sketches(normalized, fixes=fixes)
feature_values: list[tuple[dict[str, Any], str]] = []
feature = normalized.get("feature")
if isinstance(feature, dict):
@@ -310,6 +519,7 @@ def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, A
location=f"{location}.params",
fixes=fixes,
)
_normalize_angle_radians(params, location=f"{location}.params", fixes=fixes)
axis = params.get("axis")
if axis is None:
axis = {}
@@ -349,10 +559,41 @@ def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, A
sketches = normalized.get("sketches", normalized.get("add_sketches"))
if isinstance(sketches, list):
sketch_values.extend((item, f"sketches[{index}]") for index, item in enumerate(sketches) if isinstance(item, dict))
sketch_feature_index = 0
for current_feature, feature_location in feature_values:
atomic_id = str(current_feature.get("atomic_id") or "")
if not atomic_id.startswith(("extrude_", "revolve_")):
continue
current_sketch = sketch_values[sketch_feature_index][0] if sketch_feature_index < len(sketch_values) else None
sketch_feature_index += 1
if atomic_id.startswith("extrude_"):
params = current_feature.get("params")
if isinstance(params, dict):
_normalize_extrude_direction(
params,
sketch=current_sketch,
location=f"{feature_location}.params",
fixes=fixes,
)
for current_sketch, location in sketch_values:
profile = current_sketch.get("profile")
if isinstance(profile, dict) and profile.get("type") == "polygon":
_move_equivalent_field(profile, source="points", target="vertices", location=f"{location}.profile", fixes=fixes)
if isinstance(profile, dict):
_normalize_concentric_circle_contours(profile, location=f"{location}.profile", fixes=fixes)
# Earlier versions advertised sphere_add as sketch-backed even though its
# executor has always used only radius_mm and center_mm. Preserve that
# single-feature spelling without keeping an unused locator sketch in the
# immutable CDSL document.
if (
len(feature_values) == 1
and str(feature_values[0][0].get("atomic_id") or "") == "sphere_add"
and len(sketch_values) == 1
):
normalized.pop("sketch", None)
normalized.pop("sketches", None)
normalized.pop("add_sketches", None)
fixes.append({"path": "sketch", "from": "sphere locator sketch", "to": "none", "action": "dropped_unused_legacy_locator"})
return normalized, fixes
@@ -363,6 +604,9 @@ def materialize_autonomous_fragment(
engine: Any,
selector_tokens: dict[str, dict[str, Any]],
max_features: int,
source: str = "legacy_restore",
allow_legacy_aliases: bool = True,
expected_atomic_id: str = "",
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Append authored geometry while assigning only server-owned metadata.
@@ -374,7 +618,25 @@ def materialize_autonomous_fragment(
# becoming an import cycle.
from app.services.engine_service import feature_atomic_contract
from app.services.cdsl_authoring_schema import CanonicalFragmentError, validate_canonical_fragment
normalized_fragment, compatibility_fixes = normalize_autonomous_fragment(fragment)
if not allow_legacy_aliases:
if compatibility_fixes:
first = compatibility_fixes[0]
location = str(first.get("path") or "fragment")
legacy = str(first.get("from") or "legacy field")
canonical = str(first.get("to") or "canonical field")
raise AutonomousFragmentError(
f"CDSL_CANONICAL_FORMAT_REQUIRED at fragment.{location}: "
f"{legacy} is a legacy spelling; use {canonical}"
)
try:
validate_canonical_fragment(engine, fragment, expected_atomic_id=expected_atomic_id)
except CanonicalFragmentError as error:
raise AutonomousFragmentError(str(error)) from error
normalized_fragment = deepcopy(fragment)
compatibility_fixes = []
sketches, features = _fragment_lists(normalized_fragment)
if len(features) > max_features:
raise AutonomousFragmentError(f"A fragment may add at most {max_features} feature(s)")
@@ -388,7 +650,12 @@ def materialize_autonomous_fragment(
materialized_sketches: list[dict[str, Any]] = []
materialized_features: list[dict[str, Any]] = []
for index, source_feature in enumerate(features):
sketch_index = 0
for source_feature in features:
# Materialization injects server-owned ids, host faces and pattern
# sources. Work on a private copy so the original tool-call fragment
# remains intact in audit records and diagnostic replacement cards.
source_feature = deepcopy(source_feature)
forbidden = {"id", "depends_on", "sketch_id", "selectors"} & set(source_feature)
if forbidden:
raise AutonomousFragmentError("Feature identity, dependencies, sketch_id and raw selectors are server-owned: " + ", ".join(sorted(forbidden)))
@@ -399,31 +666,68 @@ def materialize_autonomous_fragment(
params = source_feature.get("params")
if not isinstance(params, dict):
raise AutonomousFragmentError("Each fragment feature must contain a params object")
if (atomic_id.startswith("hole_") or atomic_id == "hole_wizard") and "host_face" in params:
if atomic_id.startswith("revolve_") and params.get("angle_deg") is None:
# A shared batch axis is deliberately limited to the axis. A
# default revolution angle would silently turn valid partial
# revolves into a different solid, so it remains author-owned.
raise AutonomousFragmentError(
f"{atomic_id} host_face is server-owned: put exactly one face token in selector_tokens and use "
"positions as [{\"mm\":[x_mm,y_mm,z_mm]}], not raw host_face or bare coordinate arrays"
f"{atomic_id} requires params.angle_deg; revolve_axis (including shared_revolve_axis) "
"supplies only params.axis. Declare an explicit angle in degrees."
)
token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard"
tokens = source_feature.pop("selector_tokens", [])
if not isinstance(tokens, list) or not all(isinstance(token, str) for token in tokens) or len(set(tokens)) != len(tokens):
token_list_is_valid = (
isinstance(tokens, list)
and all(isinstance(token, str) for token in tokens)
and len(set(tokens)) == len(tokens)
)
if not token_list_is_valid and not token_backed_param:
raise AutonomousFragmentError("selector_tokens must be a unique array of opaque tokens")
if not token_list_is_valid:
tokens = []
selected: list[dict[str, Any]] = []
invalid_tokens: list[str] = []
for token in tokens:
candidate = selector_tokens.get(token)
if candidate is None:
if token_backed_param:
invalid_tokens.append(token)
continue
raise AutonomousFragmentError("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
selected.append(deepcopy(candidate["selector"]))
slot = contract.get("selector_slot")
token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard"
if token_backed_param:
# Report all author-correctable hole errors at once. A hole is
# topology-sensitive, so its host face remains server-owned and
# must be injected from one current face token.
issues: list[str] = []
author_params = (set(contract["required_params"]) | set(contract["optional_params"])) - {"host_face"}
if "host_face" in params:
issues.append("params.host_face is server-owned; use selector_tokens")
missing = [name for name in contract["required_params"] if name != "host_face" and name not in params]
if missing:
issues.append("missing params: " + ", ".join(missing))
unexpected = sorted(name for name in params if name not in author_params and name != "host_face")
if unexpected:
issues.append("unsupported params: " + ", ".join(unexpected))
if not token_list_is_valid:
issues.append("selector_tokens must be a unique array of opaque tokens")
if invalid_tokens:
issues.append("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
if len(tokens) != 1 or len(selected) != 1 or str((selected[0] if selected else {}).get("kind") or "") != "face":
issues.append(f"{atomic_id} requires exactly one face selector token for its host face")
if issues:
raise AutonomousFragmentError("HOLE_FRAGMENT_INVALID: " + "; ".join(issues))
if not slot and tokens and not token_backed_param:
raise AutonomousFragmentError(f"{atomic_id} does not accept selector tokens")
if isinstance(slot, dict):
minimum, maximum = int(slot.get("min_items") or 0), int(slot.get("max_items") or 0)
if not minimum <= len(selected) <= maximum:
raise AutonomousFragmentError(f"{atomic_id} requires {minimum}..{maximum} selector token(s)")
elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
if len(selected) != 1 or str(selected[0].get("kind") or "") != "face":
raise AutonomousFragmentError(f"{atomic_id} requires exactly one face selector token for its host face")
elif token_backed_param:
# Hole token validation above deliberately aggregates every
# actionable error before this materialization boundary.
pass
elif atomic_id.startswith("revolve_"):
axis = params.get("axis")
if not isinstance(axis, dict) or "origin_mm" not in axis or "direction" not in axis:
@@ -437,17 +741,18 @@ def materialize_autonomous_fragment(
output["id"] = feature_id
output["depends_on"] = [last_feature_id] if last_feature_id else []
if contract["requires_sketch"]:
if index >= len(sketches):
if sketch_index >= len(sketches):
raise AutonomousFragmentError(f"{atomic_id} requires one new sketch in the same fragment")
sketch = sketches[index]
sketch = sketches[sketch_index]
sketch_index += 1
if "id" in sketch or "attachment" in sketch or "profile_from" in sketch:
raise AutonomousFragmentError("Sketch identity and topology attachment are server-owned")
sketch_id = _autonomous_id("sketch", used_sketch_ids)
sketch["id"] = sketch_id
if atomic_id.startswith("revolve_"):
_validate_revolve_axis_in_sketch_plane(atomic_id, params, sketch)
materialized_sketches.append(sketch)
output["sketch_id"] = sketch_id
elif index < len(sketches):
raise AutonomousFragmentError(f"{atomic_id} does not accept a sketch")
if atomic_id.startswith("pattern_") and "source_feature_ids" not in params:
if not last_feature_id:
raise AutonomousFragmentError(f"{atomic_id} needs a committed source feature")
@@ -467,7 +772,7 @@ def materialize_autonomous_fragment(
output["selectors"] = []
materialized_features.append(output)
last_feature_id = feature_id
if len(sketches) != len(materialized_sketches):
if sketch_index != len(sketches):
raise AutonomousFragmentError("Each sketch must be consumed by a feature that requires a sketch")
document = materialize_fragment(document, {"add_sketches": materialized_sketches, "add_features": materialized_features})
return document, {
@@ -475,6 +780,8 @@ def materialize_autonomous_fragment(
"source_fragment": deepcopy(fragment),
"normalized_fragment": deepcopy(normalized_fragment) if compatibility_fixes else None,
"compatibility_fixes": compatibility_fixes,
"compatibility_fix_count": len(compatibility_fixes),
"legacy_input": source != "tool_call" or bool(compatibility_fixes),
"assigned_sketch_ids": [item["id"] for item in materialized_sketches],
"assigned_feature_ids": [item["id"] for item in materialized_features],
"selector_candidate_ids": [token for feature in features for token in feature.get("selector_tokens", [])],
+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}")
features = cdsl.get("features")
sketches = cdsl.get("geometry", {}).get("sketches")
if not isinstance(features, list) or not features or not isinstance(sketches, list) or not sketches:
raise ValueError("CDSL requires features and parameterized sketches")
if not isinstance(features, list) or not features or not isinstance(sketches, list):
raise ValueError("CDSL requires a feature list and a geometry.sketches array")
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
semantic_contract = _engine_schema(engine)
atomic_contracts = semantic_contract["feature_atomic_ids"]
+66 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import re
import secrets
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -192,6 +193,10 @@ class WorkspaceStore:
"lifecycle": "completed",
"run_id": "",
"requirements_path": "",
"completion_checklist_path": "",
"modeling_plan_path": "",
"modeling_plan_review_path": "",
"modeling_plan_version": 0,
"agent_state_path": "",
"active_candidate_id": "",
"active_branch_id": "main",
@@ -222,6 +227,9 @@ class WorkspaceStore:
"requirements_path": "",
"source_requirements_path": "",
"completion_checklist_path": "",
"modeling_plan_path": "",
"modeling_plan_review_path": "",
"modeling_plan_version": 0,
"agent_state_path": "",
"active_candidate_id": "",
"active_branch_id": "main",
@@ -271,6 +279,15 @@ class WorkspaceStore:
write_json(self.task_path(task_id), task)
return task
def update_task_fields(self, task_id: str, fields: dict[str, Any]) -> dict[str, Any]:
"""Update task metadata without appending a synthetic revision."""
task = self.ensure_task(task_id, "")
for key, value in fields.items():
task[str(key)] = deepcopy(value)
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def read_task(self, task_id: str) -> dict[str, Any] | None:
task = read_json(self.task_path(task_id))
return self._migrate_task(task, self.task_path(task_id)) if isinstance(task, dict) else None
@@ -293,8 +310,8 @@ class WorkspaceStore:
return task
def finish_generation(self, task_id: str, *, lifecycle: str, failure: dict[str, Any] | None = None) -> dict[str, Any]:
if lifecycle not in {"completed", "failed"}:
raise ValueError("Generation lifecycle must be completed or failed")
if lifecycle not in {"completed", "failed", "cancelled", "waiting_review", "failed_review_service"}:
raise ValueError("Generation lifecycle must be completed, failed, cancelled, waiting_review, or failed_review_service")
task = self.ensure_task(task_id, "")
failure_path = ""
if failure:
@@ -421,6 +438,53 @@ class WorkspaceStore:
path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else ""
def write_modeling_plan(self, task_id: str, markdown: str, *, version: int = 1) -> Path:
"""Persist one immutable, versioned modeling-plan document."""
text = str(markdown or "").strip()
if not text:
raise ValueError("modeling plan must not be empty")
task = self.ensure_task(task_id, "")
version = max(1, int(version))
relative = Path("plans") / f"modeling-plan-v{version}.md"
path = self.task_dir(task_id) / relative
if path.exists():
raise ValueError("modeling plan version already exists")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text + "\n", encoding="utf-8")
task["modeling_plan_path"] = relative.as_posix()
task["modeling_plan_version"] = version
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_modeling_plan(self, task_id: str) -> str:
task = self.read_task(task_id) or {}
relative = str(task.get("modeling_plan_path") or "")
if not relative:
return ""
path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else ""
def write_modeling_plan_review(self, task_id: str, review: dict[str, Any], *, version: int) -> Path:
task = self.ensure_task(task_id, "")
version = max(1, int(version))
relative = Path("plans") / f"modeling-plan-v{version}.review.json"
path = self.task_dir(task_id) / relative
if path.exists():
raise ValueError("modeling plan review already exists")
path.parent.mkdir(parents=True, exist_ok=True)
write_json(path, review)
task["modeling_plan_review_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_modeling_plan_review(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("modeling_plan_review_path") or "")
value = read_json(self.artifact_path(task_id, relative), {}) if relative else None
return value if isinstance(value, dict) else None
def new_candidate(self, task_id: str) -> tuple[str, Path]:
task = self.ensure_task(task_id, "")
candidate_id = f"candidate_{secrets.token_hex(8)}"
+249 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import base64
import copy
import json
import re
from pathlib import Path
from typing import Any
@@ -67,6 +68,28 @@ CANDIDATE_REVIEW_TOOL = {
}
MODELING_PLAN_REVIEW_TOOL = {
"type": "function",
"function": {
"name": "review_modeling_plan",
"description": "Independently review a CAD modeling plan for requirement coverage, coherent step grouping, dependencies, and observable evidence. Never generate or modify CDSL.",
"parameters": {
"type": "object",
"properties": {
"verdict": {"enum": ["pass", "revise"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"issues": {"type": "array", "items": {"type": "object", "properties": {"type": {"type": "string"}, "step_id": {"type": "string"}, "message": {"type": "string"}}, "required": ["type", "message"], "additionalProperties": False}, "maxItems": 20},
"coverage": {"type": "array", "items": {"type": "object", "properties": {"requirement": {"type": "string"}, "step_id": {"type": "string"}, "status": {"enum": ["covered", "missing"]}, "evidence": {"type": "string"}}, "required": ["requirement", "step_id", "status", "evidence"], "additionalProperties": False}},
"step_checks": {"type": "array", "items": {"type": "object", "properties": {"step_id": {"type": "string"}, "status": {"enum": ["pass", "fail"]}, "notes": {"type": "string"}}, "required": ["step_id", "status", "notes"], "additionalProperties": False}},
"action_checks": {"type": "array", "items": {"type": "object", "properties": {"step_id": {"type": "string"}, "status": {"enum": ["pass", "fail"]}, "notes": {"type": "string"}, "required_action_count": {"type": "integer", "minimum": 1}}, "required": ["step_id", "status", "notes", "required_action_count"], "additionalProperties": False}},
},
"required": ["verdict", "confidence", "issues", "coverage", "step_checks", "action_checks"],
"additionalProperties": False,
},
},
}
class VisualReviewError(RuntimeError):
pass
@@ -75,6 +98,51 @@ class _CandidateReviewFormatError(VisualReviewError):
"""A locally detected invalid reviewer tool result, eligible for one retry."""
class _ModelingPlanReviewFormatError(VisualReviewError):
"""A locally detected invalid modeling-plan verdict, eligible for one retry."""
def _checklist_key(value: Any) -> str:
return " ".join(str(value or "").strip().split()).casefold()
def _looks_like_operation_id(value: str) -> bool:
token = str(value or "").strip().casefold()
if not token or "_" not in token:
return token in {"fillet", "chamfer"}
prefixes = ("extrude_", "revolve_", "hole_", "pattern_", "sphere_", "reference_", "cylinder_", "sweep_")
suffixes = ("_add", "_cut", "_blind", "_wizard", "_linear", "_circular", "_mirror")
return token.startswith(prefixes) or token.endswith(suffixes)
def _unsupported_operation_mentions(result: dict[str, Any], runtime_operations: list[dict[str, Any]]) -> list[str]:
"""Find capability-shaped names in reviewer prose that Runtime cannot execute."""
supported = {
str(item.get("atomic_id") or "").strip().casefold()
for item in runtime_operations
if isinstance(item, dict) and str(item.get("atomic_id") or "").strip()
}
mentions: set[str] = set()
for section in (result.get("issues"), result.get("coverage"), result.get("step_checks"), result.get("action_checks")):
if not isinstance(section, list):
continue
for row in section:
if not isinstance(row, dict):
continue
for value in row.values():
if not isinstance(value, str):
continue
for token in re.findall(r"(?<![A-Za-z0-9_])([A-Za-z][A-Za-z0-9_]*)(?![A-Za-z0-9_])", value):
normalized = token.casefold()
if normalized in supported or not _looks_like_operation_id(normalized):
continue
# Runtime capabilities are exact identifiers. A prefix
# or human shorthand (for example extrude_cut for
# extrude_cut_blind) is still invalid and must be revised.
mentions.add(token)
return sorted(mentions, key=str.casefold)
def _thinking_tool_choice_rejected(response: httpx.Response) -> bool:
"""Recognize the only compatibility error for which retrying is sound.
@@ -99,6 +167,10 @@ def candidate_review_tool() -> dict[str, Any]:
return json.loads(json.dumps(CANDIDATE_REVIEW_TOOL))
def modeling_plan_review_tool() -> dict[str, Any]:
return json.loads(json.dumps(MODELING_PLAN_REVIEW_TOOL))
def _image_part(path: Path) -> dict[str, Any]:
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
media = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
@@ -222,6 +294,9 @@ async def review_candidate_batch(
batch_goal: str,
deterministic_report: dict[str, Any],
node_id: str,
plan_step: dict[str, Any] | None = None,
plan_feature_ids: list[str] | None = None,
plan_action: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Review a staged batch against its local goal and the frozen checklist."""
provider, model = settings.resolve_review_model()
@@ -237,6 +312,9 @@ async def review_candidate_batch(
"frozen_requirements": requirements,
"completion_checklist": checklist,
"batch_goal": batch_goal,
"plan_step": plan_step or None,
"plan_feature_ids": plan_feature_ids or [],
"plan_action": plan_action or None,
"deterministic_report": deterministic_report,
"instruction": (
"Assess this staged batch, not overall task completion. Accept only if the batch goal is achieved "
@@ -247,7 +325,9 @@ async def review_candidate_batch(
"intermediate model may be accepted when its batch goal is achieved. Return one coverage row for every "
"completion checklist item, preserving exact item text. Use pending for future work and regressed when "
"this batch breaks previously achieved work. Reject on disconnected geometry when the requirements call "
"for one body, wrong orientation, visibly wrong geometry, or a failed batch goal."
"for one body, wrong orientation, visibly wrong geometry, or a failed batch goal. "
"When plan_step is supplied, reject operations outside that step and confirm its planned feature goal. "
"A step may contain multiple related actions; when plan_action is supplied, assess only that action, confirm the candidate changes its target, and reject unrelated or multiple independent profiles."
),
"render_manifest": {
"renderer": manifest.get("renderer"),
@@ -341,3 +421,171 @@ async def review_candidate_batch(
"batch_goal": batch_goal,
**result,
}
async def review_modeling_plan(
settings: Settings,
*,
source_requirements: str,
requirements: str,
checklist: list[str],
plan: dict[str, Any],
runtime_operations: list[dict[str, Any]],
node_id: str = "modeling-plan",
) -> dict[str, Any]:
"""Ask an independent model to simulate and review a modeling plan."""
provider, model = settings.resolve_review_model()
payload_context = {
"node_id": node_id,
"source_requirements": source_requirements,
"frozen_requirements": requirements,
"completion_checklist": checklist,
"modeling_plan": plan,
"runtime_operations": runtime_operations,
"instruction": (
"Review the plan only; do not generate CDSL. Treat modeling_plan.plan_text as semantic "
"guidance, not a schema: missing structures/features arrays or machine IDs are not by "
"themselves failures. Infer intended structures, ordering, relationships, and evidence "
"from the prose. Check every checklist item, dependency order, step cohesion, topology "
"preconditions, Runtime feasibility, action granularity, and observable evidence. A semantic "
"step may contain related or repeated targets, but each independently located profile "
"or Runtime feature must be enumerated as a separate action within that same step. Each action "
"must be executable as one Runtime feature. A multi-position hole or supported pattern may remain "
"one action when all positions share the same host and operation contract. Treat any supplied "
"action records as execution units, and set required_action_count to the minimum number the step's "
"semantics require. Return fail when the plan contains fewer actions than that number. Return pass when the plan "
"is actionable enough for an LLM to generate CDSL in coherent batches. Do not require "
"colors or materials when runtime_operations does not provide them; accept a geometrically "
"equivalent ring, groove, or separated feature and mention the limitation only as guidance. "
"Only return revise for a real requirement omission, contradictory geometry, unsafe ordering, "
"an operation name that is not present in runtime_operations, or an operation that cannot plausibly be implemented. "
"Do not downgrade an unsupported operation to vague guidance. If a machine operation is mentioned, it must be an exact ID from runtime_operations; otherwise return revise and describe the semantic intent without inventing an operation name. "
"Return exactly one coverage row per checklist item."
),
}
payload = {
"model": model.id,
"messages": [
{"role": "system", "content": "You are an independent CAD modeling-plan reviewer. You may only call review_modeling_plan."},
{"role": "user", "content": json.dumps(payload_context, ensure_ascii=False)},
],
"tools": [modeling_plan_review_tool()],
"tool_choice": {"type": "function", "function": {"name": "review_modeling_plan"}},
"temperature": 0,
}
payload.update(provider.chat_completion_options)
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
async def request_review(client: httpx.AsyncClient, request_payload: dict[str, Any]) -> httpx.Response:
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload)
if _thinking_tool_choice_rejected(response):
request_payload.pop("tool_choice", None)
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload)
if response.status_code >= 400:
raise VisualReviewError(f"Modeling plan review request failed ({response.status_code}): {response.text[:500]}")
return response
def validate_response(response: httpx.Response) -> dict[str, Any]:
try:
call = response.json()["choices"][0]["message"]["tool_calls"][0]
if call["function"]["name"] != "review_modeling_plan":
raise KeyError("wrong tool")
result = json.loads(call["function"]["arguments"])
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer did not return a valid review tool call") from error
allowed = {"verdict", "confidence", "issues", "coverage", "step_checks", "action_checks"}
if not isinstance(result, dict) or set(result) != allowed or result.get("verdict") not in {"pass", "revise"}:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid verdict")
try:
confidence = float(result.get("confidence"))
except (TypeError, ValueError) as error:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid confidence") from error
if not 0 <= confidence <= 1:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer confidence is outside [0, 1]")
if not isinstance(result.get("issues"), list) or not all(isinstance(item, dict) for item in result["issues"]):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned invalid issues")
expected = {_checklist_key(item): item for item in checklist}
coverage = result.get("coverage")
if not isinstance(coverage, list) or len(coverage) != len(checklist):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return coverage for every checklist item")
seen: set[str] = set()
for item in coverage:
if not isinstance(item, dict) or set(item) != {"requirement", "step_id", "status", "evidence"}:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid coverage row")
key = _checklist_key(item.get("requirement"))
if key not in expected or key in seen or item.get("status") not in {"covered", "missing"} or not str(item.get("evidence") or "").strip():
raise _ModelingPlanReviewFormatError("Modeling plan reviewer coverage does not match the frozen checklist")
item["requirement"] = expected[key]
seen.add(key)
step_records = {
str(item.get("step_id") or ""): item
for item in plan.get("steps") or () if isinstance(item, dict)
}
steps = set(step_records)
checks = result.get("step_checks")
if not isinstance(checks, list) or len(checks) != len(steps):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return one step check per plan step")
checked: set[str] = set()
for item in checks:
if not isinstance(item, dict) or set(item) != {"step_id", "status", "notes"}:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid step check")
step_id = str(item.get("step_id") or "")
if step_id not in steps or step_id in checked or item.get("status") not in {"pass", "fail"}:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer step checks do not match the plan")
checked.add(step_id)
action_checks = result.get("action_checks")
if not isinstance(action_checks, list) or len(action_checks) != len(steps):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return one action check per plan step")
action_checked: set[str] = set()
for item in action_checks:
if not isinstance(item, dict) or set(item) != {"step_id", "status", "notes", "required_action_count"}:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid action check")
step_id = str(item.get("step_id") or "")
required_count = item.get("required_action_count")
if (
step_id not in steps
or step_id in action_checked
or item.get("status") not in {"pass", "fail"}
or not isinstance(required_count, int)
or isinstance(required_count, bool)
or required_count < 1
):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer action checks do not match the plan")
planned_actions = [
action for action in (step_records[step_id].get("actions") or ())
if isinstance(action, dict)
]
if required_count > len(planned_actions) and item.get("status") != "fail":
raise _ModelingPlanReviewFormatError(
"Modeling plan reviewer must fail an action check when required_action_count exceeds the plan's action count"
)
action_checked.add(step_id)
if result["verdict"] == "pass" and any(item.get("status") != "covered" for item in coverage):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every checklist item is covered")
if result["verdict"] == "pass" and any(item.get("status") != "pass" for item in checks):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every step check passes")
if result["verdict"] == "pass" and any(item.get("status") != "pass" for item in action_checks):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every action check passes")
unsupported = _unsupported_operation_mentions(result, runtime_operations)
if unsupported:
# These strings came from the reviewer's explanatory prose, not
# from the submitted plan. The plan parser enforces exact IDs for
# explicit operation declarations; do not reject an otherwise
# valid semantic plan because the reviewer hallucinated a
# shorthand while describing an alternative. Keep this warning in
# the raw audit record and scrub it before author exposure.
result["unsupported_operations"] = unsupported
result["review_warnings"] = [
"Reviewer mentioned operation name(s) absent from runtime_operations; those names were ignored as guidance."
]
return result
async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client:
response = await request_review(client, payload)
try:
result = validate_response(response)
except _ModelingPlanReviewFormatError as error:
retry_payload = copy.deepcopy(payload)
retry_payload["messages"].append({"role": "user", "content": f"Your previous review was rejected locally: {error}. Return only a complete review_modeling_plan tool call with one coverage row per checklist item and one step_checks and action_checks row per plan step."})
result = validate_response(await request_review(client, retry_payload))
return {"schema_version": "cad.modeling-plan-review.v1", "node_id": node_id, "model": model.id, **result}
+4
View File
@@ -65,6 +65,8 @@ class Settings:
agent_max_features_per_fragment: int = 6
agent_context_char_limit: int = 14000
agent_render_cache: bool = True
modeling_plan_enabled: bool = True
modeling_plan_max_revisions: int = 2
autonomous_generation: bool = True
resume_running_tasks_on_startup: bool = True
@@ -199,6 +201,8 @@ def get_settings() -> Settings:
agent_max_features_per_fragment=max(1, min(6, int(os.getenv("CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT", "6")))),
agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))),
agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True),
modeling_plan_enabled=_env_flag("CDSL_MODELING_PLAN_ENABLED", True),
modeling_plan_max_revisions=max(1, int(os.getenv("CDSL_MODELING_PLAN_MAX_REVISIONS", "2"))),
autonomous_generation=True,
# Production instances recover durable runs by default. Test workers
# can disable this before startup to guarantee they touch only tasks
+1 -1
View File
@@ -13,7 +13,7 @@
"geometry": {
"type": "object",
"properties": {
"sketches": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/sketch"}}
"sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}}
},
"required": ["sketches"],
"additionalProperties": false
@@ -15,7 +15,7 @@
"hole_blind": {"summary": "Cut one or more blind cylindrical holes in the current body.", "required_params": ["diameter_mm", "depth_mm", "positions", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face is injected from exactly one face selector token.", "requires_sketch": false},
"hole_countersink": {"summary": "Cut one or more blind holes with countersink dimensions.", "required_params": ["diameter_mm", "depth_mm", "positions", "countersink_diameter_mm", "countersink_angle_rad", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face is injected from exactly one face selector token.", "requires_sketch": false},
"hole_counterbore": {"summary": "Cut one or more blind holes with counterbore dimensions.", "required_params": ["diameter_mm", "depth_mm", "positions", "counterbore_diameter_mm", "counterbore_depth_mm", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face is injected from exactly one face selector token.", "requires_sketch": false},
"sphere_add": {"summary": "Add one spherical solid at an explicit model-space center.", "required_params": ["radius_mm", "center_mm"], "optional_params": [], "requires_sketch": true, "produces_body": true},
"sphere_add": {"summary": "Add one spherical solid at an explicit model-space center.", "required_params": ["radius_mm", "center_mm"], "optional_params": [], "requires_sketch": false, "produces_body": true},
"fillet": {"summary": "Apply a radius to selected edges or faces.", "required_params": ["radius_mm"], "optional_params": ["tangent_propagation"], "requires_sketch": false, "selector_slot": {"path": "feature.selectors", "min_items": 1, "max_items": 64}},
"chamfer": {"summary": "Apply an equal-distance or angle-distance chamfer to selected edges or faces.", "required_params": ["distance_mm"], "optional_params": ["distance_2_mm", "angle_rad"], "requires_sketch": false, "selector_slot": {"path": "feature.selectors", "min_items": 1, "max_items": 64}},
"pattern_linear": {"summary": "Repeat source features along one or two directions.", "required_params": ["source_feature_ids", "direction_1", "spacing_1_mm", "pattern_count_1"], "optional_params": ["direction_2", "spacing_2_mm", "pattern_count_2"], "requires_sketch": false},
+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.
cdsl = lower_legacy_profiles(cdsl)
sketches = cdsl.get("geometry", {}).get("sketches", [])
all_drawable = bool(sketches) and all(
_sketch_is_cdsl_drawable(s) for s in sketches
)
# Sketchless parameterized features (for example sphere_add) are fully
# executable by the CDSL-only runtime. ``all([])`` deliberately keeps
# that path available rather than forcing an unavailable legacy fallback.
all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches)
cdsl_only_error: Exception | None = None
if all_drawable and not force_exact:
+18
View File
@@ -373,6 +373,23 @@ def _revolve_axis(node: FeaturePlanNode, session: ExecutionSession) -> AxisSpec:
return resolution.record.value
def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None:
"""Defend direct CDSL execution from an out-of-plane revolve axis."""
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
direction_normal_dot = abs(vector_dot(axis.direction, plane.normal))
if direction_normal_dot > 1e-7:
raise ValueError(
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to "
f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}"
)
origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal))
if origin_plane_offset > 1e-6:
raise ValueError(
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in "
f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}"
)
def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, sketch: dict[str, Any] | None = None) -> FeatureResult:
# 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。
@@ -402,6 +419,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
else:
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
axis = _revolve_axis(node, session)
_validate_revolve_axis_in_sketch_plane(axis, selected_sketch)
angle = float(node.params.get("angle_deg") or 0.0)
if angle <= 0:
raise ValueError("revolve requires angle_deg > 0")
File diff suppressed because it is too large Load Diff
+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",
"part_id": "sphere_test",
"meta": {"unit": "mm"},
"geometry": {
"sketches": [{
"id": "sphere_locator",
"workplane": {
"origin_mm": [0, 0, 0],
"x_dir": [1, 0, 0],
"normal": [0, 0, 1],
},
"profile": {"type": "circle", "radius_mm": 1.0},
}],
},
"geometry": {"sketches": []},
"features": [{
"id": "sphere",
"atomic_id": "sphere_add",
"depends_on": [],
"name": "Test sphere",
"params": {"radius_mm": 2.5, "center_mm": [3.0, -4.0, 5.0]},
"sketch_id": "sphere_locator",
}],
}
+94 -1
View File
@@ -15,7 +15,7 @@ import httpx
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from app.services.visual_review import VisualReviewError, review_candidate_batch, review_checkpoint # noqa: E402
from app.services.visual_review import VisualReviewError, review_candidate_batch, review_checkpoint, review_modeling_plan, _unsupported_operation_mentions # noqa: E402
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
@@ -109,6 +109,99 @@ def _candidate_response(*, verdict: str = "accept", batch_goal_status: str = "ac
class VisualReviewCompatibilityTests(unittest.TestCase):
def test_plan_review_detects_unsupported_operation_mentions(self) -> None:
result = {
"issues": [{"type": "guidance", "step_id": "step_1", "message": "Prefer extrude_add; pattern_circular is unavailable."}],
"coverage": [],
"step_checks": [],
"action_checks": [],
}
self.assertEqual(
_unsupported_operation_mentions(result, [{"atomic_id": "extrude_add_blind"}, {"atomic_id": "extrude_add_two_sided"}]),
["extrude_add", "pattern_circular"],
)
self.assertEqual(
_unsupported_operation_mentions(
{"issues": [{"message": "A cut can use extrude_cut."}], "coverage": [], "step_checks": [], "action_checks": []},
[{"atomic_id": "extrude_cut_blind"}],
),
["extrude_cut"],
)
def test_plan_review_records_unsupported_operation_without_rejecting_semantic_plan(self) -> None:
arguments = {
"verdict": "pass",
"confidence": 0.9,
"issues": [{"type": "guidance", "step_id": "step_1", "message": "Prefer extrude_add for the boss."}],
"coverage": [{"requirement": "one connected plate", "step_id": "step_1", "status": "covered", "evidence": "The base is described."}],
"step_checks": [{"step_id": "step_1", "status": "pass", "notes": "The step is ordered."}],
"action_checks": [{"step_id": "step_1", "status": "pass", "notes": "One base feature is one action.", "required_action_count": 1}],
}
response = _Response(200, {"choices": [{"message": {"tool_calls": [{"function": {
"name": "review_modeling_plan", "arguments": json.dumps(arguments),
}}]}}]})
with tempfile.TemporaryDirectory() as temporary:
with patch("app.services.visual_review.httpx.AsyncClient", return_value=_Client([response])):
result = asyncio.run(review_modeling_plan(
settings(Path(temporary)),
source_requirements="Build a plate.",
requirements="# Frozen",
checklist=["one connected plate"],
plan={"steps": [{"step_id": "step_1", "actions": [{"action_id": "step_1_action_1"}]}]},
runtime_operations=[{"atomic_id": "extrude_add_blind"}, {"atomic_id": "extrude_add_two_sided"}],
))
self.assertEqual(result["verdict"], "pass")
self.assertEqual(result["unsupported_operations"], ["extrude_add"])
self.assertTrue(result["review_warnings"])
def test_plan_reviewer_cannot_pass_a_failed_action_check(self) -> None:
arguments = {
"verdict": "pass",
"confidence": 0.9,
"issues": [],
"coverage": [{"requirement": "left and right slots", "step_id": "step_1", "status": "covered", "evidence": "Both are named."}],
"step_checks": [{"step_id": "step_1", "status": "pass", "notes": "The related slots remain in one step."}],
"action_checks": [{"step_id": "step_1", "status": "fail", "notes": "Left and right need separate actions.", "required_action_count": 2}],
}
response = _Response(200, {"choices": [{"message": {"tool_calls": [{"function": {
"name": "review_modeling_plan", "arguments": json.dumps(arguments),
}}]}}]})
with tempfile.TemporaryDirectory() as temporary:
with patch("app.services.visual_review.httpx.AsyncClient", return_value=_Client([response, response])):
with self.assertRaisesRegex(VisualReviewError, "every action check passes"):
asyncio.run(review_modeling_plan(
settings(Path(temporary)),
source_requirements="Cut left and right slots.",
requirements="# Frozen",
checklist=["left and right slots"],
plan={"steps": [{"step_id": "step_1", "actions": [{"action_id": "step_1_action_1"}]}]},
runtime_operations=[{"atomic_id": "extrude_cut_blind"}],
))
def test_plan_reviewer_required_action_count_is_checked_against_plan_structure(self) -> None:
arguments = {
"verdict": "pass",
"confidence": 0.9,
"issues": [],
"coverage": [{"requirement": "two slots", "step_id": "step_1", "status": "covered", "evidence": "Both targets are described."}],
"step_checks": [{"step_id": "step_1", "status": "pass", "notes": "Ordering is coherent."}],
"action_checks": [{"step_id": "step_1", "status": "pass", "notes": "Two actions are required.", "required_action_count": 2}],
}
response = _Response(200, {"choices": [{"message": {"tool_calls": [{"function": {
"name": "review_modeling_plan", "arguments": json.dumps(arguments),
}}]}}]})
with tempfile.TemporaryDirectory() as temporary:
with patch("app.services.visual_review.httpx.AsyncClient", return_value=_Client([response, response])):
with self.assertRaisesRegex(VisualReviewError, "required_action_count exceeds"):
asyncio.run(review_modeling_plan(
settings(Path(temporary)),
source_requirements="Create two slots.",
requirements="# Frozen",
checklist=["two slots"],
plan={"steps": [{"step_id": "step_1", "actions": [{"action_id": "only_action"}]}]},
runtime_operations=[{"atomic_id": "extrude_cut_blind"}],
))
def _review(self, root: Path, client: _Client, *, source_requirements: str = "") -> dict[str, object]:
image = root / "iso.png"
image.write_bytes(b"png")
+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-dropdown-menu": "^2.1.24",
"@radix-ui/react-slider": "^1.4.7",
"@types/react-syntax-highlighter": "^15.5.13",
"ai": "7.0.37",
"animejs": "^4.5.0",
"clsx": "^2.1.1",
@@ -23,6 +24,10 @@
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-json-view-lite": "^2.5.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
"three": "0.160.0",
"three-mesh-bvh": "^0.8.0"
+22 -5
View File
@@ -56,14 +56,31 @@ export async function POST(request: NextRequest) {
}
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const textId = `assistant_${Date.now()}`;
writer.write({ type: "start", messageId: textId });
writer.write({ type: "text-start", id: textId });
const messageId = `assistant_${Date.now()}`;
writer.write({ type: "start", messageId });
let textPartIndex = 0;
let textId: string | null = null;
let sequence = 0;
for await (const item of parseSse(upstream)) {
const chunk = backendEventToUiChunk(item, textId);
if (item.event === "done") continue;
sequence += 1;
if (item.event === "text_delta") {
if (!textId) {
textId = `${messageId}_text_${textPartIndex++}`;
writer.write({ type: "text-start", id: textId });
}
const chunk = backendEventToUiChunk(item, textId, sequence);
if (chunk) writer.write(chunk);
continue;
}
if (textId) {
writer.write({ type: "text-end", id: textId });
textId = null;
}
const chunk = backendEventToUiChunk(item, `${messageId}_text_${textPartIndex}`, sequence);
if (chunk) writer.write(chunk);
}
writer.write({ type: "text-end", id: textId });
if (textId) writer.write({ type: "text-end", id: textId });
writer.write({ type: "finish", finishReason: "stop" });
},
});
@@ -9,3 +9,10 @@ export async function GET(_request: NextRequest, context: { params: Promise<{ ta
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
export async function DELETE(_request: NextRequest, context: { params: Promise<{ taskId: string }> }) {
const { taskId } = await context.params;
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" });
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
+23 -11
View File
@@ -395,17 +395,6 @@ button:disabled {
.studio-main { display: flex; min-height: 0; flex: 1; }
.agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); }
.preview-pane { position: relative; min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
.generation-status { position: absolute; z-index: 30; top: 12px; right: 12px; width: min(260px, calc(100% - 24px)); max-height: min(42vh, 360px); overflow: auto; border: 1px solid var(--ui-border); border-radius: 6px; background: var(--ui-glass-popover); box-shadow: var(--ui-shadow-soft); backdrop-filter: blur(12px); color: var(--ui-text); padding: 10px; font-size: 12px; }
.generation-status-heading { display: flex; align-items: center; gap: 6px; color: var(--ui-text-strong); font-weight: 650; }
.generation-status-active { margin: 6px 0 8px; color: var(--ui-accent-text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
.generation-status ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
.generation-status li { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--ui-border-muted); padding-top: 4px; color: var(--ui-text-muted); }
.generation-status li[data-status="completed"] small { color: var(--ui-success); }
.generation-status li[data-status="planned"] small { color: var(--ui-text-subtle); }
.generation-status li[data-status="failed"] small { color: var(--ui-error); }
.generation-status details { margin: 8px 0; border-top: 1px solid var(--ui-border-muted); padding-top: 6px; }
.generation-status summary { cursor: pointer; color: var(--ui-text-strong); }
.generation-status pre { margin: 6px 0 0; max-height: 132px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ui-text-muted); }
.agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; }
.agent-thread-shell { position: relative; }
.spin { animation: ui-spin 900ms linear infinite; }
@@ -421,6 +410,7 @@ button:disabled {
.assistant-row .message-role svg { color: var(--ui-accent); }
.message-content { min-width: 0; color: var(--ui-text); }
.message-text { margin: 0; font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; }
.message-text .markdown-document { margin-top: 0; border-left: 0; padding: 0; font-size: inherit; }
.message-request-error { display: flex; align-items: flex-start; gap: 7px; margin-top: 8px; color: var(--ui-error-text); font-size: 11px; line-height: 1.45; }
.message-request-error svg { flex: 0 0 auto; margin-top: 1px; }
.thread-empty { color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 2px 0 14px; }
@@ -448,7 +438,29 @@ button:disabled {
.cad-message-heading { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text); font-size: 12px; font-weight: 600; }
.cad-message-heading svg { flex: 0 0 auto; color: var(--ui-accent); }
.cad-status-label { color: var(--ui-text-subtle); font-size: 10px; font-weight: 500; text-transform: uppercase; }
.cad-tool-name { overflow: hidden; max-width: 46%; border: 1px solid var(--ui-border-muted); border-radius: 3px; background: var(--ui-control-bg); color: var(--ui-accent-text); font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; padding: 1px 4px; text-overflow: ellipsis; white-space: nowrap; }
.cad-message-copy { margin-left: 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
.cad-event-details { margin: 4px 0 0 21px; color: var(--ui-text-subtle); font-size: 10px; }
.cad-event-details summary { cursor: pointer; width: fit-content; }
.cad-event-details pre { max-height: 180px; overflow: auto; margin: 4px 0 0; border-left: 2px solid var(--ui-border-muted); padding-left: 8px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ui-text-muted); font: 10px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
.cad-event-details ul { display: grid; gap: 3px; margin: 4px 0 0; padding-left: 14px; color: var(--ui-text-muted); line-height: 1.4; }
.cad-document-details { margin-top: 7px; }
.markdown-document { margin-top: 7px; border-left: 2px solid var(--ui-accent-border); padding: 2px 0 2px 10px; color: var(--ui-text); font-size: 11px; line-height: 1.65; }
.markdown-document > :first-child { margin-top: 0; }.markdown-document > :last-child { margin-bottom: 0; }
.markdown-document h1, .markdown-document h2, .markdown-document h3 { margin: 12px 0 5px; color: var(--ui-text-strong); line-height: 1.3; }
.markdown-document h1 { font-size: 14px; }.markdown-document h2 { font-size: 13px; }.markdown-document h3 { font-size: 12px; }
.markdown-document p { margin: 5px 0; }.markdown-document ul, .markdown-document ol { display: block; margin: 5px 0; padding-left: 20px; color: var(--ui-text); }
.markdown-document li { margin: 2px 0; }.markdown-document li::marker { color: var(--ui-accent); }
.markdown-document input[type="checkbox"] { margin: 0 6px 0 0; accent-color: var(--ui-accent); }
.markdown-document blockquote { margin: 7px 0; border-left: 2px solid var(--ui-border-strong); padding-left: 9px; color: var(--ui-text-muted); }
.markdown-document a { color: var(--ui-link); text-decoration: underline; text-underline-offset: 2px; }
.markdown-document table { width: 100%; margin: 7px 0; border-collapse: collapse; font-size: 10px; }.markdown-document th, .markdown-document td { border: 1px solid var(--ui-border); padding: 5px 7px; text-align: left; }.markdown-document th { background: var(--ui-panel-raised); color: var(--ui-text-strong); }
.inline-code { border: 1px solid var(--ui-border-muted); border-radius: 3px; background: var(--ui-control-bg); color: var(--ui-accent-text); padding: 1px 4px; font: 0.92em/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
.code-viewer { position: relative; max-width: 100%; margin: 7px 0; overflow: auto; border: 1px solid var(--ui-border-strong); border-radius: 4px; background: #171a1d; color: #e8e8e3; }
.code-viewer pre { max-height: 320px; margin: 0 !important; border: 0; white-space: pre; font-size: 10px; line-height: 1.5; }
.code-language { position: sticky; left: 100%; top: 0; z-index: 1; display: block; width: max-content; margin: 5px 6px -18px auto; color: #969b9f; font-size: 9px; text-transform: uppercase; }
.json-tree { max-height: 260px; overflow: auto; margin-top: 6px; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); padding: 7px; color: var(--ui-text); font: 10px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
.json-tree .json-view--property { color: var(--ui-accent-text); }.json-tree .json-view--string { color: var(--ui-success-text); }.json-tree .json-view--number, .json-tree .json-view--boolean { color: var(--ui-secondary-text); }
.cad-progress.is-error .cad-message-heading, .cad-progress.is-error .cad-message-heading svg, .cad-error .cad-message-heading, .cad-error .cad-message-heading svg { color: var(--ui-error-text); }
.cad-result { margin-top: 12px; }
.cad-result-title { margin-left: 21px; color: var(--ui-accent-text); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; }
+28 -23
View File
@@ -165,6 +165,30 @@ export function AgentStudio() {
if (error.stage === "generation") setTaskRunning(false);
}, []);
const handleCancel = useCallback(() => {
setTaskRunning(false);
setLastError("正在停止 CAD 任务...");
void (async () => {
let taskId = selectedTaskId;
if (!taskId && conversationId) {
const conversationResponse = await fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { cache: "no-store" });
if (conversationResponse.ok) {
const conversation = await conversationResponse.json() as ConversationRecord;
taskId = conversation.current_task_id || "";
}
}
if (!taskId) throw new Error("尚未取得运行中的任务编号,请稍后重试");
const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" });
if (!response.ok) {
const payload = await response.json().catch(() => ({})) as { error?: string };
throw new Error(payload.error || "停止 CAD 任务失败");
}
setLastError("CAD 任务已停止");
})().catch((error) => {
setLastError(error instanceof Error ? error.message : "停止 CAD 任务失败");
});
}, [conversationId, selectedTaskId]);
const handleUpload = useCallback(async (files: FileList | null) => {
const selectedFiles = Array.from(files || []);
if (!selectedFiles.length) return;
@@ -247,6 +271,7 @@ export function AgentStudio() {
uploading={uploading}
uploadError={uploadError}
onUpload={handleUpload}
onCancel={handleCancel}
theme={theme}
onToggleTheme={toggleTheme}
providerId={providerId}
@@ -257,7 +282,6 @@ export function AgentStudio() {
onCadError={handleError}
onSelectionChange={setViewerSelection}
taskRunning={taskRunning}
taskRecord={taskRecord}
/>
</AgentRuntime>
);
@@ -478,6 +502,7 @@ function StudioShell({
uploading,
uploadError,
onUpload,
onCancel,
theme,
onToggleTheme,
providerId,
@@ -488,7 +513,6 @@ function StudioShell({
onCadError,
onSelectionChange,
taskRunning,
taskRecord,
}: {
config: BackendConfig | null;
cadResult: CadResult | null;
@@ -497,6 +521,7 @@ function StudioShell({
uploading: boolean;
uploadError: string;
onUpload: (files: FileList | null) => void;
onCancel: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
providerId: string;
@@ -507,7 +532,6 @@ function StudioShell({
onCadError: (error: CadError) => void;
onSelectionChange: (selection: ViewerSelectionContext | null) => void;
taskRunning: boolean;
taskRecord: TaskRecord | null;
}) {
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
const provider = config?.providers.find((item) => item.id === providerId);
@@ -531,9 +555,8 @@ function StudioShell({
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span></span></div> : null}
{config?.autonomous_generation && !config.review_configured ? <div className="config-warning"><AlertCircle size={16} /><span>{config.review_error || "请配置独立视觉模型。"}</span></div> : null}
<div className="studio-main">
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} /></aside>
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} onCancel={onCancel} /></aside>
<section className="preview-pane">
<GenerationStatus task={taskRecord} />
<CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onError={handleViewerError} onSelectionChange={onSelectionChange} />
</section>
</div>
@@ -541,24 +564,6 @@ function StudioShell({
);
}
function GenerationStatus({ task }: { task: TaskRecord | null }) {
if (task?.lifecycle !== "running") return null;
const agent = task.agent_state;
const events = agent?.recent_events || [];
return (
<aside className="generation-status" aria-live="polite">
<div className="generation-status-heading"><Loader2 className="spin" size={14} /><span></span></div>
<div className="generation-status-active">{task.active_candidate_id ? `候选 ${task.active_candidate_id}` : task.active_revision || "正在编写冻结需求"}</div>
{task.requirements_markdown ? <details open><summary></summary><pre>{task.requirements_markdown}</pre></details> : null}
{task.completion_checklist_markdown ? <details open><summary></summary><pre>{task.completion_checklist_markdown}</pre></details> : null}
{agent?.completion_ledger?.items?.length ? <ul className="completion-ledger">{agent.completion_ledger.items.map((item, index) => <li key={`${item.item || "item"}-${index}`}><span>{item.status === "complete" ? "完成" : item.status === "uncertain" ? "待确认" : "缺失"}</span><small>{item.item}{item.evidence ? `${item.evidence}` : ""}</small></li>)}</ul> : null}
{agent?.last_review ? <div className="generation-status-active">{agent.last_review.decision || "已记录"}</div> : null}
{agent?.last_diagnostic ? <div className="generation-status-active">{agent.last_diagnostic}</div> : null}
{events.length ? <ul>{events.slice(-6).map((item, index) => <li key={`${item.at || "event"}-${index}`}><span>{item.kind || item.tool || "工具"}</span><small>{item.message || "已更新"}</small></li>)}</ul> : null}
</aside>
);
}
function StudioLoading() {
return (
<main className="boot-screen">
+13 -6
View File
@@ -2,16 +2,17 @@
import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Upload } from "lucide-react";
import { useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAui, useAuiState } from "@assistant-ui/react";
import type { CadAttachment } from "@/lib/cad-types";
import { CadErrorPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload }: {
export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload, onCancel }: {
attachments: CadAttachment[];
uploading: boolean;
uploadError: string;
taskRunning?: boolean;
onUpload: (files: FileList | null) => void;
onCancel?: () => void;
}) {
const fileInput = useRef<HTMLInputElement>(null);
const dragDepth = useRef(0);
@@ -65,7 +66,7 @@ export function AgentThread({ attachments, uploading, uploadError, taskRunning =
</ThreadPrimitive.Empty>
<div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div>
</ThreadPrimitive.Viewport>
<Composer fileInput={fileInput} uploading={uploading} taskRunning={taskRunning} onUpload={onUpload} />
<Composer fileInput={fileInput} uploading={uploading} taskRunning={taskRunning} onUpload={onUpload} onCancel={onCancel} />
</ThreadPrimitive.Root>
{isDraggingFiles ? <div className="file-drop-overlay" role="status" aria-live="polite"><Upload size={24} /><span></span></div> : null}
</div>
@@ -104,8 +105,14 @@ function AssistantMessage() {
);
}
function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void }) {
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
function Composer({ fileInput, uploading, taskRunning = false, onUpload, onCancel }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void; onCancel?: () => void }) {
const aui = useAui();
const chatRunning = useAuiState((state) => state.thread.isRunning);
const running = chatRunning || taskRunning;
const handleCancel = () => {
onCancel?.();
if (chatRunning) aui.thread().cancelRun();
};
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.currentTarget.files?.length) onUpload(event.currentTarget.files);
event.currentTarget.value = "";
@@ -119,7 +126,7 @@ function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fil
<span><Check size={14} aria-hidden="true" /> Enter Shift + Enter </span>
<div className="composer-actions">
{running ? (
<span className="text-[11px] text-[var(--ui-text-subtle)]"><Loader2 className="mr-1 inline spin" size={13} aria-hidden="true" /></span>
<button type="button" className="composer-command composer-cancel" title="停止生成" aria-label="停止生成" onClick={handleCancel}><Loader2 className="spin" size={14} aria-hidden="true" /></button>
) : (
<>
<button type="button" className="composer-command composer-upload" title="上传图片或文档" aria-label="上传图片或文档" aria-busy={uploading || undefined} disabled={uploading} onClick={() => fileInput.current?.click()}>{uploading ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Paperclip size={14} aria-hidden="true" />}</button>
+20 -4
View File
@@ -1,32 +1,48 @@
"use client";
import { AlertTriangle, Box, Check, Download, Loader2 } from "lucide-react";
import { AlertTriangle, Box, Check, Download, Eye, FileCheck, Loader2, RotateCcw, Search, Wrench } from "lucide-react";
import { encodeArtifactUrl } from "@/lib/cad-artifacts";
import type { CadError, CadProgress, CadResult } from "@/lib/cad-types";
import { JsonTree, MarkdownDocument } from "./rich-content";
export function TextPart({ text }: { text: string }) {
if (!text.trim()) return null;
return <p className="message-text">{text}</p>;
return <div className="message-text"><MarkdownDocument>{text}</MarkdownDocument></div>;
}
export function CadProgressPart({ data }: { data: CadProgress }) {
if (data.step === "agent_stream") return null;
const status = String(data.status || "").toLowerCase();
const isRunning = status === "running";
const isError = status === "error";
const statusLabel = isRunning ? "进行中" : isError ? "失败" : status === "success" ? "完成" : data.status;
const Icon = data.step === "tool_call" ? Wrench : data.step.includes("review") || data.step === "final_review" ? Eye : data.step === "rollback" ? RotateCcw : data.step.includes("requirements") || data.step.includes("checklist") ? FileCheck : data.step.includes("diagnostic") ? Search : isError ? AlertTriangle : Check;
const evidence = data.evidence || (Array.isArray(data.review?.evidence) ? data.review.evidence.map(String) : []);
const documentTitle = data.step === "requirements_document" ? "冻结需求内容" : data.step === "completion_checklist" ? "完成清单内容" : "";
const documentMarkdown = data.step === "requirements_document" ? withoutRequirementsFilename(data.markdown || "") : data.markdown || "";
return (
<div className={`cad-message cad-progress${isError ? " is-error" : ""}`} role="status" aria-live="polite">
<div className="cad-message-heading">
{isRunning ? <Loader2 className="spin" size={14} aria-hidden="true" /> : isError ? <AlertTriangle size={14} aria-hidden="true" /> : <Check size={14} aria-hidden="true" />}
{isRunning ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Icon size={14} aria-hidden="true" />}
<span>{data.label || data.step}</span>
{data.tool ? <code className="cad-tool-name">{data.tool}</code> : null}
<span className="cad-status-label">{statusLabel}</span>
</div>
{data.message ? <div className="cad-message-copy">{data.message}</div> : null}
{documentMarkdown ? <details className="cad-event-details cad-document-details" open={data.step === "requirements_document"}><summary>{documentTitle || "文档内容"}</summary><MarkdownDocument>{documentMarkdown}</MarkdownDocument></details> : null}
{data.arguments ? <details className="cad-event-details"><summary></summary><JsonTree data={data.arguments} /></details> : null}
{data.result !== undefined ? <details className="cad-event-details"><summary></summary><JsonTree data={data.result} /></details> : null}
{evidence.length ? <details className="cad-event-details"><summary> ({evidence.length})</summary><ul>{evidence.map((item, index) => <li key={`${item}-${index}`}>{item}</li>)}</ul></details> : null}
</div>
);
}
function withoutRequirementsFilename(markdown: string) {
return markdown
.replace(/^\s*#{1,6}\s*`?requirements\.md`?\s*\n+/i, "")
.replace(/^\s*`?requirements\.md`?\s*\n+/i, "")
.trimStart();
}
export function CadResultPart({ data }: { data: CadResult }) {
const downloads: Array<[string, string]> = data.checkpoint ? [] : [
["STEP", data.stepPath],
+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", () => {
const result = latestSuccessfulResult({
task_id: "cad_abc",
+30 -10
View File
@@ -8,38 +8,58 @@ export type BackendSseEvent = {
export function backendEventToUiChunk(
item: BackendSseEvent,
textId: string,
): UIMessageChunk | null {
sequence = 0,
): (UIMessageChunk & { id?: string }) | null {
if (item.event === "text_delta") {
return { type: "text-delta", id: textId, delta: String(item.data.text || "") };
}
if (item.event === "progress") {
return {
type: "data-cad-progress",
id: `progress_${String(item.data.step || Date.now())}`,
data: item.data,
id: `progress_${String(item.data.taskId || "task")}_${sequence}`,
data: { ...item.data, sequence },
};
}
if (["requirements_document", "completion_checklist", "completion_audit", "agent_thinking", "tool_call", "candidate_result", "candidate_review", "geometry_diagnostic", "geometry_conclusion", "step_review", "checkpoint", "rollback", "final_review", "task_terminal"].includes(item.event)) {
if (["requirements_document", "completion_checklist", "completion_audit", "modeling_plan", "modeling_plan_review", "plan_step_skipped", "agent_thinking", "tool_call", "candidate_result", "candidate_review", "geometry_diagnostic", "geometry_conclusion", "step_review", "checkpoint", "rollback", "final_review", "task_terminal"].includes(item.event)) {
const review = item.data.review && typeof item.data.review === "object"
? item.data.review as Record<string, unknown>
: null;
const status = item.event === "task_terminal"
? (String(item.data.lifecycle || "") === "failed" ? "error" : "success")
: (item.event === "candidate_review" && String(review?.verdict || "") === "reject")
: (item.event === "modeling_plan_review" && String(review?.verdict || "") === "revise")
|| (item.event === "candidate_review" && String(review?.verdict || "") === "reject")
|| (item.event === "final_review" && String(review?.verdict || "") === "repair" && Number(review?.confidence || 0) >= 0.85)
? "error"
: String(item.data.status || "running");
const taskId = String(item.data.taskId || "task");
const eventId = String(item.data.eventId || `${taskId}_${sequence}_${item.event}`);
const metadata = sequence > 0 || item.data.eventId
? {
eventId,
sequence,
...(review ? { review } : {}),
}
: {};
return {
type: "data-cad-progress",
id: `${item.event}_${String(item.data.taskId || Date.now())}_${String(item.data.nodeId || "")}`,
id: `event_${eventId}`,
data: { step: item.event, label: ({
requirements_document: "冻结需求", completion_checklist: "完成清单", completion_audit: "完成审计", agent_thinking: "建模判断", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", geometry_diagnostic: "几何诊断", geometry_conclusion: "几何结论", step_review: "步骤审查", checkpoint: "构建检查点", rollback: "回滚检查点", final_review: "最终视觉复核", task_terminal: "生成任务",
} as Record<string, string>)[item.event], status, message: String(
item.data.message || item.data.reason || (review?.evidence instanceof Array ? review.evidence.join("") : ""),
requirements_document: "冻结需求", completion_checklist: "完成清单", completion_audit: "完成审计", modeling_plan: "建模计划", modeling_plan_review: "计划独立复核", plan_step_skipped: "计划步骤已满足", agent_thinking: "建模判断", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", geometry_diagnostic: "几何诊断", geometry_conclusion: "几何结论", step_review: "步骤审查", checkpoint: "构建检查点", rollback: "回滚检查点", final_review: "最终视觉复核", task_terminal: "生成任务",
} as Record<string, string>)[item.event], status, ...metadata, message: String(
item.data.message || item.data.reason
|| (review?.evidence instanceof Array ? review.evidence.join("") : "")
|| (review?.issues instanceof Array ? review.issues.map((issue) => typeof issue === "object" && issue ? String((issue as Record<string, unknown>).message || "") : String(issue)).filter(Boolean).join("") : ""),
),
...(item.data.taskId ? { taskId: String(item.data.taskId) } : {}),
...(item.data.taskId ? { taskId } : {}),
...(item.data.nodeId ? { nodeId: String(item.data.nodeId) } : {}),
...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}),
...(item.data.timestamp ? { timestamp: String(item.data.timestamp) } : {}),
...(item.data.markdown ? { markdown: String(item.data.markdown) } : {}),
...(item.data.tool ? { tool: String(item.data.tool) } : {}),
...(item.data.invocationId ? { invocationId: String(item.data.invocationId) } : {}),
...(item.data.arguments && typeof item.data.arguments === "object" ? { arguments: item.data.arguments as Record<string, unknown> } : {}),
...(item.data.result !== undefined ? { result: item.data.result } : {}),
...(Array.isArray(item.data.evidence) ? { evidence: item.data.evidence.map(String) } : {}),
},
};
}
+18
View File
@@ -4,9 +4,19 @@ export type CadProgress = {
step: string;
label: string;
status: "running" | "success" | "error" | string;
eventId?: string;
sequence?: number;
timestamp?: string;
message?: string;
markdown?: string;
taskId?: string;
nodeId?: string;
tool?: string;
invocationId?: string;
arguments?: Record<string, unknown>;
result?: unknown;
evidence?: string[];
review?: Record<string, unknown>;
lifecycle?: "running" | "completed" | "failed" | string;
attempt?: number;
maxAttempts?: number;
@@ -96,12 +106,20 @@ export type TaskRecord = {
requirements_markdown?: string | null;
completion_checklist_path?: string;
completion_checklist_markdown?: string | null;
modeling_plan_path?: string;
modeling_plan_review_path?: string;
modeling_plan_version?: number;
modeling_plan_markdown?: string | null;
modeling_plan_review?: Record<string, unknown> | null;
agent_state?: {
no_progress?: number;
cycle_tool_calls?: number;
last_diagnostic?: string;
last_review?: { candidate_id?: string; path?: string; decision?: string; recorded_at?: string };
last_candidate_review?: { verdict?: "accept" | "reject" | string; batch_goal?: string; batch_goal_status?: string; evidence?: string[]; recorded_at?: string };
modeling_plan_status?: "missing" | "pending_review" | "revise" | "approved" | "stale" | string;
active_plan_step_id?: string;
plan_step_status?: Record<string, "pending" | "complete" | string>;
completion_ledger?: {
verified_revision?: string;
items?: Array<{ item?: string; status?: "complete" | "missing" | "uncertain" | string; evidence?: string }>;