优化生成流程

This commit is contained in:
2026-08-25 17:41:24 +08:00
parent 51575161dc
commit beaa5dd9fe
50 changed files with 4204 additions and 4261 deletions
+340 -184
View File
@@ -5,7 +5,9 @@ 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
@@ -14,13 +16,10 @@ 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.flange_sleeve_template import (
TEMPLATE_ID as FLANGE_SLEEVE_TEMPLATE_ID,
build_flange_sleeve_cdsl,
flange_sleeve_plan_schema,
)
from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch
from app.services.library import CdslLibrary
from app.services.part_skills import PartSkillLibrary
from app.services.quality import QUALITY_RULE_TYPES, validate_verification
from app.services.sse import event
from app.services.storage import WorkspaceStore, now_iso
from app.settings import ProviderConfig, ProviderModel, Settings
@@ -42,6 +41,14 @@ class RepeatedToolArgumentsError(RuntimeError):
self.diagnostic_paths = diagnostic_paths or []
class CdslRepairLimitError(RuntimeError):
"""The direct CDSL repair budget is exhausted for this request."""
def __init__(self, diagnostic_paths: list[str]) -> None:
super().__init__("CDSL repair limit reached")
self.diagnostic_paths = diagnostic_paths
def user_visible_error_message(error: Exception, user_text: str) -> str:
if isinstance(error, StrictToolSchemaError) and any(
"\u4e00" <= char <= "\u9fff" for char in str(user_text or "")
@@ -61,6 +68,11 @@ def user_visible_error_message(error: Exception, user_text: str) -> str:
"请检查所选模型的函数调用兼容性;若仍出现此错误,请关闭该模型的严格工具 schema 开关后再试。"
+ diagnostics
)
if isinstance(error, CdslRepairLimitError):
diagnostics = "、".join(error.diagnostic_paths)
if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")):
return "模型重试达到四次 CDSL 修复上限,未创建新的成功 revision。每次 CDSL 校验失败的诊断已保存到:" + diagnostics + "。"
return "The four-attempt CDSL repair limit was reached. Diagnostics were saved to: " + diagnostics + "."
return str(error)
@@ -84,7 +96,7 @@ def _repair_premature_tool_wrapper_close(source: str, parsed_value: Any, parsed_
return None
if candidate[candidate_end:].strip() or not isinstance(value, dict):
return None
if not set(value).issubset({"cdsl", "summary", "assumptions"}):
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
@@ -135,10 +147,38 @@ def invalid_tool_arguments_result(name: str, error: ToolArgumentsError) -> dict[
}
def invalid_cdsl_result(error: ValueError) -> dict[str, Any]:
def _cdsl_error_details(error: Exception) -> dict[str, Any]:
message = str(error)
path_match = re.search(r"(?:at|path) (\$[^: ]*)", message)
quality = getattr(error, "quality_report", None)
if isinstance(quality, dict):
return {
"code": "VERIFICATION_FAILED",
"path": "$.verification.rules",
"kind": "verification",
"repair_instruction": "Correct the CDSL feature geometry or the verification rule, then submit a local patch or complete replacement.",
"quality": quality,
}
if "schema violation" in message:
return {
"code": "CDSL_SCHEMA_INVALID",
"path": path_match.group(1) if path_match else "$",
"kind": "schema",
"repair_instruction": "Correct the field at the reported JSONPath using the authoritative CDSL schema.",
}
return {
"code": "CDSL_RUNTIME_INVALID",
"path": path_match.group(1) if path_match else "$",
"kind": "runtime",
"repair_instruction": "Correct the invalid CDSL structure, dependency, selector, or runtime parameter and retry.",
}
def invalid_cdsl_result(error: Exception) -> dict[str, Any]:
details = _cdsl_error_details(error)
return {
"ok": False,
"code": "INVALID_CDSL",
**details,
"message": (
f"The submitted CDSL is incomplete or invalid: {error}. "
"Read the authoritative local engine schema, then call generate_cdsl_model "
@@ -149,7 +189,7 @@ def invalid_cdsl_result(error: ValueError) -> dict[str, Any]:
def user_visible_tool_message(result: dict[str, Any], user_text: str) -> str:
code = str(result.get("code") or "")
if code == "INVALID_CDSL":
if code in {"CDSL_SCHEMA_INVALID", "CDSL_RUNTIME_INVALID", "VERIFICATION_FAILED"}:
if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")):
return "CDSL 不符合 engine 的模型契约,正在请求模型按 schema 修正后重新生成。"
return "The CDSL model does not match the engine contract. Asking the model to correct it and retry."
@@ -185,14 +225,109 @@ def _cdsl_tool_schema() -> dict[str, Any]:
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")
return json.loads((engine_dir / schema_name).read_text(encoding="utf-8"))
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()
FLANGE_SLEEVE_PLAN_SCHEMA = flange_sleeve_plan_schema()
GENERATION_TOOL_NAMES = {"generate_cdsl_model", "generate_flange_sleeve_model"}
GENERATION_TOOL_NAMES = {"generate_cdsl_model", "patch_cdsl_model"}
VERIFICATION_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"rules": {
"type": "array",
"maxItems": 32,
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1},
"type": {"enum": sorted(QUALITY_RULE_TYPES)},
"feature": {"type": "string"},
"expected": {
"description": (
"For bbox, use [dx, dy, dz], "
"{min: [x, y, z], max: [x, y, z]}, or "
"{x_min, x_max, y_min, y_max, z_min, z_max}."
),
},
"tolerance": {"type": "number", "minimum": 0},
"severity": {"enum": ["blocking", "warning", "informational"]},
},
"required": ["id", "type", "expected"],
"additionalProperties": False,
},
},
},
"required": ["rules"],
"additionalProperties": False,
}
JSON_PATCH_OPERATION_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"op": {"enum": ["add", "remove", "replace", "move", "copy", "test"]},
"path": {"type": "string", "pattern": "^/"},
"from": {"type": "string", "pattern": "^/"},
"value": {},
},
"required": ["op", "path"],
"additionalProperties": False,
}
def engine_capability_manifest(settings: Settings) -> dict[str, Any]:
"""Build the compact planner-facing capability contract from engine files."""
load_engine(settings)
try:
profile = json.loads((settings.engine_root / "profile_schema.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"supported_profiles": [], "runtime_atomic_ids": [], "required_params": {}, "unsupported_profiles": []}
atomic_contracts = profile.get("feature_atomic_ids") if isinstance(profile.get("feature_atomic_ids"), dict) else {}
declared_atomics = {str(item) for item in profile.get("runtime_supported_atomic_ids") or atomic_contracts}
registered_atomics = {str(item) for item in getattr(load_engine(settings), "SUPPORTED_ATOMIC_IDS", ())}
supported_atomics = sorted(declared_atomics & registered_atomics)
declared_profiles = {str(item) for item in profile.get("runtime_supported_profiles") or ()}
registered_profiles = {str(item) for item in getattr(load_engine(settings), "SHAPE_GENERATORS", ())}
return {
"supported_profiles": sorted(declared_profiles & registered_profiles),
"runtime_atomic_ids": supported_atomics,
"required_params": {str(key): list(value.get("required_params") or []) for key, value in atomic_contracts.items() if isinstance(value, dict)},
"unsupported_profiles": sorted(str(item) for item in profile.get("unsupported_profiles") or []),
"verification_rule_types": sorted(QUALITY_RULE_TYPES),
}
TOOL_SCHEMAS: list[dict[str, Any]] = [
@@ -285,29 +420,6 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
},
},
{
"type": "function",
"function": {
"name": "generate_flange_sleeve_model",
"description": (
"Generate a square or rectangular flange sleeve from a compact semantic plan. "
"Use this preferred tool for a flange plate with a coaxial hollow tube, "
"a stepped or tapered tip, four mounting holes, and counterbores. "
"The backend's verified template creates all CDSL workplanes, sketches, "
"feature dependencies, and feature parameters."
),
"parameters": {
"type": "object",
"properties": {
"plan": FLANGE_SLEEVE_PLAN_SCHEMA,
"summary": {"type": "string", "minLength": 1},
"assumptions": {"type": "array", "items": {"type": "string"}},
},
"required": ["plan", "summary", "assumptions"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
@@ -319,12 +431,32 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [
"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,
},
},
},
]
@@ -332,7 +464,6 @@ def tools_for_model(
model: ProviderModel,
*,
include_image_analysis: bool = True,
flange_sleeve_only: bool = False,
) -> list[dict[str, Any]]:
"""Return this model's tool contract without mutating the shared schema."""
tools = deepcopy(TOOL_SCHEMAS)
@@ -342,17 +473,6 @@ def tools_for_model(
for tool in tools
if tool.get("function", {}).get("name") != "analyze_image_reference"
]
if flange_sleeve_only:
tools = [
tool
for tool in tools
if tool.get("function", {}).get("name") not in {
"search_cdsl_library",
"read_cdsl_reference",
"read_current_cdsl",
"generate_cdsl_model",
}
]
if not model.strict_tool_schema:
return tools
@@ -445,33 +565,6 @@ def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | N
return None
def use_flange_sleeve_template(analysis: dict[str, Any] | None, task_id: str) -> bool:
"""Route only a high-confidence new image reference to the verified template."""
if task_id or not isinstance(analysis, dict):
return False
visible = analysis.get("visibleFeatures", analysis.get("visible_features", []))
values = [analysis.get("partType", analysis.get("part_type", ""))]
if isinstance(visible, list):
values.extend(visible)
text = " ".join(str(value or "").casefold() for value in values)
has_flange = "法兰" in text or "flange" in text
has_sleeve = any(token in text for token in ("套筒", "管座", "圆筒", "cylind", "sleeve", "tube"))
return has_flange and has_sleeve
def flange_sleeve_template_instruction(enabled: bool) -> str:
if not enabled:
return ""
return """
Template routing (highest priority for this request):
- This new image reference has been classified as a flange sleeve.
- After describe_design_intent, call generate_flange_sleeve_model exactly once.
- The complete-CDSL generation tool is intentionally unavailable for this request.
- Supply only visible semantic dimensions and assumptions; omitted dimensions use
the verified template defaults. Do not ask for CDSL implementation fields.
"""
def image_reference_instruction(
attachments: list[dict[str, Any]],
analysis: dict[str, Any] | None,
@@ -635,15 +728,30 @@ def system_prompt(
part_skill_context: str = "",
image_reference_context: str = "",
cad_request_context: str = "",
flange_sleeve_template_mode: bool = False,
) -> 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 ""
readme_path = settings.engine_root / "README.md"
engine_readme = readme_path.read_text(encoding="utf-8") if readme_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 ""
supported_profiles = ", ".join(sorted(load_engine(settings).SHAPE_GENERATORS))
generation_contract = """- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly, not as Markdown or a JSON string.
- For a revision, call read_current_cdsl before generate_cdsl_model. Preserve unrelated CDSL features unless the user requests whole-part replacement.
- Use patch_cdsl_model only for a local RFC 6902 repair of a known base_revision_id. For structural changes, submit a complete replacement CDSL with generate_cdsl_model.
- For every feature.atomic_id, use only an ID listed in the compact capability manifest's runtime_atomic_ids. Other semantic contracts are not executable in this runtime.
- The backend normalizes only these unambiguous aliases: sketch_id -> id on sketches, sketch -> sketch_id on features, legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm.
- Every explicit user dimension, feature count, hole size, or hole position that can be measured must have a generic verification rule. Rule feature values must be IDs from the submitted CDSL.
- Do not output build123d source, compiler_context, unknown_shape, complex_arc_shape, entities, contour_edges_mm, or contour_regions_mm."""
workflow = """3. Search the local official CDSL library after the design brief. Read a relevant reference when a match exists; samples are expression guidance, not templates or higher-priority requirements.
4. Generate one complete generic CDSL and call generate_cdsl_model.
5. On a schema, runtime, or verification failure, use patch_cdsl_model for a local correction or generate_cdsl_model for a complete replacement.
6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact."""
authoring_context = f"""You own the complete CDSL: profiles, workplanes, sketch IDs, feature IDs, dependencies, selectors, and atomic IDs must be valid under the engine schema. Do not invent unsupported capabilities.
Local skill:
{skill}
Authoritative engine schema:
{profile_schema}"""
return f"""You are the CDSL CAD Agent for CDSL CAD Studio.
Language policy:
@@ -661,31 +769,12 @@ Language policy:
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.
- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly,
not as Markdown and not as a concatenated JSON string.
- For a square/rectangular flange with a coaxial hollow sleeve, stepped or
tapered front, and four mounting counterbores, call
generate_flange_sleeve_model. Submit only its compact semantic plan; do not
create workplanes, sketch IDs, feature IDs, dependencies, or raw CDSL for
that template. The backend creates and validates the complete CDSL.
- The backend can normalize only these unambiguous legacy aliases before
validation: sketch_id -> id on sketches, sketch -> sketch_id on features,
legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm.
Do not rely on it for geometry, dimensions, selectors, or feature intent.
- Call describe_design_intent first with a concise natural-language plan.
Its returned text is reference context for your CDSL work, not a second CAD
contract and not a source of feature IDs, profile bindings, or selectors.
- Do not call search_cdsl_library, read_cdsl_reference,
generate_flange_sleeve_model, or generate_cdsl_model until
describe_design_intent returns its plan.
- The generate_cdsl_model `cdsl` parameter is the complete machine-enforced
schema. Satisfy its nested object and array types exactly; do not substitute
a shorthand array for an object. For example, every hole position is
`{{"mm": [u_mm, v_mm, w_mm]}}`, never `[u_mm, v_mm]`.
- 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
@@ -698,7 +787,7 @@ Workflow limits:
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 engine guide to either generate
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.
@@ -708,24 +797,12 @@ Workflow limits:
{cad_request_context}
{flange_sleeve_template_instruction(flange_sleeve_template_mode)}
You generate executable CAD through compact semantic plans or parameterized CDSL,
never raw CAD source code. For new CAD requests:
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, and parameter roles.
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.
3. If the requested family is a flange sleeve matching the dedicated template,
call generate_flange_sleeve_model immediately after the textual plan. Do not
search the CDSL library and do not use generate_cdsl_model for that case.
4. For other families, search the local official CDSL library. Read at least
one relevant reference when a match exists; samples provide schema-valid
expressions, not higher-priority user requirements.
5. For non-template families, generate one complete CDSL and call
generate_cdsl_model. The backend-generated CDSL is the authoritative CAD
model; the textual plan is guidance only.
6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact.
{workflow}
Precedence is strict: explicit user request, then CDSL schema/runtime, then
part-skill guidance, then CDSL-library examples. Part skills never authorize
@@ -738,29 +815,10 @@ 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.
For a revision, call read_current_cdsl first and preserve unrelated features.
Use the flange sleeve template only when the user wants to replace the whole
part or the current part already follows that family; otherwise submit the
complete replacement CDSL. The backend automatically uses the latest
successful revision as the parent; do not provide a revision ID.
Do not output compiler_context, unknown_shape, complex_arc_shape, entities,
contour_edges_mm, or contour_regions_mm. Use only self-contained named profiles
and feature atomic IDs defined in the engine schema below. Read the engine
schema before selecting an atomic ID, a profile, or their parameter names. Do
not invent an atomic ID, profile, or their fields. Ask a concise clarification question when essential
dimensions or intent are missing. Ordinary explanations must not create CAD.
{authoring_context}
Local skill:
{skill}
Local engine guide:
{engine_readme}
Authoritative engine schema:
{profile_schema}
Supported named profile types:
{supported_profiles}
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."}
@@ -849,18 +907,17 @@ class AgentService:
image_inputs = image_attachments(conversation)
recorded_image_analysis = image_reference_analysis(conversation)
requires_image_intake = bool(image_inputs) and recorded_image_analysis is None
flange_sleeve_template_mode = use_flange_sleeve_template(recorded_image_analysis, task_id)
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": "WAITING_FOR_PLAN", "design_brief": ""}
planning_state: dict[str, Any] = {"phase": "INTAKE", "design_brief": "", "current_model_read": False}
yield event("progress", {
"step": "select_part_skill",
"label": "识别零件族",
"label": "选择建模 Skill",
"status": "success",
"message": "已完成零件族与辅助建模规则识别。",
"message": "已完成建模 Skill 与辅助规则选择。",
})
model_messages: list[dict[str, Any]] = [{
"role": "system",
@@ -872,7 +929,6 @@ class AgentService:
self.part_skill_library.render_context(part_skill_selection),
image_reference_instruction(image_inputs, recorded_image_analysis),
cad_request_instruction(task_id, current_task),
flange_sleeve_template_mode,
),
}]
model_messages.extend(messages_for_model(messages))
@@ -881,16 +937,21 @@ class AgentService:
tools = tools_for_model(
model,
include_image_analysis=requires_image_intake,
flange_sleeve_only=flange_sleeve_template_mode,
)
required_tool_name: str | None = "analyze_image_reference" if requires_image_intake else None
generate_argument_failures = 0
tool_argument_diagnostics: list[str] = []
cdsl_validation_diagnostics: list[str] = []
generation_completed = False
repair_attempts = 0
try:
for iteration in range(8):
if (
planning_state.get("phase") == "CDSL_REPAIR"
and repair_attempts >= self.settings.max_repair_attempts
):
raise CdslRepairLimitError(cdsl_validation_diagnostics)
response = await self._complete(model_messages, tools, provider, model, required_tool_name)
response_choice = response["choices"][0]
choice = response_choice["message"]
@@ -914,6 +975,15 @@ class AgentService:
model_messages.append(choice)
for call in tool_calls:
name = str(call.get("function", {}).get("name") or "")
if requires_image_intake and name != "analyze_image_reference":
result = {
"ok": False,
"code": "IMAGE_ANALYSIS_REQUIRED",
"message": "Analyze all current image attachments before using planning, library, or generation tools.",
}
model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)})
yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先完成图片参考分析。"})
continue
if name == "analyze_image_reference" and recorded_image_analysis is not None:
result = {
"ok": False,
@@ -996,8 +1066,12 @@ class AgentService:
"message": "CAD 工具参数格式无效,正在请求模型修正。",
})
continue
if name in GENERATION_TOOL_NAMES and planning_state.get("phase") == "CDSL_REPAIR":
if repair_attempts >= self.settings.max_repair_attempts:
raise CdslRepairLimitError(cdsl_validation_diagnostics)
repair_attempts += 1
cdsl_attempt_path = ""
if name == "generate_cdsl_model":
if name in GENERATION_TOOL_NAMES:
cdsl_attempt_path = self._record_cdsl_attempt(
conversation_id=conversation["conversation_id"],
arguments=arguments,
@@ -1020,6 +1094,8 @@ class AgentService:
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:
@@ -1038,8 +1114,24 @@ class AgentService:
)
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"):
@@ -1071,26 +1163,21 @@ class AgentService:
"visibleFeatures": result["visible_features"],
"uncertainFeatures": result["uncertain_features"],
"dimensionCandidates": result["dimension_candidates"],
"artifactPath": result.get("artifact_path"),
}
assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload})
yield event("image_analysis", image_payload)
recorded_image_analysis = image_payload
flange_sleeve_template_mode = use_flange_sleeve_template(image_payload, task_id)
requires_image_intake = False
required_tool_name = None
tools = tools_for_model(
model,
include_image_analysis=False,
flange_sleeve_only=flange_sleeve_template_mode,
)
model_messages.append({
"role": "system",
"content": image_reference_instruction(image_inputs, image_payload),
})
if flange_sleeve_template_mode:
model_messages.append({
"role": "system",
"content": flange_sleeve_template_instruction(True),
})
yield event("progress", {
"step": "analyze_image_reference",
"label": "识别图片参考",
@@ -1114,6 +1201,12 @@ class AgentService:
"summary": generated["summary"],
"referenceIds": generated["reference_ids"],
"engine": generated["engine"],
"qualityStatus": generated.get("quality_status", ""),
"qualityPath": generated.get("quality_path") or None,
"assumptions": generated.get("generation_assumptions", []),
"repairAttempts": generated.get("repair_attempts", 0),
"snapshotPaths": generated.get("snapshot_paths", []),
"snapshotStatus": generated.get("snapshot_status", "unavailable"),
}
assistant_parts.append({"type": "data-cad-result", "data": result_payload})
yield event("cad_result", result_payload)
@@ -1128,10 +1221,14 @@ class AgentService:
if generation_completed:
break
if iteration == 7:
if cdsl_validation_diagnostics:
diagnostic_paths = "、".join(cdsl_validation_diagnostics)
validation_diagnostics = cdsl_validation_diagnostics
if validation_diagnostics:
diagnostic_paths = "、".join(validation_diagnostics)
if any("\u4e00" <= char <= "\u9fff" for char in user_text):
message = f"模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:{diagnostic_paths}。"
message = (
"模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:"
f"{diagnostic_paths}。"
)
else:
message = (
"Agent tool loop reached its safety limit. "
@@ -1250,6 +1347,7 @@ class AgentService:
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(),
@@ -1269,6 +1367,8 @@ class AgentService:
"cdsl_attempt_path": cdsl_attempt_path,
"summary": str(arguments.get("summary") or ""),
"assumptions": arguments.get("assumptions") or [],
"verification": arguments.get("verification"),
"diagnostic": details,
"validation_error_type": type(error).__name__,
"validation_error": str(error),
}
@@ -1328,24 +1428,30 @@ class AgentService:
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": "WAITING_FOR_PLAN", "design_brief": ""}
phase = str(state.get("phase") or "WAITING_FOR_PLAN")
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")
return {
analysis = {
"ok": True,
"attachment_ids": list(dict.fromkeys(image_attachment_ids)),
**normalize_image_analysis(arguments),
}, None
}
if conversation_id:
analysis["artifact_path"] = self.store.write_conversation_planning(conversation_id, "reference-observation", {"schema_version": "1.0", **analysis})
state["phase"] = "REFERENCE_ANALYZED"
return analysis, None
if name == "describe_design_intent":
plan = str(arguments.get("plan") or "").strip()
assumptions = arguments.get("assumptions")
if not plan or not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions):
raise ValueError("describe_design_intent requires a non-empty plan and an array of string assumptions")
state["design_brief"] = plan
state["phase"] = "PLAN_RECORDED"
state["phase"] = "PLANNED"
return {
"ok": True,
"plan": plan,
@@ -1353,60 +1459,108 @@ class AgentService:
"message": "The design brief is recorded as reference only. CDSL remains the sole authoritative CAD model.",
}, None
if name == "search_cdsl_library":
if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}:
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
results = self.library.search(str(arguments.get("query") or request), int(arguments.get("limit") or 5))
state["phase"] = "LIBRARY_REFERENCE"
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 {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}:
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 "")
sample = self.library.read_sample(part_id)
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["phase"] = "WAITING_FOR_CDSL"
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:
return {"ok": False, "message": "The current task has no successful CDSL revision."}, None
return {"ok": True, "task_id": task_id, "cdsl": json.loads(path.read_text(encoding="utf-8"))}, None
repairable = self.store.latest_repairable_cdsl(task_id)
if repairable is None:
return {"ok": False, "message": "The current task has no successful or repairable CDSL revision."}, None
revision_id, path = repairable
payload: dict[str, Any] = {
"ok": True,
"task_id": task_id,
"revision_id": revision_id,
"cdsl": json.loads(path.read_text(encoding="utf-8")),
}
state["current_model_read"] = True
state["read_revision_id"] = revision_id
state["phase"] = "PLANNED"
return payload, None
if name in GENERATION_TOOL_NAMES:
if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}:
if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}:
return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before generating CAD."}, None
template_plan: dict[str, float | str] | None = None
operation: dict[str, Any] | None = None
if name == "generate_flange_sleeve_model":
cdsl, template_plan = build_flange_sleeve_cdsl(arguments.get("plan"))
operation = {
"type": "parameterized_template",
"template_id": FLANGE_SLEEVE_TEMPLATE_ID,
"plan": template_plan,
}
else:
selection = part_skill_selection or self.part_skill_library.select(request)
if selection.get("conflict"):
state["phase"] = "BLOCKED"
return {
"ok": False,
"code": "PART_SKILL_CONFLICT",
"message": str(selection["conflict"].get("message") or "Resolve the primary part-family conflict before generating CAD."),
}, None
state["phase"] = "CDSL_AUTHORING"
if name == "generate_cdsl_model":
if task_id and not state.get("current_model_read"):
return {"ok": False, "code": "CURRENT_CDSL_REQUIRED", "message": "Call read_current_cdsl before replacing an existing task revision."}, None
cdsl = arguments.get("cdsl")
if isinstance(cdsl, str):
cdsl = json.loads(cdsl)
if not isinstance(cdsl, dict):
raise ValueError("generate_cdsl_model requires a CDSL JSON object")
parent_revision_id = str(state.get("read_revision_id") or "") if task_id else ""
if task_id and not parent_revision_id:
parent_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "")
operation: dict[str, Any] = {"type": "cdsl_replacement" if parent_revision_id else "cdsl_create"}
else:
if not task_id:
return {"ok": False, "code": "PATCH_TASK_REQUIRED", "message": "patch_cdsl_model requires an existing task."}, None
base_revision_id = str(arguments.get("base_revision_id") or "")
base_path = self.store.revision_cdsl_path(task_id, base_revision_id)
if base_path is None:
raise ValueError("PATCH_BASE_REVISION_NOT_FOUND: base_revision_id does not identify a readable CDSL revision")
try:
cdsl = apply_cdsl_patch(json.loads(base_path.read_text(encoding="utf-8")), arguments.get("patches"))
except (CdslPatchError, json.JSONDecodeError) as error:
raise ValueError(f"INVALID_CDSL_PATCH: {error}") from error
parent_revision_id = base_revision_id
operation = {"type": "cdsl_patch", "base_revision_id": base_revision_id, "patches": deepcopy(arguments.get("patches") or [])}
cdsl, normalization_repairs = normalize_cdsl_for_engine(cdsl)
# Reject malformed model output before build_revision allocates a task
# directory or revision. build_revision will assign the real task ID.
preflight_cdsl = {**cdsl, "part_id": str(cdsl.get("part_id") or "agent_preflight")}
engine = load_engine(self.settings)
state["phase"] = "PREFLIGHT"
validate_cdsl(preflight_cdsl, engine)
summary = str(arguments.get("summary") or "Parameterized CAD model")
raw_assumptions = arguments.get("assumptions") or []
if not isinstance(raw_assumptions, list) or not all(isinstance(item, str) for item in raw_assumptions):
raise ValueError(f"{name} assumptions must be an array of strings")
assumptions = [item.strip() for item in raw_assumptions if item.strip()]
selection = part_skill_selection or self.part_skill_library.select(request)
verification = arguments.get("verification")
validate_verification(verification, cdsl)
part_skill_audit = self.part_skill_library.audit(selection, cdsl, assumptions)
current_task = self.store.read_task(task_id) if task_id else None
parent_revision_id = str((current_task or {}).get("current_revision") or "")
state["phase"] = "BUILDING"
try:
yieldable = await asyncio.to_thread(
@@ -1422,10 +1576,13 @@ class AgentService:
operation=operation,
part_skills=part_skill_audit,
generation_assumptions=assumptions,
repair_attempts=repair_attempts,
input_attachments=input_attachments,
verification=verification,
reference_records=state.get("reference_records"),
)
except Exception:
state["phase"] = "PLAN_RECORDED"
state["phase"] = "CDSL_REPAIR"
raise
state["phase"] = "COMPLETED"
return {
@@ -1434,8 +1591,7 @@ class AgentService:
"task_id": yieldable["task_id"],
"revision_id": yieldable["revision_id"],
"normalization_repairs": normalization_repairs,
"template_id": FLANGE_SLEEVE_TEMPLATE_ID if template_plan is not None else "",
"template_plan": template_plan,
"operation": operation,
}, yieldable
raise ValueError(f"Unknown agent tool: {name}")
@@ -1450,8 +1606,8 @@ class AgentService:
"read_cdsl_reference": "读取 CDSL 参考模型",
"read_current_cdsl": "读取当前 CDSL",
"describe_design_intent": "整理设计说明",
"generate_flange_sleeve_model": "生成参数化法兰套筒",
"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: