更新功能

This commit is contained in:
2026-08-26 14:13:11 +08:00
parent beaa5dd9fe
commit a508e6f07e
47 changed files with 5616 additions and 520 deletions
+7 -3
View File
@@ -1,6 +1,6 @@
# Default provider. Only providers with an API key are exposed to the UI.
CDSL_DEFAULT_PROVIDER=deepseek
CDSL_DEFAULT_MODEL=deepseek-v4-flash-vision-exp
CDSL_DEFAULT_MODEL=deepseek-v4-flash
# CDSL_DEFAULT_PROVIDER=openai
# CDSL_DEFAULT_MODEL=gpt-5.5
@@ -11,12 +11,16 @@ CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp
CDSL_LLM_TIMEOUT_S=90
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
# Incremental generation uses a separate vision-capable model for checkpoint review.
CDSL_REVIEW_PROVIDER=deepseek
CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp
# Optional OpenAI provider. Comma-separate enabled models; list vision models
# separately so image attachments can be routed safely.
CDSL_OPENAI_BASE_URL=https://api.vip1129.cc/v1
CDSL_OPENAI_API_KEY=sk-6586c229d77de8c421ba98e7eb0d9c6bb10f08ebc796de946ed17cf8d0d7a229
CDSL_OPENAI_MODELS=gpt-5.5
CDSL_OPENAI_VISION_MODELS=gpt-5.5
CDSL_OPENAI_MODELS=gpt-5.5,gpt-5.6-luna
CDSL_OPENAI_VISION_MODELS=gpt-5.5,gpt-5.6-luna
# Optional Kimi provider.
CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1
+37
View File
@@ -9,3 +9,40 @@ The backend owns the application API and CAD generation workflow:
- `tests/`: Engine, API, and end-to-end generation tests.
Expected development entrypoint: `app.main:app`, served by Uvicorn.
## Incremental Generation Configuration
Incremental generation is enabled by default. It requires a separately
configured vision-capable review model and the Python OpenCascade/Pillow
technical renderer; a run
fails instead of skipping visual review when either is unavailable.
```dotenv
# Authoring provider/model must already be configured as usual.
CDSL_INCREMENTAL_GENERATION=1
# Must name one configured provider and one model listed in that provider's
# CDSL_<PROVIDER>_VISION_MODELS setting. It is intentionally not inferred
# from the authoring model.
CDSL_REVIEW_PROVIDER=openai
CDSL_REVIEW_MODEL=gpt-4.1-mini
CDSL_OPENAI_VISION_MODELS=gpt-4.1-mini
# Install Python rendering dependencies. The renderer reads the revision STEP
# file and creates canonical images without a browser or GPU driver.
pip install -r requirements.txt
# Optional per-node retry budgets.
CDSL_NODE_AUTHORING_ATTEMPTS=2
CDSL_NODE_REPAIR_ATTEMPTS=2
CDSL_NODE_REPLAN_ATTEMPTS=1
```
Every checkpoint is rebuilt from its fully materialized CDSL through the
`cdsl_only` runtime. Checkpoint GLB files are preview-only; STEP, CDSL, and
reports are available only after the task reaches `COMPLETED`.
The generation plan contains semantic node IDs only. The backend derives the
unique CDSL feature and sketch IDs from each node, then writes them during
fragment materialization. This keeps naming and topology ownership stable
without requiring the authoring model to reproduce internal identifiers.
+181 -7
View File
@@ -1,17 +1,22 @@
from __future__ import annotations
import asyncio
import json
import secrets
from typing import Any
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from app.models.contracts import ChatRequest, ConversationPatch, ModifyRequest, ParameterUpdate
from app.services.engine_service import apply_parameter_updates, build_revision
from app.services.engine_service import QualityVerificationError, apply_parameter_updates, build_revision
from app.services.agent_service import AgentService
from app.services.library import CdslLibrary
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id, write_json
from app.services.attachments import attachment_record, classify_upload, extract_document_text
from app.services.image_processing import image_metadata
from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status
from app.services.visual_review import VisualReviewError, review_checkpoint
from app.settings import get_settings
@@ -22,6 +27,99 @@ agent = AgentService(settings, store, library)
app = FastAPI(title="CDSL CAD Agent API", version="0.1.0")
@app.on_event("startup")
async def resume_incremental_generation() -> None:
"""Restore durable generation tasks after a backend process restart."""
await agent.resume_running_tasks()
async def _finalize_controlled_revision(
*,
task_id: str,
previous_revision_id: str,
node_id: str,
built: dict[str, Any],
) -> dict[str, Any]:
"""Publish a deterministic post-completion edit only after vision review."""
revision_id = str(built["revision_id"])
try:
generation_spec = store.read_generation_spec(task_id) or {}
requirements = generation_spec.get("requirements") if isinstance(generation_spec.get("requirements"), list) else []
render_dir = store.revision_dir(task_id, revision_id) / "review"
manifest = await asyncio.to_thread(
render_checkpoint,
settings,
step_path=store.artifact_path(task_id, str(built["step_path"])),
output_dir=render_dir,
)
review = await review_checkpoint(
settings,
manifest=manifest,
requirements=requirements,
node_id=node_id,
deterministic_report={
"quality_status": built.get("quality_status"),
"verification": built.get("verification_summary", {}),
},
final_checkpoint=True,
)
manifest_path = (render_dir / "render-manifest.json").relative_to(store.task_dir(task_id)).as_posix()
review_path = (render_dir / "visual-review.json").relative_to(store.task_dir(task_id)).as_posix()
write_json(render_dir / "visual-review.json", review)
store.update_revision_metadata(task_id, revision_id, {
"render_manifest_path": manifest_path,
"visual_review_path": review_path,
})
if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85:
store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}")
store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.generation-failure.v1",
"node_id": node_id,
"stage": "visual_review",
"error_code": "HIGH_CONFIDENCE_VISUAL_REPAIR",
"message": "; ".join(review.get("evidence") or ["Visual review rejected the controlled edit"]),
"recommended_rollback_revision": previous_revision_id,
})
raise ValueError("Visual review rejected this edit; the model was rolled back to its previous revision")
store.finish_generation(task_id, lifecycle="completed")
return {**built, "visibility": "final", "lifecycle": "completed", "checkpoint": False}
except (ReviewRenderError, VisualReviewError, ValueError):
task = store.read_task(task_id) or {}
if str(task.get("lifecycle") or "") == "running":
store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}")
store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.generation-failure.v1",
"node_id": node_id,
"stage": "visual_review",
"message": "Controlled edit could not complete its required review",
"recommended_rollback_revision": previous_revision_id,
})
raise
def _require_controlled_review_configuration() -> None:
"""Fail before a post-completion edit creates an unreviewed checkpoint."""
settings.resolve_review_model()
ready, detail = renderer_status()
if not ready:
raise ValueError(detail)
def _fail_controlled_run(task_id: str, previous_revision_id: str, node_id: str, error: Exception) -> None:
task = store.read_task(task_id) or {}
if str(task.get("lifecycle") or "") != "running":
return
store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}")
store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.generation-failure.v1",
"node_id": node_id,
"stage": "controlled_build",
"error_code": type(error).__name__.upper(),
"message": str(error),
"recommended_rollback_revision": previous_revision_id,
})
@app.get("/health")
async def health() -> dict[str, Any]:
return {
@@ -50,6 +148,12 @@ async def config() -> dict[str, Any]:
for model in provider.models
],
})
try:
settings.resolve_review_model()
renderer_ready, renderer_detail = renderer_status()
review_error = "" if renderer_ready else renderer_detail
except ValueError as error:
review_error = str(error)
return {
"default_provider": settings.default_provider_id,
"default_model": settings.llm_model,
@@ -58,6 +162,9 @@ async def config() -> dict[str, Any]:
"configured": settings.llm_configured,
"library_samples": library.count(),
"max_repair_attempts": settings.max_repair_attempts,
"incremental_generation": settings.incremental_generation,
"review_configured": not review_error,
"review_error": review_error,
}
@@ -104,8 +211,13 @@ async def upload_conversation_attachment(
filename = file.filename or "attachment"
try:
conversation = safe_conversation_id(conversation_id)
if store.read_conversation(conversation) is None:
current = store.read_conversation(conversation)
if current is None:
raise HTTPException(status_code=404, detail="Conversation not found")
active_task_id = str(current.get("current_task_id") or "")
active_task = store.read_task(active_task_id) if active_task_id else None
if str((active_task or {}).get("lifecycle") or "") == "running":
raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state")
kind = classify_upload(filename, file.content_type or "", len(data))
relative_path, _ = store.write_conversation_upload(conversation, filename, data)
extracted_path = ""
@@ -113,7 +225,8 @@ async def upload_conversation_attachment(
extracted_path = relative_path + ".txt"
extracted = extract_document_text(data)
store.conversation_attachment_path(conversation, extracted_path).write_text(extracted, encoding="utf-8")
record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path)
metadata = image_metadata(data) if kind == "image" else {}
record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path, metadata)
store.add_conversation_attachment(conversation, record)
return JSONResponse(record)
except ValueError as error:
@@ -128,6 +241,11 @@ async def read_task(task_id: str) -> JSONResponse:
raise HTTPException(status_code=400, detail=str(error)) from error
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
# Keep the task endpoint self-contained for a reconnecting UI. The plan is
# immutable within a run and exposes node status, while previews always use
# the active working revision rather than a downloadable artifact.
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
task["generation_plan"] = store.read_generation_spec(task["task_id"])
return JSONResponse(task)
@@ -142,6 +260,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
raise HTTPException(status_code=400, detail=str(error)) from error
if not path.is_file():
raise HTTPException(status_code=404, detail="Artifact not found")
task = store.read_task(safe_id) or {}
parts = artifact_path.split("/")
revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else ""
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
published_revision = str(task.get("published_revision") or "")
active_revision = str(task.get("active_revision") or task.get("current_revision") or "")
if isinstance(revision, dict) and revision_id != published_revision:
# Revisions are private until publication. The currently active
# checkpoint exposes only its GLB inline for the review viewer; an old
# or superseded checkpoint has no public artifact surface at all.
if revision_id != active_revision or path.name != "model.glb":
raise HTTPException(status_code=403, detail="Only the published revision is downloadable")
return FileResponse(path, media_type="model/gltf-binary", headers={"Content-Disposition": "inline"})
return FileResponse(path, filename=path.name)
@@ -152,6 +283,8 @@ async def read_parameters(task_id: str) -> JSONResponse:
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
task = store.read_task(safe_id)
if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""):
raise HTTPException(status_code=403, detail="Checkpoint parameters are not available until publication")
revision_id = str((task or {}).get("current_revision") or "")
revision = next((item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id), None)
relative = str((revision or {}).get("parameters_path") or "")
@@ -170,6 +303,8 @@ async def read_quality(task_id: str) -> JSONResponse:
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
task = store.read_task(safe_id)
if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""):
raise HTTPException(status_code=403, detail="Checkpoint reports are not available until publication")
revision_id = str((task or {}).get("current_revision") or "")
revisions = (task or {}).get("revisions", [])
revision = next((item for item in revisions if item.get("revision_id") == revision_id), None)
@@ -200,11 +335,16 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo
try:
safe_id = safe_task_id(task_id)
task = store.read_task(safe_id)
if str((task or {}).get("lifecycle") or "") == "running":
raise ValueError("CAD task is running; parameter changes are locked")
current_revision_id = str((task or {}).get("current_revision") or "")
current_path = store.current_cdsl_path(safe_id)
if not task or not current_path or not current_revision_id:
raise ValueError("Task has no successful CDSL revision")
updated, _ = apply_parameter_updates(json.loads(current_path.read_text(encoding="utf-8")), payload.values)
if settings.incremental_generation:
_require_controlled_review_configuration()
store.start_generation(safe_id, request=f"Parameter update: {', '.join(payload.values)}")
result = build_revision(
settings=settings,
store=store,
@@ -217,9 +357,21 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo
operation={"type": "parameter_update", "values": payload.values},
part_skills=None,
generation_assumptions=[],
node_id="parameter_update" if settings.incremental_generation else "",
branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main",
visibility="checkpoint" if settings.incremental_generation else "final",
)
if settings.incremental_generation:
result = await _finalize_controlled_revision(
task_id=safe_id,
previous_revision_id=current_revision_id,
node_id="parameter_update",
built=result,
)
return JSONResponse(result)
except ValueError as error:
except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error:
if settings.incremental_generation and "safe_id" in locals() and "current_revision_id" in locals():
_fail_controlled_run(safe_id, current_revision_id, "parameter_update", error)
raise HTTPException(status_code=400, detail=str(error)) from error
@@ -228,7 +380,29 @@ async def modify_task(task_id: str, payload: ModifyRequest) -> JSONResponse:
from app.services.editing import apply_direct_edit
try:
result = apply_direct_edit(settings, store, safe_task_id(task_id), payload.operation, payload.selection, payload.parameters)
safe_id = safe_task_id(task_id)
task = store.read_task(safe_id) or {}
if str(task.get("lifecycle") or "") == "running":
raise ValueError("CAD task is running; topology edits are locked")
previous_revision_id = str(task.get("current_revision") or "")
if settings.incremental_generation:
_require_controlled_review_configuration()
store.start_generation(safe_id, request=f"Direct CDSL edit: {payload.operation}")
result = apply_direct_edit(
settings, store, safe_id, payload.operation, payload.selection, payload.parameters,
node_id="topology_edit" if settings.incremental_generation else "",
branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main",
visibility="checkpoint" if settings.incremental_generation else "final",
)
if settings.incremental_generation:
result = await _finalize_controlled_revision(
task_id=safe_id,
previous_revision_id=previous_revision_id,
node_id="topology_edit",
built=result,
)
return JSONResponse(result)
except ValueError as error:
except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error:
if settings.incremental_generation and "safe_id" in locals() and "previous_revision_id" in locals():
_fail_controlled_run(safe_id, previous_revision_id, "topology_edit", error)
raise HTTPException(status_code=400, detail=str(error)) from error
+1
View File
@@ -56,6 +56,7 @@ class CadResult(BaseModel):
parameters_path: str | None = None
selector_path: str | None = None
edges_path: str | None = None
topology_path: str | None = None
summary: str
reference_ids: list[str] = Field(default_factory=list)
engine: str = "cdsl_only"
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -42,7 +42,16 @@ def extract_document_text(data: bytes) -> str:
return text[:MAX_EXTRACTED_CHARS]
def attachment_record(conversation_id: str, filename: str, mime: str, relative_path: str, data: bytes, kind: str, extracted_path: str = "") -> dict[str, object]:
def attachment_record(
conversation_id: str,
filename: str,
mime: str,
relative_path: str,
data: bytes,
kind: str,
extracted_path: str = "",
metadata: dict[str, object] | None = None,
) -> dict[str, object]:
return {
"id": Path(relative_path).stem,
"conversation_id": conversation_id,
@@ -53,4 +62,5 @@ def attachment_record(conversation_id: str, filename: str, mime: str, relative_p
"size": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
"extracted_path": extracted_path,
**(metadata or {}),
}
+272
View File
@@ -0,0 +1,272 @@
"""Controlled CDSL fragments; the backend, never string concatenation, materialises a model."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
from typing import Any
from app.services.generation_plan import GenerationPlanError
class CdslFragmentError(ValueError):
"""A node fragment cannot be applied safely to its declared base revision."""
def cdsl_sha256(cdsl: dict[str, Any] | None) -> str:
value = cdsl or {"geometry": {"sketches": []}, "features": []}
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
def _items(value: Any, field: str) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise CdslFragmentError(f"{field} must be an array of objects")
return deepcopy(value)
def _ids(items: list[dict[str, Any]], field: str) -> list[str]:
values = [str(item.get("id") or "").strip() for item in items]
if not all(values) or len(values) != len(set(values)):
raise CdslFragmentError(f"{field} must have unique non-empty ids")
return values
def _node(plan: dict[str, Any], node_id: str) -> dict[str, Any]:
node = next((item for item in plan.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None)
if node is None:
raise CdslFragmentError(f"Fragment references an unknown node: {node_id}")
return node
def _single_output_id(node: dict[str, Any], field: str) -> str:
values = [str(item) for item in node.get(field) or () if str(item)]
if len(values) != 1:
raise CdslFragmentError(f"Node {node.get('id')} must declare exactly one {field} output")
return values[0]
def _node_feature_id(plan: dict[str, Any], node_id: str) -> str:
return _single_output_id(_node(plan, node_id), "cdsl_feature_ids")
def _materialize_node_references(value: Any, plan: dict[str, Any]) -> Any:
"""Translate fragment-only node references into CDSL feature references."""
if isinstance(value, list):
return [_materialize_node_references(item, plan) for item in value]
if not isinstance(value, dict):
return value
materialized = {key: _materialize_node_references(item, plan) for key, item in value.items()}
owner_node_id = materialized.pop("owner_node_id", None)
if owner_node_id is not None:
if not isinstance(owner_node_id, str) or not owner_node_id.strip():
raise CdslFragmentError("owner_node_id must be a non-empty plan node id")
materialized["owner_feature_id"] = _node_feature_id(plan, owner_node_id.strip())
source_node_ids = materialized.pop("source_node_ids", None)
if source_node_ids is not None:
if not isinstance(source_node_ids, list) or not source_node_ids or not all(isinstance(item, str) and item.strip() for item in source_node_ids):
raise CdslFragmentError("source_node_ids must be a non-empty array of plan node ids")
materialized["source_feature_ids"] = [_node_feature_id(plan, item.strip()) for item in source_node_ids]
return materialized
def _selector_references(value: Any) -> list[dict[str, Any]]:
"""Collect selector-shaped objects from feature selectors and params."""
found: list[dict[str, Any]] = []
if isinstance(value, dict):
if "kind" in value and ("stable_id" in value or "snapshot_id" in value or "owner_feature_id" in value or "owner_node_id" in value):
found.append(value)
for child in value.values():
found.extend(_selector_references(child))
elif isinstance(value, list):
for child in value:
found.extend(_selector_references(child))
return found
def validate_fragment(
fragment: dict[str, Any],
*,
plan: dict[str, Any],
node_id: str,
base_revision_id: str,
base_cdsl: dict[str, Any] | None,
required_snapshot_id: str = "",
) -> dict[str, Any]:
if not isinstance(fragment, dict):
raise CdslFragmentError("CDSL fragment must be an object")
version = str(fragment.get("schema_version") or "cad.cdsl-fragment.v1")
if version != "cad.cdsl-fragment.v1":
raise CdslFragmentError(f"Unsupported CDSL fragment schema: {version}")
declared_node_id = str(fragment.get("node_id") or "")
if declared_node_id != node_id:
raise CdslFragmentError("Fragment node_id does not match the active node")
if str(fragment.get("base_revision_id") or "") != base_revision_id:
raise CdslFragmentError("Fragment base_revision_id does not match the active revision")
if str(fragment.get("base_cdsl_sha256") or "") != cdsl_sha256(base_cdsl):
raise CdslFragmentError("Fragment base_cdsl_sha256 does not match the active CDSL")
snapshot_id = str(fragment.get("required_snapshot_id") or "")
if required_snapshot_id and snapshot_id != required_snapshot_id:
raise CdslFragmentError("Fragment required_snapshot_id does not match the active topology snapshot")
if not required_snapshot_id and snapshot_id:
raise CdslFragmentError("Fragment cannot use a topology snapshot before one exists")
sketches = _items(fragment.get("add_sketches"), "add_sketches")
features = _items(fragment.get("add_features"), "add_features")
node = _node(plan, node_id)
expected = {str(item) for item in node.get("cdsl_feature_ids") or ()}
expected_sketches = {str(item) for item in node.get("cdsl_sketch_ids") or ()}
if len(expected) != 1:
# Compatibility path for pre-incremental plans which could own more
# than one CDSL feature in a single node.
feature_ids = _ids(features, "add_features")
if set(feature_ids) != expected:
raise CdslFragmentError(
f"Fragment features must exactly match node outputs: expected {sorted(expected)}, got {sorted(feature_ids)}"
)
elif len(features) != 1:
raise CdslFragmentError("An atomic plan node must generate exactly one feature")
if len(expected_sketches) > 1:
sketch_ids = _ids(sketches, "add_sketches")
if set(sketch_ids) != expected_sketches:
raise CdslFragmentError(
f"Fragment sketches must exactly match node outputs: expected {sorted(expected_sketches)}, got {sorted(sketch_ids)}"
)
elif len(sketches) != len(expected_sketches):
expected_description = "one" if expected_sketches else "no"
raise CdslFragmentError(f"This node requires {expected_description} new sketch")
# The planner and fragment author never own CDSL object identities. The
# merger writes node-derived IDs and dependencies after it has validated
# the current immutable plan.
if len(expected) == 1:
features[0]["id"] = next(iter(expected))
if len(expected_sketches) == 1:
sketch_id = next(iter(expected_sketches))
sketches[0]["id"] = sketch_id
features[0]["sketch_id"] = sketch_id
elif not expected_sketches and len(features) == 1:
features[0].pop("sketch_id", None)
features = _materialize_node_references(features, plan)
sketches = _materialize_node_references(sketches, plan)
sketch_ids = _ids(sketches, "add_sketches")
feature_ids = _ids(features, "add_features")
atomic_id = str(node.get("atomic_id") or "")
if len(expected) == 1:
features[0]["atomic_id"] = atomic_id
elif any(str(feature.get("atomic_id") or "") != atomic_id for feature in features):
raise CdslFragmentError(f"Fragment features must use plan atomic_id {atomic_id}")
base_sketch_ids = {
str(item.get("id")) for item in ((base_cdsl or {}).get("geometry") or {}).get("sketches") or ()
if isinstance(item, dict)
}
base_feature_ids = {
str(item.get("id")) for item in (base_cdsl or {}).get("features") or () if isinstance(item, dict)
}
if base_sketch_ids & set(sketch_ids):
raise CdslFragmentError("Fragment attempts to overwrite an existing sketch")
if base_feature_ids & set(feature_ids):
raise CdslFragmentError("Fragment attempts to overwrite a frozen feature")
predecessor_features = {
feature_id
for dependency in node.get("depends_on") or ()
for feature_id in (_node(plan, str(dependency)).get("cdsl_feature_ids") or ())
}
if len(expected) == 1:
features[0]["depends_on"] = sorted(predecessor_features)
available_features = base_feature_ids | set(feature_ids)
available_sketches = base_sketch_ids | set(sketch_ids)
for feature in features:
feature_id = str(feature["id"])
dependencies = feature.get("depends_on") or []
if not isinstance(dependencies, list) or any(str(item) not in available_features for item in dependencies):
raise CdslFragmentError(f"Feature {feature_id} has a missing dependency")
sketch_id = str(feature.get("sketch_id") or "")
if sketch_id and sketch_id not in available_sketches:
raise CdslFragmentError(f"Feature {feature_id} references an unknown sketch")
if required_snapshot_id:
selectors = [selector for feature in features for selector in _selector_references(feature)]
if not selectors:
raise CdslFragmentError("Topology-dependent fragment must provide runtime snapshot selectors")
for selector in selectors:
if str(selector.get("snapshot_id") or "") != required_snapshot_id:
raise CdslFragmentError("Topology selector must reference the active snapshot")
if not str(selector.get("owner_feature_id") or ""):
raise CdslFragmentError("Topology selector must declare owner_feature_id")
if not isinstance(selector.get("geometry"), dict) or not selector["geometry"]:
raise CdslFragmentError("Topology selector must declare a non-empty geometry signature")
declared_dependencies = {
str(dependency)
for feature in features
for dependency in feature.get("depends_on") or ()
}
if not predecessor_features.issubset(declared_dependencies):
raise CdslFragmentError("Fragment does not preserve all plan-node dependencies")
rules = fragment.get("verification_rules") or []
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
raise CdslFragmentError("verification_rules must be an array of objects")
assumptions = fragment.get("assumptions") or []
if not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions):
raise CdslFragmentError("assumptions must be an array of strings")
return {
"schema_version": "cad.cdsl-fragment.v1",
"node_id": node_id,
"base_revision_id": base_revision_id,
"base_cdsl_sha256": cdsl_sha256(base_cdsl),
"required_snapshot_id": snapshot_id,
"add_sketches": sketches,
"add_features": features,
"expected_feature_ids": sorted(expected),
"verification_rules": deepcopy(rules),
"assumptions": [item.strip() for item in assumptions if item.strip()],
}
def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]:
"""Return the complete, append-only document that the runtime must rebuild."""
if base_cdsl is None:
document: dict[str, Any] = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "agent_preflight",
"geometry": {"sketches": []},
"features": [],
}
else:
document = deepcopy(base_cdsl)
geometry = document.setdefault("geometry", {})
if not isinstance(geometry, dict):
raise CdslFragmentError("Base CDSL geometry must be an object")
sketches = geometry.setdefault("sketches", [])
features = document.setdefault("features", [])
if not isinstance(sketches, list) or not isinstance(features, list):
raise CdslFragmentError("Base CDSL has invalid collections")
sketches.extend(deepcopy(fragment["add_sketches"]))
features.extend(deepcopy(fragment["add_features"]))
return document
def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_id: str) -> dict[str, Any]:
"""Persist the runtime's actual selector choices as auditable node evidence."""
values = []
for resolution in engine_result.get("selector_resolution") or ():
if not isinstance(resolution, dict):
continue
selector = resolution.get("selector") if isinstance(resolution.get("selector"), dict) else {}
candidates = resolution.get("candidates") if isinstance(resolution.get("candidates"), (list, tuple)) else []
values.append({
"consumer_node_id": node_id,
"consumer_feature_id": str(resolution.get("feature_id") or ""),
"source_snapshot_id": str(selector.get("snapshot_id") or snapshot_id),
"kind": str(selector.get("kind") or ""),
"owner_feature_id": str(selector.get("owner_feature_id") or ""),
"stable_id": str(selector.get("stable_id") or ""),
"geometry": deepcopy(selector.get("geometry") or {}),
"status": str(resolution.get("status") or ""),
"candidates": deepcopy(list(candidates)),
"score": resolution.get("score"),
"selected": deepcopy(resolution.get("selected") or resolution.get("record") or {}),
})
return {"schema_version": "cad.selector-bindings.v1", "node_id": node_id, "bindings": values}
+7
View File
@@ -150,6 +150,10 @@ def apply_direct_edit(
operation: str,
selection: dict[str, Any],
parameters: dict[str, Any],
*,
node_id: str = "",
branch_id: str = "main",
visibility: str = "final",
) -> dict[str, Any]:
if operation in {"add_chamfer", "add_fillet"}:
raise ValueError("Chamfer and fillet require a stable CDSL edge anchor and are not available for this model yet")
@@ -213,4 +217,7 @@ def apply_direct_edit(
},
part_skills=None,
generation_assumptions=["Legacy profile macros were lowered to direct analytic contours before this edit."] if legacy_profiles_lowered else [],
node_id=node_id,
branch_id=branch_id,
visibility=visibility,
)
+168 -2
View File
@@ -15,6 +15,7 @@ from jsonschema.exceptions import SchemaError
from vendor.cdsl_preview_runtime import step_to_glb
from app.services.quality import evaluate_quality, validate_verification
from app.services.storage import WorkspaceStore, now_iso, write_json
from app.services.cdsl_fragment import selector_bindings
from app.settings import Settings
@@ -299,7 +300,140 @@ def parameter_contract(cdsl: dict[str, Any]) -> dict[str, Any]:
return {"schema_version": "1.0", "parameters": _derived_parameters(cdsl), "source": "derived"}
def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
def topology_snapshot(
engine_result: dict[str, Any],
*,
task_id: str = "",
revision_id: str = "",
preview: dict[str, Any] | None = None,
) -> dict[str, Any]:
raw_records = [
raw for raw in engine_result.get("topology_records") or ()
if isinstance(raw, dict) and raw.get("record_id") and raw.get("kind")
]
active_body_id = next(
(
str(result.get("body_id"))
for result in reversed(engine_result.get("feature_results") or ())
if isinstance(result, dict) and result.get("body_id")
),
"",
)
if not active_body_id:
active_body_id = next(
(
str(raw.get("body_id"))
for raw in reversed(raw_records)
if raw.get("kind") == "body" and raw.get("body_id")
),
"",
)
# The runtime retains historical B-rep records for provenance, but only
# the final body can resolve face, edge, vertex, and body selectors.
active_records = [
raw for raw in raw_records
if not active_body_id
or raw.get("kind") in {"plane", "axis"}
or str(raw.get("body_id") or "") == active_body_id
]
records: list[dict[str, Any]] = []
for raw in active_records:
kind = str(raw.get("kind"))
records.append({
"record_id": str(raw["record_id"]),
"kind": kind,
"feature_id": str(raw.get("feature_id") or ""),
"body_id": str(raw.get("body_id") or "") or None,
"owner_feature_ids": [str(item) for item in raw.get("owner_feature_ids") or () if str(item)],
"geometry": copy.deepcopy(raw.get("geometry") or {}),
"executable": kind in {"body", "face", "edge", "vertex", "plane", "axis"},
"synthetic": False,
})
# Preview/B-rep fallback faces are useful for visual explanation only.
# Keep them in the unified audit snapshot, but never expose them as
# executable selector candidates.
if not any(item.get("kind") == "face" for item in records):
for index, raw in enumerate((preview or {}).get("topology_faces") or ()):
if not isinstance(raw, dict):
continue
record_id = str(raw.get("id") or f"synthetic:face:{index}")
center = raw.get("center")
normal = raw.get("normal")
raw_bbox = raw.get("bbox")
if isinstance(raw_bbox, dict) and isinstance(raw_bbox.get("min"), list) and isinstance(raw_bbox.get("max"), list):
raw_bbox = [*raw_bbox["min"], *raw_bbox["max"]]
geometry = {
"surface_type": str(raw.get("surface_type") or "unknown"),
"center_mm": copy.deepcopy(center) if isinstance(center, list) else None,
"normal": copy.deepcopy(normal) if isinstance(normal, list) else None,
"bbox_mm": copy.deepcopy(raw_bbox or {}),
}
records.append({
"record_id": record_id,
"kind": "face",
"feature_id": "",
"body_id": None,
"owner_feature_ids": [],
"geometry": geometry,
"executable": False,
"synthetic": True,
})
return {
"schema_version": "cad.topology.v1",
"task_id": task_id,
"revision_id": revision_id,
"snapshot_id": f"{task_id}/{revision_id}" if task_id and revision_id else "",
"body_id": active_body_id,
"records": records,
}
def topology_sidecars(
engine_result: dict[str, Any],
preview: dict[str, Any] | None = None,
*,
snapshot: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
runtime_records = (snapshot or topology_snapshot(engine_result)).get("records") or []
runtime_faces = [item for item in runtime_records if item.get("kind") == "face" and item.get("executable", True)]
runtime_edges = [item for item in runtime_records if item.get("kind") == "edge" and item.get("executable", True)]
if runtime_faces or runtime_edges:
references = []
for record in runtime_faces:
geometry = record.get("geometry") or {}
references.append({
"id": str(record.get("record_id")),
"selectorType": "face",
"label": str(geometry.get("surface_type") or "face"),
"center": geometry.get("center_mm"),
"normal": geometry.get("normal"),
"frame": {
"origin_mm": geometry.get("center_mm"),
"normal": geometry.get("normal"),
"x_dir": [1, 0, 0],
"y_dir": [0, 1, 0],
},
"bbox": geometry.get("bbox_mm") or {},
"surface_type": str(geometry.get("surface_type") or "unknown"),
"owner_feature_ids": record.get("owner_feature_ids") or [],
"source": "runtime_snapshot",
"snapshot_id": (snapshot or {}).get("snapshot_id") or "",
"executable": True,
})
edge_records = [
{
**record,
"selectorType": "edge",
"source": "runtime_snapshot",
"snapshot_id": (snapshot or {}).get("snapshot_id") or "",
}
for record in runtime_edges
]
return (
{"schema_version": "cad.topology.v1", "references": references, "edges": edge_records},
{"schema_version": "cad.topology.v1", "edges": edge_records},
)
topology_faces = (preview or {}).get("topology_faces")
if isinstance(topology_faces, list) and topology_faces:
references = []
@@ -322,6 +456,9 @@ def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | N
"surface_type": str(face.get("surface_type") or "unknown"),
"triangle_start": int(face.get("triangle_start") or 0),
"triangle_count": int(face.get("triangle_count") or 0),
"source": "preview",
"synthetic": True,
"executable": False,
})
if references:
return ({"schema_version": "1.1", "references": references}, {"schema_version": "1.0", "edges": []})
@@ -346,6 +483,9 @@ def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | N
"center": point, "normal": normal,
"frame": {"origin_mm": point, "normal": normal, "x_dir": x_dir, "y_dir": y_dir},
"bbox": {"min": minimum, "max": maximum},
"source": "bbox_fallback",
"synthetic": True,
"executable": False,
}
for name, point, normal, x_dir, y_dir in definitions
]
@@ -463,6 +603,11 @@ def build_revision(
repair_attempts: int = 0,
verification: dict[str, Any] | None = None,
reference_records: list[dict[str, Any]] | None = None,
feature_plan: dict[str, Any] | None = None,
node_id: str = "",
fragment: dict[str, Any] | None = None,
branch_id: str = "main",
visibility: str = "final",
) -> dict[str, Any]:
engine = load_engine(settings)
task = store.ensure_task(task_id, request)
@@ -476,12 +621,17 @@ def build_revision(
parameters_path = revision_dir / "parameters.json"
selector_path = revision_dir / "model.selector.json"
edges_path = revision_dir / "model.edges.json"
topology_path = revision_dir / "model.topology.json"
part_skills_path = revision_dir / "part-skills.json"
quality_path = revision_dir / "quality-report.json"
snapshot_manifest_path = revision_dir / "snapshot-manifest.json"
fragment_path = revision_dir / "fragment.json"
selector_bindings_path = revision_dir / "selector-bindings.json"
part_skill_audit = _part_skill_audit(part_skills, request, generation_assumptions)
generation_context = _generation_context(reference_ids, part_skill_audit)
write_json(request_path, {"request": request, "created_at": now_iso()})
if isinstance(feature_plan, dict):
store.write_feature_plan(task["task_id"], feature_plan)
write_json(references_path, {"reference_ids": reference_ids, "records": [item for item in reference_records or [] if isinstance(item, dict)]})
write_json(part_skills_path, part_skill_audit)
write_json(snapshot_manifest_path, {
@@ -499,6 +649,7 @@ def build_revision(
"cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"part_skills_path": part_skills_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"quality_path": quality_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"snapshot_manifest_path": snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
@@ -512,6 +663,11 @@ def build_revision(
"operation": operation or {},
"input_attachments": input_attachments or [],
"repair_attempts": max(0, int(repair_attempts)),
"branch_id": branch_id or "main",
"visibility": visibility if visibility in {"checkpoint", "final", "superseded"} else "checkpoint",
"node_id": node_id,
"fragment_path": fragment_path.relative_to(store.task_dir(task["task_id"])).as_posix() if fragment else "",
"selector_bindings_path": selector_bindings_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
}
if status == "success":
record.update({
@@ -519,6 +675,7 @@ def build_revision(
"glb_path": glb_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"selector_path": selector_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"edges_path": edges_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"engine": engine_name,
})
else:
@@ -538,6 +695,8 @@ def build_revision(
if not isinstance(meta.get("editable_parameters"), list) or not meta["editable_parameters"]:
meta["editable_parameters"] = _derived_parameters(cdsl_copy)
write_json(cdsl_path, cdsl_copy)
if fragment is not None:
write_json(fragment_path, fragment)
write_json(parameters_path, parameter_contract(cdsl_copy))
validate_cdsl(cdsl_copy, engine)
# Product revisions are semantic CDSL artifacts. Do not route them
@@ -547,7 +706,14 @@ def build_revision(
if engine_result.get("engine") != "cdsl_only" or not step_path.is_file() or step_path.stat().st_size == 0:
raise RuntimeError("Engine did not produce a CDSL-only STEP artifact")
preview = step_to_glb(step_path, glb_path)
selector, edges = topology_sidecars(engine_result, preview)
snapshot = topology_snapshot(engine_result, task_id=task["task_id"], revision_id=revision_id, preview=preview)
write_json(topology_path, snapshot)
write_json(selector_bindings_path, selector_bindings(
engine_result,
node_id=node_id,
snapshot_id=str(snapshot.get("snapshot_id") or ""),
))
selector, edges = topology_sidecars(engine_result, preview, snapshot=snapshot)
write_json(selector_path, selector)
write_json(edges_path, edges)
rules = validate_verification(verification, cdsl_copy)
+222
View File
@@ -0,0 +1,222 @@
"""Deterministic feature-plan validation and readiness calculations."""
from __future__ import annotations
from collections import defaultdict, deque
from copy import deepcopy
from typing import Any, Iterable
PLAN_SCHEMA_VERSION = "cad.feature-plan.v1"
NODE_STATUSES = {
"planned",
"ready",
"waiting_for_topology",
"waiting_for_selection",
"blocked",
"executing",
"executed",
"failed",
"completed",
}
_TOPOLOGY_REQUIRED_ATOMICS = {
"fillet",
"chamfer",
"hole_blind",
"hole_countersink",
"hole_counterbore",
"pattern_mirror",
}
class FeaturePlanError(ValueError):
"""A feature plan is not a valid acyclic executable plan."""
def _text(value: Any, field: str, *, required: bool = True) -> str:
result = str(value or "").strip()
if required and not result:
raise FeaturePlanError(f"{field} is required")
return result
def _bool(value: Any) -> bool:
return value is True
def _normalise_node(raw: Any, index: int) -> dict[str, Any]:
if not isinstance(raw, dict):
raise FeaturePlanError(f"nodes[{index}] must be an object")
node_id = _text(raw.get("id"), f"nodes[{index}].id")
atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id")
depends_on = raw.get("depends_on") or []
if not isinstance(depends_on, list) or not all(isinstance(item, str) and item.strip() for item in depends_on):
raise FeaturePlanError(f"nodes[{index}].depends_on must be an array of non-empty strings")
feature_ids = raw.get("cdsl_feature_ids")
if feature_ids is None:
feature_ids = [node_id]
if not isinstance(feature_ids, list) or not feature_ids or not all(isinstance(item, str) and item.strip() for item in feature_ids):
raise FeaturePlanError(f"nodes[{index}].cdsl_feature_ids must be a non-empty string array")
query = raw.get("topology_query")
if query is not None and not isinstance(query, dict):
raise FeaturePlanError(f"nodes[{index}].topology_query must be an object")
requires_topology = _bool(raw.get("requires_topology")) or atomic_id in _TOPOLOGY_REQUIRED_ATOMICS
status = str(raw.get("status") or "planned")
if status not in NODE_STATUSES:
raise FeaturePlanError(f"nodes[{index}].status is unsupported: {status}")
node = {
"id": node_id,
"intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False),
"atomic_id": atomic_id,
"depends_on": list(dict.fromkeys(item.strip() for item in depends_on)),
"requires_topology": requires_topology,
"topology_query": deepcopy(query) if query is not None else None,
"status": status,
"cdsl_feature_ids": list(dict.fromkeys(item.strip() for item in feature_ids)),
}
if raw.get("selector_required") is not None:
node["selector_required"] = _bool(raw.get("selector_required"))
if isinstance(raw.get("failure"), dict):
node["failure"] = deepcopy(raw["failure"])
return node
def validate_feature_plan(plan: dict[str, Any], *, supported_atomic_ids: Iterable[str] = ()) -> dict[str, Any]:
if not isinstance(plan, dict):
raise FeaturePlanError("Feature plan must be an object")
version = str(plan.get("schema_version") or PLAN_SCHEMA_VERSION)
if version != PLAN_SCHEMA_VERSION:
raise FeaturePlanError(f"Unsupported feature plan schema: {version}")
plan_id = _text(plan.get("plan_id"), "plan_id")
task_id = _text(plan.get("task_id"), "task_id", required=False)
topology_snapshot_id = _text(plan.get("topology_snapshot_id"), "topology_snapshot_id", required=False)
raw_nodes = plan.get("nodes")
if not isinstance(raw_nodes, list) or not raw_nodes:
raise FeaturePlanError("Feature plan requires a non-empty nodes array")
nodes = [_normalise_node(item, index) for index, item in enumerate(raw_nodes)]
by_id: dict[str, dict[str, Any]] = {}
node_index: dict[str, int] = {}
feature_owner: dict[str, str] = {}
supported = {str(item) for item in supported_atomic_ids if str(item)}
for index, node in enumerate(nodes):
if node["id"] in by_id:
raise FeaturePlanError(f"Duplicate feature plan node: {node['id']}")
if supported and node["atomic_id"] not in supported:
raise FeaturePlanError(f"Unsupported feature plan atomic_id: {node['atomic_id']}")
by_id[node["id"]] = node
node_index[node["id"]] = index
for feature_id in node["cdsl_feature_ids"]:
if feature_id in feature_owner:
raise FeaturePlanError(f"CDSL feature belongs to multiple plan nodes: {feature_id}")
feature_owner[feature_id] = node["id"]
indegree = {node_id: 0 for node_id in by_id}
children: dict[str, list[str]] = defaultdict(list)
for node in nodes:
for dependency in node["depends_on"]:
if dependency not in by_id:
raise FeaturePlanError(f"Node {node['id']} has missing dependency: {dependency}")
if node_index[dependency] >= node_index[node["id"]]:
raise FeaturePlanError(f"Node {node['id']} must appear after dependency: {dependency}")
indegree[node["id"]] += 1
children[dependency].append(node["id"])
queue = deque(node_id for node_id, degree in indegree.items() if degree == 0)
visited: list[str] = []
while queue:
node_id = queue.popleft()
visited.append(node_id)
for child in children[node_id]:
indegree[child] -= 1
if indegree[child] == 0:
queue.append(child)
if len(visited) != len(nodes):
raise FeaturePlanError("Feature plan contains a dependency cycle")
return {
"schema_version": PLAN_SCHEMA_VERSION,
"plan_id": plan_id,
"task_id": task_id,
"topology_snapshot_id": topology_snapshot_id,
"nodes": nodes,
"feature_owner": feature_owner,
}
def _topology_available(topology: dict[str, Any] | None) -> bool:
if not isinstance(topology, dict):
return False
return any(
isinstance(record, dict)
and record.get("executable", True) is not False
and record.get("kind") in {"face", "edge", "vertex", "body", "plane", "axis"}
for record in topology.get("records") or ()
)
def compute_node_statuses(
plan: dict[str, Any],
*,
cdsl: dict[str, Any] | None = None,
topology: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Return a copy with deterministic status and readiness information."""
checked = validate_feature_plan(plan)
present_features = {
str(feature.get("id"))
for feature in (cdsl or {}).get("features") or ()
if isinstance(feature, dict) and feature.get("id")
}
has_topology = _topology_available(topology)
topology_snapshot_id = str((topology or {}).get("snapshot_id") or "")
selection_ready = bool(topology_snapshot_id and checked.get("topology_snapshot_id") == topology_snapshot_id)
by_id = {node["id"]: node for node in checked["nodes"]}
result_nodes: list[dict[str, Any]] = []
for original in checked["nodes"]:
node = deepcopy(original)
if node["status"] in {"failed", "blocked"}:
result_nodes.append(node)
continue
if all(feature_id in present_features for feature_id in node["cdsl_feature_ids"]):
node["status"] = "completed"
result_nodes.append(node)
continue
dependencies_done = all(by_id[item]["status"] in {"executed", "completed"} or all(
feature_id in present_features for feature_id in by_id[item]["cdsl_feature_ids"]
) for item in node["depends_on"])
if not dependencies_done:
node["status"] = "planned"
elif node["requires_topology"] and not has_topology:
node["status"] = "waiting_for_topology"
elif node["requires_topology"] and not selection_ready:
node["status"] = "waiting_for_selection"
else:
node["status"] = "ready"
result_nodes.append(node)
ready = [node["id"] for node in result_nodes if node["status"] == "ready"]
waiting = [node["id"] for node in result_nodes if node["status"] in {"waiting_for_topology", "waiting_for_selection"}]
blocked = [node["id"] for node in result_nodes if node["status"] == "blocked"]
completed = [node["id"] for node in result_nodes if node["status"] == "completed"]
return {
**checked,
"nodes": result_nodes,
"ready_nodes": ready,
"waiting_nodes": waiting,
"blocked_nodes": blocked,
"completed_nodes": completed,
"complete": len(completed) == len(result_nodes) and not blocked,
}
def plan_feature_ids(plan: dict[str, Any], node_ids: Iterable[str]) -> set[str]:
checked = validate_feature_plan(plan)
wanted = set(node_ids)
return {
feature_id
for node in checked["nodes"]
if node["id"] in wanted
for feature_id in node["cdsl_feature_ids"]
}
def node_for_feature(plan: dict[str, Any], feature_id: str) -> dict[str, Any] | None:
checked = validate_feature_plan(plan)
return next((node for node in checked["nodes"] if feature_id in node["cdsl_feature_ids"]), None)
+228
View File
@@ -0,0 +1,228 @@
"""Strict contracts for persistent, node-by-node CAD generation."""
from __future__ import annotations
from collections import defaultdict, deque
from copy import deepcopy
from hashlib import sha256
import re
from typing import Any, Iterable
from app.services.feature_plan import FeaturePlanError, validate_feature_plan
GENERATION_PLAN_SCHEMA_VERSION = "cad.generation-plan.v2"
BACKEND_ID_STRATEGY = "backend-derived-v1"
REQUIREMENT_SOURCES = {"explicit", "assumption"}
REQUIREMENT_PRIORITIES = {"hard", "soft"}
# Keep this in sync with the engine's profile_schema.json. The plan is
# deliberately atomic: an operation that consumes a profile owns one new
# sketch, while all other operations own none.
SKETCH_REQUIRED_ATOMICS = {
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add",
}
class GenerationPlanError(ValueError):
"""The authoring plan cannot safely drive an incremental build."""
def _text(value: Any, field: str, *, required: bool = True) -> str:
result = str(value or "").strip()
if required and not result:
raise GenerationPlanError(f"{field} is required")
return result
def _string_list(value: Any, field: str, *, required: bool = False) -> list[str]:
if value is None:
value = []
if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value):
raise GenerationPlanError(f"{field} must be an array of non-empty strings")
result = list(dict.fromkeys(item.strip() for item in value))
if required and not result:
raise GenerationPlanError(f"{field} must not be empty")
return result
def _normalise_requirement(raw: Any, index: int) -> dict[str, Any]:
if not isinstance(raw, dict):
raise GenerationPlanError(f"requirements[{index}] must be an object")
source = _text(raw.get("source") or "assumption", f"requirements[{index}].source")
priority = _text(raw.get("priority") or "hard", f"requirements[{index}].priority")
if source not in REQUIREMENT_SOURCES:
raise GenerationPlanError(f"requirements[{index}].source is unsupported: {source}")
if priority not in REQUIREMENT_PRIORITIES:
raise GenerationPlanError(f"requirements[{index}].priority is unsupported: {priority}")
return {
"id": _text(raw.get("id"), f"requirements[{index}].id"),
"source": source,
"priority": priority,
"description": _text(raw.get("description"), f"requirements[{index}].description"),
"value": deepcopy(raw.get("value")),
"unit": _text(raw.get("unit"), f"requirements[{index}].unit", required=False),
"tolerance": deepcopy(raw.get("tolerance")),
}
def _backend_cdsl_id(kind: str, node_id: str) -> str:
"""Create a valid, stable CDSL identifier without trusting model naming."""
slug = re.sub(r"[^A-Za-z0-9_-]+", "_", node_id).strip("_-").lower() or "node"
digest = sha256(node_id.encode("utf-8")).hexdigest()[:8]
return f"{kind}_{slug[:60]}_{digest}"
def _backend_node_outputs(node_id: str, atomic_id: str) -> tuple[list[str], list[str]]:
feature_ids = [_backend_cdsl_id("feature", node_id)]
sketch_ids = [_backend_cdsl_id("sketch", node_id)] if atomic_id in SKETCH_REQUIRED_ATOMICS else []
return feature_ids, sketch_ids
def _stored_plan_ids(raw: dict[str, Any]) -> bool:
"""Retain IDs of plans already materialised by an earlier backend version."""
return str(raw.get("id_strategy") or "") == BACKEND_ID_STRATEGY or isinstance(raw.get("feature_owner"), dict)
def validate_generation_plan(
document: dict[str, Any],
*,
supported_atomic_ids: Iterable[str] = (),
task_id: str = "",
) -> dict[str, Any]:
"""Normalise a planner response and prove every hard requirement is owned."""
if not isinstance(document, dict):
raise GenerationPlanError("Generation plan must be an object")
version = str(document.get("schema_version") or GENERATION_PLAN_SCHEMA_VERSION)
if version != GENERATION_PLAN_SCHEMA_VERSION:
raise GenerationPlanError(f"Unsupported generation plan schema: {version}")
requirements_raw = document.get("requirements")
if not isinstance(requirements_raw, list) or not requirements_raw:
raise GenerationPlanError("Generation plan requires a non-empty requirements array")
requirements = [_normalise_requirement(item, index) for index, item in enumerate(requirements_raw)]
requirement_ids = [item["id"] for item in requirements]
if len(requirement_ids) != len(set(requirement_ids)):
raise GenerationPlanError("Generation plan has duplicate requirement ids")
raw_nodes = document.get("nodes")
if not isinstance(raw_nodes, list) or not raw_nodes:
raise GenerationPlanError("Generation plan requires a non-empty nodes array")
preserve_stored_ids = _stored_plan_ids(document)
feature_nodes: list[dict[str, Any]] = []
node_metadata: dict[str, dict[str, Any]] = {}
sketch_owner: dict[str, str] = {}
for index, raw in enumerate(raw_nodes):
if not isinstance(raw, dict):
raise GenerationPlanError(f"nodes[{index}] must be an object")
node_id = _text(raw.get("id"), f"nodes[{index}].id")
atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id")
if preserve_stored_ids:
feature_ids = _string_list(raw.get("cdsl_feature_ids"), f"nodes[{index}].cdsl_feature_ids", required=True)
sketch_ids = _string_list(raw.get("cdsl_sketch_ids"), f"nodes[{index}].cdsl_sketch_ids")
else:
# New plans own semantic node IDs only. CDSL object IDs are a
# deterministic backend implementation detail, not model output.
feature_ids, sketch_ids = _backend_node_outputs(node_id, atomic_id)
for sketch_id in sketch_ids:
previous = sketch_owner.get(sketch_id)
if previous:
raise GenerationPlanError(f"CDSL sketch belongs to multiple plan nodes: {sketch_id} ({previous}, {node_id})")
sketch_owner[sketch_id] = node_id
coverage = _string_list(raw.get("requirement_ids"), f"nodes[{index}].requirement_ids")
unknown = sorted(set(coverage) - set(requirement_ids))
if unknown:
raise GenerationPlanError(f"Node {node_id} references unknown requirements: {', '.join(unknown)}")
rules = raw.get("verification_rules") or []
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
raise GenerationPlanError(f"nodes[{index}].verification_rules must be an array of objects")
targets = raw.get("review_targets") or []
if not isinstance(targets, list) or not all(isinstance(item, dict) for item in targets):
raise GenerationPlanError(f"nodes[{index}].review_targets must be an array of objects")
feature_nodes.append({
"id": node_id,
"intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False),
"atomic_id": atomic_id,
"depends_on": _string_list(raw.get("depends_on"), f"nodes[{index}].depends_on"),
"requires_topology": raw.get("requires_topology") is True,
"topology_query": deepcopy(raw.get("topology_query")) if raw.get("topology_query") is not None else None,
"cdsl_feature_ids": feature_ids,
})
node_metadata[node_id] = {
"cdsl_sketch_ids": sketch_ids,
"requires_sketch": bool(sketch_ids),
"requirement_ids": coverage,
"verification_rules": deepcopy(rules),
"review_targets": deepcopy(targets),
"attempts": {"authoring": 0, "repair": 0, "replan": 0},
}
try:
feature_plan = validate_feature_plan({
"schema_version": "cad.feature-plan.v1",
"plan_id": document.get("plan_id"),
"task_id": task_id or document.get("task_id"),
"nodes": feature_nodes,
}, supported_atomic_ids=supported_atomic_ids)
except FeaturePlanError as error:
raise GenerationPlanError(str(error)) from error
covered = {
requirement_id
for metadata in node_metadata.values()
for requirement_id in metadata["requirement_ids"]
}
uncovered = [item["id"] for item in requirements if item["priority"] == "hard" and item["id"] not in covered]
if uncovered:
raise GenerationPlanError("Hard requirements are not covered: " + ", ".join(uncovered))
nodes = []
for node in feature_plan["nodes"]:
nodes.append({**node, **node_metadata[node["id"]]})
return {
"schema_version": GENERATION_PLAN_SCHEMA_VERSION,
"id_strategy": BACKEND_ID_STRATEGY,
"plan_id": feature_plan["plan_id"],
"task_id": task_id or feature_plan["task_id"],
"requirements": requirements,
"assumptions": _string_list(document.get("assumptions"), "assumptions"),
"nodes": nodes,
"feature_owner": feature_plan["feature_owner"],
"sketch_owner": sketch_owner,
}
def descendant_closure(plan: dict[str, Any], root_node_id: str) -> set[str]:
"""Return one node and every node whose model depends on it."""
nodes = plan.get("nodes") if isinstance(plan, dict) else None
if not isinstance(nodes, list):
raise GenerationPlanError("Generation plan has no nodes")
children: dict[str, set[str]] = defaultdict(set)
known = {str(node.get("id")) for node in nodes if isinstance(node, dict)}
if root_node_id not in known:
raise GenerationPlanError(f"Unknown generation-plan node: {root_node_id}")
for node in nodes:
if not isinstance(node, dict):
continue
for dependency in node.get("depends_on") or ():
children[str(dependency)].add(str(node.get("id")))
result: set[str] = set()
queue: deque[str] = deque([root_node_id])
while queue:
node_id = queue.popleft()
if node_id in result:
continue
result.add(node_id)
queue.extend(sorted(children[node_id] - result))
return result
def mark_nodes_stale(plan: dict[str, Any], root_node_id: str, *, reason: str) -> dict[str, Any]:
"""Invalidate a node/subtree after an upstream geometry change or rollback."""
updated = deepcopy(plan)
stale = descendant_closure(updated, root_node_id)
for node in updated.get("nodes") or ():
if isinstance(node, dict) and str(node.get("id")) in stale:
node["status"] = "planned"
node["stale"] = True
node["stale_reason"] = reason
node.pop("topology_snapshot_id", None)
return updated
+264
View File
@@ -0,0 +1,264 @@
"""Structured multi-view image observations used by the CAD agent.
The observation contract intentionally keeps uncertain image evidence separate
from executable CDSL. It can therefore retain free-form/polyline candidates
without pretending that the local CAD runtime supports them directly.
"""
from __future__ import annotations
import json
from copy import deepcopy
from typing import Any
OBSERVATION_SCHEMA_VERSION = "cad.image-observation.v2"
TEXT_LIMIT = 300
LIMITS = {
"views": 12,
"surfaces": 24,
"profiles": 32,
"segments": 256,
"holes": 64,
"bends": 16,
"measurements": 128,
"uncertainties": 64,
}
def _text(value: Any, name: str, limit: int = TEXT_LIMIT, *, required: bool = False) -> str:
result = str(value or "").strip()
if required and not result:
raise ValueError(f"{name} must be a non-empty string")
return result[:limit]
def _text_list(value: Any, name: str, limit: int) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError(f"{name} must be an array")
return [_text(item, name, required=True) for item in value[:limit]]
def _number(value: Any, name: str) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError) as error:
raise ValueError(f"{name} must be numeric") from error
def _point(value: Any, name: str, dimensions: int = 2) -> list[float] | None:
if value is None:
return None
if not isinstance(value, list) or len(value) < dimensions:
raise ValueError(f"{name} must contain at least {dimensions} numbers")
output: list[float] = []
for index, component in enumerate(value[:dimensions]):
parsed = _number(component, f"{name}[{index}]")
if parsed is None:
raise ValueError(f"{name}[{index}] must be numeric")
output.append(parsed)
return output
def _confidence(value: Any) -> float | None:
parsed = _number(value, "confidence")
if parsed is None:
return None
return max(0.0, min(1.0, parsed))
def _source_images(value: Any) -> list[str]:
return _text_list(value, "source_images", LIMITS["views"])
def _normalize_segment(segment: Any) -> dict[str, Any]:
if not isinstance(segment, dict):
raise ValueError("profile segments must contain objects")
kind = _text(segment.get("type"), "segment.type", 32, required=True)
if kind not in {"line", "arc", "circle", "polyline", "unknown_curve"}:
raise ValueError(f"unsupported image segment type: {kind}")
result: dict[str, Any] = {"type": kind}
if kind in {"line", "arc"}:
result["start"] = _point(segment.get("start"), "segment.start")
result["end"] = _point(segment.get("end"), "segment.end")
if result["start"] is None or result["end"] is None:
raise ValueError(f"{kind} segments require start and end")
if kind == "arc":
result["center"] = _point(segment.get("center"), "segment.center")
result["radius_mm"] = _number(segment.get("radius_mm"), "segment.radius_mm")
result["clockwise"] = bool(segment.get("clockwise"))
if kind == "circle":
result["center"] = _point(segment.get("center"), "segment.center")
result["radius_mm"] = _number(segment.get("radius_mm"), "segment.radius_mm")
if result["center"] is None or result["radius_mm"] is None:
raise ValueError("circle segments require center and radius_mm")
if kind in {"polyline", "unknown_curve"}:
points = segment.get("points")
if not isinstance(points, list) or not points:
raise ValueError(f"{kind} segments require points")
result["points"] = [_point(point, "segment.points") for point in points[:LIMITS["segments"]]]
if any(point is None for point in result["points"]):
raise ValueError(f"{kind} segment contains an invalid point")
result["image_uv"] = {
"start": _point(segment.get("image_start"), "segment.image_start"),
"end": _point(segment.get("image_end"), "segment.image_end"),
}
result["confidence"] = _confidence(segment.get("confidence"))
result["notes"] = _text(segment.get("notes"), "segment.notes")
return result
def _normalize_profile(profile: Any) -> dict[str, Any]:
if not isinstance(profile, dict):
raise ValueError("profiles must contain objects")
segments = profile.get("segments") or []
if not isinstance(segments, list):
raise ValueError("profile.segments must be an array")
return {
"id": _text(profile.get("id"), "profile.id", 80, required=True),
"role": _text(profile.get("role"), "profile.role", 40),
"plane_hint": _text(profile.get("plane_hint"), "profile.plane_hint"),
"closed": bool(profile.get("closed")),
"coordinate_space": _text(profile.get("coordinate_space"), "profile.coordinate_space") or "image_uv",
"segments": [_normalize_segment(item) for item in segments[:LIMITS["segments"]]],
"source_images": _source_images(profile.get("source_images")),
"confidence": _confidence(profile.get("confidence")),
"uncertain": _text_list(profile.get("uncertain"), "profile.uncertain", 16),
"notes": _text(profile.get("notes"), "profile.notes"),
}
def _normalize_measurement(measurement: Any) -> dict[str, Any]:
if not isinstance(measurement, dict):
raise ValueError("measurements must contain objects")
source = _text(measurement.get("source"), "measurement.source") or "image"
if source not in {"user", "image", "cv", "assumption"}:
raise ValueError("measurement.source must be user, image, cv, or assumption")
value = _number(measurement.get("value_mm"), "measurement.value_mm")
minimum = _number(measurement.get("min_mm"), "measurement.min_mm")
maximum = _number(measurement.get("max_mm"), "measurement.max_mm")
return {
"name": _text(measurement.get("name"), "measurement.name", 120, required=True),
"value_mm": value,
"min_mm": minimum,
"max_mm": maximum,
"source": source,
"confidence": _confidence(measurement.get("confidence")),
"evidence": _text(measurement.get("evidence"), "measurement.evidence"),
"source_images": _source_images(measurement.get("source_images")),
}
def normalize_image_observation(arguments: dict[str, Any], *, attachment_ids: list[str]) -> dict[str, Any]:
"""Normalize the survey tool output while preserving uncertain geometry."""
if not isinstance(arguments, dict):
raise ValueError("image observation arguments must be an object")
raw_ids = [str(item) for item in arguments.get("attachment_ids") or attachment_ids if str(item)]
normalized_ids = list(dict.fromkeys(raw_ids or attachment_ids))
if not normalized_ids:
raise ValueError("image observation requires at least one attachment")
views = arguments.get("views") or []
profiles = arguments.get("profiles") or []
measurements = arguments.get("measurements") or []
result: dict[str, Any] = {
"schema_version": OBSERVATION_SCHEMA_VERSION,
"attachment_ids": normalized_ids,
"part_type": _text(arguments.get("part_type"), "part_type", required=True),
"visible_features": _text_list(arguments.get("visible_features"), "visible_features", 32),
"uncertain_features": _text_list(arguments.get("uncertain_features"), "uncertain_features", LIMITS["uncertainties"]),
"views": [],
"scale_references": deepcopy(arguments.get("scale_references") or [])[:LIMITS["views"]],
"overall_geometry": arguments.get("overall_geometry") if isinstance(arguments.get("overall_geometry"), dict) else {},
"surfaces": deepcopy(arguments.get("surfaces") or [])[:LIMITS["surfaces"]],
"profiles": [_normalize_profile(item) for item in profiles[:LIMITS["profiles"]]],
"holes": deepcopy(arguments.get("holes") or [])[:LIMITS["holes"]],
"bends": deepcopy(arguments.get("bends") or [])[:LIMITS["bends"]],
"measurements": [_normalize_measurement(item) for item in measurements[:LIMITS["measurements"]]],
"uncertainties": _text_list(arguments.get("uncertainties"), "uncertainties", LIMITS["uncertainties"]),
"assumptions": _text_list(arguments.get("assumptions"), "assumptions", LIMITS["uncertainties"]),
"cv_hints": deepcopy(arguments.get("cv_hints") or [])[:LIMITS["profiles"]],
}
for item in views[:LIMITS["views"]]:
if not isinstance(item, dict):
raise ValueError("views must contain objects")
result["views"].append({
"attachment_id": _text(item.get("attachment_id"), "view.attachment_id", 120, required=True),
"view_role": _text(item.get("view_role"), "view.view_role"),
"orientation": _text(item.get("orientation"), "view.orientation"),
"visible_regions": _text_list(item.get("visible_regions"), "view.visible_regions", 24),
"occluded_regions": _text_list(item.get("occluded_regions"), "view.occluded_regions", 24),
"quality": _text(item.get("quality"), "view.quality"),
"scale_reference_id": _text(item.get("scale_reference_id"), "view.scale_reference_id", 120),
"confidence": _confidence(item.get("confidence")),
})
return result
def normalize_sketch_candidates(arguments: dict[str, Any], *, attachment_ids: list[str]) -> dict[str, Any]:
"""Normalize the second-stage sketch extraction result."""
if not isinstance(arguments, dict):
raise ValueError("sketch candidate arguments must be an object")
profiles = arguments.get("profiles") or arguments.get("sketches") or []
base = normalize_image_observation({
"attachment_ids": attachment_ids,
"part_type": arguments.get("part_type") or "image reference",
"visible_features": arguments.get("visible_features") or ["profile candidates"],
"uncertain_features": arguments.get("uncertain_features") or [],
"profiles": profiles,
"measurements": arguments.get("measurements") or [],
"uncertainties": arguments.get("uncertainties") or [],
"assumptions": arguments.get("assumptions") or [],
"cv_hints": arguments.get("cv_hints") or [],
}, attachment_ids=attachment_ids)
return {
"profiles": base["profiles"],
"measurements": base["measurements"],
"uncertainties": base["uncertainties"],
"assumptions": base["assumptions"],
"cv_hints": base["cv_hints"],
}
def merge_image_observations(survey: dict[str, Any], sketches: dict[str, Any]) -> dict[str, Any]:
"""Merge the two stages and keep user-sourced measurements authoritative."""
result = deepcopy(survey)
result["schema_version"] = OBSERVATION_SCHEMA_VERSION
result["profiles"] = sketches.get("profiles") or result.get("profiles") or []
existing = {str(item.get("name")): item for item in result.get("measurements") or () if isinstance(item, dict)}
for item in sketches.get("measurements") or ():
if not isinstance(item, dict):
continue
key = str(item.get("name") or "")
prior = existing.get(key)
if prior and prior.get("source") == "user" and item.get("source") != "user":
continue
existing[key] = item
result["measurements"] = list(existing.values())
result["uncertainties"] = list(dict.fromkeys([
*(result.get("uncertainties") or []),
*(sketches.get("uncertainties") or []),
]))[:LIMITS["uncertainties"]]
result["assumptions"] = list(dict.fromkeys([
*(result.get("assumptions") or []),
*(sketches.get("assumptions") or []),
]))[:LIMITS["uncertainties"]]
result["cv_hints"] = sketches.get("cv_hints") or result.get("cv_hints") or []
return result
def render_image_observation_context(observation: dict[str, Any] | None) -> str:
if not isinstance(observation, dict):
return ""
compact = {
key: observation.get(key)
for key in (
"schema_version", "attachment_ids", "part_type", "views", "overall_geometry",
"surfaces", "profiles", "holes", "bends", "measurements", "uncertainties", "assumptions",
)
if observation.get(key) not in (None, [], {})
}
return json.dumps(compact, ensure_ascii=False, separators=(",", ":"))
+46
View File
@@ -0,0 +1,46 @@
"""Optional image metadata and computer-vision hints for image observations."""
from __future__ import annotations
import io
from typing import Any
def image_metadata(data: bytes) -> dict[str, Any]:
"""Read safe image metadata without changing the original upload."""
try:
from PIL import Image, ImageOps
with Image.open(io.BytesIO(data)) as image:
normalized = ImageOps.exif_transpose(image)
return {
"width": int(normalized.width),
"height": int(normalized.height),
"format": str(image.format or "").lower(),
"orientation": "landscape" if normalized.width >= normalized.height else "portrait",
"has_alpha": "A" in normalized.getbands(),
}
except Exception as error:
return {"error": f"image metadata unavailable: {type(error).__name__}"}
def cv_hints(data: bytes) -> dict[str, Any]:
"""Return conservative CV hints; OpenCV is intentionally optional."""
try:
import cv2 # type: ignore
import numpy as np # type: ignore
except Exception:
return {"available": False, "hints": []}
try:
image = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_GRAYSCALE)
if image is None:
return {"available": True, "hints": [], "error": "image decode failed"}
edges = cv2.Canny(image, 50, 150)
lines = cv2.HoughLinesP(edges, 1, 3.141592653589793 / 180, threshold=50, minLineLength=30, maxLineGap=8)
line_hints = []
for line in (lines[:32] if lines is not None else []):
x1, y1, x2, y2 = [int(value) for value in line[0]]
line_hints.append({"type": "line", "start_px": [x1, y1], "end_px": [x2, y2]})
return {"available": True, "hints": line_hints, "edge_pixels": int((edges > 0).sum())}
except Exception as error:
return {"available": True, "hints": [], "error": f"cv failed: {type(error).__name__}"}
@@ -0,0 +1,509 @@
"""Persistent, full-rebuild orchestration for node-by-node CDSL authoring."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Awaitable, Callable
from copy import deepcopy
import json
from pathlib import Path
import secrets
from typing import Any
from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, materialize_fragment, validate_fragment
from app.services.engine_service import QualityVerificationError, build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl
from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan
from app.services.quality import validate_verification
from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status
from app.services.storage import WorkspaceStore, write_json
from app.services.visual_review import VisualReviewError, review_checkpoint
from app.settings import ProviderConfig, ProviderModel, Settings
Completion = Callable[[list[dict[str, Any]], list[dict[str, Any]], ProviderConfig, ProviderModel, str | None], Awaitable[dict[str, Any]]]
PLAN_TOOL = {
"type": "function",
"function": {
"name": "plan_generation_task",
"description": "Create the complete immutable requirement list and executable feature DAG before authoring any CDSL.",
"parameters": {
"type": "object",
"properties": {
"schema_version": {"type": "string", "const": "cad.generation-plan.v2"},
"plan_id": {"type": "string", "minLength": 1},
"requirements": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1},
"source": {"enum": ["explicit", "assumption"]},
"priority": {"enum": ["hard", "soft"]},
"description": {"type": "string", "minLength": 1},
"value": {},
"unit": {"type": "string"},
"tolerance": {},
},
"required": ["id", "source", "priority", "description"],
"additionalProperties": False,
},
},
"assumptions": {"type": "array", "items": {"type": "string"}},
"nodes": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1},
"intent": {"type": "string"},
"atomic_id": {"type": "string", "minLength": 1},
"depends_on": {"type": "array", "items": {"type": "string"}},
"requires_topology": {"type": "boolean"},
"topology_query": {"type": "object"},
"requirement_ids": {"type": "array", "items": {"type": "string"}},
"verification_rules": {"type": "array", "items": {"type": "object"}},
"review_targets": {"type": "array", "items": {"type": "object"}},
},
"required": ["id", "intent", "atomic_id", "depends_on", "requirement_ids", "verification_rules", "review_targets"],
"additionalProperties": False,
},
},
},
"required": ["schema_version", "plan_id", "requirements", "assumptions", "nodes"],
"additionalProperties": False,
},
},
}
FRAGMENT_TOOL = {
"type": "function",
"function": {
"name": "generate_cdsl_fragment",
"description": "Generate only the active plan node's additive CDSL fragment. Never replace or mutate existing CDSL.",
"parameters": {
"type": "object",
"properties": {
"schema_version": {"type": "string", "const": "cad.cdsl-fragment.v1"},
"node_id": {"type": "string", "minLength": 1},
"base_revision_id": {"type": "string"},
"base_cdsl_sha256": {"type": "string", "minLength": 64, "maxLength": 64},
"required_snapshot_id": {"type": "string"},
"add_sketches": {"type": "array", "items": {"type": "object"}},
"add_features": {"type": "array", "items": {"type": "object"}},
"verification_rules": {"type": "array", "items": {"type": "object"}},
"assumptions": {"type": "array", "items": {"type": "string"}},
},
"required": ["schema_version", "node_id", "base_revision_id", "base_cdsl_sha256", "add_sketches", "add_features", "verification_rules", "assumptions"],
"additionalProperties": False,
},
},
}
class IncrementalGenerationError(RuntimeError):
pass
def _tool_response(response: dict[str, Any], expected_name: str) -> dict[str, Any]:
try:
call = response["choices"][0]["message"]["tool_calls"][0]
if call["function"]["name"] != expected_name:
raise KeyError("wrong tool")
result = json.loads(call["function"]["arguments"])
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
raise IncrementalGenerationError(f"Author did not return a valid {expected_name} call") from error
if not isinstance(result, dict):
raise IncrementalGenerationError(f"{expected_name} arguments must be an object")
return result
def _node_by_id(spec: dict[str, Any], node_id: str) -> dict[str, Any]:
node = next((item for item in spec.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None)
if node is None:
raise IncrementalGenerationError(f"Generation plan has no node {node_id}")
return node
def _fragment_node_context(node: dict[str, Any]) -> dict[str, Any]:
"""Expose semantic node intent, not backend-owned CDSL implementation IDs."""
fields = (
"id", "intent", "atomic_id", "depends_on", "requires_topology",
"requires_sketch", "topology_query", "requirement_ids",
"verification_rules", "review_targets",
)
return {field: deepcopy(node[field]) for field in fields if field in node}
def _ready_node(spec: dict[str, Any], completed: set[str], has_topology: bool) -> dict[str, Any] | None:
for node in spec.get("nodes") or ():
if not isinstance(node, dict) or node.get("status") == "completed":
continue
if all(str(item) in completed for item in node.get("depends_on") or ()) and (not node.get("requires_topology") or has_topology):
return node
return None
def _error_code(error: Exception) -> str:
message = str(error)
for code in (
"SELECTOR_AMBIGUOUS", "SELECTOR_NOT_FOUND", "SELECTOR_GEOMETRY_MISMATCH",
"TOPOLOGY_SNAPSHOT_STALE", "TOPOLOGY_REQUIRED", "VERIFICATION_FAILED", "SELECTOR_OWNER_REQUIRED",
):
if code in message:
return code
return type(error).__name__.upper()
def _mark_affected_nodes_stale(plan: dict[str, Any], node_ids: list[str], *, reason: str) -> tuple[dict[str, Any], set[str]]:
"""Invalidate the union of every affected node's downstream closure."""
updated = deepcopy(plan)
stale: set[str] = set()
for node_id in dict.fromkeys(node_ids):
stale.update(descendant_closure(updated, node_id))
updated = mark_nodes_stale(updated, node_id, reason=reason)
return updated, stale
def _source_image_paths(store: WorkspaceStore, conversation: dict[str, Any]) -> list[Path]:
conversation_id = str(conversation.get("conversation_id") or "")
paths: list[Path] = []
for attachment in conversation.get("attachments") or ():
if not isinstance(attachment, dict) or attachment.get("kind") != "image" or not conversation_id:
continue
try:
paths.append(store.conversation_attachment_path(conversation_id, str(attachment.get("path") or "")))
except ValueError:
continue
return paths
class IncrementalGenerationRunner:
"""The agent-facing controller. It is deliberately full-rebuild and restart-safe."""
def __init__(self, settings: Settings, store: WorkspaceStore, complete: Completion) -> None:
self.settings = settings
self.store = store
self._complete = complete
async def _call(self, messages: list[dict[str, Any]], provider: ProviderConfig, model: ProviderModel, tool: dict[str, Any], name: str) -> dict[str, Any]:
response = await self._complete(messages, [tool], provider, model, name)
return _tool_response(response, name)
async def run(
self,
*,
task_id: str,
request: str,
conversation: dict[str, Any],
provider: ProviderConfig,
model: ProviderModel,
author_messages: list[dict[str, Any]],
part_skills: dict[str, Any] | None = None,
references: list[str] | None = None,
already_started: bool = False,
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
plan_diagnostic_path = ""
try:
task = self.store.ensure_task(task_id or None, request)
task_id = str(task["task_id"])
# Configuration is a start gate: visual review is required, never silently skipped.
self.settings.resolve_review_model()
renderer_ready, renderer_error = renderer_status()
if not renderer_ready:
raise IncrementalGenerationError(renderer_error)
task = self.store.read_task(task_id) if already_started else self.store.start_generation(task_id, request=request)
if not isinstance(task, dict):
raise IncrementalGenerationError("Generation task is unavailable")
yield "generation_plan", {"taskId": task_id, "status": "running"}
engine = load_engine(self.settings)
persisted_spec = self.store.read_generation_spec(task_id)
if persisted_spec is not None:
spec = validate_generation_plan(
persisted_spec,
supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()),
task_id=task_id,
)
yield "generation_plan", {
"taskId": task_id, "status": "success", "planId": spec["plan_id"], "resumed": True,
"requirements": spec["requirements"],
"nodes": [{"id": node["id"], "intent": node["intent"], "status": node.get("status", "planned")} for node in spec["nodes"]],
}
else:
planning_messages = [
{
"role": "system",
"content": (
"Create one complete cad.generation-plan.v2 before creating geometry. "
"Turn every user constraint into a requirement with source explicit or assumption; "
"use source assumption for missing dimensions and never ask the user questions. "
"Every hard requirement must belong to at least one node. Use only runtime-supported atomic ids. "
"Do not output expected_feature_ids or expected_sketch_ids: the backend derives all CDSL object ids from node.id."
),
},
*author_messages,
]
raw_spec = await self._call(planning_messages, provider, model, PLAN_TOOL, "plan_generation_task")
try:
spec = validate_generation_plan(
raw_spec,
supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()),
task_id=task_id,
)
except GenerationPlanError as error:
plan_diagnostic_path = self.store.write_generation_failure(task_id, {
"schema_version": "cad.generation-plan-diagnostic.v1",
"stage": "generation_plan_validation",
"message": str(error),
"raw_plan": raw_spec,
})
raise
self.store.write_generation_spec(task_id, spec)
yield "generation_plan", {
"taskId": task_id, "status": "success", "planId": spec["plan_id"],
"requirements": spec["requirements"], "nodes": [{"id": node["id"], "intent": node["intent"], "status": "planned"} for node in spec["nodes"]],
}
completed: set[str] = {
str(node["id"]) for node in spec["nodes"] if node.get("status") == "completed"
}
last_built: dict[str, Any] | None = None
task = self.store.read_task(task_id) or task
active_revision_id = str(task.get("active_revision") or "")
# A process can stop between build and review. That checkpoint is
# not a legal base revision, so recover its parent before resuming.
active_record = next(
(item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == active_revision_id),
None,
)
if isinstance(active_record, dict) and active_record.get("visibility") == "checkpoint":
current_node = str(active_record.get("node_id") or "")
if current_node and current_node not in completed:
recovered = str(active_record.get("parent_revision_id") or "")
self.store.rollback_to_revision(task_id, recovered, branch_id=f"branch_{secrets.token_hex(4)}")
active_revision_id = recovered
task = self.store.read_task(task_id) or task
base_path = self.store.current_cdsl_path(task_id)
base_cdsl = json.loads(base_path.read_text(encoding="utf-8")) if base_path and base_path.is_file() else None
while True:
topology_path = self.store.current_topology_path(task_id)
topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None
node = _ready_node(spec, completed, bool(topology and topology.get("records")))
if node is None:
if len(completed) == len(spec["nodes"]):
self.store.finish_generation(task_id, lifecycle="completed")
if last_built is not None:
yield "cad_result", self._result_payload(last_built, lifecycle="completed", checkpoint=False)
yield "task_terminal", {"taskId": task_id, "lifecycle": "completed", "revisionId": str((self.store.read_task(task_id) or {}).get("published_revision") or "")}
return
waiting = [item["id"] for item in spec["nodes"] if item.get("id") not in completed]
raise IncrementalGenerationError("No executable plan node is ready: " + ", ".join(waiting))
node_id = str(node["id"])
self.store.set_active_node(task_id, node_id)
yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "authoring"}
attempts = node.setdefault("attempts", {"authoring": 0, "repair": 0, "replan": 0})
feedback = ""
while True:
attempt_kind = "authoring" if int(attempts.get("authoring") or 0) < self.settings.node_authoring_attempts else "repair"
if attempt_kind == "repair" and int(attempts.get("repair") or 0) >= self.settings.node_repair_attempts:
if int(attempts.get("replan") or 0) >= self.settings.node_replan_attempts:
raise IncrementalGenerationError(f"Node {node_id} exhausted its authoring, repair, and replan budgets: {feedback}")
attempts["replan"] = int(attempts.get("replan") or 0) + 1
spec = await self._replan(spec, node_id, feedback, provider, model, author_messages, engine)
self.store.write_generation_spec(task_id, spec)
node = _node_by_id(spec, node_id)
node["attempts"] = {"authoring": 0, "repair": 0, "replan": attempts["replan"]}
attempts = node["attempts"]
yield "rollback", {"taskId": task_id, "nodeId": node_id, "reason": "node_replan"}
continue
attempts[attempt_kind] = int(attempts.get(attempt_kind) or 0) + 1
required_snapshot = str((topology or {}).get("snapshot_id") or "") if node.get("requires_topology") else ""
node_requirement_ids = set(node.get("requirement_ids") or ())
author_context = {
"active_node": _fragment_node_context(node),
"requirements": [
requirement for requirement in spec["requirements"]
if requirement.get("id") in node_requirement_ids
],
"base_revision_id": active_revision_id,
"base_cdsl_sha256": cdsl_sha256(base_cdsl),
"required_snapshot_id": required_snapshot,
"base_cdsl": base_cdsl,
"topology": topology if required_snapshot else None,
"previous_failure": feedback,
}
fragment_messages = [
{
"role": "system",
"content": (
"Author exactly one additive CDSL fragment for the active node. Do not change existing CDSL or generate unsupported topology selectors. "
"Do not set output id, sketch_id, or depends_on: the backend assigns them. "
"Use owner_node_id instead of owner_feature_id and source_node_ids instead of source_feature_ids when referring to plan nodes."
),
},
*author_messages,
{"role": "user", "content": json.dumps(author_context, ensure_ascii=False)},
]
try:
raw_fragment = await self._call(fragment_messages, provider, model, FRAGMENT_TOOL, "generate_cdsl_fragment")
fragment = validate_fragment(
raw_fragment, plan=spec, node_id=node_id, base_revision_id=active_revision_id,
base_cdsl=base_cdsl, required_snapshot_id=required_snapshot,
)
cdsl = materialize_fragment(base_cdsl, fragment)
cdsl, repairs = normalize_cdsl_for_engine(cdsl)
validate_cdsl(cdsl, engine)
rules = [*node.get("verification_rules", []), *fragment.get("verification_rules", [])]
verification = {"rules": rules} if rules else None
validate_verification(verification, cdsl)
fragment_base_revision = str(fragment["base_revision_id"])
built = await asyncio.to_thread(
build_revision,
settings=self.settings, store=self.store, task_id=task_id, request=request, cdsl=cdsl,
reference_ids=references or [], summary=node.get("intent") or node_id,
parent_revision_id=active_revision_id or None,
operation={"type": "cdsl_fragment", "node_id": node_id},
part_skills=part_skills, generation_assumptions=[*spec.get("assumptions", []), *fragment.get("assumptions", [])],
verification=verification, node_id=node_id, fragment=fragment,
branch_id=str((self.store.read_task(task_id) or {}).get("active_branch_id") or "main"), visibility="checkpoint",
)
candidate_revision_id = str(built["revision_id"])
try:
render_dir = self.store.revision_dir(task_id, candidate_revision_id) / "review"
manifest = await asyncio.to_thread(
render_checkpoint,
self.settings,
step_path=self.store.artifact_path(task_id, str(built["step_path"])),
output_dir=render_dir,
review_targets=node.get("review_targets"),
)
final_checkpoint = len(completed) + 1 == len(spec["nodes"])
review_requirements = spec["requirements"] if final_checkpoint else [
requirement for requirement in spec["requirements"]
if requirement.get("id") in set(node.get("requirement_ids") or ())
]
review = await review_checkpoint(
self.settings, manifest=manifest, requirements=review_requirements, node_id=node_id,
deterministic_report={"quality_status": built.get("quality_status"), "verification": built.get("verification_summary", {})},
source_images=_source_image_paths(self.store, conversation) if not completed or final_checkpoint else [],
final_checkpoint=final_checkpoint,
)
except (ReviewRenderError, VisualReviewError) as error:
self.store.rollback_to_revision(task_id, fragment_base_revision, branch_id=f"branch_{secrets.token_hex(4)}")
active_revision_id = fragment_base_revision
rollback_path = self.store.current_cdsl_path(task_id)
base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None
raise error
active_revision_id = candidate_revision_id
base_cdsl = cdsl
manifest_relative = (render_dir / "render-manifest.json").relative_to(self.store.task_dir(task_id)).as_posix()
review_relative = (render_dir / "visual-review.json").relative_to(self.store.task_dir(task_id)).as_posix()
write_json(render_dir / "visual-review.json", review)
self.store.update_revision_metadata(task_id, active_revision_id, {"render_manifest_path": manifest_relative, "visual_review_path": review_relative})
yield "render_review", {"taskId": task_id, "revisionId": active_revision_id, "nodeId": node_id, "review": review}
if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85:
affected_nodes = [
str(item) for item in review.get("affected_node_ids") or ()
if any(str(candidate.get("id") or "") == str(item) for candidate in spec.get("nodes") or ())
] or [node_id]
rollback_base = self.store.rollback_anchor_for_nodes(
task_id,
affected_nodes,
fallback_revision_id=fragment_base_revision,
)
branch = f"branch_{secrets.token_hex(4)}"
self.store.rollback_to_revision(task_id, rollback_base, branch_id=branch)
spec, stale_nodes = _mark_affected_nodes_stale(
spec, affected_nodes, reason="high_confidence_visual_review",
)
self.store.write_generation_spec(task_id, spec)
completed.difference_update(stale_nodes)
active_revision_id = rollback_base
rollback_path = self.store.current_cdsl_path(task_id)
base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None
feedback = "High-confidence visual review requires correction: " + "; ".join(review.get("evidence") or [])
yield "rollback", {
"taskId": task_id, "nodeId": node_id, "revisionId": active_revision_id,
"reason": "visual_review", "affectedNodeIds": affected_nodes,
}
continue
completed.add(node_id)
node["status"] = "completed"
node.pop("stale", None)
self.store.write_generation_spec(task_id, spec)
last_built = built
payload = self._result_payload(built, lifecycle="running", checkpoint=True)
yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "success", "revisionId": active_revision_id}
yield "cad_result", payload
break
except (CdslFragmentError, GenerationPlanError, QualityVerificationError, ReviewRenderError, VisualReviewError, ValueError, RuntimeError) as error:
feedback = str(error)
failure = {
"schema_version": "cad.generation-failure.v1",
"node_id": node_id,
"stage": attempt_kind,
"error_code": _error_code(error),
"message": feedback,
"requirement_ids": list(node.get("requirement_ids") or ()),
"selector": {"required_snapshot_id": required_snapshot},
"geometry_delta": {},
"recommended_rollback_revision": fragment_base_revision if "fragment_base_revision" in locals() else active_revision_id,
}
failure_path = self.store.write_generation_failure(task_id, failure)
yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "error", "attempt": attempt_kind, "message": feedback}
# A failed build never becomes the active base; retries are safe and deterministic.
continue
except Exception as error:
failure = {
"schema_version": "cad.generation-failure.v1",
"message": str(error),
"active_node_id": str((self.store.read_task(task_id) or {}).get("active_node_id") or ""),
}
if plan_diagnostic_path:
failure["plan_diagnostic_path"] = plan_diagnostic_path
self.store.finish_generation(task_id, lifecycle="failed", failure=failure)
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "message": str(error)}
async def _replan(
self,
spec: dict[str, Any],
node_id: str,
feedback: str,
provider: ProviderConfig,
model: ProviderModel,
author_messages: list[dict[str, Any]],
engine: Any,
) -> dict[str, Any]:
raw = await self._call([
{"role": "system", "content": "Replan only the failed node and its downstream nodes. Preserve completed node definitions and all requirements."},
*author_messages,
{"role": "user", "content": json.dumps({"existing_plan": spec, "failed_node_id": node_id, "failure": feedback}, ensure_ascii=False)},
], provider, model, PLAN_TOOL, "plan_generation_task")
next_spec = validate_generation_plan(raw, supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), task_id=str(spec.get("task_id") or ""))
stale = {item["id"] for item in mark_nodes_stale(spec, node_id, reason="replan").get("nodes") or [] if item.get("stale")}
prior = {item["id"]: item for item in spec.get("nodes") or []}
for node in next_spec["nodes"]:
if node["id"] not in stale and node["id"] in prior:
old = prior[node["id"]]
for key in ("atomic_id", "depends_on", "cdsl_feature_ids"):
if node.get(key) != old.get(key):
raise IncrementalGenerationError(f"Replan changed non-stale node {node['id']}")
return next_spec
@staticmethod
def _result_payload(built: dict[str, Any], *, lifecycle: str, checkpoint: bool) -> dict[str, Any]:
return {
"taskId": built["task_id"], "revisionId": built["revision_id"],
"cdslPath": built["cdsl_path"], "stepPath": built["step_path"], "glbPath": built["glb_path"],
"reportPath": built["report_path"], "parametersPath": built.get("parameters_path"),
"selectorPath": built.get("selector_path"), "edgesPath": built.get("edges_path"), "topologyPath": built.get("topology_path"),
"summary": built.get("summary") or "CDSL checkpoint", "referenceIds": built.get("reference_ids") or [],
"engine": built.get("engine") or "cdsl_only", "qualityStatus": built.get("quality_status") or "",
"qualityPath": built.get("quality_path"), "assumptions": built.get("generation_assumptions") or [],
"snapshotPaths": built.get("snapshot_paths") or [], "snapshotStatus": built.get("snapshot_status") or "unavailable",
"lifecycle": lifecycle, "checkpoint": checkpoint,
}
+72 -5
View File
@@ -8,9 +8,10 @@ from typing import Any
QUALITY_RULE_TYPES = frozenset({
"bbox", "solid_count", "feature_count", "hole_count", "hole_diameter",
"hole_center", "overall_length", "overall_diameter", "through_condition",
"hole_center", "overall_length", "overall_width", "overall_height",
"overall_diameter", "through_condition",
})
_FEATURE_RULE_TYPES = {"hole_count", "hole_diameter", "hole_center", "through_condition"}
FEATURE_RULE_TYPES = frozenset({"hole_count", "hole_diameter", "hole_center", "through_condition"})
_SEVERITIES = {"blocking", "warning", "informational"}
@@ -22,7 +23,12 @@ def _bbox_bounds(engine_result: dict[str, Any], feature_id: str | None = None) -
if not isinstance(record, dict):
continue
owners = record.get("owner_feature_ids") or []
if record.get("feature_id") == feature_id or feature_id in owners:
# Runtime records may carry the current operation in feature_id
# while owner_feature_ids identify the actual geometry owner. Use
# ownership when present so inherited faces do not pollute a
# feature-local measurement.
matches = feature_id in owners if owners else record.get("feature_id") == feature_id
if matches:
geometry = record.get("geometry")
if isinstance(geometry, dict):
candidates.append(geometry.get("bbox_mm"))
@@ -88,12 +94,64 @@ def _circles(profile: dict[str, Any]) -> list[dict[str, Any]]:
]
def _circle_feature_bbox(feature: dict[str, Any] | None, sketch: dict[str, Any] | None) -> dict[str, Any] | None:
"""Derive a stable world-space bbox for a circular extrude feature."""
if not isinstance(feature, dict) or not isinstance(sketch, dict):
return None
if str(feature.get("atomic_id") or "") != "extrude_add_blind":
return None
profile = sketch.get("profile")
workplane = sketch.get("workplane")
params = feature.get("params")
if not isinstance(profile, dict) or profile.get("type") != "circle" or not isinstance(workplane, dict):
return None
center_local = profile.get("center")
radius = profile.get("radius_mm")
origin = workplane.get("origin_mm")
x_dir = workplane.get("x_dir")
y_dir = workplane.get("y_dir")
normal = workplane.get("normal")
distance = (params or {}).get("distance_mm") if isinstance(params, dict) else None
if not isinstance(center_local, list) or len(center_local) < 2 or not _finite_number(radius):
return None
if not all(isinstance(value, list) and len(value) >= 3 for value in (origin, x_dir, y_dir, normal)) or not _finite_number(distance):
return None
center = [
float(origin[index]) + float(center_local[0]) * float(x_dir[index]) + float(center_local[1]) * float(y_dir[index])
for index in range(3)
]
points = []
for axis_x in (-1.0, 1.0):
for axis_y in (-1.0, 1.0):
cross_section = [
center[index]
+ axis_x * float(radius) * float(x_dir[index])
+ axis_y * float(radius) * float(y_dir[index])
for index in range(3)
]
points.append(cross_section)
points.append([
cross_section[index] + float(distance) * float(normal[index])
for index in range(3)
])
minimum = [min(point[index] for point in points) for index in range(3)]
maximum = [max(point[index] for point in points) for index in range(3)]
return {
"min": minimum,
"max": maximum,
"dimensions": [maximum[index] - minimum[index] for index in range(3)],
}
def _finite_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value))
def _validate_expected(kind: str, expected: Any, index: int) -> None:
scalar_types = {"solid_count", "feature_count", "hole_count", "hole_diameter", "overall_length", "overall_diameter"}
scalar_types = {
"solid_count", "feature_count", "hole_count", "hole_diameter",
"overall_length", "overall_width", "overall_height", "overall_diameter",
}
if kind in scalar_types and not _finite_number(expected):
raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}")
if kind == "bbox":
@@ -157,7 +215,7 @@ def validate_verification(verification: Any, cdsl: dict[str, Any]) -> list[dict[
if not math.isfinite(tolerance) or tolerance < 0:
raise ValueError(f"verification.rules[{index}].tolerance must be finite and non-negative")
feature = str(raw.get("feature") or "").strip()
if kind in _FEATURE_RULE_TYPES and not feature:
if kind in FEATURE_RULE_TYPES and not feature:
raise ValueError(f"verification.rules[{index}].feature is required for {kind}")
if feature and feature not in feature_ids:
raise ValueError(f"verification.rules[{index}].feature must reference a CDSL feature ID")
@@ -175,6 +233,9 @@ def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str,
circles = _circles(profile) if isinstance(profile, dict) else []
dimensions = _dimensions(engine_result)
if kind == "bbox":
derived = _circle_feature_bbox(feature, sketch)
if target and derived is not None:
return derived, f"cdsl.features.{target}.sketch + params"
value = _bbox_value(engine_result, target)
source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm"
return value, source
@@ -194,7 +255,13 @@ def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str,
return (centers[0] if len(centers) == 1 else centers), f"cdsl.features.{target}.sketch"
if kind == "overall_length":
return (max(dimensions) if dimensions else None), "runtime.bbox_mm"
if kind == "overall_width":
return (dimensions[1] if dimensions and len(dimensions) > 1 else None), "runtime.bbox_mm[y]"
if kind == "overall_height":
return (dimensions[2] if dimensions and len(dimensions) > 2 else None), "runtime.bbox_mm[z]"
if kind == "overall_diameter":
if target and circles:
return max(circle["radius_mm"] for circle in circles) * 2.0, f"cdsl.features.{target}.sketch"
return (min(dimensions) if dimensions else None), "runtime.bbox_mm"
if kind == "through_condition":
end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {}
+332
View File
@@ -0,0 +1,332 @@
"""Deterministic, CPU-only CAD technical renders for visual review.
OpenCascade computes exact visible/hidden edges from the revision STEP file.
Pillow rasterizes the resulting technical drawings. Neither stage needs a web
browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence
consistent on macOS, Linux, and Windows workers.
"""
from __future__ import annotations
import importlib
import json
import math
from pathlib import Path
from typing import Any
from app.settings import Settings
CANONICAL_VIEWS = ("top", "bottom", "front", "back", "left", "right", "isometric")
RENDER_SIZE = 2048
REVIEW_SIZE = 1024
FRAME_PADDING = 0.14
BACKGROUND_RGB = (246, 248, 251)
VISIBLE_EDGE_RGB = (34, 54, 69)
HIDDEN_EDGE_RGB = (142, 157, 170)
class ReviewRenderError(RuntimeError):
"""The fixed-view renderer was unavailable or produced incomplete evidence."""
def renderer_status() -> tuple[bool, str]:
"""Verify that the pure-Python/OCC renderer dependencies are importable."""
try:
_render_modules()
except ReviewRenderError as error:
return False, str(error)
return True, ""
def _render_modules() -> tuple[Any, Any, Any]:
try:
pillow_image = importlib.import_module("PIL.Image")
pillow_draw = importlib.import_module("PIL.ImageDraw")
import_step = importlib.import_module("build123d").import_step
except (ImportError, AttributeError) as error:
raise ReviewRenderError(
"Python technical renderer is unavailable; install backend requirements (build123d and Pillow)"
) from error
return pillow_image, pillow_draw, import_step
def _number_list(value: Any, *, size: int) -> list[float] | None:
if not isinstance(value, list) or len(value) < size:
return None
try:
values = [float(item) for item in value[:size]]
except (TypeError, ValueError):
return None
return values if all(math.isfinite(item) for item in values) else None
def _bounds_center(bounds: list[float]) -> list[float]:
return [
(bounds[0] + bounds[1]) / 2,
(bounds[2] + bounds[3]) / 2,
(bounds[4] + bounds[5]) / 2,
]
def _shape_bounds(shape: Any) -> list[float]:
box = shape.bounding_box()
bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)]
if not all(math.isfinite(value) for value in bounds):
raise ReviewRenderError("STEP review source has invalid bounds")
return bounds
def _target_frame(target: dict[str, Any] | None, model_bounds: list[float]) -> tuple[list[float], float]:
model_center = _bounds_center(model_bounds)
model_extent = max(model_bounds[1] - model_bounds[0], model_bounds[3] - model_bounds[2], model_bounds[5] - model_bounds[4], 1.0)
if not isinstance(target, dict):
return model_center, 0.0
bbox = _number_list(target.get("bbox_mm"), size=6)
if bbox and bbox[3] > bbox[0] and bbox[4] > bbox[1] and bbox[5] > bbox[2]:
center = [(bbox[0] + bbox[3]) / 2, (bbox[1] + bbox[4]) / 2, (bbox[2] + bbox[5]) / 2]
extent = max(bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2], 1.0)
return center, min(model_extent, extent * 1.6)
center = _number_list(target.get("center_mm"), size=3)
try:
radius = float(target.get("radius_mm"))
except (TypeError, ValueError):
radius = 0.0
if center and math.isfinite(radius) and radius > 0:
return center, min(model_extent, max(radius * 2, 1.0) * 1.6)
return model_center, 0.0
def _camera_for(view_id: str, center: list[float]) -> dict[str, Any]:
directions = {
"top": ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
"bottom": ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
"front": ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]),
"back": ([0.0, 1.0, 0.0], [0.0, 0.0, 1.0]),
"left": ([-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
"right": ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
"isometric": ([1.0, -1.0, 0.8], [0.0, 0.0, 1.0]),
}
direction, view_up = directions.get(view_id, directions["isometric"])
length = math.sqrt(sum(item * item for item in direction)) or 1.0
normal = [item / length for item in direction]
# Orthographic HLR ignores the distance, but a large deterministic value
# makes the intended camera convention explicit in the manifest.
position = [center[index] + normal[index] * 100000.0 for index in range(3)]
return {"projection": "orthographic", "position": position, "focal_point": center, "view_up": view_up}
def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]:
count = max(2, min(1024, int(math.ceil(float(edge.length) / max(spacing, 0.002))) + 1))
try:
points = edge.positions([index / (count - 1) for index in range(count)])
except Exception:
points = [edge.position_at(0), edge.position_at(1)]
return [(float(point.X), float(point.Y)) for point in points]
def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]:
points = [point for edge in edges for point in _edge_points(edge, 0.5)]
if not points:
raise ReviewRenderError("Hidden-line projection produced no drawable edges")
xs, ys = zip(*points)
return min(xs), max(xs), min(ys), max(ys)
def _frame_bounds(edges: list[Any], target_extent: float) -> tuple[float, float, float, float]:
min_x, max_x, min_y, max_y = _projected_bounds(edges)
if target_extent > 0:
# HLR maps the look-at target to the projection origin, making this
# an exact, deterministic local crop without a GPU clipping plane.
half = target_extent / (2 * (1 - 2 * FRAME_PADDING))
return -half, half, -half, half
center_x, center_y = (min_x + max_x) / 2, (min_y + max_y) / 2
extent = max(max_x - min_x, max_y - min_y, 1.0)
half = extent / (2 * (1 - 2 * FRAME_PADDING))
return center_x - half, center_x + half, center_y - half, center_y + half
def _pixel(point: tuple[float, float], frame: tuple[float, float, float, float], size: int) -> tuple[int, int]:
min_x, max_x, min_y, max_y = frame
x = round((point[0] - min_x) * (size - 1) / (max_x - min_x))
y = round((max_y - point[1]) * (size - 1) / (max_y - min_y))
return int(x), int(y)
def _draw_dashed(draw: Any, points: list[tuple[int, int]], *, fill: tuple[int, int, int], width: int) -> None:
dash, gap = 16, 10
for start, end in zip(points, points[1:]):
dx, dy = end[0] - start[0], end[1] - start[1]
length = math.hypot(dx, dy)
if length <= 0:
continue
distance = 0.0
while distance < length:
segment_end = min(length, distance + dash)
first = (round(start[0] + dx * distance / length), round(start[1] + dy * distance / length))
last = (round(start[0] + dx * segment_end / length), round(start[1] + dy * segment_end / length))
draw.line((first, last), fill=fill, width=width)
distance += dash + gap
def _rasterize(
*,
visible: list[Any],
hidden: list[Any],
frame: tuple[float, float, float, float],
output_dir: Path,
view_id: str,
intentional_crop: bool,
) -> dict[str, Any]:
pillow_image, pillow_draw, _ = _render_modules()
image = pillow_image.new("RGB", (RENDER_SIZE, RENDER_SIZE), BACKGROUND_RGB)
mask = pillow_image.new("L", (RENDER_SIZE, RENDER_SIZE), 0)
draw = pillow_draw.Draw(image)
mask_draw = pillow_draw.Draw(mask)
spacing = max((frame[1] - frame[0]) / 1800, 0.01)
for edge in hidden:
points = [_pixel(point, frame, RENDER_SIZE) for point in _edge_points(edge, spacing)]
_draw_dashed(draw, points, fill=HIDDEN_EDGE_RGB, width=3)
_draw_dashed(mask_draw, points, fill=128, width=4)
for edge in visible:
points = [_pixel(point, frame, RENDER_SIZE) for point in _edge_points(edge, spacing)]
if len(points) >= 2:
draw.line(points, fill=VISIBLE_EDGE_RGB, width=4, joint="curve")
mask_draw.line(points, fill=255, width=5, joint="curve")
high_path = output_dir / "internal" / f"{view_id}-2x.png"
high_path.parent.mkdir(parents=True, exist_ok=True)
image.save(high_path, optimize=True)
output_path = output_dir / f"{view_id}.png"
image.resize((REVIEW_SIZE, REVIEW_SIZE), resample=pillow_image.Resampling.LANCZOS).save(output_path, optimize=True)
diagnostic_dir = output_dir / "internal" / view_id
diagnostic_dir.mkdir(parents=True, exist_ok=True)
mask_path = diagnostic_dir / "line-mask.png"
mask.save(mask_path)
edge_path = diagnostic_dir / "edge.png"
mask.save(edge_path)
box = mask.getbbox()
coverage = (RENDER_SIZE * RENDER_SIZE - mask.histogram()[0]) / (RENDER_SIZE * RENDER_SIZE)
pixel_bbox = list(box) if box else []
touches_border = bool(box and (box[0] <= 1 or box[1] <= 1 or box[2] >= RENDER_SIZE - 1 or box[3] >= RENDER_SIZE - 1))
valid = bool(box and coverage >= 0.00005 and coverage <= 0.20 and (intentional_crop or not touches_border))
return {
"path": str(output_path),
"high_resolution_path": str(high_path),
"diagnostics": {
"line_mask_path": str(mask_path),
"edge_path": str(edge_path),
"coverage": coverage,
"pixel_bbox": pixel_bbox,
"touches_border": touches_border,
"intentional_crop": intentional_crop,
"visible_edge_count": len(visible),
"hidden_edge_count": len(hidden),
"valid": valid,
},
}
def _render_view(
*,
shape: Any,
view_id: str,
projection_id: str,
target: dict[str, Any] | None,
model_bounds: list[float],
output_dir: Path,
) -> dict[str, Any]:
center, target_extent = _target_frame(target, model_bounds)
camera = _camera_for(projection_id, center)
try:
visible, hidden = shape.project_to_viewport(
camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"]
)
except Exception as error:
raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
visible_edges, hidden_edges = list(visible), list(hidden)
frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent)
rendered = _rasterize(
visible=visible_edges,
hidden=hidden_edges,
frame=frame,
output_dir=output_dir,
view_id=view_id,
intentional_crop=target_extent > 0,
)
if not rendered["diagnostics"]["valid"]:
raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered}
def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str:
"""Create compact whole-model evidence for routine reviewer calls."""
pillow_image, pillow_draw, _ = _render_modules()
canonical = [item for item in views if item["id"] in CANONICAL_VIEWS]
if not canonical:
return ""
tile = 400
sheet = pillow_image.new("RGB", (tile * 3, tile * 3), BACKGROUND_RGB)
draw = pillow_draw.Draw(sheet)
for index, item in enumerate(canonical):
image = pillow_image.open(str(item["path"])).convert("RGB").resize((tile, tile), resample=pillow_image.Resampling.LANCZOS)
x, y = (index % 3) * tile, (index // 3) * tile
sheet.paste(image, (x, y))
draw.rectangle((x + 8, y + 8, x + 96, y + 33), fill=(255, 255, 255))
draw.text((x + 14, y + 13), str(item["id"]), fill=VISIBLE_EDGE_RGB)
path = output_dir / "contact-sheet.jpg"
sheet.save(path, quality=88, optimize=True, progressive=True)
return str(path)
def render_checkpoint(
settings: Settings,
*,
step_path: Path,
output_dir: Path,
review_targets: list[dict[str, Any]] | None = None,
include_canonical: bool = True,
) -> dict[str, Any]:
"""Render STEP geometry into stable canonical and bounded node-detail views."""
del settings
ready, detail = renderer_status()
if not ready:
raise ReviewRenderError(detail)
if not step_path.is_file():
raise ReviewRenderError(f"STEP review source is missing: {step_path.name}")
_, _, import_step = _render_modules()
try:
shape = import_step(str(step_path))
except Exception as error:
raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error
bounds = _shape_bounds(shape)
output_dir.mkdir(parents=True, exist_ok=True)
jobs: list[tuple[str, dict[str, Any] | None]] = []
if include_canonical:
jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS)
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3]))
views = [
_render_view(
shape=shape,
view_id=view_id,
projection_id="isometric" if view_id.startswith("detail-") else view_id,
target=target,
model_bounds=bounds,
output_dir=output_dir,
)
for view_id, target in jobs
]
canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")}
if include_canonical and canonical != set(CANONICAL_VIEWS):
raise ReviewRenderError("Python review renderer did not produce every canonical view")
contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else ""
manifest = {
"schema_version": "cad.render-manifest.v2",
"renderer": "python-occ-hlr-pillow",
"source": {"type": "step", "path": str(step_path), "bounds_mm": bounds},
"high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"},
"review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
"contact_sheet_path": contact_sheet_path,
"views": views,
}
(output_dir / "render-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
return manifest
+300 -4
View File
@@ -181,21 +181,62 @@ class WorkspaceStore:
path = self.task_path(tid)
current = read_json(path)
if current:
return current
return self._migrate_task(current, path)
task_dir = self.task_dir(tid)
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
record = {
"schema_version": "1.2",
"schema_version": "1.3",
"task_id": tid,
"request": request,
"created_at": now_iso(),
"updated_at": now_iso(),
"current_revision": "",
"active_revision": "",
"published_revision": "",
"lifecycle": "completed",
"run_id": "",
"generation_spec_path": "",
"run_context_path": "",
"active_node_id": "",
"run_failure_path": "",
"revisions": [],
}
write_json(path, record)
return record
def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]:
"""Add run-state fields lazily without rewriting successful history."""
changed = False
current = str(task.get("current_revision") or "")
defaults = {
"schema_version": "1.3",
"active_revision": current,
"published_revision": current,
"lifecycle": "completed",
"run_id": "",
"generation_spec_path": "",
"run_context_path": "",
"active_node_id": "",
"run_failure_path": "",
}
for key, value in defaults.items():
if key not in task:
task[key] = value
changed = True
for revision in task.get("revisions") or ():
if not isinstance(revision, dict):
continue
if "visibility" not in revision:
revision["visibility"] = "final" if str(revision.get("revision_id") or "") == str(task["published_revision"] or "") else "checkpoint"
changed = True
if "branch_id" not in revision:
revision["branch_id"] = "main"
changed = True
if changed:
task["updated_at"] = now_iso()
write_json(path, task)
return task
def next_revision(self, task_id: str) -> tuple[str, Path]:
task = self.ensure_task(task_id, "")
revision_id = f"rev_{len(task['revisions']) + 1:03d}"
@@ -208,16 +249,225 @@ class WorkspaceStore:
task["revisions"].append(revision)
if revision.get("status") == "success":
task["current_revision"] = revision["revision_id"]
task["active_revision"] = revision["revision_id"]
if revision.get("visibility") == "final":
task["published_revision"] = revision["revision_id"]
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def read_task(self, task_id: str) -> dict[str, Any] | None:
return read_json(self.task_path(task_id))
task = read_json(self.task_path(task_id))
return self._migrate_task(task, self.task_path(task_id)) if isinstance(task, dict) else None
def start_generation(self, task_id: str, *, request: str, run_id: str | None = None) -> dict[str, Any]:
task = self.ensure_task(task_id, request)
if str(task.get("lifecycle") or "") == "running":
raise ValueError("CAD task is already running")
task.update({
"lifecycle": "running",
"run_id": run_id or new_id("run"),
"active_node_id": "",
"run_failure_path": "",
"request": request or task.get("request") or "",
"active_revision": str(task.get("current_revision") or ""),
"updated_at": now_iso(),
})
write_json(self.task_path(task_id), task)
return task
def finish_generation(self, task_id: str, *, lifecycle: str, failure: dict[str, Any] | None = None) -> dict[str, Any]:
if lifecycle not in {"completed", "failed"}:
raise ValueError("Generation lifecycle must be completed or failed")
task = self.ensure_task(task_id, "")
failure_path = ""
if failure:
failure_path = "run-failures/" + f"failure_{secrets.token_hex(8)}.json"
write_json(self.task_dir(task_id) / failure_path, failure)
if lifecycle == "completed":
task["published_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
for revision in task.get("revisions") or ():
if isinstance(revision, dict) and revision.get("revision_id") == task["published_revision"]:
revision["visibility"] = "final"
task.update({
"lifecycle": lifecycle,
"active_node_id": "",
"run_failure_path": failure_path,
"updated_at": now_iso(),
})
write_json(self.task_path(task_id), task)
return task
def set_active_revision(self, task_id: str, revision_id: str, *, branch_id: str | None = None) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
if not revision_id:
task["active_revision"] = ""
task["current_revision"] = ""
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
if not isinstance(revision, dict) or revision.get("status") != "success":
raise ValueError("Active revision must be a successful revision")
task["active_revision"] = revision_id
task["current_revision"] = revision_id
if branch_id:
task["active_branch_id"] = branch_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def set_active_node(self, task_id: str, node_id: str) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
task["active_node_id"] = node_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def update_revision_metadata(self, task_id: str, revision_id: str, values: dict[str, Any]) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
if not isinstance(revision, dict):
raise ValueError("Revision does not exist")
revision.update(values)
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def rollback_to_revision(self, task_id: str, revision_id: str, *, branch_id: str) -> dict[str, Any]:
"""Move the generation head without deleting immutable checkpoint artifacts."""
task = self.set_active_revision(task_id, revision_id, branch_id=branch_id)
children: dict[str, set[str]] = {}
for revision in task.get("revisions") or ():
if not isinstance(revision, dict):
continue
parent = str(revision.get("parent_revision_id") or "")
child = str(revision.get("revision_id") or "")
if parent and child:
children.setdefault(parent, set()).add(child)
superseded: set[str] = set()
pending = list(children.get(revision_id, set())) if revision_id else [
str(item.get("revision_id") or "")
for item in task.get("revisions") or ()
if isinstance(item, dict) and not str(item.get("parent_revision_id") or "")
]
while pending:
child = pending.pop()
if not child or child in superseded:
continue
superseded.add(child)
pending.extend(children.get(child, set()))
for revision in task.get("revisions") or ():
if isinstance(revision, dict) and str(revision.get("revision_id") or "") in superseded and revision.get("visibility") == "checkpoint":
revision["visibility"] = "superseded"
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def rollback_anchor_for_nodes(
self,
task_id: str,
node_ids: list[str],
*,
fallback_revision_id: str = "",
) -> str:
"""Return the revision before every affected node's latest checkpoint.
Returning each affected revision's parent (rather than the revision
itself) ensures the faulty node is regenerated. A common ancestor
keeps unrelated upstream work intact while permitting a single rollback
over any number of affected nodes.
"""
task = self.read_task(task_id) or {}
revisions = [item for item in task.get("revisions") or () if isinstance(item, dict)]
by_id = {str(item.get("revision_id") or ""): item for item in revisions}
parents: list[str] = []
for node_id in dict.fromkeys(str(item) for item in node_ids if str(item)):
matching = [item for item in revisions if item.get("status") == "success" and str(item.get("node_id") or "") == node_id]
if matching:
parents.append(str(matching[-1].get("parent_revision_id") or ""))
if not parents:
return fallback_revision_id
def lineage(revision_id: str) -> list[str]:
chain = [revision_id]
seen = {revision_id}
current = revision_id
while current:
parent = str((by_id.get(current) or {}).get("parent_revision_id") or "")
if parent in seen:
break
chain.append(parent)
seen.add(parent)
current = parent
return chain
common = set(lineage(parents[0]))
for parent in parents[1:]:
common.intersection_update(lineage(parent))
if not common:
return fallback_revision_id
return next((revision for revision in lineage(parents[0]) if revision in common), fallback_revision_id)
def write_generation_failure(self, task_id: str, payload: dict[str, Any]) -> str:
"""Persist an attempt-level diagnostic without changing lifecycle."""
relative = Path("generation-failures") / f"failure_{secrets.token_hex(8)}.json"
write_json(self.task_dir(task_id) / relative, payload)
return relative.as_posix()
def generation_spec_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "generation-spec.json"
def write_generation_spec(self, task_id: str, spec: dict[str, Any]) -> Path:
task = self.ensure_task(task_id, "")
path = self.generation_spec_path(task_id)
write_json(path, spec)
task["generation_spec_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def generation_run_context_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "generation-run-context.json"
def write_generation_run_context(self, task_id: str, context: dict[str, Any]) -> Path:
"""Persist the frozen authoring inputs needed to resume after restart."""
task = self.ensure_task(task_id, "")
path = self.generation_run_context_path(task_id)
write_json(path, context)
task["run_context_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_generation_run_context(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("run_context_path") or "")
context = read_json(self.artifact_path(task_id, relative)) if relative else None
return context if isinstance(context, dict) else None
def running_tasks(self) -> list[dict[str, Any]]:
"""Enumerate durable tasks that need a process-local worker."""
tasks: list[dict[str, Any]] = []
for candidate in self.settings.task_root.glob("cad_*"):
if not candidate.is_dir() or not TASK_ID.fullmatch(candidate.name):
continue
task = self.read_task(candidate.name)
if isinstance(task, dict) and task.get("lifecycle") == "running":
tasks.append(task)
return tasks
def read_generation_spec(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("generation_spec_path") or "")
return read_json(self.artifact_path(task_id, relative)) if relative else None
def revision_dir(self, task_id: str, revision_id: str) -> Path:
return self.task_dir(task_id) / "revisions" / revision_id
def current_cdsl_path(self, task_id: str) -> Path | None:
task = self.read_task(task_id)
revision_id = str((task or {}).get("current_revision") or "")
revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "")
if not revision_id:
return None
candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json"
@@ -235,6 +485,52 @@ class WorkspaceStore:
return revision_id, path
return None
def feature_plan_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "feature-plan.json"
def read_feature_plan(self, task_id: str) -> dict[str, Any] | None:
safe_task = safe_task_id(task_id)
plan = read_json(self.feature_plan_path(safe_task))
if not isinstance(plan, dict):
return None
plan_task = str(plan.get("task_id") or "")
if plan_task and plan_task != safe_task:
return None
return plan
def write_feature_plan(self, task_id: str, plan: dict[str, Any]) -> Path:
safe_task = safe_task_id(task_id)
if not isinstance(plan, dict):
raise ValueError("Feature plan must be an object")
plan_task = str(plan.get("task_id") or "")
if plan_task and plan_task != safe_task:
raise ValueError("Feature plan task_id does not match its task")
plan["task_id"] = safe_task
path = self.feature_plan_path(safe_task)
write_json(path, plan)
return path
def revision_topology_path(self, task_id: str, revision_id: str) -> Path | None:
task = self.read_task(task_id)
revision = next(
(item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id),
None,
)
if not isinstance(revision, dict):
return None
relative = str(revision.get("topology_path") or "")
if not relative:
return None
candidate = self.artifact_path(task_id, relative)
return candidate if candidate.is_file() else None
def current_topology_path(self, task_id: str) -> Path | None:
task = self.read_task(task_id)
revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "")
if not revision_id:
return None
return self.revision_topology_path(task_id, revision_id)
def revision_cdsl_path(self, task_id: str, revision_id: str) -> Path | None:
task = self.read_task(task_id)
revision = next(
+141
View File
@@ -0,0 +1,141 @@
"""Independent, structured visual review of generated checkpoint renders."""
from __future__ import annotations
import base64
import json
from pathlib import Path
from typing import Any
import httpx
from app.settings import ProviderConfig, ProviderModel, Settings
VISUAL_REVIEW_TOOL = {
"type": "function",
"function": {
"name": "review_rendered_checkpoint",
"description": "Review fixed CAD render views against frozen requirements. Never author or modify CDSL.",
"parameters": {
"type": "object",
"properties": {
"verdict": {"enum": ["pass", "warning", "repair"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"affected_node_ids": {"type": "array", "items": {"type": "string"}, "maxItems": 12},
"requirement_ids": {"type": "array", "items": {"type": "string"}, "maxItems": 32},
"evidence": {"type": "array", "items": {"type": "string"}, "maxItems": 12},
},
"required": ["verdict", "confidence", "affected_node_ids", "requirement_ids", "evidence"],
"additionalProperties": False,
},
},
}
class VisualReviewError(RuntimeError):
pass
def _image_part(path: Path) -> dict[str, Any]:
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
media = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
return {"type": "image_url", "image_url": {"url": f"data:{media};base64,{encoded}"}}
def _selected_review_views(manifest: dict[str, Any], *, final_checkpoint: bool) -> list[dict[str, Any]]:
"""Keep each reviewer call bounded while canonical evidence stays archived.
A contact sheet establishes global context. Up to two planned detail views
provide node-specific evidence. The final checkpoint adds full canonical
views because it is the only point where those extra image tokens pay off.
"""
views = [item for item in manifest.get("views") or () if isinstance(item, dict)]
by_id = {str(item.get("id") or ""): item for item in views}
selected: list[dict[str, Any]] = []
contact = Path(str(manifest.get("contact_sheet_path") or ""))
if contact.is_file():
selected.append({"id": "contact-sheet", "path": str(contact), "camera": {"projection": "mixed"}})
for view_id in ("detail-1", "detail-2"):
item = by_id.get(view_id)
if item is not None:
selected.append(item)
if final_checkpoint:
selected.extend(by_id[view_id] for view_id in ("top", "bottom", "front", "back", "left", "right", "isometric") if view_id in by_id)
elif not selected and "isometric" in by_id:
selected.append(by_id["isometric"])
return selected or views[:1]
async def review_checkpoint(
settings: Settings,
*,
manifest: dict[str, Any],
requirements: list[dict[str, Any]],
node_id: str,
deterministic_report: dict[str, Any],
source_images: list[Path] | None = None,
final_checkpoint: bool = False,
) -> dict[str, Any]:
provider, model = settings.resolve_review_model()
views = _selected_review_views(manifest, final_checkpoint=final_checkpoint)
paths = [Path(str(item.get("path") or "")) for item in views]
if not paths or not all(path.is_file() for path in paths):
raise VisualReviewError("Review render manifest references missing image files")
content: list[dict[str, Any]] = [{
"type": "text",
"text": json.dumps({
"node_id": node_id,
"requirements": requirements,
"deterministic_report": deterministic_report,
"render_manifest": {
"renderer": manifest.get("renderer"),
"source": manifest.get("source"),
"views": [{"id": item.get("id"), "camera": item.get("camera"), "diagnostics": item.get("diagnostics")} for item in views],
},
"instruction": "Identify visible missing geometry, wrong silhouette, orientation, or proportion. Do not infer hidden dimensions. Return repair only for an observable issue.",
}, ensure_ascii=False),
}]
content.extend(_image_part(path) for path in paths)
# Reference images are only supplementary evidence. Keep this bounded so
# an attachment-heavy request does not dominate every checkpoint review.
for path in (source_images or [])[:2]:
if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}:
content.append(_image_part(path))
tool = json.loads(json.dumps(VISUAL_REVIEW_TOOL))
if model.strict_tool_schema:
tool["function"]["strict"] = True
payload = {
"model": model.id,
"messages": [
{"role": "system", "content": "You are an independent CAD visual reviewer. You may only call review_rendered_checkpoint."},
{"role": "user", "content": content},
],
"tools": [tool],
"tool_choice": {"type": "function", "function": {"name": "review_rendered_checkpoint"}},
"temperature": 0,
}
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client:
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload)
if response.status_code >= 400:
raise VisualReviewError(f"Visual review request failed ({response.status_code}): {response.text[:500]}")
try:
call = response.json()["choices"][0]["message"]["tool_calls"][0]
if call["function"]["name"] != "review_rendered_checkpoint":
raise KeyError("wrong tool")
result = json.loads(call["function"]["arguments"])
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
raise VisualReviewError("Visual reviewer did not return a valid review tool call") from error
if not isinstance(result, dict) or result.get("verdict") not in {"pass", "warning", "repair"}:
raise VisualReviewError("Visual reviewer returned an invalid verdict")
try:
confidence = float(result.get("confidence"))
except (TypeError, ValueError) as error:
raise VisualReviewError("Visual reviewer returned an invalid confidence") from error
if not 0 <= confidence <= 1:
raise VisualReviewError("Visual reviewer confidence is outside [0, 1]")
for key in ("affected_node_ids", "requirement_ids", "evidence"):
if not isinstance(result.get(key), list) or not all(isinstance(item, str) for item in result[key]):
raise VisualReviewError(f"Visual reviewer returned an invalid {key}")
return {"schema_version": "cad.visual-review.v1", "node_id": node_id, "model": model.id, **result}
+28
View File
@@ -51,6 +51,12 @@ class Settings:
default_provider_id: str
providers: tuple[ProviderConfig, ...]
max_repair_attempts: int = 4
review_provider_id: str = ""
review_model_id: str = ""
node_authoring_attempts: int = 2
node_repair_attempts: int = 2
node_replan_attempts: int = 1
incremental_generation: bool = False
@property
def llm_configured(self) -> bool:
@@ -70,6 +76,22 @@ class Settings:
raise ValueError("The selected model is not enabled for this provider")
return provider, model
def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]:
"""Return the independently configured visual reviewer, never an author fallback."""
provider_id = self.review_provider_id
if not provider_id:
raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider")
provider = self.provider_for(provider_id)
if provider is None:
raise ValueError("The configured visual review provider is unavailable")
model_id = self.review_model_id or ""
if not model_id:
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model")
model = provider.model(model_id)
if model is None or not model.vision:
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model")
return provider, model
def _enabled_model_ids(value: str) -> set[str]:
return {item.strip() for item in value.split(",") if item.strip()}
@@ -141,4 +163,10 @@ def get_settings() -> Settings:
max_repair_attempts=max(0, int(os.getenv("CDSL_MAX_REPAIR_ATTEMPTS", "4"))),
default_provider_id=default_provider_id,
providers=providers,
review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(),
review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(),
node_authoring_attempts=max(1, int(os.getenv("CDSL_NODE_AUTHORING_ATTEMPTS", "2"))),
node_repair_attempts=max(0, int(os.getenv("CDSL_NODE_REPAIR_ATTEMPTS", "2"))),
node_replan_attempts=max(0, int(os.getenv("CDSL_NODE_REPLAN_ATTEMPTS", "1"))),
incremental_generation=_as_bool(os.getenv("CDSL_INCREMENTAL_GENERATION", "1")),
)
+2 -1
View File
@@ -249,7 +249,8 @@
"stable_id": {"type": "string", "minLength": 1},
"owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"geometry": {"type": "object"},
"source": {"enum": ["solidworks", "inferred_from_step"]},
"source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]},
"snapshot_id": {"type": "string", "minLength": 1},
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["kind", "stable_id", "source", "confidence"],
+10 -2
View File
@@ -89,6 +89,7 @@ class ExecutionSession:
results: dict[str, FeatureResult] = field(default_factory=dict)
replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict)
selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
active_feature_id: str = ""
def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None:
self.body = body
@@ -103,7 +104,9 @@ class ExecutionSession:
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
resolution = self.topology.resolve(selector, active_body_id=self.body_id)
self.selector_resolutions.append(resolution.as_dict())
evidence = resolution.as_dict()
evidence["feature_id"] = self.active_feature_id
self.selector_resolutions.append(evidence)
return resolution
def result(self, node: FeaturePlanNode, *, context: PlaneSpec | AxisSpec | None = None, diagnostics: list[RuntimeDiagnostic] | None = None) -> FeatureResult:
@@ -677,7 +680,12 @@ def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_overr
executor = EXECUTORS.get(node.atomic_id)
if executor is None:
raise ValueError(f"No executor registered for {node.atomic_id!r}")
return executor(node, session, sketch_override)
previous_feature_id = session.active_feature_id
session.active_feature_id = node.feature_id
try:
return executor(node, session, sketch_override)
finally:
session.active_feature_id = previous_feature_id
ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]
@@ -381,6 +381,13 @@ class SelectorResolution:
}
if self.record is not None:
output["record"] = self.record.public_dict()
output["selected"] = self.record.public_dict()
score = next(
(candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id),
None,
)
if score is not None:
output["score"] = score
if self.diagnostic is not None:
output["diagnostic"] = self.diagnostic.as_dict()
return output
@@ -589,6 +596,66 @@ class TopologyRegistry:
if owner:
candidates = [record for record in candidates if owner in record.owners]
geometry = normalize_selector_geometry(selector.get("geometry"))
if selector.get("snapshot_id") and not owner:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_owner_required",
message="A snapshot selector requires owner_feature_id",
detail={"minimum_score": minimum_score},
),
)
stable_id = str(selector.get("stable_id") or "").strip()
if stable_id:
exact = [record for record in candidates if record.record_id == stable_id]
if len(exact) == 1:
record = exact[0]
# A stable ID is only a lookup accelerator for snapshot-aware
# selectors. It cannot revive a B-rep entity whose geometric
# signature changed after an upstream rebuild.
if selector.get("snapshot_id"):
score = self._geometry_score(geometry, record.geometry) if geometry else None
if score is None or score < minimum_score:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=({"score": round(float(score or 0), 6), **record.public_dict()},),
diagnostic=RuntimeDiagnostic(
code="selector_geometry_mismatch",
message="The stable selector record no longer matches its geometry signature",
detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score},
),
)
return SelectorResolution(
selector=selector,
status="resolved",
record=record,
candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},),
)
if len(exact) > 1:
return SelectorResolution(
selector=selector,
status="ambiguous",
candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact),
diagnostic=RuntimeDiagnostic(
code="selector_ambiguous",
message="More than one runtime topology record has the requested stable_id",
detail={"stable_id": stable_id, "candidate_count": len(exact)},
),
)
if selector.get("snapshot_id") and not geometry:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_geometry_mismatch",
message="A snapshot selector requires a geometry signature",
detail={"minimum_score": minimum_score},
),
)
scored: list[tuple[float, TopologyRecord]] = []
for candidate in candidates:
# An owner-qualified context selector is deterministic when it has
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
opencv-python-headless>=4.9,<5
+1
View File
@@ -5,3 +5,4 @@ uvicorn[standard]>=0.30,<1
build123d
python-multipart>=0.0.9,<1
jsonschema>=4.23,<5
Pillow>=10,<12
@@ -0,0 +1,97 @@
"""Explicitly remove attachments belonging to conversations without v2 image observations.
The command is dry-run by default. It only mutates data when ``--apply`` is
provided, and it never removes CAD task artifacts.
"""
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
import sys
BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
from app.services.storage import read_json, write_json
from app.settings import get_settings
def _has_v2_observation(record: dict) -> bool:
for message in record.get("messages") or []:
if not isinstance(message, dict):
continue
for part in message.get("parts") or []:
if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis":
continue
data = part.get("data")
if isinstance(data, dict) and str(data.get("schemaVersion") or data.get("schema_version") or "") == "cad.image-observation.v2":
return True
return False
def plan_cleanup(root: Path) -> list[tuple[Path, str]]:
planned: list[tuple[Path, str]] = []
for conversation_dir in sorted(root.glob("conv_*")):
record_path = conversation_dir / "conversation.json"
record = read_json(record_path)
if not isinstance(record, dict) or _has_v2_observation(record):
continue
uploads = conversation_dir / "uploads"
if uploads.is_dir():
planned.append((uploads, "legacy upload directory"))
planning = conversation_dir / "planning"
if planning.is_dir():
planned.append((planning, "legacy planning directory"))
if record.get("attachments"):
planned.append((record_path, "remove legacy attachment metadata and image-analysis parts"))
return planned
def apply_cleanup(root: Path) -> int:
changed = 0
for conversation_dir in sorted(root.glob("conv_*")):
record_path = conversation_dir / "conversation.json"
record = read_json(record_path)
if not isinstance(record, dict) or _has_v2_observation(record):
continue
for relative in ("uploads", "planning"):
target = conversation_dir / relative
if target.is_dir():
shutil.rmtree(target)
changed += 1
record["attachments"] = []
for message in record.get("messages") or []:
if not isinstance(message, dict):
continue
message["parts"] = [
part for part in message.get("parts") or []
if not (isinstance(part, dict) and part.get("type") == "data-cad-image-analysis")
]
write_json(record_path, record)
changed += 1
return changed
def main() -> int:
parser = argparse.ArgumentParser(description="Remove legacy conversation attachments")
parser.add_argument("--apply", action="store_true", help="perform deletion; default is dry-run")
parser.add_argument("--dry-run", action="store_true", help="list deletion targets without changing files")
args = parser.parse_args()
root = get_settings().conversation_root.resolve()
if root.name != "conversations":
raise SystemExit(f"Refusing unexpected conversation root: {root}")
planned = plan_cleanup(root)
for path, reason in planned:
print(f"{'DELETE' if args.apply else 'WOULD DELETE'} {path} ({reason})")
if not args.apply:
print(f"Dry-run: {len(planned)} targets. Re-run with --apply to delete.")
return 0
print(f"Deleted {apply_cleanup(root)} conversation records/directories.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+62 -1
View File
@@ -9,7 +9,7 @@ from types import SimpleNamespace
from unittest.mock import patch
from app.models.contracts import ChatMessage, MessagePart
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, get_repair_step_key, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message
from app.services.engine_service import load_engine
from app.services.quality import QUALITY_RULE_TYPES
from app.services.library import CdslLibrary
@@ -79,6 +79,38 @@ class ParseToolArgumentsTests(unittest.TestCase):
with self.assertRaisesRegex(ToolArgumentsError, "JSON object"):
parse_tool_arguments('["not", "tool arguments"]')
class RepairStepKeyTests(unittest.TestCase):
def test_generation_and_patch_share_the_same_feature_step_budget(self) -> None:
state = {
"phase": "CDSL_REPAIR",
"feature_plan": {
"plan_id": "plan_1",
"nodes": [
{"id": "boss", "status": "ready"},
{"id": "hole", "status": "waiting_for_selection"},
],
},
}
self.assertEqual(
get_repair_step_key(state, "generate_cdsl_model"),
get_repair_step_key(state, "patch_cdsl_model"),
)
def test_completed_nodes_do_not_change_the_active_step_key(self) -> None:
state = {
"phase": "CDSL_REPAIR",
"feature_plan": {
"plan_id": "plan_1",
"nodes": [
{"id": "base", "status": "completed"},
{"id": "boss", "status": "ready"},
],
},
}
self.assertEqual(get_repair_step_key(state, "generate_cdsl_model"), "plan:plan_1:boss")
def test_recovers_only_the_known_premature_cdsl_wrapper_close(self) -> None:
payload = parse_tool_arguments(
'{"cdsl":{"schema":"cad.cdsl.llm.v1"}}, "summary":"fixed envelope"}',
@@ -88,6 +120,28 @@ class ParseToolArgumentsTests(unittest.TestCase):
self.assertEqual(payload["summary"], "fixed envelope")
self.assertEqual(payload["cdsl"], {"schema": "cad.cdsl.llm.v1"})
def test_recovers_trailing_cdsl_metadata_after_a_complete_envelope(self) -> None:
payload = parse_tool_arguments(
'{"cdsl":{"schema":"cad.cdsl.llm.v1"},"summary":"fixed envelope",'
'"assumptions":["metric"]}, "summary":"repeated envelope",'
'"assumptions":["metric"]}',
recover_cdsl_wrapper=True,
)
self.assertEqual(payload, {
"cdsl": {"schema": "cad.cdsl.llm.v1"},
"summary": "fixed envelope",
"assumptions": ["metric"],
})
def test_rejects_a_second_cdsl_payload_after_a_complete_envelope(self) -> None:
with self.assertRaisesRegex(ToolArgumentsError, "trailing content"):
parse_tool_arguments(
'{"cdsl":{"schema":"cad.cdsl.llm.v1"},"summary":"original",'
'"assumptions":[]}, "cdsl":{"schema":"different"}}',
recover_cdsl_wrapper=True,
)
def test_does_not_recover_arbitrary_trailing_tool_content(self) -> None:
with self.assertRaisesRegex(ToolArgumentsError, "trailing content"):
parse_tool_arguments(
@@ -123,6 +177,13 @@ class ParseToolArgumentsTests(unittest.TestCase):
rule_type = verification["properties"]["rules"]["items"]["properties"]["type"]
self.assertEqual(set(rule_type["enum"]), set(QUALITY_RULE_TYPES))
def test_verification_schema_requires_feature_for_feature_scoped_rules(self) -> None:
generate_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "generate_cdsl_model")
rule = generate_tool["function"]["parameters"]["properties"]["verification"]["properties"]["rules"]["items"]
self.assertTrue(any("feature" in branch.get("then", {}).get("required", []) for branch in rule["allOf"]))
self.assertIn("overall_width", rule["properties"]["type"]["enum"])
self.assertIn("overall_height", rule["properties"]["type"]["enum"])
def test_strict_tool_schema_covers_generation_arguments(self) -> None:
tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True))
strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")]
+230 -1
View File
@@ -13,7 +13,7 @@ from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from app.services.agent_service import AgentService # noqa: E402
from app.services.agent_service import AgentService, _validate_snapshot_selectors # noqa: E402
from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch # noqa: E402
from app.services.engine_service import QualityVerificationError, build_revision # noqa: E402
from app.services.library import CdslLibrary # noqa: E402
@@ -129,6 +129,69 @@ class VerificationTests(unittest.TestCase):
self.assertEqual(report["status"], "passed")
self.assertEqual(report["results"][0]["source"], "runtime.topology_records[base_add].bbox_mm")
def test_feature_bbox_excludes_inherited_runtime_records(self) -> None:
document = fixture()
document["features"].append({
"id": "boss",
"atomic_id": "extrude_add_blind",
"depends_on": ["base_add"],
"sketch_id": "base",
"params": {"distance_mm": 8},
})
rules = validate_verification({"rules": [
{"id": "boss_range", "type": "bbox", "feature": "boss", "expected": [8, 58, 58]},
]}, document)
report = evaluate_quality(rules, document, {
"bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]},
"topology_records": [
{
"feature_id": "boss", "owner_feature_ids": ["base"],
"geometry": {"bbox_mm": [0, 0, 0, 128, 70, 90]},
},
{
"feature_id": "boss", "owner_feature_ids": ["boss"],
"geometry": {"bbox_mm": [60, -29, 16, 68, 29, 74]},
},
],
})
self.assertEqual(report["status"], "passed")
self.assertEqual(report["results"][0]["actual"]["dimensions"], [8.0, 58.0, 58.0])
def test_feature_diameter_uses_target_circle(self) -> None:
document = fixture()
document["features"][0]["id"] = "boss"
document["features"][0]["sketch_id"] = "boss_sketch"
document["geometry"]["sketches"][0]["id"] = "boss_sketch"
document["geometry"]["sketches"][0]["profile"] = {"type": "circle", "center": [0, 0], "radius_mm": 29}
rules = validate_verification({"rules": [
{"id": "boss_dia", "type": "overall_diameter", "feature": "boss", "expected": 58},
]}, document)
report = evaluate_quality(rules, document, {
"bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]},
})
self.assertEqual(report["status"], "passed")
self.assertEqual(report["results"][0]["actual"], 58.0)
def test_overall_width_and_height_are_supported(self) -> None:
rules = validate_verification({"rules": [
{"id": "width", "type": "overall_width", "expected": 70},
{"id": "height", "type": "overall_height", "expected": 90},
]}, fixture())
report = evaluate_quality(rules, fixture(), {
"bbox_mm": {"min": [-64, -35, 0], "max": [64, 35, 90]},
})
self.assertEqual(report["status"], "passed")
self.assertEqual([item["actual"] for item in report["results"]], [70.0, 90.0])
def test_feature_scoped_verification_requires_feature(self) -> None:
with self.assertRaisesRegex(ValueError, "feature is required for hole_count"):
validate_verification({"rules": [
{"id": "holes", "type": "hole_count", "expected": 1},
]}, fixture())
def test_bbox_shape_is_rejected_before_execution(self) -> None:
with self.assertRaisesRegex(ValueError, "expected for bbox"):
validate_verification({"rules": [
@@ -211,6 +274,147 @@ class AgentPatchFlowTests(unittest.TestCase):
self.assertEqual(captured["operation"]["type"], "cdsl_patch")
self.assertEqual(captured["cdsl"]["features"][0]["params"]["distance_mm"], 8)
def test_completed_feature_can_keep_selector_from_historical_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
task_id, parent = self._seed_revision(store)
parent_dir = store.task_dir(task_id) / "revisions" / parent
parent_snapshot_id = f"{task_id}/{parent}"
write_json(parent_dir / "model.topology.json", {
"schema_version": "cad.topology.v1",
"task_id": task_id,
"revision_id": parent,
"snapshot_id": parent_snapshot_id,
"records": [{"record_id": "body:base:edge:0", "kind": "edge", "executable": True, "geometry": {}}],
})
task = store.read_task(task_id)
task["revisions"][0]["topology_path"] = f"revisions/{parent}/model.topology.json"
write_json(store.task_path(task_id), task)
child = "rev_002"
child_dir = store.task_dir(task_id) / "revisions" / child
child_dir.mkdir(parents=True)
write_json(child_dir / "model.cdsl.json", fixture())
write_json(child_dir / "model.topology.json", {
"schema_version": "cad.topology.v1",
"task_id": task_id,
"revision_id": child,
"snapshot_id": f"{task_id}/{child}",
"records": [{"record_id": "body:base:edge:1", "kind": "edge", "executable": True, "geometry": {}}],
})
store.update_task(task_id, {
"revision_id": child,
"status": "success",
"cdsl_path": f"revisions/{child}/model.cdsl.json",
"topology_path": f"revisions/{child}/model.topology.json",
})
document = fixture()
document["features"][0]["selectors"] = [{
"kind": "edge",
"stable_id": "body:base:edge:0",
"source": "runtime_snapshot",
"snapshot_id": parent_snapshot_id,
"confidence": 1.0,
}]
_validate_snapshot_selectors(store, task_id, document)
document["features"][0]["selectors"][0]["snapshot_id"] = f"{task_id}/rev_999"
with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"):
_validate_snapshot_selectors(store, task_id, document)
def test_topology_inspection_unlocks_waiting_plan_nodes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
task_id, revision_id = self._seed_revision(store)
revision_dir = store.task_dir(task_id) / "revisions" / revision_id
snapshot_id = f"{task_id}/{revision_id}"
write_json(revision_dir / "model.topology.json", {
"schema_version": "cad.topology.v1",
"task_id": task_id,
"revision_id": revision_id,
"snapshot_id": snapshot_id,
"records": [{
"record_id": "body:base_add",
"kind": "body",
"body_id": "body:base_add",
"feature_id": "base_add",
"owner_feature_ids": ["base_add"],
"geometry": {},
"executable": True,
}],
})
task = store.read_task(task_id)
task["revisions"][0]["topology_path"] = f"revisions/{revision_id}/model.topology.json"
write_json(store.task_path(task_id), task)
state = {
"phase": "TOPOLOGY_READY",
"feature_plan": {
"schema_version": "cad.feature-plan.v1",
"plan_id": "plan_1",
"task_id": task_id,
"nodes": [
{"id": "base", "atomic_id": "extrude_add_blind", "depends_on": [], "cdsl_feature_ids": ["base_add"]},
{"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "cdsl_feature_ids": ["round"]},
],
},
}
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
result, _ = asyncio.run(agent._run_tool(
"inspect_current_topology", {}, task_id, "Round the edges", [], planning_state=state,
))
self.assertTrue(result["ok"])
self.assertEqual(state["feature_plan"]["topology_snapshot_id"], snapshot_id)
self.assertEqual(state["feature_plan"]["ready_nodes"], ["round"])
def test_successful_build_automatically_binds_topology_for_waiting_nodes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
state = {
"phase": "PLAN_READY",
"feature_plan": {
"schema_version": "cad.feature-plan.v1",
"plan_id": "plan_1",
"task_id": "",
"nodes": [
{
"id": "base",
"atomic_id": "extrude_add_blind",
"depends_on": [],
"cdsl_feature_ids": ["base_add"],
"status": "ready",
},
{
"id": "round",
"atomic_id": "fillet",
"depends_on": ["base"],
"cdsl_feature_ids": ["round"],
},
],
},
}
result, _ = asyncio.run(agent._run_tool(
"generate_cdsl_model",
{"cdsl": fixture(), "summary": "Base feature", "assumptions": []},
None,
"Create a base with a later fillet",
[],
planning_state=state,
))
self.assertTrue(result["ok"])
self.assertEqual(result["required_action"], "patch_cdsl_model")
self.assertEqual(result["plan_status"]["ready_nodes"], ["round"])
self.assertEqual(result["plan_status"]["waiting_nodes"], [])
self.assertEqual(state["feature_plan"]["topology_snapshot_id"], "{}/{}".format(result["task_id"], result["revision_id"]))
def test_bad_patch_creates_no_revision(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
@@ -230,6 +434,31 @@ class AgentPatchFlowTests(unittest.TestCase):
task = store.read_task(task_id)
self.assertEqual([item["revision_id"] for item in task["revisions"]], [parent])
def test_patch_rejects_non_current_parent_revision(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
store = WorkspaceStore(config)
task_id, parent = self._seed_revision(store)
child = "rev_002"
child_dir = store.task_dir(task_id) / "revisions" / child
child_dir.mkdir(parents=True)
write_json(child_dir / "model.cdsl.json", fixture())
store.update_task(task_id, {
"revision_id": child,
"status": "success",
"cdsl_path": f"revisions/{child}/model.cdsl.json",
})
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"):
asyncio.run(agent._run_tool(
"patch_cdsl_model",
{"base_revision_id": parent, "patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}], "summary": "stale", "assumptions": []},
task_id,
"stale patch",
[],
planning_state={"phase": "PLANNED"},
))
def test_complete_replacement_uses_current_revision_as_parent(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = settings(Path(directory))
@@ -193,6 +193,37 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
self.assertEqual(resolution.status, "ambiguous")
self.assertEqual(resolution.diagnostic.code, "selector_ambiguous")
def test_selector_resolver_prefers_exact_runtime_stable_id(self) -> None:
registry = TopologyRegistry()
geometry = {"curve_type": "circle", "center_mm": [0, 0, 0]}
registry.register(TopologyRecord("body:b:edge:18", "edge", "boss", "body:b", geometry))
registry.register(TopologyRecord("body:b:edge:20", "edge", "boss", "body:b", geometry))
resolution = registry.resolve({
"kind": "edge",
"stable_id": "body:b:edge:18",
"owner_feature_id": "boss",
"snapshot_id": "cad_test/rev_001",
"geometry": geometry,
}, active_body_id="body:b")
self.assertEqual(resolution.status, "resolved")
self.assertEqual(resolution.record.record_id, "body:b:edge:18")
def test_snapshot_stable_id_rejects_geometry_that_changed(self) -> None:
registry = TopologyRegistry()
registry.register(TopologyRecord(
"body:b:edge:18", "edge", "boss", "body:b",
{"curve_type": "circle", "center_mm": [0, 0, 0]},
))
resolution = registry.resolve({
"kind": "edge", "stable_id": "body:b:edge:18", "owner_feature_id": "boss",
"snapshot_id": "cad_test/rev_001",
"geometry": {"curve_type": "circle", "center_mm": [1, 0, 0]},
}, active_body_id="body:b")
self.assertEqual(resolution.status, "not_found")
self.assertEqual(resolution.diagnostic.code, "selector_geometry_mismatch")
def test_selector_resolver_normalizes_legacy_solidworks_plane_evidence(self) -> None:
registry = TopologyRegistry()
registry.register(TopologyRecord(
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from app.services.feature_plan import FeaturePlanError, compute_node_statuses, validate_feature_plan # noqa: E402
class FeaturePlanTests(unittest.TestCase):
def plan(self) -> dict:
return {
"schema_version": "cad.feature-plan.v1",
"plan_id": "plan_test",
"task_id": "cad_test",
"nodes": [
{"id": "base", "atomic_id": "extrude_add_blind", "depends_on": []},
{"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "requires_topology": True},
],
}
def test_rejects_cycles_and_missing_dependencies(self) -> None:
cyclic = self.plan()
cyclic["nodes"][0]["depends_on"] = ["round"]
with self.assertRaises(FeaturePlanError):
validate_feature_plan(cyclic, supported_atomic_ids={"extrude_add_blind", "fillet"})
missing = self.plan()
missing["nodes"][1]["depends_on"] = ["missing"]
with self.assertRaises(FeaturePlanError):
validate_feature_plan(missing, supported_atomic_ids={"extrude_add_blind", "fillet"})
def test_ready_prefix_waits_for_topology(self) -> None:
statuses = compute_node_statuses(self.plan(), cdsl={"features": []})
self.assertEqual(statuses["ready_nodes"], ["base"])
self.assertEqual(statuses["waiting_nodes"], [])
def test_built_prefix_unlocks_topology_dependent_node(self) -> None:
statuses = compute_node_statuses(
self.plan(),
cdsl={"features": [{"id": "base"}]},
topology={"records": [{"record_id": "body:base", "kind": "body", "executable": True}]},
)
self.assertEqual(statuses["completed_nodes"], ["base"])
self.assertEqual(statuses["waiting_nodes"], ["round"])
def test_inspected_snapshot_unlocks_topology_dependent_node(self) -> None:
plan = self.plan()
plan["topology_snapshot_id"] = "cad_test/rev_001"
statuses = compute_node_statuses(
plan,
cdsl={"features": [{"id": "base"}]},
topology={
"snapshot_id": "cad_test/rev_001",
"records": [{"record_id": "body:base", "kind": "body", "executable": True}],
},
)
self.assertEqual(statuses["ready_nodes"], ["round"])
self.assertEqual(statuses["waiting_nodes"], [])
def test_dependency_must_appear_before_dependent_node(self) -> None:
plan = self.plan()
plan["nodes"] = [plan["nodes"][1], plan["nodes"][0]]
with self.assertRaisesRegex(FeaturePlanError, "appear after dependency"):
validate_feature_plan(plan, supported_atomic_ids={"extrude_add_blind", "fillet"})
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import asyncio
import io
import json
from pathlib import Path
from tempfile import TemporaryDirectory
from PIL import Image
from app.services.image_observation import (
merge_image_observations,
normalize_image_observation,
normalize_sketch_candidates,
render_image_observation_context,
)
from app.services.image_processing import cv_hints, image_metadata
from app.services.agent_service import tools_for_model
from app.services.agent_service import AgentService
from app.services.attachments import attachment_record
from app.services.library import CdslLibrary
from app.services.storage import WorkspaceStore
from app.settings import ProviderConfig, ProviderModel, Settings
from app.models.contracts import ChatMessage, MessagePart
def test_observation_keeps_multiview_profiles_and_measurement_sources() -> None:
result = normalize_image_observation({
"part_type": "bent bracket",
"visible_features": ["plate", "irregular opening"],
"uncertain_features": ["inner bend radius"],
"views": [{"attachment_id": "upload_a", "view_role": "front", "confidence": 0.8}],
"profiles": [{
"id": "opening_01",
"role": "cutout",
"closed": True,
"source_images": ["upload_a"],
"segments": [
{"type": "line", "start": [0, 0], "end": [10, 0]},
{"type": "arc", "start": [10, 0], "end": [10, 4], "center": [8, 2], "radius_mm": 2},
{"type": "polyline", "points": [[10, 4], [5, 8], [0, 4]]},
],
}],
"measurements": [{"name": "plate_thickness", "value_mm": 1.2, "source": "user"}],
}, attachment_ids=["upload_a"])
assert result["schema_version"] == "cad.image-observation.v2"
assert result["profiles"][0]["segments"][1]["type"] == "arc"
assert result["measurements"][0]["source"] == "user"
assert "opening_01" in render_image_observation_context(result)
def test_sketch_merge_does_not_replace_user_measurement() -> None:
survey = normalize_image_observation({
"part_type": "bracket",
"visible_features": ["plate"],
"uncertain_features": [],
"views": [{"attachment_id": "upload_a"}],
"measurements": [{"name": "thickness", "value_mm": 1.2, "source": "user"}],
}, attachment_ids=["upload_a"])
sketches = normalize_sketch_candidates({
"profiles": [],
"measurements": [{"name": "thickness", "value_mm": 1.6, "source": "image"}],
"uncertainties": ["bend radius"],
}, attachment_ids=["upload_a"])
merged = merge_image_observations(survey, sketches)
assert merged["measurements"][0]["value_mm"] == 1.2
assert merged["uncertainties"] == ["bend radius"]
def test_image_metadata_and_cv_degrade_without_required_cv_support() -> None:
output = io.BytesIO()
Image.new("RGB", (32, 16), "white").save(output, format="PNG")
metadata = image_metadata(output.getvalue())
assert metadata["width"] == 32
assert metadata["height"] == 16
assert "available" in cv_hints(output.getvalue())
def test_image_tool_stages_expose_only_the_required_tool() -> None:
model = ProviderModel("vision", vision=True)
assert [tool["function"]["name"] for tool in tools_for_model(model, image_stage="survey")] == ["analyze_image_reference"]
assert [tool["function"]["name"] for tool in tools_for_model(model, image_stage="sketch", include_image_analysis=False, include_image_sketches=True)] == ["extract_image_sketch_candidates"]
def test_agent_runs_survey_then_sketch_stage_before_normal_tools() -> None:
class TwoStageAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.required: list[str | None] = []
self.responses = [
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "survey", "type": "function", "function": {"name": "analyze_image_reference", "arguments": json.dumps({
"part_type": "bracket", "visible_features": ["plate"], "uncertain_features": [],
"views": [{"attachment_id": "upload_a", "view_role": "front"}], "profiles": [],
"measurements": [], "uncertainties": [],
})},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "sketch", "type": "function", "function": {"name": "extract_image_sketch_candidates", "arguments": json.dumps({
"profiles": [{"id": "opening", "role": "cutout", "closed": True, "segments": [{"type": "line", "start": [0, 0], "end": [2, 0]}]}],
"measurements": [], "uncertainties": [],
})},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "继续建模。", "tool_calls": []}}]},
]
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
self.required.append(kwargs.get("required_tool_name") if "required_tool_name" in kwargs else args[4] if len(args) > 4 else None)
return self.responses.pop(0)
with TemporaryDirectory() as directory:
root = Path(directory)
backend_root = Path(__file__).resolve().parents[1]
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "key", (ProviderModel("vision", vision=True),))
settings = Settings(root / "tasks", root / "conversations", backend_root / "cdsl_library", backend_root / "engine" / "cdsl_engine", "", "", "", 5, "test", (provider,))
store = WorkspaceStore(settings)
conversation_id = "conv_000000000001"
store.ensure_conversation(conversation_id)
relative, _ = store.write_conversation_upload(conversation_id, "part.png", b"png")
store.add_conversation_attachment(conversation_id, attachment_record(conversation_id, "part.png", "image/png", relative, b"png", "image"))
agent = TwoStageAgent(settings, store, CdslLibrary(settings))
message = ChatMessage(id="user", role="user", parts=[MessagePart(type="text", text="根据图片继续建模")])
async def collect() -> list[dict[str, object]]:
events = []
async for chunk in agent.stream([message], conversation_id, None):
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
return events
events = asyncio.run(collect())
assert agent.required[:2] == ["analyze_image_reference", "extract_image_sketch_candidates"]
assert any(event.get("observationStage") == "complete" for event in events)
@@ -0,0 +1,340 @@
from __future__ import annotations
import asyncio
from dataclasses import replace
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from fastapi import HTTPException # noqa: E402
from app import main as api # noqa: E402
from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, validate_fragment # noqa: E402
from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan # noqa: E402
from app.services.incremental_generation import IncrementalGenerationRunner # noqa: E402
from app.services.review_renderer import CANONICAL_VIEWS, REVIEW_SIZE, RENDER_SIZE, render_checkpoint # noqa: E402
from app.services.storage import WorkspaceStore # noqa: E402
from app.services.visual_review import _selected_review_views # noqa: E402
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
def plan() -> dict:
return {
"schema_version": "cad.generation-plan.v2",
"plan_id": "incremental_test",
"requirements": [
{"id": "req_base", "source": "explicit", "priority": "hard", "description": "base solid"},
{"id": "req_round", "source": "explicit", "priority": "hard", "description": "edge round"},
],
"assumptions": [],
"nodes": [
{
"id": "base", "intent": "base", "atomic_id": "extrude_add_blind", "depends_on": [],
"requirement_ids": ["req_base"], "verification_rules": [], "review_targets": [],
},
{
"id": "round", "intent": "round", "atomic_id": "fillet", "depends_on": ["base"],
"requires_topology": True,
"requirement_ids": ["req_round"], "verification_rules": [], "review_targets": [],
},
],
}
class GenerationPlanTests(unittest.TestCase):
def test_hard_requirements_and_backend_outputs_are_owned(self) -> None:
invalid = plan()
invalid["nodes"][1]["requirement_ids"] = []
with self.assertRaisesRegex(GenerationPlanError, "Hard requirements"):
validate_generation_plan(invalid, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
generated = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
self.assertEqual(generated["id_strategy"], "backend-derived-v1")
self.assertEqual(len(generated["nodes"][0]["cdsl_feature_ids"]), 1)
self.assertEqual(len(generated["nodes"][0]["cdsl_sketch_ids"]), 1)
self.assertEqual(generated["nodes"][1]["cdsl_sketch_ids"], [])
# Legacy fields from an untrusted model output cannot choose CDSL IDs.
supplied = plan()
supplied["nodes"][0]["expected_feature_ids"] = ["model_chosen_id"]
self.assertEqual(
validate_generation_plan(supplied, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")["nodes"][0]["cdsl_feature_ids"],
generated["nodes"][0]["cdsl_feature_ids"],
)
def test_upstream_invalidation_marks_all_descendants_stale(self) -> None:
spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
spec["nodes"][0]["status"] = "completed"
spec["nodes"][1]["status"] = "completed"
self.assertEqual(descendant_closure(spec, "base"), {"base", "round"})
stale = mark_nodes_stale(spec, "base", reason="topology_changed")
self.assertTrue(all(node["stale"] for node in stale["nodes"]))
self.assertTrue(all(node["status"] == "planned" for node in stale["nodes"]))
class FragmentTests(unittest.TestCase):
def setUp(self) -> None:
self.spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
def base_fragment(self) -> dict:
return {
"schema_version": "cad.cdsl-fragment.v1", "node_id": "base", "base_revision_id": "",
"base_cdsl_sha256": cdsl_sha256(None), "add_sketches": [{}],
"add_features": [{}], "verification_rules": [], "assumptions": [],
}
def test_fragment_assigns_ids_dependencies_and_atomic_from_plan(self) -> None:
accepted = validate_fragment(self.base_fragment(), plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
base = self.spec["nodes"][0]
self.assertEqual(accepted["add_sketches"][0]["id"], base["cdsl_sketch_ids"][0])
self.assertEqual(accepted["add_features"][0]["id"], base["cdsl_feature_ids"][0])
self.assertEqual(accepted["add_features"][0]["sketch_id"], base["cdsl_sketch_ids"][0])
self.assertEqual(accepted["add_features"][0]["atomic_id"], "extrude_add_blind")
self.assertEqual(accepted["add_features"][0]["depends_on"], [])
model_ids = self.base_fragment()
model_ids["add_sketches"][0]["id"] = "model_sketch"
model_ids["add_features"][0].update({"id": "model_feature", "atomic_id": "fillet", "sketch_id": "model_sketch"})
overwritten = validate_fragment(model_ids, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
self.assertEqual(overwritten["add_features"][0]["id"], base["cdsl_feature_ids"][0])
self.assertEqual(overwritten["add_features"][0]["atomic_id"], "extrude_add_blind")
def test_fragment_rejects_wrong_hash_and_output_shape(self) -> None:
wrong_hash = self.base_fragment()
wrong_hash["base_cdsl_sha256"] = "0" * 64
with self.assertRaisesRegex(CdslFragmentError, "sha256"):
validate_fragment(wrong_hash, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
unexpected_sketch = self.base_fragment()
unexpected_sketch["add_sketches"].append({"id": "extra"})
with self.assertRaisesRegex(CdslFragmentError, "one new sketch"):
validate_fragment(unexpected_sketch, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
def test_topology_fragment_requires_active_snapshot_owner_and_geometry(self) -> None:
base = self.spec["nodes"][0]
round_node = self.spec["nodes"][1]
base_cdsl = {
"geometry": {"sketches": [{"id": base["cdsl_sketch_ids"][0]}]},
"features": [{"id": base["cdsl_feature_ids"][0], "atomic_id": "extrude_add_blind"}],
}
fragment = {
"schema_version": "cad.cdsl-fragment.v1", "node_id": "round", "base_revision_id": "rev_001",
"base_cdsl_sha256": cdsl_sha256(base_cdsl), "required_snapshot_id": "cad_test/rev_001",
"add_sketches": [],
"add_features": [{
"selectors": [{
"kind": "edge", "stable_id": "body:feature_base:edge:0", "owner_node_id": "base",
"snapshot_id": "cad_test/rev_001", "geometry": {"curve_type": "line", "length_mm": 10},
}],
}],
"verification_rules": [], "assumptions": [],
}
accepted = validate_fragment(
fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl,
required_snapshot_id="cad_test/rev_001",
)
self.assertEqual(accepted["node_id"], "round")
self.assertEqual(accepted["add_features"][0]["id"], round_node["cdsl_feature_ids"][0])
self.assertEqual(accepted["add_features"][0]["depends_on"], [base["cdsl_feature_ids"][0]])
self.assertEqual(accepted["add_features"][0]["selectors"][0]["owner_feature_id"], base["cdsl_feature_ids"][0])
fragment["add_features"][0]["selectors"][0].pop("geometry")
with self.assertRaisesRegex(CdslFragmentError, "geometry signature"):
validate_fragment(
fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl,
required_snapshot_id="cad_test/rev_001",
)
class StorageAndRunnerTests(unittest.TestCase):
def settings(self, root: Path) -> Settings:
provider = ProviderConfig("author", "Author", "https://example.invalid/v1", "secret", (ProviderModel("author-model"),))
return Settings(
task_root=root / "tasks", conversation_root=root / "conversations", library_root=root / "library",
engine_root=ROOT / "backend" / "engine" / "cdsl_engine", llm_base_url="", llm_api_key="", llm_model="author-model",
llm_timeout_s=1, default_provider_id="author", providers=(provider,), incremental_generation=True,
)
def test_rollback_anchor_rewinds_before_affected_nodes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = WorkspaceStore(self.settings(Path(directory)))
task = store.ensure_task(None, "test")
task_id = task["task_id"]
for revision_id, parent, node_id in (("rev_001", "", "base"), ("rev_002", "rev_001", "middle"), ("rev_003", "rev_002", "tip")):
store.update_task(task_id, {"revision_id": revision_id, "status": "success", "parent_revision_id": parent, "node_id": node_id, "visibility": "checkpoint"})
self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["middle", "tip"], fallback_revision_id="rev_002"), "rev_001")
self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["base"], fallback_revision_id="rev_001"), "")
store.rollback_to_revision(task_id, "rev_002", branch_id="branch_repair")
revisions = {item["revision_id"]: item for item in (store.read_task(task_id) or {})["revisions"]}
self.assertEqual(revisions["rev_001"]["visibility"], "checkpoint")
self.assertEqual(revisions["rev_002"]["visibility"], "checkpoint")
self.assertEqual(revisions["rev_003"]["visibility"], "superseded")
def test_run_context_is_persisted_for_worker_recovery(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = WorkspaceStore(self.settings(Path(directory)))
task = store.ensure_task(None, "test")
store.write_generation_run_context(task["task_id"], {
"schema_version": "cad.generation-run-context.v1",
"request": "test", "conversation_id": "conv_abcdef123456",
"provider_id": "author", "model_id": "author-model", "author_messages": [], "part_skills": {},
})
recovered = store.read_generation_run_context(task["task_id"])
self.assertEqual(recovered and recovered["request"], "test")
task = store.start_generation(task["task_id"], request="test")
self.assertEqual([item["task_id"] for item in store.running_tasks()], [task["task_id"]])
def test_missing_visual_review_configuration_fails_the_run(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
task = store.ensure_task(None, "test")
async def complete(*_args: object) -> dict:
raise AssertionError("author must not be called before visual configuration validation")
runner = IncrementalGenerationRunner(settings, store, complete)
async def collect() -> list[tuple[str, dict]]:
return [item async for item in runner.run(
task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []},
provider=settings.providers[0], model=settings.providers[0].models[0], author_messages=[],
)]
events = asyncio.run(collect())
self.assertEqual(events[-1][0], "task_terminal")
self.assertEqual(events[-1][1]["lifecycle"], "failed")
self.assertEqual((store.read_task(task["task_id"]) or {})["lifecycle"], "failed")
def test_invalid_plan_is_preserved_in_failure_diagnostics(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
provider = ProviderConfig(
"author", "Author", "https://example.invalid/v1", "secret",
(ProviderModel("author-model", vision=True),),
)
settings = replace(
self.settings(root), providers=(provider,), review_provider_id="author", review_model_id="author-model",
)
store = WorkspaceStore(settings)
task = store.ensure_task(None, "test")
raw_plan = {"schema_version": "cad.generation-plan.v2", "plan_id": "broken", "requirements": []}
async def complete(*_args: object) -> dict:
return {
"choices": [{"message": {"tool_calls": [{"function": {
"name": "plan_generation_task", "arguments": json.dumps(raw_plan),
}}]}}],
}
runner = IncrementalGenerationRunner(settings, store, complete)
engine = type("Engine", (), {"SUPPORTED_ATOMIC_IDS": ("extrude_add_blind",)})()
async def collect() -> list[tuple[str, dict]]:
return [item async for item in runner.run(
task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []},
provider=provider, model=provider.models[0], author_messages=[],
)]
with patch("app.services.incremental_generation.load_engine", return_value=engine), patch(
"app.services.incremental_generation.renderer_status", return_value=(True, "")
):
events = asyncio.run(collect())
self.assertEqual(events[-1], ("task_terminal", {
"taskId": task["task_id"], "lifecycle": "failed",
"message": "Generation plan requires a non-empty requirements array",
}))
failed_task = store.read_task(task["task_id"]) or {}
failure_path = root / "tasks" / task["task_id"] / str(failed_task["run_failure_path"])
with failure_path.open(encoding="utf-8") as handle:
failure = json.load(handle)
diagnostic_path = root / "tasks" / task["task_id"] / str(failure["plan_diagnostic_path"])
with diagnostic_path.open(encoding="utf-8") as handle:
diagnostic = json.load(handle)
self.assertEqual(diagnostic["stage"], "generation_plan_validation")
self.assertEqual(diagnostic["raw_plan"], raw_plan)
class ArtifactAccessTests(unittest.TestCase):
def test_checkpoint_allows_only_the_active_glb_until_publication(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = WorkspaceStore(StorageAndRunnerTests().settings(Path(directory)))
task = store.ensure_task(None, "test")
task_id = task["task_id"]
revision_id, revision_dir = store.next_revision(task_id)
glb = revision_dir / "model.glb"
step = revision_dir / "model.step"
glb.write_bytes(b"glb")
step.write_bytes(b"step")
store.update_task(task_id, {
"revision_id": revision_id,
"status": "success",
"cdsl_path": f"revisions/{revision_id}/model.cdsl.json",
"glb_path": f"revisions/{revision_id}/model.glb",
"step_path": f"revisions/{revision_id}/model.step",
"report_path": f"revisions/{revision_id}/rebuild-report.json",
"visibility": "checkpoint",
})
previous_store = api.store
api.store = store
try:
response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.glb"))
self.assertEqual(response.media_type, "model/gltf-binary")
with self.assertRaises(HTTPException) as rejected:
asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step"))
self.assertEqual(rejected.exception.status_code, 403)
store.finish_generation(task_id, lifecycle="completed")
response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step"))
self.assertEqual(response.status_code, 200)
finally:
api.store = previous_store
class TechnicalRenderTests(unittest.TestCase):
def test_cpu_renderer_emits_fixed_views_and_keeps_detail_separate(self) -> None:
from build123d import Box, export_step
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
step_path = root / "box.step"
export_step(Box(30, 20, 10), step_path)
manifest = render_checkpoint(
StorageAndRunnerTests().settings(root),
step_path=step_path,
output_dir=root / "review",
review_targets=[{"bbox_mm": [-5, -5, -5, 5, 5, 5]}],
)
self.assertEqual(manifest["renderer"], "python-occ-hlr-pillow")
views = {item["id"]: item for item in manifest["views"]}
self.assertEqual(set(CANONICAL_VIEWS), set(views) & set(CANONICAL_VIEWS))
self.assertIn("detail-1", views)
self.assertNotEqual(views["isometric"]["path"], views["detail-1"]["path"])
self.assertTrue(all(Path(views[view_id]["path"]).is_file() for view_id in CANONICAL_VIEWS))
self.assertTrue(all(views[view_id]["diagnostics"]["valid"] for view_id in CANONICAL_VIEWS))
self.assertTrue(views["detail-1"]["diagnostics"]["intentional_crop"])
from PIL import Image
with Image.open(views["isometric"]["path"]) as image:
self.assertEqual(image.size, (REVIEW_SIZE, REVIEW_SIZE))
with Image.open(views["isometric"]["high_resolution_path"]) as image:
self.assertEqual(image.size, (RENDER_SIZE, RENDER_SIZE))
def test_routine_visual_review_uses_compact_evidence(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
contact = root / "contact-sheet.jpg"
contact.write_bytes(b"jpg")
manifest = {
"contact_sheet_path": str(contact),
"views": [{"id": view_id, "path": str(root / f"{view_id}.png")} for view_id in (*CANONICAL_VIEWS, "detail-1", "detail-2")],
}
routine = _selected_review_views(manifest, final_checkpoint=False)
final = _selected_review_views(manifest, final_checkpoint=True)
self.assertEqual([item["id"] for item in routine], ["contact-sheet", "detail-1", "detail-2"])
self.assertEqual({item["id"] for item in final}, {"contact-sheet", "detail-1", "detail-2", *CANONICAL_VIEWS})
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -242,7 +242,7 @@ class AgentPartSkillTests(unittest.TestCase):
self.assertIn("[planning/flange]", prompt)
self.assertIn("circular-pattern atomic", prompt)
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"])
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "extract_image_sketch_candidates", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"])
def test_generation_persists_assumptions_and_selected_skills(self) -> None:
with tempfile.TemporaryDirectory() as directory:
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from app.services.engine_service import topology_snapshot, topology_sidecars # noqa: E402
class TopologySnapshotTests(unittest.TestCase):
def result(self) -> dict:
return {
"topology_records": [
{
"record_id": "body:base:edge:0",
"kind": "edge",
"feature_id": "base",
"body_id": "body:base",
"owner_feature_ids": ["base"],
"geometry": {"curve_type": "line", "length_mm": 10},
},
{
"record_id": "body:base",
"kind": "body",
"feature_id": "base",
"body_id": "body:base",
"geometry": {},
},
]
}
def test_snapshot_preserves_runtime_edges(self) -> None:
snapshot = topology_snapshot(self.result(), task_id="cad_test", revision_id="rev_001")
self.assertEqual(snapshot["snapshot_id"], "cad_test/rev_001")
self.assertEqual(snapshot["records"][0]["record_id"], "body:base:edge:0")
self.assertTrue(snapshot["records"][0]["executable"])
def test_sidecars_are_derived_from_runtime_records(self) -> None:
snapshot = topology_snapshot(self.result())
selector, edges = topology_sidecars(self.result(), snapshot=snapshot)
self.assertEqual(edges["edges"][0]["record_id"], "body:base:edge:0")
self.assertEqual(selector["edges"][0]["source"], "runtime_snapshot")
def test_snapshot_excludes_superseded_body_records(self) -> None:
result = {
"feature_results": [
{"feature_id": "base", "body_id": "body:base"},
{"feature_id": "cut", "body_id": "body:cut"},
],
"topology_records": [
{"record_id": "body:base", "kind": "body", "body_id": "body:base", "feature_id": "base"},
{"record_id": "body:base:edge:0", "kind": "edge", "body_id": "body:base", "feature_id": "base"},
{"record_id": "body:cut", "kind": "body", "body_id": "body:cut", "feature_id": "cut"},
{"record_id": "body:cut:edge:0", "kind": "edge", "body_id": "body:cut", "feature_id": "cut"},
],
}
snapshot = topology_snapshot(result)
self.assertEqual(snapshot["body_id"], "body:cut")
self.assertEqual([record["record_id"] for record in snapshot["records"]], ["body:cut", "body:cut:edge:0"])
def test_preview_faces_are_audited_but_not_executable(self) -> None:
snapshot = topology_snapshot(
{"topology_records": []},
task_id="cad_test",
revision_id="rev_001",
preview={"topology_faces": [{"id": "preview_face", "surface_type": "plane", "center": [0, 0, 1], "normal": [0, 0, 1]}]},
)
self.assertEqual(snapshot["records"][0]["record_id"], "preview_face")
self.assertTrue(snapshot["records"][0]["synthetic"])
self.assertFalse(snapshot["records"][0]["executable"])
-342
View File
@@ -31,7 +31,6 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.185.4",
"puppeteer-core": "^25.8.0",
"tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5"
@@ -1491,35 +1490,6 @@
"node": ">= 10"
}
},
"node_modules/@puppeteer/browsers": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.2.1.tgz",
"integrity": "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"modern-tar": "^0.8.0",
"yargs": "^18.0.0"
},
"bin": {
"browsers": "lib/main-cli.js"
},
"engines": {
"node": ">=22.12.0"
},
"peerDependencies": {
"proxy-agent": ">=8.0.1",
"yauzl": "^2.10.0 || ^3.4.0"
},
"peerDependenciesMeta": {
"proxy-agent": {
"optional": true
},
"yauzl": {
"optional": true
}
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.3.tgz",
@@ -3455,32 +3425,6 @@
}
}
},
"node_modules/ansi-regex": {
"version": "6.3.0",
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.3.0.tgz",
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -3557,72 +3501,12 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chromium-bidi": {
"version": "17.0.2",
"resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
},
"peerDependencies": {
"devtools-protocol": "*"
}
},
"node_modules/chromium-bidi/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cliui/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
@@ -3664,21 +3548,6 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devtools-protocol": {
"version": "0.0.1666840",
"resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz",
"integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.24.5",
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
@@ -3735,16 +3604,6 @@
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventsource-parser": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
@@ -3776,29 +3635,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -4118,23 +3954,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"dev": true,
"license": "MIT"
},
"node_modules/modern-tar": {
"version": "0.8.4",
"resolved": "https://registry.npmmirror.com/modern-tar/-/modern-tar-0.8.4.tgz",
"integrity": "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/nanoid": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-6.0.1.tgz",
@@ -4315,24 +4134,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/puppeteer-core": {
"version": "25.8.0",
"resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.8.0.tgz",
"integrity": "sha512-LDOrawV8vfCVk+yLj2ozvajNP4Sv3OV9y3Tpiyy2g2Z+aQlbcozP6KJfI4iSBq7YQER+86ihEtPa5ioiZyWxMQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.2.1",
"chromium-bidi": "17.0.2",
"devtools-protocol": "0.0.1666840",
"typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.2",
"ws": "^8.21.1"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/radix-ui": {
"version": "1.6.7",
"resolved": "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.6.7.tgz",
@@ -4614,39 +4415,6 @@
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "8.2.2",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-8.2.2.tgz",
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -4767,13 +4535,6 @@
"fsevents": "~2.3.3"
}
},
"node_modules/typed-query-selector": {
"version": "2.12.2",
"resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
@@ -4910,109 +4671,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
"integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "18.1.0",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-18.1.0.tgz",
"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^8.2.1",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
-1
View File
@@ -33,7 +33,6 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.185.4",
"puppeteer-core": "^25.8.0",
"tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5"
+9 -1
View File
@@ -394,7 +394,15 @@ button:disabled {
.config-warning { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-error-border); background: var(--ui-error-bg); color: var(--ui-error-text); font-size: 12px; padding: 8px 12px; }
.studio-main { display: flex; min-height: 0; flex: 1; }
.agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); }
.preview-pane { min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
.preview-pane { position: relative; min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
.generation-status { position: absolute; z-index: 30; top: 12px; right: 12px; width: min(260px, calc(100% - 24px)); max-height: min(42vh, 360px); overflow: auto; border: 1px solid var(--ui-border); border-radius: 6px; background: var(--ui-glass-popover); box-shadow: var(--ui-shadow-soft); backdrop-filter: blur(12px); color: var(--ui-text); padding: 10px; font-size: 12px; }
.generation-status-heading { display: flex; align-items: center; gap: 6px; color: var(--ui-text-strong); font-weight: 650; }
.generation-status-active { margin: 6px 0 8px; color: var(--ui-accent-text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
.generation-status ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
.generation-status li { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--ui-border-muted); padding-top: 4px; color: var(--ui-text-muted); }
.generation-status li[data-status="completed"] small { color: var(--ui-success); }
.generation-status li[data-status="planned"] small { color: var(--ui-text-subtle); }
.generation-status li[data-status="failed"] small { color: var(--ui-error); }
.agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; }
.agent-thread-shell { position: relative; }
.spin { animation: ui-spin 900ms linear infinite; }
+93 -8
View File
@@ -6,13 +6,14 @@ import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { AlertCircle, Box, Loader2, Moon, Sun } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { latestSuccessfulResult } from "@/lib/cad-artifacts";
import { activeCheckpointPreview, latestSuccessfulResult } from "@/lib/cad-artifacts";
import { normalizeCadMessages } from "@/lib/cad-messages";
import type { ViewerSelectionContext } from "@/lib/viewer-selection";
import type {
BackendConfig,
CadError,
CadAttachment,
CadProgress,
CadResult,
CadUIMessage,
ConversationRecord,
@@ -39,6 +40,8 @@ export function AgentStudio() {
const [modelId, setModelId] = useState("");
const [theme, setTheme] = useState<"light" | "dark">("light");
const [viewerSelection, setViewerSelection] = useState<ViewerSelectionContext | null>(null);
const [taskRunning, setTaskRunning] = useState(false);
const [taskRecord, setTaskRecord] = useState<TaskRecord | null>(null);
const syncUrl = useCallback((conversation: string, task: string) => {
const params = new URLSearchParams(window.location.search);
@@ -73,10 +76,12 @@ export function AgentStudio() {
const taskId = urlTaskId || conversation.current_task_id || "";
let restored: CadResult | null = null;
let restoredTask: TaskRecord | null = null;
if (taskId) {
const taskResponse = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { cache: "no-store" });
if (taskResponse.ok) {
restored = latestSuccessfulResult((await taskResponse.json()) as TaskRecord);
restoredTask = (await taskResponse.json()) as TaskRecord;
restored = activeCheckpointPreview(restoredTask) ?? latestSuccessfulResult(restoredTask);
}
}
@@ -90,6 +95,8 @@ export function AgentStudio() {
setProviderId(defaultProvider?.id || "");
setModelId(defaultProvider?.models.find((model) => model.id === nextConfig.default_model)?.id || defaultProvider?.models[0]?.id || "");
setCadResult(restored);
setTaskRunning(restoredTask?.lifecycle === "running");
setTaskRecord(restoredTask);
setLoadState("ready");
syncUrl(nextConversationId, taskId);
} catch (error) {
@@ -127,6 +134,7 @@ export function AgentStudio() {
setViewerSelection(null);
setSelectedTaskId(result.taskId);
setLastError("");
if (result.lifecycle) setTaskRunning(result.lifecycle === "running");
if (conversationId) {
syncUrl(conversationId, result.taskId);
void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, {
@@ -137,8 +145,24 @@ export function AgentStudio() {
}
}, [conversationId, syncUrl]);
const handleTaskState = useCallback((progress: CadProgress) => {
const taskId = String(progress.taskId || "");
if (taskId) {
setSelectedTaskId(taskId);
if (conversationId) {
syncUrl(conversationId, taskId);
void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentTaskId: taskId }),
});
}
}
if (progress.step === "generation_plan" && progress.status === "running") setTaskRunning(true);
if (progress.step === "task_terminal") setTaskRunning(progress.lifecycle === "running");
}, [conversationId, syncUrl]);
const handleError = useCallback((error: CadError) => {
setLastError(error.message);
if (error.stage === "generation") setTaskRunning(false);
}, []);
const handleUpload = useCallback(async (files: FileList | null) => {
@@ -167,6 +191,34 @@ export function AgentStudio() {
}
}, [conversationId]);
useEffect(() => {
if (!taskRunning || !selectedTaskId) return;
let cancelled = false;
const refresh = async () => {
try {
const response = await fetch(`/api/tasks/${encodeURIComponent(selectedTaskId)}`, { cache: "no-store" });
if (!response.ok) return;
const next = await response.json() as TaskRecord;
if (cancelled) return;
setTaskRecord(next);
const running = next.lifecycle === "running";
setTaskRunning(running);
if (running) {
const preview = activeCheckpointPreview(next);
if (preview) setCadResult(preview);
} else {
const restored = latestSuccessfulResult(next);
if (restored) setCadResult(restored);
}
} catch {
// Keep the persisted lock until a later poll can prove a terminal state.
}
};
void refresh();
const timer = window.setInterval(() => void refresh(), 2000);
return () => { cancelled = true; window.clearInterval(timer); };
}, [selectedTaskId, taskRunning]);
if (loadState === "loading") {
return <StudioLoading />;
}
@@ -185,6 +237,7 @@ export function AgentStudio() {
initialMessages={initialMessages}
onCadResult={handleResult}
onCadError={handleError}
onCadProgress={handleTaskState}
>
<StudioShell
config={config}
@@ -203,6 +256,8 @@ export function AgentStudio() {
onCadResult={handleResult}
onCadError={handleError}
onSelectionChange={setViewerSelection}
taskRunning={taskRunning}
taskRecord={taskRecord}
/>
</AgentRuntime>
);
@@ -217,6 +272,7 @@ function AgentRuntime({
initialMessages,
onCadResult,
onCadError,
onCadProgress,
children,
}: {
conversationId: string;
@@ -227,6 +283,7 @@ function AgentRuntime({
initialMessages: CadUIMessage[];
onCadResult: (result: CadResult) => void;
onCadError: (error: CadError) => void;
onCadProgress: (progress: CadProgress) => void;
children: React.ReactNode;
}) {
const conversationRef = useRef(conversationId);
@@ -236,6 +293,7 @@ function AgentRuntime({
const viewerSelectionRef = useRef(viewerSelection);
const onCadResultRef = useRef(onCadResult);
const onCadErrorRef = useRef(onCadError);
const onCadProgressRef = useRef(onCadProgress);
conversationRef.current = conversationId;
taskRef.current = selectedTaskId;
providerRef.current = providerId;
@@ -243,6 +301,7 @@ function AgentRuntime({
viewerSelectionRef.current = viewerSelection;
onCadResultRef.current = onCadResult;
onCadErrorRef.current = onCadError;
onCadProgressRef.current = onCadProgress;
const transport = useMemo(() => new DefaultChatTransport<CadUIMessage>({
api: "/api/chat",
@@ -267,6 +326,9 @@ function AgentRuntime({
messages: initialMessages,
transport,
onData: (part) => {
if (part.type === "data-cad-progress") {
onCadProgressRef.current(part.data as CadProgress);
}
if (part.type === "data-cad-result") {
onCadResultRef.current(part.data as CadResult);
taskRef.current = (part.data as CadResult).taskId;
@@ -425,6 +487,8 @@ function StudioShell({
onCadResult,
onCadError,
onSelectionChange,
taskRunning,
taskRecord,
}: {
config: BackendConfig | null;
cadResult: CadResult | null;
@@ -442,8 +506,10 @@ function StudioShell({
onCadResult: (result: CadResult) => void;
onCadError: (error: CadError) => void;
onSelectionChange: (selection: ViewerSelectionContext | null) => void;
taskRunning: boolean;
taskRecord: TaskRecord | null;
}) {
const running = useAuiState((state) => state.thread.isRunning);
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
const provider = config?.providers.find((item) => item.id === providerId);
const handleViewerError = useCallback((message: string) => {
onCadError({ stage: "viewer", message });
@@ -453,24 +519,43 @@ function StudioShell({
<header className="app-header">
<div className="app-brand"><Box size={16} /><strong>CDSL CAD Studio</strong>{cadResult ? <span className="task-badge">{cadResult.taskId}</span> : null}</div>
<div className="app-controls">
<select aria-label="模型提供商" value={providerId} onChange={(event) => { const id = event.target.value; onProviderChange(id); onModelChange(config?.providers.find((item) => item.id === id)?.models[0]?.id || ""); }}>
<select aria-label="模型提供商" value={providerId} disabled={running} onChange={(event) => { const id = event.target.value; onProviderChange(id); onModelChange(config?.providers.find((item) => item.id === id)?.models[0]?.id || ""); }}>
{config?.providers.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
</select>
<select aria-label="模型" value={modelId} onChange={(event) => onModelChange(event.target.value)}>
<select aria-label="模型" value={modelId} disabled={running} onChange={(event) => onModelChange(event.target.value)}>
{provider?.models.map((model) => <option key={model.id} value={model.id}>{model.id}{model.vision ? " · Vision" : ""}</option>)}
</select>
<button className="theme-button" type="button" title="切换亮暗主题" onClick={onToggleTheme}>{theme === "light" ? <Moon size={16} /> : <Sun size={16} />}</button>
<button className="theme-button" type="button" title="切换亮暗主题" disabled={running} onClick={onToggleTheme}>{theme === "light" ? <Moon size={16} /> : <Sun size={16} />}</button>
</div>
</header>
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span></span></div> : null}
{config?.incremental_generation && !config.review_configured ? <div className="config-warning"><AlertCircle size={16} /><span>{config.review_error || "请配置独立视觉模型与 Chromium。"}</span></div> : null}
<div className="studio-main">
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} onUpload={onUpload} /></aside>
<section className="preview-pane"><CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={handleViewerError} onSelectionChange={onSelectionChange} /></section>
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} /></aside>
<section className="preview-pane">
<GenerationStatus task={taskRecord} />
<CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={handleViewerError} onSelectionChange={onSelectionChange} />
</section>
</div>
</main>
);
}
function GenerationStatus({ task }: { task: TaskRecord | null }) {
const plan = task?.generation_plan;
const nodes = Array.isArray(plan?.nodes) ? plan.nodes.filter((node): node is Record<string, unknown> => Boolean(node && typeof node === "object")) : [];
if (task?.lifecycle !== "running") return null;
return (
<aside className="generation-status" aria-live="polite">
<div className="generation-status-heading"><Loader2 className="spin" size={14} /><span></span></div>
<div className="generation-status-active">{task.active_node_id || "正在生成计划"}</div>
{nodes.length ? <ul>{nodes.map((node) => <li key={String(node.id || "node")} data-status={String(node.status || "planned")}>
<span>{String(node.id || "node")}</span><small>{String(node.status || "planned")}</small>
</li>)}</ul> : null}
</aside>
);
}
function StudioLoading() {
return (
<main className="boot-screen">
+9 -8
View File
@@ -1,20 +1,21 @@
"use client";
import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Square, Upload } from "lucide-react";
import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Upload } from "lucide-react";
import { useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import type { CadAttachment } from "@/lib/cad-types";
import { CadErrorPart, CadImageAnalysisPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
export function AgentThread({ attachments, uploading, uploadError, onUpload }: {
export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload }: {
attachments: CadAttachment[];
uploading: boolean;
uploadError: string;
taskRunning?: boolean;
onUpload: (files: FileList | null) => void;
}) {
const fileInput = useRef<HTMLInputElement>(null);
const dragDepth = useRef(0);
const running = useAuiState((state) => state.thread.isRunning);
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
const canUpload = !uploading && !running;
@@ -64,7 +65,7 @@ export function AgentThread({ attachments, uploading, uploadError, onUpload }: {
</ThreadPrimitive.Empty>
<div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div>
</ThreadPrimitive.Viewport>
<Composer fileInput={fileInput} uploading={uploading} onUpload={onUpload} />
<Composer fileInput={fileInput} uploading={uploading} taskRunning={taskRunning} onUpload={onUpload} />
</ThreadPrimitive.Root>
{isDraggingFiles ? <div className="file-drop-overlay" role="status" aria-live="polite"><Upload size={24} /><span></span></div> : null}
</div>
@@ -103,8 +104,8 @@ function AssistantMessage() {
);
}
function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; onUpload: (files: FileList | null) => void }) {
const running = useAuiState((state) => state.thread.isRunning);
function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void }) {
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.currentTarget.files?.length) onUpload(event.currentTarget.files);
event.currentTarget.value = "";
@@ -113,12 +114,12 @@ function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObje
<div className="composer-shell">
<input ref={fileInput} className="composer-file-input" type="file" accept=".png,.jpg,.jpeg,.webp,.txt,.md,.csv,.json" multiple tabIndex={-1} aria-hidden="true" onChange={handleFileChange} />
<ComposerPrimitive.Root className="composer-root">
<ComposerPrimitive.Input aria-label="CAD 请求" className="composer-input" placeholder="描述要生成或修改的 CAD 模型..." submitMode="enter" rows={4} />
<ComposerPrimitive.Input aria-label="CAD 请求" className="composer-input" placeholder={running ? "CAD 正在生成,任务结束后可继续对话" : "描述要生成或修改的 CAD 模型..."} submitMode="enter" rows={4} disabled={running} />
<div className="composer-footer">
<span><Check size={14} aria-hidden="true" /> Enter Shift + Enter </span>
<div className="composer-actions">
{running ? (
<ComposerPrimitive.Cancel className="composer-command composer-cancel" title="停止生成" aria-label="停止生成"><Square size={14} fill="currentColor" aria-hidden="true" /></ComposerPrimitive.Cancel>
<span className="text-[11px] text-[var(--ui-text-subtle)]"><Loader2 className="mr-1 inline spin" size={13} aria-hidden="true" /></span>
) : (
<>
<button type="button" className="composer-command composer-upload" title="上传图片或文档" aria-label="上传图片或文档" aria-busy={uploading || undefined} disabled={uploading} onClick={() => fileInput.current?.click()}>{uploading ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Paperclip size={14} aria-hidden="true" />}</button>
+41 -7
View File
@@ -29,14 +29,14 @@ export function CadProgressPart({ data }: { data: CadProgress }) {
}
export function CadResultPart({ data }: { data: CadResult }) {
const downloads: Array<[string, string]> = [
const downloads: Array<[string, string]> = data.checkpoint ? [] : [
["STEP", data.stepPath],
["CDSL", data.cdslPath],
["GLB", data.glbPath],
["报告", data.reportPath],
];
if (data.qualityPath) downloads.push(["质量报告", data.qualityPath]);
if (data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]);
if (!data.checkpoint && data.qualityPath) downloads.push(["质量报告", data.qualityPath]);
if (!data.checkpoint && data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]);
const qualityLabels: Record<string, string> = {
accepted: "验收通过",
built_with_warnings: "构建完成,有警告",
@@ -60,16 +60,16 @@ export function CadResultPart({ data }: { data: CadResult }) {
</div>
{data.assumptions?.length ? <div className="cad-result-notes"><strong></strong><span>{data.assumptions.join("")}</span></div> : null}
{data.referenceIds.length ? <div className="cad-result-notes"><strong></strong><span>{data.referenceIds.join("")}</span></div> : null}
<QualitySummary data={data} />
{!data.checkpoint ? <QualitySummary data={data} /> : null}
{data.snapshotStatus && data.snapshotStatus !== "unavailable" ? <div className="cad-result-notes"><strong></strong><span>{data.snapshotStatus}</span></div> : null}
<div className="download-row">
{downloads.length ? <div className="download-row">
{downloads.map(([label, path]) => (
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download aria-label={`下载 ${label}`}>
<Download size={13} aria-hidden="true" />
{label}
</a>
))}
</div>
</div> : null}
</section>
);
}
@@ -114,9 +114,12 @@ function QualitySummary({ data }: { data: CadResult }) {
export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) {
const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? [];
const profileCount = data.profiles?.length || 0;
const holeCount = data.holes?.length || 0;
const viewCount = data.views?.length || data.attachmentIds.length;
return (
<section className="cad-message cad-image-analysis" aria-label="图像分析">
<div className="cad-message-heading"><Ruler size={14} aria-hidden="true" /><span></span></div>
<div className="cad-message-heading"><Ruler size={14} aria-hidden="true" /><span>{data.observationStage === "survey" ? "图片勘测" : data.observationStage === "sketch" ? "草图候选" : "图像分析"}</span></div>
<div className="cad-image-part-type">{data.partType}</div>
<div className="image-analysis-section">
<span></span>
@@ -135,6 +138,37 @@ export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) {
</li>)}
</ul>
</div> : null}
{(data.views?.length || profileCount || holeCount || data.measurements?.length || data.assumptions?.length) ? (
<details className="image-analysis-details">
<summary></summary>
<div className="image-analysis-section">
<span></span>
<p>{viewCount} {profileCount} {holeCount} /</p>
</div>
{data.profiles?.length ? <div className="image-analysis-section">
<span></span>
<ul className="image-dimension-list">
{data.profiles.slice(0, 12).map((profile) => <li key={profile.id}>
<strong>{profile.id}</strong>
<span>{profile.segments?.length || 0} {profile.closed ? "闭合" : "未确认闭合"}{profile.confidence == null ? "" : `,置信度 ${Math.round(profile.confidence * 100)}%`}</span>
</li>)}
</ul>
</div> : null}
{data.measurements?.length ? <div className="image-analysis-section">
<span></span>
<ul className="image-dimension-list">
{data.measurements.slice(0, 16).map((measurement, index) => <li key={`${measurement.name}-${index}`}>
<strong>{measurement.name}</strong>
<span>{measurement.value_mm == null ? "待确认" : `${measurement.value_mm} mm`} · {measurement.source || "image"}</span>
</li>)}
</ul>
</div> : null}
{data.uncertainties?.length ? <div className="image-analysis-section">
<span></span>
<p>{data.uncertainties.slice(0, 16).join("")}</p>
</div> : null}
</details>
) : null}
</section>
);
}
+43 -18
View File
@@ -51,6 +51,7 @@ function resultFromBackend(payload: Record<string, unknown>): CadResult {
parametersPath: typeof payload.parameters_path === "string" ? payload.parameters_path : undefined,
selectorPath: typeof payload.selector_path === "string" ? payload.selector_path : undefined,
edgesPath: typeof payload.edges_path === "string" ? payload.edges_path : undefined,
topologyPath: typeof payload.topology_path === "string" ? payload.topology_path : undefined,
summary: String(payload.summary || "Updated CDSL model"),
referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [],
engine: String(payload.engine || "cdsl_only"),
@@ -61,6 +62,8 @@ function resultFromBackend(payload: Record<string, unknown>): CadResult {
repairAttempts: Number(payload.repair_attempts || 0),
snapshotPaths: Array.isArray(payload.snapshot_paths) ? payload.snapshot_paths.map(String) : [],
snapshotStatus: String(payload.snapshot_status || "unavailable"),
checkpoint: Boolean(payload.checkpoint),
lifecycle: typeof payload.lifecycle === "string" ? payload.lifecycle : undefined,
};
}
@@ -225,14 +228,32 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
setLoadState((current) => current.kind === "ready" ? current : { kind: "loading" });
const glbUrl = encodeArtifactUrl(result.taskId, result.glbPath);
const selectorUrl = result.selectorPath ? encodeArtifactUrl(result.taskId, result.selectorPath) : "";
const topologyUrl = result.topologyPath ? encodeArtifactUrl(result.taskId, result.topologyPath) : "";
void Promise.all([
loadRenderGlb(glbUrl),
selectorUrl ? loadRenderJson(selectorUrl).catch(() => null) : Promise.resolve(null),
topologyUrl ? loadRenderJson(topologyUrl).catch(() => null) : Promise.resolve(null),
])
.then(([meshData, selectorSidecar]) => {
.then(([meshData, selectorSidecar, topologySnapshot]) => {
if (controller.signal.aborted) return;
const selectorRuntime = selectorSidecar && typeof selectorSidecar === "object"
? buildCdslSelectorRuntime(selectorSidecar, meshData)
const topologyMatchesRevision = topologySnapshot && typeof topologySnapshot === "object"
&& String((topologySnapshot as Record<string, unknown>).task_id || "") === result.taskId
&& String((topologySnapshot as Record<string, unknown>).revision_id || "") === result.revisionId;
const rawTopologyRecords = topologyMatchesRevision ? (topologySnapshot as Record<string, unknown>).records : null;
const topologyRecords = Array.isArray(rawTopologyRecords)
? rawTopologyRecords.filter((record): record is Record<string, unknown> => Boolean(record && typeof record === "object"))
: [];
const selectorPayload = selectorSidecar && typeof selectorSidecar === "object"
? {
...(selectorSidecar as Record<string, unknown>),
edges: Array.isArray((selectorSidecar as Record<string, unknown>).edges)
&& ((selectorSidecar as Record<string, unknown>).edges as unknown[]).length
? (selectorSidecar as Record<string, unknown>).edges
: topologyRecords.filter((record) => record.kind === "edge" && record.executable !== false),
}
: null;
const selectorRuntime = selectorPayload
? buildCdslSelectorRuntime(selectorPayload as Parameters<typeof buildCdslSelectorRuntime>[0], meshData)
: null;
setLoadState({ kind: "ready", meshData, selectorRuntime });
setHoveredReferenceId("");
@@ -254,7 +275,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
onError(error instanceof Error ? error.message : "CAD Viewer asset loading failed");
});
return () => controller.abort();
}, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId]);
}, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId, result?.topologyPath]);
useEffect(() => {
if (!reveal) return;
@@ -263,7 +284,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
}, [reveal]);
useEffect(() => {
if (!result) {
if (!result || result.checkpoint || isGenerating) {
setParameters([]);
setShowParameters(false);
return;
@@ -282,7 +303,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
if (!controller.signal.aborted) setParameterError(error instanceof Error ? error.message : "无法读取参数");
});
return () => controller.abort();
}, [result?.revisionId, result?.taskId]);
}, [isGenerating, result?.checkpoint, result?.revisionId, result?.taskId]);
const submitEdit = useCallback(async (operation: string, picks: Record<string, unknown>[]) => {
if (!result || !operation || !picks.length) return;
@@ -380,7 +401,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
}, [loadState, onSelectionChange, result]);
const commitParameters = useCallback(async (values: Record<string, number>) => {
if (!result || !Object.keys(values).length) return;
if (!result || result.checkpoint || isGenerating || !Object.keys(values).length) return;
const parameterId = Object.keys(values)[0];
setParameterPending(parameterId);
setParameterError("");
@@ -396,13 +417,17 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
} finally {
setParameterPending("");
}
}, [onResult, result]);
}, [isGenerating, onResult, result]);
const viewerTheme = useMemo(() => ({ ...VIEWER_THEME, colorMode: theme }), [theme]);
const activeToolDefinition = activeTool ? cadEditToolForOperation(activeTool) : null;
const pickableFaces = useMemo(
() => loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [],
[loadState]
() => !isGenerating && !result?.checkpoint && loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [],
[isGenerating, loadState, result?.checkpoint]
);
const pickableEdges = useMemo(
() => !isGenerating && !result?.checkpoint && loadState.kind === "ready" ? loadState.selectorRuntime?.edges || [] : [],
[isGenerating, loadState, result?.checkpoint]
);
if (loadState.kind === "empty") return <ViewerState icon={<Box size={20} />} text="3D 预览等待模型" />;
if (loadState.kind === "loading") return <ViewerState icon={<Loader2 className="spin" size={20} />} text="加载 CAD Viewer 资产..." />;
@@ -429,10 +454,10 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
selectedReferenceIds={selectedReferenceIds}
selectorRuntime={loadState.selectorRuntime}
pickableFaces={pickableFaces}
pickableEdges={[]}
pickableEdges={pickableEdges}
onHoverReferenceChange={setHoveredReferenceId}
onActivateReference={handleActivateReference}
editPointPickEnabled={Boolean(activeTool)}
editPointPickEnabled={Boolean(activeTool) && !isGenerating && !result?.checkpoint}
activeEditToolId={activeTool}
editToolPickKind={cadEditToolNextPickKind(activeTool, editPicks.length)}
editToolPicks={editPicks}
@@ -445,16 +470,16 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
onAiSelectionComplete={onAiSelectionComplete}
/>
<AiSelectionOverlay draft={aiSelectionDraft} />
<EditToolPickOverlay
{!isGenerating && !result?.checkpoint ? <EditToolPickOverlay
activeToolId={activeTool}
picks={editPicks}
hoverPick={editHoverPick}
parameters={editParameters}
/>
/> : null}
<EmbeddedCadEditToolbar
activeToolId={activeTool}
aiSelectionMode={selectionMode}
disabled={!result || editPending}
disabled={!result || editPending || isGenerating || Boolean(result?.checkpoint)}
unavailableToolIds={["add_chamfer", "add_fillet"]}
onSelectTool={(tool) => {
setActiveTool(tool);
@@ -473,13 +498,13 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
}}
/>
<EmbeddedCadViewToolbar
disabled={!result}
disabled={!result || isGenerating}
onResetView={() => viewerRef.current?.zoomToFit?.()}
onScreenshot={() => void viewerRef.current?.captureScreenshot?.({ filename: "cdsl-cad.png" })}
onParameters={() => setShowParameters(true)}
/>
{editPending || isGenerating ? <div className="viewer-loading"><Loader2 className="spin" size={16} /><span>{editPending ? "正在应用 CDSL 编辑..." : "正在生成 CDSL 模型..."}</span></div> : null}
{activeToolDefinition ? (
{activeToolDefinition && !isGenerating && !result?.checkpoint ? (
<div className="absolute bottom-3 left-3 z-30 w-[236px] border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-3 text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-soft)] backdrop-blur" data-viewer-interaction-overlay="true">
<div className="mb-2 text-xs font-semibold">{activeToolDefinition.label}</div>
<div className="grid gap-2">
@@ -543,7 +568,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
onClose={() => setShowParameters(false)}
onCommit={(id, value) => void commitParameters({ [id]: value })}
onReset={(values) => void commitParameters(values)}
downloads={[
downloads={result.checkpoint ? [] : [
{ label: "STEP", description: "CAD exchange", url: encodeArtifactUrl(result.taskId, result.stepPath) },
{ label: "CDSL", description: "Editable model", url: encodeArtifactUrl(result.taskId, result.cdslPath) },
{ label: "GLB", description: "Preview mesh", url: encodeArtifactUrl(result.taskId, result.glbPath) },
+20 -5
View File
@@ -6,11 +6,8 @@ export function encodeArtifactUrl(taskId: string, artifactPath: string) {
return `/api/tasks/${encodedTask}/artifacts/${encodedPath}`;
}
export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null {
if (!task) return null;
const current =
task.revisions.find((revision) => revision.revision_id === task.current_revision) ??
[...task.revisions].reverse().find((revision) => revision.status === "success");
function resultForRevision(task: TaskRecord, revisionId: string, checkpoint: boolean): CadResult | null {
const current = task.revisions.find((revision) => revision.revision_id === revisionId);
if (
!current ||
current.status !== "success" ||
@@ -31,6 +28,7 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul
parametersPath: current.parameters_path,
selectorPath: current.selector_path,
edgesPath: current.edges_path,
topologyPath: current.topology_path,
summary: current.summary || "CDSL CAD model",
referenceIds: current.reference_ids || [],
engine: current.engine || "cdsl_only",
@@ -41,5 +39,22 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul
repairAttempts: current.repair_attempts || 0,
snapshotPaths: current.snapshot_manifest_path ? [current.snapshot_manifest_path] : [],
snapshotStatus: current.snapshot_status || "unavailable",
checkpoint,
lifecycle: task.lifecycle || "completed",
};
}
export function activeCheckpointPreview(task: TaskRecord | null): CadResult | null {
if (!task || task.lifecycle !== "running") return null;
const revisionId = task.preview_revision || task.active_revision || task.current_revision;
if (!revisionId) return null;
return resultForRevision(task, revisionId, true);
}
export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null {
if (!task) return null;
const current =
task.revisions.find((revision) => revision.revision_id === (task.published_revision || task.current_revision)) ??
[...task.revisions].reverse().find((revision) => revision.status === "success" && revision.visibility !== "checkpoint");
return current ? resultForRevision(task, current.revision_id, current.visibility === "checkpoint") : null;
}
+46 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { backendEventToUiChunk } from "./cad-stream";
import { latestSuccessfulResult } from "./cad-artifacts";
import { activeCheckpointPreview, latestSuccessfulResult } from "./cad-artifacts";
import { messagesForBackend } from "./cad-messages";
import type { CadUIMessage } from "./cad-types";
@@ -14,6 +14,23 @@ test("maps backend cad_result SSE into an AI SDK data part", () => {
assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" });
});
test("keeps progressive revisions as separate data parts", () => {
const first = backendEventToUiChunk({ event: "cad_result", data: { taskId: "cad_abc", revisionId: "rev_001" } }, "text_1");
const second = backendEventToUiChunk({ event: "cad_result", data: { taskId: "cad_abc", revisionId: "rev_002" } }, "text_1");
assert.notEqual(first?.id, second?.id);
});
test("maps visual repair review into a blocking progress state", () => {
const chunk = backendEventToUiChunk({
event: "render_review",
data: { taskId: "cad_abc", nodeId: "round", review: { verdict: "repair", confidence: 0.92, evidence: ["missing round"] } },
}, "text_1");
assert.equal(chunk?.type, "data-cad-progress");
assert.deepEqual("data" in chunk! ? chunk.data : null, {
step: "render_review", label: "视觉复核", status: "error", message: "missing round", taskId: "cad_abc", nodeId: "round",
});
});
test("maps structured image analysis SSE into an AI SDK data part", () => {
const data = {
attachmentIds: ["upload_flange"],
@@ -41,6 +58,34 @@ test("restores the latest successful task revision for the viewer", () => {
assert.equal(result?.summary, "done");
});
test("prefers the published revision over an active checkpoint", () => {
const result = latestSuccessfulResult({
task_id: "cad_abc",
current_revision: "rev_002",
active_revision: "rev_002",
published_revision: "rev_001",
lifecycle: "running",
revisions: [
{ revision_id: "rev_001", status: "success", visibility: "final", cdsl_path: "a", step_path: "b", glb_path: "c", report_path: "d" },
{ revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" },
],
});
assert.equal(result?.revisionId, "rev_001");
assert.equal(result?.checkpoint, false);
});
test("restores an active checkpoint only while the task is running", () => {
const result = activeCheckpointPreview({
task_id: "cad_abc", current_revision: "rev_002", active_revision: "rev_002", published_revision: "rev_001", lifecycle: "running",
revisions: [
{ revision_id: "rev_001", status: "success", visibility: "final", cdsl_path: "a", step_path: "b", glb_path: "c", report_path: "d" },
{ revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" },
],
});
assert.equal(result?.revisionId, "rev_002");
assert.equal(result?.checkpoint, true);
});
test("strips non-text and non-CAD parts before sending to FastAPI", () => {
const messages: CadUIMessage[] = [{
id: "m1",
+28 -1
View File
@@ -19,10 +19,37 @@ export function backendEventToUiChunk(
data: item.data,
};
}
if (["generation_plan", "checkpoint", "render_review", "rollback", "task_terminal"].includes(item.event)) {
const review = item.data.review && typeof item.data.review === "object"
? item.data.review as Record<string, unknown>
: null;
const status = item.event === "task_terminal"
? (String(item.data.lifecycle || "") === "failed" ? "error" : "success")
: item.event === "render_review" && String(review?.verdict || "") === "repair" && Number(review?.confidence || 0) >= 0.85
? "error"
: String(item.data.status || "running");
return {
type: "data-cad-progress",
id: `${item.event}_${String(item.data.taskId || Date.now())}_${String(item.data.nodeId || "")}`,
data: { step: item.event, label: ({
generation_plan: "生成计划", checkpoint: "构建检查点", render_review: "视觉复核", rollback: "回滚检查点", task_terminal: "生成任务",
} as Record<string, string>)[item.event], status, message: String(
item.data.message || item.data.reason || (review?.evidence instanceof Array ? review.evidence.join("") : ""),
),
...(item.data.taskId ? { taskId: String(item.data.taskId) } : {}),
...(item.data.nodeId ? { nodeId: String(item.data.nodeId) } : {}),
...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}),
},
};
}
if (item.event === "cad_result") {
const taskId = String(item.data.taskId || "");
const revisionId = String(item.data.revisionId || Date.now());
return {
type: "data-cad-result",
id: `result_${String(item.data.taskId || Date.now())}`,
// Each revision is a distinct progressive result. Reusing only the task
// id makes the AI SDK reconcile intermediate revisions into one part.
id: `result_${taskId}_${revisionId}`,
data: item.data,
};
}
+59
View File
@@ -5,6 +5,9 @@ export type CadProgress = {
label: string;
status: "running" | "success" | "error" | string;
message?: string;
taskId?: string;
nodeId?: string;
lifecycle?: "running" | "completed" | "failed" | string;
};
export type CadResult = {
@@ -17,6 +20,7 @@ export type CadResult = {
parametersPath?: string;
selectorPath?: string;
edgesPath?: string;
topologyPath?: string;
summary: string;
referenceIds: string[];
engine: string;
@@ -27,6 +31,8 @@ export type CadResult = {
repairAttempts?: number;
snapshotPaths?: string[];
snapshotStatus?: string;
checkpoint?: boolean;
lifecycle?: "running" | "completed" | "failed" | string;
};
export type CadError = {
@@ -40,7 +46,32 @@ export type CadImageDimension = {
reason: string;
};
export type CadImageSegment = {
type: "line" | "arc" | "circle" | "polyline" | "unknown_curve" | string;
start?: number[];
end?: number[];
center?: number[];
radius_mm?: number | null;
points?: number[][];
confidence?: number | null;
notes?: string;
};
export type CadImageProfile = {
id: string;
role?: string;
plane_hint?: string;
closed?: boolean;
segments?: CadImageSegment[];
source_images?: string[];
confidence?: number | null;
uncertain?: string[];
notes?: string;
};
export type CadImageAnalysis = {
observationStage?: "survey" | "sketch" | "complete" | string;
schemaVersion?: string;
attachmentIds: string[];
partType: string;
visibleFeatures: string[];
@@ -48,6 +79,16 @@ export type CadImageAnalysis = {
dimensionCandidates?: CadImageDimension[];
// Legacy conversations stored this field before dimensions became optional.
requiredDimensions?: CadImageDimension[];
views?: Array<{ attachment_id?: string; view_role?: string; orientation?: string; quality?: string; confidence?: number | null }>;
overallGeometry?: Record<string, unknown>;
surfaces?: Array<Record<string, unknown>>;
profiles?: CadImageProfile[];
holes?: Array<Record<string, unknown>>;
bends?: Array<Record<string, unknown>>;
measurements?: Array<{ name: string; value_mm?: number | null; source?: string; confidence?: number | null; evidence?: string }>;
uncertainties?: string[];
assumptions?: string[];
cvHints?: Array<Record<string, unknown>>;
artifactPath?: string;
};
@@ -77,6 +118,10 @@ export type CadAttachment = {
size: number;
sha256: string;
extracted_path?: string;
width?: number;
height?: number;
orientation?: string;
format?: string;
};
export type TaskRevision = {
@@ -89,6 +134,7 @@ export type TaskRevision = {
parameters_path?: string;
selector_path?: string;
edges_path?: string;
topology_path?: string;
summary?: string;
reference_ids?: string[];
engine?: string;
@@ -101,11 +147,21 @@ export type TaskRevision = {
repair_attempts?: number;
snapshot_manifest_path?: string;
snapshot_status?: string;
visibility?: "checkpoint" | "final" | "superseded" | string;
node_id?: string;
render_manifest_path?: string;
visual_review_path?: string;
};
export type TaskRecord = {
task_id: string;
current_revision: string;
active_revision?: string;
published_revision?: string;
lifecycle?: "running" | "completed" | "failed" | string;
active_node_id?: string;
preview_revision?: string;
generation_plan?: Record<string, unknown> | null;
revisions: TaskRevision[];
};
@@ -121,4 +177,7 @@ export type BackendConfig = {
configured: boolean;
library_samples: number;
max_repair_attempts?: number;
incremental_generation?: boolean;
review_configured?: boolean;
review_error?: string;
};
@@ -41,3 +41,22 @@ test("uses backend face triangle ranges when they are available", () => {
assert.deepEqual([...runtime.proxy.faceIds], [0, 1]);
assert.deepEqual([...runtime.proxy.faceRuns], [0, 0, 0, 1, 0, 0, 0, 1, 1, 1]);
});
test("keeps face row lookup separate from edge row lookup", () => {
const runtime = buildCdslSelectorRuntime({
references: [
{ id: "face_000", selectorType: "face", center: [0, 0, 0], normal: [0, 0, 1], frame: { origin_mm: [0, 0, 0], normal: [0, 0, 1] } },
],
edges: [{
record_id: "edge_000",
geometry: { start_mm: [0, 0, 0], end_mm: [1, 0, 0], center_mm: [0.5, 0, 0], bbox_mm: [0, 0, 0, 1, 0, 0], curve_type: "line" },
}],
}, {
vertices: new Float32Array(9),
indices: new Uint32Array([0, 1, 2]),
});
assert.equal(runtime.faceReferenceByRowIndex.get(0)?.id, "face_000");
assert.equal(runtime.edgeReferenceByRowIndex.get(0)?.id, "edge_000");
assert.deepEqual(runtime.faces.map((reference) => reference.id), ["face_000"]);
});
+92 -12
View File
@@ -11,9 +11,20 @@ type SidecarReference = {
surface_type?: string;
triangle_start?: number;
triangle_count?: number;
executable?: boolean;
snapshot_id?: string;
};
type Sidecar = { references?: SidecarReference[] };
type SidecarEdge = {
record_id?: string;
selectorType?: string;
executable?: boolean;
snapshot_id?: string;
geometry?: Record<string, unknown>;
owner_feature_ids?: string[];
};
type Sidecar = { references?: SidecarReference[]; edges?: SidecarEdge[] };
const GLB_CAD_UNIT_SCALE = 1000;
const FACE_ID_NONE = 0xffffffff;
@@ -96,7 +107,7 @@ function triangleRuns(faceIds: Uint32Array, occurrenceRow: number) {
export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
const sourceReferences = Array.isArray(sidecar?.references) ? sidecar.references : [];
const faces = sourceReferences
.filter((reference) => String(reference?.selectorType || "").toLowerCase() === "face")
.filter((reference) => String(reference?.selectorType || "").toLowerCase() === "face" && reference?.executable !== false)
.map((reference, rowIndex) => {
const frame = sourceFrame(reference);
if (!frame) return null;
@@ -141,6 +152,73 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
})
.filter(Boolean) as any[];
const edgeLines: number[] = [];
const edgeIndices: number[] = [];
const edgeIds: number[] = [];
const edgeReferences = (Array.isArray(sidecar?.edges) ? sidecar.edges : [])
.filter((record) => record?.executable !== false)
.map((record, rowIndex) => {
const geometry = record.geometry || {};
const bbox = Array.isArray(geometry.bbox_mm) && geometry.bbox_mm.length === 6
? geometry.bbox_mm.map(Number)
: null;
const center = Array.isArray(geometry.center_mm) && geometry.center_mm.length >= 3
? geometry.center_mm.slice(0, 3).map(Number)
: null;
const start = Array.isArray(geometry.start_mm) && geometry.start_mm.length >= 3
? geometry.start_mm.slice(0, 3).map(Number)
: null;
const end = Array.isArray(geometry.end_mm) && geometry.end_mm.length >= 3
? geometry.end_mm.slice(0, 3).map(Number)
: null;
let points: number[][] = start && end ? [start, end] : [];
if (!points.length && center && bbox && String(geometry.curve_type || "").toLowerCase() === "circle") {
const extents = [bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2]];
const normalAxis = extents.indexOf(Math.min(...extents));
const axes = [0, 1, 2].filter((axis) => axis !== normalAxis);
const radius = Math.max(extents[axes[0]], extents[axes[1]]) / 2;
points = Array.from({ length: 33 }, (_, index) => {
const angle = (index / 32) * Math.PI * 2;
const point = [...center];
point[axes[0]] += Math.cos(angle) * radius;
point[axes[1]] += Math.sin(angle) * radius;
return point;
});
}
if (points.length < 2 || !points.every((point) => point.every(Number.isFinite))) return null;
const segmentStart = edgeIndices.length / 2;
for (let index = 1; index < points.length; index += 1) {
const startIndex = edgeLines.length / 3;
edgeLines.push(...previewVector(points[index - 1] as Vector3), ...previewVector(points[index] as Vector3));
edgeIndices.push(startIndex, startIndex + 1);
edgeIds.push(rowIndex);
}
const edgeId = String(record.record_id || `edge_${rowIndex}`);
const previewCenter = center ? previewVector(center as Vector3) : null;
return {
id: edgeId,
selectorType: "edge",
normalizedSelector: edgeId,
displaySelector: edgeId,
label: String(geometry.curve_type || "edge"),
summary: String(geometry.curve_type || edgeId),
shortSummary: String(geometry.curve_type || edgeId),
partId: "glb:0",
rowIndex,
pickData: {
selectorType: "edge",
center: previewCenter,
sourceBoundsMm: bbox,
bbox: bbox ? mappedBBox({ min: bbox.slice(0, 3), max: bbox.slice(3, 6) }) : null,
segmentStart,
segmentCount: points.length - 1,
curveType: String(geometry.curve_type || "edge"),
cdslCoordinateSystem: "build123d_y_up_glb",
},
};
})
.filter(Boolean) as any[];
const vertices = meshData?.vertices;
const indices = meshData?.indices;
const triangleCount = Math.floor((indices?.length || 0) / 3);
@@ -186,7 +264,7 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
}
const faceRuns = triangleRuns(faceIds, 0);
const references = faces;
const references = [...faces, ...edgeReferences];
return {
schemaVersion: 1,
surfaceEdgeRendering: false,
@@ -196,19 +274,21 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
bbox: meshData?.bounds || null,
occurrences: [{ id: "glb:0" }],
shapes: [],
faces: references,
edges: [],
faces,
edges: edgeReferences,
vertices: [],
references,
referenceMap: new Map(references.map((reference) => [reference.id, reference])),
referenceByNormalizedSelector: new Map(references.map((reference) => [reference.normalizedSelector, reference])),
referenceByDisplaySelector: new Map(references.map((reference) => [reference.displaySelector, reference])),
faceReferenceByRowIndex: new Map(references.map((reference) => [reference.rowIndex, reference])),
edgeReferenceByRowIndex: new Map(),
// Face and edge rows are separate namespaces. Mapping all references here
// lets edge rows overwrite face rows and makes face clicks unselectable.
faceReferenceByRowIndex: new Map(faces.map((reference) => [reference.rowIndex, reference])),
edgeReferenceByRowIndex: new Map(edgeReferences.map((reference) => [reference.rowIndex, reference])),
vertexReferenceByRowIndex: new Map(),
occurrenceIdByRowIndex: new Map([[0, "glb:0"]]),
faceReferenceMap: new Map(references.map((reference) => [reference.id, reference])),
edgeReferenceMap: new Map(),
faceReferenceMap: new Map(faces.map((reference) => [reference.id, reference])),
edgeReferenceMap: new Map(edgeReferences.map((reference) => [reference.id, reference])),
vertexReferenceMap: new Map(),
singleOccurrenceId: "glb:0",
proxy: {
@@ -217,9 +297,9 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
faceIds,
faceRuns,
faceRunColumns: ["occurrenceRow", "primitiveIndex", "triangleStart", "triangleCount", "faceRow"],
edgePositions: new Float32Array(0),
edgeIndices: new Uint32Array(0),
edgeIds: new Uint32Array(0),
edgePositions: new Float32Array(edgeLines),
edgeIndices: new Uint32Array(edgeIndices),
edgeIds: new Uint32Array(edgeIds),
faceEdgeRows: [],
edgeFaceRows: [],
},
+1
View File
@@ -128,6 +128,7 @@ function compactEntity(
selector: String(selectedEntity?.selector || reference?.displaySelector || reference?.normalizedSelector || id).trim(),
label: String(reference?.label || selectedEntity?.surfaceType || referenceData.surfaceType || "face").trim(),
selectorType: String(selectedEntity?.selectorType || reference?.selectorType || "face").trim(),
snapshotId: String(selectedEntity?.snapshotId || reference?.snapshot_id || "").trim(),
surfaceType: String(selectedEntity?.surfaceType || referenceData.surfaceType || "unknown").trim(),
centerMm: referenceData.centerMm,
normal: referenceData.normal,