优化生成流程
This commit is contained in:
+33
-24
@@ -57,6 +57,7 @@ async def config() -> dict[str, Any]:
|
||||
"model": settings.llm_model,
|
||||
"configured": settings.llm_configured,
|
||||
"library_samples": library.count(),
|
||||
"max_repair_attempts": settings.max_repair_attempts,
|
||||
}
|
||||
|
||||
|
||||
@@ -130,30 +131,6 @@ async def read_task(task_id: str) -> JSONResponse:
|
||||
return JSONResponse(task)
|
||||
|
||||
|
||||
@app.get("/v1/tasks/{task_id}/design-intent")
|
||||
async def read_current_design_intent(task_id: str) -> JSONResponse:
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
result = store.read_design_intent(safe_id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="No DesignIntent exists for this task")
|
||||
return JSONResponse({"task_id": safe_id, "intent_id": result["record"]["intent_id"], **result})
|
||||
|
||||
|
||||
@app.get("/v1/tasks/{task_id}/design-intents/{intent_id}")
|
||||
async def read_design_intent(task_id: str, intent_id: str) -> JSONResponse:
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
result = store.read_design_intent(safe_id, intent_id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="DesignIntent not found")
|
||||
return JSONResponse({"task_id": safe_id, "intent_id": result["record"]["intent_id"], **result})
|
||||
|
||||
|
||||
@app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}")
|
||||
async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -186,6 +163,38 @@ async def read_parameters(task_id: str) -> JSONResponse:
|
||||
return JSONResponse({"task_id": safe_id, "revision_id": revision_id, **json.loads(path.read_text(encoding="utf-8"))})
|
||||
|
||||
|
||||
@app.get("/v1/tasks/{task_id}/quality")
|
||||
async def read_quality(task_id: str) -> JSONResponse:
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
task = store.read_task(safe_id)
|
||||
revision_id = str((task or {}).get("current_revision") or "")
|
||||
revisions = (task or {}).get("revisions", [])
|
||||
revision = next((item for item in revisions if item.get("revision_id") == revision_id), None)
|
||||
if revisions and revisions[-1].get("revision_id") != revision_id:
|
||||
revision = revisions[-1]
|
||||
revision_id = str(revision.get("revision_id") or "")
|
||||
payload: dict[str, Any] = {
|
||||
"task_id": safe_id,
|
||||
"revision_id": revision_id,
|
||||
"quality_status": (revision or {}).get("quality_status", ""),
|
||||
"snapshot_status": (revision or {}).get("snapshot_status", "unavailable"),
|
||||
"snapshot_paths": (revision or {}).get("snapshot_paths", []),
|
||||
"assumptions": (revision or {}).get("generation_assumptions", []),
|
||||
"verification_summary": (revision or {}).get("verification_summary", {}),
|
||||
}
|
||||
relative = str((revision or {}).get("quality_path") or "")
|
||||
if relative:
|
||||
path = store.artifact_path(safe_id, relative)
|
||||
if path.is_file():
|
||||
payload["quality"] = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not revision:
|
||||
raise HTTPException(status_code=404, detail="Task has no revision")
|
||||
return JSONResponse(payload)
|
||||
|
||||
|
||||
@app.post("/v1/tasks/{task_id}/parameters")
|
||||
async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONResponse:
|
||||
try:
|
||||
|
||||
@@ -59,3 +59,10 @@ class CadResult(BaseModel):
|
||||
summary: str
|
||||
reference_ids: list[str] = Field(default_factory=list)
|
||||
engine: str = "cdsl_only"
|
||||
quality_status: str = ""
|
||||
quality_path: str | None = None
|
||||
assumptions: list[str] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
repair_attempts: int = 0
|
||||
snapshot_paths: list[str] = Field(default_factory=list)
|
||||
snapshot_status: str = "unavailable"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 [],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)},
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -50,6 +50,7 @@ class Settings:
|
||||
llm_timeout_s: float
|
||||
default_provider_id: str
|
||||
providers: tuple[ProviderConfig, ...]
|
||||
max_repair_attempts: int = 4
|
||||
|
||||
@property
|
||||
def llm_configured(self) -> bool:
|
||||
@@ -137,6 +138,7 @@ def get_settings() -> Settings:
|
||||
llm_api_key=default_provider.api_key,
|
||||
llm_model=default_model,
|
||||
llm_timeout_s=float(os.getenv("CDSL_LLM_TIMEOUT_S", "90")),
|
||||
max_repair_attempts=max(0, int(os.getenv("CDSL_MAX_REPAIR_ATTEMPTS", "4"))),
|
||||
default_provider_id=default_provider_id,
|
||||
providers=providers,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user