优化生成流程
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 [],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)},
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
+12
-4
@@ -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:
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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": []},
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user