优化生成流程

This commit is contained in:
2026-08-25 17:41:24 +08:00
parent 51575161dc
commit beaa5dd9fe
50 changed files with 4204 additions and 4261 deletions
+1
View File
@@ -29,6 +29,7 @@ CDSL_KIMI_VISION_MODELS=
CDSL_DEEPSEEK_STRICT_TOOL_SCHEMA=false
CDSL_OPENAI_STRICT_TOOL_SCHEMA=false
CDSL_KIMI_STRICT_TOOL_SCHEMA=true
CDSL_MAX_REPAIR_ATTEMPTS=4
# To enable individual models instead of every model from a provider, keep the
# provider-wide switch false and list exact IDs, for example:
# CDSL_OPENAI_STRICT_TOOL_MODELS=gpt-4.1
@@ -2,4 +2,4 @@
- Build a valid seed hole first and preserve its host frame and functional subtype.
- Use `pattern_linear` for one/two-dimensional rows and `pattern_mirror` for symmetry.
- For circular layouts, use an explicit `circles` profile expansion and record the audit status; do not invent a circular-pattern atomic.
- For circular layouts, use an explicit `circles` profile expansion; do not invent a circular-pattern atomic.
@@ -3,5 +3,5 @@
- Use for a circular flange, flanged sleeve, or coupling flange with a shared central axis.
- Invariants: coaxial outside diameter, bore/hub, sealing land, and fastener holes on a pitch circle.
- Plan: `revolve_add` for stepped axisymmetric bodies or circular `extrude_add_blind` -> coaxial bore -> one bolt-hole definition -> explicit circle set or supported repetition -> late finishing.
- No circular-pattern atomic exists. Expand a bolt circle into explicit circles in one cut profile and record `expanded` in the audit.
- No circular-pattern atomic exists. Express a bolt circle with explicit circles in one cut profile when the engine schema supports that profile.
- Keep bolt-circle diameter, count, start angle, bore, and hub dimensions distinct.
@@ -12,7 +12,6 @@
"exclude": ["flange", "法兰", "3d bracket", "mounting bracket", "轴承座"],
"bridge": "bridge/planning-mounting-plate.md",
"source": "source/planning/mounting-plate.planning.md",
"capability_translation_rules": ["exact: schema_valid_feature_sequence"],
"related": ["functional/mounting-plate-hole-layout", "functional/slotted-adjustment-feature", "functional/standard-hole-wizard", "functional/symmetric-feature-layout", "atomic/extrude-base-profile", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"]
},
{
@@ -24,7 +23,6 @@
"exclude": ["flat mounting plate only", "仅底板", "sheet metal"],
"bridge": "bridge/planning-mounting-bracket.md",
"source": "source/planning/mounting-bracket.planning.md",
"capability_translation_rules": ["exact: schema_valid_feature_sequence"],
"related": ["functional/slotted-adjustment-feature", "functional/standard-hole-wizard", "functional/symmetric-feature-layout", "atomic/extrude-base-profile", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"]
},
{
@@ -36,7 +34,6 @@
"exclude": ["flange nut", "法兰螺母", "side tab"],
"bridge": "bridge/planning-flange.md",
"source": "source/planning/flange.planning.md",
"capability_translation_rules": ["expanded: circular_pattern_to_explicit_circles"],
"related": ["functional/flange-bolt-circle", "functional/axisymmetric-revolve-strategy", "functional/standard-hole-wizard", "atomic/coaxial-bore-rule", "atomic/revolve-profile-around-axis", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"]
},
{
@@ -48,7 +45,6 @@
"exclude": ["spindle assembly", "bearing assembly", "gearbox shaft assembly", "主轴组件", "轴承组件"],
"bridge": "bridge/planning-simple-shaft.md",
"source": "source/planning/simple-shaft-or-cylindrical-rod.planning.md",
"capability_translation_rules": ["exact: revolve_or_extrude_supported"],
"related": ["functional/axisymmetric-revolve-strategy", "atomic/coaxial-bore-rule", "atomic/revolve-profile-around-axis", "atomic/through-hole-cut", "atomic/fillet-chamfer-last"]
},
{
@@ -60,7 +56,6 @@
"exclude": ["complete rolling bearing", "bearing assembly", "完整轴承", "轴承组件"],
"bridge": "bridge/planning-bearing-housing.md",
"source": "source/planning/bearing-housing-or-seat.planning.md",
"capability_translation_rules": ["blocked: selector_required_for_dependent_features"],
"related": ["functional/bearing-bore-seat", "functional/standard-hole-wizard", "atomic/coaxial-bore-rule", "atomic/extrude-base-profile", "atomic/revolve-profile-around-axis", "atomic/counterbored-hole-creation", "atomic/fillet-chamfer-last"]
},
{
@@ -72,25 +67,24 @@
"exclude": ["bolt", "screw", "wing nut", "cap nut", "flange nut", "螺栓", "螺钉", "蝶形螺母", "法兰螺母"],
"bridge": "bridge/planning-hexagonal-nut.md",
"source": "source/planning/hexagonal-nut.planning.md",
"capability_translation_rules": ["approximated: thread_geometry_omitted"],
"related": ["functional/axisymmetric-revolve-strategy", "functional/standard-hole-wizard", "atomic/extrude-base-profile", "atomic/coaxial-bore-rule", "atomic/threaded-hole-creation", "atomic/fillet-chamfer-last"]
},
{"id": "functional/axisymmetric-revolve-strategy", "kind": "functional", "title": "Axisymmetric Revolve", "priority": 80, "triggers": ["axisymmetric", "revolve", "revolved body", "rotational part", "turned part", "回转体", "轴对称", "车削件"], "exclude": ["simple extruded cylinder", "rectangular plate"], "bridge": "bridge/functional-axisymmetric-revolve.md", "source": "source/functional/axisymmetric-revolve-strategy.functional.md", "capability_translation_rules": ["exact: explicit_revolve_axis"], "related": []},
{"id": "functional/bearing-bore-seat", "kind": "functional", "title": "Bearing Bore Seat", "priority": 79, "triggers": ["bearing bore", "bearing pocket", "bearing seat", "bushing bore", "shaft clearance bore", "轴承孔", "轴承座孔", "轴承腔", "轴间隙孔"], "exclude": [], "bridge": "bridge/functional-bearing-bore-seat.md", "source": "source/functional/bearing-bore-seat.functional.md", "capability_translation_rules": ["blocked: selector_required_for_dependent_features"], "related": ["atomic/coaxial-bore-rule", "atomic/counterbored-hole-creation"]},
{"id": "functional/flange-bolt-circle", "kind": "functional", "title": "Flange Bolt Circle", "priority": 78, "triggers": ["flange bolt circle", "bolt circle", "pitch circle", "pcd", "circular bolt pattern", "螺栓圆", "节圆", "圆周孔阵列"], "exclude": ["rectangular hole grid", "矩形孔阵列"], "bridge": "bridge/functional-flange-bolt-circle.md", "source": "source/functional/flange-bolt-circle.functional.md", "capability_translation_rules": ["expanded: circular_pattern_to_explicit_circles", "blocked: explicit_circle_layout_required"], "related": ["atomic/pattern-holes-from-datum"]},
{"id": "functional/mounting-plate-hole-layout", "kind": "functional", "title": "Mounting Plate Hole Layout", "priority": 77, "triggers": ["mounting plate hole layout", "base plate hole pattern", "rectangular hole grid", "symmetric mounting holes", "底板孔布局", "矩形孔阵列", "安装孔阵列"], "exclude": ["flange bolt circle", "bearing bore"], "bridge": "bridge/functional-mounting-plate-hole-layout.md", "source": "source/functional/mounting-plate-hole-layout.functional.md", "capability_translation_rules": ["exact: linear_or_mirror_pattern"], "related": ["atomic/pattern-holes-from-datum", "functional/symmetric-feature-layout"]},
{"id": "functional/slotted-adjustment-feature", "kind": "functional", "title": "Slotted Adjustment Feature", "priority": 76, "triggers": ["slot", "slotted hole", "adjustment slot", "elongated hole", "guide slot", "槽", "长圆槽", "调节槽", "导向槽"], "exclude": ["shaft keyway", "revolved groove", "轴键槽", "回转槽"], "bridge": "bridge/functional-slotted-adjustment.md", "source": "source/functional/slotted-adjustment-feature.functional.md", "capability_translation_rules": ["exact: obround_extrude_cut"], "related": []},
{"id": "functional/standard-hole-wizard", "kind": "functional", "title": "Standard Hole Wizard", "priority": 75, "triggers": ["hole wizard", "standard holes", "threaded holes", "counterbored holes", "countersunk holes", "tapped holes", "孔向导", "标准孔", "螺纹孔", "沉孔", "沉头孔"], "exclude": [], "bridge": "bridge/functional-standard-hole-wizard.md", "source": "source/functional/standard-hole-wizard.functional.md", "capability_translation_rules": ["approximated: thread_geometry_omitted", "exact: supported_hole_subtype"], "related": ["atomic/counterbored-hole-creation", "atomic/threaded-hole-creation", "atomic/through-hole-cut"]},
{"id": "functional/symmetric-feature-layout", "kind": "functional", "title": "Symmetric Feature Layout", "priority": 74, "triggers": ["symmetric holes", "mirror pattern", "symmetry", "mirrored features", "centerline layout", "对称孔", "镜像阵列", "对称布局", "中心线布局"], "exclude": [], "bridge": "bridge/functional-symmetric-layout.md", "source": "source/functional/symmetric-feature-layout.functional.md", "capability_translation_rules": ["exact: pattern_mirror_or_pattern_linear"], "related": []},
{"id": "functional/axisymmetric-revolve-strategy", "kind": "functional", "title": "Axisymmetric Revolve", "priority": 80, "triggers": ["axisymmetric", "revolve", "revolved body", "rotational part", "turned part", "回转体", "轴对称", "车削件"], "exclude": ["simple extruded cylinder", "rectangular plate"], "bridge": "bridge/functional-axisymmetric-revolve.md", "source": "source/functional/axisymmetric-revolve-strategy.functional.md", "related": []},
{"id": "functional/bearing-bore-seat", "kind": "functional", "title": "Bearing Bore Seat", "priority": 79, "triggers": ["bearing bore", "bearing pocket", "bearing seat", "bushing bore", "shaft clearance bore", "轴承孔", "轴承座孔", "轴承腔", "轴间隙孔"], "exclude": [], "bridge": "bridge/functional-bearing-bore-seat.md", "source": "source/functional/bearing-bore-seat.functional.md", "related": ["atomic/coaxial-bore-rule", "atomic/counterbored-hole-creation"]},
{"id": "functional/flange-bolt-circle", "kind": "functional", "title": "Flange Bolt Circle", "priority": 78, "triggers": ["flange bolt circle", "bolt circle", "pitch circle", "pcd", "circular bolt pattern", "螺栓圆", "节圆", "圆周孔阵列"], "exclude": ["rectangular hole grid", "矩形孔阵列"], "bridge": "bridge/functional-flange-bolt-circle.md", "source": "source/functional/flange-bolt-circle.functional.md", "related": ["atomic/pattern-holes-from-datum"]},
{"id": "functional/mounting-plate-hole-layout", "kind": "functional", "title": "Mounting Plate Hole Layout", "priority": 77, "triggers": ["mounting plate hole layout", "base plate hole pattern", "rectangular hole grid", "symmetric mounting holes", "底板孔布局", "矩形孔阵列", "安装孔阵列"], "exclude": ["flange bolt circle", "bearing bore"], "bridge": "bridge/functional-mounting-plate-hole-layout.md", "source": "source/functional/mounting-plate-hole-layout.functional.md", "related": ["atomic/pattern-holes-from-datum", "functional/symmetric-feature-layout"]},
{"id": "functional/slotted-adjustment-feature", "kind": "functional", "title": "Slotted Adjustment Feature", "priority": 76, "triggers": ["slot", "slotted hole", "adjustment slot", "elongated hole", "guide slot", "槽", "长圆槽", "调节槽", "导向槽"], "exclude": ["shaft keyway", "revolved groove", "轴键槽", "回转槽"], "bridge": "bridge/functional-slotted-adjustment.md", "source": "source/functional/slotted-adjustment-feature.functional.md", "related": []},
{"id": "functional/standard-hole-wizard", "kind": "functional", "title": "Standard Hole Wizard", "priority": 75, "triggers": ["hole wizard", "standard holes", "threaded holes", "counterbored holes", "countersunk holes", "tapped holes", "孔向导", "标准孔", "螺纹孔", "沉孔", "沉头孔"], "exclude": [], "bridge": "bridge/functional-standard-hole-wizard.md", "source": "source/functional/standard-hole-wizard.functional.md", "related": ["atomic/counterbored-hole-creation", "atomic/threaded-hole-creation", "atomic/through-hole-cut"]},
{"id": "functional/symmetric-feature-layout", "kind": "functional", "title": "Symmetric Feature Layout", "priority": 74, "triggers": ["symmetric holes", "mirror pattern", "symmetry", "mirrored features", "centerline layout", "对称孔", "镜像阵列", "对称布局", "中心线布局"], "exclude": [], "bridge": "bridge/functional-symmetric-layout.md", "source": "source/functional/symmetric-feature-layout.functional.md", "related": []},
{"id": "atomic/coaxial-bore-rule", "kind": "atomic", "title": "Coaxial Bore", "priority": 60, "triggers": ["coaxial bore", "central bore", "concentric", "shaft clearance", "同轴孔", "中心孔", "同心", "轴间隙"], "exclude": [], "bridge": "bridge/atomic-coaxial-bore.md", "source": "source/atomic/coaxial-bore-rule.atomic.md", "capability_translation_rules": ["exact: explicit_axis_or_frame"], "related": []},
{"id": "atomic/counterbored-hole-creation", "kind": "atomic", "title": "Counterbored Hole", "priority": 59, "triggers": ["counterbored hole", "counterbore", "socket head screw", "cap screw", "recessed screw head", "沉孔", "内六角螺钉", "螺钉头沉孔"], "exclude": [], "bridge": "bridge/atomic-counterbore.md", "source": "source/atomic/counterbored-hole-creation.atomic.md", "capability_translation_rules": ["exact: hole_counterbore"], "related": []},
{"id": "atomic/extrude-base-profile", "kind": "atomic", "title": "Extrude Base Profile", "priority": 58, "triggers": ["extrude base profile", "extrude rectangle", "extrude circle", "base extrusion", "simple cylinder", "rectangular block", "基础拉伸", "矩形拉伸", "圆柱拉伸"], "exclude": [], "bridge": "bridge/atomic-extrude-base.md", "source": "source/atomic/extrude-base-profile.atomic.md", "capability_translation_rules": ["exact: extrude_add_blind"], "related": []},
{"id": "atomic/fillet-chamfer-last", "kind": "atomic", "title": "Fillet And Chamfer Last", "priority": 57, "triggers": ["fillet chamfer last", "edge treatment", "chamfered edges", "filleted edges", "最后倒角", "最后圆角", "边缘处理"], "exclude": [], "bridge": "bridge/atomic-fillet-chamfer-last.md", "source": "source/atomic/fillet-chamfer-last.atomic.md", "capability_translation_rules": ["omitted: selector_unavailable", "exact: unique_selector"], "related": []},
{"id": "atomic/pattern-holes-from-datum", "kind": "atomic", "title": "Pattern Holes From Datum", "priority": 56, "triggers": ["hole pattern", "pattern holes from datum", "repeated holes", "symmetric holes", "孔阵列", "重复孔", "基准孔阵列", "对称孔"], "exclude": [], "bridge": "bridge/atomic-pattern-holes.md", "source": "source/atomic/pattern-holes-from-datum.atomic.md", "capability_translation_rules": ["expanded: explicit_circle_layout", "exact: pattern_linear_or_pattern_mirror"], "related": []},
{"id": "atomic/revolve-profile-around-axis", "kind": "atomic", "title": "Revolve Profile Around Axis", "priority": 55, "triggers": ["revolve profile", "revolve around axis", "revolved cut", "axisymmetric profile", "截面回转", "绕轴回转", "回转切除"], "exclude": [], "bridge": "bridge/atomic-revolve-profile.md", "source": "source/atomic/revolve-profile-around-axis.atomic.md", "capability_translation_rules": ["exact: revolve_add_or_revolve_cut"], "related": []},
{"id": "atomic/threaded-hole-creation", "kind": "atomic", "title": "Threaded Hole", "priority": 54, "triggers": ["threaded hole", "tapped hole", "internal thread", "m3", "m4", "m5", "m6", "螺纹孔", "攻丝孔", "内螺纹"], "exclude": [], "bridge": "bridge/atomic-threaded-hole.md", "source": "source/atomic/threaded-hole-creation.atomic.md", "capability_translation_rules": ["approximated: thread_geometry_omitted"], "related": []},
{"id": "atomic/through-hole-cut", "kind": "atomic", "title": "Through Hole Cut", "priority": 53, "triggers": ["through hole", "clearance hole", "central hole", "circular opening", "cut through", "bore through", "通孔", "间隙孔", "贯穿孔", "切穿"], "exclude": [], "bridge": "bridge/atomic-through-hole.md", "source": "source/atomic/through-hole-cut.atomic.md", "capability_translation_rules": ["exact: through_hole_or_explicit_cut"], "related": []}
{"id": "atomic/coaxial-bore-rule", "kind": "atomic", "title": "Coaxial Bore", "priority": 60, "triggers": ["coaxial bore", "central bore", "concentric", "shaft clearance", "同轴孔", "中心孔", "同心", "轴间隙"], "exclude": [], "bridge": "bridge/atomic-coaxial-bore.md", "source": "source/atomic/coaxial-bore-rule.atomic.md", "related": []},
{"id": "atomic/counterbored-hole-creation", "kind": "atomic", "title": "Counterbored Hole", "priority": 59, "triggers": ["counterbored hole", "counterbore", "socket head screw", "cap screw", "recessed screw head", "沉孔", "内六角螺钉", "螺钉头沉孔"], "exclude": [], "bridge": "bridge/atomic-counterbore.md", "source": "source/atomic/counterbored-hole-creation.atomic.md", "related": []},
{"id": "atomic/extrude-base-profile", "kind": "atomic", "title": "Extrude Base Profile", "priority": 58, "triggers": ["extrude base profile", "extrude rectangle", "extrude circle", "base extrusion", "simple cylinder", "rectangular block", "基础拉伸", "矩形拉伸", "圆柱拉伸"], "exclude": [], "bridge": "bridge/atomic-extrude-base.md", "source": "source/atomic/extrude-base-profile.atomic.md", "related": []},
{"id": "atomic/fillet-chamfer-last", "kind": "atomic", "title": "Fillet And Chamfer Last", "priority": 57, "triggers": ["fillet chamfer last", "edge treatment", "chamfered edges", "filleted edges", "最后倒角", "最后圆角", "边缘处理"], "exclude": [], "bridge": "bridge/atomic-fillet-chamfer-last.md", "source": "source/atomic/fillet-chamfer-last.atomic.md", "related": []},
{"id": "atomic/pattern-holes-from-datum", "kind": "atomic", "title": "Pattern Holes From Datum", "priority": 56, "triggers": ["hole pattern", "pattern holes from datum", "repeated holes", "symmetric holes", "孔阵列", "重复孔", "基准孔阵列", "对称孔"], "exclude": [], "bridge": "bridge/atomic-pattern-holes.md", "source": "source/atomic/pattern-holes-from-datum.atomic.md", "related": []},
{"id": "atomic/revolve-profile-around-axis", "kind": "atomic", "title": "Revolve Profile Around Axis", "priority": 55, "triggers": ["revolve profile", "revolve around axis", "revolved cut", "axisymmetric profile", "截面回转", "绕轴回转", "回转切除"], "exclude": [], "bridge": "bridge/atomic-revolve-profile.md", "source": "source/atomic/revolve-profile-around-axis.atomic.md", "related": []},
{"id": "atomic/threaded-hole-creation", "kind": "atomic", "title": "Threaded Hole", "priority": 54, "triggers": ["threaded hole", "tapped hole", "internal thread", "m3", "m4", "m5", "m6", "螺纹孔", "攻丝孔", "内螺纹"], "exclude": [], "bridge": "bridge/atomic-threaded-hole.md", "source": "source/atomic/threaded-hole-creation.atomic.md", "related": []},
{"id": "atomic/through-hole-cut", "kind": "atomic", "title": "Through Hole Cut", "priority": 53, "triggers": ["through hole", "clearance hole", "central hole", "circular opening", "cut through", "bore through", "通孔", "间隙孔", "贯穿孔", "切穿"], "exclude": [], "bridge": "bridge/atomic-through-hole.md", "source": "source/atomic/through-hole-cut.atomic.md", "related": []}
]
}
+33 -24
View File
@@ -57,6 +57,7 @@ async def config() -> dict[str, Any]:
"model": settings.llm_model,
"configured": settings.llm_configured,
"library_samples": library.count(),
"max_repair_attempts": settings.max_repair_attempts,
}
@@ -130,30 +131,6 @@ async def read_task(task_id: str) -> JSONResponse:
return JSONResponse(task)
@app.get("/v1/tasks/{task_id}/design-intent")
async def read_current_design_intent(task_id: str) -> JSONResponse:
try:
safe_id = safe_task_id(task_id)
result = store.read_design_intent(safe_id)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
if result is None:
raise HTTPException(status_code=404, detail="No DesignIntent exists for this task")
return JSONResponse({"task_id": safe_id, "intent_id": result["record"]["intent_id"], **result})
@app.get("/v1/tasks/{task_id}/design-intents/{intent_id}")
async def read_design_intent(task_id: str, intent_id: str) -> JSONResponse:
try:
safe_id = safe_task_id(task_id)
result = store.read_design_intent(safe_id, intent_id)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
if result is None:
raise HTTPException(status_code=404, detail="DesignIntent not found")
return JSONResponse({"task_id": safe_id, "intent_id": result["record"]["intent_id"], **result})
@app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}")
async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
from fastapi.responses import FileResponse
@@ -186,6 +163,38 @@ async def read_parameters(task_id: str) -> JSONResponse:
return JSONResponse({"task_id": safe_id, "revision_id": revision_id, **json.loads(path.read_text(encoding="utf-8"))})
@app.get("/v1/tasks/{task_id}/quality")
async def read_quality(task_id: str) -> JSONResponse:
try:
safe_id = safe_task_id(task_id)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
task = store.read_task(safe_id)
revision_id = str((task or {}).get("current_revision") or "")
revisions = (task or {}).get("revisions", [])
revision = next((item for item in revisions if item.get("revision_id") == revision_id), None)
if revisions and revisions[-1].get("revision_id") != revision_id:
revision = revisions[-1]
revision_id = str(revision.get("revision_id") or "")
payload: dict[str, Any] = {
"task_id": safe_id,
"revision_id": revision_id,
"quality_status": (revision or {}).get("quality_status", ""),
"snapshot_status": (revision or {}).get("snapshot_status", "unavailable"),
"snapshot_paths": (revision or {}).get("snapshot_paths", []),
"assumptions": (revision or {}).get("generation_assumptions", []),
"verification_summary": (revision or {}).get("verification_summary", {}),
}
relative = str((revision or {}).get("quality_path") or "")
if relative:
path = store.artifact_path(safe_id, relative)
if path.is_file():
payload["quality"] = json.loads(path.read_text(encoding="utf-8"))
if not revision:
raise HTTPException(status_code=404, detail="Task has no revision")
return JSONResponse(payload)
@app.post("/v1/tasks/{task_id}/parameters")
async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONResponse:
try:
+7
View File
@@ -59,3 +59,10 @@ class CadResult(BaseModel):
summary: str
reference_ids: list[str] = Field(default_factory=list)
engine: str = "cdsl_only"
quality_status: str = ""
quality_path: str | None = None
assumptions: list[str] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
repair_attempts: int = 0
snapshot_paths: list[str] = Field(default_factory=list)
snapshot_status: str = "unavailable"
+340 -184
View File
@@ -5,7 +5,9 @@ import base64
from copy import deepcopy
import json
import math
import re
import secrets
import sys
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
@@ -14,13 +16,10 @@ import httpx
from app.models.contracts import ChatMessage
from app.services.engine_service import build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl
from app.services.flange_sleeve_template import (
TEMPLATE_ID as FLANGE_SLEEVE_TEMPLATE_ID,
build_flange_sleeve_cdsl,
flange_sleeve_plan_schema,
)
from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch
from app.services.library import CdslLibrary
from app.services.part_skills import PartSkillLibrary
from app.services.quality import QUALITY_RULE_TYPES, validate_verification
from app.services.sse import event
from app.services.storage import WorkspaceStore, now_iso
from app.settings import ProviderConfig, ProviderModel, Settings
@@ -42,6 +41,14 @@ class RepeatedToolArgumentsError(RuntimeError):
self.diagnostic_paths = diagnostic_paths or []
class CdslRepairLimitError(RuntimeError):
"""The direct CDSL repair budget is exhausted for this request."""
def __init__(self, diagnostic_paths: list[str]) -> None:
super().__init__("CDSL repair limit reached")
self.diagnostic_paths = diagnostic_paths
def user_visible_error_message(error: Exception, user_text: str) -> str:
if isinstance(error, StrictToolSchemaError) and any(
"\u4e00" <= char <= "\u9fff" for char in str(user_text or "")
@@ -61,6 +68,11 @@ def user_visible_error_message(error: Exception, user_text: str) -> str:
"请检查所选模型的函数调用兼容性;若仍出现此错误,请关闭该模型的严格工具 schema 开关后再试。"
+ diagnostics
)
if isinstance(error, CdslRepairLimitError):
diagnostics = "".join(error.diagnostic_paths)
if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")):
return "模型重试达到四次 CDSL 修复上限,未创建新的成功 revision。每次 CDSL 校验失败的诊断已保存到:" + diagnostics + ""
return "The four-attempt CDSL repair limit was reached. Diagnostics were saved to: " + diagnostics + "."
return str(error)
@@ -84,7 +96,7 @@ def _repair_premature_tool_wrapper_close(source: str, parsed_value: Any, parsed_
return None
if candidate[candidate_end:].strip() or not isinstance(value, dict):
return None
if not set(value).issubset({"cdsl", "summary", "assumptions"}):
if not set(value).issubset({"cdsl", "summary", "assumptions", "verification"}):
return None
if not isinstance(value.get("cdsl"), dict) or not isinstance(value.get("summary"), str):
return None
@@ -135,10 +147,38 @@ def invalid_tool_arguments_result(name: str, error: ToolArgumentsError) -> dict[
}
def invalid_cdsl_result(error: ValueError) -> dict[str, Any]:
def _cdsl_error_details(error: Exception) -> dict[str, Any]:
message = str(error)
path_match = re.search(r"(?:at|path) (\$[^: ]*)", message)
quality = getattr(error, "quality_report", None)
if isinstance(quality, dict):
return {
"code": "VERIFICATION_FAILED",
"path": "$.verification.rules",
"kind": "verification",
"repair_instruction": "Correct the CDSL feature geometry or the verification rule, then submit a local patch or complete replacement.",
"quality": quality,
}
if "schema violation" in message:
return {
"code": "CDSL_SCHEMA_INVALID",
"path": path_match.group(1) if path_match else "$",
"kind": "schema",
"repair_instruction": "Correct the field at the reported JSONPath using the authoritative CDSL schema.",
}
return {
"code": "CDSL_RUNTIME_INVALID",
"path": path_match.group(1) if path_match else "$",
"kind": "runtime",
"repair_instruction": "Correct the invalid CDSL structure, dependency, selector, or runtime parameter and retry.",
}
def invalid_cdsl_result(error: Exception) -> dict[str, Any]:
details = _cdsl_error_details(error)
return {
"ok": False,
"code": "INVALID_CDSL",
**details,
"message": (
f"The submitted CDSL is incomplete or invalid: {error}. "
"Read the authoritative local engine schema, then call generate_cdsl_model "
@@ -149,7 +189,7 @@ def invalid_cdsl_result(error: ValueError) -> dict[str, Any]:
def user_visible_tool_message(result: dict[str, Any], user_text: str) -> str:
code = str(result.get("code") or "")
if code == "INVALID_CDSL":
if code in {"CDSL_SCHEMA_INVALID", "CDSL_RUNTIME_INVALID", "VERIFICATION_FAILED"}:
if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")):
return "CDSL 不符合 engine 的模型契约,正在请求模型按 schema 修正后重新生成。"
return "The CDSL model does not match the engine contract. Asking the model to correct it and retry."
@@ -185,14 +225,109 @@ def _cdsl_tool_schema() -> dict[str, Any]:
schema_name = str(contract.get("cdsl_json_schema_file") or "")
if not schema_name or Path(schema_name).name != schema_name:
raise RuntimeError("Local engine contract has no valid CDSL JSON Schema path")
return json.loads((engine_dir / schema_name).read_text(encoding="utf-8"))
schema = json.loads((engine_dir / schema_name).read_text(encoding="utf-8"))
supported_atomics = {
str(item)
for item in contract.get("runtime_supported_atomic_ids") or ()
if str(item)
}
declared_atomics = set((contract.get("feature_atomic_ids") or {}).keys())
if not supported_atomics or not supported_atomics.issubset(declared_atomics):
raise RuntimeError("Engine capability contract has invalid runtime atomic ids")
engine_parent = str(engine_dir.parent)
if engine_parent not in sys.path:
sys.path.insert(0, engine_parent)
import cdsl_engine
registered_atomics = {str(item) for item in getattr(cdsl_engine, "SUPPORTED_ATOMIC_IDS", ())}
supported_atomics &= registered_atomics
if not supported_atomics:
raise RuntimeError("Engine has no registered atomic executors in common with its capability contract")
feature_atomic_definition = schema.get("$defs", {}).get("feature_atomic_ids")
if not isinstance(feature_atomic_definition, dict):
raise RuntimeError("Local CDSL JSON Schema has no feature atomic definition")
feature_atomic_definition["enum"] = sorted(supported_atomics)
profile_definition = schema.get("$defs", {}).get("profile_type")
supported_profiles = {
str(item)
for item in contract.get("runtime_supported_profiles") or ()
if str(item)
}
supported_profiles &= {str(item) for item in getattr(cdsl_engine, "SHAPE_GENERATORS", ())}
if isinstance(profile_definition, dict) and supported_profiles:
profile_definition["enum"] = sorted(supported_profiles)
return schema
except (OSError, json.JSONDecodeError, AttributeError) as error:
raise RuntimeError("Local CDSL JSON Schema is unavailable or invalid") from error
CDSL_TOOL_SCHEMA = _cdsl_tool_schema()
FLANGE_SLEEVE_PLAN_SCHEMA = flange_sleeve_plan_schema()
GENERATION_TOOL_NAMES = {"generate_cdsl_model", "generate_flange_sleeve_model"}
GENERATION_TOOL_NAMES = {"generate_cdsl_model", "patch_cdsl_model"}
VERIFICATION_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"rules": {
"type": "array",
"maxItems": 32,
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1},
"type": {"enum": sorted(QUALITY_RULE_TYPES)},
"feature": {"type": "string"},
"expected": {
"description": (
"For bbox, use [dx, dy, dz], "
"{min: [x, y, z], max: [x, y, z]}, or "
"{x_min, x_max, y_min, y_max, z_min, z_max}."
),
},
"tolerance": {"type": "number", "minimum": 0},
"severity": {"enum": ["blocking", "warning", "informational"]},
},
"required": ["id", "type", "expected"],
"additionalProperties": False,
},
},
},
"required": ["rules"],
"additionalProperties": False,
}
JSON_PATCH_OPERATION_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"op": {"enum": ["add", "remove", "replace", "move", "copy", "test"]},
"path": {"type": "string", "pattern": "^/"},
"from": {"type": "string", "pattern": "^/"},
"value": {},
},
"required": ["op", "path"],
"additionalProperties": False,
}
def engine_capability_manifest(settings: Settings) -> dict[str, Any]:
"""Build the compact planner-facing capability contract from engine files."""
load_engine(settings)
try:
profile = json.loads((settings.engine_root / "profile_schema.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"supported_profiles": [], "runtime_atomic_ids": [], "required_params": {}, "unsupported_profiles": []}
atomic_contracts = profile.get("feature_atomic_ids") if isinstance(profile.get("feature_atomic_ids"), dict) else {}
declared_atomics = {str(item) for item in profile.get("runtime_supported_atomic_ids") or atomic_contracts}
registered_atomics = {str(item) for item in getattr(load_engine(settings), "SUPPORTED_ATOMIC_IDS", ())}
supported_atomics = sorted(declared_atomics & registered_atomics)
declared_profiles = {str(item) for item in profile.get("runtime_supported_profiles") or ()}
registered_profiles = {str(item) for item in getattr(load_engine(settings), "SHAPE_GENERATORS", ())}
return {
"supported_profiles": sorted(declared_profiles & registered_profiles),
"runtime_atomic_ids": supported_atomics,
"required_params": {str(key): list(value.get("required_params") or []) for key, value in atomic_contracts.items() if isinstance(value, dict)},
"unsupported_profiles": sorted(str(item) for item in profile.get("unsupported_profiles") or []),
"verification_rule_types": sorted(QUALITY_RULE_TYPES),
}
TOOL_SCHEMAS: list[dict[str, Any]] = [
@@ -285,29 +420,6 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
},
},
{
"type": "function",
"function": {
"name": "generate_flange_sleeve_model",
"description": (
"Generate a square or rectangular flange sleeve from a compact semantic plan. "
"Use this preferred tool for a flange plate with a coaxial hollow tube, "
"a stepped or tapered tip, four mounting holes, and counterbores. "
"The backend's verified template creates all CDSL workplanes, sketches, "
"feature dependencies, and feature parameters."
),
"parameters": {
"type": "object",
"properties": {
"plan": FLANGE_SLEEVE_PLAN_SCHEMA,
"summary": {"type": "string", "minLength": 1},
"assumptions": {"type": "array", "items": {"type": "string"}},
},
"required": ["plan", "summary", "assumptions"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
@@ -319,12 +431,32 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [
"cdsl": CDSL_TOOL_SCHEMA,
"summary": {"type": "string", "minLength": 1},
"assumptions": {"type": "array", "items": {"type": "string"}},
"verification": VERIFICATION_SCHEMA,
},
"required": ["cdsl", "summary", "assumptions"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "patch_cdsl_model",
"description": "Apply RFC 6902 JSON Patch operations to one existing CDSL revision, validate and rebuild it as a new revision.",
"parameters": {
"type": "object",
"properties": {
"base_revision_id": {"type": "string", "minLength": 1},
"patches": {"type": "array", "minItems": 1, "maxItems": 32, "items": JSON_PATCH_OPERATION_SCHEMA},
"summary": {"type": "string", "minLength": 1},
"assumptions": {"type": "array", "items": {"type": "string"}},
"verification": VERIFICATION_SCHEMA,
},
"required": ["base_revision_id", "patches", "summary", "assumptions"],
"additionalProperties": False,
},
},
},
]
@@ -332,7 +464,6 @@ def tools_for_model(
model: ProviderModel,
*,
include_image_analysis: bool = True,
flange_sleeve_only: bool = False,
) -> list[dict[str, Any]]:
"""Return this model's tool contract without mutating the shared schema."""
tools = deepcopy(TOOL_SCHEMAS)
@@ -342,17 +473,6 @@ def tools_for_model(
for tool in tools
if tool.get("function", {}).get("name") != "analyze_image_reference"
]
if flange_sleeve_only:
tools = [
tool
for tool in tools
if tool.get("function", {}).get("name") not in {
"search_cdsl_library",
"read_cdsl_reference",
"read_current_cdsl",
"generate_cdsl_model",
}
]
if not model.strict_tool_schema:
return tools
@@ -445,33 +565,6 @@ def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | N
return None
def use_flange_sleeve_template(analysis: dict[str, Any] | None, task_id: str) -> bool:
"""Route only a high-confidence new image reference to the verified template."""
if task_id or not isinstance(analysis, dict):
return False
visible = analysis.get("visibleFeatures", analysis.get("visible_features", []))
values = [analysis.get("partType", analysis.get("part_type", ""))]
if isinstance(visible, list):
values.extend(visible)
text = " ".join(str(value or "").casefold() for value in values)
has_flange = "法兰" in text or "flange" in text
has_sleeve = any(token in text for token in ("套筒", "管座", "圆筒", "cylind", "sleeve", "tube"))
return has_flange and has_sleeve
def flange_sleeve_template_instruction(enabled: bool) -> str:
if not enabled:
return ""
return """
Template routing (highest priority for this request):
- This new image reference has been classified as a flange sleeve.
- After describe_design_intent, call generate_flange_sleeve_model exactly once.
- The complete-CDSL generation tool is intentionally unavailable for this request.
- Supply only visible semantic dimensions and assumptions; omitted dimensions use
the verified template defaults. Do not ask for CDSL implementation fields.
"""
def image_reference_instruction(
attachments: list[dict[str, Any]],
analysis: dict[str, Any] | None,
@@ -635,15 +728,30 @@ def system_prompt(
part_skill_context: str = "",
image_reference_context: str = "",
cad_request_context: str = "",
flange_sleeve_template_mode: bool = False,
) -> str:
capability_manifest = json.dumps(engine_capability_manifest(settings), ensure_ascii=False, separators=(",", ":"))
skill_path = settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "SKILL.md"
skill = skill_path.read_text(encoding="utf-8") if skill_path.is_file() else ""
readme_path = settings.engine_root / "README.md"
engine_readme = readme_path.read_text(encoding="utf-8") if readme_path.is_file() else ""
profile_schema_path = settings.engine_root / "profile_schema.json"
profile_schema = profile_schema_path.read_text(encoding="utf-8") if profile_schema_path.is_file() else ""
supported_profiles = ", ".join(sorted(load_engine(settings).SHAPE_GENERATORS))
generation_contract = """- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly, not as Markdown or a JSON string.
- For a revision, call read_current_cdsl before generate_cdsl_model. Preserve unrelated CDSL features unless the user requests whole-part replacement.
- Use patch_cdsl_model only for a local RFC 6902 repair of a known base_revision_id. For structural changes, submit a complete replacement CDSL with generate_cdsl_model.
- For every feature.atomic_id, use only an ID listed in the compact capability manifest's runtime_atomic_ids. Other semantic contracts are not executable in this runtime.
- The backend normalizes only these unambiguous aliases: sketch_id -> id on sketches, sketch -> sketch_id on features, legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm.
- Every explicit user dimension, feature count, hole size, or hole position that can be measured must have a generic verification rule. Rule feature values must be IDs from the submitted CDSL.
- Do not output build123d source, compiler_context, unknown_shape, complex_arc_shape, entities, contour_edges_mm, or contour_regions_mm."""
workflow = """3. Search the local official CDSL library after the design brief. Read a relevant reference when a match exists; samples are expression guidance, not templates or higher-priority requirements.
4. Generate one complete generic CDSL and call generate_cdsl_model.
5. On a schema, runtime, or verification failure, use patch_cdsl_model for a local correction or generate_cdsl_model for a complete replacement.
6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact."""
authoring_context = f"""You own the complete CDSL: profiles, workplanes, sketch IDs, feature IDs, dependencies, selectors, and atomic IDs must be valid under the engine schema. Do not invent unsupported capabilities.
Local skill:
{skill}
Authoritative engine schema:
{profile_schema}"""
return f"""You are the CDSL CAD Agent for CDSL CAD Studio.
Language policy:
@@ -661,31 +769,12 @@ Language policy:
Tool call contract:
- Every function call arguments field must contain exactly one valid JSON object.
- Do not append prose, Markdown code fences, comments, or a second JSON value.
- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly,
not as Markdown and not as a concatenated JSON string.
- For a square/rectangular flange with a coaxial hollow sleeve, stepped or
tapered front, and four mounting counterbores, call
generate_flange_sleeve_model. Submit only its compact semantic plan; do not
create workplanes, sketch IDs, feature IDs, dependencies, or raw CDSL for
that template. The backend creates and validates the complete CDSL.
- The backend can normalize only these unambiguous legacy aliases before
validation: sketch_id -> id on sketches, sketch -> sketch_id on features,
legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm.
Do not rely on it for geometry, dimensions, selectors, or feature intent.
- Call describe_design_intent first with a concise natural-language plan.
Its returned text is reference context for your CDSL work, not a second CAD
contract and not a source of feature IDs, profile bindings, or selectors.
- Do not call search_cdsl_library, read_cdsl_reference,
generate_flange_sleeve_model, or generate_cdsl_model until
describe_design_intent returns its plan.
- The generate_cdsl_model `cdsl` parameter is the complete machine-enforced
schema. Satisfy its nested object and array types exactly; do not substitute
a shorthand array for an object. For example, every hole position is
`{{"mm": [u_mm, v_mm, w_mm]}}`, never `[u_mm, v_mm]`.
- If a tool reports INVALID_TOOL_ARGUMENTS, correct the arguments and call that
tool again. Do not claim that the CAD model was generated.
- If a generation tool reports INVALID_CDSL, correct its plan or complete CDSL
as applicable and call that same tool again. Do not claim success.
{generation_contract}
Workflow limits:
- Do not expose internal planning or "let me" commentary to the user while
@@ -698,7 +787,7 @@ Workflow limits:
the missing dimension or decision. Do not combine it with a tool trace or a
generic progress update.
- Use at most two CDSL-library searches per user request. If neither finds a
useful reference, stop searching and use the engine guide to either generate
useful reference, stop searching and use the available capability contract to either generate
the model or ask one concise clarification question.
- Do not repeatedly search for the same unavailable feature or profile.
@@ -708,24 +797,12 @@ Workflow limits:
{cad_request_context}
{flange_sleeve_template_instruction(flange_sleeve_template_mode)}
You generate executable CAD through compact semantic plans or parameterized CDSL,
never raw CAD source code. For new CAD requests:
You generate executable CAD through complete parameterized CDSL, never raw CAD source code. For new CAD requests:
1. Use the injected part-skill guidance, when present, only to establish the
structural plan, feature dependency order, and parameter roles.
structural plan, feature dependency order, parameter roles, and reference queries.
2. Call describe_design_intent with a concise textual plan. Ask one concise
user-facing question before CAD generation if essential dimensions are missing.
3. If the requested family is a flange sleeve matching the dedicated template,
call generate_flange_sleeve_model immediately after the textual plan. Do not
search the CDSL library and do not use generate_cdsl_model for that case.
4. For other families, search the local official CDSL library. Read at least
one relevant reference when a match exists; samples provide schema-valid
expressions, not higher-priority user requirements.
5. For non-template families, generate one complete CDSL and call
generate_cdsl_model. The backend-generated CDSL is the authoritative CAD
model; the textual plan is guidance only.
6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact.
{workflow}
Precedence is strict: explicit user request, then CDSL schema/runtime, then
part-skill guidance, then CDSL-library examples. Part skills never authorize
@@ -738,29 +815,10 @@ whole-part replacement. A primary-family conflict is a hard clarification
stop: ask one concise question and do not call a generation tool until the
user resolves it.
For a revision, call read_current_cdsl first and preserve unrelated features.
Use the flange sleeve template only when the user wants to replace the whole
part or the current part already follows that family; otherwise submit the
complete replacement CDSL. The backend automatically uses the latest
successful revision as the parent; do not provide a revision ID.
Do not output compiler_context, unknown_shape, complex_arc_shape, entities,
contour_edges_mm, or contour_regions_mm. Use only self-contained named profiles
and feature atomic IDs defined in the engine schema below. Read the engine
schema before selecting an atomic ID, a profile, or their parameter names. Do
not invent an atomic ID, profile, or their fields. Ask a concise clarification question when essential
dimensions or intent are missing. Ordinary explanations must not create CAD.
{authoring_context}
Local skill:
{skill}
Local engine guide:
{engine_readme}
Authoritative engine schema:
{profile_schema}
Supported named profile types:
{supported_profiles}
Compact Engine Capability Manifest (planner-facing; backend validator remains authoritative):
{capability_manifest}
Injected part-skill context:
{part_skill_context or "No part-family skill guidance was selected for this request."}
@@ -849,18 +907,17 @@ class AgentService:
image_inputs = image_attachments(conversation)
recorded_image_analysis = image_reference_analysis(conversation)
requires_image_intake = bool(image_inputs) and recorded_image_analysis is None
flange_sleeve_template_mode = use_flange_sleeve_template(recorded_image_analysis, task_id)
yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"})
references: list[str] = []
library_searches = 0
inherited_skill_ids = self.part_skill_library.inherited_from_task(current_task)
part_skill_selection = self.part_skill_library.select(user_text, inherited_skill_ids)
planning_state: dict[str, Any] = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
planning_state: dict[str, Any] = {"phase": "INTAKE", "design_brief": "", "current_model_read": False}
yield event("progress", {
"step": "select_part_skill",
"label": "识别零件族",
"label": "选择建模 Skill",
"status": "success",
"message": "已完成零件族与辅助建模规则识别",
"message": "已完成建模 Skill 与辅助规则选择",
})
model_messages: list[dict[str, Any]] = [{
"role": "system",
@@ -872,7 +929,6 @@ class AgentService:
self.part_skill_library.render_context(part_skill_selection),
image_reference_instruction(image_inputs, recorded_image_analysis),
cad_request_instruction(task_id, current_task),
flange_sleeve_template_mode,
),
}]
model_messages.extend(messages_for_model(messages))
@@ -881,16 +937,21 @@ class AgentService:
tools = tools_for_model(
model,
include_image_analysis=requires_image_intake,
flange_sleeve_only=flange_sleeve_template_mode,
)
required_tool_name: str | None = "analyze_image_reference" if requires_image_intake else None
generate_argument_failures = 0
tool_argument_diagnostics: list[str] = []
cdsl_validation_diagnostics: list[str] = []
generation_completed = False
repair_attempts = 0
try:
for iteration in range(8):
if (
planning_state.get("phase") == "CDSL_REPAIR"
and repair_attempts >= self.settings.max_repair_attempts
):
raise CdslRepairLimitError(cdsl_validation_diagnostics)
response = await self._complete(model_messages, tools, provider, model, required_tool_name)
response_choice = response["choices"][0]
choice = response_choice["message"]
@@ -914,6 +975,15 @@ class AgentService:
model_messages.append(choice)
for call in tool_calls:
name = str(call.get("function", {}).get("name") or "")
if requires_image_intake and name != "analyze_image_reference":
result = {
"ok": False,
"code": "IMAGE_ANALYSIS_REQUIRED",
"message": "Analyze all current image attachments before using planning, library, or generation tools.",
}
model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)})
yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先完成图片参考分析。"})
continue
if name == "analyze_image_reference" and recorded_image_analysis is not None:
result = {
"ok": False,
@@ -996,8 +1066,12 @@ class AgentService:
"message": "CAD 工具参数格式无效,正在请求模型修正。",
})
continue
if name in GENERATION_TOOL_NAMES and planning_state.get("phase") == "CDSL_REPAIR":
if repair_attempts >= self.settings.max_repair_attempts:
raise CdslRepairLimitError(cdsl_validation_diagnostics)
repair_attempts += 1
cdsl_attempt_path = ""
if name == "generate_cdsl_model":
if name in GENERATION_TOOL_NAMES:
cdsl_attempt_path = self._record_cdsl_attempt(
conversation_id=conversation["conversation_id"],
arguments=arguments,
@@ -1020,6 +1094,8 @@ class AgentService:
planning_state=planning_state,
image_attachment_ids=[str(attachment["id"]) for attachment in image_inputs],
input_attachments=revision_input_attachments(conversation),
conversation_id=conversation["conversation_id"],
repair_attempts=repair_attempts,
)
except (ValueError, RuntimeError) as error:
if name in GENERATION_TOOL_NAMES:
@@ -1038,8 +1114,24 @@ class AgentService:
)
cdsl_validation_diagnostics.append(diagnostic_path)
result = invalid_cdsl_result(error)
result["diagnostic_path"] = diagnostic_path
repair_task_id = str(getattr(error, "task_id", "") or "")
repair_revision_id = str(getattr(error, "revision_id", "") or "")
if repair_task_id and repair_revision_id:
result.update({
"task_id": repair_task_id,
"base_revision_id": repair_revision_id,
"repair_instruction": (
"Patch this explicit base_revision_id for a local correction, or call read_current_cdsl "
"before a complete CDSL replacement."
),
})
if name == "patch_cdsl_model":
result["code"] = "INVALID_CDSL_PATCH"
result["repair_instruction"] = "Correct the RFC 6902 patch or call generate_cdsl_model with a complete replacement CDSL."
generated = None
required_tool_name = name
planning_state["phase"] = "CDSL_REPAIR"
else:
raise
if result.get("task_id"):
@@ -1071,26 +1163,21 @@ class AgentService:
"visibleFeatures": result["visible_features"],
"uncertainFeatures": result["uncertain_features"],
"dimensionCandidates": result["dimension_candidates"],
"artifactPath": result.get("artifact_path"),
}
assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload})
yield event("image_analysis", image_payload)
recorded_image_analysis = image_payload
flange_sleeve_template_mode = use_flange_sleeve_template(image_payload, task_id)
requires_image_intake = False
required_tool_name = None
tools = tools_for_model(
model,
include_image_analysis=False,
flange_sleeve_only=flange_sleeve_template_mode,
)
model_messages.append({
"role": "system",
"content": image_reference_instruction(image_inputs, image_payload),
})
if flange_sleeve_template_mode:
model_messages.append({
"role": "system",
"content": flange_sleeve_template_instruction(True),
})
yield event("progress", {
"step": "analyze_image_reference",
"label": "识别图片参考",
@@ -1114,6 +1201,12 @@ class AgentService:
"summary": generated["summary"],
"referenceIds": generated["reference_ids"],
"engine": generated["engine"],
"qualityStatus": generated.get("quality_status", ""),
"qualityPath": generated.get("quality_path") or None,
"assumptions": generated.get("generation_assumptions", []),
"repairAttempts": generated.get("repair_attempts", 0),
"snapshotPaths": generated.get("snapshot_paths", []),
"snapshotStatus": generated.get("snapshot_status", "unavailable"),
}
assistant_parts.append({"type": "data-cad-result", "data": result_payload})
yield event("cad_result", result_payload)
@@ -1128,10 +1221,14 @@ class AgentService:
if generation_completed:
break
if iteration == 7:
if cdsl_validation_diagnostics:
diagnostic_paths = "".join(cdsl_validation_diagnostics)
validation_diagnostics = cdsl_validation_diagnostics
if validation_diagnostics:
diagnostic_paths = "".join(validation_diagnostics)
if any("\u4e00" <= char <= "\u9fff" for char in user_text):
message = f"模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:{diagnostic_paths}"
message = (
"模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:"
f"{diagnostic_paths}"
)
else:
message = (
"Agent tool loop reached its safety limit. "
@@ -1250,6 +1347,7 @@ class AgentService:
error: Exception,
) -> str:
function = call.get("function") if isinstance(call.get("function"), dict) else {}
details = _cdsl_error_details(error)
payload = {
"schema_version": "1.0",
"recorded_at": now_iso(),
@@ -1269,6 +1367,8 @@ class AgentService:
"cdsl_attempt_path": cdsl_attempt_path,
"summary": str(arguments.get("summary") or ""),
"assumptions": arguments.get("assumptions") or [],
"verification": arguments.get("verification"),
"diagnostic": details,
"validation_error_type": type(error).__name__,
"validation_error": str(error),
}
@@ -1328,24 +1428,30 @@ class AgentService:
planning_state: dict[str, Any] | None = None,
image_attachment_ids: list[str] | None = None,
input_attachments: list[dict[str, str | int]] | None = None,
conversation_id: str | None = None,
repair_attempts: int = 0,
) -> tuple[dict[str, Any], dict[str, Any] | None]:
state = planning_state if planning_state is not None else {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
phase = str(state.get("phase") or "WAITING_FOR_PLAN")
state = planning_state if planning_state is not None else {"phase": "INTAKE", "design_brief": "", "current_model_read": False}
phase = str(state.get("phase") or "INTAKE")
if name == "analyze_image_reference":
if not image_attachment_ids:
raise ValueError("analyze_image_reference requires at least one image attachment")
return {
analysis = {
"ok": True,
"attachment_ids": list(dict.fromkeys(image_attachment_ids)),
**normalize_image_analysis(arguments),
}, None
}
if conversation_id:
analysis["artifact_path"] = self.store.write_conversation_planning(conversation_id, "reference-observation", {"schema_version": "1.0", **analysis})
state["phase"] = "REFERENCE_ANALYZED"
return analysis, None
if name == "describe_design_intent":
plan = str(arguments.get("plan") or "").strip()
assumptions = arguments.get("assumptions")
if not plan or not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions):
raise ValueError("describe_design_intent requires a non-empty plan and an array of string assumptions")
state["design_brief"] = plan
state["phase"] = "PLAN_RECORDED"
state["phase"] = "PLANNED"
return {
"ok": True,
"plan": plan,
@@ -1353,60 +1459,108 @@ class AgentService:
"message": "The design brief is recorded as reference only. CDSL remains the sole authoritative CAD model.",
}, None
if name == "search_cdsl_library":
if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}:
if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}:
return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before searching CDSL references."}, None
results = self.library.search(str(arguments.get("query") or request), int(arguments.get("limit") or 5))
state["phase"] = "LIBRARY_REFERENCE"
query = str(arguments.get("query") or request)
normalized_query = " ".join(query.casefold().split())
seen_queries = state.setdefault("library_queries", [])
if len(seen_queries) >= 2:
return {"ok": False, "code": "LIBRARY_SEARCH_LIMIT_REACHED", "message": "The CDSL library search limit for this request has been reached; continue with the available references."}, None
if normalized_query in seen_queries:
return {"ok": False, "code": "LIBRARY_QUERY_DUPLICATE", "message": "Do not repeat an identical unavailable library query; use the existing results or compile with the available capability."}, None
seen_queries.append(normalized_query)
results = self.library.search(query, min(8, int(arguments.get("limit") or 5)))
state["phase"] = "LIBRARY_SEARCHING"
return {"ok": True, "results": results}, None
if name == "read_cdsl_reference":
if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}:
if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}:
return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before reading CDSL references."}, None
part_id = str(arguments.get("part_id") or "")
sample = self.library.read_sample(part_id)
if part_id in references:
return {"ok": False, "code": "LIBRARY_REFERENCE_DUPLICATE", "message": "This CDSL reference is already loaded; choose another reference or continue to compilation."}, None
if len(state.get("reference_records") or []) >= 2:
return {"ok": False, "code": "LIBRARY_REFERENCE_LIMIT_REACHED", "message": "At most two complete CDSL library references may be read for one request."}, None
try:
sample = self.library.read_sample(part_id)
except ValueError as error:
return {"ok": False, "code": "LIBRARY_REFERENCE_NOT_FOUND", "message": str(error)}, None
if part_id not in references:
references.append(part_id)
state["phase"] = "WAITING_FOR_CDSL"
state.setdefault("reference_records", []).append({"part_id": part_id, "source": "cdsl_library", "summary": "official CDSL reference"})
state["phase"] = "LIBRARY_REFERENCE_READY"
return {"ok": True, "part_id": part_id, "cdsl": sample}, None
if name == "read_current_cdsl":
if not task_id:
return {"ok": False, "message": "No current task exists. This is a new model request."}, None
path = self.store.current_cdsl_path(task_id)
revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "")
if path is None:
return {"ok": False, "message": "The current task has no successful CDSL revision."}, None
return {"ok": True, "task_id": task_id, "cdsl": json.loads(path.read_text(encoding="utf-8"))}, None
repairable = self.store.latest_repairable_cdsl(task_id)
if repairable is None:
return {"ok": False, "message": "The current task has no successful or repairable CDSL revision."}, None
revision_id, path = repairable
payload: dict[str, Any] = {
"ok": True,
"task_id": task_id,
"revision_id": revision_id,
"cdsl": json.loads(path.read_text(encoding="utf-8")),
}
state["current_model_read"] = True
state["read_revision_id"] = revision_id
state["phase"] = "PLANNED"
return payload, None
if name in GENERATION_TOOL_NAMES:
if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}:
if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}:
return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before generating CAD."}, None
template_plan: dict[str, float | str] | None = None
operation: dict[str, Any] | None = None
if name == "generate_flange_sleeve_model":
cdsl, template_plan = build_flange_sleeve_cdsl(arguments.get("plan"))
operation = {
"type": "parameterized_template",
"template_id": FLANGE_SLEEVE_TEMPLATE_ID,
"plan": template_plan,
}
else:
selection = part_skill_selection or self.part_skill_library.select(request)
if selection.get("conflict"):
state["phase"] = "BLOCKED"
return {
"ok": False,
"code": "PART_SKILL_CONFLICT",
"message": str(selection["conflict"].get("message") or "Resolve the primary part-family conflict before generating CAD."),
}, None
state["phase"] = "CDSL_AUTHORING"
if name == "generate_cdsl_model":
if task_id and not state.get("current_model_read"):
return {"ok": False, "code": "CURRENT_CDSL_REQUIRED", "message": "Call read_current_cdsl before replacing an existing task revision."}, None
cdsl = arguments.get("cdsl")
if isinstance(cdsl, str):
cdsl = json.loads(cdsl)
if not isinstance(cdsl, dict):
raise ValueError("generate_cdsl_model requires a CDSL JSON object")
parent_revision_id = str(state.get("read_revision_id") or "") if task_id else ""
if task_id and not parent_revision_id:
parent_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "")
operation: dict[str, Any] = {"type": "cdsl_replacement" if parent_revision_id else "cdsl_create"}
else:
if not task_id:
return {"ok": False, "code": "PATCH_TASK_REQUIRED", "message": "patch_cdsl_model requires an existing task."}, None
base_revision_id = str(arguments.get("base_revision_id") or "")
base_path = self.store.revision_cdsl_path(task_id, base_revision_id)
if base_path is None:
raise ValueError("PATCH_BASE_REVISION_NOT_FOUND: base_revision_id does not identify a readable CDSL revision")
try:
cdsl = apply_cdsl_patch(json.loads(base_path.read_text(encoding="utf-8")), arguments.get("patches"))
except (CdslPatchError, json.JSONDecodeError) as error:
raise ValueError(f"INVALID_CDSL_PATCH: {error}") from error
parent_revision_id = base_revision_id
operation = {"type": "cdsl_patch", "base_revision_id": base_revision_id, "patches": deepcopy(arguments.get("patches") or [])}
cdsl, normalization_repairs = normalize_cdsl_for_engine(cdsl)
# Reject malformed model output before build_revision allocates a task
# directory or revision. build_revision will assign the real task ID.
preflight_cdsl = {**cdsl, "part_id": str(cdsl.get("part_id") or "agent_preflight")}
engine = load_engine(self.settings)
state["phase"] = "PREFLIGHT"
validate_cdsl(preflight_cdsl, engine)
summary = str(arguments.get("summary") or "Parameterized CAD model")
raw_assumptions = arguments.get("assumptions") or []
if not isinstance(raw_assumptions, list) or not all(isinstance(item, str) for item in raw_assumptions):
raise ValueError(f"{name} assumptions must be an array of strings")
assumptions = [item.strip() for item in raw_assumptions if item.strip()]
selection = part_skill_selection or self.part_skill_library.select(request)
verification = arguments.get("verification")
validate_verification(verification, cdsl)
part_skill_audit = self.part_skill_library.audit(selection, cdsl, assumptions)
current_task = self.store.read_task(task_id) if task_id else None
parent_revision_id = str((current_task or {}).get("current_revision") or "")
state["phase"] = "BUILDING"
try:
yieldable = await asyncio.to_thread(
@@ -1422,10 +1576,13 @@ class AgentService:
operation=operation,
part_skills=part_skill_audit,
generation_assumptions=assumptions,
repair_attempts=repair_attempts,
input_attachments=input_attachments,
verification=verification,
reference_records=state.get("reference_records"),
)
except Exception:
state["phase"] = "PLAN_RECORDED"
state["phase"] = "CDSL_REPAIR"
raise
state["phase"] = "COMPLETED"
return {
@@ -1434,8 +1591,7 @@ class AgentService:
"task_id": yieldable["task_id"],
"revision_id": yieldable["revision_id"],
"normalization_repairs": normalization_repairs,
"template_id": FLANGE_SLEEVE_TEMPLATE_ID if template_plan is not None else "",
"template_plan": template_plan,
"operation": operation,
}, yieldable
raise ValueError(f"Unknown agent tool: {name}")
@@ -1450,8 +1606,8 @@ class AgentService:
"read_cdsl_reference": "读取 CDSL 参考模型",
"read_current_cdsl": "读取当前 CDSL",
"describe_design_intent": "整理设计说明",
"generate_flange_sleeve_model": "生成参数化法兰套筒",
"generate_cdsl_model": "生成 CDSL",
"patch_cdsl_model": "修复 CDSL",
}.get(name, "调用 CAD 工具")
def _attachment_message(self, conversation: dict[str, Any], model: ProviderModel) -> list[dict[str, Any]] | str:
+14 -6
View File
@@ -4,8 +4,8 @@ import hashlib
from pathlib import Path
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json"}
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json", ".pdf", ".dxf", ".step", ".stp"}
MAX_IMAGE_BYTES = 10 * 1024 * 1024
MAX_DOCUMENT_BYTES = 2 * 1024 * 1024
MAX_EXTRACTED_CHARS = 30_000
@@ -13,8 +13,6 @@ MAX_EXTRACTED_CHARS = 30_000
def classify_upload(filename: str, mime: str, size: int) -> str:
suffix = Path(filename).suffix.lower()
if suffix in {".step", ".stp"}:
raise ValueError("STEP/STP upload is not supported in this version")
if suffix in IMAGE_SUFFIXES or mime.startswith("image/"):
if size > MAX_IMAGE_BYTES:
raise ValueError("Image upload exceeds the 10 MB limit")
@@ -23,14 +21,24 @@ def classify_upload(filename: str, mime: str, size: int) -> str:
if size > MAX_DOCUMENT_BYTES:
raise ValueError("Document upload exceeds the 2 MB limit")
return "document"
raise ValueError("Only PNG, JPG, WEBP, TXT, MD, CSV, and JSON uploads are supported")
raise ValueError("Only PNG, JPG, WEBP, SVG, TXT, MD, CSV, JSON, PDF, DXF, and STEP/STP uploads are supported")
def extract_document_text(data: bytes) -> str:
# PDF extraction is optional so the API can still accept a reference when
# the local PDF dependency is unavailable; the binary remains available as
# the primary attachment artifact.
if data.startswith(b"%PDF"):
try:
from pypdf import PdfReader
import io
return "\n".join(page.extract_text() or "" for page in PdfReader(io.BytesIO(data)).pages)[:MAX_EXTRACTED_CHARS]
except Exception:
return "[PDF reference uploaded; text extraction unavailable]"
try:
text = data.decode("utf-8")
except UnicodeDecodeError as error:
raise ValueError("Documents must be UTF-8 text") from error
return "[Binary CAD reference uploaded; structured geometry inspection is deferred to the CAD reference analyzer]"
return text[:MAX_EXTRACTED_CHARS]
+122
View File
@@ -0,0 +1,122 @@
"""Small, dependency-free RFC 6902 JSON Patch implementation for CDSL revisions."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
class CdslPatchError(ValueError):
"""A JSON Patch operation cannot be applied to the requested CDSL revision."""
def _tokens(path: Any) -> list[str]:
if not isinstance(path, str) or not path.startswith("/"):
raise CdslPatchError("JSON Patch paths must be JSON Pointers beginning with '/'")
return [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")]
def _index(token: str, length: int, *, allow_append: bool = False) -> int:
if allow_append and token == "-":
return length
if not token.isdigit() or (len(token) > 1 and token.startswith("0")):
raise CdslPatchError(f"Invalid JSON Patch array index: {token}")
value = int(token)
if value < 0 or value >= length:
raise CdslPatchError(f"JSON Patch array index is out of range: {token}")
return value
def _parent(document: Any, path: str) -> tuple[Any, str]:
if path == "":
raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead")
tokens = _tokens(path)
current = document
for token in tokens[:-1]:
if isinstance(current, dict):
if token not in current:
raise CdslPatchError(f"JSON Patch path does not exist: {path}")
current = current[token]
elif isinstance(current, list):
current = current[_index(token, len(current))]
else:
raise CdslPatchError(f"JSON Patch path does not resolve to a container: {path}")
return current, tokens[-1]
def _get(document: Any, path: str) -> Any:
current = document
for token in _tokens(path):
if isinstance(current, dict):
if token not in current:
raise CdslPatchError(f"JSON Patch path does not exist: {path}")
current = current[token]
elif isinstance(current, list):
current = current[_index(token, len(current))]
else:
raise CdslPatchError(f"JSON Patch path does not resolve: {path}")
return current
def _add(document: Any, path: str, value: Any) -> None:
parent, token = _parent(document, path)
if isinstance(parent, dict):
parent[token] = deepcopy(value)
elif isinstance(parent, list):
index = _index(token, len(parent), allow_append=True)
parent.insert(index, deepcopy(value))
else:
raise CdslPatchError(f"JSON Patch add target is not a container: {path}")
def _remove(document: Any, path: str) -> Any:
parent, token = _parent(document, path)
if isinstance(parent, dict):
if token not in parent:
raise CdslPatchError(f"JSON Patch path does not exist: {path}")
return parent.pop(token)
if isinstance(parent, list):
return parent.pop(_index(token, len(parent)))
raise CdslPatchError(f"JSON Patch remove target is not a container: {path}")
def apply_cdsl_patch(cdsl: dict[str, Any], patches: Any) -> dict[str, Any]:
if not isinstance(cdsl, dict):
raise CdslPatchError("The base CDSL must be an object")
if not isinstance(patches, list) or not patches:
raise CdslPatchError("patches must be a non-empty JSON Patch array")
if len(patches) > 32:
raise CdslPatchError("At most 32 JSON Patch operations are allowed")
result: Any = deepcopy(cdsl)
for index, operation in enumerate(patches):
if not isinstance(operation, dict):
raise CdslPatchError(f"Patch operation {index} must be an object")
kind = str(operation.get("op") or "")
path = operation.get("path")
if kind not in {"add", "remove", "replace", "move", "copy", "test"}:
raise CdslPatchError(f"Unsupported JSON Patch operation: {kind or '<missing>'}")
if kind in {"add", "replace", "test"} and "value" not in operation:
raise CdslPatchError(f"JSON Patch {kind} requires value")
if kind in {"move", "copy"} and "from" not in operation:
raise CdslPatchError(f"JSON Patch {kind} requires from")
if path == "":
raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead")
if kind == "add":
_add(result, path, operation["value"])
elif kind == "remove":
_remove(result, path)
elif kind == "replace":
_get(result, path)
_remove(result, path)
_add(result, path, operation["value"])
elif kind == "move":
source = str(operation["from"])
if path == source or str(path).startswith(source.rstrip("/") + "/"):
raise CdslPatchError("JSON Patch move cannot move a value into itself")
moved = _remove(result, source)
_add(result, path, moved)
elif kind == "copy":
_add(result, path, _get(result, str(operation["from"])))
elif kind == "test" and _get(result, path) != operation["value"]:
raise CdslPatchError(f"JSON Patch test failed at {path}")
return result
+39 -12
View File
@@ -5,7 +5,7 @@ import json
import math
from typing import Any
from app.services.engine_service import build_revision
from app.services.engine_service import build_revision, load_engine
from app.services.storage import WorkspaceStore
from app.settings import Settings
@@ -59,22 +59,35 @@ def _profile_for(operation: str, values: dict[str, Any]) -> tuple[dict[str, Any]
if operation == "add_slot":
width = _number(values, "slotWidth", values.get("width", 8.0))
length = _number(values, "slotLength", values.get("length", width * 3))
return {"type": "obround", "center": [0.0, 0.0], "length_mm": max(length, width), "width_mm": width}, depth
length = max(length, width)
radius = width / 2
left, right = -length / 2 + radius, length / 2 - radius
return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [
{"type": "line", "start": [right, radius], "end": [left, radius]},
{"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0.0], "radius_mm": radius},
{"type": "line", "start": [left, -radius], "end": [right, -radius]},
{"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0.0], "radius_mm": radius},
]}]}, depth
if operation == "add_pocket":
width = _number(values, "width", 20.0)
height = _number(values, "height", 12.0)
return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height}, depth
return {"type": "polygon", "vertices": [
[-width / 2, -height / 2], [width / 2, -height / 2],
[width / 2, height / 2], [-width / 2, height / 2],
]}, depth
if operation == "add_hole_pattern":
diameter = _number(values, "holeDiameter", values.get("diameter", 6.0))
rows = max(1, int(_number(values, "rows", 2, 1)))
columns = max(1, int(_number(values, "columns", 2, 1)))
return {
"type": "circle_grid", "radius_mm": diameter / 2,
"count_x": columns, "count_y": rows,
"spacing_x_mm": _number(values, "pitchX", 12.0),
"spacing_y_mm": _number(values, "pitchY", 12.0),
"center_mm": [0.0, 0.0],
}, depth
pitch_x = _number(values, "pitchX", 12.0)
pitch_y = _number(values, "pitchY", 12.0)
return {"type": "analytic_contours", "contours": [
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [
(column - (columns - 1) / 2) * pitch_x,
(row - (rows - 1) / 2) * pitch_y,
], "radius_mm": diameter / 2}]}
for row in range(rows) for column in range(columns)
]}, depth
raise ValueError(f"Unsupported direct CDSL edit: {operation}")
@@ -149,6 +162,15 @@ def apply_direct_edit(
raise ValueError("Task has no successful CDSL revision")
frame = _selection_frame(selection)
cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8")))
# Historical revisions can contain importer-only profile macros. An edit
# creates a new CDSL-only revision, so lower those profiles explicitly
# before adding the new generic feature.
load_engine(settings)
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
lowered_cdsl = lower_legacy_profiles(cdsl)
legacy_profiles_lowered = lowered_cdsl != cdsl
cdsl = lowered_cdsl
features = cdsl.setdefault("features", [])
sketches = cdsl.setdefault("geometry", {}).setdefault("sketches", [])
picks = selection.get("picks") if isinstance(selection.get("picks"), list) else []
@@ -183,7 +205,12 @@ def apply_direct_edit(
reference_ids=[],
summary=f"Applied {operation}",
parent_revision_id=revision_id,
operation={"type": operation, "selection": selection, "parameters": parameters},
operation={
"type": operation,
"selection": selection,
"parameters": parameters,
"legacy_profiles_lowered": legacy_profiles_lowered,
},
part_skills=None,
generation_assumptions=[],
generation_assumptions=["Legacy profile macros were lowered to direct analytic contours before this edit."] if legacy_profiles_lowered else [],
)
+73 -50
View File
@@ -4,6 +4,7 @@ import copy
import json
import math
import re
import shutil
import sys
from pathlib import Path
from typing import Any
@@ -12,10 +13,21 @@ from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
from vendor.cdsl_preview_runtime import step_to_glb
from app.services.quality import evaluate_quality, validate_verification
from app.services.storage import WorkspaceStore, now_iso, write_json
from app.settings import Settings
class QualityVerificationError(RuntimeError):
"""A built CDSL document missed a blocking generic verification rule."""
def __init__(self, quality_report: dict[str, Any]) -> None:
super().__init__("Blocking CDSL verification checks failed")
self.quality_report = quality_report
self.task_id = ""
self.revision_id = ""
def load_engine(settings: Settings) -> Any:
parent = str(settings.engine_root.parent)
if parent not in sys.path:
@@ -165,10 +177,12 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
semantic_contract = _engine_schema(engine)
atomic_contracts = semantic_contract["feature_atomic_ids"]
supported_atomic_ids = sorted(
declared_atomic_ids = {
str(atomic_id)
for atomic_id in semantic_contract.get("runtime_supported_atomic_ids", atomic_contracts)
)
}
registered_atomic_ids = {str(atomic_id) for atomic_id in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())}
supported_atomic_ids = sorted(declared_atomic_ids & registered_atomic_ids)
feature_ids: set[str] = set()
for feature in features:
fid = str(feature.get("id") or "")
@@ -400,7 +414,6 @@ def _part_skill_audit(
skill_ids = [str(item) for item in audit.get("skill_ids") or [] if str(item)]
if not skill_ids:
skill_ids = [str(item.get("id")) for item in skills if item.get("id")]
evidence = audit.get("evidence") if isinstance(audit.get("evidence"), dict) else {}
audit.update({
"schema_version": str(audit.get("schema_version") or "1.0"),
"request": str(audit.get("request") or request),
@@ -409,10 +422,6 @@ def _part_skill_audit(
"skills": skills,
"inherited_skill_ids": [str(item) for item in audit.get("inherited_skill_ids") or [] if str(item)],
"assumptions": [str(item) for item in generation_assumptions or []],
"evidence": evidence,
"capability_translations": [
item for item in audit.get("capability_translations") or [] if isinstance(item, dict)
],
})
return audit
@@ -420,20 +429,9 @@ def _part_skill_audit(
def _generation_context(
reference_ids: list[str],
part_skill_audit: dict[str, Any],
design_intent: dict[str, Any] | None = None,
design_intent_path: str = "",
design_intent_id: str = "",
) -> dict[str, Any]:
skills = [item for item in part_skill_audit.get("skills") or [] if isinstance(item, dict)]
intent = design_intent if isinstance(design_intent, dict) else {}
return {
"design_intent_id": str(intent.get("intent_id") or design_intent_id or ""),
"design_intent_path": design_intent_path,
"design_intent_structures": [
str(item.get("id") or "")
for item in intent.get("structures") or []
if isinstance(item, dict) and item.get("id")
],
"cdsl_reference_ids": list(reference_ids),
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
"part_skill_paths": [
@@ -445,9 +443,6 @@ def _generation_context(
for item in skills
],
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
"design_intent_assumptions": list(part_skill_audit.get("design_intent_assumptions") or []),
"design_intent_capability_gaps": list(part_skill_audit.get("design_intent_capability_gaps") or []),
"capability_translations": list(part_skill_audit.get("capability_translations") or []),
}
@@ -465,10 +460,9 @@ def build_revision(
input_attachments: list[dict[str, Any]] | None = None,
part_skills: dict[str, Any] | None = None,
generation_assumptions: list[str] | None = None,
design_intent: dict[str, Any] | None = None,
design_intent_path: str = "",
design_intent_id: str = "",
design_intent_status: str = "",
repair_attempts: int = 0,
verification: dict[str, Any] | None = None,
reference_records: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
engine = load_engine(settings)
task = store.ensure_task(task_id, request)
@@ -483,30 +477,19 @@ def build_revision(
selector_path = revision_dir / "model.selector.json"
edges_path = revision_dir / "model.edges.json"
part_skills_path = revision_dir / "part-skills.json"
intent = copy.deepcopy(design_intent) if isinstance(design_intent, dict) else {}
intent_assumptions = [str(item) for item in intent.get("assumptions") or [] if str(item)]
merged_assumptions = list(dict.fromkeys([
*intent_assumptions,
*(str(item) for item in generation_assumptions or [] if str(item)),
]))
part_skill_audit = _part_skill_audit(part_skills, request, merged_assumptions)
intent_id = str(intent.get("intent_id") or design_intent_id or "")
if design_intent_path:
design_intent_path = str(design_intent_path)
part_skill_audit.update({
"design_intent_id": intent_id,
"design_intent_path": design_intent_path,
"intent_status": str(intent.get("status") or design_intent_status or ""),
"design_intent_assumptions": intent_assumptions,
"design_intent_capability_gaps": [
item for item in intent.get("capability_gaps") or [] if isinstance(item, dict)
],
})
generation_context = _generation_context(reference_ids, part_skill_audit, intent, design_intent_path, intent_id)
quality_path = revision_dir / "quality-report.json"
snapshot_manifest_path = revision_dir / "snapshot-manifest.json"
part_skill_audit = _part_skill_audit(part_skills, request, generation_assumptions)
generation_context = _generation_context(reference_ids, part_skill_audit)
write_json(request_path, {"request": request, "created_at": now_iso()})
write_json(references_path, {"reference_ids": reference_ids})
write_json(references_path, {"reference_ids": reference_ids, "records": [item for item in reference_records or [] if isinstance(item, dict)]})
write_json(part_skills_path, part_skill_audit)
write_json(snapshot_manifest_path, {
"schema_version": "1.0",
"status": "unavailable",
"reason": "Snapshot runner is not attached to this backend build",
"snapshots": [],
})
def revision_record(status: str, *, error: str = "", engine_name: str = "") -> dict[str, Any]:
record = {
"revision_id": revision_id,
@@ -517,8 +500,10 @@ def build_revision(
"report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"part_skills_path": part_skills_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"design_intent_id": intent_id,
"design_intent_path": design_intent_path,
"quality_path": quality_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"snapshot_manifest_path": snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"snapshot_status": "unavailable",
"snapshot_paths": [snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix()],
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
"reference_ids": reference_ids,
@@ -526,6 +511,7 @@ def build_revision(
"parent_revision_id": parent_revision_id or "",
"operation": operation or {},
"input_attachments": input_attachments or [],
"repair_attempts": max(0, int(repair_attempts)),
}
if status == "success":
record.update({
@@ -539,6 +525,8 @@ def build_revision(
record["error"] = error
return record
quality_report: dict[str, Any] | None = None
quality_status = ""
try:
cdsl_copy = copy.deepcopy(cdsl)
if not isinstance(cdsl_copy, dict):
@@ -562,6 +550,17 @@ def build_revision(
selector, edges = topology_sidecars(engine_result, preview)
write_json(selector_path, selector)
write_json(edges_path, edges)
rules = validate_verification(verification, cdsl_copy)
quality_report = evaluate_quality(rules, cdsl_copy, engine_result)
quality_report["evaluated_at"] = now_iso()
write_json(quality_path, quality_report)
quality_status = (
"accepted" if rules and quality_report["status"] == "passed"
else "built_with_warnings" if quality_report["status"] == "passed"
else "needs_repair"
)
if quality_report["status"] != "passed":
raise QualityVerificationError(quality_report)
report = {
"engine_result": engine_result,
"preview": preview,
@@ -570,16 +569,40 @@ def build_revision(
}
write_json(report_path, report)
revision = revision_record("success", engine_name=str(engine_result["engine"]))
revision["quality_status"] = quality_status or "accepted"
revision["verification_summary"] = {
"requested": bool(rules),
"blocking_failures": len(quality_report.get("blocking_failures") or []),
"warnings": len(quality_report.get("warnings") or []),
}
except Exception as error:
if not isinstance(error, QualityVerificationError):
# The failed candidate is retained in the conversation diagnostics,
# not as a task revision. Runtime/schema failures must not create
# an editable revision that looks like a model version.
shutil.rmtree(revision_dir, ignore_errors=True)
raise
if not cdsl_path.is_file() and isinstance(cdsl, dict):
write_json(cdsl_path, copy.deepcopy(cdsl))
if quality_report is not None and not quality_path.is_file():
write_json(quality_path, quality_report)
write_json(report_path, {
"error": str(error),
"generation_context": generation_context,
"quality": quality_report,
"validated_at": now_iso(),
})
revision = revision_record("failed", error=str(error))
revision = revision_record("needs_repair" if quality_report is not None else "failed", error=str(error))
revision["quality_status"] = quality_status or ("needs_repair" if quality_report else "failed")
if quality_report is not None:
revision["verification_summary"] = {
"requested": bool(quality_report.get("verification_requested")),
"blocking_failures": len(quality_report.get("blocking_failures") or []),
"warnings": len(quality_report.get("warnings") or []),
}
store.update_task(task["task_id"], revision)
error.task_id = task["task_id"]
error.revision_id = revision_id
raise
store.update_task(task["task_id"], revision)
return {"task_id": task["task_id"], **revision}
@@ -1,273 +0,0 @@
from __future__ import annotations
import copy
import math
import re
from typing import Any
TEMPLATE_ID = "flange_sleeve_v1"
# These defaults describe the pictured family, but the model may override every
# dimension that affects the visible form. The backend owns all CDSL plumbing.
DEFAULT_PLAN: dict[str, float | str] = {
"name": "Parameterized flange sleeve",
"flange_width_mm": 120.0,
"flange_height_mm": 120.0,
"flange_thickness_mm": 14.0,
"corner_chamfer_mm": 10.0,
"tube_outer_diameter_mm": 70.0,
"tube_straight_length_mm": 95.0,
"tip_outer_diameter_mm": 62.0,
"tip_length_mm": 28.0,
"bore_diameter_mm": 46.0,
"boss_outer_diameter_mm": 82.0,
"boss_height_mm": 5.0,
"mount_hole_diameter_mm": 12.0,
"mount_counterbore_diameter_mm": 24.0,
"mount_counterbore_depth_mm": 5.0,
"mount_hole_u_mm": 42.0,
"mount_hole_v_mm": 42.0,
}
_DIMENSION_FIELDS = tuple(key for key in DEFAULT_PLAN if key != "name")
_PART_ID = re.compile(r"^[A-Za-z0-9_-]{3,80}$")
def flange_sleeve_plan_schema() -> dict[str, Any]:
"""Return the compact semantic plan accepted by the flange-sleeve tool."""
properties: dict[str, Any] = {
"template": {"const": TEMPLATE_ID},
"name": {"type": "string", "minLength": 1, "maxLength": 120},
"part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"},
}
for field in _DIMENSION_FIELDS:
minimum = 0 if field == "corner_chamfer_mm" else 0.001
properties[field] = {"type": "number", "exclusiveMinimum": minimum} if minimum else {
"type": "number", "minimum": 0,
}
return {
"type": "object",
"properties": properties,
"required": ["template"],
"additionalProperties": False,
}
def normalize_flange_sleeve_plan(plan: Any) -> dict[str, float | str]:
"""Validate only semantic dimensions; do not accept CDSL implementation data."""
if not isinstance(plan, dict):
raise ValueError("flange sleeve plan must be a JSON object")
if plan.get("template") != TEMPLATE_ID:
raise ValueError(f"flange sleeve plan template must be {TEMPLATE_ID}")
allowed = {"template", "part_id", *DEFAULT_PLAN}
unknown = sorted(str(key) for key in plan if key not in allowed)
if unknown:
raise ValueError(f"flange sleeve plan has unsupported fields: {', '.join(unknown)}")
normalized = copy.deepcopy(DEFAULT_PLAN)
for field in _DIMENSION_FIELDS:
if field not in plan:
continue
value = plan[field]
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
raise ValueError(f"flange sleeve plan {field} must be a finite number")
number = float(value)
if number < 0 if field == "corner_chamfer_mm" else number <= 0:
comparator = "non-negative" if field == "corner_chamfer_mm" else "greater than zero"
raise ValueError(f"flange sleeve plan {field} must be {comparator}")
normalized[field] = number
name = str(plan.get("name") or normalized["name"]).strip()
if not name:
raise ValueError("flange sleeve plan name must not be empty")
normalized["name"] = name[:120]
part_id = str(plan.get("part_id") or "flange_sleeve_template")
if not _PART_ID.fullmatch(part_id):
raise ValueError("flange sleeve plan part_id must use letters, numbers, underscores, or hyphens")
normalized["part_id"] = part_id
_validate_dimensions(normalized)
return normalized
def _validate_dimensions(plan: dict[str, float | str]) -> None:
number = lambda field: float(plan[field])
width, height = number("flange_width_mm"), number("flange_height_mm")
thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm")
tube_od, tip_od, bore = number("tube_outer_diameter_mm"), number("tip_outer_diameter_mm"), number("bore_diameter_mm")
boss_od = number("boss_outer_diameter_mm")
hole_od, counterbore_od = number("mount_hole_diameter_mm"), number("mount_counterbore_diameter_mm")
counterbore_depth = number("mount_counterbore_depth_mm")
hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm")
if chamfer * 2 >= min(width, height):
raise ValueError("flange sleeve plan corner_chamfer_mm must be less than half the flange side")
if not bore < min(tube_od, tip_od):
raise ValueError("flange sleeve plan bore_diameter_mm must be smaller than both tube diameters")
if not tube_od <= boss_od <= min(width, height):
raise ValueError("flange sleeve plan boss_outer_diameter_mm must be between tube diameter and flange side")
if counterbore_od < hole_od:
raise ValueError("flange sleeve plan mount_counterbore_diameter_mm must not be smaller than mount_hole_diameter_mm")
if counterbore_depth > thickness:
raise ValueError("flange sleeve plan mount_counterbore_depth_mm must not exceed flange_thickness_mm")
radius = counterbore_od / 2
if abs(hole_u) + radius >= width / 2 or abs(hole_v) + radius >= height / 2:
raise ValueError("flange sleeve plan mounting counterbores must remain inside the flange boundary")
if chamfer and abs(hole_u) > width / 2 - chamfer and abs(hole_v) > height / 2 - chamfer:
edge_clearance = (width / 2 - abs(hole_u)) + (height / 2 - abs(hole_v))
if edge_clearance < chamfer + radius * math.sqrt(2):
raise ValueError("flange sleeve plan mounting counterbores intersect the corner chamfers")
def _x_plane(offset_mm: float, normal_x: float = 1.0) -> dict[str, list[float]]:
return {
"origin_mm": [offset_mm, 0.0, 0.0],
"x_dir": [0.0, 1.0, 0.0],
"normal": [normal_x, 0.0, 0.0],
}
def _flange_profile(width: float, height: float, chamfer: float) -> dict[str, Any]:
if chamfer == 0:
return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height}
half_width, half_height = width / 2, height / 2
return {
"type": "polygon",
"vertices": [
[-half_width + chamfer, -half_height],
[half_width - chamfer, -half_height],
[half_width, -half_height + chamfer],
[half_width, half_height - chamfer],
[half_width - chamfer, half_height],
[-half_width + chamfer, half_height],
[-half_width, half_height - chamfer],
[-half_width, -half_height + chamfer],
],
}
def build_flange_sleeve_cdsl(plan: Any) -> tuple[dict[str, Any], dict[str, float | str]]:
"""Build a schema-valid CDSL flange sleeve from semantic, editable parameters."""
values = normalize_flange_sleeve_plan(plan)
number = lambda field: float(values[field])
width, height = number("flange_width_mm"), number("flange_height_mm")
thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm")
tube_radius, tip_radius, bore_radius = number("tube_outer_diameter_mm") / 2, number("tip_outer_diameter_mm") / 2, number("bore_diameter_mm") / 2
straight, tip_length = number("tube_straight_length_mm"), number("tip_length_mm")
boss_radius, boss_height = number("boss_outer_diameter_mm") / 2, number("boss_height_mm")
hole_radius, counterbore_radius = number("mount_hole_diameter_mm") / 2, number("mount_counterbore_diameter_mm") / 2
hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm")
hole_centers = [[u, v] for u in (-hole_u, hole_u) for v in (-hole_v, hole_v)]
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.3.0",
"part_id": str(values["part_id"]),
"kind": "part",
"meta": {"name": str(values["name"]), "units": "mm"},
"geometry": {"sketches": [
{
"id": "flange_outline",
"workplane": _x_plane(-thickness),
"profile": _flange_profile(width, height, chamfer),
},
{
"id": "sleeve_profile",
"workplane": {
"origin_mm": [0.0, 0.0, 0.0],
"x_dir": [1.0, 0.0, 0.0],
"normal": [0.0, 0.0, 1.0],
},
"profile": {
"type": "polygon",
"vertices": [
[0.0, bore_radius],
[0.0, tube_radius],
[straight, tube_radius],
[straight + tip_length, tip_radius],
[straight + tip_length, bore_radius],
],
},
},
{
"id": "front_boss_ring",
"workplane": _x_plane(0.0),
"profile": {
"type": "annulus",
"inner_radius_mm": bore_radius,
"outer_radius_mm": boss_radius,
"center": [0.0, 0.0],
},
},
{
"id": "center_bore",
"workplane": _x_plane(-thickness),
"profile": {"type": "circle", "radius_mm": bore_radius, "center": [0.0, 0.0]},
},
{
"id": "mount_holes",
"workplane": _x_plane(-thickness),
"profile": {
"type": "circles",
"items": [{"radius_mm": hole_radius, "center": center} for center in hole_centers],
},
},
{
"id": "mount_counterbores",
"workplane": _x_plane(0.0, -1.0),
"profile": {
"type": "circles",
"items": [{"radius_mm": counterbore_radius, "center": center} for center in hole_centers],
},
},
]},
"features": [
{
"id": "flange_plate",
"atomic_id": "extrude_add_blind",
"depends_on": [],
"sketch_id": "flange_outline",
"params": {"distance_mm": thickness},
},
{
"id": "sleeve_body",
"atomic_id": "revolve_add",
"depends_on": ["flange_plate"],
"sketch_id": "sleeve_profile",
"params": {
"angle_deg": 360.0,
"axis": {"origin_mm": [0.0, 0.0, 0.0], "direction": [1.0, 0.0, 0.0]},
},
},
{
"id": "front_boss",
"atomic_id": "extrude_add_blind",
"depends_on": ["flange_plate", "sleeve_body"],
"sketch_id": "front_boss_ring",
"params": {"distance_mm": boss_height},
},
{
"id": "center_bore_cut",
"atomic_id": "extrude_cut_blind",
"depends_on": ["flange_plate", "sleeve_body", "front_boss"],
"sketch_id": "center_bore",
"params": {"distance_mm": thickness + straight + tip_length + boss_height + 1.0},
},
{
"id": "mount_hole_cuts",
"atomic_id": "extrude_cut_blind",
"depends_on": ["center_bore_cut"],
"sketch_id": "mount_holes",
"params": {"distance_mm": thickness + 1.0},
},
{
"id": "mount_counterbore_cuts",
"atomic_id": "extrude_cut_blind",
"depends_on": ["mount_hole_cuts"],
"sketch_id": "mount_counterbores",
"params": {"distance_mm": number("mount_counterbore_depth_mm")},
},
],
}
return cdsl, values
+25 -1
View File
@@ -9,10 +9,28 @@ from app.settings import Settings
TOKEN_PATTERN = re.compile(r"[a-zA-Z0-9_]+")
CN_TOKEN_PATTERN = re.compile(r"[\u4e00-\u9fff]")
TERM_ALIASES = {
"法兰": {"flange", "mounting_plate"},
"套筒": {"sleeve", "tube", "cylinder"},
"": {"hole", "circle", "circles"},
"沉头": {"countersunk", "counterbore"},
"沉孔": {"counterbore", "counterbored"},
"": {"slot", "obround"},
"底板": {"plate", "rectangle", "mounting_plate"},
"": {"shaft", "cylinder"},
}
def tokens(value: str) -> set[str]:
return {token.lower() for token in TOKEN_PATTERN.findall(value)}
text = str(value or "").lower()
result = {token.lower() for token in TOKEN_PATTERN.findall(text)}
result.update(CN_TOKEN_PATTERN.findall(text))
for term, aliases in TERM_ALIASES.items():
if term in text:
result.update(aliases)
return result
class CdslLibrary:
@@ -38,6 +56,8 @@ class CdslLibrary:
"profiles": record.get("profiles", []),
"features": record.get("features", []),
"summary": record.get("summary", ""),
"family": record.get("family", ""),
"atomics": record.get("features", []),
}
for record in self._records()[:limit]
]
@@ -46,6 +66,8 @@ class CdslLibrary:
corpus = " ".join([
record.get("part_id", ""),
record.get("source_name", ""),
record.get("summary", ""),
record.get("family", ""),
" ".join(record.get("profiles", [])),
" ".join(record.get("features", [])),
" ".join(record.get("parameters", [])),
@@ -60,6 +82,8 @@ class CdslLibrary:
"profiles": record.get("profiles", []),
"features": record.get("features", []),
"summary": record.get("summary", ""),
"family": record.get("family", ""),
"atomics": record.get("features", []),
}
for _, record in scored[:limit]
]
+7 -71
View File
@@ -58,7 +58,6 @@ class PartSkill:
bridge: str
source: str
related: tuple[str, ...]
capability_translation_rules: tuple[str, ...]
@classmethod
def from_mapping(cls, value: dict[str, Any]) -> "PartSkill":
@@ -72,9 +71,6 @@ class PartSkill:
bridge=str(value["bridge"]),
source=str(value["source"]),
related=tuple(str(item) for item in value.get("related") or []),
capability_translation_rules=tuple(
str(item) for item in value.get("capability_translation_rules") or []
),
)
@@ -84,6 +80,7 @@ class PartSkillLibrary:
def __init__(self, root: Path) -> None:
self.root = Path(root)
payload = json.loads((self.root / "catalog.json").read_text(encoding="utf-8"))
self.catalog_version = str(payload.get("schema_version") or "1.0")
self.max_planning = int(payload.get("max_planning") or 1)
self.max_support = int(payload.get("max_support") or 3)
self.skills = tuple(PartSkill.from_mapping(item) for item in payload.get("skills") or [])
@@ -112,9 +109,10 @@ class PartSkillLibrary:
"kind": skill.kind,
"category": skill.kind,
"title": skill.title,
"version": self.catalog_version,
"summary": skill.title,
"source": skill.source,
"bridge": skill.bridge,
"capability_translation_rules": list(skill.capability_translation_rules),
"selection": selection,
"matched_triggers": list(matched),
}
@@ -207,14 +205,7 @@ class PartSkillLibrary:
revision_ids = [str(item) for item in (revision or {}).get("part_skill_ids") or [] if str(item) in self.by_id]
if revision_ids:
return revision_ids
# Legacy tasks can contain a DesignIntent created before their first
# runtime build. Retain this fallback only for historical artifacts.
intent_id = str((task or {}).get("current_design_intent_id") or "")
intent = next(
(item for item in (task or {}).get("design_intents") or [] if str(item.get("intent_id") or "") == intent_id),
None,
)
return [str(item) for item in (intent or {}).get("part_skill_ids") or [] if str(item) in self.by_id]
return []
def render_context(self, selection: dict[str, Any]) -> str:
records = selection.get("skills") or []
@@ -233,62 +224,9 @@ class PartSkillLibrary:
return "\n".join(sections)
def audit(self, selection: dict[str, Any], cdsl: dict[str, Any], assumptions: list[str] | None = None) -> dict[str, Any]:
features = [item for item in cdsl.get("features") or [] if isinstance(item, dict)]
feature_ids = [str(item.get("id")) for item in features if item.get("id")]
atomics = [str(item.get("atomic_id")) for item in features]
sketches = [item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict)]
profiles = [str((item.get("profile") or {}).get("type")) for item in sketches]
evidence = {
"feature_ids": feature_ids,
"atomic_ids": sorted(set(atomics)),
"profile_types": sorted(set(profiles)),
"features": [
{
"id": str(item.get("id") or ""),
"atomic_id": str(item.get("atomic_id") or ""),
"sketch_id": str(item.get("sketch_id") or ""),
"params": item.get("params") if isinstance(item.get("params"), dict) else {},
}
for item in features
],
"profiles": [
{
"id": str(item.get("id") or ""),
"type": str((item.get("profile") or {}).get("type") or ""),
"profile": item.get("profile") if isinstance(item.get("profile"), dict) else {},
}
for item in sketches
],
}
translations: list[dict[str, Any]] = []
for record in selection.get("skills") or []:
skill_id = str(record.get("id"))
status = "exact"
reason = "The selected structural guidance is represented by the submitted CDSL plan."
if skill_id == "functional/flange-bolt-circle":
if "circles" in profiles and "extrude_cut_blind" in atomics:
status, reason = "expanded", "Circular bolt layout is represented by explicit circle geometry because no circular-pattern atomic exists."
else:
status, reason = "blocked", "No explicit circular bolt layout evidence was found in the CDSL."
elif skill_id == "atomic/threaded-hole-creation":
status, reason = "approximated", "The current runtime preserves a cylindrical bore but does not generate helical thread topology."
elif skill_id == "atomic/fillet-chamfer-last":
finishing = [item for item in features if item.get("atomic_id") in {"fillet", "chamfer"}]
if not finishing:
status, reason = "omitted", "No stable finishing selector was submitted; edge treatment was omitted."
elif skill_id == "atomic/pattern-holes-from-datum":
if "circles" in profiles or "circle_grid" in profiles:
status, reason = "expanded", "The layout is represented by explicit profile circles."
elif not any(item in {"pattern_linear", "pattern_mirror"} for item in atomics):
status, reason = "blocked", "No supported pattern or explicit circle layout was found."
translation: dict[str, Any] = {"skill_id": skill_id, "status": status, "reason": reason, "evidence": evidence}
if skill_id == "functional/flange-bolt-circle" and status == "expanded":
translation["translation"] = "circular_pattern_to_explicit_circles"
elif skill_id == "atomic/threaded-hole-creation":
translation["translation"] = "thread_geometry_omitted"
elif skill_id == "atomic/fillet-chamfer-last" and status == "omitted":
translation["translation"] = "selector_unavailable"
translations.append(translation)
# Skills are prompt knowledge only. Do not interpret a selected skill
# as geometry, capability translation, or an executable model rule.
del cdsl
return {
"schema_version": "1.0",
"request": selection.get("request", ""),
@@ -298,6 +236,4 @@ class PartSkillLibrary:
"inherited_skill_ids": list(selection.get("inherited_skill_ids") or []),
"conflict": selection.get("conflict"),
"assumptions": [str(item) for item in assumptions or []],
"evidence": evidence,
"capability_translations": translations,
}
+262
View File
@@ -0,0 +1,262 @@
"""Generic, model-family-independent verification for direct CDSL revisions."""
from __future__ import annotations
import math
from typing import Any
QUALITY_RULE_TYPES = frozenset({
"bbox", "solid_count", "feature_count", "hole_count", "hole_diameter",
"hole_center", "overall_length", "overall_diameter", "through_condition",
})
_FEATURE_RULE_TYPES = {"hole_count", "hole_diameter", "hole_center", "through_condition"}
_SEVERITIES = {"blocking", "warning", "informational"}
def _bbox_bounds(engine_result: dict[str, Any], feature_id: str | None = None) -> tuple[list[float], list[float]] | None:
"""Return a whole-model or feature-owned runtime bounding box."""
candidates: list[Any] = []
if feature_id:
for record in engine_result.get("topology_records") or []:
if not isinstance(record, dict):
continue
owners = record.get("owner_feature_ids") or []
if record.get("feature_id") == feature_id or feature_id in owners:
geometry = record.get("geometry")
if isinstance(geometry, dict):
candidates.append(geometry.get("bbox_mm"))
else:
bbox = engine_result.get("bbox_mm")
if isinstance(bbox, dict):
candidates.append([
*(bbox.get("min") or []),
*(bbox.get("max") or []),
])
boxes = [
value for value in candidates
if isinstance(value, list) and len(value) == 6
and all(isinstance(item, (int, float)) and math.isfinite(float(item)) for item in value)
]
if not boxes:
return None
minimum = [min(box[index] for box in boxes) for index in (0, 1, 2)]
maximum = [max(box[index] for box in boxes) for index in (3, 4, 5)]
return [float(item) for item in minimum], [float(item) for item in maximum]
def _bbox_value(engine_result: dict[str, Any], feature_id: str | None = None) -> dict[str, Any] | None:
bounds = _bbox_bounds(engine_result, feature_id)
if bounds is None:
return None
minimum, maximum = bounds
return {
"min": minimum,
"max": maximum,
"dimensions": [maximum[index] - minimum[index] for index in range(3)],
}
def _dimensions(engine_result: dict[str, Any]) -> list[float] | None:
value = _bbox_value(engine_result)
return value["dimensions"] if value else None
def _feature(cdsl: dict[str, Any], feature_id: str | None) -> dict[str, Any] | None:
return next((item for item in cdsl.get("features") or [] if isinstance(item, dict) and str(item.get("id")) == feature_id), None)
def _sketch(cdsl: dict[str, Any], sketch_id: str | None) -> dict[str, Any] | None:
return next((item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict) and str(item.get("id")) == sketch_id), None)
def _circles(profile: dict[str, Any]) -> list[dict[str, Any]]:
if profile.get("type") == "circle":
center, radius = profile.get("center"), profile.get("radius_mm")
if isinstance(center, list) and len(center) >= 2 and isinstance(radius, (int, float)):
return [{"center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}]
if profile.get("type") != "analytic_contours":
return []
return [
{"center": [float(segment["center"][0]), float(segment["center"][1])], "radius_mm": float(segment["radius_mm"])}
for contour in profile.get("contours") or [] if isinstance(contour, dict)
for segment in contour.get("segments") or [] if isinstance(segment, dict)
and segment.get("type") == "circle"
and isinstance(segment.get("center"), list) and len(segment["center"]) >= 2
and isinstance(segment.get("radius_mm"), (int, float))
]
def _finite_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value))
def _validate_expected(kind: str, expected: Any, index: int) -> None:
scalar_types = {"solid_count", "feature_count", "hole_count", "hole_diameter", "overall_length", "overall_diameter"}
if kind in scalar_types and not _finite_number(expected):
raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}")
if kind == "bbox":
valid_dimensions = isinstance(expected, list) and len(expected) == 3 and all(_finite_number(value) for value in expected)
valid_ranges = isinstance(expected, dict) and set(expected) in ({"min", "max"}, {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"})
if valid_ranges and set(expected) == {"min", "max"}:
valid_ranges = all(
isinstance(expected[key], list)
and len(expected[key]) == 3
and all(_finite_number(value) for value in expected[key])
for key in ("min", "max")
)
elif valid_ranges:
valid_ranges = all(_finite_number(expected[key]) for key in expected)
if not valid_dimensions and not valid_ranges:
raise ValueError(
f"verification.rules[{index}].expected for bbox must be a three-number bbox [dx, dy, dz], "
"{min, max}, or {x_min, x_max, y_min, y_max, z_min, z_max}"
)
if kind == "hole_center" and (
not isinstance(expected, list) or len(expected) != 2 or not all(_finite_number(value) for value in expected)
):
raise ValueError(f"verification.rules[{index}].expected must be a two-number hole center")
if kind == "through_condition" and not isinstance(expected, bool):
raise ValueError(f"verification.rules[{index}].expected must be boolean for through_condition")
def validate_verification(verification: Any, cdsl: dict[str, Any]) -> list[dict[str, Any]]:
if verification is None:
return []
if not isinstance(verification, dict) or set(verification) - {"rules"}:
raise ValueError("verification must be an object containing only rules")
rules = verification.get("rules", [])
if not isinstance(rules, list) or len(rules) > 32:
raise ValueError("verification.rules must be an array with at most 32 rules")
feature_ids = {str(item.get("id")) for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id")}
normalized: list[dict[str, Any]] = []
ids: set[str] = set()
for index, raw in enumerate(rules):
if not isinstance(raw, dict):
raise ValueError(f"verification.rules[{index}] must be an object")
allowed = {"id", "type", "feature", "expected", "tolerance", "severity"}
if set(raw) - allowed:
raise ValueError(f"verification.rules[{index}] has unsupported fields")
rule_id = str(raw.get("id") or "").strip()
kind = str(raw.get("type") or "").strip()
if not rule_id or rule_id in ids:
raise ValueError(f"verification.rules[{index}].id must be unique and non-empty")
if kind not in QUALITY_RULE_TYPES:
raise ValueError(f"verification.rules[{index}].type is unsupported: {kind}")
if "expected" not in raw:
raise ValueError(f"verification.rules[{index}].expected is required")
_validate_expected(kind, raw["expected"], index)
severity = str(raw.get("severity") or "blocking")
if severity not in _SEVERITIES:
raise ValueError(f"verification.rules[{index}].severity is unsupported")
try:
tolerance = float(raw.get("tolerance") or 0.0)
except (TypeError, ValueError) as error:
raise ValueError(f"verification.rules[{index}].tolerance must be numeric") from error
if not math.isfinite(tolerance) or tolerance < 0:
raise ValueError(f"verification.rules[{index}].tolerance must be finite and non-negative")
feature = str(raw.get("feature") or "").strip()
if kind in _FEATURE_RULE_TYPES and not feature:
raise ValueError(f"verification.rules[{index}].feature is required for {kind}")
if feature and feature not in feature_ids:
raise ValueError(f"verification.rules[{index}].feature must reference a CDSL feature ID")
ids.add(rule_id)
normalized.append({"id": rule_id, "type": kind, "feature": feature, "expected": raw["expected"], "tolerance": tolerance, "severity": severity})
return normalized
def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> tuple[Any, str]:
kind, target = rule["type"], rule.get("feature") or None
feature = _feature(cdsl, target)
params = (feature or {}).get("params") if isinstance((feature or {}).get("params"), dict) else {}
sketch = _sketch(cdsl, str((feature or {}).get("sketch_id") or ""))
profile = (sketch or {}).get("profile") if isinstance(sketch, dict) else {}
circles = _circles(profile) if isinstance(profile, dict) else []
dimensions = _dimensions(engine_result)
if kind == "bbox":
value = _bbox_value(engine_result, target)
source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm"
return value, source
if kind == "solid_count":
return float(engine_result.get("solid_count", 1)), "runtime.solid_count"
if kind == "feature_count":
return float(len(cdsl.get("features") or [])), "cdsl.features"
if kind == "hole_count":
return float(len(circles)), f"cdsl.features.{target}.sketch"
if kind == "hole_diameter":
value = params.get("diameter_mm", params.get("hole_diameter_mm"))
if isinstance(value, (int, float)):
return float(value), f"cdsl.features.{target}.params"
return (circles[0]["radius_mm"] * 2 if circles else None), f"cdsl.features.{target}.sketch"
if kind == "hole_center":
centers = [circle["center"] for circle in circles]
return (centers[0] if len(centers) == 1 else centers), f"cdsl.features.{target}.sketch"
if kind == "overall_length":
return (max(dimensions) if dimensions else None), "runtime.bbox_mm"
if kind == "overall_diameter":
return (min(dimensions) if dimensions else None), "runtime.bbox_mm"
if kind == "through_condition":
end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {}
if end.get("type") in {"through_all", "through_all_both", "through_all_and_blind"}:
return True, f"cdsl.features.{target}.params.end_condition"
distance = params.get("distance_mm")
return bool(isinstance(distance, (int, float)) and dimensions and float(distance) >= min(dimensions) - 1e-6), "cdsl.params + runtime.bbox_mm"
return None, "unsupported verification type"
def _matches(expected: Any, actual: Any, tolerance: float) -> bool:
if actual is None:
return False
if isinstance(expected, list):
return isinstance(actual, list) and len(expected) == len(actual) and all(
_matches(expected_item, actual_item, tolerance) for expected_item, actual_item in zip(expected, actual)
)
if isinstance(expected, bool):
return bool(actual) is expected
if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
return math.isclose(float(expected), float(actual), abs_tol=tolerance, rel_tol=0.0)
return expected == actual
def _matches_bbox(expected: Any, actual: dict[str, Any] | None, tolerance: float) -> bool:
if actual is None:
return False
if isinstance(expected, list):
return _matches(expected, actual["dimensions"], tolerance)
if not isinstance(expected, dict):
return False
if set(expected) == {"min", "max"}:
return _matches(expected["min"], actual["min"], tolerance) and _matches(expected["max"], actual["max"], tolerance)
if set(expected) == {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"}:
actual_ranges = {
"x_min": actual["min"][0], "x_max": actual["max"][0],
"y_min": actual["min"][1], "y_max": actual["max"][1],
"z_min": actual["min"][2], "z_max": actual["max"][2],
}
return all(_matches(expected[key], actual_ranges[key], tolerance) for key in actual_ranges)
return False
def evaluate_quality(rules: list[dict[str, Any]], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> dict[str, Any]:
results = []
for rule in rules:
actual, source = _actual(rule, cdsl, engine_result)
passed = (
_matches_bbox(rule["expected"], actual, rule["tolerance"])
if rule["type"] == "bbox"
else _matches(rule["expected"], actual, rule["tolerance"])
)
results.append({**rule, "status": "passed" if passed else ("failed" if actual is not None else "unavailable"), "actual": actual, "source": source})
blocking = [result for result in results if result["severity"] == "blocking" and result["status"] != "passed"]
warnings = [result for result in results if result["severity"] != "blocking" and result["status"] != "passed"]
return {
"schema": "cad.quality-report.v1",
"schema_version": "1.0",
"status": "passed" if not blocking else "failed",
"verification_requested": bool(rules),
"results": results,
"blocking_failures": blocking,
"warnings": warnings,
"measurements": {"bbox_mm": engine_result.get("bbox_mm"), "solid_count": engine_result.get("solid_count", 1)},
}
+36 -86
View File
@@ -12,7 +12,6 @@ from app.settings import Settings
TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$")
CONVERSATION_ID = re.compile(r"^conv_[a-z0-9]{12}$")
DESIGN_INTENT_ID = re.compile(r"^intent_[a-z0-9]{12}$")
def now_iso() -> str:
@@ -37,13 +36,6 @@ def safe_conversation_id(conversation_id: str) -> str:
return value
def safe_design_intent_id(intent_id: str) -> str:
value = str(intent_id or "").strip()
if not DESIGN_INTENT_ID.fullmatch(value):
raise ValueError("Invalid design intent id")
return value
def safe_relative_path(value: str) -> str:
path = Path(str(value or ""))
if not value or path.is_absolute() or ".." in path.parts:
@@ -104,6 +96,15 @@ class WorkspaceStore:
write_json(path, payload)
return (Path(conversation) / relative).as_posix()
def write_conversation_planning(self, conversation_id: str, prefix: str, payload: dict[str, Any]) -> str:
"""Persist structured intake/planning evidence before a task exists."""
conversation = safe_conversation_id(conversation_id)
safe_prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", prefix).strip("-") or "planning"
relative = Path("planning") / f"{safe_prefix}-{secrets.token_hex(6)}.json"
path = self.conversation_dir(conversation) / relative
write_json(path, payload)
return (Path(conversation) / relative).as_posix()
def ensure_conversation(
self,
conversation_id: str | None,
@@ -184,14 +185,12 @@ class WorkspaceStore:
task_dir = self.task_dir(tid)
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
record = {
"schema_version": "1.1",
"schema_version": "1.2",
"task_id": tid,
"request": request,
"created_at": now_iso(),
"updated_at": now_iso(),
"current_revision": "",
"current_design_intent_id": "",
"design_intents": [],
"revisions": [],
}
write_json(path, record)
@@ -213,81 +212,6 @@ class WorkspaceStore:
write_json(self.task_path(task_id), task)
return task
def create_design_intent(
self,
task_id: str | None,
request: str,
intent: dict[str, Any],
part_skill_selection: dict[str, Any],
) -> dict[str, Any]:
"""Persist a validated plan and atomically make it the current plan."""
task = self.ensure_task(task_id, request)
task.setdefault("design_intents", [])
task.setdefault("current_design_intent_id", "")
task["schema_version"] = "1.1"
previous_id = str(task.get("current_design_intent_id") or "")
if previous_id:
for record in task["design_intents"]:
if str(record.get("intent_id") or "") == previous_id and record.get("status") != "superseded":
record["status"] = "superseded"
intent_id = new_id("intent")
created_at = now_iso()
relative = Path("planning") / f"design-intent-{intent_id}.json"
skill_ids = [str(item) for item in part_skill_selection.get("skill_ids") or [] if str(item)]
persisted = dict(intent)
persisted.update({
"intent_id": intent_id,
"created_at": created_at,
"part_skill_ids": skill_ids,
"part_skill_selection": part_skill_selection,
})
write_json(self.artifact_path(task["task_id"], relative.as_posix()), persisted)
record = {
"intent_id": intent_id,
"status": "accepted" if persisted.get("status") == "ready" else "pending",
"created_at": created_at,
"path": relative.as_posix(),
"part_skill_ids": skill_ids,
"base_revision_id": str(persisted.get("base_revision_id") or ""),
}
task["design_intents"].append(record)
task["current_design_intent_id"] = intent_id
task["updated_at"] = now_iso()
write_json(self.task_path(task["task_id"]), task)
return {"task_id": task["task_id"], **record, "intent": persisted}
def read_design_intent(self, task_id: str, intent_id: str | None = None) -> dict[str, Any] | None:
task = self.read_task(task_id)
if not task:
return None
chosen = safe_design_intent_id(intent_id) if intent_id else str(task.get("current_design_intent_id") or "")
if not chosen:
return None
record = next(
(item for item in task.get("design_intents") or [] if str(item.get("intent_id") or "") == chosen),
None,
)
if not isinstance(record, dict):
return None
path = self.artifact_path(task_id, str(record.get("path") or ""))
intent = read_json(path)
if not isinstance(intent, dict):
return None
return {"task_id": task_id, "record": record, "intent": intent}
def update_design_intent_status(self, task_id: str, intent_id: str, status: str) -> dict[str, Any]:
safe_id = safe_design_intent_id(intent_id)
task = self.ensure_task(task_id, "")
records = task.setdefault("design_intents", [])
record = next((item for item in records if str(item.get("intent_id") or "") == safe_id), None)
if not isinstance(record, dict):
raise ValueError("Design intent not found")
record["status"] = str(status)
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return record
def read_task(self, task_id: str) -> dict[str, Any] | None:
return read_json(self.task_path(task_id))
@@ -299,6 +223,32 @@ class WorkspaceStore:
candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json"
return candidate if candidate.is_file() else None
def latest_repairable_cdsl(self, task_id: str) -> tuple[str, Path] | None:
"""Return the latest revision CDSL when a quality failure needs repair."""
task = self.read_task(task_id)
for revision in reversed((task or {}).get("revisions") or []):
revision_id = str(revision.get("revision_id") or "")
if not revision_id or str(revision.get("quality_status") or "") != "needs_repair":
continue
path = self.revision_cdsl_path(task_id, revision_id)
if path is not None:
return revision_id, path
return None
def revision_cdsl_path(self, task_id: str, revision_id: str) -> Path | None:
task = self.read_task(task_id)
revision = next(
(item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id),
None,
)
if not isinstance(revision, dict):
return None
relative = str(revision.get("cdsl_path") or "")
if not relative:
return None
candidate = self.artifact_path(task_id, relative)
return candidate if candidate.is_file() else None
def artifact_path(self, task_id: str, relative_path: str) -> Path:
safe = safe_relative_path(relative_path)
root = self.task_dir(task_id).resolve()
+2
View File
@@ -50,6 +50,7 @@ class Settings:
llm_timeout_s: float
default_provider_id: str
providers: tuple[ProviderConfig, ...]
max_repair_attempts: int = 4
@property
def llm_configured(self) -> bool:
@@ -137,6 +138,7 @@ def get_settings() -> Settings:
llm_api_key=default_provider.api_key,
llm_model=default_model,
llm_timeout_s=float(os.getenv("CDSL_LLM_TIMEOUT_S", "90")),
max_repair_attempts=max(0, int(os.getenv("CDSL_MAX_REPAIR_ATTEMPTS", "4"))),
default_provider_id=default_provider_id,
providers=providers,
)
+6
View File
@@ -0,0 +1,6 @@
"""Compatibility adapters for imported and historical CDSL documents.
This package is deliberately outside ``cdsl_engine``. It may lower retired
source syntax into the engine's generic CDSL contract, but is never imported
by the CDSL runtime.
"""
@@ -0,0 +1,117 @@
"""Explicit import/rebuild compatibility for retired CDSL profile macros.
The CDSL runtime accepts only direct geometric profiles. This adapter is for
historical artifacts and importers: it expands a legacy shorthand exactly once
into an ``analytic_contours`` profile before generic runtime validation.
"""
from __future__ import annotations
import copy
from typing import Any
from .legacy_profiles import LEGACY_PROFILE_GENERATORS
class LegacyProfileError(ValueError):
"""A legacy profile could not be lowered to generic CDSL geometry."""
def legacy_profile_types() -> tuple[str, ...]:
return tuple(sorted(LEGACY_PROFILE_GENERATORS))
def _segment_from_edge(edge: dict[str, Any]) -> dict[str, Any]:
kind = str(edge.get("type") or "")
if kind == "line":
return {
"type": "line",
"start": list(edge["start_mm"][:2]),
"end": list(edge["end_mm"][:2]),
}
if kind == "arc":
segment = {
"type": "arc",
"start": list(edge["start_mm"][:2]),
"end": list(edge["end_mm"][:2]),
"center": list(edge["center_mm"][:2]),
"radius_mm": float(edge["radius_mm"]),
}
if "clockwise" in edge:
segment["clockwise"] = bool(edge["clockwise"])
return segment
raise LegacyProfileError(f"Legacy profile emitted unsupported edge type: {kind}")
def _circle_contour(entity: dict[str, Any]) -> dict[str, Any]:
radius = entity.get("radius_mm")
if not isinstance(radius, (int, float)) or float(radius) <= 0:
raise LegacyProfileError("Legacy circle must have radius_mm > 0")
center = entity.get("center") or [0.0, 0.0]
if not isinstance(center, list) or len(center) < 2:
raise LegacyProfileError("Legacy circle must have a two-dimensional center")
return {
"role": "outer",
"closed": True,
"segments": [{"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}],
}
def _edge_contour(edges: list[dict[str, Any]], *, role: str) -> dict[str, Any]:
return {
"role": role,
"closed": True,
"segments": [_segment_from_edge(edge) for edge in edges],
}
def lower_legacy_profile(profile: dict[str, Any], *, sketch_id: str = "") -> dict[str, Any]:
"""Expand one named legacy profile to a direct analytic contour profile."""
kind = str(profile.get("type") or "")
generator = LEGACY_PROFILE_GENERATORS.get(kind)
if generator is None:
raise LegacyProfileError(f"Unsupported legacy profile: {kind!r}")
meta: dict[str, Any] = {"id": sketch_id, "_entities": None, "_contour": None}
try:
entities, contour = generator(copy.deepcopy(profile), meta)
except (KeyError, TypeError, ValueError) as error:
raise LegacyProfileError(f"Could not lower legacy profile {kind!r}: {error}") from error
contours: list[dict[str, Any]] = []
if contour:
contours.append(_edge_contour(contour, role="outer"))
for region in meta.get("_regions") or []:
if not isinstance(region, dict):
continue
outer = region.get("outer") or []
if outer:
contours.append(_edge_contour(outer, role="outer"))
for hole in region.get("holes") or []:
if hole:
contours.append(_edge_contour(hole, role="inner"))
for entity in entities or []:
if entity.get("type") == "circle" and not entity.get("construction"):
contours.append(_circle_contour(entity))
if not contours:
raise LegacyProfileError(f"Legacy profile {kind!r} did not produce a closed contour")
return {"type": "analytic_contours", "contours": contours}
def lower_legacy_profiles(cdsl: dict[str, Any]) -> dict[str, Any]:
"""Return a copy with every self-contained legacy sketch lowered.
This is opt-in. Calling the runtime directly with a legacy profile still
returns an unsupported-profile diagnostic.
"""
lowered = copy.deepcopy(cdsl)
sketches = ((lowered.get("geometry") or {}).get("sketches") or [])
for sketch in sketches:
if not isinstance(sketch, dict):
continue
profile = sketch.get("profile")
if not isinstance(profile, dict):
continue
if str(profile.get("type") or "") not in LEGACY_PROFILE_GENERATORS:
continue
sketch["profile"] = lower_legacy_profile(profile, sketch_id=str(sketch.get("id") or ""))
return lowered
File diff suppressed because it is too large Load Diff
@@ -26,9 +26,9 @@ from pathlib import Path
from typing import Any, Callable
try:
from .translator import normalize_to_ir
except ImportError: # 允许直接 python engine/convert_to_cdsl.py
from translator import normalize_to_ir
from engine.cdsl_engine.translator import normalize_to_ir
except ModuleNotFoundError: # Engine root added to sys.path by product runtime.
from cdsl_engine.translator import normalize_to_ir
# ═══════════════════════════════════════════════════════════════
@@ -882,8 +882,16 @@ def convert_sw_json_to_cdsl(
},
}
# The import classifier may use historical shape labels while recognizing
# a SolidWorks sketch. Lower them before exposing the result as CDSL so
# the runtime contract remains line/arc/circle/contour based.
try:
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
except ImportError: # pragma: no cover - direct converter invocation
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
cdsl = _clean_cdsl_floats(cdsl)
return cdsl
return lower_legacy_profiles(cdsl)
def op_to_raw(feat: dict, op: dict, all_ops: list) -> dict | None:
+3 -9
View File
@@ -6,22 +6,19 @@ The package exposes the CDSL-only rebuild API used by the backend Agent.
from __future__ import annotations
from .convert_to_cdsl import convert_sw_json_to_cdsl, write_cdsl_outputs
from .llm_compiler import compile_cdsl
from .llm_engine import run_engine_plan
from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run_engine, run_rebuild
from .semantic_validation import validate_semantic_cdsl
from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches
from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches
from .runtime import ALL_ATOMIC_IDS, EXECUTORS, RuntimeExecutionError, analyze_cdsl, rebuild_cdsl
from .design_intent import DesignIntentError, design_intent_from_cdsl, validate_design_intent, validate_intent_cdsl
# The package-level runtime contract is the session executor registry. The
# older llm_engine dispatcher remains available only for legacy engine packs.
SUPPORTED_ATOMIC_IDS = ALL_ATOMIC_IDS
SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
__all__ = [
"convert_sw_json_to_cdsl",
"write_cdsl_outputs",
"compile_cdsl",
"compile_cdsl_to_pack",
"run_engine_plan",
@@ -31,6 +28,7 @@ __all__ = [
"compare_with_gold",
"resolve_all_sketches",
"resolve_required_sketches",
"CORE_SHAPE_GENERATORS",
"SHAPE_GENERATORS",
"SUPPORTED_ATOMIC_IDS",
"validate_semantic_cdsl",
@@ -39,10 +37,6 @@ __all__ = [
"rebuild_cdsl",
"analyze_cdsl",
"RuntimeExecutionError",
"DesignIntentError",
"validate_design_intent",
"validate_intent_cdsl",
"design_intent_from_cdsl",
]
__version__ = "1.0.0"
+2 -1
View File
@@ -13,6 +13,7 @@ from collections import Counter
from pathlib import Path
from typing import Any, Iterable
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
from .semantic_validation import validate_semantic_cdsl
@@ -225,7 +226,7 @@ def analyze_document(
"failure_category": "preflight_blocked",
}
try:
cdsl = json.loads(path.read_text(encoding="utf-8"))
cdsl = lower_legacy_profiles(json.loads(path.read_text(encoding="utf-8")))
report["part_id"] = str(cdsl.get("part_id") or report["part_id"])
semantic = validate_semantic_cdsl(cdsl)
report["semantic_valid"] = True
@@ -5,7 +5,7 @@ from __future__ import annotations
import math
from typing import Any, Iterable
from build123d import Axis, Edge, Face, Plane, Solid, Vector, Wire, export_step
from build123d import Axis, Compound, Edge, Face, Plane, Solid, Vector, Wire, export_step
from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature
@@ -201,13 +201,23 @@ class Build123dGeometryAdapter:
def revolve(face: Face, angle_deg: float, axis: AxisSpec) -> Solid:
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
@staticmethod
def _body_shape(value: Any) -> Any:
"""Normalize boolean results, including disconnected ShapeList values."""
if hasattr(value, "bounding_box"):
return value
shapes = list(value)
if not shapes:
raise ValueError("Boolean operation produced no shapes")
return shapes[0] if len(shapes) == 1 else Compound(shapes)
@staticmethod
def fuse(body: Any | None, solid: Solid) -> Any:
return solid if body is None else body.fuse(solid)
return solid if body is None else Build123dGeometryAdapter._body_shape(body.fuse(solid))
@staticmethod
def cut(body: Any, tool: Any) -> Any:
return body.cut(tool)
return Build123dGeometryAdapter._body_shape(body.cut(tool))
@staticmethod
def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
+4 -86
View File
@@ -258,24 +258,18 @@
"analyticSegment": {
"type": "object",
"properties": {
"type": {"enum": ["line", "arc", "circle", "bspline"]},
"type": {"enum": ["line", "arc", "circle"]},
"start": {"$ref": "#/$defs/point2"},
"end": {"$ref": "#/$defs/point2"},
"center": {"$ref": "#/$defs/point2"},
"radius_mm": {"$ref": "#/$defs/positive"},
"clockwise": {"type": "boolean"},
"degree": {"type": "integer", "minimum": 1},
"control_points": {"type": "array", "items": {"$ref": "#/$defs/point2"}},
"knots": {"type": "array", "items": {"$ref": "#/$defs/number"}},
"weights": {"type": "array", "items": {"$ref": "#/$defs/positive"}},
"periodic": {"type": "boolean"}
"clockwise": {"type": "boolean"}
},
"required": ["type"],
"allOf": [
{"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start", "end"]}},
{"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start", "end", "center", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["degree", "control_points", "knots"]}}
{"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}}
],
"additionalProperties": false
},
@@ -334,87 +328,11 @@
{"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}}
]
},
"rectangleBounds": {
"type": "object",
"properties": {"center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}},
"allOf": [
{"oneOf": [
{"required": ["center", "width_mm", "height_mm"], "not": {"anyOf": [{"required": ["min_mm"]}, {"required": ["max_mm"]}]}},
{"required": ["min_mm", "max_mm"], "not": {"anyOf": [{"required": ["center"]}, {"required": ["width_mm"]}, {"required": ["height_mm"]}]}}
]}
]
},
"rectangleBoundary": {
"type": "object",
"properties": {"type": {"enum": ["rectangle", "rectangle_with_fillets"]}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}, "fillet_radius_mm": {"$ref": "#/$defs/positive"}},
"allOf": [{"$ref": "#/$defs/rectangleBounds"}],
"additionalProperties": false
},
"profile_type": {"enum": ["circle", "annulus", "circles", "circle_grid", "rectangle", "rectangle_with_circles", "rectangle_with_fillets", "obround", "polygon", "ibone", "rectangle_with_symmetric_notches", "revolve_chamfer", "revolve_chamfer_slanted", "circle_with_arc_notches", "circular_sector_slot", "circle_with_radial_tabs", "filleted_rect_side_slots", "d_shape", "partial_ring", "partial_ring_with_arc_island", "radial_slot", "arc_chain", "patterned_cutouts", "compound_patterned_cutouts", "analytic_contours"]},
"motif_type": {"enum": ["circle", "square", "rectangle", "obround", "cross", "d_shape_polygon", "regular_hexagon", "skew_hexagon", "triangle", "teardrop_polygon", "trapezoid", "annular_sector_polygon"]},
"layout_type": {"enum": ["ring", "angular", "concentric_rings", "disc_grid", "open_arc", "spiral", "cross_lines", "x_field", "twin_strips", "corner_clusters", "diamond_field"]},
"motif": {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "circle"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "square"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "width_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "rectangle"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "width_mm", "height_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "obround"}, "length_mm": {"$ref": "#/$defs/positive"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "length_mm", "width_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "cross"}, "size_mm": {"$ref": "#/$defs/positive"}, "arm_width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "size_mm", "arm_width_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "d_shape_polygon"}, "stem_length_mm": {"$ref": "#/$defs/positive"}, "nose_depth_mm": {"$ref": "#/$defs/positive"}, "half_height_mm": {"$ref": "#/$defs/positive"}, "arc_segments": {"$ref": "#/$defs/positiveInteger"}}, "required": ["type", "stem_length_mm", "nose_depth_mm", "half_height_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "regular_hexagon"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "skew_hexagon"}, "nominal_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "nominal_radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "triangle"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "teardrop_polygon"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "shoulder_fraction": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1}, "left_width_mm": {"$ref": "#/$defs/positive"}, "right_width_mm": {"$ref": "#/$defs/positive"}, "tip_height_mm": {"$ref": "#/$defs/positive"}, "bottom_depth_mm": {"$ref": "#/$defs/positive"}, "shoulder_height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type"], "oneOf": [{"required": ["width_mm", "height_mm"], "not": {"anyOf": [{"required": ["left_width_mm"]}, {"required": ["right_width_mm"]}, {"required": ["tip_height_mm"]}, {"required": ["bottom_depth_mm"]}, {"required": ["shoulder_height_mm"]}]}}, {"required": ["left_width_mm", "right_width_mm", "tip_height_mm", "bottom_depth_mm", "shoulder_height_mm"], "not": {"anyOf": [{"required": ["width_mm"]}, {"required": ["height_mm"]}, {"required": ["shoulder_fraction"]}]}}], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "trapezoid"}, "bottom_width_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "bottom_width_mm", "top_width_mm", "height_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "annular_sector_polygon"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "arc_segments": {"$ref": "#/$defs/positiveInteger"}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "half_angle_deg"], "additionalProperties": false}
]
},
"layoutCommon": {
"type": "object",
"properties": {"orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}
},
"layout": {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "ring"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm", "count"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "angular"}, "radius_mm": {"$ref": "#/$defs/number"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "count"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "concentric_rings"}, "rings": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": {"radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}}, "required": ["radius_mm", "count"], "additionalProperties": false}}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "rings"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "disc_grid"}, "count_x": {"$ref": "#/$defs/positiveInteger"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "spacing_x_mm": {"$ref": "#/$defs/positive"}, "spacing_y_mm": {"$ref": "#/$defs/positive"}, "center_mm": {"$ref": "#/$defs/point2"}, "max_center_radius_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "open_arc"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm", "count", "start_angle_deg", "end_angle_deg"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "spiral"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_radius_mm": {"$ref": "#/$defs/positive"}, "radius_step_mm": {"$ref": "#/$defs/number"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "count", "start_radius_mm", "radius_step_mm", "angle_step_deg"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "cross_lines"}, "count_per_axis": {"$ref": "#/$defs/positiveInteger"}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "count_per_axis", "spacing_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "x_field"}, "levels": {"$ref": "#/$defs/positiveInteger"}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "levels", "spacing_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "twin_strips"}, "x_offset_mm": {"$ref": "#/$defs/positive"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "y_start_mm": {"$ref": "#/$defs/number"}, "y_end_mm": {"$ref": "#/$defs/number"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "x_offset_mm", "count_y", "y_start_mm", "y_end_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "corner_clusters"}, "levels_mm": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/positive"}}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "levels_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "diamond_field"}, "manhattan_radius": {"type": "integer", "minimum": 0}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "manhattan_radius", "spacing_mm"], "additionalProperties": false}
]
},
"pattern": {"type": "object", "properties": {"motif": {"$ref": "#/$defs/motif"}, "layout": {"$ref": "#/$defs/layout"}}, "required": ["motif", "layout"], "additionalProperties": false},
"profile_type": {"enum": ["circle", "polygon", "analytic_contours"]},
"profile": {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "annulus"}, "center": {"$ref": "#/$defs/point2"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "inner_radius_mm", "outer_radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "circles"}, "items": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "items"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "circle_grid"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count_x": {"$ref": "#/$defs/positiveInteger"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "spacing_x_mm": {"$ref": "#/$defs/positive"}, "spacing_y_mm": {"$ref": "#/$defs/positive"}, "origin_mm": {"$ref": "#/$defs/point2"}, "center_mm": {"$ref": "#/$defs/point2"}}, "required": ["type", "radius_mm", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], "additionalProperties": false, "oneOf": [{"required": ["origin_mm"], "not": {"required": ["center_mm"]}}, {"required": ["center_mm"], "not": {"required": ["origin_mm"]}}]},
{"type": "object", "properties": {"type": {"const": "rectangle"}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}}, "required": ["type"], "allOf": [{"$ref": "#/$defs/rectangleBounds"}], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "rectangle_with_circles"}, "boundary": {"$ref": "#/$defs/rectangleBoundary"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "boundary"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "rectangle_with_fillets"}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}, "fillet_radius_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type"], "allOf": [{"$ref": "#/$defs/rectangleBounds"}], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "obround"}, "center": {"$ref": "#/$defs/point2"}, "length_mm": {"$ref": "#/$defs/positive"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "length_mm", "width_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "ibone"}, "body_width_mm": {"$ref": "#/$defs/positive"}, "body_height_mm": {"$ref": "#/$defs/positive"}, "flange_width_mm": {"$ref": "#/$defs/positive"}, "flange_height_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}, "hole_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "body_width_mm", "body_height_mm", "flange_width_mm", "flange_height_mm", "corner_radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "rectangle_with_symmetric_notches"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "notch": {"type": "object", "properties": {"y_start": {"$ref": "#/$defs/number"}, "y_end": {"$ref": "#/$defs/number"}, "depth_mm": {"$ref": "#/$defs/positive"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["y_start", "y_end", "depth_mm"], "additionalProperties": false}}, "required": ["type", "width_mm", "height_mm", "notch"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "revolve_chamfer"}, "axis_height_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "bottom_width_mm": {"$ref": "#/$defs/positive"}, "wall_inset_mm": {"$ref": "#/$defs/number"}, "step_inset_mm": {"$ref": "#/$defs/number"}, "on_axis_side": {"enum": ["left", "right"]}}, "required": ["type", "axis_height_mm", "top_width_mm", "bottom_width_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "revolve_chamfer_slanted"}, "axis_height_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "wall_inset_mm": {"$ref": "#/$defs/number"}, "wall_height_mm": {"$ref": "#/$defs/positive"}, "wall_width_mm": {"$ref": "#/$defs/positive"}, "on_axis_side": {"enum": ["left", "right"]}}, "required": ["type", "axis_height_mm", "top_width_mm", "wall_height_mm", "wall_width_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "circle_with_arc_notches"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "notch_radius_mm": {"$ref": "#/$defs/positive"}, "notch_angles_deg": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/number"}}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "outer_radius_mm", "notch_radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "circular_sector_slot"}, "arc_radius_mm": {"$ref": "#/$defs/positive"}, "slot_half_width_mm": {"$ref": "#/$defs/positive"}, "chord_half_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "arc_radius_mm", "slot_half_width_mm", "chord_half_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "circle_with_radial_tabs"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "tab_u_half_mm": {"$ref": "#/$defs/positive"}, "tab_v_offset_mm": {"$ref": "#/$defs/number"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "outer_radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "filleted_rect_side_slots"}, "half_width_mm": {"$ref": "#/$defs/positive"}, "half_height_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}, "slot_radius_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "half_width_mm", "half_height_mm", "corner_radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "d_shape"}, "radius_mm": {"$ref": "#/$defs/positive"}, "chord_sign": {"enum": ["left", "right"]}, "chord_x_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "partial_ring"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "partial_ring_with_arc_island"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "island_radius_mm": {"$ref": "#/$defs/positive"}, "island_gap_mm": {"$ref": "#/$defs/positive"}, "center_angles_deg": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/number"}}, "replicas": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": {"center_angle_deg": {"$ref": "#/$defs/number"}}, "required": ["center_angle_deg"], "additionalProperties": false}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "island_radius_mm"], "additionalProperties": false, "oneOf": [{"required": ["center_angles_deg"], "not": {"required": ["replicas"]}}, {"required": ["replicas"], "not": {"required": ["center_angles_deg"]}}]},
{"type": "object", "properties": {"type": {"const": "radial_slot"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "start_angle_deg", "end_angle_deg"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "arc_chain"}, "arcs": {"type": "array", "minItems": 2, "items": {"type": "object", "properties": {"center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}}, "required": ["radius_mm", "start_angle_deg", "end_angle_deg"], "additionalProperties": false}}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "arcs"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "patterned_cutouts"}, "motif": {"$ref": "#/$defs/motif"}, "layout": {"$ref": "#/$defs/layout"}}, "required": ["type", "motif", "layout"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "compound_patterned_cutouts"}, "patterns": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/pattern"}}}, "required": ["type", "patterns"], "additionalProperties": false},
{"$ref": "#/$defs/analyticProfile"}
]
},
-331
View File
@@ -1,331 +0,0 @@
"""Validation for the semantic planning artifact between a request and CDSL."""
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
class DesignIntentError(ValueError):
"""A plan-contract violation with a stable error code for the Agent."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
def _schema_path(engine: Any) -> Path:
return Path(str(engine.__file__)).with_name("design_intent_schema.json")
def load_design_intent_schema(engine: Any) -> dict[str, Any]:
path = _schema_path(engine)
try:
schema = json.loads(path.read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
except (OSError, json.JSONDecodeError, SchemaError) as error:
raise RuntimeError("The local DesignIntent JSON Schema is unavailable or invalid") from error
return schema
def _location(error: Any) -> str:
return "$" + "".join(
f"[{item}]" if isinstance(item, int) else f".{item}"
for item in error.absolute_path
)
def _schema_validate(intent: dict[str, Any], engine: Any) -> None:
errors = sorted(
Draft202012Validator(load_design_intent_schema(engine)).iter_errors(intent),
key=lambda error: (list(error.absolute_path), error.message),
)
if errors:
error = errors[0]
raise DesignIntentError(
"INVALID_DESIGN_INTENT",
f"DesignIntent schema violation at {_location(error)}: {error.message}",
)
def _runtime_capabilities(engine: Any) -> tuple[set[str], set[str], dict[str, dict[str, Any]]]:
try:
profile_schema = json.loads(Path(str(engine.__file__)).with_name("profile_schema.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise RuntimeError("The local CDSL profile schema is unavailable or invalid") from error
atomic_ids = set(profile_schema.get("runtime_supported_atomic_ids") or getattr(engine, "SUPPORTED_ATOMIC_IDS", ()))
profiles = {
name
for name, contract in (profile_schema.get("profiles") or {}).items()
if isinstance(contract, dict) and contract.get("agent_allowed") is True
}
atomic_contracts = {
str(atomic_id): contract
for atomic_id, contract in (profile_schema.get("feature_atomic_ids") or {}).items()
if isinstance(contract, dict)
}
return {str(item) for item in atomic_ids}, {str(item) for item in profiles}, atomic_contracts
def _blocking(intent: dict[str, Any]) -> bool:
questions = intent.get("open_questions") or []
gaps = intent.get("capability_gaps") or []
return any(isinstance(item, dict) and item.get("blocking") is True for item in [*questions, *gaps])
def validate_design_intent(
intent: dict[str, Any],
engine: Any,
*,
current_revision_id: str = "",
) -> dict[str, Any]:
"""Validate DesignIntent syntax and semantic ordering against runtime capability."""
if not isinstance(intent, dict):
raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent must be a JSON object")
_schema_validate(intent, engine)
normalized = deepcopy(intent)
mode = str(normalized["mode"])
base_revision_id = str(normalized["base_revision_id"] or "")
if mode == "create" and base_revision_id:
raise DesignIntentError("INVALID_DESIGN_INTENT", "create DesignIntent must not set base_revision_id")
if mode == "revise":
if not base_revision_id:
raise DesignIntentError("INVALID_DESIGN_INTENT", "revise DesignIntent requires base_revision_id")
if current_revision_id and base_revision_id != current_revision_id:
raise DesignIntentError(
"DESIGN_INTENT_BASE_REVISION_MISMATCH",
f"revise DesignIntent base_revision_id must be {current_revision_id}, the current successful revision",
)
structures = normalized["structures"]
ids = [str(item["id"]) for item in structures]
feature_ids = [str(item["cdsl_feature_id"]) for item in structures]
if len(set(ids)) != len(ids):
raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent structure ids must be unique")
if len(set(feature_ids)) != len(feature_ids):
raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent cdsl_feature_id values must be unique")
feature_order = [str(item) for item in normalized["feature_order"]]
if len(set(feature_order)) != len(feature_order) or set(feature_order) != set(ids):
raise DesignIntentError(
"INVALID_DESIGN_INTENT",
"feature_order must list every DesignIntent structure exactly once",
)
order = {structure_id: index for index, structure_id in enumerate(feature_order)}
atomic_ids, profile_types, atomic_contracts = _runtime_capabilities(engine)
for structure in structures:
structure_id = str(structure["id"])
for dependency in structure["depends_on"]:
dependency_id = str(dependency)
if dependency_id not in order:
raise DesignIntentError(
"INVALID_DESIGN_INTENT",
f"Structure {structure_id} depends on an unknown structure {dependency_id}",
)
if order[dependency_id] >= order[structure_id]:
raise DesignIntentError(
"INVALID_DESIGN_INTENT",
f"Structure {structure_id} must depend only on earlier feature_order entries",
)
strategy = structure["cdsl_strategy"]
atomic_id = str(strategy["atomic_id"])
if atomic_id not in atomic_ids:
raise DesignIntentError(
"INVALID_DESIGN_INTENT",
f"Structure {structure_id} uses unsupported atomic_id {atomic_id}",
)
profile_type = str(strategy.get("profile_type") or "")
if atomic_contracts.get(atomic_id, {}).get("requires_sketch") and not profile_type:
raise DesignIntentError(
"INVALID_DESIGN_INTENT",
f"Structure {structure_id} uses {atomic_id} and requires a profile_type",
)
if profile_type and profile_type not in profile_types:
raise DesignIntentError(
"INVALID_DESIGN_INTENT",
f"Structure {structure_id} uses unsupported or agent-disallowed profile_type {profile_type}",
)
if normalized["status"] == "ready" and _blocking(normalized):
raise DesignIntentError(
"DESIGN_INTENT_BLOCKED",
"A DesignIntent with blocking questions or capability gaps must need clarification",
)
if normalized["status"] == "needs_clarification" and not _blocking(normalized):
raise DesignIntentError(
"INVALID_DESIGN_INTENT",
"needs_clarification requires a blocking question or capability gap",
)
if normalized["status"] == "ready" and not structures:
raise DesignIntentError("INVALID_DESIGN_INTENT", "A ready DesignIntent needs at least one structure")
if normalized["status"] == "ready" and mode == "create" and not any(item["role"] == "base" for item in structures):
raise DesignIntentError("INVALID_DESIGN_INTENT", "A ready create DesignIntent needs a base structure")
nonblocking_gaps = [item for item in normalized["capability_gaps"] if not item["blocking"]]
if nonblocking_gaps and not normalized["assumptions"]:
raise DesignIntentError(
"INVALID_DESIGN_INTENT",
"Non-blocking capability gaps require an explicit approximation assumption",
)
for gap in normalized["capability_gaps"]:
if str(gap["structure_id"]) not in set(ids):
raise DesignIntentError(
"INVALID_DESIGN_INTENT",
f"Capability gap references an unknown structure {gap['structure_id']}",
)
valid_expectation_ids = set(ids) | set(feature_ids)
for expectation in normalized["verification_expectations"]:
if expectation["type"] == "feature_count" and str(expectation["feature_id"]) not in valid_expectation_ids:
raise DesignIntentError(
"INVALID_DESIGN_INTENT",
f"feature_count expectation references an unknown structure or CDSL feature {expectation['feature_id']}",
)
return normalized
def _has_selector_evidence(value: Any, role: str) -> bool:
if isinstance(value, dict):
for key, child in value.items():
if role in {"selector", "selectors"} and key == "selectors" and isinstance(child, list) and child:
return True
if key == role and child not in (None, "", [], {}):
if not (isinstance(child, dict) and set(child) == {"unresolved"}):
return True
if _has_selector_evidence(child, role):
return True
elif isinstance(value, list):
return any(_has_selector_evidence(item, role) for item in value)
return False
def validate_intent_cdsl(
intent: dict[str, Any],
cdsl: dict[str, Any],
engine: Any,
*,
current_cdsl: dict[str, Any] | None = None,
) -> None:
"""Ensure an executable CDSL document faithfully realizes one accepted plan."""
normalized = validate_design_intent(intent, engine)
if normalized["status"] != "ready":
raise DesignIntentError("DESIGN_INTENT_BLOCKED", "CDSL generation is blocked until the DesignIntent is ready")
if not isinstance(cdsl, dict):
raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL must be a JSON object")
features = cdsl.get("features")
sketches = (cdsl.get("geometry") or {}).get("sketches")
if not isinstance(features, list) or not isinstance(sketches, list):
raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL must contain features and sketches")
feature_by_id = {str(feature.get("id") or ""): feature for feature in features if isinstance(feature, dict)}
if len(feature_by_id) != len(features):
raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL features must have unique ids")
structure_by_id = {str(item["id"]): item for item in normalized["structures"]}
expected_feature_ids = [str(structure_by_id[item]["cdsl_feature_id"]) for item in normalized["feature_order"]]
if normalized["mode"] == "revise" and isinstance(current_cdsl, dict):
base_feature_ids = {
str(feature.get("id") or "")
for feature in current_cdsl.get("features") or []
if isinstance(feature, dict) and feature.get("id")
}
if not base_feature_ids.issubset(set(expected_feature_ids)):
missing = ", ".join(sorted(base_feature_ids - set(expected_feature_ids)))
raise DesignIntentError(
"INTENT_CDSL_MISMATCH",
f"revise DesignIntent must explicitly preserve current features: {missing}",
)
actual_feature_ids = [str(feature.get("id") or "") for feature in features]
if actual_feature_ids != expected_feature_ids:
raise DesignIntentError(
"INTENT_CDSL_MISMATCH",
"CDSL feature ids and order must exactly match DesignIntent feature_order",
)
sketch_by_id = {str(sketch.get("id") or ""): sketch for sketch in sketches if isinstance(sketch, dict)}
feature_for_structure = {
str(structure["id"]): feature_by_id[str(structure["cdsl_feature_id"])]
for structure in normalized["structures"]
}
for structure in normalized["structures"]:
structure_id = str(structure["id"])
feature = feature_for_structure[structure_id]
strategy = structure["cdsl_strategy"]
if feature.get("atomic_id") != strategy["atomic_id"]:
raise DesignIntentError(
"INTENT_CDSL_MISMATCH",
f"CDSL feature {feature.get('id')} atomic_id does not match structure {structure_id}",
)
expected_dependencies = {
str(structure_by_id[dependency]["cdsl_feature_id"])
for dependency in structure["depends_on"]
}
actual_dependencies = {str(item) for item in feature.get("depends_on") or []}
if not expected_dependencies.issubset(actual_dependencies):
raise DesignIntentError(
"INTENT_CDSL_MISMATCH",
f"CDSL feature {feature.get('id')} is missing planned dependencies",
)
profile_type = str(strategy.get("profile_type") or "")
if profile_type:
sketch_id = str(feature.get("sketch_id") or "")
sketch = sketch_by_id.get(sketch_id)
actual_profile_type = str(((sketch or {}).get("profile") or {}).get("type") or "")
if actual_profile_type != profile_type:
raise DesignIntentError(
"INTENT_CDSL_MISMATCH",
f"CDSL feature {feature.get('id')} must use profile_type {profile_type}",
)
for selector_role in strategy["selector_roles"]:
if not _has_selector_evidence(feature, str(selector_role)):
raise DesignIntentError(
"INTENT_CDSL_MISMATCH",
f"CDSL feature {feature.get('id')} is missing selector evidence for {selector_role}",
)
def design_intent_from_cdsl(cdsl: dict[str, Any], *, request: str, mode: str = "create", base_revision_id: str = "") -> dict[str, Any]:
"""Create a conservative audit-only plan for legacy revisions when needed."""
sketches = (cdsl.get("geometry") or {}).get("sketches") or []
profile_by_sketch_id = {
str(sketch.get("id") or ""): str((sketch.get("profile") or {}).get("type") or "")
for sketch in sketches
if isinstance(sketch, dict)
}
structures: list[dict[str, Any]] = []
for index, feature in enumerate(cdsl.get("features") or []):
if not isinstance(feature, dict):
continue
feature_id = str(feature.get("id") or f"feature_{index + 1}")
atomic_id = str(feature.get("atomic_id") or "")
profile_type = profile_by_sketch_id.get(str(feature.get("sketch_id") or ""), "")
strategy: dict[str, Any] = {
"atomic_id": atomic_id,
"parameter_roles": sorted(str(key) for key in (feature.get("params") or {}).keys()),
"selector_roles": [],
}
if profile_type:
strategy["profile_type"] = profile_type
structures.append({
"id": feature_id,
"cdsl_feature_id": feature_id,
"role": "base" if index == 0 else "subtractive" if "cut" in atomic_id or atomic_id.startswith("hole") else "additive",
"purpose": str(feature.get("name") or feature_id),
"depends_on": [str(item) for item in feature.get("depends_on") or []],
"cdsl_strategy": strategy,
})
return {
"schema": "cad.cdsl.design-intent.v1",
"schema_version": "1.0",
"mode": mode,
"request": request,
"base_revision_id": base_revision_id,
"structures": structures,
"feature_order": [item["id"] for item in structures],
"assumptions": ["Synthesized from a legacy CDSL revision."],
"open_questions": [],
"capability_gaps": [],
"verification_expectations": [],
"status": "ready",
}
@@ -1,124 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cdsl.local/schema/cad.cdsl.design-intent.v1",
"title": "CDSL design intent",
"type": "object",
"properties": {
"schema": {"const": "cad.cdsl.design-intent.v1"},
"schema_version": {"const": "1.0"},
"mode": {"enum": ["create", "revise"]},
"request": {"type": "string", "minLength": 1},
"base_revision_id": {"type": "string"},
"structures": {"type": "array", "items": {"$ref": "#/$defs/structure"}},
"feature_order": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"assumptions": {"type": "array", "items": {"type": "string", "minLength": 1}},
"open_questions": {"type": "array", "items": {"$ref": "#/$defs/openQuestion"}},
"capability_gaps": {"type": "array", "items": {"$ref": "#/$defs/capabilityGap"}},
"verification_expectations": {"type": "array", "items": {"$ref": "#/$defs/verificationExpectation"}},
"status": {"enum": ["ready", "needs_clarification"]},
"intent_id": {"type": "string", "pattern": "^intent_[a-z0-9]{12}$"},
"created_at": {"type": "string", "minLength": 1},
"part_skill_ids": {"type": "array", "items": {"type": "string", "minLength": 1}},
"part_skill_selection": {"type": "object"}
},
"required": [
"schema",
"schema_version",
"mode",
"request",
"base_revision_id",
"structures",
"feature_order",
"assumptions",
"open_questions",
"capability_gaps",
"verification_expectations",
"status"
],
"additionalProperties": false,
"$defs": {
"structure": {
"type": "object",
"properties": {
"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"cdsl_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"role": {"enum": ["base", "reference", "additive", "subtractive", "dressup", "pattern"]},
"purpose": {"type": "string", "minLength": 1},
"depends_on": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"cdsl_strategy": {"$ref": "#/$defs/cdslStrategy"}
},
"required": ["id", "cdsl_feature_id", "role", "purpose", "depends_on", "cdsl_strategy"],
"additionalProperties": false
},
"cdslStrategy": {
"type": "object",
"properties": {
"atomic_id": {"type": "string", "minLength": 1},
"profile_type": {"type": "string", "minLength": 1},
"parameter_roles": {"type": "array", "items": {"type": "string", "minLength": 1}},
"selector_roles": {"type": "array", "items": {"type": "string", "minLength": 1}}
},
"required": ["atomic_id", "parameter_roles", "selector_roles"],
"additionalProperties": false
},
"openQuestion": {
"type": "object",
"properties": {
"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"question": {"type": "string", "minLength": 1},
"blocking": {"type": "boolean"}
},
"required": ["id", "question", "blocking"],
"additionalProperties": false
},
"capabilityGap": {
"type": "object",
"properties": {
"code": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,100}$"},
"structure_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"message": {"type": "string", "minLength": 1},
"blocking": {"type": "boolean"}
},
"required": ["code", "structure_id", "message", "blocking"],
"additionalProperties": false
},
"verificationExpectation": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "feature_count"},
"feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"expected": {"type": "integer", "minimum": 0}
},
"required": ["type", "feature_id", "expected"],
"additionalProperties": false
},
{
"type": "object",
"properties": {"type": {"const": "symmetry"}, "axis": {"enum": ["x", "y", "z"]}},
"required": ["type", "axis"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {"const": "bbox"},
"expected_mm": {
"type": "object",
"properties": {
"x": {"type": "number", "exclusiveMinimum": 0},
"y": {"type": "number", "exclusiveMinimum": 0},
"z": {"type": "number", "exclusiveMinimum": 0}
},
"required": ["x", "y", "z"],
"additionalProperties": false
}
},
"required": ["type", "expected_mm"],
"additionalProperties": false
}
]
}
}
}
@@ -1,212 +0,0 @@
"""Deterministic GenerationSpec -> CDSL compilers.
The LLM supplies semantic parameters and feature intent. This module owns
the CDSL details so tool calls do not need to contain sketch IDs, selectors, or
runtime-specific parameter wrappers.
"""
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import Any, Protocol
from .generation_spec import GenerationSpecError, validate_generation_spec
def _workplane(z: float = 0.0, normal: list[float] | None = None) -> dict[str, list[float]]:
return {
"origin_mm": [0.0, 0.0, float(z)],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 1.0, 0.0],
"normal": list(normal or [0.0, 0.0, 1.0]),
}
def _value(spec: dict[str, Any], name: str, default: float | int | str) -> Any:
parameter = (spec.get("parameters") or {}).get(name)
if isinstance(parameter, dict) and "value" in parameter:
return parameter["value"]
return default
def _number(spec: dict[str, Any], name: str, default: float) -> float:
value = _value(spec, name, default)
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise GenerationSpecError(f"Parameter {name} must be numeric", f"$.parameters.{name}.value")
number = float(value)
if not number == number or number in {float("inf"), float("-inf")}:
raise GenerationSpecError(f"Parameter {name} must be finite", f"$.parameters.{name}.value")
return number
def _integer(spec: dict[str, Any], name: str, default: int) -> int:
value = _number(spec, name, default)
if int(value) != value:
raise GenerationSpecError(f"Parameter {name} must be an integer", f"$.parameters.{name}.value")
return int(value)
def _positive(number: float, name: str) -> float:
if number <= 0:
raise GenerationSpecError(f"Parameter {name} must be greater than zero", f"$.parameters.{name}.value")
return number
def _acceptance(spec: dict[str, Any], generated: list[dict[str, Any]]) -> list[dict[str, Any]]:
existing = copy.deepcopy(spec.get("acceptance") or [])
known = {str(item.get("id")) for item in existing}
for item in generated:
if item["id"] not in known:
existing.append(item)
known.add(item["id"])
return existing
@dataclass(frozen=True)
class CompiledGeneration:
cdsl: dict[str, Any]
acceptance: list[dict[str, Any]]
provenance: dict[str, Any]
assumptions: list[str]
approximations: list[dict[str, Any]]
class Compiler(Protocol):
family: str
def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration:
...
class MountingPlateCompiler:
family = "mounting_plate"
def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration:
width = _positive(_number(spec, "width", 100.0), "width")
height = _positive(_number(spec, "height", 60.0), "height")
thickness = _positive(_number(spec, "thickness", 6.0), "thickness")
hole_diameter = _positive(_number(spec, "hole_diameter", 4.5), "hole_diameter")
hole_offset = _positive(_number(spec, "hole_edge_offset", 10.0), "hole_edge_offset")
hole_count = _integer(spec, "hole_count", 4)
counterbore_diameter = _positive(_number(spec, "counterbore_diameter", hole_diameter * 2), "counterbore_diameter")
counterbore_depth = _positive(_number(spec, "counterbore_depth", min(thickness / 2, 3.0)), "counterbore_depth")
slot_length = _positive(_number(spec, "slot_length", 20.0), "slot_length")
slot_width = _positive(_number(spec, "slot_width", 12.0), "slot_width")
if hole_count != 4:
raise GenerationSpecError("mounting_plate compiler currently requires four corner holes", "$.parameters.hole_count.value")
if counterbore_diameter <= hole_diameter:
raise GenerationSpecError("counterbore_diameter must exceed hole_diameter", "$.parameters.counterbore_diameter.value")
if counterbore_depth > thickness:
raise GenerationSpecError("counterbore_depth must not exceed thickness", "$.parameters.counterbore_depth.value")
if hole_offset * 2 >= min(width, height):
raise GenerationSpecError("hole_edge_offset leaves no usable plate area", "$.parameters.hole_edge_offset.value")
if slot_length < slot_width:
raise GenerationSpecError("slot_length must be at least slot_width", "$.parameters.slot_length.value")
centers = [
[-width / 2 + hole_offset, -height / 2 + hole_offset],
[width / 2 - hole_offset, -height / 2 + hole_offset],
[width / 2 - hole_offset, height / 2 - hole_offset],
[-width / 2 + hole_offset, height / 2 - hole_offset],
]
frame = _workplane(thickness)
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "generation-spec-preflight",
"meta": {"unit": "mm", "name": str(spec["part"]["name"])},
"geometry": {"sketches": [
{"id": "base_plate", "workplane": _workplane(), "profile": {
"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height,
}},
{"id": "mounting_holes", "workplane": frame, "profile": {
"type": "circles", "items": [{"center": center, "radius_mm": hole_diameter / 2} for center in centers],
}},
{"id": "center_slot", "workplane": frame, "profile": {
"type": "obround", "center": [0.0, 0.0], "length_mm": slot_length, "width_mm": slot_width,
}},
]},
"features": [
{"id": "base_plate", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_plate", "params": {"distance_mm": thickness}},
{"id": "mounting_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_plate"], "sketch_id": "mounting_holes", "params": {"distance_mm": thickness, "reverse": True}},
{"id": "mounting_counterbores", "atomic_id": "hole_counterbore", "depends_on": ["mounting_holes"], "sketch_id": "mounting_holes", "params": {
"diameter_mm": hole_diameter, "depth_mm": thickness, "counterbore_diameter_mm": counterbore_diameter,
"counterbore_depth_mm": counterbore_depth, "positions": [{"mm": [x, y, 0.0]} for x, y in centers], "host_face": {"frame": frame},
}},
{"id": "center_slot", "atomic_id": "extrude_cut_blind", "depends_on": ["mounting_counterbores"], "sketch_id": "center_slot", "params": {"distance_mm": thickness, "reverse": True}},
],
}
assumptions = [str(item) for item in spec.get("assumptions") or []]
if "hole_diameter" not in (spec.get("parameters") or {}):
assumptions.append("M4 clearance hole defaults to 4.5 mm")
if "counterbore_depth" not in (spec.get("parameters") or {}):
assumptions.append("Counterbore depth defaults to the lesser of 3 mm and half the plate thickness")
acceptance = _acceptance(spec, [
{"id": "overall_bbox", "type": "bbox", "expected": [width, height, thickness], "tolerance": 0.1, "severity": "blocking"},
{"id": "mounting_hole_count", "type": "feature_count", "feature": "mounting_holes", "expected": 4, "tolerance": 0, "severity": "blocking"},
{"id": "mounting_hole_diameter", "type": "hole_diameter", "feature": "mounting_holes", "expected": hole_diameter, "tolerance": 0.05, "severity": "blocking"},
{"id": "center_slot_size", "type": "overall_length", "feature": "center_slot", "expected": slot_length, "tolerance": 0.1, "severity": "blocking"},
])
provenance = {
"compiler": self.family,
"compiler_version": "1.0",
"references": references,
"feature_sources": {item["id"]: item["id"] for item in spec.get("features") or []},
"parameter_paths": {name: f"parameters.{name}.value" for name in (spec.get("parameters") or {})},
}
return CompiledGeneration(cdsl, acceptance, provenance, list(dict.fromkeys(assumptions)), list(spec.get("approximations") or []))
class FlangeSleeveCompiler:
family = "flange_sleeve"
def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration:
from app.services.flange_sleeve_template import build_flange_sleeve_cdsl, TEMPLATE_ID
aliases = {
"width": "flange_width_mm", "height": "flange_height_mm", "thickness": "flange_thickness_mm",
"tube_outer_diameter": "tube_outer_diameter_mm", "bore_diameter": "bore_diameter_mm",
"hole_diameter": "mount_hole_diameter_mm", "counterbore_diameter": "mount_counterbore_diameter_mm",
"counterbore_depth": "mount_counterbore_depth_mm", "hole_offset_x": "mount_hole_u_mm", "hole_offset_y": "mount_hole_v_mm",
}
plan: dict[str, Any] = {"template": TEMPLATE_ID, "name": spec["part"]["name"]}
for source, target in aliases.items():
if source in (spec.get("parameters") or {}):
plan[target] = _value(spec, source, 0)
cdsl, values = build_flange_sleeve_cdsl(plan)
acceptance = _acceptance(spec, [
{"id": "flange_bbox", "type": "bbox", "expected": [float(values["flange_width_mm"]), float(values["flange_height_mm"]), float(values["flange_thickness_mm"])], "tolerance": 0.1, "severity": "blocking"},
])
provenance = {"compiler": self.family, "compiler_version": "1.0", "template_id": TEMPLATE_ID, "references": references}
assumptions = list(dict.fromkeys([*map(str, spec.get("assumptions") or []), "Flange sleeve dimensions use the verified parameterized template defaults for omitted fields."]))
return CompiledGeneration(cdsl, acceptance, provenance, assumptions, list(spec.get("approximations") or []))
COMPILERS: dict[str, Compiler] = {
"mounting_plate": MountingPlateCompiler(),
"flange_sleeve": FlangeSleeveCompiler(),
}
def compiler_for(family: str) -> Compiler:
try:
return COMPILERS[family]
except KeyError as error:
raise GenerationSpecError(f"No GenerationSpec compiler registered for part family: {family}", "$.part.family") from error
def compile_generation_spec(spec: dict[str, Any], references: list[dict[str, Any]] | None = None) -> dict[str, Any]:
normalized = validate_generation_spec(spec)
compiler = compiler_for(str(normalized["part"]["family"]))
compiled = compiler.compile(normalized, references or [])
return {
"spec": normalized,
"cdsl": compiled.cdsl,
"acceptance": compiled.acceptance,
"provenance": compiled.provenance,
"assumptions": compiled.assumptions,
"approximations": compiled.approximations,
}
@@ -1,177 +0,0 @@
"""Validation and normalization for the semantic GenerationSpec contract."""
from __future__ import annotations
import copy
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
KNOWN_PART_FAMILIES = frozenset({
"mounting_plate",
"flange",
"flange_sleeve",
"simple_shaft",
"bearing_housing",
"mounting_bracket",
"hex_nut",
"slotted_plate",
})
KNOWN_FEATURE_KINDS = frozenset({
"base_extrusion",
"base_revolve",
"boss",
"through_hole",
"blind_hole",
"counterbored_hole",
"countersunk_hole",
"hole_pattern",
"counterbored_hole_pattern",
"obround_cut",
"pocket",
"revolve_profile",
"coaxial_bore",
"fillet",
"chamfer",
})
class GenerationSpecError(ValueError):
"""A stable, user-repairable GenerationSpec validation error."""
def __init__(self, message: str, path: str = "$") -> None:
super().__init__(message)
self.path = path
@lru_cache(maxsize=1)
def generation_spec_schema() -> dict[str, Any]:
path = Path(__file__).with_name("generation_spec_schema.json")
schema = json.loads(path.read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
return schema
def _schema_error(spec: dict[str, Any]) -> GenerationSpecError | None:
errors = sorted(
Draft202012Validator(generation_spec_schema()).iter_errors(spec),
key=lambda error: (list(error.absolute_path), error.message),
)
if not errors:
return None
error = errors[0]
location = "$" + "".join(
f"[{item}]" if isinstance(item, int) else f".{item}"
for item in error.absolute_path
)
return GenerationSpecError(error.message, location)
def _parameter_value(spec: dict[str, Any], name: str) -> Any:
value = (spec.get("parameters") or {}).get(name)
if not isinstance(value, dict):
raise GenerationSpecError(f"Unknown parameter: {name}", f"$.parameters.{name}")
return value.get("value")
def _numeric(value: Any, path: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise GenerationSpecError("Expected a finite numeric value", path)
number = float(value)
if number != number or number in {float("inf"), float("-inf")}:
raise GenerationSpecError("Expected a finite numeric value", path)
return number
def _check_constraints(spec: dict[str, Any]) -> None:
for index, constraint in enumerate(spec.get("constraints") or []):
path = f"$.constraints[{index}]"
kind = constraint.get("type")
if kind in {"less_than", "less_equal", "greater_than", "greater_equal", "equal"}:
left = _numeric(_parameter_value(spec, str(constraint.get("left") or "")), f"{path}.left")
right_name = constraint.get("right")
right = _numeric(_parameter_value(spec, str(right_name)), f"{path}.right") if right_name else _numeric(constraint.get("value"), f"{path}.value")
ok = {
"less_than": left < right,
"less_equal": left <= right,
"greater_than": left > right,
"greater_equal": left >= right,
"equal": abs(left - right) <= 1e-9,
}[kind]
if not ok:
raise GenerationSpecError(constraint.get("message") or f"Constraint {kind} failed", path)
def _check_graph(spec: dict[str, Any]) -> None:
features = spec.get("features") or []
ids = [str(item.get("id")) for item in features]
if len(ids) != len(set(ids)):
raise GenerationSpecError("Feature ids must be unique", "$.features")
known: set[str] = set()
for index, feature in enumerate(features):
feature_id = str(feature.get("id"))
kind = str(feature.get("kind"))
if kind not in KNOWN_FEATURE_KINDS:
raise GenerationSpecError(f"Unsupported feature kind: {kind}", f"$.features[{index}].kind")
for dependency in feature.get("depends_on") or []:
if dependency not in known:
raise GenerationSpecError(
f"Feature {feature_id} has a forward or missing dependency: {dependency}",
f"$.features[{index}].depends_on",
)
known.add(feature_id)
acceptance_ids = {str(item.get("id")) for item in spec.get("acceptance") or []}
if len(acceptance_ids) != len(spec.get("acceptance") or []):
raise GenerationSpecError("Acceptance ids must be unique", "$.acceptance")
for index, item in enumerate(spec.get("acceptance") or []):
feature = item.get("feature")
if feature and feature not in known:
raise GenerationSpecError(f"Acceptance refers to missing feature: {feature}", f"$.acceptance[{index}].feature")
def normalize_generation_spec(spec: dict[str, Any], *, request: str = "") -> dict[str, Any]:
if not isinstance(spec, dict):
raise GenerationSpecError("GenerationSpec must be a JSON object")
normalized = copy.deepcopy(spec)
normalized.setdefault("schema", "cad.generation-spec.v1")
normalized.setdefault("schema_version", "1.0")
normalized.setdefault("mode", "create")
normalized.setdefault("base_revision_id", "")
normalized.setdefault("parameters", {})
normalized.setdefault("features", [])
normalized.setdefault("constraints", [])
normalized.setdefault("acceptance", [])
normalized.setdefault("assumptions", [])
normalized.setdefault("approximations", [])
normalized.setdefault("references", [])
normalized.setdefault("patch_intent", request)
return normalized
def validate_generation_spec(
spec: dict[str, Any],
*,
known_families: set[str] | frozenset[str] = KNOWN_PART_FAMILIES,
) -> dict[str, Any]:
normalized = normalize_generation_spec(spec)
error = _schema_error(normalized)
if error:
raise error
family = str(normalized["part"]["family"])
if family not in known_families:
raise GenerationSpecError(f"Unsupported part family: {family}", "$.part.family")
for name, parameter in normalized["parameters"].items():
source = parameter["source"]
if source == "user" and not parameter["locked"]:
raise GenerationSpecError("User parameters must be locked", f"$.parameters.{name}.locked")
if source == "image_estimate" and not parameter.get("assumption"):
raise GenerationSpecError("Image estimates require an assumption", f"$.parameters.{name}.assumption")
_check_graph(normalized)
_check_constraints(normalized)
return normalized
@@ -1,100 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cdsl.local/schema/cad.generation-spec.v1",
"title": "CDSL GenerationSpec",
"type": "object",
"additionalProperties": false,
"properties": {
"schema": {"const": "cad.generation-spec.v1"},
"schema_version": {"const": "1.0"},
"mode": {"enum": ["create", "revise"]},
"base_revision_id": {"type": "string"},
"patch_intent": {"type": "string"},
"part": {"$ref": "#/$defs/part"},
"parameters": {"type": "object", "additionalProperties": {"$ref": "#/$defs/parameter"}},
"features": {"type": "array", "items": {"$ref": "#/$defs/feature"}},
"constraints": {"type": "array", "items": {"$ref": "#/$defs/constraint"}},
"acceptance": {"type": "array", "items": {"$ref": "#/$defs/acceptance"}},
"assumptions": {"type": "array", "items": {"type": "string", "minLength": 1}},
"approximations": {"type": "array", "items": {"$ref": "#/$defs/approximation"}},
"references": {"type": "array", "items": {"type": "object"}}
},
"required": ["schema", "schema_version", "mode", "base_revision_id", "part", "parameters", "features", "constraints", "acceptance", "assumptions", "approximations", "references"],
"$defs": {
"part": {
"type": "object",
"additionalProperties": false,
"properties": {
"family": {"type": "string", "minLength": 1},
"name": {"type": "string", "minLength": 1},
"units": {"const": "mm"},
"coordinate_system": {"type": "string", "minLength": 1}
},
"required": ["family", "name", "units", "coordinate_system"]
},
"parameter": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {},
"unit": {"enum": ["mm", "deg", "rad", "count", ""]},
"source": {"enum": ["user", "technical_drawing", "image_estimate", "template_default", "derived_standard", "assumption"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"locked": {"type": "boolean"},
"assumption": {"type": "string"}
},
"required": ["value", "unit", "source", "confidence", "locked", "assumption"]
},
"feature": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"kind": {"type": "string", "minLength": 1},
"depends_on": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"parameters": {"type": "object"},
"semantic_role": {"type": "string"}
},
"required": ["id", "kind", "depends_on", "parameters"]
},
"constraint": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {"enum": ["less_than", "less_equal", "greater_than", "greater_equal", "equal", "inside_boundary", "symmetric", "same_axis", "distance", "count"]},
"left": {"type": "string"},
"right": {"type": "string"},
"value": {},
"feature": {"type": "string"},
"margin": {"type": "number"},
"ratio": {"type": "number"},
"message": {"type": "string"}
},
"required": ["type"]
},
"acceptance": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"type": {"enum": ["bbox", "solid_count", "feature_count", "hole_diameter", "hole_center", "hole_spacing", "wall_thickness", "overall_length", "overall_diameter", "coaxiality", "symmetry", "through_condition", "blind_depth"]},
"feature": {"type": "string"},
"expected": {},
"tolerance": {"type": "number", "minimum": 0},
"severity": {"enum": ["blocking", "warning", "informational"]}
},
"required": ["id", "type", "expected", "tolerance", "severity"]
},
"approximation": {
"type": "object",
"additionalProperties": false,
"properties": {
"request": {"type": "string", "minLength": 1},
"status": {"enum": ["approximated", "expanded", "omitted", "blocked"]},
"translation": {"type": "string", "minLength": 1},
"reason": {"type": "string", "minLength": 1}
},
"required": ["request", "status", "translation", "reason"]
}
}
}
+5 -1
View File
@@ -11,6 +11,7 @@ from pathlib import Path
from typing import Any
from .capabilities import sketch_ids_required_by_contract
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
from .semantic_validation import validate_semantic_cdsl
from .sketch_solver import resolve_required_sketches
@@ -21,7 +22,7 @@ P3_ATOMIC_IDS = frozenset({
})
P4_ATOMIC_IDS = P3_ATOMIC_IDS | frozenset({"hole_wizard"})
P6_ATOMIC_IDS = P4_ATOMIC_IDS | frozenset({"pattern_linear", "pattern_mirror"})
P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "circles", "annulus"})
P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "polygon"})
# Static pool membership asks whether the exported history is an extrude/
# revolve history. Whether a first cut has a preceding active body remains a
# runtime preflight question, not a reason to erase it from the input pool.
@@ -66,6 +67,9 @@ def is_static_phase_ready(cdsl: dict[str, Any], phase: str) -> bool:
allowed = _PHASE_ATOMIC_IDS.get(phase)
if allowed is None:
raise ValueError(f"Unknown CDSL runtime phase {phase!r}")
# Corpus pools retain historical profiles on disk but evaluate them after
# the importer compatibility lowering used by the batch path.
cdsl = lower_legacy_profiles(cdsl)
semantic = validate_semantic_cdsl(cdsl)
if semantic["unresolved"]:
return False
+3 -187
View File
@@ -2,10 +2,10 @@
"schema": "cdsl.engine.schema.v1",
"schema_version": "1.3.0",
"cdsl_json_schema_file": "cdsl_schema.json",
"maintenance_rule": "The semantic CDSL contract is a superset of the current runtime. runtime_supported_atomic_ids and runtime_supported_profiles must stay synchronized with runtime.py EXECUTORS, sketch_solver.py SHAPE_GENERATORS, and the package-level capability tests. Legacy llm_compiler.py and llm_engine.py are not the CDSL-only runtime contract.",
"maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.",
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
"runtime_supported_atomic_ids": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "reference_plane", "reference_axis", "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror"],
"runtime_supported_profiles": ["circle", "annulus", "circles", "circle_grid", "rectangle", "rectangle_with_circles", "rectangle_with_fillets", "obround", "polygon", "ibone", "rectangle_with_symmetric_notches", "revolve_chamfer", "revolve_chamfer_slanted", "circle_with_arc_notches", "circular_sector_slot", "circle_with_radial_tabs", "filleted_rect_side_slots", "d_shape", "partial_ring", "partial_ring_with_arc_island", "arc_chain", "radial_slot", "patterned_cutouts", "compound_patterned_cutouts", "analytic_contours", "complex_arc_shape", "unknown_shape"],
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours"],
"feature_atomic_ids": {
"extrude_add_blind": {"summary": "Add the closed profile by one signed extrusion distance.", "required_params": ["distance_mm"], "optional_params": ["reverse"], "requires_sketch": true},
"extrude_add_two_sided": {"summary": "Add the closed profile with independently captured forward and reverse terminations.", "required_params": ["distance_mm", "reverse_distance_mm"], "optional_params": ["reverse", "end_condition", "reverse_end_condition"], "requires_sketch": true},
@@ -26,202 +26,18 @@
},
"profiles": {
"circle": {
"agent_allowed": true,
"summary": "A single circular closed profile.",
"required": ["radius_mm"],
"optional": ["center"],
"constraints": ["radius_mm > 0", "center defaults to [0, 0]"]
},
"annulus": {
"agent_allowed": true,
"summary": "A concentric ring.",
"required": ["inner_radius_mm", "outer_radius_mm"],
"optional": ["center"],
"constraints": ["0 < inner_radius_mm < outer_radius_mm"]
},
"circles": {
"agent_allowed": true,
"summary": "A non-empty list of circles.",
"required": ["items"],
"item_schema": {"required": ["radius_mm"], "optional": ["center"]}
},
"circle_grid": {
"agent_allowed": true,
"summary": "A rectangular grid of equal circles.",
"required": ["radius_mm", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"],
"optional": ["origin_mm", "center_mm"],
"constraints": ["radius_mm > 0", "count_x and count_y are integers >= 1", "use origin_mm or center_mm, not both"]
},
"rectangle": {
"agent_allowed": true,
"summary": "An axis-aligned rectangle.",
"one_of": [["center", "width_mm", "height_mm"], ["min_mm", "max_mm"]],
"constraints": ["width_mm > 0 and height_mm > 0 when using center"]
},
"rectangle_with_circles": {
"agent_allowed": true,
"summary": "A rectangle or filleted rectangle with optional internal circles.",
"required": ["boundary"],
"optional": ["circles"],
"nested": {"boundary": "rectangle schema plus optional type=rectangle|rectangle_with_fillets and fillet_radius_mm"}
},
"rectangle_with_fillets": {
"agent_allowed": true,
"summary": "An axis-aligned rectangle with corner fillets and optional internal circles.",
"one_of": [["center", "width_mm", "height_mm"], ["min_mm", "max_mm"]],
"optional": ["fillet_radius_mm", "circles"],
"constraints": ["fillet_radius_mm defaults to 0"]
},
"obround": {
"agent_allowed": true,
"summary": "A horizontal capsule/slot.",
"required": ["length_mm", "width_mm"],
"optional": ["center"],
"constraints": ["length_mm > 0", "width_mm > 0", "center defaults to [0, 0]"]
},
"polygon": {
"agent_allowed": true,
"summary": "A closed straight-edge polygon.",
"required": ["vertices"],
"constraints": ["vertices contains at least three [u, v] points"]
},
"ibone": {
"agent_allowed": true,
"summary": "I-shaped lug with optional four holes.",
"required": ["body_width_mm", "body_height_mm", "flange_width_mm", "flange_height_mm", "corner_radius_mm"],
"optional": ["hole_radius_mm"]
},
"rectangle_with_symmetric_notches": {
"agent_allowed": true,
"summary": "A rectangular plate with four symmetric side notches.",
"required": ["width_mm", "height_mm", "notch"],
"nested": {"notch": {"required": ["y_start", "y_end", "depth_mm"], "optional": ["inner_radius_mm", "corner_radius_mm"]}}
},
"revolve_chamfer": {
"agent_allowed": true,
"summary": "Five-edge trapezoid profile for a revolve cut.",
"required": ["axis_height_mm", "top_width_mm", "bottom_width_mm"],
"optional": ["wall_inset_mm", "step_inset_mm", "on_axis_side"],
"constraints": ["on_axis_side is left or right"]
},
"revolve_chamfer_slanted": {
"agent_allowed": true,
"summary": "Five-edge slanted profile for a revolve cut.",
"required": ["axis_height_mm", "top_width_mm", "wall_height_mm", "wall_width_mm"],
"optional": ["wall_inset_mm", "on_axis_side"],
"constraints": ["on_axis_side is left or right"]
},
"circle_with_arc_notches": {
"agent_allowed": true,
"summary": "A circular boundary with arc-shaped notches.",
"required": ["outer_radius_mm", "notch_radius_mm"],
"optional": ["notch_angles_deg", "circles"],
"constraints": ["notch_angles_deg defaults to [0, 90, 180, 270]"]
},
"circular_sector_slot": {
"agent_allowed": true,
"summary": "A circular-sector boundary with a central rectangular slot.",
"required": ["arc_radius_mm", "slot_half_width_mm", "chord_half_mm"],
"optional": ["circles"]
},
"circle_with_radial_tabs": {
"agent_allowed": true,
"summary": "A circle with two mirrored radial tabs.",
"required": ["outer_radius_mm"],
"optional": ["tab_u_half_mm", "tab_v_offset_mm", "circles"]
},
"filleted_rect_side_slots": {
"agent_allowed": true,
"summary": "A filleted rectangle with one semicircular slot at each side centre.",
"required": ["half_width_mm", "half_height_mm", "corner_radius_mm"],
"optional": ["slot_radius_mm", "circles"]
},
"d_shape": {
"agent_allowed": true,
"summary": "A D-shaped boundary made from one chord and one arc.",
"required": ["radius_mm"],
"optional": ["chord_sign", "chord_x_mm", "circles"],
"constraints": ["chord_sign is left or right"]
},
"partial_ring": {
"agent_allowed": true,
"summary": "An annular sector.",
"required": ["inner_radius_mm", "outer_radius_mm"],
"optional": ["half_angle_deg", "circles"],
"constraints": ["0 < inner_radius_mm < outer_radius_mm"]
},
"partial_ring_with_arc_island": {
"agent_allowed": true,
"summary": "One or more annular sectors with an arc island.",
"required": ["inner_radius_mm", "outer_radius_mm", "island_radius_mm"],
"optional": ["half_angle_deg", "island_gap_mm", "center_angles_deg", "replicas"],
"nested": {"replicas": {"required": ["center_angle_deg"]}}
},
"arc_chain": {
"agent_allowed": true,
"summary": "A closed chain of two or more arcs, commonly used for revolve sections.",
"required": ["arcs"],
"optional": ["circles"],
"item_schema": {"required": ["radius_mm", "start_angle_deg", "end_angle_deg"], "optional": ["center"]},
"constraints": ["arcs contains at least two endpoint-connected arcs"]
},
"radial_slot": {
"agent_allowed": true,
"summary": "A rounded annular sector slot.",
"required": ["inner_radius_mm", "outer_radius_mm", "start_angle_deg", "end_angle_deg"],
"optional": ["circles"],
"constraints": ["0 < inner_radius_mm < outer_radius_mm"]
},
"patterned_cutouts": {
"agent_allowed": true,
"summary": "One procedural cutout motif repeated by one procedural layout.",
"required": ["motif", "layout"],
"nested": {"motif": "See motif_types", "layout": "See layout_types"}
},
"compound_patterned_cutouts": {
"agent_allowed": true,
"summary": "Multiple procedural motif/layout pairs merged into one cutout sketch.",
"required": ["patterns"],
"nested": {"patterns": "Non-empty list of {motif, layout}; see patterned_cutouts."}
},
"complex_arc_shape": {
"agent_allowed": false,
"summary": "Legacy compiler-context fallback only. Never generate it."
},
"analytic_contours": {
"agent_allowed": false,
"summary": "Exact analytic line, arc and circle contours emitted by the Evidence v2 converter. The runtime resolves them into closed regions without compiler_context; B-spline currently produces an explicit unsupported diagnostic."
},
"unknown_shape": {
"agent_allowed": false,
"summary": "Legacy compiler-context fallback only. Never generate it."
"summary": "Closed direct line, arc and circle contours. B-splines are unsupported."
}
},
"motif_types": {
"circle": ["radius_mm"],
"square": ["width_mm"],
"rectangle": ["width_mm", "height_mm"],
"obround": ["length_mm", "width_mm"],
"cross": ["size_mm", "arm_width_mm"],
"d_shape_polygon": ["stem_length_mm", "nose_depth_mm", "half_height_mm"],
"regular_hexagon": ["radius_mm"],
"skew_hexagon": ["nominal_radius_mm"],
"triangle": ["radius_mm"],
"teardrop_polygon": ["width_mm", "height_mm"],
"trapezoid": ["bottom_width_mm", "top_width_mm", "height_mm"],
"annular_sector_polygon": ["inner_radius_mm", "outer_radius_mm", "half_angle_deg"]
},
"layout_types": {
"ring": ["radius_mm", "count"],
"angular": ["count"],
"concentric_rings": ["rings"],
"disc_grid": ["count_x", "count_y", "spacing_x_mm", "spacing_y_mm"],
"open_arc": ["radius_mm", "count", "start_angle_deg", "end_angle_deg"],
"spiral": ["count", "start_radius_mm", "radius_step_mm", "angle_step_deg"],
"cross_lines": ["count_per_axis", "spacing_mm"],
"x_field": ["levels", "spacing_mm"],
"twin_strips": ["x_offset_mm", "count_y", "y_start_mm", "y_end_mm"],
"corner_clusters": ["levels_mm"],
"diamond_field": ["manhattan_radius", "spacing_mm"]
}
}
+10 -5
View File
@@ -15,25 +15,30 @@ from pathlib import Path
from typing import Any
try:
from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
from .llm_compiler import compile_cdsl
from .llm_engine import run_engine_plan
from .translator import generate_build123d_code, normalize_to_ir
except ImportError: # 允许直接 python rebuild.py
from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
from llm_compiler import compile_cdsl
from llm_engine import run_engine_plan
from translator import generate_build123d_code, normalize_to_ir
def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None,
force_exact: bool = False) -> dict[str, Any]:
def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None,
force_exact: bool = False) -> dict[str, Any]:
"""主重建入口。
优先 CDSL 参数化路径sketch_solver llm_compiler llm_engine不依赖 compiler_context
回退CDSL + compiler_context translator 路径
"""
sketches = cdsl.get("geometry", {}).get("sketches", [])
# Compatibility entry point only: new CDSL-only runtime calls do not use
# macro profiles and therefore never invoke this adapter.
cdsl = lower_legacy_profiles(cdsl)
sketches = cdsl.get("geometry", {}).get("sketches", [])
all_drawable = bool(sketches) and all(
_sketch_is_cdsl_drawable(s) for s in sketches
)
+3 -3
View File
@@ -15,7 +15,7 @@ from .runtime_types import (
RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry,
vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit,
)
from .sketch_solver import SHAPE_GENERATORS, resolve_required_sketches
from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
ALL_ATOMIC_IDS = frozenset({
@@ -758,7 +758,7 @@ def analyze_cdsl(cdsl: dict[str, Any]):
resolved = resolve_required_sketches(
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
)
analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=SHAPE_GENERATORS)
analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS)
return analyzer.analyze(resolved, sketch_errors=sketch_errors)
@@ -768,7 +768,7 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
resolved = resolve_required_sketches(
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
)
analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=SHAPE_GENERATORS).analyze(
analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze(
resolved, sketch_errors=sketch_errors,
)
if strict and not analysis.runtime_eligible:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Remove retired semantic-plan artifacts from the local workspace.
Run without arguments to list every file and metadata document that would
change. Run again with --apply to make the irreversible cleanup.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
RETIRED_FILENAMES = {
"generation-spec.json",
"generation-provenance.json",
"acceptance-report.json",
}
RETIRED_DIAGNOSTIC_PREFIX = "generation_spec_"
RETIRED_TOOL_NAMES = {
"create_generation_spec",
"author_cdsl_from_generation_spec",
"compile_generation_spec",
"patch_generation_spec",
"generate_flange_sleeve_model",
}
RETIRED_METADATA_KEYS = {
"generation_spec_path",
"generation_provenance_path",
"acceptance_path",
"approximations",
"current_design_intent_id",
"design_intents",
"design_intent_id",
"design_intent_path",
"design_intent_structures",
"design_intent_assumptions",
"design_intent_capability_gaps",
"capability_translations",
"evidence",
}
def _strip_metadata(value: Any) -> bool:
changed = False
if isinstance(value, list):
for item in value:
changed = _strip_metadata(item) or changed
return changed
if not isinstance(value, dict):
return False
for key in list(value):
if key in RETIRED_METADATA_KEYS or key.startswith("generation_spec_"):
value.pop(key)
changed = True
operation = value.get("operation")
if isinstance(operation, dict) and "spec" in str(operation.get("type") or "").casefold():
value["operation"] = {}
changed = True
for key, child in list(value.items()):
if key == "source" and isinstance(child, str) and "generation_spec" in child.casefold():
value[key] = "legacy_cdsl"
changed = True
elif key in {"message", "text"} and isinstance(child, str) and "generationspec" in child.casefold():
value[key] = "已清理已弃用的规格流程诊断;当前任务统一使用直接 CDSL 生成。"
changed = True
else:
changed = _strip_metadata(child) or changed
return changed
def _retired_diagnostic(path: Path) -> bool:
if path.name.startswith(RETIRED_DIAGNOSTIC_PREFIX):
return True
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
if not isinstance(payload, dict):
return False
tool_name = str(payload.get("tool_name") or "")
arguments = str(payload.get("arguments") or "")
return tool_name in RETIRED_TOOL_NAMES or "generation-spec" in arguments or "generation_spec" in arguments
def cleanup_plan(data_root: Path) -> tuple[list[Path], list[Path]]:
files: list[Path] = []
metadata: list[Path] = []
task_root = data_root / "tasks"
conversation_root = data_root / "conversations"
if task_root.is_dir():
for path in task_root.rglob("*"):
if path.is_file() and (
path.name in RETIRED_FILENAMES
or (path.parent.name == "planning" and path.name.startswith("design-intent-"))
):
files.append(path)
for path in task_root.rglob("*.json"):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
original = json.dumps(payload, sort_keys=True, ensure_ascii=False)
_strip_metadata(payload)
if json.dumps(payload, sort_keys=True, ensure_ascii=False) != original:
metadata.append(path)
if conversation_root.is_dir():
for path in conversation_root.rglob("*.json"):
if _retired_diagnostic(path):
files.append(path)
continue
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
original = json.dumps(payload, sort_keys=True, ensure_ascii=False)
_strip_metadata(payload)
if json.dumps(payload, sort_keys=True, ensure_ascii=False) != original:
metadata.append(path)
file_set = set(files)
return sorted(file_set), sorted(set(metadata) - file_set)
def apply_cleanup(data_root: Path) -> tuple[list[Path], list[Path]]:
files, metadata = cleanup_plan(data_root)
for path in files:
path.unlink()
for path in metadata:
payload = json.loads(path.read_text(encoding="utf-8"))
if _strip_metadata(payload):
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return files, metadata
def main() -> int:
parser = argparse.ArgumentParser(description="Remove retired GenerationSpec and semantic-plan artifacts")
parser.add_argument("--data-root", type=Path, default=Path(__file__).resolve().parents[1] / "data")
parser.add_argument("--apply", action="store_true", help="Delete the listed artifacts and rewrite metadata")
args = parser.parse_args()
files, metadata = cleanup_plan(args.data_root)
action = "Will remove" if not args.apply else "Removing"
for path in files:
print(f"{action} artifact: {path}")
for path in metadata:
print(f"{action} retired metadata: {path}")
if not files and not metadata:
print("No retired GenerationSpec or semantic-plan artifacts found.")
if not args.apply:
print("Dry run only. Re-run with --apply to make these changes.")
return 0
apply_cleanup(args.data_root)
print(f"Removed {len(files)} artifacts and rewrote {len(metadata)} metadata documents.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+35 -24
View File
@@ -9,10 +9,12 @@ from types import SimpleNamespace
from unittest.mock import patch
from app.models.contracts import ChatMessage, MessagePart
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, normalize_image_analysis, parse_tool_arguments, response_language_instruction, tools_for_model, use_flange_sleeve_template, user_visible_error_message
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message
from app.services.engine_service import load_engine
from app.services.quality import QUALITY_RULE_TYPES
from app.services.library import CdslLibrary
from app.services.storage import WorkspaceStore
from app.settings import ProviderConfig, ProviderModel, Settings
from app.settings import ProviderConfig, ProviderModel, Settings, get_settings
class ToolChoiceCompatibilityTests(unittest.TestCase):
@@ -107,16 +109,31 @@ class ParseToolArgumentsTests(unittest.TestCase):
self.assertNotIn("extrude", cdsl["$defs"]["feature_atomic_ids"]["enum"])
self.assertEqual(cdsl, CDSL_TOOL_SCHEMA)
def test_generation_schema_exposes_only_runtime_atomic_ids(self) -> None:
settings = get_settings()
engine = load_engine(settings)
self.assertEqual(
set(CDSL_TOOL_SCHEMA["$defs"]["feature_atomic_ids"]["enum"]),
set(engine.SUPPORTED_ATOMIC_IDS),
)
def test_verification_schema_exposes_only_implemented_rule_types(self) -> None:
generate_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "generate_cdsl_model")
verification = generate_tool["function"]["parameters"]["properties"]["verification"]
rule_type = verification["properties"]["rules"]["items"]["properties"]["type"]
self.assertEqual(set(rule_type["enum"]), set(QUALITY_RULE_TYPES))
def test_strict_tool_schema_covers_generation_arguments(self) -> None:
tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True))
strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")]
self.assertEqual(strict_tools, ["generate_flange_sleeve_model", "generate_cdsl_model"])
template_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_flange_sleeve_model")
self.assertEqual(template_tool["function"]["parameters"]["properties"]["plan"]["required"], ["template"])
self.assertEqual(strict_tools, ["generate_cdsl_model", "patch_cdsl_model"])
generate_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_cdsl_model")
self.assertEqual(generate_tool["function"]["parameters"]["properties"]["summary"], {"type": "string", "minLength": 1})
self.assertEqual(generate_tool["function"]["parameters"]["properties"]["cdsl"], CDSL_TOOL_SCHEMA)
self.assertIn("verification", generate_tool["function"]["parameters"]["properties"])
patch_tool = next(tool for tool in tools if tool["function"]["name"] == "patch_cdsl_model")
self.assertIn("base_revision_id", patch_tool["function"]["parameters"]["required"])
self.assertFalse(generate_tool["function"]["parameters"]["additionalProperties"])
def test_default_model_does_not_receive_strict_tool_schema(self) -> None:
@@ -124,21 +141,14 @@ class ParseToolArgumentsTests(unittest.TestCase):
self.assertFalse(any(tool["function"].get("strict") for tool in tools))
def test_flange_sleeve_image_route_exposes_only_the_compact_plan_generator(self) -> None:
analysis = {
"partType": "带方形法兰的圆筒管座",
"visibleFeatures": ["同轴中空管", "四个安装孔"],
}
self.assertTrue(use_flange_sleeve_template(analysis, ""))
self.assertFalse(use_flange_sleeve_template(analysis, "cad_existing"))
tools = tools_for_model(ProviderModel("default-model"), flange_sleeve_only=True)
names = [tool["function"]["name"] for tool in tools]
self.assertIn("generate_flange_sleeve_model", names)
self.assertNotIn("search_cdsl_library", names)
self.assertNotIn("read_cdsl_reference", names)
self.assertNotIn("read_current_cdsl", names)
self.assertNotIn("generate_cdsl_model", names)
def test_direct_cdsl_tools_exclude_spec_and_template_generators(self) -> None:
names = [tool["function"]["name"] for tool in tools_for_model(ProviderModel("default-model"))]
self.assertIn("generate_cdsl_model", names)
self.assertIn("patch_cdsl_model", names)
self.assertNotIn("create_generation_spec", names)
self.assertNotIn("author_cdsl_from_generation_spec", names)
self.assertNotIn("patch_generation_spec", names)
self.assertNotIn("generate_flange_sleeve_model", names)
def test_recorded_image_analysis_is_not_exposed_as_a_tool(self) -> None:
tools = tools_for_model(ProviderModel("vision-model", vision=True), include_image_analysis=False)
@@ -385,15 +395,15 @@ class ToolArgumentsRetryTests(unittest.TestCase):
diagnostics = settings.conversation_root / conversation_id / "diagnostics"
attempts = sorted(diagnostics.glob("cdsl_attempt_*.json"))
failures = sorted(diagnostics.glob("cdsl_validation_*.json"))
self.assertEqual(len(attempts), 7)
self.assertEqual(len(failures), 7)
self.assertEqual(len(attempts), 5)
self.assertEqual(len(failures), 5)
self.assertTrue(all(json.loads(path.read_text(encoding="utf-8")) == {} for path in attempts))
records = sorted(
(json.loads(path.read_text(encoding="utf-8")) for path in failures),
key=lambda record: int(record["iteration"]),
)
self.assertEqual([record["iteration"] for record in records], list(range(2, 9)))
self.assertEqual([record["iteration"] for record in records], list(range(2, 7)))
self.assertTrue(all(record["kind"] == "cdsl_validation_failure" for record in records))
self.assertTrue(all(record["validation_error_type"] == "ValueError" for record in records))
self.assertTrue(all(record["validation_error"] for record in records))
@@ -402,7 +412,7 @@ class ToolArgumentsRetryTests(unittest.TestCase):
{path.name for path in attempts},
)
self.assertTrue(any("每次 CDSL 校验失败的诊断已保存到" in str(event.get("message", "")) for event in events))
self.assertEqual(agent.responses, [])
self.assertEqual(len(agent.responses), 2)
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
@@ -822,5 +832,6 @@ class StructuredResultResponseTests(unittest.TestCase):
self.assertEqual(json.loads(attempts[0].read_text(encoding="utf-8")), {})
self.assertEqual(list(diagnostics.glob("cdsl_validation_*.json")), [])
if __name__ == "__main__":
unittest.main()
-203
View File
@@ -1,203 +0,0 @@
from __future__ import annotations
import copy
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
def workplane(z: float = 0.0) -> dict[str, list[float]]:
return {
"origin_mm": [0.0, 0.0, z],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 1.0, 0.0],
"normal": [0.0, 0.0, 1.0],
}
def mounting_plate_intent() -> dict:
return {
"schema": "cad.cdsl.design-intent.v1",
"schema_version": "1.0",
"mode": "create",
"request": "Create a mounting plate with four holes and a center slot",
"base_revision_id": "",
"structures": [
{
"id": "base_plate",
"cdsl_feature_id": "base_add",
"role": "base",
"purpose": "Rectangular mounting plate",
"depends_on": [],
"cdsl_strategy": {
"atomic_id": "extrude_add_blind",
"profile_type": "rectangle",
"parameter_roles": ["width_mm", "height_mm", "thickness_mm"],
"selector_roles": [],
},
},
{
"id": "mounting_holes",
"cdsl_feature_id": "hole_cut",
"role": "subtractive",
"purpose": "Four mounting holes",
"depends_on": ["base_plate"],
"cdsl_strategy": {
"atomic_id": "extrude_cut_blind",
"profile_type": "circle_grid",
"parameter_roles": ["diameter_mm", "count_x", "count_y"],
"selector_roles": [],
},
},
{
"id": "adjustment_slot",
"cdsl_feature_id": "slot_cut",
"role": "subtractive",
"purpose": "Center adjustment slot",
"depends_on": ["mounting_holes"],
"cdsl_strategy": {
"atomic_id": "extrude_cut_blind",
"profile_type": "obround",
"parameter_roles": ["length_mm", "width_mm", "depth_mm"],
"selector_roles": [],
},
},
],
"feature_order": ["base_plate", "mounting_holes", "adjustment_slot"],
"assumptions": [],
"open_questions": [],
"capability_gaps": [],
"verification_expectations": [
{"type": "feature_count", "feature_id": "mounting_holes", "expected": 4},
{"type": "symmetry", "axis": "x"},
{"type": "bbox", "expected_mm": {"x": 100, "y": 60, "z": 10}},
],
"status": "ready",
}
def mounting_plate_cdsl() -> dict:
return {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.0",
"kind": "part",
"part_id": "mounting-plate",
"geometry": {
"sketches": [
{"id": "base_sketch", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}},
{"id": "holes_sketch", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}},
{"id": "slot_sketch", "workplane": workplane(10), "profile": {"type": "obround", "center": [0, 0], "length_mm": 32, "width_mm": 10}},
],
},
"features": [
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}},
{"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}},
{"id": "slot_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["hole_cut"], "sketch_id": "slot_sketch", "params": {"distance_mm": 10, "reverse": True}},
],
}
class DesignIntentValidationTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
sys.path.insert(0, str(ROOT / "backend" / "engine"))
import cdsl_engine
cls.engine = cdsl_engine
def assert_plan_error(self, intent: dict, code: str = "INVALID_DESIGN_INTENT") -> None:
with self.assertRaises(self.engine.DesignIntentError) as context:
self.engine.validate_design_intent(intent, self.engine)
self.assertEqual(context.exception.code, code)
def test_valid_mounting_plate_plan_and_cdsl_match(self) -> None:
intent = mounting_plate_intent()
validated = self.engine.validate_design_intent(intent, self.engine)
self.assertEqual(validated["feature_order"], ["base_plate", "mounting_holes", "adjustment_slot"])
self.engine.validate_intent_cdsl(validated, mounting_plate_cdsl(), self.engine)
def test_revise_requires_current_base_revision(self) -> None:
intent = mounting_plate_intent()
intent["mode"] = "revise"
intent["base_revision_id"] = ""
self.assert_plan_error(intent)
intent["base_revision_id"] = "rev_001"
self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_001")
with self.assertRaises(self.engine.DesignIntentError) as context:
self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_002")
self.assertEqual(context.exception.code, "DESIGN_INTENT_BASE_REVISION_MISMATCH")
self.assertIn("rev_002", str(context.exception))
def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None:
duplicate = mounting_plate_intent()
duplicate["structures"][1]["id"] = "base_plate"
self.assert_plan_error(duplicate)
cyclic = mounting_plate_intent()
cyclic["structures"][0]["depends_on"] = ["mounting_holes"]
self.assert_plan_error(cyclic)
unsupported = mounting_plate_intent()
unsupported["structures"][0]["cdsl_strategy"]["atomic_id"] = "thread_add"
self.assert_plan_error(unsupported)
incomplete = mounting_plate_intent()
incomplete["feature_order"].pop()
self.assert_plan_error(incomplete)
no_profile = mounting_plate_intent()
no_profile["structures"][0]["cdsl_strategy"].pop("profile_type")
self.assert_plan_error(no_profile)
unknown_expectation = mounting_plate_intent()
unknown_expectation["verification_expectations"][0]["feature_id"] = "missing"
self.assert_plan_error(unknown_expectation)
def test_blockers_and_nonblocking_approximations_are_explicit(self) -> None:
clarification = mounting_plate_intent()
clarification["open_questions"] = [{"id": "thickness", "question": "What thickness is required?", "blocking": True}]
clarification["status"] = "needs_clarification"
self.engine.validate_design_intent(clarification, self.engine)
with self.assertRaises(self.engine.DesignIntentError) as context:
self.engine.validate_intent_cdsl(clarification, mounting_plate_cdsl(), self.engine)
self.assertEqual(context.exception.code, "DESIGN_INTENT_BLOCKED")
approximation = mounting_plate_intent()
approximation["capability_gaps"] = [{
"code": "unsupported_thread_geometry",
"structure_id": "mounting_holes",
"message": "Only a cylindrical bore is available.",
"blocking": False,
}]
self.assert_plan_error(approximation)
approximation["assumptions"] = ["Thread geometry is approximated by cylindrical bores."]
self.engine.validate_design_intent(approximation, self.engine)
def test_rejects_cdsl_feature_atomic_profile_and_selector_mismatches(self) -> None:
missing_feature = mounting_plate_cdsl()
missing_feature["features"].pop()
with self.assertRaises(self.engine.DesignIntentError) as context:
self.engine.validate_intent_cdsl(mounting_plate_intent(), missing_feature, self.engine)
self.assertEqual(context.exception.code, "INTENT_CDSL_MISMATCH")
atomic = mounting_plate_cdsl()
atomic["features"][1]["atomic_id"] = "extrude_add_blind"
with self.assertRaises(self.engine.DesignIntentError):
self.engine.validate_intent_cdsl(mounting_plate_intent(), atomic, self.engine)
profile = mounting_plate_cdsl()
profile["geometry"]["sketches"][1]["profile"]["type"] = "circles"
with self.assertRaises(self.engine.DesignIntentError):
self.engine.validate_intent_cdsl(mounting_plate_intent(), profile, self.engine)
selector_plan = copy.deepcopy(mounting_plate_intent())
selector_plan["structures"][2]["cdsl_strategy"]["selector_roles"] = ["host_face"]
with self.assertRaises(self.engine.DesignIntentError):
self.engine.validate_intent_cdsl(selector_plan, mounting_plate_cdsl(), self.engine)
+37 -57
View File
@@ -14,20 +14,42 @@ sys.path.insert(0, str(ROOT / "backend"))
from app.models.contracts import ChatMessage, MessagePart # noqa: E402
from app.services.agent_service import AgentService # noqa: E402
from app.services.engine_service import load_engine # noqa: E402
from app.services.flange_sleeve_template import TEMPLATE_ID # noqa: E402
from app.services.library import CdslLibrary # noqa: E402
from app.services.part_skills import PartSkillLibrary # noqa: E402
from app.services.storage import WorkspaceStore # noqa: E402
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
from tests.test_design_intent import mounting_plate_cdsl # noqa: E402
BACKEND = ROOT / "backend"
PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills"
class DesignIntentFlowTests(unittest.TestCase):
def _workplane(z: float = 0.0) -> dict[str, list[float]]:
return {"origin_mm": [0.0, 0.0, z], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]}
def mounting_plate_cdsl() -> dict:
holes = [
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": center, "radius_mm": 3}]}
for center in [[-40, -20], [40, -20], [-40, 20], [40, 20]]
]
return {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.0",
"kind": "part",
"part_id": "mounting-plate",
"geometry": {"sketches": [
{"id": "base_sketch", "workplane": _workplane(), "profile": {"type": "polygon", "vertices": [[-50, -30], [50, -30], [50, 30], [-50, 30]]}},
{"id": "holes_sketch", "workplane": _workplane(10), "profile": {"type": "analytic_contours", "contours": holes}},
]},
"features": [
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}},
{"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}},
],
}
class DirectCdslFlowTests(unittest.TestCase):
def settings(self, root: Path) -> Settings:
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
return Settings(
@@ -48,7 +70,7 @@ class DesignIntentFlowTests(unittest.TestCase):
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
state = {"phase": "INTAKE", "design_brief": ""}
searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], planning_state=state))
generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], planning_state=state))
@@ -77,7 +99,7 @@ class DesignIntentFlowTests(unittest.TestCase):
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange base."}
state = {"phase": "PLANNED", "design_brief": "Create a flange base."}
captured_build: dict[str, object] = {}
def fake_build_revision(**kwargs: object) -> dict[str, object]:
@@ -99,45 +121,6 @@ class DesignIntentFlowTests(unittest.TestCase):
self.assertIn("workplane", built_cdsl["geometry"]["sketches"][0])
self.assertEqual(len(result["normalization_repairs"]), 3)
def test_flange_sleeve_tool_builds_cdsl_from_a_compact_semantic_plan(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange sleeve."}
captured_build: dict[str, object] = {}
def fake_build_revision(**kwargs: object) -> dict[str, object]:
captured_build.update(kwargs)
return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"}
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
result, _ = asyncio.run(agent._run_tool(
"generate_flange_sleeve_model",
{
"plan": {
"template": TEMPLATE_ID,
"name": "Image flange sleeve",
"flange_width_mm": 140,
"flange_height_mm": 120,
"mount_hole_u_mm": 48,
"mount_hole_v_mm": 38,
},
"summary": "flange sleeve",
"assumptions": ["Dimensions are approximate."],
},
"", "Create an image flange sleeve", [], planning_state=state,
))
built_cdsl = captured_build["cdsl"]
self.assertTrue(result["ok"])
self.assertEqual(result["template_id"], TEMPLATE_ID)
self.assertEqual(captured_build["operation"]["template_id"], TEMPLATE_ID)
self.assertEqual(built_cdsl["meta"]["name"], "Image flange sleeve")
self.assertEqual(built_cdsl["geometry"]["sketches"][0]["workplane"]["normal"], [1.0, 0.0, 0.0])
self.assertEqual(built_cdsl["features"][-1]["depends_on"], ["mount_hole_cuts"])
self.assertNotIn("cdsl", result["template_plan"])
def test_successful_generation_ends_the_agent_tool_loop(self) -> None:
class CaptureAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
@@ -148,7 +131,7 @@ class DesignIntentFlowTests(unittest.TestCase):
"role": "assistant", "content": "", "tool_calls": [{
"id": "brief", "type": "function", "function": {
"name": "describe_design_intent",
"arguments": json.dumps({"plan": "Create a flange sleeve.", "assumptions": []}),
"arguments": json.dumps({"plan": "Create a cylindrical part.", "assumptions": []}),
},
}],
}}],
@@ -156,11 +139,11 @@ class DesignIntentFlowTests(unittest.TestCase):
{
"choices": [{"message": {
"role": "assistant", "content": "", "tool_calls": [{
"id": "template", "type": "function", "function": {
"name": "generate_flange_sleeve_model",
"id": "generate", "type": "function", "function": {
"name": "generate_cdsl_model",
"arguments": json.dumps({
"plan": {"template": TEMPLATE_ID},
"summary": "flange sleeve",
"cdsl": mounting_plate_cdsl(),
"summary": "mounting plate",
"assumptions": [],
}),
},
@@ -189,7 +172,7 @@ class DesignIntentFlowTests(unittest.TestCase):
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
async def consume() -> None:
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a flange sleeve")])
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")])
async for _ in agent.stream([message], None, None):
pass
@@ -270,8 +253,7 @@ class DesignIntentFlowTests(unittest.TestCase):
"engine": "cdsl_only",
}
engine = load_engine(settings)
with patch.object(engine, "validate_intent_cdsl", side_effect=AssertionError("legacy intent validation must not run")) as legacy_validation, patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
async def consume() -> None:
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")])
async for _ in agent.stream([message], None, None):
@@ -281,18 +263,16 @@ class DesignIntentFlowTests(unittest.TestCase):
brief_result = json.loads(str(agent.seen_messages[1][-1]["content"]))
self.assertEqual(brief_result["plan"], "Create a rectangular mounting plate, then cut four mounting holes and a center slot.")
self.assertNotIn("design_intent_id", brief_result)
self.assertNotIn("design_intent_id", captured_build)
self.assertNotIn("structures", brief_result)
self.assertNotIn("design_intent", captured_build)
self.assertEqual(captured_build["parent_revision_id"], "")
legacy_validation.assert_not_called()
def test_revision_parent_is_taken_from_the_current_successful_cdsl_revision(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
state = {"phase": "INTAKE", "design_brief": "", "current_model_read": True}
asyncio.run(agent._run_tool(
"describe_design_intent",
{"plan": "Increase the plate thickness and preserve the existing hole layout.", "assumptions": []},
+360
View File
@@ -0,0 +1,360 @@
from __future__ import annotations
import asyncio
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from app.services.agent_service import AgentService # noqa: E402
from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch # noqa: E402
from app.services.engine_service import QualityVerificationError, build_revision # noqa: E402
from app.services.library import CdslLibrary # noqa: E402
from app.services.part_skills import PartSkillLibrary # noqa: E402
from app.services.quality import evaluate_quality, validate_verification # noqa: E402
from app.services.storage import WorkspaceStore, write_json # noqa: E402
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
BACKEND = ROOT / "backend"
PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills"
def workplane(z: float = 0.0) -> dict[str, list[float]]:
return {
"origin_mm": [0.0, 0.0, z],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 1.0, 0.0],
"normal": [0.0, 0.0, 1.0],
}
def fixture() -> dict:
return {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.0",
"kind": "part",
"part_id": "direct-cdsl-fixture",
"geometry": {
"sketches": [{
"id": "base",
"workplane": workplane(),
"profile": {"type": "polygon", "vertices": [[-10, -5], [10, -5], [10, 5], [-10, 5]]},
}],
},
"features": [{
"id": "base_add",
"atomic_id": "extrude_add_blind",
"depends_on": [],
"sketch_id": "base",
"params": {"distance_mm": 4},
}],
}
def settings(root: Path) -> Settings:
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
return Settings(
task_root=root / "tasks",
conversation_root=root / "conversations",
library_root=BACKEND / "cdsl_library",
engine_root=BACKEND / "engine" / "cdsl_engine",
llm_base_url=provider.base_url,
llm_api_key=provider.api_key,
llm_model="test-model",
llm_timeout_s=1,
default_provider_id="test",
providers=(provider,),
)
class PatchTests(unittest.TestCase):
def test_rfc6902_patch_updates_cdsl_without_replacing_its_root(self) -> None:
document = fixture()
patched = apply_cdsl_patch(document, [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}])
self.assertEqual(patched["features"][0]["params"]["distance_mm"], 8)
self.assertEqual(document["features"][0]["params"]["distance_mm"], 4)
with self.assertRaisesRegex(CdslPatchError, "complete CDSL"):
apply_cdsl_patch(document, [{"op": "replace", "path": "", "value": {}}])
def test_invalid_patch_path_is_rejected(self) -> None:
with self.assertRaisesRegex(CdslPatchError, "out of range"):
apply_cdsl_patch(fixture(), [{"op": "replace", "path": "/features/3/id", "value": "x"}])
class VerificationTests(unittest.TestCase):
def test_bbox_accepts_dimension_vector(self) -> None:
rules = validate_verification({"rules": [
{"id": "size", "type": "bbox", "expected": [20, 10, 4]},
]}, fixture())
report = evaluate_quality(rules, fixture(), {
"bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]},
})
self.assertEqual(report["status"], "passed")
self.assertEqual(report["results"][0]["actual"]["dimensions"], [20.0, 10.0, 4.0])
def test_bbox_accepts_coordinate_ranges(self) -> None:
rules = validate_verification({"rules": [
{"id": "range", "type": "bbox", "expected": {
"x_min": -10, "x_max": 10, "y_min": -5, "y_max": 5, "z_min": 0, "z_max": 4,
}},
]}, fixture())
report = evaluate_quality(rules, fixture(), {
"bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]},
})
self.assertEqual(report["status"], "passed")
def test_feature_bbox_uses_owned_topology_records(self) -> None:
rules = validate_verification({"rules": [
{"id": "base_range", "type": "bbox", "feature": "base_add", "expected": [10, 8, 2]},
]}, fixture())
report = evaluate_quality(rules, fixture(), {
"bbox_mm": {"min": [0, 0, 0], "max": [20, 20, 20]},
"topology_records": [{
"feature_id": "base_add", "owner_feature_ids": ["base_add"],
"geometry": {"bbox_mm": [0, 0, 0, 10, 8, 2]},
}],
})
self.assertEqual(report["status"], "passed")
self.assertEqual(report["results"][0]["source"], "runtime.topology_records[base_add].bbox_mm")
def test_bbox_shape_is_rejected_before_execution(self) -> None:
with self.assertRaisesRegex(ValueError, "expected for bbox"):
validate_verification({"rules": [
{"id": "bad", "type": "bbox", "expected": {"width": 10}},
]}, fixture())
def test_unknown_feature_reference_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "must reference a CDSL feature ID"):
validate_verification({"rules": [{"id": "holes", "type": "hole_count", "feature": "missing", "expected": 4}]}, fixture())
def test_invalid_expected_value_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "expected for bbox"):
validate_verification({"rules": [{"id": "size", "type": "bbox", "expected": [10, 5]}]}, fixture())
def test_warning_does_not_block_generic_quality(self) -> None:
rules = validate_verification({"rules": [
{"id": "solids", "type": "solid_count", "expected": 1, "severity": "blocking"},
{"id": "length", "type": "overall_length", "expected": 999, "severity": "warning"},
]}, fixture())
report = evaluate_quality(rules, fixture(), {
"solid_count": 1,
"bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]},
})
self.assertEqual(report["status"], "passed")
self.assertEqual(len(report["blocking_failures"]), 0)
self.assertEqual(len(report["warnings"]), 1)
def test_blocking_failure_requires_repair(self) -> None:
rules = validate_verification({"rules": [{"id": "solids", "type": "solid_count", "expected": 2}]}, fixture())
report = evaluate_quality(rules, fixture(), {"solid_count": 1, "bbox_mm": {"min": [0, 0, 0], "max": [1, 1, 1]}})
self.assertEqual(report["status"], "failed")
self.assertEqual(report["blocking_failures"][0]["id"], "solids")
class AgentPatchFlowTests(unittest.TestCase):
def _seed_revision(self, store: WorkspaceStore) -> tuple[str, str]:
task = store.ensure_task(None, "Create a direct CDSL fixture")
revision_id = "rev_001"
revision_dir = store.task_dir(task["task_id"]) / "revisions" / revision_id
revision_dir.mkdir(parents=True)
write_json(revision_dir / "model.cdsl.json", fixture())
store.update_task(task["task_id"], {
"revision_id": revision_id,
"status": "success",
"cdsl_path": f"revisions/{revision_id}/model.cdsl.json",
})
return task["task_id"], revision_id
def test_patch_creates_a_child_revision_from_explicit_parent(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
task_id, parent = self._seed_revision(store)
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
captured: dict[str, object] = {}
def fake_build_revision(**kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return {"task_id": task_id, "revision_id": "rev_002"}
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
result, _ = asyncio.run(agent._run_tool(
"patch_cdsl_model",
{
"base_revision_id": parent,
"patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}],
"summary": "Increase thickness",
"assumptions": [],
},
task_id,
"Increase thickness",
[],
planning_state={"phase": "PLANNED", "current_model_read": True},
))
self.assertTrue(result["ok"])
self.assertEqual(captured["parent_revision_id"], parent)
self.assertEqual(captured["operation"]["type"], "cdsl_patch")
self.assertEqual(captured["cdsl"]["features"][0]["params"]["distance_mm"], 8)
def test_bad_patch_creates_no_revision(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
task_id, parent = self._seed_revision(store)
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
with self.assertRaisesRegex(ValueError, "INVALID_CDSL_PATCH"):
asyncio.run(agent._run_tool(
"patch_cdsl_model",
{"base_revision_id": parent, "patches": [{"op": "replace", "path": "/missing", "value": 8}], "summary": "bad", "assumptions": []},
task_id,
"bad patch",
[],
planning_state={"phase": "PLANNED"},
))
task = store.read_task(task_id)
self.assertEqual([item["revision_id"] for item in task["revisions"]], [parent])
def test_complete_replacement_uses_current_revision_as_parent(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
task_id, parent = self._seed_revision(store)
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
captured: dict[str, object] = {}
def fake_build_revision(**kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return {"task_id": task_id, "revision_id": "rev_002"}
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
result, _ = asyncio.run(agent._run_tool(
"generate_cdsl_model",
{"cdsl": fixture(), "summary": "Replacement", "assumptions": []},
task_id,
"replace geometry",
[],
planning_state={"phase": "PLANNED", "current_model_read": True},
))
self.assertTrue(result["ok"])
self.assertEqual(captured["parent_revision_id"], parent)
self.assertEqual(captured["operation"]["type"], "cdsl_replacement")
def test_blocking_quality_failure_is_repairable_and_readable(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
with self.assertRaises(QualityVerificationError) as context:
build_revision(
settings=config,
store=store,
task_id=None,
request="Create fixture",
cdsl=fixture(),
reference_ids=[],
summary="fixture",
verification={"rules": [{"id": "wrong-solid-count", "type": "solid_count", "expected": 2}]},
)
error = context.exception
self.assertTrue(error.task_id)
self.assertEqual(error.revision_id, "rev_001")
task = store.read_task(error.task_id)
self.assertEqual(task["revisions"][0]["status"], "needs_repair")
self.assertEqual(task["revisions"][0]["quality_status"], "needs_repair")
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
read, _ = asyncio.run(agent._run_tool(
"read_current_cdsl", {}, error.task_id, "Repair fixture", [], planning_state={"phase": "INTAKE"}
))
self.assertTrue(read["ok"])
self.assertEqual(read["revision_id"], error.revision_id)
def test_runtime_build_failure_does_not_leave_a_revision(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
class BrokenEngine:
def run_cdsl_only(self, _cdsl: dict, _step: Path) -> dict:
raise RuntimeError("intentional runtime failure")
with patch("app.services.engine_service.load_engine", return_value=BrokenEngine()), patch(
"app.services.engine_service.validate_cdsl", return_value=None
), self.assertRaisesRegex(RuntimeError, "intentional runtime failure"):
build_revision(
settings=config,
store=store,
task_id=None,
request="broken fixture",
cdsl=fixture(),
reference_ids=[],
summary="broken",
)
tasks = list(config.task_root.glob("cad_*"))
self.assertEqual(len(tasks), 1)
task = store.read_task(tasks[0].name)
self.assertEqual(task["revisions"], [])
class CleanupAndBoundaryTests(unittest.TestCase):
def test_cleanup_is_dry_run_then_removes_only_retired_artifacts(self) -> None:
with tempfile.TemporaryDirectory() as directory:
data_root = Path(directory) / "data"
task_dir = data_root / "tasks" / "cad_aaaaaaaaaaaa" / "revisions" / "rev_001"
conversation_dir = data_root / "conversations" / "conv_aaaaaaaaaaaa" / "diagnostics"
task_dir.mkdir(parents=True)
conversation_dir.mkdir(parents=True)
write_json(task_dir / "generation-spec.json", {"schema": "cad.generation-spec.v1"})
write_json(task_dir / "model.cdsl.json", fixture())
(task_dir / "model.step").write_bytes(b"step")
(task_dir / "model.glb").write_bytes(b"glb")
write_json(data_root / "tasks" / "cad_aaaaaaaaaaaa" / "task.json", {
"generation_spec_path": "revisions/rev_001/generation-spec.json",
"current_design_intent_id": "intent_aaaaaaaaaaaa",
"revisions": [{"acceptance_path": "revisions/rev_001/acceptance-report.json"}],
})
write_json(conversation_dir / "generation_spec_validation_deadbeef.json", {"kind": "old"})
script = BACKEND / "scripts" / "remove_generation_spec_artifacts.py"
dry = subprocess.run([sys.executable, str(script), "--data-root", str(data_root)], check=True, capture_output=True, text=True)
self.assertIn("Dry run only", dry.stdout)
self.assertTrue((task_dir / "generation-spec.json").is_file())
subprocess.run([sys.executable, str(script), "--data-root", str(data_root), "--apply"], check=True, capture_output=True, text=True)
self.assertFalse((task_dir / "generation-spec.json").exists())
self.assertFalse((conversation_dir / "generation_spec_validation_deadbeef.json").exists())
self.assertTrue((task_dir / "model.cdsl.json").is_file())
self.assertTrue((task_dir / "model.step").is_file())
self.assertTrue((task_dir / "model.glb").is_file())
cleaned = json.loads((data_root / "tasks" / "cad_aaaaaaaaaaaa" / "task.json").read_text(encoding="utf-8"))
self.assertNotIn("generation_spec_path", cleaned)
self.assertNotIn("current_design_intent_id", cleaned)
self.assertNotIn("acceptance_path", cleaned["revisions"][0])
def test_engine_does_not_import_application_or_dispatch_part_families(self) -> None:
engine_root = BACKEND / "engine" / "cdsl_engine"
source = "\n".join(path.read_text(encoding="utf-8") for path in engine_root.rglob("*.py"))
lowered = source.casefold()
self.assertNotIn("from app", lowered)
self.assertNotIn("import app", lowered)
for business_family in ("mounting_bracket", "mounting_plate", "flange_sleeve", "bearing_housing", "hex_nut"):
self.assertNotIn(business_family, lowered)
@@ -21,6 +21,13 @@ def _workplane() -> dict:
return {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
return {"type": "polygon", "vertices": [
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
]}
class EngineRuntimeFoundationTests(unittest.TestCase):
def _base_block(self) -> dict:
return {
@@ -28,7 +35,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "base", "workplane": _workplane(),
"profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10},
"profile": _rectangle([-5, -5], [5, 5]),
}]},
"features": [{
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
@@ -490,7 +497,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "mirror-test",
"meta": {"unit": "mm"},
"geometry": {"sketches": [
{"id": "base", "workplane": _workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}},
{"id": "base", "workplane": _workplane(), "profile": _rectangle([-5, -5], [5, 5])},
{"id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "center": [2, 0], "radius_mm": 1}},
]},
"features": [
@@ -518,7 +525,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
"schema": "cad.cdsl.llm.v1",
"geometry": {"sketches": [{
"id": "circles", "workplane": _workplane(),
"profile": {"type": "circles", "items": [{"center": [2, 3], "radius_mm": 1}]},
"profile": {"type": "circle", "center": [2, 3], "radius_mm": 1},
}]},
"features": [],
}
@@ -761,7 +768,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "profile", "workplane": _workplane(),
"profile": {"type": "rectangle", "min_mm": [2, 1], "max_mm": [4, 2]},
"profile": _rectangle([2, 1], [4, 2]),
}]},
"features": [
{
+60 -99
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import copy
import json
import sys
import tempfile
@@ -14,7 +13,6 @@ sys.path.insert(0, str(ROOT / "backend"))
from app.models.contracts import ChatMessage, MessagePart # noqa: E402
from app.services.agent_service import AgentService, TOOL_SCHEMAS, system_prompt # noqa: E402
from app.services.engine_service import build_revision, load_engine, validate_cdsl # noqa: E402
from app.services.library import CdslLibrary # noqa: E402
from app.services.part_skills import PartSkillLibrary # noqa: E402
from app.services.storage import WorkspaceStore # noqa: E402
@@ -46,10 +44,49 @@ def document(part_id: str, sketches: list[dict], features: list[dict]) -> dict:
}
def rectangle(center: list[float], width: float, height: float) -> dict:
half_width, half_height = width / 2, height / 2
return {"type": "polygon", "vertices": [
[center[0] - half_width, center[1] - half_height], [center[0] + half_width, center[1] - half_height],
[center[0] + half_width, center[1] + half_height], [center[0] - half_width, center[1] + half_height],
]}
def circles(items: list[dict]) -> dict:
return {"type": "analytic_contours", "contours": [
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]}
for item in items
]}
def circle_grid(radius: float, count_x: int, count_y: int, spacing_x: float, spacing_y: float) -> dict:
return circles([
{"center": [(column - (count_x - 1) / 2) * spacing_x, (row - (count_y - 1) / 2) * spacing_y], "radius_mm": radius}
for row in range(count_y) for column in range(count_x)
])
def obround(length: float, width: float) -> dict:
radius, left, right = width / 2, -length / 2 + width / 2, length / 2 - width / 2
return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [
{"type": "line", "start": [right, radius], "end": [left, radius]},
{"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0], "radius_mm": radius},
{"type": "line", "start": [left, -radius], "end": [right, -radius]},
{"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0], "radius_mm": radius},
]}]}
def annulus(inner_radius: float, outer_radius: float) -> dict:
return circles([
{"center": [0, 0], "radius_mm": outer_radius},
{"center": [0, 0], "radius_mm": inner_radius},
])
def mounting_plate_fixture() -> dict:
return document("golden-mounting-plate", [
{"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 60}},
{"id": "grid", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 50, "spacing_y_mm": 30, "center_mm": [0, 0]}},
{"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 80, 60)},
{"id": "grid", "workplane": workplane(10), "profile": circle_grid(3, 2, 2, 50, 30)},
], [
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 10}, "sketch_id": "base"},
{"id": "mount_pattern", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 10, "reverse": True}, "sketch_id": "grid"},
@@ -60,10 +97,10 @@ def mounting_plate_fixture() -> dict:
def mounting_bracket_fixture() -> dict:
web_plane = {"origin_mm": [0, -20, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1], "normal": [0, 1, 0]}
return document("golden-mounting-bracket", [
{"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 40}},
{"id": "web", "workplane": web_plane, "profile": {"type": "rectangle", "center": [0, -15], "width_mm": 50, "height_mm": 30}},
{"id": "slot", "workplane": workplane(6), "profile": {"type": "obround", "center": [0, 0], "length_mm": 24, "width_mm": 8}},
{"id": "symmetric_holes", "workplane": workplane(6), "profile": {"type": "circles", "items": [{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}]}},
{"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 80, 40)},
{"id": "web", "workplane": web_plane, "profile": rectangle([0, -15], 50, 30)},
{"id": "slot", "workplane": workplane(6), "profile": obround(24, 8)},
{"id": "symmetric_holes", "workplane": workplane(6), "profile": circles([{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}])},
], [
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 6}, "sketch_id": "base"},
{"id": "web_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 6}, "sketch_id": "web"},
@@ -74,8 +111,8 @@ def mounting_bracket_fixture() -> dict:
def flange_fixture() -> dict:
return document("golden-flange", [
{"id": "base", "workplane": workplane(), "profile": {"type": "annulus", "inner_radius_mm": 10, "outer_radius_mm": 40}},
{"id": "bolt_circle", "workplane": workplane(12), "profile": {"type": "circles", "items": [{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}]}},
{"id": "base", "workplane": workplane(), "profile": annulus(10, 40)},
{"id": "bolt_circle", "workplane": workplane(12), "profile": circles([{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}])},
], [
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 12}, "sketch_id": "base"},
{"id": "bolt_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 12, "reverse": True}, "sketch_id": "bolt_circle"},
@@ -94,9 +131,9 @@ def shaft_fixture() -> dict:
def bearing_housing_fixture() -> dict:
return document("golden-bearing-housing", [
{"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}},
{"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 100, 60)},
{"id": "housing", "workplane": workplane(8), "profile": {"type": "circle", "radius_mm": 28}},
{"id": "base_holes", "workplane": workplane(8), "profile": {"type": "circle_grid", "radius_mm": 4, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}},
{"id": "base_holes", "workplane": workplane(8), "profile": circle_grid(4, 2, 2, 80, 40)},
], [
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 8}, "sketch_id": "base"},
{"id": "housing_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 25}, "sketch_id": "housing"},
@@ -115,16 +152,6 @@ def hex_nut_fixture() -> dict:
])
GOLDEN_CASES = (
("带沉孔和孔阵列的安装底板", mounting_plate_fixture, {"planning/mounting-plate", "atomic/counterbored-hole-creation", "atomic/pattern-holes-from-datum"}, {"atomic/pattern-holes-from-datum": "expanded"}),
("带槽和对称孔的安装支架", mounting_bracket_fixture, {"planning/mounting-bracket", "functional/slotted-adjustment-feature", "functional/symmetric-feature-layout"}, {"atomic/pattern-holes-from-datum": "expanded"}),
("带螺栓圆的法兰", flange_fixture, {"planning/flange", "functional/flange-bolt-circle"}, {"functional/flange-bolt-circle": "expanded"}),
("带回转体同轴孔的阶梯轴", shaft_fixture, {"planning/simple-shaft", "functional/axisymmetric-revolve-strategy", "atomic/coaxial-bore-rule"}, {}),
("带轴承座孔和底座孔的轴承座", bearing_housing_fixture, {"planning/bearing-housing", "functional/bearing-bore-seat"}, {}),
("M6 六角螺母", hex_nut_fixture, {"planning/hexagonal-nut", "atomic/threaded-hole-creation"}, {"atomic/threaded-hole-creation": "approximated"}),
)
class PartSkillLibraryTests(unittest.TestCase):
def setUp(self) -> None:
self.library = PartSkillLibrary(PART_SKILL_ROOT)
@@ -166,24 +193,19 @@ class PartSkillLibraryTests(unittest.TestCase):
self.assertTrue((PART_SKILL_ROOT / skill["bridge"]).is_file())
self.assertTrue((PART_SKILL_ROOT / skill["source"]).is_file())
self.assertTrue(skill["triggers"])
self.assertTrue(skill["capability_translation_rules"])
self.assertNotIn("capability_translation_rules", skill)
self.assertEqual(categories, {"planning": 6, "functional": 7, "atomic": 8})
self.assertTrue((PART_SKILL_ROOT / "LICENSE").is_file())
self.assertTrue((PART_SKILL_ROOT / "PROVENANCE.md").is_file())
def test_audit_records_exact_omitted_and_blocked_capability_states(self) -> None:
def test_audit_records_selection_and_assumptions_without_interpreting_geometry(self) -> None:
fixture = mounting_plate_fixture()
exact = self.library.audit(self.library.select("安装底板"), fixture)
finishing = self.library.audit(self.library.select("edge treatment"), fixture)
blocked = self.library.audit(self.library.select("flange bolt circle"), mounting_plate_fixture())
exact_statuses = {item["skill_id"]: item["status"] for item in exact["capability_translations"]}
finishing_statuses = {item["skill_id"]: item for item in finishing["capability_translations"]}
blocked_statuses = {item["skill_id"]: item["status"] for item in blocked["capability_translations"]}
self.assertEqual(exact_statuses["planning/mounting-plate"], "exact")
self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["status"], "omitted")
self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["translation"], "selector_unavailable")
self.assertEqual(blocked_statuses["functional/flange-bolt-circle"], "blocked")
audit = self.library.audit(self.library.select("安装底板"), fixture, ["孔位置由用户确认"])
self.assertIn("planning/mounting-plate", audit["skill_ids"])
self.assertEqual(audit["assumptions"], ["孔位置由用户确认"])
self.assertTrue(all(item.get("version") and item.get("summary") for item in audit["skills"]))
self.assertNotIn("capability_translations", audit)
self.assertNotIn("evidence", audit)
class AgentPartSkillTests(unittest.TestCase):
@@ -211,7 +233,7 @@ class AgentPartSkillTests(unittest.TestCase):
self.assertIn("[planning/flange]", str(agent.first_messages[0]["content"]))
self.assertIn("[functional/flange-bolt-circle]", str(agent.first_messages[0]["content"]))
def test_prompt_contains_bridge_and_template_tool(self) -> None:
def test_prompt_contains_bridge_and_direct_cdsl_tools(self) -> None:
library = PartSkillLibrary(PART_SKILL_ROOT)
selection = library.select("Create a flange with a bolt circle")
with tempfile.TemporaryDirectory() as directory:
@@ -220,7 +242,7 @@ class AgentPartSkillTests(unittest.TestCase):
self.assertIn("[planning/flange]", prompt)
self.assertIn("circular-pattern atomic", prompt)
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_flange_sleeve_model", "generate_cdsl_model"])
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"])
def test_generation_persists_assumptions_and_selected_skills(self) -> None:
with tempfile.TemporaryDirectory() as directory:
@@ -230,7 +252,7 @@ class AgentPartSkillTests(unittest.TestCase):
agent = AgentService(settings, store, CdslLibrary(settings), library)
request = "M6 六角螺母"
selection = library.select(request)
planning_state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
planning_state = {"phase": "INTAKE", "design_brief": ""}
planned, _ = asyncio.run(agent._run_tool(
"describe_design_intent",
{"plan": "Create an M6 hex nut with a centered cylindrical bore representing the thread.", "assumptions": []},
@@ -278,66 +300,5 @@ class AgentPartSkillTests(unittest.TestCase):
)
class PartSkillBuildAndGoldenTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.settings = AgentPartSkillTests._settings(Path(tempfile.mkdtemp()))
cls.engine = load_engine(cls.settings)
cls.library = PartSkillLibrary(PART_SKILL_ROOT)
def test_success_and_failure_revisions_always_write_part_skill_audit(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = AgentPartSkillTests._settings(Path(directory))
store = WorkspaceStore(settings)
selection = self.library.select("带螺栓圆的法兰")
audit = self.library.audit(selection, flange_fixture(), ["Explicit circle expansion"])
success = build_revision(
settings=settings, store=store, task_id=None, request="带螺栓圆的法兰", cdsl=flange_fixture(),
reference_ids=["flange-reference"], summary="flange", part_skills=audit,
generation_assumptions=["Explicit circle expansion"],
)
success_audit = json.loads(store.artifact_path(success["task_id"], success["part_skills_path"]).read_text(encoding="utf-8"))
success_report = json.loads(store.artifact_path(success["task_id"], success["report_path"]).read_text(encoding="utf-8"))
self.assertEqual(success_audit["skill_ids"], audit["skill_ids"])
self.assertIn("generation_context", success_report)
invalid = copy.deepcopy(flange_fixture())
invalid["features"][0]["atomic_id"] = "not_supported"
failed_audit = self.library.audit(selection, invalid, [])
with self.assertRaisesRegex(ValueError, "CDSL schema violation"):
build_revision(
settings=settings, store=store, task_id=success["task_id"], request="bad flange", cdsl=invalid,
reference_ids=[], summary="bad", part_skills=failed_audit, generation_assumptions=[],
)
task = store.read_task(success["task_id"])
failed = task["revisions"][-1]
self.assertEqual(failed["status"], "failed")
self.assertTrue(store.artifact_path(task["task_id"], failed["part_skills_path"]).is_file())
report = json.loads(store.artifact_path(task["task_id"], failed["report_path"]).read_text(encoding="utf-8"))
self.assertEqual(report["generation_context"]["part_skill_ids"], audit["skill_ids"])
legacy_report = {"engine_result": {"engine": "cdsl_only"}}
self.assertEqual(legacy_report.get("generation_context", {}), {})
def test_golden_part_families_validate_preflight_rebuild_and_audit(self) -> None:
for request, factory, expected_skills, expected_statuses in GOLDEN_CASES:
with self.subTest(request=request):
cdsl = factory()
selection = self.library.select(request)
self.assertTrue(expected_skills.issubset(selection["skill_ids"]))
validate_cdsl(cdsl, self.engine)
with tempfile.TemporaryDirectory() as directory:
step_path = Path(directory) / "model.step"
result = self.engine.run_cdsl_only(copy.deepcopy(cdsl), step_path)
self.assertEqual(result["engine"], "cdsl_only")
self.assertTrue(step_path.is_file())
self.assertGreater(step_path.stat().st_size, 0)
audit = self.library.audit(selection, cdsl, ["golden fixture"])
statuses = {item["skill_id"]: item["status"] for item in audit["capability_translations"]}
for skill_id, status in expected_statuses.items():
self.assertEqual(statuses[skill_id], status)
self.assertTrue(audit["evidence"]["features"])
self.assertTrue(audit["evidence"]["profiles"])
if __name__ == "__main__":
unittest.main()
+28 -40
View File
@@ -6,7 +6,6 @@ import unittest
from pathlib import Path
from app.services.engine_service import load_engine, normalize_cdsl_for_engine, validate_cdsl
from app.services.flange_sleeve_template import TEMPLATE_ID, build_flange_sleeve_cdsl
from app.settings import get_settings
@@ -23,21 +22,38 @@ class ProfileSchemaTests(unittest.TestCase):
def test_schema_and_registered_profiles_stay_in_sync(self) -> None:
self.assertEqual(set(self.schema["runtime_supported_profiles"]), set(self.engine.SHAPE_GENERATORS))
def test_legacy_profile_macros_require_explicit_adapter_lowering(self) -> None:
legacy = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "legacy-rectangle",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "base", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10},
}]},
"features": [{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base", "params": {"distance_mm": 2}}],
}
with self.assertRaisesRegex(ValueError, "CDSL schema violation"):
validate_cdsl(legacy, self.engine)
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
lowered = lower_legacy_profiles(legacy)
self.assertEqual(lowered["geometry"]["sketches"][0]["profile"]["type"], "analytic_contours")
validate_cdsl(lowered, self.engine)
def test_schema_and_executable_atomic_operations_stay_in_sync(self) -> None:
self.assertEqual(set(self.schema["runtime_supported_atomic_ids"]), set(self.engine.SUPPORTED_ATOMIC_IDS))
self.assertTrue(set(self.engine.SUPPORTED_ATOMIC_IDS).issubset(self.schema["feature_atomic_ids"]))
self.assertEqual(set(self.engine.SUPPORTED_ATOMIC_IDS), set(self.schema["feature_atomic_ids"]))
self.assertEqual(
set(self.cdsl_schema["$defs"]["feature_atomic_ids"]["enum"]),
set(self.schema["feature_atomic_ids"]),
set(self.engine.SUPPORTED_ATOMIC_IDS),
)
def test_machine_schema_and_human_contract_stay_in_sync(self) -> None:
self.assertEqual(
set(self.cdsl_schema["$defs"]["profile_type"]["enum"]),
set(self.schema["profiles"]) - {"complex_arc_shape", "unknown_shape"},
set(self.schema["runtime_supported_profiles"]),
)
self.assertEqual(set(self.cdsl_schema["$defs"]["motif_type"]["enum"]), set(self.schema["motif_types"]))
self.assertEqual(set(self.cdsl_schema["$defs"]["layout_type"]["enum"]), set(self.schema["layout_types"]))
self.assertEqual(set(self.schema["profiles"]), set(self.schema["runtime_supported_profiles"]))
def test_rejects_unsupported_atomic_operation_before_rebuild(self) -> None:
cdsl = {
@@ -135,39 +151,6 @@ class ProfileSchemaTests(unittest.TestCase):
self.assertNotIn("point_mm", axis)
self.assertIn("features[0].params.axis: point_mm -> origin_mm", repairs)
def test_flange_sleeve_template_is_schema_valid_and_runtime_buildable(self) -> None:
cdsl, plan = build_flange_sleeve_cdsl({
"template": TEMPLATE_ID,
"name": "Template flange sleeve",
"flange_width_mm": 120,
"flange_height_mm": 120,
"flange_thickness_mm": 14,
"corner_chamfer_mm": 10,
"tube_outer_diameter_mm": 70,
"tube_straight_length_mm": 95,
"tip_outer_diameter_mm": 62,
"tip_length_mm": 28,
"bore_diameter_mm": 46,
"boss_outer_diameter_mm": 82,
"boss_height_mm": 5,
"mount_hole_diameter_mm": 12,
"mount_counterbore_diameter_mm": 24,
"mount_counterbore_depth_mm": 5,
"mount_hole_u_mm": 42,
"mount_hole_v_mm": 42,
})
self.assertEqual(plan["name"], "Template flange sleeve")
self.assertEqual(cdsl["features"][1]["depends_on"], ["flange_plate"])
self.assertEqual(cdsl["geometry"]["sketches"][0]["workplane"]["x_dir"], [0.0, 1.0, 0.0])
validate_cdsl(cdsl, self.engine)
with tempfile.TemporaryDirectory() as directory:
step_path = Path(directory) / "flange-sleeve.step"
result = self.engine.run_cdsl_only(cdsl, step_path)
self.assertEqual(result["engine"], "cdsl_only")
self.assertTrue(step_path.is_file())
self.assertGreater(step_path.stat().st_size, 0)
def test_cdsl_only_rebuild_preserves_its_actual_failure(self) -> None:
cdsl = {
"schema": "cad.cdsl.llm.v1",
@@ -192,11 +175,16 @@ class ProfileSchemaTests(unittest.TestCase):
self.assertNotIn("engine.run_rebuild(cdsl_copy, step_path)", build_revision_source)
def test_all_official_samples_match_the_engine_schema(self) -> None:
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
samples = sorted(self.settings.library_root.glob("samples/**/model.cdsl.json"))
self.assertGreater(len(samples), 0)
for sample_path in samples:
with self.subTest(sample=sample_path.parent.name):
validate_cdsl(json.loads(sample_path.read_text(encoding="utf-8")), self.engine)
legacy_sample = json.loads(sample_path.read_text(encoding="utf-8"))
lowered = lower_legacy_profiles(legacy_sample)
validate_cdsl(lowered, self.engine)
self.assertTrue(self.engine.analyze_cdsl(lowered).runtime_eligible)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function GET(_: NextRequest, { params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/quality`);
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
+12
View File
@@ -442,6 +442,18 @@ button:disabled {
.cad-result { margin-top: 12px; }
.cad-result-title { margin-left: 21px; color: var(--ui-accent-text); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; }
.cad-result-meta { display: flex; flex-wrap: wrap; gap: 3px 10px; margin-left: 21px; color: var(--ui-text-muted); font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; }
.cad-quality-status { font-weight: 600; }
.quality-accepted { color: #2f8f5b; }
.quality-built_with_warnings, .quality-historical { color: #a66a1f; }
.quality-needs_repair, .quality-blocked, .quality-failed { color: #b44545; }
.cad-result-notes { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 6px; margin: 8px 0 0 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; }
.cad-result-notes strong { color: var(--ui-text); }
.cad-quality-results { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 6px; margin: 8px 0 0 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; }
.cad-quality-results > strong { color: var(--ui-text); }
.cad-quality-results ul { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
.cad-quality-results li { display: flex; flex-wrap: wrap; gap: 6px; overflow-wrap: anywhere; }
.quality-rule-passed { color: #2f8f5b; }
.quality-rule-failed, .quality-rule-unavailable { color: #b44545; }
.download-row { display: flex; flex-wrap: wrap; gap: 9px; margin: 2px 0 0 21px; }
.download-link { display: inline-flex; align-items: center; gap: 4px; color: var(--ui-link); font-size: 11px; text-decoration: none; text-underline-offset: 2px; }
.download-link:hover { color: var(--ui-link-hover); text-decoration: underline; }
@@ -1,6 +1,7 @@
"use client";
import { AlertTriangle, Box, Check, Download, Loader2, Ruler } from "lucide-react";
import { useEffect, useState } from "react";
import { encodeArtifactUrl } from "@/lib/cad-artifacts";
import type { CadError, CadImageAnalysis, CadProgress, CadResult } from "@/lib/cad-types";
@@ -34,6 +35,16 @@ export function CadResultPart({ data }: { data: CadResult }) {
["GLB", data.glbPath],
["报告", data.reportPath],
];
if (data.qualityPath) downloads.push(["质量报告", data.qualityPath]);
if (data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]);
const qualityLabels: Record<string, string> = {
accepted: "验收通过",
built_with_warnings: "构建完成,有警告",
needs_repair: "需要修复",
blocked: "已阻塞",
failed: "失败",
};
const quality = String(data.qualityStatus || "");
return (
<section className="cad-message cad-result" aria-label="CAD 结果">
<div className="cad-message-heading">
@@ -45,7 +56,12 @@ export function CadResultPart({ data }: { data: CadResult }) {
<span>{data.engine}</span>
<span>{data.revisionId}</span>
<span>{data.referenceIds.length} </span>
{quality ? <span className={`cad-quality-status quality-${quality}`}>{qualityLabels[quality] || quality}</span> : null}
</div>
{data.assumptions?.length ? <div className="cad-result-notes"><strong></strong><span>{data.assumptions.join("")}</span></div> : null}
{data.referenceIds.length ? <div className="cad-result-notes"><strong></strong><span>{data.referenceIds.join("")}</span></div> : null}
<QualitySummary data={data} />
{data.snapshotStatus && data.snapshotStatus !== "unavailable" ? <div className="cad-result-notes"><strong></strong><span>{data.snapshotStatus}</span></div> : null}
<div className="download-row">
{downloads.map(([label, path]) => (
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download aria-label={`下载 ${label}`}>
@@ -58,6 +74,44 @@ export function CadResultPart({ data }: { data: CadResult }) {
);
}
type QualityResult = { id?: string; status?: string; severity?: string; expected?: unknown; actual?: unknown };
type QualityPayload = { quality?: { results?: QualityResult[] }; quality_status?: string };
function QualitySummary({ data }: { data: CadResult }) {
const [quality, setQuality] = useState<QualityPayload | null>(null);
useEffect(() => {
if (!data.qualityPath) {
setQuality(null);
return;
}
const controller = new AbortController();
void fetch(`/api/tasks/${encodeURIComponent(data.taskId)}/quality`, { signal: controller.signal })
.then((response) => response.ok ? response.json() as Promise<QualityPayload> : null)
.then((payload) => { if (!controller.signal.aborted) setQuality(payload); })
.catch(() => { if (!controller.signal.aborted) setQuality(null); });
return () => controller.abort();
}, [data.qualityPath, data.revisionId, data.taskId]);
const results = quality?.quality?.results || [];
if (!results.length) return null;
const label: Record<string, string> = { passed: "通过", failed: "未通过", unavailable: "不可用" };
return (
<div className="cad-quality-results" aria-label="通用验证结果">
<strong></strong>
<ul>
{results.map((result, index) => (
<li key={`${result.id || "rule"}-${index}`} className={`quality-rule-${result.status || "unavailable"}`}>
<span>{result.id || "rule"}</span>
<span>{label[result.status || ""] || result.status || "不可用"}</span>
{result.severity && result.severity !== "blocking" ? <span>{result.severity}</span> : null}
</li>
))}
</ul>
</div>
);
}
export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) {
const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? [];
return (
@@ -54,6 +54,13 @@ function resultFromBackend(payload: Record<string, unknown>): CadResult {
summary: String(payload.summary || "Updated CDSL model"),
referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [],
engine: String(payload.engine || "cdsl_only"),
qualityStatus: String(payload.quality_status || ""),
qualityPath: typeof payload.quality_path === "string" ? payload.quality_path : undefined,
assumptions: Array.isArray(payload.generation_assumptions) ? payload.generation_assumptions.map(String) : [],
warnings: Array.isArray(payload.warnings) ? payload.warnings.map(String) : [],
repairAttempts: Number(payload.repair_attempts || 0),
snapshotPaths: Array.isArray(payload.snapshot_paths) ? payload.snapshot_paths.map(String) : [],
snapshotStatus: String(payload.snapshot_status || "unavailable"),
};
}
+7
View File
@@ -34,5 +34,12 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul
summary: current.summary || "CDSL CAD model",
referenceIds: current.reference_ids || [],
engine: current.engine || "cdsl_only",
qualityStatus: current.quality_status || "",
qualityPath: current.quality_path,
assumptions: current.generation_assumptions || current.assumptions || [],
warnings: current.warnings || [],
repairAttempts: current.repair_attempts || 0,
snapshotPaths: current.snapshot_manifest_path ? [current.snapshot_manifest_path] : [],
snapshotStatus: current.snapshot_status || "unavailable",
};
}
+17
View File
@@ -20,6 +20,13 @@ export type CadResult = {
summary: string;
referenceIds: string[];
engine: string;
qualityStatus?: "accepted" | "built_with_warnings" | "needs_repair" | "blocked" | "failed" | string;
qualityPath?: string;
assumptions?: string[];
warnings?: string[];
repairAttempts?: number;
snapshotPaths?: string[];
snapshotStatus?: string;
};
export type CadError = {
@@ -41,6 +48,7 @@ export type CadImageAnalysis = {
dimensionCandidates?: CadImageDimension[];
// Legacy conversations stored this field before dimensions became optional.
requiredDimensions?: CadImageDimension[];
artifactPath?: string;
};
export type CadDataParts = {
@@ -85,6 +93,14 @@ export type TaskRevision = {
reference_ids?: string[];
engine?: string;
error?: string;
quality_status?: string;
quality_path?: string;
assumptions?: string[];
generation_assumptions?: string[];
warnings?: string[];
repair_attempts?: number;
snapshot_manifest_path?: string;
snapshot_status?: string;
};
export type TaskRecord = {
@@ -104,4 +120,5 @@ export type BackendConfig = {
model: string;
configured: boolean;
library_samples: number;
max_repair_attempts?: number;
};