From 51575161dc49f91173723962c1f82a701cd56954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Tue, 25 Aug 2026 11:01:59 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BC=98=E5=8C=96agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env | 17 +- backend/agent/skills/cad-engine/SKILL.md | 16 +- .../skills/cad-engine/planning-recipe.md | 43 +- backend/app/main.py | 28 +- backend/app/models/contracts.py | 3 - backend/app/services/agent_service.py | 780 +++++++++++++----- backend/app/services/attachments.py | 4 +- backend/app/services/editing.py | 7 - backend/app/services/engine_service.py | 74 +- .../app/services/flange_sleeve_template.py | 273 ++++++ backend/app/services/part_skills.py | 4 +- backend/app/services/storage.py | 56 +- backend/engine/cdsl_engine/design_intent.py | 4 +- .../engine/cdsl_engine/generation_compiler.py | 212 +++++ backend/engine/cdsl_engine/generation_spec.py | 177 ++++ .../cdsl_engine/generation_spec_schema.json | 100 +++ backend/tests/test_agent_tool_arguments.py | 497 ++++++++++- .../tests/test_conversation_attachments.py | 56 ++ backend/tests/test_design_intent.py | 3 +- backend/tests/test_design_intent_flow.py | 326 ++++++-- backend/tests/test_part_skills.py | 20 +- backend/tests/test_profile_schema.py | 82 +- .../[conversationId]/attachments}/route.ts | 5 +- .../conversations/[conversationId]/route.ts | 1 - frontend/src/app/globals.css | 63 +- frontend/src/components/agent-studio.tsx | 39 +- frontend/src/components/agent-thread.tsx | 95 ++- frontend/src/components/cad-message-parts.tsx | 104 ++- .../src/components/cad-viewer-preview.tsx | 2 - frontend/src/lib/cad-artifacts.ts | 2 - frontend/src/lib/cad-messages.ts | 5 +- frontend/src/lib/cad-stream.test.ts | 14 + frontend/src/lib/cad-stream.ts | 7 + frontend/src/lib/cad-types.ts | 24 +- 34 files changed, 2687 insertions(+), 456 deletions(-) create mode 100644 backend/app/services/flange_sleeve_template.py create mode 100644 backend/engine/cdsl_engine/generation_compiler.py create mode 100644 backend/engine/cdsl_engine/generation_spec.py create mode 100644 backend/engine/cdsl_engine/generation_spec_schema.json create mode 100644 backend/tests/test_conversation_attachments.py rename frontend/src/app/api/{uploads => conversations/[conversationId]/attachments}/route.ts (55%) diff --git a/backend/.env b/backend/.env index eda918ba..b60a1ec1 100644 --- a/backend/.env +++ b/backend/.env @@ -1,19 +1,22 @@ # Default provider. Only providers with an API key are exposed to the UI. CDSL_DEFAULT_PROVIDER=deepseek -CDSL_DEFAULT_MODEL=deepseek-chat +CDSL_DEFAULT_MODEL=deepseek-v4-flash-vision-exp +# CDSL_DEFAULT_PROVIDER=openai +# CDSL_DEFAULT_MODEL=gpt-5.5 # DeepSeek. Fill in your own API key below. CDSL_LLM_BASE_URL=https://api.deepseek.com/v1 CDSL_LLM_API_KEY=sk-d3f8fe84bf9a4100a6563559e5d2fefd -CDSL_LLM_MODEL=deepseek-chat +CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp CDSL_LLM_TIMEOUT_S=90 +CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp # Optional OpenAI provider. Comma-separate enabled models; list vision models # separately so image attachments can be routed safely. -CDSL_OPENAI_BASE_URL=https://api.openai.com/v1 -CDSL_OPENAI_API_KEY= -CDSL_OPENAI_MODELS=gpt-4.1,gpt-4.1-mini -CDSL_OPENAI_VISION_MODELS=gpt-4.1,gpt-4.1-mini +CDSL_OPENAI_BASE_URL=https://api.vip1129.cc/v1 +CDSL_OPENAI_API_KEY=sk-6586c229d77de8c421ba98e7eb0d9c6bb10f08ebc796de946ed17cf8d0d7a229 +CDSL_OPENAI_MODELS=gpt-5.5 +CDSL_OPENAI_VISION_MODELS=gpt-5.5 # Optional Kimi provider. CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1 @@ -24,7 +27,7 @@ CDSL_KIMI_VISION_MODELS= # OpenAI-compatible function schemas. This affects generate_cdsl_model's JSON # arguments only, never ordinary assistant chat text. CDSL_DEEPSEEK_STRICT_TOOL_SCHEMA=false -CDSL_OPENAI_STRICT_TOOL_SCHEMA=true +CDSL_OPENAI_STRICT_TOOL_SCHEMA=false CDSL_KIMI_STRICT_TOOL_SCHEMA=true # To enable individual models instead of every model from a provider, keep the # provider-wide switch false and list exact IDs, for example: diff --git a/backend/agent/skills/cad-engine/SKILL.md b/backend/agent/skills/cad-engine/SKILL.md index 828b2458..9427d021 100644 --- a/backend/agent/skills/cad-engine/SKILL.md +++ b/backend/agent/skills/cad-engine/SKILL.md @@ -13,15 +13,16 @@ Help an AI agent generate CAD models through the repository's CDSL engine. 1. Use any injected CDSL part-skill bridge to establish the part structure, parameter roles, and feature order. It is planning guidance only. -2. Submit a complete DesignIntent before reading CDSL references or generating - CDSL. It contains semantic structures and mappings, never sketch coordinates - or raw CAD code. The backend records canonical selected part skills. +2. Record a concise natural-language design brief before reading CDSL references + or generating CDSL. The brief captures the intended structures, dependency + order, key dimensions, and explicit assumptions. It is reference context + only, never an executable CAD contract. 3. Read the engine README, `profile_schema.json`, and `cdsl_schema.json`, then search the official CDSL library for schema-valid profiles and feature expressions. `cdsl_schema.json` is the executable input contract. -4. Produce parameterized CDSL in feature dependency order, using the accepted - DesignIntent's feature IDs and mappings plus the part - plan for intent and CDSL references for concrete schema expressions. +4. Produce parameterized CDSL in feature dependency order. CDSL is the sole + authoritative and executable model: choose its feature IDs, profiles, + selectors, and schema-valid expressions directly from the CDSL contract. 5. Call the generation tool only when every chosen feature is executable by the current schema and runtime. Validate through the `cdsl_only` path, then generate STEP and GLB artifacts. @@ -29,7 +30,8 @@ Help an AI agent generate CAD models through the repository's CDSL engine. ## Part-skill boundary - User requirements and the CDSL schema/runtime override bridge guidance; - CDSL examples are lower-priority expression references. + CDSL examples are lower-priority expression references. The textual design + brief is planning context and is never validated against the CDSL. - Do not expose upstream source skills directly or treat them as code. Use only the injected CDSL bridge content. - Never emit build123d source or invent an atomic, profile, selector, or diff --git a/backend/agent/skills/cad-engine/planning-recipe.md b/backend/agent/skills/cad-engine/planning-recipe.md index a05cb327..95c6ff3c 100644 --- a/backend/agent/skills/cad-engine/planning-recipe.md +++ b/backend/agent/skills/cad-engine/planning-recipe.md @@ -1,40 +1,35 @@ -# DesignIntent Planning Recipe +# CDSL Planning Recipe ## Requirement analysis -1. Treat the backend-injected part-family skill as the selected family before - identifying structures. It provides planning guidance only. -2. Decompose the requested part into structures with roles: `base`, - `reference`, `additive`, `subtractive`, `dressup`, or `pattern`. -3. Every structure must state a purpose, structural dependencies, parameter - roles, selector roles, an executable CDSL atomic ID, and a named profile - type when its atomic needs a sketch. -4. Put every structure exactly once in `feature_order`, after its dependencies. - Its `cdsl_feature_id` is the immutable ID that the later CDSL feature must use. -5. Do not put coordinates, raw CDSL, Build123d, or low-level sketch data in a - DesignIntent. -6. Missing essential dimensions are blocking `open_questions`. Unsupported - geometry is a `capability_gaps` item. A blocking item requires - `status: needs_clarification`; do not approximate silently. +1. Treat the backend-injected part-family skill as planning guidance only. +2. Write a concise natural-language brief describing the requested structures, + dependency order, key dimensions, and explicit assumptions. +3. Ask the user one concise clarification question when an essential dimension + or requested outcome is unknown. Do not silently fabricate it. +4. Keep the brief free of feature IDs, selector bindings, profile bindings, + sketch coordinates, raw CDSL, and Build123d source. Those implementation + choices belong only in the final CDSL document. +5. The brief is not stored or validated as a CAD artifact. It is reference + context for the model while it writes the one authoritative CDSL document. ## Part family boundary -- Use only the backend-injected primary and support skills. Never submit - `part_skill_ids`; the backend records the canonical selection for audit. +- Use only the backend-injected primary and support skills. The backend records + the canonical selection for audit. - Explicit user requirements override part skills. The executable CDSL schema/runtime overrides both part skills and library examples. - The current primary family is inherited during revisions unless the user explicitly asks to replace the whole part. A family conflict needs a concise clarification before a ready plan can be submitted. -- CDSL library examples show only expression patterns. They never add or remove - a requested DesignIntent structure. +- CDSL library examples show expression patterns only. They never override the + user's request or the CDSL schema/runtime. ## CDSL generation -- Submit `propose_design_intent` before any library or CDSL-generation call. -- After its ready result includes `intent_id`, inspect references and generate - complete CDSL. Use every planned `cdsl_feature_id` once, in `feature_order`. -- Do not add undeclared CDSL features, delete planned features, change planned - atomics/profiles, or omit declared selector evidence. +- Submit `describe_design_intent` before any library or CDSL-generation call. +- After the text brief is returned, inspect references and generate complete, + schema-valid CDSL. CDSL controls its own feature IDs, atomics, profiles, and + selectors; it is not checked for agreement with the brief. - Keep `cdsl_only` as the only build path. Never use `compiler_context` or a legacy fallback. diff --git a/backend/app/main.py b/backend/app/main.py index 09aebdc5..a6858f17 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,7 +3,7 @@ from __future__ import annotations import json from typing import Any -from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi.responses import JSONResponse, StreamingResponse from app.models.contracts import ChatRequest, ConversationPatch, ModifyRequest, ParameterUpdate @@ -88,29 +88,32 @@ async def create_conversation() -> JSONResponse: @app.patch("/v1/conversations/{conversation_id}") async def patch_conversation(conversation_id: str, payload: ConversationPatch) -> JSONResponse: try: - record = store.ensure_conversation(safe_conversation_id(conversation_id), payload.current_task_id, payload.attachments) + record = store.ensure_conversation(safe_conversation_id(conversation_id), payload.current_task_id) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error return JSONResponse(record) -@app.post("/v1/uploads") -async def upload_attachment( +@app.post("/v1/conversations/{conversation_id}/attachments") +async def upload_conversation_attachment( + conversation_id: str, file: UploadFile = File(...), - task_id: str | None = Form(default=None), ) -> JSONResponse: data = await file.read() filename = file.filename or "attachment" try: + conversation = safe_conversation_id(conversation_id) + if store.read_conversation(conversation) is None: + raise HTTPException(status_code=404, detail="Conversation not found") kind = classify_upload(filename, file.content_type or "", len(data)) - task = store.ensure_task(safe_task_id(task_id) if task_id else None, f"Attachment: {filename}") - relative_path, _ = store.write_upload(task["task_id"], filename, data) + relative_path, _ = store.write_conversation_upload(conversation, filename, data) extracted_path = "" if kind == "document": extracted_path = relative_path + ".txt" extracted = extract_document_text(data) - store.artifact_path(task["task_id"], extracted_path).write_text(extracted, encoding="utf-8") - record = attachment_record(task["task_id"], filename, file.content_type or "", relative_path, data, kind, extracted_path) + store.conversation_attachment_path(conversation, extracted_path).write_text(extracted, encoding="utf-8") + record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path) + store.add_conversation_attachment(conversation, record) return JSONResponse(record) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error @@ -189,10 +192,6 @@ 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") @@ -209,9 +208,6 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo 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: diff --git a/backend/app/models/contracts.py b/backend/app/models/contracts.py index 95470e67..668b59ff 100644 --- a/backend/app/models/contracts.py +++ b/backend/app/models/contracts.py @@ -28,7 +28,6 @@ class ChatRequest(BaseModel): class ConversationPatch(BaseModel): current_task_id: str | None = None - attachments: list[dict[str, Any]] | None = None class ParameterUpdate(BaseModel): @@ -57,8 +56,6 @@ 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" diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index 7d92db6b..611a6735 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -13,7 +13,12 @@ from typing import Any import httpx from app.models.contracts import ChatMessage -from app.services.engine_service import build_revision, load_engine, validate_cdsl +from app.services.engine_service import build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl +from app.services.flange_sleeve_template import ( + TEMPLATE_ID as FLANGE_SLEEVE_TEMPLATE_ID, + build_flange_sleeve_cdsl, + flange_sleeve_plan_schema, +) from app.services.library import CdslLibrary from app.services.part_skills import PartSkillLibrary from app.services.sse import event @@ -142,25 +147,12 @@ 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 "") @@ -199,25 +191,55 @@ 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() +FLANGE_SLEEVE_PLAN_SCHEMA = flange_sleeve_plan_schema() +GENERATION_TOOL_NAMES = {"generate_cdsl_model", "generate_flange_sleeve_model"} TOOL_SCHEMAS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "analyze_image_reference", + "description": ( + "Analyze the current image attachments as a CAD reference. " + "Use this before any CAD planning or generation for a newly uploaded image. " + "Describe only visible geometry and identify dimensions that may need confirmation." + ), + "parameters": { + "type": "object", + "properties": { + "part_type": {"type": "string", "minLength": 1}, + "visible_features": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "minItems": 1, + "maxItems": 12, + }, + "uncertain_features": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "maxItems": 8, + }, + "dimension_candidates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "label": {"type": "string", "minLength": 1}, + "reason": {"type": "string", "minLength": 1}, + }, + "required": ["id", "label", "reason"], + "additionalProperties": False, + }, + "maxItems": 12, + }, + }, + "required": ["part_type", "visible_features", "uncertain_features"], + "additionalProperties": False, + }, + }, + }, { "type": "function", "function": { @@ -242,16 +264,15 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ { "type": "function", "function": { - "name": "propose_design_intent", - "description": "Submit a complete semantic DesignIntent plan before searching CDSL references or generating CDSL.", + "name": "describe_design_intent", + "description": "Record a concise natural-language CAD plan as reference for this turn's CDSL generation. This plan is not a CAD contract; CDSL remains the only authoritative model.", "parameters": { "type": "object", "properties": { - "intent": DESIGN_INTENT_TOOL_SCHEMA, - "summary": {"type": "string", "minLength": 1}, + "plan": {"type": "string", "minLength": 1}, "assumptions": {"type": "array", "items": {"type": "string"}}, }, - "required": ["intent", "summary", "assumptions"], + "required": ["plan", "assumptions"], "additionalProperties": False, }, }, @@ -264,6 +285,29 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, }, }, + { + "type": "function", + "function": { + "name": "generate_flange_sleeve_model", + "description": ( + "Generate a square or rectangular flange sleeve from a compact semantic plan. " + "Use this preferred tool for a flange plate with a coaxial hollow tube, " + "a stepped or tapered tip, four mounting holes, and counterbores. " + "The backend's verified template creates all CDSL workplanes, sketches, " + "feature dependencies, and feature parameters." + ), + "parameters": { + "type": "object", + "properties": { + "plan": FLANGE_SLEEVE_PLAN_SCHEMA, + "summary": {"type": "string", "minLength": 1}, + "assumptions": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["plan", "summary", "assumptions"], + "additionalProperties": False, + }, + }, + }, { "type": "function", "function": { @@ -272,12 +316,11 @@ 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": ["design_intent_id", "cdsl", "summary", "assumptions"], + "required": ["cdsl", "summary", "assumptions"], "additionalProperties": False, }, }, @@ -285,14 +328,36 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ ] -def tools_for_model(model: ProviderModel) -> list[dict[str, Any]]: +def tools_for_model( + model: ProviderModel, + *, + include_image_analysis: bool = True, + flange_sleeve_only: bool = False, +) -> list[dict[str, Any]]: """Return this model's tool contract without mutating the shared schema.""" tools = deepcopy(TOOL_SCHEMAS) + if not include_image_analysis: + tools = [ + tool + for tool in tools + if tool.get("function", {}).get("name") != "analyze_image_reference" + ] + if flange_sleeve_only: + tools = [ + tool + for tool in tools + if tool.get("function", {}).get("name") not in { + "search_cdsl_library", + "read_cdsl_reference", + "read_current_cdsl", + "generate_cdsl_model", + } + ] if not model.strict_tool_schema: return tools for tool in tools: - if tool.get("function", {}).get("name") in {"propose_design_intent", "generate_cdsl_model"}: + if tool.get("function", {}).get("name") in GENERATION_TOOL_NAMES: # 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 @@ -312,6 +377,174 @@ def messages_for_model(messages: list[ChatMessage]) -> list[dict[str, Any]]: return result +def image_attachments(conversation: dict[str, Any]) -> list[dict[str, Any]]: + return [ + attachment + for attachment in conversation.get("attachments") or [] + if isinstance(attachment, dict) and attachment.get("kind") == "image" and attachment.get("id") + ] + + +def revision_input_attachments(conversation: dict[str, Any]) -> list[dict[str, str | int]]: + """Snapshot conversation-owned inputs without duplicating their files into a task.""" + conversation_id = str(conversation.get("conversation_id") or "") + snapshots: list[dict[str, str | int]] = [] + for attachment in conversation.get("attachments") or []: + if not isinstance(attachment, dict) or str(attachment.get("conversation_id") or "") != conversation_id: + continue + attachment_id = str(attachment.get("id") or "") + if not attachment_id: + continue + snapshots.append({ + "attachment_id": attachment_id, + "conversation_id": conversation_id, + "name": str(attachment.get("name") or ""), + "kind": str(attachment.get("kind") or ""), + "mime": str(attachment.get("mime") or ""), + "size": int(attachment.get("size") or 0), + "sha256": str(attachment.get("sha256") or ""), + }) + return snapshots + + +def cad_request_instruction(task_id: str, current_task: dict[str, Any] | None) -> str: + revision_id = str((current_task or {}).get("current_revision") or "") + if revision_id: + return f""" +CAD request state: +- Mode: revision. +- Target task: {task_id}; current successful revision: {revision_id}. +- Call read_current_cdsl first, then preserve unrelated features in the complete replacement CDSL. +""" + prior_attempt = f" Task {task_id} has no successful revision and is only a failed/incomplete build attempt." if task_id else "" + return f""" +CAD request state: +- Mode: create. +- There is no current successful CDSL revision.{prior_attempt} +- Do not call read_current_cdsl. Generate a new model after the normal planning workflow. +""" + + +def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | None: + """Return an analysis only when it covers every currently attached image.""" + attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)} + if not attachment_ids: + return None + for message in reversed(conversation.get("messages") or []): + if not isinstance(message, dict): + continue + for part in reversed(message.get("parts") or []): + if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": + continue + data = part.get("data") + if not isinstance(data, dict): + continue + analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)} + if attachment_ids.issubset(analyzed_ids): + return data + return None + + +def use_flange_sleeve_template(analysis: dict[str, Any] | None, task_id: str) -> bool: + """Route only a high-confidence new image reference to the verified template.""" + if task_id or not isinstance(analysis, dict): + return False + visible = analysis.get("visibleFeatures", analysis.get("visible_features", [])) + values = [analysis.get("partType", analysis.get("part_type", ""))] + if isinstance(visible, list): + values.extend(visible) + text = " ".join(str(value or "").casefold() for value in values) + has_flange = "法兰" in text or "flange" in text + has_sleeve = any(token in text for token in ("套筒", "管座", "圆筒", "cylind", "sleeve", "tube")) + return has_flange and has_sleeve + + +def flange_sleeve_template_instruction(enabled: bool) -> str: + if not enabled: + return "" + return """ +Template routing (highest priority for this request): +- This new image reference has been classified as a flange sleeve. +- After describe_design_intent, call generate_flange_sleeve_model exactly once. +- The complete-CDSL generation tool is intentionally unavailable for this request. +- Supply only visible semantic dimensions and assumptions; omitted dimensions use + the verified template defaults. Do not ask for CDSL implementation fields. +""" + + +def image_reference_instruction( + attachments: list[dict[str, Any]], + analysis: dict[str, Any] | None, +) -> str: + if not attachments: + return "" + if analysis is None: + return """ +Image-reference intake gate (highest priority for this turn): +- The user has uploaded image references that have not yet been analyzed. +- Your only tool call in this turn must be analyze_image_reference. +- Do not call describe_design_intent, search_cdsl_library, read_cdsl_reference, + read_current_cdsl, or generate_cdsl_model in this turn. +- Describe only what is visible. Do not invent dimensions from pixels or perspective. +- The backend will present the structured result and provide it back as + visual-reference context for you to continue this same task. +""" + return """ +Image-reference analysis already recorded for the current attachments: +{data} +Use this result as visual-reference context and do not analyze the image again. +The listed dimensions are candidates for confirmation, not a mandatory gate. +Interpret the full conversation to decide whether to ask a concise question, +make clearly stated approximate assumptions, or continue the ordinary CDSL +workflow. When the user permits or requests estimates, choose coherent values +yourself and record them as assumptions instead of asking again. Respect the +user's tolerance for estimates. Never present an inferred dimension as an exact +measurement from the image. +""".format(data=json.dumps(analysis, ensure_ascii=False, separators=(",", ":"))) + + +def normalize_image_analysis(arguments: dict[str, Any]) -> dict[str, Any]: + def text(value: Any, name: str, limit: int = 300) -> str: + normalized = str(value or "").strip() + if not normalized: + raise ValueError(f"analyze_image_reference requires a non-empty {name}") + return normalized[:limit] + + def text_list(value: Any, name: str, maximum: int) -> list[str]: + if not isinstance(value, list) or not value: + raise ValueError(f"analyze_image_reference requires a non-empty {name} array") + return [text(item, name, 240) for item in value[:maximum]] + + raw_dimensions = arguments.get("dimension_candidates") + if raw_dimensions is None: + raw_dimensions = [] + if not isinstance(raw_dimensions, list): + raise ValueError("analyze_image_reference dimension_candidates must be an array") + dimensions: list[dict[str, str]] = [] + used_ids: set[str] = set() + for item in raw_dimensions[:12]: + if not isinstance(item, dict): + raise ValueError("analyze_image_reference dimension_candidates must contain objects") + dimension_id = text(item.get("id"), "dimension_candidates.id", 80) + if dimension_id in used_ids: + continue + used_ids.add(dimension_id) + dimensions.append({ + "id": dimension_id, + "label": text(item.get("label"), "dimension_candidates.label", 160), + "reason": text(item.get("reason"), "dimension_candidates.reason", 240), + }) + uncertain = arguments.get("uncertain_features") + if not isinstance(uncertain, list): + raise ValueError("analyze_image_reference requires an uncertain_features array") + return { + "part_type": text(arguments.get("part_type"), "part_type"), + "visible_features": text_list(arguments.get("visible_features"), "visible_features", 12), + "uncertain_features": [text(item, "uncertain_features", 240) for item in uncertain[:8]], + "dimension_candidates": dimensions, + } + + def _viewer_selection_text(value: Any, limit: int = 240) -> str: return str(value or "").strip()[:limit] @@ -400,11 +633,12 @@ def system_prompt( viewer_context: list[dict[str, Any]] | None = None, task_id: str = "", part_skill_context: str = "", + image_reference_context: str = "", + cad_request_context: str = "", + flange_sleeve_template_mode: bool = False, ) -> 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" @@ -429,24 +663,40 @@ 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. +- For a square/rectangular flange with a coaxial hollow sleeve, stepped or + tapered front, and four mounting counterbores, call + generate_flange_sleeve_model. Submit only its compact semantic plan; do not + create workplanes, sketch IDs, feature IDs, dependencies, or raw CDSL for + that template. The backend creates and validates the complete CDSL. +- The backend can normalize only these unambiguous legacy aliases before + validation: sketch_id -> id on sketches, sketch -> sketch_id on features, + legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm. + Do not rely on it for geometry, dimensions, selectors, or feature intent. +- Call describe_design_intent first with a concise natural-language plan. + Its returned text is reference context for your CDSL work, not a second CAD + contract and not a source of feature IDs, profile bindings, or selectors. +- Do not call search_cdsl_library, read_cdsl_reference, + generate_flange_sleeve_model, or generate_cdsl_model until + describe_design_intent returns its plan. - 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 `{{"mm": [u_mm, v_mm, w_mm]}}`, never `[u_mm, v_mm]`. - If a tool reports INVALID_TOOL_ARGUMENTS, correct the arguments and call that tool again. Do not claim that the CAD model was generated. -- If generate_cdsl_model reports INVALID_CDSL, correct the full CDSL object and - call it again. Do not submit a partial object or claim success. +- If a generation tool reports INVALID_CDSL, correct its plan or complete CDSL + as applicable and call that same tool again. Do not claim success. Workflow limits: - Do not expose internal planning or "let me" commentary to the user while using tools. The application shows tool progress separately. +- Keep final user-facing responses operational and concise. When a structured + CAD result has been produced, do not restate its name, files, revision, or + tool progress; reply only when an assumption, limitation, or next decision + needs the user's attention. Otherwise finish without a prose postscript. +- When clarification is essential, ask exactly one direct question that names + the missing dimension or decision. Do not combine it with a tool trace or a + generic progress update. - Use at most two CDSL-library searches per user request. If neither finds a useful reference, stop searching and use the engine guide to either generate the model or ask one concise clarification question. @@ -454,19 +704,28 @@ Workflow limits: {response_language_instruction(user_text)} -You generate parameterized CDSL, never raw CAD source code. For new CAD requests: + {image_reference_context} + + {cad_request_context} + + {flange_sleeve_template_instruction(flange_sleeve_template_mode)} + +You generate executable CAD through compact semantic plans or parameterized CDSL, +never raw CAD source code. For new CAD requests: 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. +2. Call describe_design_intent with a concise textual plan. Ask one concise + user-facing question before CAD generation if essential dimensions are missing. +3. If the requested family is a flange sleeve matching the dedicated template, + call generate_flange_sleeve_model immediately after the textual plan. Do not + search the CDSL library and do not use generate_cdsl_model for that case. +4. For other families, search the local official CDSL library. Read at least + one relevant reference when a match exists; samples provide schema-valid + expressions, not higher-priority user requirements. +5. For non-template families, generate one complete CDSL and call + generate_cdsl_model. The backend-generated CDSL is the authoritative CAD + model; the textual plan is guidance only. +6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact. Precedence is strict: explicit user request, then CDSL schema/runtime, then part-skill guidance, then CDSL-library examples. Part skills never authorize @@ -476,13 +735,14 @@ 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 +stop: ask one concise question and do not call a generation tool 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. +For a revision, call read_current_cdsl first and preserve unrelated features. +Use the flange sleeve template only when the user wants to replace the whole +part or the current part already follows that family; otherwise submit the +complete replacement CDSL. The backend automatically uses the latest +successful revision as the parent; do not provide a revision ID. 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 @@ -493,9 +753,6 @@ dimensions or intent are missing. Ordinary explanations must not create CAD. Local skill: {skill} -DesignIntent planning recipe: -{planning_recipe} - Local engine guide: {engine_readme} @@ -543,14 +800,22 @@ class AgentService: 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 "" + conversation = self.store.ensure_conversation(conversation_id) + task_id = str(selected_task_id or conversation.get("current_task_id") or "") + current_task = self.store.read_task(task_id) if task_id else None 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 + if task_id and current_task is None: + error_payload = {"stage": "request", "message": "The selected CAD task no longer exists. Start a new model or select a valid task."} + 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, "") + yield event("done", {}) + return + self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id or None) + try: provider, model = self.settings.resolve_model(provider_id, model_id) except ValueError as error: @@ -581,16 +846,16 @@ class AgentService: yield event("done", {}) return + image_inputs = image_attachments(conversation) + recorded_image_analysis = image_reference_analysis(conversation) + requires_image_intake = bool(image_inputs) and recorded_image_analysis is None + flange_sleeve_template_mode = use_flange_sleeve_template(recorded_image_analysis, task_id) yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"}) references: list[str] = [] library_searches = 0 - 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": "", - } + planning_state: dict[str, Any] = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} yield event("progress", { "step": "select_part_skill", "label": "识别零件族", @@ -605,15 +870,24 @@ class AgentService: viewer_context, task_id, self.part_skill_library.render_context(part_skill_selection), + image_reference_instruction(image_inputs, recorded_image_analysis), + cad_request_instruction(task_id, current_task), + flange_sleeve_template_mode, ), }] model_messages.extend(messages_for_model(messages)) if attachment_message: model_messages.append({"role": "user", "content": attachment_message}) - tools = tools_for_model(model) - required_tool_name: str | None = None + tools = tools_for_model( + model, + include_image_analysis=requires_image_intake, + flange_sleeve_only=flange_sleeve_template_mode, + ) + required_tool_name: str | None = "analyze_image_reference" if requires_image_intake else None generate_argument_failures = 0 tool_argument_diagnostics: list[str] = [] + cdsl_validation_diagnostics: list[str] = [] + generation_completed = False try: for iteration in range(8): @@ -640,6 +914,27 @@ class AgentService: model_messages.append(choice) for call in tool_calls: name = str(call.get("function", {}).get("name") or "") + if name == "analyze_image_reference" and recorded_image_analysis is not None: + result = { + "ok": False, + "code": "IMAGE_ANALYSIS_ALREADY_RECORDED", + "message": ( + "Image analysis is already recorded for the current attachments. " + "Use that result and continue the CAD workflow; do not analyze the image again." + ), + } + 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": "error", + "message": "图片识别结果已存在,正在继续后续建模流程。", + }) + continue if name == "search_cdsl_library": library_searches += 1 if library_searches > 2: @@ -684,7 +979,7 @@ class AgentService: if diagnostic_path: tool_argument_diagnostics.append(diagnostic_path) result = invalid_tool_arguments_result(name or "tool", error) - if name == "generate_cdsl_model": + if name in GENERATION_TOOL_NAMES: generate_argument_failures += 1 if generate_argument_failures >= 2: raise RepeatedToolArgumentsError(str(error), tool_argument_diagnostics) @@ -701,6 +996,13 @@ class AgentService: "message": "CAD 工具参数格式无效,正在请求模型修正。", }) continue + cdsl_attempt_path = "" + if name == "generate_cdsl_model": + cdsl_attempt_path = self._record_cdsl_attempt( + conversation_id=conversation["conversation_id"], + arguments=arguments, + iteration=iteration + 1, + ) yield event("progress", { "step": name, "label": self._tool_label(name), @@ -715,16 +1017,26 @@ class AgentService: user_text, references, part_skill_selection=part_skill_selection, - intent_state=intent_state, + planning_state=planning_state, + image_attachment_ids=[str(attachment["id"]) for attachment in image_inputs], + input_attachments=revision_input_attachments(conversation), ) except (ValueError, RuntimeError) as error: - 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": + if name in GENERATION_TOOL_NAMES: + diagnostic_path = self._record_cdsl_validation_diagnostic( + conversation_id=conversation["conversation_id"], + task_id=task_id, + provider=provider, + model=model, + response=response, + finish_reason=response_choice.get("finish_reason"), + iteration=iteration + 1, + call=call, + arguments=arguments, + cdsl_attempt_path=cdsl_attempt_path, + error=error, + ) + cdsl_validation_diagnostics.append(diagnostic_path) result = invalid_cdsl_result(error) generated = None required_tool_name = name @@ -745,14 +1057,48 @@ class AgentService: "status": "success" if result.get("ok", True) else "error", "message": user_visible_tool_message(result, user_text), }) - if name == "propose_design_intent" and result.get("ok"): + if name == "describe_design_intent" and result.get("ok"): yield event("progress", { - "step": "validate_design_intent", - "label": "校验设计意图", + "step": "record_design_brief", + "label": "记录设计说明", "status": "success", - "message": "设计意图已通过结构、依赖和能力边界校验。", + "message": "设计说明已记录,将作为 CDSL 生成参考。", }) - if name in {"propose_design_intent", "generate_cdsl_model"} and result.get("ok"): + if name == "analyze_image_reference" and result.get("ok"): + image_payload = { + "attachmentIds": result["attachment_ids"], + "partType": result["part_type"], + "visibleFeatures": result["visible_features"], + "uncertainFeatures": result["uncertain_features"], + "dimensionCandidates": result["dimension_candidates"], + } + assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) + yield event("image_analysis", image_payload) + recorded_image_analysis = image_payload + flange_sleeve_template_mode = use_flange_sleeve_template(image_payload, task_id) + required_tool_name = None + tools = tools_for_model( + model, + include_image_analysis=False, + flange_sleeve_only=flange_sleeve_template_mode, + ) + model_messages.append({ + "role": "system", + "content": image_reference_instruction(image_inputs, image_payload), + }) + if flange_sleeve_template_mode: + model_messages.append({ + "role": "system", + "content": flange_sleeve_template_instruction(True), + }) + yield event("progress", { + "step": "analyze_image_reference", + "label": "识别图片参考", + "status": "success", + "message": "已识别可见结构,正在根据对话继续判断建模方案。", + }) + continue + if name in GENERATION_TOOL_NAMES and result.get("ok"): required_tool_name = None if generated: result_payload = { @@ -765,13 +1111,10 @@ 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"], } - successful_result = result_payload assistant_parts.append({"type": "data-cad-result", "data": result_payload}) yield event("cad_result", result_payload) yield event("progress", { @@ -780,8 +1123,23 @@ class AgentService: "status": "success", "message": "已通过 cdsl_only runtime 构建 STEP 和 GLB。", }) + generation_completed = True + break + if generation_completed: + break if iteration == 7: - error_payload = {"stage": "agent", "message": "Agent tool loop reached its safety limit."} + if cdsl_validation_diagnostics: + diagnostic_paths = "、".join(cdsl_validation_diagnostics) + if any("\u4e00" <= char <= "\u9fff" for char in user_text): + message = f"模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:{diagnostic_paths}。" + else: + message = ( + "Agent tool loop reached its safety limit. " + f"CDSL validation diagnostics were saved to: {diagnostic_paths}." + ) + else: + message = "Agent tool loop reached its safety limit." + error_payload = {"stage": "agent", "message": message} assistant_parts.append({"type": "data-cad-error", "data": error_payload}) yield event("cad_error", error_payload) except Exception as error: @@ -795,8 +1153,6 @@ class AgentService: "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", {}) @@ -863,6 +1219,61 @@ class AgentService: } return self.store.write_tool_call_diagnostic(conversation_id, payload) + def _record_cdsl_attempt( + self, + *, + conversation_id: str, + arguments: dict[str, Any], + iteration: int, + ) -> str: + candidate = arguments.get("cdsl") + if isinstance(candidate, str): + try: + candidate = json.loads(candidate) + except json.JSONDecodeError: + pass + return self.store.write_cdsl_attempt(conversation_id, candidate, iteration) + + def _record_cdsl_validation_diagnostic( + self, + *, + conversation_id: str, + task_id: str, + provider: ProviderConfig, + model: ProviderModel, + response: dict[str, Any], + finish_reason: Any, + iteration: int, + call: dict[str, Any], + arguments: dict[str, Any], + cdsl_attempt_path: str, + error: Exception, + ) -> str: + function = call.get("function") if isinstance(call.get("function"), dict) else {} + payload = { + "schema_version": "1.0", + "recorded_at": now_iso(), + "kind": "cdsl_validation_failure", + "conversation_id": conversation_id, + "task_id": task_id, + "provider_id": provider.id, + "model_id": model.id, + "strict_tool_schema": model.strict_tool_schema, + "completion_id": response.get("id"), + "response_model": response.get("model"), + "finish_reason": finish_reason, + "usage": response.get("usage"), + "iteration": iteration, + "tool_call_id": call.get("id"), + "tool_name": function.get("name"), + "cdsl_attempt_path": cdsl_attempt_path, + "summary": str(arguments.get("summary") or ""), + "assumptions": arguments.get("assumptions") or [], + "validation_error_type": type(error).__name__, + "validation_error": str(error), + } + return self.store.write_cdsl_validation_diagnostic(conversation_id, payload) + async def _complete( self, messages: list[dict[str, Any]], @@ -885,6 +1296,14 @@ class AgentService: } async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client: response = await client.post(url, headers=headers, json=payload) + # Some reasoning-enabled, OpenAI-compatible models accept tools but + # reject an explicit tool_choice. Retry once without that constraint. + if ( + response.status_code == 400 + and "thinking mode does not support this tool_choice" in response.text.lower() + ): + payload.pop("tool_choice") + response = await client.post(url, headers=headers, json=payload) if response.status_code >= 400: if model.strict_tool_schema: raise StrictToolSchemaError( @@ -906,73 +1325,42 @@ class AgentService: references: list[str], *, part_skill_selection: dict[str, Any] | None = None, - intent_state: dict[str, Any] | None = None, + planning_state: dict[str, Any] | None = None, + image_attachment_ids: list[str] | None = None, + input_attachments: list[dict[str, str | int]] | 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" + state = planning_state if planning_state is not None else {"phase": "WAITING_FOR_PLAN", "design_brief": ""} + phase = str(state.get("phase") or "WAITING_FOR_PLAN") + if name == "analyze_image_reference": + if not image_attachment_ids: + raise ValueError("analyze_image_reference requires at least one image attachment") 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, + "attachment_ids": list(dict.fromkeys(image_attachment_ids)), + **normalize_image_analysis(arguments), + }, None + if name == "describe_design_intent": + plan = str(arguments.get("plan") or "").strip() + assumptions = arguments.get("assumptions") + if not plan or not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions): + raise ValueError("describe_design_intent requires a non-empty plan and an array of string assumptions") + state["design_brief"] = plan + state["phase"] = "PLAN_RECORDED" + return { + "ok": True, + "plan": plan, + "assumptions": [item.strip() for item in assumptions if item.strip()], + "message": "The design brief is recorded as reference only. CDSL remains the sole authoritative CAD model.", }, 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 + if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: + return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent 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 + if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: + return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent 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: @@ -986,38 +1374,39 @@ class AgentService: 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": - 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) - if not isinstance(cdsl, dict): - raise ValueError("generate_cdsl_model requires a CDSL JSON object") + if name in GENERATION_TOOL_NAMES: + if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: + return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before generating CAD."}, None + template_plan: dict[str, float | str] | None = None + operation: dict[str, Any] | None = None + if name == "generate_flange_sleeve_model": + cdsl, template_plan = build_flange_sleeve_cdsl(arguments.get("plan")) + operation = { + "type": "parameterized_template", + "template_id": FLANGE_SLEEVE_TEMPLATE_ID, + "plan": template_plan, + } + else: + 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") + cdsl, normalization_repairs = normalize_cdsl_for_engine(cdsl) # 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")} 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") + summary = str(arguments.get("summary") or "Parameterized CAD model") 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") + raise ValueError(f"{name} 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) + current_task = self.store.read_task(task_id) if task_id else None + parent_revision_id = str((current_task or {}).get("current_revision") or "") state["phase"] = "BUILDING" try: yieldable = await asyncio.to_thread( @@ -1029,15 +1418,14 @@ class AgentService: cdsl=cdsl, reference_ids=list(references), summary=summary, + parent_revision_id=parent_revision_id, + operation=operation, part_skills=part_skill_audit, generation_assumptions=assumptions, - design_intent=intent, - design_intent_path=str(intent_record["record"].get("path") or ""), + input_attachments=input_attachments, ) except Exception: - # The accepted plan stays current so the model can submit a - # corrected implementation without silently replanning. - state["phase"] = "INTENT_ACCEPTED" + state["phase"] = "PLAN_RECORDED" raise state["phase"] = "COMPLETED" return { @@ -1045,7 +1433,9 @@ class AgentService: "summary": summary, "task_id": yieldable["task_id"], "revision_id": yieldable["revision_id"], - "design_intent_id": design_intent_id, + "normalization_repairs": normalization_repairs, + "template_id": FLANGE_SLEEVE_TEMPLATE_ID if template_plan is not None else "", + "template_plan": template_plan, }, yieldable raise ValueError(f"Unknown agent tool: {name}") @@ -1059,7 +1449,8 @@ class AgentService: "search_cdsl_library": "检索 CDSL 模型库", "read_cdsl_reference": "读取 CDSL 参考模型", "read_current_cdsl": "读取当前 CDSL", - "propose_design_intent": "生成设计意图", + "describe_design_intent": "整理设计说明", + "generate_flange_sleeve_model": "生成参数化法兰套筒", "generate_cdsl_model": "生成 CDSL", }.get(name, "调用 CAD 工具") @@ -1067,26 +1458,31 @@ class AgentService: attachments = conversation.get("attachments") or [] if not attachments: return "" + conversation_id = str(conversation.get("conversation_id") or "") + if not conversation_id: + raise ValueError("Conversation attachment has no conversation id") 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 "") + attachment_conversation = str(attachment.get("conversation_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 attachment_conversation != conversation_id or not relative: + raise ValueError("Conversation attachment metadata is invalid") + path = self.store.conversation_attachment_path(conversation_id, relative) + if not path.is_file(): + raise ValueError(f"Conversation attachment is missing: {attachment.get('name') or attachment.get('id')}") if kind == "image": if not model.vision: - raise ValueError("The selected model does not support images. Choose a vision-capable OpenAI or Kimi model.") + raise ValueError("The selected model does not support images. Choose a vision-capable model enabled in backend/.env.") 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_path = self.store.conversation_attachment_path(conversation_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 diff --git a/backend/app/services/attachments.py b/backend/app/services/attachments.py index 101e9323..2a8372d9 100644 --- a/backend/app/services/attachments.py +++ b/backend/app/services/attachments.py @@ -34,10 +34,10 @@ def extract_document_text(data: bytes) -> str: 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]: +def attachment_record(conversation_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, + "conversation_id": conversation_id, "name": filename, "kind": kind, "path": relative_path, diff --git a/backend/app/services/editing.py b/backend/app/services/editing.py index 6de32090..779f7e35 100644 --- a/backend/app/services/editing.py +++ b/backend/app/services/editing.py @@ -147,10 +147,6 @@ 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", []) @@ -190,7 +186,4 @@ def apply_direct_edit( 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 "", ) diff --git a/backend/app/services/engine_service.py b/backend/app/services/engine_service.py index f2f3d274..eb76d849 100644 --- a/backend/app/services/engine_service.py +++ b/backend/app/services/engine_service.py @@ -75,6 +75,76 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None: raise ValueError(f"CDSL schema violation at {location}: {error.message}") +def _legacy_workplane(plane: str, offset: Any) -> dict[str, list[float]] | None: + if isinstance(offset, bool) or not isinstance(offset, (int, float)): + return None + distance = float(offset) + definitions = { + "XY": ([0.0, 0.0, distance], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + "XZ": ([0.0, distance, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), + "YZ": ([distance, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]), + } + definition = definitions.get(plane.upper()) + if definition is None: + return None + origin, x_dir, normal = definition + return {"origin_mm": origin, "x_dir": x_dir, "normal": normal} + + +def normalize_cdsl_for_engine(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: + """Convert unambiguous legacy LLM aliases into the current CDSL dialect. + + This intentionally does not infer dimensions, selectors, or feature + dependencies. Any non-mechanical error remains visible to the validator. + """ + normalized = copy.deepcopy(cdsl) + repairs: list[str] = [] + geometry = normalized.get("geometry") + sketches = geometry.get("sketches") if isinstance(geometry, dict) else None + if isinstance(sketches, list): + for index, sketch in enumerate(sketches): + if not isinstance(sketch, dict): + continue + if "id" not in sketch and isinstance(sketch.get("sketch_id"), str): + sketch["id"] = sketch.pop("sketch_id") + repairs.append(f"geometry.sketches[{index}]: sketch_id -> id") + + legacy_plane: Any = sketch.get("plane") + legacy_offset: Any = sketch.get("offset_mm", 0) + workplane_value = sketch.get("workplane") + if isinstance(workplane_value, dict) and "origin_mm" not in workplane_value: + legacy_plane = workplane_value.get("plane") + legacy_offset = workplane_value.get("offset_mm", 0) + elif "workplane" in sketch: + continue + + workplane = _legacy_workplane(legacy_plane, legacy_offset) if isinstance(legacy_plane, str) else None + if workplane is None: + continue + sketch["workplane"] = workplane + sketch.pop("plane", None) + sketch.pop("offset_mm", None) + repairs.append(f"geometry.sketches[{index}]: legacy plane/offset_mm -> workplane") + + features = normalized.get("features") + if isinstance(features, list): + for index, feature in enumerate(features): + if not isinstance(feature, dict): + continue + if "sketch_id" not in feature and isinstance(feature.get("sketch"), str): + feature["sketch_id"] = feature.pop("sketch") + repairs.append(f"features[{index}]: sketch -> sketch_id") + if "depends_on" not in feature: + feature["depends_on"] = [] + repairs.append(f"features[{index}]: added empty depends_on") + params = feature.get("params") + axis = params.get("axis") if isinstance(params, dict) else None + if isinstance(axis, dict) and "origin_mm" not in axis and "point_mm" in axis: + axis["origin_mm"] = axis.pop("point_mm") + repairs.append(f"features[{index}].params.axis: point_mm -> origin_mm") + return normalized, repairs + + def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: if not isinstance(cdsl, dict): raise ValueError("CDSL must be a JSON object") @@ -392,7 +462,7 @@ def build_revision( summary: str, parent_revision_id: str | None = None, operation: dict[str, Any] | None = None, - attachments: list[dict[str, Any]] | None = None, + input_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, @@ -455,7 +525,7 @@ def build_revision( "summary": summary, "parent_revision_id": parent_revision_id or "", "operation": operation or {}, - "attachments": attachments or [], + "input_attachments": input_attachments or [], } if status == "success": record.update({ diff --git a/backend/app/services/flange_sleeve_template.py b/backend/app/services/flange_sleeve_template.py new file mode 100644 index 00000000..f7e1f705 --- /dev/null +++ b/backend/app/services/flange_sleeve_template.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import copy +import math +import re +from typing import Any + + +TEMPLATE_ID = "flange_sleeve_v1" + +# These defaults describe the pictured family, but the model may override every +# dimension that affects the visible form. The backend owns all CDSL plumbing. +DEFAULT_PLAN: dict[str, float | str] = { + "name": "Parameterized flange sleeve", + "flange_width_mm": 120.0, + "flange_height_mm": 120.0, + "flange_thickness_mm": 14.0, + "corner_chamfer_mm": 10.0, + "tube_outer_diameter_mm": 70.0, + "tube_straight_length_mm": 95.0, + "tip_outer_diameter_mm": 62.0, + "tip_length_mm": 28.0, + "bore_diameter_mm": 46.0, + "boss_outer_diameter_mm": 82.0, + "boss_height_mm": 5.0, + "mount_hole_diameter_mm": 12.0, + "mount_counterbore_diameter_mm": 24.0, + "mount_counterbore_depth_mm": 5.0, + "mount_hole_u_mm": 42.0, + "mount_hole_v_mm": 42.0, +} + +_DIMENSION_FIELDS = tuple(key for key in DEFAULT_PLAN if key != "name") +_PART_ID = re.compile(r"^[A-Za-z0-9_-]{3,80}$") + + +def flange_sleeve_plan_schema() -> dict[str, Any]: + """Return the compact semantic plan accepted by the flange-sleeve tool.""" + properties: dict[str, Any] = { + "template": {"const": TEMPLATE_ID}, + "name": {"type": "string", "minLength": 1, "maxLength": 120}, + "part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"}, + } + for field in _DIMENSION_FIELDS: + minimum = 0 if field == "corner_chamfer_mm" else 0.001 + properties[field] = {"type": "number", "exclusiveMinimum": minimum} if minimum else { + "type": "number", "minimum": 0, + } + return { + "type": "object", + "properties": properties, + "required": ["template"], + "additionalProperties": False, + } + + +def normalize_flange_sleeve_plan(plan: Any) -> dict[str, float | str]: + """Validate only semantic dimensions; do not accept CDSL implementation data.""" + if not isinstance(plan, dict): + raise ValueError("flange sleeve plan must be a JSON object") + if plan.get("template") != TEMPLATE_ID: + raise ValueError(f"flange sleeve plan template must be {TEMPLATE_ID}") + allowed = {"template", "part_id", *DEFAULT_PLAN} + unknown = sorted(str(key) for key in plan if key not in allowed) + if unknown: + raise ValueError(f"flange sleeve plan has unsupported fields: {', '.join(unknown)}") + + normalized = copy.deepcopy(DEFAULT_PLAN) + for field in _DIMENSION_FIELDS: + if field not in plan: + continue + value = plan[field] + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)): + raise ValueError(f"flange sleeve plan {field} must be a finite number") + number = float(value) + if number < 0 if field == "corner_chamfer_mm" else number <= 0: + comparator = "non-negative" if field == "corner_chamfer_mm" else "greater than zero" + raise ValueError(f"flange sleeve plan {field} must be {comparator}") + normalized[field] = number + + name = str(plan.get("name") or normalized["name"]).strip() + if not name: + raise ValueError("flange sleeve plan name must not be empty") + normalized["name"] = name[:120] + part_id = str(plan.get("part_id") or "flange_sleeve_template") + if not _PART_ID.fullmatch(part_id): + raise ValueError("flange sleeve plan part_id must use letters, numbers, underscores, or hyphens") + normalized["part_id"] = part_id + _validate_dimensions(normalized) + return normalized + + +def _validate_dimensions(plan: dict[str, float | str]) -> None: + number = lambda field: float(plan[field]) + width, height = number("flange_width_mm"), number("flange_height_mm") + thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm") + tube_od, tip_od, bore = number("tube_outer_diameter_mm"), number("tip_outer_diameter_mm"), number("bore_diameter_mm") + boss_od = number("boss_outer_diameter_mm") + hole_od, counterbore_od = number("mount_hole_diameter_mm"), number("mount_counterbore_diameter_mm") + counterbore_depth = number("mount_counterbore_depth_mm") + hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm") + + if chamfer * 2 >= min(width, height): + raise ValueError("flange sleeve plan corner_chamfer_mm must be less than half the flange side") + if not bore < min(tube_od, tip_od): + raise ValueError("flange sleeve plan bore_diameter_mm must be smaller than both tube diameters") + if not tube_od <= boss_od <= min(width, height): + raise ValueError("flange sleeve plan boss_outer_diameter_mm must be between tube diameter and flange side") + if counterbore_od < hole_od: + raise ValueError("flange sleeve plan mount_counterbore_diameter_mm must not be smaller than mount_hole_diameter_mm") + if counterbore_depth > thickness: + raise ValueError("flange sleeve plan mount_counterbore_depth_mm must not exceed flange_thickness_mm") + + radius = counterbore_od / 2 + if abs(hole_u) + radius >= width / 2 or abs(hole_v) + radius >= height / 2: + raise ValueError("flange sleeve plan mounting counterbores must remain inside the flange boundary") + if chamfer and abs(hole_u) > width / 2 - chamfer and abs(hole_v) > height / 2 - chamfer: + edge_clearance = (width / 2 - abs(hole_u)) + (height / 2 - abs(hole_v)) + if edge_clearance < chamfer + radius * math.sqrt(2): + raise ValueError("flange sleeve plan mounting counterbores intersect the corner chamfers") + + +def _x_plane(offset_mm: float, normal_x: float = 1.0) -> dict[str, list[float]]: + return { + "origin_mm": [offset_mm, 0.0, 0.0], + "x_dir": [0.0, 1.0, 0.0], + "normal": [normal_x, 0.0, 0.0], + } + + +def _flange_profile(width: float, height: float, chamfer: float) -> dict[str, Any]: + if chamfer == 0: + return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height} + half_width, half_height = width / 2, height / 2 + return { + "type": "polygon", + "vertices": [ + [-half_width + chamfer, -half_height], + [half_width - chamfer, -half_height], + [half_width, -half_height + chamfer], + [half_width, half_height - chamfer], + [half_width - chamfer, half_height], + [-half_width + chamfer, half_height], + [-half_width, half_height - chamfer], + [-half_width, -half_height + chamfer], + ], + } + + +def build_flange_sleeve_cdsl(plan: Any) -> tuple[dict[str, Any], dict[str, float | str]]: + """Build a schema-valid CDSL flange sleeve from semantic, editable parameters.""" + values = normalize_flange_sleeve_plan(plan) + number = lambda field: float(values[field]) + width, height = number("flange_width_mm"), number("flange_height_mm") + thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm") + tube_radius, tip_radius, bore_radius = number("tube_outer_diameter_mm") / 2, number("tip_outer_diameter_mm") / 2, number("bore_diameter_mm") / 2 + straight, tip_length = number("tube_straight_length_mm"), number("tip_length_mm") + boss_radius, boss_height = number("boss_outer_diameter_mm") / 2, number("boss_height_mm") + hole_radius, counterbore_radius = number("mount_hole_diameter_mm") / 2, number("mount_counterbore_diameter_mm") / 2 + hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm") + hole_centers = [[u, v] for u in (-hole_u, hole_u) for v in (-hole_v, hole_v)] + + cdsl = { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.3.0", + "part_id": str(values["part_id"]), + "kind": "part", + "meta": {"name": str(values["name"]), "units": "mm"}, + "geometry": {"sketches": [ + { + "id": "flange_outline", + "workplane": _x_plane(-thickness), + "profile": _flange_profile(width, height, chamfer), + }, + { + "id": "sleeve_profile", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "normal": [0.0, 0.0, 1.0], + }, + "profile": { + "type": "polygon", + "vertices": [ + [0.0, bore_radius], + [0.0, tube_radius], + [straight, tube_radius], + [straight + tip_length, tip_radius], + [straight + tip_length, bore_radius], + ], + }, + }, + { + "id": "front_boss_ring", + "workplane": _x_plane(0.0), + "profile": { + "type": "annulus", + "inner_radius_mm": bore_radius, + "outer_radius_mm": boss_radius, + "center": [0.0, 0.0], + }, + }, + { + "id": "center_bore", + "workplane": _x_plane(-thickness), + "profile": {"type": "circle", "radius_mm": bore_radius, "center": [0.0, 0.0]}, + }, + { + "id": "mount_holes", + "workplane": _x_plane(-thickness), + "profile": { + "type": "circles", + "items": [{"radius_mm": hole_radius, "center": center} for center in hole_centers], + }, + }, + { + "id": "mount_counterbores", + "workplane": _x_plane(0.0, -1.0), + "profile": { + "type": "circles", + "items": [{"radius_mm": counterbore_radius, "center": center} for center in hole_centers], + }, + }, + ]}, + "features": [ + { + "id": "flange_plate", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "sketch_id": "flange_outline", + "params": {"distance_mm": thickness}, + }, + { + "id": "sleeve_body", + "atomic_id": "revolve_add", + "depends_on": ["flange_plate"], + "sketch_id": "sleeve_profile", + "params": { + "angle_deg": 360.0, + "axis": {"origin_mm": [0.0, 0.0, 0.0], "direction": [1.0, 0.0, 0.0]}, + }, + }, + { + "id": "front_boss", + "atomic_id": "extrude_add_blind", + "depends_on": ["flange_plate", "sleeve_body"], + "sketch_id": "front_boss_ring", + "params": {"distance_mm": boss_height}, + }, + { + "id": "center_bore_cut", + "atomic_id": "extrude_cut_blind", + "depends_on": ["flange_plate", "sleeve_body", "front_boss"], + "sketch_id": "center_bore", + "params": {"distance_mm": thickness + straight + tip_length + boss_height + 1.0}, + }, + { + "id": "mount_hole_cuts", + "atomic_id": "extrude_cut_blind", + "depends_on": ["center_bore_cut"], + "sketch_id": "mount_holes", + "params": {"distance_mm": thickness + 1.0}, + }, + { + "id": "mount_counterbore_cuts", + "atomic_id": "extrude_cut_blind", + "depends_on": ["mount_hole_cuts"], + "sketch_id": "mount_counterbores", + "params": {"distance_mm": number("mount_counterbore_depth_mm")}, + }, + ], + } + return cdsl, values diff --git a/backend/app/services/part_skills.py b/backend/app/services/part_skills.py index 345ef19e..653cc1aa 100644 --- a/backend/app/services/part_skills.py +++ b/backend/app/services/part_skills.py @@ -207,8 +207,8 @@ class PartSkillLibrary: 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. + # Legacy tasks can contain a DesignIntent created before their first + # runtime build. Retain this fallback only for historical artifacts. 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), diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index 8aa1732c..bcdac2a4 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -88,11 +88,26 @@ class WorkspaceStore: write_json(path, payload) return (Path(conversation) / relative).as_posix() + def write_cdsl_attempt(self, conversation_id: str, cdsl: Any, iteration: int) -> str: + """Retain a parsed model candidate before validation or execution.""" + conversation = safe_conversation_id(conversation_id) + relative = Path("diagnostics") / f"cdsl_attempt_{iteration:02d}_{secrets.token_hex(8)}.json" + path = self.conversation_dir(conversation) / relative + write_json(path, cdsl) + return (Path(conversation) / relative).as_posix() + + def write_cdsl_validation_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str: + """Persist the reason a retained CDSL candidate was rejected.""" + conversation = safe_conversation_id(conversation_id) + relative = Path("diagnostics") / f"cdsl_validation_{secrets.token_hex(8)}.json" + path = self.conversation_dir(conversation) / relative + write_json(path, payload) + return (Path(conversation) / relative).as_posix() + 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) @@ -102,21 +117,18 @@ class WorkspaceStore: 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.1", + "schema_version": "1.2", "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 [], + "attachments": [], } write_json(path, record) return record @@ -135,14 +147,34 @@ class WorkspaceStore: 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]: + def write_conversation_upload(self, conversation_id: str, filename: str, data: bytes) -> tuple[str, Path]: + conversation = safe_conversation_id(conversation_id) 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 = self.conversation_attachment_path(conversation, relative.as_posix()) target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) return relative.as_posix(), target + def add_conversation_attachment(self, conversation_id: str, attachment: dict[str, Any]) -> dict[str, Any]: + conversation = safe_conversation_id(conversation_id) + record = self.read_conversation(conversation) + if record is None: + raise ValueError("Conversation not found") + if str(attachment.get("conversation_id") or "") != conversation: + raise ValueError("Attachment does not belong to this conversation") + attachment_id = str(attachment.get("id") or "") + if not attachment_id: + raise ValueError("Attachment id is required") + self.conversation_attachment_path(conversation, str(attachment.get("path") or "")) + attachments = record.setdefault("attachments", []) + if any(str(item.get("id") or "") == attachment_id for item in attachments if isinstance(item, dict)): + raise ValueError("Attachment already exists") + attachments.append(attachment) + record["updated_at"] = now_iso() + write_json(self.conversation_path(conversation), record) + return record + 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) @@ -274,3 +306,11 @@ class WorkspaceStore: if root != target and root not in target.parents: raise ValueError("Artifact path escapes task directory") return target + + def conversation_attachment_path(self, conversation_id: str, relative_path: str) -> Path: + safe = safe_relative_path(relative_path) + root = self.conversation_dir(conversation_id).resolve() + target = (root / safe).resolve() + if root != target and root not in target.parents: + raise ValueError("Attachment path escapes conversation directory") + return target diff --git a/backend/engine/cdsl_engine/design_intent.py b/backend/engine/cdsl_engine/design_intent.py index 3abe589e..76c18b3f 100644 --- a/backend/engine/cdsl_engine/design_intent.py +++ b/backend/engine/cdsl_engine/design_intent.py @@ -99,8 +99,8 @@ def validate_design_intent( raise DesignIntentError("INVALID_DESIGN_INTENT", "revise DesignIntent requires base_revision_id") if current_revision_id and base_revision_id != current_revision_id: raise DesignIntentError( - "INVALID_DESIGN_INTENT", - "revise DesignIntent base_revision_id must be the current successful revision", + "DESIGN_INTENT_BASE_REVISION_MISMATCH", + f"revise DesignIntent base_revision_id must be {current_revision_id}, the current successful revision", ) structures = normalized["structures"] diff --git a/backend/engine/cdsl_engine/generation_compiler.py b/backend/engine/cdsl_engine/generation_compiler.py new file mode 100644 index 00000000..36bc2511 --- /dev/null +++ b/backend/engine/cdsl_engine/generation_compiler.py @@ -0,0 +1,212 @@ +"""Deterministic GenerationSpec -> CDSL compilers. + +The LLM supplies semantic parameters and feature intent. This module owns +the CDSL details so tool calls do not need to contain sketch IDs, selectors, or +runtime-specific parameter wrappers. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import Any, Protocol + +from .generation_spec import GenerationSpecError, validate_generation_spec + + +def _workplane(z: float = 0.0, normal: list[float] | None = None) -> dict[str, list[float]]: + return { + "origin_mm": [0.0, 0.0, float(z)], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": list(normal or [0.0, 0.0, 1.0]), + } + + +def _value(spec: dict[str, Any], name: str, default: float | int | str) -> Any: + parameter = (spec.get("parameters") or {}).get(name) + if isinstance(parameter, dict) and "value" in parameter: + return parameter["value"] + return default + + +def _number(spec: dict[str, Any], name: str, default: float) -> float: + value = _value(spec, name, default) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise GenerationSpecError(f"Parameter {name} must be numeric", f"$.parameters.{name}.value") + number = float(value) + if not number == number or number in {float("inf"), float("-inf")}: + raise GenerationSpecError(f"Parameter {name} must be finite", f"$.parameters.{name}.value") + return number + + +def _integer(spec: dict[str, Any], name: str, default: int) -> int: + value = _number(spec, name, default) + if int(value) != value: + raise GenerationSpecError(f"Parameter {name} must be an integer", f"$.parameters.{name}.value") + return int(value) + + +def _positive(number: float, name: str) -> float: + if number <= 0: + raise GenerationSpecError(f"Parameter {name} must be greater than zero", f"$.parameters.{name}.value") + return number + + +def _acceptance(spec: dict[str, Any], generated: list[dict[str, Any]]) -> list[dict[str, Any]]: + existing = copy.deepcopy(spec.get("acceptance") or []) + known = {str(item.get("id")) for item in existing} + for item in generated: + if item["id"] not in known: + existing.append(item) + known.add(item["id"]) + return existing + + +@dataclass(frozen=True) +class CompiledGeneration: + cdsl: dict[str, Any] + acceptance: list[dict[str, Any]] + provenance: dict[str, Any] + assumptions: list[str] + approximations: list[dict[str, Any]] + + +class Compiler(Protocol): + family: str + + def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration: + ... + + +class MountingPlateCompiler: + family = "mounting_plate" + + def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration: + width = _positive(_number(spec, "width", 100.0), "width") + height = _positive(_number(spec, "height", 60.0), "height") + thickness = _positive(_number(spec, "thickness", 6.0), "thickness") + hole_diameter = _positive(_number(spec, "hole_diameter", 4.5), "hole_diameter") + hole_offset = _positive(_number(spec, "hole_edge_offset", 10.0), "hole_edge_offset") + hole_count = _integer(spec, "hole_count", 4) + counterbore_diameter = _positive(_number(spec, "counterbore_diameter", hole_diameter * 2), "counterbore_diameter") + counterbore_depth = _positive(_number(spec, "counterbore_depth", min(thickness / 2, 3.0)), "counterbore_depth") + slot_length = _positive(_number(spec, "slot_length", 20.0), "slot_length") + slot_width = _positive(_number(spec, "slot_width", 12.0), "slot_width") + + if hole_count != 4: + raise GenerationSpecError("mounting_plate compiler currently requires four corner holes", "$.parameters.hole_count.value") + if counterbore_diameter <= hole_diameter: + raise GenerationSpecError("counterbore_diameter must exceed hole_diameter", "$.parameters.counterbore_diameter.value") + if counterbore_depth > thickness: + raise GenerationSpecError("counterbore_depth must not exceed thickness", "$.parameters.counterbore_depth.value") + if hole_offset * 2 >= min(width, height): + raise GenerationSpecError("hole_edge_offset leaves no usable plate area", "$.parameters.hole_edge_offset.value") + if slot_length < slot_width: + raise GenerationSpecError("slot_length must be at least slot_width", "$.parameters.slot_length.value") + + centers = [ + [-width / 2 + hole_offset, -height / 2 + hole_offset], + [width / 2 - hole_offset, -height / 2 + hole_offset], + [width / 2 - hole_offset, height / 2 - hole_offset], + [-width / 2 + hole_offset, height / 2 - hole_offset], + ] + frame = _workplane(thickness) + cdsl = { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.1.0", + "kind": "part", + "part_id": "generation-spec-preflight", + "meta": {"unit": "mm", "name": str(spec["part"]["name"])}, + "geometry": {"sketches": [ + {"id": "base_plate", "workplane": _workplane(), "profile": { + "type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height, + }}, + {"id": "mounting_holes", "workplane": frame, "profile": { + "type": "circles", "items": [{"center": center, "radius_mm": hole_diameter / 2} for center in centers], + }}, + {"id": "center_slot", "workplane": frame, "profile": { + "type": "obround", "center": [0.0, 0.0], "length_mm": slot_length, "width_mm": slot_width, + }}, + ]}, + "features": [ + {"id": "base_plate", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_plate", "params": {"distance_mm": thickness}}, + {"id": "mounting_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_plate"], "sketch_id": "mounting_holes", "params": {"distance_mm": thickness, "reverse": True}}, + {"id": "mounting_counterbores", "atomic_id": "hole_counterbore", "depends_on": ["mounting_holes"], "sketch_id": "mounting_holes", "params": { + "diameter_mm": hole_diameter, "depth_mm": thickness, "counterbore_diameter_mm": counterbore_diameter, + "counterbore_depth_mm": counterbore_depth, "positions": [{"mm": [x, y, 0.0]} for x, y in centers], "host_face": {"frame": frame}, + }}, + {"id": "center_slot", "atomic_id": "extrude_cut_blind", "depends_on": ["mounting_counterbores"], "sketch_id": "center_slot", "params": {"distance_mm": thickness, "reverse": True}}, + ], + } + assumptions = [str(item) for item in spec.get("assumptions") or []] + if "hole_diameter" not in (spec.get("parameters") or {}): + assumptions.append("M4 clearance hole defaults to 4.5 mm") + if "counterbore_depth" not in (spec.get("parameters") or {}): + assumptions.append("Counterbore depth defaults to the lesser of 3 mm and half the plate thickness") + acceptance = _acceptance(spec, [ + {"id": "overall_bbox", "type": "bbox", "expected": [width, height, thickness], "tolerance": 0.1, "severity": "blocking"}, + {"id": "mounting_hole_count", "type": "feature_count", "feature": "mounting_holes", "expected": 4, "tolerance": 0, "severity": "blocking"}, + {"id": "mounting_hole_diameter", "type": "hole_diameter", "feature": "mounting_holes", "expected": hole_diameter, "tolerance": 0.05, "severity": "blocking"}, + {"id": "center_slot_size", "type": "overall_length", "feature": "center_slot", "expected": slot_length, "tolerance": 0.1, "severity": "blocking"}, + ]) + provenance = { + "compiler": self.family, + "compiler_version": "1.0", + "references": references, + "feature_sources": {item["id"]: item["id"] for item in spec.get("features") or []}, + "parameter_paths": {name: f"parameters.{name}.value" for name in (spec.get("parameters") or {})}, + } + return CompiledGeneration(cdsl, acceptance, provenance, list(dict.fromkeys(assumptions)), list(spec.get("approximations") or [])) + + +class FlangeSleeveCompiler: + family = "flange_sleeve" + + def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration: + from app.services.flange_sleeve_template import build_flange_sleeve_cdsl, TEMPLATE_ID + + aliases = { + "width": "flange_width_mm", "height": "flange_height_mm", "thickness": "flange_thickness_mm", + "tube_outer_diameter": "tube_outer_diameter_mm", "bore_diameter": "bore_diameter_mm", + "hole_diameter": "mount_hole_diameter_mm", "counterbore_diameter": "mount_counterbore_diameter_mm", + "counterbore_depth": "mount_counterbore_depth_mm", "hole_offset_x": "mount_hole_u_mm", "hole_offset_y": "mount_hole_v_mm", + } + plan: dict[str, Any] = {"template": TEMPLATE_ID, "name": spec["part"]["name"]} + for source, target in aliases.items(): + if source in (spec.get("parameters") or {}): + plan[target] = _value(spec, source, 0) + cdsl, values = build_flange_sleeve_cdsl(plan) + acceptance = _acceptance(spec, [ + {"id": "flange_bbox", "type": "bbox", "expected": [float(values["flange_width_mm"]), float(values["flange_height_mm"]), float(values["flange_thickness_mm"])], "tolerance": 0.1, "severity": "blocking"}, + ]) + provenance = {"compiler": self.family, "compiler_version": "1.0", "template_id": TEMPLATE_ID, "references": references} + assumptions = list(dict.fromkeys([*map(str, spec.get("assumptions") or []), "Flange sleeve dimensions use the verified parameterized template defaults for omitted fields."])) + return CompiledGeneration(cdsl, acceptance, provenance, assumptions, list(spec.get("approximations") or [])) + + +COMPILERS: dict[str, Compiler] = { + "mounting_plate": MountingPlateCompiler(), + "flange_sleeve": FlangeSleeveCompiler(), +} + + +def compiler_for(family: str) -> Compiler: + try: + return COMPILERS[family] + except KeyError as error: + raise GenerationSpecError(f"No GenerationSpec compiler registered for part family: {family}", "$.part.family") from error + + +def compile_generation_spec(spec: dict[str, Any], references: list[dict[str, Any]] | None = None) -> dict[str, Any]: + normalized = validate_generation_spec(spec) + compiler = compiler_for(str(normalized["part"]["family"])) + compiled = compiler.compile(normalized, references or []) + return { + "spec": normalized, + "cdsl": compiled.cdsl, + "acceptance": compiled.acceptance, + "provenance": compiled.provenance, + "assumptions": compiled.assumptions, + "approximations": compiled.approximations, + } diff --git a/backend/engine/cdsl_engine/generation_spec.py b/backend/engine/cdsl_engine/generation_spec.py new file mode 100644 index 00000000..870587d4 --- /dev/null +++ b/backend/engine/cdsl_engine/generation_spec.py @@ -0,0 +1,177 @@ +"""Validation and normalization for the semantic GenerationSpec contract.""" + +from __future__ import annotations + +import copy +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator + + +KNOWN_PART_FAMILIES = frozenset({ + "mounting_plate", + "flange", + "flange_sleeve", + "simple_shaft", + "bearing_housing", + "mounting_bracket", + "hex_nut", + "slotted_plate", +}) + +KNOWN_FEATURE_KINDS = frozenset({ + "base_extrusion", + "base_revolve", + "boss", + "through_hole", + "blind_hole", + "counterbored_hole", + "countersunk_hole", + "hole_pattern", + "counterbored_hole_pattern", + "obround_cut", + "pocket", + "revolve_profile", + "coaxial_bore", + "fillet", + "chamfer", +}) + + +class GenerationSpecError(ValueError): + """A stable, user-repairable GenerationSpec validation error.""" + + def __init__(self, message: str, path: str = "$") -> None: + super().__init__(message) + self.path = path + + +@lru_cache(maxsize=1) +def generation_spec_schema() -> dict[str, Any]: + path = Path(__file__).with_name("generation_spec_schema.json") + schema = json.loads(path.read_text(encoding="utf-8")) + Draft202012Validator.check_schema(schema) + return schema + + +def _schema_error(spec: dict[str, Any]) -> GenerationSpecError | None: + errors = sorted( + Draft202012Validator(generation_spec_schema()).iter_errors(spec), + key=lambda error: (list(error.absolute_path), error.message), + ) + if not errors: + return None + error = errors[0] + location = "$" + "".join( + f"[{item}]" if isinstance(item, int) else f".{item}" + for item in error.absolute_path + ) + return GenerationSpecError(error.message, location) + + +def _parameter_value(spec: dict[str, Any], name: str) -> Any: + value = (spec.get("parameters") or {}).get(name) + if not isinstance(value, dict): + raise GenerationSpecError(f"Unknown parameter: {name}", f"$.parameters.{name}") + return value.get("value") + + +def _numeric(value: Any, path: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise GenerationSpecError("Expected a finite numeric value", path) + number = float(value) + if number != number or number in {float("inf"), float("-inf")}: + raise GenerationSpecError("Expected a finite numeric value", path) + return number + + +def _check_constraints(spec: dict[str, Any]) -> None: + for index, constraint in enumerate(spec.get("constraints") or []): + path = f"$.constraints[{index}]" + kind = constraint.get("type") + if kind in {"less_than", "less_equal", "greater_than", "greater_equal", "equal"}: + left = _numeric(_parameter_value(spec, str(constraint.get("left") or "")), f"{path}.left") + right_name = constraint.get("right") + right = _numeric(_parameter_value(spec, str(right_name)), f"{path}.right") if right_name else _numeric(constraint.get("value"), f"{path}.value") + ok = { + "less_than": left < right, + "less_equal": left <= right, + "greater_than": left > right, + "greater_equal": left >= right, + "equal": abs(left - right) <= 1e-9, + }[kind] + if not ok: + raise GenerationSpecError(constraint.get("message") or f"Constraint {kind} failed", path) + + +def _check_graph(spec: dict[str, Any]) -> None: + features = spec.get("features") or [] + ids = [str(item.get("id")) for item in features] + if len(ids) != len(set(ids)): + raise GenerationSpecError("Feature ids must be unique", "$.features") + known: set[str] = set() + for index, feature in enumerate(features): + feature_id = str(feature.get("id")) + kind = str(feature.get("kind")) + if kind not in KNOWN_FEATURE_KINDS: + raise GenerationSpecError(f"Unsupported feature kind: {kind}", f"$.features[{index}].kind") + for dependency in feature.get("depends_on") or []: + if dependency not in known: + raise GenerationSpecError( + f"Feature {feature_id} has a forward or missing dependency: {dependency}", + f"$.features[{index}].depends_on", + ) + known.add(feature_id) + + acceptance_ids = {str(item.get("id")) for item in spec.get("acceptance") or []} + if len(acceptance_ids) != len(spec.get("acceptance") or []): + raise GenerationSpecError("Acceptance ids must be unique", "$.acceptance") + for index, item in enumerate(spec.get("acceptance") or []): + feature = item.get("feature") + if feature and feature not in known: + raise GenerationSpecError(f"Acceptance refers to missing feature: {feature}", f"$.acceptance[{index}].feature") + + +def normalize_generation_spec(spec: dict[str, Any], *, request: str = "") -> dict[str, Any]: + if not isinstance(spec, dict): + raise GenerationSpecError("GenerationSpec must be a JSON object") + normalized = copy.deepcopy(spec) + normalized.setdefault("schema", "cad.generation-spec.v1") + normalized.setdefault("schema_version", "1.0") + normalized.setdefault("mode", "create") + normalized.setdefault("base_revision_id", "") + normalized.setdefault("parameters", {}) + normalized.setdefault("features", []) + normalized.setdefault("constraints", []) + normalized.setdefault("acceptance", []) + normalized.setdefault("assumptions", []) + normalized.setdefault("approximations", []) + normalized.setdefault("references", []) + normalized.setdefault("patch_intent", request) + return normalized + + +def validate_generation_spec( + spec: dict[str, Any], + *, + known_families: set[str] | frozenset[str] = KNOWN_PART_FAMILIES, +) -> dict[str, Any]: + normalized = normalize_generation_spec(spec) + error = _schema_error(normalized) + if error: + raise error + family = str(normalized["part"]["family"]) + if family not in known_families: + raise GenerationSpecError(f"Unsupported part family: {family}", "$.part.family") + for name, parameter in normalized["parameters"].items(): + source = parameter["source"] + if source == "user" and not parameter["locked"]: + raise GenerationSpecError("User parameters must be locked", f"$.parameters.{name}.locked") + if source == "image_estimate" and not parameter.get("assumption"): + raise GenerationSpecError("Image estimates require an assumption", f"$.parameters.{name}.assumption") + _check_graph(normalized) + _check_constraints(normalized) + return normalized diff --git a/backend/engine/cdsl_engine/generation_spec_schema.json b/backend/engine/cdsl_engine/generation_spec_schema.json new file mode 100644 index 00000000..d50fd219 --- /dev/null +++ b/backend/engine/cdsl_engine/generation_spec_schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cdsl.local/schema/cad.generation-spec.v1", + "title": "CDSL GenerationSpec", + "type": "object", + "additionalProperties": false, + "properties": { + "schema": {"const": "cad.generation-spec.v1"}, + "schema_version": {"const": "1.0"}, + "mode": {"enum": ["create", "revise"]}, + "base_revision_id": {"type": "string"}, + "patch_intent": {"type": "string"}, + "part": {"$ref": "#/$defs/part"}, + "parameters": {"type": "object", "additionalProperties": {"$ref": "#/$defs/parameter"}}, + "features": {"type": "array", "items": {"$ref": "#/$defs/feature"}}, + "constraints": {"type": "array", "items": {"$ref": "#/$defs/constraint"}}, + "acceptance": {"type": "array", "items": {"$ref": "#/$defs/acceptance"}}, + "assumptions": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "approximations": {"type": "array", "items": {"$ref": "#/$defs/approximation"}}, + "references": {"type": "array", "items": {"type": "object"}} + }, + "required": ["schema", "schema_version", "mode", "base_revision_id", "part", "parameters", "features", "constraints", "acceptance", "assumptions", "approximations", "references"], + "$defs": { + "part": { + "type": "object", + "additionalProperties": false, + "properties": { + "family": {"type": "string", "minLength": 1}, + "name": {"type": "string", "minLength": 1}, + "units": {"const": "mm"}, + "coordinate_system": {"type": "string", "minLength": 1} + }, + "required": ["family", "name", "units", "coordinate_system"] + }, + "parameter": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": {}, + "unit": {"enum": ["mm", "deg", "rad", "count", ""]}, + "source": {"enum": ["user", "technical_drawing", "image_estimate", "template_default", "derived_standard", "assumption"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "locked": {"type": "boolean"}, + "assumption": {"type": "string"} + }, + "required": ["value", "unit", "source", "confidence", "locked", "assumption"] + }, + "feature": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "kind": {"type": "string", "minLength": 1}, + "depends_on": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, + "parameters": {"type": "object"}, + "semantic_role": {"type": "string"} + }, + "required": ["id", "kind", "depends_on", "parameters"] + }, + "constraint": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": {"enum": ["less_than", "less_equal", "greater_than", "greater_equal", "equal", "inside_boundary", "symmetric", "same_axis", "distance", "count"]}, + "left": {"type": "string"}, + "right": {"type": "string"}, + "value": {}, + "feature": {"type": "string"}, + "margin": {"type": "number"}, + "ratio": {"type": "number"}, + "message": {"type": "string"} + }, + "required": ["type"] + }, + "acceptance": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, + "type": {"enum": ["bbox", "solid_count", "feature_count", "hole_diameter", "hole_center", "hole_spacing", "wall_thickness", "overall_length", "overall_diameter", "coaxiality", "symmetry", "through_condition", "blind_depth"]}, + "feature": {"type": "string"}, + "expected": {}, + "tolerance": {"type": "number", "minimum": 0}, + "severity": {"enum": ["blocking", "warning", "informational"]} + }, + "required": ["id", "type", "expected", "tolerance", "severity"] + }, + "approximation": { + "type": "object", + "additionalProperties": false, + "properties": { + "request": {"type": "string", "minLength": 1}, + "status": {"enum": ["approximated", "expanded", "omitted", "blocked"]}, + "translation": {"type": "string", "minLength": 1}, + "reason": {"type": "string", "minLength": 1} + }, + "required": ["request", "status", "translation", "reason"] + } + } +} diff --git a/backend/tests/test_agent_tool_arguments.py b/backend/tests/test_agent_tool_arguments.py index 52e13bb5..de89d6cb 100644 --- a/backend/tests/test_agent_tool_arguments.py +++ b/backend/tests/test_agent_tool_arguments.py @@ -5,14 +5,59 @@ import json import tempfile import unittest from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch from app.models.contracts import ChatMessage, MessagePart -from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, parse_tool_arguments, response_language_instruction, tools_for_model, user_visible_error_message +from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, normalize_image_analysis, parse_tool_arguments, response_language_instruction, tools_for_model, use_flange_sleeve_template, user_visible_error_message from app.services.library import CdslLibrary from app.services.storage import WorkspaceStore from app.settings import ProviderConfig, ProviderModel, Settings +class ToolChoiceCompatibilityTests(unittest.TestCase): + def test_retries_without_tool_choice_when_thinking_mode_rejects_it(self) -> None: + class FakeResponse: + def __init__(self, status_code: int, text: str, body: dict[str, object]) -> None: + self.status_code = status_code + self.text = text + self._body = body + + def json(self) -> dict[str, object]: + return self._body + + class FakeClient: + def __init__(self) -> None: + self.requests: list[dict[str, object]] = [] + self.responses = [ + FakeResponse(400, '{"error":{"message":"Thinking mode does not support this tool_choice"}}', {}), + FakeResponse(200, "", {"choices": [{"message": {"role": "assistant", "content": "ok"}}]}), + ] + + async def __aenter__(self) -> "FakeClient": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def post(self, _url: str, *, headers: dict[str, str], json: dict[str, object]) -> FakeResponse: + self.requests.append(dict(json)) + return self.responses.pop(0) + + agent = object.__new__(AgentService) + agent.settings = SimpleNamespace(llm_timeout_s=1) + client = FakeClient() + provider = ProviderConfig("deepseek", "DeepSeek", "https://example.invalid/v1", "test-key", (ProviderModel("deepseek-v4-flash-vision-exp", vision=True),)) + model = provider.models[0] + + with patch("app.services.agent_service.httpx.AsyncClient", return_value=client): + response = asyncio.run(agent._complete([], [], provider, model, "analyze_image_reference")) + + self.assertEqual(response["choices"][0]["message"]["content"], "ok") + self.assertEqual(client.requests[0]["tool_choice"], {"type": "function", "function": {"name": "analyze_image_reference"}}) + self.assertNotIn("tool_choice", client.requests[1]) + + class ParseToolArgumentsTests(unittest.TestCase): def test_accepts_one_json_object(self) -> None: payload = parse_tool_arguments(' {"summary":"water cup","cdsl":{"parts":[]}} ') @@ -62,11 +107,13 @@ class ParseToolArgumentsTests(unittest.TestCase): self.assertNotIn("extrude", cdsl["$defs"]["feature_atomic_ids"]["enum"]) self.assertEqual(cdsl, CDSL_TOOL_SCHEMA) - def test_strict_tool_schema_covers_design_intent_and_cdsl_generation_arguments(self) -> None: + def test_strict_tool_schema_covers_generation_arguments(self) -> None: tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True)) strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")] - self.assertEqual(strict_tools, ["propose_design_intent", "generate_cdsl_model"]) + self.assertEqual(strict_tools, ["generate_flange_sleeve_model", "generate_cdsl_model"]) + template_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_flange_sleeve_model") + self.assertEqual(template_tool["function"]["parameters"]["properties"]["plan"]["required"], ["template"]) generate_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_cdsl_model") self.assertEqual(generate_tool["function"]["parameters"]["properties"]["summary"], {"type": "string", "minLength": 1}) self.assertEqual(generate_tool["function"]["parameters"]["properties"]["cdsl"], CDSL_TOOL_SCHEMA) @@ -77,6 +124,38 @@ class ParseToolArgumentsTests(unittest.TestCase): self.assertFalse(any(tool["function"].get("strict") for tool in tools)) + def test_flange_sleeve_image_route_exposes_only_the_compact_plan_generator(self) -> None: + analysis = { + "partType": "带方形法兰的圆筒管座", + "visibleFeatures": ["同轴中空管", "四个安装孔"], + } + + self.assertTrue(use_flange_sleeve_template(analysis, "")) + self.assertFalse(use_flange_sleeve_template(analysis, "cad_existing")) + tools = tools_for_model(ProviderModel("default-model"), flange_sleeve_only=True) + names = [tool["function"]["name"] for tool in tools] + self.assertIn("generate_flange_sleeve_model", names) + self.assertNotIn("search_cdsl_library", names) + self.assertNotIn("read_cdsl_reference", names) + self.assertNotIn("read_current_cdsl", names) + self.assertNotIn("generate_cdsl_model", names) + + def test_recorded_image_analysis_is_not_exposed_as_a_tool(self) -> None: + tools = tools_for_model(ProviderModel("vision-model", vision=True), include_image_analysis=False) + + self.assertNotIn("analyze_image_reference", [tool["function"]["name"] for tool in tools]) + + def test_image_analysis_allows_no_dimension_candidates(self) -> None: + analysis_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "analyze_image_reference") + self.assertNotIn("dimension_candidates", analysis_tool["function"]["parameters"]["required"]) + + result = normalize_image_analysis({ + "part_type": "压铸外壳", + "visible_features": ["圆角矩形外轮廓"], + "uncertain_features": [], + }) + self.assertEqual(result["dimension_candidates"], []) + def test_strict_schema_rejection_is_localized_for_chinese_requests(self) -> None: message = user_visible_error_message( StrictToolSchemaError("provider rejected strict schema"), @@ -243,6 +322,332 @@ class ToolArgumentsRetryTests(unittest.TestCase): self.assertTrue(any("已修正工具参数" in str(event.get("text", "")) for event in events)) self.assertEqual(list(settings.task_root.glob("cad_*")), []) + def test_persists_every_cdsl_attempt_and_validation_failure(self) -> None: + class InvalidCdslAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + plan_call = { + "id": "design_brief", + "type": "function", + "function": { + "name": "describe_design_intent", + "arguments": json.dumps({"plan": "建立一个法兰。", "assumptions": []}), + }, + } + invalid_call = { + "id": "invalid_cdsl", + "type": "function", + "function": { + "name": "generate_cdsl_model", + "arguments": json.dumps({"cdsl": {}, "summary": "无效法兰", "assumptions": []}), + }, + } + self.responses = [ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [plan_call]}}]}, + *[ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [invalid_call]}}]} + for _ in range(7) + ], + ] + + async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]: + return self.responses.pop(0) + + backend_root = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory() as temporary_directory: + temporary_root = Path(temporary_directory) + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + settings = Settings( + task_root=temporary_root / "tasks", + conversation_root=temporary_root / "conversations", + library_root=backend_root / "cdsl_library", + engine_root=backend_root / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="test-model", + llm_timeout_s=1, + default_provider_id="test", + providers=(provider,), + ) + store = WorkspaceStore(settings) + agent = InvalidCdslAgent(settings, store, CdslLibrary(settings)) + conversation_id = "conv_000000000004" + message = ChatMessage(id="user_invalid_cdsl", role="user", parts=[MessagePart(type="text", text="生成一个法兰")]) + + async def collect_events() -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + async for chunk in agent.stream([message], conversation_id, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect_events()) + + diagnostics = settings.conversation_root / conversation_id / "diagnostics" + attempts = sorted(diagnostics.glob("cdsl_attempt_*.json")) + failures = sorted(diagnostics.glob("cdsl_validation_*.json")) + self.assertEqual(len(attempts), 7) + self.assertEqual(len(failures), 7) + self.assertTrue(all(json.loads(path.read_text(encoding="utf-8")) == {} for path in attempts)) + + records = sorted( + (json.loads(path.read_text(encoding="utf-8")) for path in failures), + key=lambda record: int(record["iteration"]), + ) + self.assertEqual([record["iteration"] for record in records], list(range(2, 9))) + self.assertTrue(all(record["kind"] == "cdsl_validation_failure" for record in records)) + self.assertTrue(all(record["validation_error_type"] == "ValueError" for record in records)) + self.assertTrue(all(record["validation_error"] for record in records)) + self.assertEqual( + {Path(record["cdsl_attempt_path"]).name for record in records}, + {path.name for path in attempts}, + ) + self.assertTrue(any("每次 CDSL 校验失败的诊断已保存到" in str(event.get("message", "")) for event in events)) + self.assertEqual(agent.responses, []) + self.assertEqual(list(settings.task_root.glob("cad_*")), []) + + +class ImageReferenceIntakeTests(unittest.TestCase): + @staticmethod + def _settings(temporary_root: Path, *, vision: bool = True) -> Settings: + backend_root = Path(__file__).resolve().parents[1] + provider = ProviderConfig( + "test", + "Test", + "https://example.invalid/v1", + "test-key", + (ProviderModel("vision-model", vision=vision),), + ) + return Settings( + task_root=temporary_root / "tasks", + conversation_root=temporary_root / "conversations", + library_root=backend_root / "cdsl_library", + engine_root=backend_root / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="vision-model", + llm_timeout_s=1, + default_provider_id="test", + providers=(provider,), + ) + + @staticmethod + def _add_image_attachment(store: WorkspaceStore, conversation_id: str) -> str: + store.ensure_conversation(conversation_id) + relative_path, _ = store.write_conversation_upload(conversation_id, "flange.png", b"image-bytes") + store.add_conversation_attachment(conversation_id, { + "id": "upload_flange", + "conversation_id": conversation_id, + "name": "flange.png", + "kind": "image", + "path": relative_path, + "mime": "image/png", + }) + return "upload_flange" + + @staticmethod + def _analysis_arguments() -> dict[str, object]: + return { + "part_type": "四孔法兰套筒", + "visible_features": ["中空圆筒", "四孔法兰", "螺栓孔"], + "uncertain_features": ["法兰背面可能有沉孔"], + "dimension_candidates": [ + {"id": "bore_diameter", "label": "中心孔直径", "reason": "图片没有标注内径"}, + {"id": "bolt_circle", "label": "螺栓孔中心距", "reason": "透视图无法确定孔距"}, + ], + } + + def test_image_request_keeps_structured_analysis_when_model_asks_a_question(self) -> None: + class ImageIntakeAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.required_tools: list[str | None] = [] + self.responses = [ + {"choices": [{"message": { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "image_analysis", + "type": "function", + "function": { + "name": "analyze_image_reference", + "arguments": json.dumps(ImageReferenceIntakeTests._analysis_arguments()), + }, + }], + }}]}, + {"choices": [{"message": { + "role": "assistant", + "content": "中心孔直径会显著影响零件用途,请确认这个尺寸。", + "tool_calls": [], + }}]}, + ] + + async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]: + self.required_tools.append(kwargs.get("required_tool_name") if "required_tool_name" in kwargs else args[4] if len(args) > 4 else None) + return self.responses.pop(0) + + with tempfile.TemporaryDirectory() as temporary_directory: + temporary_root = Path(temporary_directory) + settings = self._settings(temporary_root) + store = WorkspaceStore(settings) + conversation_id = "conv_000000000001" + self._add_image_attachment(store, conversation_id) + agent = ImageIntakeAgent(settings, store, CdslLibrary(settings)) + message = ChatMessage(id="user_image", role="user", parts=[MessagePart(type="text", text="生成图片中的模型")]) + + async def collect_events() -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + async for chunk in agent.stream([message], conversation_id, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect_events()) + conversation = store.read_conversation(conversation_id) + assistant_parts = conversation["messages"][-1]["parts"] + + self.assertEqual(agent.required_tools, ["analyze_image_reference", None]) + self.assertEqual(agent.responses, []) + self.assertTrue(any(event.get("partType") == "四孔法兰套筒" for event in events)) + self.assertTrue(any("中心孔直径" in str(event.get("text", "")) for event in events)) + self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-image-analysis", "text"]) + self.assertEqual(assistant_parts[0]["data"]["attachmentIds"], ["upload_flange"]) + self.assertEqual(list(settings.task_root.glob("cad_*")), []) + + def test_model_can_continue_to_generation_after_initial_analysis(self) -> None: + class EstimateAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.tool_sets: list[list[str]] = [] + self.tool_calls: list[str] = [] + self.responses = [ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "image_analysis", "type": "function", "function": { + "name": "analyze_image_reference", + "arguments": json.dumps(ImageReferenceIntakeTests._analysis_arguments()), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "design_brief", "type": "function", "function": { + "name": "describe_design_intent", + "arguments": json.dumps({ + "plan": "按图片比例建立法兰套筒。", + "assumptions": ["所有未标注尺寸按图片比例估算,单位为 mm。"], + }), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "build_cdsl", "type": "function", "function": { + "name": "generate_cdsl_model", + "arguments": json.dumps({"cdsl": {}, "summary": "估算尺寸的法兰套筒", "assumptions": ["尺寸按比例估算"]}), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "已按图片比例估算尺寸并生成模型。", "tool_calls": []}}]}, + ] + + async def _complete(self, messages: list[dict[str, object]], tools: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]: + self.tool_sets.append([str(tool["function"]["name"]) for tool in tools]) + return self.responses.pop(0) + + async def _run_tool(self, name: str, arguments: dict[str, object], *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]: + self.tool_calls.append(name) + if name == "generate_cdsl_model": + return {"ok": True, "summary": "估算尺寸的法兰套筒"}, None + return await super()._run_tool(name, arguments, *args, **kwargs) + + with tempfile.TemporaryDirectory() as temporary_directory: + settings = self._settings(Path(temporary_directory)) + store = WorkspaceStore(settings) + conversation_id = "conv_000000000002" + self._add_image_attachment(store, conversation_id) + agent = EstimateAgent(settings, store, CdslLibrary(settings)) + message = ChatMessage(id="user_estimate", role="user", parts=[MessagePart(type="text", text="根据图片直接推进建模,比例上的不确定性按合理工程判断处理。")]) + + async def collect_events() -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + async for chunk in agent.stream([message], conversation_id, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect_events()) + assistant_parts = store.read_conversation(conversation_id)["messages"][-1]["parts"] + + self.assertEqual(agent.tool_calls, ["analyze_image_reference", "describe_design_intent", "generate_cdsl_model"]) + self.assertIn("analyze_image_reference", agent.tool_sets[0]) + self.assertTrue(all("analyze_image_reference" not in tool_set for tool_set in agent.tool_sets[1:])) + self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-image-analysis", "text"]) + self.assertFalse(any("请补充以下尺寸" in str(event.get("text", "")) for event in events)) + + def test_recorded_analysis_reuses_context_without_reanalyzing(self) -> None: + class RecordedEstimateAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.tool_sets: list[list[str]] = [] + self.tool_calls: list[str] = [] + self.responses = [ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "design_brief", "type": "function", "function": { + "name": "describe_design_intent", + "arguments": json.dumps({"plan": "按既有识别结果建立法兰套筒。", "assumptions": ["尺寸按图片比例估算"]}), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "build_cdsl", "type": "function", "function": { + "name": "generate_cdsl_model", + "arguments": json.dumps({"cdsl": {}, "summary": "估算尺寸的法兰套筒", "assumptions": ["尺寸按比例估算"]}), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "已按已有识别结果继续生成模型。", "tool_calls": []}}]}, + ] + + async def _complete(self, messages: list[dict[str, object]], tools: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]: + self.tool_sets.append([str(tool["function"]["name"]) for tool in tools]) + return self.responses.pop(0) + + async def _run_tool(self, name: str, arguments: dict[str, object], *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]: + self.tool_calls.append(name) + if name == "generate_cdsl_model": + return {"ok": True, "summary": "估算尺寸的法兰套筒"}, None + return await super()._run_tool(name, arguments, *args, **kwargs) + + with tempfile.TemporaryDirectory() as temporary_directory: + settings = self._settings(Path(temporary_directory)) + store = WorkspaceStore(settings) + conversation_id = "conv_000000000003" + attachment_id = self._add_image_attachment(store, conversation_id) + analysis = self._analysis_arguments() + store.append_conversation_message(conversation_id, { + "id": "assistant_previous_analysis", + "role": "assistant", + "parts": [{"type": "data-cad-image-analysis", "data": { + "attachmentIds": [attachment_id], + "partType": analysis["part_type"], + "visibleFeatures": analysis["visible_features"], + "uncertainFeatures": analysis["uncertain_features"], + "dimensionCandidates": analysis["dimension_candidates"], + }}], + }) + agent = RecordedEstimateAgent(settings, store, CdslLibrary(settings)) + message = ChatMessage(id="user_estimate_again", role="user", parts=[MessagePart(type="text", text="请继续,未标注处按你的工程判断处理。")]) + + async def collect_events() -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + async for chunk in agent.stream([message], conversation_id, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect_events()) + conversation = store.read_conversation(conversation_id) + analysis_parts = [ + part + for item in conversation["messages"] + for part in item["parts"] + if part["type"] == "data-cad-image-analysis" + ] + + self.assertEqual(agent.tool_calls, ["describe_design_intent", "generate_cdsl_model"]) + self.assertTrue(all("analyze_image_reference" not in tool_set for tool_set in agent.tool_sets)) + self.assertEqual(len(analysis_parts), 1) + self.assertFalse(any("请补充以下尺寸" in str(event.get("text", "")) for event in events)) + def test_direct_cdsl_generation_is_rejected_without_creating_a_task(self) -> None: class RetryAgent(AgentService): def __init__(self, *args: object, **kwargs: object) -> None: @@ -328,10 +733,94 @@ class ToolArgumentsRetryTests(unittest.TestCase): tool_result = agent.seen_messages[1][-1] self.assertEqual(tool_result["role"], "tool") - self.assertEqual(json.loads(str(tool_result["content"]))["code"], "DESIGN_INTENT_REQUIRED") + self.assertEqual(json.loads(str(tool_result["content"]))["code"], "DESIGN_BRIEF_REQUIRED") self.assertEqual(agent.required_tools, [None, None, None]) self.assertEqual(list(settings.task_root.glob("cad_*")), []) +class StructuredResultResponseTests(unittest.TestCase): + def test_structured_result_does_not_add_a_duplicate_success_message(self) -> None: + class StructuredResultAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.responses = [ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "design_brief", + "type": "function", + "function": { + "name": "describe_design_intent", + "arguments": json.dumps({"plan": "建立带中心孔的法兰。", "assumptions": []}), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "build_cdsl", + "type": "function", + "function": { + "name": "generate_cdsl_model", + "arguments": json.dumps({"cdsl": {}, "summary": "带中心孔的法兰", "assumptions": []}), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": []}}]}, + ] + + async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]: + return self.responses.pop(0) + + async def _run_tool(self, name: str, *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]: + if name == "describe_design_intent": + return {"ok": True, "summary": "设计说明已记录"}, None + if name == "generate_cdsl_model": + return {"ok": True, "summary": "带中心孔的法兰"}, { + "task_id": "cad_000000000001", + "revision_id": "rev_001", + "cdsl_path": "model.cdsl.json", + "step_path": "model.step", + "glb_path": "model.glb", + "report_path": "report.json", + "summary": "带中心孔的法兰", + "reference_ids": [], + "engine": "cdsl_only", + } + raise AssertionError(f"unexpected tool: {name}") + + backend_root = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory() as temporary_directory: + temporary_root = Path(temporary_directory) + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + settings = Settings( + task_root=temporary_root / "tasks", + conversation_root=temporary_root / "conversations", + library_root=backend_root / "cdsl_library", + engine_root=backend_root / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="test-model", + llm_timeout_s=1, + default_provider_id="test", + providers=(provider,), + ) + store = WorkspaceStore(settings) + agent = StructuredResultAgent(settings, store, CdslLibrary(settings)) + message = ChatMessage(id="user_result", role="user", parts=[MessagePart(type="text", text="生成一个带中心孔的法兰")]) + + async def collect_events() -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + async for chunk in agent.stream([message], None, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect_events()) + conversation_id = store.read_conversation(next(settings.conversation_root.iterdir()).name)["conversation_id"] + assistant_parts = store.read_conversation(conversation_id)["messages"][-1]["parts"] + diagnostics = settings.conversation_root / conversation_id / "diagnostics" + attempts = list(diagnostics.glob("cdsl_attempt_*.json")) + + self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-result"]) + self.assertTrue(any(event.get("taskId") == "cad_000000000001" for event in events)) + self.assertFalse(any("已生成:" in str(event.get("text", "")) for event in events)) + self.assertEqual(len(attempts), 1) + self.assertEqual(json.loads(attempts[0].read_text(encoding="utf-8")), {}) + self.assertEqual(list(diagnostics.glob("cdsl_validation_*.json")), []) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_conversation_attachments.py b/backend/tests/test_conversation_attachments.py new file mode 100644 index 00000000..2815442a --- /dev/null +++ b/backend/tests/test_conversation_attachments.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from app.services.attachments import attachment_record +from app.services.storage import WorkspaceStore +from app.settings import ProviderConfig, ProviderModel, Settings + + +class ConversationAttachmentStorageTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + temporary_root = Path(self.temporary_directory.name) + backend_root = Path(__file__).resolve().parents[1] + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + self.settings = Settings( + task_root=temporary_root / "tasks", + conversation_root=temporary_root / "conversations", + library_root=backend_root / "cdsl_library", + engine_root=backend_root / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="test-model", + llm_timeout_s=1, + default_provider_id="test", + providers=(provider,), + ) + self.store = WorkspaceStore(self.settings) + self.conversation_id = "conv_000000000001" + self.store.ensure_conversation(self.conversation_id) + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def test_upload_is_owned_by_conversation_without_creating_a_task(self) -> None: + relative_path, target = self.store.write_conversation_upload(self.conversation_id, "reference.png", b"reference-bytes") + attachment = attachment_record(self.conversation_id, "reference.png", "image/png", relative_path, b"reference-bytes", "image") + conversation = self.store.add_conversation_attachment(self.conversation_id, attachment) + + self.assertEqual(target, (self.settings.conversation_root / self.conversation_id / relative_path).resolve()) + self.assertTrue(target.is_file()) + self.assertEqual(conversation["attachments"], [attachment]) + self.assertEqual(list(self.settings.task_root.glob("cad_*")), []) + + def test_attachment_cannot_be_linked_to_another_conversation(self) -> None: + relative_path, _ = self.store.write_conversation_upload(self.conversation_id, "reference.png", b"reference-bytes") + attachment = attachment_record("conv_000000000002", "reference.png", "image/png", relative_path, b"reference-bytes", "image") + + with self.assertRaisesRegex(ValueError, "does not belong"): + self.store.add_conversation_attachment(self.conversation_id, attachment) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_design_intent.py b/backend/tests/test_design_intent.py index 67e79bd1..5bf96509 100644 --- a/backend/tests/test_design_intent.py +++ b/backend/tests/test_design_intent.py @@ -132,7 +132,8 @@ class DesignIntentValidationTests(unittest.TestCase): self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_001") with self.assertRaises(self.engine.DesignIntentError) as context: self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_002") - self.assertEqual(context.exception.code, "INVALID_DESIGN_INTENT") + self.assertEqual(context.exception.code, "DESIGN_INTENT_BASE_REVISION_MISMATCH") + self.assertIn("rev_002", str(context.exception)) def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None: duplicate = mounting_plate_intent() diff --git a/backend/tests/test_design_intent_flow.py b/backend/tests/test_design_intent_flow.py index 8244d67a..72fb6b48 100644 --- a/backend/tests/test_design_intent_flow.py +++ b/backend/tests/test_design_intent_flow.py @@ -6,18 +6,21 @@ import sys import tempfile import unittest from pathlib import Path +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) +from app.models.contracts import ChatMessage, MessagePart # noqa: E402 from app.services.agent_service import AgentService # noqa: E402 from app.services.engine_service import load_engine # noqa: E402 +from app.services.flange_sleeve_template import TEMPLATE_ID # noqa: E402 from app.services.library import CdslLibrary # noqa: E402 from app.services.part_skills import PartSkillLibrary # noqa: E402 from app.services.storage import WorkspaceStore # noqa: E402 from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 -from tests.test_design_intent import mounting_plate_cdsl, mounting_plate_intent # noqa: E402 +from tests.test_design_intent import mounting_plate_cdsl # noqa: E402 BACKEND = ROOT / "backend" @@ -40,85 +43,274 @@ class DesignIntentFlowTests(unittest.TestCase): providers=(provider,), ) - def test_design_intent_is_required_before_library_or_cdsl(self) -> None: + def test_design_brief_is_required_before_library_or_cdsl(self) -> None: with tempfile.TemporaryDirectory() as directory: settings = self.settings(Path(directory)) store = WorkspaceStore(settings) agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) - state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""} - searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], intent_state=state)) - generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"design_intent_id": "intent_aaaaaaaaaaaa", "cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], intent_state=state)) + state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} + searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], planning_state=state)) + generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], planning_state=state)) - self.assertEqual(searched["code"], "DESIGN_INTENT_REQUIRED") - self.assertEqual(generated["code"], "DESIGN_INTENT_REQUIRED") + self.assertEqual(searched["code"], "DESIGN_BRIEF_REQUIRED") + self.assertEqual(generated["code"], "DESIGN_BRIEF_REQUIRED") self.assertEqual(list(settings.task_root.glob("cad_*")), []) - def test_ready_plan_persists_before_build_and_links_success_revision(self) -> None: + def test_generation_normalizes_legacy_llm_cdsl_before_building(self) -> None: + legacy_cdsl = { + "schema": "cad.cdsl.llm.v1", + "part_id": "legacy-flange-base", + "geometry": {"sketches": [{ + "id": "base_sketch", + "plane": "XY", + "offset_mm": 12, + "profile": {"type": "circle", "radius_mm": 20}, + }]}, + "features": [{ + "id": "base_add", + "atomic_id": "extrude_add_blind", + "sketch": "base_sketch", + "params": {"distance_mm": 8}, + }], + } + with tempfile.TemporaryDirectory() as directory: + settings = self.settings(Path(directory)) + store = WorkspaceStore(settings) + agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) + state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange base."} + captured_build: dict[str, object] = {} + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured_build.update(kwargs) + return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"} + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + result, _ = asyncio.run(agent._run_tool( + "generate_cdsl_model", + {"cdsl": legacy_cdsl, "summary": "flange base", "assumptions": []}, + "", "Create a flange base", [], planning_state=state, + )) + + built_cdsl = captured_build["cdsl"] + self.assertTrue(result["ok"]) + self.assertEqual(built_cdsl["features"][0]["sketch_id"], "base_sketch") + self.assertEqual(built_cdsl["features"][0]["depends_on"], []) + self.assertNotIn("sketch", built_cdsl["features"][0]) + self.assertIn("workplane", built_cdsl["geometry"]["sketches"][0]) + self.assertEqual(len(result["normalization_repairs"]), 3) + + def test_flange_sleeve_tool_builds_cdsl_from_a_compact_semantic_plan(self) -> None: + with tempfile.TemporaryDirectory() as directory: + settings = self.settings(Path(directory)) + store = WorkspaceStore(settings) + agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) + state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange sleeve."} + captured_build: dict[str, object] = {} + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured_build.update(kwargs) + return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"} + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + result, _ = asyncio.run(agent._run_tool( + "generate_flange_sleeve_model", + { + "plan": { + "template": TEMPLATE_ID, + "name": "Image flange sleeve", + "flange_width_mm": 140, + "flange_height_mm": 120, + "mount_hole_u_mm": 48, + "mount_hole_v_mm": 38, + }, + "summary": "flange sleeve", + "assumptions": ["Dimensions are approximate."], + }, + "", "Create an image flange sleeve", [], planning_state=state, + )) + + built_cdsl = captured_build["cdsl"] + self.assertTrue(result["ok"]) + self.assertEqual(result["template_id"], TEMPLATE_ID) + self.assertEqual(captured_build["operation"]["template_id"], TEMPLATE_ID) + self.assertEqual(built_cdsl["meta"]["name"], "Image flange sleeve") + self.assertEqual(built_cdsl["geometry"]["sketches"][0]["workplane"]["normal"], [1.0, 0.0, 0.0]) + self.assertEqual(built_cdsl["features"][-1]["depends_on"], ["mount_hole_cuts"]) + self.assertNotIn("cdsl", result["template_plan"]) + + def test_successful_generation_ends_the_agent_tool_loop(self) -> None: + class CaptureAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.responses = [ + { + "choices": [{"message": { + "role": "assistant", "content": "", "tool_calls": [{ + "id": "brief", "type": "function", "function": { + "name": "describe_design_intent", + "arguments": json.dumps({"plan": "Create a flange sleeve.", "assumptions": []}), + }, + }], + }}], + }, + { + "choices": [{"message": { + "role": "assistant", "content": "", "tool_calls": [{ + "id": "template", "type": "function", "function": { + "name": "generate_flange_sleeve_model", + "arguments": json.dumps({ + "plan": {"template": TEMPLATE_ID}, + "summary": "flange sleeve", + "assumptions": [], + }), + }, + }], + }}], + }, + ] + + async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]: + return self.responses.pop(0) + + with tempfile.TemporaryDirectory() as directory: + settings = self.settings(Path(directory)) + store = WorkspaceStore(settings) + agent = CaptureAgent(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + return { + "task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001", + "cdsl_path": "revisions/rev_001/model.cdsl.json", + "step_path": "revisions/rev_001/model.step", + "glb_path": "revisions/rev_001/model.glb", + "report_path": "revisions/rev_001/rebuild-report.json", + "summary": str(kwargs["summary"]), "reference_ids": [], "engine": "cdsl_only", + } + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + async def consume() -> None: + message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a flange sleeve")]) + async for _ in agent.stream([message], None, None): + pass + + asyncio.run(consume()) + + saved = store.read_conversation(next(item.name for item in settings.conversation_root.iterdir())) + parts = saved["messages"][-1]["parts"] + self.assertEqual(agent.responses, []) + self.assertTrue(any(part["type"] == "data-cad-result" for part in parts)) + self.assertFalse(any(part["type"] == "data-cad-error" for part in parts)) + + def test_text_brief_is_returned_to_model_and_cdsl_is_the_only_contract(self) -> None: + class CaptureAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.seen_messages: list[list[dict[str, object]]] = [] + self.responses = [ + { + "choices": [{"message": { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "brief", + "type": "function", + "function": { + "name": "describe_design_intent", + "arguments": json.dumps({ + "plan": "Create a rectangular mounting plate, then cut four mounting holes and a center slot.", + "assumptions": ["Use millimetres."], + }), + }, + }], + }}], + }, + { + "choices": [{"message": { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "generate", + "type": "function", + "function": { + "name": "generate_cdsl_model", + "arguments": json.dumps({ + "cdsl": mounting_plate_cdsl(), + "summary": "mounting plate", + "assumptions": ["Use millimetres."], + }), + }, + }], + }}], + }, + {"choices": [{"message": {"role": "assistant", "content": "已生成。", "tool_calls": []}}]}, + ] + + async def _complete(self, messages: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]: + self.seen_messages.append([dict(message) for message in messages]) + return self.responses.pop(0) + with tempfile.TemporaryDirectory() as directory: settings = self.settings(Path(directory)) store = WorkspaceStore(settings) skills = PartSkillLibrary(PART_SKILL_ROOT) - agent = AgentService(settings, store, CdslLibrary(settings), skills) - request = "Create a mounting plate with four holes and a center slot" - state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""} - planned, _ = asyncio.run(agent._run_tool( - "propose_design_intent", - {"intent": mounting_plate_intent(), "summary": "plate plan", "assumptions": []}, - "", request, [], part_skill_selection=skills.select(request), intent_state=state, - )) + agent = CaptureAgent(settings, store, CdslLibrary(settings), skills) + captured_build: dict[str, object] = {} - self.assertTrue(planned["ok"]) - task = store.read_task(planned["task_id"]) - self.assertEqual(task["current_revision"], "") - planning_path = store.artifact_path(planned["task_id"], planned["design_intent_path"]) - self.assertTrue(planning_path.is_file()) - saved = json.loads(planning_path.read_text(encoding="utf-8")) - self.assertEqual(saved["part_skill_ids"], skills.select(request)["skill_ids"]) + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured_build.update(kwargs) + return { + "task_id": "cad_aaaaaaaaaaaa", + "revision_id": "rev_001", + "cdsl_path": "revisions/rev_001/model.cdsl.json", + "step_path": "revisions/rev_001/model.step", + "glb_path": "revisions/rev_001/model.glb", + "report_path": "revisions/rev_001/rebuild-report.json", + "summary": str(kwargs["summary"]), + "reference_ids": list(kwargs["reference_ids"]), + "engine": "cdsl_only", + } - searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "plate"}, planned["task_id"], request, [], intent_state=state)) - self.assertTrue(searched["ok"]) - result, built = asyncio.run(agent._run_tool( - "generate_cdsl_model", - {"design_intent_id": planned["design_intent_id"], "cdsl": mounting_plate_cdsl(), "summary": "plate", "assumptions": []}, - planned["task_id"], request, [], part_skill_selection=skills.select(request), intent_state=state, + engine = load_engine(settings) + with patch.object(engine, "validate_intent_cdsl", side_effect=AssertionError("legacy intent validation must not run")) as legacy_validation, patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + async def consume() -> None: + message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")]) + async for _ in agent.stream([message], None, None): + pass + + asyncio.run(consume()) + + brief_result = json.loads(str(agent.seen_messages[1][-1]["content"])) + self.assertEqual(brief_result["plan"], "Create a rectangular mounting plate, then cut four mounting holes and a center slot.") + self.assertNotIn("design_intent_id", brief_result) + self.assertNotIn("design_intent_id", captured_build) + self.assertNotIn("design_intent", captured_build) + self.assertEqual(captured_build["parent_revision_id"], "") + legacy_validation.assert_not_called() + + def test_revision_parent_is_taken_from_the_current_successful_cdsl_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + settings = self.settings(Path(directory)) + store = WorkspaceStore(settings) + agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) + state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} + asyncio.run(agent._run_tool( + "describe_design_intent", + {"plan": "Increase the plate thickness and preserve the existing hole layout.", "assumptions": []}, + "cad_aaaaaaaaaaaa", "Revise the plate", [], planning_state=state, )) + store.read_task = lambda _task_id: {"current_revision": "rev_007"} # type: ignore[method-assign] + captured_build: dict[str, object] = {} + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured_build.update(kwargs) + return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_008"} + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + result, _ = asyncio.run(agent._run_tool( + "generate_cdsl_model", + {"cdsl": mounting_plate_cdsl(), "summary": "revised plate", "assumptions": []}, + "cad_aaaaaaaaaaaa", "Revise the plate", [], planning_state=state, + )) self.assertTrue(result["ok"]) - self.assertIsNotNone(built) - revision = store.read_task(planned["task_id"])["revisions"][-1] - self.assertEqual(revision["design_intent_id"], planned["design_intent_id"]) - self.assertEqual(revision["design_intent_path"], planned["design_intent_path"]) - audit = json.loads(store.artifact_path(planned["task_id"], revision["part_skills_path"]).read_text(encoding="utf-8")) - self.assertEqual(audit["design_intent_id"], planned["design_intent_id"]) - self.assertEqual(audit["design_intent_assumptions"], []) - - def test_blocked_plan_is_persisted_without_a_revision_and_new_plan_supersedes_it(self) -> None: - with tempfile.TemporaryDirectory() as directory: - settings = self.settings(Path(directory)) - store = WorkspaceStore(settings) - skills = PartSkillLibrary(PART_SKILL_ROOT) - agent = AgentService(settings, store, CdslLibrary(settings), skills) - request = "Create a mounting plate" - state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""} - blocked_intent = mounting_plate_intent() - blocked_intent["open_questions"] = [{"id": "thickness", "question": "Thickness?", "blocking": True}] - blocked_intent["status"] = "needs_clarification" - blocked, _ = asyncio.run(agent._run_tool( - "propose_design_intent", - {"intent": blocked_intent, "summary": "blocked", "assumptions": []}, - "", request, [], part_skill_selection=skills.select(request), intent_state=state, - )) - self.assertEqual(blocked["code"], "DESIGN_INTENT_BLOCKED") - task = store.read_task(blocked["task_id"]) - self.assertEqual(task["revisions"], []) - self.assertEqual(task["design_intents"][0]["status"], "pending") - - ready, _ = asyncio.run(agent._run_tool( - "propose_design_intent", - {"intent": mounting_plate_intent(), "summary": "ready", "assumptions": []}, - blocked["task_id"], request, [], part_skill_selection=skills.select(request), intent_state=state, - )) - self.assertTrue(ready["ok"]) - task = store.read_task(blocked["task_id"]) - self.assertEqual(task["design_intents"][0]["status"], "superseded") - self.assertEqual(task["design_intents"][-1]["status"], "accepted") + self.assertEqual(captured_build["parent_revision_id"], "rev_007") diff --git a/backend/tests/test_part_skills.py b/backend/tests/test_part_skills.py index 221bcf40..9c94a14f 100644 --- a/backend/tests/test_part_skills.py +++ b/backend/tests/test_part_skills.py @@ -211,7 +211,7 @@ class AgentPartSkillTests(unittest.TestCase): self.assertIn("[planning/flange]", str(agent.first_messages[0]["content"])) self.assertIn("[functional/flange-bolt-circle]", str(agent.first_messages[0]["content"])) - def test_prompt_contains_bridge_without_adding_tools(self) -> None: + def test_prompt_contains_bridge_and_template_tool(self) -> None: library = PartSkillLibrary(PART_SKILL_ROOT) selection = library.select("Create a flange with a bolt circle") with tempfile.TemporaryDirectory() as directory: @@ -220,7 +220,7 @@ class AgentPartSkillTests(unittest.TestCase): self.assertIn("[planning/flange]", prompt) self.assertIn("circular-pattern atomic", prompt) - self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["search_cdsl_library", "read_cdsl_reference", "propose_design_intent", "read_current_cdsl", "generate_cdsl_model"]) + self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_flange_sleeve_model", "generate_cdsl_model"]) def test_generation_persists_assumptions_and_selected_skills(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -230,27 +230,25 @@ class AgentPartSkillTests(unittest.TestCase): agent = AgentService(settings, store, CdslLibrary(settings), library) request = "M6 六角螺母" selection = library.select(request) - engine = load_engine(settings) - intent = engine.design_intent_from_cdsl(hex_nut_fixture(), request=request) - intent_state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""} + planning_state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} planned, _ = asyncio.run(agent._run_tool( - "propose_design_intent", - {"intent": intent, "summary": "M6 nut plan", "assumptions": []}, + "describe_design_intent", + {"plan": "Create an M6 hex nut with a centered cylindrical bore representing the thread.", "assumptions": []}, "", request, [], part_skill_selection=selection, - intent_state=intent_state, + planning_state=planning_state, )) self.assertTrue(planned["ok"]) result, generated = asyncio.run(agent._run_tool( "generate_cdsl_model", - {"design_intent_id": planned["design_intent_id"], "cdsl": hex_nut_fixture(), "summary": "M6 nut", "assumptions": ["M6 thread is represented as a cylindrical bore"]}, - planned["task_id"], + {"cdsl": hex_nut_fixture(), "summary": "M6 nut", "assumptions": ["M6 thread is represented as a cylindrical bore"]}, + "", request, ["reference-fixture"], part_skill_selection=selection, - intent_state=intent_state, + planning_state=planning_state, )) self.assertTrue(result["ok"]) diff --git a/backend/tests/test_profile_schema.py b/backend/tests/test_profile_schema.py index 0f33381b..d4d89116 100644 --- a/backend/tests/test_profile_schema.py +++ b/backend/tests/test_profile_schema.py @@ -5,7 +5,8 @@ import tempfile import unittest from pathlib import Path -from app.services.engine_service import load_engine, validate_cdsl +from app.services.engine_service import load_engine, normalize_cdsl_for_engine, validate_cdsl +from app.services.flange_sleeve_template import TEMPLATE_ID, build_flange_sleeve_cdsl from app.settings import get_settings @@ -88,6 +89,85 @@ class ProfileSchemaTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "positions\\[0\\].*not of type 'object'"): validate_cdsl(cdsl, self.engine) + def test_normalizes_unambiguous_legacy_llm_field_names_before_validation(self) -> None: + legacy_cdsl = { + "schema": "cad.cdsl.llm.v1", + "part_id": "legacy-flange-base", + "geometry": {"sketches": [{ + "id": "base_sketch", + "plane": "XY", + "offset_mm": 12, + "profile": {"type": "circle", "radius_mm": 20}, + }]}, + "features": [{ + "id": "base_add", + "atomic_id": "extrude_add_blind", + "sketch": "base_sketch", + "params": {"distance_mm": 8}, + }], + } + + normalized, repairs = normalize_cdsl_for_engine(legacy_cdsl) + + self.assertEqual(legacy_cdsl["geometry"]["sketches"][0]["plane"], "XY") + self.assertEqual(normalized["features"][0]["sketch_id"], "base_sketch") + self.assertNotIn("sketch", normalized["features"][0]) + self.assertEqual(normalized["features"][0]["depends_on"], []) + self.assertEqual(normalized["geometry"]["sketches"][0]["workplane"], { + "origin_mm": [0.0, 0.0, 12.0], + "x_dir": [1.0, 0.0, 0.0], + "normal": [0.0, 0.0, 1.0], + }) + self.assertEqual(len(repairs), 3) + validate_cdsl(normalized, self.engine) + + def test_normalizer_rewrites_the_legacy_revolve_axis_point_name(self) -> None: + normalized, repairs = normalize_cdsl_for_engine({ + "features": [{ + "id": "turn", + "atomic_id": "revolve_add", + "params": {"axis": {"point_mm": [0, 0, 0], "direction": [0, 0, 1]}}, + }], + }) + + axis = normalized["features"][0]["params"]["axis"] + self.assertEqual(axis["origin_mm"], [0, 0, 0]) + self.assertNotIn("point_mm", axis) + self.assertIn("features[0].params.axis: point_mm -> origin_mm", repairs) + + def test_flange_sleeve_template_is_schema_valid_and_runtime_buildable(self) -> None: + cdsl, plan = build_flange_sleeve_cdsl({ + "template": TEMPLATE_ID, + "name": "Template flange sleeve", + "flange_width_mm": 120, + "flange_height_mm": 120, + "flange_thickness_mm": 14, + "corner_chamfer_mm": 10, + "tube_outer_diameter_mm": 70, + "tube_straight_length_mm": 95, + "tip_outer_diameter_mm": 62, + "tip_length_mm": 28, + "bore_diameter_mm": 46, + "boss_outer_diameter_mm": 82, + "boss_height_mm": 5, + "mount_hole_diameter_mm": 12, + "mount_counterbore_diameter_mm": 24, + "mount_counterbore_depth_mm": 5, + "mount_hole_u_mm": 42, + "mount_hole_v_mm": 42, + }) + + self.assertEqual(plan["name"], "Template flange sleeve") + self.assertEqual(cdsl["features"][1]["depends_on"], ["flange_plate"]) + self.assertEqual(cdsl["geometry"]["sketches"][0]["workplane"]["x_dir"], [0.0, 1.0, 0.0]) + validate_cdsl(cdsl, self.engine) + with tempfile.TemporaryDirectory() as directory: + step_path = Path(directory) / "flange-sleeve.step" + result = self.engine.run_cdsl_only(cdsl, step_path) + self.assertEqual(result["engine"], "cdsl_only") + self.assertTrue(step_path.is_file()) + self.assertGreater(step_path.stat().st_size, 0) + def test_cdsl_only_rebuild_preserves_its_actual_failure(self) -> None: cdsl = { "schema": "cad.cdsl.llm.v1", diff --git a/frontend/src/app/api/uploads/route.ts b/frontend/src/app/api/conversations/[conversationId]/attachments/route.ts similarity index 55% rename from frontend/src/app/api/uploads/route.ts rename to frontend/src/app/api/conversations/[conversationId]/attachments/route.ts index 34568d25..aacd9727 100644 --- a/frontend/src/app/api/uploads/route.ts +++ b/frontend/src/app/api/conversations/[conversationId]/attachments/route.ts @@ -3,9 +3,10 @@ import { backendFetch, readBackendError } from "@/lib/backend"; export const runtime = "nodejs"; -export async function POST(request: NextRequest) { +export async function POST(request: NextRequest, context: { params: Promise<{ conversationId: string }> }) { + const { conversationId } = await context.params; const body = await request.formData(); - const response = await backendFetch("/v1/uploads", { method: "POST", body }); + const response = await backendFetch(`/v1/conversations/${encodeURIComponent(conversationId)}/attachments`, { method: "POST", body }); if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); return NextResponse.json(await response.json()); } diff --git a/frontend/src/app/api/conversations/[conversationId]/route.ts b/frontend/src/app/api/conversations/[conversationId]/route.ts index df7bc9de..40d3db6a 100644 --- a/frontend/src/app/api/conversations/[conversationId]/route.ts +++ b/frontend/src/app/api/conversations/[conversationId]/route.ts @@ -18,7 +18,6 @@ export async function PATCH(request: NextRequest, context: { params: Promise<{ c headers: { "Content-Type": "application/json" }, body: JSON.stringify({ current_task_id: body.currentTaskId || null, - attachments: Array.isArray(body.attachments) ? body.attachments : undefined, }), }); if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d00cd5e2..4dcd14b1 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -75,7 +75,6 @@ --ui-loading-overlay: rgb(233 238 242 / 70%); --ui-loading-overlay-strong: rgb(245 247 248 / 88%); --ui-drag-overlay: rgb(255 255 255 / 88%); - --ui-drag-shadow: 0 0 0 999px rgb(15 25 30 / 18%); --ui-shadow-soft: 0 12px 30px rgb(16 24 32 / 12%); --ui-shadow-panel: 0 22px 60px rgb(16 24 32 / 14%); --ui-shadow-popover: 0 18px 46px rgb(16 24 32 / 16%); @@ -161,7 +160,6 @@ --ui-loading-overlay: rgb(13 15 17 / 35%); --ui-loading-overlay-strong: rgb(13 15 17 / 76%); --ui-drag-overlay: rgb(16 19 21 / 88%); - --ui-drag-shadow: 0 0 0 999px rgb(17 19 21 / 42%); --ui-shadow-soft: 0 10px 24px rgb(0 0 0 / 20%); --ui-shadow-panel: 0 24px 60px rgb(0 0 0 / 26%); --ui-shadow-popover: 0 18px 48px rgb(0 0 0 / 28%); @@ -375,6 +373,7 @@ button:disabled { @keyframes generation-edge-soft-out { to { opacity: 0; } } @media (prefers-reduced-motion: reduce) { + .spin { animation: none; } .generation-edge-glow, .generation-edge-glow *, .generation-edge-glow *::before { @@ -397,28 +396,64 @@ button:disabled { .agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); } .preview-pane { min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); } .agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; } +.agent-thread-shell { position: relative; } +.spin { animation: ui-spin 900ms linear infinite; } +@keyframes ui-spin { to { transform: rotate(360deg); } } .agent-pane-title { display: flex; height: 40px; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-border); color: var(--ui-text-strong); font-size: 12px; font-weight: 700; padding: 0 12px; } .agent-pane-title svg { color: var(--ui-accent); } .thread-viewport { min-height: 0; flex: 1; overflow-y: auto; padding: 12px; } .message-list { display: grid; } -.message-row { display: flex; border-bottom: 1px solid var(--ui-border-muted); padding: 10px 2px; } -.user-row { justify-content: flex-end; } -.assistant-row { justify-content: flex-start; } -.message-bubble { max-width: 100%; min-width: 0; } -.user-bubble { max-width: 90%; border-radius: 5px; background: var(--ui-accent); color: var(--ui-accent-contrast); padding: 8px 10px; } -.assistant-bubble { width: 100%; color: var(--ui-text); } +.message-row { display: grid; gap: 6px; border-bottom: 1px solid var(--ui-border-muted); padding: 12px 2px; } +.user-row { padding-left: 32px; } +.message-role { display: flex; align-items: center; gap: 7px; color: var(--ui-text-muted); font-size: 11px; font-weight: 700; } +.message-role svg { color: var(--ui-text-muted); } +.assistant-row .message-role svg { color: var(--ui-accent); } +.message-content { min-width: 0; color: var(--ui-text); } .message-text { margin: 0; font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; } -.thread-empty { display: grid; gap: 7px; border: 1px dashed var(--ui-border-strong); border-radius: 5px; color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 14px; } -.thread-empty strong { color: var(--ui-text-strong); } -.attachment-list { display: grid; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--ui-border-muted); padding-bottom: 10px; } +.message-request-error { display: flex; align-items: flex-start; gap: 7px; margin-top: 8px; color: var(--ui-error-text); font-size: 11px; line-height: 1.45; } +.message-request-error svg { flex: 0 0 auto; margin-top: 1px; } +.thread-empty { color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 2px 0 14px; } +.attachment-list { display: grid; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--ui-border-muted); padding: 0 1px 10px; } .attachment-card { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text-muted); font-size: 11px; } .attachment-card svg { flex: 0 0 auto; color: var(--ui-accent); }.attachment-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.attachment-card small { margin-left: auto; color: var(--ui-text-subtle); font-size: 10px; white-space: nowrap; } +.upload-error { display: flex; align-items: flex-start; gap: 7px; margin: 0 1px 10px; border-bottom: 1px solid var(--ui-error-border); color: var(--ui-error-text); font-size: 11px; line-height: 1.45; padding: 0 0 10px; } +.upload-error svg { flex: 0 0 auto; margin-top: 1px; } .composer-shell { flex: 0 0 auto; border-top: 1px solid var(--ui-border); padding: 12px; } +.composer-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } .composer-root { display: grid; gap: 8px; } .composer-input { min-height: 96px; max-height: 176px; width: 100%; resize: none; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text); font-size: 12px; line-height: 1.55; outline: none; padding: 9px; } -.composer-input:focus { border-color: var(--ui-accent); }.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-text-muted); font-size: 11px; }.composer-footer > span, .composer-footer > div { display: flex; align-items: center; gap: 7px; }.composer-action, .composer-send { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text-muted); }.composer-send { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-contrast); } -.cad-card { display: flex; gap: 9px; margin: 6px 0; border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-panel-muted); padding: 9px; }.cad-card-icon { display: grid; flex: 0 0 auto; width: 25px; height: 25px; place-items: center; border-radius: 4px; background: var(--ui-accent-soft); color: var(--ui-accent); }.cad-card-body { min-width: 0; }.cad-card-title { color: var(--ui-text-strong); font-size: 12px; font-weight: 700; }.cad-card-copy, .cad-result-meta { color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }.download-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }.download-link { border-bottom: 1px solid currentColor; color: var(--ui-link); font-size: 11px; text-decoration: none; }.cad-error-card { border-color: var(--ui-error-border); background: var(--ui-error-bg); }.cad-error-card .cad-card-icon { background: transparent; color: var(--ui-error-text); } +.composer-input:focus-visible { border-color: var(--ui-accent); box-shadow: 0 0 0 3px var(--ui-focus-ring); } +.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-text-muted); font-size: 11px; } +.composer-footer > span, .composer-actions { display: flex; align-items: center; gap: 7px; } +.composer-actions { flex: 0 0 auto; } +.composer-command { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 5px; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text-muted); font-size: 11px; font-weight: 600; padding: 0 9px; } +.composer-command:hover:not(:disabled) { background: var(--ui-control-hover); color: var(--ui-text); } +.composer-command:focus-visible, .download-link:focus-visible { outline: 2px solid var(--ui-focus-ring); outline-offset: 2px; } +.composer-send { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-contrast); } +.composer-send:hover:not(:disabled) { background: var(--ui-accent-hover); color: var(--ui-accent-contrast); } +.composer-cancel { border-color: var(--ui-accent-border); background: var(--ui-accent-soft); color: var(--ui-accent-text); } +.file-drop-overlay { position: absolute; z-index: 20; inset: 8px; display: grid; place-content: center; justify-items: center; gap: 8px; border: 1px dashed var(--ui-accent); border-radius: 5px; background: var(--ui-drag-overlay); color: var(--ui-accent); font-size: 12px; font-weight: 700; pointer-events: none; } +.cad-message { display: grid; gap: 3px; margin: 9px 0 0; min-width: 0; padding-left: 2px; } +.cad-message-heading { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text); font-size: 12px; font-weight: 600; } +.cad-message-heading svg { flex: 0 0 auto; color: var(--ui-accent); } +.cad-status-label { color: var(--ui-text-subtle); font-size: 10px; font-weight: 500; text-transform: uppercase; } +.cad-message-copy { margin-left: 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; } +.cad-progress.is-error .cad-message-heading, .cad-progress.is-error .cad-message-heading svg, .cad-error .cad-message-heading, .cad-error .cad-message-heading svg { color: var(--ui-error-text); } +.cad-result { margin-top: 12px; } +.cad-result-title { margin-left: 21px; color: var(--ui-accent-text); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; } +.cad-result-meta { display: flex; flex-wrap: wrap; gap: 3px 10px; margin-left: 21px; color: var(--ui-text-muted); font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; } +.download-row { display: flex; flex-wrap: wrap; gap: 9px; margin: 2px 0 0 21px; } +.download-link { display: inline-flex; align-items: center; gap: 4px; color: var(--ui-link); font-size: 11px; text-decoration: none; text-underline-offset: 2px; } +.download-link:hover { color: var(--ui-link-hover); text-decoration: underline; } +.cad-image-part-type { margin-left: 21px; color: var(--ui-text); font-size: 11px; font-weight: 600; line-height: 1.45; } +.image-analysis-section { display: grid; gap: 2px; margin: 4px 0 0 21px; } +.image-analysis-section > span { color: var(--ui-text-subtle); font-size: 10px; font-weight: 700; } +.image-analysis-section p { margin: 0; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; } +.image-dimension-list { display: grid; gap: 5px; margin: 1px 0 0; padding: 0; list-style: none; } +.image-dimension-list li { display: grid; gap: 1px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.4; } +.image-dimension-list strong { color: var(--ui-text); font-weight: 600; } +.image-dimension-list span { overflow-wrap: anywhere; } .viewer-state { display: flex; height: 100%; min-height: 42vh; align-items: center; justify-content: center; gap: 9px; color: var(--ui-text-muted); font-size: 12px; }.viewer-state svg { color: var(--ui-accent); }.viewer-state-error { color: var(--ui-error-text); }.viewer-state-error svg { color: var(--ui-error-text); } .viewer-loading { position: absolute; z-index: 40; left: 50%; top: 50%; display: flex; align-items: center; gap: 8px; transform: translate(-50%, -50%); border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-glass-popover); color: var(--ui-text-muted); font-size: 12px; padding: 9px 12px; box-shadow: var(--ui-shadow-soft); } .cad-viewer-dark { background: var(--ui-viewer-bg); } -@media (max-width: 767px) { .studio-app { height: auto; min-height: 100vh; overflow: visible; }.app-header { height: auto; min-height: 48px; flex-wrap: wrap; padding: 8px 12px; }.task-badge { display: none; }.app-controls { width: 100%; }.app-controls select { flex: 1; }.studio-main { min-height: 0; flex-direction: column; }.preview-pane { order: -1; min-height: 46vh; }.agent-pane { width: 100%; min-height: 560px; border-top: 1px solid var(--ui-border); border-right: 0; }.thread-viewport { max-height: 480px; }.composer-footer > span { display: none; } } +@media (max-width: 767px) { .studio-app { height: auto; min-height: 100vh; overflow: visible; }.app-header { height: auto; min-height: 48px; flex-wrap: wrap; padding: 8px 12px; }.task-badge { display: none; }.app-controls { width: 100%; }.app-controls select { flex: 1; }.studio-main { min-height: 0; flex-direction: column; }.preview-pane { min-height: 46vh; }.agent-pane { width: 100%; min-height: 560px; border-right: 0; border-bottom: 1px solid var(--ui-border); }.thread-viewport { max-height: 480px; }.composer-footer > span { display: none; } } diff --git a/frontend/src/components/agent-studio.tsx b/frontend/src/components/agent-studio.tsx index 767c857a..208fa9f8 100644 --- a/frontend/src/components/agent-studio.tsx +++ b/frontend/src/components/agent-studio.tsx @@ -34,6 +34,7 @@ export function AgentStudio() { const [lastError, setLastError] = useState(""); const [attachments, setAttachments] = useState([]); const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(""); const [providerId, setProviderId] = useState(""); const [modelId, setModelId] = useState(""); const [theme, setTheme] = useState<"light" | "dark">("light"); @@ -141,43 +142,30 @@ export function AgentStudio() { }, []); const handleUpload = useCallback(async (files: FileList | null) => { - if (!files?.length) return; - const selectedModel = config?.providers - .find((provider) => provider.id === providerId) - ?.models.find((model) => model.id === modelId); - const includesImage = Array.from(files).some((file) => file.type.startsWith("image/") || /\.(png|jpe?g|webp)$/i.test(file.name)); - if (includesImage && !selectedModel?.vision) { - setLastError("当前模型不支持图片。请选择标记为 Vision 的 OpenAI 或 Kimi 模型后再上传图片。"); - return; - } + const selectedFiles = Array.from(files || []); + if (!selectedFiles.length) return; + setUploadError(""); setUploading(true); try { const uploaded: CadAttachment[] = []; - for (const file of Array.from(files)) { + for (const file of selectedFiles) { const form = new FormData(); form.set("file", file); - if (selectedTaskId) form.set("task_id", selectedTaskId); - const response = await fetch("/api/uploads", { method: "POST", body: form }); + const response = await fetch(`/api/conversations/${encodeURIComponent(conversationId)}/attachments`, { method: "POST", body: form }); const payload = await response.json() as CadAttachment & { error?: string }; if (!response.ok) throw new Error(payload.error || `${file.name} 上传失败`); uploaded.push(payload); - if (!selectedTaskId) setSelectedTaskId(payload.task_id); } - setAttachments((current) => { - const next = [...current, ...uploaded]; - if (conversationId) void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { - method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ currentTaskId: selectedTaskId || uploaded[0]?.task_id || null, attachments: next }), - }); - return next; - }); + setAttachments((current) => [...current, ...uploaded]); setLastError(""); } catch (error) { - setLastError(error instanceof Error ? error.message : "附件上传失败"); + const message = error instanceof Error ? error.message : "附件上传失败"; + setUploadError(message); + setLastError(message); } finally { setUploading(false); } - }, [config, conversationId, modelId, providerId, selectedTaskId]); + }, [conversationId]); if (loadState === "loading") { return ; @@ -204,6 +192,7 @@ export function AgentStudio() { lastError={lastError} attachments={attachments} uploading={uploading} + uploadError={uploadError} onUpload={handleUpload} theme={theme} onToggleTheme={toggleTheme} @@ -425,6 +414,7 @@ function StudioShell({ lastError, attachments, uploading, + uploadError, onUpload, theme, onToggleTheme, @@ -441,6 +431,7 @@ function StudioShell({ lastError: string; attachments: CadAttachment[]; uploading: boolean; + uploadError: string; onUpload: (files: FileList | null) => void; theme: "light" | "dark"; onToggleTheme: () => void; @@ -473,7 +464,7 @@ function StudioShell({ {!config?.configured ?
未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。
: null}
- +
diff --git a/frontend/src/components/agent-thread.tsx b/frontend/src/components/agent-thread.tsx index 53e71909..a2078f11 100644 --- a/frontend/src/components/agent-thread.tsx +++ b/frontend/src/components/agent-thread.tsx @@ -1,59 +1,132 @@ "use client"; -import { Check, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Square } from "lucide-react"; -import { useRef } from "react"; +import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Square, Upload } from "lucide-react"; +import { useRef, useState, type ChangeEvent, type DragEvent } from "react"; import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react"; import type { CadAttachment } from "@/lib/cad-types"; -import { CadErrorPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts"; +import { CadErrorPart, CadImageAnalysisPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts"; -export function AgentThread({ attachments, uploading, onUpload }: { +export function AgentThread({ attachments, uploading, uploadError, onUpload }: { attachments: CadAttachment[]; uploading: boolean; + uploadError: string; onUpload: (files: FileList | null) => void; }) { const fileInput = useRef(null); + const dragDepth = useRef(0); + const running = useAuiState((state) => state.thread.isRunning); + const [isDraggingFiles, setIsDraggingFiles] = useState(false); + const canUpload = !uploading && !running; + + const onDragEnter = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepth.current += 1; + if (canUpload && isFileDrag(event)) setIsDraggingFiles(true); + }; + + const onDragOver = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = canUpload ? "copy" : "none"; + }; + + const onDragLeave = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepth.current = Math.max(0, dragDepth.current - 1); + if (dragDepth.current === 0) setIsDraggingFiles(false); + }; + + const onDrop = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepth.current = 0; + setIsDraggingFiles(false); + if (canUpload && event.dataTransfer.files.length) onUpload(event.dataTransfer.files); + }; + return ( -
+
Agent
{attachments.length ?
{attachments.map((attachment) => )}
: null} + {uploadError ?
: null} -
描述要生成或修改的 CAD 模型Agent 会检索本地 CDSL 样本并生成可编辑的 CDSL 模型。
+
描述需要生成或修改的 CAD 模型。
+ {isDraggingFiles ?
拖放文件上传
: null}
); } +function isFileDrag(event: DragEvent) { + return event.dataTransfer.files.length > 0 || Array.from(event.dataTransfer.types).includes("Files"); +} + function AttachmentCard({ attachment }: { attachment: CadAttachment }) { const Icon = attachment.kind === "image" ? FileImage : FileText; return
{attachment.name}{attachment.kind === "image" ? "视觉参考" : "文本参考"}
; } function UserMessage() { - return
; + return ( + +
+
+
+ ); } function AssistantMessage() { return ( -
+
+
+ + +
+
+
); } function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObject; uploading: boolean; onUpload: (files: FileList | null) => void }) { const running = useAuiState((state) => state.thread.isRunning); + const handleFileChange = (event: ChangeEvent) => { + if (event.currentTarget.files?.length) onUpload(event.currentTarget.files); + event.currentTarget.value = ""; + }; return (
- onUpload(event.target.files)} /> + - -
Enter 发送,Shift + Enter 换行
{running ? : }
+ +
+ +
+ {running ? ( + + ) : ( + <> + + + + )} +
+
); diff --git a/frontend/src/components/cad-message-parts.tsx b/frontend/src/components/cad-message-parts.tsx index c51daf42..11ad5842 100644 --- a/frontend/src/components/cad-message-parts.tsx +++ b/frontend/src/components/cad-message-parts.tsx @@ -1,8 +1,8 @@ "use client"; -import { AlertTriangle, CheckCircle2, Download, Loader2 } from "lucide-react"; +import { AlertTriangle, Box, Check, Download, Loader2, Ruler } from "lucide-react"; import { encodeArtifactUrl } from "@/lib/cad-artifacts"; -import type { CadError, CadProgress, CadResult } from "@/lib/cad-types"; +import type { CadError, CadImageAnalysis, CadProgress, CadResult } from "@/lib/cad-types"; export function TextPart({ text }: { text: string }) { if (!text.trim()) return null; @@ -10,16 +10,19 @@ export function TextPart({ text }: { text: string }) { } export function CadProgressPart({ data }: { data: CadProgress }) { - const running = data.status === "running"; + if (data.step === "agent_stream") return null; + const status = String(data.status || "").toLowerCase(); + const isRunning = status === "running"; + const isError = status === "error"; + const statusLabel = isRunning ? "进行中" : isError ? "失败" : status === "success" ? "完成" : data.status; return ( -
-
- {running ? : } -
-
-
{data.label || data.step}
- {data.message ?
{data.message}
: null} +
+
+ {isRunning ?
+ {data.message ?
{data.message}
: null}
); } @@ -29,44 +32,71 @@ export function CadResultPart({ data }: { data: CadResult }) { ["STEP", data.stepPath], ["CDSL", data.cdslPath], ["GLB", data.glbPath], - ["REPORT", data.reportPath], + ["报告", data.reportPath], ]; - if (data.designIntentPath) downloads.push(["DESIGN INTENT", data.designIntentPath]); return ( -
-
- +
+
+
-
-
{data.summary || "生成完成"}
-
+
{data.summary || "生成完成"}
+
{data.engine} {data.revisionId} - {data.referenceIds.length} references -
-
- {downloads.map(([label, path]) => ( - - - {label} - - ))} -
+ {data.referenceIds.length} 个参考
-
+
+ {downloads.map(([label, path]) => ( + + + ))} +
+
+ ); +} + +export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) { + const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? []; + return ( +
+
+
{data.partType}
+
+ 可见特征 +

{data.visibleFeatures.join(";") || "未识别到可确认特征"}

+
+ {data.uncertainFeatures.length ?
+ 待确认特征 +

{data.uncertainFeatures.join(";")}

+
: null} + {dimensionCandidates.length ?
+ 可能需要确认的尺寸 +
    + {dimensionCandidates.map((dimension) =>
  • + {dimension.label} + {dimension.reason} +
  • )} +
+
: null} +
); } export function CadErrorPart({ data }: { data: CadError }) { + const stageLabels: Record = { + agent: "Agent", + attachment: "附件", + chat: "对话", + viewer: "预览", + }; + const stage = stageLabels[data.stage] || data.stage || "CAD 操作"; return ( -
-
- -
-
-
{data.stage || "生成失败"}
-
{data.message}
-
+
+
+
{data.message}
); } diff --git a/frontend/src/components/cad-viewer-preview.tsx b/frontend/src/components/cad-viewer-preview.tsx index f79f3709..f6b05729 100644 --- a/frontend/src/components/cad-viewer-preview.tsx +++ b/frontend/src/components/cad-viewer-preview.tsx @@ -51,8 +51,6 @@ function resultFromBackend(payload: Record): CadResult { parametersPath: typeof payload.parameters_path === "string" ? payload.parameters_path : undefined, selectorPath: typeof payload.selector_path === "string" ? payload.selector_path : undefined, edgesPath: typeof payload.edges_path === "string" ? payload.edges_path : undefined, - designIntentId: typeof payload.design_intent_id === "string" ? payload.design_intent_id : undefined, - designIntentPath: typeof payload.design_intent_path === "string" ? payload.design_intent_path : undefined, summary: String(payload.summary || "Updated CDSL model"), referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [], engine: String(payload.engine || "cdsl_only"), diff --git a/frontend/src/lib/cad-artifacts.ts b/frontend/src/lib/cad-artifacts.ts index e1b4d058..ccf3aff8 100644 --- a/frontend/src/lib/cad-artifacts.ts +++ b/frontend/src/lib/cad-artifacts.ts @@ -31,8 +31,6 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul parametersPath: current.parameters_path, selectorPath: current.selector_path, edgesPath: current.edges_path, - designIntentId: current.design_intent_id, - designIntentPath: current.design_intent_path, summary: current.summary || "CDSL CAD model", referenceIds: current.reference_ids || [], engine: current.engine || "cdsl_only", diff --git a/frontend/src/lib/cad-messages.ts b/frontend/src/lib/cad-messages.ts index bf8ce7fb..c03a7c20 100644 --- a/frontend/src/lib/cad-messages.ts +++ b/frontend/src/lib/cad-messages.ts @@ -1,4 +1,4 @@ -import type { CadError, CadProgress, CadResult, CadUIMessage } from "./cad-types"; +import type { CadError, CadImageAnalysis, CadProgress, CadResult, CadUIMessage } from "./cad-types"; type AnyPart = { type?: unknown; text?: unknown; data?: unknown; id?: unknown }; type AnyMessage = { id?: unknown; role?: unknown; parts?: unknown }; @@ -24,6 +24,9 @@ export function normalizeCadMessages(input: unknown): CadUIMessage[] { if (item.type === "data-cad-error") { return [{ type: "data-cad-error", id: stringId(item.id), data: item.data as CadError }]; } + if (item.type === "data-cad-image-analysis") { + return [{ type: "data-cad-image-analysis", id: stringId(item.id), data: item.data as CadImageAnalysis }]; + } return []; }); return [{ diff --git a/frontend/src/lib/cad-stream.test.ts b/frontend/src/lib/cad-stream.test.ts index 695abbf2..3f1ad093 100644 --- a/frontend/src/lib/cad-stream.test.ts +++ b/frontend/src/lib/cad-stream.test.ts @@ -14,6 +14,20 @@ test("maps backend cad_result SSE into an AI SDK data part", () => { assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" }); }); +test("maps structured image analysis SSE into an AI SDK data part", () => { + const data = { + attachmentIds: ["upload_flange"], + partType: "四孔法兰套筒", + visibleFeatures: ["中空圆筒"], + uncertainFeatures: [], + dimensionCandidates: [{ id: "bore_diameter", label: "中心孔直径", reason: "图片未标注" }], + }; + const chunk = backendEventToUiChunk({ event: "image_analysis", data }, "text_1"); + + assert.equal(chunk?.type, "data-cad-image-analysis"); + assert.deepEqual("data" in chunk! ? chunk.data : null, data); +}); + test("restores the latest successful task revision for the viewer", () => { const result = latestSuccessfulResult({ task_id: "cad_abc", diff --git a/frontend/src/lib/cad-stream.ts b/frontend/src/lib/cad-stream.ts index e9bd7e44..d720b325 100644 --- a/frontend/src/lib/cad-stream.ts +++ b/frontend/src/lib/cad-stream.ts @@ -33,5 +33,12 @@ export function backendEventToUiChunk( data: item.data, }; } + if (item.event === "image_analysis") { + return { + type: "data-cad-image-analysis", + id: `image_analysis_${Date.now()}`, + data: item.data, + }; + } return null; } diff --git a/frontend/src/lib/cad-types.ts b/frontend/src/lib/cad-types.ts index 07105e64..b7c490ff 100644 --- a/frontend/src/lib/cad-types.ts +++ b/frontend/src/lib/cad-types.ts @@ -17,8 +17,6 @@ export type CadResult = { parametersPath?: string; selectorPath?: string; edgesPath?: string; - designIntentId?: string; - designIntentPath?: string; summary: string; referenceIds: string[]; engine: string; @@ -29,10 +27,27 @@ export type CadError = { message: string; }; +export type CadImageDimension = { + id: string; + label: string; + reason: string; +}; + +export type CadImageAnalysis = { + attachmentIds: string[]; + partType: string; + visibleFeatures: string[]; + uncertainFeatures: string[]; + dimensionCandidates?: CadImageDimension[]; + // Legacy conversations stored this field before dimensions became optional. + requiredDimensions?: CadImageDimension[]; +}; + export type CadDataParts = { "cad-progress": CadProgress; "cad-result": CadResult; "cad-error": CadError; + "cad-image-analysis": CadImageAnalysis; }; export type CadUIMessage = UIMessage; @@ -46,7 +61,7 @@ export type ConversationRecord = { export type CadAttachment = { id: string; - task_id: string; + conversation_id: string; name: string; kind: "image" | "document"; path: string; @@ -66,8 +81,6 @@ export type TaskRevision = { parameters_path?: string; selector_path?: string; edges_path?: string; - design_intent_id?: string; - design_intent_path?: string; summary?: string; reference_ids?: string[]; engine?: string; @@ -77,7 +90,6 @@ export type TaskRevision = { export type TaskRecord = { task_id: string; current_revision: string; - current_design_intent_id?: string; revisions: TaskRevision[]; }; -- 2.52.0 From beaa5dd9fefd80e75bdbabf1ddd255f8db4b25e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Tue, 25 Aug 2026 17:41:24 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env | 1 + .../bridge/atomic-pattern-holes.md | 2 +- .../part-skills/bridge/planning-flange.md | 2 +- .../references/part-skills/catalog.json | 36 +- backend/app/main.py | 57 +- backend/app/models/contracts.py | 7 + backend/app/services/agent_service.py | 524 +++-- backend/app/services/attachments.py | 20 +- backend/app/services/cdsl_patch.py | 122 + backend/app/services/editing.py | 51 +- backend/app/services/engine_service.py | 123 +- .../app/services/flange_sleeve_template.py | 273 --- backend/app/services/library.py | 26 +- backend/app/services/part_skills.py | 78 +- backend/app/services/quality.py | 262 +++ backend/app/services/storage.py | 122 +- backend/app/settings.py | 2 + backend/cdsl_importer/__init__.py | 6 + .../distill_output3.py | 0 .../cdsl_importer/legacy_profile_adapter.py | 117 + backend/cdsl_importer/legacy_profiles.py | 2075 +++++++++++++++++ .../solidworks_to_cdsl.py} | 16 +- backend/engine/cdsl_engine/__init__.py | 12 +- backend/engine/cdsl_engine/batch_rebuild.py | 3 +- .../engine/cdsl_engine/build123d_adapter.py | 16 +- backend/engine/cdsl_engine/cdsl_schema.json | 90 +- backend/engine/cdsl_engine/design_intent.py | 331 --- .../cdsl_engine/design_intent_schema.json | 124 - .../engine/cdsl_engine/generation_compiler.py | 212 -- backend/engine/cdsl_engine/generation_spec.py | 177 -- .../cdsl_engine/generation_spec_schema.json | 100 - backend/engine/cdsl_engine/phase_pools.py | 6 +- .../engine/cdsl_engine/profile_schema.json | 190 +- backend/engine/cdsl_engine/rebuild.py | 15 +- backend/engine/cdsl_engine/runtime.py | 6 +- backend/engine/cdsl_engine/sketch_solver.py | 2036 ++-------------- .../remove_generation_spec_artifacts.py | 159 ++ backend/tests/test_agent_tool_arguments.py | 59 +- backend/tests/test_design_intent.py | 203 -- backend/tests/test_design_intent_flow.py | 94 +- backend/tests/test_direct_cdsl_pipeline.py | 360 +++ .../tests/test_engine_runtime_foundation.py | 15 +- backend/tests/test_part_skills.py | 159 +- backend/tests/test_profile_schema.py | 68 +- .../app/api/tasks/[taskId]/quality/route.ts | 11 + frontend/src/app/globals.css | 12 + frontend/src/components/cad-message-parts.tsx | 54 + .../src/components/cad-viewer-preview.tsx | 7 + frontend/src/lib/cad-artifacts.ts | 7 + frontend/src/lib/cad-types.ts | 17 + 50 files changed, 4204 insertions(+), 4261 deletions(-) create mode 100644 backend/app/services/cdsl_patch.py delete mode 100644 backend/app/services/flange_sleeve_template.py create mode 100644 backend/app/services/quality.py create mode 100644 backend/cdsl_importer/__init__.py rename backend/{engine/cdsl_engine => cdsl_importer}/distill_output3.py (100%) create mode 100644 backend/cdsl_importer/legacy_profile_adapter.py create mode 100644 backend/cdsl_importer/legacy_profiles.py rename backend/{engine/cdsl_engine/convert_to_cdsl.py => cdsl_importer/solidworks_to_cdsl.py} (98%) delete mode 100644 backend/engine/cdsl_engine/design_intent.py delete mode 100644 backend/engine/cdsl_engine/design_intent_schema.json delete mode 100644 backend/engine/cdsl_engine/generation_compiler.py delete mode 100644 backend/engine/cdsl_engine/generation_spec.py delete mode 100644 backend/engine/cdsl_engine/generation_spec_schema.json create mode 100644 backend/scripts/remove_generation_spec_artifacts.py delete mode 100644 backend/tests/test_design_intent.py create mode 100644 backend/tests/test_direct_cdsl_pipeline.py create mode 100644 frontend/src/app/api/tasks/[taskId]/quality/route.ts diff --git a/backend/.env b/backend/.env index b60a1ec1..ff21e115 100644 --- a/backend/.env +++ b/backend/.env @@ -29,6 +29,7 @@ CDSL_KIMI_VISION_MODELS= CDSL_DEEPSEEK_STRICT_TOOL_SCHEMA=false CDSL_OPENAI_STRICT_TOOL_SCHEMA=false CDSL_KIMI_STRICT_TOOL_SCHEMA=true +CDSL_MAX_REPAIR_ATTEMPTS=4 # To enable individual models instead of every model from a provider, keep the # provider-wide switch false and list exact IDs, for example: # CDSL_OPENAI_STRICT_TOOL_MODELS=gpt-4.1 diff --git a/backend/agent/skills/cad-engine/references/part-skills/bridge/atomic-pattern-holes.md b/backend/agent/skills/cad-engine/references/part-skills/bridge/atomic-pattern-holes.md index 204ad8ed..b3945d63 100644 --- a/backend/agent/skills/cad-engine/references/part-skills/bridge/atomic-pattern-holes.md +++ b/backend/agent/skills/cad-engine/references/part-skills/bridge/atomic-pattern-holes.md @@ -2,4 +2,4 @@ - Build a valid seed hole first and preserve its host frame and functional subtype. - Use `pattern_linear` for one/two-dimensional rows and `pattern_mirror` for symmetry. -- For circular layouts, use an explicit `circles` profile expansion and record the audit status; do not invent a circular-pattern atomic. +- For circular layouts, use an explicit `circles` profile expansion; do not invent a circular-pattern atomic. diff --git a/backend/agent/skills/cad-engine/references/part-skills/bridge/planning-flange.md b/backend/agent/skills/cad-engine/references/part-skills/bridge/planning-flange.md index e4e877fa..6b48bc1e 100644 --- a/backend/agent/skills/cad-engine/references/part-skills/bridge/planning-flange.md +++ b/backend/agent/skills/cad-engine/references/part-skills/bridge/planning-flange.md @@ -3,5 +3,5 @@ - Use for a circular flange, flanged sleeve, or coupling flange with a shared central axis. - Invariants: coaxial outside diameter, bore/hub, sealing land, and fastener holes on a pitch circle. - Plan: `revolve_add` for stepped axisymmetric bodies or circular `extrude_add_blind` -> coaxial bore -> one bolt-hole definition -> explicit circle set or supported repetition -> late finishing. -- No circular-pattern atomic exists. Expand a bolt circle into explicit circles in one cut profile and record `expanded` in the audit. +- No circular-pattern atomic exists. Express a bolt circle with explicit circles in one cut profile when the engine schema supports that profile. - Keep bolt-circle diameter, count, start angle, bore, and hub dimensions distinct. diff --git a/backend/agent/skills/cad-engine/references/part-skills/catalog.json b/backend/agent/skills/cad-engine/references/part-skills/catalog.json index ae537cb1..5eceb8f8 100644 --- a/backend/agent/skills/cad-engine/references/part-skills/catalog.json +++ b/backend/agent/skills/cad-engine/references/part-skills/catalog.json @@ -12,7 +12,6 @@ "exclude": ["flange", "法兰", "3d bracket", "mounting bracket", "轴承座"], "bridge": "bridge/planning-mounting-plate.md", "source": "source/planning/mounting-plate.planning.md", - "capability_translation_rules": ["exact: schema_valid_feature_sequence"], "related": ["functional/mounting-plate-hole-layout", "functional/slotted-adjustment-feature", "functional/standard-hole-wizard", "functional/symmetric-feature-layout", "atomic/extrude-base-profile", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"] }, { @@ -24,7 +23,6 @@ "exclude": ["flat mounting plate only", "仅底板", "sheet metal"], "bridge": "bridge/planning-mounting-bracket.md", "source": "source/planning/mounting-bracket.planning.md", - "capability_translation_rules": ["exact: schema_valid_feature_sequence"], "related": ["functional/slotted-adjustment-feature", "functional/standard-hole-wizard", "functional/symmetric-feature-layout", "atomic/extrude-base-profile", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"] }, { @@ -36,7 +34,6 @@ "exclude": ["flange nut", "法兰螺母", "side tab"], "bridge": "bridge/planning-flange.md", "source": "source/planning/flange.planning.md", - "capability_translation_rules": ["expanded: circular_pattern_to_explicit_circles"], "related": ["functional/flange-bolt-circle", "functional/axisymmetric-revolve-strategy", "functional/standard-hole-wizard", "atomic/coaxial-bore-rule", "atomic/revolve-profile-around-axis", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"] }, { @@ -48,7 +45,6 @@ "exclude": ["spindle assembly", "bearing assembly", "gearbox shaft assembly", "主轴组件", "轴承组件"], "bridge": "bridge/planning-simple-shaft.md", "source": "source/planning/simple-shaft-or-cylindrical-rod.planning.md", - "capability_translation_rules": ["exact: revolve_or_extrude_supported"], "related": ["functional/axisymmetric-revolve-strategy", "atomic/coaxial-bore-rule", "atomic/revolve-profile-around-axis", "atomic/through-hole-cut", "atomic/fillet-chamfer-last"] }, { @@ -60,7 +56,6 @@ "exclude": ["complete rolling bearing", "bearing assembly", "完整轴承", "轴承组件"], "bridge": "bridge/planning-bearing-housing.md", "source": "source/planning/bearing-housing-or-seat.planning.md", - "capability_translation_rules": ["blocked: selector_required_for_dependent_features"], "related": ["functional/bearing-bore-seat", "functional/standard-hole-wizard", "atomic/coaxial-bore-rule", "atomic/extrude-base-profile", "atomic/revolve-profile-around-axis", "atomic/counterbored-hole-creation", "atomic/fillet-chamfer-last"] }, { @@ -72,25 +67,24 @@ "exclude": ["bolt", "screw", "wing nut", "cap nut", "flange nut", "螺栓", "螺钉", "蝶形螺母", "法兰螺母"], "bridge": "bridge/planning-hexagonal-nut.md", "source": "source/planning/hexagonal-nut.planning.md", - "capability_translation_rules": ["approximated: thread_geometry_omitted"], "related": ["functional/axisymmetric-revolve-strategy", "functional/standard-hole-wizard", "atomic/extrude-base-profile", "atomic/coaxial-bore-rule", "atomic/threaded-hole-creation", "atomic/fillet-chamfer-last"] }, - {"id": "functional/axisymmetric-revolve-strategy", "kind": "functional", "title": "Axisymmetric Revolve", "priority": 80, "triggers": ["axisymmetric", "revolve", "revolved body", "rotational part", "turned part", "回转体", "轴对称", "车削件"], "exclude": ["simple extruded cylinder", "rectangular plate"], "bridge": "bridge/functional-axisymmetric-revolve.md", "source": "source/functional/axisymmetric-revolve-strategy.functional.md", "capability_translation_rules": ["exact: explicit_revolve_axis"], "related": []}, - {"id": "functional/bearing-bore-seat", "kind": "functional", "title": "Bearing Bore Seat", "priority": 79, "triggers": ["bearing bore", "bearing pocket", "bearing seat", "bushing bore", "shaft clearance bore", "轴承孔", "轴承座孔", "轴承腔", "轴间隙孔"], "exclude": [], "bridge": "bridge/functional-bearing-bore-seat.md", "source": "source/functional/bearing-bore-seat.functional.md", "capability_translation_rules": ["blocked: selector_required_for_dependent_features"], "related": ["atomic/coaxial-bore-rule", "atomic/counterbored-hole-creation"]}, - {"id": "functional/flange-bolt-circle", "kind": "functional", "title": "Flange Bolt Circle", "priority": 78, "triggers": ["flange bolt circle", "bolt circle", "pitch circle", "pcd", "circular bolt pattern", "螺栓圆", "节圆", "圆周孔阵列"], "exclude": ["rectangular hole grid", "矩形孔阵列"], "bridge": "bridge/functional-flange-bolt-circle.md", "source": "source/functional/flange-bolt-circle.functional.md", "capability_translation_rules": ["expanded: circular_pattern_to_explicit_circles", "blocked: explicit_circle_layout_required"], "related": ["atomic/pattern-holes-from-datum"]}, - {"id": "functional/mounting-plate-hole-layout", "kind": "functional", "title": "Mounting Plate Hole Layout", "priority": 77, "triggers": ["mounting plate hole layout", "base plate hole pattern", "rectangular hole grid", "symmetric mounting holes", "底板孔布局", "矩形孔阵列", "安装孔阵列"], "exclude": ["flange bolt circle", "bearing bore"], "bridge": "bridge/functional-mounting-plate-hole-layout.md", "source": "source/functional/mounting-plate-hole-layout.functional.md", "capability_translation_rules": ["exact: linear_or_mirror_pattern"], "related": ["atomic/pattern-holes-from-datum", "functional/symmetric-feature-layout"]}, - {"id": "functional/slotted-adjustment-feature", "kind": "functional", "title": "Slotted Adjustment Feature", "priority": 76, "triggers": ["slot", "slotted hole", "adjustment slot", "elongated hole", "guide slot", "槽", "长圆槽", "调节槽", "导向槽"], "exclude": ["shaft keyway", "revolved groove", "轴键槽", "回转槽"], "bridge": "bridge/functional-slotted-adjustment.md", "source": "source/functional/slotted-adjustment-feature.functional.md", "capability_translation_rules": ["exact: obround_extrude_cut"], "related": []}, - {"id": "functional/standard-hole-wizard", "kind": "functional", "title": "Standard Hole Wizard", "priority": 75, "triggers": ["hole wizard", "standard holes", "threaded holes", "counterbored holes", "countersunk holes", "tapped holes", "孔向导", "标准孔", "螺纹孔", "沉孔", "沉头孔"], "exclude": [], "bridge": "bridge/functional-standard-hole-wizard.md", "source": "source/functional/standard-hole-wizard.functional.md", "capability_translation_rules": ["approximated: thread_geometry_omitted", "exact: supported_hole_subtype"], "related": ["atomic/counterbored-hole-creation", "atomic/threaded-hole-creation", "atomic/through-hole-cut"]}, - {"id": "functional/symmetric-feature-layout", "kind": "functional", "title": "Symmetric Feature Layout", "priority": 74, "triggers": ["symmetric holes", "mirror pattern", "symmetry", "mirrored features", "centerline layout", "对称孔", "镜像阵列", "对称布局", "中心线布局"], "exclude": [], "bridge": "bridge/functional-symmetric-layout.md", "source": "source/functional/symmetric-feature-layout.functional.md", "capability_translation_rules": ["exact: pattern_mirror_or_pattern_linear"], "related": []}, + {"id": "functional/axisymmetric-revolve-strategy", "kind": "functional", "title": "Axisymmetric Revolve", "priority": 80, "triggers": ["axisymmetric", "revolve", "revolved body", "rotational part", "turned part", "回转体", "轴对称", "车削件"], "exclude": ["simple extruded cylinder", "rectangular plate"], "bridge": "bridge/functional-axisymmetric-revolve.md", "source": "source/functional/axisymmetric-revolve-strategy.functional.md", "related": []}, + {"id": "functional/bearing-bore-seat", "kind": "functional", "title": "Bearing Bore Seat", "priority": 79, "triggers": ["bearing bore", "bearing pocket", "bearing seat", "bushing bore", "shaft clearance bore", "轴承孔", "轴承座孔", "轴承腔", "轴间隙孔"], "exclude": [], "bridge": "bridge/functional-bearing-bore-seat.md", "source": "source/functional/bearing-bore-seat.functional.md", "related": ["atomic/coaxial-bore-rule", "atomic/counterbored-hole-creation"]}, + {"id": "functional/flange-bolt-circle", "kind": "functional", "title": "Flange Bolt Circle", "priority": 78, "triggers": ["flange bolt circle", "bolt circle", "pitch circle", "pcd", "circular bolt pattern", "螺栓圆", "节圆", "圆周孔阵列"], "exclude": ["rectangular hole grid", "矩形孔阵列"], "bridge": "bridge/functional-flange-bolt-circle.md", "source": "source/functional/flange-bolt-circle.functional.md", "related": ["atomic/pattern-holes-from-datum"]}, + {"id": "functional/mounting-plate-hole-layout", "kind": "functional", "title": "Mounting Plate Hole Layout", "priority": 77, "triggers": ["mounting plate hole layout", "base plate hole pattern", "rectangular hole grid", "symmetric mounting holes", "底板孔布局", "矩形孔阵列", "安装孔阵列"], "exclude": ["flange bolt circle", "bearing bore"], "bridge": "bridge/functional-mounting-plate-hole-layout.md", "source": "source/functional/mounting-plate-hole-layout.functional.md", "related": ["atomic/pattern-holes-from-datum", "functional/symmetric-feature-layout"]}, + {"id": "functional/slotted-adjustment-feature", "kind": "functional", "title": "Slotted Adjustment Feature", "priority": 76, "triggers": ["slot", "slotted hole", "adjustment slot", "elongated hole", "guide slot", "槽", "长圆槽", "调节槽", "导向槽"], "exclude": ["shaft keyway", "revolved groove", "轴键槽", "回转槽"], "bridge": "bridge/functional-slotted-adjustment.md", "source": "source/functional/slotted-adjustment-feature.functional.md", "related": []}, + {"id": "functional/standard-hole-wizard", "kind": "functional", "title": "Standard Hole Wizard", "priority": 75, "triggers": ["hole wizard", "standard holes", "threaded holes", "counterbored holes", "countersunk holes", "tapped holes", "孔向导", "标准孔", "螺纹孔", "沉孔", "沉头孔"], "exclude": [], "bridge": "bridge/functional-standard-hole-wizard.md", "source": "source/functional/standard-hole-wizard.functional.md", "related": ["atomic/counterbored-hole-creation", "atomic/threaded-hole-creation", "atomic/through-hole-cut"]}, + {"id": "functional/symmetric-feature-layout", "kind": "functional", "title": "Symmetric Feature Layout", "priority": 74, "triggers": ["symmetric holes", "mirror pattern", "symmetry", "mirrored features", "centerline layout", "对称孔", "镜像阵列", "对称布局", "中心线布局"], "exclude": [], "bridge": "bridge/functional-symmetric-layout.md", "source": "source/functional/symmetric-feature-layout.functional.md", "related": []}, - {"id": "atomic/coaxial-bore-rule", "kind": "atomic", "title": "Coaxial Bore", "priority": 60, "triggers": ["coaxial bore", "central bore", "concentric", "shaft clearance", "同轴孔", "中心孔", "同心", "轴间隙"], "exclude": [], "bridge": "bridge/atomic-coaxial-bore.md", "source": "source/atomic/coaxial-bore-rule.atomic.md", "capability_translation_rules": ["exact: explicit_axis_or_frame"], "related": []}, - {"id": "atomic/counterbored-hole-creation", "kind": "atomic", "title": "Counterbored Hole", "priority": 59, "triggers": ["counterbored hole", "counterbore", "socket head screw", "cap screw", "recessed screw head", "沉孔", "内六角螺钉", "螺钉头沉孔"], "exclude": [], "bridge": "bridge/atomic-counterbore.md", "source": "source/atomic/counterbored-hole-creation.atomic.md", "capability_translation_rules": ["exact: hole_counterbore"], "related": []}, - {"id": "atomic/extrude-base-profile", "kind": "atomic", "title": "Extrude Base Profile", "priority": 58, "triggers": ["extrude base profile", "extrude rectangle", "extrude circle", "base extrusion", "simple cylinder", "rectangular block", "基础拉伸", "矩形拉伸", "圆柱拉伸"], "exclude": [], "bridge": "bridge/atomic-extrude-base.md", "source": "source/atomic/extrude-base-profile.atomic.md", "capability_translation_rules": ["exact: extrude_add_blind"], "related": []}, - {"id": "atomic/fillet-chamfer-last", "kind": "atomic", "title": "Fillet And Chamfer Last", "priority": 57, "triggers": ["fillet chamfer last", "edge treatment", "chamfered edges", "filleted edges", "最后倒角", "最后圆角", "边缘处理"], "exclude": [], "bridge": "bridge/atomic-fillet-chamfer-last.md", "source": "source/atomic/fillet-chamfer-last.atomic.md", "capability_translation_rules": ["omitted: selector_unavailable", "exact: unique_selector"], "related": []}, - {"id": "atomic/pattern-holes-from-datum", "kind": "atomic", "title": "Pattern Holes From Datum", "priority": 56, "triggers": ["hole pattern", "pattern holes from datum", "repeated holes", "symmetric holes", "孔阵列", "重复孔", "基准孔阵列", "对称孔"], "exclude": [], "bridge": "bridge/atomic-pattern-holes.md", "source": "source/atomic/pattern-holes-from-datum.atomic.md", "capability_translation_rules": ["expanded: explicit_circle_layout", "exact: pattern_linear_or_pattern_mirror"], "related": []}, - {"id": "atomic/revolve-profile-around-axis", "kind": "atomic", "title": "Revolve Profile Around Axis", "priority": 55, "triggers": ["revolve profile", "revolve around axis", "revolved cut", "axisymmetric profile", "截面回转", "绕轴回转", "回转切除"], "exclude": [], "bridge": "bridge/atomic-revolve-profile.md", "source": "source/atomic/revolve-profile-around-axis.atomic.md", "capability_translation_rules": ["exact: revolve_add_or_revolve_cut"], "related": []}, - {"id": "atomic/threaded-hole-creation", "kind": "atomic", "title": "Threaded Hole", "priority": 54, "triggers": ["threaded hole", "tapped hole", "internal thread", "m3", "m4", "m5", "m6", "螺纹孔", "攻丝孔", "内螺纹"], "exclude": [], "bridge": "bridge/atomic-threaded-hole.md", "source": "source/atomic/threaded-hole-creation.atomic.md", "capability_translation_rules": ["approximated: thread_geometry_omitted"], "related": []}, - {"id": "atomic/through-hole-cut", "kind": "atomic", "title": "Through Hole Cut", "priority": 53, "triggers": ["through hole", "clearance hole", "central hole", "circular opening", "cut through", "bore through", "通孔", "间隙孔", "贯穿孔", "切穿"], "exclude": [], "bridge": "bridge/atomic-through-hole.md", "source": "source/atomic/through-hole-cut.atomic.md", "capability_translation_rules": ["exact: through_hole_or_explicit_cut"], "related": []} + {"id": "atomic/coaxial-bore-rule", "kind": "atomic", "title": "Coaxial Bore", "priority": 60, "triggers": ["coaxial bore", "central bore", "concentric", "shaft clearance", "同轴孔", "中心孔", "同心", "轴间隙"], "exclude": [], "bridge": "bridge/atomic-coaxial-bore.md", "source": "source/atomic/coaxial-bore-rule.atomic.md", "related": []}, + {"id": "atomic/counterbored-hole-creation", "kind": "atomic", "title": "Counterbored Hole", "priority": 59, "triggers": ["counterbored hole", "counterbore", "socket head screw", "cap screw", "recessed screw head", "沉孔", "内六角螺钉", "螺钉头沉孔"], "exclude": [], "bridge": "bridge/atomic-counterbore.md", "source": "source/atomic/counterbored-hole-creation.atomic.md", "related": []}, + {"id": "atomic/extrude-base-profile", "kind": "atomic", "title": "Extrude Base Profile", "priority": 58, "triggers": ["extrude base profile", "extrude rectangle", "extrude circle", "base extrusion", "simple cylinder", "rectangular block", "基础拉伸", "矩形拉伸", "圆柱拉伸"], "exclude": [], "bridge": "bridge/atomic-extrude-base.md", "source": "source/atomic/extrude-base-profile.atomic.md", "related": []}, + {"id": "atomic/fillet-chamfer-last", "kind": "atomic", "title": "Fillet And Chamfer Last", "priority": 57, "triggers": ["fillet chamfer last", "edge treatment", "chamfered edges", "filleted edges", "最后倒角", "最后圆角", "边缘处理"], "exclude": [], "bridge": "bridge/atomic-fillet-chamfer-last.md", "source": "source/atomic/fillet-chamfer-last.atomic.md", "related": []}, + {"id": "atomic/pattern-holes-from-datum", "kind": "atomic", "title": "Pattern Holes From Datum", "priority": 56, "triggers": ["hole pattern", "pattern holes from datum", "repeated holes", "symmetric holes", "孔阵列", "重复孔", "基准孔阵列", "对称孔"], "exclude": [], "bridge": "bridge/atomic-pattern-holes.md", "source": "source/atomic/pattern-holes-from-datum.atomic.md", "related": []}, + {"id": "atomic/revolve-profile-around-axis", "kind": "atomic", "title": "Revolve Profile Around Axis", "priority": 55, "triggers": ["revolve profile", "revolve around axis", "revolved cut", "axisymmetric profile", "截面回转", "绕轴回转", "回转切除"], "exclude": [], "bridge": "bridge/atomic-revolve-profile.md", "source": "source/atomic/revolve-profile-around-axis.atomic.md", "related": []}, + {"id": "atomic/threaded-hole-creation", "kind": "atomic", "title": "Threaded Hole", "priority": 54, "triggers": ["threaded hole", "tapped hole", "internal thread", "m3", "m4", "m5", "m6", "螺纹孔", "攻丝孔", "内螺纹"], "exclude": [], "bridge": "bridge/atomic-threaded-hole.md", "source": "source/atomic/threaded-hole-creation.atomic.md", "related": []}, + {"id": "atomic/through-hole-cut", "kind": "atomic", "title": "Through Hole Cut", "priority": 53, "triggers": ["through hole", "clearance hole", "central hole", "circular opening", "cut through", "bore through", "通孔", "间隙孔", "贯穿孔", "切穿"], "exclude": [], "bridge": "bridge/atomic-through-hole.md", "source": "source/atomic/through-hole-cut.atomic.md", "related": []} ] } diff --git a/backend/app/main.py b/backend/app/main.py index a6858f17..70fff67e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -57,6 +57,7 @@ async def config() -> dict[str, Any]: "model": settings.llm_model, "configured": settings.llm_configured, "library_samples": library.count(), + "max_repair_attempts": settings.max_repair_attempts, } @@ -130,30 +131,6 @@ 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 @@ -186,6 +163,38 @@ async def read_parameters(task_id: str) -> JSONResponse: return JSONResponse({"task_id": safe_id, "revision_id": revision_id, **json.loads(path.read_text(encoding="utf-8"))}) +@app.get("/v1/tasks/{task_id}/quality") +async def read_quality(task_id: str) -> JSONResponse: + try: + safe_id = safe_task_id(task_id) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + task = store.read_task(safe_id) + revision_id = str((task or {}).get("current_revision") or "") + revisions = (task or {}).get("revisions", []) + revision = next((item for item in revisions if item.get("revision_id") == revision_id), None) + if revisions and revisions[-1].get("revision_id") != revision_id: + revision = revisions[-1] + revision_id = str(revision.get("revision_id") or "") + payload: dict[str, Any] = { + "task_id": safe_id, + "revision_id": revision_id, + "quality_status": (revision or {}).get("quality_status", ""), + "snapshot_status": (revision or {}).get("snapshot_status", "unavailable"), + "snapshot_paths": (revision or {}).get("snapshot_paths", []), + "assumptions": (revision or {}).get("generation_assumptions", []), + "verification_summary": (revision or {}).get("verification_summary", {}), + } + relative = str((revision or {}).get("quality_path") or "") + if relative: + path = store.artifact_path(safe_id, relative) + if path.is_file(): + payload["quality"] = json.loads(path.read_text(encoding="utf-8")) + if not revision: + raise HTTPException(status_code=404, detail="Task has no revision") + return JSONResponse(payload) + + @app.post("/v1/tasks/{task_id}/parameters") async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONResponse: try: diff --git a/backend/app/models/contracts.py b/backend/app/models/contracts.py index 668b59ff..d50f9025 100644 --- a/backend/app/models/contracts.py +++ b/backend/app/models/contracts.py @@ -59,3 +59,10 @@ class CadResult(BaseModel): summary: str reference_ids: list[str] = Field(default_factory=list) engine: str = "cdsl_only" + quality_status: str = "" + quality_path: str | None = None + assumptions: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + repair_attempts: int = 0 + snapshot_paths: list[str] = Field(default_factory=list) + snapshot_status: str = "unavailable" diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index 611a6735..bdefac56 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -5,7 +5,9 @@ import base64 from copy import deepcopy import json import math +import re import secrets +import sys from collections.abc import AsyncIterator from pathlib import Path from typing import Any @@ -14,13 +16,10 @@ import httpx from app.models.contracts import ChatMessage from app.services.engine_service import build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl -from app.services.flange_sleeve_template import ( - TEMPLATE_ID as FLANGE_SLEEVE_TEMPLATE_ID, - build_flange_sleeve_cdsl, - flange_sleeve_plan_schema, -) +from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch from app.services.library import CdslLibrary from app.services.part_skills import PartSkillLibrary +from app.services.quality import QUALITY_RULE_TYPES, validate_verification from app.services.sse import event from app.services.storage import WorkspaceStore, now_iso from app.settings import ProviderConfig, ProviderModel, Settings @@ -42,6 +41,14 @@ class RepeatedToolArgumentsError(RuntimeError): self.diagnostic_paths = diagnostic_paths or [] +class CdslRepairLimitError(RuntimeError): + """The direct CDSL repair budget is exhausted for this request.""" + + def __init__(self, diagnostic_paths: list[str]) -> None: + super().__init__("CDSL repair limit reached") + self.diagnostic_paths = diagnostic_paths + + def user_visible_error_message(error: Exception, user_text: str) -> str: if isinstance(error, StrictToolSchemaError) and any( "\u4e00" <= char <= "\u9fff" for char in str(user_text or "") @@ -61,6 +68,11 @@ def user_visible_error_message(error: Exception, user_text: str) -> str: "请检查所选模型的函数调用兼容性;若仍出现此错误,请关闭该模型的严格工具 schema 开关后再试。" + diagnostics ) + if isinstance(error, CdslRepairLimitError): + diagnostics = "、".join(error.diagnostic_paths) + if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")): + return "模型重试达到四次 CDSL 修复上限,未创建新的成功 revision。每次 CDSL 校验失败的诊断已保存到:" + diagnostics + "。" + return "The four-attempt CDSL repair limit was reached. Diagnostics were saved to: " + diagnostics + "." return str(error) @@ -84,7 +96,7 @@ def _repair_premature_tool_wrapper_close(source: str, parsed_value: Any, parsed_ return None if candidate[candidate_end:].strip() or not isinstance(value, dict): return None - if not set(value).issubset({"cdsl", "summary", "assumptions"}): + if not set(value).issubset({"cdsl", "summary", "assumptions", "verification"}): return None if not isinstance(value.get("cdsl"), dict) or not isinstance(value.get("summary"), str): return None @@ -135,10 +147,38 @@ def invalid_tool_arguments_result(name: str, error: ToolArgumentsError) -> dict[ } -def invalid_cdsl_result(error: ValueError) -> dict[str, Any]: +def _cdsl_error_details(error: Exception) -> dict[str, Any]: + message = str(error) + path_match = re.search(r"(?:at|path) (\$[^: ]*)", message) + quality = getattr(error, "quality_report", None) + if isinstance(quality, dict): + return { + "code": "VERIFICATION_FAILED", + "path": "$.verification.rules", + "kind": "verification", + "repair_instruction": "Correct the CDSL feature geometry or the verification rule, then submit a local patch or complete replacement.", + "quality": quality, + } + if "schema violation" in message: + return { + "code": "CDSL_SCHEMA_INVALID", + "path": path_match.group(1) if path_match else "$", + "kind": "schema", + "repair_instruction": "Correct the field at the reported JSONPath using the authoritative CDSL schema.", + } + return { + "code": "CDSL_RUNTIME_INVALID", + "path": path_match.group(1) if path_match else "$", + "kind": "runtime", + "repair_instruction": "Correct the invalid CDSL structure, dependency, selector, or runtime parameter and retry.", + } + + +def invalid_cdsl_result(error: Exception) -> dict[str, Any]: + details = _cdsl_error_details(error) return { "ok": False, - "code": "INVALID_CDSL", + **details, "message": ( f"The submitted CDSL is incomplete or invalid: {error}. " "Read the authoritative local engine schema, then call generate_cdsl_model " @@ -149,7 +189,7 @@ def invalid_cdsl_result(error: ValueError) -> dict[str, Any]: def user_visible_tool_message(result: dict[str, Any], user_text: str) -> str: code = str(result.get("code") or "") - if code == "INVALID_CDSL": + if code in {"CDSL_SCHEMA_INVALID", "CDSL_RUNTIME_INVALID", "VERIFICATION_FAILED"}: 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." @@ -185,14 +225,109 @@ def _cdsl_tool_schema() -> dict[str, Any]: schema_name = str(contract.get("cdsl_json_schema_file") or "") if not schema_name or Path(schema_name).name != schema_name: raise RuntimeError("Local engine contract has no valid CDSL JSON Schema path") - return json.loads((engine_dir / schema_name).read_text(encoding="utf-8")) + schema = json.loads((engine_dir / schema_name).read_text(encoding="utf-8")) + supported_atomics = { + str(item) + for item in contract.get("runtime_supported_atomic_ids") or () + if str(item) + } + declared_atomics = set((contract.get("feature_atomic_ids") or {}).keys()) + if not supported_atomics or not supported_atomics.issubset(declared_atomics): + raise RuntimeError("Engine capability contract has invalid runtime atomic ids") + engine_parent = str(engine_dir.parent) + if engine_parent not in sys.path: + sys.path.insert(0, engine_parent) + import cdsl_engine + + registered_atomics = {str(item) for item in getattr(cdsl_engine, "SUPPORTED_ATOMIC_IDS", ())} + supported_atomics &= registered_atomics + if not supported_atomics: + raise RuntimeError("Engine has no registered atomic executors in common with its capability contract") + feature_atomic_definition = schema.get("$defs", {}).get("feature_atomic_ids") + if not isinstance(feature_atomic_definition, dict): + raise RuntimeError("Local CDSL JSON Schema has no feature atomic definition") + feature_atomic_definition["enum"] = sorted(supported_atomics) + profile_definition = schema.get("$defs", {}).get("profile_type") + supported_profiles = { + str(item) + for item in contract.get("runtime_supported_profiles") or () + if str(item) + } + supported_profiles &= {str(item) for item in getattr(cdsl_engine, "SHAPE_GENERATORS", ())} + if isinstance(profile_definition, dict) and supported_profiles: + profile_definition["enum"] = sorted(supported_profiles) + return schema except (OSError, json.JSONDecodeError, AttributeError) as error: raise RuntimeError("Local CDSL JSON Schema is unavailable or invalid") from error CDSL_TOOL_SCHEMA = _cdsl_tool_schema() -FLANGE_SLEEVE_PLAN_SCHEMA = flange_sleeve_plan_schema() -GENERATION_TOOL_NAMES = {"generate_cdsl_model", "generate_flange_sleeve_model"} +GENERATION_TOOL_NAMES = {"generate_cdsl_model", "patch_cdsl_model"} + +VERIFICATION_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "rules": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "type": {"enum": sorted(QUALITY_RULE_TYPES)}, + "feature": {"type": "string"}, + "expected": { + "description": ( + "For bbox, use [dx, dy, dz], " + "{min: [x, y, z], max: [x, y, z]}, or " + "{x_min, x_max, y_min, y_max, z_min, z_max}." + ), + }, + "tolerance": {"type": "number", "minimum": 0}, + "severity": {"enum": ["blocking", "warning", "informational"]}, + }, + "required": ["id", "type", "expected"], + "additionalProperties": False, + }, + }, + }, + "required": ["rules"], + "additionalProperties": False, +} + +JSON_PATCH_OPERATION_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "op": {"enum": ["add", "remove", "replace", "move", "copy", "test"]}, + "path": {"type": "string", "pattern": "^/"}, + "from": {"type": "string", "pattern": "^/"}, + "value": {}, + }, + "required": ["op", "path"], + "additionalProperties": False, +} + + +def engine_capability_manifest(settings: Settings) -> dict[str, Any]: + """Build the compact planner-facing capability contract from engine files.""" + load_engine(settings) + try: + profile = json.loads((settings.engine_root / "profile_schema.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"supported_profiles": [], "runtime_atomic_ids": [], "required_params": {}, "unsupported_profiles": []} + atomic_contracts = profile.get("feature_atomic_ids") if isinstance(profile.get("feature_atomic_ids"), dict) else {} + declared_atomics = {str(item) for item in profile.get("runtime_supported_atomic_ids") or atomic_contracts} + registered_atomics = {str(item) for item in getattr(load_engine(settings), "SUPPORTED_ATOMIC_IDS", ())} + supported_atomics = sorted(declared_atomics & registered_atomics) + declared_profiles = {str(item) for item in profile.get("runtime_supported_profiles") or ()} + registered_profiles = {str(item) for item in getattr(load_engine(settings), "SHAPE_GENERATORS", ())} + return { + "supported_profiles": sorted(declared_profiles & registered_profiles), + "runtime_atomic_ids": supported_atomics, + "required_params": {str(key): list(value.get("required_params") or []) for key, value in atomic_contracts.items() if isinstance(value, dict)}, + "unsupported_profiles": sorted(str(item) for item in profile.get("unsupported_profiles") or []), + "verification_rule_types": sorted(QUALITY_RULE_TYPES), + } TOOL_SCHEMAS: list[dict[str, Any]] = [ @@ -285,29 +420,6 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, }, }, - { - "type": "function", - "function": { - "name": "generate_flange_sleeve_model", - "description": ( - "Generate a square or rectangular flange sleeve from a compact semantic plan. " - "Use this preferred tool for a flange plate with a coaxial hollow tube, " - "a stepped or tapered tip, four mounting holes, and counterbores. " - "The backend's verified template creates all CDSL workplanes, sketches, " - "feature dependencies, and feature parameters." - ), - "parameters": { - "type": "object", - "properties": { - "plan": FLANGE_SLEEVE_PLAN_SCHEMA, - "summary": {"type": "string", "minLength": 1}, - "assumptions": {"type": "array", "items": {"type": "string"}}, - }, - "required": ["plan", "summary", "assumptions"], - "additionalProperties": False, - }, - }, - }, { "type": "function", "function": { @@ -319,12 +431,32 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ "cdsl": CDSL_TOOL_SCHEMA, "summary": {"type": "string", "minLength": 1}, "assumptions": {"type": "array", "items": {"type": "string"}}, + "verification": VERIFICATION_SCHEMA, }, "required": ["cdsl", "summary", "assumptions"], "additionalProperties": False, }, }, }, + { + "type": "function", + "function": { + "name": "patch_cdsl_model", + "description": "Apply RFC 6902 JSON Patch operations to one existing CDSL revision, validate and rebuild it as a new revision.", + "parameters": { + "type": "object", + "properties": { + "base_revision_id": {"type": "string", "minLength": 1}, + "patches": {"type": "array", "minItems": 1, "maxItems": 32, "items": JSON_PATCH_OPERATION_SCHEMA}, + "summary": {"type": "string", "minLength": 1}, + "assumptions": {"type": "array", "items": {"type": "string"}}, + "verification": VERIFICATION_SCHEMA, + }, + "required": ["base_revision_id", "patches", "summary", "assumptions"], + "additionalProperties": False, + }, + }, + }, ] @@ -332,7 +464,6 @@ def tools_for_model( model: ProviderModel, *, include_image_analysis: bool = True, - flange_sleeve_only: bool = False, ) -> list[dict[str, Any]]: """Return this model's tool contract without mutating the shared schema.""" tools = deepcopy(TOOL_SCHEMAS) @@ -342,17 +473,6 @@ def tools_for_model( for tool in tools if tool.get("function", {}).get("name") != "analyze_image_reference" ] - if flange_sleeve_only: - tools = [ - tool - for tool in tools - if tool.get("function", {}).get("name") not in { - "search_cdsl_library", - "read_cdsl_reference", - "read_current_cdsl", - "generate_cdsl_model", - } - ] if not model.strict_tool_schema: return tools @@ -445,33 +565,6 @@ def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | N return None -def use_flange_sleeve_template(analysis: dict[str, Any] | None, task_id: str) -> bool: - """Route only a high-confidence new image reference to the verified template.""" - if task_id or not isinstance(analysis, dict): - return False - visible = analysis.get("visibleFeatures", analysis.get("visible_features", [])) - values = [analysis.get("partType", analysis.get("part_type", ""))] - if isinstance(visible, list): - values.extend(visible) - text = " ".join(str(value or "").casefold() for value in values) - has_flange = "法兰" in text or "flange" in text - has_sleeve = any(token in text for token in ("套筒", "管座", "圆筒", "cylind", "sleeve", "tube")) - return has_flange and has_sleeve - - -def flange_sleeve_template_instruction(enabled: bool) -> str: - if not enabled: - return "" - return """ -Template routing (highest priority for this request): -- This new image reference has been classified as a flange sleeve. -- After describe_design_intent, call generate_flange_sleeve_model exactly once. -- The complete-CDSL generation tool is intentionally unavailable for this request. -- Supply only visible semantic dimensions and assumptions; omitted dimensions use - the verified template defaults. Do not ask for CDSL implementation fields. -""" - - def image_reference_instruction( attachments: list[dict[str, Any]], analysis: dict[str, Any] | None, @@ -635,15 +728,30 @@ def system_prompt( part_skill_context: str = "", image_reference_context: str = "", cad_request_context: str = "", - flange_sleeve_template_mode: bool = False, ) -> str: + capability_manifest = json.dumps(engine_capability_manifest(settings), ensure_ascii=False, separators=(",", ":")) 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 "" profile_schema_path = settings.engine_root / "profile_schema.json" profile_schema = profile_schema_path.read_text(encoding="utf-8") if profile_schema_path.is_file() else "" - supported_profiles = ", ".join(sorted(load_engine(settings).SHAPE_GENERATORS)) + generation_contract = """- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly, not as Markdown or a JSON string. +- For a revision, call read_current_cdsl before generate_cdsl_model. Preserve unrelated CDSL features unless the user requests whole-part replacement. +- Use patch_cdsl_model only for a local RFC 6902 repair of a known base_revision_id. For structural changes, submit a complete replacement CDSL with generate_cdsl_model. +- For every feature.atomic_id, use only an ID listed in the compact capability manifest's runtime_atomic_ids. Other semantic contracts are not executable in this runtime. +- The backend normalizes only these unambiguous aliases: sketch_id -> id on sketches, sketch -> sketch_id on features, legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm. +- Every explicit user dimension, feature count, hole size, or hole position that can be measured must have a generic verification rule. Rule feature values must be IDs from the submitted CDSL. +- Do not output build123d source, compiler_context, unknown_shape, complex_arc_shape, entities, contour_edges_mm, or contour_regions_mm.""" + workflow = """3. Search the local official CDSL library after the design brief. Read a relevant reference when a match exists; samples are expression guidance, not templates or higher-priority requirements. +4. Generate one complete generic CDSL and call generate_cdsl_model. +5. On a schema, runtime, or verification failure, use patch_cdsl_model for a local correction or generate_cdsl_model for a complete replacement. +6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact.""" + authoring_context = f"""You own the complete CDSL: profiles, workplanes, sketch IDs, feature IDs, dependencies, selectors, and atomic IDs must be valid under the engine schema. Do not invent unsupported capabilities. + +Local skill: +{skill} + +Authoritative engine schema: +{profile_schema}""" return f"""You are the CDSL CAD Agent for CDSL CAD Studio. Language policy: @@ -661,31 +769,12 @@ Language policy: Tool call contract: - Every function call arguments field must contain exactly one valid JSON object. - 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. -- For a square/rectangular flange with a coaxial hollow sleeve, stepped or - tapered front, and four mounting counterbores, call - generate_flange_sleeve_model. Submit only its compact semantic plan; do not - create workplanes, sketch IDs, feature IDs, dependencies, or raw CDSL for - that template. The backend creates and validates the complete CDSL. -- The backend can normalize only these unambiguous legacy aliases before - validation: sketch_id -> id on sketches, sketch -> sketch_id on features, - legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm. - Do not rely on it for geometry, dimensions, selectors, or feature intent. - Call describe_design_intent first with a concise natural-language plan. - Its returned text is reference context for your CDSL work, not a second CAD - contract and not a source of feature IDs, profile bindings, or selectors. -- Do not call search_cdsl_library, read_cdsl_reference, - generate_flange_sleeve_model, or generate_cdsl_model until - describe_design_intent returns its plan. -- 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 - `{{"mm": [u_mm, v_mm, w_mm]}}`, never `[u_mm, v_mm]`. - If a tool reports INVALID_TOOL_ARGUMENTS, correct the arguments and call that tool again. Do not claim that the CAD model was generated. - If a generation tool reports INVALID_CDSL, correct its plan or complete CDSL as applicable and call that same tool again. Do not claim success. +{generation_contract} Workflow limits: - Do not expose internal planning or "let me" commentary to the user while @@ -698,7 +787,7 @@ Workflow limits: the missing dimension or decision. Do not combine it with a tool trace or a generic progress update. - Use at most two CDSL-library searches per user request. If neither finds a - useful reference, stop searching and use the engine guide to either generate + useful reference, stop searching and use the available capability contract to either generate the model or ask one concise clarification question. - Do not repeatedly search for the same unavailable feature or profile. @@ -708,24 +797,12 @@ Workflow limits: {cad_request_context} - {flange_sleeve_template_instruction(flange_sleeve_template_mode)} - -You generate executable CAD through compact semantic plans or parameterized CDSL, -never raw CAD source code. For new CAD requests: +You generate executable CAD through complete parameterized CDSL, never raw CAD source code. For new CAD requests: 1. Use the injected part-skill guidance, when present, only to establish the - structural plan, feature dependency order, and parameter roles. + structural plan, feature dependency order, parameter roles, and reference queries. 2. Call describe_design_intent with a concise textual plan. Ask one concise user-facing question before CAD generation if essential dimensions are missing. -3. If the requested family is a flange sleeve matching the dedicated template, - call generate_flange_sleeve_model immediately after the textual plan. Do not - search the CDSL library and do not use generate_cdsl_model for that case. -4. For other families, search the local official CDSL library. Read at least - one relevant reference when a match exists; samples provide schema-valid - expressions, not higher-priority user requirements. -5. For non-template families, generate one complete CDSL and call - generate_cdsl_model. The backend-generated CDSL is the authoritative CAD - model; the textual plan is guidance only. -6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact. +{workflow} Precedence is strict: explicit user request, then CDSL schema/runtime, then part-skill guidance, then CDSL-library examples. Part skills never authorize @@ -738,29 +815,10 @@ whole-part replacement. A primary-family conflict is a hard clarification stop: ask one concise question and do not call a generation tool until the user resolves it. -For a revision, call read_current_cdsl first and preserve unrelated features. -Use the flange sleeve template only when the user wants to replace the whole -part or the current part already follows that family; otherwise submit the -complete replacement CDSL. The backend automatically uses the latest -successful revision as the parent; do not provide a revision ID. -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 -schema before selecting an atomic ID, a profile, or their parameter names. Do -not invent an atomic ID, profile, or their fields. Ask a concise clarification question when essential -dimensions or intent are missing. Ordinary explanations must not create CAD. +{authoring_context} -Local skill: -{skill} - -Local engine guide: -{engine_readme} - -Authoritative engine schema: -{profile_schema} - -Supported named profile types: -{supported_profiles} +Compact Engine Capability Manifest (planner-facing; backend validator remains authoritative): +{capability_manifest} Injected part-skill context: {part_skill_context or "No part-family skill guidance was selected for this request."} @@ -849,18 +907,17 @@ class AgentService: image_inputs = image_attachments(conversation) recorded_image_analysis = image_reference_analysis(conversation) requires_image_intake = bool(image_inputs) and recorded_image_analysis is None - flange_sleeve_template_mode = use_flange_sleeve_template(recorded_image_analysis, task_id) yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"}) references: list[str] = [] library_searches = 0 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) - planning_state: dict[str, Any] = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} + planning_state: dict[str, Any] = {"phase": "INTAKE", "design_brief": "", "current_model_read": False} yield event("progress", { "step": "select_part_skill", - "label": "识别零件族", + "label": "选择建模 Skill", "status": "success", - "message": "已完成零件族与辅助建模规则识别。", + "message": "已完成建模 Skill 与辅助规则选择。", }) model_messages: list[dict[str, Any]] = [{ "role": "system", @@ -872,7 +929,6 @@ class AgentService: self.part_skill_library.render_context(part_skill_selection), image_reference_instruction(image_inputs, recorded_image_analysis), cad_request_instruction(task_id, current_task), - flange_sleeve_template_mode, ), }] model_messages.extend(messages_for_model(messages)) @@ -881,16 +937,21 @@ class AgentService: tools = tools_for_model( model, include_image_analysis=requires_image_intake, - flange_sleeve_only=flange_sleeve_template_mode, ) required_tool_name: str | None = "analyze_image_reference" if requires_image_intake else None generate_argument_failures = 0 tool_argument_diagnostics: list[str] = [] cdsl_validation_diagnostics: list[str] = [] generation_completed = False + repair_attempts = 0 try: for iteration in range(8): + if ( + planning_state.get("phase") == "CDSL_REPAIR" + and repair_attempts >= self.settings.max_repair_attempts + ): + raise CdslRepairLimitError(cdsl_validation_diagnostics) response = await self._complete(model_messages, tools, provider, model, required_tool_name) response_choice = response["choices"][0] choice = response_choice["message"] @@ -914,6 +975,15 @@ class AgentService: model_messages.append(choice) for call in tool_calls: name = str(call.get("function", {}).get("name") or "") + if requires_image_intake and name != "analyze_image_reference": + result = { + "ok": False, + "code": "IMAGE_ANALYSIS_REQUIRED", + "message": "Analyze all current image attachments before using planning, library, or generation tools.", + } + model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)}) + yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先完成图片参考分析。"}) + continue if name == "analyze_image_reference" and recorded_image_analysis is not None: result = { "ok": False, @@ -996,8 +1066,12 @@ class AgentService: "message": "CAD 工具参数格式无效,正在请求模型修正。", }) continue + if name in GENERATION_TOOL_NAMES and planning_state.get("phase") == "CDSL_REPAIR": + if repair_attempts >= self.settings.max_repair_attempts: + raise CdslRepairLimitError(cdsl_validation_diagnostics) + repair_attempts += 1 cdsl_attempt_path = "" - if name == "generate_cdsl_model": + if name in GENERATION_TOOL_NAMES: cdsl_attempt_path = self._record_cdsl_attempt( conversation_id=conversation["conversation_id"], arguments=arguments, @@ -1020,6 +1094,8 @@ class AgentService: planning_state=planning_state, image_attachment_ids=[str(attachment["id"]) for attachment in image_inputs], input_attachments=revision_input_attachments(conversation), + conversation_id=conversation["conversation_id"], + repair_attempts=repair_attempts, ) except (ValueError, RuntimeError) as error: if name in GENERATION_TOOL_NAMES: @@ -1038,8 +1114,24 @@ class AgentService: ) cdsl_validation_diagnostics.append(diagnostic_path) result = invalid_cdsl_result(error) + result["diagnostic_path"] = diagnostic_path + repair_task_id = str(getattr(error, "task_id", "") or "") + repair_revision_id = str(getattr(error, "revision_id", "") or "") + if repair_task_id and repair_revision_id: + result.update({ + "task_id": repair_task_id, + "base_revision_id": repair_revision_id, + "repair_instruction": ( + "Patch this explicit base_revision_id for a local correction, or call read_current_cdsl " + "before a complete CDSL replacement." + ), + }) + if name == "patch_cdsl_model": + result["code"] = "INVALID_CDSL_PATCH" + result["repair_instruction"] = "Correct the RFC 6902 patch or call generate_cdsl_model with a complete replacement CDSL." generated = None required_tool_name = name + planning_state["phase"] = "CDSL_REPAIR" else: raise if result.get("task_id"): @@ -1071,26 +1163,21 @@ class AgentService: "visibleFeatures": result["visible_features"], "uncertainFeatures": result["uncertain_features"], "dimensionCandidates": result["dimension_candidates"], + "artifactPath": result.get("artifact_path"), } assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) yield event("image_analysis", image_payload) recorded_image_analysis = image_payload - flange_sleeve_template_mode = use_flange_sleeve_template(image_payload, task_id) + requires_image_intake = False required_tool_name = None tools = tools_for_model( model, include_image_analysis=False, - flange_sleeve_only=flange_sleeve_template_mode, ) model_messages.append({ "role": "system", "content": image_reference_instruction(image_inputs, image_payload), }) - if flange_sleeve_template_mode: - model_messages.append({ - "role": "system", - "content": flange_sleeve_template_instruction(True), - }) yield event("progress", { "step": "analyze_image_reference", "label": "识别图片参考", @@ -1114,6 +1201,12 @@ class AgentService: "summary": generated["summary"], "referenceIds": generated["reference_ids"], "engine": generated["engine"], + "qualityStatus": generated.get("quality_status", ""), + "qualityPath": generated.get("quality_path") or None, + "assumptions": generated.get("generation_assumptions", []), + "repairAttempts": generated.get("repair_attempts", 0), + "snapshotPaths": generated.get("snapshot_paths", []), + "snapshotStatus": generated.get("snapshot_status", "unavailable"), } assistant_parts.append({"type": "data-cad-result", "data": result_payload}) yield event("cad_result", result_payload) @@ -1128,10 +1221,14 @@ class AgentService: if generation_completed: break if iteration == 7: - if cdsl_validation_diagnostics: - diagnostic_paths = "、".join(cdsl_validation_diagnostics) + validation_diagnostics = cdsl_validation_diagnostics + if validation_diagnostics: + diagnostic_paths = "、".join(validation_diagnostics) if any("\u4e00" <= char <= "\u9fff" for char in user_text): - message = f"模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:{diagnostic_paths}。" + message = ( + "模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:" + f"{diagnostic_paths}。" + ) else: message = ( "Agent tool loop reached its safety limit. " @@ -1250,6 +1347,7 @@ class AgentService: error: Exception, ) -> str: function = call.get("function") if isinstance(call.get("function"), dict) else {} + details = _cdsl_error_details(error) payload = { "schema_version": "1.0", "recorded_at": now_iso(), @@ -1269,6 +1367,8 @@ class AgentService: "cdsl_attempt_path": cdsl_attempt_path, "summary": str(arguments.get("summary") or ""), "assumptions": arguments.get("assumptions") or [], + "verification": arguments.get("verification"), + "diagnostic": details, "validation_error_type": type(error).__name__, "validation_error": str(error), } @@ -1328,24 +1428,30 @@ class AgentService: planning_state: dict[str, Any] | None = None, image_attachment_ids: list[str] | None = None, input_attachments: list[dict[str, str | int]] | None = None, + conversation_id: str | None = None, + repair_attempts: int = 0, ) -> tuple[dict[str, Any], dict[str, Any] | None]: - state = planning_state if planning_state is not None else {"phase": "WAITING_FOR_PLAN", "design_brief": ""} - phase = str(state.get("phase") or "WAITING_FOR_PLAN") + state = planning_state if planning_state is not None else {"phase": "INTAKE", "design_brief": "", "current_model_read": False} + phase = str(state.get("phase") or "INTAKE") if name == "analyze_image_reference": if not image_attachment_ids: raise ValueError("analyze_image_reference requires at least one image attachment") - return { + analysis = { "ok": True, "attachment_ids": list(dict.fromkeys(image_attachment_ids)), **normalize_image_analysis(arguments), - }, None + } + if conversation_id: + analysis["artifact_path"] = self.store.write_conversation_planning(conversation_id, "reference-observation", {"schema_version": "1.0", **analysis}) + state["phase"] = "REFERENCE_ANALYZED" + return analysis, None if name == "describe_design_intent": plan = str(arguments.get("plan") or "").strip() assumptions = arguments.get("assumptions") if not plan or not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions): raise ValueError("describe_design_intent requires a non-empty plan and an array of string assumptions") state["design_brief"] = plan - state["phase"] = "PLAN_RECORDED" + state["phase"] = "PLANNED" return { "ok": True, "plan": plan, @@ -1353,60 +1459,108 @@ class AgentService: "message": "The design brief is recorded as reference only. CDSL remains the sole authoritative CAD model.", }, None if name == "search_cdsl_library": - if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: + if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent 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" + query = str(arguments.get("query") or request) + normalized_query = " ".join(query.casefold().split()) + seen_queries = state.setdefault("library_queries", []) + if len(seen_queries) >= 2: + return {"ok": False, "code": "LIBRARY_SEARCH_LIMIT_REACHED", "message": "The CDSL library search limit for this request has been reached; continue with the available references."}, None + if normalized_query in seen_queries: + return {"ok": False, "code": "LIBRARY_QUERY_DUPLICATE", "message": "Do not repeat an identical unavailable library query; use the existing results or compile with the available capability."}, None + seen_queries.append(normalized_query) + results = self.library.search(query, min(8, int(arguments.get("limit") or 5))) + state["phase"] = "LIBRARY_SEARCHING" return {"ok": True, "results": results}, None if name == "read_cdsl_reference": - if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: + if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before reading CDSL references."}, None part_id = str(arguments.get("part_id") or "") - sample = self.library.read_sample(part_id) + if part_id in references: + return {"ok": False, "code": "LIBRARY_REFERENCE_DUPLICATE", "message": "This CDSL reference is already loaded; choose another reference or continue to compilation."}, None + if len(state.get("reference_records") or []) >= 2: + return {"ok": False, "code": "LIBRARY_REFERENCE_LIMIT_REACHED", "message": "At most two complete CDSL library references may be read for one request."}, None + try: + sample = self.library.read_sample(part_id) + except ValueError as error: + return {"ok": False, "code": "LIBRARY_REFERENCE_NOT_FOUND", "message": str(error)}, None if part_id not in references: references.append(part_id) - state["phase"] = "WAITING_FOR_CDSL" + state.setdefault("reference_records", []).append({"part_id": part_id, "source": "cdsl_library", "summary": "official CDSL reference"}) + state["phase"] = "LIBRARY_REFERENCE_READY" 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) + revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "") 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 + repairable = self.store.latest_repairable_cdsl(task_id) + if repairable is None: + return {"ok": False, "message": "The current task has no successful or repairable CDSL revision."}, None + revision_id, path = repairable + payload: dict[str, Any] = { + "ok": True, + "task_id": task_id, + "revision_id": revision_id, + "cdsl": json.loads(path.read_text(encoding="utf-8")), + } + state["current_model_read"] = True + state["read_revision_id"] = revision_id + state["phase"] = "PLANNED" + return payload, None if name in GENERATION_TOOL_NAMES: - if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: + if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before generating CAD."}, None - template_plan: dict[str, float | str] | None = None - operation: dict[str, Any] | None = None - if name == "generate_flange_sleeve_model": - cdsl, template_plan = build_flange_sleeve_cdsl(arguments.get("plan")) - operation = { - "type": "parameterized_template", - "template_id": FLANGE_SLEEVE_TEMPLATE_ID, - "plan": template_plan, - } - else: + selection = part_skill_selection or self.part_skill_library.select(request) + if selection.get("conflict"): + state["phase"] = "BLOCKED" + return { + "ok": False, + "code": "PART_SKILL_CONFLICT", + "message": str(selection["conflict"].get("message") or "Resolve the primary part-family conflict before generating CAD."), + }, None + state["phase"] = "CDSL_AUTHORING" + if name == "generate_cdsl_model": + if task_id and not state.get("current_model_read"): + return {"ok": False, "code": "CURRENT_CDSL_REQUIRED", "message": "Call read_current_cdsl before replacing an existing task revision."}, None 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") + parent_revision_id = str(state.get("read_revision_id") or "") if task_id else "" + if task_id and not parent_revision_id: + parent_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "") + operation: dict[str, Any] = {"type": "cdsl_replacement" if parent_revision_id else "cdsl_create"} + else: + if not task_id: + return {"ok": False, "code": "PATCH_TASK_REQUIRED", "message": "patch_cdsl_model requires an existing task."}, None + base_revision_id = str(arguments.get("base_revision_id") or "") + base_path = self.store.revision_cdsl_path(task_id, base_revision_id) + if base_path is None: + raise ValueError("PATCH_BASE_REVISION_NOT_FOUND: base_revision_id does not identify a readable CDSL revision") + try: + cdsl = apply_cdsl_patch(json.loads(base_path.read_text(encoding="utf-8")), arguments.get("patches")) + except (CdslPatchError, json.JSONDecodeError) as error: + raise ValueError(f"INVALID_CDSL_PATCH: {error}") from error + parent_revision_id = base_revision_id + operation = {"type": "cdsl_patch", "base_revision_id": base_revision_id, "patches": deepcopy(arguments.get("patches") or [])} cdsl, normalization_repairs = normalize_cdsl_for_engine(cdsl) # 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")} engine = load_engine(self.settings) + state["phase"] = "PREFLIGHT" validate_cdsl(preflight_cdsl, engine) summary = str(arguments.get("summary") or "Parameterized CAD model") 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(f"{name} 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) + verification = arguments.get("verification") + validate_verification(verification, cdsl) part_skill_audit = self.part_skill_library.audit(selection, cdsl, assumptions) - current_task = self.store.read_task(task_id) if task_id else None - parent_revision_id = str((current_task or {}).get("current_revision") or "") state["phase"] = "BUILDING" try: yieldable = await asyncio.to_thread( @@ -1422,10 +1576,13 @@ class AgentService: operation=operation, part_skills=part_skill_audit, generation_assumptions=assumptions, + repair_attempts=repair_attempts, input_attachments=input_attachments, + verification=verification, + reference_records=state.get("reference_records"), ) except Exception: - state["phase"] = "PLAN_RECORDED" + state["phase"] = "CDSL_REPAIR" raise state["phase"] = "COMPLETED" return { @@ -1434,8 +1591,7 @@ class AgentService: "task_id": yieldable["task_id"], "revision_id": yieldable["revision_id"], "normalization_repairs": normalization_repairs, - "template_id": FLANGE_SLEEVE_TEMPLATE_ID if template_plan is not None else "", - "template_plan": template_plan, + "operation": operation, }, yieldable raise ValueError(f"Unknown agent tool: {name}") @@ -1450,8 +1606,8 @@ class AgentService: "read_cdsl_reference": "读取 CDSL 参考模型", "read_current_cdsl": "读取当前 CDSL", "describe_design_intent": "整理设计说明", - "generate_flange_sleeve_model": "生成参数化法兰套筒", "generate_cdsl_model": "生成 CDSL", + "patch_cdsl_model": "修复 CDSL", }.get(name, "调用 CAD 工具") def _attachment_message(self, conversation: dict[str, Any], model: ProviderModel) -> list[dict[str, Any]] | str: diff --git a/backend/app/services/attachments.py b/backend/app/services/attachments.py index 2a8372d9..65ced3ff 100644 --- a/backend/app/services/attachments.py +++ b/backend/app/services/attachments.py @@ -4,8 +4,8 @@ import hashlib from pathlib import Path -IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"} -DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json"} +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".svg"} +DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json", ".pdf", ".dxf", ".step", ".stp"} MAX_IMAGE_BYTES = 10 * 1024 * 1024 MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 MAX_EXTRACTED_CHARS = 30_000 @@ -13,8 +13,6 @@ 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") @@ -23,14 +21,24 @@ def classify_upload(filename: str, mime: str, size: int) -> str: 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") + raise ValueError("Only PNG, JPG, WEBP, SVG, TXT, MD, CSV, JSON, PDF, DXF, and STEP/STP uploads are supported") def extract_document_text(data: bytes) -> str: + # PDF extraction is optional so the API can still accept a reference when + # the local PDF dependency is unavailable; the binary remains available as + # the primary attachment artifact. + if data.startswith(b"%PDF"): + try: + from pypdf import PdfReader + import io + return "\n".join(page.extract_text() or "" for page in PdfReader(io.BytesIO(data)).pages)[:MAX_EXTRACTED_CHARS] + except Exception: + return "[PDF reference uploaded; text extraction unavailable]" try: text = data.decode("utf-8") except UnicodeDecodeError as error: - raise ValueError("Documents must be UTF-8 text") from error + return "[Binary CAD reference uploaded; structured geometry inspection is deferred to the CAD reference analyzer]" return text[:MAX_EXTRACTED_CHARS] diff --git a/backend/app/services/cdsl_patch.py b/backend/app/services/cdsl_patch.py new file mode 100644 index 00000000..3290ec2d --- /dev/null +++ b/backend/app/services/cdsl_patch.py @@ -0,0 +1,122 @@ +"""Small, dependency-free RFC 6902 JSON Patch implementation for CDSL revisions.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + + +class CdslPatchError(ValueError): + """A JSON Patch operation cannot be applied to the requested CDSL revision.""" + + +def _tokens(path: Any) -> list[str]: + if not isinstance(path, str) or not path.startswith("/"): + raise CdslPatchError("JSON Patch paths must be JSON Pointers beginning with '/'") + return [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")] + + +def _index(token: str, length: int, *, allow_append: bool = False) -> int: + if allow_append and token == "-": + return length + if not token.isdigit() or (len(token) > 1 and token.startswith("0")): + raise CdslPatchError(f"Invalid JSON Patch array index: {token}") + value = int(token) + if value < 0 or value >= length: + raise CdslPatchError(f"JSON Patch array index is out of range: {token}") + return value + + +def _parent(document: Any, path: str) -> tuple[Any, str]: + if path == "": + raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead") + tokens = _tokens(path) + current = document + for token in tokens[:-1]: + if isinstance(current, dict): + if token not in current: + raise CdslPatchError(f"JSON Patch path does not exist: {path}") + current = current[token] + elif isinstance(current, list): + current = current[_index(token, len(current))] + else: + raise CdslPatchError(f"JSON Patch path does not resolve to a container: {path}") + return current, tokens[-1] + + +def _get(document: Any, path: str) -> Any: + current = document + for token in _tokens(path): + if isinstance(current, dict): + if token not in current: + raise CdslPatchError(f"JSON Patch path does not exist: {path}") + current = current[token] + elif isinstance(current, list): + current = current[_index(token, len(current))] + else: + raise CdslPatchError(f"JSON Patch path does not resolve: {path}") + return current + + +def _add(document: Any, path: str, value: Any) -> None: + parent, token = _parent(document, path) + if isinstance(parent, dict): + parent[token] = deepcopy(value) + elif isinstance(parent, list): + index = _index(token, len(parent), allow_append=True) + parent.insert(index, deepcopy(value)) + else: + raise CdslPatchError(f"JSON Patch add target is not a container: {path}") + + +def _remove(document: Any, path: str) -> Any: + parent, token = _parent(document, path) + if isinstance(parent, dict): + if token not in parent: + raise CdslPatchError(f"JSON Patch path does not exist: {path}") + return parent.pop(token) + if isinstance(parent, list): + return parent.pop(_index(token, len(parent))) + raise CdslPatchError(f"JSON Patch remove target is not a container: {path}") + + +def apply_cdsl_patch(cdsl: dict[str, Any], patches: Any) -> dict[str, Any]: + if not isinstance(cdsl, dict): + raise CdslPatchError("The base CDSL must be an object") + if not isinstance(patches, list) or not patches: + raise CdslPatchError("patches must be a non-empty JSON Patch array") + if len(patches) > 32: + raise CdslPatchError("At most 32 JSON Patch operations are allowed") + result: Any = deepcopy(cdsl) + for index, operation in enumerate(patches): + if not isinstance(operation, dict): + raise CdslPatchError(f"Patch operation {index} must be an object") + kind = str(operation.get("op") or "") + path = operation.get("path") + if kind not in {"add", "remove", "replace", "move", "copy", "test"}: + raise CdslPatchError(f"Unsupported JSON Patch operation: {kind or ''}") + if kind in {"add", "replace", "test"} and "value" not in operation: + raise CdslPatchError(f"JSON Patch {kind} requires value") + if kind in {"move", "copy"} and "from" not in operation: + raise CdslPatchError(f"JSON Patch {kind} requires from") + if path == "": + raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead") + if kind == "add": + _add(result, path, operation["value"]) + elif kind == "remove": + _remove(result, path) + elif kind == "replace": + _get(result, path) + _remove(result, path) + _add(result, path, operation["value"]) + elif kind == "move": + source = str(operation["from"]) + if path == source or str(path).startswith(source.rstrip("/") + "/"): + raise CdslPatchError("JSON Patch move cannot move a value into itself") + moved = _remove(result, source) + _add(result, path, moved) + elif kind == "copy": + _add(result, path, _get(result, str(operation["from"]))) + elif kind == "test" and _get(result, path) != operation["value"]: + raise CdslPatchError(f"JSON Patch test failed at {path}") + return result diff --git a/backend/app/services/editing.py b/backend/app/services/editing.py index 779f7e35..81f710e2 100644 --- a/backend/app/services/editing.py +++ b/backend/app/services/editing.py @@ -5,7 +5,7 @@ import json import math from typing import Any -from app.services.engine_service import build_revision +from app.services.engine_service import build_revision, load_engine from app.services.storage import WorkspaceStore from app.settings import Settings @@ -59,22 +59,35 @@ def _profile_for(operation: str, values: dict[str, Any]) -> tuple[dict[str, Any] 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 + length = max(length, width) + radius = width / 2 + left, right = -length / 2 + radius, length / 2 - radius + return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [ + {"type": "line", "start": [right, radius], "end": [left, radius]}, + {"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0.0], "radius_mm": radius}, + {"type": "line", "start": [left, -radius], "end": [right, -radius]}, + {"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0.0], "radius_mm": radius}, + ]}]}, 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 + return {"type": "polygon", "vertices": [ + [-width / 2, -height / 2], [width / 2, -height / 2], + [width / 2, height / 2], [-width / 2, height / 2], + ]}, 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 + pitch_x = _number(values, "pitchX", 12.0) + pitch_y = _number(values, "pitchY", 12.0) + return {"type": "analytic_contours", "contours": [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [ + (column - (columns - 1) / 2) * pitch_x, + (row - (rows - 1) / 2) * pitch_y, + ], "radius_mm": diameter / 2}]} + for row in range(rows) for column in range(columns) + ]}, depth raise ValueError(f"Unsupported direct CDSL edit: {operation}") @@ -149,6 +162,15 @@ def apply_direct_edit( raise ValueError("Task has no successful CDSL revision") frame = _selection_frame(selection) cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8"))) + # Historical revisions can contain importer-only profile macros. An edit + # creates a new CDSL-only revision, so lower those profiles explicitly + # before adding the new generic feature. + load_engine(settings) + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + + lowered_cdsl = lower_legacy_profiles(cdsl) + legacy_profiles_lowered = lowered_cdsl != cdsl + cdsl = lowered_cdsl features = cdsl.setdefault("features", []) sketches = cdsl.setdefault("geometry", {}).setdefault("sketches", []) picks = selection.get("picks") if isinstance(selection.get("picks"), list) else [] @@ -183,7 +205,12 @@ def apply_direct_edit( reference_ids=[], summary=f"Applied {operation}", parent_revision_id=revision_id, - operation={"type": operation, "selection": selection, "parameters": parameters}, + operation={ + "type": operation, + "selection": selection, + "parameters": parameters, + "legacy_profiles_lowered": legacy_profiles_lowered, + }, part_skills=None, - generation_assumptions=[], + generation_assumptions=["Legacy profile macros were lowered to direct analytic contours before this edit."] if legacy_profiles_lowered else [], ) diff --git a/backend/app/services/engine_service.py b/backend/app/services/engine_service.py index eb76d849..27d8a310 100644 --- a/backend/app/services/engine_service.py +++ b/backend/app/services/engine_service.py @@ -4,6 +4,7 @@ import copy import json import math import re +import shutil import sys from pathlib import Path from typing import Any @@ -12,10 +13,21 @@ from jsonschema import Draft202012Validator from jsonschema.exceptions import SchemaError from vendor.cdsl_preview_runtime import step_to_glb +from app.services.quality import evaluate_quality, validate_verification from app.services.storage import WorkspaceStore, now_iso, write_json from app.settings import Settings +class QualityVerificationError(RuntimeError): + """A built CDSL document missed a blocking generic verification rule.""" + + def __init__(self, quality_report: dict[str, Any]) -> None: + super().__init__("Blocking CDSL verification checks failed") + self.quality_report = quality_report + self.task_id = "" + self.revision_id = "" + + def load_engine(settings: Settings) -> Any: parent = str(settings.engine_root.parent) if parent not in sys.path: @@ -165,10 +177,12 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: sketch_ids = {str(sketch.get("id")) for sketch in sketches} semantic_contract = _engine_schema(engine) atomic_contracts = semantic_contract["feature_atomic_ids"] - supported_atomic_ids = sorted( + declared_atomic_ids = { str(atomic_id) for atomic_id in semantic_contract.get("runtime_supported_atomic_ids", atomic_contracts) - ) + } + registered_atomic_ids = {str(atomic_id) for atomic_id in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())} + supported_atomic_ids = sorted(declared_atomic_ids & registered_atomic_ids) feature_ids: set[str] = set() for feature in features: fid = str(feature.get("id") or "") @@ -400,7 +414,6 @@ def _part_skill_audit( 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), @@ -409,10 +422,6 @@ def _part_skill_audit( "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 @@ -420,20 +429,9 @@ def _part_skill_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": [ @@ -445,9 +443,6 @@ def _generation_context( 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 []), } @@ -465,10 +460,9 @@ def build_revision( input_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 = "", + repair_attempts: int = 0, + verification: dict[str, Any] | None = None, + reference_records: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: engine = load_engine(settings) task = store.ensure_task(task_id, request) @@ -483,30 +477,19 @@ def build_revision( 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) + quality_path = revision_dir / "quality-report.json" + snapshot_manifest_path = revision_dir / "snapshot-manifest.json" + part_skill_audit = _part_skill_audit(part_skills, request, generation_assumptions) + generation_context = _generation_context(reference_ids, part_skill_audit) write_json(request_path, {"request": request, "created_at": now_iso()}) - write_json(references_path, {"reference_ids": reference_ids}) + write_json(references_path, {"reference_ids": reference_ids, "records": [item for item in reference_records or [] if isinstance(item, dict)]}) write_json(part_skills_path, part_skill_audit) - + write_json(snapshot_manifest_path, { + "schema_version": "1.0", + "status": "unavailable", + "reason": "Snapshot runner is not attached to this backend build", + "snapshots": [], + }) def revision_record(status: str, *, error: str = "", engine_name: str = "") -> dict[str, Any]: record = { "revision_id": revision_id, @@ -517,8 +500,10 @@ def build_revision( "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, + "quality_path": quality_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "snapshot_manifest_path": snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "snapshot_status": "unavailable", + "snapshot_paths": [snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix()], "part_skill_ids": list(part_skill_audit.get("skill_ids") or []), "generation_assumptions": list(part_skill_audit.get("assumptions") or []), "reference_ids": reference_ids, @@ -526,6 +511,7 @@ def build_revision( "parent_revision_id": parent_revision_id or "", "operation": operation or {}, "input_attachments": input_attachments or [], + "repair_attempts": max(0, int(repair_attempts)), } if status == "success": record.update({ @@ -539,6 +525,8 @@ def build_revision( record["error"] = error return record + quality_report: dict[str, Any] | None = None + quality_status = "" try: cdsl_copy = copy.deepcopy(cdsl) if not isinstance(cdsl_copy, dict): @@ -562,6 +550,17 @@ def build_revision( selector, edges = topology_sidecars(engine_result, preview) write_json(selector_path, selector) write_json(edges_path, edges) + rules = validate_verification(verification, cdsl_copy) + quality_report = evaluate_quality(rules, cdsl_copy, engine_result) + quality_report["evaluated_at"] = now_iso() + write_json(quality_path, quality_report) + quality_status = ( + "accepted" if rules and quality_report["status"] == "passed" + else "built_with_warnings" if quality_report["status"] == "passed" + else "needs_repair" + ) + if quality_report["status"] != "passed": + raise QualityVerificationError(quality_report) report = { "engine_result": engine_result, "preview": preview, @@ -570,16 +569,40 @@ def build_revision( } write_json(report_path, report) revision = revision_record("success", engine_name=str(engine_result["engine"])) + revision["quality_status"] = quality_status or "accepted" + revision["verification_summary"] = { + "requested": bool(rules), + "blocking_failures": len(quality_report.get("blocking_failures") or []), + "warnings": len(quality_report.get("warnings") or []), + } except Exception as error: + if not isinstance(error, QualityVerificationError): + # The failed candidate is retained in the conversation diagnostics, + # not as a task revision. Runtime/schema failures must not create + # an editable revision that looks like a model version. + shutil.rmtree(revision_dir, ignore_errors=True) + raise if not cdsl_path.is_file() and isinstance(cdsl, dict): write_json(cdsl_path, copy.deepcopy(cdsl)) + if quality_report is not None and not quality_path.is_file(): + write_json(quality_path, quality_report) write_json(report_path, { "error": str(error), "generation_context": generation_context, + "quality": quality_report, "validated_at": now_iso(), }) - revision = revision_record("failed", error=str(error)) + revision = revision_record("needs_repair" if quality_report is not None else "failed", error=str(error)) + revision["quality_status"] = quality_status or ("needs_repair" if quality_report else "failed") + if quality_report is not None: + revision["verification_summary"] = { + "requested": bool(quality_report.get("verification_requested")), + "blocking_failures": len(quality_report.get("blocking_failures") or []), + "warnings": len(quality_report.get("warnings") or []), + } store.update_task(task["task_id"], revision) + error.task_id = task["task_id"] + error.revision_id = revision_id raise store.update_task(task["task_id"], revision) return {"task_id": task["task_id"], **revision} diff --git a/backend/app/services/flange_sleeve_template.py b/backend/app/services/flange_sleeve_template.py deleted file mode 100644 index f7e1f705..00000000 --- a/backend/app/services/flange_sleeve_template.py +++ /dev/null @@ -1,273 +0,0 @@ -from __future__ import annotations - -import copy -import math -import re -from typing import Any - - -TEMPLATE_ID = "flange_sleeve_v1" - -# These defaults describe the pictured family, but the model may override every -# dimension that affects the visible form. The backend owns all CDSL plumbing. -DEFAULT_PLAN: dict[str, float | str] = { - "name": "Parameterized flange sleeve", - "flange_width_mm": 120.0, - "flange_height_mm": 120.0, - "flange_thickness_mm": 14.0, - "corner_chamfer_mm": 10.0, - "tube_outer_diameter_mm": 70.0, - "tube_straight_length_mm": 95.0, - "tip_outer_diameter_mm": 62.0, - "tip_length_mm": 28.0, - "bore_diameter_mm": 46.0, - "boss_outer_diameter_mm": 82.0, - "boss_height_mm": 5.0, - "mount_hole_diameter_mm": 12.0, - "mount_counterbore_diameter_mm": 24.0, - "mount_counterbore_depth_mm": 5.0, - "mount_hole_u_mm": 42.0, - "mount_hole_v_mm": 42.0, -} - -_DIMENSION_FIELDS = tuple(key for key in DEFAULT_PLAN if key != "name") -_PART_ID = re.compile(r"^[A-Za-z0-9_-]{3,80}$") - - -def flange_sleeve_plan_schema() -> dict[str, Any]: - """Return the compact semantic plan accepted by the flange-sleeve tool.""" - properties: dict[str, Any] = { - "template": {"const": TEMPLATE_ID}, - "name": {"type": "string", "minLength": 1, "maxLength": 120}, - "part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"}, - } - for field in _DIMENSION_FIELDS: - minimum = 0 if field == "corner_chamfer_mm" else 0.001 - properties[field] = {"type": "number", "exclusiveMinimum": minimum} if minimum else { - "type": "number", "minimum": 0, - } - return { - "type": "object", - "properties": properties, - "required": ["template"], - "additionalProperties": False, - } - - -def normalize_flange_sleeve_plan(plan: Any) -> dict[str, float | str]: - """Validate only semantic dimensions; do not accept CDSL implementation data.""" - if not isinstance(plan, dict): - raise ValueError("flange sleeve plan must be a JSON object") - if plan.get("template") != TEMPLATE_ID: - raise ValueError(f"flange sleeve plan template must be {TEMPLATE_ID}") - allowed = {"template", "part_id", *DEFAULT_PLAN} - unknown = sorted(str(key) for key in plan if key not in allowed) - if unknown: - raise ValueError(f"flange sleeve plan has unsupported fields: {', '.join(unknown)}") - - normalized = copy.deepcopy(DEFAULT_PLAN) - for field in _DIMENSION_FIELDS: - if field not in plan: - continue - value = plan[field] - if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)): - raise ValueError(f"flange sleeve plan {field} must be a finite number") - number = float(value) - if number < 0 if field == "corner_chamfer_mm" else number <= 0: - comparator = "non-negative" if field == "corner_chamfer_mm" else "greater than zero" - raise ValueError(f"flange sleeve plan {field} must be {comparator}") - normalized[field] = number - - name = str(plan.get("name") or normalized["name"]).strip() - if not name: - raise ValueError("flange sleeve plan name must not be empty") - normalized["name"] = name[:120] - part_id = str(plan.get("part_id") or "flange_sleeve_template") - if not _PART_ID.fullmatch(part_id): - raise ValueError("flange sleeve plan part_id must use letters, numbers, underscores, or hyphens") - normalized["part_id"] = part_id - _validate_dimensions(normalized) - return normalized - - -def _validate_dimensions(plan: dict[str, float | str]) -> None: - number = lambda field: float(plan[field]) - width, height = number("flange_width_mm"), number("flange_height_mm") - thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm") - tube_od, tip_od, bore = number("tube_outer_diameter_mm"), number("tip_outer_diameter_mm"), number("bore_diameter_mm") - boss_od = number("boss_outer_diameter_mm") - hole_od, counterbore_od = number("mount_hole_diameter_mm"), number("mount_counterbore_diameter_mm") - counterbore_depth = number("mount_counterbore_depth_mm") - hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm") - - if chamfer * 2 >= min(width, height): - raise ValueError("flange sleeve plan corner_chamfer_mm must be less than half the flange side") - if not bore < min(tube_od, tip_od): - raise ValueError("flange sleeve plan bore_diameter_mm must be smaller than both tube diameters") - if not tube_od <= boss_od <= min(width, height): - raise ValueError("flange sleeve plan boss_outer_diameter_mm must be between tube diameter and flange side") - if counterbore_od < hole_od: - raise ValueError("flange sleeve plan mount_counterbore_diameter_mm must not be smaller than mount_hole_diameter_mm") - if counterbore_depth > thickness: - raise ValueError("flange sleeve plan mount_counterbore_depth_mm must not exceed flange_thickness_mm") - - radius = counterbore_od / 2 - if abs(hole_u) + radius >= width / 2 or abs(hole_v) + radius >= height / 2: - raise ValueError("flange sleeve plan mounting counterbores must remain inside the flange boundary") - if chamfer and abs(hole_u) > width / 2 - chamfer and abs(hole_v) > height / 2 - chamfer: - edge_clearance = (width / 2 - abs(hole_u)) + (height / 2 - abs(hole_v)) - if edge_clearance < chamfer + radius * math.sqrt(2): - raise ValueError("flange sleeve plan mounting counterbores intersect the corner chamfers") - - -def _x_plane(offset_mm: float, normal_x: float = 1.0) -> dict[str, list[float]]: - return { - "origin_mm": [offset_mm, 0.0, 0.0], - "x_dir": [0.0, 1.0, 0.0], - "normal": [normal_x, 0.0, 0.0], - } - - -def _flange_profile(width: float, height: float, chamfer: float) -> dict[str, Any]: - if chamfer == 0: - return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height} - half_width, half_height = width / 2, height / 2 - return { - "type": "polygon", - "vertices": [ - [-half_width + chamfer, -half_height], - [half_width - chamfer, -half_height], - [half_width, -half_height + chamfer], - [half_width, half_height - chamfer], - [half_width - chamfer, half_height], - [-half_width + chamfer, half_height], - [-half_width, half_height - chamfer], - [-half_width, -half_height + chamfer], - ], - } - - -def build_flange_sleeve_cdsl(plan: Any) -> tuple[dict[str, Any], dict[str, float | str]]: - """Build a schema-valid CDSL flange sleeve from semantic, editable parameters.""" - values = normalize_flange_sleeve_plan(plan) - number = lambda field: float(values[field]) - width, height = number("flange_width_mm"), number("flange_height_mm") - thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm") - tube_radius, tip_radius, bore_radius = number("tube_outer_diameter_mm") / 2, number("tip_outer_diameter_mm") / 2, number("bore_diameter_mm") / 2 - straight, tip_length = number("tube_straight_length_mm"), number("tip_length_mm") - boss_radius, boss_height = number("boss_outer_diameter_mm") / 2, number("boss_height_mm") - hole_radius, counterbore_radius = number("mount_hole_diameter_mm") / 2, number("mount_counterbore_diameter_mm") / 2 - hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm") - hole_centers = [[u, v] for u in (-hole_u, hole_u) for v in (-hole_v, hole_v)] - - cdsl = { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.3.0", - "part_id": str(values["part_id"]), - "kind": "part", - "meta": {"name": str(values["name"]), "units": "mm"}, - "geometry": {"sketches": [ - { - "id": "flange_outline", - "workplane": _x_plane(-thickness), - "profile": _flange_profile(width, height, chamfer), - }, - { - "id": "sleeve_profile", - "workplane": { - "origin_mm": [0.0, 0.0, 0.0], - "x_dir": [1.0, 0.0, 0.0], - "normal": [0.0, 0.0, 1.0], - }, - "profile": { - "type": "polygon", - "vertices": [ - [0.0, bore_radius], - [0.0, tube_radius], - [straight, tube_radius], - [straight + tip_length, tip_radius], - [straight + tip_length, bore_radius], - ], - }, - }, - { - "id": "front_boss_ring", - "workplane": _x_plane(0.0), - "profile": { - "type": "annulus", - "inner_radius_mm": bore_radius, - "outer_radius_mm": boss_radius, - "center": [0.0, 0.0], - }, - }, - { - "id": "center_bore", - "workplane": _x_plane(-thickness), - "profile": {"type": "circle", "radius_mm": bore_radius, "center": [0.0, 0.0]}, - }, - { - "id": "mount_holes", - "workplane": _x_plane(-thickness), - "profile": { - "type": "circles", - "items": [{"radius_mm": hole_radius, "center": center} for center in hole_centers], - }, - }, - { - "id": "mount_counterbores", - "workplane": _x_plane(0.0, -1.0), - "profile": { - "type": "circles", - "items": [{"radius_mm": counterbore_radius, "center": center} for center in hole_centers], - }, - }, - ]}, - "features": [ - { - "id": "flange_plate", - "atomic_id": "extrude_add_blind", - "depends_on": [], - "sketch_id": "flange_outline", - "params": {"distance_mm": thickness}, - }, - { - "id": "sleeve_body", - "atomic_id": "revolve_add", - "depends_on": ["flange_plate"], - "sketch_id": "sleeve_profile", - "params": { - "angle_deg": 360.0, - "axis": {"origin_mm": [0.0, 0.0, 0.0], "direction": [1.0, 0.0, 0.0]}, - }, - }, - { - "id": "front_boss", - "atomic_id": "extrude_add_blind", - "depends_on": ["flange_plate", "sleeve_body"], - "sketch_id": "front_boss_ring", - "params": {"distance_mm": boss_height}, - }, - { - "id": "center_bore_cut", - "atomic_id": "extrude_cut_blind", - "depends_on": ["flange_plate", "sleeve_body", "front_boss"], - "sketch_id": "center_bore", - "params": {"distance_mm": thickness + straight + tip_length + boss_height + 1.0}, - }, - { - "id": "mount_hole_cuts", - "atomic_id": "extrude_cut_blind", - "depends_on": ["center_bore_cut"], - "sketch_id": "mount_holes", - "params": {"distance_mm": thickness + 1.0}, - }, - { - "id": "mount_counterbore_cuts", - "atomic_id": "extrude_cut_blind", - "depends_on": ["mount_hole_cuts"], - "sketch_id": "mount_counterbores", - "params": {"distance_mm": number("mount_counterbore_depth_mm")}, - }, - ], - } - return cdsl, values diff --git a/backend/app/services/library.py b/backend/app/services/library.py index e681eb72..fa50a53f 100644 --- a/backend/app/services/library.py +++ b/backend/app/services/library.py @@ -9,10 +9,28 @@ from app.settings import Settings TOKEN_PATTERN = re.compile(r"[a-zA-Z0-9_]+") +CN_TOKEN_PATTERN = re.compile(r"[\u4e00-\u9fff]") + +TERM_ALIASES = { + "法兰": {"flange", "mounting_plate"}, + "套筒": {"sleeve", "tube", "cylinder"}, + "孔": {"hole", "circle", "circles"}, + "沉头": {"countersunk", "counterbore"}, + "沉孔": {"counterbore", "counterbored"}, + "槽": {"slot", "obround"}, + "底板": {"plate", "rectangle", "mounting_plate"}, + "轴": {"shaft", "cylinder"}, +} def tokens(value: str) -> set[str]: - return {token.lower() for token in TOKEN_PATTERN.findall(value)} + text = str(value or "").lower() + result = {token.lower() for token in TOKEN_PATTERN.findall(text)} + result.update(CN_TOKEN_PATTERN.findall(text)) + for term, aliases in TERM_ALIASES.items(): + if term in text: + result.update(aliases) + return result class CdslLibrary: @@ -38,6 +56,8 @@ class CdslLibrary: "profiles": record.get("profiles", []), "features": record.get("features", []), "summary": record.get("summary", ""), + "family": record.get("family", ""), + "atomics": record.get("features", []), } for record in self._records()[:limit] ] @@ -46,6 +66,8 @@ class CdslLibrary: corpus = " ".join([ record.get("part_id", ""), record.get("source_name", ""), + record.get("summary", ""), + record.get("family", ""), " ".join(record.get("profiles", [])), " ".join(record.get("features", [])), " ".join(record.get("parameters", [])), @@ -60,6 +82,8 @@ class CdslLibrary: "profiles": record.get("profiles", []), "features": record.get("features", []), "summary": record.get("summary", ""), + "family": record.get("family", ""), + "atomics": record.get("features", []), } for _, record in scored[:limit] ] diff --git a/backend/app/services/part_skills.py b/backend/app/services/part_skills.py index 653cc1aa..30c13cd3 100644 --- a/backend/app/services/part_skills.py +++ b/backend/app/services/part_skills.py @@ -58,7 +58,6 @@ class PartSkill: bridge: str source: str related: tuple[str, ...] - capability_translation_rules: tuple[str, ...] @classmethod def from_mapping(cls, value: dict[str, Any]) -> "PartSkill": @@ -72,9 +71,6 @@ class PartSkill: 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 [] - ), ) @@ -84,6 +80,7 @@ class PartSkillLibrary: def __init__(self, root: Path) -> None: self.root = Path(root) payload = json.loads((self.root / "catalog.json").read_text(encoding="utf-8")) + self.catalog_version = str(payload.get("schema_version") or "1.0") 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 []) @@ -112,9 +109,10 @@ class PartSkillLibrary: "kind": skill.kind, "category": skill.kind, "title": skill.title, + "version": self.catalog_version, + "summary": skill.title, "source": skill.source, "bridge": skill.bridge, - "capability_translation_rules": list(skill.capability_translation_rules), "selection": selection, "matched_triggers": list(matched), } @@ -207,14 +205,7 @@ class PartSkillLibrary: 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 - # Legacy tasks can contain a DesignIntent created before their first - # runtime build. Retain this fallback only for historical artifacts. - 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] + return [] def render_context(self, selection: dict[str, Any]) -> str: records = selection.get("skills") or [] @@ -233,62 +224,9 @@ class PartSkillLibrary: 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) + # Skills are prompt knowledge only. Do not interpret a selected skill + # as geometry, capability translation, or an executable model rule. + del cdsl return { "schema_version": "1.0", "request": selection.get("request", ""), @@ -298,6 +236,4 @@ class PartSkillLibrary: "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, } diff --git a/backend/app/services/quality.py b/backend/app/services/quality.py new file mode 100644 index 00000000..72c6f90b --- /dev/null +++ b/backend/app/services/quality.py @@ -0,0 +1,262 @@ +"""Generic, model-family-independent verification for direct CDSL revisions.""" + +from __future__ import annotations + +import math +from typing import Any + + +QUALITY_RULE_TYPES = frozenset({ + "bbox", "solid_count", "feature_count", "hole_count", "hole_diameter", + "hole_center", "overall_length", "overall_diameter", "through_condition", +}) +_FEATURE_RULE_TYPES = {"hole_count", "hole_diameter", "hole_center", "through_condition"} +_SEVERITIES = {"blocking", "warning", "informational"} + + +def _bbox_bounds(engine_result: dict[str, Any], feature_id: str | None = None) -> tuple[list[float], list[float]] | None: + """Return a whole-model or feature-owned runtime bounding box.""" + candidates: list[Any] = [] + if feature_id: + for record in engine_result.get("topology_records") or []: + if not isinstance(record, dict): + continue + owners = record.get("owner_feature_ids") or [] + if record.get("feature_id") == feature_id or feature_id in owners: + geometry = record.get("geometry") + if isinstance(geometry, dict): + candidates.append(geometry.get("bbox_mm")) + else: + bbox = engine_result.get("bbox_mm") + if isinstance(bbox, dict): + candidates.append([ + *(bbox.get("min") or []), + *(bbox.get("max") or []), + ]) + + boxes = [ + value for value in candidates + if isinstance(value, list) and len(value) == 6 + and all(isinstance(item, (int, float)) and math.isfinite(float(item)) for item in value) + ] + if not boxes: + return None + minimum = [min(box[index] for box in boxes) for index in (0, 1, 2)] + maximum = [max(box[index] for box in boxes) for index in (3, 4, 5)] + return [float(item) for item in minimum], [float(item) for item in maximum] + + +def _bbox_value(engine_result: dict[str, Any], feature_id: str | None = None) -> dict[str, Any] | None: + bounds = _bbox_bounds(engine_result, feature_id) + if bounds is None: + return None + minimum, maximum = bounds + return { + "min": minimum, + "max": maximum, + "dimensions": [maximum[index] - minimum[index] for index in range(3)], + } + + +def _dimensions(engine_result: dict[str, Any]) -> list[float] | None: + value = _bbox_value(engine_result) + return value["dimensions"] if value else None + + +def _feature(cdsl: dict[str, Any], feature_id: str | None) -> dict[str, Any] | None: + return next((item for item in cdsl.get("features") or [] if isinstance(item, dict) and str(item.get("id")) == feature_id), None) + + +def _sketch(cdsl: dict[str, Any], sketch_id: str | None) -> dict[str, Any] | None: + return next((item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict) and str(item.get("id")) == sketch_id), None) + + +def _circles(profile: dict[str, Any]) -> list[dict[str, Any]]: + if profile.get("type") == "circle": + center, radius = profile.get("center"), profile.get("radius_mm") + if isinstance(center, list) and len(center) >= 2 and isinstance(radius, (int, float)): + return [{"center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}] + if profile.get("type") != "analytic_contours": + return [] + return [ + {"center": [float(segment["center"][0]), float(segment["center"][1])], "radius_mm": float(segment["radius_mm"])} + for contour in profile.get("contours") or [] if isinstance(contour, dict) + for segment in contour.get("segments") or [] if isinstance(segment, dict) + and segment.get("type") == "circle" + and isinstance(segment.get("center"), list) and len(segment["center"]) >= 2 + and isinstance(segment.get("radius_mm"), (int, float)) + ] + + +def _finite_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value)) + + +def _validate_expected(kind: str, expected: Any, index: int) -> None: + scalar_types = {"solid_count", "feature_count", "hole_count", "hole_diameter", "overall_length", "overall_diameter"} + if kind in scalar_types and not _finite_number(expected): + raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}") + if kind == "bbox": + valid_dimensions = isinstance(expected, list) and len(expected) == 3 and all(_finite_number(value) for value in expected) + valid_ranges = isinstance(expected, dict) and set(expected) in ({"min", "max"}, {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"}) + if valid_ranges and set(expected) == {"min", "max"}: + valid_ranges = all( + isinstance(expected[key], list) + and len(expected[key]) == 3 + and all(_finite_number(value) for value in expected[key]) + for key in ("min", "max") + ) + elif valid_ranges: + valid_ranges = all(_finite_number(expected[key]) for key in expected) + if not valid_dimensions and not valid_ranges: + raise ValueError( + f"verification.rules[{index}].expected for bbox must be a three-number bbox [dx, dy, dz], " + "{min, max}, or {x_min, x_max, y_min, y_max, z_min, z_max}" + ) + if kind == "hole_center" and ( + not isinstance(expected, list) or len(expected) != 2 or not all(_finite_number(value) for value in expected) + ): + raise ValueError(f"verification.rules[{index}].expected must be a two-number hole center") + if kind == "through_condition" and not isinstance(expected, bool): + raise ValueError(f"verification.rules[{index}].expected must be boolean for through_condition") + + +def validate_verification(verification: Any, cdsl: dict[str, Any]) -> list[dict[str, Any]]: + if verification is None: + return [] + if not isinstance(verification, dict) or set(verification) - {"rules"}: + raise ValueError("verification must be an object containing only rules") + rules = verification.get("rules", []) + if not isinstance(rules, list) or len(rules) > 32: + raise ValueError("verification.rules must be an array with at most 32 rules") + feature_ids = {str(item.get("id")) for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id")} + normalized: list[dict[str, Any]] = [] + ids: set[str] = set() + for index, raw in enumerate(rules): + if not isinstance(raw, dict): + raise ValueError(f"verification.rules[{index}] must be an object") + allowed = {"id", "type", "feature", "expected", "tolerance", "severity"} + if set(raw) - allowed: + raise ValueError(f"verification.rules[{index}] has unsupported fields") + rule_id = str(raw.get("id") or "").strip() + kind = str(raw.get("type") or "").strip() + if not rule_id or rule_id in ids: + raise ValueError(f"verification.rules[{index}].id must be unique and non-empty") + if kind not in QUALITY_RULE_TYPES: + raise ValueError(f"verification.rules[{index}].type is unsupported: {kind}") + if "expected" not in raw: + raise ValueError(f"verification.rules[{index}].expected is required") + _validate_expected(kind, raw["expected"], index) + severity = str(raw.get("severity") or "blocking") + if severity not in _SEVERITIES: + raise ValueError(f"verification.rules[{index}].severity is unsupported") + try: + tolerance = float(raw.get("tolerance") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError(f"verification.rules[{index}].tolerance must be numeric") from error + if not math.isfinite(tolerance) or tolerance < 0: + raise ValueError(f"verification.rules[{index}].tolerance must be finite and non-negative") + feature = str(raw.get("feature") or "").strip() + if kind in _FEATURE_RULE_TYPES and not feature: + raise ValueError(f"verification.rules[{index}].feature is required for {kind}") + if feature and feature not in feature_ids: + raise ValueError(f"verification.rules[{index}].feature must reference a CDSL feature ID") + ids.add(rule_id) + normalized.append({"id": rule_id, "type": kind, "feature": feature, "expected": raw["expected"], "tolerance": tolerance, "severity": severity}) + return normalized + + +def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> tuple[Any, str]: + kind, target = rule["type"], rule.get("feature") or None + feature = _feature(cdsl, target) + params = (feature or {}).get("params") if isinstance((feature or {}).get("params"), dict) else {} + sketch = _sketch(cdsl, str((feature or {}).get("sketch_id") or "")) + profile = (sketch or {}).get("profile") if isinstance(sketch, dict) else {} + circles = _circles(profile) if isinstance(profile, dict) else [] + dimensions = _dimensions(engine_result) + if kind == "bbox": + value = _bbox_value(engine_result, target) + source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm" + return value, source + if kind == "solid_count": + return float(engine_result.get("solid_count", 1)), "runtime.solid_count" + if kind == "feature_count": + return float(len(cdsl.get("features") or [])), "cdsl.features" + if kind == "hole_count": + return float(len(circles)), f"cdsl.features.{target}.sketch" + if kind == "hole_diameter": + value = params.get("diameter_mm", params.get("hole_diameter_mm")) + if isinstance(value, (int, float)): + return float(value), f"cdsl.features.{target}.params" + return (circles[0]["radius_mm"] * 2 if circles else None), f"cdsl.features.{target}.sketch" + if kind == "hole_center": + centers = [circle["center"] for circle in circles] + return (centers[0] if len(centers) == 1 else centers), f"cdsl.features.{target}.sketch" + if kind == "overall_length": + return (max(dimensions) if dimensions else None), "runtime.bbox_mm" + if kind == "overall_diameter": + return (min(dimensions) if dimensions else None), "runtime.bbox_mm" + if kind == "through_condition": + end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {} + if end.get("type") in {"through_all", "through_all_both", "through_all_and_blind"}: + return True, f"cdsl.features.{target}.params.end_condition" + distance = params.get("distance_mm") + return bool(isinstance(distance, (int, float)) and dimensions and float(distance) >= min(dimensions) - 1e-6), "cdsl.params + runtime.bbox_mm" + return None, "unsupported verification type" + + +def _matches(expected: Any, actual: Any, tolerance: float) -> bool: + if actual is None: + return False + if isinstance(expected, list): + return isinstance(actual, list) and len(expected) == len(actual) and all( + _matches(expected_item, actual_item, tolerance) for expected_item, actual_item in zip(expected, actual) + ) + if isinstance(expected, bool): + return bool(actual) is expected + if isinstance(expected, (int, float)) and isinstance(actual, (int, float)): + return math.isclose(float(expected), float(actual), abs_tol=tolerance, rel_tol=0.0) + return expected == actual + + +def _matches_bbox(expected: Any, actual: dict[str, Any] | None, tolerance: float) -> bool: + if actual is None: + return False + if isinstance(expected, list): + return _matches(expected, actual["dimensions"], tolerance) + if not isinstance(expected, dict): + return False + if set(expected) == {"min", "max"}: + return _matches(expected["min"], actual["min"], tolerance) and _matches(expected["max"], actual["max"], tolerance) + if set(expected) == {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"}: + actual_ranges = { + "x_min": actual["min"][0], "x_max": actual["max"][0], + "y_min": actual["min"][1], "y_max": actual["max"][1], + "z_min": actual["min"][2], "z_max": actual["max"][2], + } + return all(_matches(expected[key], actual_ranges[key], tolerance) for key in actual_ranges) + return False + + +def evaluate_quality(rules: list[dict[str, Any]], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> dict[str, Any]: + results = [] + for rule in rules: + actual, source = _actual(rule, cdsl, engine_result) + passed = ( + _matches_bbox(rule["expected"], actual, rule["tolerance"]) + if rule["type"] == "bbox" + else _matches(rule["expected"], actual, rule["tolerance"]) + ) + results.append({**rule, "status": "passed" if passed else ("failed" if actual is not None else "unavailable"), "actual": actual, "source": source}) + blocking = [result for result in results if result["severity"] == "blocking" and result["status"] != "passed"] + warnings = [result for result in results if result["severity"] != "blocking" and result["status"] != "passed"] + return { + "schema": "cad.quality-report.v1", + "schema_version": "1.0", + "status": "passed" if not blocking else "failed", + "verification_requested": bool(rules), + "results": results, + "blocking_failures": blocking, + "warnings": warnings, + "measurements": {"bbox_mm": engine_result.get("bbox_mm"), "solid_count": engine_result.get("solid_count", 1)}, + } diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index bcdac2a4..fa1459f7 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -12,7 +12,6 @@ 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: @@ -37,13 +36,6 @@ 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: @@ -104,6 +96,15 @@ class WorkspaceStore: write_json(path, payload) return (Path(conversation) / relative).as_posix() + def write_conversation_planning(self, conversation_id: str, prefix: str, payload: dict[str, Any]) -> str: + """Persist structured intake/planning evidence before a task exists.""" + conversation = safe_conversation_id(conversation_id) + safe_prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", prefix).strip("-") or "planning" + relative = Path("planning") / f"{safe_prefix}-{secrets.token_hex(6)}.json" + path = self.conversation_dir(conversation) / relative + write_json(path, payload) + return (Path(conversation) / relative).as_posix() + def ensure_conversation( self, conversation_id: str | None, @@ -184,14 +185,12 @@ class WorkspaceStore: task_dir = self.task_dir(tid) (task_dir / "revisions").mkdir(parents=True, exist_ok=True) record = { - "schema_version": "1.1", + "schema_version": "1.2", "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) @@ -213,81 +212,6 @@ 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)) @@ -299,6 +223,32 @@ class WorkspaceStore: candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json" return candidate if candidate.is_file() else None + def latest_repairable_cdsl(self, task_id: str) -> tuple[str, Path] | None: + """Return the latest revision CDSL when a quality failure needs repair.""" + task = self.read_task(task_id) + for revision in reversed((task or {}).get("revisions") or []): + revision_id = str(revision.get("revision_id") or "") + if not revision_id or str(revision.get("quality_status") or "") != "needs_repair": + continue + path = self.revision_cdsl_path(task_id, revision_id) + if path is not None: + return revision_id, path + return None + + def revision_cdsl_path(self, task_id: str, revision_id: str) -> Path | None: + task = self.read_task(task_id) + revision = next( + (item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id), + None, + ) + if not isinstance(revision, dict): + return None + relative = str(revision.get("cdsl_path") or "") + if not relative: + return None + candidate = self.artifact_path(task_id, relative) + 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() diff --git a/backend/app/settings.py b/backend/app/settings.py index e9320a0a..ea015be5 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -50,6 +50,7 @@ class Settings: llm_timeout_s: float default_provider_id: str providers: tuple[ProviderConfig, ...] + max_repair_attempts: int = 4 @property def llm_configured(self) -> bool: @@ -137,6 +138,7 @@ def get_settings() -> Settings: llm_api_key=default_provider.api_key, llm_model=default_model, llm_timeout_s=float(os.getenv("CDSL_LLM_TIMEOUT_S", "90")), + max_repair_attempts=max(0, int(os.getenv("CDSL_MAX_REPAIR_ATTEMPTS", "4"))), default_provider_id=default_provider_id, providers=providers, ) diff --git a/backend/cdsl_importer/__init__.py b/backend/cdsl_importer/__init__.py new file mode 100644 index 00000000..c9ee396e --- /dev/null +++ b/backend/cdsl_importer/__init__.py @@ -0,0 +1,6 @@ +"""Compatibility adapters for imported and historical CDSL documents. + +This package is deliberately outside ``cdsl_engine``. It may lower retired +source syntax into the engine's generic CDSL contract, but is never imported +by the CDSL runtime. +""" diff --git a/backend/engine/cdsl_engine/distill_output3.py b/backend/cdsl_importer/distill_output3.py similarity index 100% rename from backend/engine/cdsl_engine/distill_output3.py rename to backend/cdsl_importer/distill_output3.py diff --git a/backend/cdsl_importer/legacy_profile_adapter.py b/backend/cdsl_importer/legacy_profile_adapter.py new file mode 100644 index 00000000..d90415e9 --- /dev/null +++ b/backend/cdsl_importer/legacy_profile_adapter.py @@ -0,0 +1,117 @@ +"""Explicit import/rebuild compatibility for retired CDSL profile macros. + +The CDSL runtime accepts only direct geometric profiles. This adapter is for +historical artifacts and importers: it expands a legacy shorthand exactly once +into an ``analytic_contours`` profile before generic runtime validation. +""" + +from __future__ import annotations + +import copy +from typing import Any + +from .legacy_profiles import LEGACY_PROFILE_GENERATORS + + +class LegacyProfileError(ValueError): + """A legacy profile could not be lowered to generic CDSL geometry.""" + + +def legacy_profile_types() -> tuple[str, ...]: + return tuple(sorted(LEGACY_PROFILE_GENERATORS)) + + +def _segment_from_edge(edge: dict[str, Any]) -> dict[str, Any]: + kind = str(edge.get("type") or "") + if kind == "line": + return { + "type": "line", + "start": list(edge["start_mm"][:2]), + "end": list(edge["end_mm"][:2]), + } + if kind == "arc": + segment = { + "type": "arc", + "start": list(edge["start_mm"][:2]), + "end": list(edge["end_mm"][:2]), + "center": list(edge["center_mm"][:2]), + "radius_mm": float(edge["radius_mm"]), + } + if "clockwise" in edge: + segment["clockwise"] = bool(edge["clockwise"]) + return segment + raise LegacyProfileError(f"Legacy profile emitted unsupported edge type: {kind}") + + +def _circle_contour(entity: dict[str, Any]) -> dict[str, Any]: + radius = entity.get("radius_mm") + if not isinstance(radius, (int, float)) or float(radius) <= 0: + raise LegacyProfileError("Legacy circle must have radius_mm > 0") + center = entity.get("center") or [0.0, 0.0] + if not isinstance(center, list) or len(center) < 2: + raise LegacyProfileError("Legacy circle must have a two-dimensional center") + return { + "role": "outer", + "closed": True, + "segments": [{"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}], + } + + +def _edge_contour(edges: list[dict[str, Any]], *, role: str) -> dict[str, Any]: + return { + "role": role, + "closed": True, + "segments": [_segment_from_edge(edge) for edge in edges], + } + + +def lower_legacy_profile(profile: dict[str, Any], *, sketch_id: str = "") -> dict[str, Any]: + """Expand one named legacy profile to a direct analytic contour profile.""" + kind = str(profile.get("type") or "") + generator = LEGACY_PROFILE_GENERATORS.get(kind) + if generator is None: + raise LegacyProfileError(f"Unsupported legacy profile: {kind!r}") + meta: dict[str, Any] = {"id": sketch_id, "_entities": None, "_contour": None} + try: + entities, contour = generator(copy.deepcopy(profile), meta) + except (KeyError, TypeError, ValueError) as error: + raise LegacyProfileError(f"Could not lower legacy profile {kind!r}: {error}") from error + + contours: list[dict[str, Any]] = [] + if contour: + contours.append(_edge_contour(contour, role="outer")) + for region in meta.get("_regions") or []: + if not isinstance(region, dict): + continue + outer = region.get("outer") or [] + if outer: + contours.append(_edge_contour(outer, role="outer")) + for hole in region.get("holes") or []: + if hole: + contours.append(_edge_contour(hole, role="inner")) + for entity in entities or []: + if entity.get("type") == "circle" and not entity.get("construction"): + contours.append(_circle_contour(entity)) + if not contours: + raise LegacyProfileError(f"Legacy profile {kind!r} did not produce a closed contour") + return {"type": "analytic_contours", "contours": contours} + + +def lower_legacy_profiles(cdsl: dict[str, Any]) -> dict[str, Any]: + """Return a copy with every self-contained legacy sketch lowered. + + This is opt-in. Calling the runtime directly with a legacy profile still + returns an unsupported-profile diagnostic. + """ + lowered = copy.deepcopy(cdsl) + sketches = ((lowered.get("geometry") or {}).get("sketches") or []) + for sketch in sketches: + if not isinstance(sketch, dict): + continue + profile = sketch.get("profile") + if not isinstance(profile, dict): + continue + if str(profile.get("type") or "") not in LEGACY_PROFILE_GENERATORS: + continue + sketch["profile"] = lower_legacy_profile(profile, sketch_id=str(sketch.get("id") or "")) + return lowered diff --git a/backend/cdsl_importer/legacy_profiles.py b/backend/cdsl_importer/legacy_profiles.py new file mode 100644 index 00000000..bc6ec928 --- /dev/null +++ b/backend/cdsl_importer/legacy_profiles.py @@ -0,0 +1,2075 @@ +"""Historical profile macro lowering support. + +This module is an importer-only compatibility adapter. It is not imported by +the CDSL runtime and is never a planner-facing CDSL contract. It preserves +the historical macro definitions long enough to lower old artifacts to direct +``analytic_contours`` before calling the engine. + +架构:注册表模式 —— 每个轮廓类型对应一个生成器函数, + 按 "type" 字符串索引。新增形状或调整既有 profile 参数契约时: + 1. 写 def solver_xxx(profile, meta) -> (entities, contour) + 2. 注册: SHAPE_GENERATORS["xxx"] = solver_xxx + 3. 在 convert 脚本中输出对应的 profile + 4. 同步更新 profile_schema.json(Agent 与后端校验器的公开契约) + +支持的 profile 类型: + - circle: 单个圆 + - annulus: 同心圆环 + - circles: 多圆(引擎自动判断加/切除) + - circle_grid: 矩形圆孔阵列(行列+间距) + - rectangle: 矩形 + - rectangle_with_circles: 矩形 + 内圆孔/岛 + - rectangle_with_fillets: 带圆角的矩形(4角倒圆),可选内圆 + - rectangle_with_symmetric_notches: 对称槽板(矩形+4个U形缺口) + - obround: 槽形 / 键槽(2平行线 + 2半圆) + - polygon: N边多边形(顶点列表) + - ibone: 工字形凸耳(12线+4弧+4孔) + - circle_with_arc_notches: 圆+均匀圆弧凹口 + - circular_sector_slot: 圆弧扇区+中心矩形槽 + - circle_with_radial_tabs: 圆+径向矩形凸耳(带圆角) + - filleted_rect_side_slots: 圆角矩形+两侧中心U形槽 + - d_shape: D形(半圆+弦线) + - partial_ring: 部分圆环(同心弧+径向线) + - partial_ring_with_arc_island: 扇区环 + 弦上偏移弧岛(保留材料岛) + - concentric_arc_profile: 同心圆弧轮廓(多段弧+圆心标记) + - patterned_cutouts: 母形 + 规则布局的多区域切口 + - compound_patterned_cutouts: 多组母形/布局合并为一个切除草图 +""" + +from __future__ import annotations + +import math +from copy import deepcopy +from typing import Any, Iterable + + +# ═══════════════════════════════════════════════════════════════ +# 基础几何原语 +# ═══════════════════════════════════════════════════════════════ + +def _circle(center: list[float], radius_mm: float, construction: bool = False) -> dict[str, Any]: + return { + "type": "circle", + "center": [float(center[0]), float(center[1])], + "radius_mm": float(radius_mm), + "construction": construction, + } + + +def _line(start: list[float], end: list[float], construction: bool = False) -> dict[str, Any]: + return { + "type": "line", + "start": [float(start[0]), float(start[1])], + "end": [float(end[0]), float(end[1])], + "construction": construction, + } + + +def _contour_line(start_mm: list[float], end_mm: list[float]) -> dict[str, Any]: + return { + "type": "line", + "start_mm": [ + float(start_mm[0]), + float(start_mm[1]), + float(start_mm[2]) if len(start_mm) > 2 else 0.0, + ], + "end_mm": [ + float(end_mm[0]), + float(end_mm[1]), + float(end_mm[2]) if len(end_mm) > 2 else 0.0, + ], + } + + +def _contour_arc( + start_mm: list[float], + end_mm: list[float], + center_mm: list[float], + radius_mm: float | None, + clockwise: bool | None = None, +) -> dict[str, Any]: + result = { + "type": "arc", + "start_mm": [ + float(start_mm[0]), + float(start_mm[1]), + float(start_mm[2]) if len(start_mm) > 2 else 0.0, + ], + "end_mm": [ + float(end_mm[0]), + float(end_mm[1]), + float(end_mm[2]) if len(end_mm) > 2 else 0.0, + ], + "center_mm": [ + float(center_mm[0]), + float(center_mm[1]), + float(center_mm[2]) if len(center_mm) > 2 else 0.0, + ], + "radius_mm": float(radius_mm) if radius_mm is not None else None, + } + if clockwise is not None: + result["clockwise"] = bool(clockwise) + return result + + +# ═══════════════════════════════════════════════════════════════ +# 3D 坐标转换 +# ═══════════════════════════════════════════════════════════════ + +def _to_3d(workplane: dict[str, Any], u: float, v: float) -> list[float]: + """将2D局部坐标 (u,v) 映射到3D世界坐标。""" + origin = workplane.get("origin_mm") or [0, 0, 0] + x_dir = workplane.get("x_dir") or [1, 0, 0] + normal = workplane.get("normal") or [0, 0, 1] + 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], + ] + return [ + origin[0] + u * x_dir[0] + v * y_dir[0], + origin[1] + u * x_dir[1] + v * y_dir[1], + origin[2] + u * x_dir[2] + v * y_dir[2], + ] + + +def _transform_contours(contour: list[dict[str, Any]], wp: dict[str, Any]) -> list[dict[str, Any]]: + """将轮廓边的2D坐标映射为3D世界坐标。""" + result: list[dict[str, Any]] = [] + x_dir = wp.get("x_dir") or [1, 0, 0] + normal = wp.get("normal") or [0, 0, 1] + for e in contour: + e2 = deepcopy(e) + if e["type"] == "line": + e2["start_mm"] = _to_3d(wp, e["start_mm"][0], e["start_mm"][1]) + e2["end_mm"] = _to_3d(wp, e["end_mm"][0], e["end_mm"][1]) + elif e["type"] == "arc": + e2["start_mm"] = _to_3d(wp, e["start_mm"][0], e["start_mm"][1]) + e2["end_mm"] = _to_3d(wp, e["end_mm"][0], e["end_mm"][1]) + e2["center_mm"] = _to_3d(wp, e["center_mm"][0], e["center_mm"][1]) + e2["normal"] = list(normal) + result.append(e2) + return result + + +# ═══════════════════════════════════════════════════════════════ +# 矩形 / 圆辅助 +# ═══════════════════════════════════════════════════════════════ + +def _build_rect_bounds(profile: dict[str, Any]) -> tuple[float, float, float, float]: + """从 profile 中提取矩形的 (x0, y0, x1, y1) 边界。""" + center = profile.get("center") + w = float(profile.get("width_mm") or 0) + h = float(profile.get("height_mm") or 0) + if center and w > 0 and h > 0: + cx, cy = float(center[0]), float(center[1]) + return cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2 + mn = profile.get("min_mm") + mx = profile.get("max_mm") + if mn and mx: + return float(mn[0]), float(mn[1]), float(mx[0]), float(mx[1]) + raise ValueError("rectangle profile needs (center+width+height) or (min+max)") + + +def _rect_lines_and_contour( + x0: float, y0: float, x1: float, y1: float, +) -> tuple[list[dict], list[dict]]: + p00, p10, p11, p01 = [x0, y0, 0.0], [x1, y0, 0.0], [x1, y1, 0.0], [x0, y1, 0.0] + entities = [ + _line([x0, y0], [x1, y0]), + _line([x1, y0], [x1, y1]), + _line([x1, y1], [x0, y1]), + _line([x0, y1], [x0, y0]), + ] + contour = [ + _contour_line(p00, p10), + _contour_line(p10, p11), + _contour_line(p11, p01), + _contour_line(p01, p00), + ] + return entities, contour + + +def _build_circle_entities(items: list[dict]) -> list[dict]: + entities: list[dict] = [] + for item in items: + center = item.get("center") or [0.0, 0.0] + r = float(item.get("radius_mm") or 0) + if r <= 0: + raise ValueError("circle radius must be > 0") + entities.append(_circle(center, r, construction=False)) + return entities + + +def _filleted_rect_contour( + x0: float, y0: float, x1: float, y1: float, r: float, +) -> tuple[list[dict], list[dict]]: + """生成带圆角矩形的实体线和轮廓边(4直线 + 4圆弧)。""" + if r <= 0: + return _rect_lines_and_contour(x0, y0, x1, y1) + + cx0, cx1 = x0 + r, x1 - r + cy0, cy1 = y0 + r, y1 - r + + entities = [ + _line([cx0, y0], [cx1, y0]), + _line([x0, cy0], [x0, cy1]), + _line([cx0, y1], [cx1, y1]), + _line([x1, cy0], [x1, cy1]), + ] + + contour = [ + _contour_line([x0, cy0, 0.0], [x0, cy1, 0.0]), + _contour_arc([x0, cy1, 0.0], [cx0, y1, 0.0], [cx0, cy1, 0.0], r), + _contour_line([cx0, y1, 0.0], [cx1, y1, 0.0]), + _contour_arc([cx1, y1, 0.0], [x1, cy1, 0.0], [cx1, cy1, 0.0], r), + _contour_line([x1, cy1, 0.0], [x1, cy0, 0.0]), + _contour_arc([x1, cy0, 0.0], [cx1, y0, 0.0], [cx1, cy0, 0.0], r), + _contour_line([cx1, y0, 0.0], [cx0, y0, 0.0]), + _contour_arc([cx0, y0, 0.0], [x0, cy0, 0.0], [cx0, cy0, 0.0], r), + ] + + return entities, contour + + +# ═══════════════════════════════════════════════════════════════ +# 形状生成器(每个是一个独立函数,按 type 注册) +# ═══════════════════════════════════════════════════════════════ + +_Ctx = dict[str, Any] + + +def _gen_circle(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + center = profile.get("center") or [0.0, 0.0] + r = float(profile.get("radius_mm") or 0) + if r <= 0: + raise ValueError("circle radius must be > 0") + entities = [_circle(center, r)] + cx, cy, c3d = float(center[0]), float(center[1]), [float(center[0]), float(center[1]), 0.0] + contour = [ + _contour_arc([cx + r, cy, 0.0], [cx, cy + r, 0.0], c3d, r), + _contour_arc([cx, cy + r, 0.0], [cx - r, cy, 0.0], c3d, r), + _contour_arc([cx - r, cy, 0.0], [cx, cy - r, 0.0], c3d, r), + _contour_arc([cx, cy - r, 0.0], [cx + r, cy, 0.0], c3d, r), + ] + return entities, contour + + +def _gen_annulus(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + center = profile.get("center") or [0.0, 0.0] + inner_r = float(profile.get("inner_radius_mm") or 0) + outer_r = float(profile.get("outer_radius_mm") or 0) + if inner_r <= 0 or outer_r <= 0: + raise ValueError("annulus radii must be > 0") + if inner_r >= outer_r: + raise ValueError("inner_radius >= outer_radius") + return [_circle(center, inner_r), _circle(center, outer_r)], [] + + +def _gen_circles(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + items = profile.get("items") or [] + if not items: + raise ValueError("circles items must be non-empty") + return _build_circle_entities(items), [] + + +def _gen_circle_grid(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """矩形圆孔阵列:由起点圆心 + 间距 + 行列数生成。 + + 参数: + radius_mm: 孔半径 + count_x / count_y: 列数、行数 + spacing_x_mm / spacing_y_mm: 圆心间距 + origin_mm: 第一孔圆心 [u,v](默认沿 +u/+v 铺开) + 或 center_mm: 阵列几何中心(与 origin_mm 二选一) + + 覆盖: b006(4×5 通孔阵列) + """ + r = float(profile["radius_mm"]) + nx = int(profile["count_x"]) + ny = int(profile["count_y"]) + sx = float(profile["spacing_x_mm"]) + sy = float(profile["spacing_y_mm"]) + if r <= 0 or nx < 1 or ny < 1: + raise ValueError("circle_grid: invalid radius/counts") + + if profile.get("center_mm") is not None: + cc = profile["center_mm"] + u0 = float(cc[0]) - (nx - 1) * sx / 2.0 + v0 = float(cc[1]) - (ny - 1) * sy / 2.0 + else: + origin = profile.get("origin_mm") or [0.0, 0.0] + u0, v0 = float(origin[0]), float(origin[1]) + + items = [ + {"center": [u0 + i * sx, v0 + j * sy], "radius_mm": r} + for j in range(ny) + for i in range(nx) + ] + return _build_circle_entities(items), [] + + +def _gen_rectangle(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + x0, y0, x1, y1 = _build_rect_bounds(profile) + return _rect_lines_and_contour(x0, y0, x1, y1) + + +def _gen_rect_with_circles(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + boundary = profile.get("boundary") or {} + circle_items = profile.get("circles") or [] + btype = boundary.get("type") or "rectangle" + if btype in ("rectangle", "rectangle_with_fillets"): + if btype == "rectangle": + x0, y0, x1, y1 = _build_rect_bounds(boundary) + ent, con = _rect_lines_and_contour(x0, y0, x1, y1) + else: + fr = float(boundary.get("fillet_radius_mm") or 0) + x0, y0, x1, y1 = _build_rect_bounds(boundary) + ent, con = _filleted_rect_contour(x0, y0, x1, y1, fr) + ent.extend(_build_circle_entities(circle_items)) + return ent, con + raise ValueError(f"rectangle_with_circles: unsupported boundary type {btype!r}") + + +def _gen_rect_with_fillets(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + fr = float(profile.get("fillet_radius_mm") or 0) + x0, y0, x1, y1 = _build_rect_bounds(profile) + ent, con = _filleted_rect_contour(x0, y0, x1, y1, fr) + ent.extend(_build_circle_entities(profile.get("circles") or [])) + return ent, con + + +def _gen_obround(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + center = profile.get("center") + length = float(profile.get("length_mm") or 0) + width = float(profile.get("width_mm") or 0) + if length <= 0 or width <= 0: + raise ValueError("obround needs positive length/width") + r = width / 2 + cx, cy = (float(center[0]), float(center[1])) if center else (0.0, 0.0) + offset = max(0, (length - width) / 2) + left_cx, right_cx = cx - offset, cx + offset + + if offset < 0.001: + c3d = [cx, cy, 0.0] + contour = [ + _contour_arc([cx + r, cy, 0.0], [cx, cy + r, 0.0], c3d, r), + _contour_arc([cx, cy + r, 0.0], [cx - r, cy, 0.0], c3d, r), + _contour_arc([cx - r, cy, 0.0], [cx, cy - r, 0.0], c3d, r), + _contour_arc([cx, cy - r, 0.0], [cx + r, cy, 0.0], c3d, r), + ] + return [_circle([cx, cy], r)], contour + + top_y, bot_y = cy + r, cy - r + left_c3d, right_c3d = [left_cx, cy, 0.0], [right_cx, cy, 0.0] + contour = [ + _contour_arc([right_cx, top_y, 0.0], [right_cx + r, cy, 0.0], right_c3d, r), + _contour_arc([right_cx + r, cy, 0.0], [right_cx, bot_y, 0.0], right_c3d, r), + _contour_line([right_cx, bot_y, 0.0], [left_cx, bot_y, 0.0]), + _contour_arc([left_cx, bot_y, 0.0], [left_cx - r, cy, 0.0], left_c3d, r), + _contour_arc([left_cx - r, cy, 0.0], [left_cx, top_y, 0.0], left_c3d, r), + _contour_line([left_cx, top_y, 0.0], [right_cx, top_y, 0.0]), + ] + entities = [ + _line([left_cx, bot_y], [right_cx, bot_y]), + _line([left_cx, top_y], [right_cx, top_y]), + _circle([left_cx, cy], r), + _circle([right_cx, cy], r), + ] + return entities, contour + + +def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + vertices = profile.get("vertices") or [] + if len(vertices) >= 3: + pts_2d = [(float(v[0]), float(v[1])) for v in vertices] + entities, contour = [], [] + for i in range(len(pts_2d)): + s, e = pts_2d[i], pts_2d[(i + 1) % len(pts_2d)] + entities.append(_line(list(s), list(e))) + contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) + return entities, contour + # 015133: no vertices in profile -> use entities from compiler_context + ents = meta.get("_entities") or [] + if not ents: + raise ValueError("polygon needs at least 3 vertices or existing entities in sketch") + contour = [] + for e in ents: + t = e.get("type", "") + if t == "line": + s = e.get("start", [0, 0]) + ed = e.get("end", [0, 0]) + contour.append(_contour_line([float(s[0]), float(s[1]), 0.0], [float(ed[0]), float(ed[1]), 0.0])) + elif t == "arc": + contour.append(_contour_line( + [float(e["start"][0]), float(e["start"][1]), 0.0], + [float(e["end"][0]), float(e["end"][1]), 0.0])) + return list(ents), contour + + +def _gen_ibone(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """工字形凸耳:12线 + 4弧 + 4孔""" + bw, bh = float(profile["body_width_mm"]), float(profile["body_height_mm"]) + fw, fh = float(profile["flange_width_mm"]), float(profile["flange_height_mm"]) + cr = float(profile["corner_radius_mm"]) + hr = float(profile.get("hole_radius_mm") or 0) + hw, hfw, ar = bw / 2, fw / 2, bw / 2 - cr + av_bot, av_top = fh - cr, bh - fh + cr + + segs = [ + ("L", hfw, 0, -hfw, 0), + ("L", -hfw, 0, -hfw, fh - cr), + ("A", -hfw, fh - cr, -ar, fh, -ar, fh - cr, cr), + ("L", -ar, fh, -hw, fh), + ("L", -hw, fh, -hw, bh - fh), + ("L", -hw, bh - fh, -ar, bh - fh), + ("A", -ar, bh - fh, -hfw, bh - fh + cr, -ar, bh - fh + cr, cr), + ("L", -hfw, bh - fh + cr, -hfw, bh), + ("L", -hfw, bh, hfw, bh), + ("L", hfw, bh, hfw, bh - fh + cr), + ("A", hfw, bh - fh + cr, ar, bh - fh, ar, bh - fh + cr, cr), + ("L", ar, bh - fh, hw, bh - fh), + ("L", hw, bh - fh, hw, fh), + ("L", hw, fh, ar, fh), + ("A", ar, fh, hfw, fh - cr, ar, fh - cr, cr), + ("L", hfw, fh - cr, hfw, 0), + ] + entities, contour = [], [] + for s in segs: + if s[0] == "L": + _, u1, v1, u2, v2 = s + entities.append(_line([u1, v1], [u2, v2])) + contour.append(_contour_line([u1, v1, 0.0], [u2, v2, 0.0])) + else: + _, u1, v1, u2, v2, cu, cv, r = s + contour.append(_contour_arc([u1, v1, 0.0], [u2, v2, 0.0], [cu, cv, 0.0], r)) + + if hr > 0: + for cu, cv in [(-ar, av_bot), (ar, av_bot), (-ar, av_top), (ar, av_top)]: + entities.append(_circle([cu, cv], hr)) + return entities, contour + + +def _gen_rect_symmetric_notches(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """对称槽板:矩形+4个U形缺口(简化多边形 或 精确弧边)""" + w, h = float(profile["width_mm"]), float(profile["height_mm"]) + n = profile.get("notch") or {} + n_ys, n_ye = float(n["y_start"]), float(n["y_end"]) + n_depth = float(n["depth_mm"]) + n_ir = float(n.get("inner_radius_mm") or 0) + n_cr = float(n.get("corner_radius_mm") or 0) + hw = w / 2 + inner_u = hw - n_depth + + if n_ir > 0 and n_cr > 0: + icu, icv = hw - n_depth / 2, (n_ys + n_ye) / 2 + entities, contour = [], [] + for u1, v1, u2, v2 in [ + (hw, 0, hw, n_ys), (hw, n_ye, hw, h - n_ye), + (hw, h - n_ys, hw, h), (-hw, h, -hw, h - n_ys), + (-hw, h - n_ye, -hw, n_ye), (-hw, n_ys, -hw, 0), + (-hw, 0, hw, 0), (hw, h, -hw, h), + ]: + entities.append(_line([u1, v1], [u2, v2])) + contour.append(_contour_line([u1, v1, 0.0], [u2, v2, 0.0])) + + def _notch(sign_u, y_bot, y_top): + u = sign_u * hw + ec = sign_u * (hw - n_cr) + icu2 = sign_u * icu + contour.append(_contour_arc( + [u, y_bot, 0.0], [ec, y_bot + n_cr, 0.0], [ec, y_bot, 0.0], n_cr)) + av = y_bot + n_cr + au = icu2 - sign_u * math.sqrt(max(0.0, n_ir ** 2 - (av - icv) ** 2)) + contour.append(_contour_line([ec, av, 0.0], [au, av, 0.0])) + bv = y_top - n_cr + bu = icu2 - sign_u * math.sqrt(max(0.0, n_ir ** 2 - (bv - icv) ** 2)) + contour.append(_contour_arc( + [au, av, 0.0], [bu, bv, 0.0], [icu2, icv, 0.0], n_ir)) + contour.append(_contour_line([bu, bv, 0.0], [ec, bv, 0.0])) + contour.append(_contour_arc( + [ec, bv, 0.0], [u, y_top, 0.0], [ec, y_top, 0.0], n_cr)) + + _notch(+1, n_ys, n_ye) + _notch(+1, h - n_ye, h - n_ys) + _notch(-1, n_ys, n_ye) + _notch(-1, h - n_ye, h - n_ys) + return entities, contour + + # 简化多边形(5 参数) + verts = [ + (hw, 0), (hw, n_ys), (inner_u, n_ys), (inner_u, n_ye), + (hw, n_ye), (hw, h - n_ye), (inner_u, h - n_ye), + (inner_u, h - n_ys), (hw, h - n_ys), (hw, h), + (-hw, h), (-hw, h - n_ys), (-inner_u, h - n_ys), + (-inner_u, h - n_ye), (-hw, h - n_ye), (-hw, n_ye), + (-inner_u, n_ye), (-inner_u, n_ys), (-hw, n_ys), (-hw, 0), + ] + entities, contour = [], [] + pts = [(float(v[0]), float(v[1])) for v in verts] + for i in range(len(pts)): + s, e = pts[i], pts[(i + 1) % len(pts)] + entities.append(_line(list(s), list(e))) + contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) + return entities, contour + + +def _gen_revolve_chamfer(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """旋转切除的梯形截面(5顶点,相对轴顶点定义)。""" + ah = float(profile["axis_height_mm"]) + tw = float(profile["top_width_mm"]) + bw = float(profile["bottom_width_mm"]) + wi = float(profile.get("wall_inset_mm") or 0) + si = float(profile.get("step_inset_mm") or 0) + side = profile.get("on_axis_side", "left") + sign = -1 if side == "left" else 1 + v0 = (sign * tw, -wi); v1 = (0.0, 0.0); v2 = (0.0, -ah) + v3 = (sign * bw, -ah); v4 = (sign * tw, -si) + entities, contour = [], [] + for s, e in [(v0, v1), (v1, v2), (v2, v3), (v3, v4), (v4, v0)]: + entities.append(_line(list(s), list(e))) + contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) + return entities, contour + + +def _gen_revolve_chamfer_slanted(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """旋转切除的斜底梯形截面(5顶点)。 + + 与 revolve_chamfer 的区别:底部为斜边(轴底→壁底不是水平线)。 + 参数: + axis_height_mm: 轴侧总高度(V1→V2) + top_width_mm: 顶部宽度(轴→外壁) + wall_inset_mm: 顶部台阶深度(V0→V4 的 V 偏移) + wall_height_mm: 壁段高度(V4→V3) + wall_width_mm: 壁距轴的距离 + on_axis_side: "left"(U负) 或 "right"(U正) + """ + ah = float(profile["axis_height_mm"]) + tw = float(profile["top_width_mm"]) + wi = float(profile.get("wall_inset_mm") or 0) + wh = float(profile["wall_height_mm"]) + ww = float(profile["wall_width_mm"]) + side = profile.get("on_axis_side", "left") + sign = -1 if side == "left" else 1 + + v0 = (sign * tw, 0.0) # 顶部外侧 + v1 = (0.0, 0.0) # 轴顶点 + v2 = (0.0, -ah) # 轴底部 + v3 = (sign * ww, -wi - wh) # 壁底部(斜边连接到 V2) + v4 = (sign * ww, -wi) # 壁顶部(台阶) + + entities, contour = [], [] + for s, e in [(v0, v1), (v1, v2), (v2, v3), (v3, v4), (v4, v0)]: + entities.append(_line(list(s), list(e))) + contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) + return entities, contour + + +# ═══════════════════════════════════════════════════════════════ +# 弧边复合轮廓生成器(按"015133 手册"方法注册) +# ═══════════════════════════════════════════════════════════════ + + +def _gen_circle_with_arc_notches(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """圆+圆弧凹口:大圆上均匀分布的弧形缺口。 + + 参数: + outer_radius_mm: 大圆半径 + notch_radius_mm: 每个凹口的圆弧半径 + notch_angles_deg: 凹口所在的角度列表(度,从+u顺时针) + 默认 [0, 90, 180, 270](十字槽) + 例: [0,90,180,270] → 十字形,[45,135,225,315] → 斜十字 + + 覆盖文件: 48, 49, 50, 82 + """ + import math + R = float(profile["outer_radius_mm"]) + r = float(profile["notch_radius_mm"]) + angles_deg = profile.get("notch_angles_deg", [0, 90, 180, 270]) + + # 每个凹口在大圆上占据的半角宽度 + delta = math.acos(max(-1.0, min(1.0, 1.0 - r * r / (2.0 * R * R)))) + angles_rad = [math.radians(a) for a in sorted(angles_deg)] + + entities, contour = [], [] + n = len(angles_rad) + + for i in range(n): + prev_end = angles_rad[i - 1] + delta # 上一个凹口离开点 + curr_enter = angles_rad[i] - delta # 当前凹口入口 + + # 大弧:从上一个凹口离开点到当前凹口入口(顺时针) + ps_u, ps_v = R * math.cos(prev_end), R * math.sin(prev_end) + pe_u, pe_v = R * math.cos(curr_enter), R * math.sin(curr_enter) + + contour.append(_contour_arc( + [ps_u, ps_v, 0.0], [pe_u, pe_v, 0.0], + [0.0, 0.0, 0.0], R, + )) + + # 凹口弧:从入口→出口,中心在外圆上 + curr_exit = angles_rad[i] + delta + nc_u = R * math.cos(angles_rad[i]) + nc_v = R * math.sin(angles_rad[i]) + + pn_enter_u = R * math.cos(curr_enter) + pn_enter_v = R * math.sin(curr_enter) + pn_exit_u = R * math.cos(curr_exit) + pn_exit_v = R * math.sin(curr_exit) + + # 凹口弧:从出口回到入口(与大弧方向相反) + contour.append(_contour_arc( + [pn_exit_u, pn_exit_v, 0.0], [pn_enter_u, pn_enter_v, 0.0], + [nc_u, nc_v, 0.0], r, + )) + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_circular_sector_slot(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """圆弧扇区槽:一段大圆弧 + 两条径向线 + 一个矩形槽口。 + + 形状:一个扇形(大圆弧 + 两侧径向线),中心开矩形槽。 + 由两条大弧(上/下)、两条径向线、一个中央矩形槽组成。 + + 参数: + arc_radius_mm: 大弧半径(圆心在原点) + slot_half_width_mm: 槽口半宽(从圆心起的径向距离) + chord_half_mm: 弧弦线半长(弧的跨距,决定弧幅度) + + 覆盖文件: 87, 88, 89, 90 + """ + import math + R = float(profile["arc_radius_mm"]) + hw = float(profile["slot_half_width_mm"]) + ch = float(profile["chord_half_mm"]) + + # 弧端点:在圆上,到中心轴的垂直距离为 ch + # 弧端点在圆 R 上,距离中心轴 ch,其角度为 asin(ch/R) + half_angle = math.asin(max(-1.0, min(1.0, ch / R))) + + # 弧端点坐标(圆上,2 个象限) + arc_x = R * math.cos(half_angle) + arc_y = R * math.sin(half_angle) if ch >= 0 else -R * math.sin(-half_angle) + + # 4 个关键点(顺时针) + # 下弧: x 从 -arc_x 到 +arc_x, y = -ch (在圆上 y = ±arc_y ≈ ±ch) + # 右上角弧段 + arc_x_neg_angle = R * math.cos(-half_angle) + arc_y_neg = R * math.sin(-half_angle) + + p_bot_right = (arc_x_neg_angle, arc_y_neg) # 右下(圆上,负半角) + p_bot_left = (arc_x, arc_y) # 右下(圆上,正半角)... 等等 + + # 直接按 87 的几何定义:下弧从 (+xs, -ch) 到 (-xs, -ch),上弧从 (-xs, +ch) 到 (+xs, +ch) + # xs 由圆 R 和 ch 确定 + xs = math.sqrt(max(0, R * R - ch * ch)) + + contour = [ + # 下弧:从 (xs, -ch) 到 (-xs, -ch),圆心原点,半径 R(顺时针) + _contour_arc([xs, -ch, 0.0], [-xs, -ch, 0.0], [0.0, 0.0, 0.0], R), + # 左侧线:(-xs, -ch) → (-xs, +ch) ... + # 不对,夹着槽口 + + ] + + # 重新按 87 的实际边序列构建 + # [0] line (-27.5, -13)→(-27.5, +13) → 槽口左竖线 + # [1] line (-27.5, +13)→(-37.83, +13) → 径向连接 + # [2] arc r=40 c=(0,0) s=(+37.83, +13)→(-37.83, +13) → 上弧 + # [3] line (+27.5, +13)→(+37.83, +13) → 径向连接(右侧) + # [4] line (+27.5, -13)→(+27.5, +13) → 槽口右竖线 + # [5] line (+27.5, -13)→(+37.83, -13) → 径向连接 + # [6] arc r=40 c=(0,0) s=(-37.83, -13)→(+37.83, -13) → 下弧 + # [7] line (-27.5, -13)→(-37.83, -13) → 径向连接 + + # 参数化: + # slot_half = 27.5 (槽口半宽) + # chord_half = 13 (弧端点的 w 坐标,确定弧的跨度) + # arc_radius = 40 + # arc_x_end = sqrt(R² - ch²) = sqrt(1600 - 169) ≈ 37.83 + + sh = hw # slot half + axe = math.sqrt(max(0.0, R * R - ch * ch)) # arc x-endpoint + + contour = [ + # 槽口竖线(从左下到左上) + _contour_line([-sh, -ch, 0.0], [-sh, ch, 0.0]), + # 连接到弧(从槽口左上到弧左下) + _contour_line([-sh, ch, 0.0], [-axe, ch, 0.0]), + # 上弧(从弧左下到弧右下,经过原点顶) + _contour_arc([axe, ch, 0.0], [-axe, ch, 0.0], [0.0, 0.0, 0.0], R), + # 连接到槽口(从弧右下到槽口右上) + _contour_line([sh, ch, 0.0], [axe, ch, 0.0]), + # 槽口竖线(从右上到右下) + _contour_line([sh, ch, 0.0], [sh, -ch, 0.0]), + # 连接到弧(从槽口右下到弧右上) + _contour_line([sh, -ch, 0.0], [axe, -ch, 0.0]), + # 下弧(从弧右上到弧左上,经过原点底) + _contour_arc([-axe, -ch, 0.0], [axe, -ch, 0.0], [0.0, 0.0, 0.0], R), + # 连接到槽口(从弧左上到槽口左下) + _contour_line([-sh, -ch, 0.0], [-axe, -ch, 0.0]), + ] + + entities = [ + _line([-sh, -ch], [-sh, ch]), + _line([-sh, ch], [-axe, ch]), + _line([sh, ch], [axe, ch]), + _line([sh, ch], [sh, -ch]), + _line([sh, -ch], [axe, -ch]), + _line([-sh, -ch], [-axe, -ch]), + ] + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_circle_with_radial_tabs(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """圆+径向凸耳:大圆弧上有矩形凸起。 + + 形状:一个大圆被两侧的矩形凸耳取代部分弧段。 + 简化表示为直边多边形(忽略 r=1 的圆角,体积误差 <1%)。 + + 参数: + outer_radius_mm: 大圆半径 + tab_u_half_mm: 凸耳半宽(弧线方向,从根部到内缘) + tab_v_offset_mm: 凸耳离弧线的垂直距离(即凸耳顶部距弧线的v偏移) + + 覆盖文件: 91, 92, 93, 94, 95 + """ + import math + R = float(profile["outer_radius_mm"]) + tu = float(profile.get("tab_u_half_mm") or 0) + tv = float(profile.get("tab_v_offset_mm") or 0) + + # 凸耳根部在圆上的角度 + angle = math.asin(min(1.0, max(0.0, tv / R))) + + # 弧上根部点(右侧) + root_u = R * math.cos(angle) + root_v = R * math.sin(angle) + + # 凸耳内缘 + inner_u = root_u - tu + inner_v = root_v * 0.9 # 略浅于弧线 + + # 构建轮廓:大弧(上) → 右凸耳 → 大弧(下) → 左凸耳 → 闭合 + + contour = [] + + # 上弧:从左侧根部到右侧根部(经过顶点) + contour.append(_contour_arc( + [root_u, root_v, 0.0], [-root_u, root_v, 0.0], + [0.0, 0.0, 0.0], R)) + + # 右侧凸耳(多边形:根部→内顶→内底→根部) + contour.append(_contour_line([root_u, root_v, 0.0], [inner_u, inner_v, 0.0])) + contour.append(_contour_line([inner_u, inner_v, 0.0], [inner_u, -inner_v, 0.0])) + contour.append(_contour_line([inner_u, -inner_v, 0.0], [root_u, -root_v, 0.0])) + + # 下弧:从右侧底部到左侧底部(经过底点) + contour.append(_contour_arc( + [-root_u, -root_v, 0.0], [root_u, -root_v, 0.0], + [0.0, 0.0, 0.0], R)) + + # 左侧凸耳(镜像) + contour.append(_contour_line([-root_u, -root_v, 0.0], [-inner_u, -inner_v, 0.0])) + contour.append(_contour_line([-inner_u, -inner_v, 0.0], [-inner_u, inner_v, 0.0])) + contour.append(_contour_line([-inner_u, inner_v, 0.0], [-root_u, root_v, 0.0])) + + entities = [ + _line([root_u, root_v], [inner_u, inner_v]), + _line([inner_u, inner_v], [inner_u, -inner_v]), + _line([inner_u, -inner_v], [root_u, -root_v]), + _line([-root_u, -root_v], [-inner_u, -inner_v]), + _line([-inner_u, -inner_v], [-inner_u, inner_v]), + _line([-inner_u, inner_v], [-root_u, root_v]), + ] + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_filleted_rect_side_slots(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """圆角矩形+两侧中心凹槽。 + + 形状:圆角矩形,左右两侧中心各有一个 U 形凹槽(半圆槽)。 + + 参数: + half_width_mm: 矩形半宽(不含圆角) + half_height_mm: 矩形半高(不含圆角) + corner_radius_mm: 四角圆角半径 + slot_radius_mm: 两侧中心凹槽半径(默认 5.0) + circles: 可选内部圆(孔洞),[{center:[x,y], radius_mm:r}, …] + + 覆盖文件: 58, 63, 64, 65, 66 + """ + hw = float(profile["half_width_mm"]) + hh = float(profile["half_height_mm"]) + cr = float(profile["corner_radius_mm"]) + sr = float(profile.get("slot_radius_mm") or cr * 0.5) + + # 注意: v=-Z, 所以 v 正方向朝 Z 负 + # 矩形范围: u=[-hw,hw], v=[-hh,+hh] 对应 z=[+hh,-hh] + # 上边 (z=+hh): v=-hh, 下边 (z=-hh): v=+hh + + entities, contour = [], [] + top_v, bot_v = -hh, hh # 上边 v=-hh, 下边 v=+hh + + # 上边(直线,从左上角到右上角) + contour.append(_contour_line( + [-(hw - cr), top_v, 0.0], [(hw - cr), top_v, 0.0])) + entities.append(_line([-(hw - cr), top_v], [(hw - cr), top_v])) + + # 右上圆角(逆时针绕 center: 从顶点到右侧) + contour.append(_contour_arc( + [(hw - cr), top_v, 0.0], [hw, top_v + cr, 0.0], + [(hw - cr), top_v + cr, 0.0], cr)) + + # 右边上半(从圆角到凹槽上方) + contour.append(_contour_line( + [hw, top_v + cr, 0.0], [hw, -sr, 0.0])) + entities.append(_line([hw, top_v + cr], [hw, -sr])) + + # 右侧中心凹槽(半圆向内的 U 形凹口) + contour.append(_contour_arc( + [hw, sr, 0.0], [hw, -sr, 0.0], + [hw, 0.0, 0.0], sr)) + + # 右边下半(从凹槽下方到右下角) + contour.append(_contour_line( + [hw, sr, 0.0], [hw, bot_v - cr, 0.0])) + entities.append(_line([hw, sr], [hw, bot_v - cr])) + + # 右下圆角 + contour.append(_contour_arc( + [hw, bot_v - cr, 0.0], [(hw - cr), bot_v, 0.0], + [(hw - cr), bot_v - cr, 0.0], cr)) + + # 下边 + contour.append(_contour_line( + [(hw - cr), bot_v, 0.0], [-(hw - cr), bot_v, 0.0])) + entities.append(_line([(hw - cr), bot_v], [-(hw - cr), bot_v])) + + # 左下圆角 + contour.append(_contour_arc( + [-(hw - cr), bot_v, 0.0], [-hw, bot_v - cr, 0.0], + [-(hw - cr), bot_v - cr, 0.0], cr)) + + # 左边下半 + contour.append(_contour_line( + [-hw, bot_v - cr, 0.0], [-hw, sr, 0.0])) + entities.append(_line([-hw, bot_v - cr], [-hw, sr])) + + # 左侧中心凹槽 + contour.append(_contour_arc( + [-hw, -sr, 0.0], [-hw, sr, 0.0], + [-hw, 0.0, 0.0], sr)) + + # 左边上半 + contour.append(_contour_line( + [-hw, -sr, 0.0], [-hw, top_v + cr, 0.0])) + entities.append(_line([-hw, -sr], [-hw, top_v + cr])) + + # 左上圆角 + contour.append(_contour_arc( + [-hw, top_v + cr, 0.0], [-(hw - cr), top_v, 0.0], + [-(hw - cr), top_v + cr, 0.0], cr)) + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +# ═══════════════════════════════════════════════════════════════ +# 更多弧边复合轮廓生成器 +# ═══════════════════════════════════════════════════════════════ + + +def _gen_d_shape(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """D形(半圆+弦线):一条直线 + 一条大圆弧,形如字母 D。 + + 参数: + radius_mm: 大弧半径(圆心在原点) + chord_sign: 弦线方向,"left"=弦在 x>0 侧,"right"=弦在 x<0 侧 + 默认 "left"(弦线在 +x 侧,弧形开口朝 -x) + + 覆盖文件: 144358 + """ + import math + R = float(profile["radius_mm"]) + side = profile.get("chord_sign", "left") + sign = 1 if side == "left" else -1 + + # chord at x=cx (cx^2 + y^2 = R^2) + # For side="left": chord at x = sqrt(R^2 - y_len^2) ... + # Actually, from 144358 data: arc r=41 c=(0,0) from (34,22.9) to (34,-22.9) + # So the chord is at x=34, v ranges from -22.9 to 22.9 + # v_max = sqrt(R^2 - x^2) = sqrt(41^2 - 34^2) = sqrt(1681-1156) = sqrt(525) ≈ 22.91 ✓ + + v_max = math.sqrt(max(0.0, R * R - (R - 7) * (R - 7))) + # 实际上,chord x 可以根据 radius 推导 + # 使用 chord_x 参数如果存在,否则用近似 + chord_x = float(profile.get("chord_x_mm") or R * 0.83) # 默认在半径 83% 处 + + v_half = math.sqrt(max(0.0, R * R - chord_x * chord_x)) + cx = sign * chord_x # 弦线 x 坐标 + + contour = [ + # 弦线(从下到上) + _contour_line([cx, -v_half, 0.0], [cx, v_half, 0.0]), + # 大弧(从右上到左下,即从左到右沿弧线) + _contour_arc([cx, v_half, 0.0], [cx, -v_half, 0.0], [0.0, 0.0, 0.0], R), + ] + + entities = [ + _line([cx, -v_half], [cx, v_half]), + ] + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_partial_ring(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """部分圆环(同心圆弧+径向直线):两段同心弧 + 两条径向线。 + + 形状像一个扇区环 (sector annulus),由内外两段同心弧和两侧径向线组成。 + + 参数: + inner_radius_mm: 内弧半径 + outer_radius_mm: 外弧半径 + half_angle_deg: 弧的半角度(两侧各 half_angle 度,总张角 2*half_angle) + + 覆盖文件: 177126 + """ + import math + ir = float(profile["inner_radius_mm"]) + oR = float(profile["outer_radius_mm"]) + h_deg = float(profile.get("half_angle_deg") or 45.0) + h_rad = math.radians(h_deg) + + # 内弧端点 + iu_pos = ir * math.cos(h_rad) + iv_pos = ir * math.sin(h_rad) + iu_neg = ir * math.cos(-h_rad) + iv_neg = ir * math.sin(-h_rad) + + # 外弧端点 + ou_pos = oR * math.cos(h_rad) + ov_pos = oR * math.sin(h_rad) + ou_neg = oR * math.cos(-h_rad) + ov_neg = oR * math.sin(-h_rad) + + contour = [ + # 右侧径向线(从内弧到外弧,+h角度) + _contour_line([iu_pos, iv_pos, 0.0], [ou_pos, ov_pos, 0.0]), + # 外弧(从 +h 到 -h) + _contour_arc([ou_neg, ov_neg, 0.0], [ou_pos, ov_pos, 0.0], [0.0, 0.0, 0.0], oR), + # 左侧径向线(从外弧到内弧,-h角度) + _contour_line([ou_neg, ov_neg, 0.0], [iu_neg, iv_neg, 0.0]), + # 内弧(从 -h 到 +h) + _contour_arc([iu_pos, iv_pos, 0.0], [iu_neg, iv_neg, 0.0], [0.0, 0.0, 0.0], ir), + ] + + entities = [ + _line([iu_pos, iv_pos], [ou_pos, ov_pos]), + _line([ou_neg, ov_neg], [iu_neg, iv_neg]), + ] + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_partial_ring_with_arc_island(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """扇区环 + 外弦上的等宽弧岛(岛不切除,作为区域内孔)。 + + 每个 replica 生成一块「外轮廓=扇区环、内孔=偏移弧岛」的区域。 + 岛外弧端点落在扇区外弧弦上,横坐标取 ±inner·cos(half_angle)(相对角平分线)。 + + 参数: + inner_radius_mm / outer_radius_mm / half_angle_deg: 扇区环 + island_radius_mm: 岛外弧半径 + island_gap_mm: 岛内外弧径向间距(等宽) + center_angles_deg 或 replicas[{center_angle_deg}]: 各扇区角平分线方向(度) + + 覆盖: b005 + """ + ir = float(profile["inner_radius_mm"]) + oR = float(profile["outer_radius_mm"]) + h_deg = float(profile.get("half_angle_deg") or 45.0) + island_r = float(profile["island_radius_mm"]) + gap = float(profile.get("island_gap_mm") or 1.0) + if ir <= 0 or oR <= ir or island_r <= gap: + raise ValueError("partial_ring_with_arc_island: invalid radii") + + replicas = profile.get("replicas") + if replicas: + angles = [float(r["center_angle_deg"]) for r in replicas] + else: + angles = [float(a) for a in (profile.get("center_angles_deg") or [0.0])] + + h = math.radians(h_deg) + entities: list[_Ctx] = [] + regions: list[dict[str, Any]] = [] + + for ca_deg in angles: + ca = math.radians(ca_deg) + a0, a1 = ca - h, ca + h + + def polar(r: float, ang: float) -> list[float]: + return [r * math.cos(ang), r * math.sin(ang), 0.0] + + # 扇区环外轮廓(逆时针:外弧 a0→a1,径向,内弧 a1→a0,径向) + ou0, ou1 = polar(oR, a0), polar(oR, a1) + iu0, iu1 = polar(ir, a0), polar(ir, a1) + outer = [ + _contour_arc(ou0, ou1, [0.0, 0.0, 0.0], oR), + _contour_line(ou1, iu1), + _contour_arc(iu1, iu0, [0.0, 0.0, 0.0], ir), + _contour_line(iu0, ou0), + ] + entities.extend([ + _line(ou0[:2], ou1[:2]), + _line(ou1[:2], iu1[:2]), + _line(iu1[:2], iu0[:2]), + _line(iu0[:2], ou0[:2]), + ]) + + # 外弦中点与弦向单位向量;岛端点 = M ± inner·cos(h)·chord_dir + ux, uy = math.cos(ca), math.sin(ca) + mx = oR * ux * math.cos(h) + my = oR * uy * math.cos(h) + cdx, cdy = -uy, ux + span = ir * math.cos(h) + e1 = [mx + span * cdx, my + span * cdy, 0.0] + e2 = [mx - span * cdx, my - span * cdy, 0.0] + + # 岛心在角平分线上:|E - t·u| = island_r,取距原点较近根 + dot = e1[0] * ux + e1[1] * uy + e2n = e1[0] * e1[0] + e1[1] * e1[1] + disc = max(0.0, dot * dot - (e2n - island_r * island_r)) + t1, t2 = dot - math.sqrt(disc), dot + math.sqrt(disc) + t = t1 if abs(t1) <= abs(t2) else t2 + cx, cy = t * ux, t * uy + c3 = [cx, cy, 0.0] + + def inward(pt: list[float]) -> list[float]: + vx, vy = cx - pt[0], cy - pt[1] + L = math.hypot(vx, vy) or 1.0 + return [pt[0] + vx / L * gap, pt[1] + vy / L * gap, 0.0] + + i1, i2 = inward(e1), inward(e2) + ri = island_r - gap + + # 岛孔:外弧 e1→e2(经外侧鼓包)再经内弧返回;与扇区同向时作孔需反向 + # 外弧走短弧中指向外侧(远离原点)的那条 + hole = [ + _contour_arc(e1, e2, c3, island_r), + _contour_line(e2, i2), + _contour_arc(i2, i1, c3, ri), + _contour_line(i1, e1), + ] + entities.extend([ + _line(e1[:2], e2[:2]), + _line(e2[:2], i2[:2]), + _line(i2[:2], i1[:2]), + _line(i1[:2], e1[:2]), + ]) + regions.append({"outer": outer, "holes": [hole]}) + + meta["_regions"] = regions + return entities, [] + + +def _gen_arc_chain(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """弧链轮廓:多段首尾相连的弧形成闭合轮廓(各弧可有不同圆心)。 + + 用于 revolve 特征的截面草图,由多段圆弧端到端连接组成。 + + 参数: + arcs: 弧描述列表 [{radius_mm, center:[u,v], start_angle_deg, end_angle_deg}, ...] + (每个弧从 start_angle 到 end_angle,起点与上一条弧终点重合) + + 覆盖文件: 020543 + """ + import math + arc_list = profile.get("arcs") or [] + + if not arc_list or len(arc_list) < 2: + raise ValueError("arc_chain needs at least 2 arcs") + + entities, contour = [], [] + + for arc_desc in arc_list: + r = float(arc_desc["radius_mm"]) + center = arc_desc.get("center") or [0.0, 0.0] + cu, cv = float(center[0]), float(center[1]) + sa = math.radians(float(arc_desc["start_angle_deg"])) + ea = math.radians(float(arc_desc["end_angle_deg"])) + + su = cu + r * math.cos(sa) + sv = cv + r * math.sin(sa) + eu = cu + r * math.cos(ea) + ev = cv + r * math.sin(ea) + + contour.append(_contour_arc( + [su, sv, 0.0], [eu, ev, 0.0], + [cu, cv, 0.0], r, + )) + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_radial_slot(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """径向槽:两段同心圆弧 + 两端圆角,形如弧形环段。 + + 用于 extrude_cut 在圆柱壁面上开弧形槽口。 + + 参数: + inner_radius_mm: 内弧半径 + outer_radius_mm: 外弧半径 + start_angle_deg: 槽起始角度(度,从工作平面 x_dir 方向逆时针测量) + end_angle_deg: 槽终止角度 + + 覆盖文件: 020543 (sk_02, sk_03, sk_04) + """ + import math + ir = float(profile["inner_radius_mm"]) + oR = float(profile["outer_radius_mm"]) + sa_deg = float(profile["start_angle_deg"]) + ea_deg = float(profile["end_angle_deg"]) + + fr = (oR - ir) / 2.0 # 端盖圆角半径 + sa = math.radians(sa_deg) + ea = math.radians(ea_deg) + + entities, contour = [], [] + + # 角度从工作平面 x_dir 方向测量 → u = r·cos(θ), v = r·sin(θ) + # 1. 内弧(从 start→end) + isu = ir * math.cos(sa); isv = ir * math.sin(sa) + ieu = ir * math.cos(ea); iev = ir * math.sin(ea) + contour.append(_contour_arc( + [isu, isv, 0.0], [ieu, iev, 0.0], + [0.0, 0.0, 0.0], ir, + )) + + # 2. 终端圆角(半圆,从内弧终点到外弧终点) + fc_u = (ir + oR) / 2.0 + fcu_s = fc_u * math.cos(ea); fcv_s = fc_u * math.sin(ea) + osu = oR * math.cos(sa); osv = oR * math.sin(sa) + oeu = oR * math.cos(ea); oev = oR * math.sin(ea) + + contour.append(_contour_arc( + [ieu, iev, 0.0], [oeu, oev, 0.0], + [fcu_s, fcv_s, 0.0], fr, + )) + + # 3. 外弧(从 end→start,反向) + contour.append(_contour_arc( + [oeu, oev, 0.0], [osu, osv, 0.0], + [0.0, 0.0, 0.0], oR, + )) + + # 4. 起始端圆角(从外弧起点到内弧起点) + fcu_e = fc_u * math.cos(sa); fcv_e = fc_u * math.sin(sa) + contour.append(_contour_arc( + [osu, osv, 0.0], [isu, isv, 0.0], + [fcu_e, fcv_e, 0.0], fr, + )) + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +# ═══════════════════════════════════════════════════════════════ +# 程序化重复切口 +# ═══════════════════════════════════════════════════════════════ + +def _poly_contour(vertices: list[tuple[float, float]]) -> list[_Ctx]: + """把按顺序给出的二维顶点变成闭合直线轮廓。""" + return [ + _contour_line( + [vertices[i][0], vertices[i][1], 0.0], + [vertices[(i + 1) % len(vertices)][0], vertices[(i + 1) % len(vertices)][1], 0.0], + ) + for i in range(len(vertices)) + ] + + +def _transform_pattern_contour( + contour: list[_Ctx], + x_mm: float, + y_mm: float, + angle_deg: float, + scale: float = 1.0, +) -> list[_Ctx]: + """旋转、缩放并平移一个二维轮廓。""" + a = math.radians(angle_deg) + ca, sa = math.cos(a), math.sin(a) + + def point(p: list[float]) -> list[float]: + x, y = float(p[0]) * scale, float(p[1]) * scale + return [x_mm + x * ca - y * sa, y_mm + x * sa + y * ca, 0.0] + + result: list[_Ctx] = [] + for edge in contour: + item = deepcopy(edge) + item["start_mm"] = point(edge["start_mm"]) + item["end_mm"] = point(edge["end_mm"]) + if edge.get("center_mm") is not None: + item["center_mm"] = point(edge["center_mm"]) + if edge.get("radius_mm") is not None: + item["radius_mm"] = float(edge["radius_mm"]) * scale + result.append(item) + return result + + +def _pattern_motif_contour(motif: _Ctx) -> list[_Ctx]: + """从少量命名尺寸生成一个切口母形。""" + kind = str(motif.get("type") or "") + + if kind == "circle": + radius = float(motif["radius_mm"]) + return _gen_circle({"type": "circle", "radius_mm": radius}, {})[1] + + if kind in ("square", "rectangle"): + width = float(motif["width_mm"]) + height = float(motif.get("height_mm") or width) + return _rect_lines_and_contour(-width / 2.0, -height / 2.0, width / 2.0, height / 2.0)[1] + + if kind == "obround": + length = float(motif["length_mm"]) + width = float(motif["width_mm"]) + return _gen_obround( + {"type": "obround", "center": [0.0, 0.0], "length_mm": length, "width_mm": width}, + {}, + )[1] + + if kind == "cross": + size = float(motif["size_mm"]) + arm = float(motif["arm_width_mm"]) + half, arm_half = size / 2.0, arm / 2.0 + vertices = [ + (-arm_half, -half), (arm_half, -half), + (arm_half, -arm_half), (half, -arm_half), + (half, arm_half), (arm_half, arm_half), + (arm_half, half), (-arm_half, half), + (-arm_half, arm_half), (-half, arm_half), + (-half, -arm_half), (-arm_half, -arm_half), + ] + return _poly_contour(vertices) + + if kind == "d_shape_polygon": + stem = float(motif["stem_length_mm"]) + nose = float(motif["nose_depth_mm"]) + half_height = float(motif["half_height_mm"]) + segments = int(motif.get("arc_segments") or 14) + vertices = [(-stem, -half_height), (-stem, half_height), (0.0, half_height)] + # 右半椭圆;首尾端点已由直线给出,内部取样由引擎固化。 + for i in range(1, segments): + angle = math.pi / 2.0 - math.pi * i / segments + vertices.append((nose * math.cos(angle), half_height * math.sin(angle))) + vertices.append((0.0, -half_height)) + return _poly_contour(vertices) + + if kind == "regular_hexagon": + radius = float(motif["radius_mm"]) + return _poly_contour([ + ( + radius * math.cos(math.radians(60.0 * i)), + radius * math.sin(math.radians(60.0 * i)), + ) + for i in range(6) + ]) + + if kind == "skew_hexagon": + # 该族来自六边形母形的非对称离散模板;只保留一个名义半径, + # 其余稳定比例由引擎固化,不把六个顶点写进 CDSL。 + radius = float(motif["nominal_radius_mm"]) + return _poly_contour([ + (radius, 0.0), + (radius * 0.317014, radius * 0.682, ), + (-radius * 0.5, radius * 0.682), + (-radius * 1.183014, 0.0), + (-radius * 0.408494, -radius * 0.774519), + (radius * 0.317014, -radius * 0.774519), + ]) + + if kind == "triangle": + radius = float(motif["radius_mm"]) + return _poly_contour([ + ( + radius * math.cos(math.radians(120.0 * i)), + radius * math.sin(math.radians(120.0 * i)), + ) + for i in range(3) + ]) + + if kind == "teardrop_polygon": + if motif.get("left_width_mm") is not None: + left = float(motif["left_width_mm"]) + right = float(motif["right_width_mm"]) + tip = float(motif["tip_height_mm"]) + bottom = -float(motif["bottom_depth_mm"]) + shoulder = float(motif["shoulder_height_mm"]) + return _poly_contour([ + (0.0, tip), + (right, shoulder), + (right, bottom), + (-left, bottom), + (-left, shoulder), + ]) + width = float(motif["width_mm"]) + height = float(motif["height_mm"]) + shoulder = float(motif.get("shoulder_fraction") or 0.58) + half = width / 2.0 + top = height / 2.0 + bottom = -height / 2.0 + shoulder_y = bottom + height * shoulder + return _poly_contour([ + (0.0, top), + (half, shoulder_y), + (half, bottom), + (-half, bottom), + (-half, shoulder_y), + ]) + + if kind == "trapezoid": + bottom = float(motif["bottom_width_mm"]) + top = float(motif["top_width_mm"]) + height = float(motif["height_mm"]) + hh = height / 2.0 + return _poly_contour([ + (-bottom / 2.0, -hh), + (bottom / 2.0, -hh), + (top / 2.0, hh), + (-top / 2.0, hh), + ]) + + if kind == "annular_sector_polygon": + inner = float(motif["inner_radius_mm"]) + outer = float(motif["outer_radius_mm"]) + half_angle = float(motif["half_angle_deg"]) + segments = int(motif.get("arc_segments") or 8) + outer_pts = [ + ( + outer * math.cos(math.radians(-half_angle + 2.0 * half_angle * i / segments)), + outer * math.sin(math.radians(-half_angle + 2.0 * half_angle * i / segments)), + ) + for i in range(segments + 1) + ] + inner_pts = [ + ( + inner * math.cos(math.radians(half_angle - 2.0 * half_angle * i / segments)), + inner * math.sin(math.radians(half_angle - 2.0 * half_angle * i / segments)), + ) + for i in range(segments + 1) + ] + return _poly_contour(outer_pts + inner_pts) + + raise ValueError(f"patterned_cutouts: unsupported motif type {kind!r}") + + +def _pattern_placements(layout: _Ctx) -> list[tuple[float, float, float, float]]: + """展开语义布局,返回 (x, y, rotation_deg, scale)。""" + kind = str(layout.get("type") or "") + orientation = str(layout.get("orientation") or "fixed") + orientation_offset = float(layout.get("orientation_offset_deg") or 0.0) + + def orient(angle: float) -> float: + if orientation == "radial": + return angle + orientation_offset + if orientation == "tangential": + return angle + 90.0 + orientation_offset + if orientation == "snapped_radial": + snap = float(layout.get("orientation_snap_deg") or 45.0) + return round(angle / snap) * snap + orientation_offset + return orientation_offset + + if kind in ("ring", "angular"): + radius = float(layout.get("radius_mm") or 0.0) + count = int(layout["count"]) + start = float(layout.get("start_angle_deg") or 0.0) + step = float(layout.get("angle_step_deg") or (360.0 / count)) + angular_only = kind == "angular" + return [ + ( + 0.0 if angular_only else radius * math.cos(math.radians(start + i * step)), + 0.0 if angular_only else radius * math.sin(math.radians(start + i * step)), + orient(start + i * step), + 1.0, + ) + for i in range(count) + ] + + if kind == "concentric_rings": + result: list[tuple[float, float, float, float]] = [] + for ring in layout.get("rings") or []: + merged = dict(layout) + merged.update(ring) + merged["type"] = "ring" + result.extend(_pattern_placements(merged)) + return result + + if kind == "disc_grid": + nx, ny = int(layout["count_x"]), int(layout["count_y"]) + sx, sy = float(layout["spacing_x_mm"]), float(layout["spacing_y_mm"]) + center = layout.get("center_mm") or [0.0, 0.0] + x0 = float(center[0]) - (nx - 1) * sx / 2.0 + y0 = float(center[1]) - (ny - 1) * sy / 2.0 + limit = layout.get("max_center_radius_mm") + points = [ + (x0 + i * sx, y0 + j * sy) + for j in range(ny) + for i in range(nx) + ] + if limit is not None: + points = [(x, y) for x, y in points if math.hypot(x, y) <= float(limit) + 1e-9] + return [(x, y, orientation_offset, 1.0) for x, y in points] + + if kind == "open_arc": + radius = float(layout["radius_mm"]) + count = int(layout["count"]) + start, end = float(layout["start_angle_deg"]), float(layout["end_angle_deg"]) + step = 0.0 if count == 1 else (end - start) / (count - 1) + return [ + ( + radius * math.cos(math.radians(start + i * step)), + radius * math.sin(math.radians(start + i * step)), + orient(start + i * step), + 1.0, + ) + for i in range(count) + ] + + if kind == "spiral": + count = int(layout["count"]) + start_radius = float(layout["start_radius_mm"]) + radius_step = float(layout["radius_step_mm"]) + start_angle = float(layout.get("start_angle_deg") or 0.0) + angle_step = float(layout["angle_step_deg"]) + result = [] + for i in range(count): + radius = start_radius + i * radius_step + angle = start_angle + i * angle_step + result.append(( + radius * math.cos(math.radians(angle)), + radius * math.sin(math.radians(angle)), + orient(angle), + 1.0, + )) + return result + + if kind == "cross_lines": + count = int(layout["count_per_axis"]) + spacing = float(layout["spacing_mm"]) + start = -(count - 1) * spacing / 2.0 + result = [] + for i in range(count): + value = start + i * spacing + result.append((value, 0.0, orientation_offset, 1.0)) + result.append((0.0, value, orientation_offset + 90.0, 1.0)) + return result + + if kind == "x_field": + levels = int(layout["levels"]) + spacing = float(layout["spacing_mm"]) + start = -(levels - 1) * spacing / 2.0 + result = [] + for i in range(levels): + value = start + i * spacing + if abs(value) < 1e-9: + rotation = 135.0 + orientation_offset if orientation == "diagonal_axes" else orientation_offset + result.append((0.0, 0.0, rotation, 1.0)) + else: + for y in (value, -value): + if orientation == "diagonal_axes": + rotation = (135.0 if value * y > 0 else 45.0) + orientation_offset + else: + angle = math.degrees(math.atan2(y, value)) + rotation = orient(angle) + result.append((value, y, rotation, 1.0)) + return result + + if kind == "twin_strips": + x_offset = float(layout["x_offset_mm"]) + count = int(layout["count_y"]) + y_start = float(layout["y_start_mm"]) + y_end = float(layout["y_end_mm"]) + step = 0.0 if count == 1 else (y_end - y_start) / (count - 1) + return [ + (x, y_start + j * step, orientation_offset, 1.0) + for j in range(count) + for x in (-x_offset, x_offset) + ] + + if kind == "corner_clusters": + levels = [float(v) for v in (layout.get("levels_mm") or [])] + return [ + (sx * x, sy * y, orientation_offset, 1.0) + for sx in (-1.0, 1.0) + for sy in (-1.0, 1.0) + for y in levels + for x in levels + ] + + if kind == "diamond_field": + radius = int(layout["manhattan_radius"]) + spacing = float(layout["spacing_mm"]) + return [ + (i * spacing, j * spacing, orientation_offset, 1.0) + for distance in range(radius + 1) + for j in range(-radius, radius + 1) + for i in range(-radius, radius + 1) + if abs(i) + abs(j) == distance + ] + + raise ValueError(f"patterned_cutouts: unsupported layout type {kind!r}") + + +def _gen_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """一个母形 + 一个语义布局,运行时展开成多个独立切除区域。""" + motif = profile.get("motif") or {} + layout = profile.get("layout") or {} + base = _pattern_motif_contour(motif) + regions = [] + for x, y, angle, scale in _pattern_placements(layout): + regions.append({ + "outer": _transform_pattern_contour(base, x, y, angle, scale), + "holes": [], + }) + if not regions: + raise ValueError("patterned_cutouts: layout produced no regions") + meta["_regions"] = regions + return [], [] + + +def _gen_compound_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """把少量不同母形/布局的程序化图案合并到同一草图。""" + regions: list[_Ctx] = [] + for pattern in profile.get("patterns") or []: + motif = pattern.get("motif") or {} + layout = pattern.get("layout") or {} + base = _pattern_motif_contour(motif) + for x, y, angle, scale in _pattern_placements(layout): + regions.append({ + "outer": _transform_pattern_contour(base, x, y, angle, scale), + "holes": [], + }) + if not regions: + raise ValueError("compound_patterned_cutouts: patterns produced no regions") + meta["_regions"] = regions + return [], [] + + +# ═══════════════════════════════════════════════════════════════ +# Evidence v2 analytic contours +# ═══════════════════════════════════════════════════════════════ + +_ANALYTIC_TOLERANCE_MM = 1e-5 + + +def _distance_2d(left: list[float], right: list[float]) -> float: + return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1])) + + +def _reverse_analytic_edge(edge: _Ctx) -> _Ctx: + result = deepcopy(edge) + result["start_mm"], result["end_mm"] = result["end_mm"], result["start_mm"] + if result.get("type") == "arc" and "clockwise" in result: + result["clockwise"] = not bool(result["clockwise"]) + return result + + +def _join_analytic_edges(edges: list[_Ctx], *, closed: bool) -> list[_Ctx]: + """Order/reorient a contour without depending on SolidWorks segment order.""" + if not edges: + return [] + pending = [deepcopy(edge) for edge in edges] + ordered = [pending.pop(0)] + while pending: + tail = ordered[-1]["end_mm"] + match_index = None + reverse = False + for index, edge in enumerate(pending): + if _distance_2d(tail, edge["start_mm"]) <= _ANALYTIC_TOLERANCE_MM: + match_index = index + break + if _distance_2d(tail, edge["end_mm"]) <= _ANALYTIC_TOLERANCE_MM: + match_index = index + reverse = True + break + if match_index is None: + raise ValueError("analytic_contours: segments do not form a connected contour") + edge = pending.pop(match_index) + ordered.append(_reverse_analytic_edge(edge) if reverse else edge) + if closed and _distance_2d(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _ANALYTIC_TOLERANCE_MM: + raise ValueError("analytic_contours: closed contour endpoints do not meet") + return ordered + + +def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]: + center = segment.get("center") or [0.0, 0.0] + radius = float(segment.get("radius_mm") or 0.0) + if radius <= 0: + raise ValueError("analytic_contours: circle radius_mm must be > 0") + cx, cy = float(center[0]), float(center[1]) + clockwise = bool(segment.get("clockwise", False)) + angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0] + points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles] + return [ + _contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) + for index in range(4) + ] + + +def _analytic_segment_edges(segment: _Ctx) -> list[_Ctx]: + segment_type = segment.get("type") + if segment_type == "line": + return [_contour_line(segment["start"], segment["end"])] + if segment_type == "arc": + return [ + _contour_arc( + segment["start"], segment["end"], segment["center"], + segment.get("radius_mm"), segment.get("clockwise"), + ) + ] + if segment_type == "circle": + return _analytic_circle_edges(segment) + if segment_type == "bspline": + raise ValueError("analytic_contours: bspline requires an explicit approximation capability") + raise ValueError(f"analytic_contours: unsupported segment type {segment_type!r}") + + +def _sample_analytic_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: + """Create a deterministic planar sample only for containment classification.""" + points: list[tuple[float, float]] = [] + for edge in edges: + start = edge["start_mm"] + points.append((float(start[0]), float(start[1]))) + if edge.get("type") != "arc": + continue + center = edge["center_mm"] + end = edge["end_mm"] + sx, sy = float(start[0]) - float(center[0]), float(start[1]) - float(center[1]) + ex, ey = float(end[0]) - float(center[0]), float(end[1]) - float(center[1]) + start_angle = math.atan2(sy, sx) + end_angle = math.atan2(ey, ex) + delta = end_angle - start_angle + if edge.get("clockwise"): + if delta >= 0: + delta -= math.tau + elif delta <= 0: + delta += math.tau + for fraction in (0.25, 0.5, 0.75): + angle = start_angle + delta * fraction + radius = float(edge.get("radius_mm") or math.hypot(sx, sy)) + points.append((float(center[0]) + radius * math.cos(angle), float(center[1]) + radius * math.sin(angle))) + return points + + +def _loop_area(points: list[tuple[float, float]]) -> float: + if len(points) < 3: + return 0.0 + return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0) + + +def _endpoint_signed_area(edges: list[_Ctx]) -> float: + points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges] + return sum( + points[index][0] * points[(index + 1) % len(points)][1] + - points[(index + 1) % len(points)][0] * points[index][1] + for index in range(len(points)) + ) / 2.0 + + +def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None: + """Repair inconsistent sweep flags on a conventional rounded rectangle. + + Evidence exports occasionally label one or more 90-degree corner arcs + with the opposite direction. Honouring those isolated flags creates + 270-degree loops. This normalizer applies only to the unambiguous shape: + exactly four equal-radius quarter arcs in one closed loop. Other arcs, + including annular sectors and long sweeps, retain their captured flags. + """ + arcs = [edge for edge in edges if edge.get("type") == "arc"] + if len(arcs) != 4: + return + radii = [float(edge.get("radius_mm") or 0.0) for edge in arcs] + if min(radii) <= _ANALYTIC_TOLERANCE_MM or max(radii) - min(radii) > _ANALYTIC_TOLERANCE_MM: + return + for edge in arcs: + center = edge.get("center_mm") + if not isinstance(center, list): + return + start, end = edge["start_mm"], edge["end_mm"] + first = (float(start[0]) - float(center[0]), float(start[1]) - float(center[1])) + second = (float(end[0]) - float(center[0]), float(end[1]) - float(center[1])) + angle = abs(math.atan2(first[0] * second[1] - first[1] * second[0], first[0] * second[0] + first[1] * second[1])) + if abs(angle - math.pi / 2) > 1e-4: + return + # A clockwise endpoint loop needs clockwise short corner arcs; a + # counter-clockwise loop needs their reverse. This preserves the actual + # rounded-rectangle boundary, independent of per-segment export noise. + clockwise = _endpoint_signed_area(edges) < 0.0 + for edge in arcs: + edge["clockwise"] = clockwise + + +def _point_in_loop(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool: + if len(loop) < 3: + return False + inside = False + x, y = point + previous = loop[-1] + for current in loop: + x1, y1 = current + x2, y2 = previous + if (y1 > y) != (y2 > y): + intersect_x = (x2 - x1) * (y - y1) / (y2 - y1) + x1 + if x < intersect_x: + inside = not inside + previous = current + return inside + + +def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """Resolve Evidence v2 line/arc/circle loops into engine-neutral regions. + + The returned regions preserve holes and islands. The build adapter owns + B-rep creation; this profile generator only reasons about sketch geometry. + """ + loops: list[_Ctx] = [] + entities: list[_Ctx] = [] + for contour_index, contour in enumerate(profile.get("contours") or []): + if not contour.get("closed"): + raise ValueError(f"analytic_contours: contour {contour_index} is open") + segment_edges: list[_Ctx] = [] + for segment in contour.get("segments") or []: + segment_type = segment.get("type") + if segment_type == "line": + entities.append(_line(segment["start"], segment["end"])) + elif segment_type == "circle": + entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0))) + segment_edges.extend(_analytic_segment_edges(segment)) + if not segment_edges: + continue + edges = _join_analytic_edges(segment_edges, closed=True) + _normalize_quarter_rounding_direction(edges) + points = _sample_analytic_loop(edges) + area = _loop_area(points) + if area <= _ANALYTIC_TOLERANCE_MM * _ANALYTIC_TOLERANCE_MM: + raise ValueError(f"analytic_contours: contour {contour_index} is degenerate") + loops.append({"role": contour.get("role", "unknown"), "edges": edges, "points": points, "area": area}) + + for segment in profile.get("construction") or []: + if segment.get("type") == "line": + entities.append(_line(segment["start"], segment["end"], construction=True)) + elif segment.get("type") == "circle": + entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0), construction=True)) + + if not loops: + return entities, [] + for loop in loops: + # Role tags captured from the source sketch are useful provenance but + # not authoritative geometry. A number of exports label separate + # closed contours as ``inner`` although no outer contour contains + # them. The even-odd containment rule is deterministic for the + # supported analytic curves and preserves those independent regions. + contained_by = sum(_point_in_loop(loop["points"][0], other["points"]) for other in loops if other is not loop) + loop["role"] = "inner" if contained_by % 2 else "outer" + outers = [loop for loop in loops if loop["role"] == "outer"] + inners = [loop for loop in loops if loop["role"] == "inner"] + regions = [{"outer": outer["edges"], "holes": []} for outer in outers] + for inner in inners: + containing = [outer for outer in outers if _point_in_loop(inner["points"][0], outer["points"])] + if not containing: + raise ValueError("analytic_contours: inner contour has no containing outer contour") + selected = min(containing, key=lambda outer: outer["area"]) + regions[outers.index(selected)]["holes"].append(inner["edges"]) + meta["_regions"] = regions + return entities, [] + + +# ═══════════════════════════════════════════════════════════════ +# Runtime profile registry +# +# The CDSL-only runtime accepts direct geometric profiles only. Business +# shapes and procedural sketch macros are retained below solely for explicit +# importer/rebuild compatibility lowering and are not runtime capabilities. +# ═══════════════════════════════════════════════════════════════ + +CORE_SHAPE_GENERATORS: dict[str, Any] = { + "circle": _gen_circle, + "polygon": _gen_polygon, + "analytic_contours": _gen_analytic_contours, +} + +# Import/rebuild compatibility only. New CDSL must be lowered before runtime +# execution instead of adding any of these names to the core registry. +LEGACY_PROFILE_GENERATORS: dict[str, Any] = { + "annulus": _gen_annulus, + "circles": _gen_circles, + "circle_grid": _gen_circle_grid, + "rectangle": _gen_rectangle, + "rectangle_with_circles": _gen_rect_with_circles, + "rectangle_with_fillets": _gen_rect_with_fillets, + "obround": _gen_obround, + "ibone": _gen_ibone, + "rectangle_with_symmetric_notches": _gen_rect_symmetric_notches, + "revolve_chamfer": _gen_revolve_chamfer, + "revolve_chamfer_slanted": _gen_revolve_chamfer_slanted, + # 弧边复合轮廓(按 015133 手册方法注册,同形异构通过参数复用) + "circle_with_arc_notches": _gen_circle_with_arc_notches, + "circular_sector_slot": _gen_circular_sector_slot, + "circle_with_radial_tabs": _gen_circle_with_radial_tabs, + "filleted_rect_side_slots": _gen_filleted_rect_side_slots, + # 弧边形状 + "d_shape": _gen_d_shape, + "partial_ring": _gen_partial_ring, + "partial_ring_with_arc_island": _gen_partial_ring_with_arc_island, + "arc_chain": _gen_arc_chain, + "radial_slot": _gen_radial_slot, + "patterned_cutouts": _gen_patterned_cutouts, + "compound_patterned_cutouts": _gen_compound_patterned_cutouts, +} + +# Historical callers query this name to learn runtime capability. Keep the +# name but make it an alias to the core-only registry. +# This registry is intentionally local to the historical importer. It is +# useful for replaying old exports, but must never be treated as engine +# capability. +SHAPE_GENERATORS = {**CORE_SHAPE_GENERATORS, **LEGACY_PROFILE_GENERATORS} + +# ═══════════════════════════════════════════════════════════════ +# 注册表功能:扩展、查询 +# ═══════════════════════════════════════════════════════════════ + +def register_shape(ptype: str, generator: Any) -> None: + """Runtime profiles are a fixed CDSL contract, not plugin hooks.""" + raise RuntimeError("Runtime profile types are fixed; lower custom profiles before CDSL execution") + + +def list_registered_shapes() -> list[str]: + """返回所有已注册的形状生成器名称。""" + return sorted(SHAPE_GENERATORS.keys()) + + +# ═══════════════════════════════════════════════════════════════ +# 形状能力矩阵(供外部查询:哪些形状可自动检测,哪些需手动指定) +# ═══════════════════════════════════════════════════════════════ + +_ShapeInfo = dict[str, Any] + +LEGACY_SHAPE_CAPABILITIES: dict[str, _ShapeInfo] = { + "circle": {"detectable": True, "arity": "circle", "description": "单圆"}, + "annulus": {"detectable": True, "arity": "circles", "description": "同心圆环"}, + "circles": {"detectable": True, "arity": "circles", "description": "多圆(非同心)"}, + "circle_grid": {"detectable": False, "arity": "circles", "description": "矩形圆孔阵列"}, + "rectangle": {"detectable": True, "arity": "polygon", "description": "4线矩形"}, + "rectangle_with_circles": {"detectable": True, "arity": "mixed", "description": "矩形+内圆孔"}, + "rectangle_with_fillets": {"detectable": False, "arity": "mixed", "description": "圆角矩形(4弧+4线)"}, + "obround": {"detectable": True, "arity": "mixed", "description": "槽形/键槽(2线+2半圆弧)"}, + "polygon": {"detectable": True, "arity": "polygon", "description": "N边多边形"}, + "ibone": {"detectable": False, "arity": "mixed", "description": "工字形凸耳(12线+4弧+4孔)"}, + "rectangle_with_symmetric_notches": {"detectable": False,"arity": "mixed", "description": "对称槽板(矩形+4U形缺口)"}, + "revolve_chamfer": {"detectable": True, "arity": "polygon", "description": "旋转梯形截面"}, + "revolve_chamfer_slanted": {"detectable": True, "arity": "polygon", "description": "旋转斜底梯形截面"}, + "circle_with_arc_notches": {"detectable": False, "arity": "mixed", "description": "圆+均匀弧形凹口"}, + "circular_sector_slot": {"detectable": False, "arity": "mixed", "description": "圆弧扇区+中心矩形槽"}, + "circle_with_radial_tabs": {"detectable": False, "arity": "mixed", "description": "圆+径向矩形凸耳"}, + "filleted_rect_side_slots": {"detectable": False, "arity": "mixed", "description": "圆角矩形+两侧中心U形槽"}, + "d_shape": {"detectable": True, "arity": "mixed", "description": "D形(半圆+弦线)"}, + "partial_ring": {"detectable": True, "arity": "mixed", "description": "部分圆环(扇区环)"}, + "partial_ring_with_arc_island": {"detectable": False, "arity": "mixed", "description": "扇区环+弦上偏移弧岛"}, + "radial_slot": {"detectable": False, "arity": "mixed", "description": "径向弧形槽"}, + "arc_chain": {"detectable": False, "arity": "arcs", "description": "多段弧链轮廓"}, + "patterned_cutouts": {"detectable": False, "arity": "regions", "description": "程序化重复切口"}, + "compound_patterned_cutouts": {"detectable": False, "arity": "regions", "description": "复合程序化重复切口"}, +} + +SHAPE_CAPABILITIES: dict[str, _ShapeInfo] = { + "circle": {"detectable": True, "arity": "circle", "description": "单圆"}, + "polygon": {"detectable": True, "arity": "polygon", "description": "闭合直线轮廓"}, + "analytic_contours": {"detectable": True, "arity": "analytic", "description": "闭合线、圆弧和圆轮廓"}, +} + + +# ═══════════════════════════════════════════════════════════════ +# 主入口 +# ═══════════════════════════════════════════════════════════════ + +def resolve_profile(sketch: dict[str, Any]) -> dict[str, Any]: + """按 type 查找生成器,生成 entities + contour_edges_mm。 + + 对于返回非空 contour 的生成器,会额外保留原始 sketch.entities + 中的非 construction circle 实体(孔洞/圆岛),确保不丢失内部特征。 + """ + profile = sketch.get("profile") + if not profile: + return sketch + + ptype = profile.get("type") + generator = SHAPE_GENERATORS.get(ptype) + if generator is None: + raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {ptype!r}") + + meta = {"id": sketch.get("id"), "name": sketch.get("name"), "_entities": sketch.get("entities"), "_contour": sketch.get("contour_edges_mm")} + entities, contour = generator(profile, meta) + + out = deepcopy(sketch) + + # 保留原始草图中的非 construction circle 实体(这些是内部孔洞/圆岛) + orig_ents = sketch.get("entities") or [] + keep_circles = [ + e for e in orig_ents + if e.get("type") == "circle" and not e.get("construction") + ] + if keep_circles and contour: + # 只对生成器产出 contour 的场合保留 circles(轮廓生成器 + 内部圆孔) + entities = list(entities) + keep_circles + + out["entities"] = entities + wp = sketch.get("workplane") + if contour: + out["contour_edges_mm"] = _transform_contours(contour, wp) if wp else contour + regions = meta.get("_regions") or [] + if regions: + out["contour_regions_mm"] = [ + { + "outer": _transform_contours(reg["outer"], wp) if wp else reg["outer"], + "holes": [ + _transform_contours(hole, wp) if wp else hole + for hole in (reg.get("holes") or []) + ], + } + for reg in regions + ] + return out + + +def resolve_all_sketches(cdsl: dict[str, Any]) -> dict[str, Any]: + """对 CDSL 中所有带 profile 字段的草图进行解析。 + + 支持 profile_from 字段:引用另一个草图的 profile,避免重复。 + 例:sk_05: {"profile_from": "sk_03"} → 使用 sk_03 的 profile。 + """ + geom = cdsl.get("geometry") or {} + sketches = geom.get("sketches") or [] + + # 第一遍: 解析所有有自己 profile 的草图 + resolved: dict[str, dict] = {} + for sk in sketches: + sid = sk.get("id") + if sid is None: + continue + if "profile" in sk: + resolved[sid] = resolve_profile(sk) + + # 第二遍: 解析 profile_from 引用(支持 profile_shift 偏移) + for sk in sketches: + sid = sk.get("id") + pf = sk.get("profile_from") + if pf and sid: + src = resolved.get(pf) + if src is None: + raise ValueError( + f"sketch {sid}: profile_from={pf!r} not found or not yet resolved" + ) + sk2 = deepcopy(sk) + sk2["profile"] = deepcopy(src.get("profile")) + sk2.pop("profile_from", None) + + # profile_shift: 对 polygon 顶点做 2D 偏移(同形异构共享) + shift = sk.get("profile_shift") + if shift and len(shift) == 2 and sk2["profile"].get("type") == "polygon": + du, dv = float(shift[0]), float(shift[1]) + for v in sk2["profile"]["vertices"]: + v[0] = round(v[0] + du, 6) + v[1] = round(v[1] + dv, 6) + sk2.pop("profile_shift", None) + resolved[sid] = resolve_profile(sk2) + + # 按原顺序输出 + result = [] + for sk in sketches: + sid = sk.get("id") + if sid and sid in resolved: + result.append(resolved[sid]) + else: + result.append(deepcopy(sk)) + + out = deepcopy(cdsl) + out.setdefault("geometry", {})["sketches"] = result + return out + + +def resolve_required_sketches( + cdsl: dict[str, Any], + sketch_ids: Iterable[str], + *, + errors: dict[str, str] | None = None, +) -> dict[str, Any]: + """Resolve only profiles that an executable feature actually consumes. + + ``profile_from`` dependencies are resolved recursively. Callers that + pass ``errors`` get feature-addressable failures without losing unrelated + resolved sketches; callers that omit it retain the strict exception + behavior useful to profile tooling. + """ + sketches = list((cdsl.get("geometry") or {}).get("sketches") or []) + by_id = {str(sketch.get("id")): sketch for sketch in sketches if sketch.get("id") is not None} + resolved: dict[str, dict[str, Any]] = {} + resolving: set[str] = set() + + def resolve_one(sketch_id: str) -> dict[str, Any]: + if sketch_id in resolved: + return resolved[sketch_id] + sketch = by_id.get(sketch_id) + if sketch is None: + raise ValueError(f"sketch {sketch_id!r} was not found") + if sketch_id in resolving: + raise ValueError(f"sketch {sketch_id}: profile_from contains a cycle") + resolving.add(sketch_id) + try: + if "profile" in sketch: + output = resolve_profile(sketch) + elif sketch.get("profile_from"): + source_id = str(sketch["profile_from"]) + source = resolve_one(source_id) + if not source.get("profile"): + raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} has no profile") + output = deepcopy(sketch) + output["profile"] = deepcopy(source["profile"]) + output.pop("profile_from", None) + shift = sketch.get("profile_shift") + if shift and len(shift) == 2 and output["profile"].get("type") == "polygon": + du, dv = float(shift[0]), float(shift[1]) + for vertex in output["profile"]["vertices"]: + vertex[0] = round(vertex[0] + du, 6) + vertex[1] = round(vertex[1] + dv, 6) + output.pop("profile_shift", None) + output = resolve_profile(output) + else: + output = deepcopy(sketch) + resolved[sketch_id] = output + return output + finally: + resolving.discard(sketch_id) + + for sketch_id in {str(item) for item in sketch_ids}: + try: + resolve_one(sketch_id) + except ValueError as error: + if errors is None: + raise + errors[sketch_id] = str(error) + + output = deepcopy(cdsl) + output.setdefault("geometry", {})["sketches"] = [ + resolved.get(str(sketch.get("id")), deepcopy(sketch)) + for sketch in sketches + ] + return output diff --git a/backend/engine/cdsl_engine/convert_to_cdsl.py b/backend/cdsl_importer/solidworks_to_cdsl.py similarity index 98% rename from backend/engine/cdsl_engine/convert_to_cdsl.py rename to backend/cdsl_importer/solidworks_to_cdsl.py index b0161fdc..7c45f9ba 100644 --- a/backend/engine/cdsl_engine/convert_to_cdsl.py +++ b/backend/cdsl_importer/solidworks_to_cdsl.py @@ -26,9 +26,9 @@ from pathlib import Path from typing import Any, Callable try: - from .translator import normalize_to_ir -except ImportError: # 允许直接 python engine/convert_to_cdsl.py - from translator import normalize_to_ir + from engine.cdsl_engine.translator import normalize_to_ir +except ModuleNotFoundError: # Engine root added to sys.path by product runtime. + from cdsl_engine.translator import normalize_to_ir # ═══════════════════════════════════════════════════════════════ @@ -882,8 +882,16 @@ def convert_sw_json_to_cdsl( }, } + # The import classifier may use historical shape labels while recognizing + # a SolidWorks sketch. Lower them before exposing the result as CDSL so + # the runtime contract remains line/arc/circle/contour based. + try: + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + except ImportError: # pragma: no cover - direct converter invocation + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + cdsl = _clean_cdsl_floats(cdsl) - return cdsl + return lower_legacy_profiles(cdsl) def op_to_raw(feat: dict, op: dict, all_ops: list) -> dict | None: diff --git a/backend/engine/cdsl_engine/__init__.py b/backend/engine/cdsl_engine/__init__.py index 3afc935d..39c490f4 100644 --- a/backend/engine/cdsl_engine/__init__.py +++ b/backend/engine/cdsl_engine/__init__.py @@ -6,22 +6,19 @@ The package exposes the CDSL-only rebuild API used by the backend Agent. from __future__ import annotations -from .convert_to_cdsl import convert_sw_json_to_cdsl, write_cdsl_outputs from .llm_compiler import compile_cdsl from .llm_engine import run_engine_plan from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run_engine, run_rebuild from .semantic_validation import validate_semantic_cdsl -from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches +from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches from .runtime import ALL_ATOMIC_IDS, EXECUTORS, RuntimeExecutionError, analyze_cdsl, rebuild_cdsl -from .design_intent import DesignIntentError, design_intent_from_cdsl, validate_design_intent, validate_intent_cdsl # The package-level runtime contract is the session executor registry. The # older llm_engine dispatcher remains available only for legacy engine packs. SUPPORTED_ATOMIC_IDS = ALL_ATOMIC_IDS +SHAPE_GENERATORS = CORE_SHAPE_GENERATORS __all__ = [ - "convert_sw_json_to_cdsl", - "write_cdsl_outputs", "compile_cdsl", "compile_cdsl_to_pack", "run_engine_plan", @@ -31,6 +28,7 @@ __all__ = [ "compare_with_gold", "resolve_all_sketches", "resolve_required_sketches", + "CORE_SHAPE_GENERATORS", "SHAPE_GENERATORS", "SUPPORTED_ATOMIC_IDS", "validate_semantic_cdsl", @@ -39,10 +37,6 @@ __all__ = [ "rebuild_cdsl", "analyze_cdsl", "RuntimeExecutionError", - "DesignIntentError", - "validate_design_intent", - "validate_intent_cdsl", - "design_intent_from_cdsl", ] __version__ = "1.0.0" diff --git a/backend/engine/cdsl_engine/batch_rebuild.py b/backend/engine/cdsl_engine/batch_rebuild.py index 7260a356..6b2ea707 100644 --- a/backend/engine/cdsl_engine/batch_rebuild.py +++ b/backend/engine/cdsl_engine/batch_rebuild.py @@ -13,6 +13,7 @@ from collections import Counter from pathlib import Path from typing import Any, Iterable +from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles from .semantic_validation import validate_semantic_cdsl @@ -225,7 +226,7 @@ def analyze_document( "failure_category": "preflight_blocked", } try: - cdsl = json.loads(path.read_text(encoding="utf-8")) + cdsl = lower_legacy_profiles(json.loads(path.read_text(encoding="utf-8"))) report["part_id"] = str(cdsl.get("part_id") or report["part_id"]) semantic = validate_semantic_cdsl(cdsl) report["semantic_valid"] = True diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 0e81abcd..6537e2df 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -5,7 +5,7 @@ from __future__ import annotations import math from typing import Any, Iterable -from build123d import Axis, Edge, Face, Plane, Solid, Vector, Wire, export_step +from build123d import Axis, Compound, Edge, Face, Plane, Solid, Vector, Wire, export_step from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature @@ -201,13 +201,23 @@ class Build123dGeometryAdapter: def revolve(face: Face, angle_deg: float, axis: AxisSpec) -> Solid: return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis)) + @staticmethod + def _body_shape(value: Any) -> Any: + """Normalize boolean results, including disconnected ShapeList values.""" + if hasattr(value, "bounding_box"): + return value + shapes = list(value) + if not shapes: + raise ValueError("Boolean operation produced no shapes") + return shapes[0] if len(shapes) == 1 else Compound(shapes) + @staticmethod def fuse(body: Any | None, solid: Solid) -> Any: - return solid if body is None else body.fuse(solid) + return solid if body is None else Build123dGeometryAdapter._body_shape(body.fuse(solid)) @staticmethod def cut(body: Any, tool: Any) -> Any: - return body.cut(tool) + return Build123dGeometryAdapter._body_shape(body.cut(tool)) @staticmethod def sphere(radius_mm: float, center_mm: Vector3) -> Solid: diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index a8e05d61..ece7b8be 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -258,24 +258,18 @@ "analyticSegment": { "type": "object", "properties": { - "type": {"enum": ["line", "arc", "circle", "bspline"]}, + "type": {"enum": ["line", "arc", "circle"]}, "start": {"$ref": "#/$defs/point2"}, "end": {"$ref": "#/$defs/point2"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, - "clockwise": {"type": "boolean"}, - "degree": {"type": "integer", "minimum": 1}, - "control_points": {"type": "array", "items": {"$ref": "#/$defs/point2"}}, - "knots": {"type": "array", "items": {"$ref": "#/$defs/number"}}, - "weights": {"type": "array", "items": {"$ref": "#/$defs/positive"}}, - "periodic": {"type": "boolean"} + "clockwise": {"type": "boolean"} }, "required": ["type"], "allOf": [ {"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start", "end"]}}, {"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start", "end", "center", "radius_mm"]}}, - {"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}}, - {"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["degree", "control_points", "knots"]}} + {"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}} ], "additionalProperties": false }, @@ -334,87 +328,11 @@ {"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}} ] }, - "rectangleBounds": { - "type": "object", - "properties": {"center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}}, - "allOf": [ - {"oneOf": [ - {"required": ["center", "width_mm", "height_mm"], "not": {"anyOf": [{"required": ["min_mm"]}, {"required": ["max_mm"]}]}}, - {"required": ["min_mm", "max_mm"], "not": {"anyOf": [{"required": ["center"]}, {"required": ["width_mm"]}, {"required": ["height_mm"]}]}} - ]} - ] - }, - "rectangleBoundary": { - "type": "object", - "properties": {"type": {"enum": ["rectangle", "rectangle_with_fillets"]}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}, "fillet_radius_mm": {"$ref": "#/$defs/positive"}}, - "allOf": [{"$ref": "#/$defs/rectangleBounds"}], - "additionalProperties": false - }, - "profile_type": {"enum": ["circle", "annulus", "circles", "circle_grid", "rectangle", "rectangle_with_circles", "rectangle_with_fillets", "obround", "polygon", "ibone", "rectangle_with_symmetric_notches", "revolve_chamfer", "revolve_chamfer_slanted", "circle_with_arc_notches", "circular_sector_slot", "circle_with_radial_tabs", "filleted_rect_side_slots", "d_shape", "partial_ring", "partial_ring_with_arc_island", "radial_slot", "arc_chain", "patterned_cutouts", "compound_patterned_cutouts", "analytic_contours"]}, - "motif_type": {"enum": ["circle", "square", "rectangle", "obround", "cross", "d_shape_polygon", "regular_hexagon", "skew_hexagon", "triangle", "teardrop_polygon", "trapezoid", "annular_sector_polygon"]}, - "layout_type": {"enum": ["ring", "angular", "concentric_rings", "disc_grid", "open_arc", "spiral", "cross_lines", "x_field", "twin_strips", "corner_clusters", "diamond_field"]}, - "motif": { - "oneOf": [ - {"type": "object", "properties": {"type": {"const": "circle"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "square"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "rectangle"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "width_mm", "height_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "obround"}, "length_mm": {"$ref": "#/$defs/positive"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "length_mm", "width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "cross"}, "size_mm": {"$ref": "#/$defs/positive"}, "arm_width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "size_mm", "arm_width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "d_shape_polygon"}, "stem_length_mm": {"$ref": "#/$defs/positive"}, "nose_depth_mm": {"$ref": "#/$defs/positive"}, "half_height_mm": {"$ref": "#/$defs/positive"}, "arc_segments": {"$ref": "#/$defs/positiveInteger"}}, "required": ["type", "stem_length_mm", "nose_depth_mm", "half_height_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "regular_hexagon"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "skew_hexagon"}, "nominal_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "nominal_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "triangle"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "teardrop_polygon"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "shoulder_fraction": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1}, "left_width_mm": {"$ref": "#/$defs/positive"}, "right_width_mm": {"$ref": "#/$defs/positive"}, "tip_height_mm": {"$ref": "#/$defs/positive"}, "bottom_depth_mm": {"$ref": "#/$defs/positive"}, "shoulder_height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type"], "oneOf": [{"required": ["width_mm", "height_mm"], "not": {"anyOf": [{"required": ["left_width_mm"]}, {"required": ["right_width_mm"]}, {"required": ["tip_height_mm"]}, {"required": ["bottom_depth_mm"]}, {"required": ["shoulder_height_mm"]}]}}, {"required": ["left_width_mm", "right_width_mm", "tip_height_mm", "bottom_depth_mm", "shoulder_height_mm"], "not": {"anyOf": [{"required": ["width_mm"]}, {"required": ["height_mm"]}, {"required": ["shoulder_fraction"]}]}}], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "trapezoid"}, "bottom_width_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "bottom_width_mm", "top_width_mm", "height_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "annular_sector_polygon"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "arc_segments": {"$ref": "#/$defs/positiveInteger"}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "half_angle_deg"], "additionalProperties": false} - ] - }, - "layoutCommon": { - "type": "object", - "properties": {"orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}} - }, - "layout": { - "oneOf": [ - {"type": "object", "properties": {"type": {"const": "ring"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm", "count"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "angular"}, "radius_mm": {"$ref": "#/$defs/number"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "count"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "concentric_rings"}, "rings": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": {"radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}}, "required": ["radius_mm", "count"], "additionalProperties": false}}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "rings"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "disc_grid"}, "count_x": {"$ref": "#/$defs/positiveInteger"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "spacing_x_mm": {"$ref": "#/$defs/positive"}, "spacing_y_mm": {"$ref": "#/$defs/positive"}, "center_mm": {"$ref": "#/$defs/point2"}, "max_center_radius_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "open_arc"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm", "count", "start_angle_deg", "end_angle_deg"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "spiral"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_radius_mm": {"$ref": "#/$defs/positive"}, "radius_step_mm": {"$ref": "#/$defs/number"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "count", "start_radius_mm", "radius_step_mm", "angle_step_deg"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "cross_lines"}, "count_per_axis": {"$ref": "#/$defs/positiveInteger"}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "count_per_axis", "spacing_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "x_field"}, "levels": {"$ref": "#/$defs/positiveInteger"}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "levels", "spacing_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "twin_strips"}, "x_offset_mm": {"$ref": "#/$defs/positive"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "y_start_mm": {"$ref": "#/$defs/number"}, "y_end_mm": {"$ref": "#/$defs/number"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "x_offset_mm", "count_y", "y_start_mm", "y_end_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "corner_clusters"}, "levels_mm": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/positive"}}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "levels_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "diamond_field"}, "manhattan_radius": {"type": "integer", "minimum": 0}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "manhattan_radius", "spacing_mm"], "additionalProperties": false} - ] - }, - "pattern": {"type": "object", "properties": {"motif": {"$ref": "#/$defs/motif"}, "layout": {"$ref": "#/$defs/layout"}}, "required": ["motif", "layout"], "additionalProperties": false}, + "profile_type": {"enum": ["circle", "polygon", "analytic_contours"]}, "profile": { "oneOf": [ {"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "annulus"}, "center": {"$ref": "#/$defs/point2"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "inner_radius_mm", "outer_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circles"}, "items": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "items"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circle_grid"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count_x": {"$ref": "#/$defs/positiveInteger"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "spacing_x_mm": {"$ref": "#/$defs/positive"}, "spacing_y_mm": {"$ref": "#/$defs/positive"}, "origin_mm": {"$ref": "#/$defs/point2"}, "center_mm": {"$ref": "#/$defs/point2"}}, "required": ["type", "radius_mm", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], "additionalProperties": false, "oneOf": [{"required": ["origin_mm"], "not": {"required": ["center_mm"]}}, {"required": ["center_mm"], "not": {"required": ["origin_mm"]}}]}, - {"type": "object", "properties": {"type": {"const": "rectangle"}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}}, "required": ["type"], "allOf": [{"$ref": "#/$defs/rectangleBounds"}], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "rectangle_with_circles"}, "boundary": {"$ref": "#/$defs/rectangleBoundary"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "boundary"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "rectangle_with_fillets"}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}, "fillet_radius_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type"], "allOf": [{"$ref": "#/$defs/rectangleBounds"}], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "obround"}, "center": {"$ref": "#/$defs/point2"}, "length_mm": {"$ref": "#/$defs/positive"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "length_mm", "width_mm"], "additionalProperties": false}, {"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "ibone"}, "body_width_mm": {"$ref": "#/$defs/positive"}, "body_height_mm": {"$ref": "#/$defs/positive"}, "flange_width_mm": {"$ref": "#/$defs/positive"}, "flange_height_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}, "hole_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "body_width_mm", "body_height_mm", "flange_width_mm", "flange_height_mm", "corner_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "rectangle_with_symmetric_notches"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "notch": {"type": "object", "properties": {"y_start": {"$ref": "#/$defs/number"}, "y_end": {"$ref": "#/$defs/number"}, "depth_mm": {"$ref": "#/$defs/positive"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["y_start", "y_end", "depth_mm"], "additionalProperties": false}}, "required": ["type", "width_mm", "height_mm", "notch"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "revolve_chamfer"}, "axis_height_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "bottom_width_mm": {"$ref": "#/$defs/positive"}, "wall_inset_mm": {"$ref": "#/$defs/number"}, "step_inset_mm": {"$ref": "#/$defs/number"}, "on_axis_side": {"enum": ["left", "right"]}}, "required": ["type", "axis_height_mm", "top_width_mm", "bottom_width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "revolve_chamfer_slanted"}, "axis_height_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "wall_inset_mm": {"$ref": "#/$defs/number"}, "wall_height_mm": {"$ref": "#/$defs/positive"}, "wall_width_mm": {"$ref": "#/$defs/positive"}, "on_axis_side": {"enum": ["left", "right"]}}, "required": ["type", "axis_height_mm", "top_width_mm", "wall_height_mm", "wall_width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circle_with_arc_notches"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "notch_radius_mm": {"$ref": "#/$defs/positive"}, "notch_angles_deg": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/number"}}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "outer_radius_mm", "notch_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circular_sector_slot"}, "arc_radius_mm": {"$ref": "#/$defs/positive"}, "slot_half_width_mm": {"$ref": "#/$defs/positive"}, "chord_half_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "arc_radius_mm", "slot_half_width_mm", "chord_half_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circle_with_radial_tabs"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "tab_u_half_mm": {"$ref": "#/$defs/positive"}, "tab_v_offset_mm": {"$ref": "#/$defs/number"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "outer_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "filleted_rect_side_slots"}, "half_width_mm": {"$ref": "#/$defs/positive"}, "half_height_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}, "slot_radius_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "half_width_mm", "half_height_mm", "corner_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "d_shape"}, "radius_mm": {"$ref": "#/$defs/positive"}, "chord_sign": {"enum": ["left", "right"]}, "chord_x_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "partial_ring"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "partial_ring_with_arc_island"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "island_radius_mm": {"$ref": "#/$defs/positive"}, "island_gap_mm": {"$ref": "#/$defs/positive"}, "center_angles_deg": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/number"}}, "replicas": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": {"center_angle_deg": {"$ref": "#/$defs/number"}}, "required": ["center_angle_deg"], "additionalProperties": false}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "island_radius_mm"], "additionalProperties": false, "oneOf": [{"required": ["center_angles_deg"], "not": {"required": ["replicas"]}}, {"required": ["replicas"], "not": {"required": ["center_angles_deg"]}}]}, - {"type": "object", "properties": {"type": {"const": "radial_slot"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "start_angle_deg", "end_angle_deg"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "arc_chain"}, "arcs": {"type": "array", "minItems": 2, "items": {"type": "object", "properties": {"center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}}, "required": ["radius_mm", "start_angle_deg", "end_angle_deg"], "additionalProperties": false}}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "arcs"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "patterned_cutouts"}, "motif": {"$ref": "#/$defs/motif"}, "layout": {"$ref": "#/$defs/layout"}}, "required": ["type", "motif", "layout"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "compound_patterned_cutouts"}, "patterns": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/pattern"}}}, "required": ["type", "patterns"], "additionalProperties": false}, {"$ref": "#/$defs/analyticProfile"} ] }, diff --git a/backend/engine/cdsl_engine/design_intent.py b/backend/engine/cdsl_engine/design_intent.py deleted file mode 100644 index 76c18b3f..00000000 --- a/backend/engine/cdsl_engine/design_intent.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Validation for the semantic planning artifact between a request and CDSL.""" - -from __future__ import annotations - -import json -from copy import deepcopy -from pathlib import Path -from typing import Any - -from jsonschema import Draft202012Validator -from jsonschema.exceptions import SchemaError - - -class DesignIntentError(ValueError): - """A plan-contract violation with a stable error code for the Agent.""" - - def __init__(self, code: str, message: str) -> None: - super().__init__(message) - self.code = code - - -def _schema_path(engine: Any) -> Path: - return Path(str(engine.__file__)).with_name("design_intent_schema.json") - - -def load_design_intent_schema(engine: Any) -> dict[str, Any]: - path = _schema_path(engine) - try: - schema = json.loads(path.read_text(encoding="utf-8")) - Draft202012Validator.check_schema(schema) - except (OSError, json.JSONDecodeError, SchemaError) as error: - raise RuntimeError("The local DesignIntent JSON Schema is unavailable or invalid") from error - return schema - - -def _location(error: Any) -> str: - return "$" + "".join( - f"[{item}]" if isinstance(item, int) else f".{item}" - for item in error.absolute_path - ) - - -def _schema_validate(intent: dict[str, Any], engine: Any) -> None: - errors = sorted( - Draft202012Validator(load_design_intent_schema(engine)).iter_errors(intent), - key=lambda error: (list(error.absolute_path), error.message), - ) - if errors: - error = errors[0] - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"DesignIntent schema violation at {_location(error)}: {error.message}", - ) - - -def _runtime_capabilities(engine: Any) -> tuple[set[str], set[str], dict[str, dict[str, Any]]]: - try: - profile_schema = json.loads(Path(str(engine.__file__)).with_name("profile_schema.json").read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise RuntimeError("The local CDSL profile schema is unavailable or invalid") from error - atomic_ids = set(profile_schema.get("runtime_supported_atomic_ids") or getattr(engine, "SUPPORTED_ATOMIC_IDS", ())) - profiles = { - name - for name, contract in (profile_schema.get("profiles") or {}).items() - if isinstance(contract, dict) and contract.get("agent_allowed") is True - } - atomic_contracts = { - str(atomic_id): contract - for atomic_id, contract in (profile_schema.get("feature_atomic_ids") or {}).items() - if isinstance(contract, dict) - } - return {str(item) for item in atomic_ids}, {str(item) for item in profiles}, atomic_contracts - - -def _blocking(intent: dict[str, Any]) -> bool: - questions = intent.get("open_questions") or [] - gaps = intent.get("capability_gaps") or [] - return any(isinstance(item, dict) and item.get("blocking") is True for item in [*questions, *gaps]) - - -def validate_design_intent( - intent: dict[str, Any], - engine: Any, - *, - current_revision_id: str = "", -) -> dict[str, Any]: - """Validate DesignIntent syntax and semantic ordering against runtime capability.""" - if not isinstance(intent, dict): - raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent must be a JSON object") - _schema_validate(intent, engine) - - normalized = deepcopy(intent) - mode = str(normalized["mode"]) - base_revision_id = str(normalized["base_revision_id"] or "") - if mode == "create" and base_revision_id: - raise DesignIntentError("INVALID_DESIGN_INTENT", "create DesignIntent must not set base_revision_id") - if mode == "revise": - if not base_revision_id: - raise DesignIntentError("INVALID_DESIGN_INTENT", "revise DesignIntent requires base_revision_id") - if current_revision_id and base_revision_id != current_revision_id: - raise DesignIntentError( - "DESIGN_INTENT_BASE_REVISION_MISMATCH", - f"revise DesignIntent base_revision_id must be {current_revision_id}, the current successful revision", - ) - - structures = normalized["structures"] - ids = [str(item["id"]) for item in structures] - feature_ids = [str(item["cdsl_feature_id"]) for item in structures] - if len(set(ids)) != len(ids): - raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent structure ids must be unique") - if len(set(feature_ids)) != len(feature_ids): - raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent cdsl_feature_id values must be unique") - - feature_order = [str(item) for item in normalized["feature_order"]] - if len(set(feature_order)) != len(feature_order) or set(feature_order) != set(ids): - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - "feature_order must list every DesignIntent structure exactly once", - ) - order = {structure_id: index for index, structure_id in enumerate(feature_order)} - atomic_ids, profile_types, atomic_contracts = _runtime_capabilities(engine) - for structure in structures: - structure_id = str(structure["id"]) - for dependency in structure["depends_on"]: - dependency_id = str(dependency) - if dependency_id not in order: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} depends on an unknown structure {dependency_id}", - ) - if order[dependency_id] >= order[structure_id]: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} must depend only on earlier feature_order entries", - ) - strategy = structure["cdsl_strategy"] - atomic_id = str(strategy["atomic_id"]) - if atomic_id not in atomic_ids: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} uses unsupported atomic_id {atomic_id}", - ) - profile_type = str(strategy.get("profile_type") or "") - if atomic_contracts.get(atomic_id, {}).get("requires_sketch") and not profile_type: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} uses {atomic_id} and requires a profile_type", - ) - if profile_type and profile_type not in profile_types: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} uses unsupported or agent-disallowed profile_type {profile_type}", - ) - - if normalized["status"] == "ready" and _blocking(normalized): - raise DesignIntentError( - "DESIGN_INTENT_BLOCKED", - "A DesignIntent with blocking questions or capability gaps must need clarification", - ) - if normalized["status"] == "needs_clarification" and not _blocking(normalized): - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - "needs_clarification requires a blocking question or capability gap", - ) - if normalized["status"] == "ready" and not structures: - raise DesignIntentError("INVALID_DESIGN_INTENT", "A ready DesignIntent needs at least one structure") - if normalized["status"] == "ready" and mode == "create" and not any(item["role"] == "base" for item in structures): - raise DesignIntentError("INVALID_DESIGN_INTENT", "A ready create DesignIntent needs a base structure") - nonblocking_gaps = [item for item in normalized["capability_gaps"] if not item["blocking"]] - if nonblocking_gaps and not normalized["assumptions"]: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - "Non-blocking capability gaps require an explicit approximation assumption", - ) - for gap in normalized["capability_gaps"]: - if str(gap["structure_id"]) not in set(ids): - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Capability gap references an unknown structure {gap['structure_id']}", - ) - valid_expectation_ids = set(ids) | set(feature_ids) - for expectation in normalized["verification_expectations"]: - if expectation["type"] == "feature_count" and str(expectation["feature_id"]) not in valid_expectation_ids: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"feature_count expectation references an unknown structure or CDSL feature {expectation['feature_id']}", - ) - return normalized - - -def _has_selector_evidence(value: Any, role: str) -> bool: - if isinstance(value, dict): - for key, child in value.items(): - if role in {"selector", "selectors"} and key == "selectors" and isinstance(child, list) and child: - return True - if key == role and child not in (None, "", [], {}): - if not (isinstance(child, dict) and set(child) == {"unresolved"}): - return True - if _has_selector_evidence(child, role): - return True - elif isinstance(value, list): - return any(_has_selector_evidence(item, role) for item in value) - return False - - -def validate_intent_cdsl( - intent: dict[str, Any], - cdsl: dict[str, Any], - engine: Any, - *, - current_cdsl: dict[str, Any] | None = None, -) -> None: - """Ensure an executable CDSL document faithfully realizes one accepted plan.""" - normalized = validate_design_intent(intent, engine) - if normalized["status"] != "ready": - raise DesignIntentError("DESIGN_INTENT_BLOCKED", "CDSL generation is blocked until the DesignIntent is ready") - if not isinstance(cdsl, dict): - raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL must be a JSON object") - features = cdsl.get("features") - sketches = (cdsl.get("geometry") or {}).get("sketches") - if not isinstance(features, list) or not isinstance(sketches, list): - raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL must contain features and sketches") - feature_by_id = {str(feature.get("id") or ""): feature for feature in features if isinstance(feature, dict)} - if len(feature_by_id) != len(features): - raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL features must have unique ids") - structure_by_id = {str(item["id"]): item for item in normalized["structures"]} - expected_feature_ids = [str(structure_by_id[item]["cdsl_feature_id"]) for item in normalized["feature_order"]] - if normalized["mode"] == "revise" and isinstance(current_cdsl, dict): - base_feature_ids = { - str(feature.get("id") or "") - for feature in current_cdsl.get("features") or [] - if isinstance(feature, dict) and feature.get("id") - } - if not base_feature_ids.issubset(set(expected_feature_ids)): - missing = ", ".join(sorted(base_feature_ids - set(expected_feature_ids))) - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"revise DesignIntent must explicitly preserve current features: {missing}", - ) - actual_feature_ids = [str(feature.get("id") or "") for feature in features] - if actual_feature_ids != expected_feature_ids: - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - "CDSL feature ids and order must exactly match DesignIntent feature_order", - ) - sketch_by_id = {str(sketch.get("id") or ""): sketch for sketch in sketches if isinstance(sketch, dict)} - feature_for_structure = { - str(structure["id"]): feature_by_id[str(structure["cdsl_feature_id"])] - for structure in normalized["structures"] - } - for structure in normalized["structures"]: - structure_id = str(structure["id"]) - feature = feature_for_structure[structure_id] - strategy = structure["cdsl_strategy"] - if feature.get("atomic_id") != strategy["atomic_id"]: - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"CDSL feature {feature.get('id')} atomic_id does not match structure {structure_id}", - ) - expected_dependencies = { - str(structure_by_id[dependency]["cdsl_feature_id"]) - for dependency in structure["depends_on"] - } - actual_dependencies = {str(item) for item in feature.get("depends_on") or []} - if not expected_dependencies.issubset(actual_dependencies): - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"CDSL feature {feature.get('id')} is missing planned dependencies", - ) - profile_type = str(strategy.get("profile_type") or "") - if profile_type: - sketch_id = str(feature.get("sketch_id") or "") - sketch = sketch_by_id.get(sketch_id) - actual_profile_type = str(((sketch or {}).get("profile") or {}).get("type") or "") - if actual_profile_type != profile_type: - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"CDSL feature {feature.get('id')} must use profile_type {profile_type}", - ) - for selector_role in strategy["selector_roles"]: - if not _has_selector_evidence(feature, str(selector_role)): - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"CDSL feature {feature.get('id')} is missing selector evidence for {selector_role}", - ) - - -def design_intent_from_cdsl(cdsl: dict[str, Any], *, request: str, mode: str = "create", base_revision_id: str = "") -> dict[str, Any]: - """Create a conservative audit-only plan for legacy revisions when needed.""" - sketches = (cdsl.get("geometry") or {}).get("sketches") or [] - profile_by_sketch_id = { - str(sketch.get("id") or ""): str((sketch.get("profile") or {}).get("type") or "") - for sketch in sketches - if isinstance(sketch, dict) - } - structures: list[dict[str, Any]] = [] - for index, feature in enumerate(cdsl.get("features") or []): - if not isinstance(feature, dict): - continue - feature_id = str(feature.get("id") or f"feature_{index + 1}") - atomic_id = str(feature.get("atomic_id") or "") - profile_type = profile_by_sketch_id.get(str(feature.get("sketch_id") or ""), "") - strategy: dict[str, Any] = { - "atomic_id": atomic_id, - "parameter_roles": sorted(str(key) for key in (feature.get("params") or {}).keys()), - "selector_roles": [], - } - if profile_type: - strategy["profile_type"] = profile_type - structures.append({ - "id": feature_id, - "cdsl_feature_id": feature_id, - "role": "base" if index == 0 else "subtractive" if "cut" in atomic_id or atomic_id.startswith("hole") else "additive", - "purpose": str(feature.get("name") or feature_id), - "depends_on": [str(item) for item in feature.get("depends_on") or []], - "cdsl_strategy": strategy, - }) - return { - "schema": "cad.cdsl.design-intent.v1", - "schema_version": "1.0", - "mode": mode, - "request": request, - "base_revision_id": base_revision_id, - "structures": structures, - "feature_order": [item["id"] for item in structures], - "assumptions": ["Synthesized from a legacy CDSL revision."], - "open_questions": [], - "capability_gaps": [], - "verification_expectations": [], - "status": "ready", - } diff --git a/backend/engine/cdsl_engine/design_intent_schema.json b/backend/engine/cdsl_engine/design_intent_schema.json deleted file mode 100644 index cc1ab344..00000000 --- a/backend/engine/cdsl_engine/design_intent_schema.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://cdsl.local/schema/cad.cdsl.design-intent.v1", - "title": "CDSL design intent", - "type": "object", - "properties": { - "schema": {"const": "cad.cdsl.design-intent.v1"}, - "schema_version": {"const": "1.0"}, - "mode": {"enum": ["create", "revise"]}, - "request": {"type": "string", "minLength": 1}, - "base_revision_id": {"type": "string"}, - "structures": {"type": "array", "items": {"$ref": "#/$defs/structure"}}, - "feature_order": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, - "assumptions": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "open_questions": {"type": "array", "items": {"$ref": "#/$defs/openQuestion"}}, - "capability_gaps": {"type": "array", "items": {"$ref": "#/$defs/capabilityGap"}}, - "verification_expectations": {"type": "array", "items": {"$ref": "#/$defs/verificationExpectation"}}, - "status": {"enum": ["ready", "needs_clarification"]}, - "intent_id": {"type": "string", "pattern": "^intent_[a-z0-9]{12}$"}, - "created_at": {"type": "string", "minLength": 1}, - "part_skill_ids": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "part_skill_selection": {"type": "object"} - }, - "required": [ - "schema", - "schema_version", - "mode", - "request", - "base_revision_id", - "structures", - "feature_order", - "assumptions", - "open_questions", - "capability_gaps", - "verification_expectations", - "status" - ], - "additionalProperties": false, - "$defs": { - "structure": { - "type": "object", - "properties": { - "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "cdsl_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "role": {"enum": ["base", "reference", "additive", "subtractive", "dressup", "pattern"]}, - "purpose": {"type": "string", "minLength": 1}, - "depends_on": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, - "cdsl_strategy": {"$ref": "#/$defs/cdslStrategy"} - }, - "required": ["id", "cdsl_feature_id", "role", "purpose", "depends_on", "cdsl_strategy"], - "additionalProperties": false - }, - "cdslStrategy": { - "type": "object", - "properties": { - "atomic_id": {"type": "string", "minLength": 1}, - "profile_type": {"type": "string", "minLength": 1}, - "parameter_roles": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "selector_roles": {"type": "array", "items": {"type": "string", "minLength": 1}} - }, - "required": ["atomic_id", "parameter_roles", "selector_roles"], - "additionalProperties": false - }, - "openQuestion": { - "type": "object", - "properties": { - "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "question": {"type": "string", "minLength": 1}, - "blocking": {"type": "boolean"} - }, - "required": ["id", "question", "blocking"], - "additionalProperties": false - }, - "capabilityGap": { - "type": "object", - "properties": { - "code": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,100}$"}, - "structure_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "message": {"type": "string", "minLength": 1}, - "blocking": {"type": "boolean"} - }, - "required": ["code", "structure_id", "message", "blocking"], - "additionalProperties": false - }, - "verificationExpectation": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": {"const": "feature_count"}, - "feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "expected": {"type": "integer", "minimum": 0} - }, - "required": ["type", "feature_id", "expected"], - "additionalProperties": false - }, - { - "type": "object", - "properties": {"type": {"const": "symmetry"}, "axis": {"enum": ["x", "y", "z"]}}, - "required": ["type", "axis"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": {"const": "bbox"}, - "expected_mm": { - "type": "object", - "properties": { - "x": {"type": "number", "exclusiveMinimum": 0}, - "y": {"type": "number", "exclusiveMinimum": 0}, - "z": {"type": "number", "exclusiveMinimum": 0} - }, - "required": ["x", "y", "z"], - "additionalProperties": false - } - }, - "required": ["type", "expected_mm"], - "additionalProperties": false - } - ] - } - } -} diff --git a/backend/engine/cdsl_engine/generation_compiler.py b/backend/engine/cdsl_engine/generation_compiler.py deleted file mode 100644 index 36bc2511..00000000 --- a/backend/engine/cdsl_engine/generation_compiler.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Deterministic GenerationSpec -> CDSL compilers. - -The LLM supplies semantic parameters and feature intent. This module owns -the CDSL details so tool calls do not need to contain sketch IDs, selectors, or -runtime-specific parameter wrappers. -""" - -from __future__ import annotations - -import copy -from dataclasses import dataclass -from typing import Any, Protocol - -from .generation_spec import GenerationSpecError, validate_generation_spec - - -def _workplane(z: float = 0.0, normal: list[float] | None = None) -> dict[str, list[float]]: - return { - "origin_mm": [0.0, 0.0, float(z)], - "x_dir": [1.0, 0.0, 0.0], - "y_dir": [0.0, 1.0, 0.0], - "normal": list(normal or [0.0, 0.0, 1.0]), - } - - -def _value(spec: dict[str, Any], name: str, default: float | int | str) -> Any: - parameter = (spec.get("parameters") or {}).get(name) - if isinstance(parameter, dict) and "value" in parameter: - return parameter["value"] - return default - - -def _number(spec: dict[str, Any], name: str, default: float) -> float: - value = _value(spec, name, default) - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise GenerationSpecError(f"Parameter {name} must be numeric", f"$.parameters.{name}.value") - number = float(value) - if not number == number or number in {float("inf"), float("-inf")}: - raise GenerationSpecError(f"Parameter {name} must be finite", f"$.parameters.{name}.value") - return number - - -def _integer(spec: dict[str, Any], name: str, default: int) -> int: - value = _number(spec, name, default) - if int(value) != value: - raise GenerationSpecError(f"Parameter {name} must be an integer", f"$.parameters.{name}.value") - return int(value) - - -def _positive(number: float, name: str) -> float: - if number <= 0: - raise GenerationSpecError(f"Parameter {name} must be greater than zero", f"$.parameters.{name}.value") - return number - - -def _acceptance(spec: dict[str, Any], generated: list[dict[str, Any]]) -> list[dict[str, Any]]: - existing = copy.deepcopy(spec.get("acceptance") or []) - known = {str(item.get("id")) for item in existing} - for item in generated: - if item["id"] not in known: - existing.append(item) - known.add(item["id"]) - return existing - - -@dataclass(frozen=True) -class CompiledGeneration: - cdsl: dict[str, Any] - acceptance: list[dict[str, Any]] - provenance: dict[str, Any] - assumptions: list[str] - approximations: list[dict[str, Any]] - - -class Compiler(Protocol): - family: str - - def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration: - ... - - -class MountingPlateCompiler: - family = "mounting_plate" - - def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration: - width = _positive(_number(spec, "width", 100.0), "width") - height = _positive(_number(spec, "height", 60.0), "height") - thickness = _positive(_number(spec, "thickness", 6.0), "thickness") - hole_diameter = _positive(_number(spec, "hole_diameter", 4.5), "hole_diameter") - hole_offset = _positive(_number(spec, "hole_edge_offset", 10.0), "hole_edge_offset") - hole_count = _integer(spec, "hole_count", 4) - counterbore_diameter = _positive(_number(spec, "counterbore_diameter", hole_diameter * 2), "counterbore_diameter") - counterbore_depth = _positive(_number(spec, "counterbore_depth", min(thickness / 2, 3.0)), "counterbore_depth") - slot_length = _positive(_number(spec, "slot_length", 20.0), "slot_length") - slot_width = _positive(_number(spec, "slot_width", 12.0), "slot_width") - - if hole_count != 4: - raise GenerationSpecError("mounting_plate compiler currently requires four corner holes", "$.parameters.hole_count.value") - if counterbore_diameter <= hole_diameter: - raise GenerationSpecError("counterbore_diameter must exceed hole_diameter", "$.parameters.counterbore_diameter.value") - if counterbore_depth > thickness: - raise GenerationSpecError("counterbore_depth must not exceed thickness", "$.parameters.counterbore_depth.value") - if hole_offset * 2 >= min(width, height): - raise GenerationSpecError("hole_edge_offset leaves no usable plate area", "$.parameters.hole_edge_offset.value") - if slot_length < slot_width: - raise GenerationSpecError("slot_length must be at least slot_width", "$.parameters.slot_length.value") - - centers = [ - [-width / 2 + hole_offset, -height / 2 + hole_offset], - [width / 2 - hole_offset, -height / 2 + hole_offset], - [width / 2 - hole_offset, height / 2 - hole_offset], - [-width / 2 + hole_offset, height / 2 - hole_offset], - ] - frame = _workplane(thickness) - cdsl = { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.1.0", - "kind": "part", - "part_id": "generation-spec-preflight", - "meta": {"unit": "mm", "name": str(spec["part"]["name"])}, - "geometry": {"sketches": [ - {"id": "base_plate", "workplane": _workplane(), "profile": { - "type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height, - }}, - {"id": "mounting_holes", "workplane": frame, "profile": { - "type": "circles", "items": [{"center": center, "radius_mm": hole_diameter / 2} for center in centers], - }}, - {"id": "center_slot", "workplane": frame, "profile": { - "type": "obround", "center": [0.0, 0.0], "length_mm": slot_length, "width_mm": slot_width, - }}, - ]}, - "features": [ - {"id": "base_plate", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_plate", "params": {"distance_mm": thickness}}, - {"id": "mounting_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_plate"], "sketch_id": "mounting_holes", "params": {"distance_mm": thickness, "reverse": True}}, - {"id": "mounting_counterbores", "atomic_id": "hole_counterbore", "depends_on": ["mounting_holes"], "sketch_id": "mounting_holes", "params": { - "diameter_mm": hole_diameter, "depth_mm": thickness, "counterbore_diameter_mm": counterbore_diameter, - "counterbore_depth_mm": counterbore_depth, "positions": [{"mm": [x, y, 0.0]} for x, y in centers], "host_face": {"frame": frame}, - }}, - {"id": "center_slot", "atomic_id": "extrude_cut_blind", "depends_on": ["mounting_counterbores"], "sketch_id": "center_slot", "params": {"distance_mm": thickness, "reverse": True}}, - ], - } - assumptions = [str(item) for item in spec.get("assumptions") or []] - if "hole_diameter" not in (spec.get("parameters") or {}): - assumptions.append("M4 clearance hole defaults to 4.5 mm") - if "counterbore_depth" not in (spec.get("parameters") or {}): - assumptions.append("Counterbore depth defaults to the lesser of 3 mm and half the plate thickness") - acceptance = _acceptance(spec, [ - {"id": "overall_bbox", "type": "bbox", "expected": [width, height, thickness], "tolerance": 0.1, "severity": "blocking"}, - {"id": "mounting_hole_count", "type": "feature_count", "feature": "mounting_holes", "expected": 4, "tolerance": 0, "severity": "blocking"}, - {"id": "mounting_hole_diameter", "type": "hole_diameter", "feature": "mounting_holes", "expected": hole_diameter, "tolerance": 0.05, "severity": "blocking"}, - {"id": "center_slot_size", "type": "overall_length", "feature": "center_slot", "expected": slot_length, "tolerance": 0.1, "severity": "blocking"}, - ]) - provenance = { - "compiler": self.family, - "compiler_version": "1.0", - "references": references, - "feature_sources": {item["id"]: item["id"] for item in spec.get("features") or []}, - "parameter_paths": {name: f"parameters.{name}.value" for name in (spec.get("parameters") or {})}, - } - return CompiledGeneration(cdsl, acceptance, provenance, list(dict.fromkeys(assumptions)), list(spec.get("approximations") or [])) - - -class FlangeSleeveCompiler: - family = "flange_sleeve" - - def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration: - from app.services.flange_sleeve_template import build_flange_sleeve_cdsl, TEMPLATE_ID - - aliases = { - "width": "flange_width_mm", "height": "flange_height_mm", "thickness": "flange_thickness_mm", - "tube_outer_diameter": "tube_outer_diameter_mm", "bore_diameter": "bore_diameter_mm", - "hole_diameter": "mount_hole_diameter_mm", "counterbore_diameter": "mount_counterbore_diameter_mm", - "counterbore_depth": "mount_counterbore_depth_mm", "hole_offset_x": "mount_hole_u_mm", "hole_offset_y": "mount_hole_v_mm", - } - plan: dict[str, Any] = {"template": TEMPLATE_ID, "name": spec["part"]["name"]} - for source, target in aliases.items(): - if source in (spec.get("parameters") or {}): - plan[target] = _value(spec, source, 0) - cdsl, values = build_flange_sleeve_cdsl(plan) - acceptance = _acceptance(spec, [ - {"id": "flange_bbox", "type": "bbox", "expected": [float(values["flange_width_mm"]), float(values["flange_height_mm"]), float(values["flange_thickness_mm"])], "tolerance": 0.1, "severity": "blocking"}, - ]) - provenance = {"compiler": self.family, "compiler_version": "1.0", "template_id": TEMPLATE_ID, "references": references} - assumptions = list(dict.fromkeys([*map(str, spec.get("assumptions") or []), "Flange sleeve dimensions use the verified parameterized template defaults for omitted fields."])) - return CompiledGeneration(cdsl, acceptance, provenance, assumptions, list(spec.get("approximations") or [])) - - -COMPILERS: dict[str, Compiler] = { - "mounting_plate": MountingPlateCompiler(), - "flange_sleeve": FlangeSleeveCompiler(), -} - - -def compiler_for(family: str) -> Compiler: - try: - return COMPILERS[family] - except KeyError as error: - raise GenerationSpecError(f"No GenerationSpec compiler registered for part family: {family}", "$.part.family") from error - - -def compile_generation_spec(spec: dict[str, Any], references: list[dict[str, Any]] | None = None) -> dict[str, Any]: - normalized = validate_generation_spec(spec) - compiler = compiler_for(str(normalized["part"]["family"])) - compiled = compiler.compile(normalized, references or []) - return { - "spec": normalized, - "cdsl": compiled.cdsl, - "acceptance": compiled.acceptance, - "provenance": compiled.provenance, - "assumptions": compiled.assumptions, - "approximations": compiled.approximations, - } diff --git a/backend/engine/cdsl_engine/generation_spec.py b/backend/engine/cdsl_engine/generation_spec.py deleted file mode 100644 index 870587d4..00000000 --- a/backend/engine/cdsl_engine/generation_spec.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Validation and normalization for the semantic GenerationSpec contract.""" - -from __future__ import annotations - -import copy -import json -from functools import lru_cache -from pathlib import Path -from typing import Any - -from jsonschema import Draft202012Validator - - -KNOWN_PART_FAMILIES = frozenset({ - "mounting_plate", - "flange", - "flange_sleeve", - "simple_shaft", - "bearing_housing", - "mounting_bracket", - "hex_nut", - "slotted_plate", -}) - -KNOWN_FEATURE_KINDS = frozenset({ - "base_extrusion", - "base_revolve", - "boss", - "through_hole", - "blind_hole", - "counterbored_hole", - "countersunk_hole", - "hole_pattern", - "counterbored_hole_pattern", - "obround_cut", - "pocket", - "revolve_profile", - "coaxial_bore", - "fillet", - "chamfer", -}) - - -class GenerationSpecError(ValueError): - """A stable, user-repairable GenerationSpec validation error.""" - - def __init__(self, message: str, path: str = "$") -> None: - super().__init__(message) - self.path = path - - -@lru_cache(maxsize=1) -def generation_spec_schema() -> dict[str, Any]: - path = Path(__file__).with_name("generation_spec_schema.json") - schema = json.loads(path.read_text(encoding="utf-8")) - Draft202012Validator.check_schema(schema) - return schema - - -def _schema_error(spec: dict[str, Any]) -> GenerationSpecError | None: - errors = sorted( - Draft202012Validator(generation_spec_schema()).iter_errors(spec), - key=lambda error: (list(error.absolute_path), error.message), - ) - if not errors: - return None - error = errors[0] - location = "$" + "".join( - f"[{item}]" if isinstance(item, int) else f".{item}" - for item in error.absolute_path - ) - return GenerationSpecError(error.message, location) - - -def _parameter_value(spec: dict[str, Any], name: str) -> Any: - value = (spec.get("parameters") or {}).get(name) - if not isinstance(value, dict): - raise GenerationSpecError(f"Unknown parameter: {name}", f"$.parameters.{name}") - return value.get("value") - - -def _numeric(value: Any, path: str) -> float: - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise GenerationSpecError("Expected a finite numeric value", path) - number = float(value) - if number != number or number in {float("inf"), float("-inf")}: - raise GenerationSpecError("Expected a finite numeric value", path) - return number - - -def _check_constraints(spec: dict[str, Any]) -> None: - for index, constraint in enumerate(spec.get("constraints") or []): - path = f"$.constraints[{index}]" - kind = constraint.get("type") - if kind in {"less_than", "less_equal", "greater_than", "greater_equal", "equal"}: - left = _numeric(_parameter_value(spec, str(constraint.get("left") or "")), f"{path}.left") - right_name = constraint.get("right") - right = _numeric(_parameter_value(spec, str(right_name)), f"{path}.right") if right_name else _numeric(constraint.get("value"), f"{path}.value") - ok = { - "less_than": left < right, - "less_equal": left <= right, - "greater_than": left > right, - "greater_equal": left >= right, - "equal": abs(left - right) <= 1e-9, - }[kind] - if not ok: - raise GenerationSpecError(constraint.get("message") or f"Constraint {kind} failed", path) - - -def _check_graph(spec: dict[str, Any]) -> None: - features = spec.get("features") or [] - ids = [str(item.get("id")) for item in features] - if len(ids) != len(set(ids)): - raise GenerationSpecError("Feature ids must be unique", "$.features") - known: set[str] = set() - for index, feature in enumerate(features): - feature_id = str(feature.get("id")) - kind = str(feature.get("kind")) - if kind not in KNOWN_FEATURE_KINDS: - raise GenerationSpecError(f"Unsupported feature kind: {kind}", f"$.features[{index}].kind") - for dependency in feature.get("depends_on") or []: - if dependency not in known: - raise GenerationSpecError( - f"Feature {feature_id} has a forward or missing dependency: {dependency}", - f"$.features[{index}].depends_on", - ) - known.add(feature_id) - - acceptance_ids = {str(item.get("id")) for item in spec.get("acceptance") or []} - if len(acceptance_ids) != len(spec.get("acceptance") or []): - raise GenerationSpecError("Acceptance ids must be unique", "$.acceptance") - for index, item in enumerate(spec.get("acceptance") or []): - feature = item.get("feature") - if feature and feature not in known: - raise GenerationSpecError(f"Acceptance refers to missing feature: {feature}", f"$.acceptance[{index}].feature") - - -def normalize_generation_spec(spec: dict[str, Any], *, request: str = "") -> dict[str, Any]: - if not isinstance(spec, dict): - raise GenerationSpecError("GenerationSpec must be a JSON object") - normalized = copy.deepcopy(spec) - normalized.setdefault("schema", "cad.generation-spec.v1") - normalized.setdefault("schema_version", "1.0") - normalized.setdefault("mode", "create") - normalized.setdefault("base_revision_id", "") - normalized.setdefault("parameters", {}) - normalized.setdefault("features", []) - normalized.setdefault("constraints", []) - normalized.setdefault("acceptance", []) - normalized.setdefault("assumptions", []) - normalized.setdefault("approximations", []) - normalized.setdefault("references", []) - normalized.setdefault("patch_intent", request) - return normalized - - -def validate_generation_spec( - spec: dict[str, Any], - *, - known_families: set[str] | frozenset[str] = KNOWN_PART_FAMILIES, -) -> dict[str, Any]: - normalized = normalize_generation_spec(spec) - error = _schema_error(normalized) - if error: - raise error - family = str(normalized["part"]["family"]) - if family not in known_families: - raise GenerationSpecError(f"Unsupported part family: {family}", "$.part.family") - for name, parameter in normalized["parameters"].items(): - source = parameter["source"] - if source == "user" and not parameter["locked"]: - raise GenerationSpecError("User parameters must be locked", f"$.parameters.{name}.locked") - if source == "image_estimate" and not parameter.get("assumption"): - raise GenerationSpecError("Image estimates require an assumption", f"$.parameters.{name}.assumption") - _check_graph(normalized) - _check_constraints(normalized) - return normalized diff --git a/backend/engine/cdsl_engine/generation_spec_schema.json b/backend/engine/cdsl_engine/generation_spec_schema.json deleted file mode 100644 index d50fd219..00000000 --- a/backend/engine/cdsl_engine/generation_spec_schema.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://cdsl.local/schema/cad.generation-spec.v1", - "title": "CDSL GenerationSpec", - "type": "object", - "additionalProperties": false, - "properties": { - "schema": {"const": "cad.generation-spec.v1"}, - "schema_version": {"const": "1.0"}, - "mode": {"enum": ["create", "revise"]}, - "base_revision_id": {"type": "string"}, - "patch_intent": {"type": "string"}, - "part": {"$ref": "#/$defs/part"}, - "parameters": {"type": "object", "additionalProperties": {"$ref": "#/$defs/parameter"}}, - "features": {"type": "array", "items": {"$ref": "#/$defs/feature"}}, - "constraints": {"type": "array", "items": {"$ref": "#/$defs/constraint"}}, - "acceptance": {"type": "array", "items": {"$ref": "#/$defs/acceptance"}}, - "assumptions": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "approximations": {"type": "array", "items": {"$ref": "#/$defs/approximation"}}, - "references": {"type": "array", "items": {"type": "object"}} - }, - "required": ["schema", "schema_version", "mode", "base_revision_id", "part", "parameters", "features", "constraints", "acceptance", "assumptions", "approximations", "references"], - "$defs": { - "part": { - "type": "object", - "additionalProperties": false, - "properties": { - "family": {"type": "string", "minLength": 1}, - "name": {"type": "string", "minLength": 1}, - "units": {"const": "mm"}, - "coordinate_system": {"type": "string", "minLength": 1} - }, - "required": ["family", "name", "units", "coordinate_system"] - }, - "parameter": { - "type": "object", - "additionalProperties": false, - "properties": { - "value": {}, - "unit": {"enum": ["mm", "deg", "rad", "count", ""]}, - "source": {"enum": ["user", "technical_drawing", "image_estimate", "template_default", "derived_standard", "assumption"]}, - "confidence": {"type": "number", "minimum": 0, "maximum": 1}, - "locked": {"type": "boolean"}, - "assumption": {"type": "string"} - }, - "required": ["value", "unit", "source", "confidence", "locked", "assumption"] - }, - "feature": { - "type": "object", - "additionalProperties": false, - "properties": { - "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "kind": {"type": "string", "minLength": 1}, - "depends_on": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, - "parameters": {"type": "object"}, - "semantic_role": {"type": "string"} - }, - "required": ["id", "kind", "depends_on", "parameters"] - }, - "constraint": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": {"enum": ["less_than", "less_equal", "greater_than", "greater_equal", "equal", "inside_boundary", "symmetric", "same_axis", "distance", "count"]}, - "left": {"type": "string"}, - "right": {"type": "string"}, - "value": {}, - "feature": {"type": "string"}, - "margin": {"type": "number"}, - "ratio": {"type": "number"}, - "message": {"type": "string"} - }, - "required": ["type"] - }, - "acceptance": { - "type": "object", - "additionalProperties": false, - "properties": { - "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "type": {"enum": ["bbox", "solid_count", "feature_count", "hole_diameter", "hole_center", "hole_spacing", "wall_thickness", "overall_length", "overall_diameter", "coaxiality", "symmetry", "through_condition", "blind_depth"]}, - "feature": {"type": "string"}, - "expected": {}, - "tolerance": {"type": "number", "minimum": 0}, - "severity": {"enum": ["blocking", "warning", "informational"]} - }, - "required": ["id", "type", "expected", "tolerance", "severity"] - }, - "approximation": { - "type": "object", - "additionalProperties": false, - "properties": { - "request": {"type": "string", "minLength": 1}, - "status": {"enum": ["approximated", "expanded", "omitted", "blocked"]}, - "translation": {"type": "string", "minLength": 1}, - "reason": {"type": "string", "minLength": 1} - }, - "required": ["request", "status", "translation", "reason"] - } - } -} diff --git a/backend/engine/cdsl_engine/phase_pools.py b/backend/engine/cdsl_engine/phase_pools.py index a2118c32..fd228762 100644 --- a/backend/engine/cdsl_engine/phase_pools.py +++ b/backend/engine/cdsl_engine/phase_pools.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any from .capabilities import sketch_ids_required_by_contract +from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles from .semantic_validation import validate_semantic_cdsl from .sketch_solver import resolve_required_sketches @@ -21,7 +22,7 @@ P3_ATOMIC_IDS = frozenset({ }) P4_ATOMIC_IDS = P3_ATOMIC_IDS | frozenset({"hole_wizard"}) P6_ATOMIC_IDS = P4_ATOMIC_IDS | frozenset({"pattern_linear", "pattern_mirror"}) -P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "circles", "annulus"}) +P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "polygon"}) # Static pool membership asks whether the exported history is an extrude/ # revolve history. Whether a first cut has a preceding active body remains a # runtime preflight question, not a reason to erase it from the input pool. @@ -66,6 +67,9 @@ def is_static_phase_ready(cdsl: dict[str, Any], phase: str) -> bool: allowed = _PHASE_ATOMIC_IDS.get(phase) if allowed is None: raise ValueError(f"Unknown CDSL runtime phase {phase!r}") + # Corpus pools retain historical profiles on disk but evaluate them after + # the importer compatibility lowering used by the batch path. + cdsl = lower_legacy_profiles(cdsl) semantic = validate_semantic_cdsl(cdsl) if semantic["unresolved"]: return False diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 14ba81d4..74a90623 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -2,10 +2,10 @@ "schema": "cdsl.engine.schema.v1", "schema_version": "1.3.0", "cdsl_json_schema_file": "cdsl_schema.json", - "maintenance_rule": "The semantic CDSL contract is a superset of the current runtime. runtime_supported_atomic_ids and runtime_supported_profiles must stay synchronized with runtime.py EXECUTORS, sketch_solver.py SHAPE_GENERATORS, and the package-level capability tests. Legacy llm_compiler.py and llm_engine.py are not the CDSL-only runtime contract.", + "maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.", "coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.", "runtime_supported_atomic_ids": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "reference_plane", "reference_axis", "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror"], - "runtime_supported_profiles": ["circle", "annulus", "circles", "circle_grid", "rectangle", "rectangle_with_circles", "rectangle_with_fillets", "obround", "polygon", "ibone", "rectangle_with_symmetric_notches", "revolve_chamfer", "revolve_chamfer_slanted", "circle_with_arc_notches", "circular_sector_slot", "circle_with_radial_tabs", "filleted_rect_side_slots", "d_shape", "partial_ring", "partial_ring_with_arc_island", "arc_chain", "radial_slot", "patterned_cutouts", "compound_patterned_cutouts", "analytic_contours", "complex_arc_shape", "unknown_shape"], + "runtime_supported_profiles": ["circle", "polygon", "analytic_contours"], "feature_atomic_ids": { "extrude_add_blind": {"summary": "Add the closed profile by one signed extrusion distance.", "required_params": ["distance_mm"], "optional_params": ["reverse"], "requires_sketch": true}, "extrude_add_two_sided": {"summary": "Add the closed profile with independently captured forward and reverse terminations.", "required_params": ["distance_mm", "reverse_distance_mm"], "optional_params": ["reverse", "end_condition", "reverse_end_condition"], "requires_sketch": true}, @@ -26,202 +26,18 @@ }, "profiles": { "circle": { - "agent_allowed": true, "summary": "A single circular closed profile.", "required": ["radius_mm"], "optional": ["center"], "constraints": ["radius_mm > 0", "center defaults to [0, 0]"] }, - "annulus": { - "agent_allowed": true, - "summary": "A concentric ring.", - "required": ["inner_radius_mm", "outer_radius_mm"], - "optional": ["center"], - "constraints": ["0 < inner_radius_mm < outer_radius_mm"] - }, - "circles": { - "agent_allowed": true, - "summary": "A non-empty list of circles.", - "required": ["items"], - "item_schema": {"required": ["radius_mm"], "optional": ["center"]} - }, - "circle_grid": { - "agent_allowed": true, - "summary": "A rectangular grid of equal circles.", - "required": ["radius_mm", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], - "optional": ["origin_mm", "center_mm"], - "constraints": ["radius_mm > 0", "count_x and count_y are integers >= 1", "use origin_mm or center_mm, not both"] - }, - "rectangle": { - "agent_allowed": true, - "summary": "An axis-aligned rectangle.", - "one_of": [["center", "width_mm", "height_mm"], ["min_mm", "max_mm"]], - "constraints": ["width_mm > 0 and height_mm > 0 when using center"] - }, - "rectangle_with_circles": { - "agent_allowed": true, - "summary": "A rectangle or filleted rectangle with optional internal circles.", - "required": ["boundary"], - "optional": ["circles"], - "nested": {"boundary": "rectangle schema plus optional type=rectangle|rectangle_with_fillets and fillet_radius_mm"} - }, - "rectangle_with_fillets": { - "agent_allowed": true, - "summary": "An axis-aligned rectangle with corner fillets and optional internal circles.", - "one_of": [["center", "width_mm", "height_mm"], ["min_mm", "max_mm"]], - "optional": ["fillet_radius_mm", "circles"], - "constraints": ["fillet_radius_mm defaults to 0"] - }, - "obround": { - "agent_allowed": true, - "summary": "A horizontal capsule/slot.", - "required": ["length_mm", "width_mm"], - "optional": ["center"], - "constraints": ["length_mm > 0", "width_mm > 0", "center defaults to [0, 0]"] - }, "polygon": { - "agent_allowed": true, "summary": "A closed straight-edge polygon.", "required": ["vertices"], "constraints": ["vertices contains at least three [u, v] points"] }, - "ibone": { - "agent_allowed": true, - "summary": "I-shaped lug with optional four holes.", - "required": ["body_width_mm", "body_height_mm", "flange_width_mm", "flange_height_mm", "corner_radius_mm"], - "optional": ["hole_radius_mm"] - }, - "rectangle_with_symmetric_notches": { - "agent_allowed": true, - "summary": "A rectangular plate with four symmetric side notches.", - "required": ["width_mm", "height_mm", "notch"], - "nested": {"notch": {"required": ["y_start", "y_end", "depth_mm"], "optional": ["inner_radius_mm", "corner_radius_mm"]}} - }, - "revolve_chamfer": { - "agent_allowed": true, - "summary": "Five-edge trapezoid profile for a revolve cut.", - "required": ["axis_height_mm", "top_width_mm", "bottom_width_mm"], - "optional": ["wall_inset_mm", "step_inset_mm", "on_axis_side"], - "constraints": ["on_axis_side is left or right"] - }, - "revolve_chamfer_slanted": { - "agent_allowed": true, - "summary": "Five-edge slanted profile for a revolve cut.", - "required": ["axis_height_mm", "top_width_mm", "wall_height_mm", "wall_width_mm"], - "optional": ["wall_inset_mm", "on_axis_side"], - "constraints": ["on_axis_side is left or right"] - }, - "circle_with_arc_notches": { - "agent_allowed": true, - "summary": "A circular boundary with arc-shaped notches.", - "required": ["outer_radius_mm", "notch_radius_mm"], - "optional": ["notch_angles_deg", "circles"], - "constraints": ["notch_angles_deg defaults to [0, 90, 180, 270]"] - }, - "circular_sector_slot": { - "agent_allowed": true, - "summary": "A circular-sector boundary with a central rectangular slot.", - "required": ["arc_radius_mm", "slot_half_width_mm", "chord_half_mm"], - "optional": ["circles"] - }, - "circle_with_radial_tabs": { - "agent_allowed": true, - "summary": "A circle with two mirrored radial tabs.", - "required": ["outer_radius_mm"], - "optional": ["tab_u_half_mm", "tab_v_offset_mm", "circles"] - }, - "filleted_rect_side_slots": { - "agent_allowed": true, - "summary": "A filleted rectangle with one semicircular slot at each side centre.", - "required": ["half_width_mm", "half_height_mm", "corner_radius_mm"], - "optional": ["slot_radius_mm", "circles"] - }, - "d_shape": { - "agent_allowed": true, - "summary": "A D-shaped boundary made from one chord and one arc.", - "required": ["radius_mm"], - "optional": ["chord_sign", "chord_x_mm", "circles"], - "constraints": ["chord_sign is left or right"] - }, - "partial_ring": { - "agent_allowed": true, - "summary": "An annular sector.", - "required": ["inner_radius_mm", "outer_radius_mm"], - "optional": ["half_angle_deg", "circles"], - "constraints": ["0 < inner_radius_mm < outer_radius_mm"] - }, - "partial_ring_with_arc_island": { - "agent_allowed": true, - "summary": "One or more annular sectors with an arc island.", - "required": ["inner_radius_mm", "outer_radius_mm", "island_radius_mm"], - "optional": ["half_angle_deg", "island_gap_mm", "center_angles_deg", "replicas"], - "nested": {"replicas": {"required": ["center_angle_deg"]}} - }, - "arc_chain": { - "agent_allowed": true, - "summary": "A closed chain of two or more arcs, commonly used for revolve sections.", - "required": ["arcs"], - "optional": ["circles"], - "item_schema": {"required": ["radius_mm", "start_angle_deg", "end_angle_deg"], "optional": ["center"]}, - "constraints": ["arcs contains at least two endpoint-connected arcs"] - }, - "radial_slot": { - "agent_allowed": true, - "summary": "A rounded annular sector slot.", - "required": ["inner_radius_mm", "outer_radius_mm", "start_angle_deg", "end_angle_deg"], - "optional": ["circles"], - "constraints": ["0 < inner_radius_mm < outer_radius_mm"] - }, - "patterned_cutouts": { - "agent_allowed": true, - "summary": "One procedural cutout motif repeated by one procedural layout.", - "required": ["motif", "layout"], - "nested": {"motif": "See motif_types", "layout": "See layout_types"} - }, - "compound_patterned_cutouts": { - "agent_allowed": true, - "summary": "Multiple procedural motif/layout pairs merged into one cutout sketch.", - "required": ["patterns"], - "nested": {"patterns": "Non-empty list of {motif, layout}; see patterned_cutouts."} - }, - "complex_arc_shape": { - "agent_allowed": false, - "summary": "Legacy compiler-context fallback only. Never generate it." - }, "analytic_contours": { - "agent_allowed": false, - "summary": "Exact analytic line, arc and circle contours emitted by the Evidence v2 converter. The runtime resolves them into closed regions without compiler_context; B-spline currently produces an explicit unsupported diagnostic." - }, - "unknown_shape": { - "agent_allowed": false, - "summary": "Legacy compiler-context fallback only. Never generate it." + "summary": "Closed direct line, arc and circle contours. B-splines are unsupported." } - }, - "motif_types": { - "circle": ["radius_mm"], - "square": ["width_mm"], - "rectangle": ["width_mm", "height_mm"], - "obround": ["length_mm", "width_mm"], - "cross": ["size_mm", "arm_width_mm"], - "d_shape_polygon": ["stem_length_mm", "nose_depth_mm", "half_height_mm"], - "regular_hexagon": ["radius_mm"], - "skew_hexagon": ["nominal_radius_mm"], - "triangle": ["radius_mm"], - "teardrop_polygon": ["width_mm", "height_mm"], - "trapezoid": ["bottom_width_mm", "top_width_mm", "height_mm"], - "annular_sector_polygon": ["inner_radius_mm", "outer_radius_mm", "half_angle_deg"] - }, - "layout_types": { - "ring": ["radius_mm", "count"], - "angular": ["count"], - "concentric_rings": ["rings"], - "disc_grid": ["count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], - "open_arc": ["radius_mm", "count", "start_angle_deg", "end_angle_deg"], - "spiral": ["count", "start_radius_mm", "radius_step_mm", "angle_step_deg"], - "cross_lines": ["count_per_axis", "spacing_mm"], - "x_field": ["levels", "spacing_mm"], - "twin_strips": ["x_offset_mm", "count_y", "y_start_mm", "y_end_mm"], - "corner_clusters": ["levels_mm"], - "diamond_field": ["manhattan_radius", "spacing_mm"] } } diff --git a/backend/engine/cdsl_engine/rebuild.py b/backend/engine/cdsl_engine/rebuild.py index 57b9e7ee..7f965e73 100644 --- a/backend/engine/cdsl_engine/rebuild.py +++ b/backend/engine/cdsl_engine/rebuild.py @@ -15,25 +15,30 @@ from pathlib import Path from typing import Any try: - from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches from .llm_compiler import compile_cdsl from .llm_engine import run_engine_plan from .translator import generate_build123d_code, normalize_to_ir except ImportError: # 允许直接 python rebuild.py - from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches from llm_compiler import compile_cdsl from llm_engine import run_engine_plan from translator import generate_build123d_code, normalize_to_ir -def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None, - force_exact: bool = False) -> dict[str, Any]: +def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None, + force_exact: bool = False) -> dict[str, Any]: """主重建入口。 优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。 回退:CDSL + compiler_context 的 translator 路径。 """ - sketches = cdsl.get("geometry", {}).get("sketches", []) + # Compatibility entry point only: new CDSL-only runtime calls do not use + # macro profiles and therefore never invoke this adapter. + cdsl = lower_legacy_profiles(cdsl) + sketches = cdsl.get("geometry", {}).get("sketches", []) all_drawable = bool(sketches) and all( _sketch_is_cdsl_drawable(s) for s in sketches ) diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 060d4208..f3aa4ff8 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -15,7 +15,7 @@ from .runtime_types import ( RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit, ) -from .sketch_solver import SHAPE_GENERATORS, resolve_required_sketches +from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches ALL_ATOMIC_IDS = frozenset({ @@ -758,7 +758,7 @@ def analyze_cdsl(cdsl: dict[str, Any]): resolved = resolve_required_sketches( deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors, ) - analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=SHAPE_GENERATORS) + analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS) return analyzer.analyze(resolved, sketch_errors=sketch_errors) @@ -768,7 +768,7 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) - resolved = resolve_required_sketches( deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors, ) - analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=SHAPE_GENERATORS).analyze( + analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze( resolved, sketch_errors=sketch_errors, ) if strict and not analysis.runtime_eligible: diff --git a/backend/engine/cdsl_engine/sketch_solver.py b/backend/engine/cdsl_engine/sketch_solver.py index 4311d7dc..c9c5e4a6 100644 --- a/backend/engine/cdsl_engine/sketch_solver.py +++ b/backend/engine/cdsl_engine/sketch_solver.py @@ -1,37 +1,9 @@ -"""轮廓求解器:参数化草图描述 → entities + contour_edges_mm。 +"""Core CDSL sketch resolver. -LLM 只需输出离散决策(type, radius, width …), -求解器负责生成精确的实体和轮廓边坐标。 - -架构:注册表模式 —— 每个轮廓类型对应一个生成器函数, - 按 "type" 字符串索引。新增形状或调整既有 profile 参数契约时: - 1. 写 def solver_xxx(profile, meta) -> (entities, contour) - 2. 注册: SHAPE_GENERATORS["xxx"] = solver_xxx - 3. 在 convert 脚本中输出对应的 profile - 4. 同步更新 profile_schema.json(Agent 与后端校验器的公开契约) - -支持的 profile 类型: - - circle: 单个圆 - - annulus: 同心圆环 - - circles: 多圆(引擎自动判断加/切除) - - circle_grid: 矩形圆孔阵列(行列+间距) - - rectangle: 矩形 - - rectangle_with_circles: 矩形 + 内圆孔/岛 - - rectangle_with_fillets: 带圆角的矩形(4角倒圆),可选内圆 - - rectangle_with_symmetric_notches: 对称槽板(矩形+4个U形缺口) - - obround: 槽形 / 键槽(2平行线 + 2半圆) - - polygon: N边多边形(顶点列表) - - ibone: 工字形凸耳(12线+4弧+4孔) - - circle_with_arc_notches: 圆+均匀圆弧凹口 - - circular_sector_slot: 圆弧扇区+中心矩形槽 - - circle_with_radial_tabs: 圆+径向矩形凸耳(带圆角) - - filleted_rect_side_slots: 圆角矩形+两侧中心U形槽 - - d_shape: D形(半圆+弦线) - - partial_ring: 部分圆环(同心弧+径向线) - - partial_ring_with_arc_island: 扇区环 + 弦上偏移弧岛(保留材料岛) - - concentric_arc_profile: 同心圆弧轮廓(多段弧+圆心标记) - - patterned_cutouts: 母形 + 规则布局的多区域切口 - - compound_patterned_cutouts: 多组母形/布局合并为一个切除草图 +The runtime accepts only direct geometric descriptions: circles, straight-edge +polygons, and closed analytic line/arc/circle contours. Semantic shapes and +historical profile macros belong to the importer compatibility layer and must +be lowered before this module is invoked. """ from __future__ import annotations @@ -41,81 +13,34 @@ from copy import deepcopy from typing import Any, Iterable -# ═══════════════════════════════════════════════════════════════ -# 基础几何原语 -# ═══════════════════════════════════════════════════════════════ - -def _circle(center: list[float], radius_mm: float, construction: bool = False) -> dict[str, Any]: - return { - "type": "circle", - "center": [float(center[0]), float(center[1])], - "radius_mm": float(radius_mm), - "construction": construction, - } +_Ctx = dict[str, Any] +_TOLERANCE_MM = 1e-5 -def _line(start: list[float], end: list[float], construction: bool = False) -> dict[str, Any]: - return { - "type": "line", - "start": [float(start[0]), float(start[1])], - "end": [float(end[0]), float(end[1])], - "construction": construction, - } +def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx: + return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction} -def _contour_line(start_mm: list[float], end_mm: list[float]) -> dict[str, Any]: - return { - "type": "line", - "start_mm": [ - float(start_mm[0]), - float(start_mm[1]), - float(start_mm[2]) if len(start_mm) > 2 else 0.0, - ], - "end_mm": [ - float(end_mm[0]), - float(end_mm[1]), - float(end_mm[2]) if len(end_mm) > 2 else 0.0, - ], - } +def _line(start: list[float], end: list[float], construction: bool = False) -> _Ctx: + return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction} -def _contour_arc( - start_mm: list[float], - end_mm: list[float], - center_mm: list[float], - radius_mm: float | None, - clockwise: bool | None = None, -) -> dict[str, Any]: - result = { - "type": "arc", - "start_mm": [ - float(start_mm[0]), - float(start_mm[1]), - float(start_mm[2]) if len(start_mm) > 2 else 0.0, - ], - "end_mm": [ - float(end_mm[0]), - float(end_mm[1]), - float(end_mm[2]) if len(end_mm) > 2 else 0.0, - ], - "center_mm": [ - float(center_mm[0]), - float(center_mm[1]), - float(center_mm[2]) if len(center_mm) > 2 else 0.0, - ], - "radius_mm": float(radius_mm) if radius_mm is not None else None, - } +def _point(point: list[float]) -> list[float]: + return [float(point[0]), float(point[1]), float(point[2]) if len(point) > 2 else 0.0] + + +def _contour_line(start: list[float], end: list[float]) -> _Ctx: + return {"type": "line", "start_mm": _point(start), "end_mm": _point(end)} + + +def _contour_arc(start: list[float], end: list[float], center: list[float], radius: float | None, clockwise: bool | None = None) -> _Ctx: + edge: _Ctx = {"type": "arc", "start_mm": _point(start), "end_mm": _point(end), "center_mm": _point(center), "radius_mm": float(radius) if radius is not None else None} if clockwise is not None: - result["clockwise"] = bool(clockwise) - return result + edge["clockwise"] = bool(clockwise) + return edge -# ═══════════════════════════════════════════════════════════════ -# 3D 坐标转换 -# ═══════════════════════════════════════════════════════════════ - -def _to_3d(workplane: dict[str, Any], u: float, v: float) -> list[float]: - """将2D局部坐标 (u,v) 映射到3D世界坐标。""" +def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]: origin = workplane.get("origin_mm") or [0, 0, 0] x_dir = workplane.get("x_dir") or [1, 0, 0] normal = workplane.get("normal") or [0, 0, 1] @@ -124,1491 +49,81 @@ def _to_3d(workplane: dict[str, Any], u: float, v: float) -> list[float]: normal[2] * x_dir[0] - normal[0] * x_dir[2], normal[0] * x_dir[1] - normal[1] * x_dir[0], ] - return [ - origin[0] + u * x_dir[0] + v * y_dir[0], - origin[1] + u * x_dir[1] + v * y_dir[1], - origin[2] + u * x_dir[2] + v * y_dir[2], - ] + return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]] -def _transform_contours(contour: list[dict[str, Any]], wp: dict[str, Any]) -> list[dict[str, Any]]: - """将轮廓边的2D坐标映射为3D世界坐标。""" - result: list[dict[str, Any]] = [] - x_dir = wp.get("x_dir") or [1, 0, 0] - normal = wp.get("normal") or [0, 0, 1] - for e in contour: - e2 = deepcopy(e) - if e["type"] == "line": - e2["start_mm"] = _to_3d(wp, e["start_mm"][0], e["start_mm"][1]) - e2["end_mm"] = _to_3d(wp, e["end_mm"][0], e["end_mm"][1]) - elif e["type"] == "arc": - e2["start_mm"] = _to_3d(wp, e["start_mm"][0], e["start_mm"][1]) - e2["end_mm"] = _to_3d(wp, e["end_mm"][0], e["end_mm"][1]) - e2["center_mm"] = _to_3d(wp, e["center_mm"][0], e["center_mm"][1]) - e2["normal"] = list(normal) - result.append(e2) - return result +def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]: + transformed: list[_Ctx] = [] + normal = workplane.get("normal") or [0, 0, 1] + for edge in contours: + output = deepcopy(edge) + output["start_mm"] = _to_3d(workplane, edge["start_mm"][0], edge["start_mm"][1]) + output["end_mm"] = _to_3d(workplane, edge["end_mm"][0], edge["end_mm"][1]) + if edge["type"] == "arc": + output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1]) + output["normal"] = list(normal) + transformed.append(output) + return transformed -# ═══════════════════════════════════════════════════════════════ -# 矩形 / 圆辅助 -# ═══════════════════════════════════════════════════════════════ - -def _build_rect_bounds(profile: dict[str, Any]) -> tuple[float, float, float, float]: - """从 profile 中提取矩形的 (x0, y0, x1, y1) 边界。""" - center = profile.get("center") - w = float(profile.get("width_mm") or 0) - h = float(profile.get("height_mm") or 0) - if center and w > 0 and h > 0: - cx, cy = float(center[0]), float(center[1]) - return cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2 - mn = profile.get("min_mm") - mx = profile.get("max_mm") - if mn and mx: - return float(mn[0]), float(mn[1]), float(mx[0]), float(mx[1]) - raise ValueError("rectangle profile needs (center+width+height) or (min+max)") - - -def _rect_lines_and_contour( - x0: float, y0: float, x1: float, y1: float, -) -> tuple[list[dict], list[dict]]: - p00, p10, p11, p01 = [x0, y0, 0.0], [x1, y0, 0.0], [x1, y1, 0.0], [x0, y1, 0.0] - entities = [ - _line([x0, y0], [x1, y0]), - _line([x1, y0], [x1, y1]), - _line([x1, y1], [x0, y1]), - _line([x0, y1], [x0, y0]), - ] - contour = [ - _contour_line(p00, p10), - _contour_line(p10, p11), - _contour_line(p11, p01), - _contour_line(p01, p00), - ] - return entities, contour - - -def _build_circle_entities(items: list[dict]) -> list[dict]: - entities: list[dict] = [] - for item in items: - center = item.get("center") or [0.0, 0.0] - r = float(item.get("radius_mm") or 0) - if r <= 0: - raise ValueError("circle radius must be > 0") - entities.append(_circle(center, r, construction=False)) - return entities - - -def _filleted_rect_contour( - x0: float, y0: float, x1: float, y1: float, r: float, -) -> tuple[list[dict], list[dict]]: - """生成带圆角矩形的实体线和轮廓边(4直线 + 4圆弧)。""" - if r <= 0: - return _rect_lines_and_contour(x0, y0, x1, y1) - - cx0, cx1 = x0 + r, x1 - r - cy0, cy1 = y0 + r, y1 - r - - entities = [ - _line([cx0, y0], [cx1, y0]), - _line([x0, cy0], [x0, cy1]), - _line([cx0, y1], [cx1, y1]), - _line([x1, cy0], [x1, cy1]), - ] - - contour = [ - _contour_line([x0, cy0, 0.0], [x0, cy1, 0.0]), - _contour_arc([x0, cy1, 0.0], [cx0, y1, 0.0], [cx0, cy1, 0.0], r), - _contour_line([cx0, y1, 0.0], [cx1, y1, 0.0]), - _contour_arc([cx1, y1, 0.0], [x1, cy1, 0.0], [cx1, cy1, 0.0], r), - _contour_line([x1, cy1, 0.0], [x1, cy0, 0.0]), - _contour_arc([x1, cy0, 0.0], [cx1, y0, 0.0], [cx1, cy0, 0.0], r), - _contour_line([cx1, y0, 0.0], [cx0, y0, 0.0]), - _contour_arc([cx0, y0, 0.0], [x0, cy0, 0.0], [cx0, cy0, 0.0], r), - ] - - return entities, contour - - -# ═══════════════════════════════════════════════════════════════ -# 形状生成器(每个是一个独立函数,按 type 注册) -# ═══════════════════════════════════════════════════════════════ - -_Ctx = dict[str, Any] - - -def _gen_circle(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: +def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: center = profile.get("center") or [0.0, 0.0] - r = float(profile.get("radius_mm") or 0) - if r <= 0: + radius = float(profile.get("radius_mm") or 0.0) + if radius <= 0: raise ValueError("circle radius must be > 0") - entities = [_circle(center, r)] - cx, cy, c3d = float(center[0]), float(center[1]), [float(center[0]), float(center[1]), 0.0] - contour = [ - _contour_arc([cx + r, cy, 0.0], [cx, cy + r, 0.0], c3d, r), - _contour_arc([cx, cy + r, 0.0], [cx - r, cy, 0.0], c3d, r), - _contour_arc([cx - r, cy, 0.0], [cx, cy - r, 0.0], c3d, r), - _contour_arc([cx, cy - r, 0.0], [cx + r, cy, 0.0], c3d, r), - ] - return entities, contour - - -def _gen_annulus(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - center = profile.get("center") or [0.0, 0.0] - inner_r = float(profile.get("inner_radius_mm") or 0) - outer_r = float(profile.get("outer_radius_mm") or 0) - if inner_r <= 0 or outer_r <= 0: - raise ValueError("annulus radii must be > 0") - if inner_r >= outer_r: - raise ValueError("inner_radius >= outer_radius") - return [_circle(center, inner_r), _circle(center, outer_r)], [] - - -def _gen_circles(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - items = profile.get("items") or [] - if not items: - raise ValueError("circles items must be non-empty") - return _build_circle_entities(items), [] - - -def _gen_circle_grid(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """矩形圆孔阵列:由起点圆心 + 间距 + 行列数生成。 - - 参数: - radius_mm: 孔半径 - count_x / count_y: 列数、行数 - spacing_x_mm / spacing_y_mm: 圆心间距 - origin_mm: 第一孔圆心 [u,v](默认沿 +u/+v 铺开) - 或 center_mm: 阵列几何中心(与 origin_mm 二选一) - - 覆盖: b006(4×5 通孔阵列) - """ - r = float(profile["radius_mm"]) - nx = int(profile["count_x"]) - ny = int(profile["count_y"]) - sx = float(profile["spacing_x_mm"]) - sy = float(profile["spacing_y_mm"]) - if r <= 0 or nx < 1 or ny < 1: - raise ValueError("circle_grid: invalid radius/counts") - - if profile.get("center_mm") is not None: - cc = profile["center_mm"] - u0 = float(cc[0]) - (nx - 1) * sx / 2.0 - v0 = float(cc[1]) - (ny - 1) * sy / 2.0 - else: - origin = profile.get("origin_mm") or [0.0, 0.0] - u0, v0 = float(origin[0]), float(origin[1]) - - items = [ - {"center": [u0 + i * sx, v0 + j * sy], "radius_mm": r} - for j in range(ny) - for i in range(nx) - ] - return _build_circle_entities(items), [] - - -def _gen_rectangle(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - x0, y0, x1, y1 = _build_rect_bounds(profile) - return _rect_lines_and_contour(x0, y0, x1, y1) - - -def _gen_rect_with_circles(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - boundary = profile.get("boundary") or {} - circle_items = profile.get("circles") or [] - btype = boundary.get("type") or "rectangle" - if btype in ("rectangle", "rectangle_with_fillets"): - if btype == "rectangle": - x0, y0, x1, y1 = _build_rect_bounds(boundary) - ent, con = _rect_lines_and_contour(x0, y0, x1, y1) - else: - fr = float(boundary.get("fillet_radius_mm") or 0) - x0, y0, x1, y1 = _build_rect_bounds(boundary) - ent, con = _filleted_rect_contour(x0, y0, x1, y1, fr) - ent.extend(_build_circle_entities(circle_items)) - return ent, con - raise ValueError(f"rectangle_with_circles: unsupported boundary type {btype!r}") - - -def _gen_rect_with_fillets(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - fr = float(profile.get("fillet_radius_mm") or 0) - x0, y0, x1, y1 = _build_rect_bounds(profile) - ent, con = _filleted_rect_contour(x0, y0, x1, y1, fr) - ent.extend(_build_circle_entities(profile.get("circles") or [])) - return ent, con - - -def _gen_obround(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - center = profile.get("center") - length = float(profile.get("length_mm") or 0) - width = float(profile.get("width_mm") or 0) - if length <= 0 or width <= 0: - raise ValueError("obround needs positive length/width") - r = width / 2 - cx, cy = (float(center[0]), float(center[1])) if center else (0.0, 0.0) - offset = max(0, (length - width) / 2) - left_cx, right_cx = cx - offset, cx + offset - - if offset < 0.001: - c3d = [cx, cy, 0.0] - contour = [ - _contour_arc([cx + r, cy, 0.0], [cx, cy + r, 0.0], c3d, r), - _contour_arc([cx, cy + r, 0.0], [cx - r, cy, 0.0], c3d, r), - _contour_arc([cx - r, cy, 0.0], [cx, cy - r, 0.0], c3d, r), - _contour_arc([cx, cy - r, 0.0], [cx + r, cy, 0.0], c3d, r), - ] - return [_circle([cx, cy], r)], contour - - top_y, bot_y = cy + r, cy - r - left_c3d, right_c3d = [left_cx, cy, 0.0], [right_cx, cy, 0.0] - contour = [ - _contour_arc([right_cx, top_y, 0.0], [right_cx + r, cy, 0.0], right_c3d, r), - _contour_arc([right_cx + r, cy, 0.0], [right_cx, bot_y, 0.0], right_c3d, r), - _contour_line([right_cx, bot_y, 0.0], [left_cx, bot_y, 0.0]), - _contour_arc([left_cx, bot_y, 0.0], [left_cx - r, cy, 0.0], left_c3d, r), - _contour_arc([left_cx - r, cy, 0.0], [left_cx, top_y, 0.0], left_c3d, r), - _contour_line([left_cx, top_y, 0.0], [right_cx, top_y, 0.0]), - ] - entities = [ - _line([left_cx, bot_y], [right_cx, bot_y]), - _line([left_cx, top_y], [right_cx, top_y]), - _circle([left_cx, cy], r), - _circle([right_cx, cy], r), - ] - return entities, contour + cx, cy = float(center[0]), float(center[1]) + points = [[cx + radius, cy, 0.0], [cx, cy + radius, 0.0], [cx - radius, cy, 0.0], [cx, cy - radius, 0.0], [cx + radius, cy, 0.0]] + return [_circle([cx, cy], radius)], [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius) for index in range(4)] def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: vertices = profile.get("vertices") or [] - if len(vertices) >= 3: - pts_2d = [(float(v[0]), float(v[1])) for v in vertices] - entities, contour = [], [] - for i in range(len(pts_2d)): - s, e = pts_2d[i], pts_2d[(i + 1) % len(pts_2d)] - entities.append(_line(list(s), list(e))) - contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) - return entities, contour - # 015133: no vertices in profile -> use entities from compiler_context - ents = meta.get("_entities") or [] - if not ents: - raise ValueError("polygon needs at least 3 vertices or existing entities in sketch") - contour = [] - for e in ents: - t = e.get("type", "") - if t == "line": - s = e.get("start", [0, 0]) - ed = e.get("end", [0, 0]) - contour.append(_contour_line([float(s[0]), float(s[1]), 0.0], [float(ed[0]), float(ed[1]), 0.0])) - elif t == "arc": - contour.append(_contour_line( - [float(e["start"][0]), float(e["start"][1]), 0.0], - [float(e["end"][0]), float(e["end"][1]), 0.0])) - return list(ents), contour - - -def _gen_ibone(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """工字形凸耳:12线 + 4弧 + 4孔""" - bw, bh = float(profile["body_width_mm"]), float(profile["body_height_mm"]) - fw, fh = float(profile["flange_width_mm"]), float(profile["flange_height_mm"]) - cr = float(profile["corner_radius_mm"]) - hr = float(profile.get("hole_radius_mm") or 0) - hw, hfw, ar = bw / 2, fw / 2, bw / 2 - cr - av_bot, av_top = fh - cr, bh - fh + cr - - segs = [ - ("L", hfw, 0, -hfw, 0), - ("L", -hfw, 0, -hfw, fh - cr), - ("A", -hfw, fh - cr, -ar, fh, -ar, fh - cr, cr), - ("L", -ar, fh, -hw, fh), - ("L", -hw, fh, -hw, bh - fh), - ("L", -hw, bh - fh, -ar, bh - fh), - ("A", -ar, bh - fh, -hfw, bh - fh + cr, -ar, bh - fh + cr, cr), - ("L", -hfw, bh - fh + cr, -hfw, bh), - ("L", -hfw, bh, hfw, bh), - ("L", hfw, bh, hfw, bh - fh + cr), - ("A", hfw, bh - fh + cr, ar, bh - fh, ar, bh - fh + cr, cr), - ("L", ar, bh - fh, hw, bh - fh), - ("L", hw, bh - fh, hw, fh), - ("L", hw, fh, ar, fh), - ("A", ar, fh, hfw, fh - cr, ar, fh - cr, cr), - ("L", hfw, fh - cr, hfw, 0), - ] - entities, contour = [], [] - for s in segs: - if s[0] == "L": - _, u1, v1, u2, v2 = s - entities.append(_line([u1, v1], [u2, v2])) - contour.append(_contour_line([u1, v1, 0.0], [u2, v2, 0.0])) - else: - _, u1, v1, u2, v2, cu, cv, r = s - contour.append(_contour_arc([u1, v1, 0.0], [u2, v2, 0.0], [cu, cv, 0.0], r)) - - if hr > 0: - for cu, cv in [(-ar, av_bot), (ar, av_bot), (-ar, av_top), (ar, av_top)]: - entities.append(_circle([cu, cv], hr)) - return entities, contour - - -def _gen_rect_symmetric_notches(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """对称槽板:矩形+4个U形缺口(简化多边形 或 精确弧边)""" - w, h = float(profile["width_mm"]), float(profile["height_mm"]) - n = profile.get("notch") or {} - n_ys, n_ye = float(n["y_start"]), float(n["y_end"]) - n_depth = float(n["depth_mm"]) - n_ir = float(n.get("inner_radius_mm") or 0) - n_cr = float(n.get("corner_radius_mm") or 0) - hw = w / 2 - inner_u = hw - n_depth - - if n_ir > 0 and n_cr > 0: - icu, icv = hw - n_depth / 2, (n_ys + n_ye) / 2 - entities, contour = [], [] - for u1, v1, u2, v2 in [ - (hw, 0, hw, n_ys), (hw, n_ye, hw, h - n_ye), - (hw, h - n_ys, hw, h), (-hw, h, -hw, h - n_ys), - (-hw, h - n_ye, -hw, n_ye), (-hw, n_ys, -hw, 0), - (-hw, 0, hw, 0), (hw, h, -hw, h), - ]: - entities.append(_line([u1, v1], [u2, v2])) - contour.append(_contour_line([u1, v1, 0.0], [u2, v2, 0.0])) - - def _notch(sign_u, y_bot, y_top): - u = sign_u * hw - ec = sign_u * (hw - n_cr) - icu2 = sign_u * icu - contour.append(_contour_arc( - [u, y_bot, 0.0], [ec, y_bot + n_cr, 0.0], [ec, y_bot, 0.0], n_cr)) - av = y_bot + n_cr - au = icu2 - sign_u * math.sqrt(max(0.0, n_ir ** 2 - (av - icv) ** 2)) - contour.append(_contour_line([ec, av, 0.0], [au, av, 0.0])) - bv = y_top - n_cr - bu = icu2 - sign_u * math.sqrt(max(0.0, n_ir ** 2 - (bv - icv) ** 2)) - contour.append(_contour_arc( - [au, av, 0.0], [bu, bv, 0.0], [icu2, icv, 0.0], n_ir)) - contour.append(_contour_line([bu, bv, 0.0], [ec, bv, 0.0])) - contour.append(_contour_arc( - [ec, bv, 0.0], [u, y_top, 0.0], [ec, y_top, 0.0], n_cr)) - - _notch(+1, n_ys, n_ye) - _notch(+1, h - n_ye, h - n_ys) - _notch(-1, n_ys, n_ye) - _notch(-1, h - n_ye, h - n_ys) - return entities, contour - - # 简化多边形(5 参数) - verts = [ - (hw, 0), (hw, n_ys), (inner_u, n_ys), (inner_u, n_ye), - (hw, n_ye), (hw, h - n_ye), (inner_u, h - n_ye), - (inner_u, h - n_ys), (hw, h - n_ys), (hw, h), - (-hw, h), (-hw, h - n_ys), (-inner_u, h - n_ys), - (-inner_u, h - n_ye), (-hw, h - n_ye), (-hw, n_ye), - (-inner_u, n_ye), (-inner_u, n_ys), (-hw, n_ys), (-hw, 0), - ] - entities, contour = [], [] - pts = [(float(v[0]), float(v[1])) for v in verts] - for i in range(len(pts)): - s, e = pts[i], pts[(i + 1) % len(pts)] - entities.append(_line(list(s), list(e))) - contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) - return entities, contour - - -def _gen_revolve_chamfer(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """旋转切除的梯形截面(5顶点,相对轴顶点定义)。""" - ah = float(profile["axis_height_mm"]) - tw = float(profile["top_width_mm"]) - bw = float(profile["bottom_width_mm"]) - wi = float(profile.get("wall_inset_mm") or 0) - si = float(profile.get("step_inset_mm") or 0) - side = profile.get("on_axis_side", "left") - sign = -1 if side == "left" else 1 - v0 = (sign * tw, -wi); v1 = (0.0, 0.0); v2 = (0.0, -ah) - v3 = (sign * bw, -ah); v4 = (sign * tw, -si) - entities, contour = [], [] - for s, e in [(v0, v1), (v1, v2), (v2, v3), (v3, v4), (v4, v0)]: - entities.append(_line(list(s), list(e))) - contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) - return entities, contour - - -def _gen_revolve_chamfer_slanted(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """旋转切除的斜底梯形截面(5顶点)。 - - 与 revolve_chamfer 的区别:底部为斜边(轴底→壁底不是水平线)。 - 参数: - axis_height_mm: 轴侧总高度(V1→V2) - top_width_mm: 顶部宽度(轴→外壁) - wall_inset_mm: 顶部台阶深度(V0→V4 的 V 偏移) - wall_height_mm: 壁段高度(V4→V3) - wall_width_mm: 壁距轴的距离 - on_axis_side: "left"(U负) 或 "right"(U正) - """ - ah = float(profile["axis_height_mm"]) - tw = float(profile["top_width_mm"]) - wi = float(profile.get("wall_inset_mm") or 0) - wh = float(profile["wall_height_mm"]) - ww = float(profile["wall_width_mm"]) - side = profile.get("on_axis_side", "left") - sign = -1 if side == "left" else 1 - - v0 = (sign * tw, 0.0) # 顶部外侧 - v1 = (0.0, 0.0) # 轴顶点 - v2 = (0.0, -ah) # 轴底部 - v3 = (sign * ww, -wi - wh) # 壁底部(斜边连接到 V2) - v4 = (sign * ww, -wi) # 壁顶部(台阶) - - entities, contour = [], [] - for s, e in [(v0, v1), (v1, v2), (v2, v3), (v3, v4), (v4, v0)]: - entities.append(_line(list(s), list(e))) - contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) - return entities, contour - - -# ═══════════════════════════════════════════════════════════════ -# 弧边复合轮廓生成器(按"015133 手册"方法注册) -# ═══════════════════════════════════════════════════════════════ - - -def _gen_circle_with_arc_notches(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """圆+圆弧凹口:大圆上均匀分布的弧形缺口。 - - 参数: - outer_radius_mm: 大圆半径 - notch_radius_mm: 每个凹口的圆弧半径 - notch_angles_deg: 凹口所在的角度列表(度,从+u顺时针) - 默认 [0, 90, 180, 270](十字槽) - 例: [0,90,180,270] → 十字形,[45,135,225,315] → 斜十字 - - 覆盖文件: 48, 49, 50, 82 - """ - import math - R = float(profile["outer_radius_mm"]) - r = float(profile["notch_radius_mm"]) - angles_deg = profile.get("notch_angles_deg", [0, 90, 180, 270]) - - # 每个凹口在大圆上占据的半角宽度 - delta = math.acos(max(-1.0, min(1.0, 1.0 - r * r / (2.0 * R * R)))) - angles_rad = [math.radians(a) for a in sorted(angles_deg)] - - entities, contour = [], [] - n = len(angles_rad) - - for i in range(n): - prev_end = angles_rad[i - 1] + delta # 上一个凹口离开点 - curr_enter = angles_rad[i] - delta # 当前凹口入口 - - # 大弧:从上一个凹口离开点到当前凹口入口(顺时针) - ps_u, ps_v = R * math.cos(prev_end), R * math.sin(prev_end) - pe_u, pe_v = R * math.cos(curr_enter), R * math.sin(curr_enter) - - contour.append(_contour_arc( - [ps_u, ps_v, 0.0], [pe_u, pe_v, 0.0], - [0.0, 0.0, 0.0], R, - )) - - # 凹口弧:从入口→出口,中心在外圆上 - curr_exit = angles_rad[i] + delta - nc_u = R * math.cos(angles_rad[i]) - nc_v = R * math.sin(angles_rad[i]) - - pn_enter_u = R * math.cos(curr_enter) - pn_enter_v = R * math.sin(curr_enter) - pn_exit_u = R * math.cos(curr_exit) - pn_exit_v = R * math.sin(curr_exit) - - # 凹口弧:从出口回到入口(与大弧方向相反) - contour.append(_contour_arc( - [pn_exit_u, pn_exit_v, 0.0], [pn_enter_u, pn_enter_v, 0.0], - [nc_u, nc_v, 0.0], r, - )) - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_circular_sector_slot(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """圆弧扇区槽:一段大圆弧 + 两条径向线 + 一个矩形槽口。 - - 形状:一个扇形(大圆弧 + 两侧径向线),中心开矩形槽。 - 由两条大弧(上/下)、两条径向线、一个中央矩形槽组成。 - - 参数: - arc_radius_mm: 大弧半径(圆心在原点) - slot_half_width_mm: 槽口半宽(从圆心起的径向距离) - chord_half_mm: 弧弦线半长(弧的跨距,决定弧幅度) - - 覆盖文件: 87, 88, 89, 90 - """ - import math - R = float(profile["arc_radius_mm"]) - hw = float(profile["slot_half_width_mm"]) - ch = float(profile["chord_half_mm"]) - - # 弧端点:在圆上,到中心轴的垂直距离为 ch - # 弧端点在圆 R 上,距离中心轴 ch,其角度为 asin(ch/R) - half_angle = math.asin(max(-1.0, min(1.0, ch / R))) - - # 弧端点坐标(圆上,2 个象限) - arc_x = R * math.cos(half_angle) - arc_y = R * math.sin(half_angle) if ch >= 0 else -R * math.sin(-half_angle) - - # 4 个关键点(顺时针) - # 下弧: x 从 -arc_x 到 +arc_x, y = -ch (在圆上 y = ±arc_y ≈ ±ch) - # 右上角弧段 - arc_x_neg_angle = R * math.cos(-half_angle) - arc_y_neg = R * math.sin(-half_angle) - - p_bot_right = (arc_x_neg_angle, arc_y_neg) # 右下(圆上,负半角) - p_bot_left = (arc_x, arc_y) # 右下(圆上,正半角)... 等等 - - # 直接按 87 的几何定义:下弧从 (+xs, -ch) 到 (-xs, -ch),上弧从 (-xs, +ch) 到 (+xs, +ch) - # xs 由圆 R 和 ch 确定 - xs = math.sqrt(max(0, R * R - ch * ch)) - - contour = [ - # 下弧:从 (xs, -ch) 到 (-xs, -ch),圆心原点,半径 R(顺时针) - _contour_arc([xs, -ch, 0.0], [-xs, -ch, 0.0], [0.0, 0.0, 0.0], R), - # 左侧线:(-xs, -ch) → (-xs, +ch) ... - # 不对,夹着槽口 - - ] - - # 重新按 87 的实际边序列构建 - # [0] line (-27.5, -13)→(-27.5, +13) → 槽口左竖线 - # [1] line (-27.5, +13)→(-37.83, +13) → 径向连接 - # [2] arc r=40 c=(0,0) s=(+37.83, +13)→(-37.83, +13) → 上弧 - # [3] line (+27.5, +13)→(+37.83, +13) → 径向连接(右侧) - # [4] line (+27.5, -13)→(+27.5, +13) → 槽口右竖线 - # [5] line (+27.5, -13)→(+37.83, -13) → 径向连接 - # [6] arc r=40 c=(0,0) s=(-37.83, -13)→(+37.83, -13) → 下弧 - # [7] line (-27.5, -13)→(-37.83, -13) → 径向连接 - - # 参数化: - # slot_half = 27.5 (槽口半宽) - # chord_half = 13 (弧端点的 w 坐标,确定弧的跨度) - # arc_radius = 40 - # arc_x_end = sqrt(R² - ch²) = sqrt(1600 - 169) ≈ 37.83 - - sh = hw # slot half - axe = math.sqrt(max(0.0, R * R - ch * ch)) # arc x-endpoint - - contour = [ - # 槽口竖线(从左下到左上) - _contour_line([-sh, -ch, 0.0], [-sh, ch, 0.0]), - # 连接到弧(从槽口左上到弧左下) - _contour_line([-sh, ch, 0.0], [-axe, ch, 0.0]), - # 上弧(从弧左下到弧右下,经过原点顶) - _contour_arc([axe, ch, 0.0], [-axe, ch, 0.0], [0.0, 0.0, 0.0], R), - # 连接到槽口(从弧右下到槽口右上) - _contour_line([sh, ch, 0.0], [axe, ch, 0.0]), - # 槽口竖线(从右上到右下) - _contour_line([sh, ch, 0.0], [sh, -ch, 0.0]), - # 连接到弧(从槽口右下到弧右上) - _contour_line([sh, -ch, 0.0], [axe, -ch, 0.0]), - # 下弧(从弧右上到弧左上,经过原点底) - _contour_arc([-axe, -ch, 0.0], [axe, -ch, 0.0], [0.0, 0.0, 0.0], R), - # 连接到槽口(从弧左上到槽口左下) - _contour_line([-sh, -ch, 0.0], [-axe, -ch, 0.0]), - ] - - entities = [ - _line([-sh, -ch], [-sh, ch]), - _line([-sh, ch], [-axe, ch]), - _line([sh, ch], [axe, ch]), - _line([sh, ch], [sh, -ch]), - _line([sh, -ch], [axe, -ch]), - _line([-sh, -ch], [-axe, -ch]), - ] - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_circle_with_radial_tabs(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """圆+径向凸耳:大圆弧上有矩形凸起。 - - 形状:一个大圆被两侧的矩形凸耳取代部分弧段。 - 简化表示为直边多边形(忽略 r=1 的圆角,体积误差 <1%)。 - - 参数: - outer_radius_mm: 大圆半径 - tab_u_half_mm: 凸耳半宽(弧线方向,从根部到内缘) - tab_v_offset_mm: 凸耳离弧线的垂直距离(即凸耳顶部距弧线的v偏移) - - 覆盖文件: 91, 92, 93, 94, 95 - """ - import math - R = float(profile["outer_radius_mm"]) - tu = float(profile.get("tab_u_half_mm") or 0) - tv = float(profile.get("tab_v_offset_mm") or 0) - - # 凸耳根部在圆上的角度 - angle = math.asin(min(1.0, max(0.0, tv / R))) - - # 弧上根部点(右侧) - root_u = R * math.cos(angle) - root_v = R * math.sin(angle) - - # 凸耳内缘 - inner_u = root_u - tu - inner_v = root_v * 0.9 # 略浅于弧线 - - # 构建轮廓:大弧(上) → 右凸耳 → 大弧(下) → 左凸耳 → 闭合 - - contour = [] - - # 上弧:从左侧根部到右侧根部(经过顶点) - contour.append(_contour_arc( - [root_u, root_v, 0.0], [-root_u, root_v, 0.0], - [0.0, 0.0, 0.0], R)) - - # 右侧凸耳(多边形:根部→内顶→内底→根部) - contour.append(_contour_line([root_u, root_v, 0.0], [inner_u, inner_v, 0.0])) - contour.append(_contour_line([inner_u, inner_v, 0.0], [inner_u, -inner_v, 0.0])) - contour.append(_contour_line([inner_u, -inner_v, 0.0], [root_u, -root_v, 0.0])) - - # 下弧:从右侧底部到左侧底部(经过底点) - contour.append(_contour_arc( - [-root_u, -root_v, 0.0], [root_u, -root_v, 0.0], - [0.0, 0.0, 0.0], R)) - - # 左侧凸耳(镜像) - contour.append(_contour_line([-root_u, -root_v, 0.0], [-inner_u, -inner_v, 0.0])) - contour.append(_contour_line([-inner_u, -inner_v, 0.0], [-inner_u, inner_v, 0.0])) - contour.append(_contour_line([-inner_u, inner_v, 0.0], [-root_u, root_v, 0.0])) - - entities = [ - _line([root_u, root_v], [inner_u, inner_v]), - _line([inner_u, inner_v], [inner_u, -inner_v]), - _line([inner_u, -inner_v], [root_u, -root_v]), - _line([-root_u, -root_v], [-inner_u, -inner_v]), - _line([-inner_u, -inner_v], [-inner_u, inner_v]), - _line([-inner_u, inner_v], [-root_u, root_v]), - ] - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_filleted_rect_side_slots(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """圆角矩形+两侧中心凹槽。 - - 形状:圆角矩形,左右两侧中心各有一个 U 形凹槽(半圆槽)。 - - 参数: - half_width_mm: 矩形半宽(不含圆角) - half_height_mm: 矩形半高(不含圆角) - corner_radius_mm: 四角圆角半径 - slot_radius_mm: 两侧中心凹槽半径(默认 5.0) - circles: 可选内部圆(孔洞),[{center:[x,y], radius_mm:r}, …] - - 覆盖文件: 58, 63, 64, 65, 66 - """ - hw = float(profile["half_width_mm"]) - hh = float(profile["half_height_mm"]) - cr = float(profile["corner_radius_mm"]) - sr = float(profile.get("slot_radius_mm") or cr * 0.5) - - # 注意: v=-Z, 所以 v 正方向朝 Z 负 - # 矩形范围: u=[-hw,hw], v=[-hh,+hh] 对应 z=[+hh,-hh] - # 上边 (z=+hh): v=-hh, 下边 (z=-hh): v=+hh - - entities, contour = [], [] - top_v, bot_v = -hh, hh # 上边 v=-hh, 下边 v=+hh - - # 上边(直线,从左上角到右上角) - contour.append(_contour_line( - [-(hw - cr), top_v, 0.0], [(hw - cr), top_v, 0.0])) - entities.append(_line([-(hw - cr), top_v], [(hw - cr), top_v])) - - # 右上圆角(逆时针绕 center: 从顶点到右侧) - contour.append(_contour_arc( - [(hw - cr), top_v, 0.0], [hw, top_v + cr, 0.0], - [(hw - cr), top_v + cr, 0.0], cr)) - - # 右边上半(从圆角到凹槽上方) - contour.append(_contour_line( - [hw, top_v + cr, 0.0], [hw, -sr, 0.0])) - entities.append(_line([hw, top_v + cr], [hw, -sr])) - - # 右侧中心凹槽(半圆向内的 U 形凹口) - contour.append(_contour_arc( - [hw, sr, 0.0], [hw, -sr, 0.0], - [hw, 0.0, 0.0], sr)) - - # 右边下半(从凹槽下方到右下角) - contour.append(_contour_line( - [hw, sr, 0.0], [hw, bot_v - cr, 0.0])) - entities.append(_line([hw, sr], [hw, bot_v - cr])) - - # 右下圆角 - contour.append(_contour_arc( - [hw, bot_v - cr, 0.0], [(hw - cr), bot_v, 0.0], - [(hw - cr), bot_v - cr, 0.0], cr)) - - # 下边 - contour.append(_contour_line( - [(hw - cr), bot_v, 0.0], [-(hw - cr), bot_v, 0.0])) - entities.append(_line([(hw - cr), bot_v], [-(hw - cr), bot_v])) - - # 左下圆角 - contour.append(_contour_arc( - [-(hw - cr), bot_v, 0.0], [-hw, bot_v - cr, 0.0], - [-(hw - cr), bot_v - cr, 0.0], cr)) - - # 左边下半 - contour.append(_contour_line( - [-hw, bot_v - cr, 0.0], [-hw, sr, 0.0])) - entities.append(_line([-hw, bot_v - cr], [-hw, sr])) - - # 左侧中心凹槽 - contour.append(_contour_arc( - [-hw, -sr, 0.0], [-hw, sr, 0.0], - [-hw, 0.0, 0.0], sr)) - - # 左边上半 - contour.append(_contour_line( - [-hw, -sr, 0.0], [-hw, top_v + cr, 0.0])) - entities.append(_line([-hw, -sr], [-hw, top_v + cr])) - - # 左上圆角 - contour.append(_contour_arc( - [-hw, top_v + cr, 0.0], [-(hw - cr), top_v, 0.0], - [-(hw - cr), top_v + cr, 0.0], cr)) - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -# ═══════════════════════════════════════════════════════════════ -# 更多弧边复合轮廓生成器 -# ═══════════════════════════════════════════════════════════════ - - -def _gen_d_shape(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """D形(半圆+弦线):一条直线 + 一条大圆弧,形如字母 D。 - - 参数: - radius_mm: 大弧半径(圆心在原点) - chord_sign: 弦线方向,"left"=弦在 x>0 侧,"right"=弦在 x<0 侧 - 默认 "left"(弦线在 +x 侧,弧形开口朝 -x) - - 覆盖文件: 144358 - """ - import math - R = float(profile["radius_mm"]) - side = profile.get("chord_sign", "left") - sign = 1 if side == "left" else -1 - - # chord at x=cx (cx^2 + y^2 = R^2) - # For side="left": chord at x = sqrt(R^2 - y_len^2) ... - # Actually, from 144358 data: arc r=41 c=(0,0) from (34,22.9) to (34,-22.9) - # So the chord is at x=34, v ranges from -22.9 to 22.9 - # v_max = sqrt(R^2 - x^2) = sqrt(41^2 - 34^2) = sqrt(1681-1156) = sqrt(525) ≈ 22.91 ✓ - - v_max = math.sqrt(max(0.0, R * R - (R - 7) * (R - 7))) - # 实际上,chord x 可以根据 radius 推导 - # 使用 chord_x 参数如果存在,否则用近似 - chord_x = float(profile.get("chord_x_mm") or R * 0.83) # 默认在半径 83% 处 - - v_half = math.sqrt(max(0.0, R * R - chord_x * chord_x)) - cx = sign * chord_x # 弦线 x 坐标 - - contour = [ - # 弦线(从下到上) - _contour_line([cx, -v_half, 0.0], [cx, v_half, 0.0]), - # 大弧(从右上到左下,即从左到右沿弧线) - _contour_arc([cx, v_half, 0.0], [cx, -v_half, 0.0], [0.0, 0.0, 0.0], R), - ] - - entities = [ - _line([cx, -v_half], [cx, v_half]), - ] - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_partial_ring(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """部分圆环(同心圆弧+径向直线):两段同心弧 + 两条径向线。 - - 形状像一个扇区环 (sector annulus),由内外两段同心弧和两侧径向线组成。 - - 参数: - inner_radius_mm: 内弧半径 - outer_radius_mm: 外弧半径 - half_angle_deg: 弧的半角度(两侧各 half_angle 度,总张角 2*half_angle) - - 覆盖文件: 177126 - """ - import math - ir = float(profile["inner_radius_mm"]) - oR = float(profile["outer_radius_mm"]) - h_deg = float(profile.get("half_angle_deg") or 45.0) - h_rad = math.radians(h_deg) - - # 内弧端点 - iu_pos = ir * math.cos(h_rad) - iv_pos = ir * math.sin(h_rad) - iu_neg = ir * math.cos(-h_rad) - iv_neg = ir * math.sin(-h_rad) - - # 外弧端点 - ou_pos = oR * math.cos(h_rad) - ov_pos = oR * math.sin(h_rad) - ou_neg = oR * math.cos(-h_rad) - ov_neg = oR * math.sin(-h_rad) - - contour = [ - # 右侧径向线(从内弧到外弧,+h角度) - _contour_line([iu_pos, iv_pos, 0.0], [ou_pos, ov_pos, 0.0]), - # 外弧(从 +h 到 -h) - _contour_arc([ou_neg, ov_neg, 0.0], [ou_pos, ov_pos, 0.0], [0.0, 0.0, 0.0], oR), - # 左侧径向线(从外弧到内弧,-h角度) - _contour_line([ou_neg, ov_neg, 0.0], [iu_neg, iv_neg, 0.0]), - # 内弧(从 -h 到 +h) - _contour_arc([iu_pos, iv_pos, 0.0], [iu_neg, iv_neg, 0.0], [0.0, 0.0, 0.0], ir), - ] - - entities = [ - _line([iu_pos, iv_pos], [ou_pos, ov_pos]), - _line([ou_neg, ov_neg], [iu_neg, iv_neg]), - ] - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_partial_ring_with_arc_island(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """扇区环 + 外弦上的等宽弧岛(岛不切除,作为区域内孔)。 - - 每个 replica 生成一块「外轮廓=扇区环、内孔=偏移弧岛」的区域。 - 岛外弧端点落在扇区外弧弦上,横坐标取 ±inner·cos(half_angle)(相对角平分线)。 - - 参数: - inner_radius_mm / outer_radius_mm / half_angle_deg: 扇区环 - island_radius_mm: 岛外弧半径 - island_gap_mm: 岛内外弧径向间距(等宽) - center_angles_deg 或 replicas[{center_angle_deg}]: 各扇区角平分线方向(度) - - 覆盖: b005 - """ - ir = float(profile["inner_radius_mm"]) - oR = float(profile["outer_radius_mm"]) - h_deg = float(profile.get("half_angle_deg") or 45.0) - island_r = float(profile["island_radius_mm"]) - gap = float(profile.get("island_gap_mm") or 1.0) - if ir <= 0 or oR <= ir or island_r <= gap: - raise ValueError("partial_ring_with_arc_island: invalid radii") - - replicas = profile.get("replicas") - if replicas: - angles = [float(r["center_angle_deg"]) for r in replicas] - else: - angles = [float(a) for a in (profile.get("center_angles_deg") or [0.0])] - - h = math.radians(h_deg) - entities: list[_Ctx] = [] - regions: list[dict[str, Any]] = [] - - for ca_deg in angles: - ca = math.radians(ca_deg) - a0, a1 = ca - h, ca + h - - def polar(r: float, ang: float) -> list[float]: - return [r * math.cos(ang), r * math.sin(ang), 0.0] - - # 扇区环外轮廓(逆时针:外弧 a0→a1,径向,内弧 a1→a0,径向) - ou0, ou1 = polar(oR, a0), polar(oR, a1) - iu0, iu1 = polar(ir, a0), polar(ir, a1) - outer = [ - _contour_arc(ou0, ou1, [0.0, 0.0, 0.0], oR), - _contour_line(ou1, iu1), - _contour_arc(iu1, iu0, [0.0, 0.0, 0.0], ir), - _contour_line(iu0, ou0), - ] - entities.extend([ - _line(ou0[:2], ou1[:2]), - _line(ou1[:2], iu1[:2]), - _line(iu1[:2], iu0[:2]), - _line(iu0[:2], ou0[:2]), - ]) - - # 外弦中点与弦向单位向量;岛端点 = M ± inner·cos(h)·chord_dir - ux, uy = math.cos(ca), math.sin(ca) - mx = oR * ux * math.cos(h) - my = oR * uy * math.cos(h) - cdx, cdy = -uy, ux - span = ir * math.cos(h) - e1 = [mx + span * cdx, my + span * cdy, 0.0] - e2 = [mx - span * cdx, my - span * cdy, 0.0] - - # 岛心在角平分线上:|E - t·u| = island_r,取距原点较近根 - dot = e1[0] * ux + e1[1] * uy - e2n = e1[0] * e1[0] + e1[1] * e1[1] - disc = max(0.0, dot * dot - (e2n - island_r * island_r)) - t1, t2 = dot - math.sqrt(disc), dot + math.sqrt(disc) - t = t1 if abs(t1) <= abs(t2) else t2 - cx, cy = t * ux, t * uy - c3 = [cx, cy, 0.0] - - def inward(pt: list[float]) -> list[float]: - vx, vy = cx - pt[0], cy - pt[1] - L = math.hypot(vx, vy) or 1.0 - return [pt[0] + vx / L * gap, pt[1] + vy / L * gap, 0.0] - - i1, i2 = inward(e1), inward(e2) - ri = island_r - gap - - # 岛孔:外弧 e1→e2(经外侧鼓包)再经内弧返回;与扇区同向时作孔需反向 - # 外弧走短弧中指向外侧(远离原点)的那条 - hole = [ - _contour_arc(e1, e2, c3, island_r), - _contour_line(e2, i2), - _contour_arc(i2, i1, c3, ri), - _contour_line(i1, e1), - ] - entities.extend([ - _line(e1[:2], e2[:2]), - _line(e2[:2], i2[:2]), - _line(i2[:2], i1[:2]), - _line(i1[:2], e1[:2]), - ]) - regions.append({"outer": outer, "holes": [hole]}) - - meta["_regions"] = regions - return entities, [] - - -def _gen_arc_chain(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """弧链轮廓:多段首尾相连的弧形成闭合轮廓(各弧可有不同圆心)。 - - 用于 revolve 特征的截面草图,由多段圆弧端到端连接组成。 - - 参数: - arcs: 弧描述列表 [{radius_mm, center:[u,v], start_angle_deg, end_angle_deg}, ...] - (每个弧从 start_angle 到 end_angle,起点与上一条弧终点重合) - - 覆盖文件: 020543 - """ - import math - arc_list = profile.get("arcs") or [] - - if not arc_list or len(arc_list) < 2: - raise ValueError("arc_chain needs at least 2 arcs") - - entities, contour = [], [] - - for arc_desc in arc_list: - r = float(arc_desc["radius_mm"]) - center = arc_desc.get("center") or [0.0, 0.0] - cu, cv = float(center[0]), float(center[1]) - sa = math.radians(float(arc_desc["start_angle_deg"])) - ea = math.radians(float(arc_desc["end_angle_deg"])) - - su = cu + r * math.cos(sa) - sv = cv + r * math.sin(sa) - eu = cu + r * math.cos(ea) - ev = cv + r * math.sin(ea) - - contour.append(_contour_arc( - [su, sv, 0.0], [eu, ev, 0.0], - [cu, cv, 0.0], r, - )) - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_radial_slot(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """径向槽:两段同心圆弧 + 两端圆角,形如弧形环段。 - - 用于 extrude_cut 在圆柱壁面上开弧形槽口。 - - 参数: - inner_radius_mm: 内弧半径 - outer_radius_mm: 外弧半径 - start_angle_deg: 槽起始角度(度,从工作平面 x_dir 方向逆时针测量) - end_angle_deg: 槽终止角度 - - 覆盖文件: 020543 (sk_02, sk_03, sk_04) - """ - import math - ir = float(profile["inner_radius_mm"]) - oR = float(profile["outer_radius_mm"]) - sa_deg = float(profile["start_angle_deg"]) - ea_deg = float(profile["end_angle_deg"]) - - fr = (oR - ir) / 2.0 # 端盖圆角半径 - sa = math.radians(sa_deg) - ea = math.radians(ea_deg) - - entities, contour = [], [] - - # 角度从工作平面 x_dir 方向测量 → u = r·cos(θ), v = r·sin(θ) - # 1. 内弧(从 start→end) - isu = ir * math.cos(sa); isv = ir * math.sin(sa) - ieu = ir * math.cos(ea); iev = ir * math.sin(ea) - contour.append(_contour_arc( - [isu, isv, 0.0], [ieu, iev, 0.0], - [0.0, 0.0, 0.0], ir, - )) - - # 2. 终端圆角(半圆,从内弧终点到外弧终点) - fc_u = (ir + oR) / 2.0 - fcu_s = fc_u * math.cos(ea); fcv_s = fc_u * math.sin(ea) - osu = oR * math.cos(sa); osv = oR * math.sin(sa) - oeu = oR * math.cos(ea); oev = oR * math.sin(ea) - - contour.append(_contour_arc( - [ieu, iev, 0.0], [oeu, oev, 0.0], - [fcu_s, fcv_s, 0.0], fr, - )) - - # 3. 外弧(从 end→start,反向) - contour.append(_contour_arc( - [oeu, oev, 0.0], [osu, osv, 0.0], - [0.0, 0.0, 0.0], oR, - )) - - # 4. 起始端圆角(从外弧起点到内弧起点) - fcu_e = fc_u * math.cos(sa); fcv_e = fc_u * math.sin(sa) - contour.append(_contour_arc( - [osu, osv, 0.0], [isu, isv, 0.0], - [fcu_e, fcv_e, 0.0], fr, - )) - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -# ═══════════════════════════════════════════════════════════════ -# 程序化重复切口 -# ═══════════════════════════════════════════════════════════════ - -def _poly_contour(vertices: list[tuple[float, float]]) -> list[_Ctx]: - """把按顺序给出的二维顶点变成闭合直线轮廓。""" - return [ - _contour_line( - [vertices[i][0], vertices[i][1], 0.0], - [vertices[(i + 1) % len(vertices)][0], vertices[(i + 1) % len(vertices)][1], 0.0], - ) - for i in range(len(vertices)) - ] - - -def _transform_pattern_contour( - contour: list[_Ctx], - x_mm: float, - y_mm: float, - angle_deg: float, - scale: float = 1.0, -) -> list[_Ctx]: - """旋转、缩放并平移一个二维轮廓。""" - a = math.radians(angle_deg) - ca, sa = math.cos(a), math.sin(a) - - def point(p: list[float]) -> list[float]: - x, y = float(p[0]) * scale, float(p[1]) * scale - return [x_mm + x * ca - y * sa, y_mm + x * sa + y * ca, 0.0] - - result: list[_Ctx] = [] - for edge in contour: - item = deepcopy(edge) - item["start_mm"] = point(edge["start_mm"]) - item["end_mm"] = point(edge["end_mm"]) - if edge.get("center_mm") is not None: - item["center_mm"] = point(edge["center_mm"]) - if edge.get("radius_mm") is not None: - item["radius_mm"] = float(edge["radius_mm"]) * scale - result.append(item) - return result - - -def _pattern_motif_contour(motif: _Ctx) -> list[_Ctx]: - """从少量命名尺寸生成一个切口母形。""" - kind = str(motif.get("type") or "") - - if kind == "circle": - radius = float(motif["radius_mm"]) - return _gen_circle({"type": "circle", "radius_mm": radius}, {})[1] - - if kind in ("square", "rectangle"): - width = float(motif["width_mm"]) - height = float(motif.get("height_mm") or width) - return _rect_lines_and_contour(-width / 2.0, -height / 2.0, width / 2.0, height / 2.0)[1] - - if kind == "obround": - length = float(motif["length_mm"]) - width = float(motif["width_mm"]) - return _gen_obround( - {"type": "obround", "center": [0.0, 0.0], "length_mm": length, "width_mm": width}, - {}, - )[1] - - if kind == "cross": - size = float(motif["size_mm"]) - arm = float(motif["arm_width_mm"]) - half, arm_half = size / 2.0, arm / 2.0 - vertices = [ - (-arm_half, -half), (arm_half, -half), - (arm_half, -arm_half), (half, -arm_half), - (half, arm_half), (arm_half, arm_half), - (arm_half, half), (-arm_half, half), - (-arm_half, arm_half), (-half, arm_half), - (-half, -arm_half), (-arm_half, -arm_half), - ] - return _poly_contour(vertices) - - if kind == "d_shape_polygon": - stem = float(motif["stem_length_mm"]) - nose = float(motif["nose_depth_mm"]) - half_height = float(motif["half_height_mm"]) - segments = int(motif.get("arc_segments") or 14) - vertices = [(-stem, -half_height), (-stem, half_height), (0.0, half_height)] - # 右半椭圆;首尾端点已由直线给出,内部取样由引擎固化。 - for i in range(1, segments): - angle = math.pi / 2.0 - math.pi * i / segments - vertices.append((nose * math.cos(angle), half_height * math.sin(angle))) - vertices.append((0.0, -half_height)) - return _poly_contour(vertices) - - if kind == "regular_hexagon": - radius = float(motif["radius_mm"]) - return _poly_contour([ - ( - radius * math.cos(math.radians(60.0 * i)), - radius * math.sin(math.radians(60.0 * i)), - ) - for i in range(6) - ]) - - if kind == "skew_hexagon": - # 该族来自六边形母形的非对称离散模板;只保留一个名义半径, - # 其余稳定比例由引擎固化,不把六个顶点写进 CDSL。 - radius = float(motif["nominal_radius_mm"]) - return _poly_contour([ - (radius, 0.0), - (radius * 0.317014, radius * 0.682, ), - (-radius * 0.5, radius * 0.682), - (-radius * 1.183014, 0.0), - (-radius * 0.408494, -radius * 0.774519), - (radius * 0.317014, -radius * 0.774519), - ]) - - if kind == "triangle": - radius = float(motif["radius_mm"]) - return _poly_contour([ - ( - radius * math.cos(math.radians(120.0 * i)), - radius * math.sin(math.radians(120.0 * i)), - ) - for i in range(3) - ]) - - if kind == "teardrop_polygon": - if motif.get("left_width_mm") is not None: - left = float(motif["left_width_mm"]) - right = float(motif["right_width_mm"]) - tip = float(motif["tip_height_mm"]) - bottom = -float(motif["bottom_depth_mm"]) - shoulder = float(motif["shoulder_height_mm"]) - return _poly_contour([ - (0.0, tip), - (right, shoulder), - (right, bottom), - (-left, bottom), - (-left, shoulder), - ]) - width = float(motif["width_mm"]) - height = float(motif["height_mm"]) - shoulder = float(motif.get("shoulder_fraction") or 0.58) - half = width / 2.0 - top = height / 2.0 - bottom = -height / 2.0 - shoulder_y = bottom + height * shoulder - return _poly_contour([ - (0.0, top), - (half, shoulder_y), - (half, bottom), - (-half, bottom), - (-half, shoulder_y), - ]) - - if kind == "trapezoid": - bottom = float(motif["bottom_width_mm"]) - top = float(motif["top_width_mm"]) - height = float(motif["height_mm"]) - hh = height / 2.0 - return _poly_contour([ - (-bottom / 2.0, -hh), - (bottom / 2.0, -hh), - (top / 2.0, hh), - (-top / 2.0, hh), - ]) - - if kind == "annular_sector_polygon": - inner = float(motif["inner_radius_mm"]) - outer = float(motif["outer_radius_mm"]) - half_angle = float(motif["half_angle_deg"]) - segments = int(motif.get("arc_segments") or 8) - outer_pts = [ - ( - outer * math.cos(math.radians(-half_angle + 2.0 * half_angle * i / segments)), - outer * math.sin(math.radians(-half_angle + 2.0 * half_angle * i / segments)), - ) - for i in range(segments + 1) - ] - inner_pts = [ - ( - inner * math.cos(math.radians(half_angle - 2.0 * half_angle * i / segments)), - inner * math.sin(math.radians(half_angle - 2.0 * half_angle * i / segments)), - ) - for i in range(segments + 1) - ] - return _poly_contour(outer_pts + inner_pts) - - raise ValueError(f"patterned_cutouts: unsupported motif type {kind!r}") - - -def _pattern_placements(layout: _Ctx) -> list[tuple[float, float, float, float]]: - """展开语义布局,返回 (x, y, rotation_deg, scale)。""" - kind = str(layout.get("type") or "") - orientation = str(layout.get("orientation") or "fixed") - orientation_offset = float(layout.get("orientation_offset_deg") or 0.0) - - def orient(angle: float) -> float: - if orientation == "radial": - return angle + orientation_offset - if orientation == "tangential": - return angle + 90.0 + orientation_offset - if orientation == "snapped_radial": - snap = float(layout.get("orientation_snap_deg") or 45.0) - return round(angle / snap) * snap + orientation_offset - return orientation_offset - - if kind in ("ring", "angular"): - radius = float(layout.get("radius_mm") or 0.0) - count = int(layout["count"]) - start = float(layout.get("start_angle_deg") or 0.0) - step = float(layout.get("angle_step_deg") or (360.0 / count)) - angular_only = kind == "angular" - return [ - ( - 0.0 if angular_only else radius * math.cos(math.radians(start + i * step)), - 0.0 if angular_only else radius * math.sin(math.radians(start + i * step)), - orient(start + i * step), - 1.0, - ) - for i in range(count) - ] - - if kind == "concentric_rings": - result: list[tuple[float, float, float, float]] = [] - for ring in layout.get("rings") or []: - merged = dict(layout) - merged.update(ring) - merged["type"] = "ring" - result.extend(_pattern_placements(merged)) - return result - - if kind == "disc_grid": - nx, ny = int(layout["count_x"]), int(layout["count_y"]) - sx, sy = float(layout["spacing_x_mm"]), float(layout["spacing_y_mm"]) - center = layout.get("center_mm") or [0.0, 0.0] - x0 = float(center[0]) - (nx - 1) * sx / 2.0 - y0 = float(center[1]) - (ny - 1) * sy / 2.0 - limit = layout.get("max_center_radius_mm") - points = [ - (x0 + i * sx, y0 + j * sy) - for j in range(ny) - for i in range(nx) - ] - if limit is not None: - points = [(x, y) for x, y in points if math.hypot(x, y) <= float(limit) + 1e-9] - return [(x, y, orientation_offset, 1.0) for x, y in points] - - if kind == "open_arc": - radius = float(layout["radius_mm"]) - count = int(layout["count"]) - start, end = float(layout["start_angle_deg"]), float(layout["end_angle_deg"]) - step = 0.0 if count == 1 else (end - start) / (count - 1) - return [ - ( - radius * math.cos(math.radians(start + i * step)), - radius * math.sin(math.radians(start + i * step)), - orient(start + i * step), - 1.0, - ) - for i in range(count) - ] - - if kind == "spiral": - count = int(layout["count"]) - start_radius = float(layout["start_radius_mm"]) - radius_step = float(layout["radius_step_mm"]) - start_angle = float(layout.get("start_angle_deg") or 0.0) - angle_step = float(layout["angle_step_deg"]) - result = [] - for i in range(count): - radius = start_radius + i * radius_step - angle = start_angle + i * angle_step - result.append(( - radius * math.cos(math.radians(angle)), - radius * math.sin(math.radians(angle)), - orient(angle), - 1.0, - )) - return result - - if kind == "cross_lines": - count = int(layout["count_per_axis"]) - spacing = float(layout["spacing_mm"]) - start = -(count - 1) * spacing / 2.0 - result = [] - for i in range(count): - value = start + i * spacing - result.append((value, 0.0, orientation_offset, 1.0)) - result.append((0.0, value, orientation_offset + 90.0, 1.0)) - return result - - if kind == "x_field": - levels = int(layout["levels"]) - spacing = float(layout["spacing_mm"]) - start = -(levels - 1) * spacing / 2.0 - result = [] - for i in range(levels): - value = start + i * spacing - if abs(value) < 1e-9: - rotation = 135.0 + orientation_offset if orientation == "diagonal_axes" else orientation_offset - result.append((0.0, 0.0, rotation, 1.0)) - else: - for y in (value, -value): - if orientation == "diagonal_axes": - rotation = (135.0 if value * y > 0 else 45.0) + orientation_offset - else: - angle = math.degrees(math.atan2(y, value)) - rotation = orient(angle) - result.append((value, y, rotation, 1.0)) - return result - - if kind == "twin_strips": - x_offset = float(layout["x_offset_mm"]) - count = int(layout["count_y"]) - y_start = float(layout["y_start_mm"]) - y_end = float(layout["y_end_mm"]) - step = 0.0 if count == 1 else (y_end - y_start) / (count - 1) - return [ - (x, y_start + j * step, orientation_offset, 1.0) - for j in range(count) - for x in (-x_offset, x_offset) - ] - - if kind == "corner_clusters": - levels = [float(v) for v in (layout.get("levels_mm") or [])] - return [ - (sx * x, sy * y, orientation_offset, 1.0) - for sx in (-1.0, 1.0) - for sy in (-1.0, 1.0) - for y in levels - for x in levels - ] - - if kind == "diamond_field": - radius = int(layout["manhattan_radius"]) - spacing = float(layout["spacing_mm"]) - return [ - (i * spacing, j * spacing, orientation_offset, 1.0) - for distance in range(radius + 1) - for j in range(-radius, radius + 1) - for i in range(-radius, radius + 1) - if abs(i) + abs(j) == distance - ] - - raise ValueError(f"patterned_cutouts: unsupported layout type {kind!r}") - - -def _gen_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """一个母形 + 一个语义布局,运行时展开成多个独立切除区域。""" - motif = profile.get("motif") or {} - layout = profile.get("layout") or {} - base = _pattern_motif_contour(motif) - regions = [] - for x, y, angle, scale in _pattern_placements(layout): - regions.append({ - "outer": _transform_pattern_contour(base, x, y, angle, scale), - "holes": [], - }) - if not regions: - raise ValueError("patterned_cutouts: layout produced no regions") - meta["_regions"] = regions - return [], [] - - -def _gen_compound_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """把少量不同母形/布局的程序化图案合并到同一草图。""" - regions: list[_Ctx] = [] - for pattern in profile.get("patterns") or []: - motif = pattern.get("motif") or {} - layout = pattern.get("layout") or {} - base = _pattern_motif_contour(motif) - for x, y, angle, scale in _pattern_placements(layout): - regions.append({ - "outer": _transform_pattern_contour(base, x, y, angle, scale), - "holes": [], - }) - if not regions: - raise ValueError("compound_patterned_cutouts: patterns produced no regions") - meta["_regions"] = regions - return [], [] - - -# ═══════════════════════════════════════════════════════════════ -# Evidence v2 analytic contours -# ═══════════════════════════════════════════════════════════════ - -_ANALYTIC_TOLERANCE_MM = 1e-5 - - -def _distance_2d(left: list[float], right: list[float]) -> float: + if len(vertices) < 3: + entities = meta.get("_entities") or [] + if not entities: + raise ValueError("polygon needs at least 3 vertices") + return list(entities), [_contour_line(edge["start"], edge["end"]) for edge in entities if edge.get("type") == "line"] + points = [(float(vertex[0]), float(vertex[1])) for vertex in vertices] + return ( + [_line(list(points[index]), list(points[(index + 1) % len(points)])) for index in range(len(points))], + [_contour_line([*points[index], 0.0], [*points[(index + 1) % len(points)], 0.0]) for index in range(len(points))], + ) + + +def _distance(left: list[float], right: list[float]) -> float: return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1])) -def _reverse_analytic_edge(edge: _Ctx) -> _Ctx: - result = deepcopy(edge) - result["start_mm"], result["end_mm"] = result["end_mm"], result["start_mm"] - if result.get("type") == "arc" and "clockwise" in result: - result["clockwise"] = not bool(result["clockwise"]) - return result +def _reverse(edge: _Ctx) -> _Ctx: + output = deepcopy(edge) + output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"] + if output.get("type") == "arc" and "clockwise" in output: + output["clockwise"] = not bool(output["clockwise"]) + return output -def _join_analytic_edges(edges: list[_Ctx], *, closed: bool) -> list[_Ctx]: - """Order/reorient a contour without depending on SolidWorks segment order.""" +def _join(edges: list[_Ctx]) -> list[_Ctx]: if not edges: return [] - pending = [deepcopy(edge) for edge in edges] - ordered = [pending.pop(0)] - while pending: + remaining = [deepcopy(edge) for edge in edges] + ordered = [remaining.pop(0)] + while remaining: tail = ordered[-1]["end_mm"] - match_index = None - reverse = False - for index, edge in enumerate(pending): - if _distance_2d(tail, edge["start_mm"]) <= _ANALYTIC_TOLERANCE_MM: - match_index = index + for index, candidate in enumerate(remaining): + if _distance(tail, candidate["start_mm"]) <= _TOLERANCE_MM: + ordered.append(remaining.pop(index)) break - if _distance_2d(tail, edge["end_mm"]) <= _ANALYTIC_TOLERANCE_MM: - match_index = index - reverse = True + if _distance(tail, candidate["end_mm"]) <= _TOLERANCE_MM: + ordered.append(_reverse(remaining.pop(index))) break - if match_index is None: + else: raise ValueError("analytic_contours: segments do not form a connected contour") - edge = pending.pop(match_index) - ordered.append(_reverse_analytic_edge(edge) if reverse else edge) - if closed and _distance_2d(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _ANALYTIC_TOLERANCE_MM: + if _distance(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _TOLERANCE_MM: raise ValueError("analytic_contours: closed contour endpoints do not meet") return ordered -def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]: +def _circle_edges(segment: _Ctx) -> list[_Ctx]: center = segment.get("center") or [0.0, 0.0] radius = float(segment.get("radius_mm") or 0.0) if radius <= 0: @@ -1617,86 +132,56 @@ def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]: clockwise = bool(segment.get("clockwise", False)) angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0] points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles] - return [ - _contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) - for index in range(4) - ] + return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)] -def _analytic_segment_edges(segment: _Ctx) -> list[_Ctx]: - segment_type = segment.get("type") - if segment_type == "line": +def _segment_edges(segment: _Ctx) -> list[_Ctx]: + kind = segment.get("type") + if kind == "line": return [_contour_line(segment["start"], segment["end"])] - if segment_type == "arc": - return [ - _contour_arc( - segment["start"], segment["end"], segment["center"], - segment.get("radius_mm"), segment.get("clockwise"), - ) - ] - if segment_type == "circle": - return _analytic_circle_edges(segment) - if segment_type == "bspline": + if kind == "arc": + return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))] + if kind == "circle": + return _circle_edges(segment) + if kind == "bspline": raise ValueError("analytic_contours: bspline requires an explicit approximation capability") - raise ValueError(f"analytic_contours: unsupported segment type {segment_type!r}") + raise ValueError(f"analytic_contours: unsupported segment type {kind!r}") -def _sample_analytic_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: - """Create a deterministic planar sample only for containment classification.""" +def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: points: list[tuple[float, float]] = [] for edge in edges: start = edge["start_mm"] points.append((float(start[0]), float(start[1]))) if edge.get("type") != "arc": continue - center = edge["center_mm"] - end = edge["end_mm"] - sx, sy = float(start[0]) - float(center[0]), float(start[1]) - float(center[1]) - ex, ey = float(end[0]) - float(center[0]), float(end[1]) - float(center[1]) - start_angle = math.atan2(sy, sx) - end_angle = math.atan2(ey, ex) + center, end = edge["center_mm"], edge["end_mm"] + start_angle = math.atan2(float(start[1]) - float(center[1]), float(start[0]) - float(center[0])) + end_angle = math.atan2(float(end[1]) - float(center[1]), float(end[0]) - float(center[0])) delta = end_angle - start_angle if edge.get("clockwise"): if delta >= 0: delta -= math.tau elif delta <= 0: delta += math.tau + radius = float(edge.get("radius_mm") or _distance(start, center)) for fraction in (0.25, 0.5, 0.75): angle = start_angle + delta * fraction - radius = float(edge.get("radius_mm") or math.hypot(sx, sy)) points.append((float(center[0]) + radius * math.cos(angle), float(center[1]) + radius * math.sin(angle))) return points -def _loop_area(points: list[tuple[float, float]]) -> float: - if len(points) < 3: - return 0.0 - return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0) - - -def _endpoint_signed_area(edges: list[_Ctx]) -> float: - points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges] - return sum( - points[index][0] * points[(index + 1) % len(points)][1] - - points[(index + 1) % len(points)][0] * points[index][1] - for index in range(len(points)) - ) / 2.0 - - def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None: - """Repair inconsistent sweep flags on a conventional rounded rectangle. + """Repair inconsistent direction flags on a conventional rounded box. - Evidence exports occasionally label one or more 90-degree corner arcs - with the opposite direction. Honouring those isolated flags creates - 270-degree loops. This normalizer applies only to the unambiguous shape: - exactly four equal-radius quarter arcs in one closed loop. Other arcs, - including annular sectors and long sweeps, retain their captured flags. + The rule only applies to the unambiguous case of four equal 90-degree + corner arcs. It is geometry normalization, not a semantic shape macro. """ arcs = [edge for edge in edges if edge.get("type") == "arc"] if len(arcs) != 4: return radii = [float(edge.get("radius_mm") or 0.0) for edge in arcs] - if min(radii) <= _ANALYTIC_TOLERANCE_MM or max(radii) - min(radii) > _ANALYTIC_TOLERANCE_MM: + if min(radii) <= _TOLERANCE_MM or max(radii) - min(radii) > _TOLERANCE_MM: return for edge in arcs: center = edge.get("center_mm") @@ -1708,81 +193,62 @@ def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None: angle = abs(math.atan2(first[0] * second[1] - first[1] * second[0], first[0] * second[0] + first[1] * second[1])) if abs(angle - math.pi / 2) > 1e-4: return - # A clockwise endpoint loop needs clockwise short corner arcs; a - # counter-clockwise loop needs their reverse. This preserves the actual - # rounded-rectangle boundary, independent of per-segment export noise. - clockwise = _endpoint_signed_area(edges) < 0.0 + points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges] + clockwise = sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) < 0.0 for edge in arcs: edge["clockwise"] = clockwise -def _point_in_loop(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool: - if len(loop) < 3: - return False +def _area(points: list[tuple[float, float]]) -> float: + return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0) if len(points) >= 3 else 0.0 + + +def _contains(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool: inside = False x, y = point previous = loop[-1] for current in loop: - x1, y1 = current - x2, y2 = previous - if (y1 > y) != (y2 > y): - intersect_x = (x2 - x1) * (y - y1) / (y2 - y1) + x1 - if x < intersect_x: + if (current[1] > y) != (previous[1] > y): + crossing = (previous[0] - current[0]) * (y - current[1]) / (previous[1] - current[1]) + current[0] + if x < crossing: inside = not inside previous = current return inside def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """Resolve Evidence v2 line/arc/circle loops into engine-neutral regions. - - The returned regions preserve holes and islands. The build adapter owns - B-rep creation; this profile generator only reasons about sketch geometry. - """ loops: list[_Ctx] = [] entities: list[_Ctx] = [] - for contour_index, contour in enumerate(profile.get("contours") or []): + for index, contour in enumerate(profile.get("contours") or []): if not contour.get("closed"): - raise ValueError(f"analytic_contours: contour {contour_index} is open") - segment_edges: list[_Ctx] = [] + raise ValueError(f"analytic_contours: contour {index} is open") + raw_edges: list[_Ctx] = [] for segment in contour.get("segments") or []: - segment_type = segment.get("type") - if segment_type == "line": + if segment.get("type") == "line": entities.append(_line(segment["start"], segment["end"])) - elif segment_type == "circle": + elif segment.get("type") == "circle": entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0))) - segment_edges.extend(_analytic_segment_edges(segment)) - if not segment_edges: - continue - edges = _join_analytic_edges(segment_edges, closed=True) - _normalize_quarter_rounding_direction(edges) - points = _sample_analytic_loop(edges) - area = _loop_area(points) - if area <= _ANALYTIC_TOLERANCE_MM * _ANALYTIC_TOLERANCE_MM: - raise ValueError(f"analytic_contours: contour {contour_index} is degenerate") - loops.append({"role": contour.get("role", "unknown"), "edges": edges, "points": points, "area": area}) - + raw_edges.extend(_segment_edges(segment)) + if raw_edges: + edges = _join(raw_edges) + _normalize_quarter_rounding_direction(edges) + sample = _sample_loop(edges) + if _area(sample) <= _TOLERANCE_MM * _TOLERANCE_MM: + raise ValueError(f"analytic_contours: contour {index} is degenerate") + loops.append({"edges": edges, "points": sample, "area": _area(sample)}) for segment in profile.get("construction") or []: if segment.get("type") == "line": entities.append(_line(segment["start"], segment["end"], construction=True)) elif segment.get("type") == "circle": entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0), construction=True)) - if not loops: return entities, [] for loop in loops: - # Role tags captured from the source sketch are useful provenance but - # not authoritative geometry. A number of exports label separate - # closed contours as ``inner`` although no outer contour contains - # them. The even-odd containment rule is deterministic for the - # supported analytic curves and preserves those independent regions. - contained_by = sum(_point_in_loop(loop["points"][0], other["points"]) for other in loops if other is not loop) - loop["role"] = "inner" if contained_by % 2 else "outer" + loop["role"] = "inner" if sum(_contains(loop["points"][0], other["points"]) for other in loops if other is not loop) % 2 else "outer" outers = [loop for loop in loops if loop["role"] == "outer"] - inners = [loop for loop in loops if loop["role"] == "inner"] regions = [{"outer": outer["edges"], "holes": []} for outer in outers] - for inner in inners: - containing = [outer for outer in outers if _point_in_loop(inner["points"][0], outer["points"])] + for inner in (loop for loop in loops if loop["role"] == "inner"): + containing = [outer for outer in outers if _contains(inner["points"][0], outer["points"])] if not containing: raise ValueError("analytic_contours: inner contour has no containing outer contour") selected = min(containing, key=lambda outer: outer["area"]) @@ -1791,218 +257,84 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[ return entities, [] -# ═══════════════════════════════════════════════════════════════ -# 生成器注册表 —— 唯一索引点 -# ═══════════════════════════════════════════════════════════════ - -SHAPE_GENERATORS: dict[str, Any] = { - "circle": _gen_circle, - "annulus": _gen_annulus, - "circles": _gen_circles, - "circle_grid": _gen_circle_grid, - "rectangle": _gen_rectangle, - "rectangle_with_circles": _gen_rect_with_circles, - "rectangle_with_fillets": _gen_rect_with_fillets, - "obround": _gen_obround, - "polygon": _gen_polygon, - "ibone": _gen_ibone, - "rectangle_with_symmetric_notches": _gen_rect_symmetric_notches, - "revolve_chamfer": _gen_revolve_chamfer, - "revolve_chamfer_slanted": _gen_revolve_chamfer_slanted, - # 弧边复合轮廓(按 015133 手册方法注册,同形异构通过参数复用) - "circle_with_arc_notches": _gen_circle_with_arc_notches, - "circular_sector_slot": _gen_circular_sector_slot, - "circle_with_radial_tabs": _gen_circle_with_radial_tabs, - "filleted_rect_side_slots": _gen_filleted_rect_side_slots, - # 弧边形状 - "d_shape": _gen_d_shape, - "partial_ring": _gen_partial_ring, - "partial_ring_with_arc_island": _gen_partial_ring_with_arc_island, - "radial_slot": _gen_radial_slot, - "patterned_cutouts": _gen_patterned_cutouts, - "compound_patterned_cutouts": _gen_compound_patterned_cutouts, - "analytic_contours": _gen_analytic_contours, - "arc_chain": _gen_arc_chain, - "complex_arc_shape": _gen_polygon, # 从 compiler_context entities 重建 - "unknown_shape": _gen_polygon, # 未分类形状也走 compiler_context 回退 +CORE_SHAPE_GENERATORS: dict[str, Any] = {"circle": _gen_circle, "polygon": _gen_polygon, "analytic_contours": _gen_analytic_contours} +SHAPE_GENERATORS = CORE_SHAPE_GENERATORS +SHAPE_CAPABILITIES: dict[str, _Ctx] = { + "circle": {"detectable": True, "arity": "circle", "description": "single circular contour"}, + "polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"}, + "analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, and circle contours"}, } -# ═══════════════════════════════════════════════════════════════ -# 注册表功能:扩展、查询 -# ═══════════════════════════════════════════════════════════════ -def register_shape(ptype: str, generator: Any) -> None: - """注册一个新的轮廓生成器。扩展用途。""" - SHAPE_GENERATORS[ptype] = generator +def register_shape(_: str, __: Any) -> None: + raise RuntimeError("Runtime profile types are fixed; lower custom profiles before CDSL execution") def list_registered_shapes() -> list[str]: - """返回所有已注册的形状生成器名称。""" - return sorted(SHAPE_GENERATORS.keys()) + return sorted(SHAPE_GENERATORS) -# ═══════════════════════════════════════════════════════════════ -# 形状能力矩阵(供外部查询:哪些形状可自动检测,哪些需手动指定) -# ═══════════════════════════════════════════════════════════════ - -_ShapeInfo = dict[str, Any] - -SHAPE_CAPABILITIES: dict[str, _ShapeInfo] = { - "circle": {"detectable": True, "arity": "circle", "description": "单圆"}, - "annulus": {"detectable": True, "arity": "circles", "description": "同心圆环"}, - "circles": {"detectable": True, "arity": "circles", "description": "多圆(非同心)"}, - "circle_grid": {"detectable": False, "arity": "circles", "description": "矩形圆孔阵列"}, - "rectangle": {"detectable": True, "arity": "polygon", "description": "4线矩形"}, - "rectangle_with_circles": {"detectable": True, "arity": "mixed", "description": "矩形+内圆孔"}, - "rectangle_with_fillets": {"detectable": False, "arity": "mixed", "description": "圆角矩形(4弧+4线)"}, - "obround": {"detectable": True, "arity": "mixed", "description": "槽形/键槽(2线+2半圆弧)"}, - "polygon": {"detectable": True, "arity": "polygon", "description": "N边多边形"}, - "ibone": {"detectable": False, "arity": "mixed", "description": "工字形凸耳(12线+4弧+4孔)"}, - "rectangle_with_symmetric_notches": {"detectable": False,"arity": "mixed", "description": "对称槽板(矩形+4U形缺口)"}, - "revolve_chamfer": {"detectable": True, "arity": "polygon", "description": "旋转梯形截面"}, - "revolve_chamfer_slanted": {"detectable": True, "arity": "polygon", "description": "旋转斜底梯形截面"}, - "circle_with_arc_notches": {"detectable": False, "arity": "mixed", "description": "圆+均匀弧形凹口"}, - "circular_sector_slot": {"detectable": False, "arity": "mixed", "description": "圆弧扇区+中心矩形槽"}, - "circle_with_radial_tabs": {"detectable": False, "arity": "mixed", "description": "圆+径向矩形凸耳"}, - "filleted_rect_side_slots": {"detectable": False, "arity": "mixed", "description": "圆角矩形+两侧中心U形槽"}, - "d_shape": {"detectable": True, "arity": "mixed", "description": "D形(半圆+弦线)"}, - "partial_ring": {"detectable": True, "arity": "mixed", "description": "部分圆环(扇区环)"}, - "partial_ring_with_arc_island": {"detectable": False, "arity": "mixed", "description": "扇区环+弦上偏移弧岛"}, - "radial_slot": {"detectable": False, "arity": "mixed", "description": "径向弧形槽"}, - "arc_chain": {"detectable": False, "arity": "arcs", "description": "多段弧链轮廓"}, - "patterned_cutouts": {"detectable": False, "arity": "regions", "description": "程序化重复切口"}, - "compound_patterned_cutouts": {"detectable": False, "arity": "regions", "description": "复合程序化重复切口"}, -} - - -# ═══════════════════════════════════════════════════════════════ -# 主入口 -# ═══════════════════════════════════════════════════════════════ - -def resolve_profile(sketch: dict[str, Any]) -> dict[str, Any]: - """按 type 查找生成器,生成 entities + contour_edges_mm。 - - 对于返回非空 contour 的生成器,会额外保留原始 sketch.entities - 中的非 construction circle 实体(孔洞/圆岛),确保不丢失内部特征。 - """ +def resolve_profile(sketch: _Ctx) -> _Ctx: profile = sketch.get("profile") if not profile: return sketch - - ptype = profile.get("type") - generator = SHAPE_GENERATORS.get(ptype) + generator = SHAPE_GENERATORS.get(profile.get("type")) if generator is None: - raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {ptype!r}") - - meta = {"id": sketch.get("id"), "name": sketch.get("name"), "_entities": sketch.get("entities"), "_contour": sketch.get("contour_edges_mm")} + raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}") + meta: _Ctx = {"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": []} entities, contour = generator(profile, meta) - - out = deepcopy(sketch) - - # 保留原始草图中的非 construction circle 实体(这些是内部孔洞/圆岛) - orig_ents = sketch.get("entities") or [] - keep_circles = [ - e for e in orig_ents - if e.get("type") == "circle" and not e.get("construction") - ] - if keep_circles and contour: - # 只对生成器产出 contour 的场合保留 circles(轮廓生成器 + 内部圆孔) - entities = list(entities) + keep_circles - - out["entities"] = entities - wp = sketch.get("workplane") + output = deepcopy(sketch) + original_circles = [entity for entity in sketch.get("entities") or [] if entity.get("type") == "circle" and not entity.get("construction")] + output["entities"] = list(entities) + (original_circles if contour else []) + workplane = sketch.get("workplane") if contour: - out["contour_edges_mm"] = _transform_contours(contour, wp) if wp else contour - regions = meta.get("_regions") or [] - if regions: - out["contour_regions_mm"] = [ - { - "outer": _transform_contours(reg["outer"], wp) if wp else reg["outer"], - "holes": [ - _transform_contours(hole, wp) if wp else hole - for hole in (reg.get("holes") or []) - ], - } - for reg in regions + output["contour_edges_mm"] = _transform_contours(contour, workplane) if workplane else contour + if meta["_regions"]: + output["contour_regions_mm"] = [ + {"outer": _transform_contours(region["outer"], workplane) if workplane else region["outer"], "holes": [_transform_contours(hole, workplane) if workplane else hole for hole in region.get("holes") or []]} + for region in meta["_regions"] ] - return out + return output -def resolve_all_sketches(cdsl: dict[str, Any]) -> dict[str, Any]: - """对 CDSL 中所有带 profile 字段的草图进行解析。 - - 支持 profile_from 字段:引用另一个草图的 profile,避免重复。 - 例:sk_05: {"profile_from": "sk_03"} → 使用 sk_03 的 profile。 - """ - geom = cdsl.get("geometry") or {} - sketches = geom.get("sketches") or [] - - # 第一遍: 解析所有有自己 profile 的草图 - resolved: dict[str, dict] = {} - for sk in sketches: - sid = sk.get("id") - if sid is None: - continue - if "profile" in sk: - resolved[sid] = resolve_profile(sk) - - # 第二遍: 解析 profile_from 引用(支持 profile_shift 偏移) - for sk in sketches: - sid = sk.get("id") - pf = sk.get("profile_from") - if pf and sid: - src = resolved.get(pf) - if src is None: - raise ValueError( - f"sketch {sid}: profile_from={pf!r} not found or not yet resolved" - ) - sk2 = deepcopy(sk) - sk2["profile"] = deepcopy(src.get("profile")) - sk2.pop("profile_from", None) - - # profile_shift: 对 polygon 顶点做 2D 偏移(同形异构共享) - shift = sk.get("profile_shift") - if shift and len(shift) == 2 and sk2["profile"].get("type") == "polygon": - du, dv = float(shift[0]), float(shift[1]) - for v in sk2["profile"]["vertices"]: - v[0] = round(v[0] + du, 6) - v[1] = round(v[1] + dv, 6) - sk2.pop("profile_shift", None) - resolved[sid] = resolve_profile(sk2) - - # 按原顺序输出 - result = [] - for sk in sketches: - sid = sk.get("id") - if sid and sid in resolved: - result.append(resolved[sid]) - else: - result.append(deepcopy(sk)) - - out = deepcopy(cdsl) - out.setdefault("geometry", {})["sketches"] = result - return out +def _shift_profile(sketch: _Ctx, source: _Ctx) -> _Ctx: + output = deepcopy(sketch) + output["profile"] = deepcopy(source["profile"]) + output.pop("profile_from", None) + shift = sketch.get("profile_shift") + if shift and len(shift) == 2 and output["profile"].get("type") == "polygon": + for vertex in output["profile"]["vertices"]: + vertex[0], vertex[1] = round(float(vertex[0]) + float(shift[0]), 6), round(float(vertex[1]) + float(shift[1]), 6) + output.pop("profile_shift", None) + return output -def resolve_required_sketches( - cdsl: dict[str, Any], - sketch_ids: Iterable[str], - *, - errors: dict[str, str] | None = None, -) -> dict[str, Any]: - """Resolve only profiles that an executable feature actually consumes. +def resolve_all_sketches(cdsl: _Ctx) -> _Ctx: + sketches = list((cdsl.get("geometry") or {}).get("sketches") or []) + resolved: dict[str, _Ctx] = {} + for sketch in sketches: + sketch_id = sketch.get("id") + if sketch_id is not None and "profile" in sketch: + resolved[str(sketch_id)] = resolve_profile(sketch) + for sketch in sketches: + sketch_id, source_id = sketch.get("id"), sketch.get("profile_from") + if sketch_id is not None and source_id: + source = resolved.get(str(source_id)) + if source is None: + raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} not found or not yet resolved") + resolved[str(sketch_id)] = resolve_profile(_shift_profile(sketch, source)) + output = deepcopy(cdsl) + output.setdefault("geometry", {})["sketches"] = [resolved.get(str(sketch.get("id")), deepcopy(sketch)) for sketch in sketches] + return output - ``profile_from`` dependencies are resolved recursively. Callers that - pass ``errors`` get feature-addressable failures without losing unrelated - resolved sketches; callers that omit it retain the strict exception - behavior useful to profile tooling. - """ + +def resolve_required_sketches(cdsl: _Ctx, sketch_ids: Iterable[str], *, errors: dict[str, str] | None = None) -> _Ctx: sketches = list((cdsl.get("geometry") or {}).get("sketches") or []) by_id = {str(sketch.get("id")): sketch for sketch in sketches if sketch.get("id") is not None} - resolved: dict[str, dict[str, Any]] = {} + resolved: dict[str, _Ctx] = {} resolving: set[str] = set() - def resolve_one(sketch_id: str) -> dict[str, Any]: + def resolve_one(sketch_id: str) -> _Ctx: if sketch_id in resolved: return resolved[sketch_id] sketch = by_id.get(sketch_id) @@ -2015,21 +347,7 @@ def resolve_required_sketches( if "profile" in sketch: output = resolve_profile(sketch) elif sketch.get("profile_from"): - source_id = str(sketch["profile_from"]) - source = resolve_one(source_id) - if not source.get("profile"): - raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} has no profile") - output = deepcopy(sketch) - output["profile"] = deepcopy(source["profile"]) - output.pop("profile_from", None) - shift = sketch.get("profile_shift") - if shift and len(shift) == 2 and output["profile"].get("type") == "polygon": - du, dv = float(shift[0]), float(shift[1]) - for vertex in output["profile"]["vertices"]: - vertex[0] = round(vertex[0] + du, 6) - vertex[1] = round(vertex[1] + dv, 6) - output.pop("profile_shift", None) - output = resolve_profile(output) + output = resolve_profile(_shift_profile(sketch, resolve_one(str(sketch["profile_from"])))) else: output = deepcopy(sketch) resolved[sketch_id] = output @@ -2044,10 +362,6 @@ def resolve_required_sketches( if errors is None: raise errors[sketch_id] = str(error) - output = deepcopy(cdsl) - output.setdefault("geometry", {})["sketches"] = [ - resolved.get(str(sketch.get("id")), deepcopy(sketch)) - for sketch in sketches - ] + output.setdefault("geometry", {})["sketches"] = [resolved.get(str(sketch.get("id")), deepcopy(sketch)) for sketch in sketches] return output diff --git a/backend/scripts/remove_generation_spec_artifacts.py b/backend/scripts/remove_generation_spec_artifacts.py new file mode 100644 index 00000000..ec3977f4 --- /dev/null +++ b/backend/scripts/remove_generation_spec_artifacts.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Remove retired semantic-plan artifacts from the local workspace. + +Run without arguments to list every file and metadata document that would +change. Run again with --apply to make the irreversible cleanup. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +RETIRED_FILENAMES = { + "generation-spec.json", + "generation-provenance.json", + "acceptance-report.json", +} +RETIRED_DIAGNOSTIC_PREFIX = "generation_spec_" +RETIRED_TOOL_NAMES = { + "create_generation_spec", + "author_cdsl_from_generation_spec", + "compile_generation_spec", + "patch_generation_spec", + "generate_flange_sleeve_model", +} +RETIRED_METADATA_KEYS = { + "generation_spec_path", + "generation_provenance_path", + "acceptance_path", + "approximations", + "current_design_intent_id", + "design_intents", + "design_intent_id", + "design_intent_path", + "design_intent_structures", + "design_intent_assumptions", + "design_intent_capability_gaps", + "capability_translations", + "evidence", +} + + +def _strip_metadata(value: Any) -> bool: + changed = False + if isinstance(value, list): + for item in value: + changed = _strip_metadata(item) or changed + return changed + if not isinstance(value, dict): + return False + for key in list(value): + if key in RETIRED_METADATA_KEYS or key.startswith("generation_spec_"): + value.pop(key) + changed = True + operation = value.get("operation") + if isinstance(operation, dict) and "spec" in str(operation.get("type") or "").casefold(): + value["operation"] = {} + changed = True + for key, child in list(value.items()): + if key == "source" and isinstance(child, str) and "generation_spec" in child.casefold(): + value[key] = "legacy_cdsl" + changed = True + elif key in {"message", "text"} and isinstance(child, str) and "generationspec" in child.casefold(): + value[key] = "已清理已弃用的规格流程诊断;当前任务统一使用直接 CDSL 生成。" + changed = True + else: + changed = _strip_metadata(child) or changed + return changed + + +def _retired_diagnostic(path: Path) -> bool: + if path.name.startswith(RETIRED_DIAGNOSTIC_PREFIX): + return True + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + if not isinstance(payload, dict): + return False + tool_name = str(payload.get("tool_name") or "") + arguments = str(payload.get("arguments") or "") + return tool_name in RETIRED_TOOL_NAMES or "generation-spec" in arguments or "generation_spec" in arguments + + +def cleanup_plan(data_root: Path) -> tuple[list[Path], list[Path]]: + files: list[Path] = [] + metadata: list[Path] = [] + task_root = data_root / "tasks" + conversation_root = data_root / "conversations" + if task_root.is_dir(): + for path in task_root.rglob("*"): + if path.is_file() and ( + path.name in RETIRED_FILENAMES + or (path.parent.name == "planning" and path.name.startswith("design-intent-")) + ): + files.append(path) + for path in task_root.rglob("*.json"): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + original = json.dumps(payload, sort_keys=True, ensure_ascii=False) + _strip_metadata(payload) + if json.dumps(payload, sort_keys=True, ensure_ascii=False) != original: + metadata.append(path) + if conversation_root.is_dir(): + for path in conversation_root.rglob("*.json"): + if _retired_diagnostic(path): + files.append(path) + continue + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + original = json.dumps(payload, sort_keys=True, ensure_ascii=False) + _strip_metadata(payload) + if json.dumps(payload, sort_keys=True, ensure_ascii=False) != original: + metadata.append(path) + file_set = set(files) + return sorted(file_set), sorted(set(metadata) - file_set) + + +def apply_cleanup(data_root: Path) -> tuple[list[Path], list[Path]]: + files, metadata = cleanup_plan(data_root) + for path in files: + path.unlink() + for path in metadata: + payload = json.loads(path.read_text(encoding="utf-8")) + if _strip_metadata(payload): + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return files, metadata + + +def main() -> int: + parser = argparse.ArgumentParser(description="Remove retired GenerationSpec and semantic-plan artifacts") + parser.add_argument("--data-root", type=Path, default=Path(__file__).resolve().parents[1] / "data") + parser.add_argument("--apply", action="store_true", help="Delete the listed artifacts and rewrite metadata") + args = parser.parse_args() + files, metadata = cleanup_plan(args.data_root) + action = "Will remove" if not args.apply else "Removing" + for path in files: + print(f"{action} artifact: {path}") + for path in metadata: + print(f"{action} retired metadata: {path}") + if not files and not metadata: + print("No retired GenerationSpec or semantic-plan artifacts found.") + if not args.apply: + print("Dry run only. Re-run with --apply to make these changes.") + return 0 + apply_cleanup(args.data_root) + print(f"Removed {len(files)} artifacts and rewrote {len(metadata)} metadata documents.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_agent_tool_arguments.py b/backend/tests/test_agent_tool_arguments.py index de89d6cb..4c7a3e61 100644 --- a/backend/tests/test_agent_tool_arguments.py +++ b/backend/tests/test_agent_tool_arguments.py @@ -9,10 +9,12 @@ from types import SimpleNamespace from unittest.mock import patch from app.models.contracts import ChatMessage, MessagePart -from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, normalize_image_analysis, parse_tool_arguments, response_language_instruction, tools_for_model, use_flange_sleeve_template, user_visible_error_message +from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message +from app.services.engine_service import load_engine +from app.services.quality import QUALITY_RULE_TYPES from app.services.library import CdslLibrary from app.services.storage import WorkspaceStore -from app.settings import ProviderConfig, ProviderModel, Settings +from app.settings import ProviderConfig, ProviderModel, Settings, get_settings class ToolChoiceCompatibilityTests(unittest.TestCase): @@ -107,16 +109,31 @@ class ParseToolArgumentsTests(unittest.TestCase): self.assertNotIn("extrude", cdsl["$defs"]["feature_atomic_ids"]["enum"]) self.assertEqual(cdsl, CDSL_TOOL_SCHEMA) + def test_generation_schema_exposes_only_runtime_atomic_ids(self) -> None: + settings = get_settings() + engine = load_engine(settings) + self.assertEqual( + set(CDSL_TOOL_SCHEMA["$defs"]["feature_atomic_ids"]["enum"]), + set(engine.SUPPORTED_ATOMIC_IDS), + ) + + def test_verification_schema_exposes_only_implemented_rule_types(self) -> None: + generate_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "generate_cdsl_model") + verification = generate_tool["function"]["parameters"]["properties"]["verification"] + rule_type = verification["properties"]["rules"]["items"]["properties"]["type"] + self.assertEqual(set(rule_type["enum"]), set(QUALITY_RULE_TYPES)) + def test_strict_tool_schema_covers_generation_arguments(self) -> None: tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True)) strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")] - self.assertEqual(strict_tools, ["generate_flange_sleeve_model", "generate_cdsl_model"]) - template_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_flange_sleeve_model") - self.assertEqual(template_tool["function"]["parameters"]["properties"]["plan"]["required"], ["template"]) + self.assertEqual(strict_tools, ["generate_cdsl_model", "patch_cdsl_model"]) generate_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_cdsl_model") self.assertEqual(generate_tool["function"]["parameters"]["properties"]["summary"], {"type": "string", "minLength": 1}) self.assertEqual(generate_tool["function"]["parameters"]["properties"]["cdsl"], CDSL_TOOL_SCHEMA) + self.assertIn("verification", generate_tool["function"]["parameters"]["properties"]) + patch_tool = next(tool for tool in tools if tool["function"]["name"] == "patch_cdsl_model") + self.assertIn("base_revision_id", patch_tool["function"]["parameters"]["required"]) self.assertFalse(generate_tool["function"]["parameters"]["additionalProperties"]) def test_default_model_does_not_receive_strict_tool_schema(self) -> None: @@ -124,21 +141,14 @@ class ParseToolArgumentsTests(unittest.TestCase): self.assertFalse(any(tool["function"].get("strict") for tool in tools)) - def test_flange_sleeve_image_route_exposes_only_the_compact_plan_generator(self) -> None: - analysis = { - "partType": "带方形法兰的圆筒管座", - "visibleFeatures": ["同轴中空管", "四个安装孔"], - } - - self.assertTrue(use_flange_sleeve_template(analysis, "")) - self.assertFalse(use_flange_sleeve_template(analysis, "cad_existing")) - tools = tools_for_model(ProviderModel("default-model"), flange_sleeve_only=True) - names = [tool["function"]["name"] for tool in tools] - self.assertIn("generate_flange_sleeve_model", names) - self.assertNotIn("search_cdsl_library", names) - self.assertNotIn("read_cdsl_reference", names) - self.assertNotIn("read_current_cdsl", names) - self.assertNotIn("generate_cdsl_model", names) + def test_direct_cdsl_tools_exclude_spec_and_template_generators(self) -> None: + names = [tool["function"]["name"] for tool in tools_for_model(ProviderModel("default-model"))] + self.assertIn("generate_cdsl_model", names) + self.assertIn("patch_cdsl_model", names) + self.assertNotIn("create_generation_spec", names) + self.assertNotIn("author_cdsl_from_generation_spec", names) + self.assertNotIn("patch_generation_spec", names) + self.assertNotIn("generate_flange_sleeve_model", names) def test_recorded_image_analysis_is_not_exposed_as_a_tool(self) -> None: tools = tools_for_model(ProviderModel("vision-model", vision=True), include_image_analysis=False) @@ -385,15 +395,15 @@ class ToolArgumentsRetryTests(unittest.TestCase): diagnostics = settings.conversation_root / conversation_id / "diagnostics" attempts = sorted(diagnostics.glob("cdsl_attempt_*.json")) failures = sorted(diagnostics.glob("cdsl_validation_*.json")) - self.assertEqual(len(attempts), 7) - self.assertEqual(len(failures), 7) + self.assertEqual(len(attempts), 5) + self.assertEqual(len(failures), 5) self.assertTrue(all(json.loads(path.read_text(encoding="utf-8")) == {} for path in attempts)) records = sorted( (json.loads(path.read_text(encoding="utf-8")) for path in failures), key=lambda record: int(record["iteration"]), ) - self.assertEqual([record["iteration"] for record in records], list(range(2, 9))) + self.assertEqual([record["iteration"] for record in records], list(range(2, 7))) self.assertTrue(all(record["kind"] == "cdsl_validation_failure" for record in records)) self.assertTrue(all(record["validation_error_type"] == "ValueError" for record in records)) self.assertTrue(all(record["validation_error"] for record in records)) @@ -402,7 +412,7 @@ class ToolArgumentsRetryTests(unittest.TestCase): {path.name for path in attempts}, ) self.assertTrue(any("每次 CDSL 校验失败的诊断已保存到" in str(event.get("message", "")) for event in events)) - self.assertEqual(agent.responses, []) + self.assertEqual(len(agent.responses), 2) self.assertEqual(list(settings.task_root.glob("cad_*")), []) @@ -822,5 +832,6 @@ class StructuredResultResponseTests(unittest.TestCase): self.assertEqual(json.loads(attempts[0].read_text(encoding="utf-8")), {}) self.assertEqual(list(diagnostics.glob("cdsl_validation_*.json")), []) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_design_intent.py b/backend/tests/test_design_intent.py deleted file mode 100644 index 5bf96509..00000000 --- a/backend/tests/test_design_intent.py +++ /dev/null @@ -1,203 +0,0 @@ -from __future__ import annotations - -import copy -import sys -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "backend")) - - - -def workplane(z: float = 0.0) -> dict[str, list[float]]: - return { - "origin_mm": [0.0, 0.0, z], - "x_dir": [1.0, 0.0, 0.0], - "y_dir": [0.0, 1.0, 0.0], - "normal": [0.0, 0.0, 1.0], - } - - -def mounting_plate_intent() -> dict: - return { - "schema": "cad.cdsl.design-intent.v1", - "schema_version": "1.0", - "mode": "create", - "request": "Create a mounting plate with four holes and a center slot", - "base_revision_id": "", - "structures": [ - { - "id": "base_plate", - "cdsl_feature_id": "base_add", - "role": "base", - "purpose": "Rectangular mounting plate", - "depends_on": [], - "cdsl_strategy": { - "atomic_id": "extrude_add_blind", - "profile_type": "rectangle", - "parameter_roles": ["width_mm", "height_mm", "thickness_mm"], - "selector_roles": [], - }, - }, - { - "id": "mounting_holes", - "cdsl_feature_id": "hole_cut", - "role": "subtractive", - "purpose": "Four mounting holes", - "depends_on": ["base_plate"], - "cdsl_strategy": { - "atomic_id": "extrude_cut_blind", - "profile_type": "circle_grid", - "parameter_roles": ["diameter_mm", "count_x", "count_y"], - "selector_roles": [], - }, - }, - { - "id": "adjustment_slot", - "cdsl_feature_id": "slot_cut", - "role": "subtractive", - "purpose": "Center adjustment slot", - "depends_on": ["mounting_holes"], - "cdsl_strategy": { - "atomic_id": "extrude_cut_blind", - "profile_type": "obround", - "parameter_roles": ["length_mm", "width_mm", "depth_mm"], - "selector_roles": [], - }, - }, - ], - "feature_order": ["base_plate", "mounting_holes", "adjustment_slot"], - "assumptions": [], - "open_questions": [], - "capability_gaps": [], - "verification_expectations": [ - {"type": "feature_count", "feature_id": "mounting_holes", "expected": 4}, - {"type": "symmetry", "axis": "x"}, - {"type": "bbox", "expected_mm": {"x": 100, "y": 60, "z": 10}}, - ], - "status": "ready", - } - - -def mounting_plate_cdsl() -> dict: - return { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.0", - "kind": "part", - "part_id": "mounting-plate", - "geometry": { - "sketches": [ - {"id": "base_sketch", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}}, - {"id": "holes_sketch", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}}, - {"id": "slot_sketch", "workplane": workplane(10), "profile": {"type": "obround", "center": [0, 0], "length_mm": 32, "width_mm": 10}}, - ], - }, - "features": [ - {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}}, - {"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}}, - {"id": "slot_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["hole_cut"], "sketch_id": "slot_sketch", "params": {"distance_mm": 10, "reverse": True}}, - ], - } - - -class DesignIntentValidationTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - sys.path.insert(0, str(ROOT / "backend" / "engine")) - import cdsl_engine - - cls.engine = cdsl_engine - - def assert_plan_error(self, intent: dict, code: str = "INVALID_DESIGN_INTENT") -> None: - with self.assertRaises(self.engine.DesignIntentError) as context: - self.engine.validate_design_intent(intent, self.engine) - self.assertEqual(context.exception.code, code) - - def test_valid_mounting_plate_plan_and_cdsl_match(self) -> None: - intent = mounting_plate_intent() - validated = self.engine.validate_design_intent(intent, self.engine) - - self.assertEqual(validated["feature_order"], ["base_plate", "mounting_holes", "adjustment_slot"]) - self.engine.validate_intent_cdsl(validated, mounting_plate_cdsl(), self.engine) - - def test_revise_requires_current_base_revision(self) -> None: - intent = mounting_plate_intent() - intent["mode"] = "revise" - intent["base_revision_id"] = "" - self.assert_plan_error(intent) - - intent["base_revision_id"] = "rev_001" - self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_001") - with self.assertRaises(self.engine.DesignIntentError) as context: - self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_002") - self.assertEqual(context.exception.code, "DESIGN_INTENT_BASE_REVISION_MISMATCH") - self.assertIn("rev_002", str(context.exception)) - - def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None: - duplicate = mounting_plate_intent() - duplicate["structures"][1]["id"] = "base_plate" - self.assert_plan_error(duplicate) - - cyclic = mounting_plate_intent() - cyclic["structures"][0]["depends_on"] = ["mounting_holes"] - self.assert_plan_error(cyclic) - - unsupported = mounting_plate_intent() - unsupported["structures"][0]["cdsl_strategy"]["atomic_id"] = "thread_add" - self.assert_plan_error(unsupported) - - incomplete = mounting_plate_intent() - incomplete["feature_order"].pop() - self.assert_plan_error(incomplete) - - no_profile = mounting_plate_intent() - no_profile["structures"][0]["cdsl_strategy"].pop("profile_type") - self.assert_plan_error(no_profile) - - unknown_expectation = mounting_plate_intent() - unknown_expectation["verification_expectations"][0]["feature_id"] = "missing" - self.assert_plan_error(unknown_expectation) - - def test_blockers_and_nonblocking_approximations_are_explicit(self) -> None: - clarification = mounting_plate_intent() - clarification["open_questions"] = [{"id": "thickness", "question": "What thickness is required?", "blocking": True}] - clarification["status"] = "needs_clarification" - self.engine.validate_design_intent(clarification, self.engine) - with self.assertRaises(self.engine.DesignIntentError) as context: - self.engine.validate_intent_cdsl(clarification, mounting_plate_cdsl(), self.engine) - self.assertEqual(context.exception.code, "DESIGN_INTENT_BLOCKED") - - approximation = mounting_plate_intent() - approximation["capability_gaps"] = [{ - "code": "unsupported_thread_geometry", - "structure_id": "mounting_holes", - "message": "Only a cylindrical bore is available.", - "blocking": False, - }] - self.assert_plan_error(approximation) - approximation["assumptions"] = ["Thread geometry is approximated by cylindrical bores."] - self.engine.validate_design_intent(approximation, self.engine) - - def test_rejects_cdsl_feature_atomic_profile_and_selector_mismatches(self) -> None: - missing_feature = mounting_plate_cdsl() - missing_feature["features"].pop() - with self.assertRaises(self.engine.DesignIntentError) as context: - self.engine.validate_intent_cdsl(mounting_plate_intent(), missing_feature, self.engine) - self.assertEqual(context.exception.code, "INTENT_CDSL_MISMATCH") - - atomic = mounting_plate_cdsl() - atomic["features"][1]["atomic_id"] = "extrude_add_blind" - with self.assertRaises(self.engine.DesignIntentError): - self.engine.validate_intent_cdsl(mounting_plate_intent(), atomic, self.engine) - - profile = mounting_plate_cdsl() - profile["geometry"]["sketches"][1]["profile"]["type"] = "circles" - with self.assertRaises(self.engine.DesignIntentError): - self.engine.validate_intent_cdsl(mounting_plate_intent(), profile, self.engine) - - selector_plan = copy.deepcopy(mounting_plate_intent()) - selector_plan["structures"][2]["cdsl_strategy"]["selector_roles"] = ["host_face"] - with self.assertRaises(self.engine.DesignIntentError): - self.engine.validate_intent_cdsl(selector_plan, mounting_plate_cdsl(), self.engine) diff --git a/backend/tests/test_design_intent_flow.py b/backend/tests/test_design_intent_flow.py index 72fb6b48..fae6e704 100644 --- a/backend/tests/test_design_intent_flow.py +++ b/backend/tests/test_design_intent_flow.py @@ -14,20 +14,42 @@ sys.path.insert(0, str(ROOT / "backend")) from app.models.contracts import ChatMessage, MessagePart # noqa: E402 from app.services.agent_service import AgentService # noqa: E402 -from app.services.engine_service import load_engine # noqa: E402 -from app.services.flange_sleeve_template import TEMPLATE_ID # noqa: E402 from app.services.library import CdslLibrary # noqa: E402 from app.services.part_skills import PartSkillLibrary # noqa: E402 from app.services.storage import WorkspaceStore # noqa: E402 from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 -from tests.test_design_intent import mounting_plate_cdsl # noqa: E402 BACKEND = ROOT / "backend" PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills" -class DesignIntentFlowTests(unittest.TestCase): +def _workplane(z: float = 0.0) -> dict[str, list[float]]: + return {"origin_mm": [0.0, 0.0, z], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]} + + +def mounting_plate_cdsl() -> dict: + holes = [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": center, "radius_mm": 3}]} + for center in [[-40, -20], [40, -20], [-40, 20], [40, 20]] + ] + return { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0", + "kind": "part", + "part_id": "mounting-plate", + "geometry": {"sketches": [ + {"id": "base_sketch", "workplane": _workplane(), "profile": {"type": "polygon", "vertices": [[-50, -30], [50, -30], [50, 30], [-50, 30]]}}, + {"id": "holes_sketch", "workplane": _workplane(10), "profile": {"type": "analytic_contours", "contours": holes}}, + ]}, + "features": [ + {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}}, + {"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}}, + ], + } + + +class DirectCdslFlowTests(unittest.TestCase): def settings(self, root: Path) -> Settings: provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) return Settings( @@ -48,7 +70,7 @@ class DesignIntentFlowTests(unittest.TestCase): settings = self.settings(Path(directory)) store = WorkspaceStore(settings) agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) - state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} + state = {"phase": "INTAKE", "design_brief": ""} searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], planning_state=state)) generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], planning_state=state)) @@ -77,7 +99,7 @@ class DesignIntentFlowTests(unittest.TestCase): settings = self.settings(Path(directory)) store = WorkspaceStore(settings) agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) - state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange base."} + state = {"phase": "PLANNED", "design_brief": "Create a flange base."} captured_build: dict[str, object] = {} def fake_build_revision(**kwargs: object) -> dict[str, object]: @@ -99,45 +121,6 @@ class DesignIntentFlowTests(unittest.TestCase): self.assertIn("workplane", built_cdsl["geometry"]["sketches"][0]) self.assertEqual(len(result["normalization_repairs"]), 3) - def test_flange_sleeve_tool_builds_cdsl_from_a_compact_semantic_plan(self) -> None: - with tempfile.TemporaryDirectory() as directory: - settings = self.settings(Path(directory)) - store = WorkspaceStore(settings) - agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) - state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange sleeve."} - captured_build: dict[str, object] = {} - - def fake_build_revision(**kwargs: object) -> dict[str, object]: - captured_build.update(kwargs) - return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"} - - with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): - result, _ = asyncio.run(agent._run_tool( - "generate_flange_sleeve_model", - { - "plan": { - "template": TEMPLATE_ID, - "name": "Image flange sleeve", - "flange_width_mm": 140, - "flange_height_mm": 120, - "mount_hole_u_mm": 48, - "mount_hole_v_mm": 38, - }, - "summary": "flange sleeve", - "assumptions": ["Dimensions are approximate."], - }, - "", "Create an image flange sleeve", [], planning_state=state, - )) - - built_cdsl = captured_build["cdsl"] - self.assertTrue(result["ok"]) - self.assertEqual(result["template_id"], TEMPLATE_ID) - self.assertEqual(captured_build["operation"]["template_id"], TEMPLATE_ID) - self.assertEqual(built_cdsl["meta"]["name"], "Image flange sleeve") - self.assertEqual(built_cdsl["geometry"]["sketches"][0]["workplane"]["normal"], [1.0, 0.0, 0.0]) - self.assertEqual(built_cdsl["features"][-1]["depends_on"], ["mount_hole_cuts"]) - self.assertNotIn("cdsl", result["template_plan"]) - def test_successful_generation_ends_the_agent_tool_loop(self) -> None: class CaptureAgent(AgentService): def __init__(self, *args: object, **kwargs: object) -> None: @@ -148,7 +131,7 @@ class DesignIntentFlowTests(unittest.TestCase): "role": "assistant", "content": "", "tool_calls": [{ "id": "brief", "type": "function", "function": { "name": "describe_design_intent", - "arguments": json.dumps({"plan": "Create a flange sleeve.", "assumptions": []}), + "arguments": json.dumps({"plan": "Create a cylindrical part.", "assumptions": []}), }, }], }}], @@ -156,11 +139,11 @@ class DesignIntentFlowTests(unittest.TestCase): { "choices": [{"message": { "role": "assistant", "content": "", "tool_calls": [{ - "id": "template", "type": "function", "function": { - "name": "generate_flange_sleeve_model", + "id": "generate", "type": "function", "function": { + "name": "generate_cdsl_model", "arguments": json.dumps({ - "plan": {"template": TEMPLATE_ID}, - "summary": "flange sleeve", + "cdsl": mounting_plate_cdsl(), + "summary": "mounting plate", "assumptions": [], }), }, @@ -189,7 +172,7 @@ class DesignIntentFlowTests(unittest.TestCase): with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): async def consume() -> None: - message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a flange sleeve")]) + message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")]) async for _ in agent.stream([message], None, None): pass @@ -270,8 +253,7 @@ class DesignIntentFlowTests(unittest.TestCase): "engine": "cdsl_only", } - engine = load_engine(settings) - with patch.object(engine, "validate_intent_cdsl", side_effect=AssertionError("legacy intent validation must not run")) as legacy_validation, patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): async def consume() -> None: message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")]) async for _ in agent.stream([message], None, None): @@ -281,18 +263,16 @@ class DesignIntentFlowTests(unittest.TestCase): brief_result = json.loads(str(agent.seen_messages[1][-1]["content"])) self.assertEqual(brief_result["plan"], "Create a rectangular mounting plate, then cut four mounting holes and a center slot.") - self.assertNotIn("design_intent_id", brief_result) - self.assertNotIn("design_intent_id", captured_build) + self.assertNotIn("structures", brief_result) self.assertNotIn("design_intent", captured_build) self.assertEqual(captured_build["parent_revision_id"], "") - legacy_validation.assert_not_called() def test_revision_parent_is_taken_from_the_current_successful_cdsl_revision(self) -> None: with tempfile.TemporaryDirectory() as directory: settings = self.settings(Path(directory)) store = WorkspaceStore(settings) agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) - state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} + state = {"phase": "INTAKE", "design_brief": "", "current_model_read": True} asyncio.run(agent._run_tool( "describe_design_intent", {"plan": "Increase the plate thickness and preserve the existing hole layout.", "assumptions": []}, diff --git a/backend/tests/test_direct_cdsl_pipeline.py b/backend/tests/test_direct_cdsl_pipeline.py new file mode 100644 index 00000000..4c37b343 --- /dev/null +++ b/backend/tests/test_direct_cdsl_pipeline.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.services.agent_service import AgentService # noqa: E402 +from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch # noqa: E402 +from app.services.engine_service import QualityVerificationError, build_revision # noqa: E402 +from app.services.library import CdslLibrary # noqa: E402 +from app.services.part_skills import PartSkillLibrary # noqa: E402 +from app.services.quality import evaluate_quality, validate_verification # noqa: E402 +from app.services.storage import WorkspaceStore, write_json # noqa: E402 +from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 + + +BACKEND = ROOT / "backend" +PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills" + + +def workplane(z: float = 0.0) -> dict[str, list[float]]: + return { + "origin_mm": [0.0, 0.0, z], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0], + } + + +def fixture() -> dict: + return { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0", + "kind": "part", + "part_id": "direct-cdsl-fixture", + "geometry": { + "sketches": [{ + "id": "base", + "workplane": workplane(), + "profile": {"type": "polygon", "vertices": [[-10, -5], [10, -5], [10, 5], [-10, 5]]}, + }], + }, + "features": [{ + "id": "base_add", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "sketch_id": "base", + "params": {"distance_mm": 4}, + }], + } + + +def settings(root: Path) -> Settings: + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + return Settings( + task_root=root / "tasks", + conversation_root=root / "conversations", + library_root=BACKEND / "cdsl_library", + engine_root=BACKEND / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="test-model", + llm_timeout_s=1, + default_provider_id="test", + providers=(provider,), + ) + + +class PatchTests(unittest.TestCase): + def test_rfc6902_patch_updates_cdsl_without_replacing_its_root(self) -> None: + document = fixture() + patched = apply_cdsl_patch(document, [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}]) + + self.assertEqual(patched["features"][0]["params"]["distance_mm"], 8) + self.assertEqual(document["features"][0]["params"]["distance_mm"], 4) + with self.assertRaisesRegex(CdslPatchError, "complete CDSL"): + apply_cdsl_patch(document, [{"op": "replace", "path": "", "value": {}}]) + + def test_invalid_patch_path_is_rejected(self) -> None: + with self.assertRaisesRegex(CdslPatchError, "out of range"): + apply_cdsl_patch(fixture(), [{"op": "replace", "path": "/features/3/id", "value": "x"}]) + + +class VerificationTests(unittest.TestCase): + def test_bbox_accepts_dimension_vector(self) -> None: + rules = validate_verification({"rules": [ + {"id": "size", "type": "bbox", "expected": [20, 10, 4]}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]}, + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(report["results"][0]["actual"]["dimensions"], [20.0, 10.0, 4.0]) + + def test_bbox_accepts_coordinate_ranges(self) -> None: + rules = validate_verification({"rules": [ + {"id": "range", "type": "bbox", "expected": { + "x_min": -10, "x_max": 10, "y_min": -5, "y_max": 5, "z_min": 0, "z_max": 4, + }}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]}, + }) + + self.assertEqual(report["status"], "passed") + + def test_feature_bbox_uses_owned_topology_records(self) -> None: + rules = validate_verification({"rules": [ + {"id": "base_range", "type": "bbox", "feature": "base_add", "expected": [10, 8, 2]}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "bbox_mm": {"min": [0, 0, 0], "max": [20, 20, 20]}, + "topology_records": [{ + "feature_id": "base_add", "owner_feature_ids": ["base_add"], + "geometry": {"bbox_mm": [0, 0, 0, 10, 8, 2]}, + }], + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(report["results"][0]["source"], "runtime.topology_records[base_add].bbox_mm") + + def test_bbox_shape_is_rejected_before_execution(self) -> None: + with self.assertRaisesRegex(ValueError, "expected for bbox"): + validate_verification({"rules": [ + {"id": "bad", "type": "bbox", "expected": {"width": 10}}, + ]}, fixture()) + + def test_unknown_feature_reference_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "must reference a CDSL feature ID"): + validate_verification({"rules": [{"id": "holes", "type": "hole_count", "feature": "missing", "expected": 4}]}, fixture()) + + def test_invalid_expected_value_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "expected for bbox"): + validate_verification({"rules": [{"id": "size", "type": "bbox", "expected": [10, 5]}]}, fixture()) + + def test_warning_does_not_block_generic_quality(self) -> None: + rules = validate_verification({"rules": [ + {"id": "solids", "type": "solid_count", "expected": 1, "severity": "blocking"}, + {"id": "length", "type": "overall_length", "expected": 999, "severity": "warning"}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "solid_count": 1, + "bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]}, + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(len(report["blocking_failures"]), 0) + self.assertEqual(len(report["warnings"]), 1) + + def test_blocking_failure_requires_repair(self) -> None: + rules = validate_verification({"rules": [{"id": "solids", "type": "solid_count", "expected": 2}]}, fixture()) + report = evaluate_quality(rules, fixture(), {"solid_count": 1, "bbox_mm": {"min": [0, 0, 0], "max": [1, 1, 1]}}) + + self.assertEqual(report["status"], "failed") + self.assertEqual(report["blocking_failures"][0]["id"], "solids") + + +class AgentPatchFlowTests(unittest.TestCase): + def _seed_revision(self, store: WorkspaceStore) -> tuple[str, str]: + task = store.ensure_task(None, "Create a direct CDSL fixture") + revision_id = "rev_001" + revision_dir = store.task_dir(task["task_id"]) / "revisions" / revision_id + revision_dir.mkdir(parents=True) + write_json(revision_dir / "model.cdsl.json", fixture()) + store.update_task(task["task_id"], { + "revision_id": revision_id, + "status": "success", + "cdsl_path": f"revisions/{revision_id}/model.cdsl.json", + }) + return task["task_id"], revision_id + + def test_patch_creates_a_child_revision_from_explicit_parent(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + captured: dict[str, object] = {} + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured.update(kwargs) + return {"task_id": task_id, "revision_id": "rev_002"} + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + result, _ = asyncio.run(agent._run_tool( + "patch_cdsl_model", + { + "base_revision_id": parent, + "patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}], + "summary": "Increase thickness", + "assumptions": [], + }, + task_id, + "Increase thickness", + [], + planning_state={"phase": "PLANNED", "current_model_read": True}, + )) + + self.assertTrue(result["ok"]) + self.assertEqual(captured["parent_revision_id"], parent) + self.assertEqual(captured["operation"]["type"], "cdsl_patch") + self.assertEqual(captured["cdsl"]["features"][0]["params"]["distance_mm"], 8) + + def test_bad_patch_creates_no_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + with self.assertRaisesRegex(ValueError, "INVALID_CDSL_PATCH"): + asyncio.run(agent._run_tool( + "patch_cdsl_model", + {"base_revision_id": parent, "patches": [{"op": "replace", "path": "/missing", "value": 8}], "summary": "bad", "assumptions": []}, + task_id, + "bad patch", + [], + planning_state={"phase": "PLANNED"}, + )) + + task = store.read_task(task_id) + self.assertEqual([item["revision_id"] for item in task["revisions"]], [parent]) + + def test_complete_replacement_uses_current_revision_as_parent(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + captured: dict[str, object] = {} + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured.update(kwargs) + return {"task_id": task_id, "revision_id": "rev_002"} + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + result, _ = asyncio.run(agent._run_tool( + "generate_cdsl_model", + {"cdsl": fixture(), "summary": "Replacement", "assumptions": []}, + task_id, + "replace geometry", + [], + planning_state={"phase": "PLANNED", "current_model_read": True}, + )) + + self.assertTrue(result["ok"]) + self.assertEqual(captured["parent_revision_id"], parent) + self.assertEqual(captured["operation"]["type"], "cdsl_replacement") + + def test_blocking_quality_failure_is_repairable_and_readable(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + with self.assertRaises(QualityVerificationError) as context: + build_revision( + settings=config, + store=store, + task_id=None, + request="Create fixture", + cdsl=fixture(), + reference_ids=[], + summary="fixture", + verification={"rules": [{"id": "wrong-solid-count", "type": "solid_count", "expected": 2}]}, + ) + + error = context.exception + self.assertTrue(error.task_id) + self.assertEqual(error.revision_id, "rev_001") + task = store.read_task(error.task_id) + self.assertEqual(task["revisions"][0]["status"], "needs_repair") + self.assertEqual(task["revisions"][0]["quality_status"], "needs_repair") + + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + read, _ = asyncio.run(agent._run_tool( + "read_current_cdsl", {}, error.task_id, "Repair fixture", [], planning_state={"phase": "INTAKE"} + )) + self.assertTrue(read["ok"]) + self.assertEqual(read["revision_id"], error.revision_id) + + def test_runtime_build_failure_does_not_leave_a_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + + class BrokenEngine: + def run_cdsl_only(self, _cdsl: dict, _step: Path) -> dict: + raise RuntimeError("intentional runtime failure") + + with patch("app.services.engine_service.load_engine", return_value=BrokenEngine()), patch( + "app.services.engine_service.validate_cdsl", return_value=None + ), self.assertRaisesRegex(RuntimeError, "intentional runtime failure"): + build_revision( + settings=config, + store=store, + task_id=None, + request="broken fixture", + cdsl=fixture(), + reference_ids=[], + summary="broken", + ) + + tasks = list(config.task_root.glob("cad_*")) + self.assertEqual(len(tasks), 1) + task = store.read_task(tasks[0].name) + self.assertEqual(task["revisions"], []) + + +class CleanupAndBoundaryTests(unittest.TestCase): + def test_cleanup_is_dry_run_then_removes_only_retired_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as directory: + data_root = Path(directory) / "data" + task_dir = data_root / "tasks" / "cad_aaaaaaaaaaaa" / "revisions" / "rev_001" + conversation_dir = data_root / "conversations" / "conv_aaaaaaaaaaaa" / "diagnostics" + task_dir.mkdir(parents=True) + conversation_dir.mkdir(parents=True) + write_json(task_dir / "generation-spec.json", {"schema": "cad.generation-spec.v1"}) + write_json(task_dir / "model.cdsl.json", fixture()) + (task_dir / "model.step").write_bytes(b"step") + (task_dir / "model.glb").write_bytes(b"glb") + write_json(data_root / "tasks" / "cad_aaaaaaaaaaaa" / "task.json", { + "generation_spec_path": "revisions/rev_001/generation-spec.json", + "current_design_intent_id": "intent_aaaaaaaaaaaa", + "revisions": [{"acceptance_path": "revisions/rev_001/acceptance-report.json"}], + }) + write_json(conversation_dir / "generation_spec_validation_deadbeef.json", {"kind": "old"}) + + script = BACKEND / "scripts" / "remove_generation_spec_artifacts.py" + dry = subprocess.run([sys.executable, str(script), "--data-root", str(data_root)], check=True, capture_output=True, text=True) + self.assertIn("Dry run only", dry.stdout) + self.assertTrue((task_dir / "generation-spec.json").is_file()) + + subprocess.run([sys.executable, str(script), "--data-root", str(data_root), "--apply"], check=True, capture_output=True, text=True) + self.assertFalse((task_dir / "generation-spec.json").exists()) + self.assertFalse((conversation_dir / "generation_spec_validation_deadbeef.json").exists()) + self.assertTrue((task_dir / "model.cdsl.json").is_file()) + self.assertTrue((task_dir / "model.step").is_file()) + self.assertTrue((task_dir / "model.glb").is_file()) + cleaned = json.loads((data_root / "tasks" / "cad_aaaaaaaaaaaa" / "task.json").read_text(encoding="utf-8")) + self.assertNotIn("generation_spec_path", cleaned) + self.assertNotIn("current_design_intent_id", cleaned) + self.assertNotIn("acceptance_path", cleaned["revisions"][0]) + + def test_engine_does_not_import_application_or_dispatch_part_families(self) -> None: + engine_root = BACKEND / "engine" / "cdsl_engine" + source = "\n".join(path.read_text(encoding="utf-8") for path in engine_root.rglob("*.py")) + lowered = source.casefold() + + self.assertNotIn("from app", lowered) + self.assertNotIn("import app", lowered) + for business_family in ("mounting_bracket", "mounting_plate", "flange_sleeve", "bearing_housing", "hex_nut"): + self.assertNotIn(business_family, lowered) diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index 4d68e36c..fe68573c 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -21,6 +21,13 @@ def _workplane() -> dict: return {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]} +def _rectangle(minimum: list[float], maximum: list[float]) -> dict: + return {"type": "polygon", "vertices": [ + [minimum[0], minimum[1]], [maximum[0], minimum[1]], + [maximum[0], maximum[1]], [minimum[0], maximum[1]], + ]} + + class EngineRuntimeFoundationTests(unittest.TestCase): def _base_block(self) -> dict: return { @@ -28,7 +35,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "meta": {"unit": "mm"}, "geometry": {"sketches": [{ "id": "base", "workplane": _workplane(), - "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}, + "profile": _rectangle([-5, -5], [5, 5]), }]}, "features": [{ "id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], @@ -490,7 +497,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "mirror-test", "meta": {"unit": "mm"}, "geometry": {"sketches": [ - {"id": "base", "workplane": _workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}}, + {"id": "base", "workplane": _workplane(), "profile": _rectangle([-5, -5], [5, 5])}, {"id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "center": [2, 0], "radius_mm": 1}}, ]}, "features": [ @@ -518,7 +525,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "schema": "cad.cdsl.llm.v1", "geometry": {"sketches": [{ "id": "circles", "workplane": _workplane(), - "profile": {"type": "circles", "items": [{"center": [2, 3], "radius_mm": 1}]}, + "profile": {"type": "circle", "center": [2, 3], "radius_mm": 1}, }]}, "features": [], } @@ -761,7 +768,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "meta": {"unit": "mm"}, "geometry": {"sketches": [{ "id": "profile", "workplane": _workplane(), - "profile": {"type": "rectangle", "min_mm": [2, 1], "max_mm": [4, 2]}, + "profile": _rectangle([2, 1], [4, 2]), }]}, "features": [ { diff --git a/backend/tests/test_part_skills.py b/backend/tests/test_part_skills.py index 9c94a14f..054a662c 100644 --- a/backend/tests/test_part_skills.py +++ b/backend/tests/test_part_skills.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import copy import json import sys import tempfile @@ -14,7 +13,6 @@ sys.path.insert(0, str(ROOT / "backend")) from app.models.contracts import ChatMessage, MessagePart # noqa: E402 from app.services.agent_service import AgentService, TOOL_SCHEMAS, system_prompt # noqa: E402 -from app.services.engine_service import build_revision, load_engine, validate_cdsl # noqa: E402 from app.services.library import CdslLibrary # noqa: E402 from app.services.part_skills import PartSkillLibrary # noqa: E402 from app.services.storage import WorkspaceStore # noqa: E402 @@ -46,10 +44,49 @@ def document(part_id: str, sketches: list[dict], features: list[dict]) -> dict: } +def rectangle(center: list[float], width: float, height: float) -> dict: + half_width, half_height = width / 2, height / 2 + return {"type": "polygon", "vertices": [ + [center[0] - half_width, center[1] - half_height], [center[0] + half_width, center[1] - half_height], + [center[0] + half_width, center[1] + half_height], [center[0] - half_width, center[1] + half_height], + ]} + + +def circles(items: list[dict]) -> dict: + return {"type": "analytic_contours", "contours": [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]} + for item in items + ]} + + +def circle_grid(radius: float, count_x: int, count_y: int, spacing_x: float, spacing_y: float) -> dict: + return circles([ + {"center": [(column - (count_x - 1) / 2) * spacing_x, (row - (count_y - 1) / 2) * spacing_y], "radius_mm": radius} + for row in range(count_y) for column in range(count_x) + ]) + + +def obround(length: float, width: float) -> dict: + radius, left, right = width / 2, -length / 2 + width / 2, length / 2 - width / 2 + return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [ + {"type": "line", "start": [right, radius], "end": [left, radius]}, + {"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0], "radius_mm": radius}, + {"type": "line", "start": [left, -radius], "end": [right, -radius]}, + {"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0], "radius_mm": radius}, + ]}]} + + +def annulus(inner_radius: float, outer_radius: float) -> dict: + return circles([ + {"center": [0, 0], "radius_mm": outer_radius}, + {"center": [0, 0], "radius_mm": inner_radius}, + ]) + + def mounting_plate_fixture() -> dict: return document("golden-mounting-plate", [ - {"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 60}}, - {"id": "grid", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 50, "spacing_y_mm": 30, "center_mm": [0, 0]}}, + {"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 80, 60)}, + {"id": "grid", "workplane": workplane(10), "profile": circle_grid(3, 2, 2, 50, 30)}, ], [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 10}, "sketch_id": "base"}, {"id": "mount_pattern", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 10, "reverse": True}, "sketch_id": "grid"}, @@ -60,10 +97,10 @@ def mounting_plate_fixture() -> dict: def mounting_bracket_fixture() -> dict: web_plane = {"origin_mm": [0, -20, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1], "normal": [0, 1, 0]} return document("golden-mounting-bracket", [ - {"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 40}}, - {"id": "web", "workplane": web_plane, "profile": {"type": "rectangle", "center": [0, -15], "width_mm": 50, "height_mm": 30}}, - {"id": "slot", "workplane": workplane(6), "profile": {"type": "obround", "center": [0, 0], "length_mm": 24, "width_mm": 8}}, - {"id": "symmetric_holes", "workplane": workplane(6), "profile": {"type": "circles", "items": [{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}]}}, + {"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 80, 40)}, + {"id": "web", "workplane": web_plane, "profile": rectangle([0, -15], 50, 30)}, + {"id": "slot", "workplane": workplane(6), "profile": obround(24, 8)}, + {"id": "symmetric_holes", "workplane": workplane(6), "profile": circles([{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}])}, ], [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 6}, "sketch_id": "base"}, {"id": "web_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 6}, "sketch_id": "web"}, @@ -74,8 +111,8 @@ def mounting_bracket_fixture() -> dict: def flange_fixture() -> dict: return document("golden-flange", [ - {"id": "base", "workplane": workplane(), "profile": {"type": "annulus", "inner_radius_mm": 10, "outer_radius_mm": 40}}, - {"id": "bolt_circle", "workplane": workplane(12), "profile": {"type": "circles", "items": [{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}]}}, + {"id": "base", "workplane": workplane(), "profile": annulus(10, 40)}, + {"id": "bolt_circle", "workplane": workplane(12), "profile": circles([{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}])}, ], [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 12}, "sketch_id": "base"}, {"id": "bolt_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 12, "reverse": True}, "sketch_id": "bolt_circle"}, @@ -94,9 +131,9 @@ def shaft_fixture() -> dict: def bearing_housing_fixture() -> dict: return document("golden-bearing-housing", [ - {"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}}, + {"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 100, 60)}, {"id": "housing", "workplane": workplane(8), "profile": {"type": "circle", "radius_mm": 28}}, - {"id": "base_holes", "workplane": workplane(8), "profile": {"type": "circle_grid", "radius_mm": 4, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}}, + {"id": "base_holes", "workplane": workplane(8), "profile": circle_grid(4, 2, 2, 80, 40)}, ], [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 8}, "sketch_id": "base"}, {"id": "housing_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 25}, "sketch_id": "housing"}, @@ -115,16 +152,6 @@ def hex_nut_fixture() -> dict: ]) -GOLDEN_CASES = ( - ("带沉孔和孔阵列的安装底板", mounting_plate_fixture, {"planning/mounting-plate", "atomic/counterbored-hole-creation", "atomic/pattern-holes-from-datum"}, {"atomic/pattern-holes-from-datum": "expanded"}), - ("带槽和对称孔的安装支架", mounting_bracket_fixture, {"planning/mounting-bracket", "functional/slotted-adjustment-feature", "functional/symmetric-feature-layout"}, {"atomic/pattern-holes-from-datum": "expanded"}), - ("带螺栓圆的法兰", flange_fixture, {"planning/flange", "functional/flange-bolt-circle"}, {"functional/flange-bolt-circle": "expanded"}), - ("带回转体同轴孔的阶梯轴", shaft_fixture, {"planning/simple-shaft", "functional/axisymmetric-revolve-strategy", "atomic/coaxial-bore-rule"}, {}), - ("带轴承座孔和底座孔的轴承座", bearing_housing_fixture, {"planning/bearing-housing", "functional/bearing-bore-seat"}, {}), - ("M6 六角螺母", hex_nut_fixture, {"planning/hexagonal-nut", "atomic/threaded-hole-creation"}, {"atomic/threaded-hole-creation": "approximated"}), -) - - class PartSkillLibraryTests(unittest.TestCase): def setUp(self) -> None: self.library = PartSkillLibrary(PART_SKILL_ROOT) @@ -166,24 +193,19 @@ class PartSkillLibraryTests(unittest.TestCase): self.assertTrue((PART_SKILL_ROOT / skill["bridge"]).is_file()) self.assertTrue((PART_SKILL_ROOT / skill["source"]).is_file()) self.assertTrue(skill["triggers"]) - self.assertTrue(skill["capability_translation_rules"]) + self.assertNotIn("capability_translation_rules", skill) self.assertEqual(categories, {"planning": 6, "functional": 7, "atomic": 8}) self.assertTrue((PART_SKILL_ROOT / "LICENSE").is_file()) self.assertTrue((PART_SKILL_ROOT / "PROVENANCE.md").is_file()) - def test_audit_records_exact_omitted_and_blocked_capability_states(self) -> None: + def test_audit_records_selection_and_assumptions_without_interpreting_geometry(self) -> None: fixture = mounting_plate_fixture() - exact = self.library.audit(self.library.select("安装底板"), fixture) - finishing = self.library.audit(self.library.select("edge treatment"), fixture) - blocked = self.library.audit(self.library.select("flange bolt circle"), mounting_plate_fixture()) - - exact_statuses = {item["skill_id"]: item["status"] for item in exact["capability_translations"]} - finishing_statuses = {item["skill_id"]: item for item in finishing["capability_translations"]} - blocked_statuses = {item["skill_id"]: item["status"] for item in blocked["capability_translations"]} - self.assertEqual(exact_statuses["planning/mounting-plate"], "exact") - self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["status"], "omitted") - self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["translation"], "selector_unavailable") - self.assertEqual(blocked_statuses["functional/flange-bolt-circle"], "blocked") + audit = self.library.audit(self.library.select("安装底板"), fixture, ["孔位置由用户确认"]) + self.assertIn("planning/mounting-plate", audit["skill_ids"]) + self.assertEqual(audit["assumptions"], ["孔位置由用户确认"]) + self.assertTrue(all(item.get("version") and item.get("summary") for item in audit["skills"])) + self.assertNotIn("capability_translations", audit) + self.assertNotIn("evidence", audit) class AgentPartSkillTests(unittest.TestCase): @@ -211,7 +233,7 @@ class AgentPartSkillTests(unittest.TestCase): self.assertIn("[planning/flange]", str(agent.first_messages[0]["content"])) self.assertIn("[functional/flange-bolt-circle]", str(agent.first_messages[0]["content"])) - def test_prompt_contains_bridge_and_template_tool(self) -> None: + def test_prompt_contains_bridge_and_direct_cdsl_tools(self) -> None: library = PartSkillLibrary(PART_SKILL_ROOT) selection = library.select("Create a flange with a bolt circle") with tempfile.TemporaryDirectory() as directory: @@ -220,7 +242,7 @@ class AgentPartSkillTests(unittest.TestCase): self.assertIn("[planning/flange]", prompt) self.assertIn("circular-pattern atomic", prompt) - self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_flange_sleeve_model", "generate_cdsl_model"]) + self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"]) def test_generation_persists_assumptions_and_selected_skills(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -230,7 +252,7 @@ class AgentPartSkillTests(unittest.TestCase): agent = AgentService(settings, store, CdslLibrary(settings), library) request = "M6 六角螺母" selection = library.select(request) - planning_state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} + planning_state = {"phase": "INTAKE", "design_brief": ""} planned, _ = asyncio.run(agent._run_tool( "describe_design_intent", {"plan": "Create an M6 hex nut with a centered cylindrical bore representing the thread.", "assumptions": []}, @@ -278,66 +300,5 @@ class AgentPartSkillTests(unittest.TestCase): ) -class PartSkillBuildAndGoldenTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.settings = AgentPartSkillTests._settings(Path(tempfile.mkdtemp())) - cls.engine = load_engine(cls.settings) - cls.library = PartSkillLibrary(PART_SKILL_ROOT) - - def test_success_and_failure_revisions_always_write_part_skill_audit(self) -> None: - with tempfile.TemporaryDirectory() as directory: - settings = AgentPartSkillTests._settings(Path(directory)) - store = WorkspaceStore(settings) - selection = self.library.select("带螺栓圆的法兰") - audit = self.library.audit(selection, flange_fixture(), ["Explicit circle expansion"]) - success = build_revision( - settings=settings, store=store, task_id=None, request="带螺栓圆的法兰", cdsl=flange_fixture(), - reference_ids=["flange-reference"], summary="flange", part_skills=audit, - generation_assumptions=["Explicit circle expansion"], - ) - success_audit = json.loads(store.artifact_path(success["task_id"], success["part_skills_path"]).read_text(encoding="utf-8")) - success_report = json.loads(store.artifact_path(success["task_id"], success["report_path"]).read_text(encoding="utf-8")) - self.assertEqual(success_audit["skill_ids"], audit["skill_ids"]) - self.assertIn("generation_context", success_report) - - invalid = copy.deepcopy(flange_fixture()) - invalid["features"][0]["atomic_id"] = "not_supported" - failed_audit = self.library.audit(selection, invalid, []) - with self.assertRaisesRegex(ValueError, "CDSL schema violation"): - build_revision( - settings=settings, store=store, task_id=success["task_id"], request="bad flange", cdsl=invalid, - reference_ids=[], summary="bad", part_skills=failed_audit, generation_assumptions=[], - ) - task = store.read_task(success["task_id"]) - failed = task["revisions"][-1] - self.assertEqual(failed["status"], "failed") - self.assertTrue(store.artifact_path(task["task_id"], failed["part_skills_path"]).is_file()) - report = json.loads(store.artifact_path(task["task_id"], failed["report_path"]).read_text(encoding="utf-8")) - self.assertEqual(report["generation_context"]["part_skill_ids"], audit["skill_ids"]) - legacy_report = {"engine_result": {"engine": "cdsl_only"}} - self.assertEqual(legacy_report.get("generation_context", {}), {}) - - def test_golden_part_families_validate_preflight_rebuild_and_audit(self) -> None: - for request, factory, expected_skills, expected_statuses in GOLDEN_CASES: - with self.subTest(request=request): - cdsl = factory() - selection = self.library.select(request) - self.assertTrue(expected_skills.issubset(selection["skill_ids"])) - validate_cdsl(cdsl, self.engine) - with tempfile.TemporaryDirectory() as directory: - step_path = Path(directory) / "model.step" - result = self.engine.run_cdsl_only(copy.deepcopy(cdsl), step_path) - self.assertEqual(result["engine"], "cdsl_only") - self.assertTrue(step_path.is_file()) - self.assertGreater(step_path.stat().st_size, 0) - audit = self.library.audit(selection, cdsl, ["golden fixture"]) - statuses = {item["skill_id"]: item["status"] for item in audit["capability_translations"]} - for skill_id, status in expected_statuses.items(): - self.assertEqual(statuses[skill_id], status) - self.assertTrue(audit["evidence"]["features"]) - self.assertTrue(audit["evidence"]["profiles"]) - - if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_profile_schema.py b/backend/tests/test_profile_schema.py index d4d89116..d0509e24 100644 --- a/backend/tests/test_profile_schema.py +++ b/backend/tests/test_profile_schema.py @@ -6,7 +6,6 @@ import unittest from pathlib import Path from app.services.engine_service import load_engine, normalize_cdsl_for_engine, validate_cdsl -from app.services.flange_sleeve_template import TEMPLATE_ID, build_flange_sleeve_cdsl from app.settings import get_settings @@ -23,21 +22,38 @@ class ProfileSchemaTests(unittest.TestCase): def test_schema_and_registered_profiles_stay_in_sync(self) -> None: self.assertEqual(set(self.schema["runtime_supported_profiles"]), set(self.engine.SHAPE_GENERATORS)) + def test_legacy_profile_macros_require_explicit_adapter_lowering(self) -> None: + legacy = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "legacy-rectangle", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "base", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}, + }]}, + "features": [{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base", "params": {"distance_mm": 2}}], + } + with self.assertRaisesRegex(ValueError, "CDSL schema violation"): + validate_cdsl(legacy, self.engine) + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + + lowered = lower_legacy_profiles(legacy) + self.assertEqual(lowered["geometry"]["sketches"][0]["profile"]["type"], "analytic_contours") + validate_cdsl(lowered, self.engine) + def test_schema_and_executable_atomic_operations_stay_in_sync(self) -> None: self.assertEqual(set(self.schema["runtime_supported_atomic_ids"]), set(self.engine.SUPPORTED_ATOMIC_IDS)) - self.assertTrue(set(self.engine.SUPPORTED_ATOMIC_IDS).issubset(self.schema["feature_atomic_ids"])) + self.assertEqual(set(self.engine.SUPPORTED_ATOMIC_IDS), set(self.schema["feature_atomic_ids"])) self.assertEqual( set(self.cdsl_schema["$defs"]["feature_atomic_ids"]["enum"]), - set(self.schema["feature_atomic_ids"]), + set(self.engine.SUPPORTED_ATOMIC_IDS), ) def test_machine_schema_and_human_contract_stay_in_sync(self) -> None: self.assertEqual( set(self.cdsl_schema["$defs"]["profile_type"]["enum"]), - set(self.schema["profiles"]) - {"complex_arc_shape", "unknown_shape"}, + set(self.schema["runtime_supported_profiles"]), ) - self.assertEqual(set(self.cdsl_schema["$defs"]["motif_type"]["enum"]), set(self.schema["motif_types"])) - self.assertEqual(set(self.cdsl_schema["$defs"]["layout_type"]["enum"]), set(self.schema["layout_types"])) + self.assertEqual(set(self.schema["profiles"]), set(self.schema["runtime_supported_profiles"])) def test_rejects_unsupported_atomic_operation_before_rebuild(self) -> None: cdsl = { @@ -135,39 +151,6 @@ class ProfileSchemaTests(unittest.TestCase): self.assertNotIn("point_mm", axis) self.assertIn("features[0].params.axis: point_mm -> origin_mm", repairs) - def test_flange_sleeve_template_is_schema_valid_and_runtime_buildable(self) -> None: - cdsl, plan = build_flange_sleeve_cdsl({ - "template": TEMPLATE_ID, - "name": "Template flange sleeve", - "flange_width_mm": 120, - "flange_height_mm": 120, - "flange_thickness_mm": 14, - "corner_chamfer_mm": 10, - "tube_outer_diameter_mm": 70, - "tube_straight_length_mm": 95, - "tip_outer_diameter_mm": 62, - "tip_length_mm": 28, - "bore_diameter_mm": 46, - "boss_outer_diameter_mm": 82, - "boss_height_mm": 5, - "mount_hole_diameter_mm": 12, - "mount_counterbore_diameter_mm": 24, - "mount_counterbore_depth_mm": 5, - "mount_hole_u_mm": 42, - "mount_hole_v_mm": 42, - }) - - self.assertEqual(plan["name"], "Template flange sleeve") - self.assertEqual(cdsl["features"][1]["depends_on"], ["flange_plate"]) - self.assertEqual(cdsl["geometry"]["sketches"][0]["workplane"]["x_dir"], [0.0, 1.0, 0.0]) - validate_cdsl(cdsl, self.engine) - with tempfile.TemporaryDirectory() as directory: - step_path = Path(directory) / "flange-sleeve.step" - result = self.engine.run_cdsl_only(cdsl, step_path) - self.assertEqual(result["engine"], "cdsl_only") - self.assertTrue(step_path.is_file()) - self.assertGreater(step_path.stat().st_size, 0) - def test_cdsl_only_rebuild_preserves_its_actual_failure(self) -> None: cdsl = { "schema": "cad.cdsl.llm.v1", @@ -192,11 +175,16 @@ class ProfileSchemaTests(unittest.TestCase): self.assertNotIn("engine.run_rebuild(cdsl_copy, step_path)", build_revision_source) def test_all_official_samples_match_the_engine_schema(self) -> None: + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + samples = sorted(self.settings.library_root.glob("samples/**/model.cdsl.json")) self.assertGreater(len(samples), 0) for sample_path in samples: with self.subTest(sample=sample_path.parent.name): - validate_cdsl(json.loads(sample_path.read_text(encoding="utf-8")), self.engine) + legacy_sample = json.loads(sample_path.read_text(encoding="utf-8")) + lowered = lower_legacy_profiles(legacy_sample) + validate_cdsl(lowered, self.engine) + self.assertTrue(self.engine.analyze_cdsl(lowered).runtime_eligible) if __name__ == "__main__": unittest.main() diff --git a/frontend/src/app/api/tasks/[taskId]/quality/route.ts b/frontend/src/app/api/tasks/[taskId]/quality/route.ts new file mode 100644 index 00000000..29a3ddcf --- /dev/null +++ b/frontend/src/app/api/tasks/[taskId]/quality/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backendFetch, readBackendError } from "@/lib/backend"; + +export const runtime = "nodejs"; + +export async function GET(_: NextRequest, { params }: { params: Promise<{ taskId: string }> }) { + const { taskId } = await params; + const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/quality`); + if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); + return NextResponse.json(await response.json()); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 4dcd14b1..f8fac541 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -442,6 +442,18 @@ button:disabled { .cad-result { margin-top: 12px; } .cad-result-title { margin-left: 21px; color: var(--ui-accent-text); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; } .cad-result-meta { display: flex; flex-wrap: wrap; gap: 3px 10px; margin-left: 21px; color: var(--ui-text-muted); font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; } +.cad-quality-status { font-weight: 600; } +.quality-accepted { color: #2f8f5b; } +.quality-built_with_warnings, .quality-historical { color: #a66a1f; } +.quality-needs_repair, .quality-blocked, .quality-failed { color: #b44545; } +.cad-result-notes { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 6px; margin: 8px 0 0 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; } +.cad-result-notes strong { color: var(--ui-text); } +.cad-quality-results { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 6px; margin: 8px 0 0 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; } +.cad-quality-results > strong { color: var(--ui-text); } +.cad-quality-results ul { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; } +.cad-quality-results li { display: flex; flex-wrap: wrap; gap: 6px; overflow-wrap: anywhere; } +.quality-rule-passed { color: #2f8f5b; } +.quality-rule-failed, .quality-rule-unavailable { color: #b44545; } .download-row { display: flex; flex-wrap: wrap; gap: 9px; margin: 2px 0 0 21px; } .download-link { display: inline-flex; align-items: center; gap: 4px; color: var(--ui-link); font-size: 11px; text-decoration: none; text-underline-offset: 2px; } .download-link:hover { color: var(--ui-link-hover); text-decoration: underline; } diff --git a/frontend/src/components/cad-message-parts.tsx b/frontend/src/components/cad-message-parts.tsx index 11ad5842..b5a6c7b2 100644 --- a/frontend/src/components/cad-message-parts.tsx +++ b/frontend/src/components/cad-message-parts.tsx @@ -1,6 +1,7 @@ "use client"; import { AlertTriangle, Box, Check, Download, Loader2, Ruler } from "lucide-react"; +import { useEffect, useState } from "react"; import { encodeArtifactUrl } from "@/lib/cad-artifacts"; import type { CadError, CadImageAnalysis, CadProgress, CadResult } from "@/lib/cad-types"; @@ -34,6 +35,16 @@ export function CadResultPart({ data }: { data: CadResult }) { ["GLB", data.glbPath], ["报告", data.reportPath], ]; + if (data.qualityPath) downloads.push(["质量报告", data.qualityPath]); + if (data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]); + const qualityLabels: Record = { + accepted: "验收通过", + built_with_warnings: "构建完成,有警告", + needs_repair: "需要修复", + blocked: "已阻塞", + failed: "失败", + }; + const quality = String(data.qualityStatus || ""); return (
@@ -45,7 +56,12 @@ export function CadResultPart({ data }: { data: CadResult }) { {data.engine} {data.revisionId} {data.referenceIds.length} 个参考 + {quality ? {qualityLabels[quality] || quality} : null}
+ {data.assumptions?.length ?
假设{data.assumptions.join(";")}
: null} + {data.referenceIds.length ?
参考{data.referenceIds.join(";")}
: null} + + {data.snapshotStatus && data.snapshotStatus !== "unavailable" ?
快照{data.snapshotStatus}
: null}
{downloads.map(([label, path]) => ( @@ -58,6 +74,44 @@ export function CadResultPart({ data }: { data: CadResult }) { ); } +type QualityResult = { id?: string; status?: string; severity?: string; expected?: unknown; actual?: unknown }; +type QualityPayload = { quality?: { results?: QualityResult[] }; quality_status?: string }; + +function QualitySummary({ data }: { data: CadResult }) { + const [quality, setQuality] = useState(null); + + useEffect(() => { + if (!data.qualityPath) { + setQuality(null); + return; + } + const controller = new AbortController(); + void fetch(`/api/tasks/${encodeURIComponent(data.taskId)}/quality`, { signal: controller.signal }) + .then((response) => response.ok ? response.json() as Promise : null) + .then((payload) => { if (!controller.signal.aborted) setQuality(payload); }) + .catch(() => { if (!controller.signal.aborted) setQuality(null); }); + return () => controller.abort(); + }, [data.qualityPath, data.revisionId, data.taskId]); + + const results = quality?.quality?.results || []; + if (!results.length) return null; + const label: Record = { passed: "通过", failed: "未通过", unavailable: "不可用" }; + return ( +
+ 验证 +
    + {results.map((result, index) => ( +
  • + {result.id || "rule"} + {label[result.status || ""] || result.status || "不可用"} + {result.severity && result.severity !== "blocking" ? {result.severity} : null} +
  • + ))} +
+
+ ); +} + export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) { const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? []; return ( diff --git a/frontend/src/components/cad-viewer-preview.tsx b/frontend/src/components/cad-viewer-preview.tsx index f6b05729..4b0c3fde 100644 --- a/frontend/src/components/cad-viewer-preview.tsx +++ b/frontend/src/components/cad-viewer-preview.tsx @@ -54,6 +54,13 @@ function resultFromBackend(payload: Record): CadResult { summary: String(payload.summary || "Updated CDSL model"), referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [], engine: String(payload.engine || "cdsl_only"), + qualityStatus: String(payload.quality_status || ""), + qualityPath: typeof payload.quality_path === "string" ? payload.quality_path : undefined, + assumptions: Array.isArray(payload.generation_assumptions) ? payload.generation_assumptions.map(String) : [], + warnings: Array.isArray(payload.warnings) ? payload.warnings.map(String) : [], + repairAttempts: Number(payload.repair_attempts || 0), + snapshotPaths: Array.isArray(payload.snapshot_paths) ? payload.snapshot_paths.map(String) : [], + snapshotStatus: String(payload.snapshot_status || "unavailable"), }; } diff --git a/frontend/src/lib/cad-artifacts.ts b/frontend/src/lib/cad-artifacts.ts index ccf3aff8..600dca02 100644 --- a/frontend/src/lib/cad-artifacts.ts +++ b/frontend/src/lib/cad-artifacts.ts @@ -34,5 +34,12 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul summary: current.summary || "CDSL CAD model", referenceIds: current.reference_ids || [], engine: current.engine || "cdsl_only", + qualityStatus: current.quality_status || "", + qualityPath: current.quality_path, + assumptions: current.generation_assumptions || current.assumptions || [], + warnings: current.warnings || [], + repairAttempts: current.repair_attempts || 0, + snapshotPaths: current.snapshot_manifest_path ? [current.snapshot_manifest_path] : [], + snapshotStatus: current.snapshot_status || "unavailable", }; } diff --git a/frontend/src/lib/cad-types.ts b/frontend/src/lib/cad-types.ts index b7c490ff..3b441a6f 100644 --- a/frontend/src/lib/cad-types.ts +++ b/frontend/src/lib/cad-types.ts @@ -20,6 +20,13 @@ export type CadResult = { summary: string; referenceIds: string[]; engine: string; + qualityStatus?: "accepted" | "built_with_warnings" | "needs_repair" | "blocked" | "failed" | string; + qualityPath?: string; + assumptions?: string[]; + warnings?: string[]; + repairAttempts?: number; + snapshotPaths?: string[]; + snapshotStatus?: string; }; export type CadError = { @@ -41,6 +48,7 @@ export type CadImageAnalysis = { dimensionCandidates?: CadImageDimension[]; // Legacy conversations stored this field before dimensions became optional. requiredDimensions?: CadImageDimension[]; + artifactPath?: string; }; export type CadDataParts = { @@ -85,6 +93,14 @@ export type TaskRevision = { reference_ids?: string[]; engine?: string; error?: string; + quality_status?: string; + quality_path?: string; + assumptions?: string[]; + generation_assumptions?: string[]; + warnings?: string[]; + repair_attempts?: number; + snapshot_manifest_path?: string; + snapshot_status?: string; }; export type TaskRecord = { @@ -104,4 +120,5 @@ export type BackendConfig = { model: string; configured: boolean; library_samples: number; + max_repair_attempts?: number; }; -- 2.52.0 From a508e6f07e8130a7a024f0c85db79cc262ae4c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Wed, 26 Aug 2026 14:13:11 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env | 10 +- backend/README.md | 37 + backend/app/main.py | 188 ++- backend/app/models/contracts.py | 1 + backend/app/services/agent_service.py | 1298 +++++++++++++++-- backend/app/services/attachments.py | 12 +- backend/app/services/cdsl_fragment.py | 272 ++++ backend/app/services/editing.py | 7 + backend/app/services/engine_service.py | 170 ++- backend/app/services/feature_plan.py | 222 +++ backend/app/services/generation_plan.py | 228 +++ backend/app/services/image_observation.py | 264 ++++ backend/app/services/image_processing.py | 46 + .../app/services/incremental_generation.py | 509 +++++++ backend/app/services/quality.py | 77 +- backend/app/services/review_renderer.py | 332 +++++ backend/app/services/storage.py | 304 +++- backend/app/services/visual_review.py | 141 ++ backend/app/settings.py | 28 + backend/engine/cdsl_engine/cdsl_schema.json | 3 +- backend/engine/cdsl_engine/runtime.py | 12 +- backend/engine/cdsl_engine/runtime_types.py | 67 + backend/requirements-vision.txt | 2 + backend/requirements.txt | 1 + backend/scripts/cleanup_legacy_attachments.py | 97 ++ backend/tests/test_agent_tool_arguments.py | 63 +- backend/tests/test_direct_cdsl_pipeline.py | 231 ++- .../tests/test_engine_runtime_foundation.py | 31 + backend/tests/test_feature_plan.py | 68 + backend/tests/test_image_observation.py | 134 ++ backend/tests/test_incremental_generation.py | 340 +++++ backend/tests/test_part_skills.py | 2 +- backend/tests/test_topology_snapshot.py | 75 + frontend/package-lock.json | 342 ----- frontend/package.json | 1 - frontend/src/app/globals.css | 10 +- frontend/src/components/agent-studio.tsx | 101 +- frontend/src/components/agent-thread.tsx | 17 +- frontend/src/components/cad-message-parts.tsx | 48 +- .../src/components/cad-viewer-preview.tsx | 61 +- frontend/src/lib/cad-artifacts.ts | 25 +- frontend/src/lib/cad-stream.test.ts | 47 +- frontend/src/lib/cad-stream.ts | 29 +- frontend/src/lib/cad-types.ts | 59 + .../src/lib/cdsl-selector-runtime.test.ts | 19 + frontend/src/lib/cdsl-selector-runtime.ts | 104 +- frontend/src/lib/viewer-selection.ts | 1 + 47 files changed, 5616 insertions(+), 520 deletions(-) create mode 100644 backend/app/services/cdsl_fragment.py create mode 100644 backend/app/services/feature_plan.py create mode 100644 backend/app/services/generation_plan.py create mode 100644 backend/app/services/image_observation.py create mode 100644 backend/app/services/image_processing.py create mode 100644 backend/app/services/incremental_generation.py create mode 100644 backend/app/services/review_renderer.py create mode 100644 backend/app/services/visual_review.py create mode 100644 backend/requirements-vision.txt create mode 100644 backend/scripts/cleanup_legacy_attachments.py create mode 100644 backend/tests/test_feature_plan.py create mode 100644 backend/tests/test_image_observation.py create mode 100644 backend/tests/test_incremental_generation.py create mode 100644 backend/tests/test_topology_snapshot.py diff --git a/backend/.env b/backend/.env index ff21e115..033f5c49 100644 --- a/backend/.env +++ b/backend/.env @@ -1,6 +1,6 @@ # Default provider. Only providers with an API key are exposed to the UI. CDSL_DEFAULT_PROVIDER=deepseek -CDSL_DEFAULT_MODEL=deepseek-v4-flash-vision-exp +CDSL_DEFAULT_MODEL=deepseek-v4-flash # CDSL_DEFAULT_PROVIDER=openai # CDSL_DEFAULT_MODEL=gpt-5.5 @@ -11,12 +11,16 @@ CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp CDSL_LLM_TIMEOUT_S=90 CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp +# Incremental generation uses a separate vision-capable model for checkpoint review. +CDSL_REVIEW_PROVIDER=deepseek +CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp + # Optional OpenAI provider. Comma-separate enabled models; list vision models # separately so image attachments can be routed safely. CDSL_OPENAI_BASE_URL=https://api.vip1129.cc/v1 CDSL_OPENAI_API_KEY=sk-6586c229d77de8c421ba98e7eb0d9c6bb10f08ebc796de946ed17cf8d0d7a229 -CDSL_OPENAI_MODELS=gpt-5.5 -CDSL_OPENAI_VISION_MODELS=gpt-5.5 +CDSL_OPENAI_MODELS=gpt-5.5,gpt-5.6-luna +CDSL_OPENAI_VISION_MODELS=gpt-5.5,gpt-5.6-luna # Optional Kimi provider. CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1 diff --git a/backend/README.md b/backend/README.md index db9a62ac..e445d238 100644 --- a/backend/README.md +++ b/backend/README.md @@ -9,3 +9,40 @@ The backend owns the application API and CAD generation workflow: - `tests/`: Engine, API, and end-to-end generation tests. Expected development entrypoint: `app.main:app`, served by Uvicorn. + +## Incremental Generation Configuration + +Incremental generation is enabled by default. It requires a separately +configured vision-capable review model and the Python OpenCascade/Pillow +technical renderer; a run +fails instead of skipping visual review when either is unavailable. + +```dotenv +# Authoring provider/model must already be configured as usual. +CDSL_INCREMENTAL_GENERATION=1 + +# Must name one configured provider and one model listed in that provider's +# CDSL__VISION_MODELS setting. It is intentionally not inferred +# from the authoring model. +CDSL_REVIEW_PROVIDER=openai +CDSL_REVIEW_MODEL=gpt-4.1-mini +CDSL_OPENAI_VISION_MODELS=gpt-4.1-mini + +# Install Python rendering dependencies. The renderer reads the revision STEP +# file and creates canonical images without a browser or GPU driver. +pip install -r requirements.txt + +# Optional per-node retry budgets. +CDSL_NODE_AUTHORING_ATTEMPTS=2 +CDSL_NODE_REPAIR_ATTEMPTS=2 +CDSL_NODE_REPLAN_ATTEMPTS=1 +``` + +Every checkpoint is rebuilt from its fully materialized CDSL through the +`cdsl_only` runtime. Checkpoint GLB files are preview-only; STEP, CDSL, and +reports are available only after the task reaches `COMPLETED`. + +The generation plan contains semantic node IDs only. The backend derives the +unique CDSL feature and sketch IDs from each node, then writes them during +fragment materialization. This keeps naming and topology ownership stable +without requiring the authoring model to reproduce internal identifiers. diff --git a/backend/app/main.py b/backend/app/main.py index 70fff67e..abf6a9ad 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,17 +1,22 @@ from __future__ import annotations +import asyncio import json +import secrets from typing import Any from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi.responses import JSONResponse, StreamingResponse from app.models.contracts import ChatRequest, ConversationPatch, ModifyRequest, ParameterUpdate -from app.services.engine_service import apply_parameter_updates, build_revision +from app.services.engine_service import QualityVerificationError, apply_parameter_updates, build_revision from app.services.agent_service import AgentService from app.services.library import CdslLibrary -from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id +from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id, write_json from app.services.attachments import attachment_record, classify_upload, extract_document_text +from app.services.image_processing import image_metadata +from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status +from app.services.visual_review import VisualReviewError, review_checkpoint from app.settings import get_settings @@ -22,6 +27,99 @@ agent = AgentService(settings, store, library) app = FastAPI(title="CDSL CAD Agent API", version="0.1.0") +@app.on_event("startup") +async def resume_incremental_generation() -> None: + """Restore durable generation tasks after a backend process restart.""" + await agent.resume_running_tasks() + + +async def _finalize_controlled_revision( + *, + task_id: str, + previous_revision_id: str, + node_id: str, + built: dict[str, Any], +) -> dict[str, Any]: + """Publish a deterministic post-completion edit only after vision review.""" + revision_id = str(built["revision_id"]) + try: + generation_spec = store.read_generation_spec(task_id) or {} + requirements = generation_spec.get("requirements") if isinstance(generation_spec.get("requirements"), list) else [] + render_dir = store.revision_dir(task_id, revision_id) / "review" + manifest = await asyncio.to_thread( + render_checkpoint, + settings, + step_path=store.artifact_path(task_id, str(built["step_path"])), + output_dir=render_dir, + ) + review = await review_checkpoint( + settings, + manifest=manifest, + requirements=requirements, + node_id=node_id, + deterministic_report={ + "quality_status": built.get("quality_status"), + "verification": built.get("verification_summary", {}), + }, + final_checkpoint=True, + ) + manifest_path = (render_dir / "render-manifest.json").relative_to(store.task_dir(task_id)).as_posix() + review_path = (render_dir / "visual-review.json").relative_to(store.task_dir(task_id)).as_posix() + write_json(render_dir / "visual-review.json", review) + store.update_revision_metadata(task_id, revision_id, { + "render_manifest_path": manifest_path, + "visual_review_path": review_path, + }) + if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85: + store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}") + store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "node_id": node_id, + "stage": "visual_review", + "error_code": "HIGH_CONFIDENCE_VISUAL_REPAIR", + "message": "; ".join(review.get("evidence") or ["Visual review rejected the controlled edit"]), + "recommended_rollback_revision": previous_revision_id, + }) + raise ValueError("Visual review rejected this edit; the model was rolled back to its previous revision") + store.finish_generation(task_id, lifecycle="completed") + return {**built, "visibility": "final", "lifecycle": "completed", "checkpoint": False} + except (ReviewRenderError, VisualReviewError, ValueError): + task = store.read_task(task_id) or {} + if str(task.get("lifecycle") or "") == "running": + store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}") + store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "node_id": node_id, + "stage": "visual_review", + "message": "Controlled edit could not complete its required review", + "recommended_rollback_revision": previous_revision_id, + }) + raise + + +def _require_controlled_review_configuration() -> None: + """Fail before a post-completion edit creates an unreviewed checkpoint.""" + settings.resolve_review_model() + ready, detail = renderer_status() + if not ready: + raise ValueError(detail) + + +def _fail_controlled_run(task_id: str, previous_revision_id: str, node_id: str, error: Exception) -> None: + task = store.read_task(task_id) or {} + if str(task.get("lifecycle") or "") != "running": + return + store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}") + store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "node_id": node_id, + "stage": "controlled_build", + "error_code": type(error).__name__.upper(), + "message": str(error), + "recommended_rollback_revision": previous_revision_id, + }) + + @app.get("/health") async def health() -> dict[str, Any]: return { @@ -50,6 +148,12 @@ async def config() -> dict[str, Any]: for model in provider.models ], }) + try: + settings.resolve_review_model() + renderer_ready, renderer_detail = renderer_status() + review_error = "" if renderer_ready else renderer_detail + except ValueError as error: + review_error = str(error) return { "default_provider": settings.default_provider_id, "default_model": settings.llm_model, @@ -58,6 +162,9 @@ async def config() -> dict[str, Any]: "configured": settings.llm_configured, "library_samples": library.count(), "max_repair_attempts": settings.max_repair_attempts, + "incremental_generation": settings.incremental_generation, + "review_configured": not review_error, + "review_error": review_error, } @@ -104,8 +211,13 @@ async def upload_conversation_attachment( filename = file.filename or "attachment" try: conversation = safe_conversation_id(conversation_id) - if store.read_conversation(conversation) is None: + current = store.read_conversation(conversation) + if current is None: raise HTTPException(status_code=404, detail="Conversation not found") + active_task_id = str(current.get("current_task_id") or "") + active_task = store.read_task(active_task_id) if active_task_id else None + if str((active_task or {}).get("lifecycle") or "") == "running": + raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state") kind = classify_upload(filename, file.content_type or "", len(data)) relative_path, _ = store.write_conversation_upload(conversation, filename, data) extracted_path = "" @@ -113,7 +225,8 @@ async def upload_conversation_attachment( extracted_path = relative_path + ".txt" extracted = extract_document_text(data) store.conversation_attachment_path(conversation, extracted_path).write_text(extracted, encoding="utf-8") - record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path) + metadata = image_metadata(data) if kind == "image" else {} + record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path, metadata) store.add_conversation_attachment(conversation, record) return JSONResponse(record) except ValueError as error: @@ -128,6 +241,11 @@ async def read_task(task_id: str) -> JSONResponse: raise HTTPException(status_code=400, detail=str(error)) from error if task is None: raise HTTPException(status_code=404, detail="Task not found") + # Keep the task endpoint self-contained for a reconnecting UI. The plan is + # immutable within a run and exposes node status, while previews always use + # the active working revision rather than a downloadable artifact. + task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "") + task["generation_plan"] = store.read_generation_spec(task["task_id"]) return JSONResponse(task) @@ -142,6 +260,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse: raise HTTPException(status_code=400, detail=str(error)) from error if not path.is_file(): raise HTTPException(status_code=404, detail="Artifact not found") + task = store.read_task(safe_id) or {} + parts = artifact_path.split("/") + revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else "" + revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None) + published_revision = str(task.get("published_revision") or "") + active_revision = str(task.get("active_revision") or task.get("current_revision") or "") + if isinstance(revision, dict) and revision_id != published_revision: + # Revisions are private until publication. The currently active + # checkpoint exposes only its GLB inline for the review viewer; an old + # or superseded checkpoint has no public artifact surface at all. + if revision_id != active_revision or path.name != "model.glb": + raise HTTPException(status_code=403, detail="Only the published revision is downloadable") + return FileResponse(path, media_type="model/gltf-binary", headers={"Content-Disposition": "inline"}) return FileResponse(path, filename=path.name) @@ -152,6 +283,8 @@ async def read_parameters(task_id: str) -> JSONResponse: except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error task = store.read_task(safe_id) + if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""): + raise HTTPException(status_code=403, detail="Checkpoint parameters are not available until publication") revision_id = str((task or {}).get("current_revision") or "") revision = next((item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id), None) relative = str((revision or {}).get("parameters_path") or "") @@ -170,6 +303,8 @@ async def read_quality(task_id: str) -> JSONResponse: except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error task = store.read_task(safe_id) + if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""): + raise HTTPException(status_code=403, detail="Checkpoint reports are not available until publication") revision_id = str((task or {}).get("current_revision") or "") revisions = (task or {}).get("revisions", []) revision = next((item for item in revisions if item.get("revision_id") == revision_id), None) @@ -200,11 +335,16 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo try: safe_id = safe_task_id(task_id) task = store.read_task(safe_id) + if str((task or {}).get("lifecycle") or "") == "running": + raise ValueError("CAD task is running; parameter changes are locked") current_revision_id = str((task or {}).get("current_revision") or "") 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") updated, _ = apply_parameter_updates(json.loads(current_path.read_text(encoding="utf-8")), payload.values) + if settings.incremental_generation: + _require_controlled_review_configuration() + store.start_generation(safe_id, request=f"Parameter update: {', '.join(payload.values)}") result = build_revision( settings=settings, store=store, @@ -217,9 +357,21 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo operation={"type": "parameter_update", "values": payload.values}, part_skills=None, generation_assumptions=[], + node_id="parameter_update" if settings.incremental_generation else "", + branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main", + visibility="checkpoint" if settings.incremental_generation else "final", ) + if settings.incremental_generation: + result = await _finalize_controlled_revision( + task_id=safe_id, + previous_revision_id=current_revision_id, + node_id="parameter_update", + built=result, + ) return JSONResponse(result) - except ValueError as error: + except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error: + if settings.incremental_generation and "safe_id" in locals() and "current_revision_id" in locals(): + _fail_controlled_run(safe_id, current_revision_id, "parameter_update", error) raise HTTPException(status_code=400, detail=str(error)) from error @@ -228,7 +380,29 @@ async def modify_task(task_id: str, payload: ModifyRequest) -> JSONResponse: from app.services.editing import apply_direct_edit try: - result = apply_direct_edit(settings, store, safe_task_id(task_id), payload.operation, payload.selection, payload.parameters) + safe_id = safe_task_id(task_id) + task = store.read_task(safe_id) or {} + if str(task.get("lifecycle") or "") == "running": + raise ValueError("CAD task is running; topology edits are locked") + previous_revision_id = str(task.get("current_revision") or "") + if settings.incremental_generation: + _require_controlled_review_configuration() + store.start_generation(safe_id, request=f"Direct CDSL edit: {payload.operation}") + result = apply_direct_edit( + settings, store, safe_id, payload.operation, payload.selection, payload.parameters, + node_id="topology_edit" if settings.incremental_generation else "", + branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main", + visibility="checkpoint" if settings.incremental_generation else "final", + ) + if settings.incremental_generation: + result = await _finalize_controlled_revision( + task_id=safe_id, + previous_revision_id=previous_revision_id, + node_id="topology_edit", + built=result, + ) return JSONResponse(result) - except ValueError as error: + except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error: + if settings.incremental_generation and "safe_id" in locals() and "previous_revision_id" in locals(): + _fail_controlled_run(safe_id, previous_revision_id, "topology_edit", error) raise HTTPException(status_code=400, detail=str(error)) from error diff --git a/backend/app/models/contracts.py b/backend/app/models/contracts.py index d50f9025..71c4e261 100644 --- a/backend/app/models/contracts.py +++ b/backend/app/models/contracts.py @@ -56,6 +56,7 @@ class CadResult(BaseModel): parameters_path: str | None = None selector_path: str | None = None edges_path: str | None = None + topology_path: str | None = None summary: str reference_ids: list[str] = Field(default_factory=list) engine: str = "cdsl_only" diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index bdefac56..d90515e6 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -17,11 +17,20 @@ import httpx from app.models.contracts import ChatMessage from app.services.engine_service import build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch +from app.services.feature_plan import FeaturePlanError, compute_node_statuses, validate_feature_plan +from app.services.incremental_generation import IncrementalGenerationRunner from app.services.library import CdslLibrary from app.services.part_skills import PartSkillLibrary -from app.services.quality import QUALITY_RULE_TYPES, validate_verification +from app.services.quality import FEATURE_RULE_TYPES, QUALITY_RULE_TYPES, validate_verification from app.services.sse import event from app.services.storage import WorkspaceStore, now_iso +from app.services.image_observation import ( + merge_image_observations, + normalize_image_observation, + normalize_sketch_candidates, + render_image_observation_context, +) +from app.services.image_processing import cv_hints from app.settings import ProviderConfig, ProviderModel, Settings @@ -49,6 +58,23 @@ class CdslRepairLimitError(RuntimeError): self.diagnostic_paths = diagnostic_paths +def get_repair_step_key(planning_state: dict[str, Any], tool_name: str) -> str: + """Identify one semantic generation step without carrying failures across batches.""" + plan = planning_state.get("feature_plan") if isinstance(planning_state, dict) else None + if isinstance(plan, dict): + active_nodes = sorted( + str(node.get("id")) + for node in plan.get("nodes") or () + if isinstance(node, dict) + and node.get("status") in {"ready", "executing"} + ) + if active_nodes: + return f"plan:{str(plan.get('plan_id') or '')}:{','.join(active_nodes)}" + # ``generate_cdsl_model`` and ``patch_cdsl_model`` are both attempts at + # the same repair phase when no feature plan is available. + return f"phase:{str(planning_state.get('phase') or '')}" + + def user_visible_error_message(error: Exception, user_text: str) -> str: if isinstance(error, StrictToolSchemaError) and any( "\u4e00" <= char <= "\u9fff" for char in str(user_text or "") @@ -71,8 +97,8 @@ def user_visible_error_message(error: Exception, user_text: str) -> str: if isinstance(error, CdslRepairLimitError): diagnostics = "、".join(error.diagnostic_paths) if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")): - return "模型重试达到四次 CDSL 修复上限,未创建新的成功 revision。每次 CDSL 校验失败的诊断已保存到:" + diagnostics + "。" - return "The four-attempt CDSL repair limit was reached. Diagnostics were saved to: " + diagnostics + "." + return "同一 CDSL 步骤连续重试达到四次上限,未创建新的成功 revision。每次 CDSL 校验失败的诊断已保存到:" + diagnostics + "。" + return "The same CDSL step failed four consecutive times. Diagnostics were saved to: " + diagnostics + "." return str(error) @@ -110,8 +136,44 @@ def _repair_premature_tool_wrapper_close(source: str, parsed_value: Any, parsed_ return value +def _recover_trailing_cdsl_metadata( + source: str, + parsed_value: Any, + parsed_end: int, +) -> dict[str, Any] | None: + """Accept a complete CDSL envelope followed only by duplicate metadata.""" + required = {"cdsl", "summary", "assumptions"} + allowed = required | {"verification"} + if ( + not isinstance(parsed_value, dict) + or not required.issubset(parsed_value) + or not set(parsed_value).issubset(allowed) + ): + return None + + # Some providers continue after a complete root object with a second copy + # of its presentation metadata. Decode that suffix as its own object; + # never use a regex to parse nested JSON. The CDSL payload itself may not + # reappear, so the executable model always comes from the first object. + suffix = source[parsed_end:] + if not suffix.startswith(","): + return None + try: + duplicate, duplicate_end = json.JSONDecoder().raw_decode("{" + suffix[1:]) + except json.JSONDecodeError: + return None + if ( + duplicate_end != len(suffix) + or not isinstance(duplicate, dict) + or not duplicate + or not set(duplicate).issubset({"summary", "assumptions", "verification"}) + ): + return None + return parsed_value + + def parse_tool_arguments(raw_arguments: Any, *, recover_cdsl_wrapper: bool = False) -> dict[str, Any]: - """Decode one function-call argument object, with one guarded CDSL repair.""" + """Decode one function-call argument object, with guarded CDSL repairs.""" if raw_arguments is None or raw_arguments == "": return {} if not isinstance(raw_arguments, str): @@ -129,6 +191,9 @@ def parse_tool_arguments(raw_arguments: Any, *, recover_cdsl_wrapper: bool = Fal repaired = _repair_premature_tool_wrapper_close(source, value, parsed_end) if repaired is not None: return repaired + repaired = _recover_trailing_cdsl_metadata(source, value, parsed_end) + if repaired is not None: + return repaired raise ToolArgumentsError("arguments contain trailing content after the JSON object") if not isinstance(value, dict): raise ToolArgumentsError("arguments must decode to a JSON object") @@ -141,14 +206,43 @@ def invalid_tool_arguments_result(name: str, error: ToolArgumentsError) -> dict[ "code": "INVALID_TOOL_ARGUMENTS", "message": ( f"{name} arguments were rejected: {error}. " - "Call the same tool again with exactly one valid JSON object. " - "Do not append prose, Markdown fences, or another JSON value." + "Regenerate the same tool call with exactly one complete JSON object, " + "from its first `{` through its final `}`. Do not repeat any fields " + "or append prose, Markdown fences, or another JSON value." ), } def _cdsl_error_details(error: Exception) -> dict[str, Any]: message = str(error) + known_codes = ( + "FEATURE_PLAN_INVALID", "FEATURE_PLAN_CYCLE", "TOPOLOGY_REQUIRED", "TOPOLOGY_NOT_AVAILABLE", + "TOPOLOGY_SNAPSHOT_STALE", "SELECTOR_CONTEXT_REQUIRED", "SELECTOR_NOT_FOUND", + "SELECTOR_AMBIGUOUS", "SELECTOR_GEOMETRY_MISMATCH", "FEATURE_NOT_READY", "COMPLETED_FEATURE_MUTATION", + "INVALID_CDSL_PATCH", + ) + explicit_code = next((code for code in known_codes if code in message), "") + if explicit_code: + if explicit_code == "INVALID_CDSL_PATCH": + return { + "code": explicit_code, + "path": "$", + "kind": "patch", + "repair_instruction": "Read the current CDSL and apply a path that exists in base_revision_id; use generate_cdsl_model for structural changes.", + } + if explicit_code == "FEATURE_NOT_READY": + return { + "code": explicit_code, + "path": "$.features", + "kind": "feature_plan", + "repair_instruction": "Keep completed features unchanged and generate only the listed allowed_feature_ids from the current ready plan batch.", + } + return { + "code": explicit_code, + "path": "$", + "kind": "topology" if "SELECTOR" in explicit_code or "TOPOLOGY" in explicit_code else "feature_plan", + "repair_instruction": "Use the current topology snapshot and a valid ready feature batch, then retry.", + } path_match = re.search(r"(?:at|path) (\$[^: ]*)", message) quality = getattr(error, "quality_report", None) if isinstance(quality, dict): @@ -189,7 +283,7 @@ def invalid_cdsl_result(error: Exception) -> dict[str, Any]: def user_visible_tool_message(result: dict[str, Any], user_text: str) -> str: code = str(result.get("code") or "") - if code in {"CDSL_SCHEMA_INVALID", "CDSL_RUNTIME_INVALID", "VERIFICATION_FAILED"}: + if code in {"CDSL_SCHEMA_INVALID", "CDSL_RUNTIME_INVALID", "VERIFICATION_FAILED", "FEATURE_NOT_READY", "INVALID_CDSL_PATCH"}: 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." @@ -263,6 +357,7 @@ def _cdsl_tool_schema() -> dict[str, Any]: CDSL_TOOL_SCHEMA = _cdsl_tool_schema() GENERATION_TOOL_NAMES = {"generate_cdsl_model", "patch_cdsl_model"} +MAX_AGENT_TOOL_ITERATIONS = 16 VERIFICATION_SCHEMA: dict[str, Any] = { "type": "object", @@ -275,7 +370,7 @@ VERIFICATION_SCHEMA: dict[str, Any] = { "properties": { "id": {"type": "string", "minLength": 1}, "type": {"enum": sorted(QUALITY_RULE_TYPES)}, - "feature": {"type": "string"}, + "feature": {"type": "string", "minLength": 1}, "expected": { "description": ( "For bbox, use [dx, dy, dz], " @@ -287,6 +382,12 @@ VERIFICATION_SCHEMA: dict[str, Any] = { "severity": {"enum": ["blocking", "warning", "informational"]}, }, "required": ["id", "type", "expected"], + "allOf": [ + { + "if": {"properties": {"type": {"enum": sorted(FEATURE_RULE_TYPES)}}}, + "then": {"required": ["feature"]}, + }, + ], "additionalProperties": False, }, }, @@ -330,47 +431,117 @@ def engine_capability_manifest(settings: Settings) -> dict[str, Any]: } +IMAGE_SEGMENT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "type": {"enum": ["line", "arc", "circle", "polyline", "unknown_curve"]}, + "start": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "end": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "center": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "radius_mm": {"type": "number", "exclusiveMinimum": 0}, + "clockwise": {"type": "boolean"}, + "points": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "maxItems": 256}, + "image_start": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "image_end": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "notes": {"type": "string", "maxLength": 300}, + }, + "required": ["type"], + "additionalProperties": False, +} + +IMAGE_PROFILE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 80}, + "role": {"type": "string", "maxLength": 40}, + "plane_hint": {"type": "string", "maxLength": 120}, + "closed": {"type": "boolean"}, + "coordinate_space": {"type": "string", "maxLength": 40}, + "segments": {"type": "array", "maxItems": 256, "items": IMAGE_SEGMENT_SCHEMA}, + "source_images": {"type": "array", "maxItems": 12, "items": {"type": "string"}}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "uncertain": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 300}}, + "notes": {"type": "string", "maxLength": 300}, + }, + "required": ["id", "segments"], + "additionalProperties": False, +} + +IMAGE_MEASUREMENT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 120}, + "value_mm": {"type": "number"}, + "min_mm": {"type": "number"}, + "max_mm": {"type": "number"}, + "source": {"enum": ["user", "image", "cv", "assumption"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "evidence": {"type": "string", "maxLength": 300}, + "source_images": {"type": "array", "maxItems": 12, "items": {"type": "string"}}, + }, + "required": ["name"], + "additionalProperties": False, +} + +IMAGE_OBSERVATION_PROPERTIES: dict[str, Any] = { + "attachment_ids": {"type": "array", "maxItems": 12, "items": {"type": "string"}}, + "part_type": {"type": "string", "minLength": 1, "maxLength": 300}, + "visible_features": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 300}}, + "uncertain_features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "views": {"type": "array", "maxItems": 12, "items": {"type": "object", "properties": { + "attachment_id": {"type": "string"}, "view_role": {"type": "string"}, "orientation": {"type": "string"}, + "visible_regions": {"type": "array", "items": {"type": "string"}}, "occluded_regions": {"type": "array", "items": {"type": "string"}}, + "quality": {"type": "string"}, "scale_reference_id": {"type": "string"}, "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + }, "required": ["attachment_id"], "additionalProperties": False}}, + "scale_references": {"type": "array", "maxItems": 12, "items": {"type": "object"}}, + "overall_geometry": {"type": "object"}, + "surfaces": {"type": "array", "maxItems": 24, "items": {"type": "object"}}, + "profiles": {"type": "array", "maxItems": 32, "items": IMAGE_PROFILE_SCHEMA}, + "holes": {"type": "array", "maxItems": 64, "items": {"type": "object"}}, + "bends": {"type": "array", "maxItems": 16, "items": {"type": "object"}}, + "measurements": {"type": "array", "maxItems": 128, "items": IMAGE_MEASUREMENT_SCHEMA}, + "uncertainties": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "cv_hints": {"type": "array", "maxItems": 32, "items": {"type": "object"}}, +} + TOOL_SCHEMAS: list[dict[str, Any]] = [ { "type": "function", "function": { "name": "analyze_image_reference", "description": ( - "Analyze the current image attachments as a CAD reference. " - "Use this before any CAD planning or generation for a newly uploaded image. " - "Describe only visible geometry and identify dimensions that may need confirmation." + "Perform a complete multi-view CAD image survey. Identify every visible plane, bend, " + "outer profile, hole, slot, irregular cutout, scale reference, estimated measurement, " + "occlusion, and uncertainty. Preserve geometry evidence; do not omit an uncertain profile." ), + "parameters": { + "type": "object", + "properties": IMAGE_OBSERVATION_PROPERTIES, + "required": ["part_type", "visible_features", "uncertain_features", "views", "profiles", "measurements", "uncertainties"], + "additionalProperties": False, + }, + }, + }, + { + "type": "function", + "function": { + "name": "extract_image_sketch_candidates", + "description": "Convert the complete image survey into candidate 2D sketch profiles for outer faces and irregular openings. Keep polyline or unknown curves when line/arc decomposition is uncertain.", "parameters": { "type": "object", "properties": { - "part_type": {"type": "string", "minLength": 1}, - "visible_features": { - "type": "array", - "items": {"type": "string", "minLength": 1}, - "minItems": 1, - "maxItems": 12, - }, - "uncertain_features": { - "type": "array", - "items": {"type": "string", "minLength": 1}, - "maxItems": 8, - }, - "dimension_candidates": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "string", "minLength": 1}, - "label": {"type": "string", "minLength": 1}, - "reason": {"type": "string", "minLength": 1}, - }, - "required": ["id", "label", "reason"], - "additionalProperties": False, - }, - "maxItems": 12, - }, + "part_type": {"type": "string", "maxLength": 300}, + "profiles": {"type": "array", "maxItems": 32, "items": IMAGE_PROFILE_SCHEMA}, + "measurements": {"type": "array", "maxItems": 128, "items": IMAGE_MEASUREMENT_SCHEMA}, + "visible_features": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 300}}, + "uncertain_features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "uncertainties": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "cv_hints": {"type": "array", "maxItems": 32, "items": {"type": "object"}}, }, - "required": ["part_type", "visible_features", "uncertain_features"], + "required": ["profiles", "measurements", "uncertainties"], "additionalProperties": False, }, }, @@ -459,20 +630,102 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ }, ] +PLANNING_TOOL_SCHEMAS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "plan_feature_tree", + "description": "Validate and store an acyclic semantic feature plan. This is planning data, not executable CDSL; never invent selectors.", + "parameters": { + "type": "object", + "properties": { + "plan_id": {"type": "string", "minLength": 1}, + "nodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "intent": {"type": "string"}, + "atomic_id": {"type": "string", "minLength": 1}, + "depends_on": {"type": "array", "items": {"type": "string"}}, + "requires_topology": {"type": "boolean"}, + "topology_query": {"type": "object"}, + "cdsl_feature_ids": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["id", "atomic_id", "depends_on"], + "additionalProperties": False, + }, + }, + "replan": { + "type": "object", + "properties": { + "replace_nodes": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + "reason": {"type": "string", "minLength": 1}, + "alternatives": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["replace_nodes", "reason", "alternatives"], + "additionalProperties": False, + }, + }, + "required": ["plan_id", "nodes"], + "additionalProperties": False, + }, + }, + }, + { + "type": "function", + "function": { + "name": "inspect_current_topology", + "description": "Query real executable face/edge/vertex records from the current successful revision. Returned selectors may be copied into CDSL.", + "parameters": { + "type": "object", + "properties": { + "kind": {"enum": ["face", "edge", "vertex", "body", "plane", "axis"]}, + "feature_id": {"type": "string"}, + "owner_feature_id": {"type": "string"}, + "surface_type": {"type": "string"}, + "curve_type": {"type": "string"}, + "position_hint": {"type": "string"}, + "position": {"type": "string"}, + "bbox_mm": {"type": "array", "items": {"type": "number"}, "minItems": 6, "maxItems": 6}, + "length_range_mm": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "area_range_mm2": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "limit": {"type": "integer", "minimum": 1, "maximum": 50}, + "cursor": {"type": "integer", "minimum": 0}, + }, + "additionalProperties": False, + }, + }, + }, +] + def tools_for_model( model: ProviderModel, *, include_image_analysis: bool = True, + include_image_sketches: bool = False, + image_stage: str | None = None, ) -> list[dict[str, Any]]: """Return this model's tool contract without mutating the shared schema.""" - tools = deepcopy(TOOL_SCHEMAS) + tools = deepcopy(TOOL_SCHEMAS + PLANNING_TOOL_SCHEMAS) if not include_image_analysis: tools = [ tool for tool in tools if tool.get("function", {}).get("name") != "analyze_image_reference" ] + if not include_image_sketches: + tools = [ + tool + for tool in tools + if tool.get("function", {}).get("name") != "extract_image_sketch_candidates" + ] + if image_stage in {"survey", "sketch"}: + required_name = "analyze_image_reference" if image_stage == "survey" else "extract_image_sketch_candidates" + tools = [tool for tool in tools if tool.get("function", {}).get("name") == required_name] if not model.strict_tool_schema: return tools @@ -523,6 +776,8 @@ def revision_input_attachments(conversation: dict[str, Any]) -> list[dict[str, s "mime": str(attachment.get("mime") or ""), "size": int(attachment.get("size") or 0), "sha256": str(attachment.get("sha256") or ""), + "width": int(attachment.get("width") or 0), + "height": int(attachment.get("height") or 0), }) return snapshots @@ -546,7 +801,7 @@ CAD request state: def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | None: - """Return an analysis only when it covers every currently attached image.""" + """Return the latest complete survey for every currently attached image.""" attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)} if not attachment_ids: return None @@ -560,7 +815,49 @@ def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | N if not isinstance(data, dict): continue analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)} - if attachment_ids.issubset(analyzed_ids): + if attachment_ids.issubset(analyzed_ids) and data.get("observationStage", "complete") == "complete": + return data + return None + + +def image_reference_stage(conversation: dict[str, Any]) -> str: + """Return the next image-intake stage while retaining legacy analyses.""" + attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)} + if not attachment_ids: + return "complete" + for message in reversed(conversation.get("messages") or []): + if not isinstance(message, dict): + continue + for part in reversed(message.get("parts") or []): + if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": + continue + data = part.get("data") + if not isinstance(data, dict): + continue + analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)} + if not attachment_ids.issubset(analyzed_ids): + continue + stage = str(data.get("observationStage") or "complete") + if stage == "survey": + return "sketch" + if stage in {"sketch", "complete"}: + return "complete" + return "survey" + + +def image_reference_observation_part(conversation: dict[str, Any], stage: str) -> dict[str, Any] | None: + attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)} + for message in reversed(conversation.get("messages") or []): + if not isinstance(message, dict): + continue + for part in reversed(message.get("parts") or []): + if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": + continue + data = part.get("data") + if not isinstance(data, dict): + continue + analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)} + if attachment_ids.issubset(analyzed_ids) and str(data.get("observationStage") or "complete") == stage: return data return None @@ -568,32 +865,46 @@ def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | N def image_reference_instruction( attachments: list[dict[str, Any]], analysis: dict[str, Any] | None, + stage: str = "complete", ) -> str: if not attachments: return "" - if analysis is None: + if stage == "survey": return """ Image-reference intake gate (highest priority for this turn): - The user has uploaded image references that have not yet been analyzed. - Your only tool call in this turn must be analyze_image_reference. - Do not call describe_design_intent, search_cdsl_library, read_cdsl_reference, read_current_cdsl, or generate_cdsl_model in this turn. -- Describe only what is visible. Do not invent dimensions from pixels or perspective. +- Build a complete structured multi-view survey: views, surfaces, bends, outer profiles, + holes, irregular openings, scale references, measurements, CV hints, and uncertainties. +- Preserve uncertain profiles as polyline or unknown_curve evidence instead of dropping them. +- Describe only what is visible. Do not present image estimates as user-verified dimensions. - The backend will present the structured result and provide it back as visual-reference context for you to continue this same task. +""" + if stage == "sketch": + return """ +Image survey is recorded for the current attachments. Your only tool call in this turn +must be extract_image_sketch_candidates. Use the survey and all attached images to +produce outer-face and irregular-opening profiles. Prefer line, arc, and circle segments; +retain polyline or unknown_curve segments when the image does not justify a primitive. +Include source image ids, coordinate evidence, confidence, measurements, and unresolved +parameters. Do not call CAD planning or generation tools yet. """ return """ Image-reference analysis already recorded for the current attachments: {data} -Use this result as visual-reference context and do not analyze the image again. -The listed dimensions are candidates for confirmation, not a mandatory gate. +Use this complete survey and the sketch candidates as visual-reference context. +Do not silently discard visible profiles or openings. User-provided dimensions override +image, CV, or assumption estimates; preserve uncertain values as assumptions. Interpret the full conversation to decide whether to ask a concise question, make clearly stated approximate assumptions, or continue the ordinary CDSL workflow. When the user permits or requests estimates, choose coherent values yourself and record them as assumptions instead of asking again. Respect the user's tolerance for estimates. Never present an inferred dimension as an exact measurement from the image. -""".format(data=json.dumps(analysis, ensure_ascii=False, separators=(",", ":"))) +""".format(data=render_image_observation_context(analysis)) def normalize_image_analysis(arguments: dict[str, Any]) -> dict[str, Any]: @@ -638,6 +949,40 @@ def normalize_image_analysis(arguments: dict[str, Any]) -> dict[str, Any]: } +def image_observation_payload(observation: dict[str, Any], *, stage: str, artifact_path: str = "") -> dict[str, Any]: + """Map the persisted snake_case observation to the existing UI data part.""" + dimensions = [ + { + "id": str(item.get("name") or f"measurement_{index}"), + "label": str(item.get("name") or "尺寸"), + "reason": str(item.get("evidence") or "图片或模型估算"), + } + for index, item in enumerate(observation.get("measurements") or []) + if isinstance(item, dict) + ] + return { + "observationStage": stage, + "schemaVersion": observation.get("schema_version", "cad.image-observation.v2"), + "attachmentIds": observation.get("attachment_ids") or [], + "partType": observation.get("part_type") or "", + "visibleFeatures": observation.get("visible_features") or [], + "uncertainFeatures": observation.get("uncertain_features") or [], + "dimensionCandidates": dimensions, + "views": observation.get("views") or [], + "scaleReferences": observation.get("scale_references") or [], + "overallGeometry": observation.get("overall_geometry") or {}, + "surfaces": observation.get("surfaces") or [], + "profiles": observation.get("profiles") or [], + "holes": observation.get("holes") or [], + "bends": observation.get("bends") or [], + "measurements": observation.get("measurements") or [], + "uncertainties": observation.get("uncertainties") or [], + "assumptions": observation.get("assumptions") or [], + "cvHints": observation.get("cv_hints") or [], + "artifactPath": artifact_path or None, + } + + def _viewer_selection_text(value: Any, limit: int = 240) -> str: return str(value or "").strip()[:limit] @@ -669,6 +1014,7 @@ def _viewer_selection_entity(value: Any) -> dict[str, Any] | None: return { "referenceId": reference_id, "selector": _viewer_selection_text(value.get("selector")), + "snapshotId": _viewer_selection_text(value.get("snapshotId"), 160), "label": _viewer_selection_text(value.get("label")), "selectorType": _viewer_selection_text(value.get("selectorType"), 80), "surfaceType": _viewer_selection_text(value.get("surfaceType"), 80), @@ -720,6 +1066,281 @@ Use this data to answer questions about the selected geometry. In particular, us """.format(data=json.dumps(selections, ensure_ascii=False, separators=(",", ":"))) +def _selector_values(value: Any) -> list[dict[str, Any]]: + found: list[dict[str, Any]] = [] + if isinstance(value, dict): + if value.get("kind") and value.get("stable_id") and value.get("source"): + found.append(value) + for child in value.values(): + found.extend(_selector_values(child)) + elif isinstance(value, list): + for child in value: + found.extend(_selector_values(child)) + return found + + +def _numeric_range(value: Any, field: str) -> tuple[float, float] | None: + if value is None: + return None + if not isinstance(value, list) or len(value) != 2: + raise ValueError(f"{field} must contain exactly two numbers") + try: + left, right = float(value[0]), float(value[1]) + except (TypeError, ValueError) as error: + raise ValueError(f"{field} must contain numbers") from error + if left > right: + raise ValueError(f"{field} minimum must not exceed maximum") + return left, right + + +def _validate_snapshot_selectors(store: Any, task_id: str, cdsl: dict[str, Any]) -> None: + """Validate runtime/viewer selectors against their own task revision snapshot. + + A completed feature keeps the selector provenance from the revision in which + it was created. Requiring every selector in a later CDSL revision to point + at the newest snapshot incorrectly rejects those immutable historical + selectors before a new feature can be appended. + """ + if not task_id: + return + task = store.read_task(task_id) or {} + current_revision = str(task.get("current_revision") or "") + if not current_revision: + return + topology_path = store.current_topology_path(task_id) + current_snapshot = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None + current_snapshot_id = str((current_snapshot or {}).get("snapshot_id") or f"{task_id}/{current_revision}") + snapshots: dict[str, dict[str, Any]] = {current_snapshot_id: current_snapshot or {}} + + def snapshot_for_selector(snapshot_id: str) -> dict[str, Any] | None: + if snapshot_id in snapshots: + return snapshots[snapshot_id] + prefix = f"{task_id}/" + if not snapshot_id.startswith(prefix): + return None + revision_id = snapshot_id[len(prefix):] + if not revision_id: + return None + revision = next( + (item for item in task.get("revisions") or () + if isinstance(item, dict) and str(item.get("revision_id") or "") == revision_id), + None, + ) + if not isinstance(revision, dict) or revision.get("status") != "success": + return None + historical_path = store.revision_topology_path(task_id, revision_id) + if historical_path is None or not historical_path.is_file(): + return None + try: + historical = json.loads(historical_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + snapshots[snapshot_id] = historical + return historical + + for selector in _selector_values(cdsl): + source = str(selector.get("source") or "") + if source not in {"runtime_snapshot", "viewer_selection"}: + continue + snapshot_id = str(selector.get("snapshot_id") or "") + snapshot = snapshot_for_selector(snapshot_id) + if snapshot is None: + raise ValueError("TOPOLOGY_SNAPSHOT_STALE: selector does not belong to a known task revision") + records = {str(item.get("record_id")): item for item in snapshot.get("records") or () if isinstance(item, dict)} + record = records.get(str(selector.get("stable_id") or "")) + if not record or record.get("executable") is False: + raise ValueError("SELECTOR_NOT_FOUND: selector record is not present in the referenced topology snapshot") + if str(selector.get("kind")) != str(record.get("kind")): + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector kind differs from topology record") + owner = selector.get("owner_feature_id") + owners = {str(item) for item in record.get("owner_feature_ids") or ()} + if owner and str(owner) not in owners: + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector owner differs from topology record") + expected = selector.get("geometry") if isinstance(selector.get("geometry"), dict) else {} + actual = record.get("geometry") if isinstance(record.get("geometry"), dict) else {} + for key in ("curve_type", "surface_type"): + if key in expected and expected.get(key) != actual.get(key): + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record") + for key in ("center_mm", "normal", "plane_normal", "start_mm", "end_mm"): + if key not in expected: + continue + left, right = expected.get(key), actual.get(key) + if not isinstance(left, (list, tuple)) or not isinstance(right, (list, tuple)) or len(left) != len(right): + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record") + try: + mismatch = any(abs(float(a) - float(b)) > 1e-5 for a, b in zip(left, right)) + except (TypeError, ValueError) as error: + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} is not numeric") from error + if mismatch: + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record") + if "bbox_mm" in expected: + left, right = expected.get("bbox_mm"), actual.get("bbox_mm") + if not isinstance(left, (list, tuple)) or not isinstance(right, (list, tuple)) or len(left) != len(right): + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm differs from topology record") + try: + mismatch = any(abs(float(a) - float(b)) > 1e-5 for a, b in zip(left, right)) + except (TypeError, ValueError) as error: + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm is not numeric") from error + if mismatch: + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm differs from topology record") + for key in ("length_mm", "area_mm2"): + if key in expected and key in actual: + try: + expected_value = float(expected[key]) + actual_value = float(actual[key]) + except (TypeError, ValueError) as error: + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} is not numeric") from error + if abs(expected_value - actual_value) > max(1e-5, abs(actual_value) * 1e-5): + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record") + + +def _topology_query_result(store: Any, task_id: str, arguments: dict[str, Any]) -> dict[str, Any]: + if not task_id: + return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "No current CAD task exists."} + task = store.read_task(task_id) or {} + revision_id = str(task.get("current_revision") or "") + path = store.current_topology_path(task_id) + if not revision_id or path is None or not path.is_file(): + return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "The current task has no successful topology snapshot."} + snapshot = json.loads(path.read_text(encoding="utf-8")) + records = [record for record in snapshot.get("records") or () if isinstance(record, dict) and record.get("executable", True) is not False] + kind = str(arguments.get("kind") or "") + if kind: + records = [record for record in records if record.get("kind") == kind] + for key in ("feature_id", "owner_feature_id"): + value = str(arguments.get(key) or "") + if value: + records = [record for record in records if record.get("feature_id") == value or value in (record.get("owner_feature_ids") or [])] + for key in ("surface_type", "curve_type"): + value = str(arguments.get(key) or "") + if value: + records = [record for record in records if (record.get("geometry") or {}).get(key) == value] + position_hint = str(arguments.get("position_hint") or arguments.get("position") or "").strip().casefold() + if position_hint: + position_axis = {"top": 2, "upper": 2, "highest": 2, "bottom": 2, "lower": 2, "lowest": 2, + "left": 0, "right": 0, "front": 1, "back": 1}.get(position_hint) + position_values: list[float] = [] + if position_axis is not None: + for candidate in records: + candidate_center = (candidate.get("geometry") or {}).get("center_mm") + if isinstance(candidate_center, (list, tuple)) and len(candidate_center) > position_axis: + try: + position_values.append(float(candidate_center[position_axis])) + except (TypeError, ValueError): + pass + target = None + if position_values and position_axis is not None: + target = min(position_values) if position_hint in {"bottom", "lower", "lowest", "left", "front"} else max(position_values) + def matches_position(record: dict[str, Any]) -> bool: + geometry = record.get("geometry") or {} + center = geometry.get("center_mm") + bbox = geometry.get("bbox_mm") + if not isinstance(center, (list, tuple)) or len(center) < 3: + return False + try: + x, y, z = (float(center[index]) for index in range(3)) + except (TypeError, ValueError): + return False + if position_hint in {"top", "upper", "highest", "bottom", "lower", "lowest"}: + return target is not None and abs(z - target) <= 1e-5 + if position_hint in {"left", "right", "front", "back"}: + axis = {"left": 0, "right": 0, "front": 1, "back": 1}[position_hint] + sign = {"left": -1, "right": 1, "front": 1, "back": -1}[position_hint] + value = x if axis == 0 else y + return target is not None and abs(value - target) <= 1e-5 + return str(geometry.get("position_hint") or "").casefold() == position_hint + records = [record for record in records if matches_position(record)] + bbox_filter = arguments.get("bbox_mm") + if bbox_filter is not None: + if isinstance(bbox_filter, dict) and isinstance(bbox_filter.get("min"), list) and isinstance(bbox_filter.get("max"), list): + bbox_filter = [*bbox_filter["min"], *bbox_filter["max"]] + if not isinstance(bbox_filter, list) or len(bbox_filter) != 6: + raise ValueError("bbox_mm must contain exactly six numbers") + try: + query_bbox = tuple(float(value) for value in bbox_filter) + except (TypeError, ValueError) as error: + raise ValueError("bbox_mm must contain numbers") from error + if query_bbox[0] > query_bbox[3] or query_bbox[1] > query_bbox[4] or query_bbox[2] > query_bbox[5]: + raise ValueError("bbox_mm minimums must not exceed maximums") + def intersects(record: dict[str, Any]) -> bool: + actual = (record.get("geometry") or {}).get("bbox_mm") + if not isinstance(actual, (list, tuple)) or len(actual) != 6: + return False + try: + values = tuple(float(value) for value in actual) + except (TypeError, ValueError): + return False + return all(values[index] <= query_bbox[index + 3] and query_bbox[index] <= values[index + 3] for index in range(3)) + records = [record for record in records if intersects(record)] + length_range = _numeric_range(arguments.get("length_range_mm"), "length_range_mm") + area_range = _numeric_range(arguments.get("area_range_mm2"), "area_range_mm2") + if length_range: + records = [record for record in records if length_range[0] <= float((record.get("geometry") or {}).get("length_mm", -1)) <= length_range[1]] + if area_range: + records = [record for record in records if area_range[0] <= float((record.get("geometry") or {}).get("area_mm2", -1)) <= area_range[1]] + offset = max(0, int(arguments.get("cursor") or 0)) + limit = min(50, max(1, int(arguments.get("limit") or 20))) + selected = records[offset:offset + limit] + output_records = [] + for record in selected: + selector = { + "kind": record["kind"], + "stable_id": record["record_id"], + "source": "runtime_snapshot", + "confidence": 1.0, + "snapshot_id": snapshot.get("snapshot_id") or f"{task_id}/{revision_id}", + "geometry": record.get("geometry") or {}, + } + owners = record.get("owner_feature_ids") or [] + if owners: + selector["owner_feature_id"] = owners[0] + output_records.append({**record, "selector": selector}) + return { + "ok": True, + "task_id": task_id, + "revision_id": revision_id, + "snapshot_id": snapshot.get("snapshot_id") or f"{task_id}/{revision_id}", + "records": output_records, + "next_cursor": offset + len(output_records) if offset + len(output_records) < len(records) else None, + } + + +def _validate_plan_cdsl_transition(store: Any, task_id: str, plan: dict[str, Any] | None, cdsl: dict[str, Any]) -> None: + if not isinstance(plan, dict): + return + current_path = store.current_cdsl_path(task_id) if task_id else None + current = json.loads(current_path.read_text(encoding="utf-8")) if current_path and current_path.is_file() else {} + current_features = {str(item.get("id")): item for item in current.get("features") or () if isinstance(item, dict)} + next_features = {str(item.get("id")): item for item in cdsl.get("features") or () if isinstance(item, dict)} + completed_ids = { + str(feature_id) + for node in plan.get("nodes") or () + if isinstance(node, dict) and node.get("status") in {"completed", "executed"} + for feature_id in node.get("cdsl_feature_ids") or () + } + for feature_id in completed_ids: + if feature_id not in next_features or next_features[feature_id] != current_features.get(feature_id): + raise ValueError(f"COMPLETED_FEATURE_MUTATION: completed feature {feature_id} cannot be removed or rewritten") + allowed_ids = { + str(feature_id) + for node in plan.get("nodes") or () + if isinstance(node, dict) and node.get("status") in {"ready", "executing"} + for feature_id in node.get("cdsl_feature_ids") or () + } + for feature_id in next_features: + if feature_id not in current_features and feature_id not in allowed_ids: + ready_nodes = [ + str(node.get("id")) + for node in plan.get("nodes") or () + if isinstance(node, dict) and node.get("status") == "ready" + ] + allowed = sorted(allowed_ids) + raise ValueError( + f"FEATURE_NOT_READY: feature {feature_id} is not in the current ready plan batch; " + f"allowed_feature_ids={allowed}; ready_nodes={ready_nodes}" + ) + + def system_prompt( settings: Settings, user_text: str, @@ -735,16 +1356,23 @@ def system_prompt( profile_schema_path = settings.engine_root / "profile_schema.json" profile_schema = profile_schema_path.read_text(encoding="utf-8") if profile_schema_path.is_file() else "" generation_contract = """- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly, not as Markdown or a JSON string. +- For a multi-stage CAD request, call plan_feature_tree after describe_design_intent. The plan is a DAG of semantic features; never put invented edge/face IDs in it. +- Generate only the current ready feature batch. The server automatically binds a successful build's topology snapshot; call inspect_current_topology to retrieve exact selector candidates before adding topology-dependent features. +- Copy selectors only from inspect_current_topology or a trusted viewer selection. A screenshot is not an exact topology selector source, but its measured image survey and sketch candidates may guide profile geometry. +- Keep completed CDSL features unchanged and use patch_cdsl_model for later feature batches. +- If a selector or kernel failure blocks a node, use plan_feature_tree with `replan` to replace only that node and its downstream subtree. - For a revision, call read_current_cdsl before generate_cdsl_model. Preserve unrelated CDSL features unless the user requests whole-part replacement. - Use patch_cdsl_model only for a local RFC 6902 repair of a known base_revision_id. For structural changes, submit a complete replacement CDSL with generate_cdsl_model. - For every feature.atomic_id, use only an ID listed in the compact capability manifest's runtime_atomic_ids. Other semantic contracts are not executable in this runtime. - The backend normalizes only these unambiguous aliases: sketch_id -> id on sketches, sketch -> sketch_id on features, legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm. - Every explicit user dimension, feature count, hole size, or hole position that can be measured must have a generic verification rule. Rule feature values must be IDs from the submitted CDSL. +- Verification types include bbox, overall_length, overall_width, overall_height, overall_diameter, hole_count, hole_diameter, hole_center, through_condition, solid_count, and feature_count. Overall width and height measure the runtime Y and Z bbox dimensions. Feature-scoped rules (hole_count, hole_diameter, hole_center, through_condition) must include the exact CDSL feature ID. - Do not output build123d source, compiler_context, unknown_shape, complex_arc_shape, entities, contour_edges_mm, or contour_regions_mm.""" workflow = """3. Search the local official CDSL library after the design brief. Read a relevant reference when a match exists; samples are expression guidance, not templates or higher-priority requirements. -4. Generate one complete generic CDSL and call generate_cdsl_model. -5. On a schema, runtime, or verification failure, use patch_cdsl_model for a local correction or generate_cdsl_model for a complete replacement. -6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact.""" +4. For a multi-feature request, call plan_feature_tree and then generate only its ready batch. +5. After each successful build, inspect topology when the plan has waiting topology-dependent nodes, then patch the existing CDSL. +6. On a schema, runtime, selector, or verification failure, repair only the affected feature/subtree. +7. Never claim success unless the final plan is complete and the tool returns a successful CDSL-only STEP and GLB artifact.""" authoring_context = f"""You own the complete CDSL: profiles, workplanes, sketch IDs, feature IDs, dependencies, selectors, and atomic IDs must be valid under the engine schema. Do not invent unsupported capabilities. Local skill: @@ -842,6 +1470,92 @@ class AgentService: self.store = store self.library = library self.part_skill_library = part_skill_library or PartSkillLibrary(part_skill_root(settings)) + # The generation worker outlives an individual SSE response. The + # durable task lifecycle remains the cross-process source of truth; + # this map only owns live event delivery in the current process. + self._incremental_runs: dict[str, asyncio.Task[None]] = {} + + async def resume_running_tasks(self) -> None: + """Reattach process-local workers to persisted incremental runs. + + A browser disconnect is already independent from the worker. This + recovery path additionally prevents an application restart from + stranding a durable task in ``running``. The original frozen provider, + model, messages, and selected part skills are read from the task, not + from mutable conversation state. + """ + if not self.settings.incremental_generation: + return + for task in self.store.running_tasks(): + task_id = str(task.get("task_id") or "") + if not task_id or task_id in self._incremental_runs: + continue + context = self.store.read_generation_run_context(task_id) + if not isinstance(context, dict): + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "stage": "recovery", + "message": "The frozen generation context is unavailable after restart", + }) + continue + conversation_id = str(context.get("conversation_id") or "") + conversation = self.store.read_conversation(conversation_id) if conversation_id else None + author_messages = context.get("author_messages") + part_skills = context.get("part_skills") + if not isinstance(conversation, dict) or not isinstance(author_messages, list) or not isinstance(part_skills, dict): + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "stage": "recovery", + "message": "The frozen conversation context is invalid after restart", + }) + continue + try: + provider, model = self.settings.resolve_model( + str(context.get("provider_id") or ""), str(context.get("model_id") or ""), + ) + except ValueError as error: + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", "stage": "recovery", "message": str(error), + }) + continue + assistant_id = str(context.get("assistant_id") or f"assistant_{secrets.token_hex(8)}") + request = str(context.get("request") or task.get("request") or "") + runner = IncrementalGenerationRunner(self.settings, self.store, self._complete) + + async def consume( + *, task_id: str = task_id, request: str = request, conversation: dict[str, Any] = conversation, + provider: ProviderConfig = provider, model: ProviderModel = model, + author_messages: list[dict[str, Any]] = author_messages, part_skills: dict[str, Any] = part_skills, + assistant_id: str = assistant_id, runner: IncrementalGenerationRunner = runner, + ) -> None: + parts: list[dict[str, Any]] = [] + terminal = "" + try: + async for name, payload in runner.run( + task_id=task_id, request=request, conversation=conversation, provider=provider, model=model, + author_messages=author_messages, part_skills=part_skills, already_started=True, + ): + if name == "cad_result": + parts.append({"type": "data-cad-result", "data": payload}) + elif name == "task_terminal": + terminal = str(payload.get("lifecycle") or "") + if terminal == "failed": + parts.append({"type": "data-cad-error", "data": { + "stage": "generation", "message": str(payload.get("message") or "CAD 增量生成失败。"), + }}) + except Exception as error: + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", "stage": "recovery_worker", "message": str(error), + }) + terminal = "failed" + parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}}) + finally: + if not parts and terminal == "completed": + parts.append({"type": "text", "text": "CAD 模型已完成。"}) + self._persist_assistant(conversation["conversation_id"], assistant_id, parts, task_id) + self._incremental_runs.pop(task_id, None) + + self._incremental_runs[task_id] = asyncio.create_task(consume(), name=f"resume-incremental-cdsl-{task_id}") async def stream( self, @@ -872,6 +1586,13 @@ class AgentService: self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, "") yield event("done", {}) return + if task_id and current_task and str(current_task.get("lifecycle") or "") == "running": + # Do not append the attempted turn: the run's request and + # attachments are immutable until a terminal lifecycle state. + yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"}) + yield event("done", {}) + return + self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id or None) try: @@ -904,15 +1625,109 @@ class AgentService: yield event("done", {}) return + # The persistent node-by-node orchestrator is opt-in while existing + # installations migrate their review-model and Chromium configuration. + # Once enabled, every new request is frozen and legacy authoring tools + # are not exposed for that run. + if self.settings.incremental_generation: + if not task_id: + created = self.store.ensure_task(None, user_text) + task_id = str(created["task_id"]) + current_task = created + self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id) + author_messages: list[dict[str, Any]] = [{ + "role": "system", + "content": ( + "You are an incremental CDSL CAD author. The user request is frozen for this run. " + "Missing dimensions must become explicit assumptions. Use only the requested function tool; do not emit prose or raw CAD source." + ), + }] + author_messages.extend(messages_for_model(messages)) + if attachment_message: + author_messages.append({"role": "user", "content": attachment_message}) + runner = IncrementalGenerationRunner(self.settings, self.store, self._complete) + part_skills = self.part_skill_library.audit(self.part_skill_library.select(user_text), {}, []) + # Acquire the durable run lock before scheduling work so a second + # request cannot slip in during the first model call. + self.store.start_generation(task_id, request=user_text) + self.store.write_generation_run_context(task_id, { + "schema_version": "cad.generation-run-context.v1", + "request": user_text, + "conversation_id": conversation["conversation_id"], + "provider_id": provider.id, + "model_id": model.id, + "assistant_id": assistant_id, + "author_messages": author_messages, + "part_skills": part_skills, + }) + queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue() + + async def consume_incremental_run() -> None: + run_parts: list[dict[str, Any]] = [] + terminal = "" + try: + async for name, event_payload in runner.run( + task_id=task_id, + request=user_text, + conversation=conversation, + provider=provider, + model=model, + author_messages=author_messages, + part_skills=part_skills, + already_started=True, + ): + if name == "cad_result": + run_parts.append({"type": "data-cad-result", "data": event_payload}) + elif name == "task_terminal": + terminal = str(event_payload.get("lifecycle") or "") + if terminal == "failed": + run_parts.append({ + "type": "data-cad-error", + "data": {"stage": "generation", "message": str(event_payload.get("message") or "CAD 增量生成失败。")}, + }) + await queue.put((name, event_payload)) + except Exception as error: # runner normally converts failures to a terminal event + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", "message": str(error), "stage": "worker", + }) + terminal = "failed" + payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)} + run_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}}) + await queue.put(("task_terminal", payload)) + finally: + if not run_parts and terminal == "completed": + run_parts.append({"type": "text", "text": "CAD 模型已完成。"}) + self._persist_assistant(conversation["conversation_id"], assistant_id, run_parts, task_id) + self._incremental_runs.pop(task_id, None) + await queue.put(None) + + worker = asyncio.create_task(consume_incremental_run(), name=f"incremental-cdsl-{task_id}") + self._incremental_runs[task_id] = worker + while True: + queued = await queue.get() + if queued is None: + break + name, event_payload = queued + if name == "task_terminal" and str(event_payload.get("lifecycle") or "") == "failed": + yield event("cad_error", {"stage": "generation", "message": str(event_payload.get("message") or "CAD 增量生成失败。")}) + yield event(name, event_payload) + yield event("done", {}) + return + image_inputs = image_attachments(conversation) + intake_stage = image_reference_stage(conversation) recorded_image_analysis = image_reference_analysis(conversation) - requires_image_intake = bool(image_inputs) and recorded_image_analysis is None + partial_observation = image_reference_observation_part(conversation, "survey") yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"}) references: list[str] = [] library_searches = 0 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) planning_state: dict[str, Any] = {"phase": "INTAKE", "design_brief": "", "current_model_read": False} + if task_id: + persisted_plan = self.store.read_feature_plan(task_id) + if isinstance(persisted_plan, dict): + planning_state["feature_plan"] = persisted_plan yield event("progress", { "step": "select_part_skill", "label": "选择建模 Skill", @@ -927,7 +1742,11 @@ class AgentService: viewer_context, task_id, self.part_skill_library.render_context(part_skill_selection), - image_reference_instruction(image_inputs, recorded_image_analysis), + image_reference_instruction( + image_inputs, + recorded_image_analysis or partial_observation, + intake_stage, + ), cad_request_instruction(task_id, current_task), ), }] @@ -936,19 +1755,31 @@ class AgentService: model_messages.append({"role": "user", "content": attachment_message}) tools = tools_for_model( model, - include_image_analysis=requires_image_intake, + include_image_analysis=intake_stage == "survey", + include_image_sketches=intake_stage == "sketch", + image_stage=intake_stage if intake_stage in {"survey", "sketch"} else None, + ) + required_tool_name: str | None = ( + "analyze_image_reference" if intake_stage == "survey" + else "extract_image_sketch_candidates" if intake_stage == "sketch" + else None ) - required_tool_name: str | None = "analyze_image_reference" if requires_image_intake else None generate_argument_failures = 0 tool_argument_diagnostics: list[str] = [] cdsl_validation_diagnostics: list[str] = [] generation_completed = False repair_attempts = 0 + repair_step_key: str | None = None + last_plan_status: dict[str, Any] | None = None try: - for iteration in range(8): + for iteration in range(MAX_AGENT_TOOL_ITERATIONS): + # Once the model has been told to repair a CDSL step, stop + # before requesting another completion when that same step has + # exhausted its consecutive repair budget. if ( planning_state.get("phase") == "CDSL_REPAIR" + and required_tool_name in GENERATION_TOOL_NAMES and repair_attempts >= self.settings.max_repair_attempts ): raise CdslRepairLimitError(cdsl_validation_diagnostics) @@ -975,7 +1806,7 @@ class AgentService: model_messages.append(choice) for call in tool_calls: name = str(call.get("function", {}).get("name") or "") - if requires_image_intake and name != "analyze_image_reference": + if intake_stage == "survey" and name != "analyze_image_reference": result = { "ok": False, "code": "IMAGE_ANALYSIS_REQUIRED", @@ -984,7 +1815,16 @@ class AgentService: model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)}) yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先完成图片参考分析。"}) continue - if name == "analyze_image_reference" and recorded_image_analysis is not None: + if intake_stage == "sketch" and name != "extract_image_sketch_candidates": + result = { + "ok": False, + "code": "IMAGE_SKETCH_REQUIRED", + "message": "Extract image sketch candidates before using planning or generation tools.", + } + model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)}) + yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先提取图片草图候选。"}) + continue + if name == "analyze_image_reference" and intake_stage != "survey": result = { "ok": False, "code": "IMAGE_ANALYSIS_ALREADY_RECORDED", @@ -1005,6 +1845,14 @@ class AgentService: "message": "图片识别结果已存在,正在继续后续建模流程。", }) continue + if name == "extract_image_sketch_candidates" and intake_stage != "sketch": + result = { + "ok": False, + "code": "IMAGE_SKETCH_ALREADY_RECORDED", + "message": "Image sketch candidates are already recorded for the current attachments.", + } + model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)}) + continue if name == "search_cdsl_library": library_searches += 1 if library_searches > 2: @@ -1067,6 +1915,10 @@ class AgentService: }) continue if name in GENERATION_TOOL_NAMES and planning_state.get("phase") == "CDSL_REPAIR": + current_step_key = get_repair_step_key(planning_state, name) + if repair_step_key != current_step_key: + repair_step_key = current_step_key + repair_attempts = 0 if repair_attempts >= self.settings.max_repair_attempts: raise CdslRepairLimitError(cdsl_validation_diagnostics) repair_attempts += 1 @@ -1157,36 +2009,90 @@ class AgentService: "message": "设计说明已记录,将作为 CDSL 生成参考。", }) if name == "analyze_image_reference" and result.get("ok"): - image_payload = { - "attachmentIds": result["attachment_ids"], - "partType": result["part_type"], - "visibleFeatures": result["visible_features"], - "uncertainFeatures": result["uncertain_features"], - "dimensionCandidates": result["dimension_candidates"], - "artifactPath": result.get("artifact_path"), - } + survey = result["observation"] + if result.get("legacy"): + artifact_path = result.get("artifact_path", "") + image_payload = image_observation_payload(survey, stage="complete", artifact_path=artifact_path) + assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) + yield event("image_analysis", image_payload) + recorded_image_analysis = image_payload + intake_stage = "complete" + required_tool_name = None + tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=False) + model_messages.append({"role": "system", "content": image_reference_instruction(image_inputs, survey, "complete")}) + yield event("progress", {"step": "analyze_image_reference", "label": "识别图片参考", "status": "success", "message": "已识别图片参考,正在继续 CAD 建模流程。"}) + continue + image_payload = image_observation_payload(survey, stage="survey", artifact_path=result.get("artifact_path", "")) assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) yield event("image_analysis", image_payload) - recorded_image_analysis = image_payload - requires_image_intake = False - required_tool_name = None - tools = tools_for_model( - model, - include_image_analysis=False, - ) + partial_observation = image_payload + intake_stage = "sketch" + required_tool_name = "extract_image_sketch_candidates" + tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=True, image_stage="sketch") model_messages.append({ "role": "system", - "content": image_reference_instruction(image_inputs, image_payload), + "content": image_reference_instruction(image_inputs, survey, "sketch"), }) yield event("progress", { "step": "analyze_image_reference", - "label": "识别图片参考", + "label": "整理图片勘测", "status": "success", - "message": "已识别可见结构,正在根据对话继续判断建模方案。", + "message": "已完成多视角图片勘测,正在提取草图候选。", + }) + continue + if name == "extract_image_sketch_candidates" and result.get("ok"): + survey = partial_observation or {} + if "observationStage" in survey: + survey = { + "schema_version": survey.get("schemaVersion", "cad.image-observation.v2"), + "attachment_ids": survey.get("attachmentIds") or [], + "part_type": survey.get("partType") or "", + "visible_features": survey.get("visibleFeatures") or [], + "uncertain_features": survey.get("uncertainFeatures") or [], + "views": survey.get("views") or [], + "scale_references": survey.get("scaleReferences") or [], + "overall_geometry": survey.get("overallGeometry") or {}, + "surfaces": survey.get("surfaces") or [], + "profiles": survey.get("profiles") or [], + "holes": survey.get("holes") or [], + "bends": survey.get("bends") or [], + "measurements": survey.get("measurements") or [], + "uncertainties": survey.get("uncertainties") or [], + "assumptions": survey.get("assumptions") or [], + "cv_hints": survey.get("cvHints") or [], + } + observation = merge_image_observations(survey, result["sketches"]) + artifact_path = self.store.write_conversation_planning( + conversation["conversation_id"], + "image-observation-v2", + observation, + ) + image_payload = image_observation_payload(observation, stage="complete", artifact_path=artifact_path) + assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) + yield event("image_analysis", image_payload) + recorded_image_analysis = image_payload + intake_stage = "complete" + required_tool_name = None + tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=False) + model_messages.append({ + "role": "system", + "content": image_reference_instruction(image_inputs, observation, "complete"), + }) + yield event("progress", { + "step": "extract_image_sketch_candidates", + "label": "提取图片草图", + "status": "success", + "message": "已提取图片轮廓和草图候选,正在继续 CAD 建模流程。", }) continue if name in GENERATION_TOOL_NAMES and result.get("ok"): required_tool_name = None + if result.get("ok", True): + # A successful tool call is forward progress. The next + # generation batch must receive a fresh consecutive-failure budget. + repair_attempts = 0 + repair_step_key = None + generate_argument_failures = 0 if generated: result_payload = { "taskId": generated["task_id"], @@ -1207,6 +2113,10 @@ class AgentService: "repairAttempts": generated.get("repair_attempts", 0), "snapshotPaths": generated.get("snapshot_paths", []), "snapshotStatus": generated.get("snapshot_status", "unavailable"), + "topologyPath": generated.get("topology_path"), + "planComplete": generated.get("plan_complete", True), + "planStatus": generated.get("plan_status"), + "requiredAction": generated.get("required_action", "complete"), } assistant_parts.append({"type": "data-cad-result", "data": result_payload}) yield event("cad_result", result_payload) @@ -1216,13 +2126,25 @@ class AgentService: "status": "success", "message": "已通过 cdsl_only runtime 构建 STEP 和 GLB。", }) - generation_completed = True - break + generation_completed = bool(generated.get("plan_complete", True)) + if isinstance(generated.get("plan_status"), dict): + last_plan_status = generated["plan_status"] + if generation_completed: + break if generation_completed: break - if iteration == 7: + if iteration == MAX_AGENT_TOOL_ITERATIONS - 1: validation_diagnostics = cdsl_validation_diagnostics - if validation_diagnostics: + waiting_nodes = [ + str(item) for item in (last_plan_status or {}).get("waiting_nodes") or [] + ] + if waiting_nodes: + message = ( + "CAD 基础模型已生成,但特征计划尚未完成。等待拓扑选择的特征:" + + "、".join(waiting_nodes) + + "。请先读取当前拓扑,再继续生成这些特征。" + ) + elif validation_diagnostics: diagnostic_paths = "、".join(validation_diagnostics) if any("\u4e00" <= char <= "\u9fff" for char in user_text): message = ( @@ -1329,6 +2251,14 @@ class AgentService: candidate = json.loads(candidate) except json.JSONDecodeError: pass + # Patch calls do not contain a complete CDSL. Preserve their payload + # so an out-of-range path can be diagnosed against the exact request. + if candidate is None and "patches" in arguments: + candidate = { + "kind": "cdsl_patch_attempt", + "base_revision_id": str(arguments.get("base_revision_id") or ""), + "patches": deepcopy(arguments.get("patches") or []), + } return self.store.write_cdsl_attempt(conversation_id, candidate, iteration) def _record_cdsl_validation_diagnostic( @@ -1365,6 +2295,8 @@ class AgentService: "tool_call_id": call.get("id"), "tool_name": function.get("name"), "cdsl_attempt_path": cdsl_attempt_path, + "base_revision_id": arguments.get("base_revision_id"), + "patches": deepcopy(arguments.get("patches")) if "patches" in arguments else None, "summary": str(arguments.get("summary") or ""), "assumptions": arguments.get("assumptions") or [], "verification": arguments.get("verification"), @@ -1436,15 +2368,33 @@ class AgentService: if name == "analyze_image_reference": if not image_attachment_ids: raise ValueError("analyze_image_reference requires at least one image attachment") - analysis = { - "ok": True, - "attachment_ids": list(dict.fromkeys(image_attachment_ids)), - **normalize_image_analysis(arguments), - } + legacy_arguments = "views" not in arguments and "profiles" not in arguments + if not legacy_arguments: + observation = normalize_image_observation(arguments, attachment_ids=image_attachment_ids) + else: + legacy = normalize_image_analysis(arguments) + observation = normalize_image_observation({ + "attachment_ids": image_attachment_ids, + "part_type": legacy["part_type"], + "visible_features": legacy["visible_features"], + "uncertain_features": legacy["uncertain_features"], + "measurements": [ + {"name": item["label"], "source": "image", "evidence": item["reason"]} + for item in legacy["dimension_candidates"] + ], + "views": [{"attachment_id": attachment_id} for attachment_id in image_attachment_ids], + }, attachment_ids=image_attachment_ids) + analysis = {"ok": True, "legacy": legacy_arguments, "attachment_ids": list(dict.fromkeys(image_attachment_ids)), "observation": observation, **observation} if conversation_id: - analysis["artifact_path"] = self.store.write_conversation_planning(conversation_id, "reference-observation", {"schema_version": "1.0", **analysis}) + analysis["artifact_path"] = self.store.write_conversation_planning(conversation_id, "image-survey", analysis["observation"]) state["phase"] = "REFERENCE_ANALYZED" return analysis, None + if name == "extract_image_sketch_candidates": + if not image_attachment_ids: + raise ValueError("extract_image_sketch_candidates requires at least one image attachment") + sketches = normalize_sketch_candidates(arguments, attachment_ids=image_attachment_ids) + state["phase"] = "REFERENCE_SKETCHED" + return {"ok": True, "sketches": sketches, **sketches}, None if name == "describe_design_intent": plan = str(arguments.get("plan") or "").strip() assumptions = arguments.get("assumptions") @@ -1458,6 +2408,93 @@ class AgentService: "assumptions": [item.strip() for item in assumptions if item.strip()], "message": "The design brief is recorded as reference only. CDSL remains the sole authoritative CAD model.", }, None + if name == "plan_feature_tree": + if phase not in {"PLANNED", "TOPOLOGY_READY", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR", "PLAN_READY"}: + return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before planning the feature tree."}, None + try: + engine = load_engine(self.settings) + plan = validate_feature_plan({ + "schema_version": "cad.feature-plan.v1", + "plan_id": arguments.get("plan_id"), + "task_id": task_id, + "nodes": arguments.get("nodes"), + }, supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ())) + replan = arguments.get("replan") + if replan is not None: + if not isinstance(replan, dict): + raise FeaturePlanError("replan must be an object") + replace_nodes = replan.get("replace_nodes") + reason = str(replan.get("reason") or "").strip() + alternatives = replan.get("alternatives") + if not isinstance(replace_nodes, list) or not replace_nodes or not all(str(item).strip() for item in replace_nodes): + raise FeaturePlanError("replan.replace_nodes must be a non-empty string array") + if not reason or not isinstance(alternatives, list) or not all(isinstance(item, str) for item in alternatives): + raise FeaturePlanError("replan requires reason and alternatives") + prior = state.get("feature_plan") or (self.store.read_feature_plan(task_id) if task_id else None) + if isinstance(prior, dict): + prior_nodes = {str(item.get("id")): item for item in prior.get("nodes") or () if isinstance(item, dict)} + next_nodes = {str(item.get("id")): item for item in plan.get("nodes") or () if isinstance(item, dict)} + replace_set = {str(item) for item in replace_nodes} + if not replace_set.issubset(prior_nodes): + raise FeaturePlanError("replan.replace_nodes must identify existing plan nodes") + completed_replacements = { + node_id for node_id in replace_set + if prior_nodes[node_id].get("status") in {"completed", "executed"} + } + if completed_replacements: + raise FeaturePlanError( + "Replan cannot replace completed nodes: " + + ", ".join(sorted(completed_replacements)) + ) + missing_unchanged = set(prior_nodes) - replace_set - set(next_nodes) + if missing_unchanged: + raise FeaturePlanError( + "Replan cannot remove nodes outside replace_nodes: " + + ", ".join(sorted(missing_unchanged)) + ) + for node_id in prior_nodes.keys() & next_nodes.keys(): + if node_id not in replace_set: + old = {key: value for key, value in prior_nodes[node_id].items() if key not in {"status", "failure"}} + new = {key: value for key, value in next_nodes[node_id].items() if key not in {"status", "failure"}} + if old != new: + raise FeaturePlanError(f"Replan may only change replace_nodes; unchanged node mutated: {node_id}") + plan["replan"] = { + "replace_nodes": [str(item) for item in replace_nodes], + "reason": reason, + "alternatives": [item.strip() for item in alternatives if item.strip()], + } + except (FeaturePlanError, ValueError) as error: + state["phase"] = "BLOCKED" + message = str(error) + code = "FEATURE_PLAN_CYCLE" if "cycle" in message.casefold() else "FEATURE_PLAN_INVALID" + return {"ok": False, "code": code, "message": message}, None + state["feature_plan"] = plan + state["phase"] = "PLAN_READY" + current_cdsl = None + topology = None + if task_id: + cdsl_path = self.store.current_cdsl_path(task_id) + if cdsl_path and cdsl_path.is_file(): + current_cdsl = json.loads(cdsl_path.read_text(encoding="utf-8")) + topology_path = self.store.current_topology_path(task_id) + if topology_path and topology_path.is_file(): + topology = json.loads(topology_path.read_text(encoding="utf-8")) + self.store.write_feature_plan(task_id, plan) + status = compute_node_statuses(plan, cdsl=current_cdsl, topology=topology) + plan.update({key: status[key] for key in ("nodes", "ready_nodes", "waiting_nodes", "blocked_nodes", "completed_nodes", "complete")}) + state["feature_plan"] = plan + if task_id: + self.store.write_feature_plan(task_id, plan) + return { + "ok": True, + "plan_id": plan["plan_id"], + "ready_nodes": plan["ready_nodes"], + "waiting_nodes": plan["waiting_nodes"], + "blocked_nodes": plan["blocked_nodes"], + "completed_nodes": plan["completed_nodes"], + "required_action": "generate_cdsl_model" if plan["ready_nodes"] else "inspect_current_topology" if plan["waiting_nodes"] else "none", + "message": "Feature plan validated. Generate only ready nodes; never invent topology selectors.", + }, None if name == "search_cdsl_library": if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before searching CDSL references."}, None @@ -1509,8 +2546,22 @@ class AgentService: state["read_revision_id"] = revision_id state["phase"] = "PLANNED" return payload, None + if name == "inspect_current_topology": + if phase not in {"PLAN_READY", "TOPOLOGY_READY", "CDSL_AUTHORING", "COMPLETED", "CDSL_REPAIR", "PLANNED"}: + return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "Build a successful CDSL revision before inspecting topology."}, None + result = _topology_query_result(self.store, task_id, arguments) + if result.get("ok") and result.get("records") and isinstance(state.get("feature_plan"), dict): + plan = deepcopy(state["feature_plan"]) + plan["topology_snapshot_id"] = str(result.get("snapshot_id") or "") + cdsl_path = self.store.current_cdsl_path(task_id) + topology_path = self.store.current_topology_path(task_id) + cdsl = json.loads(cdsl_path.read_text(encoding="utf-8")) if cdsl_path and cdsl_path.is_file() else None + topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None + state["feature_plan"] = compute_node_statuses(plan, cdsl=cdsl, topology=topology) + self.store.write_feature_plan(task_id, state["feature_plan"]) + return result, None if name in GENERATION_TOOL_NAMES: - if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}: + if phase not in {"PLANNED", "PLAN_READY", "TOPOLOGY_READY", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR", "COMPLETED"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before generating CAD."}, None selection = part_skill_selection or self.part_skill_library.select(request) if selection.get("conflict"): @@ -1533,20 +2584,39 @@ class AgentService: if task_id and not parent_revision_id: parent_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "") operation: dict[str, Any] = {"type": "cdsl_replacement" if parent_revision_id else "cdsl_create"} + if phase == "CDSL_REPAIR": + operation["type"] = "cdsl_repair" else: if not task_id: return {"ok": False, "code": "PATCH_TASK_REQUIRED", "message": "patch_cdsl_model requires an existing task."}, None base_revision_id = str(arguments.get("base_revision_id") or "") + current_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "") + if not current_revision_id or base_revision_id != current_revision_id: + raise ValueError("TOPOLOGY_SNAPSHOT_STALE: base_revision_id must be the current successful revision") base_path = self.store.revision_cdsl_path(task_id, base_revision_id) if base_path is None: raise ValueError("PATCH_BASE_REVISION_NOT_FOUND: base_revision_id does not identify a readable CDSL revision") try: - cdsl = apply_cdsl_patch(json.loads(base_path.read_text(encoding="utf-8")), arguments.get("patches")) + base_cdsl = json.loads(base_path.read_text(encoding="utf-8")) + cdsl = apply_cdsl_patch(base_cdsl, arguments.get("patches")) except (CdslPatchError, json.JSONDecodeError) as error: raise ValueError(f"INVALID_CDSL_PATCH: {error}") from error parent_revision_id = base_revision_id operation = {"type": "cdsl_patch", "base_revision_id": base_revision_id, "patches": deepcopy(arguments.get("patches") or [])} + base_selectors = {(item.get("source"), item.get("stable_id"), item.get("snapshot_id")) for item in _selector_values(base_cdsl)} + next_selectors = {(item.get("source"), item.get("stable_id"), item.get("snapshot_id")) for item in _selector_values(cdsl)} + if any(source in {"runtime_snapshot", "viewer_selection"} for source, _stable_id, _snapshot_id in next_selectors - base_selectors): + operation["type"] = "cdsl_selector_patch" + if state.get("feature_plan"): + operation["type"] = "cdsl_plan_batch" if operation.get("type") in {"cdsl_create", "cdsl_replacement"} else operation.get("type") + operation["plan_id"] = str(state["feature_plan"].get("plan_id") or "") + operation["plan_nodes"] = [ + str(node.get("id")) for node in state["feature_plan"].get("nodes") or () + if isinstance(node, dict) and node.get("status") in {"ready", "executing"} + ] cdsl, normalization_repairs = normalize_cdsl_for_engine(cdsl) + _validate_plan_cdsl_transition(self.store, task_id, state.get("feature_plan"), cdsl) + _validate_snapshot_selectors(self.store, task_id, cdsl) # 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")} @@ -1580,11 +2650,30 @@ class AgentService: input_attachments=input_attachments, verification=verification, reference_records=state.get("reference_records"), + feature_plan=state.get("feature_plan"), ) except Exception: state["phase"] = "CDSL_REPAIR" raise - state["phase"] = "COMPLETED" + plan_status: dict[str, Any] | None = None + if state.get("feature_plan"): + topology_path = self.store.current_topology_path(yieldable["task_id"]) + topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None + # A successful build already produced the authoritative topology + # snapshot. Bind it here so advancing a plan never depends on the + # model remembering a bookkeeping-only topology inspection call. + plan = deepcopy(state["feature_plan"]) + snapshot_id = str((topology or {}).get("snapshot_id") or "") + if snapshot_id: + plan["topology_snapshot_id"] = snapshot_id + plan_status = compute_node_statuses(plan, cdsl=cdsl, topology=topology) + state["feature_plan"] = plan_status + self.store.write_feature_plan(yieldable["task_id"], plan_status) + state["phase"] = "COMPLETED" if plan_status["complete"] else "TOPOLOGY_READY" + else: + state["phase"] = "COMPLETED" + yieldable["plan_complete"] = bool(plan_status["complete"]) if plan_status else True + yieldable["plan_status"] = plan_status return { "ok": True, "summary": summary, @@ -1592,6 +2681,16 @@ class AgentService: "revision_id": yieldable["revision_id"], "normalization_repairs": normalization_repairs, "operation": operation, + "plan_complete": bool(plan_status["complete"]) if plan_status else True, + "plan_status": { + key: plan_status[key] + for key in ("ready_nodes", "waiting_nodes", "blocked_nodes", "completed_nodes", "complete") + } if plan_status else None, + "required_action": ( + "inspect_current_topology" if plan_status and plan_status["waiting_nodes"] + else "patch_cdsl_model" if plan_status and plan_status["ready_nodes"] + else "complete" + ) if plan_status else "complete", }, yieldable raise ValueError(f"Unknown agent tool: {name}") @@ -1602,10 +2701,14 @@ class AgentService: @staticmethod def _tool_label(name: str) -> str: return { + "analyze_image_reference": "整理图片勘测", + "extract_image_sketch_candidates": "提取图片草图", "search_cdsl_library": "检索 CDSL 模型库", "read_cdsl_reference": "读取 CDSL 参考模型", "read_current_cdsl": "读取当前 CDSL", "describe_design_intent": "整理设计说明", + "plan_feature_tree": "规划特征树", + "inspect_current_topology": "查询当前拓扑", "generate_cdsl_model": "生成 CDSL", "patch_cdsl_model": "修复 CDSL", }.get(name, "调用 CAD 工具") @@ -1617,7 +2720,7 @@ class AgentService: conversation_id = str(conversation.get("conversation_id") or "") if not conversation_id: raise ValueError("Conversation attachment has no conversation id") - content: list[dict[str, Any]] = [{"type": "text", "text": "The following local attachments are part of the CAD request."}] + content: list[dict[str, Any]] = [{"type": "text", "text": "The following local attachments are part of the CAD request. Image ids are stable references for the visual survey."}] for attachment in attachments: if not isinstance(attachment, dict): continue @@ -1633,6 +2736,25 @@ class AgentService: if not model.vision: raise ValueError("The selected model does not support images. Choose a vision-capable model enabled in backend/.env.") mime = str(attachment.get("mime") or "image/png") + metadata = { + "width": attachment.get("width"), + "height": attachment.get("height"), + "orientation": attachment.get("orientation"), + } + try: + hints = cv_hints(path.read_bytes()) + except OSError: + hints = {"available": False, "hints": []} + content.append({ + "type": "text", + "text": ( + f"IMAGE_ID: {attachment.get('id')}\n" + f"FILE_NAME: {attachment.get('name')}\n" + f"MIME: {mime}\n" + f"METADATA: {json.dumps(metadata, ensure_ascii=False, separators=(',', ':'))}\n" + f"CV_HINTS: {json.dumps(hints, ensure_ascii=False, separators=(',', ':'))}" + ), + }) encoded = base64.b64encode(path.read_bytes()).decode("ascii") content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}}) elif kind == "document": diff --git a/backend/app/services/attachments.py b/backend/app/services/attachments.py index 65ced3ff..354798f4 100644 --- a/backend/app/services/attachments.py +++ b/backend/app/services/attachments.py @@ -42,7 +42,16 @@ def extract_document_text(data: bytes) -> str: return text[:MAX_EXTRACTED_CHARS] -def attachment_record(conversation_id: str, filename: str, mime: str, relative_path: str, data: bytes, kind: str, extracted_path: str = "") -> dict[str, object]: +def attachment_record( + conversation_id: str, + filename: str, + mime: str, + relative_path: str, + data: bytes, + kind: str, + extracted_path: str = "", + metadata: dict[str, object] | None = None, +) -> dict[str, object]: return { "id": Path(relative_path).stem, "conversation_id": conversation_id, @@ -53,4 +62,5 @@ def attachment_record(conversation_id: str, filename: str, mime: str, relative_p "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), "extracted_path": extracted_path, + **(metadata or {}), } diff --git a/backend/app/services/cdsl_fragment.py b/backend/app/services/cdsl_fragment.py new file mode 100644 index 00000000..5d0840cb --- /dev/null +++ b/backend/app/services/cdsl_fragment.py @@ -0,0 +1,272 @@ +"""Controlled CDSL fragments; the backend, never string concatenation, materialises a model.""" + +from __future__ import annotations + +from copy import deepcopy +from hashlib import sha256 +import json +from typing import Any + +from app.services.generation_plan import GenerationPlanError + + +class CdslFragmentError(ValueError): + """A node fragment cannot be applied safely to its declared base revision.""" + + +def cdsl_sha256(cdsl: dict[str, Any] | None) -> str: + value = cdsl or {"geometry": {"sketches": []}, "features": []} + return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() + + +def _items(value: Any, field: str) -> list[dict[str, Any]]: + if value is None: + return [] + if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): + raise CdslFragmentError(f"{field} must be an array of objects") + return deepcopy(value) + + +def _ids(items: list[dict[str, Any]], field: str) -> list[str]: + values = [str(item.get("id") or "").strip() for item in items] + if not all(values) or len(values) != len(set(values)): + raise CdslFragmentError(f"{field} must have unique non-empty ids") + return values + + +def _node(plan: dict[str, Any], node_id: str) -> dict[str, Any]: + node = next((item for item in plan.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None) + if node is None: + raise CdslFragmentError(f"Fragment references an unknown node: {node_id}") + return node + + +def _single_output_id(node: dict[str, Any], field: str) -> str: + values = [str(item) for item in node.get(field) or () if str(item)] + if len(values) != 1: + raise CdslFragmentError(f"Node {node.get('id')} must declare exactly one {field} output") + return values[0] + + +def _node_feature_id(plan: dict[str, Any], node_id: str) -> str: + return _single_output_id(_node(plan, node_id), "cdsl_feature_ids") + + +def _materialize_node_references(value: Any, plan: dict[str, Any]) -> Any: + """Translate fragment-only node references into CDSL feature references.""" + if isinstance(value, list): + return [_materialize_node_references(item, plan) for item in value] + if not isinstance(value, dict): + return value + materialized = {key: _materialize_node_references(item, plan) for key, item in value.items()} + owner_node_id = materialized.pop("owner_node_id", None) + if owner_node_id is not None: + if not isinstance(owner_node_id, str) or not owner_node_id.strip(): + raise CdslFragmentError("owner_node_id must be a non-empty plan node id") + materialized["owner_feature_id"] = _node_feature_id(plan, owner_node_id.strip()) + source_node_ids = materialized.pop("source_node_ids", None) + if source_node_ids is not None: + if not isinstance(source_node_ids, list) or not source_node_ids or not all(isinstance(item, str) and item.strip() for item in source_node_ids): + raise CdslFragmentError("source_node_ids must be a non-empty array of plan node ids") + materialized["source_feature_ids"] = [_node_feature_id(plan, item.strip()) for item in source_node_ids] + return materialized + + +def _selector_references(value: Any) -> list[dict[str, Any]]: + """Collect selector-shaped objects from feature selectors and params.""" + found: list[dict[str, Any]] = [] + if isinstance(value, dict): + if "kind" in value and ("stable_id" in value or "snapshot_id" in value or "owner_feature_id" in value or "owner_node_id" in value): + found.append(value) + for child in value.values(): + found.extend(_selector_references(child)) + elif isinstance(value, list): + for child in value: + found.extend(_selector_references(child)) + return found + + +def validate_fragment( + fragment: dict[str, Any], + *, + plan: dict[str, Any], + node_id: str, + base_revision_id: str, + base_cdsl: dict[str, Any] | None, + required_snapshot_id: str = "", +) -> dict[str, Any]: + if not isinstance(fragment, dict): + raise CdslFragmentError("CDSL fragment must be an object") + version = str(fragment.get("schema_version") or "cad.cdsl-fragment.v1") + if version != "cad.cdsl-fragment.v1": + raise CdslFragmentError(f"Unsupported CDSL fragment schema: {version}") + declared_node_id = str(fragment.get("node_id") or "") + if declared_node_id != node_id: + raise CdslFragmentError("Fragment node_id does not match the active node") + if str(fragment.get("base_revision_id") or "") != base_revision_id: + raise CdslFragmentError("Fragment base_revision_id does not match the active revision") + if str(fragment.get("base_cdsl_sha256") or "") != cdsl_sha256(base_cdsl): + raise CdslFragmentError("Fragment base_cdsl_sha256 does not match the active CDSL") + snapshot_id = str(fragment.get("required_snapshot_id") or "") + if required_snapshot_id and snapshot_id != required_snapshot_id: + raise CdslFragmentError("Fragment required_snapshot_id does not match the active topology snapshot") + if not required_snapshot_id and snapshot_id: + raise CdslFragmentError("Fragment cannot use a topology snapshot before one exists") + sketches = _items(fragment.get("add_sketches"), "add_sketches") + features = _items(fragment.get("add_features"), "add_features") + node = _node(plan, node_id) + expected = {str(item) for item in node.get("cdsl_feature_ids") or ()} + expected_sketches = {str(item) for item in node.get("cdsl_sketch_ids") or ()} + if len(expected) != 1: + # Compatibility path for pre-incremental plans which could own more + # than one CDSL feature in a single node. + feature_ids = _ids(features, "add_features") + if set(feature_ids) != expected: + raise CdslFragmentError( + f"Fragment features must exactly match node outputs: expected {sorted(expected)}, got {sorted(feature_ids)}" + ) + elif len(features) != 1: + raise CdslFragmentError("An atomic plan node must generate exactly one feature") + if len(expected_sketches) > 1: + sketch_ids = _ids(sketches, "add_sketches") + if set(sketch_ids) != expected_sketches: + raise CdslFragmentError( + f"Fragment sketches must exactly match node outputs: expected {sorted(expected_sketches)}, got {sorted(sketch_ids)}" + ) + elif len(sketches) != len(expected_sketches): + expected_description = "one" if expected_sketches else "no" + raise CdslFragmentError(f"This node requires {expected_description} new sketch") + + # The planner and fragment author never own CDSL object identities. The + # merger writes node-derived IDs and dependencies after it has validated + # the current immutable plan. + if len(expected) == 1: + features[0]["id"] = next(iter(expected)) + if len(expected_sketches) == 1: + sketch_id = next(iter(expected_sketches)) + sketches[0]["id"] = sketch_id + features[0]["sketch_id"] = sketch_id + elif not expected_sketches and len(features) == 1: + features[0].pop("sketch_id", None) + features = _materialize_node_references(features, plan) + sketches = _materialize_node_references(sketches, plan) + sketch_ids = _ids(sketches, "add_sketches") + feature_ids = _ids(features, "add_features") + atomic_id = str(node.get("atomic_id") or "") + if len(expected) == 1: + features[0]["atomic_id"] = atomic_id + elif any(str(feature.get("atomic_id") or "") != atomic_id for feature in features): + raise CdslFragmentError(f"Fragment features must use plan atomic_id {atomic_id}") + base_sketch_ids = { + str(item.get("id")) for item in ((base_cdsl or {}).get("geometry") or {}).get("sketches") or () + if isinstance(item, dict) + } + base_feature_ids = { + str(item.get("id")) for item in (base_cdsl or {}).get("features") or () if isinstance(item, dict) + } + if base_sketch_ids & set(sketch_ids): + raise CdslFragmentError("Fragment attempts to overwrite an existing sketch") + if base_feature_ids & set(feature_ids): + raise CdslFragmentError("Fragment attempts to overwrite a frozen feature") + predecessor_features = { + feature_id + for dependency in node.get("depends_on") or () + for feature_id in (_node(plan, str(dependency)).get("cdsl_feature_ids") or ()) + } + if len(expected) == 1: + features[0]["depends_on"] = sorted(predecessor_features) + available_features = base_feature_ids | set(feature_ids) + available_sketches = base_sketch_ids | set(sketch_ids) + for feature in features: + feature_id = str(feature["id"]) + dependencies = feature.get("depends_on") or [] + if not isinstance(dependencies, list) or any(str(item) not in available_features for item in dependencies): + raise CdslFragmentError(f"Feature {feature_id} has a missing dependency") + sketch_id = str(feature.get("sketch_id") or "") + if sketch_id and sketch_id not in available_sketches: + raise CdslFragmentError(f"Feature {feature_id} references an unknown sketch") + if required_snapshot_id: + selectors = [selector for feature in features for selector in _selector_references(feature)] + if not selectors: + raise CdslFragmentError("Topology-dependent fragment must provide runtime snapshot selectors") + for selector in selectors: + if str(selector.get("snapshot_id") or "") != required_snapshot_id: + raise CdslFragmentError("Topology selector must reference the active snapshot") + if not str(selector.get("owner_feature_id") or ""): + raise CdslFragmentError("Topology selector must declare owner_feature_id") + if not isinstance(selector.get("geometry"), dict) or not selector["geometry"]: + raise CdslFragmentError("Topology selector must declare a non-empty geometry signature") + declared_dependencies = { + str(dependency) + for feature in features + for dependency in feature.get("depends_on") or () + } + if not predecessor_features.issubset(declared_dependencies): + raise CdslFragmentError("Fragment does not preserve all plan-node dependencies") + rules = fragment.get("verification_rules") or [] + if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules): + raise CdslFragmentError("verification_rules must be an array of objects") + assumptions = fragment.get("assumptions") or [] + if not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions): + raise CdslFragmentError("assumptions must be an array of strings") + return { + "schema_version": "cad.cdsl-fragment.v1", + "node_id": node_id, + "base_revision_id": base_revision_id, + "base_cdsl_sha256": cdsl_sha256(base_cdsl), + "required_snapshot_id": snapshot_id, + "add_sketches": sketches, + "add_features": features, + "expected_feature_ids": sorted(expected), + "verification_rules": deepcopy(rules), + "assumptions": [item.strip() for item in assumptions if item.strip()], + } + + +def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]: + """Return the complete, append-only document that the runtime must rebuild.""" + if base_cdsl is None: + document: dict[str, Any] = { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.1.0", + "kind": "part", + "part_id": "agent_preflight", + "geometry": {"sketches": []}, + "features": [], + } + else: + document = deepcopy(base_cdsl) + geometry = document.setdefault("geometry", {}) + if not isinstance(geometry, dict): + raise CdslFragmentError("Base CDSL geometry must be an object") + sketches = geometry.setdefault("sketches", []) + features = document.setdefault("features", []) + if not isinstance(sketches, list) or not isinstance(features, list): + raise CdslFragmentError("Base CDSL has invalid collections") + sketches.extend(deepcopy(fragment["add_sketches"])) + features.extend(deepcopy(fragment["add_features"])) + return document + + +def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_id: str) -> dict[str, Any]: + """Persist the runtime's actual selector choices as auditable node evidence.""" + values = [] + for resolution in engine_result.get("selector_resolution") or (): + if not isinstance(resolution, dict): + continue + selector = resolution.get("selector") if isinstance(resolution.get("selector"), dict) else {} + candidates = resolution.get("candidates") if isinstance(resolution.get("candidates"), (list, tuple)) else [] + values.append({ + "consumer_node_id": node_id, + "consumer_feature_id": str(resolution.get("feature_id") or ""), + "source_snapshot_id": str(selector.get("snapshot_id") or snapshot_id), + "kind": str(selector.get("kind") or ""), + "owner_feature_id": str(selector.get("owner_feature_id") or ""), + "stable_id": str(selector.get("stable_id") or ""), + "geometry": deepcopy(selector.get("geometry") or {}), + "status": str(resolution.get("status") or ""), + "candidates": deepcopy(list(candidates)), + "score": resolution.get("score"), + "selected": deepcopy(resolution.get("selected") or resolution.get("record") or {}), + }) + return {"schema_version": "cad.selector-bindings.v1", "node_id": node_id, "bindings": values} diff --git a/backend/app/services/editing.py b/backend/app/services/editing.py index 81f710e2..64bb9373 100644 --- a/backend/app/services/editing.py +++ b/backend/app/services/editing.py @@ -150,6 +150,10 @@ def apply_direct_edit( operation: str, selection: dict[str, Any], parameters: dict[str, Any], + *, + node_id: str = "", + branch_id: str = "main", + visibility: str = "final", ) -> 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") @@ -213,4 +217,7 @@ def apply_direct_edit( }, part_skills=None, generation_assumptions=["Legacy profile macros were lowered to direct analytic contours before this edit."] if legacy_profiles_lowered else [], + node_id=node_id, + branch_id=branch_id, + visibility=visibility, ) diff --git a/backend/app/services/engine_service.py b/backend/app/services/engine_service.py index 27d8a310..698435a5 100644 --- a/backend/app/services/engine_service.py +++ b/backend/app/services/engine_service.py @@ -15,6 +15,7 @@ from jsonschema.exceptions import SchemaError from vendor.cdsl_preview_runtime import step_to_glb from app.services.quality import evaluate_quality, validate_verification from app.services.storage import WorkspaceStore, now_iso, write_json +from app.services.cdsl_fragment import selector_bindings from app.settings import Settings @@ -299,7 +300,140 @@ def parameter_contract(cdsl: dict[str, Any]) -> dict[str, Any]: 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]]: +def topology_snapshot( + engine_result: dict[str, Any], + *, + task_id: str = "", + revision_id: str = "", + preview: dict[str, Any] | None = None, +) -> dict[str, Any]: + raw_records = [ + raw for raw in engine_result.get("topology_records") or () + if isinstance(raw, dict) and raw.get("record_id") and raw.get("kind") + ] + active_body_id = next( + ( + str(result.get("body_id")) + for result in reversed(engine_result.get("feature_results") or ()) + if isinstance(result, dict) and result.get("body_id") + ), + "", + ) + if not active_body_id: + active_body_id = next( + ( + str(raw.get("body_id")) + for raw in reversed(raw_records) + if raw.get("kind") == "body" and raw.get("body_id") + ), + "", + ) + + # The runtime retains historical B-rep records for provenance, but only + # the final body can resolve face, edge, vertex, and body selectors. + active_records = [ + raw for raw in raw_records + if not active_body_id + or raw.get("kind") in {"plane", "axis"} + or str(raw.get("body_id") or "") == active_body_id + ] + records: list[dict[str, Any]] = [] + for raw in active_records: + kind = str(raw.get("kind")) + records.append({ + "record_id": str(raw["record_id"]), + "kind": kind, + "feature_id": str(raw.get("feature_id") or ""), + "body_id": str(raw.get("body_id") or "") or None, + "owner_feature_ids": [str(item) for item in raw.get("owner_feature_ids") or () if str(item)], + "geometry": copy.deepcopy(raw.get("geometry") or {}), + "executable": kind in {"body", "face", "edge", "vertex", "plane", "axis"}, + "synthetic": False, + }) + # Preview/B-rep fallback faces are useful for visual explanation only. + # Keep them in the unified audit snapshot, but never expose them as + # executable selector candidates. + if not any(item.get("kind") == "face" for item in records): + for index, raw in enumerate((preview or {}).get("topology_faces") or ()): + if not isinstance(raw, dict): + continue + record_id = str(raw.get("id") or f"synthetic:face:{index}") + center = raw.get("center") + normal = raw.get("normal") + raw_bbox = raw.get("bbox") + if isinstance(raw_bbox, dict) and isinstance(raw_bbox.get("min"), list) and isinstance(raw_bbox.get("max"), list): + raw_bbox = [*raw_bbox["min"], *raw_bbox["max"]] + geometry = { + "surface_type": str(raw.get("surface_type") or "unknown"), + "center_mm": copy.deepcopy(center) if isinstance(center, list) else None, + "normal": copy.deepcopy(normal) if isinstance(normal, list) else None, + "bbox_mm": copy.deepcopy(raw_bbox or {}), + } + records.append({ + "record_id": record_id, + "kind": "face", + "feature_id": "", + "body_id": None, + "owner_feature_ids": [], + "geometry": geometry, + "executable": False, + "synthetic": True, + }) + return { + "schema_version": "cad.topology.v1", + "task_id": task_id, + "revision_id": revision_id, + "snapshot_id": f"{task_id}/{revision_id}" if task_id and revision_id else "", + "body_id": active_body_id, + "records": records, + } + + +def topology_sidecars( + engine_result: dict[str, Any], + preview: dict[str, Any] | None = None, + *, + snapshot: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + runtime_records = (snapshot or topology_snapshot(engine_result)).get("records") or [] + runtime_faces = [item for item in runtime_records if item.get("kind") == "face" and item.get("executable", True)] + runtime_edges = [item for item in runtime_records if item.get("kind") == "edge" and item.get("executable", True)] + if runtime_faces or runtime_edges: + references = [] + for record in runtime_faces: + geometry = record.get("geometry") or {} + references.append({ + "id": str(record.get("record_id")), + "selectorType": "face", + "label": str(geometry.get("surface_type") or "face"), + "center": geometry.get("center_mm"), + "normal": geometry.get("normal"), + "frame": { + "origin_mm": geometry.get("center_mm"), + "normal": geometry.get("normal"), + "x_dir": [1, 0, 0], + "y_dir": [0, 1, 0], + }, + "bbox": geometry.get("bbox_mm") or {}, + "surface_type": str(geometry.get("surface_type") or "unknown"), + "owner_feature_ids": record.get("owner_feature_ids") or [], + "source": "runtime_snapshot", + "snapshot_id": (snapshot or {}).get("snapshot_id") or "", + "executable": True, + }) + edge_records = [ + { + **record, + "selectorType": "edge", + "source": "runtime_snapshot", + "snapshot_id": (snapshot or {}).get("snapshot_id") or "", + } + for record in runtime_edges + ] + return ( + {"schema_version": "cad.topology.v1", "references": references, "edges": edge_records}, + {"schema_version": "cad.topology.v1", "edges": edge_records}, + ) topology_faces = (preview or {}).get("topology_faces") if isinstance(topology_faces, list) and topology_faces: references = [] @@ -322,6 +456,9 @@ def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | N "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), + "source": "preview", + "synthetic": True, + "executable": False, }) if references: return ({"schema_version": "1.1", "references": references}, {"schema_version": "1.0", "edges": []}) @@ -346,6 +483,9 @@ def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | N "center": point, "normal": normal, "frame": {"origin_mm": point, "normal": normal, "x_dir": x_dir, "y_dir": y_dir}, "bbox": {"min": minimum, "max": maximum}, + "source": "bbox_fallback", + "synthetic": True, + "executable": False, } for name, point, normal, x_dir, y_dir in definitions ] @@ -463,6 +603,11 @@ def build_revision( repair_attempts: int = 0, verification: dict[str, Any] | None = None, reference_records: list[dict[str, Any]] | None = None, + feature_plan: dict[str, Any] | None = None, + node_id: str = "", + fragment: dict[str, Any] | None = None, + branch_id: str = "main", + visibility: str = "final", ) -> dict[str, Any]: engine = load_engine(settings) task = store.ensure_task(task_id, request) @@ -476,12 +621,17 @@ def build_revision( parameters_path = revision_dir / "parameters.json" selector_path = revision_dir / "model.selector.json" edges_path = revision_dir / "model.edges.json" + topology_path = revision_dir / "model.topology.json" part_skills_path = revision_dir / "part-skills.json" quality_path = revision_dir / "quality-report.json" snapshot_manifest_path = revision_dir / "snapshot-manifest.json" + fragment_path = revision_dir / "fragment.json" + selector_bindings_path = revision_dir / "selector-bindings.json" part_skill_audit = _part_skill_audit(part_skills, request, generation_assumptions) generation_context = _generation_context(reference_ids, part_skill_audit) write_json(request_path, {"request": request, "created_at": now_iso()}) + if isinstance(feature_plan, dict): + store.write_feature_plan(task["task_id"], feature_plan) write_json(references_path, {"reference_ids": reference_ids, "records": [item for item in reference_records or [] if isinstance(item, dict)]}) write_json(part_skills_path, part_skill_audit) write_json(snapshot_manifest_path, { @@ -499,6 +649,7 @@ def build_revision( "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(), + "topology_path": topology_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(), "quality_path": quality_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "snapshot_manifest_path": snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix(), @@ -512,6 +663,11 @@ def build_revision( "operation": operation or {}, "input_attachments": input_attachments or [], "repair_attempts": max(0, int(repair_attempts)), + "branch_id": branch_id or "main", + "visibility": visibility if visibility in {"checkpoint", "final", "superseded"} else "checkpoint", + "node_id": node_id, + "fragment_path": fragment_path.relative_to(store.task_dir(task["task_id"])).as_posix() if fragment else "", + "selector_bindings_path": selector_bindings_path.relative_to(store.task_dir(task["task_id"])).as_posix(), } if status == "success": record.update({ @@ -519,6 +675,7 @@ def build_revision( "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(), + "topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "engine": engine_name, }) else: @@ -538,6 +695,8 @@ def build_revision( 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) + if fragment is not None: + write_json(fragment_path, fragment) write_json(parameters_path, parameter_contract(cdsl_copy)) validate_cdsl(cdsl_copy, engine) # Product revisions are semantic CDSL artifacts. Do not route them @@ -547,7 +706,14 @@ def build_revision( 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) + snapshot = topology_snapshot(engine_result, task_id=task["task_id"], revision_id=revision_id, preview=preview) + write_json(topology_path, snapshot) + write_json(selector_bindings_path, selector_bindings( + engine_result, + node_id=node_id, + snapshot_id=str(snapshot.get("snapshot_id") or ""), + )) + selector, edges = topology_sidecars(engine_result, preview, snapshot=snapshot) write_json(selector_path, selector) write_json(edges_path, edges) rules = validate_verification(verification, cdsl_copy) diff --git a/backend/app/services/feature_plan.py b/backend/app/services/feature_plan.py new file mode 100644 index 00000000..a3e3810f --- /dev/null +++ b/backend/app/services/feature_plan.py @@ -0,0 +1,222 @@ +"""Deterministic feature-plan validation and readiness calculations.""" + +from __future__ import annotations + +from collections import defaultdict, deque +from copy import deepcopy +from typing import Any, Iterable + + +PLAN_SCHEMA_VERSION = "cad.feature-plan.v1" +NODE_STATUSES = { + "planned", + "ready", + "waiting_for_topology", + "waiting_for_selection", + "blocked", + "executing", + "executed", + "failed", + "completed", +} + +_TOPOLOGY_REQUIRED_ATOMICS = { + "fillet", + "chamfer", + "hole_blind", + "hole_countersink", + "hole_counterbore", + "pattern_mirror", +} + + +class FeaturePlanError(ValueError): + """A feature plan is not a valid acyclic executable plan.""" + + +def _text(value: Any, field: str, *, required: bool = True) -> str: + result = str(value or "").strip() + if required and not result: + raise FeaturePlanError(f"{field} is required") + return result + + +def _bool(value: Any) -> bool: + return value is True + + +def _normalise_node(raw: Any, index: int) -> dict[str, Any]: + if not isinstance(raw, dict): + raise FeaturePlanError(f"nodes[{index}] must be an object") + node_id = _text(raw.get("id"), f"nodes[{index}].id") + atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id") + depends_on = raw.get("depends_on") or [] + if not isinstance(depends_on, list) or not all(isinstance(item, str) and item.strip() for item in depends_on): + raise FeaturePlanError(f"nodes[{index}].depends_on must be an array of non-empty strings") + feature_ids = raw.get("cdsl_feature_ids") + if feature_ids is None: + feature_ids = [node_id] + if not isinstance(feature_ids, list) or not feature_ids or not all(isinstance(item, str) and item.strip() for item in feature_ids): + raise FeaturePlanError(f"nodes[{index}].cdsl_feature_ids must be a non-empty string array") + query = raw.get("topology_query") + if query is not None and not isinstance(query, dict): + raise FeaturePlanError(f"nodes[{index}].topology_query must be an object") + requires_topology = _bool(raw.get("requires_topology")) or atomic_id in _TOPOLOGY_REQUIRED_ATOMICS + status = str(raw.get("status") or "planned") + if status not in NODE_STATUSES: + raise FeaturePlanError(f"nodes[{index}].status is unsupported: {status}") + node = { + "id": node_id, + "intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False), + "atomic_id": atomic_id, + "depends_on": list(dict.fromkeys(item.strip() for item in depends_on)), + "requires_topology": requires_topology, + "topology_query": deepcopy(query) if query is not None else None, + "status": status, + "cdsl_feature_ids": list(dict.fromkeys(item.strip() for item in feature_ids)), + } + if raw.get("selector_required") is not None: + node["selector_required"] = _bool(raw.get("selector_required")) + if isinstance(raw.get("failure"), dict): + node["failure"] = deepcopy(raw["failure"]) + return node + + +def validate_feature_plan(plan: dict[str, Any], *, supported_atomic_ids: Iterable[str] = ()) -> dict[str, Any]: + if not isinstance(plan, dict): + raise FeaturePlanError("Feature plan must be an object") + version = str(plan.get("schema_version") or PLAN_SCHEMA_VERSION) + if version != PLAN_SCHEMA_VERSION: + raise FeaturePlanError(f"Unsupported feature plan schema: {version}") + plan_id = _text(plan.get("plan_id"), "plan_id") + task_id = _text(plan.get("task_id"), "task_id", required=False) + topology_snapshot_id = _text(plan.get("topology_snapshot_id"), "topology_snapshot_id", required=False) + raw_nodes = plan.get("nodes") + if not isinstance(raw_nodes, list) or not raw_nodes: + raise FeaturePlanError("Feature plan requires a non-empty nodes array") + nodes = [_normalise_node(item, index) for index, item in enumerate(raw_nodes)] + by_id: dict[str, dict[str, Any]] = {} + node_index: dict[str, int] = {} + feature_owner: dict[str, str] = {} + supported = {str(item) for item in supported_atomic_ids if str(item)} + for index, node in enumerate(nodes): + if node["id"] in by_id: + raise FeaturePlanError(f"Duplicate feature plan node: {node['id']}") + if supported and node["atomic_id"] not in supported: + raise FeaturePlanError(f"Unsupported feature plan atomic_id: {node['atomic_id']}") + by_id[node["id"]] = node + node_index[node["id"]] = index + for feature_id in node["cdsl_feature_ids"]: + if feature_id in feature_owner: + raise FeaturePlanError(f"CDSL feature belongs to multiple plan nodes: {feature_id}") + feature_owner[feature_id] = node["id"] + indegree = {node_id: 0 for node_id in by_id} + children: dict[str, list[str]] = defaultdict(list) + for node in nodes: + for dependency in node["depends_on"]: + if dependency not in by_id: + raise FeaturePlanError(f"Node {node['id']} has missing dependency: {dependency}") + if node_index[dependency] >= node_index[node["id"]]: + raise FeaturePlanError(f"Node {node['id']} must appear after dependency: {dependency}") + indegree[node["id"]] += 1 + children[dependency].append(node["id"]) + queue = deque(node_id for node_id, degree in indegree.items() if degree == 0) + visited: list[str] = [] + while queue: + node_id = queue.popleft() + visited.append(node_id) + for child in children[node_id]: + indegree[child] -= 1 + if indegree[child] == 0: + queue.append(child) + if len(visited) != len(nodes): + raise FeaturePlanError("Feature plan contains a dependency cycle") + return { + "schema_version": PLAN_SCHEMA_VERSION, + "plan_id": plan_id, + "task_id": task_id, + "topology_snapshot_id": topology_snapshot_id, + "nodes": nodes, + "feature_owner": feature_owner, + } + + +def _topology_available(topology: dict[str, Any] | None) -> bool: + if not isinstance(topology, dict): + return False + return any( + isinstance(record, dict) + and record.get("executable", True) is not False + and record.get("kind") in {"face", "edge", "vertex", "body", "plane", "axis"} + for record in topology.get("records") or () + ) + + +def compute_node_statuses( + plan: dict[str, Any], + *, + cdsl: dict[str, Any] | None = None, + topology: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Return a copy with deterministic status and readiness information.""" + checked = validate_feature_plan(plan) + present_features = { + str(feature.get("id")) + for feature in (cdsl or {}).get("features") or () + if isinstance(feature, dict) and feature.get("id") + } + has_topology = _topology_available(topology) + topology_snapshot_id = str((topology or {}).get("snapshot_id") or "") + selection_ready = bool(topology_snapshot_id and checked.get("topology_snapshot_id") == topology_snapshot_id) + by_id = {node["id"]: node for node in checked["nodes"]} + result_nodes: list[dict[str, Any]] = [] + for original in checked["nodes"]: + node = deepcopy(original) + if node["status"] in {"failed", "blocked"}: + result_nodes.append(node) + continue + if all(feature_id in present_features for feature_id in node["cdsl_feature_ids"]): + node["status"] = "completed" + result_nodes.append(node) + continue + dependencies_done = all(by_id[item]["status"] in {"executed", "completed"} or all( + feature_id in present_features for feature_id in by_id[item]["cdsl_feature_ids"] + ) for item in node["depends_on"]) + if not dependencies_done: + node["status"] = "planned" + elif node["requires_topology"] and not has_topology: + node["status"] = "waiting_for_topology" + elif node["requires_topology"] and not selection_ready: + node["status"] = "waiting_for_selection" + else: + node["status"] = "ready" + result_nodes.append(node) + ready = [node["id"] for node in result_nodes if node["status"] == "ready"] + waiting = [node["id"] for node in result_nodes if node["status"] in {"waiting_for_topology", "waiting_for_selection"}] + blocked = [node["id"] for node in result_nodes if node["status"] == "blocked"] + completed = [node["id"] for node in result_nodes if node["status"] == "completed"] + return { + **checked, + "nodes": result_nodes, + "ready_nodes": ready, + "waiting_nodes": waiting, + "blocked_nodes": blocked, + "completed_nodes": completed, + "complete": len(completed) == len(result_nodes) and not blocked, + } + + +def plan_feature_ids(plan: dict[str, Any], node_ids: Iterable[str]) -> set[str]: + checked = validate_feature_plan(plan) + wanted = set(node_ids) + return { + feature_id + for node in checked["nodes"] + if node["id"] in wanted + for feature_id in node["cdsl_feature_ids"] + } + + +def node_for_feature(plan: dict[str, Any], feature_id: str) -> dict[str, Any] | None: + checked = validate_feature_plan(plan) + return next((node for node in checked["nodes"] if feature_id in node["cdsl_feature_ids"]), None) diff --git a/backend/app/services/generation_plan.py b/backend/app/services/generation_plan.py new file mode 100644 index 00000000..14ae27e2 --- /dev/null +++ b/backend/app/services/generation_plan.py @@ -0,0 +1,228 @@ +"""Strict contracts for persistent, node-by-node CAD generation.""" + +from __future__ import annotations + +from collections import defaultdict, deque +from copy import deepcopy +from hashlib import sha256 +import re +from typing import Any, Iterable + +from app.services.feature_plan import FeaturePlanError, validate_feature_plan + + +GENERATION_PLAN_SCHEMA_VERSION = "cad.generation-plan.v2" +BACKEND_ID_STRATEGY = "backend-derived-v1" +REQUIREMENT_SOURCES = {"explicit", "assumption"} +REQUIREMENT_PRIORITIES = {"hard", "soft"} +# Keep this in sync with the engine's profile_schema.json. The plan is +# deliberately atomic: an operation that consumes a profile owns one new +# sketch, while all other operations own none. +SKETCH_REQUIRED_ATOMICS = { + "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", + "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", + "hole_counterbore", "sphere_add", +} + + +class GenerationPlanError(ValueError): + """The authoring plan cannot safely drive an incremental build.""" + + +def _text(value: Any, field: str, *, required: bool = True) -> str: + result = str(value or "").strip() + if required and not result: + raise GenerationPlanError(f"{field} is required") + return result + + +def _string_list(value: Any, field: str, *, required: bool = False) -> list[str]: + if value is None: + value = [] + if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value): + raise GenerationPlanError(f"{field} must be an array of non-empty strings") + result = list(dict.fromkeys(item.strip() for item in value)) + if required and not result: + raise GenerationPlanError(f"{field} must not be empty") + return result + + +def _normalise_requirement(raw: Any, index: int) -> dict[str, Any]: + if not isinstance(raw, dict): + raise GenerationPlanError(f"requirements[{index}] must be an object") + source = _text(raw.get("source") or "assumption", f"requirements[{index}].source") + priority = _text(raw.get("priority") or "hard", f"requirements[{index}].priority") + if source not in REQUIREMENT_SOURCES: + raise GenerationPlanError(f"requirements[{index}].source is unsupported: {source}") + if priority not in REQUIREMENT_PRIORITIES: + raise GenerationPlanError(f"requirements[{index}].priority is unsupported: {priority}") + return { + "id": _text(raw.get("id"), f"requirements[{index}].id"), + "source": source, + "priority": priority, + "description": _text(raw.get("description"), f"requirements[{index}].description"), + "value": deepcopy(raw.get("value")), + "unit": _text(raw.get("unit"), f"requirements[{index}].unit", required=False), + "tolerance": deepcopy(raw.get("tolerance")), + } + + +def _backend_cdsl_id(kind: str, node_id: str) -> str: + """Create a valid, stable CDSL identifier without trusting model naming.""" + slug = re.sub(r"[^A-Za-z0-9_-]+", "_", node_id).strip("_-").lower() or "node" + digest = sha256(node_id.encode("utf-8")).hexdigest()[:8] + return f"{kind}_{slug[:60]}_{digest}" + + +def _backend_node_outputs(node_id: str, atomic_id: str) -> tuple[list[str], list[str]]: + feature_ids = [_backend_cdsl_id("feature", node_id)] + sketch_ids = [_backend_cdsl_id("sketch", node_id)] if atomic_id in SKETCH_REQUIRED_ATOMICS else [] + return feature_ids, sketch_ids + + +def _stored_plan_ids(raw: dict[str, Any]) -> bool: + """Retain IDs of plans already materialised by an earlier backend version.""" + return str(raw.get("id_strategy") or "") == BACKEND_ID_STRATEGY or isinstance(raw.get("feature_owner"), dict) + + +def validate_generation_plan( + document: dict[str, Any], + *, + supported_atomic_ids: Iterable[str] = (), + task_id: str = "", +) -> dict[str, Any]: + """Normalise a planner response and prove every hard requirement is owned.""" + if not isinstance(document, dict): + raise GenerationPlanError("Generation plan must be an object") + version = str(document.get("schema_version") or GENERATION_PLAN_SCHEMA_VERSION) + if version != GENERATION_PLAN_SCHEMA_VERSION: + raise GenerationPlanError(f"Unsupported generation plan schema: {version}") + requirements_raw = document.get("requirements") + if not isinstance(requirements_raw, list) or not requirements_raw: + raise GenerationPlanError("Generation plan requires a non-empty requirements array") + requirements = [_normalise_requirement(item, index) for index, item in enumerate(requirements_raw)] + requirement_ids = [item["id"] for item in requirements] + if len(requirement_ids) != len(set(requirement_ids)): + raise GenerationPlanError("Generation plan has duplicate requirement ids") + + raw_nodes = document.get("nodes") + if not isinstance(raw_nodes, list) or not raw_nodes: + raise GenerationPlanError("Generation plan requires a non-empty nodes array") + preserve_stored_ids = _stored_plan_ids(document) + feature_nodes: list[dict[str, Any]] = [] + node_metadata: dict[str, dict[str, Any]] = {} + sketch_owner: dict[str, str] = {} + for index, raw in enumerate(raw_nodes): + if not isinstance(raw, dict): + raise GenerationPlanError(f"nodes[{index}] must be an object") + node_id = _text(raw.get("id"), f"nodes[{index}].id") + atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id") + if preserve_stored_ids: + feature_ids = _string_list(raw.get("cdsl_feature_ids"), f"nodes[{index}].cdsl_feature_ids", required=True) + sketch_ids = _string_list(raw.get("cdsl_sketch_ids"), f"nodes[{index}].cdsl_sketch_ids") + else: + # New plans own semantic node IDs only. CDSL object IDs are a + # deterministic backend implementation detail, not model output. + feature_ids, sketch_ids = _backend_node_outputs(node_id, atomic_id) + for sketch_id in sketch_ids: + previous = sketch_owner.get(sketch_id) + if previous: + raise GenerationPlanError(f"CDSL sketch belongs to multiple plan nodes: {sketch_id} ({previous}, {node_id})") + sketch_owner[sketch_id] = node_id + coverage = _string_list(raw.get("requirement_ids"), f"nodes[{index}].requirement_ids") + unknown = sorted(set(coverage) - set(requirement_ids)) + if unknown: + raise GenerationPlanError(f"Node {node_id} references unknown requirements: {', '.join(unknown)}") + rules = raw.get("verification_rules") or [] + if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules): + raise GenerationPlanError(f"nodes[{index}].verification_rules must be an array of objects") + targets = raw.get("review_targets") or [] + if not isinstance(targets, list) or not all(isinstance(item, dict) for item in targets): + raise GenerationPlanError(f"nodes[{index}].review_targets must be an array of objects") + feature_nodes.append({ + "id": node_id, + "intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False), + "atomic_id": atomic_id, + "depends_on": _string_list(raw.get("depends_on"), f"nodes[{index}].depends_on"), + "requires_topology": raw.get("requires_topology") is True, + "topology_query": deepcopy(raw.get("topology_query")) if raw.get("topology_query") is not None else None, + "cdsl_feature_ids": feature_ids, + }) + node_metadata[node_id] = { + "cdsl_sketch_ids": sketch_ids, + "requires_sketch": bool(sketch_ids), + "requirement_ids": coverage, + "verification_rules": deepcopy(rules), + "review_targets": deepcopy(targets), + "attempts": {"authoring": 0, "repair": 0, "replan": 0}, + } + try: + feature_plan = validate_feature_plan({ + "schema_version": "cad.feature-plan.v1", + "plan_id": document.get("plan_id"), + "task_id": task_id or document.get("task_id"), + "nodes": feature_nodes, + }, supported_atomic_ids=supported_atomic_ids) + except FeaturePlanError as error: + raise GenerationPlanError(str(error)) from error + + covered = { + requirement_id + for metadata in node_metadata.values() + for requirement_id in metadata["requirement_ids"] + } + uncovered = [item["id"] for item in requirements if item["priority"] == "hard" and item["id"] not in covered] + if uncovered: + raise GenerationPlanError("Hard requirements are not covered: " + ", ".join(uncovered)) + nodes = [] + for node in feature_plan["nodes"]: + nodes.append({**node, **node_metadata[node["id"]]}) + return { + "schema_version": GENERATION_PLAN_SCHEMA_VERSION, + "id_strategy": BACKEND_ID_STRATEGY, + "plan_id": feature_plan["plan_id"], + "task_id": task_id or feature_plan["task_id"], + "requirements": requirements, + "assumptions": _string_list(document.get("assumptions"), "assumptions"), + "nodes": nodes, + "feature_owner": feature_plan["feature_owner"], + "sketch_owner": sketch_owner, + } + + +def descendant_closure(plan: dict[str, Any], root_node_id: str) -> set[str]: + """Return one node and every node whose model depends on it.""" + nodes = plan.get("nodes") if isinstance(plan, dict) else None + if not isinstance(nodes, list): + raise GenerationPlanError("Generation plan has no nodes") + children: dict[str, set[str]] = defaultdict(set) + known = {str(node.get("id")) for node in nodes if isinstance(node, dict)} + if root_node_id not in known: + raise GenerationPlanError(f"Unknown generation-plan node: {root_node_id}") + for node in nodes: + if not isinstance(node, dict): + continue + for dependency in node.get("depends_on") or (): + children[str(dependency)].add(str(node.get("id"))) + result: set[str] = set() + queue: deque[str] = deque([root_node_id]) + while queue: + node_id = queue.popleft() + if node_id in result: + continue + result.add(node_id) + queue.extend(sorted(children[node_id] - result)) + return result + + +def mark_nodes_stale(plan: dict[str, Any], root_node_id: str, *, reason: str) -> dict[str, Any]: + """Invalidate a node/subtree after an upstream geometry change or rollback.""" + updated = deepcopy(plan) + stale = descendant_closure(updated, root_node_id) + for node in updated.get("nodes") or (): + if isinstance(node, dict) and str(node.get("id")) in stale: + node["status"] = "planned" + node["stale"] = True + node["stale_reason"] = reason + node.pop("topology_snapshot_id", None) + return updated diff --git a/backend/app/services/image_observation.py b/backend/app/services/image_observation.py new file mode 100644 index 00000000..f6aad48b --- /dev/null +++ b/backend/app/services/image_observation.py @@ -0,0 +1,264 @@ +"""Structured multi-view image observations used by the CAD agent. + +The observation contract intentionally keeps uncertain image evidence separate +from executable CDSL. It can therefore retain free-form/polyline candidates +without pretending that the local CAD runtime supports them directly. +""" + +from __future__ import annotations + +import json +from copy import deepcopy +from typing import Any + + +OBSERVATION_SCHEMA_VERSION = "cad.image-observation.v2" +TEXT_LIMIT = 300 +LIMITS = { + "views": 12, + "surfaces": 24, + "profiles": 32, + "segments": 256, + "holes": 64, + "bends": 16, + "measurements": 128, + "uncertainties": 64, +} + + +def _text(value: Any, name: str, limit: int = TEXT_LIMIT, *, required: bool = False) -> str: + result = str(value or "").strip() + if required and not result: + raise ValueError(f"{name} must be a non-empty string") + return result[:limit] + + +def _text_list(value: Any, name: str, limit: int) -> list[str]: + if value is None: + return [] + if not isinstance(value, list): + raise ValueError(f"{name} must be an array") + return [_text(item, name, required=True) for item in value[:limit]] + + +def _number(value: Any, name: str) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must be numeric") from error + + +def _point(value: Any, name: str, dimensions: int = 2) -> list[float] | None: + if value is None: + return None + if not isinstance(value, list) or len(value) < dimensions: + raise ValueError(f"{name} must contain at least {dimensions} numbers") + output: list[float] = [] + for index, component in enumerate(value[:dimensions]): + parsed = _number(component, f"{name}[{index}]") + if parsed is None: + raise ValueError(f"{name}[{index}] must be numeric") + output.append(parsed) + return output + + +def _confidence(value: Any) -> float | None: + parsed = _number(value, "confidence") + if parsed is None: + return None + return max(0.0, min(1.0, parsed)) + + +def _source_images(value: Any) -> list[str]: + return _text_list(value, "source_images", LIMITS["views"]) + + +def _normalize_segment(segment: Any) -> dict[str, Any]: + if not isinstance(segment, dict): + raise ValueError("profile segments must contain objects") + kind = _text(segment.get("type"), "segment.type", 32, required=True) + if kind not in {"line", "arc", "circle", "polyline", "unknown_curve"}: + raise ValueError(f"unsupported image segment type: {kind}") + result: dict[str, Any] = {"type": kind} + if kind in {"line", "arc"}: + result["start"] = _point(segment.get("start"), "segment.start") + result["end"] = _point(segment.get("end"), "segment.end") + if result["start"] is None or result["end"] is None: + raise ValueError(f"{kind} segments require start and end") + if kind == "arc": + result["center"] = _point(segment.get("center"), "segment.center") + result["radius_mm"] = _number(segment.get("radius_mm"), "segment.radius_mm") + result["clockwise"] = bool(segment.get("clockwise")) + if kind == "circle": + result["center"] = _point(segment.get("center"), "segment.center") + result["radius_mm"] = _number(segment.get("radius_mm"), "segment.radius_mm") + if result["center"] is None or result["radius_mm"] is None: + raise ValueError("circle segments require center and radius_mm") + if kind in {"polyline", "unknown_curve"}: + points = segment.get("points") + if not isinstance(points, list) or not points: + raise ValueError(f"{kind} segments require points") + result["points"] = [_point(point, "segment.points") for point in points[:LIMITS["segments"]]] + if any(point is None for point in result["points"]): + raise ValueError(f"{kind} segment contains an invalid point") + result["image_uv"] = { + "start": _point(segment.get("image_start"), "segment.image_start"), + "end": _point(segment.get("image_end"), "segment.image_end"), + } + result["confidence"] = _confidence(segment.get("confidence")) + result["notes"] = _text(segment.get("notes"), "segment.notes") + return result + + +def _normalize_profile(profile: Any) -> dict[str, Any]: + if not isinstance(profile, dict): + raise ValueError("profiles must contain objects") + segments = profile.get("segments") or [] + if not isinstance(segments, list): + raise ValueError("profile.segments must be an array") + return { + "id": _text(profile.get("id"), "profile.id", 80, required=True), + "role": _text(profile.get("role"), "profile.role", 40), + "plane_hint": _text(profile.get("plane_hint"), "profile.plane_hint"), + "closed": bool(profile.get("closed")), + "coordinate_space": _text(profile.get("coordinate_space"), "profile.coordinate_space") or "image_uv", + "segments": [_normalize_segment(item) for item in segments[:LIMITS["segments"]]], + "source_images": _source_images(profile.get("source_images")), + "confidence": _confidence(profile.get("confidence")), + "uncertain": _text_list(profile.get("uncertain"), "profile.uncertain", 16), + "notes": _text(profile.get("notes"), "profile.notes"), + } + + +def _normalize_measurement(measurement: Any) -> dict[str, Any]: + if not isinstance(measurement, dict): + raise ValueError("measurements must contain objects") + source = _text(measurement.get("source"), "measurement.source") or "image" + if source not in {"user", "image", "cv", "assumption"}: + raise ValueError("measurement.source must be user, image, cv, or assumption") + value = _number(measurement.get("value_mm"), "measurement.value_mm") + minimum = _number(measurement.get("min_mm"), "measurement.min_mm") + maximum = _number(measurement.get("max_mm"), "measurement.max_mm") + return { + "name": _text(measurement.get("name"), "measurement.name", 120, required=True), + "value_mm": value, + "min_mm": minimum, + "max_mm": maximum, + "source": source, + "confidence": _confidence(measurement.get("confidence")), + "evidence": _text(measurement.get("evidence"), "measurement.evidence"), + "source_images": _source_images(measurement.get("source_images")), + } + + +def normalize_image_observation(arguments: dict[str, Any], *, attachment_ids: list[str]) -> dict[str, Any]: + """Normalize the survey tool output while preserving uncertain geometry.""" + if not isinstance(arguments, dict): + raise ValueError("image observation arguments must be an object") + raw_ids = [str(item) for item in arguments.get("attachment_ids") or attachment_ids if str(item)] + normalized_ids = list(dict.fromkeys(raw_ids or attachment_ids)) + if not normalized_ids: + raise ValueError("image observation requires at least one attachment") + views = arguments.get("views") or [] + profiles = arguments.get("profiles") or [] + measurements = arguments.get("measurements") or [] + result: dict[str, Any] = { + "schema_version": OBSERVATION_SCHEMA_VERSION, + "attachment_ids": normalized_ids, + "part_type": _text(arguments.get("part_type"), "part_type", required=True), + "visible_features": _text_list(arguments.get("visible_features"), "visible_features", 32), + "uncertain_features": _text_list(arguments.get("uncertain_features"), "uncertain_features", LIMITS["uncertainties"]), + "views": [], + "scale_references": deepcopy(arguments.get("scale_references") or [])[:LIMITS["views"]], + "overall_geometry": arguments.get("overall_geometry") if isinstance(arguments.get("overall_geometry"), dict) else {}, + "surfaces": deepcopy(arguments.get("surfaces") or [])[:LIMITS["surfaces"]], + "profiles": [_normalize_profile(item) for item in profiles[:LIMITS["profiles"]]], + "holes": deepcopy(arguments.get("holes") or [])[:LIMITS["holes"]], + "bends": deepcopy(arguments.get("bends") or [])[:LIMITS["bends"]], + "measurements": [_normalize_measurement(item) for item in measurements[:LIMITS["measurements"]]], + "uncertainties": _text_list(arguments.get("uncertainties"), "uncertainties", LIMITS["uncertainties"]), + "assumptions": _text_list(arguments.get("assumptions"), "assumptions", LIMITS["uncertainties"]), + "cv_hints": deepcopy(arguments.get("cv_hints") or [])[:LIMITS["profiles"]], + } + for item in views[:LIMITS["views"]]: + if not isinstance(item, dict): + raise ValueError("views must contain objects") + result["views"].append({ + "attachment_id": _text(item.get("attachment_id"), "view.attachment_id", 120, required=True), + "view_role": _text(item.get("view_role"), "view.view_role"), + "orientation": _text(item.get("orientation"), "view.orientation"), + "visible_regions": _text_list(item.get("visible_regions"), "view.visible_regions", 24), + "occluded_regions": _text_list(item.get("occluded_regions"), "view.occluded_regions", 24), + "quality": _text(item.get("quality"), "view.quality"), + "scale_reference_id": _text(item.get("scale_reference_id"), "view.scale_reference_id", 120), + "confidence": _confidence(item.get("confidence")), + }) + return result + + +def normalize_sketch_candidates(arguments: dict[str, Any], *, attachment_ids: list[str]) -> dict[str, Any]: + """Normalize the second-stage sketch extraction result.""" + if not isinstance(arguments, dict): + raise ValueError("sketch candidate arguments must be an object") + profiles = arguments.get("profiles") or arguments.get("sketches") or [] + base = normalize_image_observation({ + "attachment_ids": attachment_ids, + "part_type": arguments.get("part_type") or "image reference", + "visible_features": arguments.get("visible_features") or ["profile candidates"], + "uncertain_features": arguments.get("uncertain_features") or [], + "profiles": profiles, + "measurements": arguments.get("measurements") or [], + "uncertainties": arguments.get("uncertainties") or [], + "assumptions": arguments.get("assumptions") or [], + "cv_hints": arguments.get("cv_hints") or [], + }, attachment_ids=attachment_ids) + return { + "profiles": base["profiles"], + "measurements": base["measurements"], + "uncertainties": base["uncertainties"], + "assumptions": base["assumptions"], + "cv_hints": base["cv_hints"], + } + + +def merge_image_observations(survey: dict[str, Any], sketches: dict[str, Any]) -> dict[str, Any]: + """Merge the two stages and keep user-sourced measurements authoritative.""" + result = deepcopy(survey) + result["schema_version"] = OBSERVATION_SCHEMA_VERSION + result["profiles"] = sketches.get("profiles") or result.get("profiles") or [] + existing = {str(item.get("name")): item for item in result.get("measurements") or () if isinstance(item, dict)} + for item in sketches.get("measurements") or (): + if not isinstance(item, dict): + continue + key = str(item.get("name") or "") + prior = existing.get(key) + if prior and prior.get("source") == "user" and item.get("source") != "user": + continue + existing[key] = item + result["measurements"] = list(existing.values()) + result["uncertainties"] = list(dict.fromkeys([ + *(result.get("uncertainties") or []), + *(sketches.get("uncertainties") or []), + ]))[:LIMITS["uncertainties"]] + result["assumptions"] = list(dict.fromkeys([ + *(result.get("assumptions") or []), + *(sketches.get("assumptions") or []), + ]))[:LIMITS["uncertainties"]] + result["cv_hints"] = sketches.get("cv_hints") or result.get("cv_hints") or [] + return result + + +def render_image_observation_context(observation: dict[str, Any] | None) -> str: + if not isinstance(observation, dict): + return "" + compact = { + key: observation.get(key) + for key in ( + "schema_version", "attachment_ids", "part_type", "views", "overall_geometry", + "surfaces", "profiles", "holes", "bends", "measurements", "uncertainties", "assumptions", + ) + if observation.get(key) not in (None, [], {}) + } + return json.dumps(compact, ensure_ascii=False, separators=(",", ":")) diff --git a/backend/app/services/image_processing.py b/backend/app/services/image_processing.py new file mode 100644 index 00000000..b70e67d2 --- /dev/null +++ b/backend/app/services/image_processing.py @@ -0,0 +1,46 @@ +"""Optional image metadata and computer-vision hints for image observations.""" + +from __future__ import annotations + +import io +from typing import Any + + +def image_metadata(data: bytes) -> dict[str, Any]: + """Read safe image metadata without changing the original upload.""" + try: + from PIL import Image, ImageOps + with Image.open(io.BytesIO(data)) as image: + normalized = ImageOps.exif_transpose(image) + return { + "width": int(normalized.width), + "height": int(normalized.height), + "format": str(image.format or "").lower(), + "orientation": "landscape" if normalized.width >= normalized.height else "portrait", + "has_alpha": "A" in normalized.getbands(), + } + except Exception as error: + return {"error": f"image metadata unavailable: {type(error).__name__}"} + + +def cv_hints(data: bytes) -> dict[str, Any]: + """Return conservative CV hints; OpenCV is intentionally optional.""" + try: + import cv2 # type: ignore + import numpy as np # type: ignore + except Exception: + return {"available": False, "hints": []} + try: + image = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_GRAYSCALE) + if image is None: + return {"available": True, "hints": [], "error": "image decode failed"} + edges = cv2.Canny(image, 50, 150) + lines = cv2.HoughLinesP(edges, 1, 3.141592653589793 / 180, threshold=50, minLineLength=30, maxLineGap=8) + line_hints = [] + for line in (lines[:32] if lines is not None else []): + x1, y1, x2, y2 = [int(value) for value in line[0]] + line_hints.append({"type": "line", "start_px": [x1, y1], "end_px": [x2, y2]}) + return {"available": True, "hints": line_hints, "edge_pixels": int((edges > 0).sum())} + except Exception as error: + return {"available": True, "hints": [], "error": f"cv failed: {type(error).__name__}"} + diff --git a/backend/app/services/incremental_generation.py b/backend/app/services/incremental_generation.py new file mode 100644 index 00000000..020609b4 --- /dev/null +++ b/backend/app/services/incremental_generation.py @@ -0,0 +1,509 @@ +"""Persistent, full-rebuild orchestration for node-by-node CDSL authoring.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Awaitable, Callable +from copy import deepcopy +import json +from pathlib import Path +import secrets +from typing import Any + +from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, materialize_fragment, validate_fragment +from app.services.engine_service import QualityVerificationError, build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl +from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan +from app.services.quality import validate_verification +from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status +from app.services.storage import WorkspaceStore, write_json +from app.services.visual_review import VisualReviewError, review_checkpoint +from app.settings import ProviderConfig, ProviderModel, Settings + + +Completion = Callable[[list[dict[str, Any]], list[dict[str, Any]], ProviderConfig, ProviderModel, str | None], Awaitable[dict[str, Any]]] + + +PLAN_TOOL = { + "type": "function", + "function": { + "name": "plan_generation_task", + "description": "Create the complete immutable requirement list and executable feature DAG before authoring any CDSL.", + "parameters": { + "type": "object", + "properties": { + "schema_version": {"type": "string", "const": "cad.generation-plan.v2"}, + "plan_id": {"type": "string", "minLength": 1}, + "requirements": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "source": {"enum": ["explicit", "assumption"]}, + "priority": {"enum": ["hard", "soft"]}, + "description": {"type": "string", "minLength": 1}, + "value": {}, + "unit": {"type": "string"}, + "tolerance": {}, + }, + "required": ["id", "source", "priority", "description"], + "additionalProperties": False, + }, + }, + "assumptions": {"type": "array", "items": {"type": "string"}}, + "nodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "intent": {"type": "string"}, + "atomic_id": {"type": "string", "minLength": 1}, + "depends_on": {"type": "array", "items": {"type": "string"}}, + "requires_topology": {"type": "boolean"}, + "topology_query": {"type": "object"}, + "requirement_ids": {"type": "array", "items": {"type": "string"}}, + "verification_rules": {"type": "array", "items": {"type": "object"}}, + "review_targets": {"type": "array", "items": {"type": "object"}}, + }, + "required": ["id", "intent", "atomic_id", "depends_on", "requirement_ids", "verification_rules", "review_targets"], + "additionalProperties": False, + }, + }, + }, + "required": ["schema_version", "plan_id", "requirements", "assumptions", "nodes"], + "additionalProperties": False, + }, + }, +} + +FRAGMENT_TOOL = { + "type": "function", + "function": { + "name": "generate_cdsl_fragment", + "description": "Generate only the active plan node's additive CDSL fragment. Never replace or mutate existing CDSL.", + "parameters": { + "type": "object", + "properties": { + "schema_version": {"type": "string", "const": "cad.cdsl-fragment.v1"}, + "node_id": {"type": "string", "minLength": 1}, + "base_revision_id": {"type": "string"}, + "base_cdsl_sha256": {"type": "string", "minLength": 64, "maxLength": 64}, + "required_snapshot_id": {"type": "string"}, + "add_sketches": {"type": "array", "items": {"type": "object"}}, + "add_features": {"type": "array", "items": {"type": "object"}}, + "verification_rules": {"type": "array", "items": {"type": "object"}}, + "assumptions": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["schema_version", "node_id", "base_revision_id", "base_cdsl_sha256", "add_sketches", "add_features", "verification_rules", "assumptions"], + "additionalProperties": False, + }, + }, +} + + +class IncrementalGenerationError(RuntimeError): + pass + + +def _tool_response(response: dict[str, Any], expected_name: str) -> dict[str, Any]: + try: + call = response["choices"][0]["message"]["tool_calls"][0] + if call["function"]["name"] != expected_name: + raise KeyError("wrong tool") + result = json.loads(call["function"]["arguments"]) + except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error: + raise IncrementalGenerationError(f"Author did not return a valid {expected_name} call") from error + if not isinstance(result, dict): + raise IncrementalGenerationError(f"{expected_name} arguments must be an object") + return result + + +def _node_by_id(spec: dict[str, Any], node_id: str) -> dict[str, Any]: + node = next((item for item in spec.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None) + if node is None: + raise IncrementalGenerationError(f"Generation plan has no node {node_id}") + return node + + +def _fragment_node_context(node: dict[str, Any]) -> dict[str, Any]: + """Expose semantic node intent, not backend-owned CDSL implementation IDs.""" + fields = ( + "id", "intent", "atomic_id", "depends_on", "requires_topology", + "requires_sketch", "topology_query", "requirement_ids", + "verification_rules", "review_targets", + ) + return {field: deepcopy(node[field]) for field in fields if field in node} + + +def _ready_node(spec: dict[str, Any], completed: set[str], has_topology: bool) -> dict[str, Any] | None: + for node in spec.get("nodes") or (): + if not isinstance(node, dict) or node.get("status") == "completed": + continue + if all(str(item) in completed for item in node.get("depends_on") or ()) and (not node.get("requires_topology") or has_topology): + return node + return None + + +def _error_code(error: Exception) -> str: + message = str(error) + for code in ( + "SELECTOR_AMBIGUOUS", "SELECTOR_NOT_FOUND", "SELECTOR_GEOMETRY_MISMATCH", + "TOPOLOGY_SNAPSHOT_STALE", "TOPOLOGY_REQUIRED", "VERIFICATION_FAILED", "SELECTOR_OWNER_REQUIRED", + ): + if code in message: + return code + return type(error).__name__.upper() + + +def _mark_affected_nodes_stale(plan: dict[str, Any], node_ids: list[str], *, reason: str) -> tuple[dict[str, Any], set[str]]: + """Invalidate the union of every affected node's downstream closure.""" + updated = deepcopy(plan) + stale: set[str] = set() + for node_id in dict.fromkeys(node_ids): + stale.update(descendant_closure(updated, node_id)) + updated = mark_nodes_stale(updated, node_id, reason=reason) + return updated, stale + + +def _source_image_paths(store: WorkspaceStore, conversation: dict[str, Any]) -> list[Path]: + conversation_id = str(conversation.get("conversation_id") or "") + paths: list[Path] = [] + for attachment in conversation.get("attachments") or (): + if not isinstance(attachment, dict) or attachment.get("kind") != "image" or not conversation_id: + continue + try: + paths.append(store.conversation_attachment_path(conversation_id, str(attachment.get("path") or ""))) + except ValueError: + continue + return paths + + +class IncrementalGenerationRunner: + """The agent-facing controller. It is deliberately full-rebuild and restart-safe.""" + + def __init__(self, settings: Settings, store: WorkspaceStore, complete: Completion) -> None: + self.settings = settings + self.store = store + self._complete = complete + + async def _call(self, messages: list[dict[str, Any]], provider: ProviderConfig, model: ProviderModel, tool: dict[str, Any], name: str) -> dict[str, Any]: + response = await self._complete(messages, [tool], provider, model, name) + return _tool_response(response, name) + + async def run( + self, + *, + task_id: str, + request: str, + conversation: dict[str, Any], + provider: ProviderConfig, + model: ProviderModel, + author_messages: list[dict[str, Any]], + part_skills: dict[str, Any] | None = None, + references: list[str] | None = None, + already_started: bool = False, + ) -> AsyncIterator[tuple[str, dict[str, Any]]]: + plan_diagnostic_path = "" + try: + task = self.store.ensure_task(task_id or None, request) + task_id = str(task["task_id"]) + # Configuration is a start gate: visual review is required, never silently skipped. + self.settings.resolve_review_model() + renderer_ready, renderer_error = renderer_status() + if not renderer_ready: + raise IncrementalGenerationError(renderer_error) + task = self.store.read_task(task_id) if already_started else self.store.start_generation(task_id, request=request) + if not isinstance(task, dict): + raise IncrementalGenerationError("Generation task is unavailable") + yield "generation_plan", {"taskId": task_id, "status": "running"} + engine = load_engine(self.settings) + persisted_spec = self.store.read_generation_spec(task_id) + if persisted_spec is not None: + spec = validate_generation_plan( + persisted_spec, + supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), + task_id=task_id, + ) + yield "generation_plan", { + "taskId": task_id, "status": "success", "planId": spec["plan_id"], "resumed": True, + "requirements": spec["requirements"], + "nodes": [{"id": node["id"], "intent": node["intent"], "status": node.get("status", "planned")} for node in spec["nodes"]], + } + else: + planning_messages = [ + { + "role": "system", + "content": ( + "Create one complete cad.generation-plan.v2 before creating geometry. " + "Turn every user constraint into a requirement with source explicit or assumption; " + "use source assumption for missing dimensions and never ask the user questions. " + "Every hard requirement must belong to at least one node. Use only runtime-supported atomic ids. " + "Do not output expected_feature_ids or expected_sketch_ids: the backend derives all CDSL object ids from node.id." + ), + }, + *author_messages, + ] + raw_spec = await self._call(planning_messages, provider, model, PLAN_TOOL, "plan_generation_task") + try: + spec = validate_generation_plan( + raw_spec, + supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), + task_id=task_id, + ) + except GenerationPlanError as error: + plan_diagnostic_path = self.store.write_generation_failure(task_id, { + "schema_version": "cad.generation-plan-diagnostic.v1", + "stage": "generation_plan_validation", + "message": str(error), + "raw_plan": raw_spec, + }) + raise + self.store.write_generation_spec(task_id, spec) + yield "generation_plan", { + "taskId": task_id, "status": "success", "planId": spec["plan_id"], + "requirements": spec["requirements"], "nodes": [{"id": node["id"], "intent": node["intent"], "status": "planned"} for node in spec["nodes"]], + } + + completed: set[str] = { + str(node["id"]) for node in spec["nodes"] if node.get("status") == "completed" + } + last_built: dict[str, Any] | None = None + task = self.store.read_task(task_id) or task + active_revision_id = str(task.get("active_revision") or "") + # A process can stop between build and review. That checkpoint is + # not a legal base revision, so recover its parent before resuming. + active_record = next( + (item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == active_revision_id), + None, + ) + if isinstance(active_record, dict) and active_record.get("visibility") == "checkpoint": + current_node = str(active_record.get("node_id") or "") + if current_node and current_node not in completed: + recovered = str(active_record.get("parent_revision_id") or "") + self.store.rollback_to_revision(task_id, recovered, branch_id=f"branch_{secrets.token_hex(4)}") + active_revision_id = recovered + task = self.store.read_task(task_id) or task + base_path = self.store.current_cdsl_path(task_id) + base_cdsl = json.loads(base_path.read_text(encoding="utf-8")) if base_path and base_path.is_file() else None + while True: + topology_path = self.store.current_topology_path(task_id) + topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None + node = _ready_node(spec, completed, bool(topology and topology.get("records"))) + if node is None: + if len(completed) == len(spec["nodes"]): + self.store.finish_generation(task_id, lifecycle="completed") + if last_built is not None: + yield "cad_result", self._result_payload(last_built, lifecycle="completed", checkpoint=False) + yield "task_terminal", {"taskId": task_id, "lifecycle": "completed", "revisionId": str((self.store.read_task(task_id) or {}).get("published_revision") or "")} + return + waiting = [item["id"] for item in spec["nodes"] if item.get("id") not in completed] + raise IncrementalGenerationError("No executable plan node is ready: " + ", ".join(waiting)) + + node_id = str(node["id"]) + self.store.set_active_node(task_id, node_id) + yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "authoring"} + attempts = node.setdefault("attempts", {"authoring": 0, "repair": 0, "replan": 0}) + feedback = "" + while True: + attempt_kind = "authoring" if int(attempts.get("authoring") or 0) < self.settings.node_authoring_attempts else "repair" + if attempt_kind == "repair" and int(attempts.get("repair") or 0) >= self.settings.node_repair_attempts: + if int(attempts.get("replan") or 0) >= self.settings.node_replan_attempts: + raise IncrementalGenerationError(f"Node {node_id} exhausted its authoring, repair, and replan budgets: {feedback}") + attempts["replan"] = int(attempts.get("replan") or 0) + 1 + spec = await self._replan(spec, node_id, feedback, provider, model, author_messages, engine) + self.store.write_generation_spec(task_id, spec) + node = _node_by_id(spec, node_id) + node["attempts"] = {"authoring": 0, "repair": 0, "replan": attempts["replan"]} + attempts = node["attempts"] + yield "rollback", {"taskId": task_id, "nodeId": node_id, "reason": "node_replan"} + continue + attempts[attempt_kind] = int(attempts.get(attempt_kind) or 0) + 1 + required_snapshot = str((topology or {}).get("snapshot_id") or "") if node.get("requires_topology") else "" + node_requirement_ids = set(node.get("requirement_ids") or ()) + author_context = { + "active_node": _fragment_node_context(node), + "requirements": [ + requirement for requirement in spec["requirements"] + if requirement.get("id") in node_requirement_ids + ], + "base_revision_id": active_revision_id, + "base_cdsl_sha256": cdsl_sha256(base_cdsl), + "required_snapshot_id": required_snapshot, + "base_cdsl": base_cdsl, + "topology": topology if required_snapshot else None, + "previous_failure": feedback, + } + fragment_messages = [ + { + "role": "system", + "content": ( + "Author exactly one additive CDSL fragment for the active node. Do not change existing CDSL or generate unsupported topology selectors. " + "Do not set output id, sketch_id, or depends_on: the backend assigns them. " + "Use owner_node_id instead of owner_feature_id and source_node_ids instead of source_feature_ids when referring to plan nodes." + ), + }, + *author_messages, + {"role": "user", "content": json.dumps(author_context, ensure_ascii=False)}, + ] + try: + raw_fragment = await self._call(fragment_messages, provider, model, FRAGMENT_TOOL, "generate_cdsl_fragment") + fragment = validate_fragment( + raw_fragment, plan=spec, node_id=node_id, base_revision_id=active_revision_id, + base_cdsl=base_cdsl, required_snapshot_id=required_snapshot, + ) + cdsl = materialize_fragment(base_cdsl, fragment) + cdsl, repairs = normalize_cdsl_for_engine(cdsl) + validate_cdsl(cdsl, engine) + rules = [*node.get("verification_rules", []), *fragment.get("verification_rules", [])] + verification = {"rules": rules} if rules else None + validate_verification(verification, cdsl) + fragment_base_revision = str(fragment["base_revision_id"]) + built = await asyncio.to_thread( + build_revision, + settings=self.settings, store=self.store, task_id=task_id, request=request, cdsl=cdsl, + reference_ids=references or [], summary=node.get("intent") or node_id, + parent_revision_id=active_revision_id or None, + operation={"type": "cdsl_fragment", "node_id": node_id}, + part_skills=part_skills, generation_assumptions=[*spec.get("assumptions", []), *fragment.get("assumptions", [])], + verification=verification, node_id=node_id, fragment=fragment, + branch_id=str((self.store.read_task(task_id) or {}).get("active_branch_id") or "main"), visibility="checkpoint", + ) + candidate_revision_id = str(built["revision_id"]) + try: + render_dir = self.store.revision_dir(task_id, candidate_revision_id) / "review" + manifest = await asyncio.to_thread( + render_checkpoint, + self.settings, + step_path=self.store.artifact_path(task_id, str(built["step_path"])), + output_dir=render_dir, + review_targets=node.get("review_targets"), + ) + final_checkpoint = len(completed) + 1 == len(spec["nodes"]) + review_requirements = spec["requirements"] if final_checkpoint else [ + requirement for requirement in spec["requirements"] + if requirement.get("id") in set(node.get("requirement_ids") or ()) + ] + review = await review_checkpoint( + self.settings, manifest=manifest, requirements=review_requirements, node_id=node_id, + deterministic_report={"quality_status": built.get("quality_status"), "verification": built.get("verification_summary", {})}, + source_images=_source_image_paths(self.store, conversation) if not completed or final_checkpoint else [], + final_checkpoint=final_checkpoint, + ) + except (ReviewRenderError, VisualReviewError) as error: + self.store.rollback_to_revision(task_id, fragment_base_revision, branch_id=f"branch_{secrets.token_hex(4)}") + active_revision_id = fragment_base_revision + rollback_path = self.store.current_cdsl_path(task_id) + base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None + raise error + active_revision_id = candidate_revision_id + base_cdsl = cdsl + manifest_relative = (render_dir / "render-manifest.json").relative_to(self.store.task_dir(task_id)).as_posix() + review_relative = (render_dir / "visual-review.json").relative_to(self.store.task_dir(task_id)).as_posix() + write_json(render_dir / "visual-review.json", review) + self.store.update_revision_metadata(task_id, active_revision_id, {"render_manifest_path": manifest_relative, "visual_review_path": review_relative}) + yield "render_review", {"taskId": task_id, "revisionId": active_revision_id, "nodeId": node_id, "review": review} + if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85: + affected_nodes = [ + str(item) for item in review.get("affected_node_ids") or () + if any(str(candidate.get("id") or "") == str(item) for candidate in spec.get("nodes") or ()) + ] or [node_id] + rollback_base = self.store.rollback_anchor_for_nodes( + task_id, + affected_nodes, + fallback_revision_id=fragment_base_revision, + ) + branch = f"branch_{secrets.token_hex(4)}" + self.store.rollback_to_revision(task_id, rollback_base, branch_id=branch) + spec, stale_nodes = _mark_affected_nodes_stale( + spec, affected_nodes, reason="high_confidence_visual_review", + ) + self.store.write_generation_spec(task_id, spec) + completed.difference_update(stale_nodes) + active_revision_id = rollback_base + rollback_path = self.store.current_cdsl_path(task_id) + base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None + feedback = "High-confidence visual review requires correction: " + "; ".join(review.get("evidence") or []) + yield "rollback", { + "taskId": task_id, "nodeId": node_id, "revisionId": active_revision_id, + "reason": "visual_review", "affectedNodeIds": affected_nodes, + } + continue + completed.add(node_id) + node["status"] = "completed" + node.pop("stale", None) + self.store.write_generation_spec(task_id, spec) + last_built = built + payload = self._result_payload(built, lifecycle="running", checkpoint=True) + yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "success", "revisionId": active_revision_id} + yield "cad_result", payload + break + except (CdslFragmentError, GenerationPlanError, QualityVerificationError, ReviewRenderError, VisualReviewError, ValueError, RuntimeError) as error: + feedback = str(error) + failure = { + "schema_version": "cad.generation-failure.v1", + "node_id": node_id, + "stage": attempt_kind, + "error_code": _error_code(error), + "message": feedback, + "requirement_ids": list(node.get("requirement_ids") or ()), + "selector": {"required_snapshot_id": required_snapshot}, + "geometry_delta": {}, + "recommended_rollback_revision": fragment_base_revision if "fragment_base_revision" in locals() else active_revision_id, + } + failure_path = self.store.write_generation_failure(task_id, failure) + yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "error", "attempt": attempt_kind, "message": feedback} + # A failed build never becomes the active base; retries are safe and deterministic. + continue + except Exception as error: + failure = { + "schema_version": "cad.generation-failure.v1", + "message": str(error), + "active_node_id": str((self.store.read_task(task_id) or {}).get("active_node_id") or ""), + } + if plan_diagnostic_path: + failure["plan_diagnostic_path"] = plan_diagnostic_path + self.store.finish_generation(task_id, lifecycle="failed", failure=failure) + yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "message": str(error)} + + async def _replan( + self, + spec: dict[str, Any], + node_id: str, + feedback: str, + provider: ProviderConfig, + model: ProviderModel, + author_messages: list[dict[str, Any]], + engine: Any, + ) -> dict[str, Any]: + raw = await self._call([ + {"role": "system", "content": "Replan only the failed node and its downstream nodes. Preserve completed node definitions and all requirements."}, + *author_messages, + {"role": "user", "content": json.dumps({"existing_plan": spec, "failed_node_id": node_id, "failure": feedback}, ensure_ascii=False)}, + ], provider, model, PLAN_TOOL, "plan_generation_task") + next_spec = validate_generation_plan(raw, supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), task_id=str(spec.get("task_id") or "")) + stale = {item["id"] for item in mark_nodes_stale(spec, node_id, reason="replan").get("nodes") or [] if item.get("stale")} + prior = {item["id"]: item for item in spec.get("nodes") or []} + for node in next_spec["nodes"]: + if node["id"] not in stale and node["id"] in prior: + old = prior[node["id"]] + for key in ("atomic_id", "depends_on", "cdsl_feature_ids"): + if node.get(key) != old.get(key): + raise IncrementalGenerationError(f"Replan changed non-stale node {node['id']}") + return next_spec + + @staticmethod + def _result_payload(built: dict[str, Any], *, lifecycle: str, checkpoint: bool) -> dict[str, Any]: + return { + "taskId": built["task_id"], "revisionId": built["revision_id"], + "cdslPath": built["cdsl_path"], "stepPath": built["step_path"], "glbPath": built["glb_path"], + "reportPath": built["report_path"], "parametersPath": built.get("parameters_path"), + "selectorPath": built.get("selector_path"), "edgesPath": built.get("edges_path"), "topologyPath": built.get("topology_path"), + "summary": built.get("summary") or "CDSL checkpoint", "referenceIds": built.get("reference_ids") or [], + "engine": built.get("engine") or "cdsl_only", "qualityStatus": built.get("quality_status") or "", + "qualityPath": built.get("quality_path"), "assumptions": built.get("generation_assumptions") or [], + "snapshotPaths": built.get("snapshot_paths") or [], "snapshotStatus": built.get("snapshot_status") or "unavailable", + "lifecycle": lifecycle, "checkpoint": checkpoint, + } diff --git a/backend/app/services/quality.py b/backend/app/services/quality.py index 72c6f90b..e352e6f3 100644 --- a/backend/app/services/quality.py +++ b/backend/app/services/quality.py @@ -8,9 +8,10 @@ from typing import Any QUALITY_RULE_TYPES = frozenset({ "bbox", "solid_count", "feature_count", "hole_count", "hole_diameter", - "hole_center", "overall_length", "overall_diameter", "through_condition", + "hole_center", "overall_length", "overall_width", "overall_height", + "overall_diameter", "through_condition", }) -_FEATURE_RULE_TYPES = {"hole_count", "hole_diameter", "hole_center", "through_condition"} +FEATURE_RULE_TYPES = frozenset({"hole_count", "hole_diameter", "hole_center", "through_condition"}) _SEVERITIES = {"blocking", "warning", "informational"} @@ -22,7 +23,12 @@ def _bbox_bounds(engine_result: dict[str, Any], feature_id: str | None = None) - if not isinstance(record, dict): continue owners = record.get("owner_feature_ids") or [] - if record.get("feature_id") == feature_id or feature_id in owners: + # Runtime records may carry the current operation in feature_id + # while owner_feature_ids identify the actual geometry owner. Use + # ownership when present so inherited faces do not pollute a + # feature-local measurement. + matches = feature_id in owners if owners else record.get("feature_id") == feature_id + if matches: geometry = record.get("geometry") if isinstance(geometry, dict): candidates.append(geometry.get("bbox_mm")) @@ -88,12 +94,64 @@ def _circles(profile: dict[str, Any]) -> list[dict[str, Any]]: ] +def _circle_feature_bbox(feature: dict[str, Any] | None, sketch: dict[str, Any] | None) -> dict[str, Any] | None: + """Derive a stable world-space bbox for a circular extrude feature.""" + if not isinstance(feature, dict) or not isinstance(sketch, dict): + return None + if str(feature.get("atomic_id") or "") != "extrude_add_blind": + return None + profile = sketch.get("profile") + workplane = sketch.get("workplane") + params = feature.get("params") + if not isinstance(profile, dict) or profile.get("type") != "circle" or not isinstance(workplane, dict): + return None + center_local = profile.get("center") + radius = profile.get("radius_mm") + origin = workplane.get("origin_mm") + x_dir = workplane.get("x_dir") + y_dir = workplane.get("y_dir") + normal = workplane.get("normal") + distance = (params or {}).get("distance_mm") if isinstance(params, dict) else None + if not isinstance(center_local, list) or len(center_local) < 2 or not _finite_number(radius): + return None + if not all(isinstance(value, list) and len(value) >= 3 for value in (origin, x_dir, y_dir, normal)) or not _finite_number(distance): + return None + center = [ + float(origin[index]) + float(center_local[0]) * float(x_dir[index]) + float(center_local[1]) * float(y_dir[index]) + for index in range(3) + ] + points = [] + for axis_x in (-1.0, 1.0): + for axis_y in (-1.0, 1.0): + cross_section = [ + center[index] + + axis_x * float(radius) * float(x_dir[index]) + + axis_y * float(radius) * float(y_dir[index]) + for index in range(3) + ] + points.append(cross_section) + points.append([ + cross_section[index] + float(distance) * float(normal[index]) + for index in range(3) + ]) + minimum = [min(point[index] for point in points) for index in range(3)] + maximum = [max(point[index] for point in points) for index in range(3)] + return { + "min": minimum, + "max": maximum, + "dimensions": [maximum[index] - minimum[index] for index in range(3)], + } + + def _finite_number(value: Any) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value)) def _validate_expected(kind: str, expected: Any, index: int) -> None: - scalar_types = {"solid_count", "feature_count", "hole_count", "hole_diameter", "overall_length", "overall_diameter"} + scalar_types = { + "solid_count", "feature_count", "hole_count", "hole_diameter", + "overall_length", "overall_width", "overall_height", "overall_diameter", + } if kind in scalar_types and not _finite_number(expected): raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}") if kind == "bbox": @@ -157,7 +215,7 @@ def validate_verification(verification: Any, cdsl: dict[str, Any]) -> list[dict[ if not math.isfinite(tolerance) or tolerance < 0: raise ValueError(f"verification.rules[{index}].tolerance must be finite and non-negative") feature = str(raw.get("feature") or "").strip() - if kind in _FEATURE_RULE_TYPES and not feature: + if kind in FEATURE_RULE_TYPES and not feature: raise ValueError(f"verification.rules[{index}].feature is required for {kind}") if feature and feature not in feature_ids: raise ValueError(f"verification.rules[{index}].feature must reference a CDSL feature ID") @@ -175,6 +233,9 @@ def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, circles = _circles(profile) if isinstance(profile, dict) else [] dimensions = _dimensions(engine_result) if kind == "bbox": + derived = _circle_feature_bbox(feature, sketch) + if target and derived is not None: + return derived, f"cdsl.features.{target}.sketch + params" value = _bbox_value(engine_result, target) source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm" return value, source @@ -194,7 +255,13 @@ def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, return (centers[0] if len(centers) == 1 else centers), f"cdsl.features.{target}.sketch" if kind == "overall_length": return (max(dimensions) if dimensions else None), "runtime.bbox_mm" + if kind == "overall_width": + return (dimensions[1] if dimensions and len(dimensions) > 1 else None), "runtime.bbox_mm[y]" + if kind == "overall_height": + return (dimensions[2] if dimensions and len(dimensions) > 2 else None), "runtime.bbox_mm[z]" if kind == "overall_diameter": + if target and circles: + return max(circle["radius_mm"] for circle in circles) * 2.0, f"cdsl.features.{target}.sketch" return (min(dimensions) if dimensions else None), "runtime.bbox_mm" if kind == "through_condition": end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {} diff --git a/backend/app/services/review_renderer.py b/backend/app/services/review_renderer.py new file mode 100644 index 00000000..1c5d79f2 --- /dev/null +++ b/backend/app/services/review_renderer.py @@ -0,0 +1,332 @@ +"""Deterministic, CPU-only CAD technical renders for visual review. + +OpenCascade computes exact visible/hidden edges from the revision STEP file. +Pillow rasterizes the resulting technical drawings. Neither stage needs a web +browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence +consistent on macOS, Linux, and Windows workers. +""" + +from __future__ import annotations + +import importlib +import json +import math +from pathlib import Path +from typing import Any + +from app.settings import Settings + + +CANONICAL_VIEWS = ("top", "bottom", "front", "back", "left", "right", "isometric") +RENDER_SIZE = 2048 +REVIEW_SIZE = 1024 +FRAME_PADDING = 0.14 +BACKGROUND_RGB = (246, 248, 251) +VISIBLE_EDGE_RGB = (34, 54, 69) +HIDDEN_EDGE_RGB = (142, 157, 170) + + +class ReviewRenderError(RuntimeError): + """The fixed-view renderer was unavailable or produced incomplete evidence.""" + + +def renderer_status() -> tuple[bool, str]: + """Verify that the pure-Python/OCC renderer dependencies are importable.""" + try: + _render_modules() + except ReviewRenderError as error: + return False, str(error) + return True, "" + + +def _render_modules() -> tuple[Any, Any, Any]: + try: + pillow_image = importlib.import_module("PIL.Image") + pillow_draw = importlib.import_module("PIL.ImageDraw") + import_step = importlib.import_module("build123d").import_step + except (ImportError, AttributeError) as error: + raise ReviewRenderError( + "Python technical renderer is unavailable; install backend requirements (build123d and Pillow)" + ) from error + return pillow_image, pillow_draw, import_step + + +def _number_list(value: Any, *, size: int) -> list[float] | None: + if not isinstance(value, list) or len(value) < size: + return None + try: + values = [float(item) for item in value[:size]] + except (TypeError, ValueError): + return None + return values if all(math.isfinite(item) for item in values) else None + + +def _bounds_center(bounds: list[float]) -> list[float]: + return [ + (bounds[0] + bounds[1]) / 2, + (bounds[2] + bounds[3]) / 2, + (bounds[4] + bounds[5]) / 2, + ] + + +def _shape_bounds(shape: Any) -> list[float]: + box = shape.bounding_box() + bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)] + if not all(math.isfinite(value) for value in bounds): + raise ReviewRenderError("STEP review source has invalid bounds") + return bounds + + +def _target_frame(target: dict[str, Any] | None, model_bounds: list[float]) -> tuple[list[float], float]: + model_center = _bounds_center(model_bounds) + model_extent = max(model_bounds[1] - model_bounds[0], model_bounds[3] - model_bounds[2], model_bounds[5] - model_bounds[4], 1.0) + if not isinstance(target, dict): + return model_center, 0.0 + bbox = _number_list(target.get("bbox_mm"), size=6) + if bbox and bbox[3] > bbox[0] and bbox[4] > bbox[1] and bbox[5] > bbox[2]: + center = [(bbox[0] + bbox[3]) / 2, (bbox[1] + bbox[4]) / 2, (bbox[2] + bbox[5]) / 2] + extent = max(bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2], 1.0) + return center, min(model_extent, extent * 1.6) + center = _number_list(target.get("center_mm"), size=3) + try: + radius = float(target.get("radius_mm")) + except (TypeError, ValueError): + radius = 0.0 + if center and math.isfinite(radius) and radius > 0: + return center, min(model_extent, max(radius * 2, 1.0) * 1.6) + return model_center, 0.0 + + +def _camera_for(view_id: str, center: list[float]) -> dict[str, Any]: + directions = { + "top": ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]), + "bottom": ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]), + "front": ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]), + "back": ([0.0, 1.0, 0.0], [0.0, 0.0, 1.0]), + "left": ([-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + "right": ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + "isometric": ([1.0, -1.0, 0.8], [0.0, 0.0, 1.0]), + } + direction, view_up = directions.get(view_id, directions["isometric"]) + length = math.sqrt(sum(item * item for item in direction)) or 1.0 + normal = [item / length for item in direction] + # Orthographic HLR ignores the distance, but a large deterministic value + # makes the intended camera convention explicit in the manifest. + position = [center[index] + normal[index] * 100000.0 for index in range(3)] + return {"projection": "orthographic", "position": position, "focal_point": center, "view_up": view_up} + + +def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]: + count = max(2, min(1024, int(math.ceil(float(edge.length) / max(spacing, 0.002))) + 1)) + try: + points = edge.positions([index / (count - 1) for index in range(count)]) + except Exception: + points = [edge.position_at(0), edge.position_at(1)] + return [(float(point.X), float(point.Y)) for point in points] + + +def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]: + points = [point for edge in edges for point in _edge_points(edge, 0.5)] + if not points: + raise ReviewRenderError("Hidden-line projection produced no drawable edges") + xs, ys = zip(*points) + return min(xs), max(xs), min(ys), max(ys) + + +def _frame_bounds(edges: list[Any], target_extent: float) -> tuple[float, float, float, float]: + min_x, max_x, min_y, max_y = _projected_bounds(edges) + if target_extent > 0: + # HLR maps the look-at target to the projection origin, making this + # an exact, deterministic local crop without a GPU clipping plane. + half = target_extent / (2 * (1 - 2 * FRAME_PADDING)) + return -half, half, -half, half + center_x, center_y = (min_x + max_x) / 2, (min_y + max_y) / 2 + extent = max(max_x - min_x, max_y - min_y, 1.0) + half = extent / (2 * (1 - 2 * FRAME_PADDING)) + return center_x - half, center_x + half, center_y - half, center_y + half + + +def _pixel(point: tuple[float, float], frame: tuple[float, float, float, float], size: int) -> tuple[int, int]: + min_x, max_x, min_y, max_y = frame + x = round((point[0] - min_x) * (size - 1) / (max_x - min_x)) + y = round((max_y - point[1]) * (size - 1) / (max_y - min_y)) + return int(x), int(y) + + +def _draw_dashed(draw: Any, points: list[tuple[int, int]], *, fill: tuple[int, int, int], width: int) -> None: + dash, gap = 16, 10 + for start, end in zip(points, points[1:]): + dx, dy = end[0] - start[0], end[1] - start[1] + length = math.hypot(dx, dy) + if length <= 0: + continue + distance = 0.0 + while distance < length: + segment_end = min(length, distance + dash) + first = (round(start[0] + dx * distance / length), round(start[1] + dy * distance / length)) + last = (round(start[0] + dx * segment_end / length), round(start[1] + dy * segment_end / length)) + draw.line((first, last), fill=fill, width=width) + distance += dash + gap + + +def _rasterize( + *, + visible: list[Any], + hidden: list[Any], + frame: tuple[float, float, float, float], + output_dir: Path, + view_id: str, + intentional_crop: bool, +) -> dict[str, Any]: + pillow_image, pillow_draw, _ = _render_modules() + image = pillow_image.new("RGB", (RENDER_SIZE, RENDER_SIZE), BACKGROUND_RGB) + mask = pillow_image.new("L", (RENDER_SIZE, RENDER_SIZE), 0) + draw = pillow_draw.Draw(image) + mask_draw = pillow_draw.Draw(mask) + spacing = max((frame[1] - frame[0]) / 1800, 0.01) + for edge in hidden: + points = [_pixel(point, frame, RENDER_SIZE) for point in _edge_points(edge, spacing)] + _draw_dashed(draw, points, fill=HIDDEN_EDGE_RGB, width=3) + _draw_dashed(mask_draw, points, fill=128, width=4) + for edge in visible: + points = [_pixel(point, frame, RENDER_SIZE) for point in _edge_points(edge, spacing)] + if len(points) >= 2: + draw.line(points, fill=VISIBLE_EDGE_RGB, width=4, joint="curve") + mask_draw.line(points, fill=255, width=5, joint="curve") + high_path = output_dir / "internal" / f"{view_id}-2x.png" + high_path.parent.mkdir(parents=True, exist_ok=True) + image.save(high_path, optimize=True) + output_path = output_dir / f"{view_id}.png" + image.resize((REVIEW_SIZE, REVIEW_SIZE), resample=pillow_image.Resampling.LANCZOS).save(output_path, optimize=True) + diagnostic_dir = output_dir / "internal" / view_id + diagnostic_dir.mkdir(parents=True, exist_ok=True) + mask_path = diagnostic_dir / "line-mask.png" + mask.save(mask_path) + edge_path = diagnostic_dir / "edge.png" + mask.save(edge_path) + box = mask.getbbox() + coverage = (RENDER_SIZE * RENDER_SIZE - mask.histogram()[0]) / (RENDER_SIZE * RENDER_SIZE) + pixel_bbox = list(box) if box else [] + touches_border = bool(box and (box[0] <= 1 or box[1] <= 1 or box[2] >= RENDER_SIZE - 1 or box[3] >= RENDER_SIZE - 1)) + valid = bool(box and coverage >= 0.00005 and coverage <= 0.20 and (intentional_crop or not touches_border)) + return { + "path": str(output_path), + "high_resolution_path": str(high_path), + "diagnostics": { + "line_mask_path": str(mask_path), + "edge_path": str(edge_path), + "coverage": coverage, + "pixel_bbox": pixel_bbox, + "touches_border": touches_border, + "intentional_crop": intentional_crop, + "visible_edge_count": len(visible), + "hidden_edge_count": len(hidden), + "valid": valid, + }, + } + + +def _render_view( + *, + shape: Any, + view_id: str, + projection_id: str, + target: dict[str, Any] | None, + model_bounds: list[float], + output_dir: Path, +) -> dict[str, Any]: + center, target_extent = _target_frame(target, model_bounds) + camera = _camera_for(projection_id, center) + try: + visible, hidden = shape.project_to_viewport( + camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"] + ) + except Exception as error: + raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error + visible_edges, hidden_edges = list(visible), list(hidden) + frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent) + rendered = _rasterize( + visible=visible_edges, + hidden=hidden_edges, + frame=frame, + output_dir=output_dir, + view_id=view_id, + intentional_crop=target_extent > 0, + ) + if not rendered["diagnostics"]["valid"]: + raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}") + return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered} + + +def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str: + """Create compact whole-model evidence for routine reviewer calls.""" + pillow_image, pillow_draw, _ = _render_modules() + canonical = [item for item in views if item["id"] in CANONICAL_VIEWS] + if not canonical: + return "" + tile = 400 + sheet = pillow_image.new("RGB", (tile * 3, tile * 3), BACKGROUND_RGB) + draw = pillow_draw.Draw(sheet) + for index, item in enumerate(canonical): + image = pillow_image.open(str(item["path"])).convert("RGB").resize((tile, tile), resample=pillow_image.Resampling.LANCZOS) + x, y = (index % 3) * tile, (index // 3) * tile + sheet.paste(image, (x, y)) + draw.rectangle((x + 8, y + 8, x + 96, y + 33), fill=(255, 255, 255)) + draw.text((x + 14, y + 13), str(item["id"]), fill=VISIBLE_EDGE_RGB) + path = output_dir / "contact-sheet.jpg" + sheet.save(path, quality=88, optimize=True, progressive=True) + return str(path) + + +def render_checkpoint( + settings: Settings, + *, + step_path: Path, + output_dir: Path, + review_targets: list[dict[str, Any]] | None = None, + include_canonical: bool = True, +) -> dict[str, Any]: + """Render STEP geometry into stable canonical and bounded node-detail views.""" + del settings + ready, detail = renderer_status() + if not ready: + raise ReviewRenderError(detail) + if not step_path.is_file(): + raise ReviewRenderError(f"STEP review source is missing: {step_path.name}") + _, _, import_step = _render_modules() + try: + shape = import_step(str(step_path)) + except Exception as error: + raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error + bounds = _shape_bounds(shape) + output_dir.mkdir(parents=True, exist_ok=True) + jobs: list[tuple[str, dict[str, Any] | None]] = [] + if include_canonical: + jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS) + jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3])) + views = [ + _render_view( + shape=shape, + view_id=view_id, + projection_id="isometric" if view_id.startswith("detail-") else view_id, + target=target, + model_bounds=bounds, + output_dir=output_dir, + ) + for view_id, target in jobs + ] + canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")} + if include_canonical and canonical != set(CANONICAL_VIEWS): + raise ReviewRenderError("Python review renderer did not produce every canonical view") + contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else "" + manifest = { + "schema_version": "cad.render-manifest.v2", + "renderer": "python-occ-hlr-pillow", + "source": {"type": "step", "path": str(step_path), "bounds_mm": bounds}, + "high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"}, + "review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"}, + "contact_sheet_path": contact_sheet_path, + "views": views, + } + (output_dir / "render-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + return manifest diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index fa1459f7..6eea8e9d 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -181,21 +181,62 @@ class WorkspaceStore: path = self.task_path(tid) current = read_json(path) if current: - return current + return self._migrate_task(current, path) task_dir = self.task_dir(tid) (task_dir / "revisions").mkdir(parents=True, exist_ok=True) record = { - "schema_version": "1.2", + "schema_version": "1.3", "task_id": tid, "request": request, "created_at": now_iso(), "updated_at": now_iso(), "current_revision": "", + "active_revision": "", + "published_revision": "", + "lifecycle": "completed", + "run_id": "", + "generation_spec_path": "", + "run_context_path": "", + "active_node_id": "", + "run_failure_path": "", "revisions": [], } write_json(path, record) return record + def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]: + """Add run-state fields lazily without rewriting successful history.""" + changed = False + current = str(task.get("current_revision") or "") + defaults = { + "schema_version": "1.3", + "active_revision": current, + "published_revision": current, + "lifecycle": "completed", + "run_id": "", + "generation_spec_path": "", + "run_context_path": "", + "active_node_id": "", + "run_failure_path": "", + } + for key, value in defaults.items(): + if key not in task: + task[key] = value + changed = True + for revision in task.get("revisions") or (): + if not isinstance(revision, dict): + continue + if "visibility" not in revision: + revision["visibility"] = "final" if str(revision.get("revision_id") or "") == str(task["published_revision"] or "") else "checkpoint" + changed = True + if "branch_id" not in revision: + revision["branch_id"] = "main" + changed = True + if changed: + task["updated_at"] = now_iso() + write_json(path, task) + return task + 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}" @@ -208,16 +249,225 @@ class WorkspaceStore: task["revisions"].append(revision) if revision.get("status") == "success": task["current_revision"] = revision["revision_id"] + task["active_revision"] = revision["revision_id"] + if revision.get("visibility") == "final": + task["published_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)) + task = read_json(self.task_path(task_id)) + return self._migrate_task(task, self.task_path(task_id)) if isinstance(task, dict) else None + + def start_generation(self, task_id: str, *, request: str, run_id: str | None = None) -> dict[str, Any]: + task = self.ensure_task(task_id, request) + if str(task.get("lifecycle") or "") == "running": + raise ValueError("CAD task is already running") + task.update({ + "lifecycle": "running", + "run_id": run_id or new_id("run"), + "active_node_id": "", + "run_failure_path": "", + "request": request or task.get("request") or "", + "active_revision": str(task.get("current_revision") or ""), + "updated_at": now_iso(), + }) + write_json(self.task_path(task_id), task) + return task + + def finish_generation(self, task_id: str, *, lifecycle: str, failure: dict[str, Any] | None = None) -> dict[str, Any]: + if lifecycle not in {"completed", "failed"}: + raise ValueError("Generation lifecycle must be completed or failed") + task = self.ensure_task(task_id, "") + failure_path = "" + if failure: + failure_path = "run-failures/" + f"failure_{secrets.token_hex(8)}.json" + write_json(self.task_dir(task_id) / failure_path, failure) + if lifecycle == "completed": + task["published_revision"] = str(task.get("active_revision") or task.get("current_revision") or "") + for revision in task.get("revisions") or (): + if isinstance(revision, dict) and revision.get("revision_id") == task["published_revision"]: + revision["visibility"] = "final" + task.update({ + "lifecycle": lifecycle, + "active_node_id": "", + "run_failure_path": failure_path, + "updated_at": now_iso(), + }) + write_json(self.task_path(task_id), task) + return task + + def set_active_revision(self, task_id: str, revision_id: str, *, branch_id: str | None = None) -> dict[str, Any]: + task = self.ensure_task(task_id, "") + if not revision_id: + task["active_revision"] = "" + task["current_revision"] = "" + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None) + if not isinstance(revision, dict) or revision.get("status") != "success": + raise ValueError("Active revision must be a successful revision") + task["active_revision"] = revision_id + task["current_revision"] = revision_id + if branch_id: + task["active_branch_id"] = branch_id + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + + def set_active_node(self, task_id: str, node_id: str) -> dict[str, Any]: + task = self.ensure_task(task_id, "") + task["active_node_id"] = node_id + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + + def update_revision_metadata(self, task_id: str, revision_id: str, values: dict[str, Any]) -> dict[str, Any]: + task = self.ensure_task(task_id, "") + revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None) + if not isinstance(revision, dict): + raise ValueError("Revision does not exist") + revision.update(values) + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + + def rollback_to_revision(self, task_id: str, revision_id: str, *, branch_id: str) -> dict[str, Any]: + """Move the generation head without deleting immutable checkpoint artifacts.""" + task = self.set_active_revision(task_id, revision_id, branch_id=branch_id) + children: dict[str, set[str]] = {} + for revision in task.get("revisions") or (): + if not isinstance(revision, dict): + continue + parent = str(revision.get("parent_revision_id") or "") + child = str(revision.get("revision_id") or "") + if parent and child: + children.setdefault(parent, set()).add(child) + superseded: set[str] = set() + pending = list(children.get(revision_id, set())) if revision_id else [ + str(item.get("revision_id") or "") + for item in task.get("revisions") or () + if isinstance(item, dict) and not str(item.get("parent_revision_id") or "") + ] + while pending: + child = pending.pop() + if not child or child in superseded: + continue + superseded.add(child) + pending.extend(children.get(child, set())) + for revision in task.get("revisions") or (): + if isinstance(revision, dict) and str(revision.get("revision_id") or "") in superseded and revision.get("visibility") == "checkpoint": + revision["visibility"] = "superseded" + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + + def rollback_anchor_for_nodes( + self, + task_id: str, + node_ids: list[str], + *, + fallback_revision_id: str = "", + ) -> str: + """Return the revision before every affected node's latest checkpoint. + + Returning each affected revision's parent (rather than the revision + itself) ensures the faulty node is regenerated. A common ancestor + keeps unrelated upstream work intact while permitting a single rollback + over any number of affected nodes. + """ + task = self.read_task(task_id) or {} + revisions = [item for item in task.get("revisions") or () if isinstance(item, dict)] + by_id = {str(item.get("revision_id") or ""): item for item in revisions} + parents: list[str] = [] + for node_id in dict.fromkeys(str(item) for item in node_ids if str(item)): + matching = [item for item in revisions if item.get("status") == "success" and str(item.get("node_id") or "") == node_id] + if matching: + parents.append(str(matching[-1].get("parent_revision_id") or "")) + if not parents: + return fallback_revision_id + + def lineage(revision_id: str) -> list[str]: + chain = [revision_id] + seen = {revision_id} + current = revision_id + while current: + parent = str((by_id.get(current) or {}).get("parent_revision_id") or "") + if parent in seen: + break + chain.append(parent) + seen.add(parent) + current = parent + return chain + + common = set(lineage(parents[0])) + for parent in parents[1:]: + common.intersection_update(lineage(parent)) + if not common: + return fallback_revision_id + return next((revision for revision in lineage(parents[0]) if revision in common), fallback_revision_id) + + def write_generation_failure(self, task_id: str, payload: dict[str, Any]) -> str: + """Persist an attempt-level diagnostic without changing lifecycle.""" + relative = Path("generation-failures") / f"failure_{secrets.token_hex(8)}.json" + write_json(self.task_dir(task_id) / relative, payload) + return relative.as_posix() + + def generation_spec_path(self, task_id: str) -> Path: + return self.task_dir(task_id) / "generation-spec.json" + + def write_generation_spec(self, task_id: str, spec: dict[str, Any]) -> Path: + task = self.ensure_task(task_id, "") + path = self.generation_spec_path(task_id) + write_json(path, spec) + task["generation_spec_path"] = path.relative_to(self.task_dir(task_id)).as_posix() + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return path + + def generation_run_context_path(self, task_id: str) -> Path: + return self.task_dir(task_id) / "generation-run-context.json" + + def write_generation_run_context(self, task_id: str, context: dict[str, Any]) -> Path: + """Persist the frozen authoring inputs needed to resume after restart.""" + task = self.ensure_task(task_id, "") + path = self.generation_run_context_path(task_id) + write_json(path, context) + task["run_context_path"] = path.relative_to(self.task_dir(task_id)).as_posix() + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return path + + def read_generation_run_context(self, task_id: str) -> dict[str, Any] | None: + task = self.read_task(task_id) or {} + relative = str(task.get("run_context_path") or "") + context = read_json(self.artifact_path(task_id, relative)) if relative else None + return context if isinstance(context, dict) else None + + def running_tasks(self) -> list[dict[str, Any]]: + """Enumerate durable tasks that need a process-local worker.""" + tasks: list[dict[str, Any]] = [] + for candidate in self.settings.task_root.glob("cad_*"): + if not candidate.is_dir() or not TASK_ID.fullmatch(candidate.name): + continue + task = self.read_task(candidate.name) + if isinstance(task, dict) and task.get("lifecycle") == "running": + tasks.append(task) + return tasks + + def read_generation_spec(self, task_id: str) -> dict[str, Any] | None: + task = self.read_task(task_id) or {} + relative = str(task.get("generation_spec_path") or "") + return read_json(self.artifact_path(task_id, relative)) if relative else None + + def revision_dir(self, task_id: str, revision_id: str) -> Path: + return self.task_dir(task_id) / "revisions" / revision_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 "") + revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "") if not revision_id: return None candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json" @@ -235,6 +485,52 @@ class WorkspaceStore: return revision_id, path return None + def feature_plan_path(self, task_id: str) -> Path: + return self.task_dir(task_id) / "feature-plan.json" + + def read_feature_plan(self, task_id: str) -> dict[str, Any] | None: + safe_task = safe_task_id(task_id) + plan = read_json(self.feature_plan_path(safe_task)) + if not isinstance(plan, dict): + return None + plan_task = str(plan.get("task_id") or "") + if plan_task and plan_task != safe_task: + return None + return plan + + def write_feature_plan(self, task_id: str, plan: dict[str, Any]) -> Path: + safe_task = safe_task_id(task_id) + if not isinstance(plan, dict): + raise ValueError("Feature plan must be an object") + plan_task = str(plan.get("task_id") or "") + if plan_task and plan_task != safe_task: + raise ValueError("Feature plan task_id does not match its task") + plan["task_id"] = safe_task + path = self.feature_plan_path(safe_task) + write_json(path, plan) + return path + + def revision_topology_path(self, task_id: str, revision_id: str) -> Path | None: + task = self.read_task(task_id) + revision = next( + (item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id), + None, + ) + if not isinstance(revision, dict): + return None + relative = str(revision.get("topology_path") or "") + if not relative: + return None + candidate = self.artifact_path(task_id, relative) + return candidate if candidate.is_file() else None + + def current_topology_path(self, task_id: str) -> Path | None: + task = self.read_task(task_id) + revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "") + if not revision_id: + return None + return self.revision_topology_path(task_id, revision_id) + def revision_cdsl_path(self, task_id: str, revision_id: str) -> Path | None: task = self.read_task(task_id) revision = next( diff --git a/backend/app/services/visual_review.py b/backend/app/services/visual_review.py new file mode 100644 index 00000000..c2fecf27 --- /dev/null +++ b/backend/app/services/visual_review.py @@ -0,0 +1,141 @@ +"""Independent, structured visual review of generated checkpoint renders.""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path +from typing import Any + +import httpx + +from app.settings import ProviderConfig, ProviderModel, Settings + + +VISUAL_REVIEW_TOOL = { + "type": "function", + "function": { + "name": "review_rendered_checkpoint", + "description": "Review fixed CAD render views against frozen requirements. Never author or modify CDSL.", + "parameters": { + "type": "object", + "properties": { + "verdict": {"enum": ["pass", "warning", "repair"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "affected_node_ids": {"type": "array", "items": {"type": "string"}, "maxItems": 12}, + "requirement_ids": {"type": "array", "items": {"type": "string"}, "maxItems": 32}, + "evidence": {"type": "array", "items": {"type": "string"}, "maxItems": 12}, + }, + "required": ["verdict", "confidence", "affected_node_ids", "requirement_ids", "evidence"], + "additionalProperties": False, + }, + }, +} + + +class VisualReviewError(RuntimeError): + pass + + +def _image_part(path: Path) -> dict[str, Any]: + encoded = base64.b64encode(path.read_bytes()).decode("ascii") + media = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png" + return {"type": "image_url", "image_url": {"url": f"data:{media};base64,{encoded}"}} + + +def _selected_review_views(manifest: dict[str, Any], *, final_checkpoint: bool) -> list[dict[str, Any]]: + """Keep each reviewer call bounded while canonical evidence stays archived. + + A contact sheet establishes global context. Up to two planned detail views + provide node-specific evidence. The final checkpoint adds full canonical + views because it is the only point where those extra image tokens pay off. + """ + views = [item for item in manifest.get("views") or () if isinstance(item, dict)] + by_id = {str(item.get("id") or ""): item for item in views} + selected: list[dict[str, Any]] = [] + contact = Path(str(manifest.get("contact_sheet_path") or "")) + if contact.is_file(): + selected.append({"id": "contact-sheet", "path": str(contact), "camera": {"projection": "mixed"}}) + for view_id in ("detail-1", "detail-2"): + item = by_id.get(view_id) + if item is not None: + selected.append(item) + if final_checkpoint: + selected.extend(by_id[view_id] for view_id in ("top", "bottom", "front", "back", "left", "right", "isometric") if view_id in by_id) + elif not selected and "isometric" in by_id: + selected.append(by_id["isometric"]) + return selected or views[:1] + + +async def review_checkpoint( + settings: Settings, + *, + manifest: dict[str, Any], + requirements: list[dict[str, Any]], + node_id: str, + deterministic_report: dict[str, Any], + source_images: list[Path] | None = None, + final_checkpoint: bool = False, +) -> dict[str, Any]: + provider, model = settings.resolve_review_model() + views = _selected_review_views(manifest, final_checkpoint=final_checkpoint) + paths = [Path(str(item.get("path") or "")) for item in views] + if not paths or not all(path.is_file() for path in paths): + raise VisualReviewError("Review render manifest references missing image files") + content: list[dict[str, Any]] = [{ + "type": "text", + "text": json.dumps({ + "node_id": node_id, + "requirements": requirements, + "deterministic_report": deterministic_report, + "render_manifest": { + "renderer": manifest.get("renderer"), + "source": manifest.get("source"), + "views": [{"id": item.get("id"), "camera": item.get("camera"), "diagnostics": item.get("diagnostics")} for item in views], + }, + "instruction": "Identify visible missing geometry, wrong silhouette, orientation, or proportion. Do not infer hidden dimensions. Return repair only for an observable issue.", + }, ensure_ascii=False), + }] + content.extend(_image_part(path) for path in paths) + # Reference images are only supplementary evidence. Keep this bounded so + # an attachment-heavy request does not dominate every checkpoint review. + for path in (source_images or [])[:2]: + if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}: + content.append(_image_part(path)) + tool = json.loads(json.dumps(VISUAL_REVIEW_TOOL)) + if model.strict_tool_schema: + tool["function"]["strict"] = True + payload = { + "model": model.id, + "messages": [ + {"role": "system", "content": "You are an independent CAD visual reviewer. You may only call review_rendered_checkpoint."}, + {"role": "user", "content": content}, + ], + "tools": [tool], + "tool_choice": {"type": "function", "function": {"name": "review_rendered_checkpoint"}}, + "temperature": 0, + } + headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"} + async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client: + response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload) + if response.status_code >= 400: + raise VisualReviewError(f"Visual review request failed ({response.status_code}): {response.text[:500]}") + try: + call = response.json()["choices"][0]["message"]["tool_calls"][0] + if call["function"]["name"] != "review_rendered_checkpoint": + raise KeyError("wrong tool") + result = json.loads(call["function"]["arguments"]) + except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error: + raise VisualReviewError("Visual reviewer did not return a valid review tool call") from error + if not isinstance(result, dict) or result.get("verdict") not in {"pass", "warning", "repair"}: + raise VisualReviewError("Visual reviewer returned an invalid verdict") + try: + confidence = float(result.get("confidence")) + except (TypeError, ValueError) as error: + raise VisualReviewError("Visual reviewer returned an invalid confidence") from error + if not 0 <= confidence <= 1: + raise VisualReviewError("Visual reviewer confidence is outside [0, 1]") + for key in ("affected_node_ids", "requirement_ids", "evidence"): + if not isinstance(result.get(key), list) or not all(isinstance(item, str) for item in result[key]): + raise VisualReviewError(f"Visual reviewer returned an invalid {key}") + return {"schema_version": "cad.visual-review.v1", "node_id": node_id, "model": model.id, **result} diff --git a/backend/app/settings.py b/backend/app/settings.py index ea015be5..9f657ade 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -51,6 +51,12 @@ class Settings: default_provider_id: str providers: tuple[ProviderConfig, ...] max_repair_attempts: int = 4 + review_provider_id: str = "" + review_model_id: str = "" + node_authoring_attempts: int = 2 + node_repair_attempts: int = 2 + node_replan_attempts: int = 1 + incremental_generation: bool = False @property def llm_configured(self) -> bool: @@ -70,6 +76,22 @@ class Settings: raise ValueError("The selected model is not enabled for this provider") return provider, model + def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]: + """Return the independently configured visual reviewer, never an author fallback.""" + provider_id = self.review_provider_id + if not provider_id: + raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider") + provider = self.provider_for(provider_id) + if provider is None: + raise ValueError("The configured visual review provider is unavailable") + model_id = self.review_model_id or "" + if not model_id: + raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model") + model = provider.model(model_id) + if model is None or not model.vision: + raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model") + return provider, model + def _enabled_model_ids(value: str) -> set[str]: return {item.strip() for item in value.split(",") if item.strip()} @@ -141,4 +163,10 @@ def get_settings() -> Settings: max_repair_attempts=max(0, int(os.getenv("CDSL_MAX_REPAIR_ATTEMPTS", "4"))), default_provider_id=default_provider_id, providers=providers, + review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(), + review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(), + node_authoring_attempts=max(1, int(os.getenv("CDSL_NODE_AUTHORING_ATTEMPTS", "2"))), + node_repair_attempts=max(0, int(os.getenv("CDSL_NODE_REPAIR_ATTEMPTS", "2"))), + node_replan_attempts=max(0, int(os.getenv("CDSL_NODE_REPLAN_ATTEMPTS", "1"))), + incremental_generation=_as_bool(os.getenv("CDSL_INCREMENTAL_GENERATION", "1")), ) diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index ece7b8be..305fcdfb 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -249,7 +249,8 @@ "stable_id": {"type": "string", "minLength": 1}, "owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "geometry": {"type": "object"}, - "source": {"enum": ["solidworks", "inferred_from_step"]}, + "source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]}, + "snapshot_id": {"type": "string", "minLength": 1}, "confidence": {"type": "number", "minimum": 0, "maximum": 1} }, "required": ["kind", "stable_id", "source", "confidence"], diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index f3aa4ff8..4d5295dd 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -89,6 +89,7 @@ class ExecutionSession: results: dict[str, FeatureResult] = field(default_factory=dict) replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict) selector_resolutions: list[dict[str, Any]] = field(default_factory=list) + active_feature_id: str = "" def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None: self.body = body @@ -103,7 +104,9 @@ class ExecutionSession: def resolve(self, selector: dict[str, Any]) -> SelectorResolution: resolution = self.topology.resolve(selector, active_body_id=self.body_id) - self.selector_resolutions.append(resolution.as_dict()) + evidence = resolution.as_dict() + evidence["feature_id"] = self.active_feature_id + self.selector_resolutions.append(evidence) return resolution def result(self, node: FeaturePlanNode, *, context: PlaneSpec | AxisSpec | None = None, diagnostics: list[RuntimeDiagnostic] | None = None) -> FeatureResult: @@ -677,7 +680,12 @@ def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_overr executor = EXECUTORS.get(node.atomic_id) if executor is None: raise ValueError(f"No executor registered for {node.atomic_id!r}") - return executor(node, session, sketch_override) + previous_feature_id = session.active_feature_id + session.active_feature_id = node.feature_id + try: + return executor(node, session, sketch_override) + finally: + session.active_feature_id = previous_feature_id ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult] diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index de753079..242c1f39 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -381,6 +381,13 @@ class SelectorResolution: } if self.record is not None: output["record"] = self.record.public_dict() + output["selected"] = self.record.public_dict() + score = next( + (candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id), + None, + ) + if score is not None: + output["score"] = score if self.diagnostic is not None: output["diagnostic"] = self.diagnostic.as_dict() return output @@ -589,6 +596,66 @@ class TopologyRegistry: if owner: candidates = [record for record in candidates if owner in record.owners] geometry = normalize_selector_geometry(selector.get("geometry")) + if selector.get("snapshot_id") and not owner: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_owner_required", + message="A snapshot selector requires owner_feature_id", + detail={"minimum_score": minimum_score}, + ), + ) + stable_id = str(selector.get("stable_id") or "").strip() + if stable_id: + exact = [record for record in candidates if record.record_id == stable_id] + if len(exact) == 1: + record = exact[0] + # A stable ID is only a lookup accelerator for snapshot-aware + # selectors. It cannot revive a B-rep entity whose geometric + # signature changed after an upstream rebuild. + if selector.get("snapshot_id"): + score = self._geometry_score(geometry, record.geometry) if geometry else None + if score is None or score < minimum_score: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=({"score": round(float(score or 0), 6), **record.public_dict()},), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="The stable selector record no longer matches its geometry signature", + detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score}, + ), + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), + ) + if len(exact) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact), + diagnostic=RuntimeDiagnostic( + code="selector_ambiguous", + message="More than one runtime topology record has the requested stable_id", + detail={"stable_id": stable_id, "candidate_count": len(exact)}, + ), + ) + if selector.get("snapshot_id") and not geometry: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="A snapshot selector requires a geometry signature", + detail={"minimum_score": minimum_score}, + ), + ) scored: list[tuple[float, TopologyRecord]] = [] for candidate in candidates: # An owner-qualified context selector is deterministic when it has diff --git a/backend/requirements-vision.txt b/backend/requirements-vision.txt new file mode 100644 index 00000000..c7156dd5 --- /dev/null +++ b/backend/requirements-vision.txt @@ -0,0 +1,2 @@ +-r requirements.txt +opencv-python-headless>=4.9,<5 diff --git a/backend/requirements.txt b/backend/requirements.txt index b5249183..85fbc38f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ uvicorn[standard]>=0.30,<1 build123d python-multipart>=0.0.9,<1 jsonschema>=4.23,<5 +Pillow>=10,<12 diff --git a/backend/scripts/cleanup_legacy_attachments.py b/backend/scripts/cleanup_legacy_attachments.py new file mode 100644 index 00000000..820d7ad4 --- /dev/null +++ b/backend/scripts/cleanup_legacy_attachments.py @@ -0,0 +1,97 @@ +"""Explicitly remove attachments belonging to conversations without v2 image observations. + +The command is dry-run by default. It only mutates data when ``--apply`` is +provided, and it never removes CAD task artifacts. +""" + +from __future__ import annotations + +import argparse +import shutil +from pathlib import Path +import sys + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from app.services.storage import read_json, write_json +from app.settings import get_settings + + +def _has_v2_observation(record: dict) -> bool: + for message in record.get("messages") or []: + if not isinstance(message, dict): + continue + for part in message.get("parts") or []: + if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": + continue + data = part.get("data") + if isinstance(data, dict) and str(data.get("schemaVersion") or data.get("schema_version") or "") == "cad.image-observation.v2": + return True + return False + + +def plan_cleanup(root: Path) -> list[tuple[Path, str]]: + planned: list[tuple[Path, str]] = [] + for conversation_dir in sorted(root.glob("conv_*")): + record_path = conversation_dir / "conversation.json" + record = read_json(record_path) + if not isinstance(record, dict) or _has_v2_observation(record): + continue + uploads = conversation_dir / "uploads" + if uploads.is_dir(): + planned.append((uploads, "legacy upload directory")) + planning = conversation_dir / "planning" + if planning.is_dir(): + planned.append((planning, "legacy planning directory")) + if record.get("attachments"): + planned.append((record_path, "remove legacy attachment metadata and image-analysis parts")) + return planned + + +def apply_cleanup(root: Path) -> int: + changed = 0 + for conversation_dir in sorted(root.glob("conv_*")): + record_path = conversation_dir / "conversation.json" + record = read_json(record_path) + if not isinstance(record, dict) or _has_v2_observation(record): + continue + for relative in ("uploads", "planning"): + target = conversation_dir / relative + if target.is_dir(): + shutil.rmtree(target) + changed += 1 + record["attachments"] = [] + for message in record.get("messages") or []: + if not isinstance(message, dict): + continue + message["parts"] = [ + part for part in message.get("parts") or [] + if not (isinstance(part, dict) and part.get("type") == "data-cad-image-analysis") + ] + write_json(record_path, record) + changed += 1 + return changed + + +def main() -> int: + parser = argparse.ArgumentParser(description="Remove legacy conversation attachments") + parser.add_argument("--apply", action="store_true", help="perform deletion; default is dry-run") + parser.add_argument("--dry-run", action="store_true", help="list deletion targets without changing files") + args = parser.parse_args() + root = get_settings().conversation_root.resolve() + if root.name != "conversations": + raise SystemExit(f"Refusing unexpected conversation root: {root}") + planned = plan_cleanup(root) + for path, reason in planned: + print(f"{'DELETE' if args.apply else 'WOULD DELETE'} {path} ({reason})") + if not args.apply: + print(f"Dry-run: {len(planned)} targets. Re-run with --apply to delete.") + return 0 + print(f"Deleted {apply_cleanup(root)} conversation records/directories.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_agent_tool_arguments.py b/backend/tests/test_agent_tool_arguments.py index 4c7a3e61..82569fc2 100644 --- a/backend/tests/test_agent_tool_arguments.py +++ b/backend/tests/test_agent_tool_arguments.py @@ -9,7 +9,7 @@ from types import SimpleNamespace from unittest.mock import patch from app.models.contracts import ChatMessage, MessagePart -from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message +from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, get_repair_step_key, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message from app.services.engine_service import load_engine from app.services.quality import QUALITY_RULE_TYPES from app.services.library import CdslLibrary @@ -79,6 +79,38 @@ class ParseToolArgumentsTests(unittest.TestCase): with self.assertRaisesRegex(ToolArgumentsError, "JSON object"): parse_tool_arguments('["not", "tool arguments"]') + +class RepairStepKeyTests(unittest.TestCase): + def test_generation_and_patch_share_the_same_feature_step_budget(self) -> None: + state = { + "phase": "CDSL_REPAIR", + "feature_plan": { + "plan_id": "plan_1", + "nodes": [ + {"id": "boss", "status": "ready"}, + {"id": "hole", "status": "waiting_for_selection"}, + ], + }, + } + + self.assertEqual( + get_repair_step_key(state, "generate_cdsl_model"), + get_repair_step_key(state, "patch_cdsl_model"), + ) + + def test_completed_nodes_do_not_change_the_active_step_key(self) -> None: + state = { + "phase": "CDSL_REPAIR", + "feature_plan": { + "plan_id": "plan_1", + "nodes": [ + {"id": "base", "status": "completed"}, + {"id": "boss", "status": "ready"}, + ], + }, + } + self.assertEqual(get_repair_step_key(state, "generate_cdsl_model"), "plan:plan_1:boss") + def test_recovers_only_the_known_premature_cdsl_wrapper_close(self) -> None: payload = parse_tool_arguments( '{"cdsl":{"schema":"cad.cdsl.llm.v1"}}, "summary":"fixed envelope"}', @@ -88,6 +120,28 @@ class ParseToolArgumentsTests(unittest.TestCase): self.assertEqual(payload["summary"], "fixed envelope") self.assertEqual(payload["cdsl"], {"schema": "cad.cdsl.llm.v1"}) + def test_recovers_trailing_cdsl_metadata_after_a_complete_envelope(self) -> None: + payload = parse_tool_arguments( + '{"cdsl":{"schema":"cad.cdsl.llm.v1"},"summary":"fixed envelope",' + '"assumptions":["metric"]}, "summary":"repeated envelope",' + '"assumptions":["metric"]}', + recover_cdsl_wrapper=True, + ) + + self.assertEqual(payload, { + "cdsl": {"schema": "cad.cdsl.llm.v1"}, + "summary": "fixed envelope", + "assumptions": ["metric"], + }) + + def test_rejects_a_second_cdsl_payload_after_a_complete_envelope(self) -> None: + with self.assertRaisesRegex(ToolArgumentsError, "trailing content"): + parse_tool_arguments( + '{"cdsl":{"schema":"cad.cdsl.llm.v1"},"summary":"original",' + '"assumptions":[]}, "cdsl":{"schema":"different"}}', + recover_cdsl_wrapper=True, + ) + def test_does_not_recover_arbitrary_trailing_tool_content(self) -> None: with self.assertRaisesRegex(ToolArgumentsError, "trailing content"): parse_tool_arguments( @@ -123,6 +177,13 @@ class ParseToolArgumentsTests(unittest.TestCase): rule_type = verification["properties"]["rules"]["items"]["properties"]["type"] self.assertEqual(set(rule_type["enum"]), set(QUALITY_RULE_TYPES)) + def test_verification_schema_requires_feature_for_feature_scoped_rules(self) -> None: + generate_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "generate_cdsl_model") + rule = generate_tool["function"]["parameters"]["properties"]["verification"]["properties"]["rules"]["items"] + self.assertTrue(any("feature" in branch.get("then", {}).get("required", []) for branch in rule["allOf"])) + self.assertIn("overall_width", rule["properties"]["type"]["enum"]) + self.assertIn("overall_height", rule["properties"]["type"]["enum"]) + def test_strict_tool_schema_covers_generation_arguments(self) -> None: tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True)) strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")] diff --git a/backend/tests/test_direct_cdsl_pipeline.py b/backend/tests/test_direct_cdsl_pipeline.py index 4c37b343..2c68cf8b 100644 --- a/backend/tests/test_direct_cdsl_pipeline.py +++ b/backend/tests/test_direct_cdsl_pipeline.py @@ -13,7 +13,7 @@ from unittest.mock import patch ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) -from app.services.agent_service import AgentService # noqa: E402 +from app.services.agent_service import AgentService, _validate_snapshot_selectors # noqa: E402 from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch # noqa: E402 from app.services.engine_service import QualityVerificationError, build_revision # noqa: E402 from app.services.library import CdslLibrary # noqa: E402 @@ -129,6 +129,69 @@ class VerificationTests(unittest.TestCase): self.assertEqual(report["status"], "passed") self.assertEqual(report["results"][0]["source"], "runtime.topology_records[base_add].bbox_mm") + def test_feature_bbox_excludes_inherited_runtime_records(self) -> None: + document = fixture() + document["features"].append({ + "id": "boss", + "atomic_id": "extrude_add_blind", + "depends_on": ["base_add"], + "sketch_id": "base", + "params": {"distance_mm": 8}, + }) + rules = validate_verification({"rules": [ + {"id": "boss_range", "type": "bbox", "feature": "boss", "expected": [8, 58, 58]}, + ]}, document) + report = evaluate_quality(rules, document, { + "bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]}, + "topology_records": [ + { + "feature_id": "boss", "owner_feature_ids": ["base"], + "geometry": {"bbox_mm": [0, 0, 0, 128, 70, 90]}, + }, + { + "feature_id": "boss", "owner_feature_ids": ["boss"], + "geometry": {"bbox_mm": [60, -29, 16, 68, 29, 74]}, + }, + ], + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(report["results"][0]["actual"]["dimensions"], [8.0, 58.0, 58.0]) + + def test_feature_diameter_uses_target_circle(self) -> None: + document = fixture() + document["features"][0]["id"] = "boss" + document["features"][0]["sketch_id"] = "boss_sketch" + document["geometry"]["sketches"][0]["id"] = "boss_sketch" + document["geometry"]["sketches"][0]["profile"] = {"type": "circle", "center": [0, 0], "radius_mm": 29} + rules = validate_verification({"rules": [ + {"id": "boss_dia", "type": "overall_diameter", "feature": "boss", "expected": 58}, + ]}, document) + report = evaluate_quality(rules, document, { + "bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]}, + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(report["results"][0]["actual"], 58.0) + + def test_overall_width_and_height_are_supported(self) -> None: + rules = validate_verification({"rules": [ + {"id": "width", "type": "overall_width", "expected": 70}, + {"id": "height", "type": "overall_height", "expected": 90}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "bbox_mm": {"min": [-64, -35, 0], "max": [64, 35, 90]}, + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual([item["actual"] for item in report["results"]], [70.0, 90.0]) + + def test_feature_scoped_verification_requires_feature(self) -> None: + with self.assertRaisesRegex(ValueError, "feature is required for hole_count"): + validate_verification({"rules": [ + {"id": "holes", "type": "hole_count", "expected": 1}, + ]}, fixture()) + def test_bbox_shape_is_rejected_before_execution(self) -> None: with self.assertRaisesRegex(ValueError, "expected for bbox"): validate_verification({"rules": [ @@ -211,6 +274,147 @@ class AgentPatchFlowTests(unittest.TestCase): self.assertEqual(captured["operation"]["type"], "cdsl_patch") self.assertEqual(captured["cdsl"]["features"][0]["params"]["distance_mm"], 8) + def test_completed_feature_can_keep_selector_from_historical_snapshot(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + parent_dir = store.task_dir(task_id) / "revisions" / parent + parent_snapshot_id = f"{task_id}/{parent}" + write_json(parent_dir / "model.topology.json", { + "schema_version": "cad.topology.v1", + "task_id": task_id, + "revision_id": parent, + "snapshot_id": parent_snapshot_id, + "records": [{"record_id": "body:base:edge:0", "kind": "edge", "executable": True, "geometry": {}}], + }) + task = store.read_task(task_id) + task["revisions"][0]["topology_path"] = f"revisions/{parent}/model.topology.json" + write_json(store.task_path(task_id), task) + + child = "rev_002" + child_dir = store.task_dir(task_id) / "revisions" / child + child_dir.mkdir(parents=True) + write_json(child_dir / "model.cdsl.json", fixture()) + write_json(child_dir / "model.topology.json", { + "schema_version": "cad.topology.v1", + "task_id": task_id, + "revision_id": child, + "snapshot_id": f"{task_id}/{child}", + "records": [{"record_id": "body:base:edge:1", "kind": "edge", "executable": True, "geometry": {}}], + }) + store.update_task(task_id, { + "revision_id": child, + "status": "success", + "cdsl_path": f"revisions/{child}/model.cdsl.json", + "topology_path": f"revisions/{child}/model.topology.json", + }) + + document = fixture() + document["features"][0]["selectors"] = [{ + "kind": "edge", + "stable_id": "body:base:edge:0", + "source": "runtime_snapshot", + "snapshot_id": parent_snapshot_id, + "confidence": 1.0, + }] + _validate_snapshot_selectors(store, task_id, document) + + document["features"][0]["selectors"][0]["snapshot_id"] = f"{task_id}/rev_999" + with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"): + _validate_snapshot_selectors(store, task_id, document) + + def test_topology_inspection_unlocks_waiting_plan_nodes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, revision_id = self._seed_revision(store) + revision_dir = store.task_dir(task_id) / "revisions" / revision_id + snapshot_id = f"{task_id}/{revision_id}" + write_json(revision_dir / "model.topology.json", { + "schema_version": "cad.topology.v1", + "task_id": task_id, + "revision_id": revision_id, + "snapshot_id": snapshot_id, + "records": [{ + "record_id": "body:base_add", + "kind": "body", + "body_id": "body:base_add", + "feature_id": "base_add", + "owner_feature_ids": ["base_add"], + "geometry": {}, + "executable": True, + }], + }) + task = store.read_task(task_id) + task["revisions"][0]["topology_path"] = f"revisions/{revision_id}/model.topology.json" + write_json(store.task_path(task_id), task) + state = { + "phase": "TOPOLOGY_READY", + "feature_plan": { + "schema_version": "cad.feature-plan.v1", + "plan_id": "plan_1", + "task_id": task_id, + "nodes": [ + {"id": "base", "atomic_id": "extrude_add_blind", "depends_on": [], "cdsl_feature_ids": ["base_add"]}, + {"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "cdsl_feature_ids": ["round"]}, + ], + }, + } + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + + result, _ = asyncio.run(agent._run_tool( + "inspect_current_topology", {}, task_id, "Round the edges", [], planning_state=state, + )) + + self.assertTrue(result["ok"]) + self.assertEqual(state["feature_plan"]["topology_snapshot_id"], snapshot_id) + self.assertEqual(state["feature_plan"]["ready_nodes"], ["round"]) + + def test_successful_build_automatically_binds_topology_for_waiting_nodes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + state = { + "phase": "PLAN_READY", + "feature_plan": { + "schema_version": "cad.feature-plan.v1", + "plan_id": "plan_1", + "task_id": "", + "nodes": [ + { + "id": "base", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "cdsl_feature_ids": ["base_add"], + "status": "ready", + }, + { + "id": "round", + "atomic_id": "fillet", + "depends_on": ["base"], + "cdsl_feature_ids": ["round"], + }, + ], + }, + } + + result, _ = asyncio.run(agent._run_tool( + "generate_cdsl_model", + {"cdsl": fixture(), "summary": "Base feature", "assumptions": []}, + None, + "Create a base with a later fillet", + [], + planning_state=state, + )) + + self.assertTrue(result["ok"]) + self.assertEqual(result["required_action"], "patch_cdsl_model") + self.assertEqual(result["plan_status"]["ready_nodes"], ["round"]) + self.assertEqual(result["plan_status"]["waiting_nodes"], []) + self.assertEqual(state["feature_plan"]["topology_snapshot_id"], "{}/{}".format(result["task_id"], result["revision_id"])) + def test_bad_patch_creates_no_revision(self) -> None: with tempfile.TemporaryDirectory() as directory: config = settings(Path(directory)) @@ -230,6 +434,31 @@ class AgentPatchFlowTests(unittest.TestCase): task = store.read_task(task_id) self.assertEqual([item["revision_id"] for item in task["revisions"]], [parent]) + def test_patch_rejects_non_current_parent_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + child = "rev_002" + child_dir = store.task_dir(task_id) / "revisions" / child + child_dir.mkdir(parents=True) + write_json(child_dir / "model.cdsl.json", fixture()) + store.update_task(task_id, { + "revision_id": child, + "status": "success", + "cdsl_path": f"revisions/{child}/model.cdsl.json", + }) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"): + asyncio.run(agent._run_tool( + "patch_cdsl_model", + {"base_revision_id": parent, "patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}], "summary": "stale", "assumptions": []}, + task_id, + "stale patch", + [], + planning_state={"phase": "PLANNED"}, + )) + def test_complete_replacement_uses_current_revision_as_parent(self) -> None: with tempfile.TemporaryDirectory() as directory: config = settings(Path(directory)) diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index fe68573c..d061e342 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -193,6 +193,37 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(resolution.status, "ambiguous") self.assertEqual(resolution.diagnostic.code, "selector_ambiguous") + def test_selector_resolver_prefers_exact_runtime_stable_id(self) -> None: + registry = TopologyRegistry() + geometry = {"curve_type": "circle", "center_mm": [0, 0, 0]} + registry.register(TopologyRecord("body:b:edge:18", "edge", "boss", "body:b", geometry)) + registry.register(TopologyRecord("body:b:edge:20", "edge", "boss", "body:b", geometry)) + + resolution = registry.resolve({ + "kind": "edge", + "stable_id": "body:b:edge:18", + "owner_feature_id": "boss", + "snapshot_id": "cad_test/rev_001", + "geometry": geometry, + }, active_body_id="body:b") + + self.assertEqual(resolution.status, "resolved") + self.assertEqual(resolution.record.record_id, "body:b:edge:18") + + def test_snapshot_stable_id_rejects_geometry_that_changed(self) -> None: + registry = TopologyRegistry() + registry.register(TopologyRecord( + "body:b:edge:18", "edge", "boss", "body:b", + {"curve_type": "circle", "center_mm": [0, 0, 0]}, + )) + resolution = registry.resolve({ + "kind": "edge", "stable_id": "body:b:edge:18", "owner_feature_id": "boss", + "snapshot_id": "cad_test/rev_001", + "geometry": {"curve_type": "circle", "center_mm": [1, 0, 0]}, + }, active_body_id="body:b") + self.assertEqual(resolution.status, "not_found") + self.assertEqual(resolution.diagnostic.code, "selector_geometry_mismatch") + def test_selector_resolver_normalizes_legacy_solidworks_plane_evidence(self) -> None: registry = TopologyRegistry() registry.register(TopologyRecord( diff --git a/backend/tests/test_feature_plan.py b/backend/tests/test_feature_plan.py new file mode 100644 index 00000000..7a7eef58 --- /dev/null +++ b/backend/tests/test_feature_plan.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.services.feature_plan import FeaturePlanError, compute_node_statuses, validate_feature_plan # noqa: E402 + + +class FeaturePlanTests(unittest.TestCase): + def plan(self) -> dict: + return { + "schema_version": "cad.feature-plan.v1", + "plan_id": "plan_test", + "task_id": "cad_test", + "nodes": [ + {"id": "base", "atomic_id": "extrude_add_blind", "depends_on": []}, + {"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "requires_topology": True}, + ], + } + + def test_rejects_cycles_and_missing_dependencies(self) -> None: + cyclic = self.plan() + cyclic["nodes"][0]["depends_on"] = ["round"] + with self.assertRaises(FeaturePlanError): + validate_feature_plan(cyclic, supported_atomic_ids={"extrude_add_blind", "fillet"}) + missing = self.plan() + missing["nodes"][1]["depends_on"] = ["missing"] + with self.assertRaises(FeaturePlanError): + validate_feature_plan(missing, supported_atomic_ids={"extrude_add_blind", "fillet"}) + + def test_ready_prefix_waits_for_topology(self) -> None: + statuses = compute_node_statuses(self.plan(), cdsl={"features": []}) + self.assertEqual(statuses["ready_nodes"], ["base"]) + self.assertEqual(statuses["waiting_nodes"], []) + + def test_built_prefix_unlocks_topology_dependent_node(self) -> None: + statuses = compute_node_statuses( + self.plan(), + cdsl={"features": [{"id": "base"}]}, + topology={"records": [{"record_id": "body:base", "kind": "body", "executable": True}]}, + ) + self.assertEqual(statuses["completed_nodes"], ["base"]) + self.assertEqual(statuses["waiting_nodes"], ["round"]) + + def test_inspected_snapshot_unlocks_topology_dependent_node(self) -> None: + plan = self.plan() + plan["topology_snapshot_id"] = "cad_test/rev_001" + statuses = compute_node_statuses( + plan, + cdsl={"features": [{"id": "base"}]}, + topology={ + "snapshot_id": "cad_test/rev_001", + "records": [{"record_id": "body:base", "kind": "body", "executable": True}], + }, + ) + + self.assertEqual(statuses["ready_nodes"], ["round"]) + self.assertEqual(statuses["waiting_nodes"], []) + + def test_dependency_must_appear_before_dependent_node(self) -> None: + plan = self.plan() + plan["nodes"] = [plan["nodes"][1], plan["nodes"][0]] + with self.assertRaisesRegex(FeaturePlanError, "appear after dependency"): + validate_feature_plan(plan, supported_atomic_ids={"extrude_add_blind", "fillet"}) diff --git a/backend/tests/test_image_observation.py b/backend/tests/test_image_observation.py new file mode 100644 index 00000000..43318e75 --- /dev/null +++ b/backend/tests/test_image_observation.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import asyncio +import io +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +from PIL import Image + +from app.services.image_observation import ( + merge_image_observations, + normalize_image_observation, + normalize_sketch_candidates, + render_image_observation_context, +) +from app.services.image_processing import cv_hints, image_metadata +from app.services.agent_service import tools_for_model +from app.services.agent_service import AgentService +from app.services.attachments import attachment_record +from app.services.library import CdslLibrary +from app.services.storage import WorkspaceStore +from app.settings import ProviderConfig, ProviderModel, Settings +from app.models.contracts import ChatMessage, MessagePart + + +def test_observation_keeps_multiview_profiles_and_measurement_sources() -> None: + result = normalize_image_observation({ + "part_type": "bent bracket", + "visible_features": ["plate", "irregular opening"], + "uncertain_features": ["inner bend radius"], + "views": [{"attachment_id": "upload_a", "view_role": "front", "confidence": 0.8}], + "profiles": [{ + "id": "opening_01", + "role": "cutout", + "closed": True, + "source_images": ["upload_a"], + "segments": [ + {"type": "line", "start": [0, 0], "end": [10, 0]}, + {"type": "arc", "start": [10, 0], "end": [10, 4], "center": [8, 2], "radius_mm": 2}, + {"type": "polyline", "points": [[10, 4], [5, 8], [0, 4]]}, + ], + }], + "measurements": [{"name": "plate_thickness", "value_mm": 1.2, "source": "user"}], + }, attachment_ids=["upload_a"]) + + assert result["schema_version"] == "cad.image-observation.v2" + assert result["profiles"][0]["segments"][1]["type"] == "arc" + assert result["measurements"][0]["source"] == "user" + assert "opening_01" in render_image_observation_context(result) + + +def test_sketch_merge_does_not_replace_user_measurement() -> None: + survey = normalize_image_observation({ + "part_type": "bracket", + "visible_features": ["plate"], + "uncertain_features": [], + "views": [{"attachment_id": "upload_a"}], + "measurements": [{"name": "thickness", "value_mm": 1.2, "source": "user"}], + }, attachment_ids=["upload_a"]) + sketches = normalize_sketch_candidates({ + "profiles": [], + "measurements": [{"name": "thickness", "value_mm": 1.6, "source": "image"}], + "uncertainties": ["bend radius"], + }, attachment_ids=["upload_a"]) + merged = merge_image_observations(survey, sketches) + + assert merged["measurements"][0]["value_mm"] == 1.2 + assert merged["uncertainties"] == ["bend radius"] + + +def test_image_metadata_and_cv_degrade_without_required_cv_support() -> None: + output = io.BytesIO() + Image.new("RGB", (32, 16), "white").save(output, format="PNG") + metadata = image_metadata(output.getvalue()) + assert metadata["width"] == 32 + assert metadata["height"] == 16 + assert "available" in cv_hints(output.getvalue()) + + +def test_image_tool_stages_expose_only_the_required_tool() -> None: + model = ProviderModel("vision", vision=True) + assert [tool["function"]["name"] for tool in tools_for_model(model, image_stage="survey")] == ["analyze_image_reference"] + assert [tool["function"]["name"] for tool in tools_for_model(model, image_stage="sketch", include_image_analysis=False, include_image_sketches=True)] == ["extract_image_sketch_candidates"] + + +def test_agent_runs_survey_then_sketch_stage_before_normal_tools() -> None: + class TwoStageAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.required: list[str | None] = [] + self.responses = [ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "survey", "type": "function", "function": {"name": "analyze_image_reference", "arguments": json.dumps({ + "part_type": "bracket", "visible_features": ["plate"], "uncertain_features": [], + "views": [{"attachment_id": "upload_a", "view_role": "front"}], "profiles": [], + "measurements": [], "uncertainties": [], + })}, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "sketch", "type": "function", "function": {"name": "extract_image_sketch_candidates", "arguments": json.dumps({ + "profiles": [{"id": "opening", "role": "cutout", "closed": True, "segments": [{"type": "line", "start": [0, 0], "end": [2, 0]}]}], + "measurements": [], "uncertainties": [], + })}, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "继续建模。", "tool_calls": []}}]}, + ] + + async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]: + self.required.append(kwargs.get("required_tool_name") if "required_tool_name" in kwargs else args[4] if len(args) > 4 else None) + return self.responses.pop(0) + + with TemporaryDirectory() as directory: + root = Path(directory) + backend_root = Path(__file__).resolve().parents[1] + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "key", (ProviderModel("vision", vision=True),)) + settings = Settings(root / "tasks", root / "conversations", backend_root / "cdsl_library", backend_root / "engine" / "cdsl_engine", "", "", "", 5, "test", (provider,)) + store = WorkspaceStore(settings) + conversation_id = "conv_000000000001" + store.ensure_conversation(conversation_id) + relative, _ = store.write_conversation_upload(conversation_id, "part.png", b"png") + store.add_conversation_attachment(conversation_id, attachment_record(conversation_id, "part.png", "image/png", relative, b"png", "image")) + agent = TwoStageAgent(settings, store, CdslLibrary(settings)) + message = ChatMessage(id="user", role="user", parts=[MessagePart(type="text", text="根据图片继续建模")]) + + async def collect() -> list[dict[str, object]]: + events = [] + async for chunk in agent.stream([message], conversation_id, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect()) + assert agent.required[:2] == ["analyze_image_reference", "extract_image_sketch_candidates"] + assert any(event.get("observationStage") == "complete" for event in events) diff --git a/backend/tests/test_incremental_generation.py b/backend/tests/test_incremental_generation.py new file mode 100644 index 00000000..69bd1bda --- /dev/null +++ b/backend/tests/test_incremental_generation.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from fastapi import HTTPException # noqa: E402 +from app import main as api # noqa: E402 +from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, validate_fragment # noqa: E402 +from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan # noqa: E402 +from app.services.incremental_generation import IncrementalGenerationRunner # noqa: E402 +from app.services.review_renderer import CANONICAL_VIEWS, REVIEW_SIZE, RENDER_SIZE, render_checkpoint # noqa: E402 +from app.services.storage import WorkspaceStore # noqa: E402 +from app.services.visual_review import _selected_review_views # noqa: E402 +from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 + + +def plan() -> dict: + return { + "schema_version": "cad.generation-plan.v2", + "plan_id": "incremental_test", + "requirements": [ + {"id": "req_base", "source": "explicit", "priority": "hard", "description": "base solid"}, + {"id": "req_round", "source": "explicit", "priority": "hard", "description": "edge round"}, + ], + "assumptions": [], + "nodes": [ + { + "id": "base", "intent": "base", "atomic_id": "extrude_add_blind", "depends_on": [], + "requirement_ids": ["req_base"], "verification_rules": [], "review_targets": [], + }, + { + "id": "round", "intent": "round", "atomic_id": "fillet", "depends_on": ["base"], + "requires_topology": True, + "requirement_ids": ["req_round"], "verification_rules": [], "review_targets": [], + }, + ], + } + + +class GenerationPlanTests(unittest.TestCase): + def test_hard_requirements_and_backend_outputs_are_owned(self) -> None: + invalid = plan() + invalid["nodes"][1]["requirement_ids"] = [] + with self.assertRaisesRegex(GenerationPlanError, "Hard requirements"): + validate_generation_plan(invalid, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456") + + generated = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456") + self.assertEqual(generated["id_strategy"], "backend-derived-v1") + self.assertEqual(len(generated["nodes"][0]["cdsl_feature_ids"]), 1) + self.assertEqual(len(generated["nodes"][0]["cdsl_sketch_ids"]), 1) + self.assertEqual(generated["nodes"][1]["cdsl_sketch_ids"], []) + # Legacy fields from an untrusted model output cannot choose CDSL IDs. + supplied = plan() + supplied["nodes"][0]["expected_feature_ids"] = ["model_chosen_id"] + self.assertEqual( + validate_generation_plan(supplied, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")["nodes"][0]["cdsl_feature_ids"], + generated["nodes"][0]["cdsl_feature_ids"], + ) + + def test_upstream_invalidation_marks_all_descendants_stale(self) -> None: + spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456") + spec["nodes"][0]["status"] = "completed" + spec["nodes"][1]["status"] = "completed" + self.assertEqual(descendant_closure(spec, "base"), {"base", "round"}) + stale = mark_nodes_stale(spec, "base", reason="topology_changed") + self.assertTrue(all(node["stale"] for node in stale["nodes"])) + self.assertTrue(all(node["status"] == "planned" for node in stale["nodes"])) + + +class FragmentTests(unittest.TestCase): + def setUp(self) -> None: + self.spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456") + + def base_fragment(self) -> dict: + return { + "schema_version": "cad.cdsl-fragment.v1", "node_id": "base", "base_revision_id": "", + "base_cdsl_sha256": cdsl_sha256(None), "add_sketches": [{}], + "add_features": [{}], "verification_rules": [], "assumptions": [], + } + + def test_fragment_assigns_ids_dependencies_and_atomic_from_plan(self) -> None: + accepted = validate_fragment(self.base_fragment(), plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None) + base = self.spec["nodes"][0] + self.assertEqual(accepted["add_sketches"][0]["id"], base["cdsl_sketch_ids"][0]) + self.assertEqual(accepted["add_features"][0]["id"], base["cdsl_feature_ids"][0]) + self.assertEqual(accepted["add_features"][0]["sketch_id"], base["cdsl_sketch_ids"][0]) + self.assertEqual(accepted["add_features"][0]["atomic_id"], "extrude_add_blind") + self.assertEqual(accepted["add_features"][0]["depends_on"], []) + model_ids = self.base_fragment() + model_ids["add_sketches"][0]["id"] = "model_sketch" + model_ids["add_features"][0].update({"id": "model_feature", "atomic_id": "fillet", "sketch_id": "model_sketch"}) + overwritten = validate_fragment(model_ids, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None) + self.assertEqual(overwritten["add_features"][0]["id"], base["cdsl_feature_ids"][0]) + self.assertEqual(overwritten["add_features"][0]["atomic_id"], "extrude_add_blind") + + def test_fragment_rejects_wrong_hash_and_output_shape(self) -> None: + wrong_hash = self.base_fragment() + wrong_hash["base_cdsl_sha256"] = "0" * 64 + with self.assertRaisesRegex(CdslFragmentError, "sha256"): + validate_fragment(wrong_hash, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None) + unexpected_sketch = self.base_fragment() + unexpected_sketch["add_sketches"].append({"id": "extra"}) + with self.assertRaisesRegex(CdslFragmentError, "one new sketch"): + validate_fragment(unexpected_sketch, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None) + + def test_topology_fragment_requires_active_snapshot_owner_and_geometry(self) -> None: + base = self.spec["nodes"][0] + round_node = self.spec["nodes"][1] + base_cdsl = { + "geometry": {"sketches": [{"id": base["cdsl_sketch_ids"][0]}]}, + "features": [{"id": base["cdsl_feature_ids"][0], "atomic_id": "extrude_add_blind"}], + } + fragment = { + "schema_version": "cad.cdsl-fragment.v1", "node_id": "round", "base_revision_id": "rev_001", + "base_cdsl_sha256": cdsl_sha256(base_cdsl), "required_snapshot_id": "cad_test/rev_001", + "add_sketches": [], + "add_features": [{ + "selectors": [{ + "kind": "edge", "stable_id": "body:feature_base:edge:0", "owner_node_id": "base", + "snapshot_id": "cad_test/rev_001", "geometry": {"curve_type": "line", "length_mm": 10}, + }], + }], + "verification_rules": [], "assumptions": [], + } + accepted = validate_fragment( + fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl, + required_snapshot_id="cad_test/rev_001", + ) + self.assertEqual(accepted["node_id"], "round") + self.assertEqual(accepted["add_features"][0]["id"], round_node["cdsl_feature_ids"][0]) + self.assertEqual(accepted["add_features"][0]["depends_on"], [base["cdsl_feature_ids"][0]]) + self.assertEqual(accepted["add_features"][0]["selectors"][0]["owner_feature_id"], base["cdsl_feature_ids"][0]) + fragment["add_features"][0]["selectors"][0].pop("geometry") + with self.assertRaisesRegex(CdslFragmentError, "geometry signature"): + validate_fragment( + fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl, + required_snapshot_id="cad_test/rev_001", + ) + + +class StorageAndRunnerTests(unittest.TestCase): + def settings(self, root: Path) -> Settings: + provider = ProviderConfig("author", "Author", "https://example.invalid/v1", "secret", (ProviderModel("author-model"),)) + return Settings( + task_root=root / "tasks", conversation_root=root / "conversations", library_root=root / "library", + engine_root=ROOT / "backend" / "engine" / "cdsl_engine", llm_base_url="", llm_api_key="", llm_model="author-model", + llm_timeout_s=1, default_provider_id="author", providers=(provider,), incremental_generation=True, + ) + + def test_rollback_anchor_rewinds_before_affected_nodes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + store = WorkspaceStore(self.settings(Path(directory))) + task = store.ensure_task(None, "test") + task_id = task["task_id"] + for revision_id, parent, node_id in (("rev_001", "", "base"), ("rev_002", "rev_001", "middle"), ("rev_003", "rev_002", "tip")): + store.update_task(task_id, {"revision_id": revision_id, "status": "success", "parent_revision_id": parent, "node_id": node_id, "visibility": "checkpoint"}) + self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["middle", "tip"], fallback_revision_id="rev_002"), "rev_001") + self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["base"], fallback_revision_id="rev_001"), "") + store.rollback_to_revision(task_id, "rev_002", branch_id="branch_repair") + revisions = {item["revision_id"]: item for item in (store.read_task(task_id) or {})["revisions"]} + self.assertEqual(revisions["rev_001"]["visibility"], "checkpoint") + self.assertEqual(revisions["rev_002"]["visibility"], "checkpoint") + self.assertEqual(revisions["rev_003"]["visibility"], "superseded") + + def test_run_context_is_persisted_for_worker_recovery(self) -> None: + with tempfile.TemporaryDirectory() as directory: + store = WorkspaceStore(self.settings(Path(directory))) + task = store.ensure_task(None, "test") + store.write_generation_run_context(task["task_id"], { + "schema_version": "cad.generation-run-context.v1", + "request": "test", "conversation_id": "conv_abcdef123456", + "provider_id": "author", "model_id": "author-model", "author_messages": [], "part_skills": {}, + }) + recovered = store.read_generation_run_context(task["task_id"]) + self.assertEqual(recovered and recovered["request"], "test") + task = store.start_generation(task["task_id"], request="test") + self.assertEqual([item["task_id"] for item in store.running_tasks()], [task["task_id"]]) + + def test_missing_visual_review_configuration_fails_the_run(self) -> None: + with tempfile.TemporaryDirectory() as directory: + settings = self.settings(Path(directory)) + store = WorkspaceStore(settings) + task = store.ensure_task(None, "test") + + async def complete(*_args: object) -> dict: + raise AssertionError("author must not be called before visual configuration validation") + + runner = IncrementalGenerationRunner(settings, store, complete) + + async def collect() -> list[tuple[str, dict]]: + return [item async for item in runner.run( + task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []}, + provider=settings.providers[0], model=settings.providers[0].models[0], author_messages=[], + )] + + events = asyncio.run(collect()) + self.assertEqual(events[-1][0], "task_terminal") + self.assertEqual(events[-1][1]["lifecycle"], "failed") + self.assertEqual((store.read_task(task["task_id"]) or {})["lifecycle"], "failed") + + def test_invalid_plan_is_preserved_in_failure_diagnostics(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + provider = ProviderConfig( + "author", "Author", "https://example.invalid/v1", "secret", + (ProviderModel("author-model", vision=True),), + ) + settings = replace( + self.settings(root), providers=(provider,), review_provider_id="author", review_model_id="author-model", + ) + store = WorkspaceStore(settings) + task = store.ensure_task(None, "test") + raw_plan = {"schema_version": "cad.generation-plan.v2", "plan_id": "broken", "requirements": []} + + async def complete(*_args: object) -> dict: + return { + "choices": [{"message": {"tool_calls": [{"function": { + "name": "plan_generation_task", "arguments": json.dumps(raw_plan), + }}]}}], + } + + runner = IncrementalGenerationRunner(settings, store, complete) + engine = type("Engine", (), {"SUPPORTED_ATOMIC_IDS": ("extrude_add_blind",)})() + + async def collect() -> list[tuple[str, dict]]: + return [item async for item in runner.run( + task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []}, + provider=provider, model=provider.models[0], author_messages=[], + )] + + with patch("app.services.incremental_generation.load_engine", return_value=engine), patch( + "app.services.incremental_generation.renderer_status", return_value=(True, "") + ): + events = asyncio.run(collect()) + + self.assertEqual(events[-1], ("task_terminal", { + "taskId": task["task_id"], "lifecycle": "failed", + "message": "Generation plan requires a non-empty requirements array", + })) + failed_task = store.read_task(task["task_id"]) or {} + failure_path = root / "tasks" / task["task_id"] / str(failed_task["run_failure_path"]) + with failure_path.open(encoding="utf-8") as handle: + failure = json.load(handle) + diagnostic_path = root / "tasks" / task["task_id"] / str(failure["plan_diagnostic_path"]) + with diagnostic_path.open(encoding="utf-8") as handle: + diagnostic = json.load(handle) + self.assertEqual(diagnostic["stage"], "generation_plan_validation") + self.assertEqual(diagnostic["raw_plan"], raw_plan) + + +class ArtifactAccessTests(unittest.TestCase): + def test_checkpoint_allows_only_the_active_glb_until_publication(self) -> None: + with tempfile.TemporaryDirectory() as directory: + store = WorkspaceStore(StorageAndRunnerTests().settings(Path(directory))) + task = store.ensure_task(None, "test") + task_id = task["task_id"] + revision_id, revision_dir = store.next_revision(task_id) + glb = revision_dir / "model.glb" + step = revision_dir / "model.step" + glb.write_bytes(b"glb") + step.write_bytes(b"step") + store.update_task(task_id, { + "revision_id": revision_id, + "status": "success", + "cdsl_path": f"revisions/{revision_id}/model.cdsl.json", + "glb_path": f"revisions/{revision_id}/model.glb", + "step_path": f"revisions/{revision_id}/model.step", + "report_path": f"revisions/{revision_id}/rebuild-report.json", + "visibility": "checkpoint", + }) + previous_store = api.store + api.store = store + try: + response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.glb")) + self.assertEqual(response.media_type, "model/gltf-binary") + with self.assertRaises(HTTPException) as rejected: + asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step")) + self.assertEqual(rejected.exception.status_code, 403) + store.finish_generation(task_id, lifecycle="completed") + response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step")) + self.assertEqual(response.status_code, 200) + finally: + api.store = previous_store + + +class TechnicalRenderTests(unittest.TestCase): + def test_cpu_renderer_emits_fixed_views_and_keeps_detail_separate(self) -> None: + from build123d import Box, export_step + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + step_path = root / "box.step" + export_step(Box(30, 20, 10), step_path) + manifest = render_checkpoint( + StorageAndRunnerTests().settings(root), + step_path=step_path, + output_dir=root / "review", + review_targets=[{"bbox_mm": [-5, -5, -5, 5, 5, 5]}], + ) + self.assertEqual(manifest["renderer"], "python-occ-hlr-pillow") + views = {item["id"]: item for item in manifest["views"]} + self.assertEqual(set(CANONICAL_VIEWS), set(views) & set(CANONICAL_VIEWS)) + self.assertIn("detail-1", views) + self.assertNotEqual(views["isometric"]["path"], views["detail-1"]["path"]) + self.assertTrue(all(Path(views[view_id]["path"]).is_file() for view_id in CANONICAL_VIEWS)) + self.assertTrue(all(views[view_id]["diagnostics"]["valid"] for view_id in CANONICAL_VIEWS)) + self.assertTrue(views["detail-1"]["diagnostics"]["intentional_crop"]) + from PIL import Image + + with Image.open(views["isometric"]["path"]) as image: + self.assertEqual(image.size, (REVIEW_SIZE, REVIEW_SIZE)) + with Image.open(views["isometric"]["high_resolution_path"]) as image: + self.assertEqual(image.size, (RENDER_SIZE, RENDER_SIZE)) + + def test_routine_visual_review_uses_compact_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + contact = root / "contact-sheet.jpg" + contact.write_bytes(b"jpg") + manifest = { + "contact_sheet_path": str(contact), + "views": [{"id": view_id, "path": str(root / f"{view_id}.png")} for view_id in (*CANONICAL_VIEWS, "detail-1", "detail-2")], + } + routine = _selected_review_views(manifest, final_checkpoint=False) + final = _selected_review_views(manifest, final_checkpoint=True) + self.assertEqual([item["id"] for item in routine], ["contact-sheet", "detail-1", "detail-2"]) + self.assertEqual({item["id"] for item in final}, {"contact-sheet", "detail-1", "detail-2", *CANONICAL_VIEWS}) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_part_skills.py b/backend/tests/test_part_skills.py index 054a662c..5352f6c1 100644 --- a/backend/tests/test_part_skills.py +++ b/backend/tests/test_part_skills.py @@ -242,7 +242,7 @@ class AgentPartSkillTests(unittest.TestCase): self.assertIn("[planning/flange]", prompt) self.assertIn("circular-pattern atomic", prompt) - self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"]) + self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "extract_image_sketch_candidates", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"]) def test_generation_persists_assumptions_and_selected_skills(self) -> None: with tempfile.TemporaryDirectory() as directory: diff --git a/backend/tests/test_topology_snapshot.py b/backend/tests/test_topology_snapshot.py new file mode 100644 index 00000000..b311482e --- /dev/null +++ b/backend/tests/test_topology_snapshot.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.services.engine_service import topology_snapshot, topology_sidecars # noqa: E402 + + +class TopologySnapshotTests(unittest.TestCase): + def result(self) -> dict: + return { + "topology_records": [ + { + "record_id": "body:base:edge:0", + "kind": "edge", + "feature_id": "base", + "body_id": "body:base", + "owner_feature_ids": ["base"], + "geometry": {"curve_type": "line", "length_mm": 10}, + }, + { + "record_id": "body:base", + "kind": "body", + "feature_id": "base", + "body_id": "body:base", + "geometry": {}, + }, + ] + } + + def test_snapshot_preserves_runtime_edges(self) -> None: + snapshot = topology_snapshot(self.result(), task_id="cad_test", revision_id="rev_001") + self.assertEqual(snapshot["snapshot_id"], "cad_test/rev_001") + self.assertEqual(snapshot["records"][0]["record_id"], "body:base:edge:0") + self.assertTrue(snapshot["records"][0]["executable"]) + + def test_sidecars_are_derived_from_runtime_records(self) -> None: + snapshot = topology_snapshot(self.result()) + selector, edges = topology_sidecars(self.result(), snapshot=snapshot) + self.assertEqual(edges["edges"][0]["record_id"], "body:base:edge:0") + self.assertEqual(selector["edges"][0]["source"], "runtime_snapshot") + + def test_snapshot_excludes_superseded_body_records(self) -> None: + result = { + "feature_results": [ + {"feature_id": "base", "body_id": "body:base"}, + {"feature_id": "cut", "body_id": "body:cut"}, + ], + "topology_records": [ + {"record_id": "body:base", "kind": "body", "body_id": "body:base", "feature_id": "base"}, + {"record_id": "body:base:edge:0", "kind": "edge", "body_id": "body:base", "feature_id": "base"}, + {"record_id": "body:cut", "kind": "body", "body_id": "body:cut", "feature_id": "cut"}, + {"record_id": "body:cut:edge:0", "kind": "edge", "body_id": "body:cut", "feature_id": "cut"}, + ], + } + + snapshot = topology_snapshot(result) + + self.assertEqual(snapshot["body_id"], "body:cut") + self.assertEqual([record["record_id"] for record in snapshot["records"]], ["body:cut", "body:cut:edge:0"]) + + def test_preview_faces_are_audited_but_not_executable(self) -> None: + snapshot = topology_snapshot( + {"topology_records": []}, + task_id="cad_test", + revision_id="rev_001", + preview={"topology_faces": [{"id": "preview_face", "surface_type": "plane", "center": [0, 0, 1], "normal": [0, 0, 1]}]}, + ) + self.assertEqual(snapshot["records"][0]["record_id"], "preview_face") + self.assertTrue(snapshot["records"][0]["synthetic"]) + self.assertFalse(snapshot["records"][0]["executable"]) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8db38ace..661ff7b2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,6 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/three": "^0.185.4", - "puppeteer-core": "^25.8.0", "tailwindcss": "^4", "tsx": "^4.20.6", "typescript": "^5" @@ -1491,35 +1490,6 @@ "node": ">= 10" } }, - "node_modules/@puppeteer/browsers": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.2.1.tgz", - "integrity": "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "modern-tar": "^0.8.0", - "yargs": "^18.0.0" - }, - "bin": { - "browsers": "lib/main-cli.js" - }, - "engines": { - "node": ">=22.12.0" - }, - "peerDependencies": { - "proxy-agent": ">=8.0.1", - "yauzl": "^2.10.0 || ^3.4.0" - }, - "peerDependenciesMeta": { - "proxy-agent": { - "optional": true - }, - "yauzl": { - "optional": true - } - } - }, "node_modules/@radix-ui/number": { "version": "1.1.3", "resolved": "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.3.tgz", @@ -3455,32 +3425,6 @@ } } }, - "node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3557,72 +3501,12 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chromium-bidi": { - "version": "17.0.2", - "resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-17.0.2.tgz", - "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "engines": { - "node": ">=20.19.0 <22.0.0 || >=22.12.0" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/chromium-bidi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmmirror.com/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", @@ -3664,21 +3548,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/devtools-protocol": { - "version": "0.0.1666840", - "resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", - "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -3735,16 +3604,6 @@ "@esbuild/win32-x64": "0.28.2" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventsource-parser": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.1.tgz", @@ -3776,29 +3635,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4118,23 +3954,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, - "license": "MIT" - }, - "node_modules/modern-tar": { - "version": "0.8.4", - "resolved": "https://registry.npmmirror.com/modern-tar/-/modern-tar-0.8.4.tgz", - "integrity": "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/nanoid": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-6.0.1.tgz", @@ -4315,24 +4134,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/puppeteer-core": { - "version": "25.8.0", - "resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.8.0.tgz", - "integrity": "sha512-LDOrawV8vfCVk+yLj2ozvajNP4Sv3OV9y3Tpiyy2g2Z+aQlbcozP6KJfI4iSBq7YQER+86ihEtPa5ioiZyWxMQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "3.2.1", - "chromium-bidi": "17.0.2", - "devtools-protocol": "0.0.1666840", - "typed-query-selector": "^2.12.2", - "webdriver-bidi-protocol": "0.4.2", - "ws": "^8.21.1" - }, - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/radix-ui": { "version": "1.6.7", "resolved": "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.6.7.tgz", @@ -4614,39 +4415,6 @@ "node": ">=0.10.0" } }, - "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -4767,13 +4535,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/typed-query-selector": { - "version": "2.12.2", - "resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz", - "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", - "dev": true, - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", @@ -4910,109 +4671,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.4.2", - "resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", - "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "18.1.0", - "resolved": "https://registry.npmmirror.com/yargs/-/yargs-18.1.0.tgz", - "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^8.2.1", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4c1c4514..7880825c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,6 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/three": "^0.185.4", - "puppeteer-core": "^25.8.0", "tailwindcss": "^4", "tsx": "^4.20.6", "typescript": "^5" diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index f8fac541..29b84cc6 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -394,7 +394,15 @@ button:disabled { .config-warning { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-error-border); background: var(--ui-error-bg); color: var(--ui-error-text); font-size: 12px; padding: 8px 12px; } .studio-main { display: flex; min-height: 0; flex: 1; } .agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); } -.preview-pane { min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); } +.preview-pane { position: relative; min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); } +.generation-status { position: absolute; z-index: 30; top: 12px; right: 12px; width: min(260px, calc(100% - 24px)); max-height: min(42vh, 360px); overflow: auto; border: 1px solid var(--ui-border); border-radius: 6px; background: var(--ui-glass-popover); box-shadow: var(--ui-shadow-soft); backdrop-filter: blur(12px); color: var(--ui-text); padding: 10px; font-size: 12px; } +.generation-status-heading { display: flex; align-items: center; gap: 6px; color: var(--ui-text-strong); font-weight: 650; } +.generation-status-active { margin: 6px 0 8px; color: var(--ui-accent-text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; } +.generation-status ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; } +.generation-status li { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--ui-border-muted); padding-top: 4px; color: var(--ui-text-muted); } +.generation-status li[data-status="completed"] small { color: var(--ui-success); } +.generation-status li[data-status="planned"] small { color: var(--ui-text-subtle); } +.generation-status li[data-status="failed"] small { color: var(--ui-error); } .agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; } .agent-thread-shell { position: relative; } .spin { animation: ui-spin 900ms linear infinite; } diff --git a/frontend/src/components/agent-studio.tsx b/frontend/src/components/agent-studio.tsx index 208fa9f8..67882645 100644 --- a/frontend/src/components/agent-studio.tsx +++ b/frontend/src/components/agent-studio.tsx @@ -6,13 +6,14 @@ import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { AlertCircle, Box, Loader2, Moon, Sun } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { latestSuccessfulResult } from "@/lib/cad-artifacts"; +import { activeCheckpointPreview, latestSuccessfulResult } from "@/lib/cad-artifacts"; import { normalizeCadMessages } from "@/lib/cad-messages"; import type { ViewerSelectionContext } from "@/lib/viewer-selection"; import type { BackendConfig, CadError, CadAttachment, + CadProgress, CadResult, CadUIMessage, ConversationRecord, @@ -39,6 +40,8 @@ export function AgentStudio() { const [modelId, setModelId] = useState(""); const [theme, setTheme] = useState<"light" | "dark">("light"); const [viewerSelection, setViewerSelection] = useState(null); + const [taskRunning, setTaskRunning] = useState(false); + const [taskRecord, setTaskRecord] = useState(null); const syncUrl = useCallback((conversation: string, task: string) => { const params = new URLSearchParams(window.location.search); @@ -73,10 +76,12 @@ export function AgentStudio() { const taskId = urlTaskId || conversation.current_task_id || ""; let restored: CadResult | null = null; + let restoredTask: TaskRecord | null = null; if (taskId) { const taskResponse = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { cache: "no-store" }); if (taskResponse.ok) { - restored = latestSuccessfulResult((await taskResponse.json()) as TaskRecord); + restoredTask = (await taskResponse.json()) as TaskRecord; + restored = activeCheckpointPreview(restoredTask) ?? latestSuccessfulResult(restoredTask); } } @@ -90,6 +95,8 @@ export function AgentStudio() { setProviderId(defaultProvider?.id || ""); setModelId(defaultProvider?.models.find((model) => model.id === nextConfig.default_model)?.id || defaultProvider?.models[0]?.id || ""); setCadResult(restored); + setTaskRunning(restoredTask?.lifecycle === "running"); + setTaskRecord(restoredTask); setLoadState("ready"); syncUrl(nextConversationId, taskId); } catch (error) { @@ -127,6 +134,7 @@ export function AgentStudio() { setViewerSelection(null); setSelectedTaskId(result.taskId); setLastError(""); + if (result.lifecycle) setTaskRunning(result.lifecycle === "running"); if (conversationId) { syncUrl(conversationId, result.taskId); void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { @@ -137,8 +145,24 @@ export function AgentStudio() { } }, [conversationId, syncUrl]); + const handleTaskState = useCallback((progress: CadProgress) => { + const taskId = String(progress.taskId || ""); + if (taskId) { + setSelectedTaskId(taskId); + if (conversationId) { + syncUrl(conversationId, taskId); + void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { + method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentTaskId: taskId }), + }); + } + } + if (progress.step === "generation_plan" && progress.status === "running") setTaskRunning(true); + if (progress.step === "task_terminal") setTaskRunning(progress.lifecycle === "running"); + }, [conversationId, syncUrl]); + const handleError = useCallback((error: CadError) => { setLastError(error.message); + if (error.stage === "generation") setTaskRunning(false); }, []); const handleUpload = useCallback(async (files: FileList | null) => { @@ -167,6 +191,34 @@ export function AgentStudio() { } }, [conversationId]); + useEffect(() => { + if (!taskRunning || !selectedTaskId) return; + let cancelled = false; + const refresh = async () => { + try { + const response = await fetch(`/api/tasks/${encodeURIComponent(selectedTaskId)}`, { cache: "no-store" }); + if (!response.ok) return; + const next = await response.json() as TaskRecord; + if (cancelled) return; + setTaskRecord(next); + const running = next.lifecycle === "running"; + setTaskRunning(running); + if (running) { + const preview = activeCheckpointPreview(next); + if (preview) setCadResult(preview); + } else { + const restored = latestSuccessfulResult(next); + if (restored) setCadResult(restored); + } + } catch { + // Keep the persisted lock until a later poll can prove a terminal state. + } + }; + void refresh(); + const timer = window.setInterval(() => void refresh(), 2000); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [selectedTaskId, taskRunning]); + if (loadState === "loading") { return ; } @@ -185,6 +237,7 @@ export function AgentStudio() { initialMessages={initialMessages} onCadResult={handleResult} onCadError={handleError} + onCadProgress={handleTaskState} > ); @@ -217,6 +272,7 @@ function AgentRuntime({ initialMessages, onCadResult, onCadError, + onCadProgress, children, }: { conversationId: string; @@ -227,6 +283,7 @@ function AgentRuntime({ initialMessages: CadUIMessage[]; onCadResult: (result: CadResult) => void; onCadError: (error: CadError) => void; + onCadProgress: (progress: CadProgress) => void; children: React.ReactNode; }) { const conversationRef = useRef(conversationId); @@ -236,6 +293,7 @@ function AgentRuntime({ const viewerSelectionRef = useRef(viewerSelection); const onCadResultRef = useRef(onCadResult); const onCadErrorRef = useRef(onCadError); + const onCadProgressRef = useRef(onCadProgress); conversationRef.current = conversationId; taskRef.current = selectedTaskId; providerRef.current = providerId; @@ -243,6 +301,7 @@ function AgentRuntime({ viewerSelectionRef.current = viewerSelection; onCadResultRef.current = onCadResult; onCadErrorRef.current = onCadError; + onCadProgressRef.current = onCadProgress; const transport = useMemo(() => new DefaultChatTransport({ api: "/api/chat", @@ -267,6 +326,9 @@ function AgentRuntime({ messages: initialMessages, transport, onData: (part) => { + if (part.type === "data-cad-progress") { + onCadProgressRef.current(part.data as CadProgress); + } if (part.type === "data-cad-result") { onCadResultRef.current(part.data as CadResult); taskRef.current = (part.data as CadResult).taskId; @@ -425,6 +487,8 @@ function StudioShell({ onCadResult, onCadError, onSelectionChange, + taskRunning, + taskRecord, }: { config: BackendConfig | null; cadResult: CadResult | null; @@ -442,8 +506,10 @@ function StudioShell({ onCadResult: (result: CadResult) => void; onCadError: (error: CadError) => void; onSelectionChange: (selection: ViewerSelectionContext | null) => void; + taskRunning: boolean; + taskRecord: TaskRecord | null; }) { - const running = useAuiState((state) => state.thread.isRunning); + const running = useAuiState((state) => state.thread.isRunning) || taskRunning; const provider = config?.providers.find((item) => item.id === providerId); const handleViewerError = useCallback((message: string) => { onCadError({ stage: "viewer", message }); @@ -453,24 +519,43 @@ function StudioShell({
CDSL CAD Studio{cadResult ? {cadResult.taskId} : null}
- { const id = event.target.value; onProviderChange(id); onModelChange(config?.providers.find((item) => item.id === id)?.models[0]?.id || ""); }}> {config?.providers.map((item) => )} - onModelChange(event.target.value)}> {provider?.models.map((model) => )} - +
{!config?.configured ?
未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。
: null} + {config?.incremental_generation && !config.review_configured ?
视觉复核未配置,增量任务会拒绝启动:{config.review_error || "请配置独立视觉模型与 Chromium。"}
: null}
- -
+ +
+ + +
); } +function GenerationStatus({ task }: { task: TaskRecord | null }) { + const plan = task?.generation_plan; + const nodes = Array.isArray(plan?.nodes) ? plan.nodes.filter((node): node is Record => Boolean(node && typeof node === "object")) : []; + if (task?.lifecycle !== "running") return null; + return ( + + ); +} + function StudioLoading() { return (
diff --git a/frontend/src/components/agent-thread.tsx b/frontend/src/components/agent-thread.tsx index a2078f11..37fcef5d 100644 --- a/frontend/src/components/agent-thread.tsx +++ b/frontend/src/components/agent-thread.tsx @@ -1,20 +1,21 @@ "use client"; -import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Square, Upload } from "lucide-react"; +import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Upload } from "lucide-react"; import { useRef, useState, type ChangeEvent, type DragEvent } from "react"; import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react"; import type { CadAttachment } from "@/lib/cad-types"; import { CadErrorPart, CadImageAnalysisPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts"; -export function AgentThread({ attachments, uploading, uploadError, onUpload }: { +export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload }: { attachments: CadAttachment[]; uploading: boolean; uploadError: string; + taskRunning?: boolean; onUpload: (files: FileList | null) => void; }) { const fileInput = useRef(null); const dragDepth = useRef(0); - const running = useAuiState((state) => state.thread.isRunning); + const running = useAuiState((state) => state.thread.isRunning) || taskRunning; const [isDraggingFiles, setIsDraggingFiles] = useState(false); const canUpload = !uploading && !running; @@ -64,7 +65,7 @@ export function AgentThread({ attachments, uploading, uploadError, onUpload }: {
- + {isDraggingFiles ?
拖放文件上传
: null}
@@ -103,8 +104,8 @@ function AssistantMessage() { ); } -function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObject; uploading: boolean; onUpload: (files: FileList | null) => void }) { - const running = useAuiState((state) => state.thread.isRunning); +function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fileInput: React.RefObject; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void }) { + const running = useAuiState((state) => state.thread.isRunning) || taskRunning; const handleFileChange = (event: ChangeEvent) => { if (event.currentTarget.files?.length) onUpload(event.currentTarget.files); event.currentTarget.value = ""; @@ -113,12 +114,12 @@ function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObje
- +
{running ? ( - + ) : ( <> diff --git a/frontend/src/components/cad-message-parts.tsx b/frontend/src/components/cad-message-parts.tsx index b5a6c7b2..dfd2471c 100644 --- a/frontend/src/components/cad-message-parts.tsx +++ b/frontend/src/components/cad-message-parts.tsx @@ -29,14 +29,14 @@ export function CadProgressPart({ data }: { data: CadProgress }) { } export function CadResultPart({ data }: { data: CadResult }) { - const downloads: Array<[string, string]> = [ + const downloads: Array<[string, string]> = data.checkpoint ? [] : [ ["STEP", data.stepPath], ["CDSL", data.cdslPath], ["GLB", data.glbPath], ["报告", data.reportPath], ]; - if (data.qualityPath) downloads.push(["质量报告", data.qualityPath]); - if (data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]); + if (!data.checkpoint && data.qualityPath) downloads.push(["质量报告", data.qualityPath]); + if (!data.checkpoint && data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]); const qualityLabels: Record = { accepted: "验收通过", built_with_warnings: "构建完成,有警告", @@ -60,16 +60,16 @@ export function CadResultPart({ data }: { data: CadResult }) {
{data.assumptions?.length ?
假设{data.assumptions.join(";")}
: null} {data.referenceIds.length ?
参考{data.referenceIds.join(";")}
: null} - + {!data.checkpoint ? : null} {data.snapshotStatus && data.snapshotStatus !== "unavailable" ?
快照{data.snapshotStatus}
: null} -
: null}
); } @@ -114,9 +114,12 @@ function QualitySummary({ data }: { data: CadResult }) { export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) { const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? []; + const profileCount = data.profiles?.length || 0; + const holeCount = data.holes?.length || 0; + const viewCount = data.views?.length || data.attachmentIds.length; return (
-
+
{data.partType}
可见特征 @@ -135,6 +138,37 @@ export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) { )}
: null} + {(data.views?.length || profileCount || holeCount || data.measurements?.length || data.assumptions?.length) ? ( +
+ 勘测详情 +
+ 视角与几何 +

{viewCount} 个视角,{profileCount} 个轮廓,{holeCount} 个孔/切除特征

+
+ {data.profiles?.length ?
+ 草图候选 +
    + {data.profiles.slice(0, 12).map((profile) =>
  • + {profile.id} + {profile.segments?.length || 0} 段,{profile.closed ? "闭合" : "未确认闭合"}{profile.confidence == null ? "" : `,置信度 ${Math.round(profile.confidence * 100)}%`} +
  • )} +
+
: null} + {data.measurements?.length ?
+ 测量记录 +
    + {data.measurements.slice(0, 16).map((measurement, index) =>
  • + {measurement.name} + {measurement.value_mm == null ? "待确认" : `${measurement.value_mm} mm`} · {measurement.source || "image"} +
  • )} +
+
: null} + {data.uncertainties?.length ?
+ 勘测不确定项 +

{data.uncertainties.slice(0, 16).join(";")}

+
: null} +
+ ) : null}
); } diff --git a/frontend/src/components/cad-viewer-preview.tsx b/frontend/src/components/cad-viewer-preview.tsx index 4b0c3fde..86211d66 100644 --- a/frontend/src/components/cad-viewer-preview.tsx +++ b/frontend/src/components/cad-viewer-preview.tsx @@ -51,6 +51,7 @@ function resultFromBackend(payload: Record): CadResult { parametersPath: typeof payload.parameters_path === "string" ? payload.parameters_path : undefined, selectorPath: typeof payload.selector_path === "string" ? payload.selector_path : undefined, edgesPath: typeof payload.edges_path === "string" ? payload.edges_path : undefined, + topologyPath: typeof payload.topology_path === "string" ? payload.topology_path : undefined, summary: String(payload.summary || "Updated CDSL model"), referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [], engine: String(payload.engine || "cdsl_only"), @@ -61,6 +62,8 @@ function resultFromBackend(payload: Record): CadResult { repairAttempts: Number(payload.repair_attempts || 0), snapshotPaths: Array.isArray(payload.snapshot_paths) ? payload.snapshot_paths.map(String) : [], snapshotStatus: String(payload.snapshot_status || "unavailable"), + checkpoint: Boolean(payload.checkpoint), + lifecycle: typeof payload.lifecycle === "string" ? payload.lifecycle : undefined, }; } @@ -225,14 +228,32 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes setLoadState((current) => current.kind === "ready" ? current : { kind: "loading" }); const glbUrl = encodeArtifactUrl(result.taskId, result.glbPath); const selectorUrl = result.selectorPath ? encodeArtifactUrl(result.taskId, result.selectorPath) : ""; + const topologyUrl = result.topologyPath ? encodeArtifactUrl(result.taskId, result.topologyPath) : ""; void Promise.all([ loadRenderGlb(glbUrl), selectorUrl ? loadRenderJson(selectorUrl).catch(() => null) : Promise.resolve(null), + topologyUrl ? loadRenderJson(topologyUrl).catch(() => null) : Promise.resolve(null), ]) - .then(([meshData, selectorSidecar]) => { + .then(([meshData, selectorSidecar, topologySnapshot]) => { if (controller.signal.aborted) return; - const selectorRuntime = selectorSidecar && typeof selectorSidecar === "object" - ? buildCdslSelectorRuntime(selectorSidecar, meshData) + const topologyMatchesRevision = topologySnapshot && typeof topologySnapshot === "object" + && String((topologySnapshot as Record).task_id || "") === result.taskId + && String((topologySnapshot as Record).revision_id || "") === result.revisionId; + const rawTopologyRecords = topologyMatchesRevision ? (topologySnapshot as Record).records : null; + const topologyRecords = Array.isArray(rawTopologyRecords) + ? rawTopologyRecords.filter((record): record is Record => Boolean(record && typeof record === "object")) + : []; + const selectorPayload = selectorSidecar && typeof selectorSidecar === "object" + ? { + ...(selectorSidecar as Record), + edges: Array.isArray((selectorSidecar as Record).edges) + && ((selectorSidecar as Record).edges as unknown[]).length + ? (selectorSidecar as Record).edges + : topologyRecords.filter((record) => record.kind === "edge" && record.executable !== false), + } + : null; + const selectorRuntime = selectorPayload + ? buildCdslSelectorRuntime(selectorPayload as Parameters[0], meshData) : null; setLoadState({ kind: "ready", meshData, selectorRuntime }); setHoveredReferenceId(""); @@ -254,7 +275,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes onError(error instanceof Error ? error.message : "CAD Viewer asset loading failed"); }); return () => controller.abort(); - }, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId]); + }, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId, result?.topologyPath]); useEffect(() => { if (!reveal) return; @@ -263,7 +284,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes }, [reveal]); useEffect(() => { - if (!result) { + if (!result || result.checkpoint || isGenerating) { setParameters([]); setShowParameters(false); return; @@ -282,7 +303,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes if (!controller.signal.aborted) setParameterError(error instanceof Error ? error.message : "无法读取参数"); }); return () => controller.abort(); - }, [result?.revisionId, result?.taskId]); + }, [isGenerating, result?.checkpoint, result?.revisionId, result?.taskId]); const submitEdit = useCallback(async (operation: string, picks: Record[]) => { if (!result || !operation || !picks.length) return; @@ -380,7 +401,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes }, [loadState, onSelectionChange, result]); const commitParameters = useCallback(async (values: Record) => { - if (!result || !Object.keys(values).length) return; + if (!result || result.checkpoint || isGenerating || !Object.keys(values).length) return; const parameterId = Object.keys(values)[0]; setParameterPending(parameterId); setParameterError(""); @@ -396,13 +417,17 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes } finally { setParameterPending(""); } - }, [onResult, result]); + }, [isGenerating, onResult, result]); const viewerTheme = useMemo(() => ({ ...VIEWER_THEME, colorMode: theme }), [theme]); const activeToolDefinition = activeTool ? cadEditToolForOperation(activeTool) : null; const pickableFaces = useMemo( - () => loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [], - [loadState] + () => !isGenerating && !result?.checkpoint && loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [], + [isGenerating, loadState, result?.checkpoint] + ); + const pickableEdges = useMemo( + () => !isGenerating && !result?.checkpoint && loadState.kind === "ready" ? loadState.selectorRuntime?.edges || [] : [], + [isGenerating, loadState, result?.checkpoint] ); if (loadState.kind === "empty") return } text="3D 预览等待模型" />; if (loadState.kind === "loading") return } text="加载 CAD Viewer 资产..." />; @@ -429,10 +454,10 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes selectedReferenceIds={selectedReferenceIds} selectorRuntime={loadState.selectorRuntime} pickableFaces={pickableFaces} - pickableEdges={[]} + pickableEdges={pickableEdges} onHoverReferenceChange={setHoveredReferenceId} onActivateReference={handleActivateReference} - editPointPickEnabled={Boolean(activeTool)} + editPointPickEnabled={Boolean(activeTool) && !isGenerating && !result?.checkpoint} activeEditToolId={activeTool} editToolPickKind={cadEditToolNextPickKind(activeTool, editPicks.length)} editToolPicks={editPicks} @@ -445,16 +470,16 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes onAiSelectionComplete={onAiSelectionComplete} /> - + /> : null} { setActiveTool(tool); @@ -473,13 +498,13 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes }} /> viewerRef.current?.zoomToFit?.()} onScreenshot={() => void viewerRef.current?.captureScreenshot?.({ filename: "cdsl-cad.png" })} onParameters={() => setShowParameters(true)} /> {editPending || isGenerating ?
{editPending ? "正在应用 CDSL 编辑..." : "正在生成 CDSL 模型..."}
: null} - {activeToolDefinition ? ( + {activeToolDefinition && !isGenerating && !result?.checkpoint ? (
{activeToolDefinition.label}
@@ -543,7 +568,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes onClose={() => setShowParameters(false)} onCommit={(id, value) => void commitParameters({ [id]: value })} onReset={(values) => void commitParameters(values)} - downloads={[ + downloads={result.checkpoint ? [] : [ { label: "STEP", description: "CAD exchange", url: encodeArtifactUrl(result.taskId, result.stepPath) }, { label: "CDSL", description: "Editable model", url: encodeArtifactUrl(result.taskId, result.cdslPath) }, { label: "GLB", description: "Preview mesh", url: encodeArtifactUrl(result.taskId, result.glbPath) }, diff --git a/frontend/src/lib/cad-artifacts.ts b/frontend/src/lib/cad-artifacts.ts index 600dca02..1058dc9f 100644 --- a/frontend/src/lib/cad-artifacts.ts +++ b/frontend/src/lib/cad-artifacts.ts @@ -6,11 +6,8 @@ export function encodeArtifactUrl(taskId: string, artifactPath: string) { return `/api/tasks/${encodedTask}/artifacts/${encodedPath}`; } -export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null { - if (!task) return null; - const current = - task.revisions.find((revision) => revision.revision_id === task.current_revision) ?? - [...task.revisions].reverse().find((revision) => revision.status === "success"); +function resultForRevision(task: TaskRecord, revisionId: string, checkpoint: boolean): CadResult | null { + const current = task.revisions.find((revision) => revision.revision_id === revisionId); if ( !current || current.status !== "success" || @@ -31,6 +28,7 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul parametersPath: current.parameters_path, selectorPath: current.selector_path, edgesPath: current.edges_path, + topologyPath: current.topology_path, summary: current.summary || "CDSL CAD model", referenceIds: current.reference_ids || [], engine: current.engine || "cdsl_only", @@ -41,5 +39,22 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul repairAttempts: current.repair_attempts || 0, snapshotPaths: current.snapshot_manifest_path ? [current.snapshot_manifest_path] : [], snapshotStatus: current.snapshot_status || "unavailable", + checkpoint, + lifecycle: task.lifecycle || "completed", }; } + +export function activeCheckpointPreview(task: TaskRecord | null): CadResult | null { + if (!task || task.lifecycle !== "running") return null; + const revisionId = task.preview_revision || task.active_revision || task.current_revision; + if (!revisionId) return null; + return resultForRevision(task, revisionId, true); +} + +export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null { + if (!task) return null; + const current = + task.revisions.find((revision) => revision.revision_id === (task.published_revision || task.current_revision)) ?? + [...task.revisions].reverse().find((revision) => revision.status === "success" && revision.visibility !== "checkpoint"); + return current ? resultForRevision(task, current.revision_id, current.visibility === "checkpoint") : null; +} diff --git a/frontend/src/lib/cad-stream.test.ts b/frontend/src/lib/cad-stream.test.ts index 3f1ad093..f41130e2 100644 --- a/frontend/src/lib/cad-stream.test.ts +++ b/frontend/src/lib/cad-stream.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { backendEventToUiChunk } from "./cad-stream"; -import { latestSuccessfulResult } from "./cad-artifacts"; +import { activeCheckpointPreview, latestSuccessfulResult } from "./cad-artifacts"; import { messagesForBackend } from "./cad-messages"; import type { CadUIMessage } from "./cad-types"; @@ -14,6 +14,23 @@ test("maps backend cad_result SSE into an AI SDK data part", () => { assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" }); }); +test("keeps progressive revisions as separate data parts", () => { + const first = backendEventToUiChunk({ event: "cad_result", data: { taskId: "cad_abc", revisionId: "rev_001" } }, "text_1"); + const second = backendEventToUiChunk({ event: "cad_result", data: { taskId: "cad_abc", revisionId: "rev_002" } }, "text_1"); + assert.notEqual(first?.id, second?.id); +}); + +test("maps visual repair review into a blocking progress state", () => { + const chunk = backendEventToUiChunk({ + event: "render_review", + data: { taskId: "cad_abc", nodeId: "round", review: { verdict: "repair", confidence: 0.92, evidence: ["missing round"] } }, + }, "text_1"); + assert.equal(chunk?.type, "data-cad-progress"); + assert.deepEqual("data" in chunk! ? chunk.data : null, { + step: "render_review", label: "视觉复核", status: "error", message: "missing round", taskId: "cad_abc", nodeId: "round", + }); +}); + test("maps structured image analysis SSE into an AI SDK data part", () => { const data = { attachmentIds: ["upload_flange"], @@ -41,6 +58,34 @@ test("restores the latest successful task revision for the viewer", () => { assert.equal(result?.summary, "done"); }); +test("prefers the published revision over an active checkpoint", () => { + const result = latestSuccessfulResult({ + task_id: "cad_abc", + current_revision: "rev_002", + active_revision: "rev_002", + published_revision: "rev_001", + lifecycle: "running", + revisions: [ + { revision_id: "rev_001", status: "success", visibility: "final", cdsl_path: "a", step_path: "b", glb_path: "c", report_path: "d" }, + { revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" }, + ], + }); + assert.equal(result?.revisionId, "rev_001"); + assert.equal(result?.checkpoint, false); +}); + +test("restores an active checkpoint only while the task is running", () => { + const result = activeCheckpointPreview({ + task_id: "cad_abc", current_revision: "rev_002", active_revision: "rev_002", published_revision: "rev_001", lifecycle: "running", + revisions: [ + { revision_id: "rev_001", status: "success", visibility: "final", cdsl_path: "a", step_path: "b", glb_path: "c", report_path: "d" }, + { revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" }, + ], + }); + assert.equal(result?.revisionId, "rev_002"); + assert.equal(result?.checkpoint, true); +}); + test("strips non-text and non-CAD parts before sending to FastAPI", () => { const messages: CadUIMessage[] = [{ id: "m1", diff --git a/frontend/src/lib/cad-stream.ts b/frontend/src/lib/cad-stream.ts index d720b325..127c71bc 100644 --- a/frontend/src/lib/cad-stream.ts +++ b/frontend/src/lib/cad-stream.ts @@ -19,10 +19,37 @@ export function backendEventToUiChunk( data: item.data, }; } + if (["generation_plan", "checkpoint", "render_review", "rollback", "task_terminal"].includes(item.event)) { + const review = item.data.review && typeof item.data.review === "object" + ? item.data.review as Record + : null; + const status = item.event === "task_terminal" + ? (String(item.data.lifecycle || "") === "failed" ? "error" : "success") + : item.event === "render_review" && String(review?.verdict || "") === "repair" && Number(review?.confidence || 0) >= 0.85 + ? "error" + : String(item.data.status || "running"); + return { + type: "data-cad-progress", + id: `${item.event}_${String(item.data.taskId || Date.now())}_${String(item.data.nodeId || "")}`, + data: { step: item.event, label: ({ + generation_plan: "生成计划", checkpoint: "构建检查点", render_review: "视觉复核", rollback: "回滚检查点", task_terminal: "生成任务", + } as Record)[item.event], status, message: String( + item.data.message || item.data.reason || (review?.evidence instanceof Array ? review.evidence.join(";") : ""), + ), + ...(item.data.taskId ? { taskId: String(item.data.taskId) } : {}), + ...(item.data.nodeId ? { nodeId: String(item.data.nodeId) } : {}), + ...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}), + }, + }; + } if (item.event === "cad_result") { + const taskId = String(item.data.taskId || ""); + const revisionId = String(item.data.revisionId || Date.now()); return { type: "data-cad-result", - id: `result_${String(item.data.taskId || Date.now())}`, + // Each revision is a distinct progressive result. Reusing only the task + // id makes the AI SDK reconcile intermediate revisions into one part. + id: `result_${taskId}_${revisionId}`, data: item.data, }; } diff --git a/frontend/src/lib/cad-types.ts b/frontend/src/lib/cad-types.ts index 3b441a6f..c5044d2d 100644 --- a/frontend/src/lib/cad-types.ts +++ b/frontend/src/lib/cad-types.ts @@ -5,6 +5,9 @@ export type CadProgress = { label: string; status: "running" | "success" | "error" | string; message?: string; + taskId?: string; + nodeId?: string; + lifecycle?: "running" | "completed" | "failed" | string; }; export type CadResult = { @@ -17,6 +20,7 @@ export type CadResult = { parametersPath?: string; selectorPath?: string; edgesPath?: string; + topologyPath?: string; summary: string; referenceIds: string[]; engine: string; @@ -27,6 +31,8 @@ export type CadResult = { repairAttempts?: number; snapshotPaths?: string[]; snapshotStatus?: string; + checkpoint?: boolean; + lifecycle?: "running" | "completed" | "failed" | string; }; export type CadError = { @@ -40,7 +46,32 @@ export type CadImageDimension = { reason: string; }; +export type CadImageSegment = { + type: "line" | "arc" | "circle" | "polyline" | "unknown_curve" | string; + start?: number[]; + end?: number[]; + center?: number[]; + radius_mm?: number | null; + points?: number[][]; + confidence?: number | null; + notes?: string; +}; + +export type CadImageProfile = { + id: string; + role?: string; + plane_hint?: string; + closed?: boolean; + segments?: CadImageSegment[]; + source_images?: string[]; + confidence?: number | null; + uncertain?: string[]; + notes?: string; +}; + export type CadImageAnalysis = { + observationStage?: "survey" | "sketch" | "complete" | string; + schemaVersion?: string; attachmentIds: string[]; partType: string; visibleFeatures: string[]; @@ -48,6 +79,16 @@ export type CadImageAnalysis = { dimensionCandidates?: CadImageDimension[]; // Legacy conversations stored this field before dimensions became optional. requiredDimensions?: CadImageDimension[]; + views?: Array<{ attachment_id?: string; view_role?: string; orientation?: string; quality?: string; confidence?: number | null }>; + overallGeometry?: Record; + surfaces?: Array>; + profiles?: CadImageProfile[]; + holes?: Array>; + bends?: Array>; + measurements?: Array<{ name: string; value_mm?: number | null; source?: string; confidence?: number | null; evidence?: string }>; + uncertainties?: string[]; + assumptions?: string[]; + cvHints?: Array>; artifactPath?: string; }; @@ -77,6 +118,10 @@ export type CadAttachment = { size: number; sha256: string; extracted_path?: string; + width?: number; + height?: number; + orientation?: string; + format?: string; }; export type TaskRevision = { @@ -89,6 +134,7 @@ export type TaskRevision = { parameters_path?: string; selector_path?: string; edges_path?: string; + topology_path?: string; summary?: string; reference_ids?: string[]; engine?: string; @@ -101,11 +147,21 @@ export type TaskRevision = { repair_attempts?: number; snapshot_manifest_path?: string; snapshot_status?: string; + visibility?: "checkpoint" | "final" | "superseded" | string; + node_id?: string; + render_manifest_path?: string; + visual_review_path?: string; }; export type TaskRecord = { task_id: string; current_revision: string; + active_revision?: string; + published_revision?: string; + lifecycle?: "running" | "completed" | "failed" | string; + active_node_id?: string; + preview_revision?: string; + generation_plan?: Record | null; revisions: TaskRevision[]; }; @@ -121,4 +177,7 @@ export type BackendConfig = { configured: boolean; library_samples: number; max_repair_attempts?: number; + incremental_generation?: boolean; + review_configured?: boolean; + review_error?: string; }; diff --git a/frontend/src/lib/cdsl-selector-runtime.test.ts b/frontend/src/lib/cdsl-selector-runtime.test.ts index b512c1ef..4888829d 100644 --- a/frontend/src/lib/cdsl-selector-runtime.test.ts +++ b/frontend/src/lib/cdsl-selector-runtime.test.ts @@ -41,3 +41,22 @@ test("uses backend face triangle ranges when they are available", () => { assert.deepEqual([...runtime.proxy.faceIds], [0, 1]); assert.deepEqual([...runtime.proxy.faceRuns], [0, 0, 0, 1, 0, 0, 0, 1, 1, 1]); }); + +test("keeps face row lookup separate from edge row lookup", () => { + const runtime = buildCdslSelectorRuntime({ + references: [ + { id: "face_000", selectorType: "face", center: [0, 0, 0], normal: [0, 0, 1], frame: { origin_mm: [0, 0, 0], normal: [0, 0, 1] } }, + ], + edges: [{ + record_id: "edge_000", + geometry: { start_mm: [0, 0, 0], end_mm: [1, 0, 0], center_mm: [0.5, 0, 0], bbox_mm: [0, 0, 0, 1, 0, 0], curve_type: "line" }, + }], + }, { + vertices: new Float32Array(9), + indices: new Uint32Array([0, 1, 2]), + }); + + assert.equal(runtime.faceReferenceByRowIndex.get(0)?.id, "face_000"); + assert.equal(runtime.edgeReferenceByRowIndex.get(0)?.id, "edge_000"); + assert.deepEqual(runtime.faces.map((reference) => reference.id), ["face_000"]); +}); diff --git a/frontend/src/lib/cdsl-selector-runtime.ts b/frontend/src/lib/cdsl-selector-runtime.ts index 59a42ecc..257ffcf7 100644 --- a/frontend/src/lib/cdsl-selector-runtime.ts +++ b/frontend/src/lib/cdsl-selector-runtime.ts @@ -11,9 +11,20 @@ type SidecarReference = { surface_type?: string; triangle_start?: number; triangle_count?: number; + executable?: boolean; + snapshot_id?: string; }; -type Sidecar = { references?: SidecarReference[] }; +type SidecarEdge = { + record_id?: string; + selectorType?: string; + executable?: boolean; + snapshot_id?: string; + geometry?: Record; + owner_feature_ids?: string[]; +}; + +type Sidecar = { references?: SidecarReference[]; edges?: SidecarEdge[] }; const GLB_CAD_UNIT_SCALE = 1000; const FACE_ID_NONE = 0xffffffff; @@ -96,7 +107,7 @@ function triangleRuns(faceIds: Uint32Array, occurrenceRow: number) { export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) { const sourceReferences = Array.isArray(sidecar?.references) ? sidecar.references : []; const faces = sourceReferences - .filter((reference) => String(reference?.selectorType || "").toLowerCase() === "face") + .filter((reference) => String(reference?.selectorType || "").toLowerCase() === "face" && reference?.executable !== false) .map((reference, rowIndex) => { const frame = sourceFrame(reference); if (!frame) return null; @@ -141,6 +152,73 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) { }) .filter(Boolean) as any[]; + const edgeLines: number[] = []; + const edgeIndices: number[] = []; + const edgeIds: number[] = []; + const edgeReferences = (Array.isArray(sidecar?.edges) ? sidecar.edges : []) + .filter((record) => record?.executable !== false) + .map((record, rowIndex) => { + const geometry = record.geometry || {}; + const bbox = Array.isArray(geometry.bbox_mm) && geometry.bbox_mm.length === 6 + ? geometry.bbox_mm.map(Number) + : null; + const center = Array.isArray(geometry.center_mm) && geometry.center_mm.length >= 3 + ? geometry.center_mm.slice(0, 3).map(Number) + : null; + const start = Array.isArray(geometry.start_mm) && geometry.start_mm.length >= 3 + ? geometry.start_mm.slice(0, 3).map(Number) + : null; + const end = Array.isArray(geometry.end_mm) && geometry.end_mm.length >= 3 + ? geometry.end_mm.slice(0, 3).map(Number) + : null; + let points: number[][] = start && end ? [start, end] : []; + if (!points.length && center && bbox && String(geometry.curve_type || "").toLowerCase() === "circle") { + const extents = [bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2]]; + const normalAxis = extents.indexOf(Math.min(...extents)); + const axes = [0, 1, 2].filter((axis) => axis !== normalAxis); + const radius = Math.max(extents[axes[0]], extents[axes[1]]) / 2; + points = Array.from({ length: 33 }, (_, index) => { + const angle = (index / 32) * Math.PI * 2; + const point = [...center]; + point[axes[0]] += Math.cos(angle) * radius; + point[axes[1]] += Math.sin(angle) * radius; + return point; + }); + } + if (points.length < 2 || !points.every((point) => point.every(Number.isFinite))) return null; + const segmentStart = edgeIndices.length / 2; + for (let index = 1; index < points.length; index += 1) { + const startIndex = edgeLines.length / 3; + edgeLines.push(...previewVector(points[index - 1] as Vector3), ...previewVector(points[index] as Vector3)); + edgeIndices.push(startIndex, startIndex + 1); + edgeIds.push(rowIndex); + } + const edgeId = String(record.record_id || `edge_${rowIndex}`); + const previewCenter = center ? previewVector(center as Vector3) : null; + return { + id: edgeId, + selectorType: "edge", + normalizedSelector: edgeId, + displaySelector: edgeId, + label: String(geometry.curve_type || "edge"), + summary: String(geometry.curve_type || edgeId), + shortSummary: String(geometry.curve_type || edgeId), + partId: "glb:0", + rowIndex, + pickData: { + selectorType: "edge", + center: previewCenter, + sourceBoundsMm: bbox, + bbox: bbox ? mappedBBox({ min: bbox.slice(0, 3), max: bbox.slice(3, 6) }) : null, + segmentStart, + segmentCount: points.length - 1, + curveType: String(geometry.curve_type || "edge"), + cdslCoordinateSystem: "build123d_y_up_glb", + }, + }; + }) + .filter(Boolean) as any[]; + const vertices = meshData?.vertices; const indices = meshData?.indices; const triangleCount = Math.floor((indices?.length || 0) / 3); @@ -186,7 +264,7 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) { } const faceRuns = triangleRuns(faceIds, 0); - const references = faces; + const references = [...faces, ...edgeReferences]; return { schemaVersion: 1, surfaceEdgeRendering: false, @@ -196,19 +274,21 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) { bbox: meshData?.bounds || null, occurrences: [{ id: "glb:0" }], shapes: [], - faces: references, - edges: [], + faces, + edges: edgeReferences, vertices: [], references, referenceMap: new Map(references.map((reference) => [reference.id, reference])), referenceByNormalizedSelector: new Map(references.map((reference) => [reference.normalizedSelector, reference])), referenceByDisplaySelector: new Map(references.map((reference) => [reference.displaySelector, reference])), - faceReferenceByRowIndex: new Map(references.map((reference) => [reference.rowIndex, reference])), - edgeReferenceByRowIndex: new Map(), + // Face and edge rows are separate namespaces. Mapping all references here + // lets edge rows overwrite face rows and makes face clicks unselectable. + faceReferenceByRowIndex: new Map(faces.map((reference) => [reference.rowIndex, reference])), + edgeReferenceByRowIndex: new Map(edgeReferences.map((reference) => [reference.rowIndex, reference])), vertexReferenceByRowIndex: new Map(), occurrenceIdByRowIndex: new Map([[0, "glb:0"]]), - faceReferenceMap: new Map(references.map((reference) => [reference.id, reference])), - edgeReferenceMap: new Map(), + faceReferenceMap: new Map(faces.map((reference) => [reference.id, reference])), + edgeReferenceMap: new Map(edgeReferences.map((reference) => [reference.id, reference])), vertexReferenceMap: new Map(), singleOccurrenceId: "glb:0", proxy: { @@ -217,9 +297,9 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) { faceIds, faceRuns, faceRunColumns: ["occurrenceRow", "primitiveIndex", "triangleStart", "triangleCount", "faceRow"], - edgePositions: new Float32Array(0), - edgeIndices: new Uint32Array(0), - edgeIds: new Uint32Array(0), + edgePositions: new Float32Array(edgeLines), + edgeIndices: new Uint32Array(edgeIndices), + edgeIds: new Uint32Array(edgeIds), faceEdgeRows: [], edgeFaceRows: [], }, diff --git a/frontend/src/lib/viewer-selection.ts b/frontend/src/lib/viewer-selection.ts index 36a7cc4b..eaf7648b 100644 --- a/frontend/src/lib/viewer-selection.ts +++ b/frontend/src/lib/viewer-selection.ts @@ -128,6 +128,7 @@ function compactEntity( selector: String(selectedEntity?.selector || reference?.displaySelector || reference?.normalizedSelector || id).trim(), label: String(reference?.label || selectedEntity?.surfaceType || referenceData.surfaceType || "face").trim(), selectorType: String(selectedEntity?.selectorType || reference?.selectorType || "face").trim(), + snapshotId: String(selectedEntity?.snapshotId || reference?.snapshot_id || "").trim(), surfaceType: String(selectedEntity?.surfaceType || referenceData.surfaceType || "unknown").trim(), centerMm: referenceData.centerMm, normal: referenceData.normal, -- 2.52.0