更新功能
This commit is contained in:
+7
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {}),
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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=(",", ":"))
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
opencv-python-headless>=4.9,<5
|
||||
@@ -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())
|
||||
@@ -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")]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"})
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
Generated
-342
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user