修改agent流程,分步构建cdsl

This commit is contained in:
2026-08-27 18:59:33 +08:00
parent 7fc1dfa84d
commit b26db86d78
54 changed files with 6835 additions and 9984 deletions
+38 -242
View File
@@ -1,22 +1,18 @@
from __future__ import annotations
import asyncio
import json
import secrets
from typing import Any
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from app.models.contracts import ChatRequest, ConversationPatch, ModifyRequest, ParameterUpdate
from app.services.engine_service import QualityVerificationError, apply_parameter_updates, build_revision
from app.models.contracts import ChatRequest, ConversationPatch
from app.services.agent_service import AgentService
from app.services.library import CdslLibrary
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id, write_json
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id
from app.services.attachments import attachment_record, classify_upload, extract_document_text
from app.services.image_processing import image_metadata
from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status
from app.services.visual_review import VisualReviewError, review_checkpoint
from app.services.review_renderer import renderer_status
from app.settings import get_settings
@@ -28,96 +24,10 @@ app = FastAPI(title="CDSL CAD Agent API", version="0.1.0")
@app.on_event("startup")
async def resume_incremental_generation() -> None:
"""Restore durable generation tasks after a backend process restart."""
await agent.resume_running_tasks()
async def _finalize_controlled_revision(
*,
task_id: str,
previous_revision_id: str,
node_id: str,
built: dict[str, Any],
) -> dict[str, Any]:
"""Publish a deterministic post-completion edit only after vision review."""
revision_id = str(built["revision_id"])
try:
generation_spec = store.read_generation_spec(task_id) or {}
requirements = generation_spec.get("requirements") if isinstance(generation_spec.get("requirements"), list) else []
render_dir = store.revision_dir(task_id, revision_id) / "review"
manifest = await asyncio.to_thread(
render_checkpoint,
settings,
step_path=store.artifact_path(task_id, str(built["step_path"])),
output_dir=render_dir,
)
review = await review_checkpoint(
settings,
manifest=manifest,
requirements=requirements,
node_id=node_id,
deterministic_report={
"quality_status": built.get("quality_status"),
"verification": built.get("verification_summary", {}),
},
final_checkpoint=True,
)
manifest_path = (render_dir / "render-manifest.json").relative_to(store.task_dir(task_id)).as_posix()
review_path = (render_dir / "visual-review.json").relative_to(store.task_dir(task_id)).as_posix()
write_json(render_dir / "visual-review.json", review)
store.update_revision_metadata(task_id, revision_id, {
"render_manifest_path": manifest_path,
"visual_review_path": review_path,
})
if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85:
store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}")
store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.generation-failure.v1",
"node_id": node_id,
"stage": "visual_review",
"error_code": "HIGH_CONFIDENCE_VISUAL_REPAIR",
"message": "; ".join(review.get("evidence") or ["Visual review rejected the controlled edit"]),
"recommended_rollback_revision": previous_revision_id,
})
raise ValueError("Visual review rejected this edit; the model was rolled back to its previous revision")
store.finish_generation(task_id, lifecycle="completed")
return {**built, "visibility": "final", "lifecycle": "completed", "checkpoint": False}
except (ReviewRenderError, VisualReviewError, ValueError):
task = store.read_task(task_id) or {}
if str(task.get("lifecycle") or "") == "running":
store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}")
store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.generation-failure.v1",
"node_id": node_id,
"stage": "visual_review",
"message": "Controlled edit could not complete its required review",
"recommended_rollback_revision": previous_revision_id,
})
raise
def _require_controlled_review_configuration() -> None:
"""Fail before a post-completion edit creates an unreviewed checkpoint."""
settings.resolve_review_model()
ready, detail = renderer_status()
if not ready:
raise ValueError(detail)
def _fail_controlled_run(task_id: str, previous_revision_id: str, node_id: str, error: Exception) -> None:
task = store.read_task(task_id) or {}
if str(task.get("lifecycle") or "") != "running":
return
store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}")
store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.generation-failure.v1",
"node_id": node_id,
"stage": "controlled_build",
"error_code": type(error).__name__.upper(),
"message": str(error),
"recommended_rollback_revision": previous_revision_id,
})
async def resume_autonomous_generation() -> None:
"""Restore durable autonomous agent tasks after a backend process restart."""
if settings.resume_running_tasks_on_startup:
await agent.resume_running_tasks()
@app.get("/health")
@@ -143,7 +53,6 @@ async def config() -> dict[str, Any]:
{
"id": model.id,
"vision": model.vision,
"strict_tool_schema": model.strict_tool_schema,
}
for model in provider.models
],
@@ -161,8 +70,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,
"incremental_generation": settings.incremental_generation,
"autonomous_generation": settings.autonomous_generation,
"review_configured": not review_error,
"review_error": review_error,
}
@@ -241,11 +149,14 @@ async def read_task(task_id: str) -> JSONResponse:
raise HTTPException(status_code=400, detail=str(error)) from error
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
# Keep the task endpoint self-contained for a reconnecting UI. The plan is
# immutable within a run and exposes node status, while previews always use
# the active working revision rather than a downloadable artifact.
# Keep the task endpoint self-contained for a reconnecting UI. Requirements
# are immutable during a run; the active revision remains preview-only.
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
task["generation_plan"] = store.read_generation_spec(task["task_id"])
requirements = store.read_requirements_document(task["task_id"])
task["requirements_markdown"] = requirements or None
completion_checklist = store.read_completion_checklist(task["task_id"])
task["completion_checklist_markdown"] = completion_checklist or None
task["agent_state"] = store.read_agent_state(task["task_id"])
return JSONResponse(task)
@@ -266,143 +177,28 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
published_revision = str(task.get("published_revision") or "")
active_revision = str(task.get("active_revision") or task.get("current_revision") or "")
if isinstance(revision, dict) and revision_id != published_revision:
# Revisions are private until publication. The currently active
# checkpoint exposes only its GLB inline for the review viewer; an old
# or superseded checkpoint has no public artifact surface at all.
if revision_id != active_revision or path.name != "model.glb":
raise HTTPException(status_code=403, detail="Only the published revision is downloadable")
return FileResponse(path, media_type="model/gltf-binary", headers={"Content-Disposition": "inline"})
return FileResponse(path, filename=path.name)
if not isinstance(revision, dict):
# The task directory also contains candidate staging, agent audit and
# frozen-input files. None of those are a public artifact surface.
raise HTTPException(status_code=403, detail="This task artifact is not public")
if revision_id == published_revision:
published_paths = {
str(revision.get("cdsl_path") or ""),
str(revision.get("step_path") or ""),
str(revision.get("glb_path") or ""),
str(revision.get("report_path") or ""),
}
if artifact_path not in published_paths:
raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable")
return FileResponse(path, filename=path.name)
@app.get("/v1/tasks/{task_id}/parameters")
async def read_parameters(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)
if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""):
raise HTTPException(status_code=403, detail="Checkpoint parameters are not available until publication")
revision_id = str((task or {}).get("current_revision") or "")
revision = next((item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id), None)
relative = str((revision or {}).get("parameters_path") or "")
if not relative:
raise HTTPException(status_code=404, detail="No editable parameters exist for this task")
path = store.artifact_path(safe_id, relative)
if not path.is_file():
raise HTTPException(status_code=404, detail="Parameter contract not found")
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)
if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""):
raise HTTPException(status_code=403, detail="Checkpoint reports are not available until publication")
revision_id = str((task or {}).get("current_revision") or "")
revisions = (task or {}).get("revisions", [])
revision = next((item for item in revisions if item.get("revision_id") == revision_id), None)
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:
safe_id = safe_task_id(task_id)
task = store.read_task(safe_id)
if str((task or {}).get("lifecycle") or "") == "running":
raise ValueError("CAD task is running; parameter changes are locked")
current_revision_id = str((task or {}).get("current_revision") or "")
current_path = store.current_cdsl_path(safe_id)
if not task or not current_path or not current_revision_id:
raise ValueError("Task has no successful CDSL revision")
updated, _ = apply_parameter_updates(json.loads(current_path.read_text(encoding="utf-8")), payload.values)
if settings.incremental_generation:
_require_controlled_review_configuration()
store.start_generation(safe_id, request=f"Parameter update: {', '.join(payload.values)}")
result = build_revision(
settings=settings,
store=store,
task_id=safe_id,
request=f"Parameter update: {', '.join(payload.values)}",
cdsl=updated,
reference_ids=[],
summary="Updated CDSL parameters",
parent_revision_id=current_revision_id,
operation={"type": "parameter_update", "values": payload.values},
part_skills=None,
generation_assumptions=[],
node_id="parameter_update" if settings.incremental_generation else "",
branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main",
visibility="checkpoint" if settings.incremental_generation else "final",
)
if settings.incremental_generation:
result = await _finalize_controlled_revision(
task_id=safe_id,
previous_revision_id=current_revision_id,
node_id="parameter_update",
built=result,
)
return JSONResponse(result)
except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error:
if settings.incremental_generation and "safe_id" in locals() and "current_revision_id" in locals():
_fail_controlled_run(safe_id, current_revision_id, "parameter_update", error)
raise HTTPException(status_code=400, detail=str(error)) from error
@app.post("/v1/tasks/{task_id}/modify")
async def modify_task(task_id: str, payload: ModifyRequest) -> JSONResponse:
from app.services.editing import apply_direct_edit
try:
safe_id = safe_task_id(task_id)
task = store.read_task(safe_id) or {}
if str(task.get("lifecycle") or "") == "running":
raise ValueError("CAD task is running; topology edits are locked")
previous_revision_id = str(task.get("current_revision") or "")
if settings.incremental_generation:
_require_controlled_review_configuration()
store.start_generation(safe_id, request=f"Direct CDSL edit: {payload.operation}")
result = apply_direct_edit(
settings, store, safe_id, payload.operation, payload.selection, payload.parameters,
node_id="topology_edit" if settings.incremental_generation else "",
branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main",
visibility="checkpoint" if settings.incremental_generation else "final",
)
if settings.incremental_generation:
result = await _finalize_controlled_revision(
task_id=safe_id,
previous_revision_id=previous_revision_id,
node_id="topology_edit",
built=result,
)
return JSONResponse(result)
except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error:
if settings.incremental_generation and "safe_id" in locals() and "previous_revision_id" in locals():
_fail_controlled_run(safe_id, previous_revision_id, "topology_edit", error)
raise HTTPException(status_code=400, detail=str(error)) from error
# A running task may render its current checkpoint in the browser, but
# cannot expose any other checkpoint artifact or a failed-task preview.
if (
str(task.get("lifecycle") or "") != "running"
or revision_id != active_revision
or artifact_path != str(revision.get("glb_path") or "")
):
raise HTTPException(status_code=403, detail="Only the published revision is downloadable")
return FileResponse(path, media_type="model/gltf-binary", headers={"Content-Disposition": "inline"})
-39
View File
@@ -28,42 +28,3 @@ class ChatRequest(BaseModel):
class ConversationPatch(BaseModel):
current_task_id: str | None = None
class ParameterUpdate(BaseModel):
values: dict[str, float] = Field(default_factory=dict)
class ModifyRequest(BaseModel):
operation: str
selection: dict[str, Any] = Field(default_factory=dict)
parameters: dict[str, Any] = Field(default_factory=dict)
class TaskArtifact(BaseModel):
path: str
role: str
kind: str
class CadResult(BaseModel):
task_id: str
revision_id: str
cdsl_path: str
step_path: str
glb_path: str
report_path: str
parameters_path: str | None = None
selector_path: str | None = None
edges_path: str | None = None
topology_path: str | None = None
summary: str
reference_ids: list[str] = Field(default_factory=list)
engine: str = "cdsl_only"
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"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+425 -216
View File
@@ -1,4 +1,4 @@
"""Controlled CDSL fragments; the backend, never string concatenation, materialises a model."""
"""Append-only CDSL materialisation for the autonomous authoring loop."""
from __future__ import annotations
@@ -7,11 +7,13 @@ from hashlib import sha256
import json
from typing import Any
from app.services.generation_plan import GenerationPlanError
class CdslFragmentError(ValueError):
"""A node fragment cannot be applied safely to its declared base revision."""
"""A fragment cannot safely be applied to the current CDSL state."""
class AutonomousFragmentError(CdslFragmentError):
"""A free-form candidate violates the autonomous append-only boundary."""
def cdsl_sha256(cdsl: dict[str, Any] | None) -> str:
@@ -19,212 +21,8 @@ def cdsl_sha256(cdsl: dict[str, Any] | None) -> str:
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
def _items(value: Any, field: str) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise CdslFragmentError(f"{field} must be an array of objects")
return deepcopy(value)
def _ids(items: list[dict[str, Any]], field: str) -> list[str]:
values = [str(item.get("id") or "").strip() for item in items]
if not all(values) or len(values) != len(set(values)):
raise CdslFragmentError(f"{field} must have unique non-empty ids")
return values
def _node(plan: dict[str, Any], node_id: str) -> dict[str, Any]:
node = next((item for item in plan.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None)
if node is None:
raise CdslFragmentError(f"Fragment references an unknown node: {node_id}")
return node
def _single_output_id(node: dict[str, Any], field: str) -> str:
values = [str(item) for item in node.get(field) or () if str(item)]
if len(values) != 1:
raise CdslFragmentError(f"Node {node.get('id')} must declare exactly one {field} output")
return values[0]
def _node_feature_id(plan: dict[str, Any], node_id: str) -> str:
return _single_output_id(_node(plan, node_id), "cdsl_feature_ids")
def _materialize_node_references(value: Any, plan: dict[str, Any]) -> Any:
"""Translate fragment-only node references into CDSL feature references."""
if isinstance(value, list):
return [_materialize_node_references(item, plan) for item in value]
if not isinstance(value, dict):
return value
materialized = {key: _materialize_node_references(item, plan) for key, item in value.items()}
owner_node_id = materialized.pop("owner_node_id", None)
if owner_node_id is not None:
if not isinstance(owner_node_id, str) or not owner_node_id.strip():
raise CdslFragmentError("owner_node_id must be a non-empty plan node id")
materialized["owner_feature_id"] = _node_feature_id(plan, owner_node_id.strip())
source_node_ids = materialized.pop("source_node_ids", None)
if source_node_ids is not None:
if not isinstance(source_node_ids, list) or not source_node_ids or not all(isinstance(item, str) and item.strip() for item in source_node_ids):
raise CdslFragmentError("source_node_ids must be a non-empty array of plan node ids")
materialized["source_feature_ids"] = [_node_feature_id(plan, item.strip()) for item in source_node_ids]
return materialized
def _selector_references(value: Any) -> list[dict[str, Any]]:
"""Collect selector-shaped objects from feature selectors and params."""
found: list[dict[str, Any]] = []
if isinstance(value, dict):
if "kind" in value and ("stable_id" in value or "snapshot_id" in value or "owner_feature_id" in value or "owner_node_id" in value):
found.append(value)
for child in value.values():
found.extend(_selector_references(child))
elif isinstance(value, list):
for child in value:
found.extend(_selector_references(child))
return found
def validate_fragment(
fragment: dict[str, Any],
*,
plan: dict[str, Any],
node_id: str,
base_revision_id: str,
base_cdsl: dict[str, Any] | None,
required_snapshot_id: str = "",
) -> dict[str, Any]:
if not isinstance(fragment, dict):
raise CdslFragmentError("CDSL fragment must be an object")
version = str(fragment.get("schema_version") or "cad.cdsl-fragment.v1")
if version != "cad.cdsl-fragment.v1":
raise CdslFragmentError(f"Unsupported CDSL fragment schema: {version}")
declared_node_id = str(fragment.get("node_id") or "")
if declared_node_id != node_id:
raise CdslFragmentError("Fragment node_id does not match the active node")
if str(fragment.get("base_revision_id") or "") != base_revision_id:
raise CdslFragmentError("Fragment base_revision_id does not match the active revision")
if str(fragment.get("base_cdsl_sha256") or "") != cdsl_sha256(base_cdsl):
raise CdslFragmentError("Fragment base_cdsl_sha256 does not match the active CDSL")
snapshot_id = str(fragment.get("required_snapshot_id") or "")
if required_snapshot_id and snapshot_id != required_snapshot_id:
raise CdslFragmentError("Fragment required_snapshot_id does not match the active topology snapshot")
if not required_snapshot_id and snapshot_id:
raise CdslFragmentError("Fragment cannot use a topology snapshot before one exists")
sketches = _items(fragment.get("add_sketches"), "add_sketches")
features = _items(fragment.get("add_features"), "add_features")
node = _node(plan, node_id)
expected = {str(item) for item in node.get("cdsl_feature_ids") or ()}
expected_sketches = {str(item) for item in node.get("cdsl_sketch_ids") or ()}
if len(expected) != 1:
# Compatibility path for pre-incremental plans which could own more
# than one CDSL feature in a single node.
feature_ids = _ids(features, "add_features")
if set(feature_ids) != expected:
raise CdslFragmentError(
f"Fragment features must exactly match node outputs: expected {sorted(expected)}, got {sorted(feature_ids)}"
)
elif len(features) != 1:
raise CdslFragmentError("An atomic plan node must generate exactly one feature")
if len(expected_sketches) > 1:
sketch_ids = _ids(sketches, "add_sketches")
if set(sketch_ids) != expected_sketches:
raise CdslFragmentError(
f"Fragment sketches must exactly match node outputs: expected {sorted(expected_sketches)}, got {sorted(sketch_ids)}"
)
elif len(sketches) != len(expected_sketches):
expected_description = "one" if expected_sketches else "no"
raise CdslFragmentError(f"This node requires {expected_description} new sketch")
# The planner and fragment author never own CDSL object identities. The
# merger writes node-derived IDs and dependencies after it has validated
# the current immutable plan.
if len(expected) == 1:
features[0]["id"] = next(iter(expected))
if len(expected_sketches) == 1:
sketch_id = next(iter(expected_sketches))
sketches[0]["id"] = sketch_id
features[0]["sketch_id"] = sketch_id
elif not expected_sketches and len(features) == 1:
features[0].pop("sketch_id", None)
features = _materialize_node_references(features, plan)
sketches = _materialize_node_references(sketches, plan)
sketch_ids = _ids(sketches, "add_sketches")
feature_ids = _ids(features, "add_features")
atomic_id = str(node.get("atomic_id") or "")
if len(expected) == 1:
features[0]["atomic_id"] = atomic_id
elif any(str(feature.get("atomic_id") or "") != atomic_id for feature in features):
raise CdslFragmentError(f"Fragment features must use plan atomic_id {atomic_id}")
base_sketch_ids = {
str(item.get("id")) for item in ((base_cdsl or {}).get("geometry") or {}).get("sketches") or ()
if isinstance(item, dict)
}
base_feature_ids = {
str(item.get("id")) for item in (base_cdsl or {}).get("features") or () if isinstance(item, dict)
}
if base_sketch_ids & set(sketch_ids):
raise CdslFragmentError("Fragment attempts to overwrite an existing sketch")
if base_feature_ids & set(feature_ids):
raise CdslFragmentError("Fragment attempts to overwrite a frozen feature")
predecessor_features = {
feature_id
for dependency in node.get("depends_on") or ()
for feature_id in (_node(plan, str(dependency)).get("cdsl_feature_ids") or ())
}
if len(expected) == 1:
features[0]["depends_on"] = sorted(predecessor_features)
available_features = base_feature_ids | set(feature_ids)
available_sketches = base_sketch_ids | set(sketch_ids)
for feature in features:
feature_id = str(feature["id"])
dependencies = feature.get("depends_on") or []
if not isinstance(dependencies, list) or any(str(item) not in available_features for item in dependencies):
raise CdslFragmentError(f"Feature {feature_id} has a missing dependency")
sketch_id = str(feature.get("sketch_id") or "")
if sketch_id and sketch_id not in available_sketches:
raise CdslFragmentError(f"Feature {feature_id} references an unknown sketch")
if required_snapshot_id:
selectors = [selector for feature in features for selector in _selector_references(feature)]
if not selectors:
raise CdslFragmentError("Topology-dependent fragment must provide runtime snapshot selectors")
for selector in selectors:
if str(selector.get("snapshot_id") or "") != required_snapshot_id:
raise CdslFragmentError("Topology selector must reference the active snapshot")
if not str(selector.get("owner_feature_id") or ""):
raise CdslFragmentError("Topology selector must declare owner_feature_id")
if not isinstance(selector.get("geometry"), dict) or not selector["geometry"]:
raise CdslFragmentError("Topology selector must declare a non-empty geometry signature")
declared_dependencies = {
str(dependency)
for feature in features
for dependency in feature.get("depends_on") or ()
}
if not predecessor_features.issubset(declared_dependencies):
raise CdslFragmentError("Fragment does not preserve all plan-node dependencies")
rules = fragment.get("verification_rules") or []
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
raise CdslFragmentError("verification_rules must be an array of objects")
assumptions = fragment.get("assumptions") or []
if not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions):
raise CdslFragmentError("assumptions must be an array of strings")
return {
"schema_version": "cad.cdsl-fragment.v1",
"node_id": node_id,
"base_revision_id": base_revision_id,
"base_cdsl_sha256": cdsl_sha256(base_cdsl),
"required_snapshot_id": snapshot_id,
"add_sketches": sketches,
"add_features": features,
"expected_feature_ids": sorted(expected),
"verification_rules": deepcopy(rules),
"assumptions": [item.strip() for item in assumptions if item.strip()],
}
def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]:
"""Return the complete, append-only document that the runtime must rebuild."""
"""Create the complete CDSL document that will be rebuilt from scratch."""
if base_cdsl is None:
document: dict[str, Any] = {
"schema": "cad.cdsl.llm.v1",
@@ -237,25 +35,22 @@ def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, A
else:
document = deepcopy(base_cdsl)
geometry = document.setdefault("geometry", {})
if not isinstance(geometry, dict):
raise CdslFragmentError("Base CDSL geometry must be an object")
sketches = geometry.setdefault("sketches", [])
sketches = geometry.setdefault("sketches", []) if isinstance(geometry, dict) else None
features = document.setdefault("features", [])
if not isinstance(sketches, list) or not isinstance(features, list):
raise CdslFragmentError("Base CDSL has invalid collections")
raise CdslFragmentError("Base CDSL has invalid geometry collections")
sketches.extend(deepcopy(fragment["add_sketches"]))
features.extend(deepcopy(fragment["add_features"]))
return document
def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_id: str) -> dict[str, Any]:
"""Persist the runtime's actual selector choices as auditable node evidence."""
"""Persist runtime selector choices as build evidence."""
values = []
for resolution in engine_result.get("selector_resolution") or ():
if not isinstance(resolution, dict):
continue
selector = resolution.get("selector") if isinstance(resolution.get("selector"), dict) else {}
candidates = resolution.get("candidates") if isinstance(resolution.get("candidates"), (list, tuple)) else []
values.append({
"consumer_node_id": node_id,
"consumer_feature_id": str(resolution.get("feature_id") or ""),
@@ -265,8 +60,422 @@ def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_i
"stable_id": str(selector.get("stable_id") or ""),
"geometry": deepcopy(selector.get("geometry") or {}),
"status": str(resolution.get("status") or ""),
"candidates": deepcopy(list(candidates)),
"candidates": deepcopy(list(resolution.get("candidates") or [])),
"score": resolution.get("score"),
"selected": deepcopy(resolution.get("selected") or resolution.get("record") or {}),
})
return {"schema_version": "cad.selector-bindings.v1", "node_id": node_id, "bindings": values}
def _autonomous_id(prefix: str, used: set[str]) -> str:
index = 1
while True:
candidate = f"{prefix}_{index:03d}"
if candidate not in used:
used.add(candidate)
return candidate
index += 1
def autonomous_selector_tokens(snapshot: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
"""Make opaque, revision-scoped selector tokens from executable topology."""
if not isinstance(snapshot, dict):
return {}
snapshot_id = str(snapshot.get("snapshot_id") or "")
if not snapshot_id:
return {}
values: dict[str, dict[str, Any]] = {}
for record in snapshot.get("records") or ():
if not isinstance(record, dict) or not record.get("executable"):
continue
record_id = str(record.get("record_id") or "")
kind = str(record.get("kind") or "")
if not record_id or kind not in {"face", "edge", "vertex", "plane", "axis", "body"}:
continue
token = "sel_" + sha256(f"{snapshot_id}|{record_id}".encode("utf-8")).hexdigest()[:16]
geometry = deepcopy(record.get("geometry") or {})
owners = record.get("owner_feature_ids") or [record.get("feature_id") or ""]
values[token] = {
"token": token,
"kind": kind,
"geometry": geometry,
"selector": {
"kind": kind,
"stable_id": record_id,
"owner_feature_id": str(owners[0] or ""),
"geometry": geometry,
"source": "runtime_snapshot",
"snapshot_id": snapshot_id,
"confidence": 1.0,
},
}
return values
def _compact_prompt_geometry(geometry: dict[str, Any]) -> dict[str, Any]:
"""Keep only selector-choice facts useful to an author model.
Runtime snapshots also carry adjacency signatures, curve endpoints and
other diagnostic detail. Those fields are required for deterministic
engine work, but repeatedly placing them in an LLM prompt is expensive
and does not help choose an opaque token. Full records remain available
on disk and through targeted measurement tools.
"""
useful = (
"bbox_mm", "center_mm", "normal", "plane_normal", "plane_offset_mm",
"surface_type", "curve_type", "radius_mm", "length_mm", "area_mm2",
"volume_mm3", "solid_count",
)
return {key: deepcopy(geometry[key]) for key in useful if key in geometry}
def autonomous_candidate_prompt_tokens(tokens: dict[str, dict[str, Any]], *, kind: str = "") -> list[dict[str, Any]]:
"""Return compact safe token fields, never persistent selector IDs."""
return [
{"token": token, "kind": value["kind"], "geometry": _compact_prompt_geometry(value["geometry"])}
for token, value in sorted(tokens.items())
if not kind or value["kind"] == kind
]
def _fragment_lists(fragment: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
if not isinstance(fragment, dict):
raise AutonomousFragmentError("fragment_json must decode to a JSON object")
# Ordinary tool-call providers occasionally lift the unambiguous selector
# array one level out of a single-feature payload. Accept that shorthand
# only when it can be moved to exactly one feature without choosing or
# changing any selector ourselves.
allowed = {"sketch", "feature", "sketches", "features", "add_sketches", "add_features", "selector_tokens", "revolve_axis"}
unknown = sorted(set(fragment) - allowed)
if unknown:
raise AutonomousFragmentError("fragment_json may contain only sketch(es) and feature(s); unexpected: " + ", ".join(unknown))
sketches_raw = fragment.get("sketches", fragment.get("add_sketches", []))
features_raw = fragment.get("features", fragment.get("add_features", []))
if "sketch" in fragment:
if sketches_raw:
raise AutonomousFragmentError("Use either sketch or sketches, not both")
sketches_raw = [fragment["sketch"]]
if "feature" in fragment:
if features_raw:
raise AutonomousFragmentError("Use either feature or features, not both")
features_raw = [fragment["feature"]]
if not isinstance(sketches_raw, list) or not all(isinstance(item, dict) for item in sketches_raw):
raise AutonomousFragmentError("fragment sketches must be an array of objects")
if not isinstance(features_raw, list) or not features_raw or not all(isinstance(item, dict) for item in features_raw):
raise AutonomousFragmentError("fragment features must be a non-empty array of objects")
sketches = deepcopy(sketches_raw)
features = deepcopy(features_raw)
top_level_tokens = fragment.get("selector_tokens")
if top_level_tokens is not None:
if len(features) != 1:
raise AutonomousFragmentError("top-level selector_tokens are allowed only with exactly one feature")
if "selector_tokens" in features[0]:
raise AutonomousFragmentError("selector_tokens must appear either at fragment top level or feature level, not both")
features[0]["selector_tokens"] = deepcopy(top_level_tokens)
return sketches, features
def _move_equivalent_field(
value: dict[str, Any],
*,
source: str,
target: str,
location: str,
fixes: list[dict[str, str]],
) -> None:
"""Move a lossless spelling alias without choosing any CAD geometry."""
if source not in value:
return
if target in value:
if value[source] != value[target]:
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: both {source} and {target} were supplied with different values"
)
value.pop(source)
fixes.append({"path": location, "from": source, "to": target, "action": "deduplicated_equivalent"})
return
value[target] = value.pop(source)
fixes.append({"path": location, "from": source, "to": target, "action": "renamed_equivalent"})
def _move_axis_component(
params: dict[str, Any],
axis: dict[str, Any],
*,
source: str,
target: str,
location: str,
fixes: list[dict[str, str]],
) -> None:
"""Move an explicit top-level axis alias into the canonical axis object."""
if source not in params:
return
value = params.pop(source)
if target in axis and axis[target] != value:
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: both {source} and axis.{target} were supplied with different values"
)
if target in axis:
fixes.append({"path": location, "from": source, "to": f"axis.{target}", "action": "deduplicated_equivalent"})
return
axis[target] = value
fixes.append({"path": location, "from": source, "to": f"axis.{target}", "action": "renamed_equivalent"})
def _normalize_axis_mapping(axis: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
"""Normalize only lossless aliases used inside an already explicit axis."""
for source in ("origin", "point_mm", "axis_origin_mm", "axis_point_mm"):
_move_equivalent_field(axis, source=source, target="origin_mm", location=location, fixes=fixes)
for source in ("axis_dir", "axis_direction", "dir"):
_move_equivalent_field(axis, source=source, target="direction", location=location, fixes=fixes)
def _lift_feature_local_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None:
"""Accept the common one-feature/one-sketch nesting without choosing geometry.
The public fragment grammar owns one ordered sketch list and one ordered
feature list. Some tool-call models naturally nest each sketch below its
feature. That representation is losslessly transformable only when every
feature supplies exactly one sketch and no root sketch collection exists.
"""
if any(key in fragment for key in ("sketch", "sketches", "add_sketches")):
return
features = fragment.get("features", fragment.get("add_features"))
if not isinstance(features, list) or not features or not all(isinstance(item, dict) for item in features):
return
lifted: list[dict[str, Any]] = []
for index, feature in enumerate(features):
nested = feature.get("sketches", feature.get("sketch"))
if isinstance(nested, dict):
sketches = [nested]
elif isinstance(nested, list):
sketches = nested
else:
return
if len(sketches) != 1 or not isinstance(sketches[0], dict):
return
feature.pop("sketch", None)
feature.pop("sketches", None)
lifted.append(sketches[0])
fixes.append({"path": f"features[{index}]", "from": "feature-local sketch", "to": "sketches[]", "action": "lifted_equivalent"})
fragment["sketches"] = lifted
def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]]]:
"""Normalize only explicitly equivalent author spellings.
Normalization is deliberately narrow. It accepts common CAD vocabulary
where the target runtime field has identical units and meaning, while
refusing inputs that would need a guessed profile, direction, selector,
coordinate system, or topology decision. The original fragment and every
applied fix are retained in the candidate audit record.
"""
if not isinstance(fragment, dict):
return fragment, []
normalized = deepcopy(fragment)
fixes: list[dict[str, str]] = []
_lift_feature_local_sketches(normalized, fixes=fixes)
feature_values: list[tuple[dict[str, Any], str]] = []
feature = normalized.get("feature")
if isinstance(feature, dict):
feature_values.append((feature, "feature"))
features = normalized.get("features", normalized.get("add_features"))
if isinstance(features, list):
feature_values.extend(
(item, f"features[{index}]")
for index, item in enumerate(features)
if isinstance(item, dict)
)
for current_feature, location in feature_values:
atomic_id = str(current_feature.get("atomic_id") or "")
params = current_feature.get("params")
if not isinstance(params, dict):
continue
if atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"}:
# CAD systems commonly call a blind extrusion's travel "depth".
# CDSL calls the exact same signed scalar ``distance_mm``.
_move_equivalent_field(
params,
source="depth_mm",
target="distance_mm",
location=f"{location}.params",
fixes=fixes,
)
if atomic_id in {"revolve_add", "revolve_cut"}:
_move_equivalent_field(
params,
source="angle_degrees",
target="angle_deg",
location=f"{location}.params",
fixes=fixes,
)
axis = params.get("axis")
if axis is None:
axis = {}
params["axis"] = axis
if isinstance(axis, dict):
_move_axis_component(params, axis, source="axis_origin_mm", target="origin_mm", location=f"{location}.params", fixes=fixes)
_move_axis_component(params, axis, source="axis_point_mm", target="origin_mm", location=f"{location}.params", fixes=fixes)
_move_axis_component(params, axis, source="axis_dir", target="direction", location=f"{location}.params", fixes=fixes)
_move_axis_component(params, axis, source="axis_direction", target="direction", location=f"{location}.params", fixes=fixes)
axis = params.get("axis")
if isinstance(axis, dict):
_normalize_axis_mapping(axis, location=f"{location}.params.axis", fixes=fixes)
shared_axis = normalized.get("revolve_axis")
if shared_axis is not None:
if not isinstance(shared_axis, dict):
raise AutonomousFragmentError("revolve_axis must be an explicit {origin_mm, direction} object")
_normalize_axis_mapping(shared_axis, location="revolve_axis", fixes=fixes)
revolved = [feature for feature, _ in feature_values if str(feature.get("atomic_id") or "").startswith("revolve_")]
if not revolved:
raise AutonomousFragmentError("revolve_axis is valid only in a fragment containing revolve_add or revolve_cut")
for feature, location in feature_values:
if not str(feature.get("atomic_id") or "").startswith("revolve_"):
continue
params = feature.get("params")
if not isinstance(params, dict):
continue
axis = params.get("axis")
if not isinstance(axis, dict) or not axis:
params["axis"] = deepcopy(shared_axis)
fixes.append({"path": f"{location}.params", "from": "revolve_axis", "to": "axis", "action": "copied_explicit_batch_axis"})
sketch_values: list[tuple[dict[str, Any], str]] = []
sketch = normalized.get("sketch")
if isinstance(sketch, dict):
sketch_values.append((sketch, "sketch"))
sketches = normalized.get("sketches", normalized.get("add_sketches"))
if isinstance(sketches, list):
sketch_values.extend((item, f"sketches[{index}]") for index, item in enumerate(sketches) if isinstance(item, dict))
for current_sketch, location in sketch_values:
profile = current_sketch.get("profile")
if isinstance(profile, dict) and profile.get("type") == "polygon":
_move_equivalent_field(profile, source="points", target="vertices", location=f"{location}.profile", fixes=fixes)
return normalized, fixes
def materialize_autonomous_fragment(
base_cdsl: dict[str, Any] | None,
fragment: dict[str, Any],
*,
engine: Any,
selector_tokens: dict[str, dict[str, Any]],
max_features: int,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Append authored geometry while assigning only server-owned metadata.
Workplanes, profiles, feature parameters, directions and boolean meaning
pass through exactly as the author supplied them. Local CDSL validation and
full engine rebuild happen after this function, before a checkpoint exists.
"""
# Delayed import avoids engine_service -> selector_bindings -> this module
# becoming an import cycle.
from app.services.engine_service import feature_atomic_contract
normalized_fragment, compatibility_fixes = normalize_autonomous_fragment(fragment)
sketches, features = _fragment_lists(normalized_fragment)
if len(features) > max_features:
raise AutonomousFragmentError(f"A fragment may add at most {max_features} feature(s)")
document = materialize_fragment(base_cdsl, {"add_sketches": [], "add_features": []})
geometry = document.get("geometry") if isinstance(document.get("geometry"), dict) else {}
existing_sketches = geometry.get("sketches") if isinstance(geometry.get("sketches"), list) else []
existing_features = document.get("features") if isinstance(document.get("features"), list) else []
used_sketch_ids = {str(item.get("id") or "") for item in existing_sketches if isinstance(item, dict)}
used_feature_ids = {str(item.get("id") or "") for item in existing_features if isinstance(item, dict)}
last_feature_id = str(existing_features[-1].get("id") or "") if existing_features and isinstance(existing_features[-1], dict) else ""
materialized_sketches: list[dict[str, Any]] = []
materialized_features: list[dict[str, Any]] = []
for index, source_feature in enumerate(features):
forbidden = {"id", "depends_on", "sketch_id", "selectors"} & set(source_feature)
if forbidden:
raise AutonomousFragmentError("Feature identity, dependencies, sketch_id and raw selectors are server-owned: " + ", ".join(sorted(forbidden)))
atomic_id = str(source_feature.get("atomic_id") or "")
if not atomic_id:
raise AutonomousFragmentError("Each fragment feature must declare a runtime atomic_id")
contract = feature_atomic_contract(engine, atomic_id)
params = source_feature.get("params")
if not isinstance(params, dict):
raise AutonomousFragmentError("Each fragment feature must contain a params object")
if (atomic_id.startswith("hole_") or atomic_id == "hole_wizard") and "host_face" in params:
raise AutonomousFragmentError(
f"{atomic_id} host_face is server-owned: put exactly one face token in selector_tokens and use "
"positions as [{\"mm\":[x_mm,y_mm,z_mm]}], not raw host_face or bare coordinate arrays"
)
tokens = source_feature.pop("selector_tokens", [])
if not isinstance(tokens, list) or not all(isinstance(token, str) for token in tokens) or len(set(tokens)) != len(tokens):
raise AutonomousFragmentError("selector_tokens must be a unique array of opaque tokens")
selected: list[dict[str, Any]] = []
for token in tokens:
candidate = selector_tokens.get(token)
if candidate is None:
raise AutonomousFragmentError("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
selected.append(deepcopy(candidate["selector"]))
slot = contract.get("selector_slot")
token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard"
if not slot and tokens and not token_backed_param:
raise AutonomousFragmentError(f"{atomic_id} does not accept selector tokens")
if isinstance(slot, dict):
minimum, maximum = int(slot.get("min_items") or 0), int(slot.get("max_items") or 0)
if not minimum <= len(selected) <= maximum:
raise AutonomousFragmentError(f"{atomic_id} requires {minimum}..{maximum} selector token(s)")
elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
if len(selected) != 1 or str(selected[0].get("kind") or "") != "face":
raise AutonomousFragmentError(f"{atomic_id} requires exactly one face selector token for its host face")
elif atomic_id.startswith("revolve_"):
axis = params.get("axis")
if not isinstance(axis, dict) or "origin_mm" not in axis or "direction" not in axis:
raise AutonomousFragmentError(
f"{atomic_id} requires params.axis with explicit origin_mm and direction; "
"the revolve axis is author-defined geometry, not a topology selector token"
)
feature_id = _autonomous_id("feature", used_feature_ids)
output = {key: deepcopy(value) for key, value in source_feature.items() if key != "selector_tokens"}
output["id"] = feature_id
output["depends_on"] = [last_feature_id] if last_feature_id else []
if contract["requires_sketch"]:
if index >= len(sketches):
raise AutonomousFragmentError(f"{atomic_id} requires one new sketch in the same fragment")
sketch = sketches[index]
if "id" in sketch or "attachment" in sketch or "profile_from" in sketch:
raise AutonomousFragmentError("Sketch identity and topology attachment are server-owned")
sketch_id = _autonomous_id("sketch", used_sketch_ids)
sketch["id"] = sketch_id
materialized_sketches.append(sketch)
output["sketch_id"] = sketch_id
elif index < len(sketches):
raise AutonomousFragmentError(f"{atomic_id} does not accept a sketch")
if atomic_id.startswith("pattern_") and "source_feature_ids" not in params:
if not last_feature_id:
raise AutonomousFragmentError(f"{atomic_id} needs a committed source feature")
params["source_feature_ids"] = [last_feature_id]
if isinstance(slot, dict) and slot.get("path") == "feature.selectors":
output["selectors"] = selected
elif isinstance(slot, dict) and slot.get("path") == "params.mirror_plane":
output["params"]["mirror_plane"] = selected[0]
output["selectors"] = []
elif isinstance(slot, dict) and slot.get("path") == "params.host_face":
output["params"]["host_face"] = selected[0]
output["selectors"] = []
elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
output["params"]["host_face"] = selected[0]
output["selectors"] = []
else:
output["selectors"] = []
materialized_features.append(output)
last_feature_id = feature_id
if len(sketches) != len(materialized_sketches):
raise AutonomousFragmentError("Each sketch must be consumed by a feature that requires a sketch")
document = materialize_fragment(document, {"add_sketches": materialized_sketches, "add_features": materialized_features})
return document, {
"schema_version": "cad.autonomous-fragment.v1",
"source_fragment": deepcopy(fragment),
"normalized_fragment": deepcopy(normalized_fragment) if compatibility_fixes else None,
"compatibility_fixes": compatibility_fixes,
"assigned_sketch_ids": [item["id"] for item in materialized_sketches],
"assigned_feature_ids": [item["id"] for item in materialized_features],
"selector_candidate_ids": [token for feature in features for token in feature.get("selector_tokens", [])],
}
-122
View File
@@ -1,122 +0,0 @@
"""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
-223
View File
@@ -1,223 +0,0 @@
from __future__ import annotations
import copy
import json
import math
from typing import Any
from app.services.engine_service import build_revision, load_engine
from app.services.storage import WorkspaceStore
from app.settings import Settings
SUPPORTED_OPERATIONS = {
"add_hole", "add_counterbore", "add_countersink", "add_slot",
"add_pocket", "add_circular_pocket", "add_hole_pattern",
}
def _number(values: dict[str, Any], name: str, fallback: float, minimum: float = 0.01) -> float:
value = float(values.get(name, fallback))
if not math.isfinite(value) or value < minimum:
raise ValueError(f"{name} must be a finite number >= {minimum}")
return value
def _selection_frame(selection: dict[str, Any]) -> dict[str, list[float]]:
pick = selection.get("pick") if isinstance(selection.get("pick"), dict) else selection
surface = pick.get("surface") if isinstance(pick.get("surface"), dict) else {}
surface_type = str(surface.get("type") or surface.get("surfaceType") or "").lower()
if surface_type and "plane" not in surface_type:
raise ValueError("Direct CDSL edits currently require a planar face")
frame = pick.get("frame") if isinstance(pick.get("frame"), dict) else {}
origin = frame.get("origin_mm") or pick.get("center") or pick.get("point")
normal = frame.get("normal") or pick.get("normal")
x_dir = frame.get("x_dir") or frame.get("xDir") or [1.0, 0.0, 0.0]
y_dir = frame.get("y_dir") or frame.get("yDir") or [0.0, 1.0, 0.0]
if not all(isinstance(value, list) and len(value) >= 3 for value in (origin, normal, x_dir, y_dir)):
raise ValueError("Select a planar face before applying a direct CDSL edit")
return {
"origin_mm": [float(item) for item in origin[:3]],
"normal": [float(item) for item in normal[:3]],
"x_dir": [float(item) for item in x_dir[:3]],
"y_dir": [float(item) for item in y_dir[:3]],
}
def _next_id(prefix: str, existing: set[str]) -> str:
index = 1
while f"{prefix}_{index:03d}" in existing:
index += 1
return f"{prefix}_{index:03d}"
def _profile_for(operation: str, values: dict[str, Any]) -> tuple[dict[str, Any], float]:
depth = _through_depth(values)
if operation in {"add_hole", "add_counterbore", "add_countersink", "add_circular_pocket"}:
diameter = _number(values, "holeDiameter", values.get("diameter", 10.0))
return {"type": "circle", "center": [0.0, 0.0], "radius_mm": diameter / 2}, depth
if operation == "add_slot":
width = _number(values, "slotWidth", values.get("width", 8.0))
length = _number(values, "slotLength", values.get("length", width * 3))
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": "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)))
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}")
def _through_depth(values: dict[str, Any]) -> float:
# A through cut deliberately exceeds the model bounds. build123d clips the
# cutter against the solid, so this remains deterministic for any part size.
return 10000.0 if str(values.get("depth") or "").lower() == "through" else _number(values, "depth", 10.0)
def _hole_feature(operation: str, frame: dict[str, list[float]], values: dict[str, Any]) -> dict[str, Any]:
diameter = _number(values, "holeDiameter", values.get("diameter", 10.0))
params: dict[str, Any] = {
"diameter_mm": diameter,
"depth_mm": _through_depth(values),
"positions": [{"mm": [0.0, 0.0, 0.0]}],
"host_face": {"frame": frame},
}
atomic = "hole_blind"
if operation == "add_counterbore":
counterbore_diameter = _number(values, "counterboreDiameter", diameter * 2)
if counterbore_diameter <= diameter:
raise ValueError("counterboreDiameter must be larger than holeDiameter")
params["counterbore_diameter_mm"] = counterbore_diameter
params["counterbore_depth_mm"] = _number(values, "counterboreDepth", min(diameter, 2.0))
atomic = "hole_counterbore"
elif operation == "add_countersink":
countersink_diameter = _number(values, "countersinkDiameter", diameter * 2)
if countersink_diameter <= diameter:
raise ValueError("countersinkDiameter must be larger than holeDiameter")
params["countersink_diameter_mm"] = countersink_diameter
params["countersink_angle_rad"] = math.radians(_number(values, "countersinkAngleDeg", 90.0, 1.0))
atomic = "hole_countersink"
return {"atomic": atomic, "params": params}
def _slot_frame(frame: dict[str, list[float]], picks: list[dict[str, Any]]) -> tuple[dict[str, list[float]], float]:
if len(picks) < 2:
raise ValueError("Select the two endpoints for the slot")
first = _selection_frame({"pick": picks[0]})
second = _selection_frame({"pick": picks[1]})
vector = [second["origin_mm"][index] - first["origin_mm"][index] for index in range(3)]
length = math.sqrt(sum(value * value for value in vector))
if length < 0.01:
raise ValueError("Slot endpoints must be distinct")
x_dir = [value / length for value in vector]
normal = first["normal"]
y_dir = [
normal[1] * x_dir[2] - normal[2] * x_dir[1],
normal[2] * x_dir[0] - normal[0] * x_dir[2],
normal[0] * x_dir[1] - normal[1] * x_dir[0],
]
midpoint = [(first["origin_mm"][index] + second["origin_mm"][index]) / 2 for index in range(3)]
return {"origin_mm": midpoint, "normal": normal, "x_dir": x_dir, "y_dir": y_dir}, length
def apply_direct_edit(
settings: Settings,
store: WorkspaceStore,
task_id: str,
operation: str,
selection: dict[str, Any],
parameters: dict[str, Any],
*,
node_id: str = "",
branch_id: str = "main",
visibility: str = "final",
) -> dict[str, Any]:
if operation in {"add_chamfer", "add_fillet"}:
raise ValueError("Chamfer and fillet require a stable CDSL edge anchor and are not available for this model yet")
if operation not in SUPPORTED_OPERATIONS:
raise ValueError(f"Unsupported direct CDSL edit: {operation}")
source = store.current_cdsl_path(task_id)
task = store.read_task(task_id)
revision_id = str((task or {}).get("current_revision") or "")
if source is None or not revision_id:
raise ValueError("Task has no successful CDSL revision")
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 []
if operation == "add_slot":
frame, slot_length = _slot_frame(frame, [pick for pick in picks if isinstance(pick, dict)])
parameters = {**parameters, "slotLength": slot_length}
profile, depth = _profile_for(operation, parameters)
feature_id = _next_id("edit", {str(item.get("id")) for item in features})
sketch_id = _next_id("edit_sketch", {str(item.get("id")) for item in sketches})
dependency = str(features[-1].get("id")) if features else ""
sketches.append({"id": sketch_id, "name": operation, "workplane": frame, "profile": profile})
feature: dict[str, Any] = {
"id": feature_id,
"depends_on": [dependency] if dependency else [],
"name": operation,
"sketch_id": sketch_id,
}
if operation in {"add_hole", "add_counterbore", "add_countersink"}:
hole = _hole_feature(operation, frame, parameters)
feature["atomic_id"] = hole["atomic"]
feature["params"] = hole["params"]
else:
feature["atomic_id"] = "extrude_cut_blind"
feature["params"] = {"distance_mm": depth}
features.append(feature)
return build_revision(
settings=settings,
store=store,
task_id=task_id,
request=f"Direct CDSL edit: {operation}",
cdsl=cdsl,
reference_ids=[],
summary=f"Applied {operation}",
parent_revision_id=revision_id,
operation={
"type": operation,
"selection": selection,
"parameters": parameters,
"legacy_profiles_lowered": legacy_profiles_lowered,
},
part_skills=None,
generation_assumptions=["Legacy profile macros were lowered to direct analytic contours before this edit."] if legacy_profiles_lowered else [],
node_id=node_id,
branch_id=branch_id,
visibility=visibility,
)
+64 -371
View File
@@ -1,10 +1,10 @@
from __future__ import annotations
import copy
from functools import lru_cache
import json
import math
import re
import shutil
import sys
from pathlib import Path
from typing import Any
@@ -13,22 +13,9 @@ 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.services.cdsl_fragment import selector_bindings
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:
@@ -50,8 +37,11 @@ def _walk(value: Any) -> list[tuple[str, Any]]:
return result
def _engine_schema(engine: Any) -> dict[str, Any]:
schema_path = Path(str(engine.__file__)).with_name("profile_schema.json")
@lru_cache(maxsize=16)
def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]:
"""Load an immutable runtime schema once per on-disk version."""
del modified_ns # The mtime is intentionally part of the cache key.
schema_path = Path(path_value)
try:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
@@ -61,12 +51,63 @@ def _engine_schema(engine: Any) -> dict[str, Any]:
return schema
def load_cdsl_json_schema(engine: Any) -> dict[str, Any]:
def _engine_schema(engine: Any) -> dict[str, Any]:
schema_path = Path(str(engine.__file__)).with_name("profile_schema.json")
try:
modified_ns = schema_path.stat().st_mtime_ns
except OSError as error:
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
return _read_schema_document(str(schema_path), modified_ns)
def feature_atomic_contract(engine: Any, atomic_id: str) -> dict[str, Any]:
"""Return the runtime-supported parameter contract for one atomic feature."""
schema = _engine_schema(engine)
normalized_id = str(atomic_id or "").strip()
contracts = schema["feature_atomic_ids"]
contract = contracts.get(normalized_id)
registered = {str(item) for item in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())}
declared = {str(item) for item in schema.get("runtime_supported_atomic_ids") or ()}
if normalized_id not in registered or normalized_id not in declared or not isinstance(contract, dict):
raise ValueError(f"Unsupported runtime atomic_id: {normalized_id}")
required = contract.get("required_params") or []
optional = contract.get("optional_params") or []
if not all(isinstance(item, str) and item for item in [*required, *optional]):
raise RuntimeError(f"Runtime feature contract is invalid for {normalized_id}")
return {
"atomic_id": normalized_id,
"summary": str(contract.get("summary") or ""),
"required_params": list(dict.fromkeys(required)),
"optional_params": [item for item in dict.fromkeys(optional) if item not in required],
"position_format": str(contract.get("position_format") or ""),
"requires_sketch": contract.get("requires_sketch") is True,
"selector_slot": copy.deepcopy(contract.get("selector_slot")) if isinstance(contract.get("selector_slot"), dict) else None,
}
@lru_cache(maxsize=16)
def _cdsl_validator(path_value: str, modified_ns: int) -> Draft202012Validator:
"""Compile the JSON Schema once per runtime schema revision."""
del modified_ns
schema_path = Path(path_value)
try:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
except (OSError, json.JSONDecodeError, SchemaError) as error:
raise RuntimeError("The local CDSL JSON Schema is unavailable or invalid") from error
return Draft202012Validator(schema)
def _cdsl_schema_path(engine: Any) -> Path:
document = _engine_schema(engine)
schema_name = str(document.get("cdsl_json_schema_file") or "")
if not schema_name or Path(schema_name).name != schema_name:
raise RuntimeError("The local engine schema has an invalid CDSL JSON Schema path")
schema_path = Path(str(engine.__file__)).with_name(schema_name)
return Path(str(engine.__file__)).with_name(schema_name)
def load_cdsl_json_schema(engine: Any) -> dict[str, Any]:
schema_path = _cdsl_schema_path(engine)
try:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
@@ -76,7 +117,11 @@ def load_cdsl_json_schema(engine: Any) -> dict[str, Any]:
def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
validator = Draft202012Validator(load_cdsl_json_schema(engine))
schema_path = _cdsl_schema_path(engine)
try:
validator = _cdsl_validator(str(schema_path), schema_path.stat().st_mtime_ns)
except OSError as error:
raise RuntimeError("The local CDSL JSON Schema is unavailable or invalid") from error
errors = sorted(validator.iter_errors(cdsl), key=lambda error: (list(error.absolute_path), error.message))
if not errors:
return
@@ -88,76 +133,6 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
def _legacy_workplane(plane: str, offset: Any) -> dict[str, list[float]] | None:
if isinstance(offset, bool) or not isinstance(offset, (int, float)):
return None
distance = float(offset)
definitions = {
"XY": ([0.0, 0.0, distance], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
"XZ": ([0.0, distance, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
"YZ": ([distance, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]),
}
definition = definitions.get(plane.upper())
if definition is None:
return None
origin, x_dir, normal = definition
return {"origin_mm": origin, "x_dir": x_dir, "normal": normal}
def normalize_cdsl_for_engine(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""Convert unambiguous legacy LLM aliases into the current CDSL dialect.
This intentionally does not infer dimensions, selectors, or feature
dependencies. Any non-mechanical error remains visible to the validator.
"""
normalized = copy.deepcopy(cdsl)
repairs: list[str] = []
geometry = normalized.get("geometry")
sketches = geometry.get("sketches") if isinstance(geometry, dict) else None
if isinstance(sketches, list):
for index, sketch in enumerate(sketches):
if not isinstance(sketch, dict):
continue
if "id" not in sketch and isinstance(sketch.get("sketch_id"), str):
sketch["id"] = sketch.pop("sketch_id")
repairs.append(f"geometry.sketches[{index}]: sketch_id -> id")
legacy_plane: Any = sketch.get("plane")
legacy_offset: Any = sketch.get("offset_mm", 0)
workplane_value = sketch.get("workplane")
if isinstance(workplane_value, dict) and "origin_mm" not in workplane_value:
legacy_plane = workplane_value.get("plane")
legacy_offset = workplane_value.get("offset_mm", 0)
elif "workplane" in sketch:
continue
workplane = _legacy_workplane(legacy_plane, legacy_offset) if isinstance(legacy_plane, str) else None
if workplane is None:
continue
sketch["workplane"] = workplane
sketch.pop("plane", None)
sketch.pop("offset_mm", None)
repairs.append(f"geometry.sketches[{index}]: legacy plane/offset_mm -> workplane")
features = normalized.get("features")
if isinstance(features, list):
for index, feature in enumerate(features):
if not isinstance(feature, dict):
continue
if "sketch_id" not in feature and isinstance(feature.get("sketch"), str):
feature["sketch_id"] = feature.pop("sketch")
repairs.append(f"features[{index}]: sketch -> sketch_id")
if "depends_on" not in feature:
feature["depends_on"] = []
repairs.append(f"features[{index}]: added empty depends_on")
params = feature.get("params")
axis = params.get("axis") if isinstance(params, dict) else None
if isinstance(axis, dict) and "origin_mm" not in axis and "point_mm" in axis:
axis["origin_mm"] = axis.pop("point_mm")
repairs.append(f"features[{index}].params.axis: point_mm -> origin_mm")
return normalized, repairs
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
if not isinstance(cdsl, dict):
raise ValueError("CDSL must be a JSON object")
@@ -490,285 +465,3 @@ def topology_sidecars(
for name, point, normal, x_dir, y_dir in definitions
]
return ({"schema_version": "1.0", "references": references}, {"schema_version": "1.0", "edges": []})
def _set_parameter_value(document: dict[str, Any], path: list[str], value: float) -> None:
target: Any = document
for index, key in enumerate(path):
final = index == len(path) - 1
if isinstance(target, list):
item_index = int(key)
if item_index < 0 or item_index >= len(target):
raise ValueError("Parameter path is no longer valid")
if final:
target[item_index] = value
else:
target = target[item_index]
elif isinstance(target, dict):
if key not in target:
raise ValueError("Parameter path is no longer valid")
if final:
target[key] = value
else:
target = target[key]
else:
raise ValueError("Parameter path is no longer valid")
def apply_parameter_updates(cdsl: dict[str, Any], values: dict[str, float]) -> tuple[dict[str, Any], dict[str, Any]]:
contract = parameter_contract(cdsl)
entries = {str(item.get("id")): item for item in contract["parameters"]}
updated = copy.deepcopy(cdsl)
for parameter_id, raw_value in values.items():
entry = entries.get(parameter_id)
value = float(raw_value)
if entry is None or not entry.get("editable", False):
raise ValueError(f"Unknown editable parameter: {parameter_id}")
if not math.isfinite(value):
raise ValueError("Parameter values must be finite")
minimum, maximum = entry.get("minimum"), entry.get("maximum")
if isinstance(minimum, (int, float)) and value < float(minimum):
raise ValueError(f"{parameter_id} is below its minimum")
if isinstance(maximum, (int, float)) and value > float(maximum):
raise ValueError(f"{parameter_id} is above its maximum")
path = entry.get("path")
if not isinstance(path, list) or not all(isinstance(item, str) for item in path):
raise ValueError(f"{parameter_id} has an invalid path")
_set_parameter_value(updated, path, value)
declared = updated.get("meta", {}).get("editable_parameters")
if isinstance(declared, list):
for declared_entry in declared:
if isinstance(declared_entry, dict) and str(declared_entry.get("id")) == parameter_id:
declared_entry["value"] = value
return updated, parameter_contract(updated)
def _part_skill_audit(
part_skills: dict[str, Any] | None,
request: str,
generation_assumptions: list[str] | None,
) -> dict[str, Any]:
"""Normalize the planning audit persisted beside a product revision."""
audit = copy.deepcopy(part_skills) if isinstance(part_skills, dict) else {}
skills = [item for item in audit.get("skills") or [] if isinstance(item, dict)]
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")]
audit.update({
"schema_version": str(audit.get("schema_version") or "1.0"),
"request": str(audit.get("request") or request),
"structural_intent": str(audit.get("structural_intent") or request),
"skill_ids": skill_ids,
"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 []],
})
return audit
def _generation_context(
reference_ids: list[str],
part_skill_audit: dict[str, Any],
) -> dict[str, Any]:
skills = [item for item in part_skill_audit.get("skills") or [] if isinstance(item, dict)]
return {
"cdsl_reference_ids": list(reference_ids),
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
"part_skill_paths": [
{
"id": str(item.get("id") or ""),
"bridge": str(item.get("bridge") or ""),
"source": str(item.get("source") or ""),
}
for item in skills
],
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
}
def build_revision(
*,
settings: Settings,
store: WorkspaceStore,
task_id: str | None,
request: str,
cdsl: dict[str, Any],
reference_ids: list[str],
summary: str,
parent_revision_id: str | None = None,
operation: dict[str, Any] | None = None,
input_attachments: list[dict[str, Any]] | None = None,
part_skills: dict[str, Any] | None = None,
generation_assumptions: list[str] | None = None,
repair_attempts: int = 0,
verification: dict[str, Any] | None = None,
reference_records: list[dict[str, Any]] | None = None,
feature_plan: dict[str, Any] | None = None,
node_id: str = "",
fragment: dict[str, Any] | None = None,
branch_id: str = "main",
visibility: str = "final",
) -> dict[str, Any]:
engine = load_engine(settings)
task = store.ensure_task(task_id, request)
revision_id, revision_dir = store.next_revision(task["task_id"])
cdsl_path = revision_dir / "model.cdsl.json"
step_path = revision_dir / "model.step"
glb_path = revision_dir / "model.glb"
report_path = revision_dir / "rebuild-report.json"
request_path = revision_dir / "request.json"
references_path = revision_dir / "references.json"
parameters_path = revision_dir / "parameters.json"
selector_path = revision_dir / "model.selector.json"
edges_path = revision_dir / "model.edges.json"
topology_path = revision_dir / "model.topology.json"
part_skills_path = revision_dir / "part-skills.json"
quality_path = revision_dir / "quality-report.json"
snapshot_manifest_path = revision_dir / "snapshot-manifest.json"
fragment_path = revision_dir / "fragment.json"
selector_bindings_path = revision_dir / "selector-bindings.json"
part_skill_audit = _part_skill_audit(part_skills, request, generation_assumptions)
generation_context = _generation_context(reference_ids, part_skill_audit)
write_json(request_path, {"request": request, "created_at": now_iso()})
if isinstance(feature_plan, dict):
store.write_feature_plan(task["task_id"], feature_plan)
write_json(references_path, {"reference_ids": reference_ids, "records": [item for item in reference_records or [] if isinstance(item, dict)]})
write_json(part_skills_path, part_skill_audit)
write_json(snapshot_manifest_path, {
"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,
"status": status,
"created_at": now_iso(),
"request_path": request_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"part_skills_path": part_skills_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"quality_path": quality_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"snapshot_manifest_path": snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"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,
"summary": summary,
"parent_revision_id": parent_revision_id or "",
"operation": operation or {},
"input_attachments": input_attachments or [],
"repair_attempts": max(0, int(repair_attempts)),
"branch_id": branch_id or "main",
"visibility": visibility if visibility in {"checkpoint", "final", "superseded"} else "checkpoint",
"node_id": node_id,
"fragment_path": fragment_path.relative_to(store.task_dir(task["task_id"])).as_posix() if fragment else "",
"selector_bindings_path": selector_bindings_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
}
if status == "success":
record.update({
"step_path": step_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"glb_path": glb_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"selector_path": selector_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"edges_path": edges_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"engine": engine_name,
})
else:
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):
raise ValueError("CDSL must be a JSON object")
cdsl_copy["part_id"] = task["task_id"]
meta = cdsl_copy.setdefault("meta", {})
if not isinstance(meta, dict):
raise ValueError("CDSL meta must be an object when present")
if not isinstance(meta.get("editable_parameters"), list) or not meta["editable_parameters"]:
meta["editable_parameters"] = _derived_parameters(cdsl_copy)
write_json(cdsl_path, cdsl_copy)
if fragment is not None:
write_json(fragment_path, fragment)
write_json(parameters_path, parameter_contract(cdsl_copy))
validate_cdsl(cdsl_copy, engine)
# Product revisions are semantic CDSL artifacts. Do not route them
# through the legacy rebuild entry point, which is allowed to use
# compiler_context/translator compatibility fallbacks.
engine_result = engine.run_cdsl_only(cdsl_copy, step_path)
if engine_result.get("engine") != "cdsl_only" or not step_path.is_file() or step_path.stat().st_size == 0:
raise RuntimeError("Engine did not produce a CDSL-only STEP artifact")
preview = step_to_glb(step_path, glb_path)
snapshot = topology_snapshot(engine_result, task_id=task["task_id"], revision_id=revision_id, preview=preview)
write_json(topology_path, snapshot)
write_json(selector_bindings_path, selector_bindings(
engine_result,
node_id=node_id,
snapshot_id=str(snapshot.get("snapshot_id") or ""),
))
selector, edges = topology_sidecars(engine_result, preview, snapshot=snapshot)
write_json(selector_path, selector)
write_json(edges_path, edges)
rules = validate_verification(verification, cdsl_copy)
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,
"generation_context": generation_context,
"validated_at": now_iso(),
}
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("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}
-222
View File
@@ -1,222 +0,0 @@
"""Deterministic feature-plan validation and readiness calculations."""
from __future__ import annotations
from collections import defaultdict, deque
from copy import deepcopy
from typing import Any, Iterable
PLAN_SCHEMA_VERSION = "cad.feature-plan.v1"
NODE_STATUSES = {
"planned",
"ready",
"waiting_for_topology",
"waiting_for_selection",
"blocked",
"executing",
"executed",
"failed",
"completed",
}
_TOPOLOGY_REQUIRED_ATOMICS = {
"fillet",
"chamfer",
"hole_blind",
"hole_countersink",
"hole_counterbore",
"pattern_mirror",
}
class FeaturePlanError(ValueError):
"""A feature plan is not a valid acyclic executable plan."""
def _text(value: Any, field: str, *, required: bool = True) -> str:
result = str(value or "").strip()
if required and not result:
raise FeaturePlanError(f"{field} is required")
return result
def _bool(value: Any) -> bool:
return value is True
def _normalise_node(raw: Any, index: int) -> dict[str, Any]:
if not isinstance(raw, dict):
raise FeaturePlanError(f"nodes[{index}] must be an object")
node_id = _text(raw.get("id"), f"nodes[{index}].id")
atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id")
depends_on = raw.get("depends_on") or []
if not isinstance(depends_on, list) or not all(isinstance(item, str) and item.strip() for item in depends_on):
raise FeaturePlanError(f"nodes[{index}].depends_on must be an array of non-empty strings")
feature_ids = raw.get("cdsl_feature_ids")
if feature_ids is None:
feature_ids = [node_id]
if not isinstance(feature_ids, list) or not feature_ids or not all(isinstance(item, str) and item.strip() for item in feature_ids):
raise FeaturePlanError(f"nodes[{index}].cdsl_feature_ids must be a non-empty string array")
query = raw.get("topology_query")
if query is not None and not isinstance(query, dict):
raise FeaturePlanError(f"nodes[{index}].topology_query must be an object")
requires_topology = _bool(raw.get("requires_topology")) or atomic_id in _TOPOLOGY_REQUIRED_ATOMICS
status = str(raw.get("status") or "planned")
if status not in NODE_STATUSES:
raise FeaturePlanError(f"nodes[{index}].status is unsupported: {status}")
node = {
"id": node_id,
"intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False),
"atomic_id": atomic_id,
"depends_on": list(dict.fromkeys(item.strip() for item in depends_on)),
"requires_topology": requires_topology,
"topology_query": deepcopy(query) if query is not None else None,
"status": status,
"cdsl_feature_ids": list(dict.fromkeys(item.strip() for item in feature_ids)),
}
if raw.get("selector_required") is not None:
node["selector_required"] = _bool(raw.get("selector_required"))
if isinstance(raw.get("failure"), dict):
node["failure"] = deepcopy(raw["failure"])
return node
def validate_feature_plan(plan: dict[str, Any], *, supported_atomic_ids: Iterable[str] = ()) -> dict[str, Any]:
if not isinstance(plan, dict):
raise FeaturePlanError("Feature plan must be an object")
version = str(plan.get("schema_version") or PLAN_SCHEMA_VERSION)
if version != PLAN_SCHEMA_VERSION:
raise FeaturePlanError(f"Unsupported feature plan schema: {version}")
plan_id = _text(plan.get("plan_id"), "plan_id")
task_id = _text(plan.get("task_id"), "task_id", required=False)
topology_snapshot_id = _text(plan.get("topology_snapshot_id"), "topology_snapshot_id", required=False)
raw_nodes = plan.get("nodes")
if not isinstance(raw_nodes, list) or not raw_nodes:
raise FeaturePlanError("Feature plan requires a non-empty nodes array")
nodes = [_normalise_node(item, index) for index, item in enumerate(raw_nodes)]
by_id: dict[str, dict[str, Any]] = {}
node_index: dict[str, int] = {}
feature_owner: dict[str, str] = {}
supported = {str(item) for item in supported_atomic_ids if str(item)}
for index, node in enumerate(nodes):
if node["id"] in by_id:
raise FeaturePlanError(f"Duplicate feature plan node: {node['id']}")
if supported and node["atomic_id"] not in supported:
raise FeaturePlanError(f"Unsupported feature plan atomic_id: {node['atomic_id']}")
by_id[node["id"]] = node
node_index[node["id"]] = index
for feature_id in node["cdsl_feature_ids"]:
if feature_id in feature_owner:
raise FeaturePlanError(f"CDSL feature belongs to multiple plan nodes: {feature_id}")
feature_owner[feature_id] = node["id"]
indegree = {node_id: 0 for node_id in by_id}
children: dict[str, list[str]] = defaultdict(list)
for node in nodes:
for dependency in node["depends_on"]:
if dependency not in by_id:
raise FeaturePlanError(f"Node {node['id']} has missing dependency: {dependency}")
if node_index[dependency] >= node_index[node["id"]]:
raise FeaturePlanError(f"Node {node['id']} must appear after dependency: {dependency}")
indegree[node["id"]] += 1
children[dependency].append(node["id"])
queue = deque(node_id for node_id, degree in indegree.items() if degree == 0)
visited: list[str] = []
while queue:
node_id = queue.popleft()
visited.append(node_id)
for child in children[node_id]:
indegree[child] -= 1
if indegree[child] == 0:
queue.append(child)
if len(visited) != len(nodes):
raise FeaturePlanError("Feature plan contains a dependency cycle")
return {
"schema_version": PLAN_SCHEMA_VERSION,
"plan_id": plan_id,
"task_id": task_id,
"topology_snapshot_id": topology_snapshot_id,
"nodes": nodes,
"feature_owner": feature_owner,
}
def _topology_available(topology: dict[str, Any] | None) -> bool:
if not isinstance(topology, dict):
return False
return any(
isinstance(record, dict)
and record.get("executable", True) is not False
and record.get("kind") in {"face", "edge", "vertex", "body", "plane", "axis"}
for record in topology.get("records") or ()
)
def compute_node_statuses(
plan: dict[str, Any],
*,
cdsl: dict[str, Any] | None = None,
topology: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Return a copy with deterministic status and readiness information."""
checked = validate_feature_plan(plan)
present_features = {
str(feature.get("id"))
for feature in (cdsl or {}).get("features") or ()
if isinstance(feature, dict) and feature.get("id")
}
has_topology = _topology_available(topology)
topology_snapshot_id = str((topology or {}).get("snapshot_id") or "")
selection_ready = bool(topology_snapshot_id and checked.get("topology_snapshot_id") == topology_snapshot_id)
by_id = {node["id"]: node for node in checked["nodes"]}
result_nodes: list[dict[str, Any]] = []
for original in checked["nodes"]:
node = deepcopy(original)
if node["status"] in {"failed", "blocked"}:
result_nodes.append(node)
continue
if all(feature_id in present_features for feature_id in node["cdsl_feature_ids"]):
node["status"] = "completed"
result_nodes.append(node)
continue
dependencies_done = all(by_id[item]["status"] in {"executed", "completed"} or all(
feature_id in present_features for feature_id in by_id[item]["cdsl_feature_ids"]
) for item in node["depends_on"])
if not dependencies_done:
node["status"] = "planned"
elif node["requires_topology"] and not has_topology:
node["status"] = "waiting_for_topology"
elif node["requires_topology"] and not selection_ready:
node["status"] = "waiting_for_selection"
else:
node["status"] = "ready"
result_nodes.append(node)
ready = [node["id"] for node in result_nodes if node["status"] == "ready"]
waiting = [node["id"] for node in result_nodes if node["status"] in {"waiting_for_topology", "waiting_for_selection"}]
blocked = [node["id"] for node in result_nodes if node["status"] == "blocked"]
completed = [node["id"] for node in result_nodes if node["status"] == "completed"]
return {
**checked,
"nodes": result_nodes,
"ready_nodes": ready,
"waiting_nodes": waiting,
"blocked_nodes": blocked,
"completed_nodes": completed,
"complete": len(completed) == len(result_nodes) and not blocked,
}
def plan_feature_ids(plan: dict[str, Any], node_ids: Iterable[str]) -> set[str]:
checked = validate_feature_plan(plan)
wanted = set(node_ids)
return {
feature_id
for node in checked["nodes"]
if node["id"] in wanted
for feature_id in node["cdsl_feature_ids"]
}
def node_for_feature(plan: dict[str, Any], feature_id: str) -> dict[str, Any] | None:
checked = validate_feature_plan(plan)
return next((node for node in checked["nodes"] if feature_id in node["cdsl_feature_ids"]), None)
-228
View File
@@ -1,228 +0,0 @@
"""Strict contracts for persistent, node-by-node CAD generation."""
from __future__ import annotations
from collections import defaultdict, deque
from copy import deepcopy
from hashlib import sha256
import re
from typing import Any, Iterable
from app.services.feature_plan import FeaturePlanError, validate_feature_plan
GENERATION_PLAN_SCHEMA_VERSION = "cad.generation-plan.v2"
BACKEND_ID_STRATEGY = "backend-derived-v1"
REQUIREMENT_SOURCES = {"explicit", "assumption"}
REQUIREMENT_PRIORITIES = {"hard", "soft"}
# Keep this in sync with the engine's profile_schema.json. The plan is
# deliberately atomic: an operation that consumes a profile owns one new
# sketch, while all other operations own none.
SKETCH_REQUIRED_ATOMICS = {
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add",
}
class GenerationPlanError(ValueError):
"""The authoring plan cannot safely drive an incremental build."""
def _text(value: Any, field: str, *, required: bool = True) -> str:
result = str(value or "").strip()
if required and not result:
raise GenerationPlanError(f"{field} is required")
return result
def _string_list(value: Any, field: str, *, required: bool = False) -> list[str]:
if value is None:
value = []
if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value):
raise GenerationPlanError(f"{field} must be an array of non-empty strings")
result = list(dict.fromkeys(item.strip() for item in value))
if required and not result:
raise GenerationPlanError(f"{field} must not be empty")
return result
def _normalise_requirement(raw: Any, index: int) -> dict[str, Any]:
if not isinstance(raw, dict):
raise GenerationPlanError(f"requirements[{index}] must be an object")
source = _text(raw.get("source") or "assumption", f"requirements[{index}].source")
priority = _text(raw.get("priority") or "hard", f"requirements[{index}].priority")
if source not in REQUIREMENT_SOURCES:
raise GenerationPlanError(f"requirements[{index}].source is unsupported: {source}")
if priority not in REQUIREMENT_PRIORITIES:
raise GenerationPlanError(f"requirements[{index}].priority is unsupported: {priority}")
return {
"id": _text(raw.get("id"), f"requirements[{index}].id"),
"source": source,
"priority": priority,
"description": _text(raw.get("description"), f"requirements[{index}].description"),
"value": deepcopy(raw.get("value")),
"unit": _text(raw.get("unit"), f"requirements[{index}].unit", required=False),
"tolerance": deepcopy(raw.get("tolerance")),
}
def _backend_cdsl_id(kind: str, node_id: str) -> str:
"""Create a valid, stable CDSL identifier without trusting model naming."""
slug = re.sub(r"[^A-Za-z0-9_-]+", "_", node_id).strip("_-").lower() or "node"
digest = sha256(node_id.encode("utf-8")).hexdigest()[:8]
return f"{kind}_{slug[:60]}_{digest}"
def _backend_node_outputs(node_id: str, atomic_id: str) -> tuple[list[str], list[str]]:
feature_ids = [_backend_cdsl_id("feature", node_id)]
sketch_ids = [_backend_cdsl_id("sketch", node_id)] if atomic_id in SKETCH_REQUIRED_ATOMICS else []
return feature_ids, sketch_ids
def _stored_plan_ids(raw: dict[str, Any]) -> bool:
"""Retain IDs of plans already materialised by an earlier backend version."""
return str(raw.get("id_strategy") or "") == BACKEND_ID_STRATEGY or isinstance(raw.get("feature_owner"), dict)
def validate_generation_plan(
document: dict[str, Any],
*,
supported_atomic_ids: Iterable[str] = (),
task_id: str = "",
) -> dict[str, Any]:
"""Normalise a planner response and prove every hard requirement is owned."""
if not isinstance(document, dict):
raise GenerationPlanError("Generation plan must be an object")
version = str(document.get("schema_version") or GENERATION_PLAN_SCHEMA_VERSION)
if version != GENERATION_PLAN_SCHEMA_VERSION:
raise GenerationPlanError(f"Unsupported generation plan schema: {version}")
requirements_raw = document.get("requirements")
if not isinstance(requirements_raw, list) or not requirements_raw:
raise GenerationPlanError("Generation plan requires a non-empty requirements array")
requirements = [_normalise_requirement(item, index) for index, item in enumerate(requirements_raw)]
requirement_ids = [item["id"] for item in requirements]
if len(requirement_ids) != len(set(requirement_ids)):
raise GenerationPlanError("Generation plan has duplicate requirement ids")
raw_nodes = document.get("nodes")
if not isinstance(raw_nodes, list) or not raw_nodes:
raise GenerationPlanError("Generation plan requires a non-empty nodes array")
preserve_stored_ids = _stored_plan_ids(document)
feature_nodes: list[dict[str, Any]] = []
node_metadata: dict[str, dict[str, Any]] = {}
sketch_owner: dict[str, str] = {}
for index, raw in enumerate(raw_nodes):
if not isinstance(raw, dict):
raise GenerationPlanError(f"nodes[{index}] must be an object")
node_id = _text(raw.get("id"), f"nodes[{index}].id")
atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id")
if preserve_stored_ids:
feature_ids = _string_list(raw.get("cdsl_feature_ids"), f"nodes[{index}].cdsl_feature_ids", required=True)
sketch_ids = _string_list(raw.get("cdsl_sketch_ids"), f"nodes[{index}].cdsl_sketch_ids")
else:
# New plans own semantic node IDs only. CDSL object IDs are a
# deterministic backend implementation detail, not model output.
feature_ids, sketch_ids = _backend_node_outputs(node_id, atomic_id)
for sketch_id in sketch_ids:
previous = sketch_owner.get(sketch_id)
if previous:
raise GenerationPlanError(f"CDSL sketch belongs to multiple plan nodes: {sketch_id} ({previous}, {node_id})")
sketch_owner[sketch_id] = node_id
coverage = _string_list(raw.get("requirement_ids"), f"nodes[{index}].requirement_ids")
unknown = sorted(set(coverage) - set(requirement_ids))
if unknown:
raise GenerationPlanError(f"Node {node_id} references unknown requirements: {', '.join(unknown)}")
rules = raw.get("verification_rules") or []
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
raise GenerationPlanError(f"nodes[{index}].verification_rules must be an array of objects")
targets = raw.get("review_targets") or []
if not isinstance(targets, list) or not all(isinstance(item, dict) for item in targets):
raise GenerationPlanError(f"nodes[{index}].review_targets must be an array of objects")
feature_nodes.append({
"id": node_id,
"intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False),
"atomic_id": atomic_id,
"depends_on": _string_list(raw.get("depends_on"), f"nodes[{index}].depends_on"),
"requires_topology": raw.get("requires_topology") is True,
"topology_query": deepcopy(raw.get("topology_query")) if raw.get("topology_query") is not None else None,
"cdsl_feature_ids": feature_ids,
})
node_metadata[node_id] = {
"cdsl_sketch_ids": sketch_ids,
"requires_sketch": bool(sketch_ids),
"requirement_ids": coverage,
"verification_rules": deepcopy(rules),
"review_targets": deepcopy(targets),
"attempts": {"authoring": 0, "repair": 0, "replan": 0},
}
try:
feature_plan = validate_feature_plan({
"schema_version": "cad.feature-plan.v1",
"plan_id": document.get("plan_id"),
"task_id": task_id or document.get("task_id"),
"nodes": feature_nodes,
}, supported_atomic_ids=supported_atomic_ids)
except FeaturePlanError as error:
raise GenerationPlanError(str(error)) from error
covered = {
requirement_id
for metadata in node_metadata.values()
for requirement_id in metadata["requirement_ids"]
}
uncovered = [item["id"] for item in requirements if item["priority"] == "hard" and item["id"] not in covered]
if uncovered:
raise GenerationPlanError("Hard requirements are not covered: " + ", ".join(uncovered))
nodes = []
for node in feature_plan["nodes"]:
nodes.append({**node, **node_metadata[node["id"]]})
return {
"schema_version": GENERATION_PLAN_SCHEMA_VERSION,
"id_strategy": BACKEND_ID_STRATEGY,
"plan_id": feature_plan["plan_id"],
"task_id": task_id or feature_plan["task_id"],
"requirements": requirements,
"assumptions": _string_list(document.get("assumptions"), "assumptions"),
"nodes": nodes,
"feature_owner": feature_plan["feature_owner"],
"sketch_owner": sketch_owner,
}
def descendant_closure(plan: dict[str, Any], root_node_id: str) -> set[str]:
"""Return one node and every node whose model depends on it."""
nodes = plan.get("nodes") if isinstance(plan, dict) else None
if not isinstance(nodes, list):
raise GenerationPlanError("Generation plan has no nodes")
children: dict[str, set[str]] = defaultdict(set)
known = {str(node.get("id")) for node in nodes if isinstance(node, dict)}
if root_node_id not in known:
raise GenerationPlanError(f"Unknown generation-plan node: {root_node_id}")
for node in nodes:
if not isinstance(node, dict):
continue
for dependency in node.get("depends_on") or ():
children[str(dependency)].add(str(node.get("id")))
result: set[str] = set()
queue: deque[str] = deque([root_node_id])
while queue:
node_id = queue.popleft()
if node_id in result:
continue
result.add(node_id)
queue.extend(sorted(children[node_id] - result))
return result
def mark_nodes_stale(plan: dict[str, Any], root_node_id: str, *, reason: str) -> dict[str, Any]:
"""Invalidate a node/subtree after an upstream geometry change or rollback."""
updated = deepcopy(plan)
stale = descendant_closure(updated, root_node_id)
for node in updated.get("nodes") or ():
if isinstance(node, dict) and str(node.get("id")) in stale:
node["status"] = "planned"
node["stale"] = True
node["stale_reason"] = reason
node.pop("topology_snapshot_id", None)
return updated
@@ -1,509 +0,0 @@
"""Persistent, full-rebuild orchestration for node-by-node CDSL authoring."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Awaitable, Callable
from copy import deepcopy
import json
from pathlib import Path
import secrets
from typing import Any
from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, materialize_fragment, validate_fragment
from app.services.engine_service import QualityVerificationError, build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl
from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan
from app.services.quality import validate_verification
from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status
from app.services.storage import WorkspaceStore, write_json
from app.services.visual_review import VisualReviewError, review_checkpoint
from app.settings import ProviderConfig, ProviderModel, Settings
Completion = Callable[[list[dict[str, Any]], list[dict[str, Any]], ProviderConfig, ProviderModel, str | None], Awaitable[dict[str, Any]]]
PLAN_TOOL = {
"type": "function",
"function": {
"name": "plan_generation_task",
"description": "Create the complete immutable requirement list and executable feature DAG before authoring any CDSL.",
"parameters": {
"type": "object",
"properties": {
"schema_version": {"type": "string", "const": "cad.generation-plan.v2"},
"plan_id": {"type": "string", "minLength": 1},
"requirements": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1},
"source": {"enum": ["explicit", "assumption"]},
"priority": {"enum": ["hard", "soft"]},
"description": {"type": "string", "minLength": 1},
"value": {},
"unit": {"type": "string"},
"tolerance": {},
},
"required": ["id", "source", "priority", "description"],
"additionalProperties": False,
},
},
"assumptions": {"type": "array", "items": {"type": "string"}},
"nodes": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1},
"intent": {"type": "string"},
"atomic_id": {"type": "string", "minLength": 1},
"depends_on": {"type": "array", "items": {"type": "string"}},
"requires_topology": {"type": "boolean"},
"topology_query": {"type": "object"},
"requirement_ids": {"type": "array", "items": {"type": "string"}},
"verification_rules": {"type": "array", "items": {"type": "object"}},
"review_targets": {"type": "array", "items": {"type": "object"}},
},
"required": ["id", "intent", "atomic_id", "depends_on", "requirement_ids", "verification_rules", "review_targets"],
"additionalProperties": False,
},
},
},
"required": ["schema_version", "plan_id", "requirements", "assumptions", "nodes"],
"additionalProperties": False,
},
},
}
FRAGMENT_TOOL = {
"type": "function",
"function": {
"name": "generate_cdsl_fragment",
"description": "Generate only the active plan node's additive CDSL fragment. Never replace or mutate existing CDSL.",
"parameters": {
"type": "object",
"properties": {
"schema_version": {"type": "string", "const": "cad.cdsl-fragment.v1"},
"node_id": {"type": "string", "minLength": 1},
"base_revision_id": {"type": "string"},
"base_cdsl_sha256": {"type": "string", "minLength": 64, "maxLength": 64},
"required_snapshot_id": {"type": "string"},
"add_sketches": {"type": "array", "items": {"type": "object"}},
"add_features": {"type": "array", "items": {"type": "object"}},
"verification_rules": {"type": "array", "items": {"type": "object"}},
"assumptions": {"type": "array", "items": {"type": "string"}},
},
"required": ["schema_version", "node_id", "base_revision_id", "base_cdsl_sha256", "add_sketches", "add_features", "verification_rules", "assumptions"],
"additionalProperties": False,
},
},
}
class IncrementalGenerationError(RuntimeError):
pass
def _tool_response(response: dict[str, Any], expected_name: str) -> dict[str, Any]:
try:
call = response["choices"][0]["message"]["tool_calls"][0]
if call["function"]["name"] != expected_name:
raise KeyError("wrong tool")
result = json.loads(call["function"]["arguments"])
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
raise IncrementalGenerationError(f"Author did not return a valid {expected_name} call") from error
if not isinstance(result, dict):
raise IncrementalGenerationError(f"{expected_name} arguments must be an object")
return result
def _node_by_id(spec: dict[str, Any], node_id: str) -> dict[str, Any]:
node = next((item for item in spec.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None)
if node is None:
raise IncrementalGenerationError(f"Generation plan has no node {node_id}")
return node
def _fragment_node_context(node: dict[str, Any]) -> dict[str, Any]:
"""Expose semantic node intent, not backend-owned CDSL implementation IDs."""
fields = (
"id", "intent", "atomic_id", "depends_on", "requires_topology",
"requires_sketch", "topology_query", "requirement_ids",
"verification_rules", "review_targets",
)
return {field: deepcopy(node[field]) for field in fields if field in node}
def _ready_node(spec: dict[str, Any], completed: set[str], has_topology: bool) -> dict[str, Any] | None:
for node in spec.get("nodes") or ():
if not isinstance(node, dict) or node.get("status") == "completed":
continue
if all(str(item) in completed for item in node.get("depends_on") or ()) and (not node.get("requires_topology") or has_topology):
return node
return None
def _error_code(error: Exception) -> str:
message = str(error)
for code in (
"SELECTOR_AMBIGUOUS", "SELECTOR_NOT_FOUND", "SELECTOR_GEOMETRY_MISMATCH",
"TOPOLOGY_SNAPSHOT_STALE", "TOPOLOGY_REQUIRED", "VERIFICATION_FAILED", "SELECTOR_OWNER_REQUIRED",
):
if code in message:
return code
return type(error).__name__.upper()
def _mark_affected_nodes_stale(plan: dict[str, Any], node_ids: list[str], *, reason: str) -> tuple[dict[str, Any], set[str]]:
"""Invalidate the union of every affected node's downstream closure."""
updated = deepcopy(plan)
stale: set[str] = set()
for node_id in dict.fromkeys(node_ids):
stale.update(descendant_closure(updated, node_id))
updated = mark_nodes_stale(updated, node_id, reason=reason)
return updated, stale
def _source_image_paths(store: WorkspaceStore, conversation: dict[str, Any]) -> list[Path]:
conversation_id = str(conversation.get("conversation_id") or "")
paths: list[Path] = []
for attachment in conversation.get("attachments") or ():
if not isinstance(attachment, dict) or attachment.get("kind") != "image" or not conversation_id:
continue
try:
paths.append(store.conversation_attachment_path(conversation_id, str(attachment.get("path") or "")))
except ValueError:
continue
return paths
class IncrementalGenerationRunner:
"""The agent-facing controller. It is deliberately full-rebuild and restart-safe."""
def __init__(self, settings: Settings, store: WorkspaceStore, complete: Completion) -> None:
self.settings = settings
self.store = store
self._complete = complete
async def _call(self, messages: list[dict[str, Any]], provider: ProviderConfig, model: ProviderModel, tool: dict[str, Any], name: str) -> dict[str, Any]:
response = await self._complete(messages, [tool], provider, model, name)
return _tool_response(response, name)
async def run(
self,
*,
task_id: str,
request: str,
conversation: dict[str, Any],
provider: ProviderConfig,
model: ProviderModel,
author_messages: list[dict[str, Any]],
part_skills: dict[str, Any] | None = None,
references: list[str] | None = None,
already_started: bool = False,
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
plan_diagnostic_path = ""
try:
task = self.store.ensure_task(task_id or None, request)
task_id = str(task["task_id"])
# Configuration is a start gate: visual review is required, never silently skipped.
self.settings.resolve_review_model()
renderer_ready, renderer_error = renderer_status()
if not renderer_ready:
raise IncrementalGenerationError(renderer_error)
task = self.store.read_task(task_id) if already_started else self.store.start_generation(task_id, request=request)
if not isinstance(task, dict):
raise IncrementalGenerationError("Generation task is unavailable")
yield "generation_plan", {"taskId": task_id, "status": "running"}
engine = load_engine(self.settings)
persisted_spec = self.store.read_generation_spec(task_id)
if persisted_spec is not None:
spec = validate_generation_plan(
persisted_spec,
supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()),
task_id=task_id,
)
yield "generation_plan", {
"taskId": task_id, "status": "success", "planId": spec["plan_id"], "resumed": True,
"requirements": spec["requirements"],
"nodes": [{"id": node["id"], "intent": node["intent"], "status": node.get("status", "planned")} for node in spec["nodes"]],
}
else:
planning_messages = [
{
"role": "system",
"content": (
"Create one complete cad.generation-plan.v2 before creating geometry. "
"Turn every user constraint into a requirement with source explicit or assumption; "
"use source assumption for missing dimensions and never ask the user questions. "
"Every hard requirement must belong to at least one node. Use only runtime-supported atomic ids. "
"Do not output expected_feature_ids or expected_sketch_ids: the backend derives all CDSL object ids from node.id."
),
},
*author_messages,
]
raw_spec = await self._call(planning_messages, provider, model, PLAN_TOOL, "plan_generation_task")
try:
spec = validate_generation_plan(
raw_spec,
supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()),
task_id=task_id,
)
except GenerationPlanError as error:
plan_diagnostic_path = self.store.write_generation_failure(task_id, {
"schema_version": "cad.generation-plan-diagnostic.v1",
"stage": "generation_plan_validation",
"message": str(error),
"raw_plan": raw_spec,
})
raise
self.store.write_generation_spec(task_id, spec)
yield "generation_plan", {
"taskId": task_id, "status": "success", "planId": spec["plan_id"],
"requirements": spec["requirements"], "nodes": [{"id": node["id"], "intent": node["intent"], "status": "planned"} for node in spec["nodes"]],
}
completed: set[str] = {
str(node["id"]) for node in spec["nodes"] if node.get("status") == "completed"
}
last_built: dict[str, Any] | None = None
task = self.store.read_task(task_id) or task
active_revision_id = str(task.get("active_revision") or "")
# A process can stop between build and review. That checkpoint is
# not a legal base revision, so recover its parent before resuming.
active_record = next(
(item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == active_revision_id),
None,
)
if isinstance(active_record, dict) and active_record.get("visibility") == "checkpoint":
current_node = str(active_record.get("node_id") or "")
if current_node and current_node not in completed:
recovered = str(active_record.get("parent_revision_id") or "")
self.store.rollback_to_revision(task_id, recovered, branch_id=f"branch_{secrets.token_hex(4)}")
active_revision_id = recovered
task = self.store.read_task(task_id) or task
base_path = self.store.current_cdsl_path(task_id)
base_cdsl = json.loads(base_path.read_text(encoding="utf-8")) if base_path and base_path.is_file() else None
while True:
topology_path = self.store.current_topology_path(task_id)
topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None
node = _ready_node(spec, completed, bool(topology and topology.get("records")))
if node is None:
if len(completed) == len(spec["nodes"]):
self.store.finish_generation(task_id, lifecycle="completed")
if last_built is not None:
yield "cad_result", self._result_payload(last_built, lifecycle="completed", checkpoint=False)
yield "task_terminal", {"taskId": task_id, "lifecycle": "completed", "revisionId": str((self.store.read_task(task_id) or {}).get("published_revision") or "")}
return
waiting = [item["id"] for item in spec["nodes"] if item.get("id") not in completed]
raise IncrementalGenerationError("No executable plan node is ready: " + ", ".join(waiting))
node_id = str(node["id"])
self.store.set_active_node(task_id, node_id)
yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "authoring"}
attempts = node.setdefault("attempts", {"authoring": 0, "repair": 0, "replan": 0})
feedback = ""
while True:
attempt_kind = "authoring" if int(attempts.get("authoring") or 0) < self.settings.node_authoring_attempts else "repair"
if attempt_kind == "repair" and int(attempts.get("repair") or 0) >= self.settings.node_repair_attempts:
if int(attempts.get("replan") or 0) >= self.settings.node_replan_attempts:
raise IncrementalGenerationError(f"Node {node_id} exhausted its authoring, repair, and replan budgets: {feedback}")
attempts["replan"] = int(attempts.get("replan") or 0) + 1
spec = await self._replan(spec, node_id, feedback, provider, model, author_messages, engine)
self.store.write_generation_spec(task_id, spec)
node = _node_by_id(spec, node_id)
node["attempts"] = {"authoring": 0, "repair": 0, "replan": attempts["replan"]}
attempts = node["attempts"]
yield "rollback", {"taskId": task_id, "nodeId": node_id, "reason": "node_replan"}
continue
attempts[attempt_kind] = int(attempts.get(attempt_kind) or 0) + 1
required_snapshot = str((topology or {}).get("snapshot_id") or "") if node.get("requires_topology") else ""
node_requirement_ids = set(node.get("requirement_ids") or ())
author_context = {
"active_node": _fragment_node_context(node),
"requirements": [
requirement for requirement in spec["requirements"]
if requirement.get("id") in node_requirement_ids
],
"base_revision_id": active_revision_id,
"base_cdsl_sha256": cdsl_sha256(base_cdsl),
"required_snapshot_id": required_snapshot,
"base_cdsl": base_cdsl,
"topology": topology if required_snapshot else None,
"previous_failure": feedback,
}
fragment_messages = [
{
"role": "system",
"content": (
"Author exactly one additive CDSL fragment for the active node. Do not change existing CDSL or generate unsupported topology selectors. "
"Do not set output id, sketch_id, or depends_on: the backend assigns them. "
"Use owner_node_id instead of owner_feature_id and source_node_ids instead of source_feature_ids when referring to plan nodes."
),
},
*author_messages,
{"role": "user", "content": json.dumps(author_context, ensure_ascii=False)},
]
try:
raw_fragment = await self._call(fragment_messages, provider, model, FRAGMENT_TOOL, "generate_cdsl_fragment")
fragment = validate_fragment(
raw_fragment, plan=spec, node_id=node_id, base_revision_id=active_revision_id,
base_cdsl=base_cdsl, required_snapshot_id=required_snapshot,
)
cdsl = materialize_fragment(base_cdsl, fragment)
cdsl, repairs = normalize_cdsl_for_engine(cdsl)
validate_cdsl(cdsl, engine)
rules = [*node.get("verification_rules", []), *fragment.get("verification_rules", [])]
verification = {"rules": rules} if rules else None
validate_verification(verification, cdsl)
fragment_base_revision = str(fragment["base_revision_id"])
built = await asyncio.to_thread(
build_revision,
settings=self.settings, store=self.store, task_id=task_id, request=request, cdsl=cdsl,
reference_ids=references or [], summary=node.get("intent") or node_id,
parent_revision_id=active_revision_id or None,
operation={"type": "cdsl_fragment", "node_id": node_id},
part_skills=part_skills, generation_assumptions=[*spec.get("assumptions", []), *fragment.get("assumptions", [])],
verification=verification, node_id=node_id, fragment=fragment,
branch_id=str((self.store.read_task(task_id) or {}).get("active_branch_id") or "main"), visibility="checkpoint",
)
candidate_revision_id = str(built["revision_id"])
try:
render_dir = self.store.revision_dir(task_id, candidate_revision_id) / "review"
manifest = await asyncio.to_thread(
render_checkpoint,
self.settings,
step_path=self.store.artifact_path(task_id, str(built["step_path"])),
output_dir=render_dir,
review_targets=node.get("review_targets"),
)
final_checkpoint = len(completed) + 1 == len(spec["nodes"])
review_requirements = spec["requirements"] if final_checkpoint else [
requirement for requirement in spec["requirements"]
if requirement.get("id") in set(node.get("requirement_ids") or ())
]
review = await review_checkpoint(
self.settings, manifest=manifest, requirements=review_requirements, node_id=node_id,
deterministic_report={"quality_status": built.get("quality_status"), "verification": built.get("verification_summary", {})},
source_images=_source_image_paths(self.store, conversation) if not completed or final_checkpoint else [],
final_checkpoint=final_checkpoint,
)
except (ReviewRenderError, VisualReviewError) as error:
self.store.rollback_to_revision(task_id, fragment_base_revision, branch_id=f"branch_{secrets.token_hex(4)}")
active_revision_id = fragment_base_revision
rollback_path = self.store.current_cdsl_path(task_id)
base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None
raise error
active_revision_id = candidate_revision_id
base_cdsl = cdsl
manifest_relative = (render_dir / "render-manifest.json").relative_to(self.store.task_dir(task_id)).as_posix()
review_relative = (render_dir / "visual-review.json").relative_to(self.store.task_dir(task_id)).as_posix()
write_json(render_dir / "visual-review.json", review)
self.store.update_revision_metadata(task_id, active_revision_id, {"render_manifest_path": manifest_relative, "visual_review_path": review_relative})
yield "render_review", {"taskId": task_id, "revisionId": active_revision_id, "nodeId": node_id, "review": review}
if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85:
affected_nodes = [
str(item) for item in review.get("affected_node_ids") or ()
if any(str(candidate.get("id") or "") == str(item) for candidate in spec.get("nodes") or ())
] or [node_id]
rollback_base = self.store.rollback_anchor_for_nodes(
task_id,
affected_nodes,
fallback_revision_id=fragment_base_revision,
)
branch = f"branch_{secrets.token_hex(4)}"
self.store.rollback_to_revision(task_id, rollback_base, branch_id=branch)
spec, stale_nodes = _mark_affected_nodes_stale(
spec, affected_nodes, reason="high_confidence_visual_review",
)
self.store.write_generation_spec(task_id, spec)
completed.difference_update(stale_nodes)
active_revision_id = rollback_base
rollback_path = self.store.current_cdsl_path(task_id)
base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None
feedback = "High-confidence visual review requires correction: " + "; ".join(review.get("evidence") or [])
yield "rollback", {
"taskId": task_id, "nodeId": node_id, "revisionId": active_revision_id,
"reason": "visual_review", "affectedNodeIds": affected_nodes,
}
continue
completed.add(node_id)
node["status"] = "completed"
node.pop("stale", None)
self.store.write_generation_spec(task_id, spec)
last_built = built
payload = self._result_payload(built, lifecycle="running", checkpoint=True)
yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "success", "revisionId": active_revision_id}
yield "cad_result", payload
break
except (CdslFragmentError, GenerationPlanError, QualityVerificationError, ReviewRenderError, VisualReviewError, ValueError, RuntimeError) as error:
feedback = str(error)
failure = {
"schema_version": "cad.generation-failure.v1",
"node_id": node_id,
"stage": attempt_kind,
"error_code": _error_code(error),
"message": feedback,
"requirement_ids": list(node.get("requirement_ids") or ()),
"selector": {"required_snapshot_id": required_snapshot},
"geometry_delta": {},
"recommended_rollback_revision": fragment_base_revision if "fragment_base_revision" in locals() else active_revision_id,
}
failure_path = self.store.write_generation_failure(task_id, failure)
yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "error", "attempt": attempt_kind, "message": feedback}
# A failed build never becomes the active base; retries are safe and deterministic.
continue
except Exception as error:
failure = {
"schema_version": "cad.generation-failure.v1",
"message": str(error),
"active_node_id": str((self.store.read_task(task_id) or {}).get("active_node_id") or ""),
}
if plan_diagnostic_path:
failure["plan_diagnostic_path"] = plan_diagnostic_path
self.store.finish_generation(task_id, lifecycle="failed", failure=failure)
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "message": str(error)}
async def _replan(
self,
spec: dict[str, Any],
node_id: str,
feedback: str,
provider: ProviderConfig,
model: ProviderModel,
author_messages: list[dict[str, Any]],
engine: Any,
) -> dict[str, Any]:
raw = await self._call([
{"role": "system", "content": "Replan only the failed node and its downstream nodes. Preserve completed node definitions and all requirements."},
*author_messages,
{"role": "user", "content": json.dumps({"existing_plan": spec, "failed_node_id": node_id, "failure": feedback}, ensure_ascii=False)},
], provider, model, PLAN_TOOL, "plan_generation_task")
next_spec = validate_generation_plan(raw, supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), task_id=str(spec.get("task_id") or ""))
stale = {item["id"] for item in mark_nodes_stale(spec, node_id, reason="replan").get("nodes") or [] if item.get("stale")}
prior = {item["id"]: item for item in spec.get("nodes") or []}
for node in next_spec["nodes"]:
if node["id"] not in stale and node["id"] in prior:
old = prior[node["id"]]
for key in ("atomic_id", "depends_on", "cdsl_feature_ids"):
if node.get(key) != old.get(key):
raise IncrementalGenerationError(f"Replan changed non-stale node {node['id']}")
return next_spec
@staticmethod
def _result_payload(built: dict[str, Any], *, lifecycle: str, checkpoint: bool) -> dict[str, Any]:
return {
"taskId": built["task_id"], "revisionId": built["revision_id"],
"cdslPath": built["cdsl_path"], "stepPath": built["step_path"], "glbPath": built["glb_path"],
"reportPath": built["report_path"], "parametersPath": built.get("parameters_path"),
"selectorPath": built.get("selector_path"), "edgesPath": built.get("edges_path"), "topologyPath": built.get("topology_path"),
"summary": built.get("summary") or "CDSL checkpoint", "referenceIds": built.get("reference_ids") or [],
"engine": built.get("engine") or "cdsl_only", "qualityStatus": built.get("quality_status") or "",
"qualityPath": built.get("quality_path"), "assumptions": built.get("generation_assumptions") or [],
"snapshotPaths": built.get("snapshot_paths") or [], "snapshotStatus": built.get("snapshot_status") or "unavailable",
"lifecycle": lifecycle, "checkpoint": checkpoint,
}
-239
View File
@@ -1,239 +0,0 @@
from __future__ import annotations
import json
import re
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from typing import Any
_WORD = re.compile(r"[a-z0-9]+(?:[-'][a-z0-9]+)*")
_REPLACEMENT_INTENT = (
"replace the whole part",
"replace entire part",
"replace the part",
"replace this part",
"start over as",
"replace with a",
"替换整个零件",
"替换整个部件",
"替换零件",
"重新生成一个",
"改成一个新的",
)
def _normalize(value: str) -> str:
return unicodedata.normalize("NFKC", str(value or "")).casefold().strip()
def _matches(text: str, phrase: str) -> bool:
normalized_text = _normalize(text)
normalized_phrase = _normalize(phrase)
if not normalized_phrase:
return False
if any("\u4e00" <= char <= "\u9fff" for char in normalized_phrase):
return normalized_phrase in normalized_text
words = _WORD.findall(normalized_phrase)
if not words:
return normalized_phrase in normalized_text
return bool(re.search(r"(?<![a-z0-9])" + r"\s+".join(map(re.escape, words)) + r"(?![a-z0-9])", normalized_text))
def _specificity(phrase: str) -> int:
normalized = _normalize(phrase)
chinese = [char for char in normalized if "\u4e00" <= char <= "\u9fff"]
return len(chinese) if chinese else len(_WORD.findall(normalized))
@dataclass(frozen=True)
class PartSkill:
id: str
kind: str
title: str
priority: int
triggers: tuple[str, ...]
exclude: tuple[str, ...]
bridge: str
source: str
related: tuple[str, ...]
@classmethod
def from_mapping(cls, value: dict[str, Any]) -> "PartSkill":
return cls(
id=str(value["id"]),
kind=str(value["kind"]),
title=str(value.get("title") or value["id"]),
priority=int(value.get("priority") or 0),
triggers=tuple(str(item) for item in value.get("triggers") or []),
exclude=tuple(str(item) for item in value.get("exclude") or []),
bridge=str(value["bridge"]),
source=str(value["source"]),
related=tuple(str(item) for item in value.get("related") or []),
)
class PartSkillLibrary:
"""Deterministic local selector for CDSL-specific part planning guidance."""
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 [])
self.by_id = {skill.id: skill for skill in self.skills}
if len(self.by_id) != len(self.skills):
raise ValueError("Part skill catalog contains duplicate ids")
for skill in self.skills:
for relative in (skill.bridge, skill.source):
if not (self.root / relative).is_file():
raise ValueError(f"Part skill {skill.id} references missing file: {relative}")
def _matched(self, skill: PartSkill, request: str) -> tuple[int, tuple[str, ...]]:
if any(_matches(request, phrase) for phrase in skill.exclude):
return 0, ()
triggers = tuple(trigger for trigger in skill.triggers if _matches(request, trigger))
if not triggers:
return 0, ()
# Longer phrases are more specific than generic words such as "shaft"
# or "hole". Priority breaks ties between equally specific skills.
score = sum(max(1, _specificity(trigger)) * 10 for trigger in triggers)
return score + skill.priority, triggers
def _record(self, skill: PartSkill, *, selection: str, matched: tuple[str, ...] = ()) -> dict[str, Any]:
return {
"id": skill.id,
"kind": skill.kind,
"category": skill.kind,
"title": skill.title,
"version": self.catalog_version,
"summary": skill.title,
"source": skill.source,
"bridge": skill.bridge,
"selection": selection,
"matched_triggers": list(matched),
}
def select(self, request: str, inherited_ids: list[str] | tuple[str, ...] = ()) -> dict[str, Any]:
inherited = [self.by_id[item] for item in inherited_ids if item in self.by_id]
scores = [(self._matched(skill, request), skill) for skill in self.skills]
planning_matches = sorted(
((score, triggers, skill) for (score, triggers), skill in scores if skill.kind == "planning" and score),
key=lambda item: (-item[0], -item[2].priority, item[2].id),
)
inherited_planning = [skill for skill in inherited if skill.kind == "planning"]
planning: list[PartSkill] = inherited_planning[: self.max_planning]
conflict: dict[str, Any] | None = None
replacement_requested = any(_matches(request, phrase) for phrase in _REPLACEMENT_INTENT)
if not planning and planning_matches:
planning = [planning_matches[0][2]]
elif planning and planning_matches and planning_matches[0][2].id not in {skill.id for skill in planning}:
if replacement_requested:
planning = [planning_matches[0][2]]
else:
conflict = {
"current": planning[0].id,
"matched": planning_matches[0][2].id,
"message": "A different primary part family matched this revision request; preserve the current family unless replacement is explicit.",
}
selected_ids = {skill.id for skill in planning}
inherited_planning_ids = {skill.id for skill in inherited_planning}
records = [
self._record(
skill,
selection="inherited" if skill.id in inherited_planning_ids else "selected",
matched=next((triggers for score, triggers, candidate in planning_matches if candidate.id == skill.id), ()),
)
for skill in planning
]
support: list[tuple[int, int, int, str, PartSkill, tuple[str, ...]]] = []
related_ids = {related for skill in planning for related in skill.related}
direct_support: list[tuple[int, PartSkill, tuple[str, ...]]] = []
for (score, triggers), skill in scores:
if skill.kind == "planning" or not score:
continue
direct_support.append((score, skill, triggers))
bonus = 100 if skill.id in related_ids else 0
# Direct request matches always win over inherited context, even
# when the current planning family's catalog does not name them.
support.append((3, bonus + score, skill.priority, skill.id, skill, triggers))
direct_related_ids = {
related
for _, skill, _ in direct_support
for related in skill.related
if related in self.by_id and self.by_id[related].kind != "planning"
}
known_support_ids = {item[3] for item in support}
for related_id in sorted(direct_related_ids - known_support_ids):
skill = self.by_id[related_id]
planning_bonus = 25 if skill.id in related_ids else 0
support.append((2, 50 + planning_bonus, skill.priority, skill.id, skill, ()))
known_support_ids.add(skill.id)
for skill in inherited:
if skill.kind != "planning" and skill.id not in known_support_ids:
# Keep prior guidance only after direct request matches and
# their catalog-declared supporting rules.
support.append((1, 0, skill.priority, skill.id, skill, ()))
known_support_ids.add(skill.id)
support.sort(key=lambda item: (-item[0], -item[1], -item[2], item[3]))
for _, _, _, _, skill, triggers in support:
if len(records) - len(planning) >= self.max_support or skill.id in selected_ids:
continue
selected_ids.add(skill.id)
inherited_marker = "inherited" if skill in inherited else "selected"
records.append(self._record(skill, selection=inherited_marker, matched=triggers))
return {
"schema_version": "1.0",
"request": str(request or ""),
"skills": records,
"skill_ids": [record["id"] for record in records],
"planning_ids": [record["id"] for record in records if record["kind"] == "planning"],
"support_ids": [record["id"] for record in records if record["kind"] != "planning"],
"inherited_skill_ids": [skill.id for skill in inherited],
"replacement_requested": replacement_requested,
"conflict": conflict,
}
def inherited_from_task(self, task: dict[str, Any] | None) -> list[str]:
revision_id = str((task or {}).get("current_revision") or "")
revisions = (task or {}).get("revisions") or []
revision = next((item for item in revisions if item.get("revision_id") == revision_id), None)
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
return []
def render_context(self, selection: dict[str, Any]) -> str:
records = selection.get("skills") or []
if not records:
return "No part-family skill matched this request. Use the CDSL schema and library only."
sections = [
"Selected CDSL part-skill guidance (planning context only):",
"Part skill guidance never overrides the user request or the authoritative CDSL schema/runtime.",
"Translate the guidance into valid CDSL; do not emit build123d source or invent atomics.",
]
for record in records:
path = self.root / str(record["bridge"])
sections.append(f"\n[{record['id']}] ({record['selection']})\n{path.read_text(encoding='utf-8').strip()}")
if selection.get("conflict"):
sections.append("\nPrimary-family conflict: preserve the current task family. Ask one concise clarification question and do not generate until the user explicitly requests whole-part replacement or confirms the current family.")
return "\n".join(sections)
def audit(self, selection: dict[str, Any], cdsl: dict[str, Any], assumptions: list[str] | None = None) -> dict[str, Any]:
# 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", ""),
"structural_intent": selection.get("request", ""),
"skill_ids": list(selection.get("skill_ids") or []),
"skills": list(selection.get("skills") or []),
"inherited_skill_ids": list(selection.get("inherited_skill_ids") or []),
"conflict": selection.get("conflict"),
"assumptions": [str(item) for item in assumptions or []],
}
-329
View File
@@ -1,329 +0,0 @@
"""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_width", "overall_height",
"overall_diameter", "through_condition",
})
FEATURE_RULE_TYPES = frozenset({"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 []
# Runtime records may carry the current operation in feature_id
# while owner_feature_ids identify the actual geometry owner. Use
# ownership when present so inherited faces do not pollute a
# feature-local measurement.
matches = feature_id in owners if owners else record.get("feature_id") == feature_id
if matches:
geometry = record.get("geometry")
if isinstance(geometry, dict):
candidates.append(geometry.get("bbox_mm"))
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 _circle_feature_bbox(feature: dict[str, Any] | None, sketch: dict[str, Any] | None) -> dict[str, Any] | None:
"""Derive a stable world-space bbox for a circular extrude feature."""
if not isinstance(feature, dict) or not isinstance(sketch, dict):
return None
if str(feature.get("atomic_id") or "") != "extrude_add_blind":
return None
profile = sketch.get("profile")
workplane = sketch.get("workplane")
params = feature.get("params")
if not isinstance(profile, dict) or profile.get("type") != "circle" or not isinstance(workplane, dict):
return None
center_local = profile.get("center")
radius = profile.get("radius_mm")
origin = workplane.get("origin_mm")
x_dir = workplane.get("x_dir")
y_dir = workplane.get("y_dir")
normal = workplane.get("normal")
distance = (params or {}).get("distance_mm") if isinstance(params, dict) else None
if not isinstance(center_local, list) or len(center_local) < 2 or not _finite_number(radius):
return None
if not all(isinstance(value, list) and len(value) >= 3 for value in (origin, x_dir, y_dir, normal)) or not _finite_number(distance):
return None
center = [
float(origin[index]) + float(center_local[0]) * float(x_dir[index]) + float(center_local[1]) * float(y_dir[index])
for index in range(3)
]
points = []
for axis_x in (-1.0, 1.0):
for axis_y in (-1.0, 1.0):
cross_section = [
center[index]
+ axis_x * float(radius) * float(x_dir[index])
+ axis_y * float(radius) * float(y_dir[index])
for index in range(3)
]
points.append(cross_section)
points.append([
cross_section[index] + float(distance) * float(normal[index])
for index in range(3)
])
minimum = [min(point[index] for point in points) for index in range(3)]
maximum = [max(point[index] for point in points) for index in range(3)]
return {
"min": minimum,
"max": maximum,
"dimensions": [maximum[index] - minimum[index] for index in range(3)],
}
def _finite_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value))
def _validate_expected(kind: str, expected: Any, index: int) -> None:
scalar_types = {
"solid_count", "feature_count", "hole_count", "hole_diameter",
"overall_length", "overall_width", "overall_height", "overall_diameter",
}
if kind in scalar_types and not _finite_number(expected):
raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}")
if kind == "bbox":
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":
derived = _circle_feature_bbox(feature, sketch)
if target and derived is not None:
return derived, f"cdsl.features.{target}.sketch + params"
value = _bbox_value(engine_result, target)
source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm"
return value, source
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_width":
return (dimensions[1] if dimensions and len(dimensions) > 1 else None), "runtime.bbox_mm[y]"
if kind == "overall_height":
return (dimensions[2] if dimensions and len(dimensions) > 2 else None), "runtime.bbox_mm[z]"
if kind == "overall_diameter":
if target and circles:
return max(circle["radius_mm"] for circle in circles) * 2.0, f"cdsl.features.{target}.sketch"
return (min(dimensions) if dimensions else None), "runtime.bbox_mm"
if kind == "through_condition":
end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {}
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)},
}
+106
View File
@@ -330,3 +330,109 @@ def render_checkpoint(
}
(output_dir / "render-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
return manifest
def render_section(
settings: Settings,
*,
step_path: Path,
output_dir: Path,
origin_mm: list[float],
normal: list[float],
) -> dict[str, Any]:
"""Create an actual OpenCascade section drawing, not a clipped viewport.
It intentionally uses the same deterministic Pillow raster path as the
seven canonical review views. The output contains compact contour evidence
suitable for a multimodal author without sending a STEP file or full B-rep.
"""
del settings
ready, detail = renderer_status()
if not ready:
raise ReviewRenderError(detail)
if not step_path.is_file():
raise ReviewRenderError(f"STEP section source is missing: {step_path.name}")
origin = _number_list(origin_mm, size=3)
direction = _number_list(normal, size=3)
if origin is None or direction is None:
raise ReviewRenderError("Section origin_mm and normal must each contain three finite numbers")
length = math.sqrt(sum(value * value for value in direction))
if length <= 1e-9:
raise ReviewRenderError("Section normal must not be zero")
normal_unit = [value / length for value in direction]
_, _, import_step = _render_modules()
try:
b3d = importlib.import_module("build123d")
shape = import_step(str(step_path))
plane = b3d.Plane(origin=b3d.Vector(*origin), z_dir=b3d.Vector(*normal_unit))
# build123d exposes section as a module-level part operation. Older
# code assumed a Solid.section instance method, which does not exist
# in the supported 0.11 runtime and made an otherwise successful CAD
# task fail while collecting optional author evidence.
section = b3d.section(shape, section_by=plane)
edges = list(section.edges())
except Exception as error:
raise ReviewRenderError(f"OpenCascade section operation failed: {error}") from error
if not edges:
raise ReviewRenderError("Section plane does not intersect the model")
# Choose a deterministic right-handed in-plane frame. Projecting exact
# OCC section edges into this frame preserves holes and internal contours.
seed = [0.0, 0.0, 1.0] if abs(normal_unit[2]) < 0.9 else [0.0, 1.0, 0.0]
x_axis = [
seed[1] * normal_unit[2] - seed[2] * normal_unit[1],
seed[2] * normal_unit[0] - seed[0] * normal_unit[2],
seed[0] * normal_unit[1] - seed[1] * normal_unit[0],
]
x_length = math.sqrt(sum(value * value for value in x_axis))
x_axis = [value / x_length for value in x_axis]
y_axis = [
normal_unit[1] * x_axis[2] - normal_unit[2] * x_axis[1],
normal_unit[2] * x_axis[0] - normal_unit[0] * x_axis[2],
normal_unit[0] * x_axis[1] - normal_unit[1] * x_axis[0],
]
projected: list[list[tuple[float, float]]] = []
for edge in edges:
try:
count = max(2, min(1024, int(math.ceil(float(edge.length) / 0.25)) + 1))
points = edge.positions([index / (count - 1) for index in range(count)])
except Exception:
points = [edge.position_at(0), edge.position_at(1)]
line: list[tuple[float, float]] = []
for point in points:
offset = [float(point.X) - origin[0], float(point.Y) - origin[1], float(point.Z) - origin[2]]
line.append((sum(offset[index] * x_axis[index] for index in range(3)), sum(offset[index] * y_axis[index] for index in range(3))))
if len(line) >= 2:
projected.append(line)
if not projected:
raise ReviewRenderError("Section operation produced no drawable contours")
xs = [point[0] for line in projected for point in line]
ys = [point[1] for line in projected for point in line]
minimum_x, maximum_x, minimum_y, maximum_y = min(xs), max(xs), min(ys), max(ys)
extent = max(maximum_x - minimum_x, maximum_y - minimum_y, 1.0)
padding = extent * FRAME_PADDING
frame = (minimum_x - padding, maximum_x + padding, minimum_y - padding, maximum_y + padding)
pillow_image, pillow_draw, _ = _render_modules()
image = pillow_image.new("RGB", (RENDER_SIZE, RENDER_SIZE), BACKGROUND_RGB)
draw = pillow_draw.Draw(image)
for line in projected:
pixels = [_pixel(point, frame, RENDER_SIZE) for point in line]
draw.line(pixels, fill=VISIBLE_EDGE_RGB, width=4, joint="curve")
output_dir.mkdir(parents=True, exist_ok=True)
high_path = output_dir / "section-2x.png"
image.save(high_path, optimize=True)
output_path = output_dir / "section.png"
image.resize((REVIEW_SIZE, REVIEW_SIZE), resample=pillow_image.Resampling.LANCZOS).save(output_path, optimize=True)
result = {
"schema_version": "cad.section-render.v1",
"renderer": "python-occ-section-pillow",
"path": str(output_path),
"high_resolution_path": str(high_path),
"plane": {"origin_mm": origin, "normal": normal_unit},
"contour_count": len(projected),
"bounds_mm": [minimum_x, maximum_x, minimum_y, maximum_y],
"resolution": [REVIEW_SIZE, REVIEW_SIZE],
}
(output_dir / "section-manifest.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
return result
+163 -102
View File
@@ -12,6 +12,7 @@ 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}$")
CANDIDATE_ID = re.compile(r"^candidate_[a-z0-9]{12,32}$")
def now_iso() -> str:
@@ -96,15 +97,6 @@ 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,10 +176,14 @@ class WorkspaceStore:
return self._migrate_task(current, path)
task_dir = self.task_dir(tid)
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
source_requirements = str(request or "").strip()
source_path = task_dir / "source-requirements.md"
source_path.write_text(source_requirements + "\n", encoding="utf-8")
record = {
"schema_version": "1.3",
"schema_version": "2.0",
"task_id": tid,
"request": request,
"source_requirements_path": "source-requirements.md",
"created_at": now_iso(),
"updated_at": now_iso(),
"current_revision": "",
@@ -195,9 +191,10 @@ class WorkspaceStore:
"published_revision": "",
"lifecycle": "completed",
"run_id": "",
"generation_spec_path": "",
"run_context_path": "",
"active_node_id": "",
"requirements_path": "",
"agent_state_path": "",
"active_candidate_id": "",
"active_branch_id": "main",
"run_failure_path": "",
"revisions": [],
}
@@ -205,24 +202,42 @@ class WorkspaceStore:
return record
def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]:
"""Add run-state fields lazily without rewriting successful history."""
"""Fill in omitted fields for native autonomous tasks only.
A task from a retired protocol must never be made to look resumable by
rewriting its schema version. In particular, doing so would make an
old revision and its obsolete planning state appear to be a valid
autonomous work head. Historical tasks remain read-only records; the
service startup path marks only *running* legacy tasks as obsolete.
"""
if str(task.get("schema_version") or "") != "2.0":
return task
changed = False
current = str(task.get("current_revision") or "")
defaults = {
"schema_version": "1.3",
"active_revision": current,
"published_revision": current,
"lifecycle": "completed",
"run_id": "",
"generation_spec_path": "",
"run_context_path": "",
"active_node_id": "",
"requirements_path": "",
"source_requirements_path": "",
"completion_checklist_path": "",
"agent_state_path": "",
"active_candidate_id": "",
"active_branch_id": "main",
"run_failure_path": "",
}
for key, value in defaults.items():
if key not in task:
task[key] = value
changed = True
if not str(task.get("source_requirements_path") or ""):
relative = Path("source-requirements.md")
source_path = path.parent / relative
if not source_path.exists():
source_path.write_text(str(task.get("request") or "").strip() + "\n", encoding="utf-8")
task["source_requirements_path"] = relative.as_posix()
changed = True
for revision in task.get("revisions") or ():
if not isinstance(revision, dict):
continue
@@ -267,10 +282,11 @@ class WorkspaceStore:
task.update({
"lifecycle": "running",
"run_id": run_id or new_id("run"),
"active_node_id": "",
"active_candidate_id": "",
"run_failure_path": "",
"request": request or task.get("request") or "",
"active_revision": str(task.get("current_revision") or ""),
"active_branch_id": str(task.get("active_branch_id") or "main"),
"updated_at": now_iso(),
})
write_json(self.task_path(task_id), task)
@@ -291,7 +307,7 @@ class WorkspaceStore:
revision["visibility"] = "final"
task.update({
"lifecycle": lifecycle,
"active_node_id": "",
"active_candidate_id": "",
"run_failure_path": failure_path,
"updated_at": now_iso(),
})
@@ -303,6 +319,8 @@ class WorkspaceStore:
if not revision_id:
task["active_revision"] = ""
task["current_revision"] = ""
if branch_id:
task["active_branch_id"] = branch_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
@@ -317,13 +335,122 @@ class WorkspaceStore:
write_json(self.task_path(task_id), task)
return task
def set_active_node(self, task_id: str, node_id: str) -> dict[str, Any]:
def set_agent_state(self, task_id: str, values: dict[str, Any]) -> dict[str, Any]:
"""Persist enough autonomous-agent state to resume after a restart."""
task = self.ensure_task(task_id, "")
task["active_node_id"] = node_id
relative = Path("agent-state.json")
write_json(self.task_dir(task_id) / relative, values)
task["agent_state_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def read_agent_state(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("agent_state_path") or "")
value = read_json(self.artifact_path(task_id, relative)) if relative else None
return value if isinstance(value, dict) else None
def requirements_document_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "requirements.md"
def read_source_requirements(self, task_id: str) -> str:
"""Return the server-owned, immutable user request for this task.
Older tasks predate the artifact. Their persisted request is a
read-only compatibility fallback; new tasks always have the artifact.
"""
task = self.read_task(task_id) or {}
relative = str(task.get("source_requirements_path") or "")
if relative:
path = self.artifact_path(task_id, relative)
if path.is_file():
return path.read_text(encoding="utf-8")
return str(task.get("request") or "")
def write_requirements_document(self, task_id: str, markdown: str) -> Path:
"""Write the one immutable requirements document for an agent run."""
text = str(markdown or "").strip()
if not text:
raise ValueError("requirements.md must not be empty")
task = self.ensure_task(task_id, "")
relative = Path("requirements.md")
path = self.task_dir(task_id) / relative
if path.exists() or str(task.get("requirements_path") or ""):
raise ValueError("requirements.md is frozen and cannot be rewritten")
path.write_text(text + "\n", encoding="utf-8")
task["requirements_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_requirements_document(self, task_id: str) -> str:
task = self.read_task(task_id) or {}
relative = str(task.get("requirements_path") or "")
if not relative:
return ""
path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else ""
def completion_checklist_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "completion.md"
def write_completion_checklist(self, task_id: str, markdown: str) -> Path:
"""Persist the one immutable, author-owned completion checklist."""
text = str(markdown or "").strip()
if not text:
raise ValueError("completion.md must not be empty")
task = self.ensure_task(task_id, "")
if not self.read_requirements_document(task_id):
raise ValueError("requirements.md must be written before completion.md")
relative = Path("completion.md")
path = self.task_dir(task_id) / relative
if path.exists() or str(task.get("completion_checklist_path") or ""):
raise ValueError("completion.md is frozen and cannot be rewritten")
path.write_text(text + "\n", encoding="utf-8")
task["completion_checklist_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_completion_checklist(self, task_id: str) -> str:
task = self.read_task(task_id) or {}
relative = str(task.get("completion_checklist_path") or "")
if not relative:
return ""
path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else ""
def new_candidate(self, task_id: str) -> tuple[str, Path]:
task = self.ensure_task(task_id, "")
candidate_id = f"candidate_{secrets.token_hex(8)}"
path = self.task_dir(task_id) / "candidates" / candidate_id
path.mkdir(parents=True, exist_ok=False)
task["active_candidate_id"] = candidate_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return candidate_id, path
def candidate_dir(self, task_id: str, candidate_id: str) -> Path:
if not CANDIDATE_ID.fullmatch(str(candidate_id or "")):
raise ValueError("Invalid candidate id")
return self.task_dir(task_id) / "candidates" / candidate_id
def clear_active_candidate(self, task_id: str, candidate_id: str | None = None) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
if candidate_id and str(task.get("active_candidate_id") or "") not in {"", candidate_id}:
raise ValueError("Candidate is not active")
task["active_candidate_id"] = ""
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def append_agent_audit(self, task_id: str, kind: str, payload: dict[str, Any]) -> str:
safe_kind = re.sub(r"[^a-z0-9_-]+", "-", str(kind).lower()).strip("-") or "event"
relative = Path("agent-audit") / f"{now_iso().replace(':', '-').replace('+', '_')}_{safe_kind}_{secrets.token_hex(4)}.json"
write_json(self.task_dir(task_id) / relative, {"recorded_at": now_iso(), "kind": safe_kind, **payload})
return relative.as_posix()
def update_revision_metadata(self, task_id: str, revision_id: str, values: dict[str, Any]) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
@@ -336,21 +463,25 @@ class WorkspaceStore:
def rollback_to_revision(self, task_id: str, revision_id: str, *, branch_id: str) -> dict[str, Any]:
"""Move the generation head without deleting immutable checkpoint artifacts."""
task = self.set_active_revision(task_id, revision_id, branch_id=branch_id)
task = self.ensure_task(task_id, "")
source_branch_id = str(task.get("active_branch_id") or "main")
if revision_id:
target = next(
(item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id),
None,
)
if not isinstance(target, dict) or target.get("status") != "success":
raise ValueError("Active revision must be a successful revision")
children: dict[str, set[str]] = {}
for revision in task.get("revisions") or ():
if not isinstance(revision, dict):
continue
parent = str(revision.get("parent_revision_id") or "")
child = str(revision.get("revision_id") or "")
if parent and child:
if child and str(revision.get("branch_id") or "main") == source_branch_id:
children.setdefault(parent, set()).add(child)
superseded: set[str] = set()
pending = list(children.get(revision_id, set())) if revision_id else [
str(item.get("revision_id") or "")
for item in task.get("revisions") or ()
if isinstance(item, dict) and not str(item.get("parent_revision_id") or "")
]
pending = list(children.get(revision_id, set()))
while pending:
child = pending.pop()
if not child or child in superseded:
@@ -360,6 +491,9 @@ class WorkspaceStore:
for revision in task.get("revisions") or ():
if isinstance(revision, dict) and str(revision.get("revision_id") or "") in superseded and revision.get("visibility") == "checkpoint":
revision["visibility"] = "superseded"
task["active_revision"] = revision_id
task["current_revision"] = revision_id
task["active_branch_id"] = branch_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
@@ -415,37 +549,6 @@ class WorkspaceStore:
write_json(self.task_dir(task_id) / relative, payload)
return relative.as_posix()
def generation_spec_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "generation-spec.json"
def write_generation_spec(self, task_id: str, spec: dict[str, Any]) -> Path:
task = self.ensure_task(task_id, "")
path = self.generation_spec_path(task_id)
write_json(path, spec)
task["generation_spec_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def generation_run_context_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "generation-run-context.json"
def write_generation_run_context(self, task_id: str, context: dict[str, Any]) -> Path:
"""Persist the frozen authoring inputs needed to resume after restart."""
task = self.ensure_task(task_id, "")
path = self.generation_run_context_path(task_id)
write_json(path, context)
task["run_context_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_generation_run_context(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("run_context_path") or "")
context = read_json(self.artifact_path(task_id, relative)) if relative else None
return context if isinstance(context, dict) else None
def running_tasks(self) -> list[dict[str, Any]]:
"""Enumerate durable tasks that need a process-local worker."""
tasks: list[dict[str, Any]] = []
@@ -457,11 +560,6 @@ class WorkspaceStore:
tasks.append(task)
return tasks
def read_generation_spec(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("generation_spec_path") or "")
return read_json(self.artifact_path(task_id, relative)) if relative else None
def revision_dir(self, task_id: str, revision_id: str) -> Path:
return self.task_dir(task_id) / "revisions" / revision_id
@@ -473,43 +571,6 @@ 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 feature_plan_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "feature-plan.json"
def read_feature_plan(self, task_id: str) -> dict[str, Any] | None:
safe_task = safe_task_id(task_id)
plan = read_json(self.feature_plan_path(safe_task))
if not isinstance(plan, dict):
return None
plan_task = str(plan.get("task_id") or "")
if plan_task and plan_task != safe_task:
return None
return plan
def write_feature_plan(self, task_id: str, plan: dict[str, Any]) -> Path:
safe_task = safe_task_id(task_id)
if not isinstance(plan, dict):
raise ValueError("Feature plan must be an object")
plan_task = str(plan.get("task_id") or "")
if plan_task and plan_task != safe_task:
raise ValueError("Feature plan task_id does not match its task")
plan["task_id"] = safe_task
path = self.feature_plan_path(safe_task)
write_json(path, plan)
return path
def revision_topology_path(self, task_id: str, revision_id: str) -> Path | None:
task = self.read_task(task_id)
revision = next(
+211 -9
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import base64
import copy
import json
from pathlib import Path
from typing import Any
@@ -33,10 +34,71 @@ VISUAL_REVIEW_TOOL = {
}
CANDIDATE_REVIEW_TOOL = {
"type": "function",
"function": {
"name": "review_candidate_batch",
"description": "Independently review one staged CAD batch. Never author, modify, or approve CDSL outside this verdict.",
"parameters": {
"type": "object",
"properties": {
"verdict": {"enum": ["accept", "reject"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"batch_goal_status": {"enum": ["achieved", "partial", "failed"]},
"coverage": {
"type": "array",
"items": {
"type": "object",
"properties": {
"item": {"type": "string"},
"status": {"enum": ["complete", "pending", "regressed", "uncertain"]},
"evidence": {"type": "string"},
},
"required": ["item", "status", "evidence"],
"additionalProperties": False,
},
},
"evidence": {"type": "array", "items": {"type": "string"}, "maxItems": 12},
},
"required": ["verdict", "confidence", "batch_goal_status", "coverage", "evidence"],
"additionalProperties": False,
},
},
}
class VisualReviewError(RuntimeError):
pass
class _CandidateReviewFormatError(VisualReviewError):
"""A locally detected invalid reviewer tool result, eligible for one retry."""
def _thinking_tool_choice_rejected(response: httpx.Response) -> bool:
"""Recognize the only compatibility error for which retrying is sound.
Some OpenAI-compatible reasoning endpoints expose function tools but
reject a forced function selection while thinking is enabled. Retrying
without ``tool_choice`` retains the tool contract and the local output
validation below; it merely lets that endpoint choose the sole available
review tool itself.
"""
return (
response.status_code == 400
and "thinking mode does not support this tool_choice" in response.text.lower()
)
def visual_review_tool() -> dict[str, Any]:
"""Normal function-call schema; local validation remains authoritative."""
return json.loads(json.dumps(VISUAL_REVIEW_TOOL))
def candidate_review_tool() -> dict[str, Any]:
return json.loads(json.dumps(CANDIDATE_REVIEW_TOOL))
def _image_part(path: Path) -> dict[str, Any]:
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
media = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
@@ -62,8 +124,8 @@ def _selected_review_views(manifest: dict[str, Any], *, final_checkpoint: bool)
selected.append(item)
if final_checkpoint:
selected.extend(by_id[view_id] for view_id in ("top", "bottom", "front", "back", "left", "right", "isometric") if view_id in by_id)
elif not selected and "isometric" in by_id:
selected.append(by_id["isometric"])
else:
selected.extend(by_id[view_id] for view_id in ("top", "front", "right", "isometric") if view_id in by_id)
return selected or views[:1]
@@ -71,8 +133,9 @@ async def review_checkpoint(
settings: Settings,
*,
manifest: dict[str, Any],
requirements: list[dict[str, Any]],
node_id: str,
requirements: str | list[dict[str, Any]],
source_requirements: str = "",
node_id: str = "final",
deterministic_report: dict[str, Any],
source_images: list[Path] | None = None,
final_checkpoint: bool = False,
@@ -87,13 +150,14 @@ async def review_checkpoint(
"text": json.dumps({
"node_id": node_id,
"requirements": requirements,
"source_requirements": source_requirements,
"deterministic_report": deterministic_report,
"render_manifest": {
"renderer": manifest.get("renderer"),
"source": manifest.get("source"),
"views": [{"id": item.get("id"), "camera": item.get("camera"), "diagnostics": item.get("diagnostics")} for item in views],
},
"instruction": "Identify visible missing geometry, wrong silhouette, orientation, or proportion. Do not infer hidden dimensions. Return repair only for an observable issue.",
"instruction": "Identify visible missing geometry, wrong silhouette, orientation, or proportion. First reconcile every claimed structural element with final_model_evidence: never claim a feature, mirrored copy, slot, cut, or chamfer exists when it is absent from that evidence. Do not infer hidden dimensions. Source requirements are the original user contract. Frozen requirements may add measurable assumptions but may never remove, replace, or weaken source requirements. Return pass only when both contracts are satisfied by the views and evidence; return warning or repair for any unsatisfied or downgraded source requirement.",
}, ensure_ascii=False),
}]
content.extend(_image_part(path) for path in paths)
@@ -102,9 +166,7 @@ async def review_checkpoint(
for path in (source_images or [])[:2]:
if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}:
content.append(_image_part(path))
tool = json.loads(json.dumps(VISUAL_REVIEW_TOOL))
if model.strict_tool_schema:
tool["function"]["strict"] = True
tool = visual_review_tool()
payload = {
"model": model.id,
"messages": [
@@ -115,9 +177,17 @@ async def review_checkpoint(
"tool_choice": {"type": "function", "function": {"name": "review_rendered_checkpoint"}},
"temperature": 0,
}
payload.update(provider.chat_completion_options)
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client:
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload)
if _thinking_tool_choice_rejected(response):
# Do not remove the tool itself and do not accept prose as a
# fallback. This compatibility retry is valid only because the
# reviewer receives one callable tool and its response is still
# verified by the strict local validator below.
payload.pop("tool_choice", None)
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload)
if response.status_code >= 400:
raise VisualReviewError(f"Visual review request failed ({response.status_code}): {response.text[:500]}")
try:
@@ -127,7 +197,8 @@ async def review_checkpoint(
result = json.loads(call["function"]["arguments"])
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
raise VisualReviewError("Visual reviewer did not return a valid review tool call") from error
if not isinstance(result, dict) or result.get("verdict") not in {"pass", "warning", "repair"}:
allowed_fields = {"verdict", "confidence", "affected_node_ids", "requirement_ids", "evidence"}
if not isinstance(result, dict) or set(result) != allowed_fields or result.get("verdict") not in {"pass", "warning", "repair"}:
raise VisualReviewError("Visual reviewer returned an invalid verdict")
try:
confidence = float(result.get("confidence"))
@@ -139,3 +210,134 @@ async def review_checkpoint(
if not isinstance(result.get(key), list) or not all(isinstance(item, str) for item in result[key]):
raise VisualReviewError(f"Visual reviewer returned an invalid {key}")
return {"schema_version": "cad.visual-review.v1", "node_id": node_id, "model": model.id, **result}
async def review_candidate_batch(
settings: Settings,
*,
manifest: dict[str, Any],
requirements: str,
source_requirements: str = "",
checklist: list[str],
batch_goal: str,
deterministic_report: dict[str, Any],
node_id: str,
) -> dict[str, Any]:
"""Review a staged batch against its local goal and the frozen checklist."""
provider, model = settings.resolve_review_model()
views = _selected_review_views(manifest, final_checkpoint=False)
paths = [Path(str(item.get("path") or "")) for item in views]
if not paths or not all(path.is_file() for path in paths):
raise VisualReviewError("Candidate review render manifest references missing image files")
content: list[dict[str, Any]] = [{
"type": "text",
"text": json.dumps({
"node_id": node_id,
"source_requirements": source_requirements,
"frozen_requirements": requirements,
"completion_checklist": checklist,
"batch_goal": batch_goal,
"deterministic_report": deterministic_report,
"instruction": (
"Assess this staged batch, not overall task completion. Accept only if the batch goal is achieved "
"without visibly contradicting source requirements, frozen requirements, or deterministic evidence. "
"Source requirements are the original user contract; frozen requirements may add measurable assumptions "
"but may not remove, replace, or weaken the source contract. Reject when a batch visibly downgrades or "
"contradicts source intent. An incomplete but coherent "
"intermediate model may be accepted when its batch goal is achieved. Return one coverage row for every "
"completion checklist item, preserving exact item text. Use pending for future work and regressed when "
"this batch breaks previously achieved work. Reject on disconnected geometry when the requirements call "
"for one body, wrong orientation, visibly wrong geometry, or a failed batch goal."
),
"render_manifest": {
"renderer": manifest.get("renderer"),
"source": manifest.get("source"),
"views": [{"id": item.get("id"), "camera": item.get("camera")} for item in views],
},
}, ensure_ascii=False),
}]
content.extend(_image_part(path) for path in paths)
payload = {
"model": model.id,
"messages": [
{"role": "system", "content": "You are an independent CAD batch reviewer. You may only call review_candidate_batch."},
{"role": "user", "content": content},
],
"tools": [candidate_review_tool()],
"tool_choice": {"type": "function", "function": {"name": "review_candidate_batch"}},
"temperature": 0,
}
payload.update(provider.chat_completion_options)
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
async def request_review(client: httpx.AsyncClient, request_payload: dict[str, Any]) -> httpx.Response:
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload)
if _thinking_tool_choice_rejected(response):
request_payload.pop("tool_choice", None)
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload)
if response.status_code >= 400:
raise VisualReviewError(f"Candidate visual review request failed ({response.status_code}): {response.text[:500]}")
return response
def validate_response(response: httpx.Response) -> dict[str, Any]:
try:
call = response.json()["choices"][0]["message"]["tool_calls"][0]
if call["function"]["name"] != "review_candidate_batch":
raise KeyError("wrong tool")
result = json.loads(call["function"]["arguments"])
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
raise _CandidateReviewFormatError("Candidate reviewer did not return a valid review tool call") from error
allowed = {"verdict", "confidence", "batch_goal_status", "coverage", "evidence"}
if not isinstance(result, dict) or set(result) != allowed or result.get("verdict") not in {"accept", "reject"}:
raise _CandidateReviewFormatError("Candidate reviewer returned an invalid verdict")
try:
confidence = float(result.get("confidence"))
except (TypeError, ValueError) as error:
raise _CandidateReviewFormatError("Candidate reviewer returned an invalid confidence") from error
if not 0 <= confidence <= 1:
raise _CandidateReviewFormatError("Candidate reviewer confidence is outside [0, 1]")
if result.get("batch_goal_status") not in {"achieved", "partial", "failed"}:
raise _CandidateReviewFormatError("Candidate reviewer returned an invalid batch goal status")
if result.get("verdict") == "accept" and result.get("batch_goal_status") != "achieved":
raise _CandidateReviewFormatError("Candidate reviewer may accept only an achieved batch goal")
if not isinstance(result.get("evidence"), list) or not all(isinstance(item, str) for item in result["evidence"]):
raise _CandidateReviewFormatError("Candidate reviewer returned invalid evidence")
coverage = result.get("coverage")
if not isinstance(coverage, list) or len(coverage) != len(checklist):
raise _CandidateReviewFormatError("Candidate reviewer must return coverage for every checklist item")
expected = {item.casefold(): item for item in checklist}
seen: set[str] = set()
for item in coverage:
if not isinstance(item, dict) or set(item) != {"item", "status", "evidence"}:
raise _CandidateReviewFormatError("Candidate reviewer returned an invalid coverage row")
key = str(item.get("item") or "").casefold()
if key not in expected or key in seen:
raise _CandidateReviewFormatError("Candidate reviewer coverage does not match the frozen checklist")
if item.get("status") not in {"complete", "pending", "regressed", "uncertain"} or not str(item.get("evidence") or "").strip():
raise _CandidateReviewFormatError("Candidate reviewer returned an incomplete coverage row")
item["item"] = expected[key]
seen.add(key)
return result
async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client:
response = await request_review(client, payload)
try:
result = validate_response(response)
except _CandidateReviewFormatError as error:
retry_payload = copy.deepcopy(payload)
retry_payload["messages"].append({
"role": "user",
"content": (
"Your previous tool call was rejected by the local validator: "
f"{error}. Review the same rendered candidate again. Return only a complete "
"review_candidate_batch tool call with exactly one coverage row for every frozen checklist item."
),
})
result = validate_response(await request_review(client, retry_payload))
return {
"schema_version": "cad.candidate-review.v1",
"node_id": node_id,
"model": model.id,
"batch_goal": batch_goal,
**result,
}
+68 -33
View File
@@ -17,9 +17,6 @@ load_dotenv(BACKEND_ROOT / ".env")
class ProviderModel:
id: str
vision: bool = False
# Strict function schemas are provider/model capabilities, not an
# assumption about every OpenAI-compatible endpoint.
strict_tool_schema: bool = False
@dataclass(frozen=True)
@@ -29,6 +26,9 @@ class ProviderConfig:
base_url: str
api_key: str
models: tuple[ProviderModel, ...]
# Chat Completions uses the legacy flat ``reasoning_effort`` parameter.
# Keep it provider-scoped because compatibility varies by endpoint/model.
reasoning_effort: str = ""
@property
def configured(self) -> bool:
@@ -37,6 +37,12 @@ class ProviderConfig:
def model(self, model_id: str) -> ProviderModel | None:
return next((model for model in self.models if model.id == model_id), None)
@property
def chat_completion_options(self) -> dict[str, str]:
if self.reasoning_effort:
return {"reasoning_effort": self.reasoning_effort}
return {}
@dataclass(frozen=True)
class Settings:
@@ -50,17 +56,21 @@ class Settings:
llm_timeout_s: float
default_provider_id: str
providers: tuple[ProviderConfig, ...]
max_repair_attempts: int = 4
review_provider_id: str = ""
review_model_id: str = ""
node_authoring_attempts: int = 2
node_repair_attempts: int = 2
node_replan_attempts: int = 1
incremental_generation: bool = False
agent_tool_calls_per_cycle: int = 12
agent_candidate_attempts_per_head: int = 3
agent_consecutive_no_progress_limit: int = 6
agent_format_error_repeat_limit: int = 3
agent_max_features_per_fragment: int = 6
agent_context_char_limit: int = 14000
agent_render_cache: bool = True
autonomous_generation: bool = True
resume_running_tasks_on_startup: bool = True
@property
def llm_configured(self) -> bool:
return self.provider_for(self.default_provider_id) is not None
return any(provider.configured for provider in self.providers)
def provider_for(self, provider_id: str | None) -> ProviderConfig | None:
requested = str(provider_id or self.default_provider_id).strip().lower()
@@ -70,7 +80,14 @@ class Settings:
provider = self.provider_for(provider_id)
if provider is None:
raise ValueError("The selected model provider is not configured")
selected = str(model_id or "").strip() or provider.models[0].id
# A caller that supplies neither value is asking for the configured
# application default, not the first model listed by that provider.
# The latter made CDSL_DEFAULT_MODEL ineffective and silently routed
# new runs to an unintended author model.
selected = str(model_id or "").strip()
if not selected and not str(provider_id or "").strip():
selected = self.llm_model
selected = selected or provider.models[0].id
model = provider.model(selected)
if model is None:
raise ValueError("The selected model is not enabled for this provider")
@@ -92,28 +109,41 @@ class Settings:
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model")
return provider, model
def _enabled_model_ids(value: str) -> set[str]:
return {item.strip() for item in value.split(",") if item.strip()}
def resolve_independent_review_model(self, author_provider: ProviderConfig, author_model: ProviderModel) -> tuple[ProviderConfig, ProviderModel]:
"""Require the candidate judge to be a separately configured model."""
provider, model = self.resolve_review_model()
if provider.id == author_provider.id and model.id == author_model.id:
raise ValueError("CDSL_REVIEW_PROVIDER/CDSL_REVIEW_MODEL must differ from the autonomous author model")
return provider, model
def _as_bool(value: str) -> bool:
def _reasoning_effort(value: str) -> str:
effort = value.strip().lower()
allowed = {"none", "minimal", "low", "medium", "high", "xhigh", "max"}
if effort and effort not in allowed:
raise ValueError(
"CDSL_*_REASONING_EFFORT must be one of "
f"{', '.join(sorted(allowed))}"
)
return effort
def _env_flag(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None or not value.strip():
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _models(
value: str,
vision_value: str = "",
strict_value: str = "",
strict_all: bool = False,
) -> tuple[ProviderModel, ...]:
vision_ids = {item.strip() for item in vision_value.split(",") if item.strip()}
strict_ids = _enabled_model_ids(strict_value)
return tuple(
ProviderModel(
id=item,
vision=item in vision_ids,
strict_tool_schema=strict_all or item in strict_ids,
)
for item in (part.strip() for part in value.split(","))
if item
@@ -128,16 +158,13 @@ def _provider(prefix: str, provider_id: str, label: str, default_base_url: str,
api_key = os.getenv(f"CDSL_{prefix}_API_KEY", os.getenv("CDSL_LLM_API_KEY", "") if legacy else "")
model_list = os.getenv(f"CDSL_{prefix}_MODELS", os.getenv("CDSL_LLM_MODEL", default_model) if legacy else default_model)
vision_models = os.getenv(f"CDSL_{prefix}_VISION_MODELS", "")
# A provider-wide switch is convenient for a verified endpoint. The model
# list lets mixed capability deployments opt in only selected models.
strict_all = _as_bool(os.getenv(f"CDSL_{prefix}_STRICT_TOOL_SCHEMA", ""))
strict_models = os.getenv(f"CDSL_{prefix}_STRICT_TOOL_MODELS", "")
return ProviderConfig(
provider_id,
label,
base_url,
api_key,
_models(model_list, vision_models, strict_models, strict_all),
id=provider_id,
label=label,
base_url=base_url,
api_key=api_key,
models=_models(model_list, vision_models),
reasoning_effort=_reasoning_effort(os.getenv(f"CDSL_{prefix}_REASONING_EFFORT", "")),
)
@@ -151,6 +178,7 @@ def get_settings() -> Settings:
default_provider_id = os.getenv("CDSL_DEFAULT_PROVIDER", "deepseek").strip().lower() or "deepseek"
default_provider = next((item for item in providers if item.id == default_provider_id), providers[0])
default_model = os.getenv("CDSL_DEFAULT_MODEL", "").strip() or (default_provider.models[0].id if default_provider.models else "")
llm_timeout_s = float(os.getenv("CDSL_LLM_TIMEOUT_S", "90"))
return Settings(
task_root=data_root / "tasks",
conversation_root=data_root / "conversations",
@@ -159,14 +187,21 @@ def get_settings() -> Settings:
llm_base_url=default_provider.base_url,
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"))),
llm_timeout_s=llm_timeout_s,
default_provider_id=default_provider_id,
providers=providers,
review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(),
review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(),
node_authoring_attempts=max(1, int(os.getenv("CDSL_NODE_AUTHORING_ATTEMPTS", "2"))),
node_repair_attempts=max(0, int(os.getenv("CDSL_NODE_REPAIR_ATTEMPTS", "2"))),
node_replan_attempts=max(0, int(os.getenv("CDSL_NODE_REPLAN_ATTEMPTS", "1"))),
incremental_generation=_as_bool(os.getenv("CDSL_INCREMENTAL_GENERATION", "1")),
agent_tool_calls_per_cycle=max(1, int(os.getenv("CDSL_AGENT_TOOL_CALLS_PER_CYCLE", "12"))),
agent_candidate_attempts_per_head=max(1, int(os.getenv("CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD", "3"))),
agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))),
agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))),
agent_max_features_per_fragment=max(1, min(6, int(os.getenv("CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT", "6")))),
agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))),
agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True),
autonomous_generation=True,
# Production instances recover durable runs by default. Test workers
# can disable this before startup to guarantee they touch only tasks
# explicitly created by that worker.
resume_running_tasks_on_startup=_env_flag("CDSL_RESUME_RUNNING_TASKS", True),
)