wip: preserve local engine and agent workflow changes before ganjihong refactor integration

This commit is contained in:
2026-09-09 17:08:24 +08:00
parent a5c3ec1f36
commit 387b99e255
112 changed files with 3543 additions and 12177 deletions
+105 -440
View File
@@ -1,5 +1,4 @@
"""HTTP/SSE delivery adapter for the protocol v3 workflow."""
"""HTTP/SSE delivery adapter for the single-stage CAD protocol."""
from __future__ import annotations
import asyncio
@@ -10,8 +9,7 @@ import secrets
from typing import Any
from app.cad_agent.application.workflow import ModelIdentity
from app.cad_agent.application.capabilities import cached_model_capability, verify_model_capability
from app.cad_agent.composition import V3Services, compose_v3
from app.cad_agent.composition import CadServices, compose_cad_services
from app.cad_agent.domain.errors import ErrorCode
from app.cad_agent.domain.state import TaskPhase, transition
from app.models.contracts import ChatMessage
@@ -25,480 +23,174 @@ def text_from_message(message: ChatMessage) -> str:
return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip()
def _response_language(text: str) -> str:
cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff")
latin = sum(1 for char in text if char.isascii() and char.isalpha())
return "Chinese" if cjk >= 2 and cjk >= latin * 0.15 else "English"
_EVENT_LABELS = {
"image_observation": "参考图片观察",
"requirements_document_ready": "需求文档已冻结",
"completion_target_ready": "完成目标已冻结",
"requirements_compiled": "需求合同已编译",
"modeling_plan_ready": "建模计划已冻结",
"completion_result_ready": "完成结果已就绪",
"model_protocol_check": "模型协议检查",
"action_selection": "动作选择",
"tool_call": "建模工具",
"candidate_result": "候选构建",
"candidate_review": "候选独立复核",
"final_review": "最终独立复核",
"requirements_ready": "需求分析",
"authoring_cdsl_ready": "完整 CDSL",
"cdsl_compiled": "CDSL 编译",
"build_result": "CAD 构建",
"repair_started": "CDSL 修复",
"task_terminal": "生成任务",
"state_changed": "任务状态",
}
def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
def _progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
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")
status = "error" if lifecycle == "failed" or payload.get("status") in {"failed", "repair_required"} else "waiting" if lifecycle == "waiting_for_user" else "success" if lifecycle == "completed" else "running"
return {**payload, "step": name, "label": _EVENT_LABELS.get(name, name), "status": status}
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):
if str(existing.get("id") or "") == part_id:
parts[index] = part
return
parts.append(part)
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 # Conversation/attachment store, not v3 CAD state.
self.library = library
self.v3: V3Services = compose_v3(settings)
self.settings, self.store, self.library = settings, store, library
self.cad: CadServices = compose_cad_services(settings)
self._autonomous_runs: dict[str, asyncio.Task[None]] = {}
async def resume_running_tasks(self) -> None:
task_ids = self.v3.repository.running_task_ids()
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, reviewer_capability = await asyncio.gather(
verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=author_provider.id,
model_id=author_model.id,
role="author",
),
verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=review_provider.id,
model_id=review_model.id,
role="reviewer",
),
)
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 author_capability.get("probe_unavailable") or reviewer_capability.get("probe_unavailable"):
await self._park_startup_tasks(
task_ids,
ErrorCode.MODEL_PROTOCOL_CHECK_PENDING,
"Model protocol check is temporarily unavailable.",
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
if not self.settings.resume_running_tasks_on_startup:
return
for task_id in task_ids:
if task_id in self._autonomous_runs:
continue
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:
next_state = transition(
state,
"waiting_retry" if retryable else "failed",
error=error,
)
except ValueError:
continue
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_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)
provider, model = self.settings.resolve_model(None, None)
except ValueError:
return
author = ModelIdentity(provider.id, model.id)
for task_id in self.cad.repository.running_task_ids():
if task_id not in self._autonomous_runs:
self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, author), name=f"resume-cad-single-stage-{task_id}")
async def _consume_discarding(self, task_id: str, author: ModelIdentity) -> None:
try:
async for _name, _payload in self.cad.workflow.run(task_id=task_id, author=author):
await self.cad.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)
await self.cad.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:
state = self.v3.repository.get_state(task_id)
state = self.cad.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.
self.cad.repository.compare_and_swap(cancelled, events=[{"event": "task_cancelled", "active_revision": state.active_revision}])
await self.cad.outbox.dispatch_pending(task_id=task_id)
running = self._autonomous_runs.pop(task_id, None)
if running and not running.done():
running.cancel()
return self.v3.repository.get_task_projection(task_id)
return self.cad.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)
state = self.cad.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():
if state.phase != TaskPhase.FAILED or state.retry_from_phase is None:
raise ValueError("Only a retryable failed CAD task can be resumed")
if task_id in self._autonomous_runs and not self._autonomous_runs[task_id].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,
role="author",
)
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,
role="reviewer",
)
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)
provider, model = self.settings.resolve_model(None, None)
if not self.cad.workflow.resume(task_id):
raise ValueError("The CAD task no longer has a retry checkpoint")
self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, ModelIdentity(provider.id, model.id)), name=f"resume-cad-single-stage-{task_id}")
return self.cad.repository.get_task_projection(task_id)
async def stream(
self,
messages: list[ChatMessage],
conversation_id: str | None,
selected_task_id: str | None,
provider_id: str | None = None,
model_id: str | None = None,
viewer_context: list[dict[str, Any]] | None = None,
) -> AsyncIterator[bytes]:
async def stream(self, messages: list[ChatMessage], conversation_id: str | None, selected_task_id: str | None, provider_id: str | None = None, model_id: str | None = None, viewer_context: list[dict[str, Any]] | None = None) -> AsyncIterator[bytes]:
del viewer_context
latest_user = next((message for message in reversed(messages) if message.role == "user"), None)
if latest_user is None or not text_from_message(latest_user):
latest = next((item for item in reversed(messages) if item.role == "user"), None)
if latest is None or not text_from_message(latest):
yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."})
yield event("done", {})
return
request = text_from_message(latest_user)
request = text_from_message(latest)
conversation = self.store.ensure_conversation(conversation_id)
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": "/requirements/clarification", "message": str(question)}
for question in terminal.get("questions") or ()
if str(question).strip()
]
fields.extend(
{"path": "/requirements", "message": str(issue)}
for issue in terminal.get("issues") or ()
if str(issue).strip()
)
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
if current and str(current.get("lifecycle") or "") == "running":
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
current = self.cad.repository.get_task_projection(selected) if selected else None
resumed = False
if current and current.get("lifecycle") == "waiting_for_user":
resumed = self.cad.workflow.resume_with_user_clarification(selected, request, message_id=latest.id)
if not resumed:
yield event("cad_error", {"stage": "request", "message": "The CAD task cannot apply this clarification."})
yield event("done", {})
return
if current and current.get("lifecycle") == "running":
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成后才能继续。"})
yield event("done", {})
return
# A terminal task is immutable; follow-up text creates a new task.
task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}"
if not resumed_task_id:
task_id = selected if resumed else f"cad_{secrets.token_hex(6)}"
if not resumed:
try:
source_blocks, image_inputs = self._task_inputs(conversation, request)
self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
self.cad.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
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."})
self.store.append_conversation_message(conversation["conversation_id"], latest.model_dump(), task_id)
yield event("progress", _progress("task_started", {"taskId": task_id, "message": "CAD task started."}))
try:
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)
provider, model = self.settings.resolve_model(provider_id, model_id)
except ValueError as error:
state = self.v3.repository.get_state(task_id)
if state is not None:
state = self.cad.repository.get_state(task_id)
if state:
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
self.v3.repository.compare_and_swap(failed, events=[{
"event": "model_configuration_invalid",
"message": str(error)[:1000],
"issues": [str(error)[:1000]],
}])
terminal = {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": str(error), "userActionRequired": False}
yield event("task_terminal", terminal)
self.cad.repository.compare_and_swap(failed, events=[{"event": "model_configuration_invalid", "message": str(error)}])
yield event("cad_error", {"stage": "configuration", "message": str(error)})
yield event("done", {})
return
author = ModelIdentity(provider.id, model.id)
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
parts: list[dict[str, Any]] = []
sequence = 0
async def consume() -> None:
nonlocal sequence
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
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(),
"outboxEvent": event_name,
"message": event_name.replace("_", " "),
}
await queue.put(("progress", _visible_progress("state_changed", payload)))
try:
capability_terminal = await self._ensure_task_capabilities(
task_id,
ModelIdentity(author_provider.id, author_model.id),
ModelIdentity(review_provider.id, review_model.id),
queue,
)
if capability_terminal is not None:
sequence += 1
capability_terminal = {**capability_terminal, "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso()}
_upsert_part(parts, {"type": "data-cad-progress", "id": capability_terminal["eventId"], "data": _visible_progress("task_terminal", capability_terminal)})
await queue.put(("task_terminal", capability_terminal))
return
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 [],
}})
async for name, payload in self.cad.workflow.run(task_id=task_id, author=author):
decorated = {**payload, "taskId": task_id, "eventId": f"{task_id}_{secrets.token_hex(4)}", "timestamp": now_iso()}
parts.append({"type": "data-cad-progress", "id": decorated["eventId"], "data": _progress(name, decorated)})
await queue.put((name, decorated))
await dispatch_state_events()
except Exception as error:
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))
await self.cad.outbox.dispatch_pending(task_id=task_id)
if name == "task_terminal" and decorated.get("lifecycle") == "completed":
projection = self.cad.repository.get_task_projection(task_id) or {}
result = self._result_payload(task_id, projection)
if result is not None:
await queue.put(("cad_result", result))
finally:
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-cad-v3-{task_id}")
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"cad-single-stage-{task_id}")
while True:
try:
item = await asyncio.wait_for(queue.get(), timeout=15)
except asyncio.TimeoutError:
yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()})
continue
item = await queue.get()
if item is None:
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 autonomous generation failed."),
"tool": str(payload.get("tool") or ""),
"fieldErrors": payload.get("field_errors") if isinstance(payload.get("field_errors"), list) else [],
})
if name == "task_terminal" and payload.get("lifecycle") == "failed":
yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD generation failed."), "code": payload.get("code")})
yield event(name, payload)
yield event("done", {})
async def _ensure_task_capabilities(
self,
task_id: str,
author: ModelIdentity,
reviewer: ModelIdentity,
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None],
) -> dict[str, Any] | None:
roles = (("author", author), ("reviewer", reviewer))
cached = {
role: cached_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
provider_id=model.provider_id,
model_id=model.model_id,
role=role,
)
for role, model in roles
}
unsupported = [role for role, result in cached.items() if result is not None and not result.get("supported")]
if unsupported:
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
missing = [(role, model) for role, model in roles if cached[role] is None]
if not missing:
@staticmethod
def _result_payload(task_id: str, projection: dict[str, Any]) -> dict[str, Any] | None:
revision_id = str(projection.get("published_revision") or projection.get("active_revision") or "")
revisions = projection.get("revisions") if isinstance(projection.get("revisions"), list) else []
revision = next((item for item in revisions if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
if not isinstance(revision, dict):
return None
state = self.v3.repository.get_state(task_id)
if state is None:
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "Task state is unavailable.", "userActionRequired": False}
waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING)
if not self.v3.repository.compare_and_swap(waiting, events=[{
"event": "model_protocol_check_pending",
"code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value,
"message": "模型协议检查中,完成后将自动继续。",
}]):
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STALE_WORKING_HEAD.value, "message": "Task state changed before the model protocol check started.", "userActionRequired": False}
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "waiting", "lifecycle": "waiting_retry", "message": "模型协议检查中,完成后将自动继续。"}))
try:
results = await asyncio.gather(*(
verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=model.provider_id,
model_id=model.model_id,
role=role,
)
for role, model in missing
))
except Exception as error:
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用:{str(error)[:500]}", "userActionRequired": False}
unavailable = [role for (role, _model), result in zip(missing, results, strict=True) if result.get("probe_unavailable")]
if unavailable:
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用({', '.join(unavailable)}),可稍后重试。", "userActionRequired": False}
unsupported = [role for (role, _model), result in zip(missing, results, strict=True) if not result.get("supported")]
if unsupported:
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
if not self.v3.workflow.resume(task_id):
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Model protocol check completed but the task could not resume.", "userActionRequired": False}
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "success", "lifecycle": "running", "message": "模型协议检查完成,继续生成。"}))
return None
def _fail_capability(self, task_id: str, message: str) -> dict[str, Any]:
state = self.v3.repository.get_state(task_id)
if state is not None and state.phase not in {TaskPhase.FAILED, TaskPhase.COMPLETED, TaskPhase.CANCELLED}:
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
self.v3.repository.compare_and_swap(failed, events=[{
"event": "model_protocol_unsupported",
"message": message,
"issues": [message],
}])
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": message, "userActionRequired": False}
required = ("cdsl_path", "step_path", "glb_path", "report_path")
if not all(isinstance(revision.get(path), str) and revision[path] for path in required):
return None
return {
"taskId": task_id,
"revisionId": revision_id,
"cdslPath": revision["cdsl_path"],
"stepPath": revision["step_path"],
"glbPath": revision["glb_path"],
"reportPath": revision["report_path"],
"summary": str(revision.get("summary") or "CDSL CAD model"),
"referenceIds": list(revision.get("reference_ids") or []),
"engine": str(revision.get("engine") or "cdsl_only"),
"lifecycle": str(projection.get("lifecycle") or "completed"),
}
def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
"""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()]
image_inputs: list[dict[str, str]] = []
conversation_id = str(conversation.get("conversation_id") or "")
@@ -509,51 +201,24 @@ class AgentService:
continue
if str(attachment.get("conversation_id") or "") != conversation_id:
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):
attachment_id, relative_path, digest = str(attachment.get("id") or ""), str(attachment.get("path") or ""), str(attachment.get("sha256") or "")
if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", 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}")
binary = self.store.conversation_attachment_path(conversation_id, relative_path)
if not binary.is_file() or sha256(binary.read_bytes()).hexdigest() != digest:
raise ValueError(f"Attachment is unavailable: {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)
text_path = self.store.conversation_attachment_path(conversation_id, str(attachment.get("extracted_path") or ""))
if not text_path.is_file():
raise ValueError(f"Attachment text is missing: {attachment.get('name') or attachment_id}")
raise ValueError(f"Attachment text is unavailable: {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."
)
image_inputs.append({
"path": str(binary_path),
"mime": str(attachment.get("mime") or "image/*"),
"sha256": expected_digest,
})
text = f"Visual attachment {attachment.get('name') or attachment_id} (SHA-256 {digest})."
image_inputs.append({"path": str(binary), "mime": str(attachment.get("mime") or "image/*"), "sha256": digest})
else:
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,
},
})
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": digest}})
return blocks, image_inputs
+17 -3
View File
@@ -47,7 +47,7 @@ def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]:
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("operation_contracts"), dict):
raise RuntimeError("The local engine schema has no v3 operation contract registry")
raise RuntimeError("The local engine schema has no operation contract registry")
return schema
@@ -108,10 +108,11 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
def validate_cdsl_shape(cdsl: dict[str, Any], engine: Any) -> None:
"""Validate the static Runtime CDSL contract without resolving topology."""
if not isinstance(cdsl, dict):
raise ValueError("CDSL must be a JSON object")
if cdsl.get("schema") != "cad.cdsl.llm.v1":
if cdsl.get("schema") not in {"cad.cdsl.llm.v1", "cad.runtime.v1"}:
raise ValueError("Unsupported CDSL schema")
_validate_cdsl_json_schema(cdsl, engine)
part_id = str(cdsl.get("part_id") or "")
@@ -125,6 +126,14 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
sketches = cdsl.get("geometry", {}).get("sketches")
if not isinstance(features, list) or not features or not isinstance(sketches, list):
raise ValueError("CDSL requires a feature list and a geometry.sketches array")
if cdsl.get("schema") == "cad.runtime.v1":
bodies = cdsl.get("bodies")
if not isinstance(bodies, list) or not bodies:
raise ValueError("Runtime CDSL requires server-assigned bodies")
body_ids = [str(body.get("id") or "") for body in bodies if isinstance(body, dict)]
body_names = [str(body.get("name") or "") for body in bodies if isinstance(body, dict)]
if len(body_ids) != len(bodies) or len(body_ids) != len(set(body_ids)) or len(body_names) != len(set(body_names)):
raise ValueError("Runtime CDSL bodies must have unique server IDs and local names")
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
semantic_contract = _engine_schema(engine)
try:
@@ -185,6 +194,11 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
raise ValueError("Polygon profiles require vertices")
elif profile_type not in engine.SHAPE_GENERATORS:
raise ValueError(f"Unsupported CDSL profile: {profile_type}")
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
"""Validate static CDSL and the current executable topology semantics."""
validate_cdsl_shape(cdsl, engine)
try:
analysis = engine.analyze_cdsl(copy.deepcopy(cdsl))
except Exception as error:
@@ -1,8 +1,8 @@
"""Deterministic, CPU-only CAD technical renders for visual review.
"""Deterministic, CPU-only CAD technical render bundles.
OpenCascade computes exact visible/hidden edges from the revision STEP file.
Pillow rasterizes the resulting technical drawings. Neither stage needs a web
browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence
browser, OpenGL, a desktop session, nor a GPU, which keeps published artifacts
consistent on macOS, Linux, and Windows workers.
"""
@@ -26,7 +26,7 @@ VISIBLE_EDGE_RGB = (34, 54, 69)
HIDDEN_EDGE_RGB = (142, 157, 170)
class ReviewRenderError(RuntimeError):
class RenderBundleError(RuntimeError):
"""The fixed-view renderer was unavailable or produced incomplete evidence."""
@@ -34,7 +34,7 @@ def renderer_status() -> tuple[bool, str]:
"""Verify that the pure-Python/OCC renderer dependencies are importable."""
try:
_render_modules()
except ReviewRenderError as error:
except RenderBundleError as error:
return False, str(error)
return True, ""
@@ -45,7 +45,7 @@ def _render_modules() -> tuple[Any, Any, Any]:
pillow_draw = importlib.import_module("PIL.ImageDraw")
import_step = importlib.import_module("build123d").import_step
except (ImportError, AttributeError) as error:
raise ReviewRenderError(
raise RenderBundleError(
"Python technical renderer is unavailable; install backend requirements (build123d and Pillow)"
) from error
return pillow_image, pillow_draw, import_step
@@ -73,7 +73,7 @@ def _shape_bounds(shape: Any) -> list[float]:
box = shape.bounding_box()
bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)]
if not all(math.isfinite(value) for value in bounds):
raise ReviewRenderError("STEP review source has invalid bounds")
raise RenderBundleError("STEP render source has invalid bounds")
return bounds
@@ -128,7 +128,7 @@ def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]:
def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]:
points = [point for edge in edges for point in _edge_points(edge, 0.5)]
if not points:
raise ReviewRenderError("Hidden-line projection produced no drawable edges")
raise RenderBundleError("Hidden-line projection produced no drawable edges")
xs, ys = zip(*points)
return min(xs), max(xs), min(ys), max(ys)
@@ -242,7 +242,7 @@ def _render_view(
camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"]
)
except Exception as error:
raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
raise RenderBundleError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
visible_edges, hidden_edges = list(visible), list(hidden)
frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent)
rendered = _rasterize(
@@ -254,12 +254,12 @@ def _render_view(
intentional_crop=target_extent > 0,
)
if not rendered["diagnostics"]["valid"]:
raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
raise RenderBundleError(f"Render bundle quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered}
def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str:
"""Create compact whole-model evidence for routine reviewer calls."""
"""Create compact whole-model images for published CAD artifacts."""
pillow_image, pillow_draw, _ = _render_modules()
canonical = [item for item in views if item["id"] in CANONICAL_VIEWS]
if not canonical:
@@ -283,27 +283,27 @@ def render_checkpoint(
*,
step_path: Path,
output_dir: Path,
review_targets: list[dict[str, Any]] | None = None,
detail_targets: list[dict[str, Any]] | None = None,
include_canonical: bool = True,
) -> dict[str, Any]:
"""Render STEP geometry into stable canonical and bounded node-detail views."""
del settings
ready, detail = renderer_status()
if not ready:
raise ReviewRenderError(detail)
raise RenderBundleError(detail)
if not step_path.is_file():
raise ReviewRenderError(f"STEP review source is missing: {step_path.name}")
raise RenderBundleError(f"STEP render source is missing: {step_path.name}")
_, _, import_step = _render_modules()
try:
shape = import_step(str(step_path))
except Exception as error:
raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error
raise RenderBundleError(f"Unable to read STEP render source: {error}") from error
bounds = _shape_bounds(shape)
output_dir.mkdir(parents=True, exist_ok=True)
jobs: list[tuple[str, dict[str, Any] | None]] = []
if include_canonical:
jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS)
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3]))
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((detail_targets or [])[:3]))
views = [
_render_view(
shape=shape,
@@ -317,14 +317,14 @@ def render_checkpoint(
]
canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")}
if include_canonical and canonical != set(CANONICAL_VIEWS):
raise ReviewRenderError("Python review renderer did not produce every canonical view")
raise RenderBundleError("Python render bundle generator did not produce every canonical view")
contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else ""
manifest = {
"schema_version": "cad.render-manifest.v2",
"renderer": "python-occ-hlr-pillow",
"source": {"type": "step", "path": str(step_path), "bounds_mm": bounds},
"high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"},
"review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
"render_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
"contact_sheet_path": contact_sheet_path,
"views": views,
}
@@ -343,22 +343,22 @@ def render_section(
"""Create an actual OpenCascade section drawing, not a clipped viewport.
It intentionally uses the same deterministic Pillow raster path as the
seven canonical review views. The output contains compact contour evidence
suitable for a multimodal author without sending a STEP file or full B-rep.
seven canonical render views. The output contains compact contour evidence
suitable for inspection without sending a STEP file or full B-rep.
"""
del settings
ready, detail = renderer_status()
if not ready:
raise ReviewRenderError(detail)
raise RenderBundleError(detail)
if not step_path.is_file():
raise ReviewRenderError(f"STEP section source is missing: {step_path.name}")
raise RenderBundleError(f"STEP section source is missing: {step_path.name}")
origin = _number_list(origin_mm, size=3)
direction = _number_list(normal, size=3)
if origin is None or direction is None:
raise ReviewRenderError("Section origin_mm and normal must each contain three finite numbers")
raise RenderBundleError("Section origin_mm and normal must each contain three finite numbers")
length = math.sqrt(sum(value * value for value in direction))
if length <= 1e-9:
raise ReviewRenderError("Section normal must not be zero")
raise RenderBundleError("Section normal must not be zero")
normal_unit = [value / length for value in direction]
_, _, import_step = _render_modules()
try:
@@ -372,9 +372,9 @@ def render_section(
section = b3d.section(shape, section_by=plane)
edges = list(section.edges())
except Exception as error:
raise ReviewRenderError(f"OpenCascade section operation failed: {error}") from error
raise RenderBundleError(f"OpenCascade section operation failed: {error}") from error
if not edges:
raise ReviewRenderError("Section plane does not intersect the model")
raise RenderBundleError("Section plane does not intersect the model")
# Choose a deterministic right-handed in-plane frame. Projecting exact
# OCC section edges into this frame preserves holes and internal contours.
@@ -406,7 +406,7 @@ def render_section(
if len(line) >= 2:
projected.append(line)
if not projected:
raise ReviewRenderError("Section operation produced no drawable contours")
raise RenderBundleError("Section operation produced no drawable contours")
xs = [point[0] for line in projected for point in line]
ys = [point[1] for line in projected for point in line]
minimum_x, maximum_x, minimum_y, maximum_y = min(xs), max(xs), min(ys), max(ys)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Conversation and attachment storage for the v3 delivery boundary.
"""Conversation and attachment storage for the CAD delivery boundary.
CAD task state deliberately does not live here. Protocol v3 owns mutable task
CAD task state deliberately does not live here. The Authoring protocol owns mutable task
state in ``SqliteTaskRepository`` and immutable task artifacts in
``FileArtifactStore``.
"""