优化agent

This commit is contained in:
2026-08-25 11:01:59 +08:00
parent 9f8a55ff07
commit 51575161dc
34 changed files with 2687 additions and 456 deletions
+10 -7
View File
@@ -1,19 +1,22 @@
# Default provider. Only providers with an API key are exposed to the UI.
CDSL_DEFAULT_PROVIDER=deepseek
CDSL_DEFAULT_MODEL=deepseek-chat
CDSL_DEFAULT_MODEL=deepseek-v4-flash-vision-exp
# CDSL_DEFAULT_PROVIDER=openai
# CDSL_DEFAULT_MODEL=gpt-5.5
# DeepSeek. Fill in your own API key below.
CDSL_LLM_BASE_URL=https://api.deepseek.com/v1
CDSL_LLM_API_KEY=sk-d3f8fe84bf9a4100a6563559e5d2fefd
CDSL_LLM_MODEL=deepseek-chat
CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp
CDSL_LLM_TIMEOUT_S=90
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
# Optional OpenAI provider. Comma-separate enabled models; list vision models
# separately so image attachments can be routed safely.
CDSL_OPENAI_BASE_URL=https://api.openai.com/v1
CDSL_OPENAI_API_KEY=
CDSL_OPENAI_MODELS=gpt-4.1,gpt-4.1-mini
CDSL_OPENAI_VISION_MODELS=gpt-4.1,gpt-4.1-mini
CDSL_OPENAI_BASE_URL=https://api.vip1129.cc/v1
CDSL_OPENAI_API_KEY=sk-6586c229d77de8c421ba98e7eb0d9c6bb10f08ebc796de946ed17cf8d0d7a229
CDSL_OPENAI_MODELS=gpt-5.5
CDSL_OPENAI_VISION_MODELS=gpt-5.5
# Optional Kimi provider.
CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1
@@ -24,7 +27,7 @@ CDSL_KIMI_VISION_MODELS=
# OpenAI-compatible function schemas. This affects generate_cdsl_model's JSON
# arguments only, never ordinary assistant chat text.
CDSL_DEEPSEEK_STRICT_TOOL_SCHEMA=false
CDSL_OPENAI_STRICT_TOOL_SCHEMA=true
CDSL_OPENAI_STRICT_TOOL_SCHEMA=false
CDSL_KIMI_STRICT_TOOL_SCHEMA=true
# To enable individual models instead of every model from a provider, keep the
# provider-wide switch false and list exact IDs, for example:
+9 -7
View File
@@ -13,15 +13,16 @@ Help an AI agent generate CAD models through the repository's CDSL engine.
1. Use any injected CDSL part-skill bridge to establish the part structure,
parameter roles, and feature order. It is planning guidance only.
2. Submit a complete DesignIntent before reading CDSL references or generating
CDSL. It contains semantic structures and mappings, never sketch coordinates
or raw CAD code. The backend records canonical selected part skills.
2. Record a concise natural-language design brief before reading CDSL references
or generating CDSL. The brief captures the intended structures, dependency
order, key dimensions, and explicit assumptions. It is reference context
only, never an executable CAD contract.
3. Read the engine README, `profile_schema.json`, and `cdsl_schema.json`, then
search the official CDSL library for schema-valid profiles and feature
expressions. `cdsl_schema.json` is the executable input contract.
4. Produce parameterized CDSL in feature dependency order, using the accepted
DesignIntent's feature IDs and mappings plus the part
plan for intent and CDSL references for concrete schema expressions.
4. Produce parameterized CDSL in feature dependency order. CDSL is the sole
authoritative and executable model: choose its feature IDs, profiles,
selectors, and schema-valid expressions directly from the CDSL contract.
5. Call the generation tool only when every chosen feature is executable by
the current schema and runtime. Validate through the `cdsl_only` path,
then generate STEP and GLB artifacts.
@@ -29,7 +30,8 @@ Help an AI agent generate CAD models through the repository's CDSL engine.
## Part-skill boundary
- User requirements and the CDSL schema/runtime override bridge guidance;
CDSL examples are lower-priority expression references.
CDSL examples are lower-priority expression references. The textual design
brief is planning context and is never validated against the CDSL.
- Do not expose upstream source skills directly or treat them as code. Use
only the injected CDSL bridge content.
- Never emit build123d source or invent an atomic, profile, selector, or
@@ -1,40 +1,35 @@
# DesignIntent Planning Recipe
# CDSL Planning Recipe
## Requirement analysis
1. Treat the backend-injected part-family skill as the selected family before
identifying structures. It provides planning guidance only.
2. Decompose the requested part into structures with roles: `base`,
`reference`, `additive`, `subtractive`, `dressup`, or `pattern`.
3. Every structure must state a purpose, structural dependencies, parameter
roles, selector roles, an executable CDSL atomic ID, and a named profile
type when its atomic needs a sketch.
4. Put every structure exactly once in `feature_order`, after its dependencies.
Its `cdsl_feature_id` is the immutable ID that the later CDSL feature must use.
5. Do not put coordinates, raw CDSL, Build123d, or low-level sketch data in a
DesignIntent.
6. Missing essential dimensions are blocking `open_questions`. Unsupported
geometry is a `capability_gaps` item. A blocking item requires
`status: needs_clarification`; do not approximate silently.
1. Treat the backend-injected part-family skill as planning guidance only.
2. Write a concise natural-language brief describing the requested structures,
dependency order, key dimensions, and explicit assumptions.
3. Ask the user one concise clarification question when an essential dimension
or requested outcome is unknown. Do not silently fabricate it.
4. Keep the brief free of feature IDs, selector bindings, profile bindings,
sketch coordinates, raw CDSL, and Build123d source. Those implementation
choices belong only in the final CDSL document.
5. The brief is not stored or validated as a CAD artifact. It is reference
context for the model while it writes the one authoritative CDSL document.
## Part family boundary
- Use only the backend-injected primary and support skills. Never submit
`part_skill_ids`; the backend records the canonical selection for audit.
- Use only the backend-injected primary and support skills. The backend records
the canonical selection for audit.
- Explicit user requirements override part skills. The executable CDSL
schema/runtime overrides both part skills and library examples.
- The current primary family is inherited during revisions unless the user
explicitly asks to replace the whole part. A family conflict needs a concise
clarification before a ready plan can be submitted.
- CDSL library examples show only expression patterns. They never add or remove
a requested DesignIntent structure.
- CDSL library examples show expression patterns only. They never override the
user's request or the CDSL schema/runtime.
## CDSL generation
- Submit `propose_design_intent` before any library or CDSL-generation call.
- After its ready result includes `intent_id`, inspect references and generate
complete CDSL. Use every planned `cdsl_feature_id` once, in `feature_order`.
- Do not add undeclared CDSL features, delete planned features, change planned
atomics/profiles, or omit declared selector evidence.
- Submit `describe_design_intent` before any library or CDSL-generation call.
- After the text brief is returned, inspect references and generate complete,
schema-valid CDSL. CDSL controls its own feature IDs, atomics, profiles, and
selectors; it is not checked for agreement with the brief.
- Keep `cdsl_only` as the only build path. Never use `compiler_context` or a
legacy fallback.
+12 -16
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from app.models.contracts import ChatRequest, ConversationPatch, ModifyRequest, ParameterUpdate
@@ -88,29 +88,32 @@ async def create_conversation() -> JSONResponse:
@app.patch("/v1/conversations/{conversation_id}")
async def patch_conversation(conversation_id: str, payload: ConversationPatch) -> JSONResponse:
try:
record = store.ensure_conversation(safe_conversation_id(conversation_id), payload.current_task_id, payload.attachments)
record = store.ensure_conversation(safe_conversation_id(conversation_id), payload.current_task_id)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
return JSONResponse(record)
@app.post("/v1/uploads")
async def upload_attachment(
@app.post("/v1/conversations/{conversation_id}/attachments")
async def upload_conversation_attachment(
conversation_id: str,
file: UploadFile = File(...),
task_id: str | None = Form(default=None),
) -> JSONResponse:
data = await file.read()
filename = file.filename or "attachment"
try:
conversation = safe_conversation_id(conversation_id)
if store.read_conversation(conversation) is None:
raise HTTPException(status_code=404, detail="Conversation not found")
kind = classify_upload(filename, file.content_type or "", len(data))
task = store.ensure_task(safe_task_id(task_id) if task_id else None, f"Attachment: {filename}")
relative_path, _ = store.write_upload(task["task_id"], filename, data)
relative_path, _ = store.write_conversation_upload(conversation, filename, data)
extracted_path = ""
if kind == "document":
extracted_path = relative_path + ".txt"
extracted = extract_document_text(data)
store.artifact_path(task["task_id"], extracted_path).write_text(extracted, encoding="utf-8")
record = attachment_record(task["task_id"], filename, file.content_type or "", relative_path, data, kind, extracted_path)
store.conversation_attachment_path(conversation, extracted_path).write_text(extracted, encoding="utf-8")
record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path)
store.add_conversation_attachment(conversation, record)
return JSONResponse(record)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@@ -189,10 +192,6 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo
safe_id = safe_task_id(task_id)
task = store.read_task(safe_id)
current_revision_id = str((task or {}).get("current_revision") or "")
current_revision = next(
(item for item in (task or {}).get("revisions", []) if item.get("revision_id") == current_revision_id),
{},
)
current_path = store.current_cdsl_path(safe_id)
if not task or not current_path or not current_revision_id:
raise ValueError("Task has no successful CDSL revision")
@@ -209,9 +208,6 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo
operation={"type": "parameter_update", "values": payload.values},
part_skills=None,
generation_assumptions=[],
design_intent_id=str(current_revision.get("design_intent_id") or ""),
design_intent_path=str(current_revision.get("design_intent_path") or ""),
design_intent_status="accepted" if current_revision.get("design_intent_id") else "",
)
return JSONResponse(result)
except ValueError as error:
-3
View File
@@ -28,7 +28,6 @@ class ChatRequest(BaseModel):
class ConversationPatch(BaseModel):
current_task_id: str | None = None
attachments: list[dict[str, Any]] | None = None
class ParameterUpdate(BaseModel):
@@ -57,8 +56,6 @@ class CadResult(BaseModel):
parameters_path: str | None = None
selector_path: str | None = None
edges_path: str | None = None
design_intent_id: str | None = None
design_intent_path: str | None = None
summary: str
reference_ids: list[str] = Field(default_factory=list)
engine: str = "cdsl_only"
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -34,10 +34,10 @@ def extract_document_text(data: bytes) -> str:
return text[:MAX_EXTRACTED_CHARS]
def attachment_record(task_id: str, filename: str, mime: str, relative_path: str, data: bytes, kind: str, extracted_path: str = "") -> dict[str, object]:
def attachment_record(conversation_id: str, filename: str, mime: str, relative_path: str, data: bytes, kind: str, extracted_path: str = "") -> dict[str, object]:
return {
"id": Path(relative_path).stem,
"task_id": task_id,
"conversation_id": conversation_id,
"name": filename,
"kind": kind,
"path": relative_path,
-7
View File
@@ -147,10 +147,6 @@ def apply_direct_edit(
revision_id = str((task or {}).get("current_revision") or "")
if source is None or not revision_id:
raise ValueError("Task has no successful CDSL revision")
current_revision = next(
(item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id),
{},
)
frame = _selection_frame(selection)
cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8")))
features = cdsl.setdefault("features", [])
@@ -190,7 +186,4 @@ def apply_direct_edit(
operation={"type": operation, "selection": selection, "parameters": parameters},
part_skills=None,
generation_assumptions=[],
design_intent_id=str(current_revision.get("design_intent_id") or ""),
design_intent_path=str(current_revision.get("design_intent_path") or ""),
design_intent_status="accepted" if current_revision.get("design_intent_id") else "",
)
+72 -2
View File
@@ -75,6 +75,76 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
def _legacy_workplane(plane: str, offset: Any) -> dict[str, list[float]] | None:
if isinstance(offset, bool) or not isinstance(offset, (int, float)):
return None
distance = float(offset)
definitions = {
"XY": ([0.0, 0.0, distance], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
"XZ": ([0.0, distance, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
"YZ": ([distance, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]),
}
definition = definitions.get(plane.upper())
if definition is None:
return None
origin, x_dir, normal = definition
return {"origin_mm": origin, "x_dir": x_dir, "normal": normal}
def normalize_cdsl_for_engine(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""Convert unambiguous legacy LLM aliases into the current CDSL dialect.
This intentionally does not infer dimensions, selectors, or feature
dependencies. Any non-mechanical error remains visible to the validator.
"""
normalized = copy.deepcopy(cdsl)
repairs: list[str] = []
geometry = normalized.get("geometry")
sketches = geometry.get("sketches") if isinstance(geometry, dict) else None
if isinstance(sketches, list):
for index, sketch in enumerate(sketches):
if not isinstance(sketch, dict):
continue
if "id" not in sketch and isinstance(sketch.get("sketch_id"), str):
sketch["id"] = sketch.pop("sketch_id")
repairs.append(f"geometry.sketches[{index}]: sketch_id -> id")
legacy_plane: Any = sketch.get("plane")
legacy_offset: Any = sketch.get("offset_mm", 0)
workplane_value = sketch.get("workplane")
if isinstance(workplane_value, dict) and "origin_mm" not in workplane_value:
legacy_plane = workplane_value.get("plane")
legacy_offset = workplane_value.get("offset_mm", 0)
elif "workplane" in sketch:
continue
workplane = _legacy_workplane(legacy_plane, legacy_offset) if isinstance(legacy_plane, str) else None
if workplane is None:
continue
sketch["workplane"] = workplane
sketch.pop("plane", None)
sketch.pop("offset_mm", None)
repairs.append(f"geometry.sketches[{index}]: legacy plane/offset_mm -> workplane")
features = normalized.get("features")
if isinstance(features, list):
for index, feature in enumerate(features):
if not isinstance(feature, dict):
continue
if "sketch_id" not in feature and isinstance(feature.get("sketch"), str):
feature["sketch_id"] = feature.pop("sketch")
repairs.append(f"features[{index}]: sketch -> sketch_id")
if "depends_on" not in feature:
feature["depends_on"] = []
repairs.append(f"features[{index}]: added empty depends_on")
params = feature.get("params")
axis = params.get("axis") if isinstance(params, dict) else None
if isinstance(axis, dict) and "origin_mm" not in axis and "point_mm" in axis:
axis["origin_mm"] = axis.pop("point_mm")
repairs.append(f"features[{index}].params.axis: point_mm -> origin_mm")
return normalized, repairs
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
if not isinstance(cdsl, dict):
raise ValueError("CDSL must be a JSON object")
@@ -392,7 +462,7 @@ def build_revision(
summary: str,
parent_revision_id: str | None = None,
operation: dict[str, Any] | None = None,
attachments: list[dict[str, Any]] | None = None,
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,
@@ -455,7 +525,7 @@ def build_revision(
"summary": summary,
"parent_revision_id": parent_revision_id or "",
"operation": operation or {},
"attachments": attachments or [],
"input_attachments": input_attachments or [],
}
if status == "success":
record.update({
@@ -0,0 +1,273 @@
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
+2 -2
View File
@@ -207,8 +207,8 @@ 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
# A valid DesignIntent creates a task before the first runtime build.
# Its canonical backend selection must therefore be inheritable too.
# 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),
+48 -8
View File
@@ -88,11 +88,26 @@ class WorkspaceStore:
write_json(path, payload)
return (Path(conversation) / relative).as_posix()
def write_cdsl_attempt(self, conversation_id: str, cdsl: Any, iteration: int) -> str:
"""Retain a parsed model candidate before validation or execution."""
conversation = safe_conversation_id(conversation_id)
relative = Path("diagnostics") / f"cdsl_attempt_{iteration:02d}_{secrets.token_hex(8)}.json"
path = self.conversation_dir(conversation) / relative
write_json(path, cdsl)
return (Path(conversation) / relative).as_posix()
def write_cdsl_validation_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str:
"""Persist the reason a retained CDSL candidate was rejected."""
conversation = safe_conversation_id(conversation_id)
relative = Path("diagnostics") / f"cdsl_validation_{secrets.token_hex(8)}.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,
current_task_id: str | None = None,
attachments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
cid = safe_conversation_id(conversation_id) if conversation_id else new_id("conv")
path = self.conversation_path(cid)
@@ -102,21 +117,18 @@ class WorkspaceStore:
if current_task_id:
current["current_task_id"] = safe_task_id(current_task_id)
changed = True
if attachments is not None:
current["attachments"] = attachments
changed = True
if changed:
current["updated_at"] = now_iso()
write_json(path, current)
return current
record = {
"schema_version": "1.1",
"schema_version": "1.2",
"conversation_id": cid,
"created_at": now_iso(),
"updated_at": now_iso(),
"current_task_id": safe_task_id(current_task_id) if current_task_id else "",
"messages": [],
"attachments": attachments or [],
"attachments": [],
}
write_json(path, record)
return record
@@ -135,14 +147,34 @@ class WorkspaceStore:
write_json(self.conversation_path(record["conversation_id"]), record)
return record
def write_upload(self, task_id: str, filename: str, data: bytes) -> tuple[str, Path]:
def write_conversation_upload(self, conversation_id: str, filename: str, data: bytes) -> tuple[str, Path]:
conversation = safe_conversation_id(conversation_id)
safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "_", Path(filename).name).strip("._") or "attachment"
relative = Path("uploads") / f"upload_{secrets.token_hex(6)}_{safe_name}"
target = self.artifact_path(task_id, relative.as_posix())
target = self.conversation_attachment_path(conversation, relative.as_posix())
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
return relative.as_posix(), target
def add_conversation_attachment(self, conversation_id: str, attachment: dict[str, Any]) -> dict[str, Any]:
conversation = safe_conversation_id(conversation_id)
record = self.read_conversation(conversation)
if record is None:
raise ValueError("Conversation not found")
if str(attachment.get("conversation_id") or "") != conversation:
raise ValueError("Attachment does not belong to this conversation")
attachment_id = str(attachment.get("id") or "")
if not attachment_id:
raise ValueError("Attachment id is required")
self.conversation_attachment_path(conversation, str(attachment.get("path") or ""))
attachments = record.setdefault("attachments", [])
if any(str(item.get("id") or "") == attachment_id for item in attachments if isinstance(item, dict)):
raise ValueError("Attachment already exists")
attachments.append(attachment)
record["updated_at"] = now_iso()
write_json(self.conversation_path(conversation), record)
return record
def ensure_task(self, task_id: str | None, request: str) -> dict[str, Any]:
tid = safe_task_id(task_id) if task_id else new_id("cad")
path = self.task_path(tid)
@@ -274,3 +306,11 @@ class WorkspaceStore:
if root != target and root not in target.parents:
raise ValueError("Artifact path escapes task directory")
return target
def conversation_attachment_path(self, conversation_id: str, relative_path: str) -> Path:
safe = safe_relative_path(relative_path)
root = self.conversation_dir(conversation_id).resolve()
target = (root / safe).resolve()
if root != target and root not in target.parents:
raise ValueError("Attachment path escapes conversation directory")
return target
+2 -2
View File
@@ -99,8 +99,8 @@ def validate_design_intent(
raise DesignIntentError("INVALID_DESIGN_INTENT", "revise DesignIntent requires base_revision_id")
if current_revision_id and base_revision_id != current_revision_id:
raise DesignIntentError(
"INVALID_DESIGN_INTENT",
"revise DesignIntent base_revision_id must be the current successful revision",
"DESIGN_INTENT_BASE_REVISION_MISMATCH",
f"revise DesignIntent base_revision_id must be {current_revision_id}, the current successful revision",
)
structures = normalized["structures"]
@@ -0,0 +1,212 @@
"""Deterministic GenerationSpec -> CDSL compilers.
The LLM supplies semantic parameters and feature intent. This module owns
the CDSL details so tool calls do not need to contain sketch IDs, selectors, or
runtime-specific parameter wrappers.
"""
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import Any, Protocol
from .generation_spec import GenerationSpecError, validate_generation_spec
def _workplane(z: float = 0.0, normal: list[float] | None = None) -> dict[str, list[float]]:
return {
"origin_mm": [0.0, 0.0, float(z)],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 1.0, 0.0],
"normal": list(normal or [0.0, 0.0, 1.0]),
}
def _value(spec: dict[str, Any], name: str, default: float | int | str) -> Any:
parameter = (spec.get("parameters") or {}).get(name)
if isinstance(parameter, dict) and "value" in parameter:
return parameter["value"]
return default
def _number(spec: dict[str, Any], name: str, default: float) -> float:
value = _value(spec, name, default)
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise GenerationSpecError(f"Parameter {name} must be numeric", f"$.parameters.{name}.value")
number = float(value)
if not number == number or number in {float("inf"), float("-inf")}:
raise GenerationSpecError(f"Parameter {name} must be finite", f"$.parameters.{name}.value")
return number
def _integer(spec: dict[str, Any], name: str, default: int) -> int:
value = _number(spec, name, default)
if int(value) != value:
raise GenerationSpecError(f"Parameter {name} must be an integer", f"$.parameters.{name}.value")
return int(value)
def _positive(number: float, name: str) -> float:
if number <= 0:
raise GenerationSpecError(f"Parameter {name} must be greater than zero", f"$.parameters.{name}.value")
return number
def _acceptance(spec: dict[str, Any], generated: list[dict[str, Any]]) -> list[dict[str, Any]]:
existing = copy.deepcopy(spec.get("acceptance") or [])
known = {str(item.get("id")) for item in existing}
for item in generated:
if item["id"] not in known:
existing.append(item)
known.add(item["id"])
return existing
@dataclass(frozen=True)
class CompiledGeneration:
cdsl: dict[str, Any]
acceptance: list[dict[str, Any]]
provenance: dict[str, Any]
assumptions: list[str]
approximations: list[dict[str, Any]]
class Compiler(Protocol):
family: str
def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration:
...
class MountingPlateCompiler:
family = "mounting_plate"
def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration:
width = _positive(_number(spec, "width", 100.0), "width")
height = _positive(_number(spec, "height", 60.0), "height")
thickness = _positive(_number(spec, "thickness", 6.0), "thickness")
hole_diameter = _positive(_number(spec, "hole_diameter", 4.5), "hole_diameter")
hole_offset = _positive(_number(spec, "hole_edge_offset", 10.0), "hole_edge_offset")
hole_count = _integer(spec, "hole_count", 4)
counterbore_diameter = _positive(_number(spec, "counterbore_diameter", hole_diameter * 2), "counterbore_diameter")
counterbore_depth = _positive(_number(spec, "counterbore_depth", min(thickness / 2, 3.0)), "counterbore_depth")
slot_length = _positive(_number(spec, "slot_length", 20.0), "slot_length")
slot_width = _positive(_number(spec, "slot_width", 12.0), "slot_width")
if hole_count != 4:
raise GenerationSpecError("mounting_plate compiler currently requires four corner holes", "$.parameters.hole_count.value")
if counterbore_diameter <= hole_diameter:
raise GenerationSpecError("counterbore_diameter must exceed hole_diameter", "$.parameters.counterbore_diameter.value")
if counterbore_depth > thickness:
raise GenerationSpecError("counterbore_depth must not exceed thickness", "$.parameters.counterbore_depth.value")
if hole_offset * 2 >= min(width, height):
raise GenerationSpecError("hole_edge_offset leaves no usable plate area", "$.parameters.hole_edge_offset.value")
if slot_length < slot_width:
raise GenerationSpecError("slot_length must be at least slot_width", "$.parameters.slot_length.value")
centers = [
[-width / 2 + hole_offset, -height / 2 + hole_offset],
[width / 2 - hole_offset, -height / 2 + hole_offset],
[width / 2 - hole_offset, height / 2 - hole_offset],
[-width / 2 + hole_offset, height / 2 - hole_offset],
]
frame = _workplane(thickness)
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "generation-spec-preflight",
"meta": {"unit": "mm", "name": str(spec["part"]["name"])},
"geometry": {"sketches": [
{"id": "base_plate", "workplane": _workplane(), "profile": {
"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height,
}},
{"id": "mounting_holes", "workplane": frame, "profile": {
"type": "circles", "items": [{"center": center, "radius_mm": hole_diameter / 2} for center in centers],
}},
{"id": "center_slot", "workplane": frame, "profile": {
"type": "obround", "center": [0.0, 0.0], "length_mm": slot_length, "width_mm": slot_width,
}},
]},
"features": [
{"id": "base_plate", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_plate", "params": {"distance_mm": thickness}},
{"id": "mounting_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_plate"], "sketch_id": "mounting_holes", "params": {"distance_mm": thickness, "reverse": True}},
{"id": "mounting_counterbores", "atomic_id": "hole_counterbore", "depends_on": ["mounting_holes"], "sketch_id": "mounting_holes", "params": {
"diameter_mm": hole_diameter, "depth_mm": thickness, "counterbore_diameter_mm": counterbore_diameter,
"counterbore_depth_mm": counterbore_depth, "positions": [{"mm": [x, y, 0.0]} for x, y in centers], "host_face": {"frame": frame},
}},
{"id": "center_slot", "atomic_id": "extrude_cut_blind", "depends_on": ["mounting_counterbores"], "sketch_id": "center_slot", "params": {"distance_mm": thickness, "reverse": True}},
],
}
assumptions = [str(item) for item in spec.get("assumptions") or []]
if "hole_diameter" not in (spec.get("parameters") or {}):
assumptions.append("M4 clearance hole defaults to 4.5 mm")
if "counterbore_depth" not in (spec.get("parameters") or {}):
assumptions.append("Counterbore depth defaults to the lesser of 3 mm and half the plate thickness")
acceptance = _acceptance(spec, [
{"id": "overall_bbox", "type": "bbox", "expected": [width, height, thickness], "tolerance": 0.1, "severity": "blocking"},
{"id": "mounting_hole_count", "type": "feature_count", "feature": "mounting_holes", "expected": 4, "tolerance": 0, "severity": "blocking"},
{"id": "mounting_hole_diameter", "type": "hole_diameter", "feature": "mounting_holes", "expected": hole_diameter, "tolerance": 0.05, "severity": "blocking"},
{"id": "center_slot_size", "type": "overall_length", "feature": "center_slot", "expected": slot_length, "tolerance": 0.1, "severity": "blocking"},
])
provenance = {
"compiler": self.family,
"compiler_version": "1.0",
"references": references,
"feature_sources": {item["id"]: item["id"] for item in spec.get("features") or []},
"parameter_paths": {name: f"parameters.{name}.value" for name in (spec.get("parameters") or {})},
}
return CompiledGeneration(cdsl, acceptance, provenance, list(dict.fromkeys(assumptions)), list(spec.get("approximations") or []))
class FlangeSleeveCompiler:
family = "flange_sleeve"
def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration:
from app.services.flange_sleeve_template import build_flange_sleeve_cdsl, TEMPLATE_ID
aliases = {
"width": "flange_width_mm", "height": "flange_height_mm", "thickness": "flange_thickness_mm",
"tube_outer_diameter": "tube_outer_diameter_mm", "bore_diameter": "bore_diameter_mm",
"hole_diameter": "mount_hole_diameter_mm", "counterbore_diameter": "mount_counterbore_diameter_mm",
"counterbore_depth": "mount_counterbore_depth_mm", "hole_offset_x": "mount_hole_u_mm", "hole_offset_y": "mount_hole_v_mm",
}
plan: dict[str, Any] = {"template": TEMPLATE_ID, "name": spec["part"]["name"]}
for source, target in aliases.items():
if source in (spec.get("parameters") or {}):
plan[target] = _value(spec, source, 0)
cdsl, values = build_flange_sleeve_cdsl(plan)
acceptance = _acceptance(spec, [
{"id": "flange_bbox", "type": "bbox", "expected": [float(values["flange_width_mm"]), float(values["flange_height_mm"]), float(values["flange_thickness_mm"])], "tolerance": 0.1, "severity": "blocking"},
])
provenance = {"compiler": self.family, "compiler_version": "1.0", "template_id": TEMPLATE_ID, "references": references}
assumptions = list(dict.fromkeys([*map(str, spec.get("assumptions") or []), "Flange sleeve dimensions use the verified parameterized template defaults for omitted fields."]))
return CompiledGeneration(cdsl, acceptance, provenance, assumptions, list(spec.get("approximations") or []))
COMPILERS: dict[str, Compiler] = {
"mounting_plate": MountingPlateCompiler(),
"flange_sleeve": FlangeSleeveCompiler(),
}
def compiler_for(family: str) -> Compiler:
try:
return COMPILERS[family]
except KeyError as error:
raise GenerationSpecError(f"No GenerationSpec compiler registered for part family: {family}", "$.part.family") from error
def compile_generation_spec(spec: dict[str, Any], references: list[dict[str, Any]] | None = None) -> dict[str, Any]:
normalized = validate_generation_spec(spec)
compiler = compiler_for(str(normalized["part"]["family"]))
compiled = compiler.compile(normalized, references or [])
return {
"spec": normalized,
"cdsl": compiled.cdsl,
"acceptance": compiled.acceptance,
"provenance": compiled.provenance,
"assumptions": compiled.assumptions,
"approximations": compiled.approximations,
}
@@ -0,0 +1,177 @@
"""Validation and normalization for the semantic GenerationSpec contract."""
from __future__ import annotations
import copy
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
KNOWN_PART_FAMILIES = frozenset({
"mounting_plate",
"flange",
"flange_sleeve",
"simple_shaft",
"bearing_housing",
"mounting_bracket",
"hex_nut",
"slotted_plate",
})
KNOWN_FEATURE_KINDS = frozenset({
"base_extrusion",
"base_revolve",
"boss",
"through_hole",
"blind_hole",
"counterbored_hole",
"countersunk_hole",
"hole_pattern",
"counterbored_hole_pattern",
"obround_cut",
"pocket",
"revolve_profile",
"coaxial_bore",
"fillet",
"chamfer",
})
class GenerationSpecError(ValueError):
"""A stable, user-repairable GenerationSpec validation error."""
def __init__(self, message: str, path: str = "$") -> None:
super().__init__(message)
self.path = path
@lru_cache(maxsize=1)
def generation_spec_schema() -> dict[str, Any]:
path = Path(__file__).with_name("generation_spec_schema.json")
schema = json.loads(path.read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
return schema
def _schema_error(spec: dict[str, Any]) -> GenerationSpecError | None:
errors = sorted(
Draft202012Validator(generation_spec_schema()).iter_errors(spec),
key=lambda error: (list(error.absolute_path), error.message),
)
if not errors:
return None
error = errors[0]
location = "$" + "".join(
f"[{item}]" if isinstance(item, int) else f".{item}"
for item in error.absolute_path
)
return GenerationSpecError(error.message, location)
def _parameter_value(spec: dict[str, Any], name: str) -> Any:
value = (spec.get("parameters") or {}).get(name)
if not isinstance(value, dict):
raise GenerationSpecError(f"Unknown parameter: {name}", f"$.parameters.{name}")
return value.get("value")
def _numeric(value: Any, path: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise GenerationSpecError("Expected a finite numeric value", path)
number = float(value)
if number != number or number in {float("inf"), float("-inf")}:
raise GenerationSpecError("Expected a finite numeric value", path)
return number
def _check_constraints(spec: dict[str, Any]) -> None:
for index, constraint in enumerate(spec.get("constraints") or []):
path = f"$.constraints[{index}]"
kind = constraint.get("type")
if kind in {"less_than", "less_equal", "greater_than", "greater_equal", "equal"}:
left = _numeric(_parameter_value(spec, str(constraint.get("left") or "")), f"{path}.left")
right_name = constraint.get("right")
right = _numeric(_parameter_value(spec, str(right_name)), f"{path}.right") if right_name else _numeric(constraint.get("value"), f"{path}.value")
ok = {
"less_than": left < right,
"less_equal": left <= right,
"greater_than": left > right,
"greater_equal": left >= right,
"equal": abs(left - right) <= 1e-9,
}[kind]
if not ok:
raise GenerationSpecError(constraint.get("message") or f"Constraint {kind} failed", path)
def _check_graph(spec: dict[str, Any]) -> None:
features = spec.get("features") or []
ids = [str(item.get("id")) for item in features]
if len(ids) != len(set(ids)):
raise GenerationSpecError("Feature ids must be unique", "$.features")
known: set[str] = set()
for index, feature in enumerate(features):
feature_id = str(feature.get("id"))
kind = str(feature.get("kind"))
if kind not in KNOWN_FEATURE_KINDS:
raise GenerationSpecError(f"Unsupported feature kind: {kind}", f"$.features[{index}].kind")
for dependency in feature.get("depends_on") or []:
if dependency not in known:
raise GenerationSpecError(
f"Feature {feature_id} has a forward or missing dependency: {dependency}",
f"$.features[{index}].depends_on",
)
known.add(feature_id)
acceptance_ids = {str(item.get("id")) for item in spec.get("acceptance") or []}
if len(acceptance_ids) != len(spec.get("acceptance") or []):
raise GenerationSpecError("Acceptance ids must be unique", "$.acceptance")
for index, item in enumerate(spec.get("acceptance") or []):
feature = item.get("feature")
if feature and feature not in known:
raise GenerationSpecError(f"Acceptance refers to missing feature: {feature}", f"$.acceptance[{index}].feature")
def normalize_generation_spec(spec: dict[str, Any], *, request: str = "") -> dict[str, Any]:
if not isinstance(spec, dict):
raise GenerationSpecError("GenerationSpec must be a JSON object")
normalized = copy.deepcopy(spec)
normalized.setdefault("schema", "cad.generation-spec.v1")
normalized.setdefault("schema_version", "1.0")
normalized.setdefault("mode", "create")
normalized.setdefault("base_revision_id", "")
normalized.setdefault("parameters", {})
normalized.setdefault("features", [])
normalized.setdefault("constraints", [])
normalized.setdefault("acceptance", [])
normalized.setdefault("assumptions", [])
normalized.setdefault("approximations", [])
normalized.setdefault("references", [])
normalized.setdefault("patch_intent", request)
return normalized
def validate_generation_spec(
spec: dict[str, Any],
*,
known_families: set[str] | frozenset[str] = KNOWN_PART_FAMILIES,
) -> dict[str, Any]:
normalized = normalize_generation_spec(spec)
error = _schema_error(normalized)
if error:
raise error
family = str(normalized["part"]["family"])
if family not in known_families:
raise GenerationSpecError(f"Unsupported part family: {family}", "$.part.family")
for name, parameter in normalized["parameters"].items():
source = parameter["source"]
if source == "user" and not parameter["locked"]:
raise GenerationSpecError("User parameters must be locked", f"$.parameters.{name}.locked")
if source == "image_estimate" and not parameter.get("assumption"):
raise GenerationSpecError("Image estimates require an assumption", f"$.parameters.{name}.assumption")
_check_graph(normalized)
_check_constraints(normalized)
return normalized
@@ -0,0 +1,100 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cdsl.local/schema/cad.generation-spec.v1",
"title": "CDSL GenerationSpec",
"type": "object",
"additionalProperties": false,
"properties": {
"schema": {"const": "cad.generation-spec.v1"},
"schema_version": {"const": "1.0"},
"mode": {"enum": ["create", "revise"]},
"base_revision_id": {"type": "string"},
"patch_intent": {"type": "string"},
"part": {"$ref": "#/$defs/part"},
"parameters": {"type": "object", "additionalProperties": {"$ref": "#/$defs/parameter"}},
"features": {"type": "array", "items": {"$ref": "#/$defs/feature"}},
"constraints": {"type": "array", "items": {"$ref": "#/$defs/constraint"}},
"acceptance": {"type": "array", "items": {"$ref": "#/$defs/acceptance"}},
"assumptions": {"type": "array", "items": {"type": "string", "minLength": 1}},
"approximations": {"type": "array", "items": {"$ref": "#/$defs/approximation"}},
"references": {"type": "array", "items": {"type": "object"}}
},
"required": ["schema", "schema_version", "mode", "base_revision_id", "part", "parameters", "features", "constraints", "acceptance", "assumptions", "approximations", "references"],
"$defs": {
"part": {
"type": "object",
"additionalProperties": false,
"properties": {
"family": {"type": "string", "minLength": 1},
"name": {"type": "string", "minLength": 1},
"units": {"const": "mm"},
"coordinate_system": {"type": "string", "minLength": 1}
},
"required": ["family", "name", "units", "coordinate_system"]
},
"parameter": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {},
"unit": {"enum": ["mm", "deg", "rad", "count", ""]},
"source": {"enum": ["user", "technical_drawing", "image_estimate", "template_default", "derived_standard", "assumption"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"locked": {"type": "boolean"},
"assumption": {"type": "string"}
},
"required": ["value", "unit", "source", "confidence", "locked", "assumption"]
},
"feature": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"kind": {"type": "string", "minLength": 1},
"depends_on": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"parameters": {"type": "object"},
"semantic_role": {"type": "string"}
},
"required": ["id", "kind", "depends_on", "parameters"]
},
"constraint": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {"enum": ["less_than", "less_equal", "greater_than", "greater_equal", "equal", "inside_boundary", "symmetric", "same_axis", "distance", "count"]},
"left": {"type": "string"},
"right": {"type": "string"},
"value": {},
"feature": {"type": "string"},
"margin": {"type": "number"},
"ratio": {"type": "number"},
"message": {"type": "string"}
},
"required": ["type"]
},
"acceptance": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"type": {"enum": ["bbox", "solid_count", "feature_count", "hole_diameter", "hole_center", "hole_spacing", "wall_thickness", "overall_length", "overall_diameter", "coaxiality", "symmetry", "through_condition", "blind_depth"]},
"feature": {"type": "string"},
"expected": {},
"tolerance": {"type": "number", "minimum": 0},
"severity": {"enum": ["blocking", "warning", "informational"]}
},
"required": ["id", "type", "expected", "tolerance", "severity"]
},
"approximation": {
"type": "object",
"additionalProperties": false,
"properties": {
"request": {"type": "string", "minLength": 1},
"status": {"enum": ["approximated", "expanded", "omitted", "blocked"]},
"translation": {"type": "string", "minLength": 1},
"reason": {"type": "string", "minLength": 1}
},
"required": ["request", "status", "translation", "reason"]
}
}
}
+493 -4
View File
@@ -5,14 +5,59 @@ import json
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from app.models.contracts import ChatMessage, MessagePart
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, parse_tool_arguments, response_language_instruction, tools_for_model, user_visible_error_message
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, normalize_image_analysis, parse_tool_arguments, response_language_instruction, tools_for_model, use_flange_sleeve_template, user_visible_error_message
from app.services.library import CdslLibrary
from app.services.storage import WorkspaceStore
from app.settings import ProviderConfig, ProviderModel, Settings
class ToolChoiceCompatibilityTests(unittest.TestCase):
def test_retries_without_tool_choice_when_thinking_mode_rejects_it(self) -> None:
class FakeResponse:
def __init__(self, status_code: int, text: str, body: dict[str, object]) -> None:
self.status_code = status_code
self.text = text
self._body = body
def json(self) -> dict[str, object]:
return self._body
class FakeClient:
def __init__(self) -> None:
self.requests: list[dict[str, object]] = []
self.responses = [
FakeResponse(400, '{"error":{"message":"Thinking mode does not support this tool_choice"}}', {}),
FakeResponse(200, "", {"choices": [{"message": {"role": "assistant", "content": "ok"}}]}),
]
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *args: object) -> None:
return None
async def post(self, _url: str, *, headers: dict[str, str], json: dict[str, object]) -> FakeResponse:
self.requests.append(dict(json))
return self.responses.pop(0)
agent = object.__new__(AgentService)
agent.settings = SimpleNamespace(llm_timeout_s=1)
client = FakeClient()
provider = ProviderConfig("deepseek", "DeepSeek", "https://example.invalid/v1", "test-key", (ProviderModel("deepseek-v4-flash-vision-exp", vision=True),))
model = provider.models[0]
with patch("app.services.agent_service.httpx.AsyncClient", return_value=client):
response = asyncio.run(agent._complete([], [], provider, model, "analyze_image_reference"))
self.assertEqual(response["choices"][0]["message"]["content"], "ok")
self.assertEqual(client.requests[0]["tool_choice"], {"type": "function", "function": {"name": "analyze_image_reference"}})
self.assertNotIn("tool_choice", client.requests[1])
class ParseToolArgumentsTests(unittest.TestCase):
def test_accepts_one_json_object(self) -> None:
payload = parse_tool_arguments(' {"summary":"water cup","cdsl":{"parts":[]}} ')
@@ -62,11 +107,13 @@ class ParseToolArgumentsTests(unittest.TestCase):
self.assertNotIn("extrude", cdsl["$defs"]["feature_atomic_ids"]["enum"])
self.assertEqual(cdsl, CDSL_TOOL_SCHEMA)
def test_strict_tool_schema_covers_design_intent_and_cdsl_generation_arguments(self) -> None:
def test_strict_tool_schema_covers_generation_arguments(self) -> None:
tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True))
strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")]
self.assertEqual(strict_tools, ["propose_design_intent", "generate_cdsl_model"])
self.assertEqual(strict_tools, ["generate_flange_sleeve_model", "generate_cdsl_model"])
template_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_flange_sleeve_model")
self.assertEqual(template_tool["function"]["parameters"]["properties"]["plan"]["required"], ["template"])
generate_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_cdsl_model")
self.assertEqual(generate_tool["function"]["parameters"]["properties"]["summary"], {"type": "string", "minLength": 1})
self.assertEqual(generate_tool["function"]["parameters"]["properties"]["cdsl"], CDSL_TOOL_SCHEMA)
@@ -77,6 +124,38 @@ class ParseToolArgumentsTests(unittest.TestCase):
self.assertFalse(any(tool["function"].get("strict") for tool in tools))
def test_flange_sleeve_image_route_exposes_only_the_compact_plan_generator(self) -> None:
analysis = {
"partType": "带方形法兰的圆筒管座",
"visibleFeatures": ["同轴中空管", "四个安装孔"],
}
self.assertTrue(use_flange_sleeve_template(analysis, ""))
self.assertFalse(use_flange_sleeve_template(analysis, "cad_existing"))
tools = tools_for_model(ProviderModel("default-model"), flange_sleeve_only=True)
names = [tool["function"]["name"] for tool in tools]
self.assertIn("generate_flange_sleeve_model", names)
self.assertNotIn("search_cdsl_library", names)
self.assertNotIn("read_cdsl_reference", names)
self.assertNotIn("read_current_cdsl", names)
self.assertNotIn("generate_cdsl_model", names)
def test_recorded_image_analysis_is_not_exposed_as_a_tool(self) -> None:
tools = tools_for_model(ProviderModel("vision-model", vision=True), include_image_analysis=False)
self.assertNotIn("analyze_image_reference", [tool["function"]["name"] for tool in tools])
def test_image_analysis_allows_no_dimension_candidates(self) -> None:
analysis_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "analyze_image_reference")
self.assertNotIn("dimension_candidates", analysis_tool["function"]["parameters"]["required"])
result = normalize_image_analysis({
"part_type": "压铸外壳",
"visible_features": ["圆角矩形外轮廓"],
"uncertain_features": [],
})
self.assertEqual(result["dimension_candidates"], [])
def test_strict_schema_rejection_is_localized_for_chinese_requests(self) -> None:
message = user_visible_error_message(
StrictToolSchemaError("provider rejected strict schema"),
@@ -243,6 +322,332 @@ class ToolArgumentsRetryTests(unittest.TestCase):
self.assertTrue(any("已修正工具参数" in str(event.get("text", "")) for event in events))
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
def test_persists_every_cdsl_attempt_and_validation_failure(self) -> None:
class InvalidCdslAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
plan_call = {
"id": "design_brief",
"type": "function",
"function": {
"name": "describe_design_intent",
"arguments": json.dumps({"plan": "建立一个法兰。", "assumptions": []}),
},
}
invalid_call = {
"id": "invalid_cdsl",
"type": "function",
"function": {
"name": "generate_cdsl_model",
"arguments": json.dumps({"cdsl": {}, "summary": "无效法兰", "assumptions": []}),
},
}
self.responses = [
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [plan_call]}}]},
*[
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [invalid_call]}}]}
for _ in range(7)
],
]
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
return self.responses.pop(0)
backend_root = Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory() as temporary_directory:
temporary_root = Path(temporary_directory)
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
settings = Settings(
task_root=temporary_root / "tasks",
conversation_root=temporary_root / "conversations",
library_root=backend_root / "cdsl_library",
engine_root=backend_root / "engine" / "cdsl_engine",
llm_base_url=provider.base_url,
llm_api_key=provider.api_key,
llm_model="test-model",
llm_timeout_s=1,
default_provider_id="test",
providers=(provider,),
)
store = WorkspaceStore(settings)
agent = InvalidCdslAgent(settings, store, CdslLibrary(settings))
conversation_id = "conv_000000000004"
message = ChatMessage(id="user_invalid_cdsl", role="user", parts=[MessagePart(type="text", text="生成一个法兰")])
async def collect_events() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
async for chunk in agent.stream([message], conversation_id, None):
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
return events
events = asyncio.run(collect_events())
diagnostics = settings.conversation_root / conversation_id / "diagnostics"
attempts = sorted(diagnostics.glob("cdsl_attempt_*.json"))
failures = sorted(diagnostics.glob("cdsl_validation_*.json"))
self.assertEqual(len(attempts), 7)
self.assertEqual(len(failures), 7)
self.assertTrue(all(json.loads(path.read_text(encoding="utf-8")) == {} for path in attempts))
records = sorted(
(json.loads(path.read_text(encoding="utf-8")) for path in failures),
key=lambda record: int(record["iteration"]),
)
self.assertEqual([record["iteration"] for record in records], list(range(2, 9)))
self.assertTrue(all(record["kind"] == "cdsl_validation_failure" for record in records))
self.assertTrue(all(record["validation_error_type"] == "ValueError" for record in records))
self.assertTrue(all(record["validation_error"] for record in records))
self.assertEqual(
{Path(record["cdsl_attempt_path"]).name for record in records},
{path.name for path in attempts},
)
self.assertTrue(any("每次 CDSL 校验失败的诊断已保存到" in str(event.get("message", "")) for event in events))
self.assertEqual(agent.responses, [])
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
class ImageReferenceIntakeTests(unittest.TestCase):
@staticmethod
def _settings(temporary_root: Path, *, vision: bool = True) -> Settings:
backend_root = Path(__file__).resolve().parents[1]
provider = ProviderConfig(
"test",
"Test",
"https://example.invalid/v1",
"test-key",
(ProviderModel("vision-model", vision=vision),),
)
return Settings(
task_root=temporary_root / "tasks",
conversation_root=temporary_root / "conversations",
library_root=backend_root / "cdsl_library",
engine_root=backend_root / "engine" / "cdsl_engine",
llm_base_url=provider.base_url,
llm_api_key=provider.api_key,
llm_model="vision-model",
llm_timeout_s=1,
default_provider_id="test",
providers=(provider,),
)
@staticmethod
def _add_image_attachment(store: WorkspaceStore, conversation_id: str) -> str:
store.ensure_conversation(conversation_id)
relative_path, _ = store.write_conversation_upload(conversation_id, "flange.png", b"image-bytes")
store.add_conversation_attachment(conversation_id, {
"id": "upload_flange",
"conversation_id": conversation_id,
"name": "flange.png",
"kind": "image",
"path": relative_path,
"mime": "image/png",
})
return "upload_flange"
@staticmethod
def _analysis_arguments() -> dict[str, object]:
return {
"part_type": "四孔法兰套筒",
"visible_features": ["中空圆筒", "四孔法兰", "螺栓孔"],
"uncertain_features": ["法兰背面可能有沉孔"],
"dimension_candidates": [
{"id": "bore_diameter", "label": "中心孔直径", "reason": "图片没有标注内径"},
{"id": "bolt_circle", "label": "螺栓孔中心距", "reason": "透视图无法确定孔距"},
],
}
def test_image_request_keeps_structured_analysis_when_model_asks_a_question(self) -> None:
class ImageIntakeAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.required_tools: list[str | None] = []
self.responses = [
{"choices": [{"message": {
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "image_analysis",
"type": "function",
"function": {
"name": "analyze_image_reference",
"arguments": json.dumps(ImageReferenceIntakeTests._analysis_arguments()),
},
}],
}}]},
{"choices": [{"message": {
"role": "assistant",
"content": "中心孔直径会显著影响零件用途,请确认这个尺寸。",
"tool_calls": [],
}}]},
]
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
self.required_tools.append(kwargs.get("required_tool_name") if "required_tool_name" in kwargs else args[4] if len(args) > 4 else None)
return self.responses.pop(0)
with tempfile.TemporaryDirectory() as temporary_directory:
temporary_root = Path(temporary_directory)
settings = self._settings(temporary_root)
store = WorkspaceStore(settings)
conversation_id = "conv_000000000001"
self._add_image_attachment(store, conversation_id)
agent = ImageIntakeAgent(settings, store, CdslLibrary(settings))
message = ChatMessage(id="user_image", role="user", parts=[MessagePart(type="text", text="生成图片中的模型")])
async def collect_events() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
async for chunk in agent.stream([message], conversation_id, None):
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
return events
events = asyncio.run(collect_events())
conversation = store.read_conversation(conversation_id)
assistant_parts = conversation["messages"][-1]["parts"]
self.assertEqual(agent.required_tools, ["analyze_image_reference", None])
self.assertEqual(agent.responses, [])
self.assertTrue(any(event.get("partType") == "四孔法兰套筒" for event in events))
self.assertTrue(any("中心孔直径" in str(event.get("text", "")) for event in events))
self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-image-analysis", "text"])
self.assertEqual(assistant_parts[0]["data"]["attachmentIds"], ["upload_flange"])
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
def test_model_can_continue_to_generation_after_initial_analysis(self) -> None:
class EstimateAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.tool_sets: list[list[str]] = []
self.tool_calls: list[str] = []
self.responses = [
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "image_analysis", "type": "function", "function": {
"name": "analyze_image_reference",
"arguments": json.dumps(ImageReferenceIntakeTests._analysis_arguments()),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "design_brief", "type": "function", "function": {
"name": "describe_design_intent",
"arguments": json.dumps({
"plan": "按图片比例建立法兰套筒。",
"assumptions": ["所有未标注尺寸按图片比例估算,单位为 mm。"],
}),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "build_cdsl", "type": "function", "function": {
"name": "generate_cdsl_model",
"arguments": json.dumps({"cdsl": {}, "summary": "估算尺寸的法兰套筒", "assumptions": ["尺寸按比例估算"]}),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "已按图片比例估算尺寸并生成模型。", "tool_calls": []}}]},
]
async def _complete(self, messages: list[dict[str, object]], tools: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]:
self.tool_sets.append([str(tool["function"]["name"]) for tool in tools])
return self.responses.pop(0)
async def _run_tool(self, name: str, arguments: dict[str, object], *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]:
self.tool_calls.append(name)
if name == "generate_cdsl_model":
return {"ok": True, "summary": "估算尺寸的法兰套筒"}, None
return await super()._run_tool(name, arguments, *args, **kwargs)
with tempfile.TemporaryDirectory() as temporary_directory:
settings = self._settings(Path(temporary_directory))
store = WorkspaceStore(settings)
conversation_id = "conv_000000000002"
self._add_image_attachment(store, conversation_id)
agent = EstimateAgent(settings, store, CdslLibrary(settings))
message = ChatMessage(id="user_estimate", role="user", parts=[MessagePart(type="text", text="根据图片直接推进建模,比例上的不确定性按合理工程判断处理。")])
async def collect_events() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
async for chunk in agent.stream([message], conversation_id, None):
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
return events
events = asyncio.run(collect_events())
assistant_parts = store.read_conversation(conversation_id)["messages"][-1]["parts"]
self.assertEqual(agent.tool_calls, ["analyze_image_reference", "describe_design_intent", "generate_cdsl_model"])
self.assertIn("analyze_image_reference", agent.tool_sets[0])
self.assertTrue(all("analyze_image_reference" not in tool_set for tool_set in agent.tool_sets[1:]))
self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-image-analysis", "text"])
self.assertFalse(any("请补充以下尺寸" in str(event.get("text", "")) for event in events))
def test_recorded_analysis_reuses_context_without_reanalyzing(self) -> None:
class RecordedEstimateAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.tool_sets: list[list[str]] = []
self.tool_calls: list[str] = []
self.responses = [
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "design_brief", "type": "function", "function": {
"name": "describe_design_intent",
"arguments": json.dumps({"plan": "按既有识别结果建立法兰套筒。", "assumptions": ["尺寸按图片比例估算"]}),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "build_cdsl", "type": "function", "function": {
"name": "generate_cdsl_model",
"arguments": json.dumps({"cdsl": {}, "summary": "估算尺寸的法兰套筒", "assumptions": ["尺寸按比例估算"]}),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "已按已有识别结果继续生成模型。", "tool_calls": []}}]},
]
async def _complete(self, messages: list[dict[str, object]], tools: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]:
self.tool_sets.append([str(tool["function"]["name"]) for tool in tools])
return self.responses.pop(0)
async def _run_tool(self, name: str, arguments: dict[str, object], *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]:
self.tool_calls.append(name)
if name == "generate_cdsl_model":
return {"ok": True, "summary": "估算尺寸的法兰套筒"}, None
return await super()._run_tool(name, arguments, *args, **kwargs)
with tempfile.TemporaryDirectory() as temporary_directory:
settings = self._settings(Path(temporary_directory))
store = WorkspaceStore(settings)
conversation_id = "conv_000000000003"
attachment_id = self._add_image_attachment(store, conversation_id)
analysis = self._analysis_arguments()
store.append_conversation_message(conversation_id, {
"id": "assistant_previous_analysis",
"role": "assistant",
"parts": [{"type": "data-cad-image-analysis", "data": {
"attachmentIds": [attachment_id],
"partType": analysis["part_type"],
"visibleFeatures": analysis["visible_features"],
"uncertainFeatures": analysis["uncertain_features"],
"dimensionCandidates": analysis["dimension_candidates"],
}}],
})
agent = RecordedEstimateAgent(settings, store, CdslLibrary(settings))
message = ChatMessage(id="user_estimate_again", role="user", parts=[MessagePart(type="text", text="请继续,未标注处按你的工程判断处理。")])
async def collect_events() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
async for chunk in agent.stream([message], conversation_id, None):
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
return events
events = asyncio.run(collect_events())
conversation = store.read_conversation(conversation_id)
analysis_parts = [
part
for item in conversation["messages"]
for part in item["parts"]
if part["type"] == "data-cad-image-analysis"
]
self.assertEqual(agent.tool_calls, ["describe_design_intent", "generate_cdsl_model"])
self.assertTrue(all("analyze_image_reference" not in tool_set for tool_set in agent.tool_sets))
self.assertEqual(len(analysis_parts), 1)
self.assertFalse(any("请补充以下尺寸" in str(event.get("text", "")) for event in events))
def test_direct_cdsl_generation_is_rejected_without_creating_a_task(self) -> None:
class RetryAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
@@ -328,10 +733,94 @@ class ToolArgumentsRetryTests(unittest.TestCase):
tool_result = agent.seen_messages[1][-1]
self.assertEqual(tool_result["role"], "tool")
self.assertEqual(json.loads(str(tool_result["content"]))["code"], "DESIGN_INTENT_REQUIRED")
self.assertEqual(json.loads(str(tool_result["content"]))["code"], "DESIGN_BRIEF_REQUIRED")
self.assertEqual(agent.required_tools, [None, None, None])
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
class StructuredResultResponseTests(unittest.TestCase):
def test_structured_result_does_not_add_a_duplicate_success_message(self) -> None:
class StructuredResultAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.responses = [
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "design_brief",
"type": "function",
"function": {
"name": "describe_design_intent",
"arguments": json.dumps({"plan": "建立带中心孔的法兰。", "assumptions": []}),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "build_cdsl",
"type": "function",
"function": {
"name": "generate_cdsl_model",
"arguments": json.dumps({"cdsl": {}, "summary": "带中心孔的法兰", "assumptions": []}),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": []}}]},
]
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
return self.responses.pop(0)
async def _run_tool(self, name: str, *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]:
if name == "describe_design_intent":
return {"ok": True, "summary": "设计说明已记录"}, None
if name == "generate_cdsl_model":
return {"ok": True, "summary": "带中心孔的法兰"}, {
"task_id": "cad_000000000001",
"revision_id": "rev_001",
"cdsl_path": "model.cdsl.json",
"step_path": "model.step",
"glb_path": "model.glb",
"report_path": "report.json",
"summary": "带中心孔的法兰",
"reference_ids": [],
"engine": "cdsl_only",
}
raise AssertionError(f"unexpected tool: {name}")
backend_root = Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory() as temporary_directory:
temporary_root = Path(temporary_directory)
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
settings = Settings(
task_root=temporary_root / "tasks",
conversation_root=temporary_root / "conversations",
library_root=backend_root / "cdsl_library",
engine_root=backend_root / "engine" / "cdsl_engine",
llm_base_url=provider.base_url,
llm_api_key=provider.api_key,
llm_model="test-model",
llm_timeout_s=1,
default_provider_id="test",
providers=(provider,),
)
store = WorkspaceStore(settings)
agent = StructuredResultAgent(settings, store, CdslLibrary(settings))
message = ChatMessage(id="user_result", role="user", parts=[MessagePart(type="text", text="生成一个带中心孔的法兰")])
async def collect_events() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
async for chunk in agent.stream([message], None, None):
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
return events
events = asyncio.run(collect_events())
conversation_id = store.read_conversation(next(settings.conversation_root.iterdir()).name)["conversation_id"]
assistant_parts = store.read_conversation(conversation_id)["messages"][-1]["parts"]
diagnostics = settings.conversation_root / conversation_id / "diagnostics"
attempts = list(diagnostics.glob("cdsl_attempt_*.json"))
self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-result"])
self.assertTrue(any(event.get("taskId") == "cad_000000000001" for event in events))
self.assertFalse(any("已生成:" in str(event.get("text", "")) for event in events))
self.assertEqual(len(attempts), 1)
self.assertEqual(json.loads(attempts[0].read_text(encoding="utf-8")), {})
self.assertEqual(list(diagnostics.glob("cdsl_validation_*.json")), [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,56 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from app.services.attachments import attachment_record
from app.services.storage import WorkspaceStore
from app.settings import ProviderConfig, ProviderModel, Settings
class ConversationAttachmentStorageTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary_directory = tempfile.TemporaryDirectory()
temporary_root = Path(self.temporary_directory.name)
backend_root = Path(__file__).resolve().parents[1]
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
self.settings = Settings(
task_root=temporary_root / "tasks",
conversation_root=temporary_root / "conversations",
library_root=backend_root / "cdsl_library",
engine_root=backend_root / "engine" / "cdsl_engine",
llm_base_url=provider.base_url,
llm_api_key=provider.api_key,
llm_model="test-model",
llm_timeout_s=1,
default_provider_id="test",
providers=(provider,),
)
self.store = WorkspaceStore(self.settings)
self.conversation_id = "conv_000000000001"
self.store.ensure_conversation(self.conversation_id)
def tearDown(self) -> None:
self.temporary_directory.cleanup()
def test_upload_is_owned_by_conversation_without_creating_a_task(self) -> None:
relative_path, target = self.store.write_conversation_upload(self.conversation_id, "reference.png", b"reference-bytes")
attachment = attachment_record(self.conversation_id, "reference.png", "image/png", relative_path, b"reference-bytes", "image")
conversation = self.store.add_conversation_attachment(self.conversation_id, attachment)
self.assertEqual(target, (self.settings.conversation_root / self.conversation_id / relative_path).resolve())
self.assertTrue(target.is_file())
self.assertEqual(conversation["attachments"], [attachment])
self.assertEqual(list(self.settings.task_root.glob("cad_*")), [])
def test_attachment_cannot_be_linked_to_another_conversation(self) -> None:
relative_path, _ = self.store.write_conversation_upload(self.conversation_id, "reference.png", b"reference-bytes")
attachment = attachment_record("conv_000000000002", "reference.png", "image/png", relative_path, b"reference-bytes", "image")
with self.assertRaisesRegex(ValueError, "does not belong"):
self.store.add_conversation_attachment(self.conversation_id, attachment)
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -132,7 +132,8 @@ class DesignIntentValidationTests(unittest.TestCase):
self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_001")
with self.assertRaises(self.engine.DesignIntentError) as context:
self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_002")
self.assertEqual(context.exception.code, "INVALID_DESIGN_INTENT")
self.assertEqual(context.exception.code, "DESIGN_INTENT_BASE_REVISION_MISMATCH")
self.assertIn("rev_002", str(context.exception))
def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None:
duplicate = mounting_plate_intent()
+259 -67
View File
@@ -6,18 +6,21 @@ import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from app.models.contracts import ChatMessage, MessagePart # noqa: E402
from app.services.agent_service import AgentService # noqa: E402
from app.services.engine_service import load_engine # noqa: E402
from app.services.flange_sleeve_template import TEMPLATE_ID # noqa: E402
from app.services.library import CdslLibrary # noqa: E402
from app.services.part_skills import PartSkillLibrary # noqa: E402
from app.services.storage import WorkspaceStore # noqa: E402
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
from tests.test_design_intent import mounting_plate_cdsl, mounting_plate_intent # noqa: E402
from tests.test_design_intent import mounting_plate_cdsl # noqa: E402
BACKEND = ROOT / "backend"
@@ -40,85 +43,274 @@ class DesignIntentFlowTests(unittest.TestCase):
providers=(provider,),
)
def test_design_intent_is_required_before_library_or_cdsl(self) -> None:
def test_design_brief_is_required_before_library_or_cdsl(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""}
searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], intent_state=state))
generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"design_intent_id": "intent_aaaaaaaaaaaa", "cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], intent_state=state))
state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], planning_state=state))
generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], planning_state=state))
self.assertEqual(searched["code"], "DESIGN_INTENT_REQUIRED")
self.assertEqual(generated["code"], "DESIGN_INTENT_REQUIRED")
self.assertEqual(searched["code"], "DESIGN_BRIEF_REQUIRED")
self.assertEqual(generated["code"], "DESIGN_BRIEF_REQUIRED")
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
def test_ready_plan_persists_before_build_and_links_success_revision(self) -> None:
def test_generation_normalizes_legacy_llm_cdsl_before_building(self) -> None:
legacy_cdsl = {
"schema": "cad.cdsl.llm.v1",
"part_id": "legacy-flange-base",
"geometry": {"sketches": [{
"id": "base_sketch",
"plane": "XY",
"offset_mm": 12,
"profile": {"type": "circle", "radius_mm": 20},
}]},
"features": [{
"id": "base_add",
"atomic_id": "extrude_add_blind",
"sketch": "base_sketch",
"params": {"distance_mm": 8},
}],
}
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange base."}
captured_build: dict[str, object] = {}
def fake_build_revision(**kwargs: object) -> dict[str, object]:
captured_build.update(kwargs)
return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"}
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
result, _ = asyncio.run(agent._run_tool(
"generate_cdsl_model",
{"cdsl": legacy_cdsl, "summary": "flange base", "assumptions": []},
"", "Create a flange base", [], planning_state=state,
))
built_cdsl = captured_build["cdsl"]
self.assertTrue(result["ok"])
self.assertEqual(built_cdsl["features"][0]["sketch_id"], "base_sketch")
self.assertEqual(built_cdsl["features"][0]["depends_on"], [])
self.assertNotIn("sketch", built_cdsl["features"][0])
self.assertIn("workplane", built_cdsl["geometry"]["sketches"][0])
self.assertEqual(len(result["normalization_repairs"]), 3)
def test_flange_sleeve_tool_builds_cdsl_from_a_compact_semantic_plan(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange sleeve."}
captured_build: dict[str, object] = {}
def fake_build_revision(**kwargs: object) -> dict[str, object]:
captured_build.update(kwargs)
return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"}
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
result, _ = asyncio.run(agent._run_tool(
"generate_flange_sleeve_model",
{
"plan": {
"template": TEMPLATE_ID,
"name": "Image flange sleeve",
"flange_width_mm": 140,
"flange_height_mm": 120,
"mount_hole_u_mm": 48,
"mount_hole_v_mm": 38,
},
"summary": "flange sleeve",
"assumptions": ["Dimensions are approximate."],
},
"", "Create an image flange sleeve", [], planning_state=state,
))
built_cdsl = captured_build["cdsl"]
self.assertTrue(result["ok"])
self.assertEqual(result["template_id"], TEMPLATE_ID)
self.assertEqual(captured_build["operation"]["template_id"], TEMPLATE_ID)
self.assertEqual(built_cdsl["meta"]["name"], "Image flange sleeve")
self.assertEqual(built_cdsl["geometry"]["sketches"][0]["workplane"]["normal"], [1.0, 0.0, 0.0])
self.assertEqual(built_cdsl["features"][-1]["depends_on"], ["mount_hole_cuts"])
self.assertNotIn("cdsl", result["template_plan"])
def test_successful_generation_ends_the_agent_tool_loop(self) -> None:
class CaptureAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.responses = [
{
"choices": [{"message": {
"role": "assistant", "content": "", "tool_calls": [{
"id": "brief", "type": "function", "function": {
"name": "describe_design_intent",
"arguments": json.dumps({"plan": "Create a flange sleeve.", "assumptions": []}),
},
}],
}}],
},
{
"choices": [{"message": {
"role": "assistant", "content": "", "tool_calls": [{
"id": "template", "type": "function", "function": {
"name": "generate_flange_sleeve_model",
"arguments": json.dumps({
"plan": {"template": TEMPLATE_ID},
"summary": "flange sleeve",
"assumptions": [],
}),
},
}],
}}],
},
]
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
return self.responses.pop(0)
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = CaptureAgent(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
def fake_build_revision(**kwargs: object) -> dict[str, object]:
return {
"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001",
"cdsl_path": "revisions/rev_001/model.cdsl.json",
"step_path": "revisions/rev_001/model.step",
"glb_path": "revisions/rev_001/model.glb",
"report_path": "revisions/rev_001/rebuild-report.json",
"summary": str(kwargs["summary"]), "reference_ids": [], "engine": "cdsl_only",
}
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
async def consume() -> None:
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a flange sleeve")])
async for _ in agent.stream([message], None, None):
pass
asyncio.run(consume())
saved = store.read_conversation(next(item.name for item in settings.conversation_root.iterdir()))
parts = saved["messages"][-1]["parts"]
self.assertEqual(agent.responses, [])
self.assertTrue(any(part["type"] == "data-cad-result" for part in parts))
self.assertFalse(any(part["type"] == "data-cad-error" for part in parts))
def test_text_brief_is_returned_to_model_and_cdsl_is_the_only_contract(self) -> None:
class CaptureAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.seen_messages: list[list[dict[str, object]]] = []
self.responses = [
{
"choices": [{"message": {
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "brief",
"type": "function",
"function": {
"name": "describe_design_intent",
"arguments": json.dumps({
"plan": "Create a rectangular mounting plate, then cut four mounting holes and a center slot.",
"assumptions": ["Use millimetres."],
}),
},
}],
}}],
},
{
"choices": [{"message": {
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "generate",
"type": "function",
"function": {
"name": "generate_cdsl_model",
"arguments": json.dumps({
"cdsl": mounting_plate_cdsl(),
"summary": "mounting plate",
"assumptions": ["Use millimetres."],
}),
},
}],
}}],
},
{"choices": [{"message": {"role": "assistant", "content": "已生成。", "tool_calls": []}}]},
]
async def _complete(self, messages: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]:
self.seen_messages.append([dict(message) for message in messages])
return self.responses.pop(0)
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
skills = PartSkillLibrary(PART_SKILL_ROOT)
agent = AgentService(settings, store, CdslLibrary(settings), skills)
request = "Create a mounting plate with four holes and a center slot"
state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""}
planned, _ = asyncio.run(agent._run_tool(
"propose_design_intent",
{"intent": mounting_plate_intent(), "summary": "plate plan", "assumptions": []},
"", request, [], part_skill_selection=skills.select(request), intent_state=state,
))
agent = CaptureAgent(settings, store, CdslLibrary(settings), skills)
captured_build: dict[str, object] = {}
self.assertTrue(planned["ok"])
task = store.read_task(planned["task_id"])
self.assertEqual(task["current_revision"], "")
planning_path = store.artifact_path(planned["task_id"], planned["design_intent_path"])
self.assertTrue(planning_path.is_file())
saved = json.loads(planning_path.read_text(encoding="utf-8"))
self.assertEqual(saved["part_skill_ids"], skills.select(request)["skill_ids"])
def fake_build_revision(**kwargs: object) -> dict[str, object]:
captured_build.update(kwargs)
return {
"task_id": "cad_aaaaaaaaaaaa",
"revision_id": "rev_001",
"cdsl_path": "revisions/rev_001/model.cdsl.json",
"step_path": "revisions/rev_001/model.step",
"glb_path": "revisions/rev_001/model.glb",
"report_path": "revisions/rev_001/rebuild-report.json",
"summary": str(kwargs["summary"]),
"reference_ids": list(kwargs["reference_ids"]),
"engine": "cdsl_only",
}
searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "plate"}, planned["task_id"], request, [], intent_state=state))
self.assertTrue(searched["ok"])
result, built = asyncio.run(agent._run_tool(
"generate_cdsl_model",
{"design_intent_id": planned["design_intent_id"], "cdsl": mounting_plate_cdsl(), "summary": "plate", "assumptions": []},
planned["task_id"], request, [], part_skill_selection=skills.select(request), intent_state=state,
engine = load_engine(settings)
with patch.object(engine, "validate_intent_cdsl", side_effect=AssertionError("legacy intent validation must not run")) as legacy_validation, patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
async def consume() -> None:
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")])
async for _ in agent.stream([message], None, None):
pass
asyncio.run(consume())
brief_result = json.loads(str(agent.seen_messages[1][-1]["content"]))
self.assertEqual(brief_result["plan"], "Create a rectangular mounting plate, then cut four mounting holes and a center slot.")
self.assertNotIn("design_intent_id", brief_result)
self.assertNotIn("design_intent_id", captured_build)
self.assertNotIn("design_intent", captured_build)
self.assertEqual(captured_build["parent_revision_id"], "")
legacy_validation.assert_not_called()
def test_revision_parent_is_taken_from_the_current_successful_cdsl_revision(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
asyncio.run(agent._run_tool(
"describe_design_intent",
{"plan": "Increase the plate thickness and preserve the existing hole layout.", "assumptions": []},
"cad_aaaaaaaaaaaa", "Revise the plate", [], planning_state=state,
))
store.read_task = lambda _task_id: {"current_revision": "rev_007"} # type: ignore[method-assign]
captured_build: dict[str, object] = {}
def fake_build_revision(**kwargs: object) -> dict[str, object]:
captured_build.update(kwargs)
return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_008"}
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
result, _ = asyncio.run(agent._run_tool(
"generate_cdsl_model",
{"cdsl": mounting_plate_cdsl(), "summary": "revised plate", "assumptions": []},
"cad_aaaaaaaaaaaa", "Revise the plate", [], planning_state=state,
))
self.assertTrue(result["ok"])
self.assertIsNotNone(built)
revision = store.read_task(planned["task_id"])["revisions"][-1]
self.assertEqual(revision["design_intent_id"], planned["design_intent_id"])
self.assertEqual(revision["design_intent_path"], planned["design_intent_path"])
audit = json.loads(store.artifact_path(planned["task_id"], revision["part_skills_path"]).read_text(encoding="utf-8"))
self.assertEqual(audit["design_intent_id"], planned["design_intent_id"])
self.assertEqual(audit["design_intent_assumptions"], [])
def test_blocked_plan_is_persisted_without_a_revision_and_new_plan_supersedes_it(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
skills = PartSkillLibrary(PART_SKILL_ROOT)
agent = AgentService(settings, store, CdslLibrary(settings), skills)
request = "Create a mounting plate"
state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""}
blocked_intent = mounting_plate_intent()
blocked_intent["open_questions"] = [{"id": "thickness", "question": "Thickness?", "blocking": True}]
blocked_intent["status"] = "needs_clarification"
blocked, _ = asyncio.run(agent._run_tool(
"propose_design_intent",
{"intent": blocked_intent, "summary": "blocked", "assumptions": []},
"", request, [], part_skill_selection=skills.select(request), intent_state=state,
))
self.assertEqual(blocked["code"], "DESIGN_INTENT_BLOCKED")
task = store.read_task(blocked["task_id"])
self.assertEqual(task["revisions"], [])
self.assertEqual(task["design_intents"][0]["status"], "pending")
ready, _ = asyncio.run(agent._run_tool(
"propose_design_intent",
{"intent": mounting_plate_intent(), "summary": "ready", "assumptions": []},
blocked["task_id"], request, [], part_skill_selection=skills.select(request), intent_state=state,
))
self.assertTrue(ready["ok"])
task = store.read_task(blocked["task_id"])
self.assertEqual(task["design_intents"][0]["status"], "superseded")
self.assertEqual(task["design_intents"][-1]["status"], "accepted")
self.assertEqual(captured_build["parent_revision_id"], "rev_007")
+9 -11
View File
@@ -211,7 +211,7 @@ class AgentPartSkillTests(unittest.TestCase):
self.assertIn("[planning/flange]", str(agent.first_messages[0]["content"]))
self.assertIn("[functional/flange-bolt-circle]", str(agent.first_messages[0]["content"]))
def test_prompt_contains_bridge_without_adding_tools(self) -> None:
def test_prompt_contains_bridge_and_template_tool(self) -> None:
library = PartSkillLibrary(PART_SKILL_ROOT)
selection = library.select("Create a flange with a bolt circle")
with tempfile.TemporaryDirectory() as directory:
@@ -220,7 +220,7 @@ class AgentPartSkillTests(unittest.TestCase):
self.assertIn("[planning/flange]", prompt)
self.assertIn("circular-pattern atomic", prompt)
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["search_cdsl_library", "read_cdsl_reference", "propose_design_intent", "read_current_cdsl", "generate_cdsl_model"])
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_flange_sleeve_model", "generate_cdsl_model"])
def test_generation_persists_assumptions_and_selected_skills(self) -> None:
with tempfile.TemporaryDirectory() as directory:
@@ -230,27 +230,25 @@ class AgentPartSkillTests(unittest.TestCase):
agent = AgentService(settings, store, CdslLibrary(settings), library)
request = "M6 六角螺母"
selection = library.select(request)
engine = load_engine(settings)
intent = engine.design_intent_from_cdsl(hex_nut_fixture(), request=request)
intent_state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""}
planning_state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
planned, _ = asyncio.run(agent._run_tool(
"propose_design_intent",
{"intent": intent, "summary": "M6 nut plan", "assumptions": []},
"describe_design_intent",
{"plan": "Create an M6 hex nut with a centered cylindrical bore representing the thread.", "assumptions": []},
"",
request,
[],
part_skill_selection=selection,
intent_state=intent_state,
planning_state=planning_state,
))
self.assertTrue(planned["ok"])
result, generated = asyncio.run(agent._run_tool(
"generate_cdsl_model",
{"design_intent_id": planned["design_intent_id"], "cdsl": hex_nut_fixture(), "summary": "M6 nut", "assumptions": ["M6 thread is represented as a cylindrical bore"]},
planned["task_id"],
{"cdsl": hex_nut_fixture(), "summary": "M6 nut", "assumptions": ["M6 thread is represented as a cylindrical bore"]},
"",
request,
["reference-fixture"],
part_skill_selection=selection,
intent_state=intent_state,
planning_state=planning_state,
))
self.assertTrue(result["ok"])
+81 -1
View File
@@ -5,7 +5,8 @@ import tempfile
import unittest
from pathlib import Path
from app.services.engine_service import load_engine, validate_cdsl
from app.services.engine_service import load_engine, normalize_cdsl_for_engine, validate_cdsl
from app.services.flange_sleeve_template import TEMPLATE_ID, build_flange_sleeve_cdsl
from app.settings import get_settings
@@ -88,6 +89,85 @@ class ProfileSchemaTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "positions\\[0\\].*not of type 'object'"):
validate_cdsl(cdsl, self.engine)
def test_normalizes_unambiguous_legacy_llm_field_names_before_validation(self) -> None:
legacy_cdsl = {
"schema": "cad.cdsl.llm.v1",
"part_id": "legacy-flange-base",
"geometry": {"sketches": [{
"id": "base_sketch",
"plane": "XY",
"offset_mm": 12,
"profile": {"type": "circle", "radius_mm": 20},
}]},
"features": [{
"id": "base_add",
"atomic_id": "extrude_add_blind",
"sketch": "base_sketch",
"params": {"distance_mm": 8},
}],
}
normalized, repairs = normalize_cdsl_for_engine(legacy_cdsl)
self.assertEqual(legacy_cdsl["geometry"]["sketches"][0]["plane"], "XY")
self.assertEqual(normalized["features"][0]["sketch_id"], "base_sketch")
self.assertNotIn("sketch", normalized["features"][0])
self.assertEqual(normalized["features"][0]["depends_on"], [])
self.assertEqual(normalized["geometry"]["sketches"][0]["workplane"], {
"origin_mm": [0.0, 0.0, 12.0],
"x_dir": [1.0, 0.0, 0.0],
"normal": [0.0, 0.0, 1.0],
})
self.assertEqual(len(repairs), 3)
validate_cdsl(normalized, self.engine)
def test_normalizer_rewrites_the_legacy_revolve_axis_point_name(self) -> None:
normalized, repairs = normalize_cdsl_for_engine({
"features": [{
"id": "turn",
"atomic_id": "revolve_add",
"params": {"axis": {"point_mm": [0, 0, 0], "direction": [0, 0, 1]}},
}],
})
axis = normalized["features"][0]["params"]["axis"]
self.assertEqual(axis["origin_mm"], [0, 0, 0])
self.assertNotIn("point_mm", axis)
self.assertIn("features[0].params.axis: point_mm -> origin_mm", repairs)
def test_flange_sleeve_template_is_schema_valid_and_runtime_buildable(self) -> None:
cdsl, plan = build_flange_sleeve_cdsl({
"template": TEMPLATE_ID,
"name": "Template flange sleeve",
"flange_width_mm": 120,
"flange_height_mm": 120,
"flange_thickness_mm": 14,
"corner_chamfer_mm": 10,
"tube_outer_diameter_mm": 70,
"tube_straight_length_mm": 95,
"tip_outer_diameter_mm": 62,
"tip_length_mm": 28,
"bore_diameter_mm": 46,
"boss_outer_diameter_mm": 82,
"boss_height_mm": 5,
"mount_hole_diameter_mm": 12,
"mount_counterbore_diameter_mm": 24,
"mount_counterbore_depth_mm": 5,
"mount_hole_u_mm": 42,
"mount_hole_v_mm": 42,
})
self.assertEqual(plan["name"], "Template flange sleeve")
self.assertEqual(cdsl["features"][1]["depends_on"], ["flange_plate"])
self.assertEqual(cdsl["geometry"]["sketches"][0]["workplane"]["x_dir"], [0.0, 1.0, 0.0])
validate_cdsl(cdsl, self.engine)
with tempfile.TemporaryDirectory() as directory:
step_path = Path(directory) / "flange-sleeve.step"
result = self.engine.run_cdsl_only(cdsl, step_path)
self.assertEqual(result["engine"], "cdsl_only")
self.assertTrue(step_path.is_file())
self.assertGreater(step_path.stat().st_size, 0)
def test_cdsl_only_rebuild_preserves_its_actual_failure(self) -> None:
cdsl = {
"schema": "cad.cdsl.llm.v1",
@@ -3,9 +3,10 @@ import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
export async function POST(request: NextRequest, context: { params: Promise<{ conversationId: string }> }) {
const { conversationId } = await context.params;
const body = await request.formData();
const response = await backendFetch("/v1/uploads", { method: "POST", body });
const response = await backendFetch(`/v1/conversations/${encodeURIComponent(conversationId)}/attachments`, { method: "POST", body });
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
@@ -18,7 +18,6 @@ export async function PATCH(request: NextRequest, context: { params: Promise<{ c
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
current_task_id: body.currentTaskId || null,
attachments: Array.isArray(body.attachments) ? body.attachments : undefined,
}),
});
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
+49 -14
View File
@@ -75,7 +75,6 @@
--ui-loading-overlay: rgb(233 238 242 / 70%);
--ui-loading-overlay-strong: rgb(245 247 248 / 88%);
--ui-drag-overlay: rgb(255 255 255 / 88%);
--ui-drag-shadow: 0 0 0 999px rgb(15 25 30 / 18%);
--ui-shadow-soft: 0 12px 30px rgb(16 24 32 / 12%);
--ui-shadow-panel: 0 22px 60px rgb(16 24 32 / 14%);
--ui-shadow-popover: 0 18px 46px rgb(16 24 32 / 16%);
@@ -161,7 +160,6 @@
--ui-loading-overlay: rgb(13 15 17 / 35%);
--ui-loading-overlay-strong: rgb(13 15 17 / 76%);
--ui-drag-overlay: rgb(16 19 21 / 88%);
--ui-drag-shadow: 0 0 0 999px rgb(17 19 21 / 42%);
--ui-shadow-soft: 0 10px 24px rgb(0 0 0 / 20%);
--ui-shadow-panel: 0 24px 60px rgb(0 0 0 / 26%);
--ui-shadow-popover: 0 18px 48px rgb(0 0 0 / 28%);
@@ -375,6 +373,7 @@ button:disabled {
@keyframes generation-edge-soft-out { to { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.spin { animation: none; }
.generation-edge-glow,
.generation-edge-glow *,
.generation-edge-glow *::before {
@@ -397,28 +396,64 @@ button:disabled {
.agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); }
.preview-pane { min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
.agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; }
.agent-thread-shell { position: relative; }
.spin { animation: ui-spin 900ms linear infinite; }
@keyframes ui-spin { to { transform: rotate(360deg); } }
.agent-pane-title { display: flex; height: 40px; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-border); color: var(--ui-text-strong); font-size: 12px; font-weight: 700; padding: 0 12px; }
.agent-pane-title svg { color: var(--ui-accent); }
.thread-viewport { min-height: 0; flex: 1; overflow-y: auto; padding: 12px; }
.message-list { display: grid; }
.message-row { display: flex; border-bottom: 1px solid var(--ui-border-muted); padding: 10px 2px; }
.user-row { justify-content: flex-end; }
.assistant-row { justify-content: flex-start; }
.message-bubble { max-width: 100%; min-width: 0; }
.user-bubble { max-width: 90%; border-radius: 5px; background: var(--ui-accent); color: var(--ui-accent-contrast); padding: 8px 10px; }
.assistant-bubble { width: 100%; color: var(--ui-text); }
.message-row { display: grid; gap: 6px; border-bottom: 1px solid var(--ui-border-muted); padding: 12px 2px; }
.user-row { padding-left: 32px; }
.message-role { display: flex; align-items: center; gap: 7px; color: var(--ui-text-muted); font-size: 11px; font-weight: 700; }
.message-role svg { color: var(--ui-text-muted); }
.assistant-row .message-role svg { color: var(--ui-accent); }
.message-content { min-width: 0; color: var(--ui-text); }
.message-text { margin: 0; font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; }
.thread-empty { display: grid; gap: 7px; border: 1px dashed var(--ui-border-strong); border-radius: 5px; color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 14px; }
.thread-empty strong { color: var(--ui-text-strong); }
.attachment-list { display: grid; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--ui-border-muted); padding-bottom: 10px; }
.message-request-error { display: flex; align-items: flex-start; gap: 7px; margin-top: 8px; color: var(--ui-error-text); font-size: 11px; line-height: 1.45; }
.message-request-error svg { flex: 0 0 auto; margin-top: 1px; }
.thread-empty { color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 2px 0 14px; }
.attachment-list { display: grid; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--ui-border-muted); padding: 0 1px 10px; }
.attachment-card { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text-muted); font-size: 11px; }
.attachment-card svg { flex: 0 0 auto; color: var(--ui-accent); }.attachment-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.attachment-card small { margin-left: auto; color: var(--ui-text-subtle); font-size: 10px; white-space: nowrap; }
.upload-error { display: flex; align-items: flex-start; gap: 7px; margin: 0 1px 10px; border-bottom: 1px solid var(--ui-error-border); color: var(--ui-error-text); font-size: 11px; line-height: 1.45; padding: 0 0 10px; }
.upload-error svg { flex: 0 0 auto; margin-top: 1px; }
.composer-shell { flex: 0 0 auto; border-top: 1px solid var(--ui-border); padding: 12px; }
.composer-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.composer-root { display: grid; gap: 8px; }
.composer-input { min-height: 96px; max-height: 176px; width: 100%; resize: none; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text); font-size: 12px; line-height: 1.55; outline: none; padding: 9px; }
.composer-input:focus { border-color: var(--ui-accent); }.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-text-muted); font-size: 11px; }.composer-footer > span, .composer-footer > div { display: flex; align-items: center; gap: 7px; }.composer-action, .composer-send { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text-muted); }.composer-send { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-contrast); }
.cad-card { display: flex; gap: 9px; margin: 6px 0; border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-panel-muted); padding: 9px; }.cad-card-icon { display: grid; flex: 0 0 auto; width: 25px; height: 25px; place-items: center; border-radius: 4px; background: var(--ui-accent-soft); color: var(--ui-accent); }.cad-card-body { min-width: 0; }.cad-card-title { color: var(--ui-text-strong); font-size: 12px; font-weight: 700; }.cad-card-copy, .cad-result-meta { color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }.download-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }.download-link { border-bottom: 1px solid currentColor; color: var(--ui-link); font-size: 11px; text-decoration: none; }.cad-error-card { border-color: var(--ui-error-border); background: var(--ui-error-bg); }.cad-error-card .cad-card-icon { background: transparent; color: var(--ui-error-text); }
.composer-input:focus-visible { border-color: var(--ui-accent); box-shadow: 0 0 0 3px var(--ui-focus-ring); }
.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-text-muted); font-size: 11px; }
.composer-footer > span, .composer-actions { display: flex; align-items: center; gap: 7px; }
.composer-actions { flex: 0 0 auto; }
.composer-command { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 5px; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text-muted); font-size: 11px; font-weight: 600; padding: 0 9px; }
.composer-command:hover:not(:disabled) { background: var(--ui-control-hover); color: var(--ui-text); }
.composer-command:focus-visible, .download-link:focus-visible { outline: 2px solid var(--ui-focus-ring); outline-offset: 2px; }
.composer-send { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-contrast); }
.composer-send:hover:not(:disabled) { background: var(--ui-accent-hover); color: var(--ui-accent-contrast); }
.composer-cancel { border-color: var(--ui-accent-border); background: var(--ui-accent-soft); color: var(--ui-accent-text); }
.file-drop-overlay { position: absolute; z-index: 20; inset: 8px; display: grid; place-content: center; justify-items: center; gap: 8px; border: 1px dashed var(--ui-accent); border-radius: 5px; background: var(--ui-drag-overlay); color: var(--ui-accent); font-size: 12px; font-weight: 700; pointer-events: none; }
.cad-message { display: grid; gap: 3px; margin: 9px 0 0; min-width: 0; padding-left: 2px; }
.cad-message-heading { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text); font-size: 12px; font-weight: 600; }
.cad-message-heading svg { flex: 0 0 auto; color: var(--ui-accent); }
.cad-status-label { color: var(--ui-text-subtle); font-size: 10px; font-weight: 500; text-transform: uppercase; }
.cad-message-copy { margin-left: 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
.cad-progress.is-error .cad-message-heading, .cad-progress.is-error .cad-message-heading svg, .cad-error .cad-message-heading, .cad-error .cad-message-heading svg { color: var(--ui-error-text); }
.cad-result { margin-top: 12px; }
.cad-result-title { margin-left: 21px; color: var(--ui-accent-text); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; }
.cad-result-meta { display: flex; flex-wrap: wrap; gap: 3px 10px; margin-left: 21px; color: var(--ui-text-muted); font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; }
.download-row { display: flex; flex-wrap: wrap; gap: 9px; margin: 2px 0 0 21px; }
.download-link { display: inline-flex; align-items: center; gap: 4px; color: var(--ui-link); font-size: 11px; text-decoration: none; text-underline-offset: 2px; }
.download-link:hover { color: var(--ui-link-hover); text-decoration: underline; }
.cad-image-part-type { margin-left: 21px; color: var(--ui-text); font-size: 11px; font-weight: 600; line-height: 1.45; }
.image-analysis-section { display: grid; gap: 2px; margin: 4px 0 0 21px; }
.image-analysis-section > span { color: var(--ui-text-subtle); font-size: 10px; font-weight: 700; }
.image-analysis-section p { margin: 0; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
.image-dimension-list { display: grid; gap: 5px; margin: 1px 0 0; padding: 0; list-style: none; }
.image-dimension-list li { display: grid; gap: 1px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.4; }
.image-dimension-list strong { color: var(--ui-text); font-weight: 600; }
.image-dimension-list span { overflow-wrap: anywhere; }
.viewer-state { display: flex; height: 100%; min-height: 42vh; align-items: center; justify-content: center; gap: 9px; color: var(--ui-text-muted); font-size: 12px; }.viewer-state svg { color: var(--ui-accent); }.viewer-state-error { color: var(--ui-error-text); }.viewer-state-error svg { color: var(--ui-error-text); }
.viewer-loading { position: absolute; z-index: 40; left: 50%; top: 50%; display: flex; align-items: center; gap: 8px; transform: translate(-50%, -50%); border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-glass-popover); color: var(--ui-text-muted); font-size: 12px; padding: 9px 12px; box-shadow: var(--ui-shadow-soft); }
.cad-viewer-dark { background: var(--ui-viewer-bg); }
@media (max-width: 767px) { .studio-app { height: auto; min-height: 100vh; overflow: visible; }.app-header { height: auto; min-height: 48px; flex-wrap: wrap; padding: 8px 12px; }.task-badge { display: none; }.app-controls { width: 100%; }.app-controls select { flex: 1; }.studio-main { min-height: 0; flex-direction: column; }.preview-pane { order: -1; min-height: 46vh; }.agent-pane { width: 100%; min-height: 560px; border-top: 1px solid var(--ui-border); border-right: 0; }.thread-viewport { max-height: 480px; }.composer-footer > span { display: none; } }
@media (max-width: 767px) { .studio-app { height: auto; min-height: 100vh; overflow: visible; }.app-header { height: auto; min-height: 48px; flex-wrap: wrap; padding: 8px 12px; }.task-badge { display: none; }.app-controls { width: 100%; }.app-controls select { flex: 1; }.studio-main { min-height: 0; flex-direction: column; }.preview-pane { min-height: 46vh; }.agent-pane { width: 100%; min-height: 560px; border-right: 0; border-bottom: 1px solid var(--ui-border); }.thread-viewport { max-height: 480px; }.composer-footer > span { display: none; } }
+15 -24
View File
@@ -34,6 +34,7 @@ export function AgentStudio() {
const [lastError, setLastError] = useState("");
const [attachments, setAttachments] = useState<CadAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState("");
const [providerId, setProviderId] = useState("");
const [modelId, setModelId] = useState("");
const [theme, setTheme] = useState<"light" | "dark">("light");
@@ -141,43 +142,30 @@ export function AgentStudio() {
}, []);
const handleUpload = useCallback(async (files: FileList | null) => {
if (!files?.length) return;
const selectedModel = config?.providers
.find((provider) => provider.id === providerId)
?.models.find((model) => model.id === modelId);
const includesImage = Array.from(files).some((file) => file.type.startsWith("image/") || /\.(png|jpe?g|webp)$/i.test(file.name));
if (includesImage && !selectedModel?.vision) {
setLastError("当前模型不支持图片。请选择标记为 Vision 的 OpenAI 或 Kimi 模型后再上传图片。");
return;
}
const selectedFiles = Array.from(files || []);
if (!selectedFiles.length) return;
setUploadError("");
setUploading(true);
try {
const uploaded: CadAttachment[] = [];
for (const file of Array.from(files)) {
for (const file of selectedFiles) {
const form = new FormData();
form.set("file", file);
if (selectedTaskId) form.set("task_id", selectedTaskId);
const response = await fetch("/api/uploads", { method: "POST", body: form });
const response = await fetch(`/api/conversations/${encodeURIComponent(conversationId)}/attachments`, { method: "POST", body: form });
const payload = await response.json() as CadAttachment & { error?: string };
if (!response.ok) throw new Error(payload.error || `${file.name} 上传失败`);
uploaded.push(payload);
if (!selectedTaskId) setSelectedTaskId(payload.task_id);
}
setAttachments((current) => {
const next = [...current, ...uploaded];
if (conversationId) void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentTaskId: selectedTaskId || uploaded[0]?.task_id || null, attachments: next }),
});
return next;
});
setAttachments((current) => [...current, ...uploaded]);
setLastError("");
} catch (error) {
setLastError(error instanceof Error ? error.message : "附件上传失败");
const message = error instanceof Error ? error.message : "附件上传失败";
setUploadError(message);
setLastError(message);
} finally {
setUploading(false);
}
}, [config, conversationId, modelId, providerId, selectedTaskId]);
}, [conversationId]);
if (loadState === "loading") {
return <StudioLoading />;
@@ -204,6 +192,7 @@ export function AgentStudio() {
lastError={lastError}
attachments={attachments}
uploading={uploading}
uploadError={uploadError}
onUpload={handleUpload}
theme={theme}
onToggleTheme={toggleTheme}
@@ -425,6 +414,7 @@ function StudioShell({
lastError,
attachments,
uploading,
uploadError,
onUpload,
theme,
onToggleTheme,
@@ -441,6 +431,7 @@ function StudioShell({
lastError: string;
attachments: CadAttachment[];
uploading: boolean;
uploadError: string;
onUpload: (files: FileList | null) => void;
theme: "light" | "dark";
onToggleTheme: () => void;
@@ -473,7 +464,7 @@ function StudioShell({
</header>
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span>未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。</span></div> : null}
<div className="studio-main">
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} onUpload={onUpload} /></aside>
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} onUpload={onUpload} /></aside>
<section className="preview-pane"><CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={handleViewerError} onSelectionChange={onSelectionChange} /></section>
</div>
</main>
+84 -11
View File
@@ -1,59 +1,132 @@
"use client";
import { Check, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Square } from "lucide-react";
import { useRef } from "react";
import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Square, Upload } from "lucide-react";
import { useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import type { CadAttachment } from "@/lib/cad-types";
import { CadErrorPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
import { CadErrorPart, CadImageAnalysisPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
export function AgentThread({ attachments, uploading, onUpload }: {
export function AgentThread({ attachments, uploading, uploadError, onUpload }: {
attachments: CadAttachment[];
uploading: boolean;
uploadError: string;
onUpload: (files: FileList | null) => void;
}) {
const fileInput = useRef<HTMLInputElement>(null);
const dragDepth = useRef(0);
const running = useAuiState((state) => state.thread.isRunning);
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
const canUpload = !uploading && !running;
const onDragEnter = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepth.current += 1;
if (canUpload && isFileDrag(event)) setIsDraggingFiles(true);
};
const onDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = canUpload ? "copy" : "none";
};
const onDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setIsDraggingFiles(false);
};
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepth.current = 0;
setIsDraggingFiles(false);
if (canUpload && event.dataTransfer.files.length) onUpload(event.dataTransfer.files);
};
return (
<div className="agent-thread-shell">
<div
className="agent-thread-shell"
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<div className="agent-pane-title"><MessageSquare size={16} /><span>Agent</span></div>
<ThreadPrimitive.Root className="thread-root">
<ThreadPrimitive.Viewport className="thread-viewport scrollbar-thin" autoScroll>
{attachments.length ? <div className="attachment-list">{attachments.map((attachment) => <AttachmentCard key={attachment.id} attachment={attachment} />)}</div> : null}
{uploadError ? <div className="upload-error" role="alert"><CircleAlert size={14} aria-hidden="true" />{uploadError}</div> : null}
<ThreadPrimitive.Empty>
<div className="thread-empty"><strong>描述要生成或修改的 CAD 模型</strong><span>Agent 会检索本地 CDSL 样本并生成可编辑的 CDSL 模型。</span></div>
<div className="thread-empty">描述需要生成或修改的 CAD 模型。</div>
</ThreadPrimitive.Empty>
<div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div>
</ThreadPrimitive.Viewport>
<Composer fileInput={fileInput} uploading={uploading} onUpload={onUpload} />
</ThreadPrimitive.Root>
{isDraggingFiles ? <div className="file-drop-overlay" role="status" aria-live="polite"><Upload size={24} /><span>拖放文件上传</span></div> : null}
</div>
);
}
function isFileDrag(event: DragEvent<HTMLDivElement>) {
return event.dataTransfer.files.length > 0 || Array.from(event.dataTransfer.types).includes("Files");
}
function AttachmentCard({ attachment }: { attachment: CadAttachment }) {
const Icon = attachment.kind === "image" ? FileImage : FileText;
return <div className="attachment-card"><Icon size={14} /><span>{attachment.name}</span><small>{attachment.kind === "image" ? "视觉参考" : "文本参考"}</small></div>;
}
function UserMessage() {
return <MessagePrimitive.Root className="message-row user-row"><div className="message-bubble user-bubble"><MessagePrimitive.Parts components={{ Text: TextPart }} /></div></MessagePrimitive.Root>;
return (
<MessagePrimitive.Root className="message-row user-row">
<div className="message-role"><Sparkles size={14} aria-hidden="true" /><span>You</span></div>
<div className="message-content"><MessagePrimitive.Parts components={{ Text: TextPart }} /></div>
</MessagePrimitive.Root>
);
}
function AssistantMessage() {
return (
<MessagePrimitive.Root className="message-row assistant-row">
<div className="message-bubble assistant-bubble"><MessagePrimitive.Parts components={{ Text: TextPart, data: { by_name: { "cad-progress": CadProgressPart, "cad-result": CadResultPart, "cad-error": CadErrorPart } } }} /></div>
<div className="message-role"><Bot size={14} aria-hidden="true" /><span>Agent</span></div>
<div className="message-content">
<MessagePrimitive.Parts components={{ Text: TextPart, data: { by_name: { "cad-progress": CadProgressPart, "cad-result": CadResultPart, "cad-error": CadErrorPart, "cad-image-analysis": CadImageAnalysisPart } } }} />
<MessagePrimitive.Error>
<div className="message-request-error" role="alert"><CircleAlert size={14} aria-hidden="true" /> Agent 请求失败,请检查服务端日志和模型配置。</div>
</MessagePrimitive.Error>
</div>
</MessagePrimitive.Root>
);
}
function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; onUpload: (files: FileList | null) => void }) {
const running = useAuiState((state) => state.thread.isRunning);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.currentTarget.files?.length) onUpload(event.currentTarget.files);
event.currentTarget.value = "";
};
return (
<div className="composer-shell">
<input ref={fileInput} className="hidden" type="file" accept=".png,.jpg,.jpeg,.webp,.txt,.md,.csv,.json" multiple onChange={(event) => onUpload(event.target.files)} />
<input ref={fileInput} className="composer-file-input" type="file" accept=".png,.jpg,.jpeg,.webp,.txt,.md,.csv,.json" multiple tabIndex={-1} aria-hidden="true" onChange={handleFileChange} />
<ComposerPrimitive.Root className="composer-root">
<ComposerPrimitive.Input className="composer-input" placeholder="描述要生成或修改的 CAD 模型..." submitMode="enter" rows={4} />
<div className="composer-footer"><span><Check size={14} /> Enter 发送,Shift + Enter 换行</span><div>{running ? <ComposerPrimitive.Cancel className="composer-action" title="停止生成"><Square size={15} /></ComposerPrimitive.Cancel> : <button type="button" className="composer-action" title="上传图片或文档" disabled={uploading} onClick={() => fileInput.current?.click()}>{uploading ? <Loader2 className="spin" size={15} /> : <Paperclip size={15} />}</button>}<ComposerPrimitive.Send className="composer-send" title="发送"><Send size={16} /></ComposerPrimitive.Send></div></div>
<ComposerPrimitive.Input aria-label="CAD 请求" className="composer-input" placeholder="描述要生成或修改的 CAD 模型..." submitMode="enter" rows={4} />
<div className="composer-footer">
<span><Check size={14} aria-hidden="true" /> Enter 发送,Shift + Enter 换行</span>
<div className="composer-actions">
{running ? (
<ComposerPrimitive.Cancel className="composer-command composer-cancel" title="停止生成" aria-label="停止生成"><Square size={14} fill="currentColor" aria-hidden="true" />停止</ComposerPrimitive.Cancel>
) : (
<>
<button type="button" className="composer-command composer-upload" title="上传图片或文档" aria-label="上传图片或文档" aria-busy={uploading || undefined} disabled={uploading} onClick={() => fileInput.current?.click()}>{uploading ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Paperclip size={14} aria-hidden="true" />}上传</button>
<ComposerPrimitive.Send className="composer-command composer-send" title="发送" aria-label="发送"><Send size={14} aria-hidden="true" />发送</ComposerPrimitive.Send>
</>
)}
</div>
</div>
</ComposerPrimitive.Root>
</div>
);
+67 -37
View File
@@ -1,8 +1,8 @@
"use client";
import { AlertTriangle, CheckCircle2, Download, Loader2 } from "lucide-react";
import { AlertTriangle, Box, Check, Download, Loader2, Ruler } from "lucide-react";
import { encodeArtifactUrl } from "@/lib/cad-artifacts";
import type { CadError, CadProgress, CadResult } from "@/lib/cad-types";
import type { CadError, CadImageAnalysis, CadProgress, CadResult } from "@/lib/cad-types";
export function TextPart({ text }: { text: string }) {
if (!text.trim()) return null;
@@ -10,16 +10,19 @@ export function TextPart({ text }: { text: string }) {
}
export function CadProgressPart({ data }: { data: CadProgress }) {
const running = data.status === "running";
if (data.step === "agent_stream") return null;
const status = String(data.status || "").toLowerCase();
const isRunning = status === "running";
const isError = status === "error";
const statusLabel = isRunning ? "进行中" : isError ? "失败" : status === "success" ? "完成" : data.status;
return (
<div className="cad-card cad-progress-card">
<div className="cad-card-icon" data-status={data.status}>
{running ? <Loader2 className="spin" size={16} /> : <CheckCircle2 size={16} />}
</div>
<div className="cad-card-body">
<div className="cad-card-title">{data.label || data.step}</div>
{data.message ? <div className="cad-card-copy">{data.message}</div> : null}
<div className={`cad-message cad-progress${isError ? " is-error" : ""}`} role="status" aria-live="polite">
<div className="cad-message-heading">
{isRunning ? <Loader2 className="spin" size={14} aria-hidden="true" /> : isError ? <AlertTriangle size={14} aria-hidden="true" /> : <Check size={14} aria-hidden="true" />}
<span>{data.label || data.step}</span>
<span className="cad-status-label">{statusLabel}</span>
</div>
{data.message ? <div className="cad-message-copy">{data.message}</div> : null}
</div>
);
}
@@ -29,44 +32,71 @@ export function CadResultPart({ data }: { data: CadResult }) {
["STEP", data.stepPath],
["CDSL", data.cdslPath],
["GLB", data.glbPath],
["REPORT", data.reportPath],
["报告", data.reportPath],
];
if (data.designIntentPath) downloads.push(["DESIGN INTENT", data.designIntentPath]);
return (
<div className="cad-card cad-result-card">
<div className="cad-card-icon success">
<CheckCircle2 size={16} />
<section className="cad-message cad-result" aria-label="CAD 结果">
<div className="cad-message-heading">
<Box size={14} aria-hidden="true" />
<span>CAD 结果</span>
</div>
<div className="cad-card-body">
<div className="cad-card-title">{data.summary || "生成完成"}</div>
<div className="cad-result-meta">
<div className="cad-result-title">{data.summary || "生成完成"}</div>
<div className="cad-result-meta">
<span>{data.engine}</span>
<span>{data.revisionId}</span>
<span>{data.referenceIds.length} references</span>
</div>
<div className="download-row">
{downloads.map(([label, path]) => (
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download>
<Download size={14} />
{label}
</a>
))}
</div>
<span>{data.referenceIds.length} 个参考</span>
</div>
</div>
<div className="download-row">
{downloads.map(([label, path]) => (
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download aria-label={`下载 ${label}`}>
<Download size={13} aria-hidden="true" />
{label}
</a>
))}
</div>
</section>
);
}
export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) {
const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? [];
return (
<section className="cad-message cad-image-analysis" aria-label="图像分析">
<div className="cad-message-heading"><Ruler size={14} aria-hidden="true" /><span>图像分析</span></div>
<div className="cad-image-part-type">{data.partType}</div>
<div className="image-analysis-section">
<span>可见特征</span>
<p>{data.visibleFeatures.join(";") || "未识别到可确认特征"}</p>
</div>
{data.uncertainFeatures.length ? <div className="image-analysis-section">
<span>待确认特征</span>
<p>{data.uncertainFeatures.join(";")}</p>
</div> : null}
{dimensionCandidates.length ? <div className="image-analysis-section">
<span>可能需要确认的尺寸</span>
<ul className="image-dimension-list">
{dimensionCandidates.map((dimension) => <li key={dimension.id}>
<strong>{dimension.label}</strong>
<span>{dimension.reason}</span>
</li>)}
</ul>
</div> : null}
</section>
);
}
export function CadErrorPart({ data }: { data: CadError }) {
const stageLabels: Record<string, string> = {
agent: "Agent",
attachment: "附件",
chat: "对话",
viewer: "预览",
};
const stage = stageLabels[data.stage] || data.stage || "CAD 操作";
return (
<div className="cad-card cad-error-card">
<div className="cad-card-icon error">
<AlertTriangle size={16} />
</div>
<div className="cad-card-body">
<div className="cad-card-title">{data.stage || "生成失败"}</div>
<div className="cad-card-copy">{data.message}</div>
</div>
<div className="cad-message cad-error" role="alert">
<div className="cad-message-heading"><AlertTriangle size={14} aria-hidden="true" /><span>{stage}失败</span></div>
<div className="cad-message-copy">{data.message}</div>
</div>
);
}
@@ -51,8 +51,6 @@ function resultFromBackend(payload: Record<string, unknown>): CadResult {
parametersPath: typeof payload.parameters_path === "string" ? payload.parameters_path : undefined,
selectorPath: typeof payload.selector_path === "string" ? payload.selector_path : undefined,
edgesPath: typeof payload.edges_path === "string" ? payload.edges_path : undefined,
designIntentId: typeof payload.design_intent_id === "string" ? payload.design_intent_id : undefined,
designIntentPath: typeof payload.design_intent_path === "string" ? payload.design_intent_path : undefined,
summary: String(payload.summary || "Updated CDSL model"),
referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [],
engine: String(payload.engine || "cdsl_only"),
-2
View File
@@ -31,8 +31,6 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul
parametersPath: current.parameters_path,
selectorPath: current.selector_path,
edgesPath: current.edges_path,
designIntentId: current.design_intent_id,
designIntentPath: current.design_intent_path,
summary: current.summary || "CDSL CAD model",
referenceIds: current.reference_ids || [],
engine: current.engine || "cdsl_only",
+4 -1
View File
@@ -1,4 +1,4 @@
import type { CadError, CadProgress, CadResult, CadUIMessage } from "./cad-types";
import type { CadError, CadImageAnalysis, CadProgress, CadResult, CadUIMessage } from "./cad-types";
type AnyPart = { type?: unknown; text?: unknown; data?: unknown; id?: unknown };
type AnyMessage = { id?: unknown; role?: unknown; parts?: unknown };
@@ -24,6 +24,9 @@ export function normalizeCadMessages(input: unknown): CadUIMessage[] {
if (item.type === "data-cad-error") {
return [{ type: "data-cad-error", id: stringId(item.id), data: item.data as CadError }];
}
if (item.type === "data-cad-image-analysis") {
return [{ type: "data-cad-image-analysis", id: stringId(item.id), data: item.data as CadImageAnalysis }];
}
return [];
});
return [{
+14
View File
@@ -14,6 +14,20 @@ test("maps backend cad_result SSE into an AI SDK data part", () => {
assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" });
});
test("maps structured image analysis SSE into an AI SDK data part", () => {
const data = {
attachmentIds: ["upload_flange"],
partType: "四孔法兰套筒",
visibleFeatures: ["中空圆筒"],
uncertainFeatures: [],
dimensionCandidates: [{ id: "bore_diameter", label: "中心孔直径", reason: "图片未标注" }],
};
const chunk = backendEventToUiChunk({ event: "image_analysis", data }, "text_1");
assert.equal(chunk?.type, "data-cad-image-analysis");
assert.deepEqual("data" in chunk! ? chunk.data : null, data);
});
test("restores the latest successful task revision for the viewer", () => {
const result = latestSuccessfulResult({
task_id: "cad_abc",
+7
View File
@@ -33,5 +33,12 @@ export function backendEventToUiChunk(
data: item.data,
};
}
if (item.event === "image_analysis") {
return {
type: "data-cad-image-analysis",
id: `image_analysis_${Date.now()}`,
data: item.data,
};
}
return null;
}
+18 -6
View File
@@ -17,8 +17,6 @@ export type CadResult = {
parametersPath?: string;
selectorPath?: string;
edgesPath?: string;
designIntentId?: string;
designIntentPath?: string;
summary: string;
referenceIds: string[];
engine: string;
@@ -29,10 +27,27 @@ export type CadError = {
message: string;
};
export type CadImageDimension = {
id: string;
label: string;
reason: string;
};
export type CadImageAnalysis = {
attachmentIds: string[];
partType: string;
visibleFeatures: string[];
uncertainFeatures: string[];
dimensionCandidates?: CadImageDimension[];
// Legacy conversations stored this field before dimensions became optional.
requiredDimensions?: CadImageDimension[];
};
export type CadDataParts = {
"cad-progress": CadProgress;
"cad-result": CadResult;
"cad-error": CadError;
"cad-image-analysis": CadImageAnalysis;
};
export type CadUIMessage = UIMessage<unknown, CadDataParts>;
@@ -46,7 +61,7 @@ export type ConversationRecord = {
export type CadAttachment = {
id: string;
task_id: string;
conversation_id: string;
name: string;
kind: "image" | "document";
path: string;
@@ -66,8 +81,6 @@ export type TaskRevision = {
parameters_path?: string;
selector_path?: string;
edges_path?: string;
design_intent_id?: string;
design_intent_path?: string;
summary?: string;
reference_ids?: string[];
engine?: string;
@@ -77,7 +90,6 @@ export type TaskRevision = {
export type TaskRecord = {
task_id: string;
current_revision: string;
current_design_intent_id?: string;
revisions: TaskRevision[];
};