diff --git a/backend/.env b/backend/.env index ff21e115..033f5c49 100644 --- a/backend/.env +++ b/backend/.env @@ -1,6 +1,6 @@ # Default provider. Only providers with an API key are exposed to the UI. CDSL_DEFAULT_PROVIDER=deepseek -CDSL_DEFAULT_MODEL=deepseek-v4-flash-vision-exp +CDSL_DEFAULT_MODEL=deepseek-v4-flash # CDSL_DEFAULT_PROVIDER=openai # CDSL_DEFAULT_MODEL=gpt-5.5 @@ -11,12 +11,16 @@ CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp CDSL_LLM_TIMEOUT_S=90 CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp +# Incremental generation uses a separate vision-capable model for checkpoint review. +CDSL_REVIEW_PROVIDER=deepseek +CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp + # Optional OpenAI provider. Comma-separate enabled models; list vision models # separately so image attachments can be routed safely. CDSL_OPENAI_BASE_URL=https://api.vip1129.cc/v1 CDSL_OPENAI_API_KEY=sk-6586c229d77de8c421ba98e7eb0d9c6bb10f08ebc796de946ed17cf8d0d7a229 -CDSL_OPENAI_MODELS=gpt-5.5 -CDSL_OPENAI_VISION_MODELS=gpt-5.5 +CDSL_OPENAI_MODELS=gpt-5.5,gpt-5.6-luna +CDSL_OPENAI_VISION_MODELS=gpt-5.5,gpt-5.6-luna # Optional Kimi provider. CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1 diff --git a/backend/README.md b/backend/README.md index db9a62ac..e445d238 100644 --- a/backend/README.md +++ b/backend/README.md @@ -9,3 +9,40 @@ The backend owns the application API and CAD generation workflow: - `tests/`: Engine, API, and end-to-end generation tests. Expected development entrypoint: `app.main:app`, served by Uvicorn. + +## Incremental Generation Configuration + +Incremental generation is enabled by default. It requires a separately +configured vision-capable review model and the Python OpenCascade/Pillow +technical renderer; a run +fails instead of skipping visual review when either is unavailable. + +```dotenv +# Authoring provider/model must already be configured as usual. +CDSL_INCREMENTAL_GENERATION=1 + +# Must name one configured provider and one model listed in that provider's +# CDSL__VISION_MODELS setting. It is intentionally not inferred +# from the authoring model. +CDSL_REVIEW_PROVIDER=openai +CDSL_REVIEW_MODEL=gpt-4.1-mini +CDSL_OPENAI_VISION_MODELS=gpt-4.1-mini + +# Install Python rendering dependencies. The renderer reads the revision STEP +# file and creates canonical images without a browser or GPU driver. +pip install -r requirements.txt + +# Optional per-node retry budgets. +CDSL_NODE_AUTHORING_ATTEMPTS=2 +CDSL_NODE_REPAIR_ATTEMPTS=2 +CDSL_NODE_REPLAN_ATTEMPTS=1 +``` + +Every checkpoint is rebuilt from its fully materialized CDSL through the +`cdsl_only` runtime. Checkpoint GLB files are preview-only; STEP, CDSL, and +reports are available only after the task reaches `COMPLETED`. + +The generation plan contains semantic node IDs only. The backend derives the +unique CDSL feature and sketch IDs from each node, then writes them during +fragment materialization. This keeps naming and topology ownership stable +without requiring the authoring model to reproduce internal identifiers. diff --git a/backend/app/main.py b/backend/app/main.py index 70fff67e..abf6a9ad 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,17 +1,22 @@ from __future__ import annotations +import asyncio import json +import secrets from typing import Any from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi.responses import JSONResponse, StreamingResponse from app.models.contracts import ChatRequest, ConversationPatch, ModifyRequest, ParameterUpdate -from app.services.engine_service import apply_parameter_updates, build_revision +from app.services.engine_service import QualityVerificationError, apply_parameter_updates, build_revision from app.services.agent_service import AgentService from app.services.library import CdslLibrary -from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id +from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id, write_json from app.services.attachments import attachment_record, classify_upload, extract_document_text +from app.services.image_processing import image_metadata +from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status +from app.services.visual_review import VisualReviewError, review_checkpoint from app.settings import get_settings @@ -22,6 +27,99 @@ agent = AgentService(settings, store, library) app = FastAPI(title="CDSL CAD Agent API", version="0.1.0") +@app.on_event("startup") +async def resume_incremental_generation() -> None: + """Restore durable generation tasks after a backend process restart.""" + await agent.resume_running_tasks() + + +async def _finalize_controlled_revision( + *, + task_id: str, + previous_revision_id: str, + node_id: str, + built: dict[str, Any], +) -> dict[str, Any]: + """Publish a deterministic post-completion edit only after vision review.""" + revision_id = str(built["revision_id"]) + try: + generation_spec = store.read_generation_spec(task_id) or {} + requirements = generation_spec.get("requirements") if isinstance(generation_spec.get("requirements"), list) else [] + render_dir = store.revision_dir(task_id, revision_id) / "review" + manifest = await asyncio.to_thread( + render_checkpoint, + settings, + step_path=store.artifact_path(task_id, str(built["step_path"])), + output_dir=render_dir, + ) + review = await review_checkpoint( + settings, + manifest=manifest, + requirements=requirements, + node_id=node_id, + deterministic_report={ + "quality_status": built.get("quality_status"), + "verification": built.get("verification_summary", {}), + }, + final_checkpoint=True, + ) + manifest_path = (render_dir / "render-manifest.json").relative_to(store.task_dir(task_id)).as_posix() + review_path = (render_dir / "visual-review.json").relative_to(store.task_dir(task_id)).as_posix() + write_json(render_dir / "visual-review.json", review) + store.update_revision_metadata(task_id, revision_id, { + "render_manifest_path": manifest_path, + "visual_review_path": review_path, + }) + if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85: + store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}") + store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "node_id": node_id, + "stage": "visual_review", + "error_code": "HIGH_CONFIDENCE_VISUAL_REPAIR", + "message": "; ".join(review.get("evidence") or ["Visual review rejected the controlled edit"]), + "recommended_rollback_revision": previous_revision_id, + }) + raise ValueError("Visual review rejected this edit; the model was rolled back to its previous revision") + store.finish_generation(task_id, lifecycle="completed") + return {**built, "visibility": "final", "lifecycle": "completed", "checkpoint": False} + except (ReviewRenderError, VisualReviewError, ValueError): + task = store.read_task(task_id) or {} + if str(task.get("lifecycle") or "") == "running": + store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}") + store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "node_id": node_id, + "stage": "visual_review", + "message": "Controlled edit could not complete its required review", + "recommended_rollback_revision": previous_revision_id, + }) + raise + + +def _require_controlled_review_configuration() -> None: + """Fail before a post-completion edit creates an unreviewed checkpoint.""" + settings.resolve_review_model() + ready, detail = renderer_status() + if not ready: + raise ValueError(detail) + + +def _fail_controlled_run(task_id: str, previous_revision_id: str, node_id: str, error: Exception) -> None: + task = store.read_task(task_id) or {} + if str(task.get("lifecycle") or "") != "running": + return + store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}") + store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "node_id": node_id, + "stage": "controlled_build", + "error_code": type(error).__name__.upper(), + "message": str(error), + "recommended_rollback_revision": previous_revision_id, + }) + + @app.get("/health") async def health() -> dict[str, Any]: return { @@ -50,6 +148,12 @@ async def config() -> dict[str, Any]: for model in provider.models ], }) + try: + settings.resolve_review_model() + renderer_ready, renderer_detail = renderer_status() + review_error = "" if renderer_ready else renderer_detail + except ValueError as error: + review_error = str(error) return { "default_provider": settings.default_provider_id, "default_model": settings.llm_model, @@ -58,6 +162,9 @@ async def config() -> dict[str, Any]: "configured": settings.llm_configured, "library_samples": library.count(), "max_repair_attempts": settings.max_repair_attempts, + "incremental_generation": settings.incremental_generation, + "review_configured": not review_error, + "review_error": review_error, } @@ -104,8 +211,13 @@ async def upload_conversation_attachment( filename = file.filename or "attachment" try: conversation = safe_conversation_id(conversation_id) - if store.read_conversation(conversation) is None: + current = store.read_conversation(conversation) + if current is None: raise HTTPException(status_code=404, detail="Conversation not found") + active_task_id = str(current.get("current_task_id") or "") + active_task = store.read_task(active_task_id) if active_task_id else None + if str((active_task or {}).get("lifecycle") or "") == "running": + raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state") kind = classify_upload(filename, file.content_type or "", len(data)) relative_path, _ = store.write_conversation_upload(conversation, filename, data) extracted_path = "" @@ -113,7 +225,8 @@ async def upload_conversation_attachment( extracted_path = relative_path + ".txt" extracted = extract_document_text(data) store.conversation_attachment_path(conversation, extracted_path).write_text(extracted, encoding="utf-8") - record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path) + metadata = image_metadata(data) if kind == "image" else {} + record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path, metadata) store.add_conversation_attachment(conversation, record) return JSONResponse(record) except ValueError as error: @@ -128,6 +241,11 @@ async def read_task(task_id: str) -> JSONResponse: raise HTTPException(status_code=400, detail=str(error)) from error if task is None: raise HTTPException(status_code=404, detail="Task not found") + # Keep the task endpoint self-contained for a reconnecting UI. The plan is + # immutable within a run and exposes node status, while previews always use + # the active working revision rather than a downloadable artifact. + task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "") + task["generation_plan"] = store.read_generation_spec(task["task_id"]) return JSONResponse(task) @@ -142,6 +260,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse: raise HTTPException(status_code=400, detail=str(error)) from error if not path.is_file(): raise HTTPException(status_code=404, detail="Artifact not found") + task = store.read_task(safe_id) or {} + parts = artifact_path.split("/") + revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else "" + revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None) + published_revision = str(task.get("published_revision") or "") + active_revision = str(task.get("active_revision") or task.get("current_revision") or "") + if isinstance(revision, dict) and revision_id != published_revision: + # Revisions are private until publication. The currently active + # checkpoint exposes only its GLB inline for the review viewer; an old + # or superseded checkpoint has no public artifact surface at all. + if revision_id != active_revision or path.name != "model.glb": + raise HTTPException(status_code=403, detail="Only the published revision is downloadable") + return FileResponse(path, media_type="model/gltf-binary", headers={"Content-Disposition": "inline"}) return FileResponse(path, filename=path.name) @@ -152,6 +283,8 @@ async def read_parameters(task_id: str) -> JSONResponse: except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error task = store.read_task(safe_id) + if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""): + raise HTTPException(status_code=403, detail="Checkpoint parameters are not available until publication") revision_id = str((task or {}).get("current_revision") or "") revision = next((item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id), None) relative = str((revision or {}).get("parameters_path") or "") @@ -170,6 +303,8 @@ async def read_quality(task_id: str) -> JSONResponse: except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error task = store.read_task(safe_id) + if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""): + raise HTTPException(status_code=403, detail="Checkpoint reports are not available until publication") revision_id = str((task or {}).get("current_revision") or "") revisions = (task or {}).get("revisions", []) revision = next((item for item in revisions if item.get("revision_id") == revision_id), None) @@ -200,11 +335,16 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo try: safe_id = safe_task_id(task_id) task = store.read_task(safe_id) + if str((task or {}).get("lifecycle") or "") == "running": + raise ValueError("CAD task is running; parameter changes are locked") current_revision_id = str((task or {}).get("current_revision") or "") current_path = store.current_cdsl_path(safe_id) if not task or not current_path or not current_revision_id: raise ValueError("Task has no successful CDSL revision") updated, _ = apply_parameter_updates(json.loads(current_path.read_text(encoding="utf-8")), payload.values) + if settings.incremental_generation: + _require_controlled_review_configuration() + store.start_generation(safe_id, request=f"Parameter update: {', '.join(payload.values)}") result = build_revision( settings=settings, store=store, @@ -217,9 +357,21 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo operation={"type": "parameter_update", "values": payload.values}, part_skills=None, generation_assumptions=[], + node_id="parameter_update" if settings.incremental_generation else "", + branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main", + visibility="checkpoint" if settings.incremental_generation else "final", ) + if settings.incremental_generation: + result = await _finalize_controlled_revision( + task_id=safe_id, + previous_revision_id=current_revision_id, + node_id="parameter_update", + built=result, + ) return JSONResponse(result) - except ValueError as error: + except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error: + if settings.incremental_generation and "safe_id" in locals() and "current_revision_id" in locals(): + _fail_controlled_run(safe_id, current_revision_id, "parameter_update", error) raise HTTPException(status_code=400, detail=str(error)) from error @@ -228,7 +380,29 @@ async def modify_task(task_id: str, payload: ModifyRequest) -> JSONResponse: from app.services.editing import apply_direct_edit try: - result = apply_direct_edit(settings, store, safe_task_id(task_id), payload.operation, payload.selection, payload.parameters) + safe_id = safe_task_id(task_id) + task = store.read_task(safe_id) or {} + if str(task.get("lifecycle") or "") == "running": + raise ValueError("CAD task is running; topology edits are locked") + previous_revision_id = str(task.get("current_revision") or "") + if settings.incremental_generation: + _require_controlled_review_configuration() + store.start_generation(safe_id, request=f"Direct CDSL edit: {payload.operation}") + result = apply_direct_edit( + settings, store, safe_id, payload.operation, payload.selection, payload.parameters, + node_id="topology_edit" if settings.incremental_generation else "", + branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main", + visibility="checkpoint" if settings.incremental_generation else "final", + ) + if settings.incremental_generation: + result = await _finalize_controlled_revision( + task_id=safe_id, + previous_revision_id=previous_revision_id, + node_id="topology_edit", + built=result, + ) return JSONResponse(result) - except ValueError as error: + except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error: + if settings.incremental_generation and "safe_id" in locals() and "previous_revision_id" in locals(): + _fail_controlled_run(safe_id, previous_revision_id, "topology_edit", error) raise HTTPException(status_code=400, detail=str(error)) from error diff --git a/backend/app/models/contracts.py b/backend/app/models/contracts.py index d50f9025..71c4e261 100644 --- a/backend/app/models/contracts.py +++ b/backend/app/models/contracts.py @@ -56,6 +56,7 @@ class CadResult(BaseModel): parameters_path: str | None = None selector_path: str | None = None edges_path: str | None = None + topology_path: str | None = None summary: str reference_ids: list[str] = Field(default_factory=list) engine: str = "cdsl_only" diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index bdefac56..d90515e6 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -17,11 +17,20 @@ import httpx from app.models.contracts import ChatMessage from app.services.engine_service import build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch +from app.services.feature_plan import FeaturePlanError, compute_node_statuses, validate_feature_plan +from app.services.incremental_generation import IncrementalGenerationRunner from app.services.library import CdslLibrary from app.services.part_skills import PartSkillLibrary -from app.services.quality import QUALITY_RULE_TYPES, validate_verification +from app.services.quality import FEATURE_RULE_TYPES, QUALITY_RULE_TYPES, validate_verification from app.services.sse import event from app.services.storage import WorkspaceStore, now_iso +from app.services.image_observation import ( + merge_image_observations, + normalize_image_observation, + normalize_sketch_candidates, + render_image_observation_context, +) +from app.services.image_processing import cv_hints from app.settings import ProviderConfig, ProviderModel, Settings @@ -49,6 +58,23 @@ class CdslRepairLimitError(RuntimeError): self.diagnostic_paths = diagnostic_paths +def get_repair_step_key(planning_state: dict[str, Any], tool_name: str) -> str: + """Identify one semantic generation step without carrying failures across batches.""" + plan = planning_state.get("feature_plan") if isinstance(planning_state, dict) else None + if isinstance(plan, dict): + active_nodes = sorted( + str(node.get("id")) + for node in plan.get("nodes") or () + if isinstance(node, dict) + and node.get("status") in {"ready", "executing"} + ) + if active_nodes: + return f"plan:{str(plan.get('plan_id') or '')}:{','.join(active_nodes)}" + # ``generate_cdsl_model`` and ``patch_cdsl_model`` are both attempts at + # the same repair phase when no feature plan is available. + return f"phase:{str(planning_state.get('phase') or '')}" + + def user_visible_error_message(error: Exception, user_text: str) -> str: if isinstance(error, StrictToolSchemaError) and any( "\u4e00" <= char <= "\u9fff" for char in str(user_text or "") @@ -71,8 +97,8 @@ def user_visible_error_message(error: Exception, user_text: str) -> str: if isinstance(error, CdslRepairLimitError): diagnostics = "、".join(error.diagnostic_paths) if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")): - return "模型重试达到四次 CDSL 修复上限,未创建新的成功 revision。每次 CDSL 校验失败的诊断已保存到:" + diagnostics + "。" - return "The four-attempt CDSL repair limit was reached. Diagnostics were saved to: " + diagnostics + "." + return "同一 CDSL 步骤连续重试达到四次上限,未创建新的成功 revision。每次 CDSL 校验失败的诊断已保存到:" + diagnostics + "。" + return "The same CDSL step failed four consecutive times. Diagnostics were saved to: " + diagnostics + "." return str(error) @@ -110,8 +136,44 @@ def _repair_premature_tool_wrapper_close(source: str, parsed_value: Any, parsed_ return value +def _recover_trailing_cdsl_metadata( + source: str, + parsed_value: Any, + parsed_end: int, +) -> dict[str, Any] | None: + """Accept a complete CDSL envelope followed only by duplicate metadata.""" + required = {"cdsl", "summary", "assumptions"} + allowed = required | {"verification"} + if ( + not isinstance(parsed_value, dict) + or not required.issubset(parsed_value) + or not set(parsed_value).issubset(allowed) + ): + return None + + # Some providers continue after a complete root object with a second copy + # of its presentation metadata. Decode that suffix as its own object; + # never use a regex to parse nested JSON. The CDSL payload itself may not + # reappear, so the executable model always comes from the first object. + suffix = source[parsed_end:] + if not suffix.startswith(","): + return None + try: + duplicate, duplicate_end = json.JSONDecoder().raw_decode("{" + suffix[1:]) + except json.JSONDecodeError: + return None + if ( + duplicate_end != len(suffix) + or not isinstance(duplicate, dict) + or not duplicate + or not set(duplicate).issubset({"summary", "assumptions", "verification"}) + ): + return None + return parsed_value + + def parse_tool_arguments(raw_arguments: Any, *, recover_cdsl_wrapper: bool = False) -> dict[str, Any]: - """Decode one function-call argument object, with one guarded CDSL repair.""" + """Decode one function-call argument object, with guarded CDSL repairs.""" if raw_arguments is None or raw_arguments == "": return {} if not isinstance(raw_arguments, str): @@ -129,6 +191,9 @@ def parse_tool_arguments(raw_arguments: Any, *, recover_cdsl_wrapper: bool = Fal repaired = _repair_premature_tool_wrapper_close(source, value, parsed_end) if repaired is not None: return repaired + repaired = _recover_trailing_cdsl_metadata(source, value, parsed_end) + if repaired is not None: + return repaired raise ToolArgumentsError("arguments contain trailing content after the JSON object") if not isinstance(value, dict): raise ToolArgumentsError("arguments must decode to a JSON object") @@ -141,14 +206,43 @@ def invalid_tool_arguments_result(name: str, error: ToolArgumentsError) -> dict[ "code": "INVALID_TOOL_ARGUMENTS", "message": ( f"{name} arguments were rejected: {error}. " - "Call the same tool again with exactly one valid JSON object. " - "Do not append prose, Markdown fences, or another JSON value." + "Regenerate the same tool call with exactly one complete JSON object, " + "from its first `{` through its final `}`. Do not repeat any fields " + "or append prose, Markdown fences, or another JSON value." ), } def _cdsl_error_details(error: Exception) -> dict[str, Any]: message = str(error) + known_codes = ( + "FEATURE_PLAN_INVALID", "FEATURE_PLAN_CYCLE", "TOPOLOGY_REQUIRED", "TOPOLOGY_NOT_AVAILABLE", + "TOPOLOGY_SNAPSHOT_STALE", "SELECTOR_CONTEXT_REQUIRED", "SELECTOR_NOT_FOUND", + "SELECTOR_AMBIGUOUS", "SELECTOR_GEOMETRY_MISMATCH", "FEATURE_NOT_READY", "COMPLETED_FEATURE_MUTATION", + "INVALID_CDSL_PATCH", + ) + explicit_code = next((code for code in known_codes if code in message), "") + if explicit_code: + if explicit_code == "INVALID_CDSL_PATCH": + return { + "code": explicit_code, + "path": "$", + "kind": "patch", + "repair_instruction": "Read the current CDSL and apply a path that exists in base_revision_id; use generate_cdsl_model for structural changes.", + } + if explicit_code == "FEATURE_NOT_READY": + return { + "code": explicit_code, + "path": "$.features", + "kind": "feature_plan", + "repair_instruction": "Keep completed features unchanged and generate only the listed allowed_feature_ids from the current ready plan batch.", + } + return { + "code": explicit_code, + "path": "$", + "kind": "topology" if "SELECTOR" in explicit_code or "TOPOLOGY" in explicit_code else "feature_plan", + "repair_instruction": "Use the current topology snapshot and a valid ready feature batch, then retry.", + } path_match = re.search(r"(?:at|path) (\$[^: ]*)", message) quality = getattr(error, "quality_report", None) if isinstance(quality, dict): @@ -189,7 +283,7 @@ def invalid_cdsl_result(error: Exception) -> dict[str, Any]: def user_visible_tool_message(result: dict[str, Any], user_text: str) -> str: code = str(result.get("code") or "") - if code in {"CDSL_SCHEMA_INVALID", "CDSL_RUNTIME_INVALID", "VERIFICATION_FAILED"}: + if code in {"CDSL_SCHEMA_INVALID", "CDSL_RUNTIME_INVALID", "VERIFICATION_FAILED", "FEATURE_NOT_READY", "INVALID_CDSL_PATCH"}: if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")): return "CDSL 不符合 engine 的模型契约,正在请求模型按 schema 修正后重新生成。" return "The CDSL model does not match the engine contract. Asking the model to correct it and retry." @@ -263,6 +357,7 @@ def _cdsl_tool_schema() -> dict[str, Any]: CDSL_TOOL_SCHEMA = _cdsl_tool_schema() GENERATION_TOOL_NAMES = {"generate_cdsl_model", "patch_cdsl_model"} +MAX_AGENT_TOOL_ITERATIONS = 16 VERIFICATION_SCHEMA: dict[str, Any] = { "type": "object", @@ -275,7 +370,7 @@ VERIFICATION_SCHEMA: dict[str, Any] = { "properties": { "id": {"type": "string", "minLength": 1}, "type": {"enum": sorted(QUALITY_RULE_TYPES)}, - "feature": {"type": "string"}, + "feature": {"type": "string", "minLength": 1}, "expected": { "description": ( "For bbox, use [dx, dy, dz], " @@ -287,6 +382,12 @@ VERIFICATION_SCHEMA: dict[str, Any] = { "severity": {"enum": ["blocking", "warning", "informational"]}, }, "required": ["id", "type", "expected"], + "allOf": [ + { + "if": {"properties": {"type": {"enum": sorted(FEATURE_RULE_TYPES)}}}, + "then": {"required": ["feature"]}, + }, + ], "additionalProperties": False, }, }, @@ -330,47 +431,117 @@ def engine_capability_manifest(settings: Settings) -> dict[str, Any]: } +IMAGE_SEGMENT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "type": {"enum": ["line", "arc", "circle", "polyline", "unknown_curve"]}, + "start": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "end": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "center": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "radius_mm": {"type": "number", "exclusiveMinimum": 0}, + "clockwise": {"type": "boolean"}, + "points": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "maxItems": 256}, + "image_start": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "image_end": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "notes": {"type": "string", "maxLength": 300}, + }, + "required": ["type"], + "additionalProperties": False, +} + +IMAGE_PROFILE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 80}, + "role": {"type": "string", "maxLength": 40}, + "plane_hint": {"type": "string", "maxLength": 120}, + "closed": {"type": "boolean"}, + "coordinate_space": {"type": "string", "maxLength": 40}, + "segments": {"type": "array", "maxItems": 256, "items": IMAGE_SEGMENT_SCHEMA}, + "source_images": {"type": "array", "maxItems": 12, "items": {"type": "string"}}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "uncertain": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 300}}, + "notes": {"type": "string", "maxLength": 300}, + }, + "required": ["id", "segments"], + "additionalProperties": False, +} + +IMAGE_MEASUREMENT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 120}, + "value_mm": {"type": "number"}, + "min_mm": {"type": "number"}, + "max_mm": {"type": "number"}, + "source": {"enum": ["user", "image", "cv", "assumption"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "evidence": {"type": "string", "maxLength": 300}, + "source_images": {"type": "array", "maxItems": 12, "items": {"type": "string"}}, + }, + "required": ["name"], + "additionalProperties": False, +} + +IMAGE_OBSERVATION_PROPERTIES: dict[str, Any] = { + "attachment_ids": {"type": "array", "maxItems": 12, "items": {"type": "string"}}, + "part_type": {"type": "string", "minLength": 1, "maxLength": 300}, + "visible_features": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 300}}, + "uncertain_features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "views": {"type": "array", "maxItems": 12, "items": {"type": "object", "properties": { + "attachment_id": {"type": "string"}, "view_role": {"type": "string"}, "orientation": {"type": "string"}, + "visible_regions": {"type": "array", "items": {"type": "string"}}, "occluded_regions": {"type": "array", "items": {"type": "string"}}, + "quality": {"type": "string"}, "scale_reference_id": {"type": "string"}, "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + }, "required": ["attachment_id"], "additionalProperties": False}}, + "scale_references": {"type": "array", "maxItems": 12, "items": {"type": "object"}}, + "overall_geometry": {"type": "object"}, + "surfaces": {"type": "array", "maxItems": 24, "items": {"type": "object"}}, + "profiles": {"type": "array", "maxItems": 32, "items": IMAGE_PROFILE_SCHEMA}, + "holes": {"type": "array", "maxItems": 64, "items": {"type": "object"}}, + "bends": {"type": "array", "maxItems": 16, "items": {"type": "object"}}, + "measurements": {"type": "array", "maxItems": 128, "items": IMAGE_MEASUREMENT_SCHEMA}, + "uncertainties": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "cv_hints": {"type": "array", "maxItems": 32, "items": {"type": "object"}}, +} + TOOL_SCHEMAS: list[dict[str, Any]] = [ { "type": "function", "function": { "name": "analyze_image_reference", "description": ( - "Analyze the current image attachments as a CAD reference. " - "Use this before any CAD planning or generation for a newly uploaded image. " - "Describe only visible geometry and identify dimensions that may need confirmation." + "Perform a complete multi-view CAD image survey. Identify every visible plane, bend, " + "outer profile, hole, slot, irregular cutout, scale reference, estimated measurement, " + "occlusion, and uncertainty. Preserve geometry evidence; do not omit an uncertain profile." ), + "parameters": { + "type": "object", + "properties": IMAGE_OBSERVATION_PROPERTIES, + "required": ["part_type", "visible_features", "uncertain_features", "views", "profiles", "measurements", "uncertainties"], + "additionalProperties": False, + }, + }, + }, + { + "type": "function", + "function": { + "name": "extract_image_sketch_candidates", + "description": "Convert the complete image survey into candidate 2D sketch profiles for outer faces and irregular openings. Keep polyline or unknown curves when line/arc decomposition is uncertain.", "parameters": { "type": "object", "properties": { - "part_type": {"type": "string", "minLength": 1}, - "visible_features": { - "type": "array", - "items": {"type": "string", "minLength": 1}, - "minItems": 1, - "maxItems": 12, - }, - "uncertain_features": { - "type": "array", - "items": {"type": "string", "minLength": 1}, - "maxItems": 8, - }, - "dimension_candidates": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "string", "minLength": 1}, - "label": {"type": "string", "minLength": 1}, - "reason": {"type": "string", "minLength": 1}, - }, - "required": ["id", "label", "reason"], - "additionalProperties": False, - }, - "maxItems": 12, - }, + "part_type": {"type": "string", "maxLength": 300}, + "profiles": {"type": "array", "maxItems": 32, "items": IMAGE_PROFILE_SCHEMA}, + "measurements": {"type": "array", "maxItems": 128, "items": IMAGE_MEASUREMENT_SCHEMA}, + "visible_features": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 300}}, + "uncertain_features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "uncertainties": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "cv_hints": {"type": "array", "maxItems": 32, "items": {"type": "object"}}, }, - "required": ["part_type", "visible_features", "uncertain_features"], + "required": ["profiles", "measurements", "uncertainties"], "additionalProperties": False, }, }, @@ -459,20 +630,102 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ }, ] +PLANNING_TOOL_SCHEMAS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "plan_feature_tree", + "description": "Validate and store an acyclic semantic feature plan. This is planning data, not executable CDSL; never invent selectors.", + "parameters": { + "type": "object", + "properties": { + "plan_id": {"type": "string", "minLength": 1}, + "nodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "intent": {"type": "string"}, + "atomic_id": {"type": "string", "minLength": 1}, + "depends_on": {"type": "array", "items": {"type": "string"}}, + "requires_topology": {"type": "boolean"}, + "topology_query": {"type": "object"}, + "cdsl_feature_ids": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["id", "atomic_id", "depends_on"], + "additionalProperties": False, + }, + }, + "replan": { + "type": "object", + "properties": { + "replace_nodes": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + "reason": {"type": "string", "minLength": 1}, + "alternatives": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["replace_nodes", "reason", "alternatives"], + "additionalProperties": False, + }, + }, + "required": ["plan_id", "nodes"], + "additionalProperties": False, + }, + }, + }, + { + "type": "function", + "function": { + "name": "inspect_current_topology", + "description": "Query real executable face/edge/vertex records from the current successful revision. Returned selectors may be copied into CDSL.", + "parameters": { + "type": "object", + "properties": { + "kind": {"enum": ["face", "edge", "vertex", "body", "plane", "axis"]}, + "feature_id": {"type": "string"}, + "owner_feature_id": {"type": "string"}, + "surface_type": {"type": "string"}, + "curve_type": {"type": "string"}, + "position_hint": {"type": "string"}, + "position": {"type": "string"}, + "bbox_mm": {"type": "array", "items": {"type": "number"}, "minItems": 6, "maxItems": 6}, + "length_range_mm": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "area_range_mm2": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "limit": {"type": "integer", "minimum": 1, "maximum": 50}, + "cursor": {"type": "integer", "minimum": 0}, + }, + "additionalProperties": False, + }, + }, + }, +] + def tools_for_model( model: ProviderModel, *, include_image_analysis: bool = True, + include_image_sketches: bool = False, + image_stage: str | None = None, ) -> list[dict[str, Any]]: """Return this model's tool contract without mutating the shared schema.""" - tools = deepcopy(TOOL_SCHEMAS) + tools = deepcopy(TOOL_SCHEMAS + PLANNING_TOOL_SCHEMAS) if not include_image_analysis: tools = [ tool for tool in tools if tool.get("function", {}).get("name") != "analyze_image_reference" ] + if not include_image_sketches: + tools = [ + tool + for tool in tools + if tool.get("function", {}).get("name") != "extract_image_sketch_candidates" + ] + if image_stage in {"survey", "sketch"}: + required_name = "analyze_image_reference" if image_stage == "survey" else "extract_image_sketch_candidates" + tools = [tool for tool in tools if tool.get("function", {}).get("name") == required_name] if not model.strict_tool_schema: return tools @@ -523,6 +776,8 @@ def revision_input_attachments(conversation: dict[str, Any]) -> list[dict[str, s "mime": str(attachment.get("mime") or ""), "size": int(attachment.get("size") or 0), "sha256": str(attachment.get("sha256") or ""), + "width": int(attachment.get("width") or 0), + "height": int(attachment.get("height") or 0), }) return snapshots @@ -546,7 +801,7 @@ CAD request state: def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | None: - """Return an analysis only when it covers every currently attached image.""" + """Return the latest complete survey for every currently attached image.""" attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)} if not attachment_ids: return None @@ -560,7 +815,49 @@ def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | N if not isinstance(data, dict): continue analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)} - if attachment_ids.issubset(analyzed_ids): + if attachment_ids.issubset(analyzed_ids) and data.get("observationStage", "complete") == "complete": + return data + return None + + +def image_reference_stage(conversation: dict[str, Any]) -> str: + """Return the next image-intake stage while retaining legacy analyses.""" + attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)} + if not attachment_ids: + return "complete" + for message in reversed(conversation.get("messages") or []): + if not isinstance(message, dict): + continue + for part in reversed(message.get("parts") or []): + if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": + continue + data = part.get("data") + if not isinstance(data, dict): + continue + analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)} + if not attachment_ids.issubset(analyzed_ids): + continue + stage = str(data.get("observationStage") or "complete") + if stage == "survey": + return "sketch" + if stage in {"sketch", "complete"}: + return "complete" + return "survey" + + +def image_reference_observation_part(conversation: dict[str, Any], stage: str) -> dict[str, Any] | None: + attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)} + for message in reversed(conversation.get("messages") or []): + if not isinstance(message, dict): + continue + for part in reversed(message.get("parts") or []): + if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": + continue + data = part.get("data") + if not isinstance(data, dict): + continue + analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)} + if attachment_ids.issubset(analyzed_ids) and str(data.get("observationStage") or "complete") == stage: return data return None @@ -568,32 +865,46 @@ def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | N def image_reference_instruction( attachments: list[dict[str, Any]], analysis: dict[str, Any] | None, + stage: str = "complete", ) -> str: if not attachments: return "" - if analysis is None: + if stage == "survey": return """ Image-reference intake gate (highest priority for this turn): - The user has uploaded image references that have not yet been analyzed. - Your only tool call in this turn must be analyze_image_reference. - Do not call describe_design_intent, search_cdsl_library, read_cdsl_reference, read_current_cdsl, or generate_cdsl_model in this turn. -- Describe only what is visible. Do not invent dimensions from pixels or perspective. +- Build a complete structured multi-view survey: views, surfaces, bends, outer profiles, + holes, irregular openings, scale references, measurements, CV hints, and uncertainties. +- Preserve uncertain profiles as polyline or unknown_curve evidence instead of dropping them. +- Describe only what is visible. Do not present image estimates as user-verified dimensions. - The backend will present the structured result and provide it back as visual-reference context for you to continue this same task. +""" + if stage == "sketch": + return """ +Image survey is recorded for the current attachments. Your only tool call in this turn +must be extract_image_sketch_candidates. Use the survey and all attached images to +produce outer-face and irregular-opening profiles. Prefer line, arc, and circle segments; +retain polyline or unknown_curve segments when the image does not justify a primitive. +Include source image ids, coordinate evidence, confidence, measurements, and unresolved +parameters. Do not call CAD planning or generation tools yet. """ return """ Image-reference analysis already recorded for the current attachments: {data} -Use this result as visual-reference context and do not analyze the image again. -The listed dimensions are candidates for confirmation, not a mandatory gate. +Use this complete survey and the sketch candidates as visual-reference context. +Do not silently discard visible profiles or openings. User-provided dimensions override +image, CV, or assumption estimates; preserve uncertain values as assumptions. Interpret the full conversation to decide whether to ask a concise question, make clearly stated approximate assumptions, or continue the ordinary CDSL workflow. When the user permits or requests estimates, choose coherent values yourself and record them as assumptions instead of asking again. Respect the user's tolerance for estimates. Never present an inferred dimension as an exact measurement from the image. -""".format(data=json.dumps(analysis, ensure_ascii=False, separators=(",", ":"))) +""".format(data=render_image_observation_context(analysis)) def normalize_image_analysis(arguments: dict[str, Any]) -> dict[str, Any]: @@ -638,6 +949,40 @@ def normalize_image_analysis(arguments: dict[str, Any]) -> dict[str, Any]: } +def image_observation_payload(observation: dict[str, Any], *, stage: str, artifact_path: str = "") -> dict[str, Any]: + """Map the persisted snake_case observation to the existing UI data part.""" + dimensions = [ + { + "id": str(item.get("name") or f"measurement_{index}"), + "label": str(item.get("name") or "尺寸"), + "reason": str(item.get("evidence") or "图片或模型估算"), + } + for index, item in enumerate(observation.get("measurements") or []) + if isinstance(item, dict) + ] + return { + "observationStage": stage, + "schemaVersion": observation.get("schema_version", "cad.image-observation.v2"), + "attachmentIds": observation.get("attachment_ids") or [], + "partType": observation.get("part_type") or "", + "visibleFeatures": observation.get("visible_features") or [], + "uncertainFeatures": observation.get("uncertain_features") or [], + "dimensionCandidates": dimensions, + "views": observation.get("views") or [], + "scaleReferences": observation.get("scale_references") or [], + "overallGeometry": observation.get("overall_geometry") or {}, + "surfaces": observation.get("surfaces") or [], + "profiles": observation.get("profiles") or [], + "holes": observation.get("holes") or [], + "bends": observation.get("bends") or [], + "measurements": observation.get("measurements") or [], + "uncertainties": observation.get("uncertainties") or [], + "assumptions": observation.get("assumptions") or [], + "cvHints": observation.get("cv_hints") or [], + "artifactPath": artifact_path or None, + } + + def _viewer_selection_text(value: Any, limit: int = 240) -> str: return str(value or "").strip()[:limit] @@ -669,6 +1014,7 @@ def _viewer_selection_entity(value: Any) -> dict[str, Any] | None: return { "referenceId": reference_id, "selector": _viewer_selection_text(value.get("selector")), + "snapshotId": _viewer_selection_text(value.get("snapshotId"), 160), "label": _viewer_selection_text(value.get("label")), "selectorType": _viewer_selection_text(value.get("selectorType"), 80), "surfaceType": _viewer_selection_text(value.get("surfaceType"), 80), @@ -720,6 +1066,281 @@ Use this data to answer questions about the selected geometry. In particular, us """.format(data=json.dumps(selections, ensure_ascii=False, separators=(",", ":"))) +def _selector_values(value: Any) -> list[dict[str, Any]]: + found: list[dict[str, Any]] = [] + if isinstance(value, dict): + if value.get("kind") and value.get("stable_id") and value.get("source"): + found.append(value) + for child in value.values(): + found.extend(_selector_values(child)) + elif isinstance(value, list): + for child in value: + found.extend(_selector_values(child)) + return found + + +def _numeric_range(value: Any, field: str) -> tuple[float, float] | None: + if value is None: + return None + if not isinstance(value, list) or len(value) != 2: + raise ValueError(f"{field} must contain exactly two numbers") + try: + left, right = float(value[0]), float(value[1]) + except (TypeError, ValueError) as error: + raise ValueError(f"{field} must contain numbers") from error + if left > right: + raise ValueError(f"{field} minimum must not exceed maximum") + return left, right + + +def _validate_snapshot_selectors(store: Any, task_id: str, cdsl: dict[str, Any]) -> None: + """Validate runtime/viewer selectors against their own task revision snapshot. + + A completed feature keeps the selector provenance from the revision in which + it was created. Requiring every selector in a later CDSL revision to point + at the newest snapshot incorrectly rejects those immutable historical + selectors before a new feature can be appended. + """ + if not task_id: + return + task = store.read_task(task_id) or {} + current_revision = str(task.get("current_revision") or "") + if not current_revision: + return + topology_path = store.current_topology_path(task_id) + current_snapshot = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None + current_snapshot_id = str((current_snapshot or {}).get("snapshot_id") or f"{task_id}/{current_revision}") + snapshots: dict[str, dict[str, Any]] = {current_snapshot_id: current_snapshot or {}} + + def snapshot_for_selector(snapshot_id: str) -> dict[str, Any] | None: + if snapshot_id in snapshots: + return snapshots[snapshot_id] + prefix = f"{task_id}/" + if not snapshot_id.startswith(prefix): + return None + revision_id = snapshot_id[len(prefix):] + if not revision_id: + return None + revision = next( + (item for item in task.get("revisions") or () + if isinstance(item, dict) and str(item.get("revision_id") or "") == revision_id), + None, + ) + if not isinstance(revision, dict) or revision.get("status") != "success": + return None + historical_path = store.revision_topology_path(task_id, revision_id) + if historical_path is None or not historical_path.is_file(): + return None + try: + historical = json.loads(historical_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + snapshots[snapshot_id] = historical + return historical + + for selector in _selector_values(cdsl): + source = str(selector.get("source") or "") + if source not in {"runtime_snapshot", "viewer_selection"}: + continue + snapshot_id = str(selector.get("snapshot_id") or "") + snapshot = snapshot_for_selector(snapshot_id) + if snapshot is None: + raise ValueError("TOPOLOGY_SNAPSHOT_STALE: selector does not belong to a known task revision") + records = {str(item.get("record_id")): item for item in snapshot.get("records") or () if isinstance(item, dict)} + record = records.get(str(selector.get("stable_id") or "")) + if not record or record.get("executable") is False: + raise ValueError("SELECTOR_NOT_FOUND: selector record is not present in the referenced topology snapshot") + if str(selector.get("kind")) != str(record.get("kind")): + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector kind differs from topology record") + owner = selector.get("owner_feature_id") + owners = {str(item) for item in record.get("owner_feature_ids") or ()} + if owner and str(owner) not in owners: + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector owner differs from topology record") + expected = selector.get("geometry") if isinstance(selector.get("geometry"), dict) else {} + actual = record.get("geometry") if isinstance(record.get("geometry"), dict) else {} + for key in ("curve_type", "surface_type"): + if key in expected and expected.get(key) != actual.get(key): + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record") + for key in ("center_mm", "normal", "plane_normal", "start_mm", "end_mm"): + if key not in expected: + continue + left, right = expected.get(key), actual.get(key) + if not isinstance(left, (list, tuple)) or not isinstance(right, (list, tuple)) or len(left) != len(right): + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record") + try: + mismatch = any(abs(float(a) - float(b)) > 1e-5 for a, b in zip(left, right)) + except (TypeError, ValueError) as error: + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} is not numeric") from error + if mismatch: + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record") + if "bbox_mm" in expected: + left, right = expected.get("bbox_mm"), actual.get("bbox_mm") + if not isinstance(left, (list, tuple)) or not isinstance(right, (list, tuple)) or len(left) != len(right): + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm differs from topology record") + try: + mismatch = any(abs(float(a) - float(b)) > 1e-5 for a, b in zip(left, right)) + except (TypeError, ValueError) as error: + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm is not numeric") from error + if mismatch: + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm differs from topology record") + for key in ("length_mm", "area_mm2"): + if key in expected and key in actual: + try: + expected_value = float(expected[key]) + actual_value = float(actual[key]) + except (TypeError, ValueError) as error: + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} is not numeric") from error + if abs(expected_value - actual_value) > max(1e-5, abs(actual_value) * 1e-5): + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record") + + +def _topology_query_result(store: Any, task_id: str, arguments: dict[str, Any]) -> dict[str, Any]: + if not task_id: + return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "No current CAD task exists."} + task = store.read_task(task_id) or {} + revision_id = str(task.get("current_revision") or "") + path = store.current_topology_path(task_id) + if not revision_id or path is None or not path.is_file(): + return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "The current task has no successful topology snapshot."} + snapshot = json.loads(path.read_text(encoding="utf-8")) + records = [record for record in snapshot.get("records") or () if isinstance(record, dict) and record.get("executable", True) is not False] + kind = str(arguments.get("kind") or "") + if kind: + records = [record for record in records if record.get("kind") == kind] + for key in ("feature_id", "owner_feature_id"): + value = str(arguments.get(key) or "") + if value: + records = [record for record in records if record.get("feature_id") == value or value in (record.get("owner_feature_ids") or [])] + for key in ("surface_type", "curve_type"): + value = str(arguments.get(key) or "") + if value: + records = [record for record in records if (record.get("geometry") or {}).get(key) == value] + position_hint = str(arguments.get("position_hint") or arguments.get("position") or "").strip().casefold() + if position_hint: + position_axis = {"top": 2, "upper": 2, "highest": 2, "bottom": 2, "lower": 2, "lowest": 2, + "left": 0, "right": 0, "front": 1, "back": 1}.get(position_hint) + position_values: list[float] = [] + if position_axis is not None: + for candidate in records: + candidate_center = (candidate.get("geometry") or {}).get("center_mm") + if isinstance(candidate_center, (list, tuple)) and len(candidate_center) > position_axis: + try: + position_values.append(float(candidate_center[position_axis])) + except (TypeError, ValueError): + pass + target = None + if position_values and position_axis is not None: + target = min(position_values) if position_hint in {"bottom", "lower", "lowest", "left", "front"} else max(position_values) + def matches_position(record: dict[str, Any]) -> bool: + geometry = record.get("geometry") or {} + center = geometry.get("center_mm") + bbox = geometry.get("bbox_mm") + if not isinstance(center, (list, tuple)) or len(center) < 3: + return False + try: + x, y, z = (float(center[index]) for index in range(3)) + except (TypeError, ValueError): + return False + if position_hint in {"top", "upper", "highest", "bottom", "lower", "lowest"}: + return target is not None and abs(z - target) <= 1e-5 + if position_hint in {"left", "right", "front", "back"}: + axis = {"left": 0, "right": 0, "front": 1, "back": 1}[position_hint] + sign = {"left": -1, "right": 1, "front": 1, "back": -1}[position_hint] + value = x if axis == 0 else y + return target is not None and abs(value - target) <= 1e-5 + return str(geometry.get("position_hint") or "").casefold() == position_hint + records = [record for record in records if matches_position(record)] + bbox_filter = arguments.get("bbox_mm") + if bbox_filter is not None: + if isinstance(bbox_filter, dict) and isinstance(bbox_filter.get("min"), list) and isinstance(bbox_filter.get("max"), list): + bbox_filter = [*bbox_filter["min"], *bbox_filter["max"]] + if not isinstance(bbox_filter, list) or len(bbox_filter) != 6: + raise ValueError("bbox_mm must contain exactly six numbers") + try: + query_bbox = tuple(float(value) for value in bbox_filter) + except (TypeError, ValueError) as error: + raise ValueError("bbox_mm must contain numbers") from error + if query_bbox[0] > query_bbox[3] or query_bbox[1] > query_bbox[4] or query_bbox[2] > query_bbox[5]: + raise ValueError("bbox_mm minimums must not exceed maximums") + def intersects(record: dict[str, Any]) -> bool: + actual = (record.get("geometry") or {}).get("bbox_mm") + if not isinstance(actual, (list, tuple)) or len(actual) != 6: + return False + try: + values = tuple(float(value) for value in actual) + except (TypeError, ValueError): + return False + return all(values[index] <= query_bbox[index + 3] and query_bbox[index] <= values[index + 3] for index in range(3)) + records = [record for record in records if intersects(record)] + length_range = _numeric_range(arguments.get("length_range_mm"), "length_range_mm") + area_range = _numeric_range(arguments.get("area_range_mm2"), "area_range_mm2") + if length_range: + records = [record for record in records if length_range[0] <= float((record.get("geometry") or {}).get("length_mm", -1)) <= length_range[1]] + if area_range: + records = [record for record in records if area_range[0] <= float((record.get("geometry") or {}).get("area_mm2", -1)) <= area_range[1]] + offset = max(0, int(arguments.get("cursor") or 0)) + limit = min(50, max(1, int(arguments.get("limit") or 20))) + selected = records[offset:offset + limit] + output_records = [] + for record in selected: + selector = { + "kind": record["kind"], + "stable_id": record["record_id"], + "source": "runtime_snapshot", + "confidence": 1.0, + "snapshot_id": snapshot.get("snapshot_id") or f"{task_id}/{revision_id}", + "geometry": record.get("geometry") or {}, + } + owners = record.get("owner_feature_ids") or [] + if owners: + selector["owner_feature_id"] = owners[0] + output_records.append({**record, "selector": selector}) + return { + "ok": True, + "task_id": task_id, + "revision_id": revision_id, + "snapshot_id": snapshot.get("snapshot_id") or f"{task_id}/{revision_id}", + "records": output_records, + "next_cursor": offset + len(output_records) if offset + len(output_records) < len(records) else None, + } + + +def _validate_plan_cdsl_transition(store: Any, task_id: str, plan: dict[str, Any] | None, cdsl: dict[str, Any]) -> None: + if not isinstance(plan, dict): + return + current_path = store.current_cdsl_path(task_id) if task_id else None + current = json.loads(current_path.read_text(encoding="utf-8")) if current_path and current_path.is_file() else {} + current_features = {str(item.get("id")): item for item in current.get("features") or () if isinstance(item, dict)} + next_features = {str(item.get("id")): item for item in cdsl.get("features") or () if isinstance(item, dict)} + completed_ids = { + str(feature_id) + for node in plan.get("nodes") or () + if isinstance(node, dict) and node.get("status") in {"completed", "executed"} + for feature_id in node.get("cdsl_feature_ids") or () + } + for feature_id in completed_ids: + if feature_id not in next_features or next_features[feature_id] != current_features.get(feature_id): + raise ValueError(f"COMPLETED_FEATURE_MUTATION: completed feature {feature_id} cannot be removed or rewritten") + allowed_ids = { + str(feature_id) + for node in plan.get("nodes") or () + if isinstance(node, dict) and node.get("status") in {"ready", "executing"} + for feature_id in node.get("cdsl_feature_ids") or () + } + for feature_id in next_features: + if feature_id not in current_features and feature_id not in allowed_ids: + ready_nodes = [ + str(node.get("id")) + for node in plan.get("nodes") or () + if isinstance(node, dict) and node.get("status") == "ready" + ] + allowed = sorted(allowed_ids) + raise ValueError( + f"FEATURE_NOT_READY: feature {feature_id} is not in the current ready plan batch; " + f"allowed_feature_ids={allowed}; ready_nodes={ready_nodes}" + ) + + def system_prompt( settings: Settings, user_text: str, @@ -735,16 +1356,23 @@ def system_prompt( profile_schema_path = settings.engine_root / "profile_schema.json" profile_schema = profile_schema_path.read_text(encoding="utf-8") if profile_schema_path.is_file() else "" generation_contract = """- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly, not as Markdown or a JSON string. +- For a multi-stage CAD request, call plan_feature_tree after describe_design_intent. The plan is a DAG of semantic features; never put invented edge/face IDs in it. +- Generate only the current ready feature batch. The server automatically binds a successful build's topology snapshot; call inspect_current_topology to retrieve exact selector candidates before adding topology-dependent features. +- Copy selectors only from inspect_current_topology or a trusted viewer selection. A screenshot is not an exact topology selector source, but its measured image survey and sketch candidates may guide profile geometry. +- Keep completed CDSL features unchanged and use patch_cdsl_model for later feature batches. +- If a selector or kernel failure blocks a node, use plan_feature_tree with `replan` to replace only that node and its downstream subtree. - For a revision, call read_current_cdsl before generate_cdsl_model. Preserve unrelated CDSL features unless the user requests whole-part replacement. - Use patch_cdsl_model only for a local RFC 6902 repair of a known base_revision_id. For structural changes, submit a complete replacement CDSL with generate_cdsl_model. - For every feature.atomic_id, use only an ID listed in the compact capability manifest's runtime_atomic_ids. Other semantic contracts are not executable in this runtime. - The backend normalizes only these unambiguous aliases: sketch_id -> id on sketches, sketch -> sketch_id on features, legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm. - Every explicit user dimension, feature count, hole size, or hole position that can be measured must have a generic verification rule. Rule feature values must be IDs from the submitted CDSL. +- Verification types include bbox, overall_length, overall_width, overall_height, overall_diameter, hole_count, hole_diameter, hole_center, through_condition, solid_count, and feature_count. Overall width and height measure the runtime Y and Z bbox dimensions. Feature-scoped rules (hole_count, hole_diameter, hole_center, through_condition) must include the exact CDSL feature ID. - Do not output build123d source, compiler_context, unknown_shape, complex_arc_shape, entities, contour_edges_mm, or contour_regions_mm.""" workflow = """3. Search the local official CDSL library after the design brief. Read a relevant reference when a match exists; samples are expression guidance, not templates or higher-priority requirements. -4. Generate one complete generic CDSL and call generate_cdsl_model. -5. On a schema, runtime, or verification failure, use patch_cdsl_model for a local correction or generate_cdsl_model for a complete replacement. -6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact.""" +4. For a multi-feature request, call plan_feature_tree and then generate only its ready batch. +5. After each successful build, inspect topology when the plan has waiting topology-dependent nodes, then patch the existing CDSL. +6. On a schema, runtime, selector, or verification failure, repair only the affected feature/subtree. +7. Never claim success unless the final plan is complete and the tool returns a successful CDSL-only STEP and GLB artifact.""" authoring_context = f"""You own the complete CDSL: profiles, workplanes, sketch IDs, feature IDs, dependencies, selectors, and atomic IDs must be valid under the engine schema. Do not invent unsupported capabilities. Local skill: @@ -842,6 +1470,92 @@ class AgentService: self.store = store self.library = library self.part_skill_library = part_skill_library or PartSkillLibrary(part_skill_root(settings)) + # The generation worker outlives an individual SSE response. The + # durable task lifecycle remains the cross-process source of truth; + # this map only owns live event delivery in the current process. + self._incremental_runs: dict[str, asyncio.Task[None]] = {} + + async def resume_running_tasks(self) -> None: + """Reattach process-local workers to persisted incremental runs. + + A browser disconnect is already independent from the worker. This + recovery path additionally prevents an application restart from + stranding a durable task in ``running``. The original frozen provider, + model, messages, and selected part skills are read from the task, not + from mutable conversation state. + """ + if not self.settings.incremental_generation: + return + for task in self.store.running_tasks(): + task_id = str(task.get("task_id") or "") + if not task_id or task_id in self._incremental_runs: + continue + context = self.store.read_generation_run_context(task_id) + if not isinstance(context, dict): + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "stage": "recovery", + "message": "The frozen generation context is unavailable after restart", + }) + continue + conversation_id = str(context.get("conversation_id") or "") + conversation = self.store.read_conversation(conversation_id) if conversation_id else None + author_messages = context.get("author_messages") + part_skills = context.get("part_skills") + if not isinstance(conversation, dict) or not isinstance(author_messages, list) or not isinstance(part_skills, dict): + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "stage": "recovery", + "message": "The frozen conversation context is invalid after restart", + }) + continue + try: + provider, model = self.settings.resolve_model( + str(context.get("provider_id") or ""), str(context.get("model_id") or ""), + ) + except ValueError as error: + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", "stage": "recovery", "message": str(error), + }) + continue + assistant_id = str(context.get("assistant_id") or f"assistant_{secrets.token_hex(8)}") + request = str(context.get("request") or task.get("request") or "") + runner = IncrementalGenerationRunner(self.settings, self.store, self._complete) + + async def consume( + *, task_id: str = task_id, request: str = request, conversation: dict[str, Any] = conversation, + provider: ProviderConfig = provider, model: ProviderModel = model, + author_messages: list[dict[str, Any]] = author_messages, part_skills: dict[str, Any] = part_skills, + assistant_id: str = assistant_id, runner: IncrementalGenerationRunner = runner, + ) -> None: + parts: list[dict[str, Any]] = [] + terminal = "" + try: + async for name, payload in runner.run( + task_id=task_id, request=request, conversation=conversation, provider=provider, model=model, + author_messages=author_messages, part_skills=part_skills, already_started=True, + ): + if name == "cad_result": + parts.append({"type": "data-cad-result", "data": payload}) + elif name == "task_terminal": + terminal = str(payload.get("lifecycle") or "") + if terminal == "failed": + parts.append({"type": "data-cad-error", "data": { + "stage": "generation", "message": str(payload.get("message") or "CAD 增量生成失败。"), + }}) + except Exception as error: + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", "stage": "recovery_worker", "message": str(error), + }) + terminal = "failed" + parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}}) + finally: + if not parts and terminal == "completed": + parts.append({"type": "text", "text": "CAD 模型已完成。"}) + self._persist_assistant(conversation["conversation_id"], assistant_id, parts, task_id) + self._incremental_runs.pop(task_id, None) + + self._incremental_runs[task_id] = asyncio.create_task(consume(), name=f"resume-incremental-cdsl-{task_id}") async def stream( self, @@ -872,6 +1586,13 @@ class AgentService: self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, "") yield event("done", {}) return + if task_id and current_task and str(current_task.get("lifecycle") or "") == "running": + # Do not append the attempted turn: the run's request and + # attachments are immutable until a terminal lifecycle state. + yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"}) + yield event("done", {}) + return + self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id or None) try: @@ -904,15 +1625,109 @@ class AgentService: yield event("done", {}) return + # The persistent node-by-node orchestrator is opt-in while existing + # installations migrate their review-model and Chromium configuration. + # Once enabled, every new request is frozen and legacy authoring tools + # are not exposed for that run. + if self.settings.incremental_generation: + if not task_id: + created = self.store.ensure_task(None, user_text) + task_id = str(created["task_id"]) + current_task = created + self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id) + author_messages: list[dict[str, Any]] = [{ + "role": "system", + "content": ( + "You are an incremental CDSL CAD author. The user request is frozen for this run. " + "Missing dimensions must become explicit assumptions. Use only the requested function tool; do not emit prose or raw CAD source." + ), + }] + author_messages.extend(messages_for_model(messages)) + if attachment_message: + author_messages.append({"role": "user", "content": attachment_message}) + runner = IncrementalGenerationRunner(self.settings, self.store, self._complete) + part_skills = self.part_skill_library.audit(self.part_skill_library.select(user_text), {}, []) + # Acquire the durable run lock before scheduling work so a second + # request cannot slip in during the first model call. + self.store.start_generation(task_id, request=user_text) + self.store.write_generation_run_context(task_id, { + "schema_version": "cad.generation-run-context.v1", + "request": user_text, + "conversation_id": conversation["conversation_id"], + "provider_id": provider.id, + "model_id": model.id, + "assistant_id": assistant_id, + "author_messages": author_messages, + "part_skills": part_skills, + }) + queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue() + + async def consume_incremental_run() -> None: + run_parts: list[dict[str, Any]] = [] + terminal = "" + try: + async for name, event_payload in runner.run( + task_id=task_id, + request=user_text, + conversation=conversation, + provider=provider, + model=model, + author_messages=author_messages, + part_skills=part_skills, + already_started=True, + ): + if name == "cad_result": + run_parts.append({"type": "data-cad-result", "data": event_payload}) + elif name == "task_terminal": + terminal = str(event_payload.get("lifecycle") or "") + if terminal == "failed": + run_parts.append({ + "type": "data-cad-error", + "data": {"stage": "generation", "message": str(event_payload.get("message") or "CAD 增量生成失败。")}, + }) + await queue.put((name, event_payload)) + except Exception as error: # runner normally converts failures to a terminal event + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", "message": str(error), "stage": "worker", + }) + terminal = "failed" + payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)} + run_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}}) + await queue.put(("task_terminal", payload)) + finally: + if not run_parts and terminal == "completed": + run_parts.append({"type": "text", "text": "CAD 模型已完成。"}) + self._persist_assistant(conversation["conversation_id"], assistant_id, run_parts, task_id) + self._incremental_runs.pop(task_id, None) + await queue.put(None) + + worker = asyncio.create_task(consume_incremental_run(), name=f"incremental-cdsl-{task_id}") + self._incremental_runs[task_id] = worker + while True: + queued = await queue.get() + if queued is None: + break + name, event_payload = queued + if name == "task_terminal" and str(event_payload.get("lifecycle") or "") == "failed": + yield event("cad_error", {"stage": "generation", "message": str(event_payload.get("message") or "CAD 增量生成失败。")}) + yield event(name, event_payload) + yield event("done", {}) + return + image_inputs = image_attachments(conversation) + intake_stage = image_reference_stage(conversation) recorded_image_analysis = image_reference_analysis(conversation) - requires_image_intake = bool(image_inputs) and recorded_image_analysis is None + partial_observation = image_reference_observation_part(conversation, "survey") yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"}) references: list[str] = [] library_searches = 0 inherited_skill_ids = self.part_skill_library.inherited_from_task(current_task) part_skill_selection = self.part_skill_library.select(user_text, inherited_skill_ids) planning_state: dict[str, Any] = {"phase": "INTAKE", "design_brief": "", "current_model_read": False} + if task_id: + persisted_plan = self.store.read_feature_plan(task_id) + if isinstance(persisted_plan, dict): + planning_state["feature_plan"] = persisted_plan yield event("progress", { "step": "select_part_skill", "label": "选择建模 Skill", @@ -927,7 +1742,11 @@ class AgentService: viewer_context, task_id, self.part_skill_library.render_context(part_skill_selection), - image_reference_instruction(image_inputs, recorded_image_analysis), + image_reference_instruction( + image_inputs, + recorded_image_analysis or partial_observation, + intake_stage, + ), cad_request_instruction(task_id, current_task), ), }] @@ -936,19 +1755,31 @@ class AgentService: model_messages.append({"role": "user", "content": attachment_message}) tools = tools_for_model( model, - include_image_analysis=requires_image_intake, + include_image_analysis=intake_stage == "survey", + include_image_sketches=intake_stage == "sketch", + image_stage=intake_stage if intake_stage in {"survey", "sketch"} else None, + ) + required_tool_name: str | None = ( + "analyze_image_reference" if intake_stage == "survey" + else "extract_image_sketch_candidates" if intake_stage == "sketch" + else None ) - required_tool_name: str | None = "analyze_image_reference" if requires_image_intake else None generate_argument_failures = 0 tool_argument_diagnostics: list[str] = [] cdsl_validation_diagnostics: list[str] = [] generation_completed = False repair_attempts = 0 + repair_step_key: str | None = None + last_plan_status: dict[str, Any] | None = None try: - for iteration in range(8): + for iteration in range(MAX_AGENT_TOOL_ITERATIONS): + # Once the model has been told to repair a CDSL step, stop + # before requesting another completion when that same step has + # exhausted its consecutive repair budget. if ( planning_state.get("phase") == "CDSL_REPAIR" + and required_tool_name in GENERATION_TOOL_NAMES and repair_attempts >= self.settings.max_repair_attempts ): raise CdslRepairLimitError(cdsl_validation_diagnostics) @@ -975,7 +1806,7 @@ class AgentService: model_messages.append(choice) for call in tool_calls: name = str(call.get("function", {}).get("name") or "") - if requires_image_intake and name != "analyze_image_reference": + if intake_stage == "survey" and name != "analyze_image_reference": result = { "ok": False, "code": "IMAGE_ANALYSIS_REQUIRED", @@ -984,7 +1815,16 @@ class AgentService: model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)}) yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先完成图片参考分析。"}) continue - if name == "analyze_image_reference" and recorded_image_analysis is not None: + if intake_stage == "sketch" and name != "extract_image_sketch_candidates": + result = { + "ok": False, + "code": "IMAGE_SKETCH_REQUIRED", + "message": "Extract image sketch candidates before using planning or generation tools.", + } + model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)}) + yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先提取图片草图候选。"}) + continue + if name == "analyze_image_reference" and intake_stage != "survey": result = { "ok": False, "code": "IMAGE_ANALYSIS_ALREADY_RECORDED", @@ -1005,6 +1845,14 @@ class AgentService: "message": "图片识别结果已存在,正在继续后续建模流程。", }) continue + if name == "extract_image_sketch_candidates" and intake_stage != "sketch": + result = { + "ok": False, + "code": "IMAGE_SKETCH_ALREADY_RECORDED", + "message": "Image sketch candidates are already recorded for the current attachments.", + } + model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)}) + continue if name == "search_cdsl_library": library_searches += 1 if library_searches > 2: @@ -1067,6 +1915,10 @@ class AgentService: }) continue if name in GENERATION_TOOL_NAMES and planning_state.get("phase") == "CDSL_REPAIR": + current_step_key = get_repair_step_key(planning_state, name) + if repair_step_key != current_step_key: + repair_step_key = current_step_key + repair_attempts = 0 if repair_attempts >= self.settings.max_repair_attempts: raise CdslRepairLimitError(cdsl_validation_diagnostics) repair_attempts += 1 @@ -1157,36 +2009,90 @@ class AgentService: "message": "设计说明已记录,将作为 CDSL 生成参考。", }) if name == "analyze_image_reference" and result.get("ok"): - image_payload = { - "attachmentIds": result["attachment_ids"], - "partType": result["part_type"], - "visibleFeatures": result["visible_features"], - "uncertainFeatures": result["uncertain_features"], - "dimensionCandidates": result["dimension_candidates"], - "artifactPath": result.get("artifact_path"), - } + survey = result["observation"] + if result.get("legacy"): + artifact_path = result.get("artifact_path", "") + image_payload = image_observation_payload(survey, stage="complete", artifact_path=artifact_path) + assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) + yield event("image_analysis", image_payload) + recorded_image_analysis = image_payload + intake_stage = "complete" + required_tool_name = None + tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=False) + model_messages.append({"role": "system", "content": image_reference_instruction(image_inputs, survey, "complete")}) + yield event("progress", {"step": "analyze_image_reference", "label": "识别图片参考", "status": "success", "message": "已识别图片参考,正在继续 CAD 建模流程。"}) + continue + image_payload = image_observation_payload(survey, stage="survey", artifact_path=result.get("artifact_path", "")) assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) yield event("image_analysis", image_payload) - recorded_image_analysis = image_payload - requires_image_intake = False - required_tool_name = None - tools = tools_for_model( - model, - include_image_analysis=False, - ) + partial_observation = image_payload + intake_stage = "sketch" + required_tool_name = "extract_image_sketch_candidates" + tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=True, image_stage="sketch") model_messages.append({ "role": "system", - "content": image_reference_instruction(image_inputs, image_payload), + "content": image_reference_instruction(image_inputs, survey, "sketch"), }) yield event("progress", { "step": "analyze_image_reference", - "label": "识别图片参考", + "label": "整理图片勘测", "status": "success", - "message": "已识别可见结构,正在根据对话继续判断建模方案。", + "message": "已完成多视角图片勘测,正在提取草图候选。", + }) + continue + if name == "extract_image_sketch_candidates" and result.get("ok"): + survey = partial_observation or {} + if "observationStage" in survey: + survey = { + "schema_version": survey.get("schemaVersion", "cad.image-observation.v2"), + "attachment_ids": survey.get("attachmentIds") or [], + "part_type": survey.get("partType") or "", + "visible_features": survey.get("visibleFeatures") or [], + "uncertain_features": survey.get("uncertainFeatures") or [], + "views": survey.get("views") or [], + "scale_references": survey.get("scaleReferences") or [], + "overall_geometry": survey.get("overallGeometry") or {}, + "surfaces": survey.get("surfaces") or [], + "profiles": survey.get("profiles") or [], + "holes": survey.get("holes") or [], + "bends": survey.get("bends") or [], + "measurements": survey.get("measurements") or [], + "uncertainties": survey.get("uncertainties") or [], + "assumptions": survey.get("assumptions") or [], + "cv_hints": survey.get("cvHints") or [], + } + observation = merge_image_observations(survey, result["sketches"]) + artifact_path = self.store.write_conversation_planning( + conversation["conversation_id"], + "image-observation-v2", + observation, + ) + image_payload = image_observation_payload(observation, stage="complete", artifact_path=artifact_path) + assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) + yield event("image_analysis", image_payload) + recorded_image_analysis = image_payload + intake_stage = "complete" + required_tool_name = None + tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=False) + model_messages.append({ + "role": "system", + "content": image_reference_instruction(image_inputs, observation, "complete"), + }) + yield event("progress", { + "step": "extract_image_sketch_candidates", + "label": "提取图片草图", + "status": "success", + "message": "已提取图片轮廓和草图候选,正在继续 CAD 建模流程。", }) continue if name in GENERATION_TOOL_NAMES and result.get("ok"): required_tool_name = None + if result.get("ok", True): + # A successful tool call is forward progress. The next + # generation batch must receive a fresh consecutive-failure budget. + repair_attempts = 0 + repair_step_key = None + generate_argument_failures = 0 if generated: result_payload = { "taskId": generated["task_id"], @@ -1207,6 +2113,10 @@ class AgentService: "repairAttempts": generated.get("repair_attempts", 0), "snapshotPaths": generated.get("snapshot_paths", []), "snapshotStatus": generated.get("snapshot_status", "unavailable"), + "topologyPath": generated.get("topology_path"), + "planComplete": generated.get("plan_complete", True), + "planStatus": generated.get("plan_status"), + "requiredAction": generated.get("required_action", "complete"), } assistant_parts.append({"type": "data-cad-result", "data": result_payload}) yield event("cad_result", result_payload) @@ -1216,13 +2126,25 @@ class AgentService: "status": "success", "message": "已通过 cdsl_only runtime 构建 STEP 和 GLB。", }) - generation_completed = True - break + generation_completed = bool(generated.get("plan_complete", True)) + if isinstance(generated.get("plan_status"), dict): + last_plan_status = generated["plan_status"] + if generation_completed: + break if generation_completed: break - if iteration == 7: + if iteration == MAX_AGENT_TOOL_ITERATIONS - 1: validation_diagnostics = cdsl_validation_diagnostics - if validation_diagnostics: + waiting_nodes = [ + str(item) for item in (last_plan_status or {}).get("waiting_nodes") or [] + ] + if waiting_nodes: + message = ( + "CAD 基础模型已生成,但特征计划尚未完成。等待拓扑选择的特征:" + + "、".join(waiting_nodes) + + "。请先读取当前拓扑,再继续生成这些特征。" + ) + elif validation_diagnostics: diagnostic_paths = "、".join(validation_diagnostics) if any("\u4e00" <= char <= "\u9fff" for char in user_text): message = ( @@ -1329,6 +2251,14 @@ class AgentService: candidate = json.loads(candidate) except json.JSONDecodeError: pass + # Patch calls do not contain a complete CDSL. Preserve their payload + # so an out-of-range path can be diagnosed against the exact request. + if candidate is None and "patches" in arguments: + candidate = { + "kind": "cdsl_patch_attempt", + "base_revision_id": str(arguments.get("base_revision_id") or ""), + "patches": deepcopy(arguments.get("patches") or []), + } return self.store.write_cdsl_attempt(conversation_id, candidate, iteration) def _record_cdsl_validation_diagnostic( @@ -1365,6 +2295,8 @@ class AgentService: "tool_call_id": call.get("id"), "tool_name": function.get("name"), "cdsl_attempt_path": cdsl_attempt_path, + "base_revision_id": arguments.get("base_revision_id"), + "patches": deepcopy(arguments.get("patches")) if "patches" in arguments else None, "summary": str(arguments.get("summary") or ""), "assumptions": arguments.get("assumptions") or [], "verification": arguments.get("verification"), @@ -1436,15 +2368,33 @@ class AgentService: if name == "analyze_image_reference": if not image_attachment_ids: raise ValueError("analyze_image_reference requires at least one image attachment") - analysis = { - "ok": True, - "attachment_ids": list(dict.fromkeys(image_attachment_ids)), - **normalize_image_analysis(arguments), - } + legacy_arguments = "views" not in arguments and "profiles" not in arguments + if not legacy_arguments: + observation = normalize_image_observation(arguments, attachment_ids=image_attachment_ids) + else: + legacy = normalize_image_analysis(arguments) + observation = normalize_image_observation({ + "attachment_ids": image_attachment_ids, + "part_type": legacy["part_type"], + "visible_features": legacy["visible_features"], + "uncertain_features": legacy["uncertain_features"], + "measurements": [ + {"name": item["label"], "source": "image", "evidence": item["reason"]} + for item in legacy["dimension_candidates"] + ], + "views": [{"attachment_id": attachment_id} for attachment_id in image_attachment_ids], + }, attachment_ids=image_attachment_ids) + analysis = {"ok": True, "legacy": legacy_arguments, "attachment_ids": list(dict.fromkeys(image_attachment_ids)), "observation": observation, **observation} if conversation_id: - analysis["artifact_path"] = self.store.write_conversation_planning(conversation_id, "reference-observation", {"schema_version": "1.0", **analysis}) + analysis["artifact_path"] = self.store.write_conversation_planning(conversation_id, "image-survey", analysis["observation"]) state["phase"] = "REFERENCE_ANALYZED" return analysis, None + if name == "extract_image_sketch_candidates": + if not image_attachment_ids: + raise ValueError("extract_image_sketch_candidates requires at least one image attachment") + sketches = normalize_sketch_candidates(arguments, attachment_ids=image_attachment_ids) + state["phase"] = "REFERENCE_SKETCHED" + return {"ok": True, "sketches": sketches, **sketches}, None if name == "describe_design_intent": plan = str(arguments.get("plan") or "").strip() assumptions = arguments.get("assumptions") @@ -1458,6 +2408,93 @@ class AgentService: "assumptions": [item.strip() for item in assumptions if item.strip()], "message": "The design brief is recorded as reference only. CDSL remains the sole authoritative CAD model.", }, None + if name == "plan_feature_tree": + if phase not in {"PLANNED", "TOPOLOGY_READY", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR", "PLAN_READY"}: + return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before planning the feature tree."}, None + try: + engine = load_engine(self.settings) + plan = validate_feature_plan({ + "schema_version": "cad.feature-plan.v1", + "plan_id": arguments.get("plan_id"), + "task_id": task_id, + "nodes": arguments.get("nodes"), + }, supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ())) + replan = arguments.get("replan") + if replan is not None: + if not isinstance(replan, dict): + raise FeaturePlanError("replan must be an object") + replace_nodes = replan.get("replace_nodes") + reason = str(replan.get("reason") or "").strip() + alternatives = replan.get("alternatives") + if not isinstance(replace_nodes, list) or not replace_nodes or not all(str(item).strip() for item in replace_nodes): + raise FeaturePlanError("replan.replace_nodes must be a non-empty string array") + if not reason or not isinstance(alternatives, list) or not all(isinstance(item, str) for item in alternatives): + raise FeaturePlanError("replan requires reason and alternatives") + prior = state.get("feature_plan") or (self.store.read_feature_plan(task_id) if task_id else None) + if isinstance(prior, dict): + prior_nodes = {str(item.get("id")): item for item in prior.get("nodes") or () if isinstance(item, dict)} + next_nodes = {str(item.get("id")): item for item in plan.get("nodes") or () if isinstance(item, dict)} + replace_set = {str(item) for item in replace_nodes} + if not replace_set.issubset(prior_nodes): + raise FeaturePlanError("replan.replace_nodes must identify existing plan nodes") + completed_replacements = { + node_id for node_id in replace_set + if prior_nodes[node_id].get("status") in {"completed", "executed"} + } + if completed_replacements: + raise FeaturePlanError( + "Replan cannot replace completed nodes: " + + ", ".join(sorted(completed_replacements)) + ) + missing_unchanged = set(prior_nodes) - replace_set - set(next_nodes) + if missing_unchanged: + raise FeaturePlanError( + "Replan cannot remove nodes outside replace_nodes: " + + ", ".join(sorted(missing_unchanged)) + ) + for node_id in prior_nodes.keys() & next_nodes.keys(): + if node_id not in replace_set: + old = {key: value for key, value in prior_nodes[node_id].items() if key not in {"status", "failure"}} + new = {key: value for key, value in next_nodes[node_id].items() if key not in {"status", "failure"}} + if old != new: + raise FeaturePlanError(f"Replan may only change replace_nodes; unchanged node mutated: {node_id}") + plan["replan"] = { + "replace_nodes": [str(item) for item in replace_nodes], + "reason": reason, + "alternatives": [item.strip() for item in alternatives if item.strip()], + } + except (FeaturePlanError, ValueError) as error: + state["phase"] = "BLOCKED" + message = str(error) + code = "FEATURE_PLAN_CYCLE" if "cycle" in message.casefold() else "FEATURE_PLAN_INVALID" + return {"ok": False, "code": code, "message": message}, None + state["feature_plan"] = plan + state["phase"] = "PLAN_READY" + current_cdsl = None + topology = None + if task_id: + cdsl_path = self.store.current_cdsl_path(task_id) + if cdsl_path and cdsl_path.is_file(): + current_cdsl = json.loads(cdsl_path.read_text(encoding="utf-8")) + topology_path = self.store.current_topology_path(task_id) + if topology_path and topology_path.is_file(): + topology = json.loads(topology_path.read_text(encoding="utf-8")) + self.store.write_feature_plan(task_id, plan) + status = compute_node_statuses(plan, cdsl=current_cdsl, topology=topology) + plan.update({key: status[key] for key in ("nodes", "ready_nodes", "waiting_nodes", "blocked_nodes", "completed_nodes", "complete")}) + state["feature_plan"] = plan + if task_id: + self.store.write_feature_plan(task_id, plan) + return { + "ok": True, + "plan_id": plan["plan_id"], + "ready_nodes": plan["ready_nodes"], + "waiting_nodes": plan["waiting_nodes"], + "blocked_nodes": plan["blocked_nodes"], + "completed_nodes": plan["completed_nodes"], + "required_action": "generate_cdsl_model" if plan["ready_nodes"] else "inspect_current_topology" if plan["waiting_nodes"] else "none", + "message": "Feature plan validated. Generate only ready nodes; never invent topology selectors.", + }, None if name == "search_cdsl_library": if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before searching CDSL references."}, None @@ -1509,8 +2546,22 @@ class AgentService: state["read_revision_id"] = revision_id state["phase"] = "PLANNED" return payload, None + if name == "inspect_current_topology": + if phase not in {"PLAN_READY", "TOPOLOGY_READY", "CDSL_AUTHORING", "COMPLETED", "CDSL_REPAIR", "PLANNED"}: + return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "Build a successful CDSL revision before inspecting topology."}, None + result = _topology_query_result(self.store, task_id, arguments) + if result.get("ok") and result.get("records") and isinstance(state.get("feature_plan"), dict): + plan = deepcopy(state["feature_plan"]) + plan["topology_snapshot_id"] = str(result.get("snapshot_id") or "") + cdsl_path = self.store.current_cdsl_path(task_id) + topology_path = self.store.current_topology_path(task_id) + cdsl = json.loads(cdsl_path.read_text(encoding="utf-8")) if cdsl_path and cdsl_path.is_file() else None + topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None + state["feature_plan"] = compute_node_statuses(plan, cdsl=cdsl, topology=topology) + self.store.write_feature_plan(task_id, state["feature_plan"]) + return result, None if name in GENERATION_TOOL_NAMES: - if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}: + if phase not in {"PLANNED", "PLAN_READY", "TOPOLOGY_READY", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR", "COMPLETED"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before generating CAD."}, None selection = part_skill_selection or self.part_skill_library.select(request) if selection.get("conflict"): @@ -1533,20 +2584,39 @@ class AgentService: if task_id and not parent_revision_id: parent_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "") operation: dict[str, Any] = {"type": "cdsl_replacement" if parent_revision_id else "cdsl_create"} + if phase == "CDSL_REPAIR": + operation["type"] = "cdsl_repair" else: if not task_id: return {"ok": False, "code": "PATCH_TASK_REQUIRED", "message": "patch_cdsl_model requires an existing task."}, None base_revision_id = str(arguments.get("base_revision_id") or "") + current_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "") + if not current_revision_id or base_revision_id != current_revision_id: + raise ValueError("TOPOLOGY_SNAPSHOT_STALE: base_revision_id must be the current successful revision") base_path = self.store.revision_cdsl_path(task_id, base_revision_id) if base_path is None: raise ValueError("PATCH_BASE_REVISION_NOT_FOUND: base_revision_id does not identify a readable CDSL revision") try: - cdsl = apply_cdsl_patch(json.loads(base_path.read_text(encoding="utf-8")), arguments.get("patches")) + base_cdsl = json.loads(base_path.read_text(encoding="utf-8")) + cdsl = apply_cdsl_patch(base_cdsl, arguments.get("patches")) except (CdslPatchError, json.JSONDecodeError) as error: raise ValueError(f"INVALID_CDSL_PATCH: {error}") from error parent_revision_id = base_revision_id operation = {"type": "cdsl_patch", "base_revision_id": base_revision_id, "patches": deepcopy(arguments.get("patches") or [])} + base_selectors = {(item.get("source"), item.get("stable_id"), item.get("snapshot_id")) for item in _selector_values(base_cdsl)} + next_selectors = {(item.get("source"), item.get("stable_id"), item.get("snapshot_id")) for item in _selector_values(cdsl)} + if any(source in {"runtime_snapshot", "viewer_selection"} for source, _stable_id, _snapshot_id in next_selectors - base_selectors): + operation["type"] = "cdsl_selector_patch" + if state.get("feature_plan"): + operation["type"] = "cdsl_plan_batch" if operation.get("type") in {"cdsl_create", "cdsl_replacement"} else operation.get("type") + operation["plan_id"] = str(state["feature_plan"].get("plan_id") or "") + operation["plan_nodes"] = [ + str(node.get("id")) for node in state["feature_plan"].get("nodes") or () + if isinstance(node, dict) and node.get("status") in {"ready", "executing"} + ] cdsl, normalization_repairs = normalize_cdsl_for_engine(cdsl) + _validate_plan_cdsl_transition(self.store, task_id, state.get("feature_plan"), cdsl) + _validate_snapshot_selectors(self.store, task_id, cdsl) # Reject malformed model output before build_revision allocates a task # directory or revision. build_revision will assign the real task ID. preflight_cdsl = {**cdsl, "part_id": str(cdsl.get("part_id") or "agent_preflight")} @@ -1580,11 +2650,30 @@ class AgentService: input_attachments=input_attachments, verification=verification, reference_records=state.get("reference_records"), + feature_plan=state.get("feature_plan"), ) except Exception: state["phase"] = "CDSL_REPAIR" raise - state["phase"] = "COMPLETED" + plan_status: dict[str, Any] | None = None + if state.get("feature_plan"): + topology_path = self.store.current_topology_path(yieldable["task_id"]) + topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None + # A successful build already produced the authoritative topology + # snapshot. Bind it here so advancing a plan never depends on the + # model remembering a bookkeeping-only topology inspection call. + plan = deepcopy(state["feature_plan"]) + snapshot_id = str((topology or {}).get("snapshot_id") or "") + if snapshot_id: + plan["topology_snapshot_id"] = snapshot_id + plan_status = compute_node_statuses(plan, cdsl=cdsl, topology=topology) + state["feature_plan"] = plan_status + self.store.write_feature_plan(yieldable["task_id"], plan_status) + state["phase"] = "COMPLETED" if plan_status["complete"] else "TOPOLOGY_READY" + else: + state["phase"] = "COMPLETED" + yieldable["plan_complete"] = bool(plan_status["complete"]) if plan_status else True + yieldable["plan_status"] = plan_status return { "ok": True, "summary": summary, @@ -1592,6 +2681,16 @@ class AgentService: "revision_id": yieldable["revision_id"], "normalization_repairs": normalization_repairs, "operation": operation, + "plan_complete": bool(plan_status["complete"]) if plan_status else True, + "plan_status": { + key: plan_status[key] + for key in ("ready_nodes", "waiting_nodes", "blocked_nodes", "completed_nodes", "complete") + } if plan_status else None, + "required_action": ( + "inspect_current_topology" if plan_status and plan_status["waiting_nodes"] + else "patch_cdsl_model" if plan_status and plan_status["ready_nodes"] + else "complete" + ) if plan_status else "complete", }, yieldable raise ValueError(f"Unknown agent tool: {name}") @@ -1602,10 +2701,14 @@ class AgentService: @staticmethod def _tool_label(name: str) -> str: return { + "analyze_image_reference": "整理图片勘测", + "extract_image_sketch_candidates": "提取图片草图", "search_cdsl_library": "检索 CDSL 模型库", "read_cdsl_reference": "读取 CDSL 参考模型", "read_current_cdsl": "读取当前 CDSL", "describe_design_intent": "整理设计说明", + "plan_feature_tree": "规划特征树", + "inspect_current_topology": "查询当前拓扑", "generate_cdsl_model": "生成 CDSL", "patch_cdsl_model": "修复 CDSL", }.get(name, "调用 CAD 工具") @@ -1617,7 +2720,7 @@ class AgentService: conversation_id = str(conversation.get("conversation_id") or "") if not conversation_id: raise ValueError("Conversation attachment has no conversation id") - content: list[dict[str, Any]] = [{"type": "text", "text": "The following local attachments are part of the CAD request."}] + content: list[dict[str, Any]] = [{"type": "text", "text": "The following local attachments are part of the CAD request. Image ids are stable references for the visual survey."}] for attachment in attachments: if not isinstance(attachment, dict): continue @@ -1633,6 +2736,25 @@ class AgentService: if not model.vision: raise ValueError("The selected model does not support images. Choose a vision-capable model enabled in backend/.env.") mime = str(attachment.get("mime") or "image/png") + metadata = { + "width": attachment.get("width"), + "height": attachment.get("height"), + "orientation": attachment.get("orientation"), + } + try: + hints = cv_hints(path.read_bytes()) + except OSError: + hints = {"available": False, "hints": []} + content.append({ + "type": "text", + "text": ( + f"IMAGE_ID: {attachment.get('id')}\n" + f"FILE_NAME: {attachment.get('name')}\n" + f"MIME: {mime}\n" + f"METADATA: {json.dumps(metadata, ensure_ascii=False, separators=(',', ':'))}\n" + f"CV_HINTS: {json.dumps(hints, ensure_ascii=False, separators=(',', ':'))}" + ), + }) encoded = base64.b64encode(path.read_bytes()).decode("ascii") content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}}) elif kind == "document": diff --git a/backend/app/services/attachments.py b/backend/app/services/attachments.py index 65ced3ff..354798f4 100644 --- a/backend/app/services/attachments.py +++ b/backend/app/services/attachments.py @@ -42,7 +42,16 @@ def extract_document_text(data: bytes) -> str: return text[:MAX_EXTRACTED_CHARS] -def attachment_record(conversation_id: str, filename: str, mime: str, relative_path: str, data: bytes, kind: str, extracted_path: str = "") -> dict[str, object]: +def attachment_record( + conversation_id: str, + filename: str, + mime: str, + relative_path: str, + data: bytes, + kind: str, + extracted_path: str = "", + metadata: dict[str, object] | None = None, +) -> dict[str, object]: return { "id": Path(relative_path).stem, "conversation_id": conversation_id, @@ -53,4 +62,5 @@ def attachment_record(conversation_id: str, filename: str, mime: str, relative_p "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), "extracted_path": extracted_path, + **(metadata or {}), } diff --git a/backend/app/services/cdsl_fragment.py b/backend/app/services/cdsl_fragment.py new file mode 100644 index 00000000..5d0840cb --- /dev/null +++ b/backend/app/services/cdsl_fragment.py @@ -0,0 +1,272 @@ +"""Controlled CDSL fragments; the backend, never string concatenation, materialises a model.""" + +from __future__ import annotations + +from copy import deepcopy +from hashlib import sha256 +import json +from typing import Any + +from app.services.generation_plan import GenerationPlanError + + +class CdslFragmentError(ValueError): + """A node fragment cannot be applied safely to its declared base revision.""" + + +def cdsl_sha256(cdsl: dict[str, Any] | None) -> str: + value = cdsl or {"geometry": {"sketches": []}, "features": []} + return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() + + +def _items(value: Any, field: str) -> list[dict[str, Any]]: + if value is None: + return [] + if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): + raise CdslFragmentError(f"{field} must be an array of objects") + return deepcopy(value) + + +def _ids(items: list[dict[str, Any]], field: str) -> list[str]: + values = [str(item.get("id") or "").strip() for item in items] + if not all(values) or len(values) != len(set(values)): + raise CdslFragmentError(f"{field} must have unique non-empty ids") + return values + + +def _node(plan: dict[str, Any], node_id: str) -> dict[str, Any]: + node = next((item for item in plan.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None) + if node is None: + raise CdslFragmentError(f"Fragment references an unknown node: {node_id}") + return node + + +def _single_output_id(node: dict[str, Any], field: str) -> str: + values = [str(item) for item in node.get(field) or () if str(item)] + if len(values) != 1: + raise CdslFragmentError(f"Node {node.get('id')} must declare exactly one {field} output") + return values[0] + + +def _node_feature_id(plan: dict[str, Any], node_id: str) -> str: + return _single_output_id(_node(plan, node_id), "cdsl_feature_ids") + + +def _materialize_node_references(value: Any, plan: dict[str, Any]) -> Any: + """Translate fragment-only node references into CDSL feature references.""" + if isinstance(value, list): + return [_materialize_node_references(item, plan) for item in value] + if not isinstance(value, dict): + return value + materialized = {key: _materialize_node_references(item, plan) for key, item in value.items()} + owner_node_id = materialized.pop("owner_node_id", None) + if owner_node_id is not None: + if not isinstance(owner_node_id, str) or not owner_node_id.strip(): + raise CdslFragmentError("owner_node_id must be a non-empty plan node id") + materialized["owner_feature_id"] = _node_feature_id(plan, owner_node_id.strip()) + source_node_ids = materialized.pop("source_node_ids", None) + if source_node_ids is not None: + if not isinstance(source_node_ids, list) or not source_node_ids or not all(isinstance(item, str) and item.strip() for item in source_node_ids): + raise CdslFragmentError("source_node_ids must be a non-empty array of plan node ids") + materialized["source_feature_ids"] = [_node_feature_id(plan, item.strip()) for item in source_node_ids] + return materialized + + +def _selector_references(value: Any) -> list[dict[str, Any]]: + """Collect selector-shaped objects from feature selectors and params.""" + found: list[dict[str, Any]] = [] + if isinstance(value, dict): + if "kind" in value and ("stable_id" in value or "snapshot_id" in value or "owner_feature_id" in value or "owner_node_id" in value): + found.append(value) + for child in value.values(): + found.extend(_selector_references(child)) + elif isinstance(value, list): + for child in value: + found.extend(_selector_references(child)) + return found + + +def validate_fragment( + fragment: dict[str, Any], + *, + plan: dict[str, Any], + node_id: str, + base_revision_id: str, + base_cdsl: dict[str, Any] | None, + required_snapshot_id: str = "", +) -> dict[str, Any]: + if not isinstance(fragment, dict): + raise CdslFragmentError("CDSL fragment must be an object") + version = str(fragment.get("schema_version") or "cad.cdsl-fragment.v1") + if version != "cad.cdsl-fragment.v1": + raise CdslFragmentError(f"Unsupported CDSL fragment schema: {version}") + declared_node_id = str(fragment.get("node_id") or "") + if declared_node_id != node_id: + raise CdslFragmentError("Fragment node_id does not match the active node") + if str(fragment.get("base_revision_id") or "") != base_revision_id: + raise CdslFragmentError("Fragment base_revision_id does not match the active revision") + if str(fragment.get("base_cdsl_sha256") or "") != cdsl_sha256(base_cdsl): + raise CdslFragmentError("Fragment base_cdsl_sha256 does not match the active CDSL") + snapshot_id = str(fragment.get("required_snapshot_id") or "") + if required_snapshot_id and snapshot_id != required_snapshot_id: + raise CdslFragmentError("Fragment required_snapshot_id does not match the active topology snapshot") + if not required_snapshot_id and snapshot_id: + raise CdslFragmentError("Fragment cannot use a topology snapshot before one exists") + sketches = _items(fragment.get("add_sketches"), "add_sketches") + features = _items(fragment.get("add_features"), "add_features") + node = _node(plan, node_id) + expected = {str(item) for item in node.get("cdsl_feature_ids") or ()} + expected_sketches = {str(item) for item in node.get("cdsl_sketch_ids") or ()} + if len(expected) != 1: + # Compatibility path for pre-incremental plans which could own more + # than one CDSL feature in a single node. + feature_ids = _ids(features, "add_features") + if set(feature_ids) != expected: + raise CdslFragmentError( + f"Fragment features must exactly match node outputs: expected {sorted(expected)}, got {sorted(feature_ids)}" + ) + elif len(features) != 1: + raise CdslFragmentError("An atomic plan node must generate exactly one feature") + if len(expected_sketches) > 1: + sketch_ids = _ids(sketches, "add_sketches") + if set(sketch_ids) != expected_sketches: + raise CdslFragmentError( + f"Fragment sketches must exactly match node outputs: expected {sorted(expected_sketches)}, got {sorted(sketch_ids)}" + ) + elif len(sketches) != len(expected_sketches): + expected_description = "one" if expected_sketches else "no" + raise CdslFragmentError(f"This node requires {expected_description} new sketch") + + # The planner and fragment author never own CDSL object identities. The + # merger writes node-derived IDs and dependencies after it has validated + # the current immutable plan. + if len(expected) == 1: + features[0]["id"] = next(iter(expected)) + if len(expected_sketches) == 1: + sketch_id = next(iter(expected_sketches)) + sketches[0]["id"] = sketch_id + features[0]["sketch_id"] = sketch_id + elif not expected_sketches and len(features) == 1: + features[0].pop("sketch_id", None) + features = _materialize_node_references(features, plan) + sketches = _materialize_node_references(sketches, plan) + sketch_ids = _ids(sketches, "add_sketches") + feature_ids = _ids(features, "add_features") + atomic_id = str(node.get("atomic_id") or "") + if len(expected) == 1: + features[0]["atomic_id"] = atomic_id + elif any(str(feature.get("atomic_id") or "") != atomic_id for feature in features): + raise CdslFragmentError(f"Fragment features must use plan atomic_id {atomic_id}") + base_sketch_ids = { + str(item.get("id")) for item in ((base_cdsl or {}).get("geometry") or {}).get("sketches") or () + if isinstance(item, dict) + } + base_feature_ids = { + str(item.get("id")) for item in (base_cdsl or {}).get("features") or () if isinstance(item, dict) + } + if base_sketch_ids & set(sketch_ids): + raise CdslFragmentError("Fragment attempts to overwrite an existing sketch") + if base_feature_ids & set(feature_ids): + raise CdslFragmentError("Fragment attempts to overwrite a frozen feature") + predecessor_features = { + feature_id + for dependency in node.get("depends_on") or () + for feature_id in (_node(plan, str(dependency)).get("cdsl_feature_ids") or ()) + } + if len(expected) == 1: + features[0]["depends_on"] = sorted(predecessor_features) + available_features = base_feature_ids | set(feature_ids) + available_sketches = base_sketch_ids | set(sketch_ids) + for feature in features: + feature_id = str(feature["id"]) + dependencies = feature.get("depends_on") or [] + if not isinstance(dependencies, list) or any(str(item) not in available_features for item in dependencies): + raise CdslFragmentError(f"Feature {feature_id} has a missing dependency") + sketch_id = str(feature.get("sketch_id") or "") + if sketch_id and sketch_id not in available_sketches: + raise CdslFragmentError(f"Feature {feature_id} references an unknown sketch") + if required_snapshot_id: + selectors = [selector for feature in features for selector in _selector_references(feature)] + if not selectors: + raise CdslFragmentError("Topology-dependent fragment must provide runtime snapshot selectors") + for selector in selectors: + if str(selector.get("snapshot_id") or "") != required_snapshot_id: + raise CdslFragmentError("Topology selector must reference the active snapshot") + if not str(selector.get("owner_feature_id") or ""): + raise CdslFragmentError("Topology selector must declare owner_feature_id") + if not isinstance(selector.get("geometry"), dict) or not selector["geometry"]: + raise CdslFragmentError("Topology selector must declare a non-empty geometry signature") + declared_dependencies = { + str(dependency) + for feature in features + for dependency in feature.get("depends_on") or () + } + if not predecessor_features.issubset(declared_dependencies): + raise CdslFragmentError("Fragment does not preserve all plan-node dependencies") + rules = fragment.get("verification_rules") or [] + if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules): + raise CdslFragmentError("verification_rules must be an array of objects") + assumptions = fragment.get("assumptions") or [] + if not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions): + raise CdslFragmentError("assumptions must be an array of strings") + return { + "schema_version": "cad.cdsl-fragment.v1", + "node_id": node_id, + "base_revision_id": base_revision_id, + "base_cdsl_sha256": cdsl_sha256(base_cdsl), + "required_snapshot_id": snapshot_id, + "add_sketches": sketches, + "add_features": features, + "expected_feature_ids": sorted(expected), + "verification_rules": deepcopy(rules), + "assumptions": [item.strip() for item in assumptions if item.strip()], + } + + +def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]: + """Return the complete, append-only document that the runtime must rebuild.""" + if base_cdsl is None: + document: dict[str, Any] = { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.1.0", + "kind": "part", + "part_id": "agent_preflight", + "geometry": {"sketches": []}, + "features": [], + } + else: + document = deepcopy(base_cdsl) + geometry = document.setdefault("geometry", {}) + if not isinstance(geometry, dict): + raise CdslFragmentError("Base CDSL geometry must be an object") + sketches = geometry.setdefault("sketches", []) + features = document.setdefault("features", []) + if not isinstance(sketches, list) or not isinstance(features, list): + raise CdslFragmentError("Base CDSL has invalid collections") + sketches.extend(deepcopy(fragment["add_sketches"])) + features.extend(deepcopy(fragment["add_features"])) + return document + + +def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_id: str) -> dict[str, Any]: + """Persist the runtime's actual selector choices as auditable node evidence.""" + values = [] + for resolution in engine_result.get("selector_resolution") or (): + if not isinstance(resolution, dict): + continue + selector = resolution.get("selector") if isinstance(resolution.get("selector"), dict) else {} + candidates = resolution.get("candidates") if isinstance(resolution.get("candidates"), (list, tuple)) else [] + values.append({ + "consumer_node_id": node_id, + "consumer_feature_id": str(resolution.get("feature_id") or ""), + "source_snapshot_id": str(selector.get("snapshot_id") or snapshot_id), + "kind": str(selector.get("kind") or ""), + "owner_feature_id": str(selector.get("owner_feature_id") or ""), + "stable_id": str(selector.get("stable_id") or ""), + "geometry": deepcopy(selector.get("geometry") or {}), + "status": str(resolution.get("status") or ""), + "candidates": deepcopy(list(candidates)), + "score": resolution.get("score"), + "selected": deepcopy(resolution.get("selected") or resolution.get("record") or {}), + }) + return {"schema_version": "cad.selector-bindings.v1", "node_id": node_id, "bindings": values} diff --git a/backend/app/services/editing.py b/backend/app/services/editing.py index 81f710e2..64bb9373 100644 --- a/backend/app/services/editing.py +++ b/backend/app/services/editing.py @@ -150,6 +150,10 @@ def apply_direct_edit( operation: str, selection: dict[str, Any], parameters: dict[str, Any], + *, + node_id: str = "", + branch_id: str = "main", + visibility: str = "final", ) -> dict[str, Any]: if operation in {"add_chamfer", "add_fillet"}: raise ValueError("Chamfer and fillet require a stable CDSL edge anchor and are not available for this model yet") @@ -213,4 +217,7 @@ def apply_direct_edit( }, part_skills=None, generation_assumptions=["Legacy profile macros were lowered to direct analytic contours before this edit."] if legacy_profiles_lowered else [], + node_id=node_id, + branch_id=branch_id, + visibility=visibility, ) diff --git a/backend/app/services/engine_service.py b/backend/app/services/engine_service.py index 27d8a310..698435a5 100644 --- a/backend/app/services/engine_service.py +++ b/backend/app/services/engine_service.py @@ -15,6 +15,7 @@ from jsonschema.exceptions import SchemaError from vendor.cdsl_preview_runtime import step_to_glb from app.services.quality import evaluate_quality, validate_verification from app.services.storage import WorkspaceStore, now_iso, write_json +from app.services.cdsl_fragment import selector_bindings from app.settings import Settings @@ -299,7 +300,140 @@ def parameter_contract(cdsl: dict[str, Any]) -> dict[str, Any]: return {"schema_version": "1.0", "parameters": _derived_parameters(cdsl), "source": "derived"} -def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]: +def topology_snapshot( + engine_result: dict[str, Any], + *, + task_id: str = "", + revision_id: str = "", + preview: dict[str, Any] | None = None, +) -> dict[str, Any]: + raw_records = [ + raw for raw in engine_result.get("topology_records") or () + if isinstance(raw, dict) and raw.get("record_id") and raw.get("kind") + ] + active_body_id = next( + ( + str(result.get("body_id")) + for result in reversed(engine_result.get("feature_results") or ()) + if isinstance(result, dict) and result.get("body_id") + ), + "", + ) + if not active_body_id: + active_body_id = next( + ( + str(raw.get("body_id")) + for raw in reversed(raw_records) + if raw.get("kind") == "body" and raw.get("body_id") + ), + "", + ) + + # The runtime retains historical B-rep records for provenance, but only + # the final body can resolve face, edge, vertex, and body selectors. + active_records = [ + raw for raw in raw_records + if not active_body_id + or raw.get("kind") in {"plane", "axis"} + or str(raw.get("body_id") or "") == active_body_id + ] + records: list[dict[str, Any]] = [] + for raw in active_records: + kind = str(raw.get("kind")) + records.append({ + "record_id": str(raw["record_id"]), + "kind": kind, + "feature_id": str(raw.get("feature_id") or ""), + "body_id": str(raw.get("body_id") or "") or None, + "owner_feature_ids": [str(item) for item in raw.get("owner_feature_ids") or () if str(item)], + "geometry": copy.deepcopy(raw.get("geometry") or {}), + "executable": kind in {"body", "face", "edge", "vertex", "plane", "axis"}, + "synthetic": False, + }) + # Preview/B-rep fallback faces are useful for visual explanation only. + # Keep them in the unified audit snapshot, but never expose them as + # executable selector candidates. + if not any(item.get("kind") == "face" for item in records): + for index, raw in enumerate((preview or {}).get("topology_faces") or ()): + if not isinstance(raw, dict): + continue + record_id = str(raw.get("id") or f"synthetic:face:{index}") + center = raw.get("center") + normal = raw.get("normal") + raw_bbox = raw.get("bbox") + if isinstance(raw_bbox, dict) and isinstance(raw_bbox.get("min"), list) and isinstance(raw_bbox.get("max"), list): + raw_bbox = [*raw_bbox["min"], *raw_bbox["max"]] + geometry = { + "surface_type": str(raw.get("surface_type") or "unknown"), + "center_mm": copy.deepcopy(center) if isinstance(center, list) else None, + "normal": copy.deepcopy(normal) if isinstance(normal, list) else None, + "bbox_mm": copy.deepcopy(raw_bbox or {}), + } + records.append({ + "record_id": record_id, + "kind": "face", + "feature_id": "", + "body_id": None, + "owner_feature_ids": [], + "geometry": geometry, + "executable": False, + "synthetic": True, + }) + return { + "schema_version": "cad.topology.v1", + "task_id": task_id, + "revision_id": revision_id, + "snapshot_id": f"{task_id}/{revision_id}" if task_id and revision_id else "", + "body_id": active_body_id, + "records": records, + } + + +def topology_sidecars( + engine_result: dict[str, Any], + preview: dict[str, Any] | None = None, + *, + snapshot: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + runtime_records = (snapshot or topology_snapshot(engine_result)).get("records") or [] + runtime_faces = [item for item in runtime_records if item.get("kind") == "face" and item.get("executable", True)] + runtime_edges = [item for item in runtime_records if item.get("kind") == "edge" and item.get("executable", True)] + if runtime_faces or runtime_edges: + references = [] + for record in runtime_faces: + geometry = record.get("geometry") or {} + references.append({ + "id": str(record.get("record_id")), + "selectorType": "face", + "label": str(geometry.get("surface_type") or "face"), + "center": geometry.get("center_mm"), + "normal": geometry.get("normal"), + "frame": { + "origin_mm": geometry.get("center_mm"), + "normal": geometry.get("normal"), + "x_dir": [1, 0, 0], + "y_dir": [0, 1, 0], + }, + "bbox": geometry.get("bbox_mm") or {}, + "surface_type": str(geometry.get("surface_type") or "unknown"), + "owner_feature_ids": record.get("owner_feature_ids") or [], + "source": "runtime_snapshot", + "snapshot_id": (snapshot or {}).get("snapshot_id") or "", + "executable": True, + }) + edge_records = [ + { + **record, + "selectorType": "edge", + "source": "runtime_snapshot", + "snapshot_id": (snapshot or {}).get("snapshot_id") or "", + } + for record in runtime_edges + ] + return ( + {"schema_version": "cad.topology.v1", "references": references, "edges": edge_records}, + {"schema_version": "cad.topology.v1", "edges": edge_records}, + ) topology_faces = (preview or {}).get("topology_faces") if isinstance(topology_faces, list) and topology_faces: references = [] @@ -322,6 +456,9 @@ def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | N "surface_type": str(face.get("surface_type") or "unknown"), "triangle_start": int(face.get("triangle_start") or 0), "triangle_count": int(face.get("triangle_count") or 0), + "source": "preview", + "synthetic": True, + "executable": False, }) if references: return ({"schema_version": "1.1", "references": references}, {"schema_version": "1.0", "edges": []}) @@ -346,6 +483,9 @@ def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | N "center": point, "normal": normal, "frame": {"origin_mm": point, "normal": normal, "x_dir": x_dir, "y_dir": y_dir}, "bbox": {"min": minimum, "max": maximum}, + "source": "bbox_fallback", + "synthetic": True, + "executable": False, } for name, point, normal, x_dir, y_dir in definitions ] @@ -463,6 +603,11 @@ def build_revision( repair_attempts: int = 0, verification: dict[str, Any] | None = None, reference_records: list[dict[str, Any]] | None = None, + feature_plan: dict[str, Any] | None = None, + node_id: str = "", + fragment: dict[str, Any] | None = None, + branch_id: str = "main", + visibility: str = "final", ) -> dict[str, Any]: engine = load_engine(settings) task = store.ensure_task(task_id, request) @@ -476,12 +621,17 @@ def build_revision( parameters_path = revision_dir / "parameters.json" selector_path = revision_dir / "model.selector.json" edges_path = revision_dir / "model.edges.json" + topology_path = revision_dir / "model.topology.json" part_skills_path = revision_dir / "part-skills.json" quality_path = revision_dir / "quality-report.json" snapshot_manifest_path = revision_dir / "snapshot-manifest.json" + fragment_path = revision_dir / "fragment.json" + selector_bindings_path = revision_dir / "selector-bindings.json" part_skill_audit = _part_skill_audit(part_skills, request, generation_assumptions) generation_context = _generation_context(reference_ids, part_skill_audit) write_json(request_path, {"request": request, "created_at": now_iso()}) + if isinstance(feature_plan, dict): + store.write_feature_plan(task["task_id"], feature_plan) write_json(references_path, {"reference_ids": reference_ids, "records": [item for item in reference_records or [] if isinstance(item, dict)]}) write_json(part_skills_path, part_skill_audit) write_json(snapshot_manifest_path, { @@ -499,6 +649,7 @@ def build_revision( "cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "part_skills_path": part_skills_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "quality_path": quality_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "snapshot_manifest_path": snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix(), @@ -512,6 +663,11 @@ def build_revision( "operation": operation or {}, "input_attachments": input_attachments or [], "repair_attempts": max(0, int(repair_attempts)), + "branch_id": branch_id or "main", + "visibility": visibility if visibility in {"checkpoint", "final", "superseded"} else "checkpoint", + "node_id": node_id, + "fragment_path": fragment_path.relative_to(store.task_dir(task["task_id"])).as_posix() if fragment else "", + "selector_bindings_path": selector_bindings_path.relative_to(store.task_dir(task["task_id"])).as_posix(), } if status == "success": record.update({ @@ -519,6 +675,7 @@ def build_revision( "glb_path": glb_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "selector_path": selector_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "edges_path": edges_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "engine": engine_name, }) else: @@ -538,6 +695,8 @@ def build_revision( if not isinstance(meta.get("editable_parameters"), list) or not meta["editable_parameters"]: meta["editable_parameters"] = _derived_parameters(cdsl_copy) write_json(cdsl_path, cdsl_copy) + if fragment is not None: + write_json(fragment_path, fragment) write_json(parameters_path, parameter_contract(cdsl_copy)) validate_cdsl(cdsl_copy, engine) # Product revisions are semantic CDSL artifacts. Do not route them @@ -547,7 +706,14 @@ def build_revision( if engine_result.get("engine") != "cdsl_only" or not step_path.is_file() or step_path.stat().st_size == 0: raise RuntimeError("Engine did not produce a CDSL-only STEP artifact") preview = step_to_glb(step_path, glb_path) - selector, edges = topology_sidecars(engine_result, preview) + snapshot = topology_snapshot(engine_result, task_id=task["task_id"], revision_id=revision_id, preview=preview) + write_json(topology_path, snapshot) + write_json(selector_bindings_path, selector_bindings( + engine_result, + node_id=node_id, + snapshot_id=str(snapshot.get("snapshot_id") or ""), + )) + selector, edges = topology_sidecars(engine_result, preview, snapshot=snapshot) write_json(selector_path, selector) write_json(edges_path, edges) rules = validate_verification(verification, cdsl_copy) diff --git a/backend/app/services/feature_plan.py b/backend/app/services/feature_plan.py new file mode 100644 index 00000000..a3e3810f --- /dev/null +++ b/backend/app/services/feature_plan.py @@ -0,0 +1,222 @@ +"""Deterministic feature-plan validation and readiness calculations.""" + +from __future__ import annotations + +from collections import defaultdict, deque +from copy import deepcopy +from typing import Any, Iterable + + +PLAN_SCHEMA_VERSION = "cad.feature-plan.v1" +NODE_STATUSES = { + "planned", + "ready", + "waiting_for_topology", + "waiting_for_selection", + "blocked", + "executing", + "executed", + "failed", + "completed", +} + +_TOPOLOGY_REQUIRED_ATOMICS = { + "fillet", + "chamfer", + "hole_blind", + "hole_countersink", + "hole_counterbore", + "pattern_mirror", +} + + +class FeaturePlanError(ValueError): + """A feature plan is not a valid acyclic executable plan.""" + + +def _text(value: Any, field: str, *, required: bool = True) -> str: + result = str(value or "").strip() + if required and not result: + raise FeaturePlanError(f"{field} is required") + return result + + +def _bool(value: Any) -> bool: + return value is True + + +def _normalise_node(raw: Any, index: int) -> dict[str, Any]: + if not isinstance(raw, dict): + raise FeaturePlanError(f"nodes[{index}] must be an object") + node_id = _text(raw.get("id"), f"nodes[{index}].id") + atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id") + depends_on = raw.get("depends_on") or [] + if not isinstance(depends_on, list) or not all(isinstance(item, str) and item.strip() for item in depends_on): + raise FeaturePlanError(f"nodes[{index}].depends_on must be an array of non-empty strings") + feature_ids = raw.get("cdsl_feature_ids") + if feature_ids is None: + feature_ids = [node_id] + if not isinstance(feature_ids, list) or not feature_ids or not all(isinstance(item, str) and item.strip() for item in feature_ids): + raise FeaturePlanError(f"nodes[{index}].cdsl_feature_ids must be a non-empty string array") + query = raw.get("topology_query") + if query is not None and not isinstance(query, dict): + raise FeaturePlanError(f"nodes[{index}].topology_query must be an object") + requires_topology = _bool(raw.get("requires_topology")) or atomic_id in _TOPOLOGY_REQUIRED_ATOMICS + status = str(raw.get("status") or "planned") + if status not in NODE_STATUSES: + raise FeaturePlanError(f"nodes[{index}].status is unsupported: {status}") + node = { + "id": node_id, + "intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False), + "atomic_id": atomic_id, + "depends_on": list(dict.fromkeys(item.strip() for item in depends_on)), + "requires_topology": requires_topology, + "topology_query": deepcopy(query) if query is not None else None, + "status": status, + "cdsl_feature_ids": list(dict.fromkeys(item.strip() for item in feature_ids)), + } + if raw.get("selector_required") is not None: + node["selector_required"] = _bool(raw.get("selector_required")) + if isinstance(raw.get("failure"), dict): + node["failure"] = deepcopy(raw["failure"]) + return node + + +def validate_feature_plan(plan: dict[str, Any], *, supported_atomic_ids: Iterable[str] = ()) -> dict[str, Any]: + if not isinstance(plan, dict): + raise FeaturePlanError("Feature plan must be an object") + version = str(plan.get("schema_version") or PLAN_SCHEMA_VERSION) + if version != PLAN_SCHEMA_VERSION: + raise FeaturePlanError(f"Unsupported feature plan schema: {version}") + plan_id = _text(plan.get("plan_id"), "plan_id") + task_id = _text(plan.get("task_id"), "task_id", required=False) + topology_snapshot_id = _text(plan.get("topology_snapshot_id"), "topology_snapshot_id", required=False) + raw_nodes = plan.get("nodes") + if not isinstance(raw_nodes, list) or not raw_nodes: + raise FeaturePlanError("Feature plan requires a non-empty nodes array") + nodes = [_normalise_node(item, index) for index, item in enumerate(raw_nodes)] + by_id: dict[str, dict[str, Any]] = {} + node_index: dict[str, int] = {} + feature_owner: dict[str, str] = {} + supported = {str(item) for item in supported_atomic_ids if str(item)} + for index, node in enumerate(nodes): + if node["id"] in by_id: + raise FeaturePlanError(f"Duplicate feature plan node: {node['id']}") + if supported and node["atomic_id"] not in supported: + raise FeaturePlanError(f"Unsupported feature plan atomic_id: {node['atomic_id']}") + by_id[node["id"]] = node + node_index[node["id"]] = index + for feature_id in node["cdsl_feature_ids"]: + if feature_id in feature_owner: + raise FeaturePlanError(f"CDSL feature belongs to multiple plan nodes: {feature_id}") + feature_owner[feature_id] = node["id"] + indegree = {node_id: 0 for node_id in by_id} + children: dict[str, list[str]] = defaultdict(list) + for node in nodes: + for dependency in node["depends_on"]: + if dependency not in by_id: + raise FeaturePlanError(f"Node {node['id']} has missing dependency: {dependency}") + if node_index[dependency] >= node_index[node["id"]]: + raise FeaturePlanError(f"Node {node['id']} must appear after dependency: {dependency}") + indegree[node["id"]] += 1 + children[dependency].append(node["id"]) + queue = deque(node_id for node_id, degree in indegree.items() if degree == 0) + visited: list[str] = [] + while queue: + node_id = queue.popleft() + visited.append(node_id) + for child in children[node_id]: + indegree[child] -= 1 + if indegree[child] == 0: + queue.append(child) + if len(visited) != len(nodes): + raise FeaturePlanError("Feature plan contains a dependency cycle") + return { + "schema_version": PLAN_SCHEMA_VERSION, + "plan_id": plan_id, + "task_id": task_id, + "topology_snapshot_id": topology_snapshot_id, + "nodes": nodes, + "feature_owner": feature_owner, + } + + +def _topology_available(topology: dict[str, Any] | None) -> bool: + if not isinstance(topology, dict): + return False + return any( + isinstance(record, dict) + and record.get("executable", True) is not False + and record.get("kind") in {"face", "edge", "vertex", "body", "plane", "axis"} + for record in topology.get("records") or () + ) + + +def compute_node_statuses( + plan: dict[str, Any], + *, + cdsl: dict[str, Any] | None = None, + topology: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Return a copy with deterministic status and readiness information.""" + checked = validate_feature_plan(plan) + present_features = { + str(feature.get("id")) + for feature in (cdsl or {}).get("features") or () + if isinstance(feature, dict) and feature.get("id") + } + has_topology = _topology_available(topology) + topology_snapshot_id = str((topology or {}).get("snapshot_id") or "") + selection_ready = bool(topology_snapshot_id and checked.get("topology_snapshot_id") == topology_snapshot_id) + by_id = {node["id"]: node for node in checked["nodes"]} + result_nodes: list[dict[str, Any]] = [] + for original in checked["nodes"]: + node = deepcopy(original) + if node["status"] in {"failed", "blocked"}: + result_nodes.append(node) + continue + if all(feature_id in present_features for feature_id in node["cdsl_feature_ids"]): + node["status"] = "completed" + result_nodes.append(node) + continue + dependencies_done = all(by_id[item]["status"] in {"executed", "completed"} or all( + feature_id in present_features for feature_id in by_id[item]["cdsl_feature_ids"] + ) for item in node["depends_on"]) + if not dependencies_done: + node["status"] = "planned" + elif node["requires_topology"] and not has_topology: + node["status"] = "waiting_for_topology" + elif node["requires_topology"] and not selection_ready: + node["status"] = "waiting_for_selection" + else: + node["status"] = "ready" + result_nodes.append(node) + ready = [node["id"] for node in result_nodes if node["status"] == "ready"] + waiting = [node["id"] for node in result_nodes if node["status"] in {"waiting_for_topology", "waiting_for_selection"}] + blocked = [node["id"] for node in result_nodes if node["status"] == "blocked"] + completed = [node["id"] for node in result_nodes if node["status"] == "completed"] + return { + **checked, + "nodes": result_nodes, + "ready_nodes": ready, + "waiting_nodes": waiting, + "blocked_nodes": blocked, + "completed_nodes": completed, + "complete": len(completed) == len(result_nodes) and not blocked, + } + + +def plan_feature_ids(plan: dict[str, Any], node_ids: Iterable[str]) -> set[str]: + checked = validate_feature_plan(plan) + wanted = set(node_ids) + return { + feature_id + for node in checked["nodes"] + if node["id"] in wanted + for feature_id in node["cdsl_feature_ids"] + } + + +def node_for_feature(plan: dict[str, Any], feature_id: str) -> dict[str, Any] | None: + checked = validate_feature_plan(plan) + return next((node for node in checked["nodes"] if feature_id in node["cdsl_feature_ids"]), None) diff --git a/backend/app/services/generation_plan.py b/backend/app/services/generation_plan.py new file mode 100644 index 00000000..14ae27e2 --- /dev/null +++ b/backend/app/services/generation_plan.py @@ -0,0 +1,228 @@ +"""Strict contracts for persistent, node-by-node CAD generation.""" + +from __future__ import annotations + +from collections import defaultdict, deque +from copy import deepcopy +from hashlib import sha256 +import re +from typing import Any, Iterable + +from app.services.feature_plan import FeaturePlanError, validate_feature_plan + + +GENERATION_PLAN_SCHEMA_VERSION = "cad.generation-plan.v2" +BACKEND_ID_STRATEGY = "backend-derived-v1" +REQUIREMENT_SOURCES = {"explicit", "assumption"} +REQUIREMENT_PRIORITIES = {"hard", "soft"} +# Keep this in sync with the engine's profile_schema.json. The plan is +# deliberately atomic: an operation that consumes a profile owns one new +# sketch, while all other operations own none. +SKETCH_REQUIRED_ATOMICS = { + "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", + "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", + "hole_counterbore", "sphere_add", +} + + +class GenerationPlanError(ValueError): + """The authoring plan cannot safely drive an incremental build.""" + + +def _text(value: Any, field: str, *, required: bool = True) -> str: + result = str(value or "").strip() + if required and not result: + raise GenerationPlanError(f"{field} is required") + return result + + +def _string_list(value: Any, field: str, *, required: bool = False) -> list[str]: + if value is None: + value = [] + if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value): + raise GenerationPlanError(f"{field} must be an array of non-empty strings") + result = list(dict.fromkeys(item.strip() for item in value)) + if required and not result: + raise GenerationPlanError(f"{field} must not be empty") + return result + + +def _normalise_requirement(raw: Any, index: int) -> dict[str, Any]: + if not isinstance(raw, dict): + raise GenerationPlanError(f"requirements[{index}] must be an object") + source = _text(raw.get("source") or "assumption", f"requirements[{index}].source") + priority = _text(raw.get("priority") or "hard", f"requirements[{index}].priority") + if source not in REQUIREMENT_SOURCES: + raise GenerationPlanError(f"requirements[{index}].source is unsupported: {source}") + if priority not in REQUIREMENT_PRIORITIES: + raise GenerationPlanError(f"requirements[{index}].priority is unsupported: {priority}") + return { + "id": _text(raw.get("id"), f"requirements[{index}].id"), + "source": source, + "priority": priority, + "description": _text(raw.get("description"), f"requirements[{index}].description"), + "value": deepcopy(raw.get("value")), + "unit": _text(raw.get("unit"), f"requirements[{index}].unit", required=False), + "tolerance": deepcopy(raw.get("tolerance")), + } + + +def _backend_cdsl_id(kind: str, node_id: str) -> str: + """Create a valid, stable CDSL identifier without trusting model naming.""" + slug = re.sub(r"[^A-Za-z0-9_-]+", "_", node_id).strip("_-").lower() or "node" + digest = sha256(node_id.encode("utf-8")).hexdigest()[:8] + return f"{kind}_{slug[:60]}_{digest}" + + +def _backend_node_outputs(node_id: str, atomic_id: str) -> tuple[list[str], list[str]]: + feature_ids = [_backend_cdsl_id("feature", node_id)] + sketch_ids = [_backend_cdsl_id("sketch", node_id)] if atomic_id in SKETCH_REQUIRED_ATOMICS else [] + return feature_ids, sketch_ids + + +def _stored_plan_ids(raw: dict[str, Any]) -> bool: + """Retain IDs of plans already materialised by an earlier backend version.""" + return str(raw.get("id_strategy") or "") == BACKEND_ID_STRATEGY or isinstance(raw.get("feature_owner"), dict) + + +def validate_generation_plan( + document: dict[str, Any], + *, + supported_atomic_ids: Iterable[str] = (), + task_id: str = "", +) -> dict[str, Any]: + """Normalise a planner response and prove every hard requirement is owned.""" + if not isinstance(document, dict): + raise GenerationPlanError("Generation plan must be an object") + version = str(document.get("schema_version") or GENERATION_PLAN_SCHEMA_VERSION) + if version != GENERATION_PLAN_SCHEMA_VERSION: + raise GenerationPlanError(f"Unsupported generation plan schema: {version}") + requirements_raw = document.get("requirements") + if not isinstance(requirements_raw, list) or not requirements_raw: + raise GenerationPlanError("Generation plan requires a non-empty requirements array") + requirements = [_normalise_requirement(item, index) for index, item in enumerate(requirements_raw)] + requirement_ids = [item["id"] for item in requirements] + if len(requirement_ids) != len(set(requirement_ids)): + raise GenerationPlanError("Generation plan has duplicate requirement ids") + + raw_nodes = document.get("nodes") + if not isinstance(raw_nodes, list) or not raw_nodes: + raise GenerationPlanError("Generation plan requires a non-empty nodes array") + preserve_stored_ids = _stored_plan_ids(document) + feature_nodes: list[dict[str, Any]] = [] + node_metadata: dict[str, dict[str, Any]] = {} + sketch_owner: dict[str, str] = {} + for index, raw in enumerate(raw_nodes): + if not isinstance(raw, dict): + raise GenerationPlanError(f"nodes[{index}] must be an object") + node_id = _text(raw.get("id"), f"nodes[{index}].id") + atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id") + if preserve_stored_ids: + feature_ids = _string_list(raw.get("cdsl_feature_ids"), f"nodes[{index}].cdsl_feature_ids", required=True) + sketch_ids = _string_list(raw.get("cdsl_sketch_ids"), f"nodes[{index}].cdsl_sketch_ids") + else: + # New plans own semantic node IDs only. CDSL object IDs are a + # deterministic backend implementation detail, not model output. + feature_ids, sketch_ids = _backend_node_outputs(node_id, atomic_id) + for sketch_id in sketch_ids: + previous = sketch_owner.get(sketch_id) + if previous: + raise GenerationPlanError(f"CDSL sketch belongs to multiple plan nodes: {sketch_id} ({previous}, {node_id})") + sketch_owner[sketch_id] = node_id + coverage = _string_list(raw.get("requirement_ids"), f"nodes[{index}].requirement_ids") + unknown = sorted(set(coverage) - set(requirement_ids)) + if unknown: + raise GenerationPlanError(f"Node {node_id} references unknown requirements: {', '.join(unknown)}") + rules = raw.get("verification_rules") or [] + if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules): + raise GenerationPlanError(f"nodes[{index}].verification_rules must be an array of objects") + targets = raw.get("review_targets") or [] + if not isinstance(targets, list) or not all(isinstance(item, dict) for item in targets): + raise GenerationPlanError(f"nodes[{index}].review_targets must be an array of objects") + feature_nodes.append({ + "id": node_id, + "intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False), + "atomic_id": atomic_id, + "depends_on": _string_list(raw.get("depends_on"), f"nodes[{index}].depends_on"), + "requires_topology": raw.get("requires_topology") is True, + "topology_query": deepcopy(raw.get("topology_query")) if raw.get("topology_query") is not None else None, + "cdsl_feature_ids": feature_ids, + }) + node_metadata[node_id] = { + "cdsl_sketch_ids": sketch_ids, + "requires_sketch": bool(sketch_ids), + "requirement_ids": coverage, + "verification_rules": deepcopy(rules), + "review_targets": deepcopy(targets), + "attempts": {"authoring": 0, "repair": 0, "replan": 0}, + } + try: + feature_plan = validate_feature_plan({ + "schema_version": "cad.feature-plan.v1", + "plan_id": document.get("plan_id"), + "task_id": task_id or document.get("task_id"), + "nodes": feature_nodes, + }, supported_atomic_ids=supported_atomic_ids) + except FeaturePlanError as error: + raise GenerationPlanError(str(error)) from error + + covered = { + requirement_id + for metadata in node_metadata.values() + for requirement_id in metadata["requirement_ids"] + } + uncovered = [item["id"] for item in requirements if item["priority"] == "hard" and item["id"] not in covered] + if uncovered: + raise GenerationPlanError("Hard requirements are not covered: " + ", ".join(uncovered)) + nodes = [] + for node in feature_plan["nodes"]: + nodes.append({**node, **node_metadata[node["id"]]}) + return { + "schema_version": GENERATION_PLAN_SCHEMA_VERSION, + "id_strategy": BACKEND_ID_STRATEGY, + "plan_id": feature_plan["plan_id"], + "task_id": task_id or feature_plan["task_id"], + "requirements": requirements, + "assumptions": _string_list(document.get("assumptions"), "assumptions"), + "nodes": nodes, + "feature_owner": feature_plan["feature_owner"], + "sketch_owner": sketch_owner, + } + + +def descendant_closure(plan: dict[str, Any], root_node_id: str) -> set[str]: + """Return one node and every node whose model depends on it.""" + nodes = plan.get("nodes") if isinstance(plan, dict) else None + if not isinstance(nodes, list): + raise GenerationPlanError("Generation plan has no nodes") + children: dict[str, set[str]] = defaultdict(set) + known = {str(node.get("id")) for node in nodes if isinstance(node, dict)} + if root_node_id not in known: + raise GenerationPlanError(f"Unknown generation-plan node: {root_node_id}") + for node in nodes: + if not isinstance(node, dict): + continue + for dependency in node.get("depends_on") or (): + children[str(dependency)].add(str(node.get("id"))) + result: set[str] = set() + queue: deque[str] = deque([root_node_id]) + while queue: + node_id = queue.popleft() + if node_id in result: + continue + result.add(node_id) + queue.extend(sorted(children[node_id] - result)) + return result + + +def mark_nodes_stale(plan: dict[str, Any], root_node_id: str, *, reason: str) -> dict[str, Any]: + """Invalidate a node/subtree after an upstream geometry change or rollback.""" + updated = deepcopy(plan) + stale = descendant_closure(updated, root_node_id) + for node in updated.get("nodes") or (): + if isinstance(node, dict) and str(node.get("id")) in stale: + node["status"] = "planned" + node["stale"] = True + node["stale_reason"] = reason + node.pop("topology_snapshot_id", None) + return updated diff --git a/backend/app/services/image_observation.py b/backend/app/services/image_observation.py new file mode 100644 index 00000000..f6aad48b --- /dev/null +++ b/backend/app/services/image_observation.py @@ -0,0 +1,264 @@ +"""Structured multi-view image observations used by the CAD agent. + +The observation contract intentionally keeps uncertain image evidence separate +from executable CDSL. It can therefore retain free-form/polyline candidates +without pretending that the local CAD runtime supports them directly. +""" + +from __future__ import annotations + +import json +from copy import deepcopy +from typing import Any + + +OBSERVATION_SCHEMA_VERSION = "cad.image-observation.v2" +TEXT_LIMIT = 300 +LIMITS = { + "views": 12, + "surfaces": 24, + "profiles": 32, + "segments": 256, + "holes": 64, + "bends": 16, + "measurements": 128, + "uncertainties": 64, +} + + +def _text(value: Any, name: str, limit: int = TEXT_LIMIT, *, required: bool = False) -> str: + result = str(value or "").strip() + if required and not result: + raise ValueError(f"{name} must be a non-empty string") + return result[:limit] + + +def _text_list(value: Any, name: str, limit: int) -> list[str]: + if value is None: + return [] + if not isinstance(value, list): + raise ValueError(f"{name} must be an array") + return [_text(item, name, required=True) for item in value[:limit]] + + +def _number(value: Any, name: str) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must be numeric") from error + + +def _point(value: Any, name: str, dimensions: int = 2) -> list[float] | None: + if value is None: + return None + if not isinstance(value, list) or len(value) < dimensions: + raise ValueError(f"{name} must contain at least {dimensions} numbers") + output: list[float] = [] + for index, component in enumerate(value[:dimensions]): + parsed = _number(component, f"{name}[{index}]") + if parsed is None: + raise ValueError(f"{name}[{index}] must be numeric") + output.append(parsed) + return output + + +def _confidence(value: Any) -> float | None: + parsed = _number(value, "confidence") + if parsed is None: + return None + return max(0.0, min(1.0, parsed)) + + +def _source_images(value: Any) -> list[str]: + return _text_list(value, "source_images", LIMITS["views"]) + + +def _normalize_segment(segment: Any) -> dict[str, Any]: + if not isinstance(segment, dict): + raise ValueError("profile segments must contain objects") + kind = _text(segment.get("type"), "segment.type", 32, required=True) + if kind not in {"line", "arc", "circle", "polyline", "unknown_curve"}: + raise ValueError(f"unsupported image segment type: {kind}") + result: dict[str, Any] = {"type": kind} + if kind in {"line", "arc"}: + result["start"] = _point(segment.get("start"), "segment.start") + result["end"] = _point(segment.get("end"), "segment.end") + if result["start"] is None or result["end"] is None: + raise ValueError(f"{kind} segments require start and end") + if kind == "arc": + result["center"] = _point(segment.get("center"), "segment.center") + result["radius_mm"] = _number(segment.get("radius_mm"), "segment.radius_mm") + result["clockwise"] = bool(segment.get("clockwise")) + if kind == "circle": + result["center"] = _point(segment.get("center"), "segment.center") + result["radius_mm"] = _number(segment.get("radius_mm"), "segment.radius_mm") + if result["center"] is None or result["radius_mm"] is None: + raise ValueError("circle segments require center and radius_mm") + if kind in {"polyline", "unknown_curve"}: + points = segment.get("points") + if not isinstance(points, list) or not points: + raise ValueError(f"{kind} segments require points") + result["points"] = [_point(point, "segment.points") for point in points[:LIMITS["segments"]]] + if any(point is None for point in result["points"]): + raise ValueError(f"{kind} segment contains an invalid point") + result["image_uv"] = { + "start": _point(segment.get("image_start"), "segment.image_start"), + "end": _point(segment.get("image_end"), "segment.image_end"), + } + result["confidence"] = _confidence(segment.get("confidence")) + result["notes"] = _text(segment.get("notes"), "segment.notes") + return result + + +def _normalize_profile(profile: Any) -> dict[str, Any]: + if not isinstance(profile, dict): + raise ValueError("profiles must contain objects") + segments = profile.get("segments") or [] + if not isinstance(segments, list): + raise ValueError("profile.segments must be an array") + return { + "id": _text(profile.get("id"), "profile.id", 80, required=True), + "role": _text(profile.get("role"), "profile.role", 40), + "plane_hint": _text(profile.get("plane_hint"), "profile.plane_hint"), + "closed": bool(profile.get("closed")), + "coordinate_space": _text(profile.get("coordinate_space"), "profile.coordinate_space") or "image_uv", + "segments": [_normalize_segment(item) for item in segments[:LIMITS["segments"]]], + "source_images": _source_images(profile.get("source_images")), + "confidence": _confidence(profile.get("confidence")), + "uncertain": _text_list(profile.get("uncertain"), "profile.uncertain", 16), + "notes": _text(profile.get("notes"), "profile.notes"), + } + + +def _normalize_measurement(measurement: Any) -> dict[str, Any]: + if not isinstance(measurement, dict): + raise ValueError("measurements must contain objects") + source = _text(measurement.get("source"), "measurement.source") or "image" + if source not in {"user", "image", "cv", "assumption"}: + raise ValueError("measurement.source must be user, image, cv, or assumption") + value = _number(measurement.get("value_mm"), "measurement.value_mm") + minimum = _number(measurement.get("min_mm"), "measurement.min_mm") + maximum = _number(measurement.get("max_mm"), "measurement.max_mm") + return { + "name": _text(measurement.get("name"), "measurement.name", 120, required=True), + "value_mm": value, + "min_mm": minimum, + "max_mm": maximum, + "source": source, + "confidence": _confidence(measurement.get("confidence")), + "evidence": _text(measurement.get("evidence"), "measurement.evidence"), + "source_images": _source_images(measurement.get("source_images")), + } + + +def normalize_image_observation(arguments: dict[str, Any], *, attachment_ids: list[str]) -> dict[str, Any]: + """Normalize the survey tool output while preserving uncertain geometry.""" + if not isinstance(arguments, dict): + raise ValueError("image observation arguments must be an object") + raw_ids = [str(item) for item in arguments.get("attachment_ids") or attachment_ids if str(item)] + normalized_ids = list(dict.fromkeys(raw_ids or attachment_ids)) + if not normalized_ids: + raise ValueError("image observation requires at least one attachment") + views = arguments.get("views") or [] + profiles = arguments.get("profiles") or [] + measurements = arguments.get("measurements") or [] + result: dict[str, Any] = { + "schema_version": OBSERVATION_SCHEMA_VERSION, + "attachment_ids": normalized_ids, + "part_type": _text(arguments.get("part_type"), "part_type", required=True), + "visible_features": _text_list(arguments.get("visible_features"), "visible_features", 32), + "uncertain_features": _text_list(arguments.get("uncertain_features"), "uncertain_features", LIMITS["uncertainties"]), + "views": [], + "scale_references": deepcopy(arguments.get("scale_references") or [])[:LIMITS["views"]], + "overall_geometry": arguments.get("overall_geometry") if isinstance(arguments.get("overall_geometry"), dict) else {}, + "surfaces": deepcopy(arguments.get("surfaces") or [])[:LIMITS["surfaces"]], + "profiles": [_normalize_profile(item) for item in profiles[:LIMITS["profiles"]]], + "holes": deepcopy(arguments.get("holes") or [])[:LIMITS["holes"]], + "bends": deepcopy(arguments.get("bends") or [])[:LIMITS["bends"]], + "measurements": [_normalize_measurement(item) for item in measurements[:LIMITS["measurements"]]], + "uncertainties": _text_list(arguments.get("uncertainties"), "uncertainties", LIMITS["uncertainties"]), + "assumptions": _text_list(arguments.get("assumptions"), "assumptions", LIMITS["uncertainties"]), + "cv_hints": deepcopy(arguments.get("cv_hints") or [])[:LIMITS["profiles"]], + } + for item in views[:LIMITS["views"]]: + if not isinstance(item, dict): + raise ValueError("views must contain objects") + result["views"].append({ + "attachment_id": _text(item.get("attachment_id"), "view.attachment_id", 120, required=True), + "view_role": _text(item.get("view_role"), "view.view_role"), + "orientation": _text(item.get("orientation"), "view.orientation"), + "visible_regions": _text_list(item.get("visible_regions"), "view.visible_regions", 24), + "occluded_regions": _text_list(item.get("occluded_regions"), "view.occluded_regions", 24), + "quality": _text(item.get("quality"), "view.quality"), + "scale_reference_id": _text(item.get("scale_reference_id"), "view.scale_reference_id", 120), + "confidence": _confidence(item.get("confidence")), + }) + return result + + +def normalize_sketch_candidates(arguments: dict[str, Any], *, attachment_ids: list[str]) -> dict[str, Any]: + """Normalize the second-stage sketch extraction result.""" + if not isinstance(arguments, dict): + raise ValueError("sketch candidate arguments must be an object") + profiles = arguments.get("profiles") or arguments.get("sketches") or [] + base = normalize_image_observation({ + "attachment_ids": attachment_ids, + "part_type": arguments.get("part_type") or "image reference", + "visible_features": arguments.get("visible_features") or ["profile candidates"], + "uncertain_features": arguments.get("uncertain_features") or [], + "profiles": profiles, + "measurements": arguments.get("measurements") or [], + "uncertainties": arguments.get("uncertainties") or [], + "assumptions": arguments.get("assumptions") or [], + "cv_hints": arguments.get("cv_hints") or [], + }, attachment_ids=attachment_ids) + return { + "profiles": base["profiles"], + "measurements": base["measurements"], + "uncertainties": base["uncertainties"], + "assumptions": base["assumptions"], + "cv_hints": base["cv_hints"], + } + + +def merge_image_observations(survey: dict[str, Any], sketches: dict[str, Any]) -> dict[str, Any]: + """Merge the two stages and keep user-sourced measurements authoritative.""" + result = deepcopy(survey) + result["schema_version"] = OBSERVATION_SCHEMA_VERSION + result["profiles"] = sketches.get("profiles") or result.get("profiles") or [] + existing = {str(item.get("name")): item for item in result.get("measurements") or () if isinstance(item, dict)} + for item in sketches.get("measurements") or (): + if not isinstance(item, dict): + continue + key = str(item.get("name") or "") + prior = existing.get(key) + if prior and prior.get("source") == "user" and item.get("source") != "user": + continue + existing[key] = item + result["measurements"] = list(existing.values()) + result["uncertainties"] = list(dict.fromkeys([ + *(result.get("uncertainties") or []), + *(sketches.get("uncertainties") or []), + ]))[:LIMITS["uncertainties"]] + result["assumptions"] = list(dict.fromkeys([ + *(result.get("assumptions") or []), + *(sketches.get("assumptions") or []), + ]))[:LIMITS["uncertainties"]] + result["cv_hints"] = sketches.get("cv_hints") or result.get("cv_hints") or [] + return result + + +def render_image_observation_context(observation: dict[str, Any] | None) -> str: + if not isinstance(observation, dict): + return "" + compact = { + key: observation.get(key) + for key in ( + "schema_version", "attachment_ids", "part_type", "views", "overall_geometry", + "surfaces", "profiles", "holes", "bends", "measurements", "uncertainties", "assumptions", + ) + if observation.get(key) not in (None, [], {}) + } + return json.dumps(compact, ensure_ascii=False, separators=(",", ":")) diff --git a/backend/app/services/image_processing.py b/backend/app/services/image_processing.py new file mode 100644 index 00000000..b70e67d2 --- /dev/null +++ b/backend/app/services/image_processing.py @@ -0,0 +1,46 @@ +"""Optional image metadata and computer-vision hints for image observations.""" + +from __future__ import annotations + +import io +from typing import Any + + +def image_metadata(data: bytes) -> dict[str, Any]: + """Read safe image metadata without changing the original upload.""" + try: + from PIL import Image, ImageOps + with Image.open(io.BytesIO(data)) as image: + normalized = ImageOps.exif_transpose(image) + return { + "width": int(normalized.width), + "height": int(normalized.height), + "format": str(image.format or "").lower(), + "orientation": "landscape" if normalized.width >= normalized.height else "portrait", + "has_alpha": "A" in normalized.getbands(), + } + except Exception as error: + return {"error": f"image metadata unavailable: {type(error).__name__}"} + + +def cv_hints(data: bytes) -> dict[str, Any]: + """Return conservative CV hints; OpenCV is intentionally optional.""" + try: + import cv2 # type: ignore + import numpy as np # type: ignore + except Exception: + return {"available": False, "hints": []} + try: + image = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_GRAYSCALE) + if image is None: + return {"available": True, "hints": [], "error": "image decode failed"} + edges = cv2.Canny(image, 50, 150) + lines = cv2.HoughLinesP(edges, 1, 3.141592653589793 / 180, threshold=50, minLineLength=30, maxLineGap=8) + line_hints = [] + for line in (lines[:32] if lines is not None else []): + x1, y1, x2, y2 = [int(value) for value in line[0]] + line_hints.append({"type": "line", "start_px": [x1, y1], "end_px": [x2, y2]}) + return {"available": True, "hints": line_hints, "edge_pixels": int((edges > 0).sum())} + except Exception as error: + return {"available": True, "hints": [], "error": f"cv failed: {type(error).__name__}"} + diff --git a/backend/app/services/incremental_generation.py b/backend/app/services/incremental_generation.py new file mode 100644 index 00000000..020609b4 --- /dev/null +++ b/backend/app/services/incremental_generation.py @@ -0,0 +1,509 @@ +"""Persistent, full-rebuild orchestration for node-by-node CDSL authoring.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Awaitable, Callable +from copy import deepcopy +import json +from pathlib import Path +import secrets +from typing import Any + +from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, materialize_fragment, validate_fragment +from app.services.engine_service import QualityVerificationError, build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl +from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan +from app.services.quality import validate_verification +from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status +from app.services.storage import WorkspaceStore, write_json +from app.services.visual_review import VisualReviewError, review_checkpoint +from app.settings import ProviderConfig, ProviderModel, Settings + + +Completion = Callable[[list[dict[str, Any]], list[dict[str, Any]], ProviderConfig, ProviderModel, str | None], Awaitable[dict[str, Any]]] + + +PLAN_TOOL = { + "type": "function", + "function": { + "name": "plan_generation_task", + "description": "Create the complete immutable requirement list and executable feature DAG before authoring any CDSL.", + "parameters": { + "type": "object", + "properties": { + "schema_version": {"type": "string", "const": "cad.generation-plan.v2"}, + "plan_id": {"type": "string", "minLength": 1}, + "requirements": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "source": {"enum": ["explicit", "assumption"]}, + "priority": {"enum": ["hard", "soft"]}, + "description": {"type": "string", "minLength": 1}, + "value": {}, + "unit": {"type": "string"}, + "tolerance": {}, + }, + "required": ["id", "source", "priority", "description"], + "additionalProperties": False, + }, + }, + "assumptions": {"type": "array", "items": {"type": "string"}}, + "nodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "intent": {"type": "string"}, + "atomic_id": {"type": "string", "minLength": 1}, + "depends_on": {"type": "array", "items": {"type": "string"}}, + "requires_topology": {"type": "boolean"}, + "topology_query": {"type": "object"}, + "requirement_ids": {"type": "array", "items": {"type": "string"}}, + "verification_rules": {"type": "array", "items": {"type": "object"}}, + "review_targets": {"type": "array", "items": {"type": "object"}}, + }, + "required": ["id", "intent", "atomic_id", "depends_on", "requirement_ids", "verification_rules", "review_targets"], + "additionalProperties": False, + }, + }, + }, + "required": ["schema_version", "plan_id", "requirements", "assumptions", "nodes"], + "additionalProperties": False, + }, + }, +} + +FRAGMENT_TOOL = { + "type": "function", + "function": { + "name": "generate_cdsl_fragment", + "description": "Generate only the active plan node's additive CDSL fragment. Never replace or mutate existing CDSL.", + "parameters": { + "type": "object", + "properties": { + "schema_version": {"type": "string", "const": "cad.cdsl-fragment.v1"}, + "node_id": {"type": "string", "minLength": 1}, + "base_revision_id": {"type": "string"}, + "base_cdsl_sha256": {"type": "string", "minLength": 64, "maxLength": 64}, + "required_snapshot_id": {"type": "string"}, + "add_sketches": {"type": "array", "items": {"type": "object"}}, + "add_features": {"type": "array", "items": {"type": "object"}}, + "verification_rules": {"type": "array", "items": {"type": "object"}}, + "assumptions": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["schema_version", "node_id", "base_revision_id", "base_cdsl_sha256", "add_sketches", "add_features", "verification_rules", "assumptions"], + "additionalProperties": False, + }, + }, +} + + +class IncrementalGenerationError(RuntimeError): + pass + + +def _tool_response(response: dict[str, Any], expected_name: str) -> dict[str, Any]: + try: + call = response["choices"][0]["message"]["tool_calls"][0] + if call["function"]["name"] != expected_name: + raise KeyError("wrong tool") + result = json.loads(call["function"]["arguments"]) + except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error: + raise IncrementalGenerationError(f"Author did not return a valid {expected_name} call") from error + if not isinstance(result, dict): + raise IncrementalGenerationError(f"{expected_name} arguments must be an object") + return result + + +def _node_by_id(spec: dict[str, Any], node_id: str) -> dict[str, Any]: + node = next((item for item in spec.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None) + if node is None: + raise IncrementalGenerationError(f"Generation plan has no node {node_id}") + return node + + +def _fragment_node_context(node: dict[str, Any]) -> dict[str, Any]: + """Expose semantic node intent, not backend-owned CDSL implementation IDs.""" + fields = ( + "id", "intent", "atomic_id", "depends_on", "requires_topology", + "requires_sketch", "topology_query", "requirement_ids", + "verification_rules", "review_targets", + ) + return {field: deepcopy(node[field]) for field in fields if field in node} + + +def _ready_node(spec: dict[str, Any], completed: set[str], has_topology: bool) -> dict[str, Any] | None: + for node in spec.get("nodes") or (): + if not isinstance(node, dict) or node.get("status") == "completed": + continue + if all(str(item) in completed for item in node.get("depends_on") or ()) and (not node.get("requires_topology") or has_topology): + return node + return None + + +def _error_code(error: Exception) -> str: + message = str(error) + for code in ( + "SELECTOR_AMBIGUOUS", "SELECTOR_NOT_FOUND", "SELECTOR_GEOMETRY_MISMATCH", + "TOPOLOGY_SNAPSHOT_STALE", "TOPOLOGY_REQUIRED", "VERIFICATION_FAILED", "SELECTOR_OWNER_REQUIRED", + ): + if code in message: + return code + return type(error).__name__.upper() + + +def _mark_affected_nodes_stale(plan: dict[str, Any], node_ids: list[str], *, reason: str) -> tuple[dict[str, Any], set[str]]: + """Invalidate the union of every affected node's downstream closure.""" + updated = deepcopy(plan) + stale: set[str] = set() + for node_id in dict.fromkeys(node_ids): + stale.update(descendant_closure(updated, node_id)) + updated = mark_nodes_stale(updated, node_id, reason=reason) + return updated, stale + + +def _source_image_paths(store: WorkspaceStore, conversation: dict[str, Any]) -> list[Path]: + conversation_id = str(conversation.get("conversation_id") or "") + paths: list[Path] = [] + for attachment in conversation.get("attachments") or (): + if not isinstance(attachment, dict) or attachment.get("kind") != "image" or not conversation_id: + continue + try: + paths.append(store.conversation_attachment_path(conversation_id, str(attachment.get("path") or ""))) + except ValueError: + continue + return paths + + +class IncrementalGenerationRunner: + """The agent-facing controller. It is deliberately full-rebuild and restart-safe.""" + + def __init__(self, settings: Settings, store: WorkspaceStore, complete: Completion) -> None: + self.settings = settings + self.store = store + self._complete = complete + + async def _call(self, messages: list[dict[str, Any]], provider: ProviderConfig, model: ProviderModel, tool: dict[str, Any], name: str) -> dict[str, Any]: + response = await self._complete(messages, [tool], provider, model, name) + return _tool_response(response, name) + + async def run( + self, + *, + task_id: str, + request: str, + conversation: dict[str, Any], + provider: ProviderConfig, + model: ProviderModel, + author_messages: list[dict[str, Any]], + part_skills: dict[str, Any] | None = None, + references: list[str] | None = None, + already_started: bool = False, + ) -> AsyncIterator[tuple[str, dict[str, Any]]]: + plan_diagnostic_path = "" + try: + task = self.store.ensure_task(task_id or None, request) + task_id = str(task["task_id"]) + # Configuration is a start gate: visual review is required, never silently skipped. + self.settings.resolve_review_model() + renderer_ready, renderer_error = renderer_status() + if not renderer_ready: + raise IncrementalGenerationError(renderer_error) + task = self.store.read_task(task_id) if already_started else self.store.start_generation(task_id, request=request) + if not isinstance(task, dict): + raise IncrementalGenerationError("Generation task is unavailable") + yield "generation_plan", {"taskId": task_id, "status": "running"} + engine = load_engine(self.settings) + persisted_spec = self.store.read_generation_spec(task_id) + if persisted_spec is not None: + spec = validate_generation_plan( + persisted_spec, + supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), + task_id=task_id, + ) + yield "generation_plan", { + "taskId": task_id, "status": "success", "planId": spec["plan_id"], "resumed": True, + "requirements": spec["requirements"], + "nodes": [{"id": node["id"], "intent": node["intent"], "status": node.get("status", "planned")} for node in spec["nodes"]], + } + else: + planning_messages = [ + { + "role": "system", + "content": ( + "Create one complete cad.generation-plan.v2 before creating geometry. " + "Turn every user constraint into a requirement with source explicit or assumption; " + "use source assumption for missing dimensions and never ask the user questions. " + "Every hard requirement must belong to at least one node. Use only runtime-supported atomic ids. " + "Do not output expected_feature_ids or expected_sketch_ids: the backend derives all CDSL object ids from node.id." + ), + }, + *author_messages, + ] + raw_spec = await self._call(planning_messages, provider, model, PLAN_TOOL, "plan_generation_task") + try: + spec = validate_generation_plan( + raw_spec, + supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), + task_id=task_id, + ) + except GenerationPlanError as error: + plan_diagnostic_path = self.store.write_generation_failure(task_id, { + "schema_version": "cad.generation-plan-diagnostic.v1", + "stage": "generation_plan_validation", + "message": str(error), + "raw_plan": raw_spec, + }) + raise + self.store.write_generation_spec(task_id, spec) + yield "generation_plan", { + "taskId": task_id, "status": "success", "planId": spec["plan_id"], + "requirements": spec["requirements"], "nodes": [{"id": node["id"], "intent": node["intent"], "status": "planned"} for node in spec["nodes"]], + } + + completed: set[str] = { + str(node["id"]) for node in spec["nodes"] if node.get("status") == "completed" + } + last_built: dict[str, Any] | None = None + task = self.store.read_task(task_id) or task + active_revision_id = str(task.get("active_revision") or "") + # A process can stop between build and review. That checkpoint is + # not a legal base revision, so recover its parent before resuming. + active_record = next( + (item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == active_revision_id), + None, + ) + if isinstance(active_record, dict) and active_record.get("visibility") == "checkpoint": + current_node = str(active_record.get("node_id") or "") + if current_node and current_node not in completed: + recovered = str(active_record.get("parent_revision_id") or "") + self.store.rollback_to_revision(task_id, recovered, branch_id=f"branch_{secrets.token_hex(4)}") + active_revision_id = recovered + task = self.store.read_task(task_id) or task + base_path = self.store.current_cdsl_path(task_id) + base_cdsl = json.loads(base_path.read_text(encoding="utf-8")) if base_path and base_path.is_file() else None + while True: + topology_path = self.store.current_topology_path(task_id) + topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None + node = _ready_node(spec, completed, bool(topology and topology.get("records"))) + if node is None: + if len(completed) == len(spec["nodes"]): + self.store.finish_generation(task_id, lifecycle="completed") + if last_built is not None: + yield "cad_result", self._result_payload(last_built, lifecycle="completed", checkpoint=False) + yield "task_terminal", {"taskId": task_id, "lifecycle": "completed", "revisionId": str((self.store.read_task(task_id) or {}).get("published_revision") or "")} + return + waiting = [item["id"] for item in spec["nodes"] if item.get("id") not in completed] + raise IncrementalGenerationError("No executable plan node is ready: " + ", ".join(waiting)) + + node_id = str(node["id"]) + self.store.set_active_node(task_id, node_id) + yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "authoring"} + attempts = node.setdefault("attempts", {"authoring": 0, "repair": 0, "replan": 0}) + feedback = "" + while True: + attempt_kind = "authoring" if int(attempts.get("authoring") or 0) < self.settings.node_authoring_attempts else "repair" + if attempt_kind == "repair" and int(attempts.get("repair") or 0) >= self.settings.node_repair_attempts: + if int(attempts.get("replan") or 0) >= self.settings.node_replan_attempts: + raise IncrementalGenerationError(f"Node {node_id} exhausted its authoring, repair, and replan budgets: {feedback}") + attempts["replan"] = int(attempts.get("replan") or 0) + 1 + spec = await self._replan(spec, node_id, feedback, provider, model, author_messages, engine) + self.store.write_generation_spec(task_id, spec) + node = _node_by_id(spec, node_id) + node["attempts"] = {"authoring": 0, "repair": 0, "replan": attempts["replan"]} + attempts = node["attempts"] + yield "rollback", {"taskId": task_id, "nodeId": node_id, "reason": "node_replan"} + continue + attempts[attempt_kind] = int(attempts.get(attempt_kind) or 0) + 1 + required_snapshot = str((topology or {}).get("snapshot_id") or "") if node.get("requires_topology") else "" + node_requirement_ids = set(node.get("requirement_ids") or ()) + author_context = { + "active_node": _fragment_node_context(node), + "requirements": [ + requirement for requirement in spec["requirements"] + if requirement.get("id") in node_requirement_ids + ], + "base_revision_id": active_revision_id, + "base_cdsl_sha256": cdsl_sha256(base_cdsl), + "required_snapshot_id": required_snapshot, + "base_cdsl": base_cdsl, + "topology": topology if required_snapshot else None, + "previous_failure": feedback, + } + fragment_messages = [ + { + "role": "system", + "content": ( + "Author exactly one additive CDSL fragment for the active node. Do not change existing CDSL or generate unsupported topology selectors. " + "Do not set output id, sketch_id, or depends_on: the backend assigns them. " + "Use owner_node_id instead of owner_feature_id and source_node_ids instead of source_feature_ids when referring to plan nodes." + ), + }, + *author_messages, + {"role": "user", "content": json.dumps(author_context, ensure_ascii=False)}, + ] + try: + raw_fragment = await self._call(fragment_messages, provider, model, FRAGMENT_TOOL, "generate_cdsl_fragment") + fragment = validate_fragment( + raw_fragment, plan=spec, node_id=node_id, base_revision_id=active_revision_id, + base_cdsl=base_cdsl, required_snapshot_id=required_snapshot, + ) + cdsl = materialize_fragment(base_cdsl, fragment) + cdsl, repairs = normalize_cdsl_for_engine(cdsl) + validate_cdsl(cdsl, engine) + rules = [*node.get("verification_rules", []), *fragment.get("verification_rules", [])] + verification = {"rules": rules} if rules else None + validate_verification(verification, cdsl) + fragment_base_revision = str(fragment["base_revision_id"]) + built = await asyncio.to_thread( + build_revision, + settings=self.settings, store=self.store, task_id=task_id, request=request, cdsl=cdsl, + reference_ids=references or [], summary=node.get("intent") or node_id, + parent_revision_id=active_revision_id or None, + operation={"type": "cdsl_fragment", "node_id": node_id}, + part_skills=part_skills, generation_assumptions=[*spec.get("assumptions", []), *fragment.get("assumptions", [])], + verification=verification, node_id=node_id, fragment=fragment, + branch_id=str((self.store.read_task(task_id) or {}).get("active_branch_id") or "main"), visibility="checkpoint", + ) + candidate_revision_id = str(built["revision_id"]) + try: + render_dir = self.store.revision_dir(task_id, candidate_revision_id) / "review" + manifest = await asyncio.to_thread( + render_checkpoint, + self.settings, + step_path=self.store.artifact_path(task_id, str(built["step_path"])), + output_dir=render_dir, + review_targets=node.get("review_targets"), + ) + final_checkpoint = len(completed) + 1 == len(spec["nodes"]) + review_requirements = spec["requirements"] if final_checkpoint else [ + requirement for requirement in spec["requirements"] + if requirement.get("id") in set(node.get("requirement_ids") or ()) + ] + review = await review_checkpoint( + self.settings, manifest=manifest, requirements=review_requirements, node_id=node_id, + deterministic_report={"quality_status": built.get("quality_status"), "verification": built.get("verification_summary", {})}, + source_images=_source_image_paths(self.store, conversation) if not completed or final_checkpoint else [], + final_checkpoint=final_checkpoint, + ) + except (ReviewRenderError, VisualReviewError) as error: + self.store.rollback_to_revision(task_id, fragment_base_revision, branch_id=f"branch_{secrets.token_hex(4)}") + active_revision_id = fragment_base_revision + rollback_path = self.store.current_cdsl_path(task_id) + base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None + raise error + active_revision_id = candidate_revision_id + base_cdsl = cdsl + manifest_relative = (render_dir / "render-manifest.json").relative_to(self.store.task_dir(task_id)).as_posix() + review_relative = (render_dir / "visual-review.json").relative_to(self.store.task_dir(task_id)).as_posix() + write_json(render_dir / "visual-review.json", review) + self.store.update_revision_metadata(task_id, active_revision_id, {"render_manifest_path": manifest_relative, "visual_review_path": review_relative}) + yield "render_review", {"taskId": task_id, "revisionId": active_revision_id, "nodeId": node_id, "review": review} + if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85: + affected_nodes = [ + str(item) for item in review.get("affected_node_ids") or () + if any(str(candidate.get("id") or "") == str(item) for candidate in spec.get("nodes") or ()) + ] or [node_id] + rollback_base = self.store.rollback_anchor_for_nodes( + task_id, + affected_nodes, + fallback_revision_id=fragment_base_revision, + ) + branch = f"branch_{secrets.token_hex(4)}" + self.store.rollback_to_revision(task_id, rollback_base, branch_id=branch) + spec, stale_nodes = _mark_affected_nodes_stale( + spec, affected_nodes, reason="high_confidence_visual_review", + ) + self.store.write_generation_spec(task_id, spec) + completed.difference_update(stale_nodes) + active_revision_id = rollback_base + rollback_path = self.store.current_cdsl_path(task_id) + base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None + feedback = "High-confidence visual review requires correction: " + "; ".join(review.get("evidence") or []) + yield "rollback", { + "taskId": task_id, "nodeId": node_id, "revisionId": active_revision_id, + "reason": "visual_review", "affectedNodeIds": affected_nodes, + } + continue + completed.add(node_id) + node["status"] = "completed" + node.pop("stale", None) + self.store.write_generation_spec(task_id, spec) + last_built = built + payload = self._result_payload(built, lifecycle="running", checkpoint=True) + yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "success", "revisionId": active_revision_id} + yield "cad_result", payload + break + except (CdslFragmentError, GenerationPlanError, QualityVerificationError, ReviewRenderError, VisualReviewError, ValueError, RuntimeError) as error: + feedback = str(error) + failure = { + "schema_version": "cad.generation-failure.v1", + "node_id": node_id, + "stage": attempt_kind, + "error_code": _error_code(error), + "message": feedback, + "requirement_ids": list(node.get("requirement_ids") or ()), + "selector": {"required_snapshot_id": required_snapshot}, + "geometry_delta": {}, + "recommended_rollback_revision": fragment_base_revision if "fragment_base_revision" in locals() else active_revision_id, + } + failure_path = self.store.write_generation_failure(task_id, failure) + yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "error", "attempt": attempt_kind, "message": feedback} + # A failed build never becomes the active base; retries are safe and deterministic. + continue + except Exception as error: + failure = { + "schema_version": "cad.generation-failure.v1", + "message": str(error), + "active_node_id": str((self.store.read_task(task_id) or {}).get("active_node_id") or ""), + } + if plan_diagnostic_path: + failure["plan_diagnostic_path"] = plan_diagnostic_path + self.store.finish_generation(task_id, lifecycle="failed", failure=failure) + yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "message": str(error)} + + async def _replan( + self, + spec: dict[str, Any], + node_id: str, + feedback: str, + provider: ProviderConfig, + model: ProviderModel, + author_messages: list[dict[str, Any]], + engine: Any, + ) -> dict[str, Any]: + raw = await self._call([ + {"role": "system", "content": "Replan only the failed node and its downstream nodes. Preserve completed node definitions and all requirements."}, + *author_messages, + {"role": "user", "content": json.dumps({"existing_plan": spec, "failed_node_id": node_id, "failure": feedback}, ensure_ascii=False)}, + ], provider, model, PLAN_TOOL, "plan_generation_task") + next_spec = validate_generation_plan(raw, supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), task_id=str(spec.get("task_id") or "")) + stale = {item["id"] for item in mark_nodes_stale(spec, node_id, reason="replan").get("nodes") or [] if item.get("stale")} + prior = {item["id"]: item for item in spec.get("nodes") or []} + for node in next_spec["nodes"]: + if node["id"] not in stale and node["id"] in prior: + old = prior[node["id"]] + for key in ("atomic_id", "depends_on", "cdsl_feature_ids"): + if node.get(key) != old.get(key): + raise IncrementalGenerationError(f"Replan changed non-stale node {node['id']}") + return next_spec + + @staticmethod + def _result_payload(built: dict[str, Any], *, lifecycle: str, checkpoint: bool) -> dict[str, Any]: + return { + "taskId": built["task_id"], "revisionId": built["revision_id"], + "cdslPath": built["cdsl_path"], "stepPath": built["step_path"], "glbPath": built["glb_path"], + "reportPath": built["report_path"], "parametersPath": built.get("parameters_path"), + "selectorPath": built.get("selector_path"), "edgesPath": built.get("edges_path"), "topologyPath": built.get("topology_path"), + "summary": built.get("summary") or "CDSL checkpoint", "referenceIds": built.get("reference_ids") or [], + "engine": built.get("engine") or "cdsl_only", "qualityStatus": built.get("quality_status") or "", + "qualityPath": built.get("quality_path"), "assumptions": built.get("generation_assumptions") or [], + "snapshotPaths": built.get("snapshot_paths") or [], "snapshotStatus": built.get("snapshot_status") or "unavailable", + "lifecycle": lifecycle, "checkpoint": checkpoint, + } diff --git a/backend/app/services/quality.py b/backend/app/services/quality.py index 72c6f90b..e352e6f3 100644 --- a/backend/app/services/quality.py +++ b/backend/app/services/quality.py @@ -8,9 +8,10 @@ from typing import Any QUALITY_RULE_TYPES = frozenset({ "bbox", "solid_count", "feature_count", "hole_count", "hole_diameter", - "hole_center", "overall_length", "overall_diameter", "through_condition", + "hole_center", "overall_length", "overall_width", "overall_height", + "overall_diameter", "through_condition", }) -_FEATURE_RULE_TYPES = {"hole_count", "hole_diameter", "hole_center", "through_condition"} +FEATURE_RULE_TYPES = frozenset({"hole_count", "hole_diameter", "hole_center", "through_condition"}) _SEVERITIES = {"blocking", "warning", "informational"} @@ -22,7 +23,12 @@ def _bbox_bounds(engine_result: dict[str, Any], feature_id: str | None = None) - if not isinstance(record, dict): continue owners = record.get("owner_feature_ids") or [] - if record.get("feature_id") == feature_id or feature_id in owners: + # Runtime records may carry the current operation in feature_id + # while owner_feature_ids identify the actual geometry owner. Use + # ownership when present so inherited faces do not pollute a + # feature-local measurement. + matches = feature_id in owners if owners else record.get("feature_id") == feature_id + if matches: geometry = record.get("geometry") if isinstance(geometry, dict): candidates.append(geometry.get("bbox_mm")) @@ -88,12 +94,64 @@ def _circles(profile: dict[str, Any]) -> list[dict[str, Any]]: ] +def _circle_feature_bbox(feature: dict[str, Any] | None, sketch: dict[str, Any] | None) -> dict[str, Any] | None: + """Derive a stable world-space bbox for a circular extrude feature.""" + if not isinstance(feature, dict) or not isinstance(sketch, dict): + return None + if str(feature.get("atomic_id") or "") != "extrude_add_blind": + return None + profile = sketch.get("profile") + workplane = sketch.get("workplane") + params = feature.get("params") + if not isinstance(profile, dict) or profile.get("type") != "circle" or not isinstance(workplane, dict): + return None + center_local = profile.get("center") + radius = profile.get("radius_mm") + origin = workplane.get("origin_mm") + x_dir = workplane.get("x_dir") + y_dir = workplane.get("y_dir") + normal = workplane.get("normal") + distance = (params or {}).get("distance_mm") if isinstance(params, dict) else None + if not isinstance(center_local, list) or len(center_local) < 2 or not _finite_number(radius): + return None + if not all(isinstance(value, list) and len(value) >= 3 for value in (origin, x_dir, y_dir, normal)) or not _finite_number(distance): + return None + center = [ + float(origin[index]) + float(center_local[0]) * float(x_dir[index]) + float(center_local[1]) * float(y_dir[index]) + for index in range(3) + ] + points = [] + for axis_x in (-1.0, 1.0): + for axis_y in (-1.0, 1.0): + cross_section = [ + center[index] + + axis_x * float(radius) * float(x_dir[index]) + + axis_y * float(radius) * float(y_dir[index]) + for index in range(3) + ] + points.append(cross_section) + points.append([ + cross_section[index] + float(distance) * float(normal[index]) + for index in range(3) + ]) + minimum = [min(point[index] for point in points) for index in range(3)] + maximum = [max(point[index] for point in points) for index in range(3)] + return { + "min": minimum, + "max": maximum, + "dimensions": [maximum[index] - minimum[index] for index in range(3)], + } + + def _finite_number(value: Any) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value)) def _validate_expected(kind: str, expected: Any, index: int) -> None: - scalar_types = {"solid_count", "feature_count", "hole_count", "hole_diameter", "overall_length", "overall_diameter"} + scalar_types = { + "solid_count", "feature_count", "hole_count", "hole_diameter", + "overall_length", "overall_width", "overall_height", "overall_diameter", + } if kind in scalar_types and not _finite_number(expected): raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}") if kind == "bbox": @@ -157,7 +215,7 @@ def validate_verification(verification: Any, cdsl: dict[str, Any]) -> list[dict[ if not math.isfinite(tolerance) or tolerance < 0: raise ValueError(f"verification.rules[{index}].tolerance must be finite and non-negative") feature = str(raw.get("feature") or "").strip() - if kind in _FEATURE_RULE_TYPES and not feature: + if kind in FEATURE_RULE_TYPES and not feature: raise ValueError(f"verification.rules[{index}].feature is required for {kind}") if feature and feature not in feature_ids: raise ValueError(f"verification.rules[{index}].feature must reference a CDSL feature ID") @@ -175,6 +233,9 @@ def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, circles = _circles(profile) if isinstance(profile, dict) else [] dimensions = _dimensions(engine_result) if kind == "bbox": + derived = _circle_feature_bbox(feature, sketch) + if target and derived is not None: + return derived, f"cdsl.features.{target}.sketch + params" value = _bbox_value(engine_result, target) source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm" return value, source @@ -194,7 +255,13 @@ def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, return (centers[0] if len(centers) == 1 else centers), f"cdsl.features.{target}.sketch" if kind == "overall_length": return (max(dimensions) if dimensions else None), "runtime.bbox_mm" + if kind == "overall_width": + return (dimensions[1] if dimensions and len(dimensions) > 1 else None), "runtime.bbox_mm[y]" + if kind == "overall_height": + return (dimensions[2] if dimensions and len(dimensions) > 2 else None), "runtime.bbox_mm[z]" if kind == "overall_diameter": + if target and circles: + return max(circle["radius_mm"] for circle in circles) * 2.0, f"cdsl.features.{target}.sketch" return (min(dimensions) if dimensions else None), "runtime.bbox_mm" if kind == "through_condition": end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {} diff --git a/backend/app/services/review_renderer.py b/backend/app/services/review_renderer.py new file mode 100644 index 00000000..1c5d79f2 --- /dev/null +++ b/backend/app/services/review_renderer.py @@ -0,0 +1,332 @@ +"""Deterministic, CPU-only CAD technical renders for visual review. + +OpenCascade computes exact visible/hidden edges from the revision STEP file. +Pillow rasterizes the resulting technical drawings. Neither stage needs a web +browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence +consistent on macOS, Linux, and Windows workers. +""" + +from __future__ import annotations + +import importlib +import json +import math +from pathlib import Path +from typing import Any + +from app.settings import Settings + + +CANONICAL_VIEWS = ("top", "bottom", "front", "back", "left", "right", "isometric") +RENDER_SIZE = 2048 +REVIEW_SIZE = 1024 +FRAME_PADDING = 0.14 +BACKGROUND_RGB = (246, 248, 251) +VISIBLE_EDGE_RGB = (34, 54, 69) +HIDDEN_EDGE_RGB = (142, 157, 170) + + +class ReviewRenderError(RuntimeError): + """The fixed-view renderer was unavailable or produced incomplete evidence.""" + + +def renderer_status() -> tuple[bool, str]: + """Verify that the pure-Python/OCC renderer dependencies are importable.""" + try: + _render_modules() + except ReviewRenderError as error: + return False, str(error) + return True, "" + + +def _render_modules() -> tuple[Any, Any, Any]: + try: + pillow_image = importlib.import_module("PIL.Image") + pillow_draw = importlib.import_module("PIL.ImageDraw") + import_step = importlib.import_module("build123d").import_step + except (ImportError, AttributeError) as error: + raise ReviewRenderError( + "Python technical renderer is unavailable; install backend requirements (build123d and Pillow)" + ) from error + return pillow_image, pillow_draw, import_step + + +def _number_list(value: Any, *, size: int) -> list[float] | None: + if not isinstance(value, list) or len(value) < size: + return None + try: + values = [float(item) for item in value[:size]] + except (TypeError, ValueError): + return None + return values if all(math.isfinite(item) for item in values) else None + + +def _bounds_center(bounds: list[float]) -> list[float]: + return [ + (bounds[0] + bounds[1]) / 2, + (bounds[2] + bounds[3]) / 2, + (bounds[4] + bounds[5]) / 2, + ] + + +def _shape_bounds(shape: Any) -> list[float]: + box = shape.bounding_box() + bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)] + if not all(math.isfinite(value) for value in bounds): + raise ReviewRenderError("STEP review source has invalid bounds") + return bounds + + +def _target_frame(target: dict[str, Any] | None, model_bounds: list[float]) -> tuple[list[float], float]: + model_center = _bounds_center(model_bounds) + model_extent = max(model_bounds[1] - model_bounds[0], model_bounds[3] - model_bounds[2], model_bounds[5] - model_bounds[4], 1.0) + if not isinstance(target, dict): + return model_center, 0.0 + bbox = _number_list(target.get("bbox_mm"), size=6) + if bbox and bbox[3] > bbox[0] and bbox[4] > bbox[1] and bbox[5] > bbox[2]: + center = [(bbox[0] + bbox[3]) / 2, (bbox[1] + bbox[4]) / 2, (bbox[2] + bbox[5]) / 2] + extent = max(bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2], 1.0) + return center, min(model_extent, extent * 1.6) + center = _number_list(target.get("center_mm"), size=3) + try: + radius = float(target.get("radius_mm")) + except (TypeError, ValueError): + radius = 0.0 + if center and math.isfinite(radius) and radius > 0: + return center, min(model_extent, max(radius * 2, 1.0) * 1.6) + return model_center, 0.0 + + +def _camera_for(view_id: str, center: list[float]) -> dict[str, Any]: + directions = { + "top": ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]), + "bottom": ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]), + "front": ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]), + "back": ([0.0, 1.0, 0.0], [0.0, 0.0, 1.0]), + "left": ([-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + "right": ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + "isometric": ([1.0, -1.0, 0.8], [0.0, 0.0, 1.0]), + } + direction, view_up = directions.get(view_id, directions["isometric"]) + length = math.sqrt(sum(item * item for item in direction)) or 1.0 + normal = [item / length for item in direction] + # Orthographic HLR ignores the distance, but a large deterministic value + # makes the intended camera convention explicit in the manifest. + position = [center[index] + normal[index] * 100000.0 for index in range(3)] + return {"projection": "orthographic", "position": position, "focal_point": center, "view_up": view_up} + + +def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]: + count = max(2, min(1024, int(math.ceil(float(edge.length) / max(spacing, 0.002))) + 1)) + try: + points = edge.positions([index / (count - 1) for index in range(count)]) + except Exception: + points = [edge.position_at(0), edge.position_at(1)] + return [(float(point.X), float(point.Y)) for point in points] + + +def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]: + points = [point for edge in edges for point in _edge_points(edge, 0.5)] + if not points: + raise ReviewRenderError("Hidden-line projection produced no drawable edges") + xs, ys = zip(*points) + return min(xs), max(xs), min(ys), max(ys) + + +def _frame_bounds(edges: list[Any], target_extent: float) -> tuple[float, float, float, float]: + min_x, max_x, min_y, max_y = _projected_bounds(edges) + if target_extent > 0: + # HLR maps the look-at target to the projection origin, making this + # an exact, deterministic local crop without a GPU clipping plane. + half = target_extent / (2 * (1 - 2 * FRAME_PADDING)) + return -half, half, -half, half + center_x, center_y = (min_x + max_x) / 2, (min_y + max_y) / 2 + extent = max(max_x - min_x, max_y - min_y, 1.0) + half = extent / (2 * (1 - 2 * FRAME_PADDING)) + return center_x - half, center_x + half, center_y - half, center_y + half + + +def _pixel(point: tuple[float, float], frame: tuple[float, float, float, float], size: int) -> tuple[int, int]: + min_x, max_x, min_y, max_y = frame + x = round((point[0] - min_x) * (size - 1) / (max_x - min_x)) + y = round((max_y - point[1]) * (size - 1) / (max_y - min_y)) + return int(x), int(y) + + +def _draw_dashed(draw: Any, points: list[tuple[int, int]], *, fill: tuple[int, int, int], width: int) -> None: + dash, gap = 16, 10 + for start, end in zip(points, points[1:]): + dx, dy = end[0] - start[0], end[1] - start[1] + length = math.hypot(dx, dy) + if length <= 0: + continue + distance = 0.0 + while distance < length: + segment_end = min(length, distance + dash) + first = (round(start[0] + dx * distance / length), round(start[1] + dy * distance / length)) + last = (round(start[0] + dx * segment_end / length), round(start[1] + dy * segment_end / length)) + draw.line((first, last), fill=fill, width=width) + distance += dash + gap + + +def _rasterize( + *, + visible: list[Any], + hidden: list[Any], + frame: tuple[float, float, float, float], + output_dir: Path, + view_id: str, + intentional_crop: bool, +) -> dict[str, Any]: + pillow_image, pillow_draw, _ = _render_modules() + image = pillow_image.new("RGB", (RENDER_SIZE, RENDER_SIZE), BACKGROUND_RGB) + mask = pillow_image.new("L", (RENDER_SIZE, RENDER_SIZE), 0) + draw = pillow_draw.Draw(image) + mask_draw = pillow_draw.Draw(mask) + spacing = max((frame[1] - frame[0]) / 1800, 0.01) + for edge in hidden: + points = [_pixel(point, frame, RENDER_SIZE) for point in _edge_points(edge, spacing)] + _draw_dashed(draw, points, fill=HIDDEN_EDGE_RGB, width=3) + _draw_dashed(mask_draw, points, fill=128, width=4) + for edge in visible: + points = [_pixel(point, frame, RENDER_SIZE) for point in _edge_points(edge, spacing)] + if len(points) >= 2: + draw.line(points, fill=VISIBLE_EDGE_RGB, width=4, joint="curve") + mask_draw.line(points, fill=255, width=5, joint="curve") + high_path = output_dir / "internal" / f"{view_id}-2x.png" + high_path.parent.mkdir(parents=True, exist_ok=True) + image.save(high_path, optimize=True) + output_path = output_dir / f"{view_id}.png" + image.resize((REVIEW_SIZE, REVIEW_SIZE), resample=pillow_image.Resampling.LANCZOS).save(output_path, optimize=True) + diagnostic_dir = output_dir / "internal" / view_id + diagnostic_dir.mkdir(parents=True, exist_ok=True) + mask_path = diagnostic_dir / "line-mask.png" + mask.save(mask_path) + edge_path = diagnostic_dir / "edge.png" + mask.save(edge_path) + box = mask.getbbox() + coverage = (RENDER_SIZE * RENDER_SIZE - mask.histogram()[0]) / (RENDER_SIZE * RENDER_SIZE) + pixel_bbox = list(box) if box else [] + touches_border = bool(box and (box[0] <= 1 or box[1] <= 1 or box[2] >= RENDER_SIZE - 1 or box[3] >= RENDER_SIZE - 1)) + valid = bool(box and coverage >= 0.00005 and coverage <= 0.20 and (intentional_crop or not touches_border)) + return { + "path": str(output_path), + "high_resolution_path": str(high_path), + "diagnostics": { + "line_mask_path": str(mask_path), + "edge_path": str(edge_path), + "coverage": coverage, + "pixel_bbox": pixel_bbox, + "touches_border": touches_border, + "intentional_crop": intentional_crop, + "visible_edge_count": len(visible), + "hidden_edge_count": len(hidden), + "valid": valid, + }, + } + + +def _render_view( + *, + shape: Any, + view_id: str, + projection_id: str, + target: dict[str, Any] | None, + model_bounds: list[float], + output_dir: Path, +) -> dict[str, Any]: + center, target_extent = _target_frame(target, model_bounds) + camera = _camera_for(projection_id, center) + try: + visible, hidden = shape.project_to_viewport( + camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"] + ) + except Exception as error: + raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error + visible_edges, hidden_edges = list(visible), list(hidden) + frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent) + rendered = _rasterize( + visible=visible_edges, + hidden=hidden_edges, + frame=frame, + output_dir=output_dir, + view_id=view_id, + intentional_crop=target_extent > 0, + ) + if not rendered["diagnostics"]["valid"]: + raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}") + return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered} + + +def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str: + """Create compact whole-model evidence for routine reviewer calls.""" + pillow_image, pillow_draw, _ = _render_modules() + canonical = [item for item in views if item["id"] in CANONICAL_VIEWS] + if not canonical: + return "" + tile = 400 + sheet = pillow_image.new("RGB", (tile * 3, tile * 3), BACKGROUND_RGB) + draw = pillow_draw.Draw(sheet) + for index, item in enumerate(canonical): + image = pillow_image.open(str(item["path"])).convert("RGB").resize((tile, tile), resample=pillow_image.Resampling.LANCZOS) + x, y = (index % 3) * tile, (index // 3) * tile + sheet.paste(image, (x, y)) + draw.rectangle((x + 8, y + 8, x + 96, y + 33), fill=(255, 255, 255)) + draw.text((x + 14, y + 13), str(item["id"]), fill=VISIBLE_EDGE_RGB) + path = output_dir / "contact-sheet.jpg" + sheet.save(path, quality=88, optimize=True, progressive=True) + return str(path) + + +def render_checkpoint( + settings: Settings, + *, + step_path: Path, + output_dir: Path, + review_targets: list[dict[str, Any]] | None = None, + include_canonical: bool = True, +) -> dict[str, Any]: + """Render STEP geometry into stable canonical and bounded node-detail views.""" + del settings + ready, detail = renderer_status() + if not ready: + raise ReviewRenderError(detail) + if not step_path.is_file(): + raise ReviewRenderError(f"STEP review source is missing: {step_path.name}") + _, _, import_step = _render_modules() + try: + shape = import_step(str(step_path)) + except Exception as error: + raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error + bounds = _shape_bounds(shape) + output_dir.mkdir(parents=True, exist_ok=True) + jobs: list[tuple[str, dict[str, Any] | None]] = [] + if include_canonical: + jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS) + jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3])) + views = [ + _render_view( + shape=shape, + view_id=view_id, + projection_id="isometric" if view_id.startswith("detail-") else view_id, + target=target, + model_bounds=bounds, + output_dir=output_dir, + ) + for view_id, target in jobs + ] + canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")} + if include_canonical and canonical != set(CANONICAL_VIEWS): + raise ReviewRenderError("Python review renderer did not produce every canonical view") + contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else "" + manifest = { + "schema_version": "cad.render-manifest.v2", + "renderer": "python-occ-hlr-pillow", + "source": {"type": "step", "path": str(step_path), "bounds_mm": bounds}, + "high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"}, + "review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"}, + "contact_sheet_path": contact_sheet_path, + "views": views, + } + (output_dir / "render-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + return manifest diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index fa1459f7..6eea8e9d 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -181,21 +181,62 @@ class WorkspaceStore: path = self.task_path(tid) current = read_json(path) if current: - return current + return self._migrate_task(current, path) task_dir = self.task_dir(tid) (task_dir / "revisions").mkdir(parents=True, exist_ok=True) record = { - "schema_version": "1.2", + "schema_version": "1.3", "task_id": tid, "request": request, "created_at": now_iso(), "updated_at": now_iso(), "current_revision": "", + "active_revision": "", + "published_revision": "", + "lifecycle": "completed", + "run_id": "", + "generation_spec_path": "", + "run_context_path": "", + "active_node_id": "", + "run_failure_path": "", "revisions": [], } write_json(path, record) return record + def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]: + """Add run-state fields lazily without rewriting successful history.""" + changed = False + current = str(task.get("current_revision") or "") + defaults = { + "schema_version": "1.3", + "active_revision": current, + "published_revision": current, + "lifecycle": "completed", + "run_id": "", + "generation_spec_path": "", + "run_context_path": "", + "active_node_id": "", + "run_failure_path": "", + } + for key, value in defaults.items(): + if key not in task: + task[key] = value + changed = True + for revision in task.get("revisions") or (): + if not isinstance(revision, dict): + continue + if "visibility" not in revision: + revision["visibility"] = "final" if str(revision.get("revision_id") or "") == str(task["published_revision"] or "") else "checkpoint" + changed = True + if "branch_id" not in revision: + revision["branch_id"] = "main" + changed = True + if changed: + task["updated_at"] = now_iso() + write_json(path, task) + return task + def next_revision(self, task_id: str) -> tuple[str, Path]: task = self.ensure_task(task_id, "") revision_id = f"rev_{len(task['revisions']) + 1:03d}" @@ -208,16 +249,225 @@ class WorkspaceStore: task["revisions"].append(revision) if revision.get("status") == "success": task["current_revision"] = revision["revision_id"] + task["active_revision"] = revision["revision_id"] + if revision.get("visibility") == "final": + task["published_revision"] = revision["revision_id"] task["updated_at"] = now_iso() write_json(self.task_path(task_id), task) return task def read_task(self, task_id: str) -> dict[str, Any] | None: - return read_json(self.task_path(task_id)) + task = read_json(self.task_path(task_id)) + return self._migrate_task(task, self.task_path(task_id)) if isinstance(task, dict) else None + + def start_generation(self, task_id: str, *, request: str, run_id: str | None = None) -> dict[str, Any]: + task = self.ensure_task(task_id, request) + if str(task.get("lifecycle") or "") == "running": + raise ValueError("CAD task is already running") + task.update({ + "lifecycle": "running", + "run_id": run_id or new_id("run"), + "active_node_id": "", + "run_failure_path": "", + "request": request or task.get("request") or "", + "active_revision": str(task.get("current_revision") or ""), + "updated_at": now_iso(), + }) + write_json(self.task_path(task_id), task) + return task + + def finish_generation(self, task_id: str, *, lifecycle: str, failure: dict[str, Any] | None = None) -> dict[str, Any]: + if lifecycle not in {"completed", "failed"}: + raise ValueError("Generation lifecycle must be completed or failed") + task = self.ensure_task(task_id, "") + failure_path = "" + if failure: + failure_path = "run-failures/" + f"failure_{secrets.token_hex(8)}.json" + write_json(self.task_dir(task_id) / failure_path, failure) + if lifecycle == "completed": + task["published_revision"] = str(task.get("active_revision") or task.get("current_revision") or "") + for revision in task.get("revisions") or (): + if isinstance(revision, dict) and revision.get("revision_id") == task["published_revision"]: + revision["visibility"] = "final" + task.update({ + "lifecycle": lifecycle, + "active_node_id": "", + "run_failure_path": failure_path, + "updated_at": now_iso(), + }) + write_json(self.task_path(task_id), task) + return task + + def set_active_revision(self, task_id: str, revision_id: str, *, branch_id: str | None = None) -> dict[str, Any]: + task = self.ensure_task(task_id, "") + if not revision_id: + task["active_revision"] = "" + task["current_revision"] = "" + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None) + if not isinstance(revision, dict) or revision.get("status") != "success": + raise ValueError("Active revision must be a successful revision") + task["active_revision"] = revision_id + task["current_revision"] = revision_id + if branch_id: + task["active_branch_id"] = branch_id + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + + def set_active_node(self, task_id: str, node_id: str) -> dict[str, Any]: + task = self.ensure_task(task_id, "") + task["active_node_id"] = node_id + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + + def update_revision_metadata(self, task_id: str, revision_id: str, values: dict[str, Any]) -> dict[str, Any]: + task = self.ensure_task(task_id, "") + revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None) + if not isinstance(revision, dict): + raise ValueError("Revision does not exist") + revision.update(values) + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + + def rollback_to_revision(self, task_id: str, revision_id: str, *, branch_id: str) -> dict[str, Any]: + """Move the generation head without deleting immutable checkpoint artifacts.""" + task = self.set_active_revision(task_id, revision_id, branch_id=branch_id) + children: dict[str, set[str]] = {} + for revision in task.get("revisions") or (): + if not isinstance(revision, dict): + continue + parent = str(revision.get("parent_revision_id") or "") + child = str(revision.get("revision_id") or "") + if parent and child: + children.setdefault(parent, set()).add(child) + superseded: set[str] = set() + pending = list(children.get(revision_id, set())) if revision_id else [ + str(item.get("revision_id") or "") + for item in task.get("revisions") or () + if isinstance(item, dict) and not str(item.get("parent_revision_id") or "") + ] + while pending: + child = pending.pop() + if not child or child in superseded: + continue + superseded.add(child) + pending.extend(children.get(child, set())) + for revision in task.get("revisions") or (): + if isinstance(revision, dict) and str(revision.get("revision_id") or "") in superseded and revision.get("visibility") == "checkpoint": + revision["visibility"] = "superseded" + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + + def rollback_anchor_for_nodes( + self, + task_id: str, + node_ids: list[str], + *, + fallback_revision_id: str = "", + ) -> str: + """Return the revision before every affected node's latest checkpoint. + + Returning each affected revision's parent (rather than the revision + itself) ensures the faulty node is regenerated. A common ancestor + keeps unrelated upstream work intact while permitting a single rollback + over any number of affected nodes. + """ + task = self.read_task(task_id) or {} + revisions = [item for item in task.get("revisions") or () if isinstance(item, dict)] + by_id = {str(item.get("revision_id") or ""): item for item in revisions} + parents: list[str] = [] + for node_id in dict.fromkeys(str(item) for item in node_ids if str(item)): + matching = [item for item in revisions if item.get("status") == "success" and str(item.get("node_id") or "") == node_id] + if matching: + parents.append(str(matching[-1].get("parent_revision_id") or "")) + if not parents: + return fallback_revision_id + + def lineage(revision_id: str) -> list[str]: + chain = [revision_id] + seen = {revision_id} + current = revision_id + while current: + parent = str((by_id.get(current) or {}).get("parent_revision_id") or "") + if parent in seen: + break + chain.append(parent) + seen.add(parent) + current = parent + return chain + + common = set(lineage(parents[0])) + for parent in parents[1:]: + common.intersection_update(lineage(parent)) + if not common: + return fallback_revision_id + return next((revision for revision in lineage(parents[0]) if revision in common), fallback_revision_id) + + def write_generation_failure(self, task_id: str, payload: dict[str, Any]) -> str: + """Persist an attempt-level diagnostic without changing lifecycle.""" + relative = Path("generation-failures") / f"failure_{secrets.token_hex(8)}.json" + write_json(self.task_dir(task_id) / relative, payload) + return relative.as_posix() + + def generation_spec_path(self, task_id: str) -> Path: + return self.task_dir(task_id) / "generation-spec.json" + + def write_generation_spec(self, task_id: str, spec: dict[str, Any]) -> Path: + task = self.ensure_task(task_id, "") + path = self.generation_spec_path(task_id) + write_json(path, spec) + task["generation_spec_path"] = path.relative_to(self.task_dir(task_id)).as_posix() + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return path + + def generation_run_context_path(self, task_id: str) -> Path: + return self.task_dir(task_id) / "generation-run-context.json" + + def write_generation_run_context(self, task_id: str, context: dict[str, Any]) -> Path: + """Persist the frozen authoring inputs needed to resume after restart.""" + task = self.ensure_task(task_id, "") + path = self.generation_run_context_path(task_id) + write_json(path, context) + task["run_context_path"] = path.relative_to(self.task_dir(task_id)).as_posix() + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return path + + def read_generation_run_context(self, task_id: str) -> dict[str, Any] | None: + task = self.read_task(task_id) or {} + relative = str(task.get("run_context_path") or "") + context = read_json(self.artifact_path(task_id, relative)) if relative else None + return context if isinstance(context, dict) else None + + def running_tasks(self) -> list[dict[str, Any]]: + """Enumerate durable tasks that need a process-local worker.""" + tasks: list[dict[str, Any]] = [] + for candidate in self.settings.task_root.glob("cad_*"): + if not candidate.is_dir() or not TASK_ID.fullmatch(candidate.name): + continue + task = self.read_task(candidate.name) + if isinstance(task, dict) and task.get("lifecycle") == "running": + tasks.append(task) + return tasks + + def read_generation_spec(self, task_id: str) -> dict[str, Any] | None: + task = self.read_task(task_id) or {} + relative = str(task.get("generation_spec_path") or "") + return read_json(self.artifact_path(task_id, relative)) if relative else None + + def revision_dir(self, task_id: str, revision_id: str) -> Path: + return self.task_dir(task_id) / "revisions" / revision_id def current_cdsl_path(self, task_id: str) -> Path | None: task = self.read_task(task_id) - revision_id = str((task or {}).get("current_revision") or "") + revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "") if not revision_id: return None candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json" @@ -235,6 +485,52 @@ class WorkspaceStore: return revision_id, path return None + def feature_plan_path(self, task_id: str) -> Path: + return self.task_dir(task_id) / "feature-plan.json" + + def read_feature_plan(self, task_id: str) -> dict[str, Any] | None: + safe_task = safe_task_id(task_id) + plan = read_json(self.feature_plan_path(safe_task)) + if not isinstance(plan, dict): + return None + plan_task = str(plan.get("task_id") or "") + if plan_task and plan_task != safe_task: + return None + return plan + + def write_feature_plan(self, task_id: str, plan: dict[str, Any]) -> Path: + safe_task = safe_task_id(task_id) + if not isinstance(plan, dict): + raise ValueError("Feature plan must be an object") + plan_task = str(plan.get("task_id") or "") + if plan_task and plan_task != safe_task: + raise ValueError("Feature plan task_id does not match its task") + plan["task_id"] = safe_task + path = self.feature_plan_path(safe_task) + write_json(path, plan) + return path + + def revision_topology_path(self, task_id: str, revision_id: str) -> Path | None: + task = self.read_task(task_id) + revision = next( + (item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id), + None, + ) + if not isinstance(revision, dict): + return None + relative = str(revision.get("topology_path") or "") + if not relative: + return None + candidate = self.artifact_path(task_id, relative) + return candidate if candidate.is_file() else None + + def current_topology_path(self, task_id: str) -> Path | None: + task = self.read_task(task_id) + revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "") + if not revision_id: + return None + return self.revision_topology_path(task_id, revision_id) + def revision_cdsl_path(self, task_id: str, revision_id: str) -> Path | None: task = self.read_task(task_id) revision = next( diff --git a/backend/app/services/visual_review.py b/backend/app/services/visual_review.py new file mode 100644 index 00000000..c2fecf27 --- /dev/null +++ b/backend/app/services/visual_review.py @@ -0,0 +1,141 @@ +"""Independent, structured visual review of generated checkpoint renders.""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path +from typing import Any + +import httpx + +from app.settings import ProviderConfig, ProviderModel, Settings + + +VISUAL_REVIEW_TOOL = { + "type": "function", + "function": { + "name": "review_rendered_checkpoint", + "description": "Review fixed CAD render views against frozen requirements. Never author or modify CDSL.", + "parameters": { + "type": "object", + "properties": { + "verdict": {"enum": ["pass", "warning", "repair"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "affected_node_ids": {"type": "array", "items": {"type": "string"}, "maxItems": 12}, + "requirement_ids": {"type": "array", "items": {"type": "string"}, "maxItems": 32}, + "evidence": {"type": "array", "items": {"type": "string"}, "maxItems": 12}, + }, + "required": ["verdict", "confidence", "affected_node_ids", "requirement_ids", "evidence"], + "additionalProperties": False, + }, + }, +} + + +class VisualReviewError(RuntimeError): + pass + + +def _image_part(path: Path) -> dict[str, Any]: + encoded = base64.b64encode(path.read_bytes()).decode("ascii") + media = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png" + return {"type": "image_url", "image_url": {"url": f"data:{media};base64,{encoded}"}} + + +def _selected_review_views(manifest: dict[str, Any], *, final_checkpoint: bool) -> list[dict[str, Any]]: + """Keep each reviewer call bounded while canonical evidence stays archived. + + A contact sheet establishes global context. Up to two planned detail views + provide node-specific evidence. The final checkpoint adds full canonical + views because it is the only point where those extra image tokens pay off. + """ + views = [item for item in manifest.get("views") or () if isinstance(item, dict)] + by_id = {str(item.get("id") or ""): item for item in views} + selected: list[dict[str, Any]] = [] + contact = Path(str(manifest.get("contact_sheet_path") or "")) + if contact.is_file(): + selected.append({"id": "contact-sheet", "path": str(contact), "camera": {"projection": "mixed"}}) + for view_id in ("detail-1", "detail-2"): + item = by_id.get(view_id) + if item is not None: + selected.append(item) + if final_checkpoint: + selected.extend(by_id[view_id] for view_id in ("top", "bottom", "front", "back", "left", "right", "isometric") if view_id in by_id) + elif not selected and "isometric" in by_id: + selected.append(by_id["isometric"]) + return selected or views[:1] + + +async def review_checkpoint( + settings: Settings, + *, + manifest: dict[str, Any], + requirements: list[dict[str, Any]], + node_id: str, + deterministic_report: dict[str, Any], + source_images: list[Path] | None = None, + final_checkpoint: bool = False, +) -> dict[str, Any]: + provider, model = settings.resolve_review_model() + views = _selected_review_views(manifest, final_checkpoint=final_checkpoint) + paths = [Path(str(item.get("path") or "")) for item in views] + if not paths or not all(path.is_file() for path in paths): + raise VisualReviewError("Review render manifest references missing image files") + content: list[dict[str, Any]] = [{ + "type": "text", + "text": json.dumps({ + "node_id": node_id, + "requirements": requirements, + "deterministic_report": deterministic_report, + "render_manifest": { + "renderer": manifest.get("renderer"), + "source": manifest.get("source"), + "views": [{"id": item.get("id"), "camera": item.get("camera"), "diagnostics": item.get("diagnostics")} for item in views], + }, + "instruction": "Identify visible missing geometry, wrong silhouette, orientation, or proportion. Do not infer hidden dimensions. Return repair only for an observable issue.", + }, ensure_ascii=False), + }] + content.extend(_image_part(path) for path in paths) + # Reference images are only supplementary evidence. Keep this bounded so + # an attachment-heavy request does not dominate every checkpoint review. + for path in (source_images or [])[:2]: + if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}: + content.append(_image_part(path)) + tool = json.loads(json.dumps(VISUAL_REVIEW_TOOL)) + if model.strict_tool_schema: + tool["function"]["strict"] = True + payload = { + "model": model.id, + "messages": [ + {"role": "system", "content": "You are an independent CAD visual reviewer. You may only call review_rendered_checkpoint."}, + {"role": "user", "content": content}, + ], + "tools": [tool], + "tool_choice": {"type": "function", "function": {"name": "review_rendered_checkpoint"}}, + "temperature": 0, + } + headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"} + async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client: + response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload) + if response.status_code >= 400: + raise VisualReviewError(f"Visual review request failed ({response.status_code}): {response.text[:500]}") + try: + call = response.json()["choices"][0]["message"]["tool_calls"][0] + if call["function"]["name"] != "review_rendered_checkpoint": + raise KeyError("wrong tool") + result = json.loads(call["function"]["arguments"]) + except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error: + raise VisualReviewError("Visual reviewer did not return a valid review tool call") from error + if not isinstance(result, dict) or result.get("verdict") not in {"pass", "warning", "repair"}: + raise VisualReviewError("Visual reviewer returned an invalid verdict") + try: + confidence = float(result.get("confidence")) + except (TypeError, ValueError) as error: + raise VisualReviewError("Visual reviewer returned an invalid confidence") from error + if not 0 <= confidence <= 1: + raise VisualReviewError("Visual reviewer confidence is outside [0, 1]") + for key in ("affected_node_ids", "requirement_ids", "evidence"): + if not isinstance(result.get(key), list) or not all(isinstance(item, str) for item in result[key]): + raise VisualReviewError(f"Visual reviewer returned an invalid {key}") + return {"schema_version": "cad.visual-review.v1", "node_id": node_id, "model": model.id, **result} diff --git a/backend/app/settings.py b/backend/app/settings.py index ea015be5..9f657ade 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -51,6 +51,12 @@ class Settings: default_provider_id: str providers: tuple[ProviderConfig, ...] max_repair_attempts: int = 4 + review_provider_id: str = "" + review_model_id: str = "" + node_authoring_attempts: int = 2 + node_repair_attempts: int = 2 + node_replan_attempts: int = 1 + incremental_generation: bool = False @property def llm_configured(self) -> bool: @@ -70,6 +76,22 @@ class Settings: raise ValueError("The selected model is not enabled for this provider") return provider, model + def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]: + """Return the independently configured visual reviewer, never an author fallback.""" + provider_id = self.review_provider_id + if not provider_id: + raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider") + provider = self.provider_for(provider_id) + if provider is None: + raise ValueError("The configured visual review provider is unavailable") + model_id = self.review_model_id or "" + if not model_id: + raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model") + model = provider.model(model_id) + if model is None or not model.vision: + raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model") + return provider, model + def _enabled_model_ids(value: str) -> set[str]: return {item.strip() for item in value.split(",") if item.strip()} @@ -141,4 +163,10 @@ def get_settings() -> Settings: max_repair_attempts=max(0, int(os.getenv("CDSL_MAX_REPAIR_ATTEMPTS", "4"))), default_provider_id=default_provider_id, providers=providers, + review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(), + review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(), + node_authoring_attempts=max(1, int(os.getenv("CDSL_NODE_AUTHORING_ATTEMPTS", "2"))), + node_repair_attempts=max(0, int(os.getenv("CDSL_NODE_REPAIR_ATTEMPTS", "2"))), + node_replan_attempts=max(0, int(os.getenv("CDSL_NODE_REPLAN_ATTEMPTS", "1"))), + incremental_generation=_as_bool(os.getenv("CDSL_INCREMENTAL_GENERATION", "1")), ) diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index ece7b8be..305fcdfb 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -249,7 +249,8 @@ "stable_id": {"type": "string", "minLength": 1}, "owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "geometry": {"type": "object"}, - "source": {"enum": ["solidworks", "inferred_from_step"]}, + "source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]}, + "snapshot_id": {"type": "string", "minLength": 1}, "confidence": {"type": "number", "minimum": 0, "maximum": 1} }, "required": ["kind", "stable_id", "source", "confidence"], diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index f3aa4ff8..4d5295dd 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -89,6 +89,7 @@ class ExecutionSession: results: dict[str, FeatureResult] = field(default_factory=dict) replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict) selector_resolutions: list[dict[str, Any]] = field(default_factory=list) + active_feature_id: str = "" def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None: self.body = body @@ -103,7 +104,9 @@ class ExecutionSession: def resolve(self, selector: dict[str, Any]) -> SelectorResolution: resolution = self.topology.resolve(selector, active_body_id=self.body_id) - self.selector_resolutions.append(resolution.as_dict()) + evidence = resolution.as_dict() + evidence["feature_id"] = self.active_feature_id + self.selector_resolutions.append(evidence) return resolution def result(self, node: FeaturePlanNode, *, context: PlaneSpec | AxisSpec | None = None, diagnostics: list[RuntimeDiagnostic] | None = None) -> FeatureResult: @@ -677,7 +680,12 @@ def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_overr executor = EXECUTORS.get(node.atomic_id) if executor is None: raise ValueError(f"No executor registered for {node.atomic_id!r}") - return executor(node, session, sketch_override) + previous_feature_id = session.active_feature_id + session.active_feature_id = node.feature_id + try: + return executor(node, session, sketch_override) + finally: + session.active_feature_id = previous_feature_id ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult] diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index de753079..242c1f39 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -381,6 +381,13 @@ class SelectorResolution: } if self.record is not None: output["record"] = self.record.public_dict() + output["selected"] = self.record.public_dict() + score = next( + (candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id), + None, + ) + if score is not None: + output["score"] = score if self.diagnostic is not None: output["diagnostic"] = self.diagnostic.as_dict() return output @@ -589,6 +596,66 @@ class TopologyRegistry: if owner: candidates = [record for record in candidates if owner in record.owners] geometry = normalize_selector_geometry(selector.get("geometry")) + if selector.get("snapshot_id") and not owner: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_owner_required", + message="A snapshot selector requires owner_feature_id", + detail={"minimum_score": minimum_score}, + ), + ) + stable_id = str(selector.get("stable_id") or "").strip() + if stable_id: + exact = [record for record in candidates if record.record_id == stable_id] + if len(exact) == 1: + record = exact[0] + # A stable ID is only a lookup accelerator for snapshot-aware + # selectors. It cannot revive a B-rep entity whose geometric + # signature changed after an upstream rebuild. + if selector.get("snapshot_id"): + score = self._geometry_score(geometry, record.geometry) if geometry else None + if score is None or score < minimum_score: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=({"score": round(float(score or 0), 6), **record.public_dict()},), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="The stable selector record no longer matches its geometry signature", + detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score}, + ), + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), + ) + if len(exact) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact), + diagnostic=RuntimeDiagnostic( + code="selector_ambiguous", + message="More than one runtime topology record has the requested stable_id", + detail={"stable_id": stable_id, "candidate_count": len(exact)}, + ), + ) + if selector.get("snapshot_id") and not geometry: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="A snapshot selector requires a geometry signature", + detail={"minimum_score": minimum_score}, + ), + ) scored: list[tuple[float, TopologyRecord]] = [] for candidate in candidates: # An owner-qualified context selector is deterministic when it has diff --git a/backend/requirements-vision.txt b/backend/requirements-vision.txt new file mode 100644 index 00000000..c7156dd5 --- /dev/null +++ b/backend/requirements-vision.txt @@ -0,0 +1,2 @@ +-r requirements.txt +opencv-python-headless>=4.9,<5 diff --git a/backend/requirements.txt b/backend/requirements.txt index b5249183..85fbc38f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ uvicorn[standard]>=0.30,<1 build123d python-multipart>=0.0.9,<1 jsonschema>=4.23,<5 +Pillow>=10,<12 diff --git a/backend/scripts/cleanup_legacy_attachments.py b/backend/scripts/cleanup_legacy_attachments.py new file mode 100644 index 00000000..820d7ad4 --- /dev/null +++ b/backend/scripts/cleanup_legacy_attachments.py @@ -0,0 +1,97 @@ +"""Explicitly remove attachments belonging to conversations without v2 image observations. + +The command is dry-run by default. It only mutates data when ``--apply`` is +provided, and it never removes CAD task artifacts. +""" + +from __future__ import annotations + +import argparse +import shutil +from pathlib import Path +import sys + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from app.services.storage import read_json, write_json +from app.settings import get_settings + + +def _has_v2_observation(record: dict) -> bool: + for message in record.get("messages") or []: + if not isinstance(message, dict): + continue + for part in message.get("parts") or []: + if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": + continue + data = part.get("data") + if isinstance(data, dict) and str(data.get("schemaVersion") or data.get("schema_version") or "") == "cad.image-observation.v2": + return True + return False + + +def plan_cleanup(root: Path) -> list[tuple[Path, str]]: + planned: list[tuple[Path, str]] = [] + for conversation_dir in sorted(root.glob("conv_*")): + record_path = conversation_dir / "conversation.json" + record = read_json(record_path) + if not isinstance(record, dict) or _has_v2_observation(record): + continue + uploads = conversation_dir / "uploads" + if uploads.is_dir(): + planned.append((uploads, "legacy upload directory")) + planning = conversation_dir / "planning" + if planning.is_dir(): + planned.append((planning, "legacy planning directory")) + if record.get("attachments"): + planned.append((record_path, "remove legacy attachment metadata and image-analysis parts")) + return planned + + +def apply_cleanup(root: Path) -> int: + changed = 0 + for conversation_dir in sorted(root.glob("conv_*")): + record_path = conversation_dir / "conversation.json" + record = read_json(record_path) + if not isinstance(record, dict) or _has_v2_observation(record): + continue + for relative in ("uploads", "planning"): + target = conversation_dir / relative + if target.is_dir(): + shutil.rmtree(target) + changed += 1 + record["attachments"] = [] + for message in record.get("messages") or []: + if not isinstance(message, dict): + continue + message["parts"] = [ + part for part in message.get("parts") or [] + if not (isinstance(part, dict) and part.get("type") == "data-cad-image-analysis") + ] + write_json(record_path, record) + changed += 1 + return changed + + +def main() -> int: + parser = argparse.ArgumentParser(description="Remove legacy conversation attachments") + parser.add_argument("--apply", action="store_true", help="perform deletion; default is dry-run") + parser.add_argument("--dry-run", action="store_true", help="list deletion targets without changing files") + args = parser.parse_args() + root = get_settings().conversation_root.resolve() + if root.name != "conversations": + raise SystemExit(f"Refusing unexpected conversation root: {root}") + planned = plan_cleanup(root) + for path, reason in planned: + print(f"{'DELETE' if args.apply else 'WOULD DELETE'} {path} ({reason})") + if not args.apply: + print(f"Dry-run: {len(planned)} targets. Re-run with --apply to delete.") + return 0 + print(f"Deleted {apply_cleanup(root)} conversation records/directories.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_agent_tool_arguments.py b/backend/tests/test_agent_tool_arguments.py index 4c7a3e61..82569fc2 100644 --- a/backend/tests/test_agent_tool_arguments.py +++ b/backend/tests/test_agent_tool_arguments.py @@ -9,7 +9,7 @@ from types import SimpleNamespace from unittest.mock import patch from app.models.contracts import ChatMessage, MessagePart -from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message +from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, get_repair_step_key, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message from app.services.engine_service import load_engine from app.services.quality import QUALITY_RULE_TYPES from app.services.library import CdslLibrary @@ -79,6 +79,38 @@ class ParseToolArgumentsTests(unittest.TestCase): with self.assertRaisesRegex(ToolArgumentsError, "JSON object"): parse_tool_arguments('["not", "tool arguments"]') + +class RepairStepKeyTests(unittest.TestCase): + def test_generation_and_patch_share_the_same_feature_step_budget(self) -> None: + state = { + "phase": "CDSL_REPAIR", + "feature_plan": { + "plan_id": "plan_1", + "nodes": [ + {"id": "boss", "status": "ready"}, + {"id": "hole", "status": "waiting_for_selection"}, + ], + }, + } + + self.assertEqual( + get_repair_step_key(state, "generate_cdsl_model"), + get_repair_step_key(state, "patch_cdsl_model"), + ) + + def test_completed_nodes_do_not_change_the_active_step_key(self) -> None: + state = { + "phase": "CDSL_REPAIR", + "feature_plan": { + "plan_id": "plan_1", + "nodes": [ + {"id": "base", "status": "completed"}, + {"id": "boss", "status": "ready"}, + ], + }, + } + self.assertEqual(get_repair_step_key(state, "generate_cdsl_model"), "plan:plan_1:boss") + def test_recovers_only_the_known_premature_cdsl_wrapper_close(self) -> None: payload = parse_tool_arguments( '{"cdsl":{"schema":"cad.cdsl.llm.v1"}}, "summary":"fixed envelope"}', @@ -88,6 +120,28 @@ class ParseToolArgumentsTests(unittest.TestCase): self.assertEqual(payload["summary"], "fixed envelope") self.assertEqual(payload["cdsl"], {"schema": "cad.cdsl.llm.v1"}) + def test_recovers_trailing_cdsl_metadata_after_a_complete_envelope(self) -> None: + payload = parse_tool_arguments( + '{"cdsl":{"schema":"cad.cdsl.llm.v1"},"summary":"fixed envelope",' + '"assumptions":["metric"]}, "summary":"repeated envelope",' + '"assumptions":["metric"]}', + recover_cdsl_wrapper=True, + ) + + self.assertEqual(payload, { + "cdsl": {"schema": "cad.cdsl.llm.v1"}, + "summary": "fixed envelope", + "assumptions": ["metric"], + }) + + def test_rejects_a_second_cdsl_payload_after_a_complete_envelope(self) -> None: + with self.assertRaisesRegex(ToolArgumentsError, "trailing content"): + parse_tool_arguments( + '{"cdsl":{"schema":"cad.cdsl.llm.v1"},"summary":"original",' + '"assumptions":[]}, "cdsl":{"schema":"different"}}', + recover_cdsl_wrapper=True, + ) + def test_does_not_recover_arbitrary_trailing_tool_content(self) -> None: with self.assertRaisesRegex(ToolArgumentsError, "trailing content"): parse_tool_arguments( @@ -123,6 +177,13 @@ class ParseToolArgumentsTests(unittest.TestCase): rule_type = verification["properties"]["rules"]["items"]["properties"]["type"] self.assertEqual(set(rule_type["enum"]), set(QUALITY_RULE_TYPES)) + def test_verification_schema_requires_feature_for_feature_scoped_rules(self) -> None: + generate_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "generate_cdsl_model") + rule = generate_tool["function"]["parameters"]["properties"]["verification"]["properties"]["rules"]["items"] + self.assertTrue(any("feature" in branch.get("then", {}).get("required", []) for branch in rule["allOf"])) + self.assertIn("overall_width", rule["properties"]["type"]["enum"]) + self.assertIn("overall_height", rule["properties"]["type"]["enum"]) + def test_strict_tool_schema_covers_generation_arguments(self) -> None: tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True)) strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")] diff --git a/backend/tests/test_direct_cdsl_pipeline.py b/backend/tests/test_direct_cdsl_pipeline.py index 4c37b343..2c68cf8b 100644 --- a/backend/tests/test_direct_cdsl_pipeline.py +++ b/backend/tests/test_direct_cdsl_pipeline.py @@ -13,7 +13,7 @@ from unittest.mock import patch ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) -from app.services.agent_service import AgentService # noqa: E402 +from app.services.agent_service import AgentService, _validate_snapshot_selectors # noqa: E402 from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch # noqa: E402 from app.services.engine_service import QualityVerificationError, build_revision # noqa: E402 from app.services.library import CdslLibrary # noqa: E402 @@ -129,6 +129,69 @@ class VerificationTests(unittest.TestCase): self.assertEqual(report["status"], "passed") self.assertEqual(report["results"][0]["source"], "runtime.topology_records[base_add].bbox_mm") + def test_feature_bbox_excludes_inherited_runtime_records(self) -> None: + document = fixture() + document["features"].append({ + "id": "boss", + "atomic_id": "extrude_add_blind", + "depends_on": ["base_add"], + "sketch_id": "base", + "params": {"distance_mm": 8}, + }) + rules = validate_verification({"rules": [ + {"id": "boss_range", "type": "bbox", "feature": "boss", "expected": [8, 58, 58]}, + ]}, document) + report = evaluate_quality(rules, document, { + "bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]}, + "topology_records": [ + { + "feature_id": "boss", "owner_feature_ids": ["base"], + "geometry": {"bbox_mm": [0, 0, 0, 128, 70, 90]}, + }, + { + "feature_id": "boss", "owner_feature_ids": ["boss"], + "geometry": {"bbox_mm": [60, -29, 16, 68, 29, 74]}, + }, + ], + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(report["results"][0]["actual"]["dimensions"], [8.0, 58.0, 58.0]) + + def test_feature_diameter_uses_target_circle(self) -> None: + document = fixture() + document["features"][0]["id"] = "boss" + document["features"][0]["sketch_id"] = "boss_sketch" + document["geometry"]["sketches"][0]["id"] = "boss_sketch" + document["geometry"]["sketches"][0]["profile"] = {"type": "circle", "center": [0, 0], "radius_mm": 29} + rules = validate_verification({"rules": [ + {"id": "boss_dia", "type": "overall_diameter", "feature": "boss", "expected": 58}, + ]}, document) + report = evaluate_quality(rules, document, { + "bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]}, + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(report["results"][0]["actual"], 58.0) + + def test_overall_width_and_height_are_supported(self) -> None: + rules = validate_verification({"rules": [ + {"id": "width", "type": "overall_width", "expected": 70}, + {"id": "height", "type": "overall_height", "expected": 90}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "bbox_mm": {"min": [-64, -35, 0], "max": [64, 35, 90]}, + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual([item["actual"] for item in report["results"]], [70.0, 90.0]) + + def test_feature_scoped_verification_requires_feature(self) -> None: + with self.assertRaisesRegex(ValueError, "feature is required for hole_count"): + validate_verification({"rules": [ + {"id": "holes", "type": "hole_count", "expected": 1}, + ]}, fixture()) + def test_bbox_shape_is_rejected_before_execution(self) -> None: with self.assertRaisesRegex(ValueError, "expected for bbox"): validate_verification({"rules": [ @@ -211,6 +274,147 @@ class AgentPatchFlowTests(unittest.TestCase): self.assertEqual(captured["operation"]["type"], "cdsl_patch") self.assertEqual(captured["cdsl"]["features"][0]["params"]["distance_mm"], 8) + def test_completed_feature_can_keep_selector_from_historical_snapshot(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + parent_dir = store.task_dir(task_id) / "revisions" / parent + parent_snapshot_id = f"{task_id}/{parent}" + write_json(parent_dir / "model.topology.json", { + "schema_version": "cad.topology.v1", + "task_id": task_id, + "revision_id": parent, + "snapshot_id": parent_snapshot_id, + "records": [{"record_id": "body:base:edge:0", "kind": "edge", "executable": True, "geometry": {}}], + }) + task = store.read_task(task_id) + task["revisions"][0]["topology_path"] = f"revisions/{parent}/model.topology.json" + write_json(store.task_path(task_id), task) + + child = "rev_002" + child_dir = store.task_dir(task_id) / "revisions" / child + child_dir.mkdir(parents=True) + write_json(child_dir / "model.cdsl.json", fixture()) + write_json(child_dir / "model.topology.json", { + "schema_version": "cad.topology.v1", + "task_id": task_id, + "revision_id": child, + "snapshot_id": f"{task_id}/{child}", + "records": [{"record_id": "body:base:edge:1", "kind": "edge", "executable": True, "geometry": {}}], + }) + store.update_task(task_id, { + "revision_id": child, + "status": "success", + "cdsl_path": f"revisions/{child}/model.cdsl.json", + "topology_path": f"revisions/{child}/model.topology.json", + }) + + document = fixture() + document["features"][0]["selectors"] = [{ + "kind": "edge", + "stable_id": "body:base:edge:0", + "source": "runtime_snapshot", + "snapshot_id": parent_snapshot_id, + "confidence": 1.0, + }] + _validate_snapshot_selectors(store, task_id, document) + + document["features"][0]["selectors"][0]["snapshot_id"] = f"{task_id}/rev_999" + with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"): + _validate_snapshot_selectors(store, task_id, document) + + def test_topology_inspection_unlocks_waiting_plan_nodes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, revision_id = self._seed_revision(store) + revision_dir = store.task_dir(task_id) / "revisions" / revision_id + snapshot_id = f"{task_id}/{revision_id}" + write_json(revision_dir / "model.topology.json", { + "schema_version": "cad.topology.v1", + "task_id": task_id, + "revision_id": revision_id, + "snapshot_id": snapshot_id, + "records": [{ + "record_id": "body:base_add", + "kind": "body", + "body_id": "body:base_add", + "feature_id": "base_add", + "owner_feature_ids": ["base_add"], + "geometry": {}, + "executable": True, + }], + }) + task = store.read_task(task_id) + task["revisions"][0]["topology_path"] = f"revisions/{revision_id}/model.topology.json" + write_json(store.task_path(task_id), task) + state = { + "phase": "TOPOLOGY_READY", + "feature_plan": { + "schema_version": "cad.feature-plan.v1", + "plan_id": "plan_1", + "task_id": task_id, + "nodes": [ + {"id": "base", "atomic_id": "extrude_add_blind", "depends_on": [], "cdsl_feature_ids": ["base_add"]}, + {"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "cdsl_feature_ids": ["round"]}, + ], + }, + } + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + + result, _ = asyncio.run(agent._run_tool( + "inspect_current_topology", {}, task_id, "Round the edges", [], planning_state=state, + )) + + self.assertTrue(result["ok"]) + self.assertEqual(state["feature_plan"]["topology_snapshot_id"], snapshot_id) + self.assertEqual(state["feature_plan"]["ready_nodes"], ["round"]) + + def test_successful_build_automatically_binds_topology_for_waiting_nodes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + state = { + "phase": "PLAN_READY", + "feature_plan": { + "schema_version": "cad.feature-plan.v1", + "plan_id": "plan_1", + "task_id": "", + "nodes": [ + { + "id": "base", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "cdsl_feature_ids": ["base_add"], + "status": "ready", + }, + { + "id": "round", + "atomic_id": "fillet", + "depends_on": ["base"], + "cdsl_feature_ids": ["round"], + }, + ], + }, + } + + result, _ = asyncio.run(agent._run_tool( + "generate_cdsl_model", + {"cdsl": fixture(), "summary": "Base feature", "assumptions": []}, + None, + "Create a base with a later fillet", + [], + planning_state=state, + )) + + self.assertTrue(result["ok"]) + self.assertEqual(result["required_action"], "patch_cdsl_model") + self.assertEqual(result["plan_status"]["ready_nodes"], ["round"]) + self.assertEqual(result["plan_status"]["waiting_nodes"], []) + self.assertEqual(state["feature_plan"]["topology_snapshot_id"], "{}/{}".format(result["task_id"], result["revision_id"])) + def test_bad_patch_creates_no_revision(self) -> None: with tempfile.TemporaryDirectory() as directory: config = settings(Path(directory)) @@ -230,6 +434,31 @@ class AgentPatchFlowTests(unittest.TestCase): task = store.read_task(task_id) self.assertEqual([item["revision_id"] for item in task["revisions"]], [parent]) + def test_patch_rejects_non_current_parent_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + child = "rev_002" + child_dir = store.task_dir(task_id) / "revisions" / child + child_dir.mkdir(parents=True) + write_json(child_dir / "model.cdsl.json", fixture()) + store.update_task(task_id, { + "revision_id": child, + "status": "success", + "cdsl_path": f"revisions/{child}/model.cdsl.json", + }) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"): + asyncio.run(agent._run_tool( + "patch_cdsl_model", + {"base_revision_id": parent, "patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}], "summary": "stale", "assumptions": []}, + task_id, + "stale patch", + [], + planning_state={"phase": "PLANNED"}, + )) + def test_complete_replacement_uses_current_revision_as_parent(self) -> None: with tempfile.TemporaryDirectory() as directory: config = settings(Path(directory)) diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index fe68573c..d061e342 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -193,6 +193,37 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(resolution.status, "ambiguous") self.assertEqual(resolution.diagnostic.code, "selector_ambiguous") + def test_selector_resolver_prefers_exact_runtime_stable_id(self) -> None: + registry = TopologyRegistry() + geometry = {"curve_type": "circle", "center_mm": [0, 0, 0]} + registry.register(TopologyRecord("body:b:edge:18", "edge", "boss", "body:b", geometry)) + registry.register(TopologyRecord("body:b:edge:20", "edge", "boss", "body:b", geometry)) + + resolution = registry.resolve({ + "kind": "edge", + "stable_id": "body:b:edge:18", + "owner_feature_id": "boss", + "snapshot_id": "cad_test/rev_001", + "geometry": geometry, + }, active_body_id="body:b") + + self.assertEqual(resolution.status, "resolved") + self.assertEqual(resolution.record.record_id, "body:b:edge:18") + + def test_snapshot_stable_id_rejects_geometry_that_changed(self) -> None: + registry = TopologyRegistry() + registry.register(TopologyRecord( + "body:b:edge:18", "edge", "boss", "body:b", + {"curve_type": "circle", "center_mm": [0, 0, 0]}, + )) + resolution = registry.resolve({ + "kind": "edge", "stable_id": "body:b:edge:18", "owner_feature_id": "boss", + "snapshot_id": "cad_test/rev_001", + "geometry": {"curve_type": "circle", "center_mm": [1, 0, 0]}, + }, active_body_id="body:b") + self.assertEqual(resolution.status, "not_found") + self.assertEqual(resolution.diagnostic.code, "selector_geometry_mismatch") + def test_selector_resolver_normalizes_legacy_solidworks_plane_evidence(self) -> None: registry = TopologyRegistry() registry.register(TopologyRecord( diff --git a/backend/tests/test_feature_plan.py b/backend/tests/test_feature_plan.py new file mode 100644 index 00000000..7a7eef58 --- /dev/null +++ b/backend/tests/test_feature_plan.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.services.feature_plan import FeaturePlanError, compute_node_statuses, validate_feature_plan # noqa: E402 + + +class FeaturePlanTests(unittest.TestCase): + def plan(self) -> dict: + return { + "schema_version": "cad.feature-plan.v1", + "plan_id": "plan_test", + "task_id": "cad_test", + "nodes": [ + {"id": "base", "atomic_id": "extrude_add_blind", "depends_on": []}, + {"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "requires_topology": True}, + ], + } + + def test_rejects_cycles_and_missing_dependencies(self) -> None: + cyclic = self.plan() + cyclic["nodes"][0]["depends_on"] = ["round"] + with self.assertRaises(FeaturePlanError): + validate_feature_plan(cyclic, supported_atomic_ids={"extrude_add_blind", "fillet"}) + missing = self.plan() + missing["nodes"][1]["depends_on"] = ["missing"] + with self.assertRaises(FeaturePlanError): + validate_feature_plan(missing, supported_atomic_ids={"extrude_add_blind", "fillet"}) + + def test_ready_prefix_waits_for_topology(self) -> None: + statuses = compute_node_statuses(self.plan(), cdsl={"features": []}) + self.assertEqual(statuses["ready_nodes"], ["base"]) + self.assertEqual(statuses["waiting_nodes"], []) + + def test_built_prefix_unlocks_topology_dependent_node(self) -> None: + statuses = compute_node_statuses( + self.plan(), + cdsl={"features": [{"id": "base"}]}, + topology={"records": [{"record_id": "body:base", "kind": "body", "executable": True}]}, + ) + self.assertEqual(statuses["completed_nodes"], ["base"]) + self.assertEqual(statuses["waiting_nodes"], ["round"]) + + def test_inspected_snapshot_unlocks_topology_dependent_node(self) -> None: + plan = self.plan() + plan["topology_snapshot_id"] = "cad_test/rev_001" + statuses = compute_node_statuses( + plan, + cdsl={"features": [{"id": "base"}]}, + topology={ + "snapshot_id": "cad_test/rev_001", + "records": [{"record_id": "body:base", "kind": "body", "executable": True}], + }, + ) + + self.assertEqual(statuses["ready_nodes"], ["round"]) + self.assertEqual(statuses["waiting_nodes"], []) + + def test_dependency_must_appear_before_dependent_node(self) -> None: + plan = self.plan() + plan["nodes"] = [plan["nodes"][1], plan["nodes"][0]] + with self.assertRaisesRegex(FeaturePlanError, "appear after dependency"): + validate_feature_plan(plan, supported_atomic_ids={"extrude_add_blind", "fillet"}) diff --git a/backend/tests/test_image_observation.py b/backend/tests/test_image_observation.py new file mode 100644 index 00000000..43318e75 --- /dev/null +++ b/backend/tests/test_image_observation.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import asyncio +import io +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +from PIL import Image + +from app.services.image_observation import ( + merge_image_observations, + normalize_image_observation, + normalize_sketch_candidates, + render_image_observation_context, +) +from app.services.image_processing import cv_hints, image_metadata +from app.services.agent_service import tools_for_model +from app.services.agent_service import AgentService +from app.services.attachments import attachment_record +from app.services.library import CdslLibrary +from app.services.storage import WorkspaceStore +from app.settings import ProviderConfig, ProviderModel, Settings +from app.models.contracts import ChatMessage, MessagePart + + +def test_observation_keeps_multiview_profiles_and_measurement_sources() -> None: + result = normalize_image_observation({ + "part_type": "bent bracket", + "visible_features": ["plate", "irregular opening"], + "uncertain_features": ["inner bend radius"], + "views": [{"attachment_id": "upload_a", "view_role": "front", "confidence": 0.8}], + "profiles": [{ + "id": "opening_01", + "role": "cutout", + "closed": True, + "source_images": ["upload_a"], + "segments": [ + {"type": "line", "start": [0, 0], "end": [10, 0]}, + {"type": "arc", "start": [10, 0], "end": [10, 4], "center": [8, 2], "radius_mm": 2}, + {"type": "polyline", "points": [[10, 4], [5, 8], [0, 4]]}, + ], + }], + "measurements": [{"name": "plate_thickness", "value_mm": 1.2, "source": "user"}], + }, attachment_ids=["upload_a"]) + + assert result["schema_version"] == "cad.image-observation.v2" + assert result["profiles"][0]["segments"][1]["type"] == "arc" + assert result["measurements"][0]["source"] == "user" + assert "opening_01" in render_image_observation_context(result) + + +def test_sketch_merge_does_not_replace_user_measurement() -> None: + survey = normalize_image_observation({ + "part_type": "bracket", + "visible_features": ["plate"], + "uncertain_features": [], + "views": [{"attachment_id": "upload_a"}], + "measurements": [{"name": "thickness", "value_mm": 1.2, "source": "user"}], + }, attachment_ids=["upload_a"]) + sketches = normalize_sketch_candidates({ + "profiles": [], + "measurements": [{"name": "thickness", "value_mm": 1.6, "source": "image"}], + "uncertainties": ["bend radius"], + }, attachment_ids=["upload_a"]) + merged = merge_image_observations(survey, sketches) + + assert merged["measurements"][0]["value_mm"] == 1.2 + assert merged["uncertainties"] == ["bend radius"] + + +def test_image_metadata_and_cv_degrade_without_required_cv_support() -> None: + output = io.BytesIO() + Image.new("RGB", (32, 16), "white").save(output, format="PNG") + metadata = image_metadata(output.getvalue()) + assert metadata["width"] == 32 + assert metadata["height"] == 16 + assert "available" in cv_hints(output.getvalue()) + + +def test_image_tool_stages_expose_only_the_required_tool() -> None: + model = ProviderModel("vision", vision=True) + assert [tool["function"]["name"] for tool in tools_for_model(model, image_stage="survey")] == ["analyze_image_reference"] + assert [tool["function"]["name"] for tool in tools_for_model(model, image_stage="sketch", include_image_analysis=False, include_image_sketches=True)] == ["extract_image_sketch_candidates"] + + +def test_agent_runs_survey_then_sketch_stage_before_normal_tools() -> None: + class TwoStageAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.required: list[str | None] = [] + self.responses = [ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "survey", "type": "function", "function": {"name": "analyze_image_reference", "arguments": json.dumps({ + "part_type": "bracket", "visible_features": ["plate"], "uncertain_features": [], + "views": [{"attachment_id": "upload_a", "view_role": "front"}], "profiles": [], + "measurements": [], "uncertainties": [], + })}, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "sketch", "type": "function", "function": {"name": "extract_image_sketch_candidates", "arguments": json.dumps({ + "profiles": [{"id": "opening", "role": "cutout", "closed": True, "segments": [{"type": "line", "start": [0, 0], "end": [2, 0]}]}], + "measurements": [], "uncertainties": [], + })}, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "继续建模。", "tool_calls": []}}]}, + ] + + async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]: + self.required.append(kwargs.get("required_tool_name") if "required_tool_name" in kwargs else args[4] if len(args) > 4 else None) + return self.responses.pop(0) + + with TemporaryDirectory() as directory: + root = Path(directory) + backend_root = Path(__file__).resolve().parents[1] + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "key", (ProviderModel("vision", vision=True),)) + settings = Settings(root / "tasks", root / "conversations", backend_root / "cdsl_library", backend_root / "engine" / "cdsl_engine", "", "", "", 5, "test", (provider,)) + store = WorkspaceStore(settings) + conversation_id = "conv_000000000001" + store.ensure_conversation(conversation_id) + relative, _ = store.write_conversation_upload(conversation_id, "part.png", b"png") + store.add_conversation_attachment(conversation_id, attachment_record(conversation_id, "part.png", "image/png", relative, b"png", "image")) + agent = TwoStageAgent(settings, store, CdslLibrary(settings)) + message = ChatMessage(id="user", role="user", parts=[MessagePart(type="text", text="根据图片继续建模")]) + + async def collect() -> list[dict[str, object]]: + events = [] + async for chunk in agent.stream([message], conversation_id, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect()) + assert agent.required[:2] == ["analyze_image_reference", "extract_image_sketch_candidates"] + assert any(event.get("observationStage") == "complete" for event in events) diff --git a/backend/tests/test_incremental_generation.py b/backend/tests/test_incremental_generation.py new file mode 100644 index 00000000..69bd1bda --- /dev/null +++ b/backend/tests/test_incremental_generation.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from fastapi import HTTPException # noqa: E402 +from app import main as api # noqa: E402 +from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, validate_fragment # noqa: E402 +from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan # noqa: E402 +from app.services.incremental_generation import IncrementalGenerationRunner # noqa: E402 +from app.services.review_renderer import CANONICAL_VIEWS, REVIEW_SIZE, RENDER_SIZE, render_checkpoint # noqa: E402 +from app.services.storage import WorkspaceStore # noqa: E402 +from app.services.visual_review import _selected_review_views # noqa: E402 +from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 + + +def plan() -> dict: + return { + "schema_version": "cad.generation-plan.v2", + "plan_id": "incremental_test", + "requirements": [ + {"id": "req_base", "source": "explicit", "priority": "hard", "description": "base solid"}, + {"id": "req_round", "source": "explicit", "priority": "hard", "description": "edge round"}, + ], + "assumptions": [], + "nodes": [ + { + "id": "base", "intent": "base", "atomic_id": "extrude_add_blind", "depends_on": [], + "requirement_ids": ["req_base"], "verification_rules": [], "review_targets": [], + }, + { + "id": "round", "intent": "round", "atomic_id": "fillet", "depends_on": ["base"], + "requires_topology": True, + "requirement_ids": ["req_round"], "verification_rules": [], "review_targets": [], + }, + ], + } + + +class GenerationPlanTests(unittest.TestCase): + def test_hard_requirements_and_backend_outputs_are_owned(self) -> None: + invalid = plan() + invalid["nodes"][1]["requirement_ids"] = [] + with self.assertRaisesRegex(GenerationPlanError, "Hard requirements"): + validate_generation_plan(invalid, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456") + + generated = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456") + self.assertEqual(generated["id_strategy"], "backend-derived-v1") + self.assertEqual(len(generated["nodes"][0]["cdsl_feature_ids"]), 1) + self.assertEqual(len(generated["nodes"][0]["cdsl_sketch_ids"]), 1) + self.assertEqual(generated["nodes"][1]["cdsl_sketch_ids"], []) + # Legacy fields from an untrusted model output cannot choose CDSL IDs. + supplied = plan() + supplied["nodes"][0]["expected_feature_ids"] = ["model_chosen_id"] + self.assertEqual( + validate_generation_plan(supplied, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")["nodes"][0]["cdsl_feature_ids"], + generated["nodes"][0]["cdsl_feature_ids"], + ) + + def test_upstream_invalidation_marks_all_descendants_stale(self) -> None: + spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456") + spec["nodes"][0]["status"] = "completed" + spec["nodes"][1]["status"] = "completed" + self.assertEqual(descendant_closure(spec, "base"), {"base", "round"}) + stale = mark_nodes_stale(spec, "base", reason="topology_changed") + self.assertTrue(all(node["stale"] for node in stale["nodes"])) + self.assertTrue(all(node["status"] == "planned" for node in stale["nodes"])) + + +class FragmentTests(unittest.TestCase): + def setUp(self) -> None: + self.spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456") + + def base_fragment(self) -> dict: + return { + "schema_version": "cad.cdsl-fragment.v1", "node_id": "base", "base_revision_id": "", + "base_cdsl_sha256": cdsl_sha256(None), "add_sketches": [{}], + "add_features": [{}], "verification_rules": [], "assumptions": [], + } + + def test_fragment_assigns_ids_dependencies_and_atomic_from_plan(self) -> None: + accepted = validate_fragment(self.base_fragment(), plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None) + base = self.spec["nodes"][0] + self.assertEqual(accepted["add_sketches"][0]["id"], base["cdsl_sketch_ids"][0]) + self.assertEqual(accepted["add_features"][0]["id"], base["cdsl_feature_ids"][0]) + self.assertEqual(accepted["add_features"][0]["sketch_id"], base["cdsl_sketch_ids"][0]) + self.assertEqual(accepted["add_features"][0]["atomic_id"], "extrude_add_blind") + self.assertEqual(accepted["add_features"][0]["depends_on"], []) + model_ids = self.base_fragment() + model_ids["add_sketches"][0]["id"] = "model_sketch" + model_ids["add_features"][0].update({"id": "model_feature", "atomic_id": "fillet", "sketch_id": "model_sketch"}) + overwritten = validate_fragment(model_ids, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None) + self.assertEqual(overwritten["add_features"][0]["id"], base["cdsl_feature_ids"][0]) + self.assertEqual(overwritten["add_features"][0]["atomic_id"], "extrude_add_blind") + + def test_fragment_rejects_wrong_hash_and_output_shape(self) -> None: + wrong_hash = self.base_fragment() + wrong_hash["base_cdsl_sha256"] = "0" * 64 + with self.assertRaisesRegex(CdslFragmentError, "sha256"): + validate_fragment(wrong_hash, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None) + unexpected_sketch = self.base_fragment() + unexpected_sketch["add_sketches"].append({"id": "extra"}) + with self.assertRaisesRegex(CdslFragmentError, "one new sketch"): + validate_fragment(unexpected_sketch, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None) + + def test_topology_fragment_requires_active_snapshot_owner_and_geometry(self) -> None: + base = self.spec["nodes"][0] + round_node = self.spec["nodes"][1] + base_cdsl = { + "geometry": {"sketches": [{"id": base["cdsl_sketch_ids"][0]}]}, + "features": [{"id": base["cdsl_feature_ids"][0], "atomic_id": "extrude_add_blind"}], + } + fragment = { + "schema_version": "cad.cdsl-fragment.v1", "node_id": "round", "base_revision_id": "rev_001", + "base_cdsl_sha256": cdsl_sha256(base_cdsl), "required_snapshot_id": "cad_test/rev_001", + "add_sketches": [], + "add_features": [{ + "selectors": [{ + "kind": "edge", "stable_id": "body:feature_base:edge:0", "owner_node_id": "base", + "snapshot_id": "cad_test/rev_001", "geometry": {"curve_type": "line", "length_mm": 10}, + }], + }], + "verification_rules": [], "assumptions": [], + } + accepted = validate_fragment( + fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl, + required_snapshot_id="cad_test/rev_001", + ) + self.assertEqual(accepted["node_id"], "round") + self.assertEqual(accepted["add_features"][0]["id"], round_node["cdsl_feature_ids"][0]) + self.assertEqual(accepted["add_features"][0]["depends_on"], [base["cdsl_feature_ids"][0]]) + self.assertEqual(accepted["add_features"][0]["selectors"][0]["owner_feature_id"], base["cdsl_feature_ids"][0]) + fragment["add_features"][0]["selectors"][0].pop("geometry") + with self.assertRaisesRegex(CdslFragmentError, "geometry signature"): + validate_fragment( + fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl, + required_snapshot_id="cad_test/rev_001", + ) + + +class StorageAndRunnerTests(unittest.TestCase): + def settings(self, root: Path) -> Settings: + provider = ProviderConfig("author", "Author", "https://example.invalid/v1", "secret", (ProviderModel("author-model"),)) + return Settings( + task_root=root / "tasks", conversation_root=root / "conversations", library_root=root / "library", + engine_root=ROOT / "backend" / "engine" / "cdsl_engine", llm_base_url="", llm_api_key="", llm_model="author-model", + llm_timeout_s=1, default_provider_id="author", providers=(provider,), incremental_generation=True, + ) + + def test_rollback_anchor_rewinds_before_affected_nodes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + store = WorkspaceStore(self.settings(Path(directory))) + task = store.ensure_task(None, "test") + task_id = task["task_id"] + for revision_id, parent, node_id in (("rev_001", "", "base"), ("rev_002", "rev_001", "middle"), ("rev_003", "rev_002", "tip")): + store.update_task(task_id, {"revision_id": revision_id, "status": "success", "parent_revision_id": parent, "node_id": node_id, "visibility": "checkpoint"}) + self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["middle", "tip"], fallback_revision_id="rev_002"), "rev_001") + self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["base"], fallback_revision_id="rev_001"), "") + store.rollback_to_revision(task_id, "rev_002", branch_id="branch_repair") + revisions = {item["revision_id"]: item for item in (store.read_task(task_id) or {})["revisions"]} + self.assertEqual(revisions["rev_001"]["visibility"], "checkpoint") + self.assertEqual(revisions["rev_002"]["visibility"], "checkpoint") + self.assertEqual(revisions["rev_003"]["visibility"], "superseded") + + def test_run_context_is_persisted_for_worker_recovery(self) -> None: + with tempfile.TemporaryDirectory() as directory: + store = WorkspaceStore(self.settings(Path(directory))) + task = store.ensure_task(None, "test") + store.write_generation_run_context(task["task_id"], { + "schema_version": "cad.generation-run-context.v1", + "request": "test", "conversation_id": "conv_abcdef123456", + "provider_id": "author", "model_id": "author-model", "author_messages": [], "part_skills": {}, + }) + recovered = store.read_generation_run_context(task["task_id"]) + self.assertEqual(recovered and recovered["request"], "test") + task = store.start_generation(task["task_id"], request="test") + self.assertEqual([item["task_id"] for item in store.running_tasks()], [task["task_id"]]) + + def test_missing_visual_review_configuration_fails_the_run(self) -> None: + with tempfile.TemporaryDirectory() as directory: + settings = self.settings(Path(directory)) + store = WorkspaceStore(settings) + task = store.ensure_task(None, "test") + + async def complete(*_args: object) -> dict: + raise AssertionError("author must not be called before visual configuration validation") + + runner = IncrementalGenerationRunner(settings, store, complete) + + async def collect() -> list[tuple[str, dict]]: + return [item async for item in runner.run( + task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []}, + provider=settings.providers[0], model=settings.providers[0].models[0], author_messages=[], + )] + + events = asyncio.run(collect()) + self.assertEqual(events[-1][0], "task_terminal") + self.assertEqual(events[-1][1]["lifecycle"], "failed") + self.assertEqual((store.read_task(task["task_id"]) or {})["lifecycle"], "failed") + + def test_invalid_plan_is_preserved_in_failure_diagnostics(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + provider = ProviderConfig( + "author", "Author", "https://example.invalid/v1", "secret", + (ProviderModel("author-model", vision=True),), + ) + settings = replace( + self.settings(root), providers=(provider,), review_provider_id="author", review_model_id="author-model", + ) + store = WorkspaceStore(settings) + task = store.ensure_task(None, "test") + raw_plan = {"schema_version": "cad.generation-plan.v2", "plan_id": "broken", "requirements": []} + + async def complete(*_args: object) -> dict: + return { + "choices": [{"message": {"tool_calls": [{"function": { + "name": "plan_generation_task", "arguments": json.dumps(raw_plan), + }}]}}], + } + + runner = IncrementalGenerationRunner(settings, store, complete) + engine = type("Engine", (), {"SUPPORTED_ATOMIC_IDS": ("extrude_add_blind",)})() + + async def collect() -> list[tuple[str, dict]]: + return [item async for item in runner.run( + task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []}, + provider=provider, model=provider.models[0], author_messages=[], + )] + + with patch("app.services.incremental_generation.load_engine", return_value=engine), patch( + "app.services.incremental_generation.renderer_status", return_value=(True, "") + ): + events = asyncio.run(collect()) + + self.assertEqual(events[-1], ("task_terminal", { + "taskId": task["task_id"], "lifecycle": "failed", + "message": "Generation plan requires a non-empty requirements array", + })) + failed_task = store.read_task(task["task_id"]) or {} + failure_path = root / "tasks" / task["task_id"] / str(failed_task["run_failure_path"]) + with failure_path.open(encoding="utf-8") as handle: + failure = json.load(handle) + diagnostic_path = root / "tasks" / task["task_id"] / str(failure["plan_diagnostic_path"]) + with diagnostic_path.open(encoding="utf-8") as handle: + diagnostic = json.load(handle) + self.assertEqual(diagnostic["stage"], "generation_plan_validation") + self.assertEqual(diagnostic["raw_plan"], raw_plan) + + +class ArtifactAccessTests(unittest.TestCase): + def test_checkpoint_allows_only_the_active_glb_until_publication(self) -> None: + with tempfile.TemporaryDirectory() as directory: + store = WorkspaceStore(StorageAndRunnerTests().settings(Path(directory))) + task = store.ensure_task(None, "test") + task_id = task["task_id"] + revision_id, revision_dir = store.next_revision(task_id) + glb = revision_dir / "model.glb" + step = revision_dir / "model.step" + glb.write_bytes(b"glb") + step.write_bytes(b"step") + store.update_task(task_id, { + "revision_id": revision_id, + "status": "success", + "cdsl_path": f"revisions/{revision_id}/model.cdsl.json", + "glb_path": f"revisions/{revision_id}/model.glb", + "step_path": f"revisions/{revision_id}/model.step", + "report_path": f"revisions/{revision_id}/rebuild-report.json", + "visibility": "checkpoint", + }) + previous_store = api.store + api.store = store + try: + response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.glb")) + self.assertEqual(response.media_type, "model/gltf-binary") + with self.assertRaises(HTTPException) as rejected: + asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step")) + self.assertEqual(rejected.exception.status_code, 403) + store.finish_generation(task_id, lifecycle="completed") + response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step")) + self.assertEqual(response.status_code, 200) + finally: + api.store = previous_store + + +class TechnicalRenderTests(unittest.TestCase): + def test_cpu_renderer_emits_fixed_views_and_keeps_detail_separate(self) -> None: + from build123d import Box, export_step + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + step_path = root / "box.step" + export_step(Box(30, 20, 10), step_path) + manifest = render_checkpoint( + StorageAndRunnerTests().settings(root), + step_path=step_path, + output_dir=root / "review", + review_targets=[{"bbox_mm": [-5, -5, -5, 5, 5, 5]}], + ) + self.assertEqual(manifest["renderer"], "python-occ-hlr-pillow") + views = {item["id"]: item for item in manifest["views"]} + self.assertEqual(set(CANONICAL_VIEWS), set(views) & set(CANONICAL_VIEWS)) + self.assertIn("detail-1", views) + self.assertNotEqual(views["isometric"]["path"], views["detail-1"]["path"]) + self.assertTrue(all(Path(views[view_id]["path"]).is_file() for view_id in CANONICAL_VIEWS)) + self.assertTrue(all(views[view_id]["diagnostics"]["valid"] for view_id in CANONICAL_VIEWS)) + self.assertTrue(views["detail-1"]["diagnostics"]["intentional_crop"]) + from PIL import Image + + with Image.open(views["isometric"]["path"]) as image: + self.assertEqual(image.size, (REVIEW_SIZE, REVIEW_SIZE)) + with Image.open(views["isometric"]["high_resolution_path"]) as image: + self.assertEqual(image.size, (RENDER_SIZE, RENDER_SIZE)) + + def test_routine_visual_review_uses_compact_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + contact = root / "contact-sheet.jpg" + contact.write_bytes(b"jpg") + manifest = { + "contact_sheet_path": str(contact), + "views": [{"id": view_id, "path": str(root / f"{view_id}.png")} for view_id in (*CANONICAL_VIEWS, "detail-1", "detail-2")], + } + routine = _selected_review_views(manifest, final_checkpoint=False) + final = _selected_review_views(manifest, final_checkpoint=True) + self.assertEqual([item["id"] for item in routine], ["contact-sheet", "detail-1", "detail-2"]) + self.assertEqual({item["id"] for item in final}, {"contact-sheet", "detail-1", "detail-2", *CANONICAL_VIEWS}) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_part_skills.py b/backend/tests/test_part_skills.py index 054a662c..5352f6c1 100644 --- a/backend/tests/test_part_skills.py +++ b/backend/tests/test_part_skills.py @@ -242,7 +242,7 @@ class AgentPartSkillTests(unittest.TestCase): self.assertIn("[planning/flange]", prompt) self.assertIn("circular-pattern atomic", prompt) - self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"]) + self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "extract_image_sketch_candidates", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"]) def test_generation_persists_assumptions_and_selected_skills(self) -> None: with tempfile.TemporaryDirectory() as directory: diff --git a/backend/tests/test_topology_snapshot.py b/backend/tests/test_topology_snapshot.py new file mode 100644 index 00000000..b311482e --- /dev/null +++ b/backend/tests/test_topology_snapshot.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.services.engine_service import topology_snapshot, topology_sidecars # noqa: E402 + + +class TopologySnapshotTests(unittest.TestCase): + def result(self) -> dict: + return { + "topology_records": [ + { + "record_id": "body:base:edge:0", + "kind": "edge", + "feature_id": "base", + "body_id": "body:base", + "owner_feature_ids": ["base"], + "geometry": {"curve_type": "line", "length_mm": 10}, + }, + { + "record_id": "body:base", + "kind": "body", + "feature_id": "base", + "body_id": "body:base", + "geometry": {}, + }, + ] + } + + def test_snapshot_preserves_runtime_edges(self) -> None: + snapshot = topology_snapshot(self.result(), task_id="cad_test", revision_id="rev_001") + self.assertEqual(snapshot["snapshot_id"], "cad_test/rev_001") + self.assertEqual(snapshot["records"][0]["record_id"], "body:base:edge:0") + self.assertTrue(snapshot["records"][0]["executable"]) + + def test_sidecars_are_derived_from_runtime_records(self) -> None: + snapshot = topology_snapshot(self.result()) + selector, edges = topology_sidecars(self.result(), snapshot=snapshot) + self.assertEqual(edges["edges"][0]["record_id"], "body:base:edge:0") + self.assertEqual(selector["edges"][0]["source"], "runtime_snapshot") + + def test_snapshot_excludes_superseded_body_records(self) -> None: + result = { + "feature_results": [ + {"feature_id": "base", "body_id": "body:base"}, + {"feature_id": "cut", "body_id": "body:cut"}, + ], + "topology_records": [ + {"record_id": "body:base", "kind": "body", "body_id": "body:base", "feature_id": "base"}, + {"record_id": "body:base:edge:0", "kind": "edge", "body_id": "body:base", "feature_id": "base"}, + {"record_id": "body:cut", "kind": "body", "body_id": "body:cut", "feature_id": "cut"}, + {"record_id": "body:cut:edge:0", "kind": "edge", "body_id": "body:cut", "feature_id": "cut"}, + ], + } + + snapshot = topology_snapshot(result) + + self.assertEqual(snapshot["body_id"], "body:cut") + self.assertEqual([record["record_id"] for record in snapshot["records"]], ["body:cut", "body:cut:edge:0"]) + + def test_preview_faces_are_audited_but_not_executable(self) -> None: + snapshot = topology_snapshot( + {"topology_records": []}, + task_id="cad_test", + revision_id="rev_001", + preview={"topology_faces": [{"id": "preview_face", "surface_type": "plane", "center": [0, 0, 1], "normal": [0, 0, 1]}]}, + ) + self.assertEqual(snapshot["records"][0]["record_id"], "preview_face") + self.assertTrue(snapshot["records"][0]["synthetic"]) + self.assertFalse(snapshot["records"][0]["executable"]) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8db38ace..661ff7b2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,6 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/three": "^0.185.4", - "puppeteer-core": "^25.8.0", "tailwindcss": "^4", "tsx": "^4.20.6", "typescript": "^5" @@ -1491,35 +1490,6 @@ "node": ">= 10" } }, - "node_modules/@puppeteer/browsers": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.2.1.tgz", - "integrity": "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "modern-tar": "^0.8.0", - "yargs": "^18.0.0" - }, - "bin": { - "browsers": "lib/main-cli.js" - }, - "engines": { - "node": ">=22.12.0" - }, - "peerDependencies": { - "proxy-agent": ">=8.0.1", - "yauzl": "^2.10.0 || ^3.4.0" - }, - "peerDependenciesMeta": { - "proxy-agent": { - "optional": true - }, - "yauzl": { - "optional": true - } - } - }, "node_modules/@radix-ui/number": { "version": "1.1.3", "resolved": "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.3.tgz", @@ -3455,32 +3425,6 @@ } } }, - "node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3557,72 +3501,12 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chromium-bidi": { - "version": "17.0.2", - "resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-17.0.2.tgz", - "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "engines": { - "node": ">=20.19.0 <22.0.0 || >=22.12.0" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/chromium-bidi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmmirror.com/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", @@ -3664,21 +3548,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/devtools-protocol": { - "version": "0.0.1666840", - "resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", - "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -3735,16 +3604,6 @@ "@esbuild/win32-x64": "0.28.2" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventsource-parser": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.1.tgz", @@ -3776,29 +3635,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4118,23 +3954,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, - "license": "MIT" - }, - "node_modules/modern-tar": { - "version": "0.8.4", - "resolved": "https://registry.npmmirror.com/modern-tar/-/modern-tar-0.8.4.tgz", - "integrity": "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/nanoid": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-6.0.1.tgz", @@ -4315,24 +4134,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/puppeteer-core": { - "version": "25.8.0", - "resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.8.0.tgz", - "integrity": "sha512-LDOrawV8vfCVk+yLj2ozvajNP4Sv3OV9y3Tpiyy2g2Z+aQlbcozP6KJfI4iSBq7YQER+86ihEtPa5ioiZyWxMQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "3.2.1", - "chromium-bidi": "17.0.2", - "devtools-protocol": "0.0.1666840", - "typed-query-selector": "^2.12.2", - "webdriver-bidi-protocol": "0.4.2", - "ws": "^8.21.1" - }, - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/radix-ui": { "version": "1.6.7", "resolved": "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.6.7.tgz", @@ -4614,39 +4415,6 @@ "node": ">=0.10.0" } }, - "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -4767,13 +4535,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/typed-query-selector": { - "version": "2.12.2", - "resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz", - "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", - "dev": true, - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", @@ -4910,109 +4671,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.4.2", - "resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", - "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "18.1.0", - "resolved": "https://registry.npmmirror.com/yargs/-/yargs-18.1.0.tgz", - "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^8.2.1", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4c1c4514..7880825c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,6 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/three": "^0.185.4", - "puppeteer-core": "^25.8.0", "tailwindcss": "^4", "tsx": "^4.20.6", "typescript": "^5" diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index f8fac541..29b84cc6 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -394,7 +394,15 @@ button:disabled { .config-warning { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-error-border); background: var(--ui-error-bg); color: var(--ui-error-text); font-size: 12px; padding: 8px 12px; } .studio-main { display: flex; min-height: 0; flex: 1; } .agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); } -.preview-pane { min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); } +.preview-pane { position: relative; min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); } +.generation-status { position: absolute; z-index: 30; top: 12px; right: 12px; width: min(260px, calc(100% - 24px)); max-height: min(42vh, 360px); overflow: auto; border: 1px solid var(--ui-border); border-radius: 6px; background: var(--ui-glass-popover); box-shadow: var(--ui-shadow-soft); backdrop-filter: blur(12px); color: var(--ui-text); padding: 10px; font-size: 12px; } +.generation-status-heading { display: flex; align-items: center; gap: 6px; color: var(--ui-text-strong); font-weight: 650; } +.generation-status-active { margin: 6px 0 8px; color: var(--ui-accent-text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; } +.generation-status ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; } +.generation-status li { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--ui-border-muted); padding-top: 4px; color: var(--ui-text-muted); } +.generation-status li[data-status="completed"] small { color: var(--ui-success); } +.generation-status li[data-status="planned"] small { color: var(--ui-text-subtle); } +.generation-status li[data-status="failed"] small { color: var(--ui-error); } .agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; } .agent-thread-shell { position: relative; } .spin { animation: ui-spin 900ms linear infinite; } diff --git a/frontend/src/components/agent-studio.tsx b/frontend/src/components/agent-studio.tsx index 208fa9f8..67882645 100644 --- a/frontend/src/components/agent-studio.tsx +++ b/frontend/src/components/agent-studio.tsx @@ -6,13 +6,14 @@ import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { AlertCircle, Box, Loader2, Moon, Sun } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { latestSuccessfulResult } from "@/lib/cad-artifacts"; +import { activeCheckpointPreview, latestSuccessfulResult } from "@/lib/cad-artifacts"; import { normalizeCadMessages } from "@/lib/cad-messages"; import type { ViewerSelectionContext } from "@/lib/viewer-selection"; import type { BackendConfig, CadError, CadAttachment, + CadProgress, CadResult, CadUIMessage, ConversationRecord, @@ -39,6 +40,8 @@ export function AgentStudio() { const [modelId, setModelId] = useState(""); const [theme, setTheme] = useState<"light" | "dark">("light"); const [viewerSelection, setViewerSelection] = useState(null); + const [taskRunning, setTaskRunning] = useState(false); + const [taskRecord, setTaskRecord] = useState(null); const syncUrl = useCallback((conversation: string, task: string) => { const params = new URLSearchParams(window.location.search); @@ -73,10 +76,12 @@ export function AgentStudio() { const taskId = urlTaskId || conversation.current_task_id || ""; let restored: CadResult | null = null; + let restoredTask: TaskRecord | null = null; if (taskId) { const taskResponse = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { cache: "no-store" }); if (taskResponse.ok) { - restored = latestSuccessfulResult((await taskResponse.json()) as TaskRecord); + restoredTask = (await taskResponse.json()) as TaskRecord; + restored = activeCheckpointPreview(restoredTask) ?? latestSuccessfulResult(restoredTask); } } @@ -90,6 +95,8 @@ export function AgentStudio() { setProviderId(defaultProvider?.id || ""); setModelId(defaultProvider?.models.find((model) => model.id === nextConfig.default_model)?.id || defaultProvider?.models[0]?.id || ""); setCadResult(restored); + setTaskRunning(restoredTask?.lifecycle === "running"); + setTaskRecord(restoredTask); setLoadState("ready"); syncUrl(nextConversationId, taskId); } catch (error) { @@ -127,6 +134,7 @@ export function AgentStudio() { setViewerSelection(null); setSelectedTaskId(result.taskId); setLastError(""); + if (result.lifecycle) setTaskRunning(result.lifecycle === "running"); if (conversationId) { syncUrl(conversationId, result.taskId); void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { @@ -137,8 +145,24 @@ export function AgentStudio() { } }, [conversationId, syncUrl]); + const handleTaskState = useCallback((progress: CadProgress) => { + const taskId = String(progress.taskId || ""); + if (taskId) { + setSelectedTaskId(taskId); + if (conversationId) { + syncUrl(conversationId, taskId); + void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { + method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentTaskId: taskId }), + }); + } + } + if (progress.step === "generation_plan" && progress.status === "running") setTaskRunning(true); + if (progress.step === "task_terminal") setTaskRunning(progress.lifecycle === "running"); + }, [conversationId, syncUrl]); + const handleError = useCallback((error: CadError) => { setLastError(error.message); + if (error.stage === "generation") setTaskRunning(false); }, []); const handleUpload = useCallback(async (files: FileList | null) => { @@ -167,6 +191,34 @@ export function AgentStudio() { } }, [conversationId]); + useEffect(() => { + if (!taskRunning || !selectedTaskId) return; + let cancelled = false; + const refresh = async () => { + try { + const response = await fetch(`/api/tasks/${encodeURIComponent(selectedTaskId)}`, { cache: "no-store" }); + if (!response.ok) return; + const next = await response.json() as TaskRecord; + if (cancelled) return; + setTaskRecord(next); + const running = next.lifecycle === "running"; + setTaskRunning(running); + if (running) { + const preview = activeCheckpointPreview(next); + if (preview) setCadResult(preview); + } else { + const restored = latestSuccessfulResult(next); + if (restored) setCadResult(restored); + } + } catch { + // Keep the persisted lock until a later poll can prove a terminal state. + } + }; + void refresh(); + const timer = window.setInterval(() => void refresh(), 2000); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [selectedTaskId, taskRunning]); + if (loadState === "loading") { return ; } @@ -185,6 +237,7 @@ export function AgentStudio() { initialMessages={initialMessages} onCadResult={handleResult} onCadError={handleError} + onCadProgress={handleTaskState} > ); @@ -217,6 +272,7 @@ function AgentRuntime({ initialMessages, onCadResult, onCadError, + onCadProgress, children, }: { conversationId: string; @@ -227,6 +283,7 @@ function AgentRuntime({ initialMessages: CadUIMessage[]; onCadResult: (result: CadResult) => void; onCadError: (error: CadError) => void; + onCadProgress: (progress: CadProgress) => void; children: React.ReactNode; }) { const conversationRef = useRef(conversationId); @@ -236,6 +293,7 @@ function AgentRuntime({ const viewerSelectionRef = useRef(viewerSelection); const onCadResultRef = useRef(onCadResult); const onCadErrorRef = useRef(onCadError); + const onCadProgressRef = useRef(onCadProgress); conversationRef.current = conversationId; taskRef.current = selectedTaskId; providerRef.current = providerId; @@ -243,6 +301,7 @@ function AgentRuntime({ viewerSelectionRef.current = viewerSelection; onCadResultRef.current = onCadResult; onCadErrorRef.current = onCadError; + onCadProgressRef.current = onCadProgress; const transport = useMemo(() => new DefaultChatTransport({ api: "/api/chat", @@ -267,6 +326,9 @@ function AgentRuntime({ messages: initialMessages, transport, onData: (part) => { + if (part.type === "data-cad-progress") { + onCadProgressRef.current(part.data as CadProgress); + } if (part.type === "data-cad-result") { onCadResultRef.current(part.data as CadResult); taskRef.current = (part.data as CadResult).taskId; @@ -425,6 +487,8 @@ function StudioShell({ onCadResult, onCadError, onSelectionChange, + taskRunning, + taskRecord, }: { config: BackendConfig | null; cadResult: CadResult | null; @@ -442,8 +506,10 @@ function StudioShell({ onCadResult: (result: CadResult) => void; onCadError: (error: CadError) => void; onSelectionChange: (selection: ViewerSelectionContext | null) => void; + taskRunning: boolean; + taskRecord: TaskRecord | null; }) { - const running = useAuiState((state) => state.thread.isRunning); + const running = useAuiState((state) => state.thread.isRunning) || taskRunning; const provider = config?.providers.find((item) => item.id === providerId); const handleViewerError = useCallback((message: string) => { onCadError({ stage: "viewer", message }); @@ -453,24 +519,43 @@ function StudioShell({
CDSL CAD Studio{cadResult ? {cadResult.taskId} : null}
- { const id = event.target.value; onProviderChange(id); onModelChange(config?.providers.find((item) => item.id === id)?.models[0]?.id || ""); }}> {config?.providers.map((item) => )} - onModelChange(event.target.value)}> {provider?.models.map((model) => )} - +
{!config?.configured ?
未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。
: null} + {config?.incremental_generation && !config.review_configured ?
视觉复核未配置,增量任务会拒绝启动:{config.review_error || "请配置独立视觉模型与 Chromium。"}
: null}
- -
+ +
+ + +
); } +function GenerationStatus({ task }: { task: TaskRecord | null }) { + const plan = task?.generation_plan; + const nodes = Array.isArray(plan?.nodes) ? plan.nodes.filter((node): node is Record => Boolean(node && typeof node === "object")) : []; + if (task?.lifecycle !== "running") return null; + return ( + + ); +} + function StudioLoading() { return (
diff --git a/frontend/src/components/agent-thread.tsx b/frontend/src/components/agent-thread.tsx index a2078f11..37fcef5d 100644 --- a/frontend/src/components/agent-thread.tsx +++ b/frontend/src/components/agent-thread.tsx @@ -1,20 +1,21 @@ "use client"; -import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Square, Upload } from "lucide-react"; +import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Upload } from "lucide-react"; import { useRef, useState, type ChangeEvent, type DragEvent } from "react"; import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react"; import type { CadAttachment } from "@/lib/cad-types"; import { CadErrorPart, CadImageAnalysisPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts"; -export function AgentThread({ attachments, uploading, uploadError, onUpload }: { +export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload }: { attachments: CadAttachment[]; uploading: boolean; uploadError: string; + taskRunning?: boolean; onUpload: (files: FileList | null) => void; }) { const fileInput = useRef(null); const dragDepth = useRef(0); - const running = useAuiState((state) => state.thread.isRunning); + const running = useAuiState((state) => state.thread.isRunning) || taskRunning; const [isDraggingFiles, setIsDraggingFiles] = useState(false); const canUpload = !uploading && !running; @@ -64,7 +65,7 @@ export function AgentThread({ attachments, uploading, uploadError, onUpload }: {
- + {isDraggingFiles ?
拖放文件上传
: null} @@ -103,8 +104,8 @@ function AssistantMessage() { ); } -function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObject; uploading: boolean; onUpload: (files: FileList | null) => void }) { - const running = useAuiState((state) => state.thread.isRunning); +function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fileInput: React.RefObject; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void }) { + const running = useAuiState((state) => state.thread.isRunning) || taskRunning; const handleFileChange = (event: ChangeEvent) => { if (event.currentTarget.files?.length) onUpload(event.currentTarget.files); event.currentTarget.value = ""; @@ -113,12 +114,12 @@ function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObje
- +
{running ? ( - + ) : ( <> diff --git a/frontend/src/components/cad-message-parts.tsx b/frontend/src/components/cad-message-parts.tsx index b5a6c7b2..dfd2471c 100644 --- a/frontend/src/components/cad-message-parts.tsx +++ b/frontend/src/components/cad-message-parts.tsx @@ -29,14 +29,14 @@ export function CadProgressPart({ data }: { data: CadProgress }) { } export function CadResultPart({ data }: { data: CadResult }) { - const downloads: Array<[string, string]> = [ + const downloads: Array<[string, string]> = data.checkpoint ? [] : [ ["STEP", data.stepPath], ["CDSL", data.cdslPath], ["GLB", data.glbPath], ["报告", data.reportPath], ]; - if (data.qualityPath) downloads.push(["质量报告", data.qualityPath]); - if (data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]); + if (!data.checkpoint && data.qualityPath) downloads.push(["质量报告", data.qualityPath]); + if (!data.checkpoint && data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]); const qualityLabels: Record = { accepted: "验收通过", built_with_warnings: "构建完成,有警告", @@ -60,16 +60,16 @@ export function CadResultPart({ data }: { data: CadResult }) {
{data.assumptions?.length ?
假设{data.assumptions.join(";")}
: null} {data.referenceIds.length ?
参考{data.referenceIds.join(";")}
: null} - + {!data.checkpoint ? : null} {data.snapshotStatus && data.snapshotStatus !== "unavailable" ?
快照{data.snapshotStatus}
: null} -
+ {downloads.length ?
{downloads.map(([label, path]) => ( ))} -
+
: null} ); } @@ -114,9 +114,12 @@ function QualitySummary({ data }: { data: CadResult }) { export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) { const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? []; + const profileCount = data.profiles?.length || 0; + const holeCount = data.holes?.length || 0; + const viewCount = data.views?.length || data.attachmentIds.length; return (
-
+
{data.partType}
可见特征 @@ -135,6 +138,37 @@ export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) { )}
: null} + {(data.views?.length || profileCount || holeCount || data.measurements?.length || data.assumptions?.length) ? ( +
+ 勘测详情 +
+ 视角与几何 +

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

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

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

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