2767 lines
148 KiB
Python
2767 lines
148 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.feature_plan import FeaturePlanError, compute_node_statuses, validate_feature_plan
|
|
from app.services.incremental_generation import IncrementalGenerationRunner
|
|
from app.services.library import CdslLibrary
|
|
from app.services.part_skills import PartSkillLibrary
|
|
from app.services.quality import FEATURE_RULE_TYPES, QUALITY_RULE_TYPES, validate_verification
|
|
from app.services.sse import event
|
|
from app.services.storage import WorkspaceStore, now_iso
|
|
from app.services.image_observation import (
|
|
merge_image_observations,
|
|
normalize_image_observation,
|
|
normalize_sketch_candidates,
|
|
render_image_observation_context,
|
|
)
|
|
from app.services.image_processing import cv_hints
|
|
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 get_repair_step_key(planning_state: dict[str, Any], tool_name: str) -> str:
|
|
"""Identify one semantic generation step without carrying failures across batches."""
|
|
plan = planning_state.get("feature_plan") if isinstance(planning_state, dict) else None
|
|
if isinstance(plan, dict):
|
|
active_nodes = sorted(
|
|
str(node.get("id"))
|
|
for node in plan.get("nodes") or ()
|
|
if isinstance(node, dict)
|
|
and node.get("status") in {"ready", "executing"}
|
|
)
|
|
if active_nodes:
|
|
return f"plan:{str(plan.get('plan_id') or '')}:{','.join(active_nodes)}"
|
|
# ``generate_cdsl_model`` and ``patch_cdsl_model`` are both attempts at
|
|
# the same repair phase when no feature plan is available.
|
|
return f"phase:{str(planning_state.get('phase') or '')}"
|
|
|
|
|
|
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 same CDSL step failed four consecutive times. 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 _recover_trailing_cdsl_metadata(
|
|
source: str,
|
|
parsed_value: Any,
|
|
parsed_end: int,
|
|
) -> dict[str, Any] | None:
|
|
"""Accept a complete CDSL envelope followed only by duplicate metadata."""
|
|
required = {"cdsl", "summary", "assumptions"}
|
|
allowed = required | {"verification"}
|
|
if (
|
|
not isinstance(parsed_value, dict)
|
|
or not required.issubset(parsed_value)
|
|
or not set(parsed_value).issubset(allowed)
|
|
):
|
|
return None
|
|
|
|
# Some providers continue after a complete root object with a second copy
|
|
# of its presentation metadata. Decode that suffix as its own object;
|
|
# never use a regex to parse nested JSON. The CDSL payload itself may not
|
|
# reappear, so the executable model always comes from the first object.
|
|
suffix = source[parsed_end:]
|
|
if not suffix.startswith(","):
|
|
return None
|
|
try:
|
|
duplicate, duplicate_end = json.JSONDecoder().raw_decode("{" + suffix[1:])
|
|
except json.JSONDecodeError:
|
|
return None
|
|
if (
|
|
duplicate_end != len(suffix)
|
|
or not isinstance(duplicate, dict)
|
|
or not duplicate
|
|
or not set(duplicate).issubset({"summary", "assumptions", "verification"})
|
|
):
|
|
return None
|
|
return parsed_value
|
|
|
|
|
|
def parse_tool_arguments(raw_arguments: Any, *, recover_cdsl_wrapper: bool = False) -> dict[str, Any]:
|
|
"""Decode one function-call argument object, with guarded CDSL repairs."""
|
|
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
|
|
repaired = _recover_trailing_cdsl_metadata(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}. "
|
|
"Regenerate the same tool call with exactly one complete JSON object, "
|
|
"from its first `{` through its final `}`. Do not repeat any fields "
|
|
"or append prose, Markdown fences, or another JSON value."
|
|
),
|
|
}
|
|
|
|
|
|
def _cdsl_error_details(error: Exception) -> dict[str, Any]:
|
|
message = str(error)
|
|
known_codes = (
|
|
"FEATURE_PLAN_INVALID", "FEATURE_PLAN_CYCLE", "TOPOLOGY_REQUIRED", "TOPOLOGY_NOT_AVAILABLE",
|
|
"TOPOLOGY_SNAPSHOT_STALE", "SELECTOR_CONTEXT_REQUIRED", "SELECTOR_NOT_FOUND",
|
|
"SELECTOR_AMBIGUOUS", "SELECTOR_GEOMETRY_MISMATCH", "FEATURE_NOT_READY", "COMPLETED_FEATURE_MUTATION",
|
|
"INVALID_CDSL_PATCH",
|
|
)
|
|
explicit_code = next((code for code in known_codes if code in message), "")
|
|
if explicit_code:
|
|
if explicit_code == "INVALID_CDSL_PATCH":
|
|
return {
|
|
"code": explicit_code,
|
|
"path": "$",
|
|
"kind": "patch",
|
|
"repair_instruction": "Read the current CDSL and apply a path that exists in base_revision_id; use generate_cdsl_model for structural changes.",
|
|
}
|
|
if explicit_code == "FEATURE_NOT_READY":
|
|
return {
|
|
"code": explicit_code,
|
|
"path": "$.features",
|
|
"kind": "feature_plan",
|
|
"repair_instruction": "Keep completed features unchanged and generate only the listed allowed_feature_ids from the current ready plan batch.",
|
|
}
|
|
return {
|
|
"code": explicit_code,
|
|
"path": "$",
|
|
"kind": "topology" if "SELECTOR" in explicit_code or "TOPOLOGY" in explicit_code else "feature_plan",
|
|
"repair_instruction": "Use the current topology snapshot and a valid ready feature batch, then retry.",
|
|
}
|
|
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", "FEATURE_NOT_READY", "INVALID_CDSL_PATCH"}:
|
|
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"}
|
|
MAX_AGENT_TOOL_ITERATIONS = 16
|
|
|
|
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", "minLength": 1},
|
|
"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"],
|
|
"allOf": [
|
|
{
|
|
"if": {"properties": {"type": {"enum": sorted(FEATURE_RULE_TYPES)}}},
|
|
"then": {"required": ["feature"]},
|
|
},
|
|
],
|
|
"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),
|
|
}
|
|
|
|
|
|
IMAGE_SEGMENT_SCHEMA: dict[str, Any] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"type": {"enum": ["line", "arc", "circle", "polyline", "unknown_curve"]},
|
|
"start": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
|
|
"end": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
|
|
"center": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
|
|
"radius_mm": {"type": "number", "exclusiveMinimum": 0},
|
|
"clockwise": {"type": "boolean"},
|
|
"points": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "maxItems": 256},
|
|
"image_start": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
|
|
"image_end": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
|
|
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
|
"notes": {"type": "string", "maxLength": 300},
|
|
},
|
|
"required": ["type"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
IMAGE_PROFILE_SCHEMA: dict[str, Any] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string", "minLength": 1, "maxLength": 80},
|
|
"role": {"type": "string", "maxLength": 40},
|
|
"plane_hint": {"type": "string", "maxLength": 120},
|
|
"closed": {"type": "boolean"},
|
|
"coordinate_space": {"type": "string", "maxLength": 40},
|
|
"segments": {"type": "array", "maxItems": 256, "items": IMAGE_SEGMENT_SCHEMA},
|
|
"source_images": {"type": "array", "maxItems": 12, "items": {"type": "string"}},
|
|
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
|
"uncertain": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 300}},
|
|
"notes": {"type": "string", "maxLength": 300},
|
|
},
|
|
"required": ["id", "segments"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
IMAGE_MEASUREMENT_SCHEMA: dict[str, Any] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string", "minLength": 1, "maxLength": 120},
|
|
"value_mm": {"type": "number"},
|
|
"min_mm": {"type": "number"},
|
|
"max_mm": {"type": "number"},
|
|
"source": {"enum": ["user", "image", "cv", "assumption"]},
|
|
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
|
"evidence": {"type": "string", "maxLength": 300},
|
|
"source_images": {"type": "array", "maxItems": 12, "items": {"type": "string"}},
|
|
},
|
|
"required": ["name"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
IMAGE_OBSERVATION_PROPERTIES: dict[str, Any] = {
|
|
"attachment_ids": {"type": "array", "maxItems": 12, "items": {"type": "string"}},
|
|
"part_type": {"type": "string", "minLength": 1, "maxLength": 300},
|
|
"visible_features": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 300}},
|
|
"uncertain_features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}},
|
|
"views": {"type": "array", "maxItems": 12, "items": {"type": "object", "properties": {
|
|
"attachment_id": {"type": "string"}, "view_role": {"type": "string"}, "orientation": {"type": "string"},
|
|
"visible_regions": {"type": "array", "items": {"type": "string"}}, "occluded_regions": {"type": "array", "items": {"type": "string"}},
|
|
"quality": {"type": "string"}, "scale_reference_id": {"type": "string"}, "confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
|
}, "required": ["attachment_id"], "additionalProperties": False}},
|
|
"scale_references": {"type": "array", "maxItems": 12, "items": {"type": "object"}},
|
|
"overall_geometry": {"type": "object"},
|
|
"surfaces": {"type": "array", "maxItems": 24, "items": {"type": "object"}},
|
|
"profiles": {"type": "array", "maxItems": 32, "items": IMAGE_PROFILE_SCHEMA},
|
|
"holes": {"type": "array", "maxItems": 64, "items": {"type": "object"}},
|
|
"bends": {"type": "array", "maxItems": 16, "items": {"type": "object"}},
|
|
"measurements": {"type": "array", "maxItems": 128, "items": IMAGE_MEASUREMENT_SCHEMA},
|
|
"uncertainties": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}},
|
|
"assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}},
|
|
"cv_hints": {"type": "array", "maxItems": 32, "items": {"type": "object"}},
|
|
}
|
|
|
|
TOOL_SCHEMAS: list[dict[str, Any]] = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "analyze_image_reference",
|
|
"description": (
|
|
"Perform a complete multi-view CAD image survey. Identify every visible plane, bend, "
|
|
"outer profile, hole, slot, irregular cutout, scale reference, estimated measurement, "
|
|
"occlusion, and uncertainty. Preserve geometry evidence; do not omit an uncertain profile."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": IMAGE_OBSERVATION_PROPERTIES,
|
|
"required": ["part_type", "visible_features", "uncertain_features", "views", "profiles", "measurements", "uncertainties"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "extract_image_sketch_candidates",
|
|
"description": "Convert the complete image survey into candidate 2D sketch profiles for outer faces and irregular openings. Keep polyline or unknown curves when line/arc decomposition is uncertain.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"part_type": {"type": "string", "maxLength": 300},
|
|
"profiles": {"type": "array", "maxItems": 32, "items": IMAGE_PROFILE_SCHEMA},
|
|
"measurements": {"type": "array", "maxItems": 128, "items": IMAGE_MEASUREMENT_SCHEMA},
|
|
"visible_features": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 300}},
|
|
"uncertain_features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}},
|
|
"uncertainties": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}},
|
|
"assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}},
|
|
"cv_hints": {"type": "array", "maxItems": 32, "items": {"type": "object"}},
|
|
},
|
|
"required": ["profiles", "measurements", "uncertainties"],
|
|
"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,
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
PLANNING_TOOL_SCHEMAS: list[dict[str, Any]] = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "plan_feature_tree",
|
|
"description": "Validate and store an acyclic semantic feature plan. This is planning data, not executable CDSL; never invent selectors.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"plan_id": {"type": "string", "minLength": 1},
|
|
"nodes": {
|
|
"type": "array",
|
|
"minItems": 1,
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string", "minLength": 1},
|
|
"intent": {"type": "string"},
|
|
"atomic_id": {"type": "string", "minLength": 1},
|
|
"depends_on": {"type": "array", "items": {"type": "string"}},
|
|
"requires_topology": {"type": "boolean"},
|
|
"topology_query": {"type": "object"},
|
|
"cdsl_feature_ids": {"type": "array", "items": {"type": "string"}},
|
|
},
|
|
"required": ["id", "atomic_id", "depends_on"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
"replan": {
|
|
"type": "object",
|
|
"properties": {
|
|
"replace_nodes": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
|
"reason": {"type": "string", "minLength": 1},
|
|
"alternatives": {"type": "array", "items": {"type": "string"}},
|
|
},
|
|
"required": ["replace_nodes", "reason", "alternatives"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
"required": ["plan_id", "nodes"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "inspect_current_topology",
|
|
"description": "Query real executable face/edge/vertex records from the current successful revision. Returned selectors may be copied into CDSL.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"kind": {"enum": ["face", "edge", "vertex", "body", "plane", "axis"]},
|
|
"feature_id": {"type": "string"},
|
|
"owner_feature_id": {"type": "string"},
|
|
"surface_type": {"type": "string"},
|
|
"curve_type": {"type": "string"},
|
|
"position_hint": {"type": "string"},
|
|
"position": {"type": "string"},
|
|
"bbox_mm": {"type": "array", "items": {"type": "number"}, "minItems": 6, "maxItems": 6},
|
|
"length_range_mm": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
|
|
"area_range_mm2": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
|
|
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
|
|
"cursor": {"type": "integer", "minimum": 0},
|
|
},
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def tools_for_model(
|
|
model: ProviderModel,
|
|
*,
|
|
include_image_analysis: bool = True,
|
|
include_image_sketches: bool = False,
|
|
image_stage: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Return this model's tool contract without mutating the shared schema."""
|
|
tools = deepcopy(TOOL_SCHEMAS + PLANNING_TOOL_SCHEMAS)
|
|
if not include_image_analysis:
|
|
tools = [
|
|
tool
|
|
for tool in tools
|
|
if tool.get("function", {}).get("name") != "analyze_image_reference"
|
|
]
|
|
if not include_image_sketches:
|
|
tools = [
|
|
tool
|
|
for tool in tools
|
|
if tool.get("function", {}).get("name") != "extract_image_sketch_candidates"
|
|
]
|
|
if image_stage in {"survey", "sketch"}:
|
|
required_name = "analyze_image_reference" if image_stage == "survey" else "extract_image_sketch_candidates"
|
|
tools = [tool for tool in tools if tool.get("function", {}).get("name") == required_name]
|
|
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 ""),
|
|
"width": int(attachment.get("width") or 0),
|
|
"height": int(attachment.get("height") or 0),
|
|
})
|
|
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 the latest complete survey for 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) and data.get("observationStage", "complete") == "complete":
|
|
return data
|
|
return None
|
|
|
|
|
|
def image_reference_stage(conversation: dict[str, Any]) -> str:
|
|
"""Return the next image-intake stage while retaining legacy analyses."""
|
|
attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)}
|
|
if not attachment_ids:
|
|
return "complete"
|
|
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 not attachment_ids.issubset(analyzed_ids):
|
|
continue
|
|
stage = str(data.get("observationStage") or "complete")
|
|
if stage == "survey":
|
|
return "sketch"
|
|
if stage in {"sketch", "complete"}:
|
|
return "complete"
|
|
return "survey"
|
|
|
|
|
|
def image_reference_observation_part(conversation: dict[str, Any], stage: str) -> dict[str, Any] | None:
|
|
attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)}
|
|
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) and str(data.get("observationStage") or "complete") == stage:
|
|
return data
|
|
return None
|
|
|
|
|
|
def image_reference_instruction(
|
|
attachments: list[dict[str, Any]],
|
|
analysis: dict[str, Any] | None,
|
|
stage: str = "complete",
|
|
) -> str:
|
|
if not attachments:
|
|
return ""
|
|
if stage == "survey":
|
|
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.
|
|
- Build a complete structured multi-view survey: views, surfaces, bends, outer profiles,
|
|
holes, irregular openings, scale references, measurements, CV hints, and uncertainties.
|
|
- Preserve uncertain profiles as polyline or unknown_curve evidence instead of dropping them.
|
|
- Describe only what is visible. Do not present image estimates as user-verified dimensions.
|
|
- The backend will present the structured result and provide it back as
|
|
visual-reference context for you to continue this same task.
|
|
"""
|
|
if stage == "sketch":
|
|
return """
|
|
Image survey is recorded for the current attachments. Your only tool call in this turn
|
|
must be extract_image_sketch_candidates. Use the survey and all attached images to
|
|
produce outer-face and irregular-opening profiles. Prefer line, arc, and circle segments;
|
|
retain polyline or unknown_curve segments when the image does not justify a primitive.
|
|
Include source image ids, coordinate evidence, confidence, measurements, and unresolved
|
|
parameters. Do not call CAD planning or generation tools yet.
|
|
"""
|
|
return """
|
|
Image-reference analysis already recorded for the current attachments:
|
|
{data}
|
|
Use this complete survey and the sketch candidates as visual-reference context.
|
|
Do not silently discard visible profiles or openings. User-provided dimensions override
|
|
image, CV, or assumption estimates; preserve uncertain values as assumptions.
|
|
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=render_image_observation_context(analysis))
|
|
|
|
|
|
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 image_observation_payload(observation: dict[str, Any], *, stage: str, artifact_path: str = "") -> dict[str, Any]:
|
|
"""Map the persisted snake_case observation to the existing UI data part."""
|
|
dimensions = [
|
|
{
|
|
"id": str(item.get("name") or f"measurement_{index}"),
|
|
"label": str(item.get("name") or "尺寸"),
|
|
"reason": str(item.get("evidence") or "图片或模型估算"),
|
|
}
|
|
for index, item in enumerate(observation.get("measurements") or [])
|
|
if isinstance(item, dict)
|
|
]
|
|
return {
|
|
"observationStage": stage,
|
|
"schemaVersion": observation.get("schema_version", "cad.image-observation.v2"),
|
|
"attachmentIds": observation.get("attachment_ids") or [],
|
|
"partType": observation.get("part_type") or "",
|
|
"visibleFeatures": observation.get("visible_features") or [],
|
|
"uncertainFeatures": observation.get("uncertain_features") or [],
|
|
"dimensionCandidates": dimensions,
|
|
"views": observation.get("views") or [],
|
|
"scaleReferences": observation.get("scale_references") or [],
|
|
"overallGeometry": observation.get("overall_geometry") or {},
|
|
"surfaces": observation.get("surfaces") or [],
|
|
"profiles": observation.get("profiles") or [],
|
|
"holes": observation.get("holes") or [],
|
|
"bends": observation.get("bends") or [],
|
|
"measurements": observation.get("measurements") or [],
|
|
"uncertainties": observation.get("uncertainties") or [],
|
|
"assumptions": observation.get("assumptions") or [],
|
|
"cvHints": observation.get("cv_hints") or [],
|
|
"artifactPath": artifact_path or None,
|
|
}
|
|
|
|
|
|
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")),
|
|
"snapshotId": _viewer_selection_text(value.get("snapshotId"), 160),
|
|
"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 _selector_values(value: Any) -> list[dict[str, Any]]:
|
|
found: list[dict[str, Any]] = []
|
|
if isinstance(value, dict):
|
|
if value.get("kind") and value.get("stable_id") and value.get("source"):
|
|
found.append(value)
|
|
for child in value.values():
|
|
found.extend(_selector_values(child))
|
|
elif isinstance(value, list):
|
|
for child in value:
|
|
found.extend(_selector_values(child))
|
|
return found
|
|
|
|
|
|
def _numeric_range(value: Any, field: str) -> tuple[float, float] | None:
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, list) or len(value) != 2:
|
|
raise ValueError(f"{field} must contain exactly two numbers")
|
|
try:
|
|
left, right = float(value[0]), float(value[1])
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError(f"{field} must contain numbers") from error
|
|
if left > right:
|
|
raise ValueError(f"{field} minimum must not exceed maximum")
|
|
return left, right
|
|
|
|
|
|
def _validate_snapshot_selectors(store: Any, task_id: str, cdsl: dict[str, Any]) -> None:
|
|
"""Validate runtime/viewer selectors against their own task revision snapshot.
|
|
|
|
A completed feature keeps the selector provenance from the revision in which
|
|
it was created. Requiring every selector in a later CDSL revision to point
|
|
at the newest snapshot incorrectly rejects those immutable historical
|
|
selectors before a new feature can be appended.
|
|
"""
|
|
if not task_id:
|
|
return
|
|
task = store.read_task(task_id) or {}
|
|
current_revision = str(task.get("current_revision") or "")
|
|
if not current_revision:
|
|
return
|
|
topology_path = store.current_topology_path(task_id)
|
|
current_snapshot = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None
|
|
current_snapshot_id = str((current_snapshot or {}).get("snapshot_id") or f"{task_id}/{current_revision}")
|
|
snapshots: dict[str, dict[str, Any]] = {current_snapshot_id: current_snapshot or {}}
|
|
|
|
def snapshot_for_selector(snapshot_id: str) -> dict[str, Any] | None:
|
|
if snapshot_id in snapshots:
|
|
return snapshots[snapshot_id]
|
|
prefix = f"{task_id}/"
|
|
if not snapshot_id.startswith(prefix):
|
|
return None
|
|
revision_id = snapshot_id[len(prefix):]
|
|
if not revision_id:
|
|
return None
|
|
revision = next(
|
|
(item for item in task.get("revisions") or ()
|
|
if isinstance(item, dict) and str(item.get("revision_id") or "") == revision_id),
|
|
None,
|
|
)
|
|
if not isinstance(revision, dict) or revision.get("status") != "success":
|
|
return None
|
|
historical_path = store.revision_topology_path(task_id, revision_id)
|
|
if historical_path is None or not historical_path.is_file():
|
|
return None
|
|
try:
|
|
historical = json.loads(historical_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
snapshots[snapshot_id] = historical
|
|
return historical
|
|
|
|
for selector in _selector_values(cdsl):
|
|
source = str(selector.get("source") or "")
|
|
if source not in {"runtime_snapshot", "viewer_selection"}:
|
|
continue
|
|
snapshot_id = str(selector.get("snapshot_id") or "")
|
|
snapshot = snapshot_for_selector(snapshot_id)
|
|
if snapshot is None:
|
|
raise ValueError("TOPOLOGY_SNAPSHOT_STALE: selector does not belong to a known task revision")
|
|
records = {str(item.get("record_id")): item for item in snapshot.get("records") or () if isinstance(item, dict)}
|
|
record = records.get(str(selector.get("stable_id") or ""))
|
|
if not record or record.get("executable") is False:
|
|
raise ValueError("SELECTOR_NOT_FOUND: selector record is not present in the referenced topology snapshot")
|
|
if str(selector.get("kind")) != str(record.get("kind")):
|
|
raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector kind differs from topology record")
|
|
owner = selector.get("owner_feature_id")
|
|
owners = {str(item) for item in record.get("owner_feature_ids") or ()}
|
|
if owner and str(owner) not in owners:
|
|
raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector owner differs from topology record")
|
|
expected = selector.get("geometry") if isinstance(selector.get("geometry"), dict) else {}
|
|
actual = record.get("geometry") if isinstance(record.get("geometry"), dict) else {}
|
|
for key in ("curve_type", "surface_type"):
|
|
if key in expected and expected.get(key) != actual.get(key):
|
|
raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record")
|
|
for key in ("center_mm", "normal", "plane_normal", "start_mm", "end_mm"):
|
|
if key not in expected:
|
|
continue
|
|
left, right = expected.get(key), actual.get(key)
|
|
if not isinstance(left, (list, tuple)) or not isinstance(right, (list, tuple)) or len(left) != len(right):
|
|
raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record")
|
|
try:
|
|
mismatch = any(abs(float(a) - float(b)) > 1e-5 for a, b in zip(left, right))
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} is not numeric") from error
|
|
if mismatch:
|
|
raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record")
|
|
if "bbox_mm" in expected:
|
|
left, right = expected.get("bbox_mm"), actual.get("bbox_mm")
|
|
if not isinstance(left, (list, tuple)) or not isinstance(right, (list, tuple)) or len(left) != len(right):
|
|
raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm differs from topology record")
|
|
try:
|
|
mismatch = any(abs(float(a) - float(b)) > 1e-5 for a, b in zip(left, right))
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm is not numeric") from error
|
|
if mismatch:
|
|
raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm differs from topology record")
|
|
for key in ("length_mm", "area_mm2"):
|
|
if key in expected and key in actual:
|
|
try:
|
|
expected_value = float(expected[key])
|
|
actual_value = float(actual[key])
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} is not numeric") from error
|
|
if abs(expected_value - actual_value) > max(1e-5, abs(actual_value) * 1e-5):
|
|
raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record")
|
|
|
|
|
|
def _topology_query_result(store: Any, task_id: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
if not task_id:
|
|
return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "No current CAD task exists."}
|
|
task = store.read_task(task_id) or {}
|
|
revision_id = str(task.get("current_revision") or "")
|
|
path = store.current_topology_path(task_id)
|
|
if not revision_id or path is None or not path.is_file():
|
|
return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "The current task has no successful topology snapshot."}
|
|
snapshot = json.loads(path.read_text(encoding="utf-8"))
|
|
records = [record for record in snapshot.get("records") or () if isinstance(record, dict) and record.get("executable", True) is not False]
|
|
kind = str(arguments.get("kind") or "")
|
|
if kind:
|
|
records = [record for record in records if record.get("kind") == kind]
|
|
for key in ("feature_id", "owner_feature_id"):
|
|
value = str(arguments.get(key) or "")
|
|
if value:
|
|
records = [record for record in records if record.get("feature_id") == value or value in (record.get("owner_feature_ids") or [])]
|
|
for key in ("surface_type", "curve_type"):
|
|
value = str(arguments.get(key) or "")
|
|
if value:
|
|
records = [record for record in records if (record.get("geometry") or {}).get(key) == value]
|
|
position_hint = str(arguments.get("position_hint") or arguments.get("position") or "").strip().casefold()
|
|
if position_hint:
|
|
position_axis = {"top": 2, "upper": 2, "highest": 2, "bottom": 2, "lower": 2, "lowest": 2,
|
|
"left": 0, "right": 0, "front": 1, "back": 1}.get(position_hint)
|
|
position_values: list[float] = []
|
|
if position_axis is not None:
|
|
for candidate in records:
|
|
candidate_center = (candidate.get("geometry") or {}).get("center_mm")
|
|
if isinstance(candidate_center, (list, tuple)) and len(candidate_center) > position_axis:
|
|
try:
|
|
position_values.append(float(candidate_center[position_axis]))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
target = None
|
|
if position_values and position_axis is not None:
|
|
target = min(position_values) if position_hint in {"bottom", "lower", "lowest", "left", "front"} else max(position_values)
|
|
def matches_position(record: dict[str, Any]) -> bool:
|
|
geometry = record.get("geometry") or {}
|
|
center = geometry.get("center_mm")
|
|
bbox = geometry.get("bbox_mm")
|
|
if not isinstance(center, (list, tuple)) or len(center) < 3:
|
|
return False
|
|
try:
|
|
x, y, z = (float(center[index]) for index in range(3))
|
|
except (TypeError, ValueError):
|
|
return False
|
|
if position_hint in {"top", "upper", "highest", "bottom", "lower", "lowest"}:
|
|
return target is not None and abs(z - target) <= 1e-5
|
|
if position_hint in {"left", "right", "front", "back"}:
|
|
axis = {"left": 0, "right": 0, "front": 1, "back": 1}[position_hint]
|
|
sign = {"left": -1, "right": 1, "front": 1, "back": -1}[position_hint]
|
|
value = x if axis == 0 else y
|
|
return target is not None and abs(value - target) <= 1e-5
|
|
return str(geometry.get("position_hint") or "").casefold() == position_hint
|
|
records = [record for record in records if matches_position(record)]
|
|
bbox_filter = arguments.get("bbox_mm")
|
|
if bbox_filter is not None:
|
|
if isinstance(bbox_filter, dict) and isinstance(bbox_filter.get("min"), list) and isinstance(bbox_filter.get("max"), list):
|
|
bbox_filter = [*bbox_filter["min"], *bbox_filter["max"]]
|
|
if not isinstance(bbox_filter, list) or len(bbox_filter) != 6:
|
|
raise ValueError("bbox_mm must contain exactly six numbers")
|
|
try:
|
|
query_bbox = tuple(float(value) for value in bbox_filter)
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError("bbox_mm must contain numbers") from error
|
|
if query_bbox[0] > query_bbox[3] or query_bbox[1] > query_bbox[4] or query_bbox[2] > query_bbox[5]:
|
|
raise ValueError("bbox_mm minimums must not exceed maximums")
|
|
def intersects(record: dict[str, Any]) -> bool:
|
|
actual = (record.get("geometry") or {}).get("bbox_mm")
|
|
if not isinstance(actual, (list, tuple)) or len(actual) != 6:
|
|
return False
|
|
try:
|
|
values = tuple(float(value) for value in actual)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return all(values[index] <= query_bbox[index + 3] and query_bbox[index] <= values[index + 3] for index in range(3))
|
|
records = [record for record in records if intersects(record)]
|
|
length_range = _numeric_range(arguments.get("length_range_mm"), "length_range_mm")
|
|
area_range = _numeric_range(arguments.get("area_range_mm2"), "area_range_mm2")
|
|
if length_range:
|
|
records = [record for record in records if length_range[0] <= float((record.get("geometry") or {}).get("length_mm", -1)) <= length_range[1]]
|
|
if area_range:
|
|
records = [record for record in records if area_range[0] <= float((record.get("geometry") or {}).get("area_mm2", -1)) <= area_range[1]]
|
|
offset = max(0, int(arguments.get("cursor") or 0))
|
|
limit = min(50, max(1, int(arguments.get("limit") or 20)))
|
|
selected = records[offset:offset + limit]
|
|
output_records = []
|
|
for record in selected:
|
|
selector = {
|
|
"kind": record["kind"],
|
|
"stable_id": record["record_id"],
|
|
"source": "runtime_snapshot",
|
|
"confidence": 1.0,
|
|
"snapshot_id": snapshot.get("snapshot_id") or f"{task_id}/{revision_id}",
|
|
"geometry": record.get("geometry") or {},
|
|
}
|
|
owners = record.get("owner_feature_ids") or []
|
|
if owners:
|
|
selector["owner_feature_id"] = owners[0]
|
|
output_records.append({**record, "selector": selector})
|
|
return {
|
|
"ok": True,
|
|
"task_id": task_id,
|
|
"revision_id": revision_id,
|
|
"snapshot_id": snapshot.get("snapshot_id") or f"{task_id}/{revision_id}",
|
|
"records": output_records,
|
|
"next_cursor": offset + len(output_records) if offset + len(output_records) < len(records) else None,
|
|
}
|
|
|
|
|
|
def _validate_plan_cdsl_transition(store: Any, task_id: str, plan: dict[str, Any] | None, cdsl: dict[str, Any]) -> None:
|
|
if not isinstance(plan, dict):
|
|
return
|
|
current_path = store.current_cdsl_path(task_id) if task_id else None
|
|
current = json.loads(current_path.read_text(encoding="utf-8")) if current_path and current_path.is_file() else {}
|
|
current_features = {str(item.get("id")): item for item in current.get("features") or () if isinstance(item, dict)}
|
|
next_features = {str(item.get("id")): item for item in cdsl.get("features") or () if isinstance(item, dict)}
|
|
completed_ids = {
|
|
str(feature_id)
|
|
for node in plan.get("nodes") or ()
|
|
if isinstance(node, dict) and node.get("status") in {"completed", "executed"}
|
|
for feature_id in node.get("cdsl_feature_ids") or ()
|
|
}
|
|
for feature_id in completed_ids:
|
|
if feature_id not in next_features or next_features[feature_id] != current_features.get(feature_id):
|
|
raise ValueError(f"COMPLETED_FEATURE_MUTATION: completed feature {feature_id} cannot be removed or rewritten")
|
|
allowed_ids = {
|
|
str(feature_id)
|
|
for node in plan.get("nodes") or ()
|
|
if isinstance(node, dict) and node.get("status") in {"ready", "executing"}
|
|
for feature_id in node.get("cdsl_feature_ids") or ()
|
|
}
|
|
for feature_id in next_features:
|
|
if feature_id not in current_features and feature_id not in allowed_ids:
|
|
ready_nodes = [
|
|
str(node.get("id"))
|
|
for node in plan.get("nodes") or ()
|
|
if isinstance(node, dict) and node.get("status") == "ready"
|
|
]
|
|
allowed = sorted(allowed_ids)
|
|
raise ValueError(
|
|
f"FEATURE_NOT_READY: feature {feature_id} is not in the current ready plan batch; "
|
|
f"allowed_feature_ids={allowed}; ready_nodes={ready_nodes}"
|
|
)
|
|
|
|
|
|
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 multi-stage CAD request, call plan_feature_tree after describe_design_intent. The plan is a DAG of semantic features; never put invented edge/face IDs in it.
|
|
- Generate only the current ready feature batch. The server automatically binds a successful build's topology snapshot; call inspect_current_topology to retrieve exact selector candidates before adding topology-dependent features.
|
|
- Copy selectors only from inspect_current_topology or a trusted viewer selection. A screenshot is not an exact topology selector source, but its measured image survey and sketch candidates may guide profile geometry.
|
|
- Keep completed CDSL features unchanged and use patch_cdsl_model for later feature batches.
|
|
- If a selector or kernel failure blocks a node, use plan_feature_tree with `replan` to replace only that node and its downstream subtree.
|
|
- 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.
|
|
- Verification types include bbox, overall_length, overall_width, overall_height, overall_diameter, hole_count, hole_diameter, hole_center, through_condition, solid_count, and feature_count. Overall width and height measure the runtime Y and Z bbox dimensions. Feature-scoped rules (hole_count, hole_diameter, hole_center, through_condition) must include the exact CDSL feature ID.
|
|
- 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. For a multi-feature request, call plan_feature_tree and then generate only its ready batch.
|
|
5. After each successful build, inspect topology when the plan has waiting topology-dependent nodes, then patch the existing CDSL.
|
|
6. On a schema, runtime, selector, or verification failure, repair only the affected feature/subtree.
|
|
7. Never claim success unless the final plan is complete and 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))
|
|
# The generation worker outlives an individual SSE response. The
|
|
# durable task lifecycle remains the cross-process source of truth;
|
|
# this map only owns live event delivery in the current process.
|
|
self._incremental_runs: dict[str, asyncio.Task[None]] = {}
|
|
|
|
async def resume_running_tasks(self) -> None:
|
|
"""Reattach process-local workers to persisted incremental runs.
|
|
|
|
A browser disconnect is already independent from the worker. This
|
|
recovery path additionally prevents an application restart from
|
|
stranding a durable task in ``running``. The original frozen provider,
|
|
model, messages, and selected part skills are read from the task, not
|
|
from mutable conversation state.
|
|
"""
|
|
if not self.settings.incremental_generation:
|
|
return
|
|
for task in self.store.running_tasks():
|
|
task_id = str(task.get("task_id") or "")
|
|
if not task_id or task_id in self._incremental_runs:
|
|
continue
|
|
context = self.store.read_generation_run_context(task_id)
|
|
if not isinstance(context, dict):
|
|
self.store.finish_generation(task_id, lifecycle="failed", failure={
|
|
"schema_version": "cad.generation-failure.v1",
|
|
"stage": "recovery",
|
|
"message": "The frozen generation context is unavailable after restart",
|
|
})
|
|
continue
|
|
conversation_id = str(context.get("conversation_id") or "")
|
|
conversation = self.store.read_conversation(conversation_id) if conversation_id else None
|
|
author_messages = context.get("author_messages")
|
|
part_skills = context.get("part_skills")
|
|
if not isinstance(conversation, dict) or not isinstance(author_messages, list) or not isinstance(part_skills, dict):
|
|
self.store.finish_generation(task_id, lifecycle="failed", failure={
|
|
"schema_version": "cad.generation-failure.v1",
|
|
"stage": "recovery",
|
|
"message": "The frozen conversation context is invalid after restart",
|
|
})
|
|
continue
|
|
try:
|
|
provider, model = self.settings.resolve_model(
|
|
str(context.get("provider_id") or ""), str(context.get("model_id") or ""),
|
|
)
|
|
except ValueError as error:
|
|
self.store.finish_generation(task_id, lifecycle="failed", failure={
|
|
"schema_version": "cad.generation-failure.v1", "stage": "recovery", "message": str(error),
|
|
})
|
|
continue
|
|
assistant_id = str(context.get("assistant_id") or f"assistant_{secrets.token_hex(8)}")
|
|
request = str(context.get("request") or task.get("request") or "")
|
|
runner = IncrementalGenerationRunner(self.settings, self.store, self._complete)
|
|
|
|
async def consume(
|
|
*, task_id: str = task_id, request: str = request, conversation: dict[str, Any] = conversation,
|
|
provider: ProviderConfig = provider, model: ProviderModel = model,
|
|
author_messages: list[dict[str, Any]] = author_messages, part_skills: dict[str, Any] = part_skills,
|
|
assistant_id: str = assistant_id, runner: IncrementalGenerationRunner = runner,
|
|
) -> None:
|
|
parts: list[dict[str, Any]] = []
|
|
terminal = ""
|
|
try:
|
|
async for name, payload in runner.run(
|
|
task_id=task_id, request=request, conversation=conversation, provider=provider, model=model,
|
|
author_messages=author_messages, part_skills=part_skills, already_started=True,
|
|
):
|
|
if name == "cad_result":
|
|
parts.append({"type": "data-cad-result", "data": payload})
|
|
elif name == "task_terminal":
|
|
terminal = str(payload.get("lifecycle") or "")
|
|
if terminal == "failed":
|
|
parts.append({"type": "data-cad-error", "data": {
|
|
"stage": "generation", "message": str(payload.get("message") or "CAD 增量生成失败。"),
|
|
}})
|
|
except Exception as error:
|
|
self.store.finish_generation(task_id, lifecycle="failed", failure={
|
|
"schema_version": "cad.generation-failure.v1", "stage": "recovery_worker", "message": str(error),
|
|
})
|
|
terminal = "failed"
|
|
parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}})
|
|
finally:
|
|
if not parts and terminal == "completed":
|
|
parts.append({"type": "text", "text": "CAD 模型已完成。"})
|
|
self._persist_assistant(conversation["conversation_id"], assistant_id, parts, task_id)
|
|
self._incremental_runs.pop(task_id, None)
|
|
|
|
self._incremental_runs[task_id] = asyncio.create_task(consume(), name=f"resume-incremental-cdsl-{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]:
|
|
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
|
|
if task_id and current_task and str(current_task.get("lifecycle") or "") == "running":
|
|
# Do not append the attempted turn: the run's request and
|
|
# attachments are immutable until a terminal lifecycle state.
|
|
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
|
|
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
|
|
|
|
# The persistent node-by-node orchestrator is opt-in while existing
|
|
# installations migrate their review-model and Chromium configuration.
|
|
# Once enabled, every new request is frozen and legacy authoring tools
|
|
# are not exposed for that run.
|
|
if self.settings.incremental_generation:
|
|
if not task_id:
|
|
created = self.store.ensure_task(None, user_text)
|
|
task_id = str(created["task_id"])
|
|
current_task = created
|
|
self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id)
|
|
author_messages: list[dict[str, Any]] = [{
|
|
"role": "system",
|
|
"content": (
|
|
"You are an incremental CDSL CAD author. The user request is frozen for this run. "
|
|
"Missing dimensions must become explicit assumptions. Use only the requested function tool; do not emit prose or raw CAD source."
|
|
),
|
|
}]
|
|
author_messages.extend(messages_for_model(messages))
|
|
if attachment_message:
|
|
author_messages.append({"role": "user", "content": attachment_message})
|
|
runner = IncrementalGenerationRunner(self.settings, self.store, self._complete)
|
|
part_skills = self.part_skill_library.audit(self.part_skill_library.select(user_text), {}, [])
|
|
# Acquire the durable run lock before scheduling work so a second
|
|
# request cannot slip in during the first model call.
|
|
self.store.start_generation(task_id, request=user_text)
|
|
self.store.write_generation_run_context(task_id, {
|
|
"schema_version": "cad.generation-run-context.v1",
|
|
"request": user_text,
|
|
"conversation_id": conversation["conversation_id"],
|
|
"provider_id": provider.id,
|
|
"model_id": model.id,
|
|
"assistant_id": assistant_id,
|
|
"author_messages": author_messages,
|
|
"part_skills": part_skills,
|
|
})
|
|
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
|
|
|
|
async def consume_incremental_run() -> None:
|
|
run_parts: list[dict[str, Any]] = []
|
|
terminal = ""
|
|
try:
|
|
async for name, event_payload in runner.run(
|
|
task_id=task_id,
|
|
request=user_text,
|
|
conversation=conversation,
|
|
provider=provider,
|
|
model=model,
|
|
author_messages=author_messages,
|
|
part_skills=part_skills,
|
|
already_started=True,
|
|
):
|
|
if name == "cad_result":
|
|
run_parts.append({"type": "data-cad-result", "data": event_payload})
|
|
elif name == "task_terminal":
|
|
terminal = str(event_payload.get("lifecycle") or "")
|
|
if terminal == "failed":
|
|
run_parts.append({
|
|
"type": "data-cad-error",
|
|
"data": {"stage": "generation", "message": str(event_payload.get("message") or "CAD 增量生成失败。")},
|
|
})
|
|
await queue.put((name, event_payload))
|
|
except Exception as error: # runner normally converts failures to a terminal event
|
|
self.store.finish_generation(task_id, lifecycle="failed", failure={
|
|
"schema_version": "cad.generation-failure.v1", "message": str(error), "stage": "worker",
|
|
})
|
|
terminal = "failed"
|
|
payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)}
|
|
run_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}})
|
|
await queue.put(("task_terminal", payload))
|
|
finally:
|
|
if not run_parts and terminal == "completed":
|
|
run_parts.append({"type": "text", "text": "CAD 模型已完成。"})
|
|
self._persist_assistant(conversation["conversation_id"], assistant_id, run_parts, task_id)
|
|
self._incremental_runs.pop(task_id, None)
|
|
await queue.put(None)
|
|
|
|
worker = asyncio.create_task(consume_incremental_run(), name=f"incremental-cdsl-{task_id}")
|
|
self._incremental_runs[task_id] = worker
|
|
while True:
|
|
queued = await queue.get()
|
|
if queued is None:
|
|
break
|
|
name, event_payload = queued
|
|
if name == "task_terminal" and str(event_payload.get("lifecycle") or "") == "failed":
|
|
yield event("cad_error", {"stage": "generation", "message": str(event_payload.get("message") or "CAD 增量生成失败。")})
|
|
yield event(name, event_payload)
|
|
yield event("done", {})
|
|
return
|
|
|
|
image_inputs = image_attachments(conversation)
|
|
intake_stage = image_reference_stage(conversation)
|
|
recorded_image_analysis = image_reference_analysis(conversation)
|
|
partial_observation = image_reference_observation_part(conversation, "survey")
|
|
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}
|
|
if task_id:
|
|
persisted_plan = self.store.read_feature_plan(task_id)
|
|
if isinstance(persisted_plan, dict):
|
|
planning_state["feature_plan"] = persisted_plan
|
|
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 or partial_observation,
|
|
intake_stage,
|
|
),
|
|
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=intake_stage == "survey",
|
|
include_image_sketches=intake_stage == "sketch",
|
|
image_stage=intake_stage if intake_stage in {"survey", "sketch"} else None,
|
|
)
|
|
required_tool_name: str | None = (
|
|
"analyze_image_reference" if intake_stage == "survey"
|
|
else "extract_image_sketch_candidates" if intake_stage == "sketch"
|
|
else None
|
|
)
|
|
generate_argument_failures = 0
|
|
tool_argument_diagnostics: list[str] = []
|
|
cdsl_validation_diagnostics: list[str] = []
|
|
generation_completed = False
|
|
repair_attempts = 0
|
|
repair_step_key: str | None = None
|
|
last_plan_status: dict[str, Any] | None = None
|
|
|
|
try:
|
|
for iteration in range(MAX_AGENT_TOOL_ITERATIONS):
|
|
# Once the model has been told to repair a CDSL step, stop
|
|
# before requesting another completion when that same step has
|
|
# exhausted its consecutive repair budget.
|
|
if (
|
|
planning_state.get("phase") == "CDSL_REPAIR"
|
|
and required_tool_name in GENERATION_TOOL_NAMES
|
|
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 intake_stage == "survey" 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 intake_stage == "sketch" and name != "extract_image_sketch_candidates":
|
|
result = {
|
|
"ok": False,
|
|
"code": "IMAGE_SKETCH_REQUIRED",
|
|
"message": "Extract image sketch candidates before using planning 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 intake_stage != "survey":
|
|
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 == "extract_image_sketch_candidates" and intake_stage != "sketch":
|
|
result = {
|
|
"ok": False,
|
|
"code": "IMAGE_SKETCH_ALREADY_RECORDED",
|
|
"message": "Image sketch candidates are already recorded for the current attachments.",
|
|
}
|
|
model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)})
|
|
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":
|
|
current_step_key = get_repair_step_key(planning_state, name)
|
|
if repair_step_key != current_step_key:
|
|
repair_step_key = current_step_key
|
|
repair_attempts = 0
|
|
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"):
|
|
survey = result["observation"]
|
|
if result.get("legacy"):
|
|
artifact_path = result.get("artifact_path", "")
|
|
image_payload = image_observation_payload(survey, stage="complete", artifact_path=artifact_path)
|
|
assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload})
|
|
yield event("image_analysis", image_payload)
|
|
recorded_image_analysis = image_payload
|
|
intake_stage = "complete"
|
|
required_tool_name = None
|
|
tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=False)
|
|
model_messages.append({"role": "system", "content": image_reference_instruction(image_inputs, survey, "complete")})
|
|
yield event("progress", {"step": "analyze_image_reference", "label": "识别图片参考", "status": "success", "message": "已识别图片参考,正在继续 CAD 建模流程。"})
|
|
continue
|
|
image_payload = image_observation_payload(survey, stage="survey", artifact_path=result.get("artifact_path", ""))
|
|
assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload})
|
|
yield event("image_analysis", image_payload)
|
|
partial_observation = image_payload
|
|
intake_stage = "sketch"
|
|
required_tool_name = "extract_image_sketch_candidates"
|
|
tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=True, image_stage="sketch")
|
|
model_messages.append({
|
|
"role": "system",
|
|
"content": image_reference_instruction(image_inputs, survey, "sketch"),
|
|
})
|
|
yield event("progress", {
|
|
"step": "analyze_image_reference",
|
|
"label": "整理图片勘测",
|
|
"status": "success",
|
|
"message": "已完成多视角图片勘测,正在提取草图候选。",
|
|
})
|
|
continue
|
|
if name == "extract_image_sketch_candidates" and result.get("ok"):
|
|
survey = partial_observation or {}
|
|
if "observationStage" in survey:
|
|
survey = {
|
|
"schema_version": survey.get("schemaVersion", "cad.image-observation.v2"),
|
|
"attachment_ids": survey.get("attachmentIds") or [],
|
|
"part_type": survey.get("partType") or "",
|
|
"visible_features": survey.get("visibleFeatures") or [],
|
|
"uncertain_features": survey.get("uncertainFeatures") or [],
|
|
"views": survey.get("views") or [],
|
|
"scale_references": survey.get("scaleReferences") or [],
|
|
"overall_geometry": survey.get("overallGeometry") or {},
|
|
"surfaces": survey.get("surfaces") or [],
|
|
"profiles": survey.get("profiles") or [],
|
|
"holes": survey.get("holes") or [],
|
|
"bends": survey.get("bends") or [],
|
|
"measurements": survey.get("measurements") or [],
|
|
"uncertainties": survey.get("uncertainties") or [],
|
|
"assumptions": survey.get("assumptions") or [],
|
|
"cv_hints": survey.get("cvHints") or [],
|
|
}
|
|
observation = merge_image_observations(survey, result["sketches"])
|
|
artifact_path = self.store.write_conversation_planning(
|
|
conversation["conversation_id"],
|
|
"image-observation-v2",
|
|
observation,
|
|
)
|
|
image_payload = image_observation_payload(observation, stage="complete", artifact_path=artifact_path)
|
|
assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload})
|
|
yield event("image_analysis", image_payload)
|
|
recorded_image_analysis = image_payload
|
|
intake_stage = "complete"
|
|
required_tool_name = None
|
|
tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=False)
|
|
model_messages.append({
|
|
"role": "system",
|
|
"content": image_reference_instruction(image_inputs, observation, "complete"),
|
|
})
|
|
yield event("progress", {
|
|
"step": "extract_image_sketch_candidates",
|
|
"label": "提取图片草图",
|
|
"status": "success",
|
|
"message": "已提取图片轮廓和草图候选,正在继续 CAD 建模流程。",
|
|
})
|
|
continue
|
|
if name in GENERATION_TOOL_NAMES and result.get("ok"):
|
|
required_tool_name = None
|
|
if result.get("ok", True):
|
|
# A successful tool call is forward progress. The next
|
|
# generation batch must receive a fresh consecutive-failure budget.
|
|
repair_attempts = 0
|
|
repair_step_key = None
|
|
generate_argument_failures = 0
|
|
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"),
|
|
"topologyPath": generated.get("topology_path"),
|
|
"planComplete": generated.get("plan_complete", True),
|
|
"planStatus": generated.get("plan_status"),
|
|
"requiredAction": generated.get("required_action", "complete"),
|
|
}
|
|
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 = bool(generated.get("plan_complete", True))
|
|
if isinstance(generated.get("plan_status"), dict):
|
|
last_plan_status = generated["plan_status"]
|
|
if generation_completed:
|
|
break
|
|
if generation_completed:
|
|
break
|
|
if iteration == MAX_AGENT_TOOL_ITERATIONS - 1:
|
|
validation_diagnostics = cdsl_validation_diagnostics
|
|
waiting_nodes = [
|
|
str(item) for item in (last_plan_status or {}).get("waiting_nodes") or []
|
|
]
|
|
if waiting_nodes:
|
|
message = (
|
|
"CAD 基础模型已生成,但特征计划尚未完成。等待拓扑选择的特征:"
|
|
+ "、".join(waiting_nodes)
|
|
+ "。请先读取当前拓扑,再继续生成这些特征。"
|
|
)
|
|
elif 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
|
|
# Patch calls do not contain a complete CDSL. Preserve their payload
|
|
# so an out-of-range path can be diagnosed against the exact request.
|
|
if candidate is None and "patches" in arguments:
|
|
candidate = {
|
|
"kind": "cdsl_patch_attempt",
|
|
"base_revision_id": str(arguments.get("base_revision_id") or ""),
|
|
"patches": deepcopy(arguments.get("patches") or []),
|
|
}
|
|
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,
|
|
"base_revision_id": arguments.get("base_revision_id"),
|
|
"patches": deepcopy(arguments.get("patches")) if "patches" in arguments else None,
|
|
"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")
|
|
legacy_arguments = "views" not in arguments and "profiles" not in arguments
|
|
if not legacy_arguments:
|
|
observation = normalize_image_observation(arguments, attachment_ids=image_attachment_ids)
|
|
else:
|
|
legacy = normalize_image_analysis(arguments)
|
|
observation = normalize_image_observation({
|
|
"attachment_ids": image_attachment_ids,
|
|
"part_type": legacy["part_type"],
|
|
"visible_features": legacy["visible_features"],
|
|
"uncertain_features": legacy["uncertain_features"],
|
|
"measurements": [
|
|
{"name": item["label"], "source": "image", "evidence": item["reason"]}
|
|
for item in legacy["dimension_candidates"]
|
|
],
|
|
"views": [{"attachment_id": attachment_id} for attachment_id in image_attachment_ids],
|
|
}, attachment_ids=image_attachment_ids)
|
|
analysis = {"ok": True, "legacy": legacy_arguments, "attachment_ids": list(dict.fromkeys(image_attachment_ids)), "observation": observation, **observation}
|
|
if conversation_id:
|
|
analysis["artifact_path"] = self.store.write_conversation_planning(conversation_id, "image-survey", analysis["observation"])
|
|
state["phase"] = "REFERENCE_ANALYZED"
|
|
return analysis, None
|
|
if name == "extract_image_sketch_candidates":
|
|
if not image_attachment_ids:
|
|
raise ValueError("extract_image_sketch_candidates requires at least one image attachment")
|
|
sketches = normalize_sketch_candidates(arguments, attachment_ids=image_attachment_ids)
|
|
state["phase"] = "REFERENCE_SKETCHED"
|
|
return {"ok": True, "sketches": sketches, **sketches}, 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 == "plan_feature_tree":
|
|
if phase not in {"PLANNED", "TOPOLOGY_READY", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR", "PLAN_READY"}:
|
|
return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before planning the feature tree."}, None
|
|
try:
|
|
engine = load_engine(self.settings)
|
|
plan = validate_feature_plan({
|
|
"schema_version": "cad.feature-plan.v1",
|
|
"plan_id": arguments.get("plan_id"),
|
|
"task_id": task_id,
|
|
"nodes": arguments.get("nodes"),
|
|
}, supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()))
|
|
replan = arguments.get("replan")
|
|
if replan is not None:
|
|
if not isinstance(replan, dict):
|
|
raise FeaturePlanError("replan must be an object")
|
|
replace_nodes = replan.get("replace_nodes")
|
|
reason = str(replan.get("reason") or "").strip()
|
|
alternatives = replan.get("alternatives")
|
|
if not isinstance(replace_nodes, list) or not replace_nodes or not all(str(item).strip() for item in replace_nodes):
|
|
raise FeaturePlanError("replan.replace_nodes must be a non-empty string array")
|
|
if not reason or not isinstance(alternatives, list) or not all(isinstance(item, str) for item in alternatives):
|
|
raise FeaturePlanError("replan requires reason and alternatives")
|
|
prior = state.get("feature_plan") or (self.store.read_feature_plan(task_id) if task_id else None)
|
|
if isinstance(prior, dict):
|
|
prior_nodes = {str(item.get("id")): item for item in prior.get("nodes") or () if isinstance(item, dict)}
|
|
next_nodes = {str(item.get("id")): item for item in plan.get("nodes") or () if isinstance(item, dict)}
|
|
replace_set = {str(item) for item in replace_nodes}
|
|
if not replace_set.issubset(prior_nodes):
|
|
raise FeaturePlanError("replan.replace_nodes must identify existing plan nodes")
|
|
completed_replacements = {
|
|
node_id for node_id in replace_set
|
|
if prior_nodes[node_id].get("status") in {"completed", "executed"}
|
|
}
|
|
if completed_replacements:
|
|
raise FeaturePlanError(
|
|
"Replan cannot replace completed nodes: "
|
|
+ ", ".join(sorted(completed_replacements))
|
|
)
|
|
missing_unchanged = set(prior_nodes) - replace_set - set(next_nodes)
|
|
if missing_unchanged:
|
|
raise FeaturePlanError(
|
|
"Replan cannot remove nodes outside replace_nodes: "
|
|
+ ", ".join(sorted(missing_unchanged))
|
|
)
|
|
for node_id in prior_nodes.keys() & next_nodes.keys():
|
|
if node_id not in replace_set:
|
|
old = {key: value for key, value in prior_nodes[node_id].items() if key not in {"status", "failure"}}
|
|
new = {key: value for key, value in next_nodes[node_id].items() if key not in {"status", "failure"}}
|
|
if old != new:
|
|
raise FeaturePlanError(f"Replan may only change replace_nodes; unchanged node mutated: {node_id}")
|
|
plan["replan"] = {
|
|
"replace_nodes": [str(item) for item in replace_nodes],
|
|
"reason": reason,
|
|
"alternatives": [item.strip() for item in alternatives if item.strip()],
|
|
}
|
|
except (FeaturePlanError, ValueError) as error:
|
|
state["phase"] = "BLOCKED"
|
|
message = str(error)
|
|
code = "FEATURE_PLAN_CYCLE" if "cycle" in message.casefold() else "FEATURE_PLAN_INVALID"
|
|
return {"ok": False, "code": code, "message": message}, None
|
|
state["feature_plan"] = plan
|
|
state["phase"] = "PLAN_READY"
|
|
current_cdsl = None
|
|
topology = None
|
|
if task_id:
|
|
cdsl_path = self.store.current_cdsl_path(task_id)
|
|
if cdsl_path and cdsl_path.is_file():
|
|
current_cdsl = json.loads(cdsl_path.read_text(encoding="utf-8"))
|
|
topology_path = self.store.current_topology_path(task_id)
|
|
if topology_path and topology_path.is_file():
|
|
topology = json.loads(topology_path.read_text(encoding="utf-8"))
|
|
self.store.write_feature_plan(task_id, plan)
|
|
status = compute_node_statuses(plan, cdsl=current_cdsl, topology=topology)
|
|
plan.update({key: status[key] for key in ("nodes", "ready_nodes", "waiting_nodes", "blocked_nodes", "completed_nodes", "complete")})
|
|
state["feature_plan"] = plan
|
|
if task_id:
|
|
self.store.write_feature_plan(task_id, plan)
|
|
return {
|
|
"ok": True,
|
|
"plan_id": plan["plan_id"],
|
|
"ready_nodes": plan["ready_nodes"],
|
|
"waiting_nodes": plan["waiting_nodes"],
|
|
"blocked_nodes": plan["blocked_nodes"],
|
|
"completed_nodes": plan["completed_nodes"],
|
|
"required_action": "generate_cdsl_model" if plan["ready_nodes"] else "inspect_current_topology" if plan["waiting_nodes"] else "none",
|
|
"message": "Feature plan validated. Generate only ready nodes; never invent topology selectors.",
|
|
}, 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 == "inspect_current_topology":
|
|
if phase not in {"PLAN_READY", "TOPOLOGY_READY", "CDSL_AUTHORING", "COMPLETED", "CDSL_REPAIR", "PLANNED"}:
|
|
return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "Build a successful CDSL revision before inspecting topology."}, None
|
|
result = _topology_query_result(self.store, task_id, arguments)
|
|
if result.get("ok") and result.get("records") and isinstance(state.get("feature_plan"), dict):
|
|
plan = deepcopy(state["feature_plan"])
|
|
plan["topology_snapshot_id"] = str(result.get("snapshot_id") or "")
|
|
cdsl_path = self.store.current_cdsl_path(task_id)
|
|
topology_path = self.store.current_topology_path(task_id)
|
|
cdsl = json.loads(cdsl_path.read_text(encoding="utf-8")) if cdsl_path and cdsl_path.is_file() else None
|
|
topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None
|
|
state["feature_plan"] = compute_node_statuses(plan, cdsl=cdsl, topology=topology)
|
|
self.store.write_feature_plan(task_id, state["feature_plan"])
|
|
return result, None
|
|
if name in GENERATION_TOOL_NAMES:
|
|
if phase not in {"PLANNED", "PLAN_READY", "TOPOLOGY_READY", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR", "COMPLETED"}:
|
|
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"}
|
|
if phase == "CDSL_REPAIR":
|
|
operation["type"] = "cdsl_repair"
|
|
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 "")
|
|
current_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "")
|
|
if not current_revision_id or base_revision_id != current_revision_id:
|
|
raise ValueError("TOPOLOGY_SNAPSHOT_STALE: base_revision_id must be the current successful revision")
|
|
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:
|
|
base_cdsl = json.loads(base_path.read_text(encoding="utf-8"))
|
|
cdsl = apply_cdsl_patch(base_cdsl, 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 [])}
|
|
base_selectors = {(item.get("source"), item.get("stable_id"), item.get("snapshot_id")) for item in _selector_values(base_cdsl)}
|
|
next_selectors = {(item.get("source"), item.get("stable_id"), item.get("snapshot_id")) for item in _selector_values(cdsl)}
|
|
if any(source in {"runtime_snapshot", "viewer_selection"} for source, _stable_id, _snapshot_id in next_selectors - base_selectors):
|
|
operation["type"] = "cdsl_selector_patch"
|
|
if state.get("feature_plan"):
|
|
operation["type"] = "cdsl_plan_batch" if operation.get("type") in {"cdsl_create", "cdsl_replacement"} else operation.get("type")
|
|
operation["plan_id"] = str(state["feature_plan"].get("plan_id") or "")
|
|
operation["plan_nodes"] = [
|
|
str(node.get("id")) for node in state["feature_plan"].get("nodes") or ()
|
|
if isinstance(node, dict) and node.get("status") in {"ready", "executing"}
|
|
]
|
|
cdsl, normalization_repairs = normalize_cdsl_for_engine(cdsl)
|
|
_validate_plan_cdsl_transition(self.store, task_id, state.get("feature_plan"), cdsl)
|
|
_validate_snapshot_selectors(self.store, task_id, 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"),
|
|
feature_plan=state.get("feature_plan"),
|
|
)
|
|
except Exception:
|
|
state["phase"] = "CDSL_REPAIR"
|
|
raise
|
|
plan_status: dict[str, Any] | None = None
|
|
if state.get("feature_plan"):
|
|
topology_path = self.store.current_topology_path(yieldable["task_id"])
|
|
topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None
|
|
# A successful build already produced the authoritative topology
|
|
# snapshot. Bind it here so advancing a plan never depends on the
|
|
# model remembering a bookkeeping-only topology inspection call.
|
|
plan = deepcopy(state["feature_plan"])
|
|
snapshot_id = str((topology or {}).get("snapshot_id") or "")
|
|
if snapshot_id:
|
|
plan["topology_snapshot_id"] = snapshot_id
|
|
plan_status = compute_node_statuses(plan, cdsl=cdsl, topology=topology)
|
|
state["feature_plan"] = plan_status
|
|
self.store.write_feature_plan(yieldable["task_id"], plan_status)
|
|
state["phase"] = "COMPLETED" if plan_status["complete"] else "TOPOLOGY_READY"
|
|
else:
|
|
state["phase"] = "COMPLETED"
|
|
yieldable["plan_complete"] = bool(plan_status["complete"]) if plan_status else True
|
|
yieldable["plan_status"] = plan_status
|
|
return {
|
|
"ok": True,
|
|
"summary": summary,
|
|
"task_id": yieldable["task_id"],
|
|
"revision_id": yieldable["revision_id"],
|
|
"normalization_repairs": normalization_repairs,
|
|
"operation": operation,
|
|
"plan_complete": bool(plan_status["complete"]) if plan_status else True,
|
|
"plan_status": {
|
|
key: plan_status[key]
|
|
for key in ("ready_nodes", "waiting_nodes", "blocked_nodes", "completed_nodes", "complete")
|
|
} if plan_status else None,
|
|
"required_action": (
|
|
"inspect_current_topology" if plan_status and plan_status["waiting_nodes"]
|
|
else "patch_cdsl_model" if plan_status and plan_status["ready_nodes"]
|
|
else "complete"
|
|
) if plan_status else "complete",
|
|
}, 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 {
|
|
"analyze_image_reference": "整理图片勘测",
|
|
"extract_image_sketch_candidates": "提取图片草图",
|
|
"search_cdsl_library": "检索 CDSL 模型库",
|
|
"read_cdsl_reference": "读取 CDSL 参考模型",
|
|
"read_current_cdsl": "读取当前 CDSL",
|
|
"describe_design_intent": "整理设计说明",
|
|
"plan_feature_tree": "规划特征树",
|
|
"inspect_current_topology": "查询当前拓扑",
|
|
"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. Image ids are stable references for the visual survey."}]
|
|
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")
|
|
metadata = {
|
|
"width": attachment.get("width"),
|
|
"height": attachment.get("height"),
|
|
"orientation": attachment.get("orientation"),
|
|
}
|
|
try:
|
|
hints = cv_hints(path.read_bytes())
|
|
except OSError:
|
|
hints = {"available": False, "hints": []}
|
|
content.append({
|
|
"type": "text",
|
|
"text": (
|
|
f"IMAGE_ID: {attachment.get('id')}\n"
|
|
f"FILE_NAME: {attachment.get('name')}\n"
|
|
f"MIME: {mime}\n"
|
|
f"METADATA: {json.dumps(metadata, ensure_ascii=False, separators=(',', ':'))}\n"
|
|
f"CV_HINTS: {json.dumps(hints, ensure_ascii=False, separators=(',', ':'))}"
|
|
),
|
|
})
|
|
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
|