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] =?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[]; };