优化agent
This commit is contained in:
+10
-7
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 "",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
-2
@@ -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 });
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user