重构 改为标准化输出

This commit is contained in:
2026-09-01 14:10:23 +08:00
parent 7533b298f1
commit bc87bd6d2d
10151 changed files with 1709487 additions and 11776 deletions
+332 -448
View File
@@ -1,24 +1,24 @@
"""HTTP-facing lifecycle owner for the autonomous CDSL author."""
"""HTTP/SSE delivery adapter for the protocol v3 workflow."""
from __future__ import annotations
import asyncio
import base64
from collections.abc import AsyncIterator, Awaitable, Callable
import json
from collections.abc import AsyncIterator
from hashlib import sha256
import re
import secrets
from pathlib import Path
from typing import Any
import httpx
from app.cad_agent.application.workflow import ModelIdentity
from app.cad_agent.application.capabilities import verify_model_capability
from app.cad_agent.composition import V3Services, compose_v3
from app.cad_agent.domain.errors import ErrorCode
from app.cad_agent.domain.state import TaskPhase, transition
from app.models.contracts import ChatMessage
from app.services.autonomous_cdsl_generation import AutonomousCdslGenerationRunner
from app.services.library import CdslLibrary
from app.services.review_renderer import renderer_status
from app.services.sse import event
from app.services.storage import WorkspaceStore, now_iso
from app.settings import ProviderConfig, ProviderModel, Settings
from app.settings import Settings
def text_from_message(message: ChatMessage) -> str:
@@ -32,48 +32,25 @@ def _response_language(text: str) -> str:
_EVENT_LABELS = {
"requirements_document": "冻结需求", "completion_checklist": "完成清单", "completion_audit": "完成审计",
"modeling_plan": "建模计划", "modeling_plan_review": "计划独立复核",
"tool_call": "建模工具", "candidate_result": "候选构建", "candidate_review": "候选独立复核",
"geometry_diagnostic": "几何诊断", "geometry_conclusion": "几何结论", "step_review": "步骤审查",
"checkpoint": "构建检查点", "rollback": "回滚检查点", "final_review": "最终视觉复核", "task_terminal": "生成任务",
"requirements_review": "需求合同独立复核",
"action_selection": "动作选择",
"tool_call": "建模工具",
"candidate_result": "候选构建",
"candidate_review": "候选独立复核",
"final_review": "最终独立复核",
"task_terminal": "生成任务",
"state_changed": "任务状态",
}
def _event_status(name: str, payload: dict[str, Any]) -> str:
if name == "task_terminal":
lifecycle = str(payload.get("lifecycle") or "")
if lifecycle == "failed":
return "error"
if lifecycle == "cancelled":
return "cancelled"
return "success"
review = payload.get("review") if isinstance(payload.get("review"), dict) else {}
if (name == "modeling_plan_review" and str(review.get("verdict") or "") == "revise") or (
name == "candidate_review" and str(review.get("verdict") or "") == "reject"
) or (
name == "final_review" and str(review.get("verdict") or "") == "repair"
):
return "error"
return str(payload.get("status") or "running")
def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
data.update({"step": name, "label": _EVENT_LABELS.get(name, name), "status": _event_status(name, payload)})
return data
lifecycle = str(payload.get("lifecycle") or "")
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
status = "error" if lifecycle == "failed" or str(payload.get("status") or "") == "error" or str(result.get("status") or "") in {"rejected", "repair"} else "waiting" if lifecycle in {"waiting_for_user", "waiting_retry"} else "success" if lifecycle == "completed" else str(payload.get("status") or "running")
return {**payload, "step": name, "label": _EVENT_LABELS.get(name, name), "status": status}
def _append_text_part(parts: list[dict[str, Any]], text: str) -> None:
if not text:
return
if parts and parts[-1].get("type") == "text":
parts[-1]["text"] = str(parts[-1].get("text") or "") + text
else:
parts.append({"type": "text", "text": text})
def _upsert_data_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None:
def _upsert_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None:
part_id = str(part.get("id") or "")
if part_id:
for index, existing in enumerate(parts):
@@ -83,128 +60,180 @@ def _upsert_data_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None
parts.append(part)
class _StreamingUnsupported(RuntimeError):
pass
def conversation_user_context(conversation: dict[str, Any]) -> list[dict[str, str]]:
"""Return durable user intent from the whole conversation.
A terminal CAD task always starts a fresh frozen requirements document,
but it must not lose the earlier user messages that explain what a short
follow-up means. This deliberately has no keyword handling: ``retry``,
``change it`` and a full replacement prompt are all ordinary conversation
turns. Assistant prose is excluded because it is generated evidence, not
user intent.
"""
entries: list[str] = []
for message in conversation.get("messages") or ():
if not isinstance(message, dict) or message.get("role") != "user":
continue
parts = message.get("parts") or ()
text = "\n".join(
str(part.get("text") or "")
for part in parts
if isinstance(part, dict) and str(part.get("type") or "") == "text"
).strip()
if text:
entries.append(text)
if not entries:
return []
# Bound history without giving an arbitrary short follow-up precedence
# over its preceding detailed request. The latest user turn remains last.
selected: list[str] = []
remaining = 18_000
for text in reversed(entries):
if len(text) > remaining and selected:
break
selected.append(text[-remaining:])
remaining -= len(selected[-1])
if remaining <= 0 or len(selected) >= 12:
break
selected.reverse()
return [{
"role": "user",
"content": "Conversation user context, ordered from earlier to latest. Use it to interpret the current CAD request; do not treat short follow-ups as a replacement unless they explicitly say so:\n\n"
+ "\n\n--- Next user turn ---\n\n".join(selected),
}]
class AgentService:
"""Delivery boundary: no CAD state transitions or provider calls live here."""
def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None:
self.settings = settings
self.store = store
self.store = store # Conversation/attachment store, not v3 CAD state.
self.library = library
self.v3: V3Services = compose_v3(settings)
self._autonomous_runs: dict[str, asyncio.Task[None]] = {}
async def resume_running_tasks(self) -> None:
"""Restart only durable native autonomous workers after process recovery."""
for task in self.store.running_tasks():
task_id = str(task.get("task_id") or "")
state = self.store.read_agent_state(task_id) if task_id else None
if not task_id or task_id in self._autonomous_runs:
task_ids = self.v3.repository.running_task_ids()
if not task_ids:
return
try:
author_provider, author_model = self.settings.resolve_model(None, None)
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
except ValueError as error:
await self._park_startup_tasks(
task_ids,
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
str(error),
retryable=False,
)
return
try:
author_capability = await verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=author_provider.id,
model_id=author_model.id,
)
reviewer_capability = await verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=review_provider.id,
model_id=review_model.id,
)
except Exception as error:
# Startup recovery must never bypass a production capability gate.
# Connectivity failures are recoverable, but they may not leave a
# task falsely marked running without a worker.
await self._park_startup_tasks(
task_ids,
ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE,
str(error),
retryable=True,
)
return
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
await self._park_startup_tasks(
task_ids,
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
"Selected author or reviewer did not pass the v3 conformance suite.",
retryable=False,
)
return
for task_id in task_ids:
if task_id in self._autonomous_runs:
continue
if str(task.get("schema_version") or "") != "2.0" or not isinstance(state, dict):
self.store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.autonomous-failure.v1",
"stage": "recovery",
"error_code": "AUTONOMOUS_PROTOCOL_OBSOLETE",
"message": "This running task uses the retired planning/strict-authoring protocol and cannot be resumed.",
})
self._autonomous_runs[task_id] = asyncio.create_task(
self._consume_discarding(task_id, ModelIdentity(author_provider.id, author_model.id), ModelIdentity(review_provider.id, review_model.id)),
name=f"resume-autonomous-cad-v3-{task_id}",
)
async def _park_startup_tasks(
self,
task_ids: list[str],
error: ErrorCode,
message: str,
*,
retryable: bool,
) -> None:
"""Make a failed startup gate durable instead of leaving phantom runs."""
event_name = "waiting_retry" if retryable else "failed_model_capability"
for task_id in task_ids:
state = self.v3.repository.get_state(task_id)
if state is None or task_id in self._autonomous_runs:
continue
try:
provider, model = self.settings.resolve_model(str(state.get("provider_id") or ""), str(state.get("model_id") or ""))
except ValueError as error:
self.store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.autonomous-failure.v1", "stage": "recovery", "message": str(error),
})
next_state = transition(
state,
"waiting_retry" if retryable else "failed",
error=error,
)
except ValueError:
continue
try:
self.settings.resolve_independent_review_model(provider, model)
renderer_ready, renderer_detail = renderer_status()
if not renderer_ready:
raise ValueError(renderer_detail)
except ValueError as error:
self.store.finish_generation(task_id, lifecycle="failed", failure={
"schema_version": "cad.autonomous-failure.v1",
"stage": "recovery",
"error_code": "FINAL_REVIEW_UNAVAILABLE",
"message": str(error),
})
continue
request = str(state.get("request") or task.get("request") or "")
conversation_id = str(state.get("conversation_id") or "")
initial_messages = state.get("initial_messages") if isinstance(state.get("initial_messages"), list) else []
runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete)
if self.v3.repository.compare_and_swap(next_state, events=[{
"event": event_name,
"code": error.value,
"message": message[:1000],
"startup_recovery": True,
}]):
await self.v3.outbox.dispatch_pending(task_id=task_id)
async def consume(
*, task_id: str = task_id, request: str = request, conversation_id: str = conversation_id,
provider: ProviderConfig = provider, model: ProviderModel = model,
initial_messages: list[dict[str, Any]] = initial_messages, runner: AutonomousCdslGenerationRunner = runner,
) -> None:
try:
async for _name, _payload in runner.run(
task_id=task_id, request=request, conversation_id=conversation_id, provider=provider,
model=model, initial_messages=initial_messages, already_started=True,
):
pass
finally:
self._autonomous_runs.pop(task_id, None)
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"resume-autonomous-cdsl-{task_id}")
async def _consume_discarding(self, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> None:
try:
async for _name, _payload in self.v3.workflow.run(task_id=task_id, author=author, reviewer=reviewer):
# A resumed task has no active SSE client, but its durable
# state events must still leave the transactional outbox.
await self.v3.outbox.dispatch_pending(task_id=task_id)
finally:
# Flush a final transition emitted immediately before the worker
# exits, such as WAITING_RETRY or FAILED_INTERNAL.
await self.v3.outbox.dispatch_pending(task_id=task_id)
self._autonomous_runs.pop(task_id, None)
async def cancel(self, task_id: str) -> dict[str, Any] | None:
task = self.store.read_task(task_id)
if task is None:
state = self.v3.repository.get_state(task_id)
if state is None:
return None
if state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
cancelled = transition(state, "cancelled", error=ErrorCode.CANCELLED)
if self.v3.repository.compare_and_swap(cancelled, events=[{
"event": "task_cancelled",
"from_phase": state.phase.value,
"active_revision": state.active_revision,
"checkpoint_preserved": bool(state.active_revision),
}]):
await self.v3.outbox.dispatch_pending(task_id=task_id)
# Persist the terminal transition before interrupting the coroutine.
# A concurrent worker will fail its optimistic CAS instead of reviving
# the task after the caller has requested cancellation.
running = self._autonomous_runs.pop(task_id, None)
if running and not running.done():
running.cancel()
if str(task.get("lifecycle") or "") == "running":
task = self.store.finish_generation(task_id, lifecycle="cancelled", failure={
"schema_version": "cad.autonomous-cancelled.v1", "stage": "cancelled", "message": "CAD generation was cancelled by the user.",
})
return task
return self.v3.repository.get_task_projection(task_id)
async def resume_retry(self, task_id: str) -> dict[str, Any] | None:
"""Explicitly restart one durably parked infrastructure retry.
``WAITING_FOR_USER`` is deliberately excluded: it requires new user
input, while this endpoint is only the controlled recovery route for
bounded provider/render/storage failures.
"""
state = self.v3.repository.get_state(task_id)
if state is None:
return None
if state.phase.value != "WAITING_RETRY":
raise ValueError("Only a WAITING_RETRY CAD task can be resumed through this endpoint")
running = self._autonomous_runs.get(task_id)
if running is not None and not running.done():
raise ValueError("The CAD task is already running")
author_provider, author_model = self.settings.resolve_model(None, None)
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
author_capability = await verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=author_provider.id,
model_id=author_model.id,
)
reviewer_capability = await verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=review_provider.id,
model_id=review_model.id,
)
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite")
if not self.v3.workflow.resume(task_id):
raise ValueError("The CAD task no longer has a recoverable retry checkpoint")
self._autonomous_runs[task_id] = asyncio.create_task(
self._consume_discarding(
task_id,
ModelIdentity(author_provider.id, author_model.id),
ModelIdentity(review_provider.id, review_model.id),
),
name=f"resume-autonomous-cad-v3-{task_id}",
)
return self.v3.repository.get_task_projection(task_id)
async def stream(
self,
@@ -215,150 +244,136 @@ class AgentService:
model_id: str | None = None,
viewer_context: list[dict[str, Any]] | None = None,
) -> AsyncIterator[bytes]:
del viewer_context # Viewer topology remains frozen while a run executes.
del viewer_context
latest_user = next((message for message in reversed(messages) if message.role == "user"), None)
if latest_user is None:
yield event("cad_error", {"stage": "request", "message": "A user message is required."})
yield event("done", {})
return
user_text = text_from_message(latest_user)
if not user_text:
if latest_user is None or not text_from_message(latest_user):
yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."})
yield event("done", {})
return
request = text_from_message(latest_user)
conversation = self.store.ensure_conversation(conversation_id)
task_id = str(selected_task_id or conversation.get("current_task_id") or "")
current_task = self.store.read_task(task_id) if task_id else None
if task_id and current_task is None:
yield event("cad_error", {"stage": "request", "message": "The selected CAD task no longer exists."})
yield event("done", {})
return
if current_task and str(current_task.get("lifecycle") or "") == "running":
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
yield event("done", {})
return
selected = str(selected_task_id or conversation.get("current_task_id") or "")
current = self.v3.repository.get_task_projection(selected) if selected else None
resumed_task_id = ""
if current and str(current.get("lifecycle") or "") == "waiting_for_user":
state = self.v3.repository.get_state(selected)
if state is not None:
terminal = self.v3.workflow.waiting_for_user_terminal(selected, state)
fields = [
{
"path": f"/requirements/{str(item.get('draftId') or 'review')}",
"message": str(item.get("question") or item.get("reasonCode") or "Unresolved review item."),
}
for item in terminal.get("unresolved") or ()
if isinstance(item, dict)
]
fields.extend({"path": "/requirements/review", "message": issue} for issue in terminal.get("issues") or ())
if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id):
yield event("cad_error", {
"stage": "request",
"message": "This parked CAD task cannot apply the supplied clarification. " + str(terminal["message"]),
"fieldErrors": fields,
"taskId": selected,
"blockerType": terminal.get("blockerType"),
"userActionRequired": terminal.get("userActionRequired"),
})
yield event("done", {})
return
resumed_task_id = selected
try:
provider, model = self.settings.resolve_model(provider_id, model_id)
author_provider, author_model = self.settings.resolve_model(provider_id, model_id)
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
except ValueError as error:
yield event("cad_error", {"stage": "configuration", "message": str(error)})
yield event("done", {})
return
# This is a fail-closed delivery gate, not a per-checkpoint cost. A
# model must never spend a long authoring run only to discover at final
# publication that independent visual review cannot run.
try:
self.settings.resolve_independent_review_model(provider, model)
renderer_ready, renderer_detail = renderer_status()
if not renderer_ready:
raise ValueError(renderer_detail)
except ValueError as error:
yield event("cad_error", {"stage": "configuration", "message": f"最终视觉复核不可用:{error}"})
author_capability = await verify_model_capability(self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=author_provider.id, model_id=author_model.id)
reviewer_capability = await verify_model_capability(self.v3.repository, self.v3.workflow.runtime, self.v3.models, provider_id=review_provider.id, model_id=review_model.id)
except Exception as error:
yield event("cad_error", {"stage": "configuration", "message": f"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: {str(error)[:500]}"})
yield event("done", {})
return
try:
attachment_content = self._attachment_message(conversation, model)
except ValueError as error:
yield event("cad_error", {"stage": "attachment", "message": str(error)})
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
yield event("cad_error", {"stage": "configuration", "message": "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite."})
yield event("done", {})
return
if not task_id:
created = self.store.ensure_task(None, user_text)
task_id = str(created["task_id"])
current_task = created
elif str(current_task.get("schema_version") or "") != "2.0":
yield event("cad_error", {"stage": "generation", "message": "AUTONOMOUS_PROTOCOL_OBSOLETE: this task belongs to the retired protocol. Start a new CAD task."})
if current and str(current.get("lifecycle") or "") == "running":
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
yield event("done", {})
return
else:
# requirements.md is immutable for a task. A post-terminal user
# request therefore starts a clean autonomous run instead of
# silently mixing two independent frozen specifications.
created = self.store.ensure_task(None, user_text)
task_id = str(created["task_id"])
current_task = created
# A terminal task is immutable. A legacy task is never migrated: both
# cases intentionally create a fresh v3 task.
task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}"
if not resumed_task_id:
try:
source_blocks = self._task_source_blocks(conversation, request)
self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks)
except ValueError as error:
yield event("cad_error", {"stage": "request", "message": str(error)})
yield event("done", {})
return
conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id)
yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."})
conversation = self.store.append_conversation_message(
conversation["conversation_id"], latest_user.model_dump(), task_id,
)
initial_messages: list[dict[str, Any]] = conversation_user_context(conversation)
if attachment_content:
initial_messages.append({"role": "user", "content": attachment_content})
frozen_attachment_ids = [
str(attachment.get("id") or "")
for attachment in conversation.get("attachments") or ()
if isinstance(attachment, dict) and str(attachment.get("id") or "")
]
self.store.start_generation(task_id, request=user_text)
yield event("progress", {
"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running",
"message": "CAD 任务已启动。" if _response_language(user_text) == "Chinese" else "CAD task started.",
})
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
assistant_parts: list[dict[str, Any]] = []
parts: list[dict[str, Any]] = []
sequence = 0
async def on_text_delta(text: str) -> None:
# Text is persisted and streamed through the same ordered queue as
# tool events, so a refresh produces the exact same transcript.
nonlocal sequence
sequence += 1
_append_text_part(assistant_parts, text)
await queue.put(("text_delta", {
"text": text, "taskId": task_id, "eventId": f"{task_id}_{sequence}_text_delta",
"sequence": sequence, "timestamp": now_iso(),
}))
runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete, on_text_delta=on_text_delta)
assistant_id = f"assistant_{secrets.token_hex(8)}"
async def consume() -> None:
nonlocal sequence
terminal = ""
try:
async for name, payload in runner.run(
task_id=task_id, request=user_text, conversation_id=conversation["conversation_id"], provider=provider,
model=model, initial_messages=initial_messages, frozen_attachment_ids=frozen_attachment_ids, already_started=True,
):
async def dispatch_state_events() -> None:
"""Publish state notifications only through the outbox.
The dispatcher preserves at-least-once semantics. The SSE
event ID derives from the durable outbox row, so reconnecting
clients can safely de-duplicate a delivery replay.
"""
nonlocal sequence
for outbox_event in await self.v3.outbox.dispatch_pending(task_id=task_id):
sequence += 1
decorated = dict(payload)
decorated.setdefault("taskId", task_id)
decorated.setdefault("eventId", f"{task_id}_{sequence}_{name}")
decorated.update({
event_name = str(outbox_event.get("event") or "state_changed")
payload = {
"taskId": task_id,
"eventId": f"outbox_{outbox_event['event_id']}",
"sequence": sequence,
"timestamp": now_iso(),
})
if name == "cad_result":
_upsert_data_part(assistant_parts, {"type": "data-cad-result", "id": decorated["eventId"], "data": decorated})
elif name == "agent_thinking":
visible = str(payload.get("message") or "")
if visible:
# Streaming completions already delivered this
# content through on_text_delta; only persist the
# full response for the compatibility path.
if not assistant_parts or assistant_parts[-1].get("type") != "text" or visible not in str(assistant_parts[-1].get("text") or ""):
_append_text_part(assistant_parts, visible + "\n\n")
elif name == "task_terminal":
terminal = str(payload.get("lifecycle") or "")
if terminal == "failed":
_upsert_data_part(assistant_parts, {"type": "data-cad-error", "id": decorated["eventId"], "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}})
elif name != "text_delta":
_upsert_data_part(assistant_parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)})
"outboxEvent": event_name,
"message": event_name.replace("_", " "),
}
await queue.put(("progress", _visible_progress("state_changed", payload)))
try:
async for name, payload in self.v3.workflow.run(
task_id=task_id,
author=ModelIdentity(author_provider.id, author_model.id),
reviewer=ModelIdentity(review_provider.id, review_model.id),
):
sequence += 1
decorated = {**payload, "taskId": task_id, "eventId": str(payload.get("eventId") or f"{task_id}_{sequence}_{name}"), "sequence": sequence, "timestamp": now_iso()}
_upsert_part(parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)})
if name == "task_terminal" and str(decorated.get("lifecycle") or "") == "failed":
_upsert_part(parts, {"type": "data-cad-error", "id": f"{decorated['eventId']}_error", "data": {
"stage": "generation",
"message": str(decorated.get("message") or "CAD autonomous generation failed."),
"tool": str(decorated.get("tool") or ""),
"fieldErrors": decorated.get("field_errors") if isinstance(decorated.get("field_errors"), list) else [],
}})
await queue.put((name, decorated))
await dispatch_state_events()
except Exception as error:
self.store.finish_generation(task_id, lifecycle="failed", failure={"schema_version": "cad.autonomous-failure.v1", "stage": "worker", "message": str(error)})
terminal = "failed"
payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)}
error_id = f"{task_id}_{sequence + 1}_cad_error"
assistant_parts.append({"type": "data-cad-error", "id": error_id, "data": {"stage": "generation", "message": str(error)}})
await queue.put(("task_terminal", payload))
sequence += 1
terminal = {"taskId": task_id, "lifecycle": "failed", "code": "FAILED_INTERNAL", "message": str(error)[:1000], "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso(), "userActionRequired": False}
_upsert_part(parts, {"type": "data-cad-progress", "id": terminal["eventId"], "data": _visible_progress("task_terminal", terminal)})
_upsert_part(parts, {"type": "data-cad-error", "id": f"{terminal['eventId']}_error", "data": {"stage": "generation", "message": terminal["message"], "fieldErrors": []}})
await queue.put(("task_terminal", terminal))
finally:
if terminal == "completed" and not assistant_parts:
assistant_parts.append({"type": "text", "text": "CAD 模型已完成。"})
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id)
self.store.append_conversation_message(conversation["conversation_id"], {"id": f"assistant_{secrets.token_hex(8)}", "role": "assistant", "parts": parts}, task_id)
self._autonomous_runs.pop(task_id, None)
await queue.put(None)
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cdsl-{task_id}")
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cad-v3-{task_id}")
while True:
try:
item = await asyncio.wait_for(queue.get(), timeout=15)
@@ -369,197 +384,66 @@ class AgentService:
break
name, payload = item
if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed":
yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")})
elif name == "agent_thinking":
message = str(payload.get("message") or "")
if message:
yield event("text_delta", {"text": message + "\n\n"})
else:
yield event(name, payload)
yield event("cad_error", {
"stage": "generation",
"message": str(payload.get("message") or "CAD autonomous generation failed."),
"tool": str(payload.get("tool") or ""),
"fieldErrors": payload.get("field_errors") if isinstance(payload.get("field_errors"), list) else [],
})
yield event(name, payload)
yield event("done", {})
def _persist_assistant(self, conversation_id: str, assistant_id: str, parts: list[dict[str, Any]], task_id: str) -> None:
self.store.append_conversation_message(
conversation_id,
{"id": assistant_id, "role": "assistant", "parts": parts},
task_id or None,
)
async def _complete(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
provider: ProviderConfig,
model: ProviderModel,
required_tool_name: str | None = None,
*,
on_text_delta: Callable[[str], Awaitable[None]] | None = None,
) -> dict[str, Any]:
if on_text_delta is None:
return await self._complete_once(messages, tools, provider, model, required_tool_name)
try:
return await self._complete_stream(messages, tools, provider, model, required_tool_name, on_text_delta)
except _StreamingUnsupported:
await on_text_delta("当前模型不支持工具流式,已切换为兼容模式。\n\n")
response = await self._complete_once(messages, tools, provider, model, required_tool_name)
content = str((((response.get("choices") or [{}])[0] or {}).get("message") or {}).get("content") or "")
if content:
await on_text_delta(content + "\n\n")
# Prevent the runner from emitting this same full response again.
choice = ((response.get("choices") or [{}])[0] or {}).get("message") or {}
return {"choices": [{"message": {**choice, "content": ""}}]}
async def _complete_stream(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
provider: ProviderConfig,
model: ProviderModel,
required_tool_name: str | None,
on_text_delta: Callable[[str], Awaitable[None]],
) -> dict[str, Any]:
tool_choice: str | dict[str, Any] = "auto"
if required_tool_name:
tool_choice = {"type": "function", "function": {"name": required_tool_name}}
payload: dict[str, Any] = {
"model": model.id, "messages": messages, "tools": tools,
"tool_choice": tool_choice, "temperature": 0.1, "stream": True,
}
payload.update(provider.chat_completion_options)
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
try:
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client:
async with client.stream("POST", f"{provider.base_url}/chat/completions", headers=headers, json=payload) as response:
if response.status_code in {400, 404, 405, 415, 422}:
detail = (await response.aread()).decode(errors="replace")[:400]
raise _StreamingUnsupported(detail)
if response.status_code >= 400:
raise RuntimeError(f"LLM request failed ({response.status_code}): {(await response.aread()).decode(errors='replace')[:800]}")
if "text/event-stream" not in str(response.headers.get("content-type") or ""):
raise _StreamingUnsupported("provider returned a non-stream response")
tool_calls: dict[int, dict[str, Any]] = {}
async for line in response.aiter_lines():
if not line.startswith("data:"):
continue
raw = line[5:].strip()
if raw == "[DONE]":
break
try:
chunk = json.loads(raw)
except json.JSONDecodeError:
continue
choices = chunk.get("choices") if isinstance(chunk, dict) else None
delta = choices[0].get("delta") if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
if not isinstance(delta, dict):
continue
text = str(delta.get("content") or delta.get("reasoning_content") or "")
if text:
await on_text_delta(text)
for call in delta.get("tool_calls") or []:
if not isinstance(call, dict):
continue
index = int(call.get("index") or 0)
current = tool_calls.setdefault(index, {"id": str(call.get("id") or f"call_{index}"), "type": "function", "function": {"name": "", "arguments": ""}})
if call.get("id"):
current["id"] = str(call["id"])
function = call.get("function") if isinstance(call.get("function"), dict) else {}
if function.get("name"):
current["function"]["name"] += str(function["name"])
if function.get("arguments"):
current["function"]["arguments"] += str(function["arguments"])
except _StreamingUnsupported:
raise
except (httpx.HTTPError, asyncio.TimeoutError) as error:
raise RuntimeError(f"LLM streaming connection failed: {error}") from error
return {"choices": [{"message": {"content": "", "tool_calls": [tool_calls[key] for key in sorted(tool_calls)]}}]}
async def _complete_once(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
provider: ProviderConfig,
model: ProviderModel,
required_tool_name: str | None = None,
) -> dict[str, Any]:
tool_choice: str | dict[str, Any] = "auto"
if required_tool_name:
tool_choice = {"type": "function", "function": {"name": required_tool_name}}
payload: dict[str, Any] = {
"model": model.id,
"messages": messages,
"tools": tools,
"tool_choice": tool_choice,
"temperature": 0.1,
}
payload.update(provider.chat_completion_options)
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
# A transport failure is independent of authoring quality. Retry it
# here, before the durable runner can classify the task as failed.
# Keep the bound small: retries must not hide a broken provider or
# leave a locked task waiting indefinitely.
transport_error: Exception | None = None
response: httpx.Response | None = None
for attempt in range(3):
try:
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client:
# ``httpx`` timeouts occasionally fail to interrupt a
# locally proxied socket. The coroutine deadline is the
# final authority so a durable CAD task cannot remain
# locked forever waiting for one author response.
response = await asyncio.wait_for(
client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload),
timeout=self.settings.llm_timeout_s,
)
if response.status_code == 400 and "thinking mode does not support this tool_choice" in response.text.lower():
retry_payload = dict(payload)
retry_payload.pop("tool_choice", None)
response = await asyncio.wait_for(
client.post(f"{provider.base_url}/chat/completions", headers=headers, json=retry_payload),
timeout=self.settings.llm_timeout_s,
)
break
except (httpx.HTTPError, asyncio.TimeoutError) as error:
transport_error = error
if attempt == 2:
raise RuntimeError(f"LLM connection failed after 3 attempts: {error}") from error
await asyncio.sleep(0.5 * (2**attempt))
if response is None:
raise RuntimeError(f"LLM connection failed after 3 attempts: {transport_error}")
if response.status_code >= 400:
raise RuntimeError(f"LLM request failed ({response.status_code}): {response.text[:800]}")
body = response.json()
if not isinstance(body, dict) or not isinstance(body.get("choices"), list) or not body["choices"]:
raise RuntimeError("LLM response contains no completion choices")
return body
def _attachment_message(self, conversation: dict[str, Any], model: ProviderModel) -> list[dict[str, Any]] | str:
attachments = conversation.get("attachments") or []
if not attachments:
return ""
def _task_source_blocks(self, conversation: dict[str, Any], request: str) -> list[dict[str, Any]]:
"""Freeze message paragraphs and attachment blocks before task creation."""
blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()]
conversation_id = str(conversation.get("conversation_id") or "")
if not conversation_id:
raise ValueError("Conversation attachment has no conversation id")
content: list[dict[str, Any]] = [{"type": "text", "text": "These frozen attachments are part of the CAD request."}]
for attachment in attachments:
raise ValueError("Conversation has no identifier")
for attachment in conversation.get("attachments") or ():
if not isinstance(attachment, dict):
continue
if str(attachment.get("conversation_id") or "") != conversation_id:
raise ValueError("Conversation attachment metadata is invalid")
relative = str(attachment.get("path") or "")
if not relative:
raise ValueError("Conversation attachment is missing its artifact path")
path = self.store.conversation_attachment_path(conversation_id, relative)
if not path.is_file():
raise ValueError(f"Conversation attachment is missing: {attachment.get('name') or attachment.get('id')}")
if str(attachment.get("kind") or "") == "image":
if not model.vision:
raise ValueError("The selected author model does not support images. Select a vision-capable model for image attachments.")
mime = str(attachment.get("mime") or "image/png")
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}})
raise ValueError("Attachment does not belong to this conversation")
attachment_id = str(attachment.get("id") or "")
relative_path = str(attachment.get("path") or "")
expected_digest = str(attachment.get("sha256") or "")
if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", expected_digest):
raise ValueError("Attachment metadata is incomplete")
binary_path = self.store.conversation_attachment_path(conversation_id, relative_path)
if not binary_path.is_file():
raise ValueError(f"Attachment is missing: {attachment.get('name') or attachment_id}")
if sha256(binary_path.read_bytes()).hexdigest() != expected_digest:
raise ValueError(f"Attachment checksum mismatch: {attachment.get('name') or attachment_id}")
kind = str(attachment.get("kind") or "")
if kind == "document":
extracted_path = str(attachment.get("extracted_path") or "")
if not extracted_path:
raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}")
text_path = self.store.conversation_attachment_path(conversation_id, extracted_path)
if not text_path.is_file():
raise ValueError(f"Attachment text is missing: {attachment.get('name') or attachment_id}")
text = text_path.read_text(encoding="utf-8", errors="replace").strip()
elif kind == "image":
# The binary is integrity-checked above. Do not claim that a
# text-only author has interpreted visual content; the source
# block is still a stable, reviewable attachment reference.
text = (
f"Visual attachment {attachment.get('name') or attachment_id} "
f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). "
"It is a visual reference and requires explicit visual verification."
)
else:
extracted = str(attachment.get("extracted_path") or "")
document_path = self.store.conversation_attachment_path(conversation_id, extracted) if extracted else path
text = document_path.read_text(encoding="utf-8", errors="replace")[:24000] if document_path.is_file() else ""
content.append({"type": "text", "text": f"Document {attachment.get('name') or path.name}:\n{text}"})
return content
raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}")
if not text:
raise ValueError(f"Attachment source is empty: {attachment.get('name') or attachment_id}")
blocks.append({
"text": text,
"attachment": {
"attachment_id": attachment_id,
"name": str(attachment.get("name") or attachment_id),
"kind": kind,
"mime": str(attachment.get("mime") or "application/octet-stream"),
"sha256": expected_digest,
},
})
return blocks
File diff suppressed because it is too large Load Diff
@@ -1,403 +0,0 @@
from __future__ import annotations
import hashlib
import json
from copy import deepcopy
from typing import Any
from jsonschema import Draft202012Validator
from app.services.engine_service import feature_atomic_contract
class CanonicalFragmentError(ValueError):
def __init__(self, message: str, *, path: str = "fragment", code: str = "CDSL_SCHEMA_INVALID") -> None:
super().__init__(message)
self.path = path
self.code = code
def _point(size: int) -> dict[str, Any]:
return {
"type": "array",
"items": {"type": "number"},
"minItems": size,
"maxItems": size,
}
def _workplane_schema() -> dict[str, Any]:
point3 = _point(3)
return {
"type": "object",
"properties": {
"origin_mm": deepcopy(point3),
"x_dir": deepcopy(point3),
"normal": deepcopy(point3),
},
"required": ["origin_mm", "x_dir", "normal"],
"additionalProperties": False,
}
def _analytic_segment_schema() -> dict[str, Any]:
point2 = _point(2)
return {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "line"},
"start": deepcopy(point2),
"end": deepcopy(point2),
},
"required": ["type", "start", "end"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"type": {"const": "arc"},
"start": deepcopy(point2),
"end": deepcopy(point2),
"center": deepcopy(point2),
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
"clockwise": {"type": "boolean"},
},
"required": ["type", "start", "end", "center", "radius_mm"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"center": deepcopy(point2),
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
},
"required": ["type", "center", "radius_mm"],
"additionalProperties": False,
},
]
}
def canonical_profile_schema() -> dict[str, Any]:
point2 = _point(2)
segment = _analytic_segment_schema()
contour = {
"type": "object",
"properties": {
"role": {"enum": ["outer", "inner"]},
"closed": {"const": True},
"segments": {"type": "array", "minItems": 1, "items": segment},
},
"required": ["role", "closed", "segments"],
"additionalProperties": False,
}
return {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"center": deepcopy(point2),
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
},
"required": ["type", "radius_mm"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"type": {"const": "polygon"},
"vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)},
},
"required": ["type", "vertices"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"type": {"const": "analytic_contours"},
"contours": {"type": "array", "minItems": 1, "maxItems": 2, "items": contour},
},
"required": ["type", "contours"],
"additionalProperties": False,
},
]
}
def _end_condition_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {
"type": {"type": "string", "minLength": 1},
"solidworks_code": {"type": "integer"},
},
"required": ["type", "solidworks_code"],
"additionalProperties": False,
}
def _axis_schema() -> dict[str, Any]:
point3 = _point(3)
return {
"type": "object",
"properties": {"origin_mm": deepcopy(point3), "direction": deepcopy(point3)},
"required": ["origin_mm", "direction"],
"additionalProperties": False,
}
def _hole_positions_schema() -> dict[str, Any]:
return {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {"mm": _point(3)},
"required": ["mm"],
"additionalProperties": False,
},
}
def _parameter_schema(atomic_id: str, contract: dict[str, Any]) -> dict[str, Any]:
positive = {"type": "number", "exclusiveMinimum": 0}
number = {"type": "number"}
integer = {"type": "integer", "minimum": 1}
point3 = _point(3)
properties: dict[str, Any] = {}
required = [name for name in contract["required_params"] if name not in {"host_face", "source_feature_ids", "mirror_plane"}]
if atomic_id.startswith("extrude_"):
properties = {
"distance_mm": deepcopy(number),
"reverse_distance_mm": deepcopy(number),
"reverse": {"type": "boolean"},
"end_condition": _end_condition_schema(),
"reverse_end_condition": _end_condition_schema(),
}
elif atomic_id.startswith("revolve_"):
properties = {
"angle_deg": {"type": "number", "exclusiveMinimum": 0, "maximum": 360},
"axis": _axis_schema(),
"reverse": {"type": "boolean"},
}
elif atomic_id.startswith("hole_"):
properties = {
"diameter_mm": deepcopy(positive),
"depth_mm": deepcopy(positive),
"positions": _hole_positions_schema(),
"drill_angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793},
"countersink_diameter_mm": deepcopy(positive),
"countersink_angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793},
"counterbore_diameter_mm": deepcopy(positive),
"counterbore_depth_mm": deepcopy(positive),
}
elif atomic_id == "sphere_add":
properties = {"radius_mm": deepcopy(positive), "center_mm": deepcopy(point3)}
elif atomic_id == "fillet":
properties = {"radius_mm": deepcopy(positive), "tangent_propagation": {"type": "boolean"}}
elif atomic_id == "chamfer":
properties = {
"distance_mm": deepcopy(positive),
"distance_2_mm": deepcopy(positive),
"angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793},
}
elif atomic_id == "pattern_linear":
properties = {
"direction_1": deepcopy(point3),
"spacing_1_mm": deepcopy(positive),
"pattern_count_1": deepcopy(integer),
"direction_2": deepcopy(point3),
"spacing_2_mm": deepcopy(positive),
"pattern_count_2": deepcopy(integer),
}
elif atomic_id == "pattern_mirror":
properties = {}
elif atomic_id == "reference_axis":
properties = {"axis": _axis_schema()}
elif atomic_id == "reference_plane":
properties = {"plane": _workplane_schema()}
elif atomic_id == "hole_wizard":
required = ["hole_type", "diameter_mm", "depth_mm"]
properties = {
"hole_type": {"type": "string", "minLength": 1},
"diameter_mm": deepcopy(positive),
"depth_mm": deepcopy(positive),
"positions": _hole_positions_schema(),
"thread": {"type": "object"},
"countersink": {"type": "object"},
"counterbore": {"type": "object"},
}
else:
for name in [*contract["required_params"], *contract["optional_params"]]:
if name not in {"host_face", "source_feature_ids", "mirror_plane"}:
properties[name] = {}
allowed = set(required) | {
name for name in contract["optional_params"]
if name not in {"host_face", "source_feature_ids", "mirror_plane"}
}
properties = {name: value for name, value in properties.items() if name in allowed}
return {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
}
def _selector_limits(atomic_id: str, contract: dict[str, Any]) -> tuple[int, int] | None:
if atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
return 1, 1
slot = contract.get("selector_slot")
if not isinstance(slot, dict):
return None
return int(slot.get("min_items") or 1), int(slot.get("max_items") or 1)
def build_operation_fragment_schema(
engine: Any,
atomic_id: str,
*,
correction: dict[str, Any] | None = None,
allow_multi_feature: bool = False,
) -> dict[str, Any]:
del correction
if allow_multi_feature:
raise ValueError("Canonical autonomous authoring currently permits exactly one feature")
contract = feature_atomic_contract(engine, atomic_id)
feature_properties: dict[str, Any] = {
"atomic_id": {"const": atomic_id},
"params": _parameter_schema(atomic_id, contract),
}
feature_required = ["atomic_id", "params"]
selector_limits = _selector_limits(atomic_id, contract)
if selector_limits is not None:
minimum, maximum = selector_limits
feature_properties["selector_tokens"] = {
"type": "array",
"items": {"type": "string", "minLength": 1},
"minItems": minimum,
"maxItems": maximum,
"uniqueItems": True,
}
feature_required.append("selector_tokens")
feature_schema = {
"type": "object",
"properties": feature_properties,
"required": feature_required,
"additionalProperties": False,
}
properties: dict[str, Any] = {"feature": feature_schema}
required = ["feature"]
if contract["requires_sketch"]:
properties["sketch"] = {
"type": "object",
"properties": {"workplane": _workplane_schema(), "profile": canonical_profile_schema()},
"required": ["workplane", "profile"],
"additionalProperties": False,
}
required.insert(0, "sketch")
return {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
}
def canonical_fragment_example(engine: Any, atomic_id: str) -> dict[str, Any]:
contract = feature_atomic_contract(engine, atomic_id)
feature: dict[str, Any] = {"atomic_id": atomic_id, "params": {}}
params = feature["params"]
if atomic_id.startswith("extrude_"):
params["distance_mm"] = 10
if "reverse_distance_mm" in contract["required_params"]:
params["reverse_distance_mm"] = 5
elif atomic_id.startswith("revolve_"):
params.update({"angle_deg": 360, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}})
elif atomic_id.startswith("hole_"):
params.update({"diameter_mm": 10, "depth_mm": 20, "positions": [{"mm": [0, 0, 0]}]})
if atomic_id == "hole_countersink":
params.update({"countersink_diameter_mm": 16, "countersink_angle_rad": 1.5707963267948966})
if atomic_id == "hole_counterbore":
params.update({"counterbore_diameter_mm": 16, "counterbore_depth_mm": 4})
feature["selector_tokens"] = ["face-token-from-current-topology"]
elif atomic_id == "hole_wizard":
params.update({"hole_type": "simple", "diameter_mm": 10, "depth_mm": 20})
feature["selector_tokens"] = ["face-token-from-current-topology"]
elif atomic_id == "sphere_add":
params.update({"radius_mm": 10, "center_mm": [0, 0, 0]})
elif atomic_id == "fillet":
params["radius_mm"] = 0.5
feature["selector_tokens"] = ["edge-token-from-current-topology"]
elif atomic_id == "chamfer":
params["distance_mm"] = 0.5
feature["selector_tokens"] = ["edge-token-from-current-topology"]
elif atomic_id == "pattern_linear":
params.update({"direction_1": [1, 0, 0], "spacing_1_mm": 10, "pattern_count_1": 2})
elif atomic_id == "pattern_mirror":
feature["selector_tokens"] = ["plane-token-from-current-topology"]
elif atomic_id == "reference_axis":
params["axis"] = {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}
elif atomic_id == "reference_plane":
params["plane"] = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
fragment: dict[str, Any] = {"feature": feature}
if contract["requires_sketch"]:
fragment["sketch"] = {
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": {"type": "polygon", "vertices": [[-10, -5], [10, -5], [10, 5], [-10, 5]]},
}
fragment = {"sketch": fragment["sketch"], "feature": feature}
return fragment
def operation_contract_hash(atomic_id: str, schema: dict[str, Any]) -> str:
encoded = json.dumps({"atomic_id": atomic_id, "schema": schema}, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def validate_canonical_fragment(engine: Any, fragment: dict[str, Any], *, expected_atomic_id: str = "") -> str:
feature = fragment.get("feature") if isinstance(fragment, dict) else None
atomic_id = str((feature or {}).get("atomic_id") or "") if isinstance(feature, dict) else ""
if expected_atomic_id and atomic_id and atomic_id != expected_atomic_id:
raise CanonicalFragmentError(
f"OPERATION_CONTRACT_MISMATCH: expected {expected_atomic_id}, received {atomic_id}",
path="fragment.feature.atomic_id",
code="OPERATION_CONTRACT_MISMATCH",
)
if not atomic_id:
raise CanonicalFragmentError(
"CDSL_SCHEMA_INVALID: canonical fragment requires fragment.feature.atomic_id",
path="fragment.feature.atomic_id",
)
schema = build_operation_fragment_schema(engine, atomic_id)
errors = sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda error: (list(error.absolute_path), error.message))
if errors:
error = errors[0]
path = "fragment" + "".join(
f"[{item}]" if isinstance(item, int) else f".{item}"
for item in error.absolute_path
)
raise CanonicalFragmentError(f"CDSL_SCHEMA_INVALID at {path}: {error.message}", path=path)
profile = ((fragment.get("sketch") or {}).get("profile") or {}) if isinstance(fragment, dict) else {}
if isinstance(profile, dict) and profile.get("type") == "analytic_contours":
roles = [str(item.get("role") or "") for item in profile.get("contours") or () if isinstance(item, dict)]
if roles.count("outer") != 1 or roles.count("inner") > 1:
raise CanonicalFragmentError(
"CDSL_PROFILE_MULTIPLE_OUTERS: analytic_contours requires exactly one outer contour and at most one inner contour",
path="fragment.sketch.profile.contours",
code="CDSL_PROFILE_MULTIPLE_OUTERS",
)
if roles.count("outer") == 0 and roles.count("inner"):
raise CanonicalFragmentError(
"CDSL_PROFILE_INNER_WITHOUT_OUTER: analytic_contours cannot contain an inner contour without an outer contour",
path="fragment.sketch.profile.contours",
code="CDSL_PROFILE_INNER_WITHOUT_OUTER",
)
return atomic_id
-788
View File
@@ -1,788 +0,0 @@
"""Append-only CDSL materialisation for the autonomous authoring loop."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
import math
from typing import Any
class CdslFragmentError(ValueError):
"""A fragment cannot safely be applied to the current CDSL state."""
class AutonomousFragmentError(CdslFragmentError):
"""A free-form candidate violates the autonomous append-only boundary."""
def cdsl_sha256(cdsl: dict[str, Any] | None) -> str:
value = cdsl or {"geometry": {"sketches": []}, "features": []}
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]:
"""Create the complete CDSL document that will be rebuilt from scratch."""
if base_cdsl is None:
document: dict[str, Any] = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "agent_preflight",
"geometry": {"sketches": []},
"features": [],
}
else:
document = deepcopy(base_cdsl)
geometry = document.setdefault("geometry", {})
sketches = geometry.setdefault("sketches", []) if isinstance(geometry, dict) else None
features = document.setdefault("features", [])
if not isinstance(sketches, list) or not isinstance(features, list):
raise CdslFragmentError("Base CDSL has invalid geometry collections")
sketches.extend(deepcopy(fragment["add_sketches"]))
features.extend(deepcopy(fragment["add_features"]))
return document
def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_id: str) -> dict[str, Any]:
"""Persist runtime selector choices as build evidence."""
values = []
for resolution in engine_result.get("selector_resolution") or ():
if not isinstance(resolution, dict):
continue
selector = resolution.get("selector") if isinstance(resolution.get("selector"), dict) else {}
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(resolution.get("candidates") or [])),
"score": resolution.get("score"),
"selected": deepcopy(resolution.get("selected") or resolution.get("record") or {}),
})
return {"schema_version": "cad.selector-bindings.v1", "node_id": node_id, "bindings": values}
def _autonomous_id(prefix: str, used: set[str]) -> str:
index = 1
while True:
candidate = f"{prefix}_{index:03d}"
if candidate not in used:
used.add(candidate)
return candidate
index += 1
def autonomous_selector_tokens(snapshot: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
"""Make opaque, revision-scoped selector tokens from executable topology."""
if not isinstance(snapshot, dict):
return {}
snapshot_id = str(snapshot.get("snapshot_id") or "")
if not snapshot_id:
return {}
values: dict[str, dict[str, Any]] = {}
for record in snapshot.get("records") or ():
if not isinstance(record, dict) or not record.get("executable"):
continue
record_id = str(record.get("record_id") or "")
kind = str(record.get("kind") or "")
if not record_id or kind not in {"face", "edge", "vertex", "plane", "axis", "body"}:
continue
token = "sel_" + sha256(f"{snapshot_id}|{record_id}".encode("utf-8")).hexdigest()[:16]
geometry = deepcopy(record.get("geometry") or {})
owners = record.get("owner_feature_ids") or [record.get("feature_id") or ""]
values[token] = {
"token": token,
"kind": kind,
"geometry": geometry,
"selector": {
"kind": kind,
"stable_id": record_id,
"owner_feature_id": str(owners[0] or ""),
"geometry": geometry,
"source": "runtime_snapshot",
"snapshot_id": snapshot_id,
"confidence": 1.0,
},
}
return values
def _compact_prompt_geometry(geometry: dict[str, Any]) -> dict[str, Any]:
"""Keep only selector-choice facts useful to an author model.
Runtime snapshots also carry adjacency signatures, curve endpoints and
other diagnostic detail. Those fields are required for deterministic
engine work, but repeatedly placing them in an LLM prompt is expensive
and does not help choose an opaque token. Full records remain available
on disk and through targeted measurement tools.
"""
useful = (
"bbox_mm", "center_mm", "normal", "plane_normal", "plane_offset_mm",
"surface_type", "curve_type", "radius_mm", "length_mm", "area_mm2",
"volume_mm3", "solid_count",
)
return {key: deepcopy(geometry[key]) for key in useful if key in geometry}
def autonomous_candidate_prompt_tokens(tokens: dict[str, dict[str, Any]], *, kind: str = "") -> list[dict[str, Any]]:
"""Return compact safe token fields, never persistent selector IDs."""
return [
{"token": token, "kind": value["kind"], "geometry": _compact_prompt_geometry(value["geometry"])}
for token, value in sorted(tokens.items())
if not kind or value["kind"] == kind
]
def _fragment_lists(fragment: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
if not isinstance(fragment, dict):
raise AutonomousFragmentError("fragment_json must decode to a JSON object")
# Ordinary tool-call providers occasionally lift the unambiguous selector
# array one level out of a single-feature payload. Accept that shorthand
# only when it can be moved to exactly one feature without choosing or
# changing any selector ourselves.
allowed = {"sketch", "feature", "sketches", "features", "add_sketches", "add_features", "selector_tokens", "revolve_axis"}
unknown = sorted(set(fragment) - allowed)
if unknown:
raise AutonomousFragmentError("fragment_json may contain only sketch(es) and feature(s); unexpected: " + ", ".join(unknown))
sketches_raw = fragment.get("sketches", fragment.get("add_sketches", []))
features_raw = fragment.get("features", fragment.get("add_features", []))
if "sketch" in fragment:
if sketches_raw:
raise AutonomousFragmentError("Use either sketch or sketches, not both")
sketches_raw = [fragment["sketch"]]
if "feature" in fragment:
if features_raw:
raise AutonomousFragmentError("Use either feature or features, not both")
features_raw = [fragment["feature"]]
if not isinstance(sketches_raw, list) or not all(isinstance(item, dict) for item in sketches_raw):
raise AutonomousFragmentError("fragment sketches must be an array of objects")
if not isinstance(features_raw, list) or not features_raw or not all(isinstance(item, dict) for item in features_raw):
raise AutonomousFragmentError("fragment features must be a non-empty array of objects")
sketches = deepcopy(sketches_raw)
features = deepcopy(features_raw)
top_level_tokens = fragment.get("selector_tokens")
if top_level_tokens is not None:
if len(features) != 1:
raise AutonomousFragmentError("top-level selector_tokens are allowed only with exactly one feature")
if "selector_tokens" in features[0]:
raise AutonomousFragmentError("selector_tokens must appear either at fragment top level or feature level, not both")
features[0]["selector_tokens"] = deepcopy(top_level_tokens)
return sketches, features
def _move_equivalent_field(
value: dict[str, Any],
*,
source: str,
target: str,
location: str,
fixes: list[dict[str, str]],
) -> None:
"""Move a lossless spelling alias without choosing any CAD geometry."""
if source not in value:
return
if target in value:
if value[source] != value[target]:
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: both {source} and {target} were supplied with different values"
)
value.pop(source)
fixes.append({"path": location, "from": source, "to": target, "action": "deduplicated_equivalent"})
return
value[target] = value.pop(source)
fixes.append({"path": location, "from": source, "to": target, "action": "renamed_equivalent"})
def _move_axis_component(
params: dict[str, Any],
axis: dict[str, Any],
*,
source: str,
target: str,
location: str,
fixes: list[dict[str, str]],
) -> None:
"""Move an explicit top-level axis alias into the canonical axis object."""
if source not in params:
return
value = params.pop(source)
if target in axis and axis[target] != value:
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: both {source} and axis.{target} were supplied with different values"
)
if target in axis:
fixes.append({"path": location, "from": source, "to": f"axis.{target}", "action": "deduplicated_equivalent"})
return
axis[target] = value
fixes.append({"path": location, "from": source, "to": f"axis.{target}", "action": "renamed_equivalent"})
def _normalize_axis_mapping(axis: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
"""Normalize only lossless aliases used inside an already explicit axis."""
for source in ("origin", "point_mm", "axis_origin_mm", "axis_point_mm"):
_move_equivalent_field(axis, source=source, target="origin_mm", location=location, fixes=fixes)
for source in ("axis_dir", "axis_direction", "dir"):
_move_equivalent_field(axis, source=source, target="direction", location=location, fixes=fixes)
def _lift_feature_local_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None:
"""Accept common feature-local sketch spellings without choosing geometry.
The public fragment grammar owns one ordered sketch list and one ordered
feature list. Tool-call models commonly emit either a direct feature with
a local ``sketch`` or a wrapper shaped as ``{sketch, feature}``. Both are
losslessly transformable when the fragment has no root sketch collection.
Sketchless features such as ``sphere_add`` and ``chamfer`` may be mixed in
the same batch; the materializer pairs only sketch-requiring features with
the lifted sketches.
"""
if any(key in fragment for key in ("sketch", "sketches", "add_sketches")):
return
features = fragment.get("features", fragment.get("add_features"))
if not isinstance(features, list) or not features or not all(isinstance(item, dict) for item in features):
return
lifted: list[dict[str, Any]] = []
normalized_features: list[dict[str, Any]] = []
for index, item in enumerate(features):
wrapped = item.get("feature")
if wrapped is not None:
if not isinstance(wrapped, dict):
return
feature = deepcopy(wrapped)
if "selector_tokens" in item:
if "selector_tokens" in feature:
raise AutonomousFragmentError(
f"features[{index}] supplies selector_tokens both on the wrapper and feature"
)
feature["selector_tokens"] = deepcopy(item["selector_tokens"])
nested = item.get("sketches", item.get("sketch"))
fixes.append({"path": f"features[{index}]", "from": "{sketch,feature}", "to": "feature", "action": "unwrapped_equivalent"})
else:
feature = deepcopy(item)
nested = feature.get("sketches", feature.get("sketch"))
if isinstance(nested, dict):
sketches = [nested]
elif isinstance(nested, list):
sketches = nested
else:
normalized_features.append(feature)
continue
if len(sketches) != 1 or not isinstance(sketches[0], dict):
return
feature.pop("sketch", None)
feature.pop("sketches", None)
lifted.append(sketches[0])
normalized_features.append(feature)
fixes.append({"path": f"features[{index}]", "from": "feature-local sketch", "to": "sketches[]", "action": "lifted_equivalent"})
fragment["features"] = normalized_features
if lifted:
fragment["sketches"] = lifted
def _lift_param_embedded_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None:
"""Lift an exact legacy ``params.workplane/profile`` sketch spelling.
Some authors place an extrusion's complete sketch inside its params object.
The workplane and profile retain their meaning verbatim, so extracting them
is safe. Partial shapes remain invalid instead of being guessed.
"""
if any(key in fragment for key in ("sketch", "sketches", "add_sketches")):
return
features = fragment.get("features", fragment.get("add_features"))
if not isinstance(features, list) or not all(isinstance(item, dict) for item in features):
return
lifted: list[dict[str, Any]] = []
for index, feature in enumerate(features):
atomic_id = str(feature.get("atomic_id") or "")
params = feature.get("params")
if not atomic_id.startswith(("extrude_", "revolve_")) or not isinstance(params, dict):
continue
workplane = params.get("workplane")
profile = params.get("profile")
if workplane is None and profile is None:
continue
if not isinstance(workplane, dict) or not isinstance(profile, dict):
return
params.pop("workplane")
params.pop("profile")
lifted.append({"workplane": workplane, "profile": profile})
fixes.append({"path": f"features[{index}].params", "from": "workplane/profile", "to": "sketches[]", "action": "lifted_equivalent"})
if lifted:
fragment["sketches"] = lifted
def _set_reverse_from_direction(params: dict[str, Any], *, reverse: bool, location: str, fixes: list[dict[str, str]]) -> None:
if "reverse" in params and params["reverse"] is not reverse:
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: direction conflicts with reverse"
)
params["reverse"] = reverse
params.pop("direction", None)
fixes.append({"path": location, "from": "direction", "to": "reverse", "action": "normalized_equivalent"})
def _normalize_extrude_direction(
params: dict[str, Any],
sketch: dict[str, Any] | None,
*,
location: str,
fixes: list[dict[str, str]],
) -> None:
"""Accept an extrusion direction only when it exactly restates the sketch."""
direction = params.get("direction")
if direction is None:
return
if isinstance(direction, str):
normalized = direction.strip().lower()
if normalized in {"negative", "reverse", "-normal"}:
_set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes)
elif normalized in {"positive", "forward", "+normal"}:
_set_reverse_from_direction(params, reverse=False, location=location, fixes=fixes)
return
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
normal = workplane.get("normal") if isinstance(workplane, dict) else None
if (
not isinstance(direction, list)
or not isinstance(normal, list)
or len(direction) != 3
or len(normal) != 3
or not all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in [*direction, *normal])
):
return
direction_norm = math.sqrt(sum(float(value) ** 2 for value in direction))
normal_norm = math.sqrt(sum(float(value) ** 2 for value in normal))
if direction_norm == 0 or normal_norm == 0:
return
cosine = sum(float(direction[index]) * float(normal[index]) for index in range(3)) / (direction_norm * normal_norm)
if math.isclose(cosine, 1.0, abs_tol=1e-9):
params.pop("direction")
fixes.append({"path": location, "from": "direction", "to": "workplane.normal", "action": "deduplicated_equivalent"})
elif math.isclose(cosine, -1.0, abs_tol=1e-9):
_set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes)
def _normalize_angle_radians(params: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
"""Convert the explicitly unit-labelled angle_rad alias to angle_deg."""
if "angle_rad" not in params:
return
radians = params["angle_rad"]
if not isinstance(radians, (int, float)) or isinstance(radians, bool) or not math.isfinite(float(radians)):
return
degrees = float(radians) * 180.0 / math.pi
if "angle_deg" in params:
supplied = params["angle_deg"]
if not isinstance(supplied, (int, float)) or isinstance(supplied, bool) or not math.isclose(float(supplied), degrees, rel_tol=0.0, abs_tol=1e-9):
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: angle_rad conflicts with angle_deg"
)
params.pop("angle_rad")
fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "deduplicated_equivalent"})
return
params["angle_deg"] = degrees
params.pop("angle_rad")
fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "converted_unit"})
def _normalize_concentric_circle_contours(profile: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
"""Expand a common two-circle annulus shorthand into analytic contours."""
if profile.get("type") != "analytic_contours":
return
contours = profile.get("contours")
if not isinstance(contours, list) or len(contours) != 2 or not all(isinstance(item, dict) for item in contours):
return
if not all(item.get("type") == "circle" and isinstance(item.get("center"), list) and len(item["center"]) == 2 for item in contours):
return
if contours[0]["center"] != contours[1]["center"]:
return
try:
ordered = sorted(contours, key=lambda item: float(item["radius_mm"]), reverse=True)
except (KeyError, TypeError, ValueError):
return
if float(ordered[0]["radius_mm"]) <= float(ordered[1]["radius_mm"]):
return
profile["contours"] = [
{"role": role, "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]}
for role, item in zip(("outer", "inner"), ordered)
]
fixes.append({"path": location, "from": "circle contour shorthand", "to": "analytic_contours.segments", "action": "expanded_equivalent"})
def _finite_vector3(value: Any) -> tuple[float, float, float] | None:
"""Return a finite numeric vector when the author supplied one."""
if (
not isinstance(value, list)
or len(value) != 3
or not all(isinstance(component, (int, float)) and not isinstance(component, bool) for component in value)
):
return None
result = tuple(float(component) for component in value)
return result if all(math.isfinite(component) for component in result) else None
def _validate_revolve_axis_in_sketch_plane(
atomic_id: str,
params: dict[str, Any],
sketch: dict[str, Any],
) -> None:
"""Reject a revolve axis that cannot be a construction line of its sketch.
A solid revolve is defined around an axis in the source sketch plane.
Letting an out-of-plane axis reach OCC can produce degenerate BReps that
fail much later during tessellation, so enforce this geometric invariant
before candidate staging. Malformed vectors are left to CDSL schema
validation, which can report their field-level shape.
"""
axis = params.get("axis")
workplane = sketch.get("workplane") if isinstance(sketch.get("workplane"), dict) else None
if not isinstance(axis, dict) or not isinstance(workplane, dict):
return
axis_origin = _finite_vector3(axis.get("origin_mm"))
axis_direction = _finite_vector3(axis.get("direction"))
plane_origin = _finite_vector3(workplane.get("origin_mm"))
plane_normal = _finite_vector3(workplane.get("normal"))
if None in {axis_origin, axis_direction, plane_origin, plane_normal}:
return
assert axis_origin is not None and axis_direction is not None and plane_origin is not None and plane_normal is not None
direction_length = math.sqrt(sum(component * component for component in axis_direction))
normal_length = math.sqrt(sum(component * component for component in plane_normal))
if direction_length == 0 or normal_length == 0:
return
direction_normal_dot = abs(sum(axis_direction[index] * plane_normal[index] for index in range(3)) / (direction_length * normal_length))
if direction_normal_dot > 1e-7:
raise AutonomousFragmentError(
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: "
f"{atomic_id} params.axis.direction must be parallel to sketch.workplane; "
f"abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}"
)
origin_plane_offset = abs(sum((axis_origin[index] - plane_origin[index]) * plane_normal[index] for index in range(3)) / normal_length)
if origin_plane_offset > 1e-6:
raise AutonomousFragmentError(
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: "
f"{atomic_id} params.axis.origin_mm must lie in sketch.workplane; "
f"plane_offset_mm={origin_plane_offset:.3g}"
)
def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]]]:
"""Normalize only explicitly equivalent author spellings.
Normalization is deliberately narrow. It accepts common CAD vocabulary
where the target runtime field has identical units and meaning, while
refusing inputs that would need a guessed profile, direction, selector,
coordinate system, or topology decision. The original fragment and every
applied fix are retained in the candidate audit record.
"""
if not isinstance(fragment, dict):
return fragment, []
normalized = deepcopy(fragment)
fixes: list[dict[str, str]] = []
_lift_feature_local_sketches(normalized, fixes=fixes)
_lift_param_embedded_sketches(normalized, fixes=fixes)
feature_values: list[tuple[dict[str, Any], str]] = []
feature = normalized.get("feature")
if isinstance(feature, dict):
feature_values.append((feature, "feature"))
features = normalized.get("features", normalized.get("add_features"))
if isinstance(features, list):
feature_values.extend(
(item, f"features[{index}]")
for index, item in enumerate(features)
if isinstance(item, dict)
)
for current_feature, location in feature_values:
atomic_id = str(current_feature.get("atomic_id") or "")
params = current_feature.get("params")
if not isinstance(params, dict):
continue
if atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"}:
# CAD systems commonly call a blind extrusion's travel "depth".
# CDSL calls the exact same signed scalar ``distance_mm``.
_move_equivalent_field(
params,
source="depth_mm",
target="distance_mm",
location=f"{location}.params",
fixes=fixes,
)
if atomic_id in {"revolve_add", "revolve_cut"}:
_move_equivalent_field(
params,
source="angle_degrees",
target="angle_deg",
location=f"{location}.params",
fixes=fixes,
)
_normalize_angle_radians(params, location=f"{location}.params", fixes=fixes)
axis = params.get("axis")
if axis is None:
axis = {}
params["axis"] = axis
if isinstance(axis, dict):
_move_axis_component(params, axis, source="axis_origin_mm", target="origin_mm", location=f"{location}.params", fixes=fixes)
_move_axis_component(params, axis, source="axis_point_mm", target="origin_mm", location=f"{location}.params", fixes=fixes)
_move_axis_component(params, axis, source="axis_dir", target="direction", location=f"{location}.params", fixes=fixes)
_move_axis_component(params, axis, source="axis_direction", target="direction", location=f"{location}.params", fixes=fixes)
axis = params.get("axis")
if isinstance(axis, dict):
_normalize_axis_mapping(axis, location=f"{location}.params.axis", fixes=fixes)
shared_axis = normalized.get("revolve_axis")
if shared_axis is not None:
if not isinstance(shared_axis, dict):
raise AutonomousFragmentError("revolve_axis must be an explicit {origin_mm, direction} object")
_normalize_axis_mapping(shared_axis, location="revolve_axis", fixes=fixes)
revolved = [feature for feature, _ in feature_values if str(feature.get("atomic_id") or "").startswith("revolve_")]
if not revolved:
raise AutonomousFragmentError("revolve_axis is valid only in a fragment containing revolve_add or revolve_cut")
for feature, location in feature_values:
if not str(feature.get("atomic_id") or "").startswith("revolve_"):
continue
params = feature.get("params")
if not isinstance(params, dict):
continue
axis = params.get("axis")
if not isinstance(axis, dict) or not axis:
params["axis"] = deepcopy(shared_axis)
fixes.append({"path": f"{location}.params", "from": "revolve_axis", "to": "axis", "action": "copied_explicit_batch_axis"})
sketch_values: list[tuple[dict[str, Any], str]] = []
sketch = normalized.get("sketch")
if isinstance(sketch, dict):
sketch_values.append((sketch, "sketch"))
sketches = normalized.get("sketches", normalized.get("add_sketches"))
if isinstance(sketches, list):
sketch_values.extend((item, f"sketches[{index}]") for index, item in enumerate(sketches) if isinstance(item, dict))
sketch_feature_index = 0
for current_feature, feature_location in feature_values:
atomic_id = str(current_feature.get("atomic_id") or "")
if not atomic_id.startswith(("extrude_", "revolve_")):
continue
current_sketch = sketch_values[sketch_feature_index][0] if sketch_feature_index < len(sketch_values) else None
sketch_feature_index += 1
if atomic_id.startswith("extrude_"):
params = current_feature.get("params")
if isinstance(params, dict):
_normalize_extrude_direction(
params,
sketch=current_sketch,
location=f"{feature_location}.params",
fixes=fixes,
)
for current_sketch, location in sketch_values:
profile = current_sketch.get("profile")
if isinstance(profile, dict) and profile.get("type") == "polygon":
_move_equivalent_field(profile, source="points", target="vertices", location=f"{location}.profile", fixes=fixes)
if isinstance(profile, dict):
_normalize_concentric_circle_contours(profile, location=f"{location}.profile", fixes=fixes)
# Earlier versions advertised sphere_add as sketch-backed even though its
# executor has always used only radius_mm and center_mm. Preserve that
# single-feature spelling without keeping an unused locator sketch in the
# immutable CDSL document.
if (
len(feature_values) == 1
and str(feature_values[0][0].get("atomic_id") or "") == "sphere_add"
and len(sketch_values) == 1
):
normalized.pop("sketch", None)
normalized.pop("sketches", None)
normalized.pop("add_sketches", None)
fixes.append({"path": "sketch", "from": "sphere locator sketch", "to": "none", "action": "dropped_unused_legacy_locator"})
return normalized, fixes
def materialize_autonomous_fragment(
base_cdsl: dict[str, Any] | None,
fragment: dict[str, Any],
*,
engine: Any,
selector_tokens: dict[str, dict[str, Any]],
max_features: int,
source: str = "legacy_restore",
allow_legacy_aliases: bool = True,
expected_atomic_id: str = "",
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Append authored geometry while assigning only server-owned metadata.
Workplanes, profiles, feature parameters, directions and boolean meaning
pass through exactly as the author supplied them. Local CDSL validation and
full engine rebuild happen after this function, before a checkpoint exists.
"""
# Delayed import avoids engine_service -> selector_bindings -> this module
# becoming an import cycle.
from app.services.engine_service import feature_atomic_contract
from app.services.cdsl_authoring_schema import CanonicalFragmentError, validate_canonical_fragment
normalized_fragment, compatibility_fixes = normalize_autonomous_fragment(fragment)
if not allow_legacy_aliases:
if compatibility_fixes:
first = compatibility_fixes[0]
location = str(first.get("path") or "fragment")
legacy = str(first.get("from") or "legacy field")
canonical = str(first.get("to") or "canonical field")
raise AutonomousFragmentError(
f"CDSL_CANONICAL_FORMAT_REQUIRED at fragment.{location}: "
f"{legacy} is a legacy spelling; use {canonical}"
)
try:
validate_canonical_fragment(engine, fragment, expected_atomic_id=expected_atomic_id)
except CanonicalFragmentError as error:
raise AutonomousFragmentError(str(error)) from error
normalized_fragment = deepcopy(fragment)
compatibility_fixes = []
sketches, features = _fragment_lists(normalized_fragment)
if len(features) > max_features:
raise AutonomousFragmentError(f"A fragment may add at most {max_features} feature(s)")
document = materialize_fragment(base_cdsl, {"add_sketches": [], "add_features": []})
geometry = document.get("geometry") if isinstance(document.get("geometry"), dict) else {}
existing_sketches = geometry.get("sketches") if isinstance(geometry.get("sketches"), list) else []
existing_features = document.get("features") if isinstance(document.get("features"), list) else []
used_sketch_ids = {str(item.get("id") or "") for item in existing_sketches if isinstance(item, dict)}
used_feature_ids = {str(item.get("id") or "") for item in existing_features if isinstance(item, dict)}
last_feature_id = str(existing_features[-1].get("id") or "") if existing_features and isinstance(existing_features[-1], dict) else ""
materialized_sketches: list[dict[str, Any]] = []
materialized_features: list[dict[str, Any]] = []
sketch_index = 0
for source_feature in features:
# Materialization injects server-owned ids, host faces and pattern
# sources. Work on a private copy so the original tool-call fragment
# remains intact in audit records and diagnostic replacement cards.
source_feature = deepcopy(source_feature)
forbidden = {"id", "depends_on", "sketch_id", "selectors"} & set(source_feature)
if forbidden:
raise AutonomousFragmentError("Feature identity, dependencies, sketch_id and raw selectors are server-owned: " + ", ".join(sorted(forbidden)))
atomic_id = str(source_feature.get("atomic_id") or "")
if not atomic_id:
raise AutonomousFragmentError("Each fragment feature must declare a runtime atomic_id")
contract = feature_atomic_contract(engine, atomic_id)
params = source_feature.get("params")
if not isinstance(params, dict):
raise AutonomousFragmentError("Each fragment feature must contain a params object")
if atomic_id.startswith("revolve_") and params.get("angle_deg") is None:
# A shared batch axis is deliberately limited to the axis. A
# default revolution angle would silently turn valid partial
# revolves into a different solid, so it remains author-owned.
raise AutonomousFragmentError(
f"{atomic_id} requires params.angle_deg; revolve_axis (including shared_revolve_axis) "
"supplies only params.axis. Declare an explicit angle in degrees."
)
token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard"
tokens = source_feature.pop("selector_tokens", [])
token_list_is_valid = (
isinstance(tokens, list)
and all(isinstance(token, str) for token in tokens)
and len(set(tokens)) == len(tokens)
)
if not token_list_is_valid and not token_backed_param:
raise AutonomousFragmentError("selector_tokens must be a unique array of opaque tokens")
if not token_list_is_valid:
tokens = []
selected: list[dict[str, Any]] = []
invalid_tokens: list[str] = []
for token in tokens:
candidate = selector_tokens.get(token)
if candidate is None:
if token_backed_param:
invalid_tokens.append(token)
continue
raise AutonomousFragmentError("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
selected.append(deepcopy(candidate["selector"]))
slot = contract.get("selector_slot")
if token_backed_param:
# Report all author-correctable hole errors at once. A hole is
# topology-sensitive, so its host face remains server-owned and
# must be injected from one current face token.
issues: list[str] = []
author_params = (set(contract["required_params"]) | set(contract["optional_params"])) - {"host_face"}
if "host_face" in params:
issues.append("params.host_face is server-owned; use selector_tokens")
missing = [name for name in contract["required_params"] if name != "host_face" and name not in params]
if missing:
issues.append("missing params: " + ", ".join(missing))
unexpected = sorted(name for name in params if name not in author_params and name != "host_face")
if unexpected:
issues.append("unsupported params: " + ", ".join(unexpected))
if not token_list_is_valid:
issues.append("selector_tokens must be a unique array of opaque tokens")
if invalid_tokens:
issues.append("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
if len(tokens) != 1 or len(selected) != 1 or str((selected[0] if selected else {}).get("kind") or "") != "face":
issues.append(f"{atomic_id} requires exactly one face selector token for its host face")
if issues:
raise AutonomousFragmentError("HOLE_FRAGMENT_INVALID: " + "; ".join(issues))
if not slot and tokens and not token_backed_param:
raise AutonomousFragmentError(f"{atomic_id} does not accept selector tokens")
if isinstance(slot, dict):
minimum, maximum = int(slot.get("min_items") or 0), int(slot.get("max_items") or 0)
if not minimum <= len(selected) <= maximum:
raise AutonomousFragmentError(f"{atomic_id} requires {minimum}..{maximum} selector token(s)")
elif token_backed_param:
# Hole token validation above deliberately aggregates every
# actionable error before this materialization boundary.
pass
elif atomic_id.startswith("revolve_"):
axis = params.get("axis")
if not isinstance(axis, dict) or "origin_mm" not in axis or "direction" not in axis:
raise AutonomousFragmentError(
f"{atomic_id} requires params.axis with explicit origin_mm and direction; "
"the revolve axis is author-defined geometry, not a topology selector token"
)
feature_id = _autonomous_id("feature", used_feature_ids)
output = {key: deepcopy(value) for key, value in source_feature.items() if key != "selector_tokens"}
output["id"] = feature_id
output["depends_on"] = [last_feature_id] if last_feature_id else []
if contract["requires_sketch"]:
if sketch_index >= len(sketches):
raise AutonomousFragmentError(f"{atomic_id} requires one new sketch in the same fragment")
sketch = sketches[sketch_index]
sketch_index += 1
if "id" in sketch or "attachment" in sketch or "profile_from" in sketch:
raise AutonomousFragmentError("Sketch identity and topology attachment are server-owned")
sketch_id = _autonomous_id("sketch", used_sketch_ids)
sketch["id"] = sketch_id
if atomic_id.startswith("revolve_"):
_validate_revolve_axis_in_sketch_plane(atomic_id, params, sketch)
materialized_sketches.append(sketch)
output["sketch_id"] = sketch_id
if atomic_id.startswith("pattern_") and "source_feature_ids" not in params:
if not last_feature_id:
raise AutonomousFragmentError(f"{atomic_id} needs a committed source feature")
params["source_feature_ids"] = [last_feature_id]
if isinstance(slot, dict) and slot.get("path") == "feature.selectors":
output["selectors"] = selected
elif isinstance(slot, dict) and slot.get("path") == "params.mirror_plane":
output["params"]["mirror_plane"] = selected[0]
output["selectors"] = []
elif isinstance(slot, dict) and slot.get("path") == "params.host_face":
output["params"]["host_face"] = selected[0]
output["selectors"] = []
elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
output["params"]["host_face"] = selected[0]
output["selectors"] = []
else:
output["selectors"] = []
materialized_features.append(output)
last_feature_id = feature_id
if sketch_index != len(sketches):
raise AutonomousFragmentError("Each sketch must be consumed by a feature that requires a sketch")
document = materialize_fragment(document, {"add_sketches": materialized_sketches, "add_features": materialized_features})
return document, {
"schema_version": "cad.autonomous-fragment.v1",
"source_fragment": deepcopy(fragment),
"normalized_fragment": deepcopy(normalized_fragment) if compatibility_fixes else None,
"compatibility_fixes": compatibility_fixes,
"compatibility_fix_count": len(compatibility_fixes),
"legacy_input": source != "tool_call" or bool(compatibility_fixes),
"assigned_sketch_ids": [item["id"] for item in materialized_sketches],
"assigned_feature_ids": [item["id"] for item in materialized_features],
"selector_candidate_ids": [token for feature in features for token in feature.get("selector_tokens", [])],
}
+7 -32
View File
@@ -46,8 +46,8 @@ def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
if not isinstance(schema, dict) or not isinstance(schema.get("feature_atomic_ids"), dict):
raise RuntimeError("The local engine schema has no feature_atomic_ids contract")
if not isinstance(schema, dict) or not isinstance(schema.get("operation_contracts"), dict):
raise RuntimeError("The local engine schema has no v3 operation contract registry")
return schema
@@ -60,31 +60,6 @@ def _engine_schema(engine: Any) -> dict[str, Any]:
return _read_schema_document(str(schema_path), modified_ns)
def feature_atomic_contract(engine: Any, atomic_id: str) -> dict[str, Any]:
"""Return the runtime-supported parameter contract for one atomic feature."""
schema = _engine_schema(engine)
normalized_id = str(atomic_id or "").strip()
contracts = schema["feature_atomic_ids"]
contract = contracts.get(normalized_id)
registered = {str(item) for item in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())}
declared = {str(item) for item in schema.get("runtime_supported_atomic_ids") or ()}
if normalized_id not in registered or normalized_id not in declared or not isinstance(contract, dict):
raise ValueError(f"Unsupported runtime atomic_id: {normalized_id}")
required = contract.get("required_params") or []
optional = contract.get("optional_params") or []
if not all(isinstance(item, str) and item for item in [*required, *optional]):
raise RuntimeError(f"Runtime feature contract is invalid for {normalized_id}")
return {
"atomic_id": normalized_id,
"summary": str(contract.get("summary") or ""),
"required_params": list(dict.fromkeys(required)),
"optional_params": [item for item in dict.fromkeys(optional) if item not in required],
"position_format": str(contract.get("position_format") or ""),
"requires_sketch": contract.get("requires_sketch") is True,
"selector_slot": copy.deepcopy(contract.get("selector_slot")) if isinstance(contract.get("selector_slot"), dict) else None,
}
@lru_cache(maxsize=16)
def _cdsl_validator(path_value: str, modified_ns: int) -> Draft202012Validator:
"""Compile the JSON Schema once per runtime schema revision."""
@@ -152,11 +127,11 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
raise ValueError("CDSL requires a feature list and a geometry.sketches array")
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
semantic_contract = _engine_schema(engine)
atomic_contracts = semantic_contract["feature_atomic_ids"]
declared_atomic_ids = {
str(atomic_id)
for atomic_id in semantic_contract.get("runtime_supported_atomic_ids", atomic_contracts)
}
try:
atomic_contracts = engine.materialized_feature_contracts(semantic_contract)
except (AttributeError, ValueError) as error:
raise RuntimeError("The local engine operation contract registry is invalid") from error
declared_atomic_ids = {str(atomic_id) for atomic_id in atomic_contracts}
registered_atomic_ids = {str(atomic_id) for atomic_id in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())}
supported_atomic_ids = sorted(declared_atomic_ids & registered_atomic_ids)
feature_ids: set[str] = set()
+43 -587
View File
@@ -1,11 +1,17 @@
"""Conversation and attachment storage for the v3 delivery boundary.
CAD task state deliberately does not live here. Protocol v3 owns mutable task
state in ``SqliteTaskRepository`` and immutable task artifacts in
``FileArtifactStore``.
"""
from __future__ import annotations
import json
import re
import secrets
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
import re
import secrets
from typing import Any
from app.settings import Settings
@@ -13,17 +19,12 @@ from app.settings import Settings
TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$")
CONVERSATION_ID = re.compile(r"^conv_[a-z0-9]{12}$")
CANDIDATE_ID = re.compile(r"^candidate_[a-z0-9]{12,32}$")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def new_id(prefix: str) -> str:
return f"{prefix}_{secrets.token_hex(6)}"
def safe_task_id(task_id: str) -> str:
value = str(task_id or "").strip()
if not TASK_ID.fullmatch(value):
@@ -38,117 +39,89 @@ def safe_conversation_id(conversation_id: str) -> str:
return value
def safe_relative_path(value: str) -> str:
def _safe_relative_path(value: str) -> str:
path = Path(str(value or ""))
if not value or path.is_absolute() or ".." in path.parts:
raise ValueError("Invalid artifact path")
raise ValueError("Invalid conversation artifact path")
return path.as_posix()
def write_json(path: Path, payload: Any) -> None:
def _write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def read_json(path: Path, fallback: Any = None) -> Any:
def _read_json(path: Path) -> dict[str, Any] | None:
if not path.is_file():
return fallback
return json.loads(path.read_text(encoding="utf-8"))
return None
value = json.loads(path.read_text(encoding="utf-8"))
return value if isinstance(value, dict) else None
class WorkspaceStore:
"""A conversation store, intentionally not a CAD task repository."""
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.settings.task_root.mkdir(parents=True, exist_ok=True)
self.settings.conversation_root.mkdir(parents=True, exist_ok=True)
def task_dir(self, task_id: str) -> Path:
return self.settings.task_root / safe_task_id(task_id)
def task_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "task.json"
def conversation_dir(self, conversation_id: str) -> Path:
return self.settings.conversation_root / safe_conversation_id(conversation_id)
def conversation_path(self, conversation_id: str) -> Path:
return self.conversation_dir(conversation_id) / "conversation.json"
def write_tool_call_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str:
"""Persist one failed model tool call without creating a CAD revision."""
conversation = safe_conversation_id(conversation_id)
relative = Path("diagnostics") / f"tool_call_{secrets.token_hex(8)}.json"
path = self.conversation_dir(conversation) / relative
write_json(path, payload)
return (Path(conversation) / relative).as_posix()
def write_cdsl_attempt(self, conversation_id: str, cdsl: Any, iteration: int) -> str:
"""Retain a parsed model candidate before validation or execution."""
conversation = safe_conversation_id(conversation_id)
relative = Path("diagnostics") / f"cdsl_attempt_{iteration:02d}_{secrets.token_hex(8)}.json"
path = self.conversation_dir(conversation) / relative
write_json(path, cdsl)
return (Path(conversation) / relative).as_posix()
def write_cdsl_validation_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str:
"""Persist the reason a retained CDSL candidate was rejected."""
conversation = safe_conversation_id(conversation_id)
relative = Path("diagnostics") / f"cdsl_validation_{secrets.token_hex(8)}.json"
path = self.conversation_dir(conversation) / relative
write_json(path, payload)
return (Path(conversation) / relative).as_posix()
def ensure_conversation(
self,
conversation_id: str | None,
current_task_id: str | None = None,
) -> dict[str, Any]:
cid = safe_conversation_id(conversation_id) if conversation_id else new_id("conv")
path = self.conversation_path(cid)
current = read_json(path)
if current:
changed = False
if current_task_id:
def ensure_conversation(self, conversation_id: str | None, current_task_id: str | None = None) -> dict[str, Any]:
conversation = safe_conversation_id(conversation_id) if conversation_id else f"conv_{secrets.token_hex(6)}"
path = self.conversation_path(conversation)
current = _read_json(path)
if current is not None:
if current_task_id and current.get("current_task_id") != safe_task_id(current_task_id):
current["current_task_id"] = safe_task_id(current_task_id)
changed = True
if changed:
current["updated_at"] = now_iso()
write_json(path, current)
_write_json(path, current)
return current
record = {
"schema_version": "1.2",
"conversation_id": cid,
"schema_version": "3.0",
"conversation_id": conversation,
"created_at": now_iso(),
"updated_at": now_iso(),
"current_task_id": safe_task_id(current_task_id) if current_task_id else "",
"messages": [],
"attachments": [],
}
write_json(path, record)
_write_json(path, record)
return record
def read_conversation(self, conversation_id: str) -> dict[str, Any] | None:
return read_json(self.conversation_path(conversation_id))
return _read_json(self.conversation_path(conversation_id))
def append_conversation_message(self, conversation_id: str, message: dict[str, Any], current_task_id: str | None = None) -> dict[str, Any]:
record = self.ensure_conversation(conversation_id, current_task_id)
known = {str(item.get("id")) for item in record["messages"]}
if str(message.get("id")) not in known:
known = {str(item.get("id") or "") for item in record["messages"] if isinstance(item, dict)}
if str(message.get("id") or "") not in known:
record["messages"].append(message)
if current_task_id:
record["current_task_id"] = safe_task_id(current_task_id)
record["updated_at"] = now_iso()
write_json(self.conversation_path(record["conversation_id"]), record)
_write_json(self.conversation_path(record["conversation_id"]), record)
return record
def conversation_attachment_path(self, conversation_id: str, relative_path: str) -> Path:
root = self.conversation_dir(conversation_id).resolve()
candidate = (root / _safe_relative_path(relative_path)).resolve()
if root != candidate and root not in candidate.parents:
raise ValueError("Attachment path escapes conversation directory")
return candidate
def write_conversation_upload(self, conversation_id: str, filename: str, data: bytes) -> tuple[str, Path]:
conversation = safe_conversation_id(conversation_id)
safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "_", Path(filename).name).strip("._") or "attachment"
relative = Path("uploads") / f"upload_{secrets.token_hex(6)}_{safe_name}"
target = self.conversation_attachment_path(conversation, relative.as_posix())
relative = (Path("uploads") / f"upload_{secrets.token_hex(6)}_{safe_name}").as_posix()
target = self.conversation_attachment_path(conversation, relative)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
return relative.as_posix(), target
return relative, target
def add_conversation_attachment(self, conversation_id: str, attachment: dict[str, Any]) -> dict[str, Any]:
conversation = safe_conversation_id(conversation_id)
@@ -166,522 +139,5 @@ class WorkspaceStore:
raise ValueError("Attachment already exists")
attachments.append(attachment)
record["updated_at"] = now_iso()
write_json(self.conversation_path(conversation), record)
_write_json(self.conversation_path(conversation), record)
return record
def ensure_task(self, task_id: str | None, request: str) -> dict[str, Any]:
tid = safe_task_id(task_id) if task_id else new_id("cad")
path = self.task_path(tid)
current = read_json(path)
if current:
return self._migrate_task(current, path)
task_dir = self.task_dir(tid)
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
source_requirements = str(request or "").strip()
source_path = task_dir / "source-requirements.md"
source_path.write_text(source_requirements + "\n", encoding="utf-8")
record = {
"schema_version": "2.0",
"task_id": tid,
"request": request,
"source_requirements_path": "source-requirements.md",
"created_at": now_iso(),
"updated_at": now_iso(),
"current_revision": "",
"active_revision": "",
"published_revision": "",
"lifecycle": "completed",
"run_id": "",
"requirements_path": "",
"completion_checklist_path": "",
"modeling_plan_path": "",
"modeling_plan_review_path": "",
"modeling_plan_version": 0,
"agent_state_path": "",
"active_candidate_id": "",
"active_branch_id": "main",
"run_failure_path": "",
"revisions": [],
}
write_json(path, record)
return record
def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]:
"""Fill in omitted fields for native autonomous tasks only.
A task from a retired protocol must never be made to look resumable by
rewriting its schema version. In particular, doing so would make an
old revision and its obsolete planning state appear to be a valid
autonomous work head. Historical tasks remain read-only records; the
service startup path marks only *running* legacy tasks as obsolete.
"""
if str(task.get("schema_version") or "") != "2.0":
return task
changed = False
current = str(task.get("current_revision") or "")
defaults = {
"active_revision": current,
"published_revision": current,
"lifecycle": "completed",
"run_id": "",
"requirements_path": "",
"source_requirements_path": "",
"completion_checklist_path": "",
"modeling_plan_path": "",
"modeling_plan_review_path": "",
"modeling_plan_version": 0,
"agent_state_path": "",
"active_candidate_id": "",
"active_branch_id": "main",
"run_failure_path": "",
}
for key, value in defaults.items():
if key not in task:
task[key] = value
changed = True
if not str(task.get("source_requirements_path") or ""):
relative = Path("source-requirements.md")
source_path = path.parent / relative
if not source_path.exists():
source_path.write_text(str(task.get("request") or "").strip() + "\n", encoding="utf-8")
task["source_requirements_path"] = relative.as_posix()
changed = True
for revision in task.get("revisions") or ():
if not isinstance(revision, dict):
continue
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}"
revision_dir = self.task_dir(task_id) / "revisions" / revision_id
revision_dir.mkdir(parents=True, exist_ok=False)
return revision_id, revision_dir
def update_task(self, task_id: str, revision: dict[str, Any]) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
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 update_task_fields(self, task_id: str, fields: dict[str, Any]) -> dict[str, Any]:
"""Update task metadata without appending a synthetic revision."""
task = self.ensure_task(task_id, "")
for key, value in fields.items():
task[str(key)] = deepcopy(value)
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:
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_candidate_id": "",
"run_failure_path": "",
"request": request or task.get("request") or "",
"active_revision": str(task.get("current_revision") or ""),
"active_branch_id": str(task.get("active_branch_id") or "main"),
"updated_at": now_iso(),
})
write_json(self.task_path(task_id), task)
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", "cancelled", "waiting_review", "failed_review_service"}:
raise ValueError("Generation lifecycle must be completed, failed, cancelled, waiting_review, or failed_review_service")
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_candidate_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"] = ""
if branch_id:
task["active_branch_id"] = branch_id
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_agent_state(self, task_id: str, values: dict[str, Any]) -> dict[str, Any]:
"""Persist enough autonomous-agent state to resume after a restart."""
task = self.ensure_task(task_id, "")
relative = Path("agent-state.json")
write_json(self.task_dir(task_id) / relative, values)
task["agent_state_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def read_agent_state(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("agent_state_path") or "")
value = read_json(self.artifact_path(task_id, relative)) if relative else None
return value if isinstance(value, dict) else None
def requirements_document_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "requirements.md"
def read_source_requirements(self, task_id: str) -> str:
"""Return the server-owned, immutable user request for this task.
Older tasks predate the artifact. Their persisted request is a
read-only compatibility fallback; new tasks always have the artifact.
"""
task = self.read_task(task_id) or {}
relative = str(task.get("source_requirements_path") or "")
if relative:
path = self.artifact_path(task_id, relative)
if path.is_file():
return path.read_text(encoding="utf-8")
return str(task.get("request") or "")
def write_requirements_document(self, task_id: str, markdown: str) -> Path:
"""Write the one immutable requirements document for an agent run."""
text = str(markdown or "").strip()
if not text:
raise ValueError("requirements.md must not be empty")
task = self.ensure_task(task_id, "")
relative = Path("requirements.md")
path = self.task_dir(task_id) / relative
if path.exists() or str(task.get("requirements_path") or ""):
raise ValueError("requirements.md is frozen and cannot be rewritten")
path.write_text(text + "\n", encoding="utf-8")
task["requirements_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_requirements_document(self, task_id: str) -> str:
task = self.read_task(task_id) or {}
relative = str(task.get("requirements_path") or "")
if not relative:
return ""
path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else ""
def completion_checklist_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "completion.md"
def write_completion_checklist(self, task_id: str, markdown: str) -> Path:
"""Persist the one immutable, author-owned completion checklist."""
text = str(markdown or "").strip()
if not text:
raise ValueError("completion.md must not be empty")
task = self.ensure_task(task_id, "")
if not self.read_requirements_document(task_id):
raise ValueError("requirements.md must be written before completion.md")
relative = Path("completion.md")
path = self.task_dir(task_id) / relative
if path.exists() or str(task.get("completion_checklist_path") or ""):
raise ValueError("completion.md is frozen and cannot be rewritten")
path.write_text(text + "\n", encoding="utf-8")
task["completion_checklist_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_completion_checklist(self, task_id: str) -> str:
task = self.read_task(task_id) or {}
relative = str(task.get("completion_checklist_path") or "")
if not relative:
return ""
path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else ""
def write_modeling_plan(self, task_id: str, markdown: str, *, version: int = 1) -> Path:
"""Persist one immutable, versioned modeling-plan document."""
text = str(markdown or "").strip()
if not text:
raise ValueError("modeling plan must not be empty")
task = self.ensure_task(task_id, "")
version = max(1, int(version))
relative = Path("plans") / f"modeling-plan-v{version}.md"
path = self.task_dir(task_id) / relative
if path.exists():
raise ValueError("modeling plan version already exists")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text + "\n", encoding="utf-8")
task["modeling_plan_path"] = relative.as_posix()
task["modeling_plan_version"] = version
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_modeling_plan(self, task_id: str) -> str:
task = self.read_task(task_id) or {}
relative = str(task.get("modeling_plan_path") or "")
if not relative:
return ""
path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else ""
def write_modeling_plan_review(self, task_id: str, review: dict[str, Any], *, version: int) -> Path:
task = self.ensure_task(task_id, "")
version = max(1, int(version))
relative = Path("plans") / f"modeling-plan-v{version}.review.json"
path = self.task_dir(task_id) / relative
if path.exists():
raise ValueError("modeling plan review already exists")
path.parent.mkdir(parents=True, exist_ok=True)
write_json(path, review)
task["modeling_plan_review_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_modeling_plan_review(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("modeling_plan_review_path") or "")
value = read_json(self.artifact_path(task_id, relative), {}) if relative else None
return value if isinstance(value, dict) else None
def new_candidate(self, task_id: str) -> tuple[str, Path]:
task = self.ensure_task(task_id, "")
candidate_id = f"candidate_{secrets.token_hex(8)}"
path = self.task_dir(task_id) / "candidates" / candidate_id
path.mkdir(parents=True, exist_ok=False)
task["active_candidate_id"] = candidate_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return candidate_id, path
def candidate_dir(self, task_id: str, candidate_id: str) -> Path:
if not CANDIDATE_ID.fullmatch(str(candidate_id or "")):
raise ValueError("Invalid candidate id")
return self.task_dir(task_id) / "candidates" / candidate_id
def clear_active_candidate(self, task_id: str, candidate_id: str | None = None) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
if candidate_id and str(task.get("active_candidate_id") or "") not in {"", candidate_id}:
raise ValueError("Candidate is not active")
task["active_candidate_id"] = ""
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def append_agent_audit(self, task_id: str, kind: str, payload: dict[str, Any]) -> str:
safe_kind = re.sub(r"[^a-z0-9_-]+", "-", str(kind).lower()).strip("-") or "event"
relative = Path("agent-audit") / f"{now_iso().replace(':', '-').replace('+', '_')}_{safe_kind}_{secrets.token_hex(4)}.json"
write_json(self.task_dir(task_id) / relative, {"recorded_at": now_iso(), "kind": safe_kind, **payload})
return relative.as_posix()
def update_revision_metadata(self, task_id: str, revision_id: str, values: dict[str, Any]) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
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.ensure_task(task_id, "")
source_branch_id = str(task.get("active_branch_id") or "main")
if revision_id:
target = next(
(item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id),
None,
)
if not isinstance(target, dict) or target.get("status") != "success":
raise ValueError("Active revision must be a successful revision")
children: dict[str, set[str]] = {}
for revision in task.get("revisions") or ():
if not isinstance(revision, dict):
continue
parent = str(revision.get("parent_revision_id") or "")
child = str(revision.get("revision_id") or "")
if child and str(revision.get("branch_id") or "main") == source_branch_id:
children.setdefault(parent, set()).add(child)
superseded: set[str] = set()
pending = list(children.get(revision_id, set()))
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["active_revision"] = revision_id
task["current_revision"] = revision_id
task["active_branch_id"] = branch_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
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 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 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("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"
return candidate if candidate.is_file() else None
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(
(item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id),
None,
)
if not isinstance(revision, dict):
return None
relative = str(revision.get("cdsl_path") or "")
if not relative:
return None
candidate = self.artifact_path(task_id, relative)
return candidate if candidate.is_file() else None
def artifact_path(self, task_id: str, relative_path: str) -> Path:
safe = safe_relative_path(relative_path)
root = self.task_dir(task_id).resolve()
target = (root / safe).resolve()
if root != target and root not in target.parents:
raise ValueError("Artifact path escapes task directory")
return target
def conversation_attachment_path(self, conversation_id: str, relative_path: str) -> Path:
safe = safe_relative_path(relative_path)
root = self.conversation_dir(conversation_id).resolve()
target = (root / safe).resolve()
if root != target and root not in target.parents:
raise ValueError("Attachment path escapes conversation directory")
return target
-591
View File
@@ -1,591 +0,0 @@
"""Independent, structured visual review of generated checkpoint renders."""
from __future__ import annotations
import base64
import copy
import json
import re
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,
},
},
}
CANDIDATE_REVIEW_TOOL = {
"type": "function",
"function": {
"name": "review_candidate_batch",
"description": "Independently review one staged CAD batch. Never author, modify, or approve CDSL outside this verdict.",
"parameters": {
"type": "object",
"properties": {
"verdict": {"enum": ["accept", "reject"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"batch_goal_status": {"enum": ["achieved", "partial", "failed"]},
"coverage": {
"type": "array",
"items": {
"type": "object",
"properties": {
"item": {"type": "string"},
"status": {"enum": ["complete", "pending", "regressed", "uncertain"]},
"evidence": {"type": "string"},
},
"required": ["item", "status", "evidence"],
"additionalProperties": False,
},
},
"evidence": {"type": "array", "items": {"type": "string"}, "maxItems": 12},
},
"required": ["verdict", "confidence", "batch_goal_status", "coverage", "evidence"],
"additionalProperties": False,
},
},
}
MODELING_PLAN_REVIEW_TOOL = {
"type": "function",
"function": {
"name": "review_modeling_plan",
"description": "Independently review a CAD modeling plan for requirement coverage, coherent step grouping, dependencies, and observable evidence. Never generate or modify CDSL.",
"parameters": {
"type": "object",
"properties": {
"verdict": {"enum": ["pass", "revise"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"issues": {"type": "array", "items": {"type": "object", "properties": {"type": {"type": "string"}, "step_id": {"type": "string"}, "message": {"type": "string"}}, "required": ["type", "message"], "additionalProperties": False}, "maxItems": 20},
"coverage": {"type": "array", "items": {"type": "object", "properties": {"requirement": {"type": "string"}, "step_id": {"type": "string"}, "status": {"enum": ["covered", "missing"]}, "evidence": {"type": "string"}}, "required": ["requirement", "step_id", "status", "evidence"], "additionalProperties": False}},
"step_checks": {"type": "array", "items": {"type": "object", "properties": {"step_id": {"type": "string"}, "status": {"enum": ["pass", "fail"]}, "notes": {"type": "string"}}, "required": ["step_id", "status", "notes"], "additionalProperties": False}},
"action_checks": {"type": "array", "items": {"type": "object", "properties": {"step_id": {"type": "string"}, "status": {"enum": ["pass", "fail"]}, "notes": {"type": "string"}, "required_action_count": {"type": "integer", "minimum": 1}}, "required": ["step_id", "status", "notes", "required_action_count"], "additionalProperties": False}},
},
"required": ["verdict", "confidence", "issues", "coverage", "step_checks", "action_checks"],
"additionalProperties": False,
},
},
}
class VisualReviewError(RuntimeError):
pass
class _CandidateReviewFormatError(VisualReviewError):
"""A locally detected invalid reviewer tool result, eligible for one retry."""
class _ModelingPlanReviewFormatError(VisualReviewError):
"""A locally detected invalid modeling-plan verdict, eligible for one retry."""
def _checklist_key(value: Any) -> str:
return " ".join(str(value or "").strip().split()).casefold()
def _looks_like_operation_id(value: str) -> bool:
token = str(value or "").strip().casefold()
if not token or "_" not in token:
return token in {"fillet", "chamfer"}
prefixes = ("extrude_", "revolve_", "hole_", "pattern_", "sphere_", "reference_", "cylinder_", "sweep_")
suffixes = ("_add", "_cut", "_blind", "_wizard", "_linear", "_circular", "_mirror")
return token.startswith(prefixes) or token.endswith(suffixes)
def _unsupported_operation_mentions(result: dict[str, Any], runtime_operations: list[dict[str, Any]]) -> list[str]:
"""Find capability-shaped names in reviewer prose that Runtime cannot execute."""
supported = {
str(item.get("atomic_id") or "").strip().casefold()
for item in runtime_operations
if isinstance(item, dict) and str(item.get("atomic_id") or "").strip()
}
mentions: set[str] = set()
for section in (result.get("issues"), result.get("coverage"), result.get("step_checks"), result.get("action_checks")):
if not isinstance(section, list):
continue
for row in section:
if not isinstance(row, dict):
continue
for value in row.values():
if not isinstance(value, str):
continue
for token in re.findall(r"(?<![A-Za-z0-9_])([A-Za-z][A-Za-z0-9_]*)(?![A-Za-z0-9_])", value):
normalized = token.casefold()
if normalized in supported or not _looks_like_operation_id(normalized):
continue
# Runtime capabilities are exact identifiers. A prefix
# or human shorthand (for example extrude_cut for
# extrude_cut_blind) is still invalid and must be revised.
mentions.add(token)
return sorted(mentions, key=str.casefold)
def _thinking_tool_choice_rejected(response: httpx.Response) -> bool:
"""Recognize the only compatibility error for which retrying is sound.
Some OpenAI-compatible reasoning endpoints expose function tools but
reject a forced function selection while thinking is enabled. Retrying
without ``tool_choice`` retains the tool contract and the local output
validation below; it merely lets that endpoint choose the sole available
review tool itself.
"""
return (
response.status_code == 400
and "thinking mode does not support this tool_choice" in response.text.lower()
)
def visual_review_tool() -> dict[str, Any]:
"""Normal function-call schema; local validation remains authoritative."""
return json.loads(json.dumps(VISUAL_REVIEW_TOOL))
def candidate_review_tool() -> dict[str, Any]:
return json.loads(json.dumps(CANDIDATE_REVIEW_TOOL))
def modeling_plan_review_tool() -> dict[str, Any]:
return json.loads(json.dumps(MODELING_PLAN_REVIEW_TOOL))
def _image_part(path: Path) -> dict[str, Any]:
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
media = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
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)
else:
selected.extend(by_id[view_id] for view_id in ("top", "front", "right", "isometric") if view_id in by_id)
return selected or views[:1]
async def review_checkpoint(
settings: Settings,
*,
manifest: dict[str, Any],
requirements: str | list[dict[str, Any]],
source_requirements: str = "",
node_id: str = "final",
deterministic_report: dict[str, Any],
source_images: list[Path] | None = None,
final_checkpoint: bool = False,
) -> 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,
"source_requirements": source_requirements,
"deterministic_report": deterministic_report,
"render_manifest": {
"renderer": manifest.get("renderer"),
"source": manifest.get("source"),
"views": [{"id": item.get("id"), "camera": item.get("camera"), "diagnostics": item.get("diagnostics")} for item in views],
},
"instruction": "Identify visible missing geometry, wrong silhouette, orientation, or proportion. First reconcile every claimed structural element with final_model_evidence: never claim a feature, mirrored copy, slot, cut, or chamfer exists when it is absent from that evidence. Do not infer hidden dimensions. Source requirements are the original user contract. Frozen requirements may add measurable assumptions but may never remove, replace, or weaken source requirements. Return pass only when both contracts are satisfied by the views and evidence; return warning or repair for any unsatisfied or downgraded source requirement.",
}, ensure_ascii=False),
}]
content.extend(_image_part(path) for path in paths)
# 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 = visual_review_tool()
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,
}
payload.update(provider.chat_completion_options)
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client:
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload)
if _thinking_tool_choice_rejected(response):
# Do not remove the tool itself and do not accept prose as a
# fallback. This compatibility retry is valid only because the
# reviewer receives one callable tool and its response is still
# verified by the strict local validator below.
payload.pop("tool_choice", None)
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload)
if response.status_code >= 400:
raise VisualReviewError(f"Visual review request failed ({response.status_code}): {response.text[:500]}")
try:
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
allowed_fields = {"verdict", "confidence", "affected_node_ids", "requirement_ids", "evidence"}
if not isinstance(result, dict) or set(result) != allowed_fields or result.get("verdict") not in {"pass", "warning", "repair"}:
raise VisualReviewError("Visual reviewer returned an invalid verdict")
try:
confidence = float(result.get("confidence"))
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}
async def review_candidate_batch(
settings: Settings,
*,
manifest: dict[str, Any],
requirements: str,
source_requirements: str = "",
checklist: list[str],
batch_goal: str,
deterministic_report: dict[str, Any],
node_id: str,
plan_step: dict[str, Any] | None = None,
plan_feature_ids: list[str] | None = None,
plan_action: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Review a staged batch against its local goal and the frozen checklist."""
provider, model = settings.resolve_review_model()
views = _selected_review_views(manifest, final_checkpoint=False)
paths = [Path(str(item.get("path") or "")) for item in views]
if not paths or not all(path.is_file() for path in paths):
raise VisualReviewError("Candidate review render manifest references missing image files")
content: list[dict[str, Any]] = [{
"type": "text",
"text": json.dumps({
"node_id": node_id,
"source_requirements": source_requirements,
"frozen_requirements": requirements,
"completion_checklist": checklist,
"batch_goal": batch_goal,
"plan_step": plan_step or None,
"plan_feature_ids": plan_feature_ids or [],
"plan_action": plan_action or None,
"deterministic_report": deterministic_report,
"instruction": (
"Assess this staged batch, not overall task completion. Accept only if the batch goal is achieved "
"without visibly contradicting source requirements, frozen requirements, or deterministic evidence. "
"Source requirements are the original user contract; frozen requirements may add measurable assumptions "
"but may not remove, replace, or weaken the source contract. Reject when a batch visibly downgrades or "
"contradicts source intent. An incomplete but coherent "
"intermediate model may be accepted when its batch goal is achieved. Return one coverage row for every "
"completion checklist item, preserving exact item text. Use pending for future work and regressed when "
"this batch breaks previously achieved work. Reject on disconnected geometry when the requirements call "
"for one body, wrong orientation, visibly wrong geometry, or a failed batch goal. "
"When plan_step is supplied, reject operations outside that step and confirm its planned feature goal. "
"A step may contain multiple related actions; when plan_action is supplied, assess only that action, confirm the candidate changes its target, and reject unrelated or multiple independent profiles."
),
"render_manifest": {
"renderer": manifest.get("renderer"),
"source": manifest.get("source"),
"views": [{"id": item.get("id"), "camera": item.get("camera")} for item in views],
},
}, ensure_ascii=False),
}]
content.extend(_image_part(path) for path in paths)
payload = {
"model": model.id,
"messages": [
{"role": "system", "content": "You are an independent CAD batch reviewer. You may only call review_candidate_batch."},
{"role": "user", "content": content},
],
"tools": [candidate_review_tool()],
"tool_choice": {"type": "function", "function": {"name": "review_candidate_batch"}},
"temperature": 0,
}
payload.update(provider.chat_completion_options)
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
async def request_review(client: httpx.AsyncClient, request_payload: dict[str, Any]) -> httpx.Response:
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload)
if _thinking_tool_choice_rejected(response):
request_payload.pop("tool_choice", None)
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload)
if response.status_code >= 400:
raise VisualReviewError(f"Candidate visual review request failed ({response.status_code}): {response.text[:500]}")
return response
def validate_response(response: httpx.Response) -> dict[str, Any]:
try:
call = response.json()["choices"][0]["message"]["tool_calls"][0]
if call["function"]["name"] != "review_candidate_batch":
raise KeyError("wrong tool")
result = json.loads(call["function"]["arguments"])
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
raise _CandidateReviewFormatError("Candidate reviewer did not return a valid review tool call") from error
allowed = {"verdict", "confidence", "batch_goal_status", "coverage", "evidence"}
if not isinstance(result, dict) or set(result) != allowed or result.get("verdict") not in {"accept", "reject"}:
raise _CandidateReviewFormatError("Candidate reviewer returned an invalid verdict")
try:
confidence = float(result.get("confidence"))
except (TypeError, ValueError) as error:
raise _CandidateReviewFormatError("Candidate reviewer returned an invalid confidence") from error
if not 0 <= confidence <= 1:
raise _CandidateReviewFormatError("Candidate reviewer confidence is outside [0, 1]")
if result.get("batch_goal_status") not in {"achieved", "partial", "failed"}:
raise _CandidateReviewFormatError("Candidate reviewer returned an invalid batch goal status")
if result.get("verdict") == "accept" and result.get("batch_goal_status") != "achieved":
raise _CandidateReviewFormatError("Candidate reviewer may accept only an achieved batch goal")
if not isinstance(result.get("evidence"), list) or not all(isinstance(item, str) for item in result["evidence"]):
raise _CandidateReviewFormatError("Candidate reviewer returned invalid evidence")
coverage = result.get("coverage")
if not isinstance(coverage, list) or len(coverage) != len(checklist):
raise _CandidateReviewFormatError("Candidate reviewer must return coverage for every checklist item")
expected = {item.casefold(): item for item in checklist}
seen: set[str] = set()
for item in coverage:
if not isinstance(item, dict) or set(item) != {"item", "status", "evidence"}:
raise _CandidateReviewFormatError("Candidate reviewer returned an invalid coverage row")
key = str(item.get("item") or "").casefold()
if key not in expected or key in seen:
raise _CandidateReviewFormatError("Candidate reviewer coverage does not match the frozen checklist")
if item.get("status") not in {"complete", "pending", "regressed", "uncertain"} or not str(item.get("evidence") or "").strip():
raise _CandidateReviewFormatError("Candidate reviewer returned an incomplete coverage row")
item["item"] = expected[key]
seen.add(key)
return result
async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client:
response = await request_review(client, payload)
try:
result = validate_response(response)
except _CandidateReviewFormatError as error:
retry_payload = copy.deepcopy(payload)
retry_payload["messages"].append({
"role": "user",
"content": (
"Your previous tool call was rejected by the local validator: "
f"{error}. Review the same rendered candidate again. Return only a complete "
"review_candidate_batch tool call with exactly one coverage row for every frozen checklist item."
),
})
result = validate_response(await request_review(client, retry_payload))
return {
"schema_version": "cad.candidate-review.v1",
"node_id": node_id,
"model": model.id,
"batch_goal": batch_goal,
**result,
}
async def review_modeling_plan(
settings: Settings,
*,
source_requirements: str,
requirements: str,
checklist: list[str],
plan: dict[str, Any],
runtime_operations: list[dict[str, Any]],
node_id: str = "modeling-plan",
) -> dict[str, Any]:
"""Ask an independent model to simulate and review a modeling plan."""
provider, model = settings.resolve_review_model()
payload_context = {
"node_id": node_id,
"source_requirements": source_requirements,
"frozen_requirements": requirements,
"completion_checklist": checklist,
"modeling_plan": plan,
"runtime_operations": runtime_operations,
"instruction": (
"Review the plan only; do not generate CDSL. Treat modeling_plan.plan_text as semantic "
"guidance, not a schema: missing structures/features arrays or machine IDs are not by "
"themselves failures. Infer intended structures, ordering, relationships, and evidence "
"from the prose. Check every checklist item, dependency order, step cohesion, topology "
"preconditions, Runtime feasibility, action granularity, and observable evidence. A semantic "
"step may contain related or repeated targets, but each independently located profile "
"or Runtime feature must be enumerated as a separate action within that same step. Each action "
"must be executable as one Runtime feature. A multi-position hole or supported pattern may remain "
"one action when all positions share the same host and operation contract. Treat any supplied "
"action records as execution units, and set required_action_count to the minimum number the step's "
"semantics require. Return fail when the plan contains fewer actions than that number. Return pass when the plan "
"is actionable enough for an LLM to generate CDSL in coherent batches. Do not require "
"colors or materials when runtime_operations does not provide them; accept a geometrically "
"equivalent ring, groove, or separated feature and mention the limitation only as guidance. "
"Only return revise for a real requirement omission, contradictory geometry, unsafe ordering, "
"an operation name that is not present in runtime_operations, or an operation that cannot plausibly be implemented. "
"Do not downgrade an unsupported operation to vague guidance. If a machine operation is mentioned, it must be an exact ID from runtime_operations; otherwise return revise and describe the semantic intent without inventing an operation name. "
"Return exactly one coverage row per checklist item."
),
}
payload = {
"model": model.id,
"messages": [
{"role": "system", "content": "You are an independent CAD modeling-plan reviewer. You may only call review_modeling_plan."},
{"role": "user", "content": json.dumps(payload_context, ensure_ascii=False)},
],
"tools": [modeling_plan_review_tool()],
"tool_choice": {"type": "function", "function": {"name": "review_modeling_plan"}},
"temperature": 0,
}
payload.update(provider.chat_completion_options)
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
async def request_review(client: httpx.AsyncClient, request_payload: dict[str, Any]) -> httpx.Response:
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload)
if _thinking_tool_choice_rejected(response):
request_payload.pop("tool_choice", None)
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload)
if response.status_code >= 400:
raise VisualReviewError(f"Modeling plan review request failed ({response.status_code}): {response.text[:500]}")
return response
def validate_response(response: httpx.Response) -> dict[str, Any]:
try:
call = response.json()["choices"][0]["message"]["tool_calls"][0]
if call["function"]["name"] != "review_modeling_plan":
raise KeyError("wrong tool")
result = json.loads(call["function"]["arguments"])
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer did not return a valid review tool call") from error
allowed = {"verdict", "confidence", "issues", "coverage", "step_checks", "action_checks"}
if not isinstance(result, dict) or set(result) != allowed or result.get("verdict") not in {"pass", "revise"}:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid verdict")
try:
confidence = float(result.get("confidence"))
except (TypeError, ValueError) as error:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid confidence") from error
if not 0 <= confidence <= 1:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer confidence is outside [0, 1]")
if not isinstance(result.get("issues"), list) or not all(isinstance(item, dict) for item in result["issues"]):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned invalid issues")
expected = {_checklist_key(item): item for item in checklist}
coverage = result.get("coverage")
if not isinstance(coverage, list) or len(coverage) != len(checklist):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return coverage for every checklist item")
seen: set[str] = set()
for item in coverage:
if not isinstance(item, dict) or set(item) != {"requirement", "step_id", "status", "evidence"}:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid coverage row")
key = _checklist_key(item.get("requirement"))
if key not in expected or key in seen or item.get("status") not in {"covered", "missing"} or not str(item.get("evidence") or "").strip():
raise _ModelingPlanReviewFormatError("Modeling plan reviewer coverage does not match the frozen checklist")
item["requirement"] = expected[key]
seen.add(key)
step_records = {
str(item.get("step_id") or ""): item
for item in plan.get("steps") or () if isinstance(item, dict)
}
steps = set(step_records)
checks = result.get("step_checks")
if not isinstance(checks, list) or len(checks) != len(steps):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return one step check per plan step")
checked: set[str] = set()
for item in checks:
if not isinstance(item, dict) or set(item) != {"step_id", "status", "notes"}:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid step check")
step_id = str(item.get("step_id") or "")
if step_id not in steps or step_id in checked or item.get("status") not in {"pass", "fail"}:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer step checks do not match the plan")
checked.add(step_id)
action_checks = result.get("action_checks")
if not isinstance(action_checks, list) or len(action_checks) != len(steps):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer must return one action check per plan step")
action_checked: set[str] = set()
for item in action_checks:
if not isinstance(item, dict) or set(item) != {"step_id", "status", "notes", "required_action_count"}:
raise _ModelingPlanReviewFormatError("Modeling plan reviewer returned an invalid action check")
step_id = str(item.get("step_id") or "")
required_count = item.get("required_action_count")
if (
step_id not in steps
or step_id in action_checked
or item.get("status") not in {"pass", "fail"}
or not isinstance(required_count, int)
or isinstance(required_count, bool)
or required_count < 1
):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer action checks do not match the plan")
planned_actions = [
action for action in (step_records[step_id].get("actions") or ())
if isinstance(action, dict)
]
if required_count > len(planned_actions) and item.get("status") != "fail":
raise _ModelingPlanReviewFormatError(
"Modeling plan reviewer must fail an action check when required_action_count exceeds the plan's action count"
)
action_checked.add(step_id)
if result["verdict"] == "pass" and any(item.get("status") != "covered" for item in coverage):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every checklist item is covered")
if result["verdict"] == "pass" and any(item.get("status") != "pass" for item in checks):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every step check passes")
if result["verdict"] == "pass" and any(item.get("status") != "pass" for item in action_checks):
raise _ModelingPlanReviewFormatError("Modeling plan reviewer may pass only when every action check passes")
unsupported = _unsupported_operation_mentions(result, runtime_operations)
if unsupported:
# These strings came from the reviewer's explanatory prose, not
# from the submitted plan. The plan parser enforces exact IDs for
# explicit operation declarations; do not reject an otherwise
# valid semantic plan because the reviewer hallucinated a
# shorthand while describing an alternative. Keep this warning in
# the raw audit record and scrub it before author exposure.
result["unsupported_operations"] = unsupported
result["review_warnings"] = [
"Reviewer mentioned operation name(s) absent from runtime_operations; those names were ignored as guidance."
]
return result
async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client:
response = await request_review(client, payload)
try:
result = validate_response(response)
except _ModelingPlanReviewFormatError as error:
retry_payload = copy.deepcopy(payload)
retry_payload["messages"].append({"role": "user", "content": f"Your previous review was rejected locally: {error}. Return only a complete review_modeling_plan tool call with one coverage row per checklist item and one step_checks and action_checks row per plan step."})
result = validate_response(await request_review(client, retry_payload))
return {"schema_version": "cad.modeling-plan-review.v1", "node_id": node_id, "model": model.id, **result}