重构 改为标准化输出
This commit is contained in:
@@ -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
|
||||
@@ -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", [])],
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user