Initial commit

This commit is contained in:
2026-08-19 19:34:30 +08:00
commit beba8744b4
285 changed files with 74293 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+362
View File
@@ -0,0 +1,362 @@
from __future__ import annotations
import asyncio
import base64
import json
import secrets
from collections.abc import AsyncIterator
from typing import Any
import httpx
from app.models.contracts import ChatMessage
from app.services.engine_service import build_revision, load_engine
from app.services.library import CdslLibrary
from app.services.sse import event
from app.services.storage import WorkspaceStore
from app.settings import ProviderConfig, ProviderModel, Settings
TOOL_SCHEMAS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "search_cdsl_library",
"description": "Search the official local CDSL library for similar geometry and feature sequences.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 8}},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "read_cdsl_reference",
"description": "Read one official CDSL sample by part_id. Use this before creating geometry based on a reference.",
"parameters": {"type": "object", "properties": {"part_id": {"type": "string"}}, "required": ["part_id"]},
},
},
{
"type": "function",
"function": {
"name": "read_current_cdsl",
"description": "Read the current task's latest CDSL before making a natural-language revision.",
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
},
},
{
"type": "function",
"function": {
"name": "generate_cdsl_model",
"description": "Validate and execute a complete parameterized CDSL model. Use only for explicit CAD generation or revision.",
"parameters": {
"type": "object",
"properties": {
"cdsl": {"type": "object", "description": "Complete cad.cdsl.llm.v1 JSON object."},
"summary": {"type": "string"},
"assumptions": {"type": "array", "items": {"type": "string"}},
},
"required": ["cdsl", "summary"],
},
},
},
]
def text_from_message(message: ChatMessage) -> str:
return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip()
def messages_for_model(messages: list[ChatMessage]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for message in messages[-20:]:
text = text_from_message(message)
if text:
result.append({"role": message.role, "content": text})
return result
def system_prompt(settings: Settings) -> str:
skill_path = settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "SKILL.md"
skill = skill_path.read_text(encoding="utf-8") if skill_path.is_file() else ""
readme_path = settings.engine_root / "README.md"
engine_readme = readme_path.read_text(encoding="utf-8") if readme_path.is_file() else ""
supported_profiles = ", ".join(sorted(load_engine(settings).SHAPE_GENERATORS))
return f"""You are the CDSL CAD Agent for CDSL CAD Studio.
You generate parameterized CDSL, never raw CAD source code. For new CAD requests:
1. Search the local official CDSL library.
2. Read at least one relevant reference when a match exists.
3. Call generate_cdsl_model only when the request is sufficiently specified.
4. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact.
For a revision, call read_current_cdsl first and preserve unrelated features.
Do not output compiler_context, unknown_shape, complex_arc_shape, entities,
contour_edges_mm, or contour_regions_mm. Use only self-contained named profiles
supported by the engine. Ask a concise clarification question when essential
dimensions or intent are missing. Ordinary explanations must not create CAD.
Local skill:
{skill}
Local engine guide:
{engine_readme}
Supported named profile types:
{supported_profiles}
"""
class AgentService:
def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None:
self.settings = settings
self.store = store
self.library = library
async def stream(
self,
messages: list[ChatMessage],
conversation_id: str | None,
selected_task_id: str | None,
provider_id: str | None = None,
model_id: str | None = None,
) -> AsyncIterator[bytes]:
latest_user = next((message for message in reversed(messages) if message.role == "user"), None)
if latest_user is None:
yield event("cad_error", {"stage": "request", "message": "A user message is required."})
yield event("done", {})
return
user_text = text_from_message(latest_user)
conversation = self.store.ensure_conversation(conversation_id, selected_task_id)
self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), selected_task_id)
task_id = selected_task_id or conversation.get("current_task_id") or ""
assistant_parts: list[dict[str, Any]] = []
assistant_id = f"assistant_{secrets.token_hex(8)}"
successful_result: dict[str, Any] | None = None
error_payload: dict[str, Any] | None = None
try:
provider, model = self.settings.resolve_model(provider_id, model_id)
except ValueError as error:
provider = None
model = None
configuration_error = str(error)
else:
configuration_error = ""
if not self.settings.llm_configured or provider is None or model is None:
message = "Agent 尚未配置模型。请设置 CDSL_LLM_BASE_URL、CDSL_LLM_API_KEY 和 CDSL_LLM_MODEL。"
if configuration_error:
message = configuration_error
error_payload = {"stage": "configuration", "message": message}
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
yield event("cad_error", error_payload)
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id)
yield event("done", {})
return
try:
attachment_message = self._attachment_message(conversation, model)
except ValueError as error:
error_payload = {"stage": "attachment", "message": str(error)}
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
yield event("cad_error", error_payload)
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id)
yield event("done", {})
return
yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"})
references: list[str] = []
model_messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt(self.settings)}]
model_messages.extend(messages_for_model(messages))
if attachment_message:
model_messages.append({"role": "user", "content": attachment_message})
tools = TOOL_SCHEMAS
try:
for iteration in range(8):
response = await self._complete(model_messages, tools, provider, model)
choice = response["choices"][0]["message"]
tool_calls = choice.get("tool_calls") or []
content = str(choice.get("content") or "")
if content:
assistant_parts.append({"type": "text", "text": content})
for chunk in self._chunks(content):
yield event("text_delta", {"text": chunk})
if not tool_calls:
break
model_messages.append(choice)
for call in tool_calls:
name = str(call.get("function", {}).get("name") or "")
arguments = json.loads(call.get("function", {}).get("arguments") or "{}")
yield event("progress", {
"step": name,
"label": self._tool_label(name),
"status": "running",
"message": "Agent 正在调用本地 CAD 工具。",
})
result, generated = await self._run_tool(name, arguments, task_id, user_text, references)
if generated:
task_id = generated["task_id"]
model_messages.append({
"role": "tool",
"tool_call_id": call.get("id", ""),
"content": json.dumps(result, ensure_ascii=False),
})
yield event("progress", {
"step": name,
"label": self._tool_label(name),
"status": "success" if result.get("ok", True) else "error",
"message": result.get("message") or result.get("summary") or "",
})
if generated:
result_payload = {
"taskId": generated["task_id"],
"revisionId": generated["revision_id"],
"cdslPath": generated["cdsl_path"],
"stepPath": generated["step_path"],
"glbPath": generated["glb_path"],
"reportPath": generated["report_path"],
"parametersPath": generated.get("parameters_path"),
"selectorPath": generated.get("selector_path"),
"edgesPath": generated.get("edges_path"),
"summary": generated["summary"],
"referenceIds": generated["reference_ids"],
"engine": generated["engine"],
}
successful_result = result_payload
assistant_parts.append({"type": "data-cad-result", "data": result_payload})
yield event("cad_result", result_payload)
if iteration == 7:
error_payload = {"stage": "agent", "message": "Agent tool loop reached its safety limit."}
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
yield event("cad_error", error_payload)
except Exception as error:
error_payload = {"stage": "agent", "message": str(error)}
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
yield event("cad_error", error_payload)
if task_id:
self.store.ensure_conversation(conversation["conversation_id"], task_id)
if not assistant_parts:
assistant_parts.append({
"type": "text",
"text": "我暂时没有生成可执行的 CAD 结果。请补充尺寸、形状或修改目标。",
})
if successful_result and not any(part.get("type") == "text" for part in assistant_parts):
assistant_parts.insert(0, {"type": "text", "text": f"已生成:{successful_result['summary']}。"})
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id)
yield event("progress", {"step": "agent_stream", "label": "调用模型和工具", "status": "success", "message": "Agent 请求已完成。"})
yield event("done", {})
def _persist_assistant(
self,
conversation_id: str,
assistant_id: str,
parts: list[dict[str, Any]],
task_id: str,
) -> None:
self.store.append_conversation_message(
conversation_id,
{
"id": assistant_id or f"assistant_{conversation_id}_{len(parts)}",
"role": "assistant",
"parts": parts,
},
task_id or None,
)
async def _complete(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
provider: ProviderConfig,
model: ProviderModel,
) -> dict[str, Any]:
url = f"{provider.base_url}/chat/completions"
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
payload = {"model": model.id, "messages": messages, "tools": tools, "tool_choice": "auto", "temperature": 0.1}
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client:
response = await client.post(url, headers=headers, json=payload)
if response.status_code >= 400:
raise RuntimeError(f"LLM request failed ({response.status_code}): {response.text[:800]}")
return response.json()
async def _run_tool(self, name: str, arguments: dict[str, Any], task_id: str, request: str, references: list[str]) -> tuple[dict[str, Any], dict[str, Any] | None]:
if name == "search_cdsl_library":
results = self.library.search(str(arguments.get("query") or request), int(arguments.get("limit") or 5))
return {"ok": True, "results": results}, None
if name == "read_cdsl_reference":
part_id = str(arguments.get("part_id") or "")
sample = self.library.read_sample(part_id)
if part_id not in references:
references.append(part_id)
return {"ok": True, "part_id": part_id, "cdsl": sample}, None
if name == "read_current_cdsl":
if not task_id:
return {"ok": False, "message": "No current task exists. This is a new model request."}, None
path = self.store.current_cdsl_path(task_id)
if path is None:
return {"ok": False, "message": "The current task has no successful CDSL revision."}, None
return {"ok": True, "task_id": task_id, "cdsl": json.loads(path.read_text(encoding="utf-8"))}, None
if name == "generate_cdsl_model":
cdsl = arguments.get("cdsl")
if isinstance(cdsl, str):
cdsl = json.loads(cdsl)
if not isinstance(cdsl, dict):
raise ValueError("generate_cdsl_model requires a CDSL JSON object")
summary = str(arguments.get("summary") or "CDSL CAD model")
yieldable = await asyncio.to_thread(
build_revision,
settings=self.settings,
store=self.store,
task_id=task_id or None,
request=request,
cdsl=cdsl,
reference_ids=list(references),
summary=summary,
)
return {"ok": True, "summary": summary, "task_id": yieldable["task_id"], "revision_id": yieldable["revision_id"]}, yieldable
raise ValueError(f"Unknown agent tool: {name}")
@staticmethod
def _chunks(text: str) -> list[str]:
return [text[index:index + 96] for index in range(0, len(text), 96)]
@staticmethod
def _tool_label(name: str) -> str:
return {
"search_cdsl_library": "检索 CDSL 模型库",
"read_cdsl_reference": "读取 CDSL 参考模型",
"read_current_cdsl": "读取当前 CDSL",
"generate_cdsl_model": "生成 CDSL CAD 模型",
}.get(name, "调用 CAD 工具")
def _attachment_message(self, conversation: dict[str, Any], model: ProviderModel) -> list[dict[str, Any]] | str:
attachments = conversation.get("attachments") or []
if not attachments:
return ""
content: list[dict[str, Any]] = [{"type": "text", "text": "The following local attachments are part of the CAD request."}]
for attachment in attachments:
if not isinstance(attachment, dict):
continue
kind = str(attachment.get("kind") or "")
task_id = str(attachment.get("task_id") or "")
relative = str(attachment.get("path") or "")
if not task_id or not relative:
continue
path = self.store.artifact_path(task_id, relative)
if kind == "image":
if not model.vision:
raise ValueError("The selected model does not support images. Choose a vision-capable OpenAI or Kimi model.")
mime = str(attachment.get("mime") or "image/png")
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}})
elif kind == "document":
extracted = str(attachment.get("extracted_path") or "")
if extracted:
text_path = self.store.artifact_path(task_id, extracted)
text = text_path.read_text(encoding="utf-8")[:30_000]
content.append({"type": "text", "text": f"Document {attachment.get('name')}:\n{text}"})
return content
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import hashlib
from pathlib import Path
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json"}
MAX_IMAGE_BYTES = 10 * 1024 * 1024
MAX_DOCUMENT_BYTES = 2 * 1024 * 1024
MAX_EXTRACTED_CHARS = 30_000
def classify_upload(filename: str, mime: str, size: int) -> str:
suffix = Path(filename).suffix.lower()
if suffix in {".step", ".stp"}:
raise ValueError("STEP/STP upload is not supported in this version")
if suffix in IMAGE_SUFFIXES or mime.startswith("image/"):
if size > MAX_IMAGE_BYTES:
raise ValueError("Image upload exceeds the 10 MB limit")
return "image"
if suffix in DOCUMENT_SUFFIXES:
if size > MAX_DOCUMENT_BYTES:
raise ValueError("Document upload exceeds the 2 MB limit")
return "document"
raise ValueError("Only PNG, JPG, WEBP, TXT, MD, CSV, and JSON uploads are supported")
def extract_document_text(data: bytes) -> str:
try:
text = data.decode("utf-8")
except UnicodeDecodeError as error:
raise ValueError("Documents must be UTF-8 text") from error
return text[:MAX_EXTRACTED_CHARS]
def attachment_record(task_id: str, filename: str, mime: str, relative_path: str, data: bytes, kind: str, extracted_path: str = "") -> dict[str, object]:
return {
"id": Path(relative_path).stem,
"task_id": task_id,
"name": filename,
"kind": kind,
"path": relative_path,
"mime": mime or "application/octet-stream",
"size": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
"extracted_path": extracted_path,
}
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
import copy
import json
import math
from typing import Any
from app.services.engine_service import build_revision
from app.services.storage import WorkspaceStore
from app.settings import Settings
SUPPORTED_OPERATIONS = {
"add_hole", "add_counterbore", "add_countersink", "add_slot",
"add_pocket", "add_circular_pocket", "add_hole_pattern",
}
def _number(values: dict[str, Any], name: str, fallback: float, minimum: float = 0.01) -> float:
value = float(values.get(name, fallback))
if not math.isfinite(value) or value < minimum:
raise ValueError(f"{name} must be a finite number >= {minimum}")
return value
def _selection_frame(selection: dict[str, Any]) -> dict[str, list[float]]:
pick = selection.get("pick") if isinstance(selection.get("pick"), dict) else selection
surface = pick.get("surface") if isinstance(pick.get("surface"), dict) else {}
surface_type = str(surface.get("type") or surface.get("surfaceType") or "").lower()
if surface_type and "plane" not in surface_type:
raise ValueError("Direct CDSL edits currently require a planar face")
frame = pick.get("frame") if isinstance(pick.get("frame"), dict) else {}
origin = frame.get("origin_mm") or pick.get("center") or pick.get("point")
normal = frame.get("normal") or pick.get("normal")
x_dir = frame.get("x_dir") or frame.get("xDir") or [1.0, 0.0, 0.0]
y_dir = frame.get("y_dir") or frame.get("yDir") or [0.0, 1.0, 0.0]
if not all(isinstance(value, list) and len(value) >= 3 for value in (origin, normal, x_dir, y_dir)):
raise ValueError("Select a planar face before applying a direct CDSL edit")
return {
"origin_mm": [float(item) for item in origin[:3]],
"normal": [float(item) for item in normal[:3]],
"x_dir": [float(item) for item in x_dir[:3]],
"y_dir": [float(item) for item in y_dir[:3]],
}
def _next_id(prefix: str, existing: set[str]) -> str:
index = 1
while f"{prefix}_{index:03d}" in existing:
index += 1
return f"{prefix}_{index:03d}"
def _profile_for(operation: str, values: dict[str, Any]) -> tuple[dict[str, Any], float]:
depth = _through_depth(values)
if operation in {"add_hole", "add_counterbore", "add_countersink", "add_circular_pocket"}:
diameter = _number(values, "holeDiameter", values.get("diameter", 10.0))
return {"type": "circle", "center": [0.0, 0.0], "radius_mm": diameter / 2}, depth
if operation == "add_slot":
width = _number(values, "slotWidth", values.get("width", 8.0))
length = _number(values, "slotLength", values.get("length", width * 3))
return {"type": "obround", "center": [0.0, 0.0], "length_mm": max(length, width), "width_mm": width}, depth
if operation == "add_pocket":
width = _number(values, "width", 20.0)
height = _number(values, "height", 12.0)
return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height}, depth
if operation == "add_hole_pattern":
diameter = _number(values, "holeDiameter", values.get("diameter", 6.0))
rows = max(1, int(_number(values, "rows", 2, 1)))
columns = max(1, int(_number(values, "columns", 2, 1)))
return {
"type": "circle_grid", "radius_mm": diameter / 2,
"count_x": columns, "count_y": rows,
"spacing_x_mm": _number(values, "pitchX", 12.0),
"spacing_y_mm": _number(values, "pitchY", 12.0),
"center_mm": [0.0, 0.0],
}, depth
raise ValueError(f"Unsupported direct CDSL edit: {operation}")
def _through_depth(values: dict[str, Any]) -> float:
# A through cut deliberately exceeds the model bounds. build123d clips the
# cutter against the solid, so this remains deterministic for any part size.
return 10000.0 if str(values.get("depth") or "").lower() == "through" else _number(values, "depth", 10.0)
def _hole_feature(operation: str, frame: dict[str, list[float]], values: dict[str, Any]) -> dict[str, Any]:
diameter = _number(values, "holeDiameter", values.get("diameter", 10.0))
params: dict[str, Any] = {
"diameter_mm": diameter,
"depth_mm": _through_depth(values),
"positions": [{"mm": [0.0, 0.0, 0.0]}],
"host_face": {"frame": frame},
}
atomic = "hole_blind"
if operation == "add_counterbore":
counterbore_diameter = _number(values, "counterboreDiameter", diameter * 2)
if counterbore_diameter <= diameter:
raise ValueError("counterboreDiameter must be larger than holeDiameter")
params["counterbore_diameter_mm"] = counterbore_diameter
params["counterbore_depth_mm"] = _number(values, "counterboreDepth", min(diameter, 2.0))
atomic = "hole_counterbore"
elif operation == "add_countersink":
countersink_diameter = _number(values, "countersinkDiameter", diameter * 2)
if countersink_diameter <= diameter:
raise ValueError("countersinkDiameter must be larger than holeDiameter")
params["countersink_diameter_mm"] = countersink_diameter
params["countersink_angle_rad"] = math.radians(_number(values, "countersinkAngleDeg", 90.0, 1.0))
atomic = "hole_countersink"
return {"atomic": atomic, "params": params}
def _slot_frame(frame: dict[str, list[float]], picks: list[dict[str, Any]]) -> tuple[dict[str, list[float]], float]:
if len(picks) < 2:
raise ValueError("Select the two endpoints for the slot")
first = _selection_frame({"pick": picks[0]})
second = _selection_frame({"pick": picks[1]})
vector = [second["origin_mm"][index] - first["origin_mm"][index] for index in range(3)]
length = math.sqrt(sum(value * value for value in vector))
if length < 0.01:
raise ValueError("Slot endpoints must be distinct")
x_dir = [value / length for value in vector]
normal = first["normal"]
y_dir = [
normal[1] * x_dir[2] - normal[2] * x_dir[1],
normal[2] * x_dir[0] - normal[0] * x_dir[2],
normal[0] * x_dir[1] - normal[1] * x_dir[0],
]
midpoint = [(first["origin_mm"][index] + second["origin_mm"][index]) / 2 for index in range(3)]
return {"origin_mm": midpoint, "normal": normal, "x_dir": x_dir, "y_dir": y_dir}, length
def apply_direct_edit(
settings: Settings,
store: WorkspaceStore,
task_id: str,
operation: str,
selection: dict[str, Any],
parameters: dict[str, Any],
) -> dict[str, Any]:
if operation in {"add_chamfer", "add_fillet"}:
raise ValueError("Chamfer and fillet require a stable CDSL edge anchor and are not available for this model yet")
if operation not in SUPPORTED_OPERATIONS:
raise ValueError(f"Unsupported direct CDSL edit: {operation}")
source = store.current_cdsl_path(task_id)
task = store.read_task(task_id)
revision_id = str((task or {}).get("current_revision") or "")
if source is None or not revision_id:
raise ValueError("Task has no successful CDSL revision")
frame = _selection_frame(selection)
cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8")))
features = cdsl.setdefault("features", [])
sketches = cdsl.setdefault("geometry", {}).setdefault("sketches", [])
picks = selection.get("picks") if isinstance(selection.get("picks"), list) else []
if operation == "add_slot":
frame, slot_length = _slot_frame(frame, [pick for pick in picks if isinstance(pick, dict)])
parameters = {**parameters, "slotLength": slot_length}
profile, depth = _profile_for(operation, parameters)
feature_id = _next_id("edit", {str(item.get("id")) for item in features})
sketch_id = _next_id("edit_sketch", {str(item.get("id")) for item in sketches})
dependency = str(features[-1].get("id")) if features else ""
sketches.append({"id": sketch_id, "name": operation, "workplane": frame, "profile": profile})
feature: dict[str, Any] = {
"id": feature_id,
"depends_on": [dependency] if dependency else [],
"name": operation,
"sketch_id": sketch_id,
}
if operation in {"add_hole", "add_counterbore", "add_countersink"}:
hole = _hole_feature(operation, frame, parameters)
feature["atomic_id"] = hole["atomic"]
feature["params"] = hole["params"]
else:
feature["atomic_id"] = "extrude_cut_blind"
feature["params"] = {"distance_mm": depth}
features.append(feature)
return build_revision(
settings=settings,
store=store,
task_id=task_id,
request=f"Direct CDSL edit: {operation}",
cdsl=cdsl,
reference_ids=[],
summary=f"Applied {operation}",
parent_revision_id=revision_id,
operation={"type": operation, "selection": selection, "parameters": parameters},
)
+334
View File
@@ -0,0 +1,334 @@
from __future__ import annotations
import copy
import json
import math
import re
import sys
from pathlib import Path
from typing import Any
from vendor.cdsl_preview_runtime import step_to_glb
from app.services.storage import WorkspaceStore, now_iso, write_json
from app.settings import Settings
def load_engine(settings: Settings) -> Any:
parent = str(settings.engine_root.parent)
if parent not in sys.path:
sys.path.insert(0, parent)
import cdsl_engine
return cdsl_engine
def _walk(value: Any) -> list[tuple[str, Any]]:
result: list[tuple[str, Any]] = []
if isinstance(value, dict):
for key, child in value.items():
result.append((str(key), child))
result.extend(_walk(child))
elif isinstance(value, list):
for child in value:
result.extend(_walk(child))
return result
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
if not isinstance(cdsl, dict):
raise ValueError("CDSL must be a JSON object")
if cdsl.get("schema") != "cad.cdsl.llm.v1":
raise ValueError("Unsupported CDSL schema")
part_id = str(cdsl.get("part_id") or "")
if not re.fullmatch(r"[a-zA-Z0-9_-]{3,80}", part_id):
raise ValueError("part_id must use letters, numbers, underscores, or hyphens")
forbidden = {"compiler_context", "unknown_shape", "complex_arc_shape", "contour_edges_mm", "contour_regions_mm", "entities"}
for key, value in _walk(cdsl):
if key in forbidden or (isinstance(value, str) and value in {"unknown_shape", "complex_arc_shape"}):
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")
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
feature_ids: set[str] = set()
for feature in features:
fid = str(feature.get("id") or "")
if not fid or fid in feature_ids:
raise ValueError("Feature ids must be unique")
feature_ids.add(fid)
if str(feature.get("sketch_id") or "") not in sketch_ids:
raise ValueError(f"Feature {fid} refers to a missing sketch")
if not str(feature.get("atomic_id") or ""):
raise ValueError(f"Feature {fid} has no atomic_id")
for dependency in feature.get("depends_on") or []:
if dependency not in feature_ids:
raise ValueError(f"Feature {fid} has a forward or missing dependency")
for sketch in sketches:
profile = sketch.get("profile")
if sketch.get("profile_from"):
continue
if not isinstance(profile, dict):
raise ValueError(f"Sketch {sketch.get('id')} has no self-contained profile")
profile_type = str(profile.get("type") or "")
if profile_type == "polygon":
if not profile.get("vertices"):
raise ValueError("Polygon profiles require vertices")
elif profile_type not in engine.SHAPE_GENERATORS:
raise ValueError(f"Unsupported CDSL profile: {profile_type}")
def _parameter_id(path: list[str]) -> str:
return "param_" + "_".join(re.sub(r"[^a-zA-Z0-9]+", "_", item).strip("_") for item in path)
def _parameter_label(path: list[str]) -> str:
return " / ".join(path[-2:]).replace("_mm", " (mm)").replace("_", " ")
def _derived_parameters(cdsl: dict[str, Any]) -> list[dict[str, Any]]:
parameters: list[dict[str, Any]] = []
def add(path: list[str], value: Any, group: str) -> None:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
return
number = float(value)
magnitude = max(abs(number), 1.0)
parameters.append({
"id": _parameter_id(path),
"name": ".".join(path),
"display_name": _parameter_label(path),
"path": path,
"value": number,
"default_value": number,
"minimum": 0.01 if number >= 0 else -magnitude * 10,
"maximum": magnitude * 10,
"step": 0.1 if abs(number) < 100 else 1.0,
"precision": 2,
"unit": "mm" if path[-1].endswith("_mm") else "",
"group": group,
"editable": True,
})
for feature_index, feature in enumerate(cdsl.get("features") or []):
for key, value in (feature.get("params") or {}).items():
add(["features", str(feature_index), "params", str(key)], value, "Features")
for sketch_index, sketch in enumerate(cdsl.get("geometry", {}).get("sketches") or []):
profile = sketch.get("profile") or {}
def walk_profile(value: Any, path: list[str]) -> None:
if isinstance(value, dict):
for key, child in value.items():
walk_profile(child, [*path, str(key)])
elif isinstance(value, list):
# Coordinates are topology anchors, not user-facing dimensions.
return
else:
add(path, value, "Sketches")
walk_profile(profile, ["geometry", "sketches", str(sketch_index), "profile"])
return parameters
def parameter_contract(cdsl: dict[str, Any]) -> dict[str, Any]:
declared = cdsl.get("meta", {}).get("editable_parameters")
if isinstance(declared, list) and declared:
values = [item for item in declared if isinstance(item, dict) and isinstance(item.get("path"), list)]
if values:
return {"schema_version": "1.0", "parameters": values, "source": "declared"}
return {"schema_version": "1.0", "parameters": _derived_parameters(cdsl), "source": "derived"}
def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
topology_faces = (preview or {}).get("topology_faces")
if isinstance(topology_faces, list) and topology_faces:
references = []
for face in topology_faces:
if not isinstance(face, dict):
continue
frame = face.get("frame")
center = face.get("center")
normal = face.get("normal")
if not isinstance(frame, dict) or not isinstance(center, list) or not isinstance(normal, list):
continue
references.append({
"id": str(face.get("id") or f"face_{len(references):03d}"),
"selectorType": "face",
"label": str(face.get("surface_type") or "face"),
"center": center,
"normal": normal,
"frame": frame,
"bbox": face.get("bbox") or {},
"surface_type": str(face.get("surface_type") or "unknown"),
"triangle_start": int(face.get("triangle_start") or 0),
"triangle_count": int(face.get("triangle_count") or 0),
})
if references:
return ({"schema_version": "1.1", "references": references}, {"schema_version": "1.0", "edges": []})
bbox = engine_result.get("bbox_mm") or {}
minimum = [float(value) for value in bbox.get("min") or [0, 0, 0]]
maximum = [float(value) for value in bbox.get("max") or [0, 0, 0]]
if len(minimum) != 3 or len(maximum) != 3:
raise ValueError("Engine result is missing a valid bounding box")
center = [(minimum[index] + maximum[index]) / 2 for index in range(3)]
definitions = [
("top", [center[0], center[1], maximum[2]], [0, 0, 1], [1, 0, 0], [0, 1, 0]),
("bottom", [center[0], center[1], minimum[2]], [0, 0, -1], [1, 0, 0], [0, -1, 0]),
("right", [maximum[0], center[1], center[2]], [1, 0, 0], [0, 1, 0], [0, 0, 1]),
("left", [minimum[0], center[1], center[2]], [-1, 0, 0], [0, 1, 0], [0, 0, -1]),
("front", [center[0], maximum[1], center[2]], [0, 1, 0], [1, 0, 0], [0, 0, -1]),
("back", [center[0], minimum[1], center[2]], [0, -1, 0], [1, 0, 0], [0, 0, 1]),
]
references = [
{
"id": f"face_{name}", "selectorType": "face", "label": name,
"center": point, "normal": normal,
"frame": {"origin_mm": point, "normal": normal, "x_dir": x_dir, "y_dir": y_dir},
"bbox": {"min": minimum, "max": maximum},
}
for name, point, normal, x_dir, y_dir in definitions
]
return ({"schema_version": "1.0", "references": references}, {"schema_version": "1.0", "edges": []})
def _set_parameter_value(document: dict[str, Any], path: list[str], value: float) -> None:
target: Any = document
for index, key in enumerate(path):
final = index == len(path) - 1
if isinstance(target, list):
item_index = int(key)
if item_index < 0 or item_index >= len(target):
raise ValueError("Parameter path is no longer valid")
if final:
target[item_index] = value
else:
target = target[item_index]
elif isinstance(target, dict):
if key not in target:
raise ValueError("Parameter path is no longer valid")
if final:
target[key] = value
else:
target = target[key]
else:
raise ValueError("Parameter path is no longer valid")
def apply_parameter_updates(cdsl: dict[str, Any], values: dict[str, float]) -> tuple[dict[str, Any], dict[str, Any]]:
contract = parameter_contract(cdsl)
entries = {str(item.get("id")): item for item in contract["parameters"]}
updated = copy.deepcopy(cdsl)
for parameter_id, raw_value in values.items():
entry = entries.get(parameter_id)
value = float(raw_value)
if entry is None or not entry.get("editable", False):
raise ValueError(f"Unknown editable parameter: {parameter_id}")
if not math.isfinite(value):
raise ValueError("Parameter values must be finite")
minimum, maximum = entry.get("minimum"), entry.get("maximum")
if isinstance(minimum, (int, float)) and value < float(minimum):
raise ValueError(f"{parameter_id} is below its minimum")
if isinstance(maximum, (int, float)) and value > float(maximum):
raise ValueError(f"{parameter_id} is above its maximum")
path = entry.get("path")
if not isinstance(path, list) or not all(isinstance(item, str) for item in path):
raise ValueError(f"{parameter_id} has an invalid path")
_set_parameter_value(updated, path, value)
declared = updated.get("meta", {}).get("editable_parameters")
if isinstance(declared, list):
for declared_entry in declared:
if isinstance(declared_entry, dict) and str(declared_entry.get("id")) == parameter_id:
declared_entry["value"] = value
return updated, parameter_contract(updated)
def build_revision(
*,
settings: Settings,
store: WorkspaceStore,
task_id: str | None,
request: str,
cdsl: dict[str, Any],
reference_ids: list[str],
summary: str,
parent_revision_id: str | None = None,
operation: dict[str, Any] | None = None,
attachments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
engine = load_engine(settings)
task = store.ensure_task(task_id, request)
revision_id, revision_dir = store.next_revision(task["task_id"])
cdsl_copy = copy.deepcopy(cdsl)
cdsl_copy["part_id"] = task["task_id"]
meta = cdsl_copy.setdefault("meta", {})
if not isinstance(meta, dict):
raise ValueError("CDSL meta must be an object when present")
if not isinstance(meta.get("editable_parameters"), list) or not meta["editable_parameters"]:
meta["editable_parameters"] = _derived_parameters(cdsl_copy)
validate_cdsl(cdsl_copy, engine)
cdsl_path = revision_dir / "model.cdsl.json"
step_path = revision_dir / "model.step"
glb_path = revision_dir / "model.glb"
report_path = revision_dir / "rebuild-report.json"
request_path = revision_dir / "request.json"
references_path = revision_dir / "references.json"
parameters_path = revision_dir / "parameters.json"
selector_path = revision_dir / "model.selector.json"
edges_path = revision_dir / "model.edges.json"
write_json(request_path, {"request": request, "created_at": now_iso()})
write_json(references_path, {"reference_ids": reference_ids})
write_json(cdsl_path, cdsl_copy)
contract = parameter_contract(cdsl_copy)
write_json(parameters_path, contract)
try:
engine_result = engine.run_rebuild(cdsl_copy, step_path)
if engine_result.get("engine") != "cdsl_only" or not step_path.is_file() or step_path.stat().st_size == 0:
raise RuntimeError("Engine did not produce a CDSL-only STEP artifact")
preview = step_to_glb(step_path, glb_path)
selector, edges = topology_sidecars(engine_result, preview)
write_json(selector_path, selector)
write_json(edges_path, edges)
report = {"engine_result": engine_result, "preview": preview, "validated_at": now_iso()}
write_json(report_path, report)
revision = {
"revision_id": revision_id,
"status": "success",
"created_at": now_iso(),
"request_path": request_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"step_path": step_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"glb_path": glb_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"selector_path": selector_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"edges_path": edges_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"reference_ids": reference_ids,
"summary": summary,
"engine": engine_result["engine"],
"parent_revision_id": parent_revision_id or "",
"operation": operation or {},
"attachments": attachments or [],
}
except Exception as error:
write_json(report_path, {"error": str(error), "validated_at": now_iso()})
revision = {
"revision_id": revision_id,
"status": "failed",
"created_at": now_iso(),
"request_path": request_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"reference_ids": reference_ids,
"summary": summary,
"error": str(error),
"parent_revision_id": parent_revision_id or "",
"operation": operation or {},
"attachments": attachments or [],
}
store.update_task(task["task_id"], revision)
raise
store.update_task(task["task_id"], revision)
return {"task_id": task["task_id"], **revision}
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from app.settings import Settings
TOKEN_PATTERN = re.compile(r"[a-zA-Z0-9_]+")
def tokens(value: str) -> set[str]:
return {token.lower() for token in TOKEN_PATTERN.findall(value)}
class CdslLibrary:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.index_path = settings.library_root / "index" / "catalog.json"
def _records(self) -> list[dict[str, Any]]:
if not self.index_path.is_file():
return []
payload = json.loads(self.index_path.read_text(encoding="utf-8"))
return payload.get("samples", [])
def count(self) -> int:
return len(self._records())
def search(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
query_tokens = tokens(query)
if not query_tokens:
return [
{
"part_id": record["part_id"],
"profiles": record.get("profiles", []),
"features": record.get("features", []),
"summary": record.get("summary", ""),
}
for record in self._records()[:limit]
]
scored: list[tuple[int, dict[str, Any]]] = []
for record in self._records():
corpus = " ".join([
record.get("part_id", ""),
record.get("source_name", ""),
" ".join(record.get("profiles", [])),
" ".join(record.get("features", [])),
" ".join(record.get("parameters", [])),
])
score = len(query_tokens & tokens(corpus))
if score:
scored.append((score, record))
scored.sort(key=lambda item: (-item[0], item[1]["part_id"]))
return [
{
"part_id": record["part_id"],
"profiles": record.get("profiles", []),
"features": record.get("features", []),
"summary": record.get("summary", ""),
}
for _, record in scored[:limit]
]
def read_sample(self, part_id: str) -> dict[str, Any]:
for record in self._records():
if record.get("part_id") == part_id:
source = self.settings.library_root / "samples" / part_id / "model.cdsl.json"
if not source.is_file():
break
return json.loads(source.read_text(encoding="utf-8"))
raise ValueError(f"CDSL sample not found: {part_id}")
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from typing import Any
def event(name: str, data: dict[str, Any]) -> bytes:
return f"event: {name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n".encode("utf-8")
async def one_event(name: str, data: dict[str, Any]) -> AsyncIterator[bytes]:
yield event(name, data)
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
import json
import re
import secrets
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.settings import Settings
TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$")
CONVERSATION_ID = re.compile(r"^conv_[a-z0-9]{12}$")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def new_id(prefix: str) -> str:
return f"{prefix}_{secrets.token_hex(6)}"
def safe_task_id(task_id: str) -> str:
value = str(task_id or "").strip()
if not TASK_ID.fullmatch(value):
raise ValueError("Invalid task id")
return value
def safe_conversation_id(conversation_id: str) -> str:
value = str(conversation_id or "").strip()
if not CONVERSATION_ID.fullmatch(value):
raise ValueError("Invalid conversation id")
return value
def safe_relative_path(value: str) -> str:
path = Path(str(value or ""))
if not value or path.is_absolute() or ".." in path.parts:
raise ValueError("Invalid artifact path")
return path.as_posix()
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def read_json(path: Path, fallback: Any = None) -> Any:
if not path.is_file():
return fallback
return json.loads(path.read_text(encoding="utf-8"))
class WorkspaceStore:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.settings.task_root.mkdir(parents=True, exist_ok=True)
self.settings.conversation_root.mkdir(parents=True, exist_ok=True)
def task_dir(self, task_id: str) -> Path:
return self.settings.task_root / safe_task_id(task_id)
def task_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "task.json"
def conversation_dir(self, conversation_id: str) -> Path:
return self.settings.conversation_root / safe_conversation_id(conversation_id)
def conversation_path(self, conversation_id: str) -> Path:
return self.conversation_dir(conversation_id) / "conversation.json"
def ensure_conversation(
self,
conversation_id: str | None,
current_task_id: str | None = None,
attachments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
cid = safe_conversation_id(conversation_id) if conversation_id else new_id("conv")
path = self.conversation_path(cid)
current = read_json(path)
if current:
changed = False
if current_task_id:
current["current_task_id"] = safe_task_id(current_task_id)
changed = True
if attachments is not None:
current["attachments"] = attachments
changed = True
if changed:
current["updated_at"] = now_iso()
write_json(path, current)
return current
record = {
"schema_version": "1.0",
"conversation_id": cid,
"created_at": now_iso(),
"updated_at": now_iso(),
"current_task_id": safe_task_id(current_task_id) if current_task_id else "",
"messages": [],
"attachments": attachments or [],
}
write_json(path, record)
return record
def read_conversation(self, conversation_id: str) -> dict[str, Any] | None:
return read_json(self.conversation_path(conversation_id))
def append_conversation_message(self, conversation_id: str, message: dict[str, Any], current_task_id: str | None = None) -> dict[str, Any]:
record = self.ensure_conversation(conversation_id, current_task_id)
known = {str(item.get("id")) for item in record["messages"]}
if str(message.get("id")) not in known:
record["messages"].append(message)
if current_task_id:
record["current_task_id"] = safe_task_id(current_task_id)
record["updated_at"] = now_iso()
write_json(self.conversation_path(record["conversation_id"]), record)
return record
def write_upload(self, task_id: str, filename: str, data: bytes) -> tuple[str, Path]:
safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "_", Path(filename).name).strip("._") or "attachment"
relative = Path("uploads") / f"upload_{secrets.token_hex(6)}_{safe_name}"
target = self.artifact_path(task_id, relative.as_posix())
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
return relative.as_posix(), target
def ensure_task(self, task_id: str | None, request: str) -> dict[str, Any]:
tid = safe_task_id(task_id) if task_id else new_id("cad")
path = self.task_path(tid)
current = read_json(path)
if current:
return current
task_dir = self.task_dir(tid)
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
record = {
"schema_version": "1.0",
"task_id": tid,
"request": request,
"created_at": now_iso(),
"updated_at": now_iso(),
"current_revision": "",
"revisions": [],
}
write_json(path, record)
return record
def next_revision(self, task_id: str) -> tuple[str, Path]:
task = self.ensure_task(task_id, "")
revision_id = f"rev_{len(task['revisions']) + 1:03d}"
revision_dir = self.task_dir(task_id) / "revisions" / revision_id
revision_dir.mkdir(parents=True, exist_ok=False)
return revision_id, revision_dir
def update_task(self, task_id: str, revision: dict[str, Any]) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
task["revisions"].append(revision)
if revision.get("status") == "success":
task["current_revision"] = revision["revision_id"]
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:
return read_json(self.task_path(task_id))
def current_cdsl_path(self, task_id: str) -> Path | None:
task = self.read_task(task_id)
revision_id = str((task or {}).get("current_revision") or "")
if not revision_id:
return None
candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json"
return candidate if candidate.is_file() else None
def artifact_path(self, task_id: str, relative_path: str) -> Path:
safe = safe_relative_path(relative_path)
root = self.task_dir(task_id).resolve()
target = (root / safe).resolve()
if root != target and root not in target.parents:
raise ValueError("Artifact path escapes task directory")
return target