1645 lines
81 KiB
Python
1645 lines
81 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
from copy import deepcopy
|
|
import json
|
|
import math
|
|
import re
|
|
import secrets
|
|
import sys
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from app.models.contracts import ChatMessage
|
|
from app.services.engine_service import build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl
|
|
from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch
|
|
from app.services.library import CdslLibrary
|
|
from app.services.part_skills import PartSkillLibrary
|
|
from app.services.quality import QUALITY_RULE_TYPES, validate_verification
|
|
from app.services.sse import event
|
|
from app.services.storage import WorkspaceStore, now_iso
|
|
from app.settings import ProviderConfig, ProviderModel, Settings
|
|
|
|
|
|
class ToolArgumentsError(ValueError):
|
|
"""A model returned function-call arguments that are not one JSON object."""
|
|
|
|
|
|
class StrictToolSchemaError(RuntimeError):
|
|
"""The selected endpoint rejected an explicitly enabled strict schema."""
|
|
|
|
|
|
class RepeatedToolArgumentsError(RuntimeError):
|
|
"""The model failed to emit valid function arguments after a retry."""
|
|
|
|
def __init__(self, message: str, diagnostic_paths: list[str] | None = None) -> None:
|
|
super().__init__(message)
|
|
self.diagnostic_paths = diagnostic_paths or []
|
|
|
|
|
|
class CdslRepairLimitError(RuntimeError):
|
|
"""The direct CDSL repair budget is exhausted for this request."""
|
|
|
|
def __init__(self, diagnostic_paths: list[str]) -> None:
|
|
super().__init__("CDSL repair limit reached")
|
|
self.diagnostic_paths = diagnostic_paths
|
|
|
|
|
|
def user_visible_error_message(error: Exception, user_text: str) -> str:
|
|
if isinstance(error, StrictToolSchemaError) and any(
|
|
"\u4e00" <= char <= "\u9fff" for char in str(user_text or "")
|
|
):
|
|
return (
|
|
"所选模型不支持严格 CDSL 工具 schema。请在 backend/.env 中关闭该供应商的 "
|
|
"CDSL_*_STRICT_TOOL_SCHEMA 或 CDSL_*_STRICT_TOOL_MODELS,或者改用已验证支持严格函数 schema 的模型。"
|
|
)
|
|
if isinstance(error, RepeatedToolArgumentsError) and any(
|
|
"\u4e00" <= char <= "\u9fff" for char in str(user_text or "")
|
|
):
|
|
diagnostics = ""
|
|
if error.diagnostic_paths:
|
|
diagnostics = " 原始工具参数和停止原因已保存到:" + "、".join(error.diagnostic_paths) + "。"
|
|
return (
|
|
"模型连续两次未返回完整的 CDSL 工具 JSON,已停止重试且未创建模型。"
|
|
"请检查所选模型的函数调用兼容性;若仍出现此错误,请关闭该模型的严格工具 schema 开关后再试。"
|
|
+ diagnostics
|
|
)
|
|
if isinstance(error, CdslRepairLimitError):
|
|
diagnostics = "、".join(error.diagnostic_paths)
|
|
if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")):
|
|
return "模型重试达到四次 CDSL 修复上限,未创建新的成功 revision。每次 CDSL 校验失败的诊断已保存到:" + diagnostics + "。"
|
|
return "The four-attempt CDSL repair limit was reached. Diagnostics were saved to: " + diagnostics + "."
|
|
return str(error)
|
|
|
|
|
|
def _repair_premature_tool_wrapper_close(source: str, parsed_value: Any, parsed_end: int) -> dict[str, Any] | None:
|
|
"""Recover one known provider defect without accepting arbitrary malformed JSON."""
|
|
if (
|
|
not isinstance(parsed_value, dict)
|
|
or set(parsed_value) != {"cdsl"}
|
|
or parsed_end < 1
|
|
or source[parsed_end - 1] != "}"
|
|
):
|
|
return None
|
|
|
|
# Some OpenAI-compatible endpoints close the tool-argument root after
|
|
# `cdsl`, then emit `, "summary": ...}` outside it. Re-open exactly that
|
|
# wrapper and accept the result only when it is a complete known envelope.
|
|
candidate = source[:parsed_end - 1] + source[parsed_end:]
|
|
try:
|
|
value, candidate_end = json.JSONDecoder().raw_decode(candidate)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
if candidate[candidate_end:].strip() or not isinstance(value, dict):
|
|
return None
|
|
if not set(value).issubset({"cdsl", "summary", "assumptions", "verification"}):
|
|
return None
|
|
if not isinstance(value.get("cdsl"), dict) or not isinstance(value.get("summary"), str):
|
|
return None
|
|
if not value["summary"].strip():
|
|
return None
|
|
if "assumptions" in value and (
|
|
not isinstance(value["assumptions"], list)
|
|
or not all(isinstance(item, str) for item in value["assumptions"])
|
|
):
|
|
return None
|
|
return value
|
|
|
|
|
|
def parse_tool_arguments(raw_arguments: Any, *, recover_cdsl_wrapper: bool = False) -> dict[str, Any]:
|
|
"""Decode one function-call argument object, with one guarded CDSL repair."""
|
|
if raw_arguments is None or raw_arguments == "":
|
|
return {}
|
|
if not isinstance(raw_arguments, str):
|
|
raise ToolArgumentsError("arguments must be a JSON object string")
|
|
|
|
source = raw_arguments.strip()
|
|
if not source:
|
|
return {}
|
|
try:
|
|
value, parsed_end = json.JSONDecoder().raw_decode(source)
|
|
except json.JSONDecodeError as error:
|
|
raise ToolArgumentsError("arguments are not valid JSON") from error
|
|
if source[parsed_end:].strip():
|
|
if recover_cdsl_wrapper:
|
|
repaired = _repair_premature_tool_wrapper_close(source, value, parsed_end)
|
|
if repaired is not None:
|
|
return repaired
|
|
raise ToolArgumentsError("arguments contain trailing content after the JSON object")
|
|
if not isinstance(value, dict):
|
|
raise ToolArgumentsError("arguments must decode to a JSON object")
|
|
return value
|
|
|
|
|
|
def invalid_tool_arguments_result(name: str, error: ToolArgumentsError) -> dict[str, Any]:
|
|
return {
|
|
"ok": False,
|
|
"code": "INVALID_TOOL_ARGUMENTS",
|
|
"message": (
|
|
f"{name} arguments were rejected: {error}. "
|
|
"Call the same tool again with exactly one valid JSON object. "
|
|
"Do not append prose, Markdown fences, or another JSON value."
|
|
),
|
|
}
|
|
|
|
|
|
def _cdsl_error_details(error: Exception) -> dict[str, Any]:
|
|
message = str(error)
|
|
path_match = re.search(r"(?:at|path) (\$[^: ]*)", message)
|
|
quality = getattr(error, "quality_report", None)
|
|
if isinstance(quality, dict):
|
|
return {
|
|
"code": "VERIFICATION_FAILED",
|
|
"path": "$.verification.rules",
|
|
"kind": "verification",
|
|
"repair_instruction": "Correct the CDSL feature geometry or the verification rule, then submit a local patch or complete replacement.",
|
|
"quality": quality,
|
|
}
|
|
if "schema violation" in message:
|
|
return {
|
|
"code": "CDSL_SCHEMA_INVALID",
|
|
"path": path_match.group(1) if path_match else "$",
|
|
"kind": "schema",
|
|
"repair_instruction": "Correct the field at the reported JSONPath using the authoritative CDSL schema.",
|
|
}
|
|
return {
|
|
"code": "CDSL_RUNTIME_INVALID",
|
|
"path": path_match.group(1) if path_match else "$",
|
|
"kind": "runtime",
|
|
"repair_instruction": "Correct the invalid CDSL structure, dependency, selector, or runtime parameter and retry.",
|
|
}
|
|
|
|
|
|
def invalid_cdsl_result(error: Exception) -> dict[str, Any]:
|
|
details = _cdsl_error_details(error)
|
|
return {
|
|
"ok": False,
|
|
**details,
|
|
"message": (
|
|
f"The submitted CDSL is incomplete or invalid: {error}. "
|
|
"Read the authoritative local engine schema, then call generate_cdsl_model "
|
|
"again with a complete compatible model."
|
|
),
|
|
}
|
|
|
|
|
|
def user_visible_tool_message(result: dict[str, Any], user_text: str) -> str:
|
|
code = str(result.get("code") or "")
|
|
if code in {"CDSL_SCHEMA_INVALID", "CDSL_RUNTIME_INVALID", "VERIFICATION_FAILED"}:
|
|
if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")):
|
|
return "CDSL 不符合 engine 的模型契约,正在请求模型按 schema 修正后重新生成。"
|
|
return "The CDSL model does not match the engine contract. Asking the model to correct it and retry."
|
|
return str(result.get("message") or result.get("summary") or "")
|
|
|
|
|
|
def response_language_instruction(user_text: str) -> str:
|
|
"""Make the language requirement concrete for scripts we can identify safely."""
|
|
text = str(user_text or "")
|
|
chinese = sum("\u4e00" <= char <= "\u9fff" for char in text)
|
|
japanese = sum("\u3040" <= char <= "\u30ff" for char in text)
|
|
korean = sum("\uac00" <= char <= "\ud7af" for char in text)
|
|
if japanese:
|
|
language = "Japanese"
|
|
elif korean:
|
|
language = "Korean"
|
|
elif chinese:
|
|
language = "Chinese"
|
|
else:
|
|
language = "the same primary natural language as the latest user message"
|
|
return (
|
|
"This turn's output language is mandatory: use "
|
|
f"{language} for every user-facing natural-language response. "
|
|
"Do not use English unless that is the user's primary language."
|
|
)
|
|
|
|
|
|
def _cdsl_tool_schema() -> dict[str, Any]:
|
|
engine_dir = Path(__file__).resolve().parents[2] / "engine" / "cdsl_engine"
|
|
contract_path = engine_dir / "profile_schema.json"
|
|
try:
|
|
contract = json.loads(contract_path.read_text(encoding="utf-8"))
|
|
schema_name = str(contract.get("cdsl_json_schema_file") or "")
|
|
if not schema_name or Path(schema_name).name != schema_name:
|
|
raise RuntimeError("Local engine contract has no valid CDSL JSON Schema path")
|
|
schema = json.loads((engine_dir / schema_name).read_text(encoding="utf-8"))
|
|
supported_atomics = {
|
|
str(item)
|
|
for item in contract.get("runtime_supported_atomic_ids") or ()
|
|
if str(item)
|
|
}
|
|
declared_atomics = set((contract.get("feature_atomic_ids") or {}).keys())
|
|
if not supported_atomics or not supported_atomics.issubset(declared_atomics):
|
|
raise RuntimeError("Engine capability contract has invalid runtime atomic ids")
|
|
engine_parent = str(engine_dir.parent)
|
|
if engine_parent not in sys.path:
|
|
sys.path.insert(0, engine_parent)
|
|
import cdsl_engine
|
|
|
|
registered_atomics = {str(item) for item in getattr(cdsl_engine, "SUPPORTED_ATOMIC_IDS", ())}
|
|
supported_atomics &= registered_atomics
|
|
if not supported_atomics:
|
|
raise RuntimeError("Engine has no registered atomic executors in common with its capability contract")
|
|
feature_atomic_definition = schema.get("$defs", {}).get("feature_atomic_ids")
|
|
if not isinstance(feature_atomic_definition, dict):
|
|
raise RuntimeError("Local CDSL JSON Schema has no feature atomic definition")
|
|
feature_atomic_definition["enum"] = sorted(supported_atomics)
|
|
profile_definition = schema.get("$defs", {}).get("profile_type")
|
|
supported_profiles = {
|
|
str(item)
|
|
for item in contract.get("runtime_supported_profiles") or ()
|
|
if str(item)
|
|
}
|
|
supported_profiles &= {str(item) for item in getattr(cdsl_engine, "SHAPE_GENERATORS", ())}
|
|
if isinstance(profile_definition, dict) and supported_profiles:
|
|
profile_definition["enum"] = sorted(supported_profiles)
|
|
return schema
|
|
except (OSError, json.JSONDecodeError, AttributeError) as error:
|
|
raise RuntimeError("Local CDSL JSON Schema is unavailable or invalid") from error
|
|
|
|
|
|
CDSL_TOOL_SCHEMA = _cdsl_tool_schema()
|
|
GENERATION_TOOL_NAMES = {"generate_cdsl_model", "patch_cdsl_model"}
|
|
|
|
VERIFICATION_SCHEMA: dict[str, Any] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"rules": {
|
|
"type": "array",
|
|
"maxItems": 32,
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string", "minLength": 1},
|
|
"type": {"enum": sorted(QUALITY_RULE_TYPES)},
|
|
"feature": {"type": "string"},
|
|
"expected": {
|
|
"description": (
|
|
"For bbox, use [dx, dy, dz], "
|
|
"{min: [x, y, z], max: [x, y, z]}, or "
|
|
"{x_min, x_max, y_min, y_max, z_min, z_max}."
|
|
),
|
|
},
|
|
"tolerance": {"type": "number", "minimum": 0},
|
|
"severity": {"enum": ["blocking", "warning", "informational"]},
|
|
},
|
|
"required": ["id", "type", "expected"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
},
|
|
"required": ["rules"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
JSON_PATCH_OPERATION_SCHEMA: dict[str, Any] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"op": {"enum": ["add", "remove", "replace", "move", "copy", "test"]},
|
|
"path": {"type": "string", "pattern": "^/"},
|
|
"from": {"type": "string", "pattern": "^/"},
|
|
"value": {},
|
|
},
|
|
"required": ["op", "path"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
|
|
def engine_capability_manifest(settings: Settings) -> dict[str, Any]:
|
|
"""Build the compact planner-facing capability contract from engine files."""
|
|
load_engine(settings)
|
|
try:
|
|
profile = json.loads((settings.engine_root / "profile_schema.json").read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {"supported_profiles": [], "runtime_atomic_ids": [], "required_params": {}, "unsupported_profiles": []}
|
|
atomic_contracts = profile.get("feature_atomic_ids") if isinstance(profile.get("feature_atomic_ids"), dict) else {}
|
|
declared_atomics = {str(item) for item in profile.get("runtime_supported_atomic_ids") or atomic_contracts}
|
|
registered_atomics = {str(item) for item in getattr(load_engine(settings), "SUPPORTED_ATOMIC_IDS", ())}
|
|
supported_atomics = sorted(declared_atomics & registered_atomics)
|
|
declared_profiles = {str(item) for item in profile.get("runtime_supported_profiles") or ()}
|
|
registered_profiles = {str(item) for item in getattr(load_engine(settings), "SHAPE_GENERATORS", ())}
|
|
return {
|
|
"supported_profiles": sorted(declared_profiles & registered_profiles),
|
|
"runtime_atomic_ids": supported_atomics,
|
|
"required_params": {str(key): list(value.get("required_params") or []) for key, value in atomic_contracts.items() if isinstance(value, dict)},
|
|
"unsupported_profiles": sorted(str(item) for item in profile.get("unsupported_profiles") or []),
|
|
"verification_rule_types": sorted(QUALITY_RULE_TYPES),
|
|
}
|
|
|
|
|
|
TOOL_SCHEMAS: list[dict[str, Any]] = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "analyze_image_reference",
|
|
"description": (
|
|
"Analyze the current image attachments as a CAD reference. "
|
|
"Use this before any CAD planning or generation for a newly uploaded image. "
|
|
"Describe only visible geometry and identify dimensions that may need confirmation."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"part_type": {"type": "string", "minLength": 1},
|
|
"visible_features": {
|
|
"type": "array",
|
|
"items": {"type": "string", "minLength": 1},
|
|
"minItems": 1,
|
|
"maxItems": 12,
|
|
},
|
|
"uncertain_features": {
|
|
"type": "array",
|
|
"items": {"type": "string", "minLength": 1},
|
|
"maxItems": 8,
|
|
},
|
|
"dimension_candidates": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string", "minLength": 1},
|
|
"label": {"type": "string", "minLength": 1},
|
|
"reason": {"type": "string", "minLength": 1},
|
|
},
|
|
"required": ["id", "label", "reason"],
|
|
"additionalProperties": False,
|
|
},
|
|
"maxItems": 12,
|
|
},
|
|
},
|
|
"required": ["part_type", "visible_features", "uncertain_features"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search_cdsl_library",
|
|
"description": "Search the official local CDSL library for similar geometry and feature sequences.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 8}},
|
|
"required": ["query"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "read_cdsl_reference",
|
|
"description": "Read one official CDSL sample by part_id. Use this before creating geometry based on a reference.",
|
|
"parameters": {"type": "object", "properties": {"part_id": {"type": "string"}}, "required": ["part_id"], "additionalProperties": False},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "describe_design_intent",
|
|
"description": "Record a concise natural-language CAD plan as reference for this turn's CDSL generation. This plan is not a CAD contract; CDSL remains the only authoritative model.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"plan": {"type": "string", "minLength": 1},
|
|
"assumptions": {"type": "array", "items": {"type": "string"}},
|
|
},
|
|
"required": ["plan", "assumptions"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "read_current_cdsl",
|
|
"description": "Read the current task's latest CDSL before making a natural-language revision.",
|
|
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "generate_cdsl_model",
|
|
"description": "Validate and execute a complete parameterized CDSL model. Use only for explicit CAD generation or revision.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"cdsl": CDSL_TOOL_SCHEMA,
|
|
"summary": {"type": "string", "minLength": 1},
|
|
"assumptions": {"type": "array", "items": {"type": "string"}},
|
|
"verification": VERIFICATION_SCHEMA,
|
|
},
|
|
"required": ["cdsl", "summary", "assumptions"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "patch_cdsl_model",
|
|
"description": "Apply RFC 6902 JSON Patch operations to one existing CDSL revision, validate and rebuild it as a new revision.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"base_revision_id": {"type": "string", "minLength": 1},
|
|
"patches": {"type": "array", "minItems": 1, "maxItems": 32, "items": JSON_PATCH_OPERATION_SCHEMA},
|
|
"summary": {"type": "string", "minLength": 1},
|
|
"assumptions": {"type": "array", "items": {"type": "string"}},
|
|
"verification": VERIFICATION_SCHEMA,
|
|
},
|
|
"required": ["base_revision_id", "patches", "summary", "assumptions"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def tools_for_model(
|
|
model: ProviderModel,
|
|
*,
|
|
include_image_analysis: bool = True,
|
|
) -> list[dict[str, Any]]:
|
|
"""Return this model's tool contract without mutating the shared schema."""
|
|
tools = deepcopy(TOOL_SCHEMAS)
|
|
if not include_image_analysis:
|
|
tools = [
|
|
tool
|
|
for tool in tools
|
|
if tool.get("function", {}).get("name") != "analyze_image_reference"
|
|
]
|
|
if not model.strict_tool_schema:
|
|
return tools
|
|
|
|
for tool in tools:
|
|
if tool.get("function", {}).get("name") in GENERATION_TOOL_NAMES:
|
|
# This flag constrains function arguments only. It has no effect on
|
|
# normal assistant text, the user's prompt, or the summary.
|
|
tool["function"]["strict"] = True
|
|
return tools
|
|
|
|
|
|
def text_from_message(message: ChatMessage) -> str:
|
|
return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip()
|
|
|
|
|
|
def messages_for_model(messages: list[ChatMessage]) -> list[dict[str, Any]]:
|
|
result: list[dict[str, Any]] = []
|
|
for message in messages[-20:]:
|
|
text = text_from_message(message)
|
|
if text:
|
|
result.append({"role": message.role, "content": text})
|
|
return result
|
|
|
|
|
|
def image_attachments(conversation: dict[str, Any]) -> list[dict[str, Any]]:
|
|
return [
|
|
attachment
|
|
for attachment in conversation.get("attachments") or []
|
|
if isinstance(attachment, dict) and attachment.get("kind") == "image" and attachment.get("id")
|
|
]
|
|
|
|
|
|
def revision_input_attachments(conversation: dict[str, Any]) -> list[dict[str, str | int]]:
|
|
"""Snapshot conversation-owned inputs without duplicating their files into a task."""
|
|
conversation_id = str(conversation.get("conversation_id") or "")
|
|
snapshots: list[dict[str, str | int]] = []
|
|
for attachment in conversation.get("attachments") or []:
|
|
if not isinstance(attachment, dict) or str(attachment.get("conversation_id") or "") != conversation_id:
|
|
continue
|
|
attachment_id = str(attachment.get("id") or "")
|
|
if not attachment_id:
|
|
continue
|
|
snapshots.append({
|
|
"attachment_id": attachment_id,
|
|
"conversation_id": conversation_id,
|
|
"name": str(attachment.get("name") or ""),
|
|
"kind": str(attachment.get("kind") or ""),
|
|
"mime": str(attachment.get("mime") or ""),
|
|
"size": int(attachment.get("size") or 0),
|
|
"sha256": str(attachment.get("sha256") or ""),
|
|
})
|
|
return snapshots
|
|
|
|
|
|
def cad_request_instruction(task_id: str, current_task: dict[str, Any] | None) -> str:
|
|
revision_id = str((current_task or {}).get("current_revision") or "")
|
|
if revision_id:
|
|
return f"""
|
|
CAD request state:
|
|
- Mode: revision.
|
|
- Target task: {task_id}; current successful revision: {revision_id}.
|
|
- Call read_current_cdsl first, then preserve unrelated features in the complete replacement CDSL.
|
|
"""
|
|
prior_attempt = f" Task {task_id} has no successful revision and is only a failed/incomplete build attempt." if task_id else ""
|
|
return f"""
|
|
CAD request state:
|
|
- Mode: create.
|
|
- There is no current successful CDSL revision.{prior_attempt}
|
|
- Do not call read_current_cdsl. Generate a new model after the normal planning workflow.
|
|
"""
|
|
|
|
|
|
def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""Return an analysis only when it covers every currently attached image."""
|
|
attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)}
|
|
if not attachment_ids:
|
|
return None
|
|
for message in reversed(conversation.get("messages") or []):
|
|
if not isinstance(message, dict):
|
|
continue
|
|
for part in reversed(message.get("parts") or []):
|
|
if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis":
|
|
continue
|
|
data = part.get("data")
|
|
if not isinstance(data, dict):
|
|
continue
|
|
analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)}
|
|
if attachment_ids.issubset(analyzed_ids):
|
|
return data
|
|
return None
|
|
|
|
|
|
def image_reference_instruction(
|
|
attachments: list[dict[str, Any]],
|
|
analysis: dict[str, Any] | None,
|
|
) -> str:
|
|
if not attachments:
|
|
return ""
|
|
if analysis is None:
|
|
return """
|
|
Image-reference intake gate (highest priority for this turn):
|
|
- The user has uploaded image references that have not yet been analyzed.
|
|
- Your only tool call in this turn must be analyze_image_reference.
|
|
- Do not call describe_design_intent, search_cdsl_library, read_cdsl_reference,
|
|
read_current_cdsl, or generate_cdsl_model in this turn.
|
|
- Describe only what is visible. Do not invent dimensions from pixels or perspective.
|
|
- The backend will present the structured result and provide it back as
|
|
visual-reference context for you to continue this same task.
|
|
"""
|
|
return """
|
|
Image-reference analysis already recorded for the current attachments:
|
|
{data}
|
|
Use this result as visual-reference context and do not analyze the image again.
|
|
The listed dimensions are candidates for confirmation, not a mandatory gate.
|
|
Interpret the full conversation to decide whether to ask a concise question,
|
|
make clearly stated approximate assumptions, or continue the ordinary CDSL
|
|
workflow. When the user permits or requests estimates, choose coherent values
|
|
yourself and record them as assumptions instead of asking again. Respect the
|
|
user's tolerance for estimates. Never present an inferred dimension as an exact
|
|
measurement from the image.
|
|
""".format(data=json.dumps(analysis, ensure_ascii=False, separators=(",", ":")))
|
|
|
|
|
|
def normalize_image_analysis(arguments: dict[str, Any]) -> dict[str, Any]:
|
|
def text(value: Any, name: str, limit: int = 300) -> str:
|
|
normalized = str(value or "").strip()
|
|
if not normalized:
|
|
raise ValueError(f"analyze_image_reference requires a non-empty {name}")
|
|
return normalized[:limit]
|
|
|
|
def text_list(value: Any, name: str, maximum: int) -> list[str]:
|
|
if not isinstance(value, list) or not value:
|
|
raise ValueError(f"analyze_image_reference requires a non-empty {name} array")
|
|
return [text(item, name, 240) for item in value[:maximum]]
|
|
|
|
raw_dimensions = arguments.get("dimension_candidates")
|
|
if raw_dimensions is None:
|
|
raw_dimensions = []
|
|
if not isinstance(raw_dimensions, list):
|
|
raise ValueError("analyze_image_reference dimension_candidates must be an array")
|
|
dimensions: list[dict[str, str]] = []
|
|
used_ids: set[str] = set()
|
|
for item in raw_dimensions[:12]:
|
|
if not isinstance(item, dict):
|
|
raise ValueError("analyze_image_reference dimension_candidates must contain objects")
|
|
dimension_id = text(item.get("id"), "dimension_candidates.id", 80)
|
|
if dimension_id in used_ids:
|
|
continue
|
|
used_ids.add(dimension_id)
|
|
dimensions.append({
|
|
"id": dimension_id,
|
|
"label": text(item.get("label"), "dimension_candidates.label", 160),
|
|
"reason": text(item.get("reason"), "dimension_candidates.reason", 240),
|
|
})
|
|
uncertain = arguments.get("uncertain_features")
|
|
if not isinstance(uncertain, list):
|
|
raise ValueError("analyze_image_reference requires an uncertain_features array")
|
|
return {
|
|
"part_type": text(arguments.get("part_type"), "part_type"),
|
|
"visible_features": text_list(arguments.get("visible_features"), "visible_features", 12),
|
|
"uncertain_features": [text(item, "uncertain_features", 240) for item in uncertain[:8]],
|
|
"dimension_candidates": dimensions,
|
|
}
|
|
|
|
|
|
def _viewer_selection_text(value: Any, limit: int = 240) -> str:
|
|
return str(value or "").strip()[:limit]
|
|
|
|
|
|
def _viewer_selection_vector(value: Any) -> list[float] | None:
|
|
if not isinstance(value, list) or len(value) < 3:
|
|
return None
|
|
try:
|
|
vector = [float(component) for component in value[:3]]
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return vector if all(math.isfinite(component) for component in vector) else None
|
|
|
|
|
|
def _viewer_selection_bbox(value: Any) -> dict[str, list[float]] | None:
|
|
if not isinstance(value, dict):
|
|
return None
|
|
minimum = _viewer_selection_vector(value.get("min"))
|
|
maximum = _viewer_selection_vector(value.get("max"))
|
|
return {"min": minimum, "max": maximum} if minimum and maximum else None
|
|
|
|
|
|
def _viewer_selection_entity(value: Any) -> dict[str, Any] | None:
|
|
if not isinstance(value, dict):
|
|
return None
|
|
reference_id = _viewer_selection_text(value.get("referenceId"), 120)
|
|
if not reference_id:
|
|
return None
|
|
return {
|
|
"referenceId": reference_id,
|
|
"selector": _viewer_selection_text(value.get("selector")),
|
|
"label": _viewer_selection_text(value.get("label")),
|
|
"selectorType": _viewer_selection_text(value.get("selectorType"), 80),
|
|
"surfaceType": _viewer_selection_text(value.get("surfaceType"), 80),
|
|
"centerMm": _viewer_selection_vector(value.get("centerMm")),
|
|
"normal": _viewer_selection_vector(value.get("normal")),
|
|
"bboxMm": _viewer_selection_bbox(value.get("bboxMm")),
|
|
"verticalPositionHint": _viewer_selection_text(value.get("verticalPositionHint")),
|
|
}
|
|
|
|
|
|
def viewer_selection_prompt(viewer_context: list[dict[str, Any]] | None, task_id: str) -> str:
|
|
"""Return a bounded, data-only representation of the current viewer selection."""
|
|
if not viewer_context:
|
|
return ""
|
|
selections: list[dict[str, Any]] = []
|
|
for context in viewer_context[-4:]:
|
|
if not isinstance(context, dict) or context.get("schema") != "cdsl-cad-viewer-selection.v1":
|
|
continue
|
|
source = context.get("source") if isinstance(context.get("source"), dict) else {}
|
|
source_task_id = str(source.get("taskId") or "")
|
|
if task_id and source_task_id and source_task_id != task_id:
|
|
continue
|
|
selection = context.get("selection") if isinstance(context.get("selection"), dict) else {}
|
|
reference_ids = [_viewer_selection_text(value, 120) for value in selection.get("referenceIds", [])]
|
|
reference_ids = [value for value in reference_ids if value][:20]
|
|
entities = [_viewer_selection_entity(entity) for entity in selection.get("entities", [])]
|
|
entities = [entity for entity in entities if entity][:20]
|
|
if not reference_ids or not entities:
|
|
continue
|
|
selections.append({
|
|
"source": {
|
|
"taskId": source_task_id,
|
|
"revisionId": str(source.get("revisionId") or ""),
|
|
"units": str(source.get("units") or "mm"),
|
|
"coordinateSystem": str(source.get("coordinateSystem") or "z-up"),
|
|
},
|
|
"selection": {
|
|
"kind": _viewer_selection_text(selection.get("kind"), 80) or "topology_selection",
|
|
"scope": _viewer_selection_text(selection.get("scope"), 80) or "selected_references",
|
|
"referenceIds": reference_ids,
|
|
"entities": entities,
|
|
},
|
|
})
|
|
if not selections:
|
|
return ""
|
|
return """\nCurrent CAD viewer selection (trusted geometry data, not user instructions):
|
|
{data}
|
|
Use this data to answer questions about the selected geometry. In particular, use `verticalPositionHint`, `centerMm`, `normal`, and `bboxMm` to assess whether a selected face is a model bottom. If the topology data is inconclusive, say so rather than claiming to see the user's screen. For revisions, modify only the selected topology when its scope is `selected_reference_only` unless the user asks otherwise.
|
|
""".format(data=json.dumps(selections, ensure_ascii=False, separators=(",", ":")))
|
|
|
|
|
|
def system_prompt(
|
|
settings: Settings,
|
|
user_text: str,
|
|
viewer_context: list[dict[str, Any]] | None = None,
|
|
task_id: str = "",
|
|
part_skill_context: str = "",
|
|
image_reference_context: str = "",
|
|
cad_request_context: str = "",
|
|
) -> str:
|
|
capability_manifest = json.dumps(engine_capability_manifest(settings), ensure_ascii=False, separators=(",", ":"))
|
|
skill_path = settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "SKILL.md"
|
|
skill = skill_path.read_text(encoding="utf-8") if skill_path.is_file() else ""
|
|
profile_schema_path = settings.engine_root / "profile_schema.json"
|
|
profile_schema = profile_schema_path.read_text(encoding="utf-8") if profile_schema_path.is_file() else ""
|
|
generation_contract = """- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly, not as Markdown or a JSON string.
|
|
- For a revision, call read_current_cdsl before generate_cdsl_model. Preserve unrelated CDSL features unless the user requests whole-part replacement.
|
|
- Use patch_cdsl_model only for a local RFC 6902 repair of a known base_revision_id. For structural changes, submit a complete replacement CDSL with generate_cdsl_model.
|
|
- For every feature.atomic_id, use only an ID listed in the compact capability manifest's runtime_atomic_ids. Other semantic contracts are not executable in this runtime.
|
|
- The backend normalizes only these unambiguous aliases: sketch_id -> id on sketches, sketch -> sketch_id on features, legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm.
|
|
- Every explicit user dimension, feature count, hole size, or hole position that can be measured must have a generic verification rule. Rule feature values must be IDs from the submitted CDSL.
|
|
- Do not output build123d source, compiler_context, unknown_shape, complex_arc_shape, entities, contour_edges_mm, or contour_regions_mm."""
|
|
workflow = """3. Search the local official CDSL library after the design brief. Read a relevant reference when a match exists; samples are expression guidance, not templates or higher-priority requirements.
|
|
4. Generate one complete generic CDSL and call generate_cdsl_model.
|
|
5. On a schema, runtime, or verification failure, use patch_cdsl_model for a local correction or generate_cdsl_model for a complete replacement.
|
|
6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact."""
|
|
authoring_context = f"""You own the complete CDSL: profiles, workplanes, sketch IDs, feature IDs, dependencies, selectors, and atomic IDs must be valid under the engine schema. Do not invent unsupported capabilities.
|
|
|
|
Local skill:
|
|
{skill}
|
|
|
|
Authoritative engine schema:
|
|
{profile_schema}"""
|
|
return f"""You are the CDSL CAD Agent for CDSL CAD Studio.
|
|
|
|
Language policy:
|
|
- Detect the primary natural language of the latest user message.
|
|
- Write every user-facing natural-language response in that same language.
|
|
- This includes explanations, clarification questions, generation summaries,
|
|
assumptions, progress commentary, and tool-result summaries.
|
|
- If the user mixes languages, use the language that carries most of the
|
|
request. Do not switch to English merely because this instruction, the local
|
|
skill, the engine guide, or a tool schema is written in English.
|
|
- Preserve technical identifiers exactly as required: CDSL keys, JSON values
|
|
that are enums, profile names, tool names, file names, and model IDs may stay
|
|
in their original form.
|
|
|
|
Tool call contract:
|
|
- Every function call arguments field must contain exactly one valid JSON object.
|
|
- Do not append prose, Markdown code fences, comments, or a second JSON value.
|
|
- Call describe_design_intent first with a concise natural-language plan.
|
|
- If a tool reports INVALID_TOOL_ARGUMENTS, correct the arguments and call that
|
|
tool again. Do not claim that the CAD model was generated.
|
|
- If a generation tool reports INVALID_CDSL, correct its plan or complete CDSL
|
|
as applicable and call that same tool again. Do not claim success.
|
|
{generation_contract}
|
|
|
|
Workflow limits:
|
|
- Do not expose internal planning or "let me" commentary to the user while
|
|
using tools. The application shows tool progress separately.
|
|
- Keep final user-facing responses operational and concise. When a structured
|
|
CAD result has been produced, do not restate its name, files, revision, or
|
|
tool progress; reply only when an assumption, limitation, or next decision
|
|
needs the user's attention. Otherwise finish without a prose postscript.
|
|
- When clarification is essential, ask exactly one direct question that names
|
|
the missing dimension or decision. Do not combine it with a tool trace or a
|
|
generic progress update.
|
|
- Use at most two CDSL-library searches per user request. If neither finds a
|
|
useful reference, stop searching and use the available capability contract to either generate
|
|
the model or ask one concise clarification question.
|
|
- Do not repeatedly search for the same unavailable feature or profile.
|
|
|
|
{response_language_instruction(user_text)}
|
|
|
|
{image_reference_context}
|
|
|
|
{cad_request_context}
|
|
|
|
You generate executable CAD through complete parameterized CDSL, never raw CAD source code. For new CAD requests:
|
|
1. Use the injected part-skill guidance, when present, only to establish the
|
|
structural plan, feature dependency order, parameter roles, and reference queries.
|
|
2. Call describe_design_intent with a concise textual plan. Ask one concise
|
|
user-facing question before CAD generation if essential dimensions are missing.
|
|
{workflow}
|
|
|
|
Precedence is strict: explicit user request, then CDSL schema/runtime, then
|
|
part-skill guidance, then CDSL-library examples. Part skills never authorize
|
|
build123d source, an unknown atomic/profile, an invented selector, or a free-
|
|
coordinate substitute for a capability the runtime cannot express. For an
|
|
unsupported requested structure, ask one concise clarification question or
|
|
state the blocker rather than fabricating geometry. If a part-family conflict
|
|
is injected, preserve the current part unless the user explicitly requests a
|
|
whole-part replacement. A primary-family conflict is a hard clarification
|
|
stop: ask one concise question and do not call a generation tool until the
|
|
user resolves it.
|
|
|
|
{authoring_context}
|
|
|
|
Compact Engine Capability Manifest (planner-facing; backend validator remains authoritative):
|
|
{capability_manifest}
|
|
|
|
Injected part-skill context:
|
|
{part_skill_context or "No part-family skill guidance was selected for this request."}
|
|
{viewer_selection_prompt(viewer_context, task_id)}
|
|
"""
|
|
|
|
|
|
def part_skill_root(settings: Settings) -> Path:
|
|
return settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "references" / "part-skills"
|
|
|
|
|
|
class AgentService:
|
|
def __init__(
|
|
self,
|
|
settings: Settings,
|
|
store: WorkspaceStore,
|
|
library: CdslLibrary,
|
|
part_skill_library: PartSkillLibrary | None = None,
|
|
) -> None:
|
|
self.settings = settings
|
|
self.store = store
|
|
self.library = library
|
|
self.part_skill_library = part_skill_library or PartSkillLibrary(part_skill_root(settings))
|
|
|
|
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]:
|
|
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)
|
|
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
|
|
assistant_parts: list[dict[str, Any]] = []
|
|
assistant_id = f"assistant_{secrets.token_hex(8)}"
|
|
error_payload: dict[str, Any] | None = None
|
|
|
|
if task_id and current_task is None:
|
|
error_payload = {"stage": "request", "message": "The selected CAD task no longer exists. Start a new model or select a valid task."}
|
|
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
|
|
yield event("cad_error", error_payload)
|
|
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, "")
|
|
yield event("done", {})
|
|
return
|
|
self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id or None)
|
|
|
|
try:
|
|
provider, model = self.settings.resolve_model(provider_id, model_id)
|
|
except ValueError as error:
|
|
provider = None
|
|
model = None
|
|
configuration_error = str(error)
|
|
else:
|
|
configuration_error = ""
|
|
|
|
if not self.settings.llm_configured or provider is None or model is None:
|
|
message = "Agent 尚未配置模型。请设置 CDSL_LLM_BASE_URL、CDSL_LLM_API_KEY 和 CDSL_LLM_MODEL。"
|
|
if configuration_error:
|
|
message = configuration_error
|
|
error_payload = {"stage": "configuration", "message": message}
|
|
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
|
|
yield event("cad_error", error_payload)
|
|
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id)
|
|
yield event("done", {})
|
|
return
|
|
|
|
try:
|
|
attachment_message = self._attachment_message(conversation, model)
|
|
except ValueError as error:
|
|
error_payload = {"stage": "attachment", "message": str(error)}
|
|
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
|
|
yield event("cad_error", error_payload)
|
|
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id)
|
|
yield event("done", {})
|
|
return
|
|
|
|
image_inputs = image_attachments(conversation)
|
|
recorded_image_analysis = image_reference_analysis(conversation)
|
|
requires_image_intake = bool(image_inputs) and recorded_image_analysis is None
|
|
yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"})
|
|
references: list[str] = []
|
|
library_searches = 0
|
|
inherited_skill_ids = self.part_skill_library.inherited_from_task(current_task)
|
|
part_skill_selection = self.part_skill_library.select(user_text, inherited_skill_ids)
|
|
planning_state: dict[str, Any] = {"phase": "INTAKE", "design_brief": "", "current_model_read": False}
|
|
yield event("progress", {
|
|
"step": "select_part_skill",
|
|
"label": "选择建模 Skill",
|
|
"status": "success",
|
|
"message": "已完成建模 Skill 与辅助规则选择。",
|
|
})
|
|
model_messages: list[dict[str, Any]] = [{
|
|
"role": "system",
|
|
"content": system_prompt(
|
|
self.settings,
|
|
user_text,
|
|
viewer_context,
|
|
task_id,
|
|
self.part_skill_library.render_context(part_skill_selection),
|
|
image_reference_instruction(image_inputs, recorded_image_analysis),
|
|
cad_request_instruction(task_id, current_task),
|
|
),
|
|
}]
|
|
model_messages.extend(messages_for_model(messages))
|
|
if attachment_message:
|
|
model_messages.append({"role": "user", "content": attachment_message})
|
|
tools = tools_for_model(
|
|
model,
|
|
include_image_analysis=requires_image_intake,
|
|
)
|
|
required_tool_name: str | None = "analyze_image_reference" if requires_image_intake else None
|
|
generate_argument_failures = 0
|
|
tool_argument_diagnostics: list[str] = []
|
|
cdsl_validation_diagnostics: list[str] = []
|
|
generation_completed = False
|
|
repair_attempts = 0
|
|
|
|
try:
|
|
for iteration in range(8):
|
|
if (
|
|
planning_state.get("phase") == "CDSL_REPAIR"
|
|
and repair_attempts >= self.settings.max_repair_attempts
|
|
):
|
|
raise CdslRepairLimitError(cdsl_validation_diagnostics)
|
|
response = await self._complete(model_messages, tools, provider, model, required_tool_name)
|
|
response_choice = response["choices"][0]
|
|
choice = response_choice["message"]
|
|
tool_calls = choice.get("tool_calls") or []
|
|
content = str(choice.get("content") or "")
|
|
# Tool-call content is implementation planning. It is retained in
|
|
# model_messages for the next round but not shown to the user.
|
|
if content and not tool_calls and not required_tool_name:
|
|
assistant_parts.append({"type": "text", "text": content})
|
|
for chunk in self._chunks(content):
|
|
yield event("text_delta", {"text": chunk})
|
|
if not tool_calls:
|
|
if required_tool_name:
|
|
model_messages.append(choice)
|
|
model_messages.append({
|
|
"role": "system",
|
|
"content": f"You must now call {required_tool_name} with corrected complete arguments. Do not reply with prose.",
|
|
})
|
|
continue
|
|
break
|
|
model_messages.append(choice)
|
|
for call in tool_calls:
|
|
name = str(call.get("function", {}).get("name") or "")
|
|
if requires_image_intake and name != "analyze_image_reference":
|
|
result = {
|
|
"ok": False,
|
|
"code": "IMAGE_ANALYSIS_REQUIRED",
|
|
"message": "Analyze all current image attachments before using planning, library, or generation tools.",
|
|
}
|
|
model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)})
|
|
yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先完成图片参考分析。"})
|
|
continue
|
|
if name == "analyze_image_reference" and recorded_image_analysis is not None:
|
|
result = {
|
|
"ok": False,
|
|
"code": "IMAGE_ANALYSIS_ALREADY_RECORDED",
|
|
"message": (
|
|
"Image analysis is already recorded for the current attachments. "
|
|
"Use that result and continue the CAD workflow; do not analyze the image again."
|
|
),
|
|
}
|
|
model_messages.append({
|
|
"role": "tool",
|
|
"tool_call_id": call.get("id", ""),
|
|
"content": json.dumps(result, ensure_ascii=False),
|
|
})
|
|
yield event("progress", {
|
|
"step": name,
|
|
"label": self._tool_label(name),
|
|
"status": "error",
|
|
"message": "图片识别结果已存在,正在继续后续建模流程。",
|
|
})
|
|
continue
|
|
if name == "search_cdsl_library":
|
|
library_searches += 1
|
|
if library_searches > 2:
|
|
result = {
|
|
"ok": False,
|
|
"code": "LIBRARY_SEARCH_LIMIT_REACHED",
|
|
"message": (
|
|
"The CDSL library search limit for this request has been reached. "
|
|
"Do not search again. Use the engine guide to call generate_cdsl_model "
|
|
"or ask the user one concise clarification question."
|
|
),
|
|
}
|
|
model_messages.append({
|
|
"role": "tool",
|
|
"tool_call_id": call.get("id", ""),
|
|
"content": json.dumps(result, ensure_ascii=False),
|
|
})
|
|
yield event("progress", {
|
|
"step": name,
|
|
"label": self._tool_label(name),
|
|
"status": "error",
|
|
"message": "模型库未找到更多匹配项,正在继续生成模型。",
|
|
})
|
|
continue
|
|
try:
|
|
arguments = parse_tool_arguments(
|
|
call.get("function", {}).get("arguments"),
|
|
recover_cdsl_wrapper=name == "generate_cdsl_model",
|
|
)
|
|
except ToolArgumentsError as error:
|
|
diagnostic_path = self._record_tool_call_diagnostic(
|
|
conversation_id=conversation["conversation_id"],
|
|
task_id=task_id,
|
|
provider=provider,
|
|
model=model,
|
|
response=response,
|
|
finish_reason=response_choice.get("finish_reason"),
|
|
iteration=iteration + 1,
|
|
call=call,
|
|
error=error,
|
|
)
|
|
if diagnostic_path:
|
|
tool_argument_diagnostics.append(diagnostic_path)
|
|
result = invalid_tool_arguments_result(name or "tool", error)
|
|
if name in GENERATION_TOOL_NAMES:
|
|
generate_argument_failures += 1
|
|
if generate_argument_failures >= 2:
|
|
raise RepeatedToolArgumentsError(str(error), tool_argument_diagnostics)
|
|
required_tool_name = name
|
|
model_messages.append({
|
|
"role": "tool",
|
|
"tool_call_id": call.get("id", ""),
|
|
"content": json.dumps(result, ensure_ascii=False),
|
|
})
|
|
yield event("progress", {
|
|
"step": name or "tool_arguments",
|
|
"label": self._tool_label(name),
|
|
"status": "error",
|
|
"message": "CAD 工具参数格式无效,正在请求模型修正。",
|
|
})
|
|
continue
|
|
if name in GENERATION_TOOL_NAMES and planning_state.get("phase") == "CDSL_REPAIR":
|
|
if repair_attempts >= self.settings.max_repair_attempts:
|
|
raise CdslRepairLimitError(cdsl_validation_diagnostics)
|
|
repair_attempts += 1
|
|
cdsl_attempt_path = ""
|
|
if name in GENERATION_TOOL_NAMES:
|
|
cdsl_attempt_path = self._record_cdsl_attempt(
|
|
conversation_id=conversation["conversation_id"],
|
|
arguments=arguments,
|
|
iteration=iteration + 1,
|
|
)
|
|
yield event("progress", {
|
|
"step": name,
|
|
"label": self._tool_label(name),
|
|
"status": "running",
|
|
"message": "Agent 正在调用本地 CAD 工具。",
|
|
})
|
|
try:
|
|
result, generated = await self._run_tool(
|
|
name,
|
|
arguments,
|
|
task_id,
|
|
user_text,
|
|
references,
|
|
part_skill_selection=part_skill_selection,
|
|
planning_state=planning_state,
|
|
image_attachment_ids=[str(attachment["id"]) for attachment in image_inputs],
|
|
input_attachments=revision_input_attachments(conversation),
|
|
conversation_id=conversation["conversation_id"],
|
|
repair_attempts=repair_attempts,
|
|
)
|
|
except (ValueError, RuntimeError) as error:
|
|
if name in GENERATION_TOOL_NAMES:
|
|
diagnostic_path = self._record_cdsl_validation_diagnostic(
|
|
conversation_id=conversation["conversation_id"],
|
|
task_id=task_id,
|
|
provider=provider,
|
|
model=model,
|
|
response=response,
|
|
finish_reason=response_choice.get("finish_reason"),
|
|
iteration=iteration + 1,
|
|
call=call,
|
|
arguments=arguments,
|
|
cdsl_attempt_path=cdsl_attempt_path,
|
|
error=error,
|
|
)
|
|
cdsl_validation_diagnostics.append(diagnostic_path)
|
|
result = invalid_cdsl_result(error)
|
|
result["diagnostic_path"] = diagnostic_path
|
|
repair_task_id = str(getattr(error, "task_id", "") or "")
|
|
repair_revision_id = str(getattr(error, "revision_id", "") or "")
|
|
if repair_task_id and repair_revision_id:
|
|
result.update({
|
|
"task_id": repair_task_id,
|
|
"base_revision_id": repair_revision_id,
|
|
"repair_instruction": (
|
|
"Patch this explicit base_revision_id for a local correction, or call read_current_cdsl "
|
|
"before a complete CDSL replacement."
|
|
),
|
|
})
|
|
if name == "patch_cdsl_model":
|
|
result["code"] = "INVALID_CDSL_PATCH"
|
|
result["repair_instruction"] = "Correct the RFC 6902 patch or call generate_cdsl_model with a complete replacement CDSL."
|
|
generated = None
|
|
required_tool_name = name
|
|
planning_state["phase"] = "CDSL_REPAIR"
|
|
else:
|
|
raise
|
|
if result.get("task_id"):
|
|
task_id = str(result["task_id"])
|
|
if generated:
|
|
task_id = generated["task_id"]
|
|
model_messages.append({
|
|
"role": "tool",
|
|
"tool_call_id": call.get("id", ""),
|
|
"content": json.dumps(result, ensure_ascii=False),
|
|
})
|
|
yield event("progress", {
|
|
"step": name,
|
|
"label": self._tool_label(name),
|
|
"status": "success" if result.get("ok", True) else "error",
|
|
"message": user_visible_tool_message(result, user_text),
|
|
})
|
|
if name == "describe_design_intent" and result.get("ok"):
|
|
yield event("progress", {
|
|
"step": "record_design_brief",
|
|
"label": "记录设计说明",
|
|
"status": "success",
|
|
"message": "设计说明已记录,将作为 CDSL 生成参考。",
|
|
})
|
|
if name == "analyze_image_reference" and result.get("ok"):
|
|
image_payload = {
|
|
"attachmentIds": result["attachment_ids"],
|
|
"partType": result["part_type"],
|
|
"visibleFeatures": result["visible_features"],
|
|
"uncertainFeatures": result["uncertain_features"],
|
|
"dimensionCandidates": result["dimension_candidates"],
|
|
"artifactPath": result.get("artifact_path"),
|
|
}
|
|
assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload})
|
|
yield event("image_analysis", image_payload)
|
|
recorded_image_analysis = image_payload
|
|
requires_image_intake = False
|
|
required_tool_name = None
|
|
tools = tools_for_model(
|
|
model,
|
|
include_image_analysis=False,
|
|
)
|
|
model_messages.append({
|
|
"role": "system",
|
|
"content": image_reference_instruction(image_inputs, image_payload),
|
|
})
|
|
yield event("progress", {
|
|
"step": "analyze_image_reference",
|
|
"label": "识别图片参考",
|
|
"status": "success",
|
|
"message": "已识别可见结构,正在根据对话继续判断建模方案。",
|
|
})
|
|
continue
|
|
if name in GENERATION_TOOL_NAMES and result.get("ok"):
|
|
required_tool_name = None
|
|
if generated:
|
|
result_payload = {
|
|
"taskId": generated["task_id"],
|
|
"revisionId": generated["revision_id"],
|
|
"cdslPath": generated["cdsl_path"],
|
|
"stepPath": generated["step_path"],
|
|
"glbPath": generated["glb_path"],
|
|
"reportPath": generated["report_path"],
|
|
"parametersPath": generated.get("parameters_path"),
|
|
"selectorPath": generated.get("selector_path"),
|
|
"edgesPath": generated.get("edges_path"),
|
|
"summary": generated["summary"],
|
|
"referenceIds": generated["reference_ids"],
|
|
"engine": generated["engine"],
|
|
"qualityStatus": generated.get("quality_status", ""),
|
|
"qualityPath": generated.get("quality_path") or None,
|
|
"assumptions": generated.get("generation_assumptions", []),
|
|
"repairAttempts": generated.get("repair_attempts", 0),
|
|
"snapshotPaths": generated.get("snapshot_paths", []),
|
|
"snapshotStatus": generated.get("snapshot_status", "unavailable"),
|
|
}
|
|
assistant_parts.append({"type": "data-cad-result", "data": result_payload})
|
|
yield event("cad_result", result_payload)
|
|
yield event("progress", {
|
|
"step": "build_cad",
|
|
"label": "构建 CAD",
|
|
"status": "success",
|
|
"message": "已通过 cdsl_only runtime 构建 STEP 和 GLB。",
|
|
})
|
|
generation_completed = True
|
|
break
|
|
if generation_completed:
|
|
break
|
|
if iteration == 7:
|
|
validation_diagnostics = cdsl_validation_diagnostics
|
|
if validation_diagnostics:
|
|
diagnostic_paths = "、".join(validation_diagnostics)
|
|
if any("\u4e00" <= char <= "\u9fff" for char in user_text):
|
|
message = (
|
|
"模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:"
|
|
f"{diagnostic_paths}。"
|
|
)
|
|
else:
|
|
message = (
|
|
"Agent tool loop reached its safety limit. "
|
|
f"CDSL validation diagnostics were saved to: {diagnostic_paths}."
|
|
)
|
|
else:
|
|
message = "Agent tool loop reached its safety limit."
|
|
error_payload = {"stage": "agent", "message": message}
|
|
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
|
|
yield event("cad_error", error_payload)
|
|
except Exception as error:
|
|
error_payload = {"stage": "agent", "message": user_visible_error_message(error, user_text)}
|
|
assistant_parts.append({"type": "data-cad-error", "data": error_payload})
|
|
yield event("cad_error", error_payload)
|
|
if task_id:
|
|
self.store.ensure_conversation(conversation["conversation_id"], task_id)
|
|
if not assistant_parts:
|
|
assistant_parts.append({
|
|
"type": "text",
|
|
"text": "我暂时没有生成可执行的 CAD 结果。请补充尺寸、形状或修改目标。",
|
|
})
|
|
self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id)
|
|
yield event("progress", {"step": "agent_stream", "label": "调用模型和工具", "status": "success", "message": "Agent 请求已完成。"})
|
|
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 or f"assistant_{conversation_id}_{len(parts)}",
|
|
"role": "assistant",
|
|
"parts": parts,
|
|
},
|
|
task_id or None,
|
|
)
|
|
|
|
def _record_tool_call_diagnostic(
|
|
self,
|
|
*,
|
|
conversation_id: str,
|
|
task_id: str,
|
|
provider: ProviderConfig,
|
|
model: ProviderModel,
|
|
response: dict[str, Any],
|
|
finish_reason: Any,
|
|
iteration: int,
|
|
call: dict[str, Any],
|
|
error: ToolArgumentsError,
|
|
) -> str:
|
|
function = call.get("function") if isinstance(call.get("function"), dict) else {}
|
|
raw_arguments = function.get("arguments")
|
|
raw_text = raw_arguments if isinstance(raw_arguments, str) else json.dumps(raw_arguments, ensure_ascii=False)
|
|
json_error = error.__cause__ if isinstance(error.__cause__, json.JSONDecodeError) else None
|
|
payload = {
|
|
"schema_version": "1.0",
|
|
"recorded_at": now_iso(),
|
|
"conversation_id": conversation_id,
|
|
"task_id": task_id,
|
|
"provider_id": provider.id,
|
|
"model_id": model.id,
|
|
"strict_tool_schema": model.strict_tool_schema,
|
|
"completion_id": response.get("id"),
|
|
"response_model": response.get("model"),
|
|
"finish_reason": finish_reason,
|
|
"usage": response.get("usage"),
|
|
"iteration": iteration,
|
|
"tool_call_id": call.get("id"),
|
|
"tool_name": function.get("name"),
|
|
"parse_error": str(error),
|
|
"json_error": {
|
|
"message": json_error.msg,
|
|
"line": json_error.lineno,
|
|
"column": json_error.colno,
|
|
"character": json_error.pos,
|
|
} if json_error else None,
|
|
"arguments_type": type(raw_arguments).__name__,
|
|
"arguments_utf8_bytes": len(raw_text.encode("utf-8")),
|
|
"arguments": raw_arguments,
|
|
}
|
|
return self.store.write_tool_call_diagnostic(conversation_id, payload)
|
|
|
|
def _record_cdsl_attempt(
|
|
self,
|
|
*,
|
|
conversation_id: str,
|
|
arguments: dict[str, Any],
|
|
iteration: int,
|
|
) -> str:
|
|
candidate = arguments.get("cdsl")
|
|
if isinstance(candidate, str):
|
|
try:
|
|
candidate = json.loads(candidate)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return self.store.write_cdsl_attempt(conversation_id, candidate, iteration)
|
|
|
|
def _record_cdsl_validation_diagnostic(
|
|
self,
|
|
*,
|
|
conversation_id: str,
|
|
task_id: str,
|
|
provider: ProviderConfig,
|
|
model: ProviderModel,
|
|
response: dict[str, Any],
|
|
finish_reason: Any,
|
|
iteration: int,
|
|
call: dict[str, Any],
|
|
arguments: dict[str, Any],
|
|
cdsl_attempt_path: str,
|
|
error: Exception,
|
|
) -> str:
|
|
function = call.get("function") if isinstance(call.get("function"), dict) else {}
|
|
details = _cdsl_error_details(error)
|
|
payload = {
|
|
"schema_version": "1.0",
|
|
"recorded_at": now_iso(),
|
|
"kind": "cdsl_validation_failure",
|
|
"conversation_id": conversation_id,
|
|
"task_id": task_id,
|
|
"provider_id": provider.id,
|
|
"model_id": model.id,
|
|
"strict_tool_schema": model.strict_tool_schema,
|
|
"completion_id": response.get("id"),
|
|
"response_model": response.get("model"),
|
|
"finish_reason": finish_reason,
|
|
"usage": response.get("usage"),
|
|
"iteration": iteration,
|
|
"tool_call_id": call.get("id"),
|
|
"tool_name": function.get("name"),
|
|
"cdsl_attempt_path": cdsl_attempt_path,
|
|
"summary": str(arguments.get("summary") or ""),
|
|
"assumptions": arguments.get("assumptions") or [],
|
|
"verification": arguments.get("verification"),
|
|
"diagnostic": details,
|
|
"validation_error_type": type(error).__name__,
|
|
"validation_error": str(error),
|
|
}
|
|
return self.store.write_cdsl_validation_diagnostic(conversation_id, payload)
|
|
|
|
async def _complete(
|
|
self,
|
|
messages: list[dict[str, Any]],
|
|
tools: list[dict[str, Any]],
|
|
provider: ProviderConfig,
|
|
model: ProviderModel,
|
|
required_tool_name: str | None = None,
|
|
) -> dict[str, Any]:
|
|
url = f"{provider.base_url}/chat/completions"
|
|
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
|
|
tool_choice: str | dict[str, Any] = "auto"
|
|
if required_tool_name:
|
|
tool_choice = {"type": "function", "function": {"name": required_tool_name}}
|
|
payload = {
|
|
"model": model.id,
|
|
"messages": messages,
|
|
"tools": tools,
|
|
"tool_choice": tool_choice,
|
|
"temperature": 0.1,
|
|
}
|
|
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client:
|
|
response = await client.post(url, headers=headers, json=payload)
|
|
# Some reasoning-enabled, OpenAI-compatible models accept tools but
|
|
# reject an explicit tool_choice. Retry once without that constraint.
|
|
if (
|
|
response.status_code == 400
|
|
and "thinking mode does not support this tool_choice" in response.text.lower()
|
|
):
|
|
payload.pop("tool_choice")
|
|
response = await client.post(url, headers=headers, json=payload)
|
|
if response.status_code >= 400:
|
|
if model.strict_tool_schema:
|
|
raise StrictToolSchemaError(
|
|
"LLM provider rejected the strict CDSL tool schema "
|
|
f"({response.status_code}). Disable CDSL_*_STRICT_TOOL_SCHEMA "
|
|
"or CDSL_*_STRICT_TOOL_MODELS for this endpoint, or select a "
|
|
"model that supports strict function schemas. "
|
|
f"Provider response: {response.text[:500]}"
|
|
)
|
|
raise RuntimeError(f"LLM request failed ({response.status_code}): {response.text[:800]}")
|
|
return response.json()
|
|
|
|
async def _run_tool(
|
|
self,
|
|
name: str,
|
|
arguments: dict[str, Any],
|
|
task_id: str,
|
|
request: str,
|
|
references: list[str],
|
|
*,
|
|
part_skill_selection: dict[str, Any] | None = None,
|
|
planning_state: dict[str, Any] | None = None,
|
|
image_attachment_ids: list[str] | None = None,
|
|
input_attachments: list[dict[str, str | int]] | None = None,
|
|
conversation_id: str | None = None,
|
|
repair_attempts: int = 0,
|
|
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
state = planning_state if planning_state is not None else {"phase": "INTAKE", "design_brief": "", "current_model_read": False}
|
|
phase = str(state.get("phase") or "INTAKE")
|
|
if name == "analyze_image_reference":
|
|
if not image_attachment_ids:
|
|
raise ValueError("analyze_image_reference requires at least one image attachment")
|
|
analysis = {
|
|
"ok": True,
|
|
"attachment_ids": list(dict.fromkeys(image_attachment_ids)),
|
|
**normalize_image_analysis(arguments),
|
|
}
|
|
if conversation_id:
|
|
analysis["artifact_path"] = self.store.write_conversation_planning(conversation_id, "reference-observation", {"schema_version": "1.0", **analysis})
|
|
state["phase"] = "REFERENCE_ANALYZED"
|
|
return analysis, None
|
|
if name == "describe_design_intent":
|
|
plan = str(arguments.get("plan") or "").strip()
|
|
assumptions = arguments.get("assumptions")
|
|
if not plan or not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions):
|
|
raise ValueError("describe_design_intent requires a non-empty plan and an array of string assumptions")
|
|
state["design_brief"] = plan
|
|
state["phase"] = "PLANNED"
|
|
return {
|
|
"ok": True,
|
|
"plan": plan,
|
|
"assumptions": [item.strip() for item in assumptions if item.strip()],
|
|
"message": "The design brief is recorded as reference only. CDSL remains the sole authoritative CAD model.",
|
|
}, None
|
|
if name == "search_cdsl_library":
|
|
if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}:
|
|
return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before searching CDSL references."}, None
|
|
query = str(arguments.get("query") or request)
|
|
normalized_query = " ".join(query.casefold().split())
|
|
seen_queries = state.setdefault("library_queries", [])
|
|
if len(seen_queries) >= 2:
|
|
return {"ok": False, "code": "LIBRARY_SEARCH_LIMIT_REACHED", "message": "The CDSL library search limit for this request has been reached; continue with the available references."}, None
|
|
if normalized_query in seen_queries:
|
|
return {"ok": False, "code": "LIBRARY_QUERY_DUPLICATE", "message": "Do not repeat an identical unavailable library query; use the existing results or compile with the available capability."}, None
|
|
seen_queries.append(normalized_query)
|
|
results = self.library.search(query, min(8, int(arguments.get("limit") or 5)))
|
|
state["phase"] = "LIBRARY_SEARCHING"
|
|
return {"ok": True, "results": results}, None
|
|
if name == "read_cdsl_reference":
|
|
if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}:
|
|
return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before reading CDSL references."}, None
|
|
part_id = str(arguments.get("part_id") or "")
|
|
if part_id in references:
|
|
return {"ok": False, "code": "LIBRARY_REFERENCE_DUPLICATE", "message": "This CDSL reference is already loaded; choose another reference or continue to compilation."}, None
|
|
if len(state.get("reference_records") or []) >= 2:
|
|
return {"ok": False, "code": "LIBRARY_REFERENCE_LIMIT_REACHED", "message": "At most two complete CDSL library references may be read for one request."}, None
|
|
try:
|
|
sample = self.library.read_sample(part_id)
|
|
except ValueError as error:
|
|
return {"ok": False, "code": "LIBRARY_REFERENCE_NOT_FOUND", "message": str(error)}, None
|
|
if part_id not in references:
|
|
references.append(part_id)
|
|
state.setdefault("reference_records", []).append({"part_id": part_id, "source": "cdsl_library", "summary": "official CDSL reference"})
|
|
state["phase"] = "LIBRARY_REFERENCE_READY"
|
|
return {"ok": True, "part_id": part_id, "cdsl": sample}, None
|
|
if name == "read_current_cdsl":
|
|
if not task_id:
|
|
return {"ok": False, "message": "No current task exists. This is a new model request."}, None
|
|
path = self.store.current_cdsl_path(task_id)
|
|
revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "")
|
|
if path is None:
|
|
repairable = self.store.latest_repairable_cdsl(task_id)
|
|
if repairable is None:
|
|
return {"ok": False, "message": "The current task has no successful or repairable CDSL revision."}, None
|
|
revision_id, path = repairable
|
|
payload: dict[str, Any] = {
|
|
"ok": True,
|
|
"task_id": task_id,
|
|
"revision_id": revision_id,
|
|
"cdsl": json.loads(path.read_text(encoding="utf-8")),
|
|
}
|
|
state["current_model_read"] = True
|
|
state["read_revision_id"] = revision_id
|
|
state["phase"] = "PLANNED"
|
|
return payload, None
|
|
if name in GENERATION_TOOL_NAMES:
|
|
if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}:
|
|
return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before generating CAD."}, None
|
|
selection = part_skill_selection or self.part_skill_library.select(request)
|
|
if selection.get("conflict"):
|
|
state["phase"] = "BLOCKED"
|
|
return {
|
|
"ok": False,
|
|
"code": "PART_SKILL_CONFLICT",
|
|
"message": str(selection["conflict"].get("message") or "Resolve the primary part-family conflict before generating CAD."),
|
|
}, None
|
|
state["phase"] = "CDSL_AUTHORING"
|
|
if name == "generate_cdsl_model":
|
|
if task_id and not state.get("current_model_read"):
|
|
return {"ok": False, "code": "CURRENT_CDSL_REQUIRED", "message": "Call read_current_cdsl before replacing an existing task revision."}, None
|
|
cdsl = arguments.get("cdsl")
|
|
if isinstance(cdsl, str):
|
|
cdsl = json.loads(cdsl)
|
|
if not isinstance(cdsl, dict):
|
|
raise ValueError("generate_cdsl_model requires a CDSL JSON object")
|
|
parent_revision_id = str(state.get("read_revision_id") or "") if task_id else ""
|
|
if task_id and not parent_revision_id:
|
|
parent_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "")
|
|
operation: dict[str, Any] = {"type": "cdsl_replacement" if parent_revision_id else "cdsl_create"}
|
|
else:
|
|
if not task_id:
|
|
return {"ok": False, "code": "PATCH_TASK_REQUIRED", "message": "patch_cdsl_model requires an existing task."}, None
|
|
base_revision_id = str(arguments.get("base_revision_id") or "")
|
|
base_path = self.store.revision_cdsl_path(task_id, base_revision_id)
|
|
if base_path is None:
|
|
raise ValueError("PATCH_BASE_REVISION_NOT_FOUND: base_revision_id does not identify a readable CDSL revision")
|
|
try:
|
|
cdsl = apply_cdsl_patch(json.loads(base_path.read_text(encoding="utf-8")), arguments.get("patches"))
|
|
except (CdslPatchError, json.JSONDecodeError) as error:
|
|
raise ValueError(f"INVALID_CDSL_PATCH: {error}") from error
|
|
parent_revision_id = base_revision_id
|
|
operation = {"type": "cdsl_patch", "base_revision_id": base_revision_id, "patches": deepcopy(arguments.get("patches") or [])}
|
|
cdsl, normalization_repairs = normalize_cdsl_for_engine(cdsl)
|
|
# Reject malformed model output before build_revision allocates a task
|
|
# directory or revision. build_revision will assign the real task ID.
|
|
preflight_cdsl = {**cdsl, "part_id": str(cdsl.get("part_id") or "agent_preflight")}
|
|
engine = load_engine(self.settings)
|
|
state["phase"] = "PREFLIGHT"
|
|
validate_cdsl(preflight_cdsl, engine)
|
|
summary = str(arguments.get("summary") or "Parameterized CAD model")
|
|
raw_assumptions = arguments.get("assumptions") or []
|
|
if not isinstance(raw_assumptions, list) or not all(isinstance(item, str) for item in raw_assumptions):
|
|
raise ValueError(f"{name} assumptions must be an array of strings")
|
|
assumptions = [item.strip() for item in raw_assumptions if item.strip()]
|
|
verification = arguments.get("verification")
|
|
validate_verification(verification, cdsl)
|
|
part_skill_audit = self.part_skill_library.audit(selection, cdsl, assumptions)
|
|
state["phase"] = "BUILDING"
|
|
try:
|
|
yieldable = await asyncio.to_thread(
|
|
build_revision,
|
|
settings=self.settings,
|
|
store=self.store,
|
|
task_id=task_id or None,
|
|
request=request,
|
|
cdsl=cdsl,
|
|
reference_ids=list(references),
|
|
summary=summary,
|
|
parent_revision_id=parent_revision_id,
|
|
operation=operation,
|
|
part_skills=part_skill_audit,
|
|
generation_assumptions=assumptions,
|
|
repair_attempts=repair_attempts,
|
|
input_attachments=input_attachments,
|
|
verification=verification,
|
|
reference_records=state.get("reference_records"),
|
|
)
|
|
except Exception:
|
|
state["phase"] = "CDSL_REPAIR"
|
|
raise
|
|
state["phase"] = "COMPLETED"
|
|
return {
|
|
"ok": True,
|
|
"summary": summary,
|
|
"task_id": yieldable["task_id"],
|
|
"revision_id": yieldable["revision_id"],
|
|
"normalization_repairs": normalization_repairs,
|
|
"operation": operation,
|
|
}, yieldable
|
|
raise ValueError(f"Unknown agent tool: {name}")
|
|
|
|
@staticmethod
|
|
def _chunks(text: str) -> list[str]:
|
|
return [text[index:index + 96] for index in range(0, len(text), 96)]
|
|
|
|
@staticmethod
|
|
def _tool_label(name: str) -> str:
|
|
return {
|
|
"search_cdsl_library": "检索 CDSL 模型库",
|
|
"read_cdsl_reference": "读取 CDSL 参考模型",
|
|
"read_current_cdsl": "读取当前 CDSL",
|
|
"describe_design_intent": "整理设计说明",
|
|
"generate_cdsl_model": "生成 CDSL",
|
|
"patch_cdsl_model": "修复 CDSL",
|
|
}.get(name, "调用 CAD 工具")
|
|
|
|
def _attachment_message(self, conversation: dict[str, Any], model: ProviderModel) -> list[dict[str, Any]] | str:
|
|
attachments = conversation.get("attachments") or []
|
|
if not attachments:
|
|
return ""
|
|
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": "The following local attachments are part of the CAD request."}]
|
|
for attachment in attachments:
|
|
if not isinstance(attachment, dict):
|
|
continue
|
|
kind = str(attachment.get("kind") or "")
|
|
attachment_conversation = str(attachment.get("conversation_id") or "")
|
|
relative = str(attachment.get("path") or "")
|
|
if attachment_conversation != conversation_id or not relative:
|
|
raise ValueError("Conversation attachment metadata is invalid")
|
|
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 kind == "image":
|
|
if not model.vision:
|
|
raise ValueError("The selected model does not support images. Choose a vision-capable model enabled in backend/.env.")
|
|
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}"}})
|
|
elif kind == "document":
|
|
extracted = str(attachment.get("extracted_path") or "")
|
|
if extracted:
|
|
text_path = self.store.conversation_attachment_path(conversation_id, extracted)
|
|
text = text_path.read_text(encoding="utf-8")[:30_000]
|
|
content.append({"type": "text", "text": f"Document {attachment.get('name')}:\n{text}"})
|
|
return content
|