更新功能
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"])
|
||||
Reference in New Issue
Block a user