新增cdsl

This commit is contained in:
2026-08-24 14:52:08 +08:00
parent fd8c0c37ad
commit 9f8a55ff07
3892 changed files with 2872721 additions and 9169 deletions
+33
View File
@@ -127,6 +127,30 @@ async def read_task(task_id: str) -> JSONResponse:
return JSONResponse(task)
@app.get("/v1/tasks/{task_id}/design-intent")
async def read_current_design_intent(task_id: str) -> JSONResponse:
try:
safe_id = safe_task_id(task_id)
result = store.read_design_intent(safe_id)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
if result is None:
raise HTTPException(status_code=404, detail="No DesignIntent exists for this task")
return JSONResponse({"task_id": safe_id, "intent_id": result["record"]["intent_id"], **result})
@app.get("/v1/tasks/{task_id}/design-intents/{intent_id}")
async def read_design_intent(task_id: str, intent_id: str) -> JSONResponse:
try:
safe_id = safe_task_id(task_id)
result = store.read_design_intent(safe_id, intent_id)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
if result is None:
raise HTTPException(status_code=404, detail="DesignIntent not found")
return JSONResponse({"task_id": safe_id, "intent_id": result["record"]["intent_id"], **result})
@app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}")
async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
from fastapi.responses import FileResponse
@@ -165,6 +189,10 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo
safe_id = safe_task_id(task_id)
task = store.read_task(safe_id)
current_revision_id = str((task or {}).get("current_revision") or "")
current_revision = next(
(item for item in (task or {}).get("revisions", []) if item.get("revision_id") == current_revision_id),
{},
)
current_path = store.current_cdsl_path(safe_id)
if not task or not current_path or not current_revision_id:
raise ValueError("Task has no successful CDSL revision")
@@ -179,6 +207,11 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo
summary="Updated CDSL parameters",
parent_revision_id=current_revision_id,
operation={"type": "parameter_update", "values": payload.values},
part_skills=None,
generation_assumptions=[],
design_intent_id=str(current_revision.get("design_intent_id") or ""),
design_intent_path=str(current_revision.get("design_intent_path") or ""),
design_intent_status="accepted" if current_revision.get("design_intent_id") else "",
)
return JSONResponse(result)
except ValueError as error:
+2
View File
@@ -57,6 +57,8 @@ class CadResult(BaseModel):
parameters_path: str | None = None
selector_path: str | None = None
edges_path: str | None = None
design_intent_id: str | None = None
design_intent_path: str | None = None
summary: str
reference_ids: list[str] = Field(default_factory=list)
engine: str = "cdsl_only"
+293 -33
View File
@@ -15,6 +15,7 @@ import httpx
from app.models.contracts import ChatMessage
from app.services.engine_service import build_revision, load_engine, validate_cdsl
from app.services.library import CdslLibrary
from app.services.part_skills import PartSkillLibrary
from app.services.sse import event
from app.services.storage import WorkspaceStore, now_iso
from app.settings import ProviderConfig, ProviderModel, Settings
@@ -141,12 +142,25 @@ def invalid_cdsl_result(error: ValueError) -> dict[str, Any]:
}
def invalid_design_intent_result(error: Exception) -> dict[str, Any]:
code = str(getattr(error, "code", "INVALID_DESIGN_INTENT"))
return {
"ok": False,
"code": code,
"message": f"The DesignIntent plan was rejected: {error}. Correct the complete plan before generating CDSL.",
}
def user_visible_tool_message(result: dict[str, Any], user_text: str) -> str:
code = str(result.get("code") or "")
if code == "INVALID_CDSL":
if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")):
return "CDSL 不符合 engine 的模型契约,正在请求模型按 schema 修正后重新生成。"
return "The CDSL model does not match the engine contract. Asking the model to correct it and retry."
if code in {"INVALID_DESIGN_INTENT", "INTENT_CDSL_MISMATCH", "DESIGN_INTENT_REQUIRED", "DESIGN_INTENT_BLOCKED"}:
if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")):
return "设计意图尚未通过校验,未进入 CAD 构建。"
return "The design intent has not passed validation, so CAD construction has not started."
return str(result.get("message") or result.get("summary") or "")
@@ -187,6 +201,22 @@ def _cdsl_tool_schema() -> dict[str, Any]:
CDSL_TOOL_SCHEMA = _cdsl_tool_schema()
def _design_intent_tool_schema() -> dict[str, Any]:
engine_dir = Path(__file__).resolve().parents[2] / "engine" / "cdsl_engine"
try:
schema = json.loads((engine_dir / "design_intent_schema.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise RuntimeError("Local DesignIntent JSON Schema is unavailable or invalid") from error
# The model cannot forge storage/audit fields. They are added only after
# backend validation by WorkspaceStore.create_design_intent().
for field in ("intent_id", "created_at", "part_skill_ids", "part_skill_selection"):
schema.get("properties", {}).pop(field, None)
return schema
DESIGN_INTENT_TOOL_SCHEMA = _design_intent_tool_schema()
TOOL_SCHEMAS: list[dict[str, Any]] = [
{
"type": "function",
@@ -197,6 +227,7 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [
"type": "object",
"properties": {"query": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 8}},
"required": ["query"],
"additionalProperties": False,
},
},
},
@@ -205,7 +236,24 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [
"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"]},
"parameters": {"type": "object", "properties": {"part_id": {"type": "string"}}, "required": ["part_id"], "additionalProperties": False},
},
},
{
"type": "function",
"function": {
"name": "propose_design_intent",
"description": "Submit a complete semantic DesignIntent plan before searching CDSL references or generating CDSL.",
"parameters": {
"type": "object",
"properties": {
"intent": DESIGN_INTENT_TOOL_SCHEMA,
"summary": {"type": "string", "minLength": 1},
"assumptions": {"type": "array", "items": {"type": "string"}},
},
"required": ["intent", "summary", "assumptions"],
"additionalProperties": False,
},
},
},
{
@@ -224,11 +272,12 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [
"parameters": {
"type": "object",
"properties": {
"design_intent_id": {"type": "string", "pattern": "^intent_[a-z0-9]{12}$"},
"cdsl": CDSL_TOOL_SCHEMA,
"summary": {"type": "string", "minLength": 1},
"assumptions": {"type": "array", "items": {"type": "string"}},
},
"required": ["cdsl", "summary"],
"required": ["design_intent_id", "cdsl", "summary", "assumptions"],
"additionalProperties": False,
},
},
@@ -242,13 +291,11 @@ def tools_for_model(model: ProviderModel) -> list[dict[str, Any]]:
if not model.strict_tool_schema:
return tools
generate_tool = next(
tool for tool in tools
if tool.get("function", {}).get("name") == "generate_cdsl_model"
)
# This flag constrains function arguments only. It has no effect on normal
# assistant text, the user's prompt, or the natural-language summary.
generate_tool["function"]["strict"] = True
for tool in tools:
if tool.get("function", {}).get("name") in {"propose_design_intent", "generate_cdsl_model"}:
# This flag constrains function arguments only. It has no effect on
# normal assistant text, the user's prompt, or the summary.
tool["function"]["strict"] = True
return tools
@@ -352,9 +399,12 @@ def system_prompt(
user_text: str,
viewer_context: list[dict[str, Any]] | None = None,
task_id: str = "",
part_skill_context: str = "",
) -> 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 ""
planning_recipe_path = skill_path.with_name("planning-recipe.md")
planning_recipe = planning_recipe_path.read_text(encoding="utf-8") if planning_recipe_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 ""
profile_schema_path = settings.engine_root / "profile_schema.json"
@@ -379,6 +429,12 @@ Tool call contract:
- Do not append prose, Markdown code fences, comments, or a second JSON value.
- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly,
not as Markdown and not as a concatenated JSON string.
- Call propose_design_intent first. Its `intent` is the complete semantic
planning JSON, without sketch coordinates, raw CAD code, storage IDs, or
part-skill IDs. The backend chooses and persists part skills itself.
- Do not call search_cdsl_library, read_cdsl_reference, or
generate_cdsl_model until propose_design_intent returns an accepted
`intent_id`. Pass that exact ID to generate_cdsl_model.
- The generate_cdsl_model `cdsl` parameter is the complete machine-enforced
schema. Satisfy its nested object and array types exactly; do not substitute
a shorthand array for an object. For example, every hole position is
@@ -399,12 +455,34 @@ Workflow limits:
{response_language_instruction(user_text)}
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.
1. Use the injected part-skill guidance, when present, only to establish the
structural plan, feature dependency order, and parameter roles.
2. Call propose_design_intent. A blocking question or capability gap must make
the plan `needs_clarification`; then ask one concise user-facing question
and do not call CDSL tools.
3. Only after an accepted ready intent, search the local official CDSL library.
Read at least one relevant reference when a match exists; samples provide
schema-valid expressions, not higher-priority part intent.
4. Generate a complete CDSL whose feature IDs, atomics, dependencies, profiles,
and selector evidence exactly realize the accepted DesignIntent, then call
generate_cdsl_model with its intent ID.
5. 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.
Precedence is strict: explicit user request, then CDSL schema/runtime, then
part-skill guidance, then CDSL-library examples. Part skills never authorize
build123d source, an unknown atomic/profile, an invented selector, or a free-
coordinate substitute for a capability the runtime cannot express. For an
unsupported requested structure, ask one concise clarification question or
state the blocker rather than fabricating geometry. If a part-family conflict
is injected, preserve the current part unless the user explicitly requests a
whole-part replacement. A primary-family conflict is a hard clarification
stop: ask one concise question and do not call generate_cdsl_model until the
user resolves it.
For a revision, call read_current_cdsl first and preserve unrelated features,
then propose a revise DesignIntent with base_revision_id set to the current
successful revision. Do not search references or generate CDSL before that plan
is accepted.
Do not output compiler_context, unknown_shape, complex_arc_shape, entities,
contour_edges_mm, or contour_regions_mm. Use only self-contained named profiles
and feature atomic IDs defined in the engine schema below. Read the engine
@@ -415,6 +493,9 @@ dimensions or intent are missing. Ordinary explanations must not create CAD.
Local skill:
{skill}
DesignIntent planning recipe:
{planning_recipe}
Local engine guide:
{engine_readme}
@@ -423,15 +504,29 @@ Authoritative engine schema:
Supported named profile types:
{supported_profiles}
Injected part-skill context:
{part_skill_context or "No part-family skill guidance was selected for this request."}
{viewer_selection_prompt(viewer_context, task_id)}
"""
def part_skill_root(settings: Settings) -> Path:
return settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "references" / "part-skills"
class AgentService:
def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None:
def __init__(
self,
settings: Settings,
store: WorkspaceStore,
library: CdslLibrary,
part_skill_library: PartSkillLibrary | None = None,
) -> None:
self.settings = settings
self.store = store
self.library = library
self.part_skill_library = part_skill_library or PartSkillLibrary(part_skill_root(settings))
async def stream(
self,
@@ -489,7 +584,29 @@ class AgentService:
yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"})
references: list[str] = []
library_searches = 0
model_messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt(self.settings, user_text, viewer_context, task_id)}]
current_task = self.store.read_task(task_id) if task_id else None
inherited_skill_ids = self.part_skill_library.inherited_from_task(current_task)
part_skill_selection = self.part_skill_library.select(user_text, inherited_skill_ids)
intent_state: dict[str, Any] = {
"phase": "WAITING_FOR_INTENT",
"design_intent_id": "",
}
yield event("progress", {
"step": "select_part_skill",
"label": "识别零件族",
"status": "success",
"message": "已完成零件族与辅助建模规则识别。",
})
model_messages: list[dict[str, Any]] = [{
"role": "system",
"content": system_prompt(
self.settings,
user_text,
viewer_context,
task_id,
self.part_skill_library.render_context(part_skill_selection),
),
}]
model_messages.extend(messages_for_model(messages))
if attachment_message:
model_messages.append({"role": "user", "content": attachment_message})
@@ -591,14 +708,30 @@ class AgentService:
"message": "Agent 正在调用本地 CAD 工具。",
})
try:
result, generated = await self._run_tool(name, arguments, task_id, user_text, references)
result, generated = await self._run_tool(
name,
arguments,
task_id,
user_text,
references,
part_skill_selection=part_skill_selection,
intent_state=intent_state,
)
except (ValueError, RuntimeError) as error:
if name == "generate_cdsl_model":
code = str(getattr(error, "code", ""))
if code in {"INVALID_DESIGN_INTENT", "INTENT_CDSL_MISMATCH", "DESIGN_INTENT_REQUIRED", "DESIGN_INTENT_BLOCKED"}:
result = invalid_design_intent_result(error)
generated = None
if name in {"propose_design_intent", "generate_cdsl_model"} and code != "DESIGN_INTENT_BLOCKED":
required_tool_name = name
elif name == "generate_cdsl_model":
result = invalid_cdsl_result(error)
generated = None
required_tool_name = name
else:
raise
if result.get("task_id"):
task_id = str(result["task_id"])
if generated:
task_id = generated["task_id"]
model_messages.append({
@@ -612,7 +745,14 @@ class AgentService:
"status": "success" if result.get("ok", True) else "error",
"message": user_visible_tool_message(result, user_text),
})
if name == "generate_cdsl_model" and result.get("ok"):
if name == "propose_design_intent" and result.get("ok"):
yield event("progress", {
"step": "validate_design_intent",
"label": "校验设计意图",
"status": "success",
"message": "设计意图已通过结构、依赖和能力边界校验。",
})
if name in {"propose_design_intent", "generate_cdsl_model"} and result.get("ok"):
required_tool_name = None
if generated:
result_payload = {
@@ -625,6 +765,8 @@ class AgentService:
"parametersPath": generated.get("parameters_path"),
"selectorPath": generated.get("selector_path"),
"edgesPath": generated.get("edges_path"),
"designIntentId": generated.get("design_intent_id"),
"designIntentPath": generated.get("design_intent_path"),
"summary": generated["summary"],
"referenceIds": generated["reference_ids"],
"engine": generated["engine"],
@@ -632,6 +774,12 @@ class AgentService:
successful_result = result_payload
assistant_parts.append({"type": "data-cad-result", "data": result_payload})
yield event("cad_result", result_payload)
yield event("progress", {
"step": "build_cad",
"label": "构建 CAD",
"status": "success",
"message": "已通过 cdsl_only runtime 构建 STEP 和 GLB。",
})
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})
@@ -749,15 +897,87 @@ class AgentService:
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]:
async def _run_tool(
self,
name: str,
arguments: dict[str, Any],
task_id: str,
request: str,
references: list[str],
*,
part_skill_selection: dict[str, Any] | None = None,
intent_state: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], dict[str, Any] | None]:
state = intent_state if intent_state is not None else {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""}
phase = str(state.get("phase") or "WAITING_FOR_INTENT")
if name == "propose_design_intent":
intent = arguments.get("intent")
if not isinstance(intent, dict):
raise ValueError("propose_design_intent requires an intent JSON object")
if any(field in intent for field in ("intent_id", "created_at", "part_skill_ids", "part_skill_selection")):
raise ValueError("DesignIntent audit fields are assigned only by the backend")
summary = str(arguments.get("summary") or "").strip()
assumptions = arguments.get("assumptions")
if not summary or not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions):
raise ValueError("propose_design_intent requires a summary and an array of string assumptions")
selection = part_skill_selection or self.part_skill_library.select(request)
if selection.get("conflict"):
return {
"ok": False,
"code": "DESIGN_INTENT_BLOCKED",
"message": str(selection["conflict"].get("message") or "The current part family must be clarified before planning."),
}, None
engine = load_engine(self.settings)
current_task = self.store.read_task(task_id) if task_id else None
current_revision_id = str((current_task or {}).get("current_revision") or "")
if current_revision_id and intent.get("mode") != "revise":
raise engine.DesignIntentError("INVALID_DESIGN_INTENT", "A task with a successful revision requires a revise DesignIntent")
if intent.get("mode") == "revise" and not current_revision_id:
raise engine.DesignIntentError("INVALID_DESIGN_INTENT", "A revise DesignIntent requires a current successful revision")
normalized = deepcopy(intent)
if assumptions:
normalized["assumptions"] = list(dict.fromkeys([
*normalized.get("assumptions", []),
*(item.strip() for item in assumptions if item.strip()),
]))
normalized = engine.validate_design_intent(normalized, engine, current_revision_id=current_revision_id)
persisted = self.store.create_design_intent(task_id or None, request, normalized, selection)
state["design_intent_id"] = persisted["intent_id"]
if normalized["status"] != "ready":
state["phase"] = "WAITING_FOR_INTENT"
return {
"ok": False,
"code": "DESIGN_INTENT_BLOCKED",
"task_id": persisted["task_id"],
"design_intent_id": persisted["intent_id"],
"status": normalized["status"],
"intent": persisted["intent"],
"message": "The DesignIntent is saved but blocked. Ask the user only about its blocking question or capability gap.",
}, None
state["phase"] = "INTENT_ACCEPTED"
return {
"ok": True,
"task_id": persisted["task_id"],
"design_intent_id": persisted["intent_id"],
"design_intent_path": persisted["path"],
"status": "accepted",
"intent": persisted["intent"],
"summary": summary,
}, None
if name == "search_cdsl_library":
if phase not in {"INTENT_ACCEPTED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}:
return {"ok": False, "code": "DESIGN_INTENT_REQUIRED", "message": "Submit and receive an accepted DesignIntent before searching CDSL references."}, None
results = self.library.search(str(arguments.get("query") or request), int(arguments.get("limit") or 5))
state["phase"] = "LIBRARY_REFERENCE"
return {"ok": True, "results": results}, None
if name == "read_cdsl_reference":
if phase not in {"INTENT_ACCEPTED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}:
return {"ok": False, "code": "DESIGN_INTENT_REQUIRED", "message": "Submit and receive an accepted DesignIntent before reading CDSL references."}, None
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)
state["phase"] = "WAITING_FOR_CDSL"
return {"ok": True, "part_id": part_id, "cdsl": sample}, None
if name == "read_current_cdsl":
if not task_id:
@@ -767,6 +987,17 @@ class AgentService:
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":
if phase not in {"INTENT_ACCEPTED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}:
return {"ok": False, "code": "DESIGN_INTENT_REQUIRED", "message": "Submit and receive an accepted DesignIntent before generating CDSL."}, None
design_intent_id = str(arguments.get("design_intent_id") or "")
if not design_intent_id or design_intent_id != str(state.get("design_intent_id") or "") or not task_id:
return {"ok": False, "code": "DESIGN_INTENT_REQUIRED", "message": "generate_cdsl_model must use the accepted DesignIntent ID for this task."}, None
intent_record = self.store.read_design_intent(task_id, design_intent_id)
if not intent_record or intent_record["record"].get("status") != "accepted":
return {"ok": False, "code": "DESIGN_INTENT_REQUIRED", "message": "The requested DesignIntent is not accepted for this task."}, None
intent = intent_record["intent"]
if intent.get("status") != "ready":
return {"ok": False, "code": "DESIGN_INTENT_BLOCKED", "message": "The DesignIntent is blocked and cannot be built."}, None
cdsl = arguments.get("cdsl")
if isinstance(cdsl, str):
cdsl = json.loads(cdsl)
@@ -775,19 +1006,47 @@ class AgentService:
# Reject malformed model output before build_revision allocates a task
# directory or revision. build_revision will assign the real task ID.
preflight_cdsl = {**cdsl, "part_id": str(cdsl.get("part_id") or "agent_preflight")}
validate_cdsl(preflight_cdsl, load_engine(self.settings))
engine = load_engine(self.settings)
current_path = self.store.current_cdsl_path(task_id)
current_cdsl = json.loads(current_path.read_text(encoding="utf-8")) if current_path else None
engine.validate_intent_cdsl(intent, preflight_cdsl, engine, current_cdsl=current_cdsl)
validate_cdsl(preflight_cdsl, engine)
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
raw_assumptions = arguments.get("assumptions") or []
if not isinstance(raw_assumptions, list) or not all(isinstance(item, str) for item in raw_assumptions):
raise ValueError("generate_cdsl_model assumptions must be an array of strings")
assumptions = [item.strip() for item in raw_assumptions if item.strip()]
selection = part_skill_selection or self.part_skill_library.select(request)
part_skill_audit = self.part_skill_library.audit(selection, cdsl, assumptions)
state["phase"] = "BUILDING"
try:
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,
part_skills=part_skill_audit,
generation_assumptions=assumptions,
design_intent=intent,
design_intent_path=str(intent_record["record"].get("path") or ""),
)
except Exception:
# The accepted plan stays current so the model can submit a
# corrected implementation without silently replanning.
state["phase"] = "INTENT_ACCEPTED"
raise
state["phase"] = "COMPLETED"
return {
"ok": True,
"summary": summary,
"task_id": yieldable["task_id"],
"revision_id": yieldable["revision_id"],
"design_intent_id": design_intent_id,
}, yieldable
raise ValueError(f"Unknown agent tool: {name}")
@staticmethod
@@ -800,7 +1059,8 @@ class AgentService:
"search_cdsl_library": "检索 CDSL 模型库",
"read_cdsl_reference": "读取 CDSL 参考模型",
"read_current_cdsl": "读取当前 CDSL",
"generate_cdsl_model": "生成 CDSL CAD 模型",
"propose_design_intent": "生成设计意图",
"generate_cdsl_model": "生成 CDSL",
}.get(name, "调用 CAD 工具")
def _attachment_message(self, conversation: dict[str, Any], model: ProviderModel) -> list[dict[str, Any]] | str:
+9
View File
@@ -147,6 +147,10 @@ def apply_direct_edit(
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")
current_revision = next(
(item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id),
{},
)
frame = _selection_frame(selection)
cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8")))
features = cdsl.setdefault("features", [])
@@ -184,4 +188,9 @@ def apply_direct_edit(
summary=f"Applied {operation}",
parent_revision_id=revision_id,
operation={"type": operation, "selection": selection, "parameters": parameters},
part_skills=None,
generation_assumptions=[],
design_intent_id=str(current_revision.get("design_intent_id") or ""),
design_intent_path=str(current_revision.get("design_intent_path") or ""),
design_intent_status="accepted" if current_revision.get("design_intent_id") else "",
)
+148 -46
View File
@@ -319,6 +319,68 @@ def apply_parameter_updates(cdsl: dict[str, Any], values: dict[str, float]) -> t
return updated, parameter_contract(updated)
def _part_skill_audit(
part_skills: dict[str, Any] | None,
request: str,
generation_assumptions: list[str] | None,
) -> dict[str, Any]:
"""Normalize the planning audit persisted beside a product revision."""
audit = copy.deepcopy(part_skills) if isinstance(part_skills, dict) else {}
skills = [item for item in audit.get("skills") or [] if isinstance(item, dict)]
skill_ids = [str(item) for item in audit.get("skill_ids") or [] if str(item)]
if not skill_ids:
skill_ids = [str(item.get("id")) for item in skills if item.get("id")]
evidence = audit.get("evidence") if isinstance(audit.get("evidence"), dict) else {}
audit.update({
"schema_version": str(audit.get("schema_version") or "1.0"),
"request": str(audit.get("request") or request),
"structural_intent": str(audit.get("structural_intent") or request),
"skill_ids": skill_ids,
"skills": skills,
"inherited_skill_ids": [str(item) for item in audit.get("inherited_skill_ids") or [] if str(item)],
"assumptions": [str(item) for item in generation_assumptions or []],
"evidence": evidence,
"capability_translations": [
item for item in audit.get("capability_translations") or [] if isinstance(item, dict)
],
})
return audit
def _generation_context(
reference_ids: list[str],
part_skill_audit: dict[str, Any],
design_intent: dict[str, Any] | None = None,
design_intent_path: str = "",
design_intent_id: str = "",
) -> dict[str, Any]:
skills = [item for item in part_skill_audit.get("skills") or [] if isinstance(item, dict)]
intent = design_intent if isinstance(design_intent, dict) else {}
return {
"design_intent_id": str(intent.get("intent_id") or design_intent_id or ""),
"design_intent_path": design_intent_path,
"design_intent_structures": [
str(item.get("id") or "")
for item in intent.get("structures") or []
if isinstance(item, dict) and item.get("id")
],
"cdsl_reference_ids": list(reference_ids),
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
"part_skill_paths": [
{
"id": str(item.get("id") or ""),
"bridge": str(item.get("bridge") or ""),
"source": str(item.get("source") or ""),
}
for item in skills
],
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
"design_intent_assumptions": list(part_skill_audit.get("design_intent_assumptions") or []),
"design_intent_capability_gaps": list(part_skill_audit.get("design_intent_capability_gaps") or []),
"capability_translations": list(part_skill_audit.get("capability_translations") or []),
}
def build_revision(
*,
settings: Settings,
@@ -331,18 +393,16 @@ def build_revision(
parent_revision_id: str | None = None,
operation: dict[str, Any] | None = None,
attachments: list[dict[str, Any]] | None = None,
part_skills: dict[str, Any] | None = None,
generation_assumptions: list[str] | None = None,
design_intent: dict[str, Any] | None = None,
design_intent_path: str = "",
design_intent_id: str = "",
design_intent_status: str = "",
) -> 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"
@@ -352,13 +412,76 @@ def build_revision(
parameters_path = revision_dir / "parameters.json"
selector_path = revision_dir / "model.selector.json"
edges_path = revision_dir / "model.edges.json"
part_skills_path = revision_dir / "part-skills.json"
intent = copy.deepcopy(design_intent) if isinstance(design_intent, dict) else {}
intent_assumptions = [str(item) for item in intent.get("assumptions") or [] if str(item)]
merged_assumptions = list(dict.fromkeys([
*intent_assumptions,
*(str(item) for item in generation_assumptions or [] if str(item)),
]))
part_skill_audit = _part_skill_audit(part_skills, request, merged_assumptions)
intent_id = str(intent.get("intent_id") or design_intent_id or "")
if design_intent_path:
design_intent_path = str(design_intent_path)
part_skill_audit.update({
"design_intent_id": intent_id,
"design_intent_path": design_intent_path,
"intent_status": str(intent.get("status") or design_intent_status or ""),
"design_intent_assumptions": intent_assumptions,
"design_intent_capability_gaps": [
item for item in intent.get("capability_gaps") or [] if isinstance(item, dict)
],
})
generation_context = _generation_context(reference_ids, part_skill_audit, intent, design_intent_path, intent_id)
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)
write_json(part_skills_path, part_skill_audit)
def revision_record(status: str, *, error: str = "", engine_name: str = "") -> dict[str, Any]:
record = {
"revision_id": revision_id,
"status": status,
"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(),
"part_skills_path": part_skills_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"design_intent_id": intent_id,
"design_intent_path": design_intent_path,
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
"reference_ids": reference_ids,
"summary": summary,
"parent_revision_id": parent_revision_id or "",
"operation": operation or {},
"attachments": attachments or [],
}
if status == "success":
record.update({
"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(),
"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(),
"engine": engine_name,
})
else:
record["error"] = error
return record
try:
cdsl_copy = copy.deepcopy(cdsl)
if not isinstance(cdsl_copy, dict):
raise ValueError("CDSL must be a JSON object")
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)
write_json(cdsl_path, cdsl_copy)
write_json(parameters_path, parameter_contract(cdsl_copy))
validate_cdsl(cdsl_copy, engine)
# Product revisions are semantic CDSL artifacts. Do not route them
# through the legacy rebuild entry point, which is allowed to use
# compiler_context/translator compatibility fallbacks.
@@ -369,44 +492,23 @@ def build_revision(
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()}
report = {
"engine_result": engine_result,
"preview": preview,
"generation_context": generation_context,
"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 [],
}
revision = revision_record("success", engine_name=str(engine_result["engine"]))
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,
if not cdsl_path.is_file() and isinstance(cdsl, dict):
write_json(cdsl_path, copy.deepcopy(cdsl))
write_json(report_path, {
"error": str(error),
"parent_revision_id": parent_revision_id or "",
"operation": operation or {},
"attachments": attachments or [],
}
"generation_context": generation_context,
"validated_at": now_iso(),
})
revision = revision_record("failed", error=str(error))
store.update_task(task["task_id"], revision)
raise
store.update_task(task["task_id"], revision)
+303
View File
@@ -0,0 +1,303 @@
from __future__ import annotations
import json
import re
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from typing import Any
_WORD = re.compile(r"[a-z0-9]+(?:[-'][a-z0-9]+)*")
_REPLACEMENT_INTENT = (
"replace the whole part",
"replace entire part",
"replace the part",
"replace this part",
"start over as",
"replace with a",
"替换整个零件",
"替换整个部件",
"替换零件",
"重新生成一个",
"改成一个新的",
)
def _normalize(value: str) -> str:
return unicodedata.normalize("NFKC", str(value or "")).casefold().strip()
def _matches(text: str, phrase: str) -> bool:
normalized_text = _normalize(text)
normalized_phrase = _normalize(phrase)
if not normalized_phrase:
return False
if any("\u4e00" <= char <= "\u9fff" for char in normalized_phrase):
return normalized_phrase in normalized_text
words = _WORD.findall(normalized_phrase)
if not words:
return normalized_phrase in normalized_text
return bool(re.search(r"(?<![a-z0-9])" + r"\s+".join(map(re.escape, words)) + r"(?![a-z0-9])", normalized_text))
def _specificity(phrase: str) -> int:
normalized = _normalize(phrase)
chinese = [char for char in normalized if "\u4e00" <= char <= "\u9fff"]
return len(chinese) if chinese else len(_WORD.findall(normalized))
@dataclass(frozen=True)
class PartSkill:
id: str
kind: str
title: str
priority: int
triggers: tuple[str, ...]
exclude: tuple[str, ...]
bridge: str
source: str
related: tuple[str, ...]
capability_translation_rules: tuple[str, ...]
@classmethod
def from_mapping(cls, value: dict[str, Any]) -> "PartSkill":
return cls(
id=str(value["id"]),
kind=str(value["kind"]),
title=str(value.get("title") or value["id"]),
priority=int(value.get("priority") or 0),
triggers=tuple(str(item) for item in value.get("triggers") or []),
exclude=tuple(str(item) for item in value.get("exclude") or []),
bridge=str(value["bridge"]),
source=str(value["source"]),
related=tuple(str(item) for item in value.get("related") or []),
capability_translation_rules=tuple(
str(item) for item in value.get("capability_translation_rules") or []
),
)
class PartSkillLibrary:
"""Deterministic local selector for CDSL-specific part planning guidance."""
def __init__(self, root: Path) -> None:
self.root = Path(root)
payload = json.loads((self.root / "catalog.json").read_text(encoding="utf-8"))
self.max_planning = int(payload.get("max_planning") or 1)
self.max_support = int(payload.get("max_support") or 3)
self.skills = tuple(PartSkill.from_mapping(item) for item in payload.get("skills") or [])
self.by_id = {skill.id: skill for skill in self.skills}
if len(self.by_id) != len(self.skills):
raise ValueError("Part skill catalog contains duplicate ids")
for skill in self.skills:
for relative in (skill.bridge, skill.source):
if not (self.root / relative).is_file():
raise ValueError(f"Part skill {skill.id} references missing file: {relative}")
def _matched(self, skill: PartSkill, request: str) -> tuple[int, tuple[str, ...]]:
if any(_matches(request, phrase) for phrase in skill.exclude):
return 0, ()
triggers = tuple(trigger for trigger in skill.triggers if _matches(request, trigger))
if not triggers:
return 0, ()
# Longer phrases are more specific than generic words such as "shaft"
# or "hole". Priority breaks ties between equally specific skills.
score = sum(max(1, _specificity(trigger)) * 10 for trigger in triggers)
return score + skill.priority, triggers
def _record(self, skill: PartSkill, *, selection: str, matched: tuple[str, ...] = ()) -> dict[str, Any]:
return {
"id": skill.id,
"kind": skill.kind,
"category": skill.kind,
"title": skill.title,
"source": skill.source,
"bridge": skill.bridge,
"capability_translation_rules": list(skill.capability_translation_rules),
"selection": selection,
"matched_triggers": list(matched),
}
def select(self, request: str, inherited_ids: list[str] | tuple[str, ...] = ()) -> dict[str, Any]:
inherited = [self.by_id[item] for item in inherited_ids if item in self.by_id]
scores = [(self._matched(skill, request), skill) for skill in self.skills]
planning_matches = sorted(
((score, triggers, skill) for (score, triggers), skill in scores if skill.kind == "planning" and score),
key=lambda item: (-item[0], -item[2].priority, item[2].id),
)
inherited_planning = [skill for skill in inherited if skill.kind == "planning"]
planning: list[PartSkill] = inherited_planning[: self.max_planning]
conflict: dict[str, Any] | None = None
replacement_requested = any(_matches(request, phrase) for phrase in _REPLACEMENT_INTENT)
if not planning and planning_matches:
planning = [planning_matches[0][2]]
elif planning and planning_matches and planning_matches[0][2].id not in {skill.id for skill in planning}:
if replacement_requested:
planning = [planning_matches[0][2]]
else:
conflict = {
"current": planning[0].id,
"matched": planning_matches[0][2].id,
"message": "A different primary part family matched this revision request; preserve the current family unless replacement is explicit.",
}
selected_ids = {skill.id for skill in planning}
inherited_planning_ids = {skill.id for skill in inherited_planning}
records = [
self._record(
skill,
selection="inherited" if skill.id in inherited_planning_ids else "selected",
matched=next((triggers for score, triggers, candidate in planning_matches if candidate.id == skill.id), ()),
)
for skill in planning
]
support: list[tuple[int, int, int, str, PartSkill, tuple[str, ...]]] = []
related_ids = {related for skill in planning for related in skill.related}
direct_support: list[tuple[int, PartSkill, tuple[str, ...]]] = []
for (score, triggers), skill in scores:
if skill.kind == "planning" or not score:
continue
direct_support.append((score, skill, triggers))
bonus = 100 if skill.id in related_ids else 0
# Direct request matches always win over inherited context, even
# when the current planning family's catalog does not name them.
support.append((3, bonus + score, skill.priority, skill.id, skill, triggers))
direct_related_ids = {
related
for _, skill, _ in direct_support
for related in skill.related
if related in self.by_id and self.by_id[related].kind != "planning"
}
known_support_ids = {item[3] for item in support}
for related_id in sorted(direct_related_ids - known_support_ids):
skill = self.by_id[related_id]
planning_bonus = 25 if skill.id in related_ids else 0
support.append((2, 50 + planning_bonus, skill.priority, skill.id, skill, ()))
known_support_ids.add(skill.id)
for skill in inherited:
if skill.kind != "planning" and skill.id not in known_support_ids:
# Keep prior guidance only after direct request matches and
# their catalog-declared supporting rules.
support.append((1, 0, skill.priority, skill.id, skill, ()))
known_support_ids.add(skill.id)
support.sort(key=lambda item: (-item[0], -item[1], -item[2], item[3]))
for _, _, _, _, skill, triggers in support:
if len(records) - len(planning) >= self.max_support or skill.id in selected_ids:
continue
selected_ids.add(skill.id)
inherited_marker = "inherited" if skill in inherited else "selected"
records.append(self._record(skill, selection=inherited_marker, matched=triggers))
return {
"schema_version": "1.0",
"request": str(request or ""),
"skills": records,
"skill_ids": [record["id"] for record in records],
"planning_ids": [record["id"] for record in records if record["kind"] == "planning"],
"support_ids": [record["id"] for record in records if record["kind"] != "planning"],
"inherited_skill_ids": [skill.id for skill in inherited],
"replacement_requested": replacement_requested,
"conflict": conflict,
}
def inherited_from_task(self, task: dict[str, Any] | None) -> list[str]:
revision_id = str((task or {}).get("current_revision") or "")
revisions = (task or {}).get("revisions") or []
revision = next((item for item in revisions if item.get("revision_id") == revision_id), None)
revision_ids = [str(item) for item in (revision or {}).get("part_skill_ids") or [] if str(item) in self.by_id]
if revision_ids:
return revision_ids
# A valid DesignIntent creates a task before the first runtime build.
# Its canonical backend selection must therefore be inheritable too.
intent_id = str((task or {}).get("current_design_intent_id") or "")
intent = next(
(item for item in (task or {}).get("design_intents") or [] if str(item.get("intent_id") or "") == intent_id),
None,
)
return [str(item) for item in (intent or {}).get("part_skill_ids") or [] if str(item) in self.by_id]
def render_context(self, selection: dict[str, Any]) -> str:
records = selection.get("skills") or []
if not records:
return "No part-family skill matched this request. Use the CDSL schema and library only."
sections = [
"Selected CDSL part-skill guidance (planning context only):",
"Part skill guidance never overrides the user request or the authoritative CDSL schema/runtime.",
"Translate the guidance into valid CDSL; do not emit build123d source or invent atomics.",
]
for record in records:
path = self.root / str(record["bridge"])
sections.append(f"\n[{record['id']}] ({record['selection']})\n{path.read_text(encoding='utf-8').strip()}")
if selection.get("conflict"):
sections.append("\nPrimary-family conflict: preserve the current task family. Ask one concise clarification question and do not generate until the user explicitly requests whole-part replacement or confirms the current family.")
return "\n".join(sections)
def audit(self, selection: dict[str, Any], cdsl: dict[str, Any], assumptions: list[str] | None = None) -> dict[str, Any]:
features = [item for item in cdsl.get("features") or [] if isinstance(item, dict)]
feature_ids = [str(item.get("id")) for item in features if item.get("id")]
atomics = [str(item.get("atomic_id")) for item in features]
sketches = [item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict)]
profiles = [str((item.get("profile") or {}).get("type")) for item in sketches]
evidence = {
"feature_ids": feature_ids,
"atomic_ids": sorted(set(atomics)),
"profile_types": sorted(set(profiles)),
"features": [
{
"id": str(item.get("id") or ""),
"atomic_id": str(item.get("atomic_id") or ""),
"sketch_id": str(item.get("sketch_id") or ""),
"params": item.get("params") if isinstance(item.get("params"), dict) else {},
}
for item in features
],
"profiles": [
{
"id": str(item.get("id") or ""),
"type": str((item.get("profile") or {}).get("type") or ""),
"profile": item.get("profile") if isinstance(item.get("profile"), dict) else {},
}
for item in sketches
],
}
translations: list[dict[str, Any]] = []
for record in selection.get("skills") or []:
skill_id = str(record.get("id"))
status = "exact"
reason = "The selected structural guidance is represented by the submitted CDSL plan."
if skill_id == "functional/flange-bolt-circle":
if "circles" in profiles and "extrude_cut_blind" in atomics:
status, reason = "expanded", "Circular bolt layout is represented by explicit circle geometry because no circular-pattern atomic exists."
else:
status, reason = "blocked", "No explicit circular bolt layout evidence was found in the CDSL."
elif skill_id == "atomic/threaded-hole-creation":
status, reason = "approximated", "The current runtime preserves a cylindrical bore but does not generate helical thread topology."
elif skill_id == "atomic/fillet-chamfer-last":
finishing = [item for item in features if item.get("atomic_id") in {"fillet", "chamfer"}]
if not finishing:
status, reason = "omitted", "No stable finishing selector was submitted; edge treatment was omitted."
elif skill_id == "atomic/pattern-holes-from-datum":
if "circles" in profiles or "circle_grid" in profiles:
status, reason = "expanded", "The layout is represented by explicit profile circles."
elif not any(item in {"pattern_linear", "pattern_mirror"} for item in atomics):
status, reason = "blocked", "No supported pattern or explicit circle layout was found."
translation: dict[str, Any] = {"skill_id": skill_id, "status": status, "reason": reason, "evidence": evidence}
if skill_id == "functional/flange-bolt-circle" and status == "expanded":
translation["translation"] = "circular_pattern_to_explicit_circles"
elif skill_id == "atomic/threaded-hole-creation":
translation["translation"] = "thread_geometry_omitted"
elif skill_id == "atomic/fillet-chamfer-last" and status == "omitted":
translation["translation"] = "selector_unavailable"
translations.append(translation)
return {
"schema_version": "1.0",
"request": selection.get("request", ""),
"structural_intent": selection.get("request", ""),
"skill_ids": list(selection.get("skill_ids") or []),
"skills": list(selection.get("skills") or []),
"inherited_skill_ids": list(selection.get("inherited_skill_ids") or []),
"conflict": selection.get("conflict"),
"assumptions": [str(item) for item in assumptions or []],
"evidence": evidence,
"capability_translations": translations,
}
+87 -2
View File
@@ -12,6 +12,7 @@ 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}$")
DESIGN_INTENT_ID = re.compile(r"^intent_[a-z0-9]{12}$")
def now_iso() -> str:
@@ -36,6 +37,13 @@ def safe_conversation_id(conversation_id: str) -> str:
return value
def safe_design_intent_id(intent_id: str) -> str:
value = str(intent_id or "").strip()
if not DESIGN_INTENT_ID.fullmatch(value):
raise ValueError("Invalid design intent 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:
@@ -102,7 +110,7 @@ class WorkspaceStore:
write_json(path, current)
return current
record = {
"schema_version": "1.0",
"schema_version": "1.1",
"conversation_id": cid,
"created_at": now_iso(),
"updated_at": now_iso(),
@@ -144,12 +152,14 @@ class WorkspaceStore:
task_dir = self.task_dir(tid)
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
record = {
"schema_version": "1.0",
"schema_version": "1.1",
"task_id": tid,
"request": request,
"created_at": now_iso(),
"updated_at": now_iso(),
"current_revision": "",
"current_design_intent_id": "",
"design_intents": [],
"revisions": [],
}
write_json(path, record)
@@ -171,6 +181,81 @@ class WorkspaceStore:
write_json(self.task_path(task_id), task)
return task
def create_design_intent(
self,
task_id: str | None,
request: str,
intent: dict[str, Any],
part_skill_selection: dict[str, Any],
) -> dict[str, Any]:
"""Persist a validated plan and atomically make it the current plan."""
task = self.ensure_task(task_id, request)
task.setdefault("design_intents", [])
task.setdefault("current_design_intent_id", "")
task["schema_version"] = "1.1"
previous_id = str(task.get("current_design_intent_id") or "")
if previous_id:
for record in task["design_intents"]:
if str(record.get("intent_id") or "") == previous_id and record.get("status") != "superseded":
record["status"] = "superseded"
intent_id = new_id("intent")
created_at = now_iso()
relative = Path("planning") / f"design-intent-{intent_id}.json"
skill_ids = [str(item) for item in part_skill_selection.get("skill_ids") or [] if str(item)]
persisted = dict(intent)
persisted.update({
"intent_id": intent_id,
"created_at": created_at,
"part_skill_ids": skill_ids,
"part_skill_selection": part_skill_selection,
})
write_json(self.artifact_path(task["task_id"], relative.as_posix()), persisted)
record = {
"intent_id": intent_id,
"status": "accepted" if persisted.get("status") == "ready" else "pending",
"created_at": created_at,
"path": relative.as_posix(),
"part_skill_ids": skill_ids,
"base_revision_id": str(persisted.get("base_revision_id") or ""),
}
task["design_intents"].append(record)
task["current_design_intent_id"] = intent_id
task["updated_at"] = now_iso()
write_json(self.task_path(task["task_id"]), task)
return {"task_id": task["task_id"], **record, "intent": persisted}
def read_design_intent(self, task_id: str, intent_id: str | None = None) -> dict[str, Any] | None:
task = self.read_task(task_id)
if not task:
return None
chosen = safe_design_intent_id(intent_id) if intent_id else str(task.get("current_design_intent_id") or "")
if not chosen:
return None
record = next(
(item for item in task.get("design_intents") or [] if str(item.get("intent_id") or "") == chosen),
None,
)
if not isinstance(record, dict):
return None
path = self.artifact_path(task_id, str(record.get("path") or ""))
intent = read_json(path)
if not isinstance(intent, dict):
return None
return {"task_id": task_id, "record": record, "intent": intent}
def update_design_intent_status(self, task_id: str, intent_id: str, status: str) -> dict[str, Any]:
safe_id = safe_design_intent_id(intent_id)
task = self.ensure_task(task_id, "")
records = task.setdefault("design_intents", [])
record = next((item for item in records if str(item.get("intent_id") or "") == safe_id), None)
if not isinstance(record, dict):
raise ValueError("Design intent not found")
record["status"] = str(status)
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return record
def read_task(self, task_id: str) -> dict[str, Any] | None:
return read_json(self.task_path(task_id))