Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user