优化生成流程

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:
+14 -6
View File
@@ -4,8 +4,8 @@ import hashlib
from pathlib import Path
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json"}
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json", ".pdf", ".dxf", ".step", ".stp"}
MAX_IMAGE_BYTES = 10 * 1024 * 1024
MAX_DOCUMENT_BYTES = 2 * 1024 * 1024
MAX_EXTRACTED_CHARS = 30_000
@@ -13,8 +13,6 @@ MAX_EXTRACTED_CHARS = 30_000
def classify_upload(filename: str, mime: str, size: int) -> str:
suffix = Path(filename).suffix.lower()
if suffix in {".step", ".stp"}:
raise ValueError("STEP/STP upload is not supported in this version")
if suffix in IMAGE_SUFFIXES or mime.startswith("image/"):
if size > MAX_IMAGE_BYTES:
raise ValueError("Image upload exceeds the 10 MB limit")
@@ -23,14 +21,24 @@ def classify_upload(filename: str, mime: str, size: int) -> str:
if size > MAX_DOCUMENT_BYTES:
raise ValueError("Document upload exceeds the 2 MB limit")
return "document"
raise ValueError("Only PNG, JPG, WEBP, TXT, MD, CSV, and JSON uploads are supported")
raise ValueError("Only PNG, JPG, WEBP, SVG, TXT, MD, CSV, JSON, PDF, DXF, and STEP/STP uploads are supported")
def extract_document_text(data: bytes) -> str:
# PDF extraction is optional so the API can still accept a reference when
# the local PDF dependency is unavailable; the binary remains available as
# the primary attachment artifact.
if data.startswith(b"%PDF"):
try:
from pypdf import PdfReader
import io
return "\n".join(page.extract_text() or "" for page in PdfReader(io.BytesIO(data)).pages)[:MAX_EXTRACTED_CHARS]
except Exception:
return "[PDF reference uploaded; text extraction unavailable]"
try:
text = data.decode("utf-8")
except UnicodeDecodeError as error:
raise ValueError("Documents must be UTF-8 text") from error
return "[Binary CAD reference uploaded; structured geometry inspection is deferred to the CAD reference analyzer]"
return text[:MAX_EXTRACTED_CHARS]
+122
View File
@@ -0,0 +1,122 @@
"""Small, dependency-free RFC 6902 JSON Patch implementation for CDSL revisions."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
class CdslPatchError(ValueError):
"""A JSON Patch operation cannot be applied to the requested CDSL revision."""
def _tokens(path: Any) -> list[str]:
if not isinstance(path, str) or not path.startswith("/"):
raise CdslPatchError("JSON Patch paths must be JSON Pointers beginning with '/'")
return [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")]
def _index(token: str, length: int, *, allow_append: bool = False) -> int:
if allow_append and token == "-":
return length
if not token.isdigit() or (len(token) > 1 and token.startswith("0")):
raise CdslPatchError(f"Invalid JSON Patch array index: {token}")
value = int(token)
if value < 0 or value >= length:
raise CdslPatchError(f"JSON Patch array index is out of range: {token}")
return value
def _parent(document: Any, path: str) -> tuple[Any, str]:
if path == "":
raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead")
tokens = _tokens(path)
current = document
for token in tokens[:-1]:
if isinstance(current, dict):
if token not in current:
raise CdslPatchError(f"JSON Patch path does not exist: {path}")
current = current[token]
elif isinstance(current, list):
current = current[_index(token, len(current))]
else:
raise CdslPatchError(f"JSON Patch path does not resolve to a container: {path}")
return current, tokens[-1]
def _get(document: Any, path: str) -> Any:
current = document
for token in _tokens(path):
if isinstance(current, dict):
if token not in current:
raise CdslPatchError(f"JSON Patch path does not exist: {path}")
current = current[token]
elif isinstance(current, list):
current = current[_index(token, len(current))]
else:
raise CdslPatchError(f"JSON Patch path does not resolve: {path}")
return current
def _add(document: Any, path: str, value: Any) -> None:
parent, token = _parent(document, path)
if isinstance(parent, dict):
parent[token] = deepcopy(value)
elif isinstance(parent, list):
index = _index(token, len(parent), allow_append=True)
parent.insert(index, deepcopy(value))
else:
raise CdslPatchError(f"JSON Patch add target is not a container: {path}")
def _remove(document: Any, path: str) -> Any:
parent, token = _parent(document, path)
if isinstance(parent, dict):
if token not in parent:
raise CdslPatchError(f"JSON Patch path does not exist: {path}")
return parent.pop(token)
if isinstance(parent, list):
return parent.pop(_index(token, len(parent)))
raise CdslPatchError(f"JSON Patch remove target is not a container: {path}")
def apply_cdsl_patch(cdsl: dict[str, Any], patches: Any) -> dict[str, Any]:
if not isinstance(cdsl, dict):
raise CdslPatchError("The base CDSL must be an object")
if not isinstance(patches, list) or not patches:
raise CdslPatchError("patches must be a non-empty JSON Patch array")
if len(patches) > 32:
raise CdslPatchError("At most 32 JSON Patch operations are allowed")
result: Any = deepcopy(cdsl)
for index, operation in enumerate(patches):
if not isinstance(operation, dict):
raise CdslPatchError(f"Patch operation {index} must be an object")
kind = str(operation.get("op") or "")
path = operation.get("path")
if kind not in {"add", "remove", "replace", "move", "copy", "test"}:
raise CdslPatchError(f"Unsupported JSON Patch operation: {kind or '<missing>'}")
if kind in {"add", "replace", "test"} and "value" not in operation:
raise CdslPatchError(f"JSON Patch {kind} requires value")
if kind in {"move", "copy"} and "from" not in operation:
raise CdslPatchError(f"JSON Patch {kind} requires from")
if path == "":
raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead")
if kind == "add":
_add(result, path, operation["value"])
elif kind == "remove":
_remove(result, path)
elif kind == "replace":
_get(result, path)
_remove(result, path)
_add(result, path, operation["value"])
elif kind == "move":
source = str(operation["from"])
if path == source or str(path).startswith(source.rstrip("/") + "/"):
raise CdslPatchError("JSON Patch move cannot move a value into itself")
moved = _remove(result, source)
_add(result, path, moved)
elif kind == "copy":
_add(result, path, _get(result, str(operation["from"])))
elif kind == "test" and _get(result, path) != operation["value"]:
raise CdslPatchError(f"JSON Patch test failed at {path}")
return result
+39 -12
View File
@@ -5,7 +5,7 @@ import json
import math
from typing import Any
from app.services.engine_service import build_revision
from app.services.engine_service import build_revision, load_engine
from app.services.storage import WorkspaceStore
from app.settings import Settings
@@ -59,22 +59,35 @@ def _profile_for(operation: str, values: dict[str, Any]) -> tuple[dict[str, Any]
if operation == "add_slot":
width = _number(values, "slotWidth", values.get("width", 8.0))
length = _number(values, "slotLength", values.get("length", width * 3))
return {"type": "obround", "center": [0.0, 0.0], "length_mm": max(length, width), "width_mm": width}, depth
length = max(length, width)
radius = width / 2
left, right = -length / 2 + radius, length / 2 - radius
return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [
{"type": "line", "start": [right, radius], "end": [left, radius]},
{"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0.0], "radius_mm": radius},
{"type": "line", "start": [left, -radius], "end": [right, -radius]},
{"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0.0], "radius_mm": radius},
]}]}, depth
if operation == "add_pocket":
width = _number(values, "width", 20.0)
height = _number(values, "height", 12.0)
return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height}, depth
return {"type": "polygon", "vertices": [
[-width / 2, -height / 2], [width / 2, -height / 2],
[width / 2, height / 2], [-width / 2, height / 2],
]}, depth
if operation == "add_hole_pattern":
diameter = _number(values, "holeDiameter", values.get("diameter", 6.0))
rows = max(1, int(_number(values, "rows", 2, 1)))
columns = max(1, int(_number(values, "columns", 2, 1)))
return {
"type": "circle_grid", "radius_mm": diameter / 2,
"count_x": columns, "count_y": rows,
"spacing_x_mm": _number(values, "pitchX", 12.0),
"spacing_y_mm": _number(values, "pitchY", 12.0),
"center_mm": [0.0, 0.0],
}, depth
pitch_x = _number(values, "pitchX", 12.0)
pitch_y = _number(values, "pitchY", 12.0)
return {"type": "analytic_contours", "contours": [
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [
(column - (columns - 1) / 2) * pitch_x,
(row - (rows - 1) / 2) * pitch_y,
], "radius_mm": diameter / 2}]}
for row in range(rows) for column in range(columns)
]}, depth
raise ValueError(f"Unsupported direct CDSL edit: {operation}")
@@ -149,6 +162,15 @@ def apply_direct_edit(
raise ValueError("Task has no successful CDSL revision")
frame = _selection_frame(selection)
cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8")))
# Historical revisions can contain importer-only profile macros. An edit
# creates a new CDSL-only revision, so lower those profiles explicitly
# before adding the new generic feature.
load_engine(settings)
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
lowered_cdsl = lower_legacy_profiles(cdsl)
legacy_profiles_lowered = lowered_cdsl != cdsl
cdsl = lowered_cdsl
features = cdsl.setdefault("features", [])
sketches = cdsl.setdefault("geometry", {}).setdefault("sketches", [])
picks = selection.get("picks") if isinstance(selection.get("picks"), list) else []
@@ -183,7 +205,12 @@ def apply_direct_edit(
reference_ids=[],
summary=f"Applied {operation}",
parent_revision_id=revision_id,
operation={"type": operation, "selection": selection, "parameters": parameters},
operation={
"type": operation,
"selection": selection,
"parameters": parameters,
"legacy_profiles_lowered": legacy_profiles_lowered,
},
part_skills=None,
generation_assumptions=[],
generation_assumptions=["Legacy profile macros were lowered to direct analytic contours before this edit."] if legacy_profiles_lowered else [],
)
+73 -50
View File
@@ -4,6 +4,7 @@ import copy
import json
import math
import re
import shutil
import sys
from pathlib import Path
from typing import Any
@@ -12,10 +13,21 @@ from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
from vendor.cdsl_preview_runtime import step_to_glb
from app.services.quality import evaluate_quality, validate_verification
from app.services.storage import WorkspaceStore, now_iso, write_json
from app.settings import Settings
class QualityVerificationError(RuntimeError):
"""A built CDSL document missed a blocking generic verification rule."""
def __init__(self, quality_report: dict[str, Any]) -> None:
super().__init__("Blocking CDSL verification checks failed")
self.quality_report = quality_report
self.task_id = ""
self.revision_id = ""
def load_engine(settings: Settings) -> Any:
parent = str(settings.engine_root.parent)
if parent not in sys.path:
@@ -165,10 +177,12 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
semantic_contract = _engine_schema(engine)
atomic_contracts = semantic_contract["feature_atomic_ids"]
supported_atomic_ids = sorted(
declared_atomic_ids = {
str(atomic_id)
for atomic_id in semantic_contract.get("runtime_supported_atomic_ids", atomic_contracts)
)
}
registered_atomic_ids = {str(atomic_id) for atomic_id in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())}
supported_atomic_ids = sorted(declared_atomic_ids & registered_atomic_ids)
feature_ids: set[str] = set()
for feature in features:
fid = str(feature.get("id") or "")
@@ -400,7 +414,6 @@ def _part_skill_audit(
skill_ids = [str(item) for item in audit.get("skill_ids") or [] if str(item)]
if not skill_ids:
skill_ids = [str(item.get("id")) for item in skills if item.get("id")]
evidence = audit.get("evidence") if isinstance(audit.get("evidence"), dict) else {}
audit.update({
"schema_version": str(audit.get("schema_version") or "1.0"),
"request": str(audit.get("request") or request),
@@ -409,10 +422,6 @@ def _part_skill_audit(
"skills": skills,
"inherited_skill_ids": [str(item) for item in audit.get("inherited_skill_ids") or [] if str(item)],
"assumptions": [str(item) for item in generation_assumptions or []],
"evidence": evidence,
"capability_translations": [
item for item in audit.get("capability_translations") or [] if isinstance(item, dict)
],
})
return audit
@@ -420,20 +429,9 @@ def _part_skill_audit(
def _generation_context(
reference_ids: list[str],
part_skill_audit: dict[str, Any],
design_intent: dict[str, Any] | None = None,
design_intent_path: str = "",
design_intent_id: str = "",
) -> dict[str, Any]:
skills = [item for item in part_skill_audit.get("skills") or [] if isinstance(item, dict)]
intent = design_intent if isinstance(design_intent, dict) else {}
return {
"design_intent_id": str(intent.get("intent_id") or design_intent_id or ""),
"design_intent_path": design_intent_path,
"design_intent_structures": [
str(item.get("id") or "")
for item in intent.get("structures") or []
if isinstance(item, dict) and item.get("id")
],
"cdsl_reference_ids": list(reference_ids),
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
"part_skill_paths": [
@@ -445,9 +443,6 @@ def _generation_context(
for item in skills
],
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
"design_intent_assumptions": list(part_skill_audit.get("design_intent_assumptions") or []),
"design_intent_capability_gaps": list(part_skill_audit.get("design_intent_capability_gaps") or []),
"capability_translations": list(part_skill_audit.get("capability_translations") or []),
}
@@ -465,10 +460,9 @@ def build_revision(
input_attachments: list[dict[str, Any]] | None = None,
part_skills: dict[str, Any] | None = None,
generation_assumptions: list[str] | None = None,
design_intent: dict[str, Any] | None = None,
design_intent_path: str = "",
design_intent_id: str = "",
design_intent_status: str = "",
repair_attempts: int = 0,
verification: dict[str, Any] | None = None,
reference_records: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
engine = load_engine(settings)
task = store.ensure_task(task_id, request)
@@ -483,30 +477,19 @@ def build_revision(
selector_path = revision_dir / "model.selector.json"
edges_path = revision_dir / "model.edges.json"
part_skills_path = revision_dir / "part-skills.json"
intent = copy.deepcopy(design_intent) if isinstance(design_intent, dict) else {}
intent_assumptions = [str(item) for item in intent.get("assumptions") or [] if str(item)]
merged_assumptions = list(dict.fromkeys([
*intent_assumptions,
*(str(item) for item in generation_assumptions or [] if str(item)),
]))
part_skill_audit = _part_skill_audit(part_skills, request, merged_assumptions)
intent_id = str(intent.get("intent_id") or design_intent_id or "")
if design_intent_path:
design_intent_path = str(design_intent_path)
part_skill_audit.update({
"design_intent_id": intent_id,
"design_intent_path": design_intent_path,
"intent_status": str(intent.get("status") or design_intent_status or ""),
"design_intent_assumptions": intent_assumptions,
"design_intent_capability_gaps": [
item for item in intent.get("capability_gaps") or [] if isinstance(item, dict)
],
})
generation_context = _generation_context(reference_ids, part_skill_audit, intent, design_intent_path, intent_id)
quality_path = revision_dir / "quality-report.json"
snapshot_manifest_path = revision_dir / "snapshot-manifest.json"
part_skill_audit = _part_skill_audit(part_skills, request, generation_assumptions)
generation_context = _generation_context(reference_ids, part_skill_audit)
write_json(request_path, {"request": request, "created_at": now_iso()})
write_json(references_path, {"reference_ids": reference_ids})
write_json(references_path, {"reference_ids": reference_ids, "records": [item for item in reference_records or [] if isinstance(item, dict)]})
write_json(part_skills_path, part_skill_audit)
write_json(snapshot_manifest_path, {
"schema_version": "1.0",
"status": "unavailable",
"reason": "Snapshot runner is not attached to this backend build",
"snapshots": [],
})
def revision_record(status: str, *, error: str = "", engine_name: str = "") -> dict[str, Any]:
record = {
"revision_id": revision_id,
@@ -517,8 +500,10 @@ def build_revision(
"report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"part_skills_path": part_skills_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"design_intent_id": intent_id,
"design_intent_path": design_intent_path,
"quality_path": quality_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"snapshot_manifest_path": snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"snapshot_status": "unavailable",
"snapshot_paths": [snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix()],
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
"reference_ids": reference_ids,
@@ -526,6 +511,7 @@ def build_revision(
"parent_revision_id": parent_revision_id or "",
"operation": operation or {},
"input_attachments": input_attachments or [],
"repair_attempts": max(0, int(repair_attempts)),
}
if status == "success":
record.update({
@@ -539,6 +525,8 @@ def build_revision(
record["error"] = error
return record
quality_report: dict[str, Any] | None = None
quality_status = ""
try:
cdsl_copy = copy.deepcopy(cdsl)
if not isinstance(cdsl_copy, dict):
@@ -562,6 +550,17 @@ def build_revision(
selector, edges = topology_sidecars(engine_result, preview)
write_json(selector_path, selector)
write_json(edges_path, edges)
rules = validate_verification(verification, cdsl_copy)
quality_report = evaluate_quality(rules, cdsl_copy, engine_result)
quality_report["evaluated_at"] = now_iso()
write_json(quality_path, quality_report)
quality_status = (
"accepted" if rules and quality_report["status"] == "passed"
else "built_with_warnings" if quality_report["status"] == "passed"
else "needs_repair"
)
if quality_report["status"] != "passed":
raise QualityVerificationError(quality_report)
report = {
"engine_result": engine_result,
"preview": preview,
@@ -570,16 +569,40 @@ def build_revision(
}
write_json(report_path, report)
revision = revision_record("success", engine_name=str(engine_result["engine"]))
revision["quality_status"] = quality_status or "accepted"
revision["verification_summary"] = {
"requested": bool(rules),
"blocking_failures": len(quality_report.get("blocking_failures") or []),
"warnings": len(quality_report.get("warnings") or []),
}
except Exception as error:
if not isinstance(error, QualityVerificationError):
# The failed candidate is retained in the conversation diagnostics,
# not as a task revision. Runtime/schema failures must not create
# an editable revision that looks like a model version.
shutil.rmtree(revision_dir, ignore_errors=True)
raise
if not cdsl_path.is_file() and isinstance(cdsl, dict):
write_json(cdsl_path, copy.deepcopy(cdsl))
if quality_report is not None and not quality_path.is_file():
write_json(quality_path, quality_report)
write_json(report_path, {
"error": str(error),
"generation_context": generation_context,
"quality": quality_report,
"validated_at": now_iso(),
})
revision = revision_record("failed", error=str(error))
revision = revision_record("needs_repair" if quality_report is not None else "failed", error=str(error))
revision["quality_status"] = quality_status or ("needs_repair" if quality_report else "failed")
if quality_report is not None:
revision["verification_summary"] = {
"requested": bool(quality_report.get("verification_requested")),
"blocking_failures": len(quality_report.get("blocking_failures") or []),
"warnings": len(quality_report.get("warnings") or []),
}
store.update_task(task["task_id"], revision)
error.task_id = task["task_id"]
error.revision_id = revision_id
raise
store.update_task(task["task_id"], revision)
return {"task_id": task["task_id"], **revision}
@@ -1,273 +0,0 @@
from __future__ import annotations
import copy
import math
import re
from typing import Any
TEMPLATE_ID = "flange_sleeve_v1"
# These defaults describe the pictured family, but the model may override every
# dimension that affects the visible form. The backend owns all CDSL plumbing.
DEFAULT_PLAN: dict[str, float | str] = {
"name": "Parameterized flange sleeve",
"flange_width_mm": 120.0,
"flange_height_mm": 120.0,
"flange_thickness_mm": 14.0,
"corner_chamfer_mm": 10.0,
"tube_outer_diameter_mm": 70.0,
"tube_straight_length_mm": 95.0,
"tip_outer_diameter_mm": 62.0,
"tip_length_mm": 28.0,
"bore_diameter_mm": 46.0,
"boss_outer_diameter_mm": 82.0,
"boss_height_mm": 5.0,
"mount_hole_diameter_mm": 12.0,
"mount_counterbore_diameter_mm": 24.0,
"mount_counterbore_depth_mm": 5.0,
"mount_hole_u_mm": 42.0,
"mount_hole_v_mm": 42.0,
}
_DIMENSION_FIELDS = tuple(key for key in DEFAULT_PLAN if key != "name")
_PART_ID = re.compile(r"^[A-Za-z0-9_-]{3,80}$")
def flange_sleeve_plan_schema() -> dict[str, Any]:
"""Return the compact semantic plan accepted by the flange-sleeve tool."""
properties: dict[str, Any] = {
"template": {"const": TEMPLATE_ID},
"name": {"type": "string", "minLength": 1, "maxLength": 120},
"part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"},
}
for field in _DIMENSION_FIELDS:
minimum = 0 if field == "corner_chamfer_mm" else 0.001
properties[field] = {"type": "number", "exclusiveMinimum": minimum} if minimum else {
"type": "number", "minimum": 0,
}
return {
"type": "object",
"properties": properties,
"required": ["template"],
"additionalProperties": False,
}
def normalize_flange_sleeve_plan(plan: Any) -> dict[str, float | str]:
"""Validate only semantic dimensions; do not accept CDSL implementation data."""
if not isinstance(plan, dict):
raise ValueError("flange sleeve plan must be a JSON object")
if plan.get("template") != TEMPLATE_ID:
raise ValueError(f"flange sleeve plan template must be {TEMPLATE_ID}")
allowed = {"template", "part_id", *DEFAULT_PLAN}
unknown = sorted(str(key) for key in plan if key not in allowed)
if unknown:
raise ValueError(f"flange sleeve plan has unsupported fields: {', '.join(unknown)}")
normalized = copy.deepcopy(DEFAULT_PLAN)
for field in _DIMENSION_FIELDS:
if field not in plan:
continue
value = plan[field]
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
raise ValueError(f"flange sleeve plan {field} must be a finite number")
number = float(value)
if number < 0 if field == "corner_chamfer_mm" else number <= 0:
comparator = "non-negative" if field == "corner_chamfer_mm" else "greater than zero"
raise ValueError(f"flange sleeve plan {field} must be {comparator}")
normalized[field] = number
name = str(plan.get("name") or normalized["name"]).strip()
if not name:
raise ValueError("flange sleeve plan name must not be empty")
normalized["name"] = name[:120]
part_id = str(plan.get("part_id") or "flange_sleeve_template")
if not _PART_ID.fullmatch(part_id):
raise ValueError("flange sleeve plan part_id must use letters, numbers, underscores, or hyphens")
normalized["part_id"] = part_id
_validate_dimensions(normalized)
return normalized
def _validate_dimensions(plan: dict[str, float | str]) -> None:
number = lambda field: float(plan[field])
width, height = number("flange_width_mm"), number("flange_height_mm")
thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm")
tube_od, tip_od, bore = number("tube_outer_diameter_mm"), number("tip_outer_diameter_mm"), number("bore_diameter_mm")
boss_od = number("boss_outer_diameter_mm")
hole_od, counterbore_od = number("mount_hole_diameter_mm"), number("mount_counterbore_diameter_mm")
counterbore_depth = number("mount_counterbore_depth_mm")
hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm")
if chamfer * 2 >= min(width, height):
raise ValueError("flange sleeve plan corner_chamfer_mm must be less than half the flange side")
if not bore < min(tube_od, tip_od):
raise ValueError("flange sleeve plan bore_diameter_mm must be smaller than both tube diameters")
if not tube_od <= boss_od <= min(width, height):
raise ValueError("flange sleeve plan boss_outer_diameter_mm must be between tube diameter and flange side")
if counterbore_od < hole_od:
raise ValueError("flange sleeve plan mount_counterbore_diameter_mm must not be smaller than mount_hole_diameter_mm")
if counterbore_depth > thickness:
raise ValueError("flange sleeve plan mount_counterbore_depth_mm must not exceed flange_thickness_mm")
radius = counterbore_od / 2
if abs(hole_u) + radius >= width / 2 or abs(hole_v) + radius >= height / 2:
raise ValueError("flange sleeve plan mounting counterbores must remain inside the flange boundary")
if chamfer and abs(hole_u) > width / 2 - chamfer and abs(hole_v) > height / 2 - chamfer:
edge_clearance = (width / 2 - abs(hole_u)) + (height / 2 - abs(hole_v))
if edge_clearance < chamfer + radius * math.sqrt(2):
raise ValueError("flange sleeve plan mounting counterbores intersect the corner chamfers")
def _x_plane(offset_mm: float, normal_x: float = 1.0) -> dict[str, list[float]]:
return {
"origin_mm": [offset_mm, 0.0, 0.0],
"x_dir": [0.0, 1.0, 0.0],
"normal": [normal_x, 0.0, 0.0],
}
def _flange_profile(width: float, height: float, chamfer: float) -> dict[str, Any]:
if chamfer == 0:
return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height}
half_width, half_height = width / 2, height / 2
return {
"type": "polygon",
"vertices": [
[-half_width + chamfer, -half_height],
[half_width - chamfer, -half_height],
[half_width, -half_height + chamfer],
[half_width, half_height - chamfer],
[half_width - chamfer, half_height],
[-half_width + chamfer, half_height],
[-half_width, half_height - chamfer],
[-half_width, -half_height + chamfer],
],
}
def build_flange_sleeve_cdsl(plan: Any) -> tuple[dict[str, Any], dict[str, float | str]]:
"""Build a schema-valid CDSL flange sleeve from semantic, editable parameters."""
values = normalize_flange_sleeve_plan(plan)
number = lambda field: float(values[field])
width, height = number("flange_width_mm"), number("flange_height_mm")
thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm")
tube_radius, tip_radius, bore_radius = number("tube_outer_diameter_mm") / 2, number("tip_outer_diameter_mm") / 2, number("bore_diameter_mm") / 2
straight, tip_length = number("tube_straight_length_mm"), number("tip_length_mm")
boss_radius, boss_height = number("boss_outer_diameter_mm") / 2, number("boss_height_mm")
hole_radius, counterbore_radius = number("mount_hole_diameter_mm") / 2, number("mount_counterbore_diameter_mm") / 2
hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm")
hole_centers = [[u, v] for u in (-hole_u, hole_u) for v in (-hole_v, hole_v)]
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.3.0",
"part_id": str(values["part_id"]),
"kind": "part",
"meta": {"name": str(values["name"]), "units": "mm"},
"geometry": {"sketches": [
{
"id": "flange_outline",
"workplane": _x_plane(-thickness),
"profile": _flange_profile(width, height, chamfer),
},
{
"id": "sleeve_profile",
"workplane": {
"origin_mm": [0.0, 0.0, 0.0],
"x_dir": [1.0, 0.0, 0.0],
"normal": [0.0, 0.0, 1.0],
},
"profile": {
"type": "polygon",
"vertices": [
[0.0, bore_radius],
[0.0, tube_radius],
[straight, tube_radius],
[straight + tip_length, tip_radius],
[straight + tip_length, bore_radius],
],
},
},
{
"id": "front_boss_ring",
"workplane": _x_plane(0.0),
"profile": {
"type": "annulus",
"inner_radius_mm": bore_radius,
"outer_radius_mm": boss_radius,
"center": [0.0, 0.0],
},
},
{
"id": "center_bore",
"workplane": _x_plane(-thickness),
"profile": {"type": "circle", "radius_mm": bore_radius, "center": [0.0, 0.0]},
},
{
"id": "mount_holes",
"workplane": _x_plane(-thickness),
"profile": {
"type": "circles",
"items": [{"radius_mm": hole_radius, "center": center} for center in hole_centers],
},
},
{
"id": "mount_counterbores",
"workplane": _x_plane(0.0, -1.0),
"profile": {
"type": "circles",
"items": [{"radius_mm": counterbore_radius, "center": center} for center in hole_centers],
},
},
]},
"features": [
{
"id": "flange_plate",
"atomic_id": "extrude_add_blind",
"depends_on": [],
"sketch_id": "flange_outline",
"params": {"distance_mm": thickness},
},
{
"id": "sleeve_body",
"atomic_id": "revolve_add",
"depends_on": ["flange_plate"],
"sketch_id": "sleeve_profile",
"params": {
"angle_deg": 360.0,
"axis": {"origin_mm": [0.0, 0.0, 0.0], "direction": [1.0, 0.0, 0.0]},
},
},
{
"id": "front_boss",
"atomic_id": "extrude_add_blind",
"depends_on": ["flange_plate", "sleeve_body"],
"sketch_id": "front_boss_ring",
"params": {"distance_mm": boss_height},
},
{
"id": "center_bore_cut",
"atomic_id": "extrude_cut_blind",
"depends_on": ["flange_plate", "sleeve_body", "front_boss"],
"sketch_id": "center_bore",
"params": {"distance_mm": thickness + straight + tip_length + boss_height + 1.0},
},
{
"id": "mount_hole_cuts",
"atomic_id": "extrude_cut_blind",
"depends_on": ["center_bore_cut"],
"sketch_id": "mount_holes",
"params": {"distance_mm": thickness + 1.0},
},
{
"id": "mount_counterbore_cuts",
"atomic_id": "extrude_cut_blind",
"depends_on": ["mount_hole_cuts"],
"sketch_id": "mount_counterbores",
"params": {"distance_mm": number("mount_counterbore_depth_mm")},
},
],
}
return cdsl, values
+25 -1
View File
@@ -9,10 +9,28 @@ from app.settings import Settings
TOKEN_PATTERN = re.compile(r"[a-zA-Z0-9_]+")
CN_TOKEN_PATTERN = re.compile(r"[\u4e00-\u9fff]")
TERM_ALIASES = {
"法兰": {"flange", "mounting_plate"},
"套筒": {"sleeve", "tube", "cylinder"},
"孔": {"hole", "circle", "circles"},
"沉头": {"countersunk", "counterbore"},
"沉孔": {"counterbore", "counterbored"},
"槽": {"slot", "obround"},
"底板": {"plate", "rectangle", "mounting_plate"},
"轴": {"shaft", "cylinder"},
}
def tokens(value: str) -> set[str]:
return {token.lower() for token in TOKEN_PATTERN.findall(value)}
text = str(value or "").lower()
result = {token.lower() for token in TOKEN_PATTERN.findall(text)}
result.update(CN_TOKEN_PATTERN.findall(text))
for term, aliases in TERM_ALIASES.items():
if term in text:
result.update(aliases)
return result
class CdslLibrary:
@@ -38,6 +56,8 @@ class CdslLibrary:
"profiles": record.get("profiles", []),
"features": record.get("features", []),
"summary": record.get("summary", ""),
"family": record.get("family", ""),
"atomics": record.get("features", []),
}
for record in self._records()[:limit]
]
@@ -46,6 +66,8 @@ class CdslLibrary:
corpus = " ".join([
record.get("part_id", ""),
record.get("source_name", ""),
record.get("summary", ""),
record.get("family", ""),
" ".join(record.get("profiles", [])),
" ".join(record.get("features", [])),
" ".join(record.get("parameters", [])),
@@ -60,6 +82,8 @@ class CdslLibrary:
"profiles": record.get("profiles", []),
"features": record.get("features", []),
"summary": record.get("summary", ""),
"family": record.get("family", ""),
"atomics": record.get("features", []),
}
for _, record in scored[:limit]
]
+7 -71
View File
@@ -58,7 +58,6 @@ class PartSkill:
bridge: str
source: str
related: tuple[str, ...]
capability_translation_rules: tuple[str, ...]
@classmethod
def from_mapping(cls, value: dict[str, Any]) -> "PartSkill":
@@ -72,9 +71,6 @@ class PartSkill:
bridge=str(value["bridge"]),
source=str(value["source"]),
related=tuple(str(item) for item in value.get("related") or []),
capability_translation_rules=tuple(
str(item) for item in value.get("capability_translation_rules") or []
),
)
@@ -84,6 +80,7 @@ class PartSkillLibrary:
def __init__(self, root: Path) -> None:
self.root = Path(root)
payload = json.loads((self.root / "catalog.json").read_text(encoding="utf-8"))
self.catalog_version = str(payload.get("schema_version") or "1.0")
self.max_planning = int(payload.get("max_planning") or 1)
self.max_support = int(payload.get("max_support") or 3)
self.skills = tuple(PartSkill.from_mapping(item) for item in payload.get("skills") or [])
@@ -112,9 +109,10 @@ class PartSkillLibrary:
"kind": skill.kind,
"category": skill.kind,
"title": skill.title,
"version": self.catalog_version,
"summary": skill.title,
"source": skill.source,
"bridge": skill.bridge,
"capability_translation_rules": list(skill.capability_translation_rules),
"selection": selection,
"matched_triggers": list(matched),
}
@@ -207,14 +205,7 @@ class PartSkillLibrary:
revision_ids = [str(item) for item in (revision or {}).get("part_skill_ids") or [] if str(item) in self.by_id]
if revision_ids:
return revision_ids
# Legacy tasks can contain a DesignIntent created before their first
# runtime build. Retain this fallback only for historical artifacts.
intent_id = str((task or {}).get("current_design_intent_id") or "")
intent = next(
(item for item in (task or {}).get("design_intents") or [] if str(item.get("intent_id") or "") == intent_id),
None,
)
return [str(item) for item in (intent or {}).get("part_skill_ids") or [] if str(item) in self.by_id]
return []
def render_context(self, selection: dict[str, Any]) -> str:
records = selection.get("skills") or []
@@ -233,62 +224,9 @@ class PartSkillLibrary:
return "\n".join(sections)
def audit(self, selection: dict[str, Any], cdsl: dict[str, Any], assumptions: list[str] | None = None) -> dict[str, Any]:
features = [item for item in cdsl.get("features") or [] if isinstance(item, dict)]
feature_ids = [str(item.get("id")) for item in features if item.get("id")]
atomics = [str(item.get("atomic_id")) for item in features]
sketches = [item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict)]
profiles = [str((item.get("profile") or {}).get("type")) for item in sketches]
evidence = {
"feature_ids": feature_ids,
"atomic_ids": sorted(set(atomics)),
"profile_types": sorted(set(profiles)),
"features": [
{
"id": str(item.get("id") or ""),
"atomic_id": str(item.get("atomic_id") or ""),
"sketch_id": str(item.get("sketch_id") or ""),
"params": item.get("params") if isinstance(item.get("params"), dict) else {},
}
for item in features
],
"profiles": [
{
"id": str(item.get("id") or ""),
"type": str((item.get("profile") or {}).get("type") or ""),
"profile": item.get("profile") if isinstance(item.get("profile"), dict) else {},
}
for item in sketches
],
}
translations: list[dict[str, Any]] = []
for record in selection.get("skills") or []:
skill_id = str(record.get("id"))
status = "exact"
reason = "The selected structural guidance is represented by the submitted CDSL plan."
if skill_id == "functional/flange-bolt-circle":
if "circles" in profiles and "extrude_cut_blind" in atomics:
status, reason = "expanded", "Circular bolt layout is represented by explicit circle geometry because no circular-pattern atomic exists."
else:
status, reason = "blocked", "No explicit circular bolt layout evidence was found in the CDSL."
elif skill_id == "atomic/threaded-hole-creation":
status, reason = "approximated", "The current runtime preserves a cylindrical bore but does not generate helical thread topology."
elif skill_id == "atomic/fillet-chamfer-last":
finishing = [item for item in features if item.get("atomic_id") in {"fillet", "chamfer"}]
if not finishing:
status, reason = "omitted", "No stable finishing selector was submitted; edge treatment was omitted."
elif skill_id == "atomic/pattern-holes-from-datum":
if "circles" in profiles or "circle_grid" in profiles:
status, reason = "expanded", "The layout is represented by explicit profile circles."
elif not any(item in {"pattern_linear", "pattern_mirror"} for item in atomics):
status, reason = "blocked", "No supported pattern or explicit circle layout was found."
translation: dict[str, Any] = {"skill_id": skill_id, "status": status, "reason": reason, "evidence": evidence}
if skill_id == "functional/flange-bolt-circle" and status == "expanded":
translation["translation"] = "circular_pattern_to_explicit_circles"
elif skill_id == "atomic/threaded-hole-creation":
translation["translation"] = "thread_geometry_omitted"
elif skill_id == "atomic/fillet-chamfer-last" and status == "omitted":
translation["translation"] = "selector_unavailable"
translations.append(translation)
# Skills are prompt knowledge only. Do not interpret a selected skill
# as geometry, capability translation, or an executable model rule.
del cdsl
return {
"schema_version": "1.0",
"request": selection.get("request", ""),
@@ -298,6 +236,4 @@ class PartSkillLibrary:
"inherited_skill_ids": list(selection.get("inherited_skill_ids") or []),
"conflict": selection.get("conflict"),
"assumptions": [str(item) for item in assumptions or []],
"evidence": evidence,
"capability_translations": translations,
}
+262
View File
@@ -0,0 +1,262 @@
"""Generic, model-family-independent verification for direct CDSL revisions."""
from __future__ import annotations
import math
from typing import Any
QUALITY_RULE_TYPES = frozenset({
"bbox", "solid_count", "feature_count", "hole_count", "hole_diameter",
"hole_center", "overall_length", "overall_diameter", "through_condition",
})
_FEATURE_RULE_TYPES = {"hole_count", "hole_diameter", "hole_center", "through_condition"}
_SEVERITIES = {"blocking", "warning", "informational"}
def _bbox_bounds(engine_result: dict[str, Any], feature_id: str | None = None) -> tuple[list[float], list[float]] | None:
"""Return a whole-model or feature-owned runtime bounding box."""
candidates: list[Any] = []
if feature_id:
for record in engine_result.get("topology_records") or []:
if not isinstance(record, dict):
continue
owners = record.get("owner_feature_ids") or []
if record.get("feature_id") == feature_id or feature_id in owners:
geometry = record.get("geometry")
if isinstance(geometry, dict):
candidates.append(geometry.get("bbox_mm"))
else:
bbox = engine_result.get("bbox_mm")
if isinstance(bbox, dict):
candidates.append([
*(bbox.get("min") or []),
*(bbox.get("max") or []),
])
boxes = [
value for value in candidates
if isinstance(value, list) and len(value) == 6
and all(isinstance(item, (int, float)) and math.isfinite(float(item)) for item in value)
]
if not boxes:
return None
minimum = [min(box[index] for box in boxes) for index in (0, 1, 2)]
maximum = [max(box[index] for box in boxes) for index in (3, 4, 5)]
return [float(item) for item in minimum], [float(item) for item in maximum]
def _bbox_value(engine_result: dict[str, Any], feature_id: str | None = None) -> dict[str, Any] | None:
bounds = _bbox_bounds(engine_result, feature_id)
if bounds is None:
return None
minimum, maximum = bounds
return {
"min": minimum,
"max": maximum,
"dimensions": [maximum[index] - minimum[index] for index in range(3)],
}
def _dimensions(engine_result: dict[str, Any]) -> list[float] | None:
value = _bbox_value(engine_result)
return value["dimensions"] if value else None
def _feature(cdsl: dict[str, Any], feature_id: str | None) -> dict[str, Any] | None:
return next((item for item in cdsl.get("features") or [] if isinstance(item, dict) and str(item.get("id")) == feature_id), None)
def _sketch(cdsl: dict[str, Any], sketch_id: str | None) -> dict[str, Any] | None:
return next((item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict) and str(item.get("id")) == sketch_id), None)
def _circles(profile: dict[str, Any]) -> list[dict[str, Any]]:
if profile.get("type") == "circle":
center, radius = profile.get("center"), profile.get("radius_mm")
if isinstance(center, list) and len(center) >= 2 and isinstance(radius, (int, float)):
return [{"center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}]
if profile.get("type") != "analytic_contours":
return []
return [
{"center": [float(segment["center"][0]), float(segment["center"][1])], "radius_mm": float(segment["radius_mm"])}
for contour in profile.get("contours") or [] if isinstance(contour, dict)
for segment in contour.get("segments") or [] if isinstance(segment, dict)
and segment.get("type") == "circle"
and isinstance(segment.get("center"), list) and len(segment["center"]) >= 2
and isinstance(segment.get("radius_mm"), (int, float))
]
def _finite_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value))
def _validate_expected(kind: str, expected: Any, index: int) -> None:
scalar_types = {"solid_count", "feature_count", "hole_count", "hole_diameter", "overall_length", "overall_diameter"}
if kind in scalar_types and not _finite_number(expected):
raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}")
if kind == "bbox":
valid_dimensions = isinstance(expected, list) and len(expected) == 3 and all(_finite_number(value) for value in expected)
valid_ranges = isinstance(expected, dict) and set(expected) in ({"min", "max"}, {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"})
if valid_ranges and set(expected) == {"min", "max"}:
valid_ranges = all(
isinstance(expected[key], list)
and len(expected[key]) == 3
and all(_finite_number(value) for value in expected[key])
for key in ("min", "max")
)
elif valid_ranges:
valid_ranges = all(_finite_number(expected[key]) for key in expected)
if not valid_dimensions and not valid_ranges:
raise ValueError(
f"verification.rules[{index}].expected for bbox must be a three-number bbox [dx, dy, dz], "
"{min, max}, or {x_min, x_max, y_min, y_max, z_min, z_max}"
)
if kind == "hole_center" and (
not isinstance(expected, list) or len(expected) != 2 or not all(_finite_number(value) for value in expected)
):
raise ValueError(f"verification.rules[{index}].expected must be a two-number hole center")
if kind == "through_condition" and not isinstance(expected, bool):
raise ValueError(f"verification.rules[{index}].expected must be boolean for through_condition")
def validate_verification(verification: Any, cdsl: dict[str, Any]) -> list[dict[str, Any]]:
if verification is None:
return []
if not isinstance(verification, dict) or set(verification) - {"rules"}:
raise ValueError("verification must be an object containing only rules")
rules = verification.get("rules", [])
if not isinstance(rules, list) or len(rules) > 32:
raise ValueError("verification.rules must be an array with at most 32 rules")
feature_ids = {str(item.get("id")) for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id")}
normalized: list[dict[str, Any]] = []
ids: set[str] = set()
for index, raw in enumerate(rules):
if not isinstance(raw, dict):
raise ValueError(f"verification.rules[{index}] must be an object")
allowed = {"id", "type", "feature", "expected", "tolerance", "severity"}
if set(raw) - allowed:
raise ValueError(f"verification.rules[{index}] has unsupported fields")
rule_id = str(raw.get("id") or "").strip()
kind = str(raw.get("type") or "").strip()
if not rule_id or rule_id in ids:
raise ValueError(f"verification.rules[{index}].id must be unique and non-empty")
if kind not in QUALITY_RULE_TYPES:
raise ValueError(f"verification.rules[{index}].type is unsupported: {kind}")
if "expected" not in raw:
raise ValueError(f"verification.rules[{index}].expected is required")
_validate_expected(kind, raw["expected"], index)
severity = str(raw.get("severity") or "blocking")
if severity not in _SEVERITIES:
raise ValueError(f"verification.rules[{index}].severity is unsupported")
try:
tolerance = float(raw.get("tolerance") or 0.0)
except (TypeError, ValueError) as error:
raise ValueError(f"verification.rules[{index}].tolerance must be numeric") from error
if not math.isfinite(tolerance) or tolerance < 0:
raise ValueError(f"verification.rules[{index}].tolerance must be finite and non-negative")
feature = str(raw.get("feature") or "").strip()
if kind in _FEATURE_RULE_TYPES and not feature:
raise ValueError(f"verification.rules[{index}].feature is required for {kind}")
if feature and feature not in feature_ids:
raise ValueError(f"verification.rules[{index}].feature must reference a CDSL feature ID")
ids.add(rule_id)
normalized.append({"id": rule_id, "type": kind, "feature": feature, "expected": raw["expected"], "tolerance": tolerance, "severity": severity})
return normalized
def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> tuple[Any, str]:
kind, target = rule["type"], rule.get("feature") or None
feature = _feature(cdsl, target)
params = (feature or {}).get("params") if isinstance((feature or {}).get("params"), dict) else {}
sketch = _sketch(cdsl, str((feature or {}).get("sketch_id") or ""))
profile = (sketch or {}).get("profile") if isinstance(sketch, dict) else {}
circles = _circles(profile) if isinstance(profile, dict) else []
dimensions = _dimensions(engine_result)
if kind == "bbox":
value = _bbox_value(engine_result, target)
source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm"
return value, source
if kind == "solid_count":
return float(engine_result.get("solid_count", 1)), "runtime.solid_count"
if kind == "feature_count":
return float(len(cdsl.get("features") or [])), "cdsl.features"
if kind == "hole_count":
return float(len(circles)), f"cdsl.features.{target}.sketch"
if kind == "hole_diameter":
value = params.get("diameter_mm", params.get("hole_diameter_mm"))
if isinstance(value, (int, float)):
return float(value), f"cdsl.features.{target}.params"
return (circles[0]["radius_mm"] * 2 if circles else None), f"cdsl.features.{target}.sketch"
if kind == "hole_center":
centers = [circle["center"] for circle in circles]
return (centers[0] if len(centers) == 1 else centers), f"cdsl.features.{target}.sketch"
if kind == "overall_length":
return (max(dimensions) if dimensions else None), "runtime.bbox_mm"
if kind == "overall_diameter":
return (min(dimensions) if dimensions else None), "runtime.bbox_mm"
if kind == "through_condition":
end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {}
if end.get("type") in {"through_all", "through_all_both", "through_all_and_blind"}:
return True, f"cdsl.features.{target}.params.end_condition"
distance = params.get("distance_mm")
return bool(isinstance(distance, (int, float)) and dimensions and float(distance) >= min(dimensions) - 1e-6), "cdsl.params + runtime.bbox_mm"
return None, "unsupported verification type"
def _matches(expected: Any, actual: Any, tolerance: float) -> bool:
if actual is None:
return False
if isinstance(expected, list):
return isinstance(actual, list) and len(expected) == len(actual) and all(
_matches(expected_item, actual_item, tolerance) for expected_item, actual_item in zip(expected, actual)
)
if isinstance(expected, bool):
return bool(actual) is expected
if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
return math.isclose(float(expected), float(actual), abs_tol=tolerance, rel_tol=0.0)
return expected == actual
def _matches_bbox(expected: Any, actual: dict[str, Any] | None, tolerance: float) -> bool:
if actual is None:
return False
if isinstance(expected, list):
return _matches(expected, actual["dimensions"], tolerance)
if not isinstance(expected, dict):
return False
if set(expected) == {"min", "max"}:
return _matches(expected["min"], actual["min"], tolerance) and _matches(expected["max"], actual["max"], tolerance)
if set(expected) == {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"}:
actual_ranges = {
"x_min": actual["min"][0], "x_max": actual["max"][0],
"y_min": actual["min"][1], "y_max": actual["max"][1],
"z_min": actual["min"][2], "z_max": actual["max"][2],
}
return all(_matches(expected[key], actual_ranges[key], tolerance) for key in actual_ranges)
return False
def evaluate_quality(rules: list[dict[str, Any]], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> dict[str, Any]:
results = []
for rule in rules:
actual, source = _actual(rule, cdsl, engine_result)
passed = (
_matches_bbox(rule["expected"], actual, rule["tolerance"])
if rule["type"] == "bbox"
else _matches(rule["expected"], actual, rule["tolerance"])
)
results.append({**rule, "status": "passed" if passed else ("failed" if actual is not None else "unavailable"), "actual": actual, "source": source})
blocking = [result for result in results if result["severity"] == "blocking" and result["status"] != "passed"]
warnings = [result for result in results if result["severity"] != "blocking" and result["status"] != "passed"]
return {
"schema": "cad.quality-report.v1",
"schema_version": "1.0",
"status": "passed" if not blocking else "failed",
"verification_requested": bool(rules),
"results": results,
"blocking_failures": blocking,
"warnings": warnings,
"measurements": {"bbox_mm": engine_result.get("bbox_mm"), "solid_count": engine_result.get("solid_count", 1)},
}
+36 -86
View File
@@ -12,7 +12,6 @@ from app.settings import Settings
TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$")
CONVERSATION_ID = re.compile(r"^conv_[a-z0-9]{12}$")
DESIGN_INTENT_ID = re.compile(r"^intent_[a-z0-9]{12}$")
def now_iso() -> str:
@@ -37,13 +36,6 @@ def safe_conversation_id(conversation_id: str) -> str:
return value
def safe_design_intent_id(intent_id: str) -> str:
value = str(intent_id or "").strip()
if not DESIGN_INTENT_ID.fullmatch(value):
raise ValueError("Invalid design intent id")
return value
def safe_relative_path(value: str) -> str:
path = Path(str(value or ""))
if not value or path.is_absolute() or ".." in path.parts:
@@ -104,6 +96,15 @@ class WorkspaceStore:
write_json(path, payload)
return (Path(conversation) / relative).as_posix()
def write_conversation_planning(self, conversation_id: str, prefix: str, payload: dict[str, Any]) -> str:
"""Persist structured intake/planning evidence before a task exists."""
conversation = safe_conversation_id(conversation_id)
safe_prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", prefix).strip("-") or "planning"
relative = Path("planning") / f"{safe_prefix}-{secrets.token_hex(6)}.json"
path = self.conversation_dir(conversation) / relative
write_json(path, payload)
return (Path(conversation) / relative).as_posix()
def ensure_conversation(
self,
conversation_id: str | None,
@@ -184,14 +185,12 @@ class WorkspaceStore:
task_dir = self.task_dir(tid)
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
record = {
"schema_version": "1.1",
"schema_version": "1.2",
"task_id": tid,
"request": request,
"created_at": now_iso(),
"updated_at": now_iso(),
"current_revision": "",
"current_design_intent_id": "",
"design_intents": [],
"revisions": [],
}
write_json(path, record)
@@ -213,81 +212,6 @@ class WorkspaceStore:
write_json(self.task_path(task_id), task)
return task
def create_design_intent(
self,
task_id: str | None,
request: str,
intent: dict[str, Any],
part_skill_selection: dict[str, Any],
) -> dict[str, Any]:
"""Persist a validated plan and atomically make it the current plan."""
task = self.ensure_task(task_id, request)
task.setdefault("design_intents", [])
task.setdefault("current_design_intent_id", "")
task["schema_version"] = "1.1"
previous_id = str(task.get("current_design_intent_id") or "")
if previous_id:
for record in task["design_intents"]:
if str(record.get("intent_id") or "") == previous_id and record.get("status") != "superseded":
record["status"] = "superseded"
intent_id = new_id("intent")
created_at = now_iso()
relative = Path("planning") / f"design-intent-{intent_id}.json"
skill_ids = [str(item) for item in part_skill_selection.get("skill_ids") or [] if str(item)]
persisted = dict(intent)
persisted.update({
"intent_id": intent_id,
"created_at": created_at,
"part_skill_ids": skill_ids,
"part_skill_selection": part_skill_selection,
})
write_json(self.artifact_path(task["task_id"], relative.as_posix()), persisted)
record = {
"intent_id": intent_id,
"status": "accepted" if persisted.get("status") == "ready" else "pending",
"created_at": created_at,
"path": relative.as_posix(),
"part_skill_ids": skill_ids,
"base_revision_id": str(persisted.get("base_revision_id") or ""),
}
task["design_intents"].append(record)
task["current_design_intent_id"] = intent_id
task["updated_at"] = now_iso()
write_json(self.task_path(task["task_id"]), task)
return {"task_id": task["task_id"], **record, "intent": persisted}
def read_design_intent(self, task_id: str, intent_id: str | None = None) -> dict[str, Any] | None:
task = self.read_task(task_id)
if not task:
return None
chosen = safe_design_intent_id(intent_id) if intent_id else str(task.get("current_design_intent_id") or "")
if not chosen:
return None
record = next(
(item for item in task.get("design_intents") or [] if str(item.get("intent_id") or "") == chosen),
None,
)
if not isinstance(record, dict):
return None
path = self.artifact_path(task_id, str(record.get("path") or ""))
intent = read_json(path)
if not isinstance(intent, dict):
return None
return {"task_id": task_id, "record": record, "intent": intent}
def update_design_intent_status(self, task_id: str, intent_id: str, status: str) -> dict[str, Any]:
safe_id = safe_design_intent_id(intent_id)
task = self.ensure_task(task_id, "")
records = task.setdefault("design_intents", [])
record = next((item for item in records if str(item.get("intent_id") or "") == safe_id), None)
if not isinstance(record, dict):
raise ValueError("Design intent not found")
record["status"] = str(status)
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return record
def read_task(self, task_id: str) -> dict[str, Any] | None:
return read_json(self.task_path(task_id))
@@ -299,6 +223,32 @@ class WorkspaceStore:
candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json"
return candidate if candidate.is_file() else None
def latest_repairable_cdsl(self, task_id: str) -> tuple[str, Path] | None:
"""Return the latest revision CDSL when a quality failure needs repair."""
task = self.read_task(task_id)
for revision in reversed((task or {}).get("revisions") or []):
revision_id = str(revision.get("revision_id") or "")
if not revision_id or str(revision.get("quality_status") or "") != "needs_repair":
continue
path = self.revision_cdsl_path(task_id, revision_id)
if path is not None:
return revision_id, path
return None
def revision_cdsl_path(self, task_id: str, revision_id: str) -> Path | None:
task = self.read_task(task_id)
revision = next(
(item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id),
None,
)
if not isinstance(revision, dict):
return None
relative = str(revision.get("cdsl_path") or "")
if not relative:
return None
candidate = self.artifact_path(task_id, relative)
return candidate if candidate.is_file() else None
def artifact_path(self, task_id: str, relative_path: str) -> Path:
safe = safe_relative_path(relative_path)
root = self.task_dir(task_id).resolve()