Lk dev #2
+15
-7
@@ -1,19 +1,26 @@
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# Incremental generation uses a separate vision-capable model for checkpoint review.
|
||||
CDSL_REVIEW_PROVIDER=deepseek
|
||||
CDSL_REVIEW_MODEL=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,gpt-5.6-luna
|
||||
CDSL_OPENAI_VISION_MODELS=gpt-5.5,gpt-5.6-luna
|
||||
|
||||
# Optional Kimi provider.
|
||||
CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1
|
||||
@@ -24,8 +31,9 @@ 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
|
||||
CDSL_MAX_REPAIR_ATTEMPTS=4
|
||||
# To enable individual models instead of every model from a provider, keep the
|
||||
# provider-wide switch false and list exact IDs, for example:
|
||||
# CDSL_OPENAI_STRICT_TOOL_MODELS=gpt-4.1
|
||||
|
||||
@@ -9,3 +9,40 @@ The backend owns the application API and CAD generation workflow:
|
||||
- `tests/`: Engine, API, and end-to-end generation tests.
|
||||
|
||||
Expected development entrypoint: `app.main:app`, served by Uvicorn.
|
||||
|
||||
## Incremental Generation Configuration
|
||||
|
||||
Incremental generation is enabled by default. It requires a separately
|
||||
configured vision-capable review model and the Python OpenCascade/Pillow
|
||||
technical renderer; a run
|
||||
fails instead of skipping visual review when either is unavailable.
|
||||
|
||||
```dotenv
|
||||
# Authoring provider/model must already be configured as usual.
|
||||
CDSL_INCREMENTAL_GENERATION=1
|
||||
|
||||
# Must name one configured provider and one model listed in that provider's
|
||||
# CDSL_<PROVIDER>_VISION_MODELS setting. It is intentionally not inferred
|
||||
# from the authoring model.
|
||||
CDSL_REVIEW_PROVIDER=openai
|
||||
CDSL_REVIEW_MODEL=gpt-4.1-mini
|
||||
CDSL_OPENAI_VISION_MODELS=gpt-4.1-mini
|
||||
|
||||
# Install Python rendering dependencies. The renderer reads the revision STEP
|
||||
# file and creates canonical images without a browser or GPU driver.
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Optional per-node retry budgets.
|
||||
CDSL_NODE_AUTHORING_ATTEMPTS=2
|
||||
CDSL_NODE_REPAIR_ATTEMPTS=2
|
||||
CDSL_NODE_REPLAN_ATTEMPTS=1
|
||||
```
|
||||
|
||||
Every checkpoint is rebuilt from its fully materialized CDSL through the
|
||||
`cdsl_only` runtime. Checkpoint GLB files are preview-only; STEP, CDSL, and
|
||||
reports are available only after the task reaches `COMPLETED`.
|
||||
|
||||
The generation plan contains semantic node IDs only. The backend derives the
|
||||
unique CDSL feature and sketch IDs from each node, then writes them during
|
||||
fragment materialization. This keeps naming and topology ownership stable
|
||||
without requiring the authoring model to reproduce internal identifiers.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
- Build a valid seed hole first and preserve its host frame and functional subtype.
|
||||
- Use `pattern_linear` for one/two-dimensional rows and `pattern_mirror` for symmetry.
|
||||
- For circular layouts, use an explicit `circles` profile expansion and record the audit status; do not invent a circular-pattern atomic.
|
||||
- For circular layouts, use an explicit `circles` profile expansion; do not invent a circular-pattern atomic.
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
- Use for a circular flange, flanged sleeve, or coupling flange with a shared central axis.
|
||||
- Invariants: coaxial outside diameter, bore/hub, sealing land, and fastener holes on a pitch circle.
|
||||
- Plan: `revolve_add` for stepped axisymmetric bodies or circular `extrude_add_blind` -> coaxial bore -> one bolt-hole definition -> explicit circle set or supported repetition -> late finishing.
|
||||
- No circular-pattern atomic exists. Expand a bolt circle into explicit circles in one cut profile and record `expanded` in the audit.
|
||||
- No circular-pattern atomic exists. Express a bolt circle with explicit circles in one cut profile when the engine schema supports that profile.
|
||||
- Keep bolt-circle diameter, count, start angle, bore, and hub dimensions distinct.
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"exclude": ["flange", "法兰", "3d bracket", "mounting bracket", "轴承座"],
|
||||
"bridge": "bridge/planning-mounting-plate.md",
|
||||
"source": "source/planning/mounting-plate.planning.md",
|
||||
"capability_translation_rules": ["exact: schema_valid_feature_sequence"],
|
||||
"related": ["functional/mounting-plate-hole-layout", "functional/slotted-adjustment-feature", "functional/standard-hole-wizard", "functional/symmetric-feature-layout", "atomic/extrude-base-profile", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"]
|
||||
},
|
||||
{
|
||||
@@ -24,7 +23,6 @@
|
||||
"exclude": ["flat mounting plate only", "仅底板", "sheet metal"],
|
||||
"bridge": "bridge/planning-mounting-bracket.md",
|
||||
"source": "source/planning/mounting-bracket.planning.md",
|
||||
"capability_translation_rules": ["exact: schema_valid_feature_sequence"],
|
||||
"related": ["functional/slotted-adjustment-feature", "functional/standard-hole-wizard", "functional/symmetric-feature-layout", "atomic/extrude-base-profile", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"]
|
||||
},
|
||||
{
|
||||
@@ -36,7 +34,6 @@
|
||||
"exclude": ["flange nut", "法兰螺母", "side tab"],
|
||||
"bridge": "bridge/planning-flange.md",
|
||||
"source": "source/planning/flange.planning.md",
|
||||
"capability_translation_rules": ["expanded: circular_pattern_to_explicit_circles"],
|
||||
"related": ["functional/flange-bolt-circle", "functional/axisymmetric-revolve-strategy", "functional/standard-hole-wizard", "atomic/coaxial-bore-rule", "atomic/revolve-profile-around-axis", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"]
|
||||
},
|
||||
{
|
||||
@@ -48,7 +45,6 @@
|
||||
"exclude": ["spindle assembly", "bearing assembly", "gearbox shaft assembly", "主轴组件", "轴承组件"],
|
||||
"bridge": "bridge/planning-simple-shaft.md",
|
||||
"source": "source/planning/simple-shaft-or-cylindrical-rod.planning.md",
|
||||
"capability_translation_rules": ["exact: revolve_or_extrude_supported"],
|
||||
"related": ["functional/axisymmetric-revolve-strategy", "atomic/coaxial-bore-rule", "atomic/revolve-profile-around-axis", "atomic/through-hole-cut", "atomic/fillet-chamfer-last"]
|
||||
},
|
||||
{
|
||||
@@ -60,7 +56,6 @@
|
||||
"exclude": ["complete rolling bearing", "bearing assembly", "完整轴承", "轴承组件"],
|
||||
"bridge": "bridge/planning-bearing-housing.md",
|
||||
"source": "source/planning/bearing-housing-or-seat.planning.md",
|
||||
"capability_translation_rules": ["blocked: selector_required_for_dependent_features"],
|
||||
"related": ["functional/bearing-bore-seat", "functional/standard-hole-wizard", "atomic/coaxial-bore-rule", "atomic/extrude-base-profile", "atomic/revolve-profile-around-axis", "atomic/counterbored-hole-creation", "atomic/fillet-chamfer-last"]
|
||||
},
|
||||
{
|
||||
@@ -72,25 +67,24 @@
|
||||
"exclude": ["bolt", "screw", "wing nut", "cap nut", "flange nut", "螺栓", "螺钉", "蝶形螺母", "法兰螺母"],
|
||||
"bridge": "bridge/planning-hexagonal-nut.md",
|
||||
"source": "source/planning/hexagonal-nut.planning.md",
|
||||
"capability_translation_rules": ["approximated: thread_geometry_omitted"],
|
||||
"related": ["functional/axisymmetric-revolve-strategy", "functional/standard-hole-wizard", "atomic/extrude-base-profile", "atomic/coaxial-bore-rule", "atomic/threaded-hole-creation", "atomic/fillet-chamfer-last"]
|
||||
},
|
||||
|
||||
{"id": "functional/axisymmetric-revolve-strategy", "kind": "functional", "title": "Axisymmetric Revolve", "priority": 80, "triggers": ["axisymmetric", "revolve", "revolved body", "rotational part", "turned part", "回转体", "轴对称", "车削件"], "exclude": ["simple extruded cylinder", "rectangular plate"], "bridge": "bridge/functional-axisymmetric-revolve.md", "source": "source/functional/axisymmetric-revolve-strategy.functional.md", "capability_translation_rules": ["exact: explicit_revolve_axis"], "related": []},
|
||||
{"id": "functional/bearing-bore-seat", "kind": "functional", "title": "Bearing Bore Seat", "priority": 79, "triggers": ["bearing bore", "bearing pocket", "bearing seat", "bushing bore", "shaft clearance bore", "轴承孔", "轴承座孔", "轴承腔", "轴间隙孔"], "exclude": [], "bridge": "bridge/functional-bearing-bore-seat.md", "source": "source/functional/bearing-bore-seat.functional.md", "capability_translation_rules": ["blocked: selector_required_for_dependent_features"], "related": ["atomic/coaxial-bore-rule", "atomic/counterbored-hole-creation"]},
|
||||
{"id": "functional/flange-bolt-circle", "kind": "functional", "title": "Flange Bolt Circle", "priority": 78, "triggers": ["flange bolt circle", "bolt circle", "pitch circle", "pcd", "circular bolt pattern", "螺栓圆", "节圆", "圆周孔阵列"], "exclude": ["rectangular hole grid", "矩形孔阵列"], "bridge": "bridge/functional-flange-bolt-circle.md", "source": "source/functional/flange-bolt-circle.functional.md", "capability_translation_rules": ["expanded: circular_pattern_to_explicit_circles", "blocked: explicit_circle_layout_required"], "related": ["atomic/pattern-holes-from-datum"]},
|
||||
{"id": "functional/mounting-plate-hole-layout", "kind": "functional", "title": "Mounting Plate Hole Layout", "priority": 77, "triggers": ["mounting plate hole layout", "base plate hole pattern", "rectangular hole grid", "symmetric mounting holes", "底板孔布局", "矩形孔阵列", "安装孔阵列"], "exclude": ["flange bolt circle", "bearing bore"], "bridge": "bridge/functional-mounting-plate-hole-layout.md", "source": "source/functional/mounting-plate-hole-layout.functional.md", "capability_translation_rules": ["exact: linear_or_mirror_pattern"], "related": ["atomic/pattern-holes-from-datum", "functional/symmetric-feature-layout"]},
|
||||
{"id": "functional/slotted-adjustment-feature", "kind": "functional", "title": "Slotted Adjustment Feature", "priority": 76, "triggers": ["slot", "slotted hole", "adjustment slot", "elongated hole", "guide slot", "槽", "长圆槽", "调节槽", "导向槽"], "exclude": ["shaft keyway", "revolved groove", "轴键槽", "回转槽"], "bridge": "bridge/functional-slotted-adjustment.md", "source": "source/functional/slotted-adjustment-feature.functional.md", "capability_translation_rules": ["exact: obround_extrude_cut"], "related": []},
|
||||
{"id": "functional/standard-hole-wizard", "kind": "functional", "title": "Standard Hole Wizard", "priority": 75, "triggers": ["hole wizard", "standard holes", "threaded holes", "counterbored holes", "countersunk holes", "tapped holes", "孔向导", "标准孔", "螺纹孔", "沉孔", "沉头孔"], "exclude": [], "bridge": "bridge/functional-standard-hole-wizard.md", "source": "source/functional/standard-hole-wizard.functional.md", "capability_translation_rules": ["approximated: thread_geometry_omitted", "exact: supported_hole_subtype"], "related": ["atomic/counterbored-hole-creation", "atomic/threaded-hole-creation", "atomic/through-hole-cut"]},
|
||||
{"id": "functional/symmetric-feature-layout", "kind": "functional", "title": "Symmetric Feature Layout", "priority": 74, "triggers": ["symmetric holes", "mirror pattern", "symmetry", "mirrored features", "centerline layout", "对称孔", "镜像阵列", "对称布局", "中心线布局"], "exclude": [], "bridge": "bridge/functional-symmetric-layout.md", "source": "source/functional/symmetric-feature-layout.functional.md", "capability_translation_rules": ["exact: pattern_mirror_or_pattern_linear"], "related": []},
|
||||
{"id": "functional/axisymmetric-revolve-strategy", "kind": "functional", "title": "Axisymmetric Revolve", "priority": 80, "triggers": ["axisymmetric", "revolve", "revolved body", "rotational part", "turned part", "回转体", "轴对称", "车削件"], "exclude": ["simple extruded cylinder", "rectangular plate"], "bridge": "bridge/functional-axisymmetric-revolve.md", "source": "source/functional/axisymmetric-revolve-strategy.functional.md", "related": []},
|
||||
{"id": "functional/bearing-bore-seat", "kind": "functional", "title": "Bearing Bore Seat", "priority": 79, "triggers": ["bearing bore", "bearing pocket", "bearing seat", "bushing bore", "shaft clearance bore", "轴承孔", "轴承座孔", "轴承腔", "轴间隙孔"], "exclude": [], "bridge": "bridge/functional-bearing-bore-seat.md", "source": "source/functional/bearing-bore-seat.functional.md", "related": ["atomic/coaxial-bore-rule", "atomic/counterbored-hole-creation"]},
|
||||
{"id": "functional/flange-bolt-circle", "kind": "functional", "title": "Flange Bolt Circle", "priority": 78, "triggers": ["flange bolt circle", "bolt circle", "pitch circle", "pcd", "circular bolt pattern", "螺栓圆", "节圆", "圆周孔阵列"], "exclude": ["rectangular hole grid", "矩形孔阵列"], "bridge": "bridge/functional-flange-bolt-circle.md", "source": "source/functional/flange-bolt-circle.functional.md", "related": ["atomic/pattern-holes-from-datum"]},
|
||||
{"id": "functional/mounting-plate-hole-layout", "kind": "functional", "title": "Mounting Plate Hole Layout", "priority": 77, "triggers": ["mounting plate hole layout", "base plate hole pattern", "rectangular hole grid", "symmetric mounting holes", "底板孔布局", "矩形孔阵列", "安装孔阵列"], "exclude": ["flange bolt circle", "bearing bore"], "bridge": "bridge/functional-mounting-plate-hole-layout.md", "source": "source/functional/mounting-plate-hole-layout.functional.md", "related": ["atomic/pattern-holes-from-datum", "functional/symmetric-feature-layout"]},
|
||||
{"id": "functional/slotted-adjustment-feature", "kind": "functional", "title": "Slotted Adjustment Feature", "priority": 76, "triggers": ["slot", "slotted hole", "adjustment slot", "elongated hole", "guide slot", "槽", "长圆槽", "调节槽", "导向槽"], "exclude": ["shaft keyway", "revolved groove", "轴键槽", "回转槽"], "bridge": "bridge/functional-slotted-adjustment.md", "source": "source/functional/slotted-adjustment-feature.functional.md", "related": []},
|
||||
{"id": "functional/standard-hole-wizard", "kind": "functional", "title": "Standard Hole Wizard", "priority": 75, "triggers": ["hole wizard", "standard holes", "threaded holes", "counterbored holes", "countersunk holes", "tapped holes", "孔向导", "标准孔", "螺纹孔", "沉孔", "沉头孔"], "exclude": [], "bridge": "bridge/functional-standard-hole-wizard.md", "source": "source/functional/standard-hole-wizard.functional.md", "related": ["atomic/counterbored-hole-creation", "atomic/threaded-hole-creation", "atomic/through-hole-cut"]},
|
||||
{"id": "functional/symmetric-feature-layout", "kind": "functional", "title": "Symmetric Feature Layout", "priority": 74, "triggers": ["symmetric holes", "mirror pattern", "symmetry", "mirrored features", "centerline layout", "对称孔", "镜像阵列", "对称布局", "中心线布局"], "exclude": [], "bridge": "bridge/functional-symmetric-layout.md", "source": "source/functional/symmetric-feature-layout.functional.md", "related": []},
|
||||
|
||||
{"id": "atomic/coaxial-bore-rule", "kind": "atomic", "title": "Coaxial Bore", "priority": 60, "triggers": ["coaxial bore", "central bore", "concentric", "shaft clearance", "同轴孔", "中心孔", "同心", "轴间隙"], "exclude": [], "bridge": "bridge/atomic-coaxial-bore.md", "source": "source/atomic/coaxial-bore-rule.atomic.md", "capability_translation_rules": ["exact: explicit_axis_or_frame"], "related": []},
|
||||
{"id": "atomic/counterbored-hole-creation", "kind": "atomic", "title": "Counterbored Hole", "priority": 59, "triggers": ["counterbored hole", "counterbore", "socket head screw", "cap screw", "recessed screw head", "沉孔", "内六角螺钉", "螺钉头沉孔"], "exclude": [], "bridge": "bridge/atomic-counterbore.md", "source": "source/atomic/counterbored-hole-creation.atomic.md", "capability_translation_rules": ["exact: hole_counterbore"], "related": []},
|
||||
{"id": "atomic/extrude-base-profile", "kind": "atomic", "title": "Extrude Base Profile", "priority": 58, "triggers": ["extrude base profile", "extrude rectangle", "extrude circle", "base extrusion", "simple cylinder", "rectangular block", "基础拉伸", "矩形拉伸", "圆柱拉伸"], "exclude": [], "bridge": "bridge/atomic-extrude-base.md", "source": "source/atomic/extrude-base-profile.atomic.md", "capability_translation_rules": ["exact: extrude_add_blind"], "related": []},
|
||||
{"id": "atomic/fillet-chamfer-last", "kind": "atomic", "title": "Fillet And Chamfer Last", "priority": 57, "triggers": ["fillet chamfer last", "edge treatment", "chamfered edges", "filleted edges", "最后倒角", "最后圆角", "边缘处理"], "exclude": [], "bridge": "bridge/atomic-fillet-chamfer-last.md", "source": "source/atomic/fillet-chamfer-last.atomic.md", "capability_translation_rules": ["omitted: selector_unavailable", "exact: unique_selector"], "related": []},
|
||||
{"id": "atomic/pattern-holes-from-datum", "kind": "atomic", "title": "Pattern Holes From Datum", "priority": 56, "triggers": ["hole pattern", "pattern holes from datum", "repeated holes", "symmetric holes", "孔阵列", "重复孔", "基准孔阵列", "对称孔"], "exclude": [], "bridge": "bridge/atomic-pattern-holes.md", "source": "source/atomic/pattern-holes-from-datum.atomic.md", "capability_translation_rules": ["expanded: explicit_circle_layout", "exact: pattern_linear_or_pattern_mirror"], "related": []},
|
||||
{"id": "atomic/revolve-profile-around-axis", "kind": "atomic", "title": "Revolve Profile Around Axis", "priority": 55, "triggers": ["revolve profile", "revolve around axis", "revolved cut", "axisymmetric profile", "截面回转", "绕轴回转", "回转切除"], "exclude": [], "bridge": "bridge/atomic-revolve-profile.md", "source": "source/atomic/revolve-profile-around-axis.atomic.md", "capability_translation_rules": ["exact: revolve_add_or_revolve_cut"], "related": []},
|
||||
{"id": "atomic/threaded-hole-creation", "kind": "atomic", "title": "Threaded Hole", "priority": 54, "triggers": ["threaded hole", "tapped hole", "internal thread", "m3", "m4", "m5", "m6", "螺纹孔", "攻丝孔", "内螺纹"], "exclude": [], "bridge": "bridge/atomic-threaded-hole.md", "source": "source/atomic/threaded-hole-creation.atomic.md", "capability_translation_rules": ["approximated: thread_geometry_omitted"], "related": []},
|
||||
{"id": "atomic/through-hole-cut", "kind": "atomic", "title": "Through Hole Cut", "priority": 53, "triggers": ["through hole", "clearance hole", "central hole", "circular opening", "cut through", "bore through", "通孔", "间隙孔", "贯穿孔", "切穿"], "exclude": [], "bridge": "bridge/atomic-through-hole.md", "source": "source/atomic/through-hole-cut.atomic.md", "capability_translation_rules": ["exact: through_hole_or_explicit_cut"], "related": []}
|
||||
{"id": "atomic/coaxial-bore-rule", "kind": "atomic", "title": "Coaxial Bore", "priority": 60, "triggers": ["coaxial bore", "central bore", "concentric", "shaft clearance", "同轴孔", "中心孔", "同心", "轴间隙"], "exclude": [], "bridge": "bridge/atomic-coaxial-bore.md", "source": "source/atomic/coaxial-bore-rule.atomic.md", "related": []},
|
||||
{"id": "atomic/counterbored-hole-creation", "kind": "atomic", "title": "Counterbored Hole", "priority": 59, "triggers": ["counterbored hole", "counterbore", "socket head screw", "cap screw", "recessed screw head", "沉孔", "内六角螺钉", "螺钉头沉孔"], "exclude": [], "bridge": "bridge/atomic-counterbore.md", "source": "source/atomic/counterbored-hole-creation.atomic.md", "related": []},
|
||||
{"id": "atomic/extrude-base-profile", "kind": "atomic", "title": "Extrude Base Profile", "priority": 58, "triggers": ["extrude base profile", "extrude rectangle", "extrude circle", "base extrusion", "simple cylinder", "rectangular block", "基础拉伸", "矩形拉伸", "圆柱拉伸"], "exclude": [], "bridge": "bridge/atomic-extrude-base.md", "source": "source/atomic/extrude-base-profile.atomic.md", "related": []},
|
||||
{"id": "atomic/fillet-chamfer-last", "kind": "atomic", "title": "Fillet And Chamfer Last", "priority": 57, "triggers": ["fillet chamfer last", "edge treatment", "chamfered edges", "filleted edges", "最后倒角", "最后圆角", "边缘处理"], "exclude": [], "bridge": "bridge/atomic-fillet-chamfer-last.md", "source": "source/atomic/fillet-chamfer-last.atomic.md", "related": []},
|
||||
{"id": "atomic/pattern-holes-from-datum", "kind": "atomic", "title": "Pattern Holes From Datum", "priority": 56, "triggers": ["hole pattern", "pattern holes from datum", "repeated holes", "symmetric holes", "孔阵列", "重复孔", "基准孔阵列", "对称孔"], "exclude": [], "bridge": "bridge/atomic-pattern-holes.md", "source": "source/atomic/pattern-holes-from-datum.atomic.md", "related": []},
|
||||
{"id": "atomic/revolve-profile-around-axis", "kind": "atomic", "title": "Revolve Profile Around Axis", "priority": 55, "triggers": ["revolve profile", "revolve around axis", "revolved cut", "axisymmetric profile", "截面回转", "绕轴回转", "回转切除"], "exclude": [], "bridge": "bridge/atomic-revolve-profile.md", "source": "source/atomic/revolve-profile-around-axis.atomic.md", "related": []},
|
||||
{"id": "atomic/threaded-hole-creation", "kind": "atomic", "title": "Threaded Hole", "priority": 54, "triggers": ["threaded hole", "tapped hole", "internal thread", "m3", "m4", "m5", "m6", "螺纹孔", "攻丝孔", "内螺纹"], "exclude": [], "bridge": "bridge/atomic-threaded-hole.md", "source": "source/atomic/threaded-hole-creation.atomic.md", "related": []},
|
||||
{"id": "atomic/through-hole-cut", "kind": "atomic", "title": "Through Hole Cut", "priority": 53, "triggers": ["through hole", "clearance hole", "central hole", "circular opening", "cut through", "bore through", "通孔", "间隙孔", "贯穿孔", "切穿"], "exclude": [], "bridge": "bridge/atomic-through-hole.md", "source": "source/atomic/through-hole-cut.atomic.md", "related": []}
|
||||
]
|
||||
}
|
||||
|
||||
+224
-45
@@ -1,17 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
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
|
||||
from app.services.engine_service import apply_parameter_updates, build_revision
|
||||
from app.services.engine_service import QualityVerificationError, apply_parameter_updates, build_revision
|
||||
from app.services.agent_service import AgentService
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id
|
||||
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id, write_json
|
||||
from app.services.attachments import attachment_record, classify_upload, extract_document_text
|
||||
from app.services.image_processing import image_metadata
|
||||
from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status
|
||||
from app.services.visual_review import VisualReviewError, review_checkpoint
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
@@ -22,6 +27,99 @@ agent = AgentService(settings, store, library)
|
||||
app = FastAPI(title="CDSL CAD Agent API", version="0.1.0")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def resume_incremental_generation() -> None:
|
||||
"""Restore durable generation tasks after a backend process restart."""
|
||||
await agent.resume_running_tasks()
|
||||
|
||||
|
||||
async def _finalize_controlled_revision(
|
||||
*,
|
||||
task_id: str,
|
||||
previous_revision_id: str,
|
||||
node_id: str,
|
||||
built: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Publish a deterministic post-completion edit only after vision review."""
|
||||
revision_id = str(built["revision_id"])
|
||||
try:
|
||||
generation_spec = store.read_generation_spec(task_id) or {}
|
||||
requirements = generation_spec.get("requirements") if isinstance(generation_spec.get("requirements"), list) else []
|
||||
render_dir = store.revision_dir(task_id, revision_id) / "review"
|
||||
manifest = await asyncio.to_thread(
|
||||
render_checkpoint,
|
||||
settings,
|
||||
step_path=store.artifact_path(task_id, str(built["step_path"])),
|
||||
output_dir=render_dir,
|
||||
)
|
||||
review = await review_checkpoint(
|
||||
settings,
|
||||
manifest=manifest,
|
||||
requirements=requirements,
|
||||
node_id=node_id,
|
||||
deterministic_report={
|
||||
"quality_status": built.get("quality_status"),
|
||||
"verification": built.get("verification_summary", {}),
|
||||
},
|
||||
final_checkpoint=True,
|
||||
)
|
||||
manifest_path = (render_dir / "render-manifest.json").relative_to(store.task_dir(task_id)).as_posix()
|
||||
review_path = (render_dir / "visual-review.json").relative_to(store.task_dir(task_id)).as_posix()
|
||||
write_json(render_dir / "visual-review.json", review)
|
||||
store.update_revision_metadata(task_id, revision_id, {
|
||||
"render_manifest_path": manifest_path,
|
||||
"visual_review_path": review_path,
|
||||
})
|
||||
if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85:
|
||||
store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}")
|
||||
store.finish_generation(task_id, lifecycle="failed", failure={
|
||||
"schema_version": "cad.generation-failure.v1",
|
||||
"node_id": node_id,
|
||||
"stage": "visual_review",
|
||||
"error_code": "HIGH_CONFIDENCE_VISUAL_REPAIR",
|
||||
"message": "; ".join(review.get("evidence") or ["Visual review rejected the controlled edit"]),
|
||||
"recommended_rollback_revision": previous_revision_id,
|
||||
})
|
||||
raise ValueError("Visual review rejected this edit; the model was rolled back to its previous revision")
|
||||
store.finish_generation(task_id, lifecycle="completed")
|
||||
return {**built, "visibility": "final", "lifecycle": "completed", "checkpoint": False}
|
||||
except (ReviewRenderError, VisualReviewError, ValueError):
|
||||
task = store.read_task(task_id) or {}
|
||||
if str(task.get("lifecycle") or "") == "running":
|
||||
store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}")
|
||||
store.finish_generation(task_id, lifecycle="failed", failure={
|
||||
"schema_version": "cad.generation-failure.v1",
|
||||
"node_id": node_id,
|
||||
"stage": "visual_review",
|
||||
"message": "Controlled edit could not complete its required review",
|
||||
"recommended_rollback_revision": previous_revision_id,
|
||||
})
|
||||
raise
|
||||
|
||||
|
||||
def _require_controlled_review_configuration() -> None:
|
||||
"""Fail before a post-completion edit creates an unreviewed checkpoint."""
|
||||
settings.resolve_review_model()
|
||||
ready, detail = renderer_status()
|
||||
if not ready:
|
||||
raise ValueError(detail)
|
||||
|
||||
|
||||
def _fail_controlled_run(task_id: str, previous_revision_id: str, node_id: str, error: Exception) -> None:
|
||||
task = store.read_task(task_id) or {}
|
||||
if str(task.get("lifecycle") or "") != "running":
|
||||
return
|
||||
store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}")
|
||||
store.finish_generation(task_id, lifecycle="failed", failure={
|
||||
"schema_version": "cad.generation-failure.v1",
|
||||
"node_id": node_id,
|
||||
"stage": "controlled_build",
|
||||
"error_code": type(error).__name__.upper(),
|
||||
"message": str(error),
|
||||
"recommended_rollback_revision": previous_revision_id,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
return {
|
||||
@@ -50,6 +148,12 @@ async def config() -> dict[str, Any]:
|
||||
for model in provider.models
|
||||
],
|
||||
})
|
||||
try:
|
||||
settings.resolve_review_model()
|
||||
renderer_ready, renderer_detail = renderer_status()
|
||||
review_error = "" if renderer_ready else renderer_detail
|
||||
except ValueError as error:
|
||||
review_error = str(error)
|
||||
return {
|
||||
"default_provider": settings.default_provider_id,
|
||||
"default_model": settings.llm_model,
|
||||
@@ -57,6 +161,10 @@ async def config() -> dict[str, Any]:
|
||||
"model": settings.llm_model,
|
||||
"configured": settings.llm_configured,
|
||||
"library_samples": library.count(),
|
||||
"max_repair_attempts": settings.max_repair_attempts,
|
||||
"incremental_generation": settings.incremental_generation,
|
||||
"review_configured": not review_error,
|
||||
"review_error": review_error,
|
||||
}
|
||||
|
||||
|
||||
@@ -88,29 +196,38 @@ 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)
|
||||
current = store.read_conversation(conversation)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
active_task_id = str(current.get("current_task_id") or "")
|
||||
active_task = store.read_task(active_task_id) if active_task_id else None
|
||||
if str((active_task or {}).get("lifecycle") or "") == "running":
|
||||
raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state")
|
||||
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")
|
||||
metadata = image_metadata(data) if kind == "image" else {}
|
||||
record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path, metadata)
|
||||
store.add_conversation_attachment(conversation, record)
|
||||
return JSONResponse(record)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
@@ -124,33 +241,14 @@ async def read_task(task_id: str) -> JSONResponse:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
# Keep the task endpoint self-contained for a reconnecting UI. The plan is
|
||||
# immutable within a run and exposes node status, while previews always use
|
||||
# the active working revision rather than a downloadable artifact.
|
||||
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
|
||||
task["generation_plan"] = store.read_generation_spec(task["task_id"])
|
||||
return JSONResponse(task)
|
||||
|
||||
|
||||
@app.get("/v1/tasks/{task_id}/design-intent")
|
||||
async def read_current_design_intent(task_id: str) -> JSONResponse:
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
result = store.read_design_intent(safe_id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="No DesignIntent exists for this task")
|
||||
return JSONResponse({"task_id": safe_id, "intent_id": result["record"]["intent_id"], **result})
|
||||
|
||||
|
||||
@app.get("/v1/tasks/{task_id}/design-intents/{intent_id}")
|
||||
async def read_design_intent(task_id: str, intent_id: str) -> JSONResponse:
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
result = store.read_design_intent(safe_id, intent_id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="DesignIntent not found")
|
||||
return JSONResponse({"task_id": safe_id, "intent_id": result["record"]["intent_id"], **result})
|
||||
|
||||
|
||||
@app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}")
|
||||
async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -162,6 +260,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
task = store.read_task(safe_id) or {}
|
||||
parts = artifact_path.split("/")
|
||||
revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else ""
|
||||
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
|
||||
published_revision = str(task.get("published_revision") or "")
|
||||
active_revision = str(task.get("active_revision") or task.get("current_revision") or "")
|
||||
if isinstance(revision, dict) and revision_id != published_revision:
|
||||
# Revisions are private until publication. The currently active
|
||||
# checkpoint exposes only its GLB inline for the review viewer; an old
|
||||
# or superseded checkpoint has no public artifact surface at all.
|
||||
if revision_id != active_revision or path.name != "model.glb":
|
||||
raise HTTPException(status_code=403, detail="Only the published revision is downloadable")
|
||||
return FileResponse(path, media_type="model/gltf-binary", headers={"Content-Disposition": "inline"})
|
||||
return FileResponse(path, filename=path.name)
|
||||
|
||||
|
||||
@@ -172,6 +283,8 @@ async def read_parameters(task_id: str) -> JSONResponse:
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
task = store.read_task(safe_id)
|
||||
if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""):
|
||||
raise HTTPException(status_code=403, detail="Checkpoint parameters are not available until publication")
|
||||
revision_id = str((task or {}).get("current_revision") or "")
|
||||
revision = next((item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id), None)
|
||||
relative = str((revision or {}).get("parameters_path") or "")
|
||||
@@ -183,20 +296,55 @@ async def read_parameters(task_id: str) -> JSONResponse:
|
||||
return JSONResponse({"task_id": safe_id, "revision_id": revision_id, **json.loads(path.read_text(encoding="utf-8"))})
|
||||
|
||||
|
||||
@app.get("/v1/tasks/{task_id}/quality")
|
||||
async def read_quality(task_id: str) -> JSONResponse:
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
task = store.read_task(safe_id)
|
||||
if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""):
|
||||
raise HTTPException(status_code=403, detail="Checkpoint reports are not available until publication")
|
||||
revision_id = str((task or {}).get("current_revision") or "")
|
||||
revisions = (task or {}).get("revisions", [])
|
||||
revision = next((item for item in revisions if item.get("revision_id") == revision_id), None)
|
||||
if revisions and revisions[-1].get("revision_id") != revision_id:
|
||||
revision = revisions[-1]
|
||||
revision_id = str(revision.get("revision_id") or "")
|
||||
payload: dict[str, Any] = {
|
||||
"task_id": safe_id,
|
||||
"revision_id": revision_id,
|
||||
"quality_status": (revision or {}).get("quality_status", ""),
|
||||
"snapshot_status": (revision or {}).get("snapshot_status", "unavailable"),
|
||||
"snapshot_paths": (revision or {}).get("snapshot_paths", []),
|
||||
"assumptions": (revision or {}).get("generation_assumptions", []),
|
||||
"verification_summary": (revision or {}).get("verification_summary", {}),
|
||||
}
|
||||
relative = str((revision or {}).get("quality_path") or "")
|
||||
if relative:
|
||||
path = store.artifact_path(safe_id, relative)
|
||||
if path.is_file():
|
||||
payload["quality"] = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not revision:
|
||||
raise HTTPException(status_code=404, detail="Task has no revision")
|
||||
return JSONResponse(payload)
|
||||
|
||||
|
||||
@app.post("/v1/tasks/{task_id}/parameters")
|
||||
async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONResponse:
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
task = store.read_task(safe_id)
|
||||
if str((task or {}).get("lifecycle") or "") == "running":
|
||||
raise ValueError("CAD task is running; parameter changes are locked")
|
||||
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")
|
||||
updated, _ = apply_parameter_updates(json.loads(current_path.read_text(encoding="utf-8")), payload.values)
|
||||
if settings.incremental_generation:
|
||||
_require_controlled_review_configuration()
|
||||
store.start_generation(safe_id, request=f"Parameter update: {', '.join(payload.values)}")
|
||||
result = build_revision(
|
||||
settings=settings,
|
||||
store=store,
|
||||
@@ -209,12 +357,21 @@ 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 "",
|
||||
node_id="parameter_update" if settings.incremental_generation else "",
|
||||
branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main",
|
||||
visibility="checkpoint" if settings.incremental_generation else "final",
|
||||
)
|
||||
if settings.incremental_generation:
|
||||
result = await _finalize_controlled_revision(
|
||||
task_id=safe_id,
|
||||
previous_revision_id=current_revision_id,
|
||||
node_id="parameter_update",
|
||||
built=result,
|
||||
)
|
||||
return JSONResponse(result)
|
||||
except ValueError as error:
|
||||
except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error:
|
||||
if settings.incremental_generation and "safe_id" in locals() and "current_revision_id" in locals():
|
||||
_fail_controlled_run(safe_id, current_revision_id, "parameter_update", error)
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
|
||||
|
||||
@@ -223,7 +380,29 @@ async def modify_task(task_id: str, payload: ModifyRequest) -> JSONResponse:
|
||||
from app.services.editing import apply_direct_edit
|
||||
|
||||
try:
|
||||
result = apply_direct_edit(settings, store, safe_task_id(task_id), payload.operation, payload.selection, payload.parameters)
|
||||
safe_id = safe_task_id(task_id)
|
||||
task = store.read_task(safe_id) or {}
|
||||
if str(task.get("lifecycle") or "") == "running":
|
||||
raise ValueError("CAD task is running; topology edits are locked")
|
||||
previous_revision_id = str(task.get("current_revision") or "")
|
||||
if settings.incremental_generation:
|
||||
_require_controlled_review_configuration()
|
||||
store.start_generation(safe_id, request=f"Direct CDSL edit: {payload.operation}")
|
||||
result = apply_direct_edit(
|
||||
settings, store, safe_id, payload.operation, payload.selection, payload.parameters,
|
||||
node_id="topology_edit" if settings.incremental_generation else "",
|
||||
branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main",
|
||||
visibility="checkpoint" if settings.incremental_generation else "final",
|
||||
)
|
||||
if settings.incremental_generation:
|
||||
result = await _finalize_controlled_revision(
|
||||
task_id=safe_id,
|
||||
previous_revision_id=previous_revision_id,
|
||||
node_id="topology_edit",
|
||||
built=result,
|
||||
)
|
||||
return JSONResponse(result)
|
||||
except ValueError as error:
|
||||
except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error:
|
||||
if settings.incremental_generation and "safe_id" in locals() and "previous_revision_id" in locals():
|
||||
_fail_controlled_run(safe_id, previous_revision_id, "topology_edit", error)
|
||||
raise HTTPException(status_code=400, detail=str(error)) from 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,14 @@ 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
|
||||
topology_path: str | None = None
|
||||
summary: str
|
||||
reference_ids: list[str] = Field(default_factory=list)
|
||||
engine: str = "cdsl_only"
|
||||
quality_status: str = ""
|
||||
quality_path: str | None = None
|
||||
assumptions: list[str] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
repair_attempts: int = 0
|
||||
snapshot_paths: list[str] = Field(default_factory=list)
|
||||
snapshot_status: str = "unavailable"
|
||||
|
||||
+1910
-236
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@ import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json"}
|
||||
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
|
||||
DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json", ".pdf", ".dxf", ".step", ".stp"}
|
||||
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
MAX_DOCUMENT_BYTES = 2 * 1024 * 1024
|
||||
MAX_EXTRACTED_CHARS = 30_000
|
||||
@@ -13,8 +13,6 @@ MAX_EXTRACTED_CHARS = 30_000
|
||||
|
||||
def classify_upload(filename: str, mime: str, size: int) -> str:
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix in {".step", ".stp"}:
|
||||
raise ValueError("STEP/STP upload is not supported in this version")
|
||||
if suffix in IMAGE_SUFFIXES or mime.startswith("image/"):
|
||||
if size > MAX_IMAGE_BYTES:
|
||||
raise ValueError("Image upload exceeds the 10 MB limit")
|
||||
@@ -23,21 +21,40 @@ def classify_upload(filename: str, mime: str, size: int) -> str:
|
||||
if size > MAX_DOCUMENT_BYTES:
|
||||
raise ValueError("Document upload exceeds the 2 MB limit")
|
||||
return "document"
|
||||
raise ValueError("Only PNG, JPG, WEBP, TXT, MD, CSV, and JSON uploads are supported")
|
||||
raise ValueError("Only PNG, JPG, WEBP, SVG, TXT, MD, CSV, JSON, PDF, DXF, and STEP/STP uploads are supported")
|
||||
|
||||
|
||||
def extract_document_text(data: bytes) -> str:
|
||||
# PDF extraction is optional so the API can still accept a reference when
|
||||
# the local PDF dependency is unavailable; the binary remains available as
|
||||
# the primary attachment artifact.
|
||||
if data.startswith(b"%PDF"):
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
import io
|
||||
return "\n".join(page.extract_text() or "" for page in PdfReader(io.BytesIO(data)).pages)[:MAX_EXTRACTED_CHARS]
|
||||
except Exception:
|
||||
return "[PDF reference uploaded; text extraction unavailable]"
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValueError("Documents must be UTF-8 text") from error
|
||||
return "[Binary CAD reference uploaded; structured geometry inspection is deferred to the CAD reference analyzer]"
|
||||
return text[:MAX_EXTRACTED_CHARS]
|
||||
|
||||
|
||||
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 = "",
|
||||
metadata: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"id": Path(relative_path).stem,
|
||||
"task_id": task_id,
|
||||
"conversation_id": conversation_id,
|
||||
"name": filename,
|
||||
"kind": kind,
|
||||
"path": relative_path,
|
||||
@@ -45,4 +62,5 @@ def attachment_record(task_id: str, filename: str, mime: str, relative_path: str
|
||||
"size": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"extracted_path": extracted_path,
|
||||
**(metadata or {}),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Controlled CDSL fragments; the backend, never string concatenation, materialises a model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.services.generation_plan import GenerationPlanError
|
||||
|
||||
|
||||
class CdslFragmentError(ValueError):
|
||||
"""A node fragment cannot be applied safely to its declared base revision."""
|
||||
|
||||
|
||||
def cdsl_sha256(cdsl: dict[str, Any] | None) -> str:
|
||||
value = cdsl or {"geometry": {"sketches": []}, "features": []}
|
||||
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _items(value: Any, field: str) -> list[dict[str, Any]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
||||
raise CdslFragmentError(f"{field} must be an array of objects")
|
||||
return deepcopy(value)
|
||||
|
||||
|
||||
def _ids(items: list[dict[str, Any]], field: str) -> list[str]:
|
||||
values = [str(item.get("id") or "").strip() for item in items]
|
||||
if not all(values) or len(values) != len(set(values)):
|
||||
raise CdslFragmentError(f"{field} must have unique non-empty ids")
|
||||
return values
|
||||
|
||||
|
||||
def _node(plan: dict[str, Any], node_id: str) -> dict[str, Any]:
|
||||
node = next((item for item in plan.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None)
|
||||
if node is None:
|
||||
raise CdslFragmentError(f"Fragment references an unknown node: {node_id}")
|
||||
return node
|
||||
|
||||
|
||||
def _single_output_id(node: dict[str, Any], field: str) -> str:
|
||||
values = [str(item) for item in node.get(field) or () if str(item)]
|
||||
if len(values) != 1:
|
||||
raise CdslFragmentError(f"Node {node.get('id')} must declare exactly one {field} output")
|
||||
return values[0]
|
||||
|
||||
|
||||
def _node_feature_id(plan: dict[str, Any], node_id: str) -> str:
|
||||
return _single_output_id(_node(plan, node_id), "cdsl_feature_ids")
|
||||
|
||||
|
||||
def _materialize_node_references(value: Any, plan: dict[str, Any]) -> Any:
|
||||
"""Translate fragment-only node references into CDSL feature references."""
|
||||
if isinstance(value, list):
|
||||
return [_materialize_node_references(item, plan) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
materialized = {key: _materialize_node_references(item, plan) for key, item in value.items()}
|
||||
owner_node_id = materialized.pop("owner_node_id", None)
|
||||
if owner_node_id is not None:
|
||||
if not isinstance(owner_node_id, str) or not owner_node_id.strip():
|
||||
raise CdslFragmentError("owner_node_id must be a non-empty plan node id")
|
||||
materialized["owner_feature_id"] = _node_feature_id(plan, owner_node_id.strip())
|
||||
source_node_ids = materialized.pop("source_node_ids", None)
|
||||
if source_node_ids is not None:
|
||||
if not isinstance(source_node_ids, list) or not source_node_ids or not all(isinstance(item, str) and item.strip() for item in source_node_ids):
|
||||
raise CdslFragmentError("source_node_ids must be a non-empty array of plan node ids")
|
||||
materialized["source_feature_ids"] = [_node_feature_id(plan, item.strip()) for item in source_node_ids]
|
||||
return materialized
|
||||
|
||||
|
||||
def _selector_references(value: Any) -> list[dict[str, Any]]:
|
||||
"""Collect selector-shaped objects from feature selectors and params."""
|
||||
found: list[dict[str, Any]] = []
|
||||
if isinstance(value, dict):
|
||||
if "kind" in value and ("stable_id" in value or "snapshot_id" in value or "owner_feature_id" in value or "owner_node_id" in value):
|
||||
found.append(value)
|
||||
for child in value.values():
|
||||
found.extend(_selector_references(child))
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
found.extend(_selector_references(child))
|
||||
return found
|
||||
|
||||
|
||||
def validate_fragment(
|
||||
fragment: dict[str, Any],
|
||||
*,
|
||||
plan: dict[str, Any],
|
||||
node_id: str,
|
||||
base_revision_id: str,
|
||||
base_cdsl: dict[str, Any] | None,
|
||||
required_snapshot_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(fragment, dict):
|
||||
raise CdslFragmentError("CDSL fragment must be an object")
|
||||
version = str(fragment.get("schema_version") or "cad.cdsl-fragment.v1")
|
||||
if version != "cad.cdsl-fragment.v1":
|
||||
raise CdslFragmentError(f"Unsupported CDSL fragment schema: {version}")
|
||||
declared_node_id = str(fragment.get("node_id") or "")
|
||||
if declared_node_id != node_id:
|
||||
raise CdslFragmentError("Fragment node_id does not match the active node")
|
||||
if str(fragment.get("base_revision_id") or "") != base_revision_id:
|
||||
raise CdslFragmentError("Fragment base_revision_id does not match the active revision")
|
||||
if str(fragment.get("base_cdsl_sha256") or "") != cdsl_sha256(base_cdsl):
|
||||
raise CdslFragmentError("Fragment base_cdsl_sha256 does not match the active CDSL")
|
||||
snapshot_id = str(fragment.get("required_snapshot_id") or "")
|
||||
if required_snapshot_id and snapshot_id != required_snapshot_id:
|
||||
raise CdslFragmentError("Fragment required_snapshot_id does not match the active topology snapshot")
|
||||
if not required_snapshot_id and snapshot_id:
|
||||
raise CdslFragmentError("Fragment cannot use a topology snapshot before one exists")
|
||||
sketches = _items(fragment.get("add_sketches"), "add_sketches")
|
||||
features = _items(fragment.get("add_features"), "add_features")
|
||||
node = _node(plan, node_id)
|
||||
expected = {str(item) for item in node.get("cdsl_feature_ids") or ()}
|
||||
expected_sketches = {str(item) for item in node.get("cdsl_sketch_ids") or ()}
|
||||
if len(expected) != 1:
|
||||
# Compatibility path for pre-incremental plans which could own more
|
||||
# than one CDSL feature in a single node.
|
||||
feature_ids = _ids(features, "add_features")
|
||||
if set(feature_ids) != expected:
|
||||
raise CdslFragmentError(
|
||||
f"Fragment features must exactly match node outputs: expected {sorted(expected)}, got {sorted(feature_ids)}"
|
||||
)
|
||||
elif len(features) != 1:
|
||||
raise CdslFragmentError("An atomic plan node must generate exactly one feature")
|
||||
if len(expected_sketches) > 1:
|
||||
sketch_ids = _ids(sketches, "add_sketches")
|
||||
if set(sketch_ids) != expected_sketches:
|
||||
raise CdslFragmentError(
|
||||
f"Fragment sketches must exactly match node outputs: expected {sorted(expected_sketches)}, got {sorted(sketch_ids)}"
|
||||
)
|
||||
elif len(sketches) != len(expected_sketches):
|
||||
expected_description = "one" if expected_sketches else "no"
|
||||
raise CdslFragmentError(f"This node requires {expected_description} new sketch")
|
||||
|
||||
# The planner and fragment author never own CDSL object identities. The
|
||||
# merger writes node-derived IDs and dependencies after it has validated
|
||||
# the current immutable plan.
|
||||
if len(expected) == 1:
|
||||
features[0]["id"] = next(iter(expected))
|
||||
if len(expected_sketches) == 1:
|
||||
sketch_id = next(iter(expected_sketches))
|
||||
sketches[0]["id"] = sketch_id
|
||||
features[0]["sketch_id"] = sketch_id
|
||||
elif not expected_sketches and len(features) == 1:
|
||||
features[0].pop("sketch_id", None)
|
||||
features = _materialize_node_references(features, plan)
|
||||
sketches = _materialize_node_references(sketches, plan)
|
||||
sketch_ids = _ids(sketches, "add_sketches")
|
||||
feature_ids = _ids(features, "add_features")
|
||||
atomic_id = str(node.get("atomic_id") or "")
|
||||
if len(expected) == 1:
|
||||
features[0]["atomic_id"] = atomic_id
|
||||
elif any(str(feature.get("atomic_id") or "") != atomic_id for feature in features):
|
||||
raise CdslFragmentError(f"Fragment features must use plan atomic_id {atomic_id}")
|
||||
base_sketch_ids = {
|
||||
str(item.get("id")) for item in ((base_cdsl or {}).get("geometry") or {}).get("sketches") or ()
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
base_feature_ids = {
|
||||
str(item.get("id")) for item in (base_cdsl or {}).get("features") or () if isinstance(item, dict)
|
||||
}
|
||||
if base_sketch_ids & set(sketch_ids):
|
||||
raise CdslFragmentError("Fragment attempts to overwrite an existing sketch")
|
||||
if base_feature_ids & set(feature_ids):
|
||||
raise CdslFragmentError("Fragment attempts to overwrite a frozen feature")
|
||||
predecessor_features = {
|
||||
feature_id
|
||||
for dependency in node.get("depends_on") or ()
|
||||
for feature_id in (_node(plan, str(dependency)).get("cdsl_feature_ids") or ())
|
||||
}
|
||||
if len(expected) == 1:
|
||||
features[0]["depends_on"] = sorted(predecessor_features)
|
||||
available_features = base_feature_ids | set(feature_ids)
|
||||
available_sketches = base_sketch_ids | set(sketch_ids)
|
||||
for feature in features:
|
||||
feature_id = str(feature["id"])
|
||||
dependencies = feature.get("depends_on") or []
|
||||
if not isinstance(dependencies, list) or any(str(item) not in available_features for item in dependencies):
|
||||
raise CdslFragmentError(f"Feature {feature_id} has a missing dependency")
|
||||
sketch_id = str(feature.get("sketch_id") or "")
|
||||
if sketch_id and sketch_id not in available_sketches:
|
||||
raise CdslFragmentError(f"Feature {feature_id} references an unknown sketch")
|
||||
if required_snapshot_id:
|
||||
selectors = [selector for feature in features for selector in _selector_references(feature)]
|
||||
if not selectors:
|
||||
raise CdslFragmentError("Topology-dependent fragment must provide runtime snapshot selectors")
|
||||
for selector in selectors:
|
||||
if str(selector.get("snapshot_id") or "") != required_snapshot_id:
|
||||
raise CdslFragmentError("Topology selector must reference the active snapshot")
|
||||
if not str(selector.get("owner_feature_id") or ""):
|
||||
raise CdslFragmentError("Topology selector must declare owner_feature_id")
|
||||
if not isinstance(selector.get("geometry"), dict) or not selector["geometry"]:
|
||||
raise CdslFragmentError("Topology selector must declare a non-empty geometry signature")
|
||||
declared_dependencies = {
|
||||
str(dependency)
|
||||
for feature in features
|
||||
for dependency in feature.get("depends_on") or ()
|
||||
}
|
||||
if not predecessor_features.issubset(declared_dependencies):
|
||||
raise CdslFragmentError("Fragment does not preserve all plan-node dependencies")
|
||||
rules = fragment.get("verification_rules") or []
|
||||
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
|
||||
raise CdslFragmentError("verification_rules must be an array of objects")
|
||||
assumptions = fragment.get("assumptions") or []
|
||||
if not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions):
|
||||
raise CdslFragmentError("assumptions must be an array of strings")
|
||||
return {
|
||||
"schema_version": "cad.cdsl-fragment.v1",
|
||||
"node_id": node_id,
|
||||
"base_revision_id": base_revision_id,
|
||||
"base_cdsl_sha256": cdsl_sha256(base_cdsl),
|
||||
"required_snapshot_id": snapshot_id,
|
||||
"add_sketches": sketches,
|
||||
"add_features": features,
|
||||
"expected_feature_ids": sorted(expected),
|
||||
"verification_rules": deepcopy(rules),
|
||||
"assumptions": [item.strip() for item in assumptions if item.strip()],
|
||||
}
|
||||
|
||||
|
||||
def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the complete, append-only document that the runtime must rebuild."""
|
||||
if base_cdsl is None:
|
||||
document: dict[str, Any] = {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.1.0",
|
||||
"kind": "part",
|
||||
"part_id": "agent_preflight",
|
||||
"geometry": {"sketches": []},
|
||||
"features": [],
|
||||
}
|
||||
else:
|
||||
document = deepcopy(base_cdsl)
|
||||
geometry = document.setdefault("geometry", {})
|
||||
if not isinstance(geometry, dict):
|
||||
raise CdslFragmentError("Base CDSL geometry must be an object")
|
||||
sketches = geometry.setdefault("sketches", [])
|
||||
features = document.setdefault("features", [])
|
||||
if not isinstance(sketches, list) or not isinstance(features, list):
|
||||
raise CdslFragmentError("Base CDSL has invalid collections")
|
||||
sketches.extend(deepcopy(fragment["add_sketches"]))
|
||||
features.extend(deepcopy(fragment["add_features"]))
|
||||
return document
|
||||
|
||||
|
||||
def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_id: str) -> dict[str, Any]:
|
||||
"""Persist the runtime's actual selector choices as auditable node evidence."""
|
||||
values = []
|
||||
for resolution in engine_result.get("selector_resolution") or ():
|
||||
if not isinstance(resolution, dict):
|
||||
continue
|
||||
selector = resolution.get("selector") if isinstance(resolution.get("selector"), dict) else {}
|
||||
candidates = resolution.get("candidates") if isinstance(resolution.get("candidates"), (list, tuple)) else []
|
||||
values.append({
|
||||
"consumer_node_id": node_id,
|
||||
"consumer_feature_id": str(resolution.get("feature_id") or ""),
|
||||
"source_snapshot_id": str(selector.get("snapshot_id") or snapshot_id),
|
||||
"kind": str(selector.get("kind") or ""),
|
||||
"owner_feature_id": str(selector.get("owner_feature_id") or ""),
|
||||
"stable_id": str(selector.get("stable_id") or ""),
|
||||
"geometry": deepcopy(selector.get("geometry") or {}),
|
||||
"status": str(resolution.get("status") or ""),
|
||||
"candidates": deepcopy(list(candidates)),
|
||||
"score": resolution.get("score"),
|
||||
"selected": deepcopy(resolution.get("selected") or resolution.get("record") or {}),
|
||||
})
|
||||
return {"schema_version": "cad.selector-bindings.v1", "node_id": node_id, "bindings": values}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Small, dependency-free RFC 6902 JSON Patch implementation for CDSL revisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
|
||||
class CdslPatchError(ValueError):
|
||||
"""A JSON Patch operation cannot be applied to the requested CDSL revision."""
|
||||
|
||||
|
||||
def _tokens(path: Any) -> list[str]:
|
||||
if not isinstance(path, str) or not path.startswith("/"):
|
||||
raise CdslPatchError("JSON Patch paths must be JSON Pointers beginning with '/'")
|
||||
return [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")]
|
||||
|
||||
|
||||
def _index(token: str, length: int, *, allow_append: bool = False) -> int:
|
||||
if allow_append and token == "-":
|
||||
return length
|
||||
if not token.isdigit() or (len(token) > 1 and token.startswith("0")):
|
||||
raise CdslPatchError(f"Invalid JSON Patch array index: {token}")
|
||||
value = int(token)
|
||||
if value < 0 or value >= length:
|
||||
raise CdslPatchError(f"JSON Patch array index is out of range: {token}")
|
||||
return value
|
||||
|
||||
|
||||
def _parent(document: Any, path: str) -> tuple[Any, str]:
|
||||
if path == "":
|
||||
raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead")
|
||||
tokens = _tokens(path)
|
||||
current = document
|
||||
for token in tokens[:-1]:
|
||||
if isinstance(current, dict):
|
||||
if token not in current:
|
||||
raise CdslPatchError(f"JSON Patch path does not exist: {path}")
|
||||
current = current[token]
|
||||
elif isinstance(current, list):
|
||||
current = current[_index(token, len(current))]
|
||||
else:
|
||||
raise CdslPatchError(f"JSON Patch path does not resolve to a container: {path}")
|
||||
return current, tokens[-1]
|
||||
|
||||
|
||||
def _get(document: Any, path: str) -> Any:
|
||||
current = document
|
||||
for token in _tokens(path):
|
||||
if isinstance(current, dict):
|
||||
if token not in current:
|
||||
raise CdslPatchError(f"JSON Patch path does not exist: {path}")
|
||||
current = current[token]
|
||||
elif isinstance(current, list):
|
||||
current = current[_index(token, len(current))]
|
||||
else:
|
||||
raise CdslPatchError(f"JSON Patch path does not resolve: {path}")
|
||||
return current
|
||||
|
||||
|
||||
def _add(document: Any, path: str, value: Any) -> None:
|
||||
parent, token = _parent(document, path)
|
||||
if isinstance(parent, dict):
|
||||
parent[token] = deepcopy(value)
|
||||
elif isinstance(parent, list):
|
||||
index = _index(token, len(parent), allow_append=True)
|
||||
parent.insert(index, deepcopy(value))
|
||||
else:
|
||||
raise CdslPatchError(f"JSON Patch add target is not a container: {path}")
|
||||
|
||||
|
||||
def _remove(document: Any, path: str) -> Any:
|
||||
parent, token = _parent(document, path)
|
||||
if isinstance(parent, dict):
|
||||
if token not in parent:
|
||||
raise CdslPatchError(f"JSON Patch path does not exist: {path}")
|
||||
return parent.pop(token)
|
||||
if isinstance(parent, list):
|
||||
return parent.pop(_index(token, len(parent)))
|
||||
raise CdslPatchError(f"JSON Patch remove target is not a container: {path}")
|
||||
|
||||
|
||||
def apply_cdsl_patch(cdsl: dict[str, Any], patches: Any) -> dict[str, Any]:
|
||||
if not isinstance(cdsl, dict):
|
||||
raise CdslPatchError("The base CDSL must be an object")
|
||||
if not isinstance(patches, list) or not patches:
|
||||
raise CdslPatchError("patches must be a non-empty JSON Patch array")
|
||||
if len(patches) > 32:
|
||||
raise CdslPatchError("At most 32 JSON Patch operations are allowed")
|
||||
result: Any = deepcopy(cdsl)
|
||||
for index, operation in enumerate(patches):
|
||||
if not isinstance(operation, dict):
|
||||
raise CdslPatchError(f"Patch operation {index} must be an object")
|
||||
kind = str(operation.get("op") or "")
|
||||
path = operation.get("path")
|
||||
if kind not in {"add", "remove", "replace", "move", "copy", "test"}:
|
||||
raise CdslPatchError(f"Unsupported JSON Patch operation: {kind or '<missing>'}")
|
||||
if kind in {"add", "replace", "test"} and "value" not in operation:
|
||||
raise CdslPatchError(f"JSON Patch {kind} requires value")
|
||||
if kind in {"move", "copy"} and "from" not in operation:
|
||||
raise CdslPatchError(f"JSON Patch {kind} requires from")
|
||||
if path == "":
|
||||
raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead")
|
||||
if kind == "add":
|
||||
_add(result, path, operation["value"])
|
||||
elif kind == "remove":
|
||||
_remove(result, path)
|
||||
elif kind == "replace":
|
||||
_get(result, path)
|
||||
_remove(result, path)
|
||||
_add(result, path, operation["value"])
|
||||
elif kind == "move":
|
||||
source = str(operation["from"])
|
||||
if path == source or str(path).startswith(source.rstrip("/") + "/"):
|
||||
raise CdslPatchError("JSON Patch move cannot move a value into itself")
|
||||
moved = _remove(result, source)
|
||||
_add(result, path, moved)
|
||||
elif kind == "copy":
|
||||
_add(result, path, _get(result, str(operation["from"])))
|
||||
elif kind == "test" and _get(result, path) != operation["value"]:
|
||||
raise CdslPatchError(f"JSON Patch test failed at {path}")
|
||||
return result
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from app.services.engine_service import build_revision
|
||||
from app.services.engine_service import build_revision, load_engine
|
||||
from app.services.storage import WorkspaceStore
|
||||
from app.settings import Settings
|
||||
|
||||
@@ -59,22 +59,35 @@ def _profile_for(operation: str, values: dict[str, Any]) -> tuple[dict[str, Any]
|
||||
if operation == "add_slot":
|
||||
width = _number(values, "slotWidth", values.get("width", 8.0))
|
||||
length = _number(values, "slotLength", values.get("length", width * 3))
|
||||
return {"type": "obround", "center": [0.0, 0.0], "length_mm": max(length, width), "width_mm": width}, depth
|
||||
length = max(length, width)
|
||||
radius = width / 2
|
||||
left, right = -length / 2 + radius, length / 2 - radius
|
||||
return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [
|
||||
{"type": "line", "start": [right, radius], "end": [left, radius]},
|
||||
{"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0.0], "radius_mm": radius},
|
||||
{"type": "line", "start": [left, -radius], "end": [right, -radius]},
|
||||
{"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0.0], "radius_mm": radius},
|
||||
]}]}, depth
|
||||
if operation == "add_pocket":
|
||||
width = _number(values, "width", 20.0)
|
||||
height = _number(values, "height", 12.0)
|
||||
return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height}, depth
|
||||
return {"type": "polygon", "vertices": [
|
||||
[-width / 2, -height / 2], [width / 2, -height / 2],
|
||||
[width / 2, height / 2], [-width / 2, height / 2],
|
||||
]}, depth
|
||||
if operation == "add_hole_pattern":
|
||||
diameter = _number(values, "holeDiameter", values.get("diameter", 6.0))
|
||||
rows = max(1, int(_number(values, "rows", 2, 1)))
|
||||
columns = max(1, int(_number(values, "columns", 2, 1)))
|
||||
return {
|
||||
"type": "circle_grid", "radius_mm": diameter / 2,
|
||||
"count_x": columns, "count_y": rows,
|
||||
"spacing_x_mm": _number(values, "pitchX", 12.0),
|
||||
"spacing_y_mm": _number(values, "pitchY", 12.0),
|
||||
"center_mm": [0.0, 0.0],
|
||||
}, depth
|
||||
pitch_x = _number(values, "pitchX", 12.0)
|
||||
pitch_y = _number(values, "pitchY", 12.0)
|
||||
return {"type": "analytic_contours", "contours": [
|
||||
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [
|
||||
(column - (columns - 1) / 2) * pitch_x,
|
||||
(row - (rows - 1) / 2) * pitch_y,
|
||||
], "radius_mm": diameter / 2}]}
|
||||
for row in range(rows) for column in range(columns)
|
||||
]}, depth
|
||||
raise ValueError(f"Unsupported direct CDSL edit: {operation}")
|
||||
|
||||
|
||||
@@ -137,6 +150,10 @@ def apply_direct_edit(
|
||||
operation: str,
|
||||
selection: dict[str, Any],
|
||||
parameters: dict[str, Any],
|
||||
*,
|
||||
node_id: str = "",
|
||||
branch_id: str = "main",
|
||||
visibility: str = "final",
|
||||
) -> dict[str, Any]:
|
||||
if operation in {"add_chamfer", "add_fillet"}:
|
||||
raise ValueError("Chamfer and fillet require a stable CDSL edge anchor and are not available for this model yet")
|
||||
@@ -147,12 +164,17 @@ 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")))
|
||||
# Historical revisions can contain importer-only profile macros. An edit
|
||||
# creates a new CDSL-only revision, so lower those profiles explicitly
|
||||
# before adding the new generic feature.
|
||||
load_engine(settings)
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
|
||||
lowered_cdsl = lower_legacy_profiles(cdsl)
|
||||
legacy_profiles_lowered = lowered_cdsl != cdsl
|
||||
cdsl = lowered_cdsl
|
||||
features = cdsl.setdefault("features", [])
|
||||
sketches = cdsl.setdefault("geometry", {}).setdefault("sketches", [])
|
||||
picks = selection.get("picks") if isinstance(selection.get("picks"), list) else []
|
||||
@@ -187,10 +209,15 @@ def apply_direct_edit(
|
||||
reference_ids=[],
|
||||
summary=f"Applied {operation}",
|
||||
parent_revision_id=revision_id,
|
||||
operation={"type": operation, "selection": selection, "parameters": parameters},
|
||||
operation={
|
||||
"type": operation,
|
||||
"selection": selection,
|
||||
"parameters": parameters,
|
||||
"legacy_profiles_lowered": legacy_profiles_lowered,
|
||||
},
|
||||
part_skills=None,
|
||||
generation_assumptions=[],
|
||||
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 "",
|
||||
generation_assumptions=["Legacy profile macros were lowered to direct analytic contours before this edit."] if legacy_profiles_lowered else [],
|
||||
node_id=node_id,
|
||||
branch_id=branch_id,
|
||||
visibility=visibility,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import copy
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -12,10 +13,22 @@ from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import SchemaError
|
||||
|
||||
from vendor.cdsl_preview_runtime import step_to_glb
|
||||
from app.services.quality import evaluate_quality, validate_verification
|
||||
from app.services.storage import WorkspaceStore, now_iso, write_json
|
||||
from app.services.cdsl_fragment import selector_bindings
|
||||
from app.settings import Settings
|
||||
|
||||
|
||||
class QualityVerificationError(RuntimeError):
|
||||
"""A built CDSL document missed a blocking generic verification rule."""
|
||||
|
||||
def __init__(self, quality_report: dict[str, Any]) -> None:
|
||||
super().__init__("Blocking CDSL verification checks failed")
|
||||
self.quality_report = quality_report
|
||||
self.task_id = ""
|
||||
self.revision_id = ""
|
||||
|
||||
|
||||
def load_engine(settings: Settings) -> Any:
|
||||
parent = str(settings.engine_root.parent)
|
||||
if parent not in sys.path:
|
||||
@@ -75,6 +88,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")
|
||||
@@ -95,10 +178,12 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
|
||||
semantic_contract = _engine_schema(engine)
|
||||
atomic_contracts = semantic_contract["feature_atomic_ids"]
|
||||
supported_atomic_ids = sorted(
|
||||
declared_atomic_ids = {
|
||||
str(atomic_id)
|
||||
for atomic_id in semantic_contract.get("runtime_supported_atomic_ids", atomic_contracts)
|
||||
)
|
||||
}
|
||||
registered_atomic_ids = {str(atomic_id) for atomic_id in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())}
|
||||
supported_atomic_ids = sorted(declared_atomic_ids & registered_atomic_ids)
|
||||
feature_ids: set[str] = set()
|
||||
for feature in features:
|
||||
fid = str(feature.get("id") or "")
|
||||
@@ -215,7 +300,140 @@ def parameter_contract(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"schema_version": "1.0", "parameters": _derived_parameters(cdsl), "source": "derived"}
|
||||
|
||||
|
||||
def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
def topology_snapshot(
|
||||
engine_result: dict[str, Any],
|
||||
*,
|
||||
task_id: str = "",
|
||||
revision_id: str = "",
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
raw_records = [
|
||||
raw for raw in engine_result.get("topology_records") or ()
|
||||
if isinstance(raw, dict) and raw.get("record_id") and raw.get("kind")
|
||||
]
|
||||
active_body_id = next(
|
||||
(
|
||||
str(result.get("body_id"))
|
||||
for result in reversed(engine_result.get("feature_results") or ())
|
||||
if isinstance(result, dict) and result.get("body_id")
|
||||
),
|
||||
"",
|
||||
)
|
||||
if not active_body_id:
|
||||
active_body_id = next(
|
||||
(
|
||||
str(raw.get("body_id"))
|
||||
for raw in reversed(raw_records)
|
||||
if raw.get("kind") == "body" and raw.get("body_id")
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
# The runtime retains historical B-rep records for provenance, but only
|
||||
# the final body can resolve face, edge, vertex, and body selectors.
|
||||
active_records = [
|
||||
raw for raw in raw_records
|
||||
if not active_body_id
|
||||
or raw.get("kind") in {"plane", "axis"}
|
||||
or str(raw.get("body_id") or "") == active_body_id
|
||||
]
|
||||
records: list[dict[str, Any]] = []
|
||||
for raw in active_records:
|
||||
kind = str(raw.get("kind"))
|
||||
records.append({
|
||||
"record_id": str(raw["record_id"]),
|
||||
"kind": kind,
|
||||
"feature_id": str(raw.get("feature_id") or ""),
|
||||
"body_id": str(raw.get("body_id") or "") or None,
|
||||
"owner_feature_ids": [str(item) for item in raw.get("owner_feature_ids") or () if str(item)],
|
||||
"geometry": copy.deepcopy(raw.get("geometry") or {}),
|
||||
"executable": kind in {"body", "face", "edge", "vertex", "plane", "axis"},
|
||||
"synthetic": False,
|
||||
})
|
||||
# Preview/B-rep fallback faces are useful for visual explanation only.
|
||||
# Keep them in the unified audit snapshot, but never expose them as
|
||||
# executable selector candidates.
|
||||
if not any(item.get("kind") == "face" for item in records):
|
||||
for index, raw in enumerate((preview or {}).get("topology_faces") or ()):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
record_id = str(raw.get("id") or f"synthetic:face:{index}")
|
||||
center = raw.get("center")
|
||||
normal = raw.get("normal")
|
||||
raw_bbox = raw.get("bbox")
|
||||
if isinstance(raw_bbox, dict) and isinstance(raw_bbox.get("min"), list) and isinstance(raw_bbox.get("max"), list):
|
||||
raw_bbox = [*raw_bbox["min"], *raw_bbox["max"]]
|
||||
geometry = {
|
||||
"surface_type": str(raw.get("surface_type") or "unknown"),
|
||||
"center_mm": copy.deepcopy(center) if isinstance(center, list) else None,
|
||||
"normal": copy.deepcopy(normal) if isinstance(normal, list) else None,
|
||||
"bbox_mm": copy.deepcopy(raw_bbox or {}),
|
||||
}
|
||||
records.append({
|
||||
"record_id": record_id,
|
||||
"kind": "face",
|
||||
"feature_id": "",
|
||||
"body_id": None,
|
||||
"owner_feature_ids": [],
|
||||
"geometry": geometry,
|
||||
"executable": False,
|
||||
"synthetic": True,
|
||||
})
|
||||
return {
|
||||
"schema_version": "cad.topology.v1",
|
||||
"task_id": task_id,
|
||||
"revision_id": revision_id,
|
||||
"snapshot_id": f"{task_id}/{revision_id}" if task_id and revision_id else "",
|
||||
"body_id": active_body_id,
|
||||
"records": records,
|
||||
}
|
||||
|
||||
|
||||
def topology_sidecars(
|
||||
engine_result: dict[str, Any],
|
||||
preview: dict[str, Any] | None = None,
|
||||
*,
|
||||
snapshot: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
runtime_records = (snapshot or topology_snapshot(engine_result)).get("records") or []
|
||||
runtime_faces = [item for item in runtime_records if item.get("kind") == "face" and item.get("executable", True)]
|
||||
runtime_edges = [item for item in runtime_records if item.get("kind") == "edge" and item.get("executable", True)]
|
||||
if runtime_faces or runtime_edges:
|
||||
references = []
|
||||
for record in runtime_faces:
|
||||
geometry = record.get("geometry") or {}
|
||||
references.append({
|
||||
"id": str(record.get("record_id")),
|
||||
"selectorType": "face",
|
||||
"label": str(geometry.get("surface_type") or "face"),
|
||||
"center": geometry.get("center_mm"),
|
||||
"normal": geometry.get("normal"),
|
||||
"frame": {
|
||||
"origin_mm": geometry.get("center_mm"),
|
||||
"normal": geometry.get("normal"),
|
||||
"x_dir": [1, 0, 0],
|
||||
"y_dir": [0, 1, 0],
|
||||
},
|
||||
"bbox": geometry.get("bbox_mm") or {},
|
||||
"surface_type": str(geometry.get("surface_type") or "unknown"),
|
||||
"owner_feature_ids": record.get("owner_feature_ids") or [],
|
||||
"source": "runtime_snapshot",
|
||||
"snapshot_id": (snapshot or {}).get("snapshot_id") or "",
|
||||
"executable": True,
|
||||
})
|
||||
edge_records = [
|
||||
{
|
||||
**record,
|
||||
"selectorType": "edge",
|
||||
"source": "runtime_snapshot",
|
||||
"snapshot_id": (snapshot or {}).get("snapshot_id") or "",
|
||||
}
|
||||
for record in runtime_edges
|
||||
]
|
||||
return (
|
||||
{"schema_version": "cad.topology.v1", "references": references, "edges": edge_records},
|
||||
{"schema_version": "cad.topology.v1", "edges": edge_records},
|
||||
)
|
||||
topology_faces = (preview or {}).get("topology_faces")
|
||||
if isinstance(topology_faces, list) and topology_faces:
|
||||
references = []
|
||||
@@ -238,6 +456,9 @@ def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | N
|
||||
"surface_type": str(face.get("surface_type") or "unknown"),
|
||||
"triangle_start": int(face.get("triangle_start") or 0),
|
||||
"triangle_count": int(face.get("triangle_count") or 0),
|
||||
"source": "preview",
|
||||
"synthetic": True,
|
||||
"executable": False,
|
||||
})
|
||||
if references:
|
||||
return ({"schema_version": "1.1", "references": references}, {"schema_version": "1.0", "edges": []})
|
||||
@@ -262,6 +483,9 @@ def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | N
|
||||
"center": point, "normal": normal,
|
||||
"frame": {"origin_mm": point, "normal": normal, "x_dir": x_dir, "y_dir": y_dir},
|
||||
"bbox": {"min": minimum, "max": maximum},
|
||||
"source": "bbox_fallback",
|
||||
"synthetic": True,
|
||||
"executable": False,
|
||||
}
|
||||
for name, point, normal, x_dir, y_dir in definitions
|
||||
]
|
||||
@@ -330,7 +554,6 @@ def _part_skill_audit(
|
||||
skill_ids = [str(item) for item in audit.get("skill_ids") or [] if str(item)]
|
||||
if not skill_ids:
|
||||
skill_ids = [str(item.get("id")) for item in skills if item.get("id")]
|
||||
evidence = audit.get("evidence") if isinstance(audit.get("evidence"), dict) else {}
|
||||
audit.update({
|
||||
"schema_version": str(audit.get("schema_version") or "1.0"),
|
||||
"request": str(audit.get("request") or request),
|
||||
@@ -339,10 +562,6 @@ def _part_skill_audit(
|
||||
"skills": skills,
|
||||
"inherited_skill_ids": [str(item) for item in audit.get("inherited_skill_ids") or [] if str(item)],
|
||||
"assumptions": [str(item) for item in generation_assumptions or []],
|
||||
"evidence": evidence,
|
||||
"capability_translations": [
|
||||
item for item in audit.get("capability_translations") or [] if isinstance(item, dict)
|
||||
],
|
||||
})
|
||||
return audit
|
||||
|
||||
@@ -350,20 +569,9 @@ def _part_skill_audit(
|
||||
def _generation_context(
|
||||
reference_ids: list[str],
|
||||
part_skill_audit: dict[str, Any],
|
||||
design_intent: dict[str, Any] | None = None,
|
||||
design_intent_path: str = "",
|
||||
design_intent_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
skills = [item for item in part_skill_audit.get("skills") or [] if isinstance(item, dict)]
|
||||
intent = design_intent if isinstance(design_intent, dict) else {}
|
||||
return {
|
||||
"design_intent_id": str(intent.get("intent_id") or design_intent_id or ""),
|
||||
"design_intent_path": design_intent_path,
|
||||
"design_intent_structures": [
|
||||
str(item.get("id") or "")
|
||||
for item in intent.get("structures") or []
|
||||
if isinstance(item, dict) and item.get("id")
|
||||
],
|
||||
"cdsl_reference_ids": list(reference_ids),
|
||||
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
|
||||
"part_skill_paths": [
|
||||
@@ -375,9 +583,6 @@ def _generation_context(
|
||||
for item in skills
|
||||
],
|
||||
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
|
||||
"design_intent_assumptions": list(part_skill_audit.get("design_intent_assumptions") or []),
|
||||
"design_intent_capability_gaps": list(part_skill_audit.get("design_intent_capability_gaps") or []),
|
||||
"capability_translations": list(part_skill_audit.get("capability_translations") or []),
|
||||
}
|
||||
|
||||
|
||||
@@ -392,13 +597,17 @@ 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,
|
||||
design_intent_path: str = "",
|
||||
design_intent_id: str = "",
|
||||
design_intent_status: str = "",
|
||||
repair_attempts: int = 0,
|
||||
verification: dict[str, Any] | None = None,
|
||||
reference_records: list[dict[str, Any]] | None = None,
|
||||
feature_plan: dict[str, Any] | None = None,
|
||||
node_id: str = "",
|
||||
fragment: dict[str, Any] | None = None,
|
||||
branch_id: str = "main",
|
||||
visibility: str = "final",
|
||||
) -> dict[str, Any]:
|
||||
engine = load_engine(settings)
|
||||
task = store.ensure_task(task_id, request)
|
||||
@@ -412,31 +621,25 @@ def build_revision(
|
||||
parameters_path = revision_dir / "parameters.json"
|
||||
selector_path = revision_dir / "model.selector.json"
|
||||
edges_path = revision_dir / "model.edges.json"
|
||||
topology_path = revision_dir / "model.topology.json"
|
||||
part_skills_path = revision_dir / "part-skills.json"
|
||||
intent = copy.deepcopy(design_intent) if isinstance(design_intent, dict) else {}
|
||||
intent_assumptions = [str(item) for item in intent.get("assumptions") or [] if str(item)]
|
||||
merged_assumptions = list(dict.fromkeys([
|
||||
*intent_assumptions,
|
||||
*(str(item) for item in generation_assumptions or [] if str(item)),
|
||||
]))
|
||||
part_skill_audit = _part_skill_audit(part_skills, request, merged_assumptions)
|
||||
intent_id = str(intent.get("intent_id") or design_intent_id or "")
|
||||
if design_intent_path:
|
||||
design_intent_path = str(design_intent_path)
|
||||
part_skill_audit.update({
|
||||
"design_intent_id": intent_id,
|
||||
"design_intent_path": design_intent_path,
|
||||
"intent_status": str(intent.get("status") or design_intent_status or ""),
|
||||
"design_intent_assumptions": intent_assumptions,
|
||||
"design_intent_capability_gaps": [
|
||||
item for item in intent.get("capability_gaps") or [] if isinstance(item, dict)
|
||||
],
|
||||
})
|
||||
generation_context = _generation_context(reference_ids, part_skill_audit, intent, design_intent_path, intent_id)
|
||||
quality_path = revision_dir / "quality-report.json"
|
||||
snapshot_manifest_path = revision_dir / "snapshot-manifest.json"
|
||||
fragment_path = revision_dir / "fragment.json"
|
||||
selector_bindings_path = revision_dir / "selector-bindings.json"
|
||||
part_skill_audit = _part_skill_audit(part_skills, request, generation_assumptions)
|
||||
generation_context = _generation_context(reference_ids, part_skill_audit)
|
||||
write_json(request_path, {"request": request, "created_at": now_iso()})
|
||||
write_json(references_path, {"reference_ids": reference_ids})
|
||||
if isinstance(feature_plan, dict):
|
||||
store.write_feature_plan(task["task_id"], feature_plan)
|
||||
write_json(references_path, {"reference_ids": reference_ids, "records": [item for item in reference_records or [] if isinstance(item, dict)]})
|
||||
write_json(part_skills_path, part_skill_audit)
|
||||
|
||||
write_json(snapshot_manifest_path, {
|
||||
"schema_version": "1.0",
|
||||
"status": "unavailable",
|
||||
"reason": "Snapshot runner is not attached to this backend build",
|
||||
"snapshots": [],
|
||||
})
|
||||
def revision_record(status: str, *, error: str = "", engine_name: str = "") -> dict[str, Any]:
|
||||
record = {
|
||||
"revision_id": revision_id,
|
||||
@@ -446,16 +649,25 @@ def build_revision(
|
||||
"cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"part_skills_path": part_skills_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"design_intent_id": intent_id,
|
||||
"design_intent_path": design_intent_path,
|
||||
"quality_path": quality_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"snapshot_manifest_path": snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"snapshot_status": "unavailable",
|
||||
"snapshot_paths": [snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix()],
|
||||
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
|
||||
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
|
||||
"reference_ids": reference_ids,
|
||||
"summary": summary,
|
||||
"parent_revision_id": parent_revision_id or "",
|
||||
"operation": operation or {},
|
||||
"attachments": attachments or [],
|
||||
"input_attachments": input_attachments or [],
|
||||
"repair_attempts": max(0, int(repair_attempts)),
|
||||
"branch_id": branch_id or "main",
|
||||
"visibility": visibility if visibility in {"checkpoint", "final", "superseded"} else "checkpoint",
|
||||
"node_id": node_id,
|
||||
"fragment_path": fragment_path.relative_to(store.task_dir(task["task_id"])).as_posix() if fragment else "",
|
||||
"selector_bindings_path": selector_bindings_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
}
|
||||
if status == "success":
|
||||
record.update({
|
||||
@@ -463,12 +675,15 @@ def build_revision(
|
||||
"glb_path": glb_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"selector_path": selector_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"edges_path": edges_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"engine": engine_name,
|
||||
})
|
||||
else:
|
||||
record["error"] = error
|
||||
return record
|
||||
|
||||
quality_report: dict[str, Any] | None = None
|
||||
quality_status = ""
|
||||
try:
|
||||
cdsl_copy = copy.deepcopy(cdsl)
|
||||
if not isinstance(cdsl_copy, dict):
|
||||
@@ -480,6 +695,8 @@ def build_revision(
|
||||
if not isinstance(meta.get("editable_parameters"), list) or not meta["editable_parameters"]:
|
||||
meta["editable_parameters"] = _derived_parameters(cdsl_copy)
|
||||
write_json(cdsl_path, cdsl_copy)
|
||||
if fragment is not None:
|
||||
write_json(fragment_path, fragment)
|
||||
write_json(parameters_path, parameter_contract(cdsl_copy))
|
||||
validate_cdsl(cdsl_copy, engine)
|
||||
# Product revisions are semantic CDSL artifacts. Do not route them
|
||||
@@ -489,9 +706,27 @@ def build_revision(
|
||||
if engine_result.get("engine") != "cdsl_only" or not step_path.is_file() or step_path.stat().st_size == 0:
|
||||
raise RuntimeError("Engine did not produce a CDSL-only STEP artifact")
|
||||
preview = step_to_glb(step_path, glb_path)
|
||||
selector, edges = topology_sidecars(engine_result, preview)
|
||||
snapshot = topology_snapshot(engine_result, task_id=task["task_id"], revision_id=revision_id, preview=preview)
|
||||
write_json(topology_path, snapshot)
|
||||
write_json(selector_bindings_path, selector_bindings(
|
||||
engine_result,
|
||||
node_id=node_id,
|
||||
snapshot_id=str(snapshot.get("snapshot_id") or ""),
|
||||
))
|
||||
selector, edges = topology_sidecars(engine_result, preview, snapshot=snapshot)
|
||||
write_json(selector_path, selector)
|
||||
write_json(edges_path, edges)
|
||||
rules = validate_verification(verification, cdsl_copy)
|
||||
quality_report = evaluate_quality(rules, cdsl_copy, engine_result)
|
||||
quality_report["evaluated_at"] = now_iso()
|
||||
write_json(quality_path, quality_report)
|
||||
quality_status = (
|
||||
"accepted" if rules and quality_report["status"] == "passed"
|
||||
else "built_with_warnings" if quality_report["status"] == "passed"
|
||||
else "needs_repair"
|
||||
)
|
||||
if quality_report["status"] != "passed":
|
||||
raise QualityVerificationError(quality_report)
|
||||
report = {
|
||||
"engine_result": engine_result,
|
||||
"preview": preview,
|
||||
@@ -500,16 +735,40 @@ def build_revision(
|
||||
}
|
||||
write_json(report_path, report)
|
||||
revision = revision_record("success", engine_name=str(engine_result["engine"]))
|
||||
revision["quality_status"] = quality_status or "accepted"
|
||||
revision["verification_summary"] = {
|
||||
"requested": bool(rules),
|
||||
"blocking_failures": len(quality_report.get("blocking_failures") or []),
|
||||
"warnings": len(quality_report.get("warnings") or []),
|
||||
}
|
||||
except Exception as error:
|
||||
if not isinstance(error, QualityVerificationError):
|
||||
# The failed candidate is retained in the conversation diagnostics,
|
||||
# not as a task revision. Runtime/schema failures must not create
|
||||
# an editable revision that looks like a model version.
|
||||
shutil.rmtree(revision_dir, ignore_errors=True)
|
||||
raise
|
||||
if not cdsl_path.is_file() and isinstance(cdsl, dict):
|
||||
write_json(cdsl_path, copy.deepcopy(cdsl))
|
||||
if quality_report is not None and not quality_path.is_file():
|
||||
write_json(quality_path, quality_report)
|
||||
write_json(report_path, {
|
||||
"error": str(error),
|
||||
"generation_context": generation_context,
|
||||
"quality": quality_report,
|
||||
"validated_at": now_iso(),
|
||||
})
|
||||
revision = revision_record("failed", error=str(error))
|
||||
revision = revision_record("needs_repair" if quality_report is not None else "failed", error=str(error))
|
||||
revision["quality_status"] = quality_status or ("needs_repair" if quality_report else "failed")
|
||||
if quality_report is not None:
|
||||
revision["verification_summary"] = {
|
||||
"requested": bool(quality_report.get("verification_requested")),
|
||||
"blocking_failures": len(quality_report.get("blocking_failures") or []),
|
||||
"warnings": len(quality_report.get("warnings") or []),
|
||||
}
|
||||
store.update_task(task["task_id"], revision)
|
||||
error.task_id = task["task_id"]
|
||||
error.revision_id = revision_id
|
||||
raise
|
||||
store.update_task(task["task_id"], revision)
|
||||
return {"task_id": task["task_id"], **revision}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Deterministic feature-plan validation and readiness calculations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from copy import deepcopy
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
PLAN_SCHEMA_VERSION = "cad.feature-plan.v1"
|
||||
NODE_STATUSES = {
|
||||
"planned",
|
||||
"ready",
|
||||
"waiting_for_topology",
|
||||
"waiting_for_selection",
|
||||
"blocked",
|
||||
"executing",
|
||||
"executed",
|
||||
"failed",
|
||||
"completed",
|
||||
}
|
||||
|
||||
_TOPOLOGY_REQUIRED_ATOMICS = {
|
||||
"fillet",
|
||||
"chamfer",
|
||||
"hole_blind",
|
||||
"hole_countersink",
|
||||
"hole_counterbore",
|
||||
"pattern_mirror",
|
||||
}
|
||||
|
||||
|
||||
class FeaturePlanError(ValueError):
|
||||
"""A feature plan is not a valid acyclic executable plan."""
|
||||
|
||||
|
||||
def _text(value: Any, field: str, *, required: bool = True) -> str:
|
||||
result = str(value or "").strip()
|
||||
if required and not result:
|
||||
raise FeaturePlanError(f"{field} is required")
|
||||
return result
|
||||
|
||||
|
||||
def _bool(value: Any) -> bool:
|
||||
return value is True
|
||||
|
||||
|
||||
def _normalise_node(raw: Any, index: int) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
raise FeaturePlanError(f"nodes[{index}] must be an object")
|
||||
node_id = _text(raw.get("id"), f"nodes[{index}].id")
|
||||
atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id")
|
||||
depends_on = raw.get("depends_on") or []
|
||||
if not isinstance(depends_on, list) or not all(isinstance(item, str) and item.strip() for item in depends_on):
|
||||
raise FeaturePlanError(f"nodes[{index}].depends_on must be an array of non-empty strings")
|
||||
feature_ids = raw.get("cdsl_feature_ids")
|
||||
if feature_ids is None:
|
||||
feature_ids = [node_id]
|
||||
if not isinstance(feature_ids, list) or not feature_ids or not all(isinstance(item, str) and item.strip() for item in feature_ids):
|
||||
raise FeaturePlanError(f"nodes[{index}].cdsl_feature_ids must be a non-empty string array")
|
||||
query = raw.get("topology_query")
|
||||
if query is not None and not isinstance(query, dict):
|
||||
raise FeaturePlanError(f"nodes[{index}].topology_query must be an object")
|
||||
requires_topology = _bool(raw.get("requires_topology")) or atomic_id in _TOPOLOGY_REQUIRED_ATOMICS
|
||||
status = str(raw.get("status") or "planned")
|
||||
if status not in NODE_STATUSES:
|
||||
raise FeaturePlanError(f"nodes[{index}].status is unsupported: {status}")
|
||||
node = {
|
||||
"id": node_id,
|
||||
"intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False),
|
||||
"atomic_id": atomic_id,
|
||||
"depends_on": list(dict.fromkeys(item.strip() for item in depends_on)),
|
||||
"requires_topology": requires_topology,
|
||||
"topology_query": deepcopy(query) if query is not None else None,
|
||||
"status": status,
|
||||
"cdsl_feature_ids": list(dict.fromkeys(item.strip() for item in feature_ids)),
|
||||
}
|
||||
if raw.get("selector_required") is not None:
|
||||
node["selector_required"] = _bool(raw.get("selector_required"))
|
||||
if isinstance(raw.get("failure"), dict):
|
||||
node["failure"] = deepcopy(raw["failure"])
|
||||
return node
|
||||
|
||||
|
||||
def validate_feature_plan(plan: dict[str, Any], *, supported_atomic_ids: Iterable[str] = ()) -> dict[str, Any]:
|
||||
if not isinstance(plan, dict):
|
||||
raise FeaturePlanError("Feature plan must be an object")
|
||||
version = str(plan.get("schema_version") or PLAN_SCHEMA_VERSION)
|
||||
if version != PLAN_SCHEMA_VERSION:
|
||||
raise FeaturePlanError(f"Unsupported feature plan schema: {version}")
|
||||
plan_id = _text(plan.get("plan_id"), "plan_id")
|
||||
task_id = _text(plan.get("task_id"), "task_id", required=False)
|
||||
topology_snapshot_id = _text(plan.get("topology_snapshot_id"), "topology_snapshot_id", required=False)
|
||||
raw_nodes = plan.get("nodes")
|
||||
if not isinstance(raw_nodes, list) or not raw_nodes:
|
||||
raise FeaturePlanError("Feature plan requires a non-empty nodes array")
|
||||
nodes = [_normalise_node(item, index) for index, item in enumerate(raw_nodes)]
|
||||
by_id: dict[str, dict[str, Any]] = {}
|
||||
node_index: dict[str, int] = {}
|
||||
feature_owner: dict[str, str] = {}
|
||||
supported = {str(item) for item in supported_atomic_ids if str(item)}
|
||||
for index, node in enumerate(nodes):
|
||||
if node["id"] in by_id:
|
||||
raise FeaturePlanError(f"Duplicate feature plan node: {node['id']}")
|
||||
if supported and node["atomic_id"] not in supported:
|
||||
raise FeaturePlanError(f"Unsupported feature plan atomic_id: {node['atomic_id']}")
|
||||
by_id[node["id"]] = node
|
||||
node_index[node["id"]] = index
|
||||
for feature_id in node["cdsl_feature_ids"]:
|
||||
if feature_id in feature_owner:
|
||||
raise FeaturePlanError(f"CDSL feature belongs to multiple plan nodes: {feature_id}")
|
||||
feature_owner[feature_id] = node["id"]
|
||||
indegree = {node_id: 0 for node_id in by_id}
|
||||
children: dict[str, list[str]] = defaultdict(list)
|
||||
for node in nodes:
|
||||
for dependency in node["depends_on"]:
|
||||
if dependency not in by_id:
|
||||
raise FeaturePlanError(f"Node {node['id']} has missing dependency: {dependency}")
|
||||
if node_index[dependency] >= node_index[node["id"]]:
|
||||
raise FeaturePlanError(f"Node {node['id']} must appear after dependency: {dependency}")
|
||||
indegree[node["id"]] += 1
|
||||
children[dependency].append(node["id"])
|
||||
queue = deque(node_id for node_id, degree in indegree.items() if degree == 0)
|
||||
visited: list[str] = []
|
||||
while queue:
|
||||
node_id = queue.popleft()
|
||||
visited.append(node_id)
|
||||
for child in children[node_id]:
|
||||
indegree[child] -= 1
|
||||
if indegree[child] == 0:
|
||||
queue.append(child)
|
||||
if len(visited) != len(nodes):
|
||||
raise FeaturePlanError("Feature plan contains a dependency cycle")
|
||||
return {
|
||||
"schema_version": PLAN_SCHEMA_VERSION,
|
||||
"plan_id": plan_id,
|
||||
"task_id": task_id,
|
||||
"topology_snapshot_id": topology_snapshot_id,
|
||||
"nodes": nodes,
|
||||
"feature_owner": feature_owner,
|
||||
}
|
||||
|
||||
|
||||
def _topology_available(topology: dict[str, Any] | None) -> bool:
|
||||
if not isinstance(topology, dict):
|
||||
return False
|
||||
return any(
|
||||
isinstance(record, dict)
|
||||
and record.get("executable", True) is not False
|
||||
and record.get("kind") in {"face", "edge", "vertex", "body", "plane", "axis"}
|
||||
for record in topology.get("records") or ()
|
||||
)
|
||||
|
||||
|
||||
def compute_node_statuses(
|
||||
plan: dict[str, Any],
|
||||
*,
|
||||
cdsl: dict[str, Any] | None = None,
|
||||
topology: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a copy with deterministic status and readiness information."""
|
||||
checked = validate_feature_plan(plan)
|
||||
present_features = {
|
||||
str(feature.get("id"))
|
||||
for feature in (cdsl or {}).get("features") or ()
|
||||
if isinstance(feature, dict) and feature.get("id")
|
||||
}
|
||||
has_topology = _topology_available(topology)
|
||||
topology_snapshot_id = str((topology or {}).get("snapshot_id") or "")
|
||||
selection_ready = bool(topology_snapshot_id and checked.get("topology_snapshot_id") == topology_snapshot_id)
|
||||
by_id = {node["id"]: node for node in checked["nodes"]}
|
||||
result_nodes: list[dict[str, Any]] = []
|
||||
for original in checked["nodes"]:
|
||||
node = deepcopy(original)
|
||||
if node["status"] in {"failed", "blocked"}:
|
||||
result_nodes.append(node)
|
||||
continue
|
||||
if all(feature_id in present_features for feature_id in node["cdsl_feature_ids"]):
|
||||
node["status"] = "completed"
|
||||
result_nodes.append(node)
|
||||
continue
|
||||
dependencies_done = all(by_id[item]["status"] in {"executed", "completed"} or all(
|
||||
feature_id in present_features for feature_id in by_id[item]["cdsl_feature_ids"]
|
||||
) for item in node["depends_on"])
|
||||
if not dependencies_done:
|
||||
node["status"] = "planned"
|
||||
elif node["requires_topology"] and not has_topology:
|
||||
node["status"] = "waiting_for_topology"
|
||||
elif node["requires_topology"] and not selection_ready:
|
||||
node["status"] = "waiting_for_selection"
|
||||
else:
|
||||
node["status"] = "ready"
|
||||
result_nodes.append(node)
|
||||
ready = [node["id"] for node in result_nodes if node["status"] == "ready"]
|
||||
waiting = [node["id"] for node in result_nodes if node["status"] in {"waiting_for_topology", "waiting_for_selection"}]
|
||||
blocked = [node["id"] for node in result_nodes if node["status"] == "blocked"]
|
||||
completed = [node["id"] for node in result_nodes if node["status"] == "completed"]
|
||||
return {
|
||||
**checked,
|
||||
"nodes": result_nodes,
|
||||
"ready_nodes": ready,
|
||||
"waiting_nodes": waiting,
|
||||
"blocked_nodes": blocked,
|
||||
"completed_nodes": completed,
|
||||
"complete": len(completed) == len(result_nodes) and not blocked,
|
||||
}
|
||||
|
||||
|
||||
def plan_feature_ids(plan: dict[str, Any], node_ids: Iterable[str]) -> set[str]:
|
||||
checked = validate_feature_plan(plan)
|
||||
wanted = set(node_ids)
|
||||
return {
|
||||
feature_id
|
||||
for node in checked["nodes"]
|
||||
if node["id"] in wanted
|
||||
for feature_id in node["cdsl_feature_ids"]
|
||||
}
|
||||
|
||||
|
||||
def node_for_feature(plan: dict[str, Any], feature_id: str) -> dict[str, Any] | None:
|
||||
checked = validate_feature_plan(plan)
|
||||
return next((node for node in checked["nodes"] if feature_id in node["cdsl_feature_ids"]), None)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Strict contracts for persistent, node-by-node CAD generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import re
|
||||
from typing import Any, Iterable
|
||||
|
||||
from app.services.feature_plan import FeaturePlanError, validate_feature_plan
|
||||
|
||||
|
||||
GENERATION_PLAN_SCHEMA_VERSION = "cad.generation-plan.v2"
|
||||
BACKEND_ID_STRATEGY = "backend-derived-v1"
|
||||
REQUIREMENT_SOURCES = {"explicit", "assumption"}
|
||||
REQUIREMENT_PRIORITIES = {"hard", "soft"}
|
||||
# Keep this in sync with the engine's profile_schema.json. The plan is
|
||||
# deliberately atomic: an operation that consumes a profile owns one new
|
||||
# sketch, while all other operations own none.
|
||||
SKETCH_REQUIRED_ATOMICS = {
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
|
||||
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
|
||||
"hole_counterbore", "sphere_add",
|
||||
}
|
||||
|
||||
|
||||
class GenerationPlanError(ValueError):
|
||||
"""The authoring plan cannot safely drive an incremental build."""
|
||||
|
||||
|
||||
def _text(value: Any, field: str, *, required: bool = True) -> str:
|
||||
result = str(value or "").strip()
|
||||
if required and not result:
|
||||
raise GenerationPlanError(f"{field} is required")
|
||||
return result
|
||||
|
||||
|
||||
def _string_list(value: Any, field: str, *, required: bool = False) -> list[str]:
|
||||
if value is None:
|
||||
value = []
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value):
|
||||
raise GenerationPlanError(f"{field} must be an array of non-empty strings")
|
||||
result = list(dict.fromkeys(item.strip() for item in value))
|
||||
if required and not result:
|
||||
raise GenerationPlanError(f"{field} must not be empty")
|
||||
return result
|
||||
|
||||
|
||||
def _normalise_requirement(raw: Any, index: int) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
raise GenerationPlanError(f"requirements[{index}] must be an object")
|
||||
source = _text(raw.get("source") or "assumption", f"requirements[{index}].source")
|
||||
priority = _text(raw.get("priority") or "hard", f"requirements[{index}].priority")
|
||||
if source not in REQUIREMENT_SOURCES:
|
||||
raise GenerationPlanError(f"requirements[{index}].source is unsupported: {source}")
|
||||
if priority not in REQUIREMENT_PRIORITIES:
|
||||
raise GenerationPlanError(f"requirements[{index}].priority is unsupported: {priority}")
|
||||
return {
|
||||
"id": _text(raw.get("id"), f"requirements[{index}].id"),
|
||||
"source": source,
|
||||
"priority": priority,
|
||||
"description": _text(raw.get("description"), f"requirements[{index}].description"),
|
||||
"value": deepcopy(raw.get("value")),
|
||||
"unit": _text(raw.get("unit"), f"requirements[{index}].unit", required=False),
|
||||
"tolerance": deepcopy(raw.get("tolerance")),
|
||||
}
|
||||
|
||||
|
||||
def _backend_cdsl_id(kind: str, node_id: str) -> str:
|
||||
"""Create a valid, stable CDSL identifier without trusting model naming."""
|
||||
slug = re.sub(r"[^A-Za-z0-9_-]+", "_", node_id).strip("_-").lower() or "node"
|
||||
digest = sha256(node_id.encode("utf-8")).hexdigest()[:8]
|
||||
return f"{kind}_{slug[:60]}_{digest}"
|
||||
|
||||
|
||||
def _backend_node_outputs(node_id: str, atomic_id: str) -> tuple[list[str], list[str]]:
|
||||
feature_ids = [_backend_cdsl_id("feature", node_id)]
|
||||
sketch_ids = [_backend_cdsl_id("sketch", node_id)] if atomic_id in SKETCH_REQUIRED_ATOMICS else []
|
||||
return feature_ids, sketch_ids
|
||||
|
||||
|
||||
def _stored_plan_ids(raw: dict[str, Any]) -> bool:
|
||||
"""Retain IDs of plans already materialised by an earlier backend version."""
|
||||
return str(raw.get("id_strategy") or "") == BACKEND_ID_STRATEGY or isinstance(raw.get("feature_owner"), dict)
|
||||
|
||||
|
||||
def validate_generation_plan(
|
||||
document: dict[str, Any],
|
||||
*,
|
||||
supported_atomic_ids: Iterable[str] = (),
|
||||
task_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Normalise a planner response and prove every hard requirement is owned."""
|
||||
if not isinstance(document, dict):
|
||||
raise GenerationPlanError("Generation plan must be an object")
|
||||
version = str(document.get("schema_version") or GENERATION_PLAN_SCHEMA_VERSION)
|
||||
if version != GENERATION_PLAN_SCHEMA_VERSION:
|
||||
raise GenerationPlanError(f"Unsupported generation plan schema: {version}")
|
||||
requirements_raw = document.get("requirements")
|
||||
if not isinstance(requirements_raw, list) or not requirements_raw:
|
||||
raise GenerationPlanError("Generation plan requires a non-empty requirements array")
|
||||
requirements = [_normalise_requirement(item, index) for index, item in enumerate(requirements_raw)]
|
||||
requirement_ids = [item["id"] for item in requirements]
|
||||
if len(requirement_ids) != len(set(requirement_ids)):
|
||||
raise GenerationPlanError("Generation plan has duplicate requirement ids")
|
||||
|
||||
raw_nodes = document.get("nodes")
|
||||
if not isinstance(raw_nodes, list) or not raw_nodes:
|
||||
raise GenerationPlanError("Generation plan requires a non-empty nodes array")
|
||||
preserve_stored_ids = _stored_plan_ids(document)
|
||||
feature_nodes: list[dict[str, Any]] = []
|
||||
node_metadata: dict[str, dict[str, Any]] = {}
|
||||
sketch_owner: dict[str, str] = {}
|
||||
for index, raw in enumerate(raw_nodes):
|
||||
if not isinstance(raw, dict):
|
||||
raise GenerationPlanError(f"nodes[{index}] must be an object")
|
||||
node_id = _text(raw.get("id"), f"nodes[{index}].id")
|
||||
atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id")
|
||||
if preserve_stored_ids:
|
||||
feature_ids = _string_list(raw.get("cdsl_feature_ids"), f"nodes[{index}].cdsl_feature_ids", required=True)
|
||||
sketch_ids = _string_list(raw.get("cdsl_sketch_ids"), f"nodes[{index}].cdsl_sketch_ids")
|
||||
else:
|
||||
# New plans own semantic node IDs only. CDSL object IDs are a
|
||||
# deterministic backend implementation detail, not model output.
|
||||
feature_ids, sketch_ids = _backend_node_outputs(node_id, atomic_id)
|
||||
for sketch_id in sketch_ids:
|
||||
previous = sketch_owner.get(sketch_id)
|
||||
if previous:
|
||||
raise GenerationPlanError(f"CDSL sketch belongs to multiple plan nodes: {sketch_id} ({previous}, {node_id})")
|
||||
sketch_owner[sketch_id] = node_id
|
||||
coverage = _string_list(raw.get("requirement_ids"), f"nodes[{index}].requirement_ids")
|
||||
unknown = sorted(set(coverage) - set(requirement_ids))
|
||||
if unknown:
|
||||
raise GenerationPlanError(f"Node {node_id} references unknown requirements: {', '.join(unknown)}")
|
||||
rules = raw.get("verification_rules") or []
|
||||
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
|
||||
raise GenerationPlanError(f"nodes[{index}].verification_rules must be an array of objects")
|
||||
targets = raw.get("review_targets") or []
|
||||
if not isinstance(targets, list) or not all(isinstance(item, dict) for item in targets):
|
||||
raise GenerationPlanError(f"nodes[{index}].review_targets must be an array of objects")
|
||||
feature_nodes.append({
|
||||
"id": node_id,
|
||||
"intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False),
|
||||
"atomic_id": atomic_id,
|
||||
"depends_on": _string_list(raw.get("depends_on"), f"nodes[{index}].depends_on"),
|
||||
"requires_topology": raw.get("requires_topology") is True,
|
||||
"topology_query": deepcopy(raw.get("topology_query")) if raw.get("topology_query") is not None else None,
|
||||
"cdsl_feature_ids": feature_ids,
|
||||
})
|
||||
node_metadata[node_id] = {
|
||||
"cdsl_sketch_ids": sketch_ids,
|
||||
"requires_sketch": bool(sketch_ids),
|
||||
"requirement_ids": coverage,
|
||||
"verification_rules": deepcopy(rules),
|
||||
"review_targets": deepcopy(targets),
|
||||
"attempts": {"authoring": 0, "repair": 0, "replan": 0},
|
||||
}
|
||||
try:
|
||||
feature_plan = validate_feature_plan({
|
||||
"schema_version": "cad.feature-plan.v1",
|
||||
"plan_id": document.get("plan_id"),
|
||||
"task_id": task_id or document.get("task_id"),
|
||||
"nodes": feature_nodes,
|
||||
}, supported_atomic_ids=supported_atomic_ids)
|
||||
except FeaturePlanError as error:
|
||||
raise GenerationPlanError(str(error)) from error
|
||||
|
||||
covered = {
|
||||
requirement_id
|
||||
for metadata in node_metadata.values()
|
||||
for requirement_id in metadata["requirement_ids"]
|
||||
}
|
||||
uncovered = [item["id"] for item in requirements if item["priority"] == "hard" and item["id"] not in covered]
|
||||
if uncovered:
|
||||
raise GenerationPlanError("Hard requirements are not covered: " + ", ".join(uncovered))
|
||||
nodes = []
|
||||
for node in feature_plan["nodes"]:
|
||||
nodes.append({**node, **node_metadata[node["id"]]})
|
||||
return {
|
||||
"schema_version": GENERATION_PLAN_SCHEMA_VERSION,
|
||||
"id_strategy": BACKEND_ID_STRATEGY,
|
||||
"plan_id": feature_plan["plan_id"],
|
||||
"task_id": task_id or feature_plan["task_id"],
|
||||
"requirements": requirements,
|
||||
"assumptions": _string_list(document.get("assumptions"), "assumptions"),
|
||||
"nodes": nodes,
|
||||
"feature_owner": feature_plan["feature_owner"],
|
||||
"sketch_owner": sketch_owner,
|
||||
}
|
||||
|
||||
|
||||
def descendant_closure(plan: dict[str, Any], root_node_id: str) -> set[str]:
|
||||
"""Return one node and every node whose model depends on it."""
|
||||
nodes = plan.get("nodes") if isinstance(plan, dict) else None
|
||||
if not isinstance(nodes, list):
|
||||
raise GenerationPlanError("Generation plan has no nodes")
|
||||
children: dict[str, set[str]] = defaultdict(set)
|
||||
known = {str(node.get("id")) for node in nodes if isinstance(node, dict)}
|
||||
if root_node_id not in known:
|
||||
raise GenerationPlanError(f"Unknown generation-plan node: {root_node_id}")
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
for dependency in node.get("depends_on") or ():
|
||||
children[str(dependency)].add(str(node.get("id")))
|
||||
result: set[str] = set()
|
||||
queue: deque[str] = deque([root_node_id])
|
||||
while queue:
|
||||
node_id = queue.popleft()
|
||||
if node_id in result:
|
||||
continue
|
||||
result.add(node_id)
|
||||
queue.extend(sorted(children[node_id] - result))
|
||||
return result
|
||||
|
||||
|
||||
def mark_nodes_stale(plan: dict[str, Any], root_node_id: str, *, reason: str) -> dict[str, Any]:
|
||||
"""Invalidate a node/subtree after an upstream geometry change or rollback."""
|
||||
updated = deepcopy(plan)
|
||||
stale = descendant_closure(updated, root_node_id)
|
||||
for node in updated.get("nodes") or ():
|
||||
if isinstance(node, dict) and str(node.get("id")) in stale:
|
||||
node["status"] = "planned"
|
||||
node["stale"] = True
|
||||
node["stale_reason"] = reason
|
||||
node.pop("topology_snapshot_id", None)
|
||||
return updated
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Structured multi-view image observations used by the CAD agent.
|
||||
|
||||
The observation contract intentionally keeps uncertain image evidence separate
|
||||
from executable CDSL. It can therefore retain free-form/polyline candidates
|
||||
without pretending that the local CAD runtime supports them directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
|
||||
OBSERVATION_SCHEMA_VERSION = "cad.image-observation.v2"
|
||||
TEXT_LIMIT = 300
|
||||
LIMITS = {
|
||||
"views": 12,
|
||||
"surfaces": 24,
|
||||
"profiles": 32,
|
||||
"segments": 256,
|
||||
"holes": 64,
|
||||
"bends": 16,
|
||||
"measurements": 128,
|
||||
"uncertainties": 64,
|
||||
}
|
||||
|
||||
|
||||
def _text(value: Any, name: str, limit: int = TEXT_LIMIT, *, required: bool = False) -> str:
|
||||
result = str(value or "").strip()
|
||||
if required and not result:
|
||||
raise ValueError(f"{name} must be a non-empty string")
|
||||
return result[:limit]
|
||||
|
||||
|
||||
def _text_list(value: Any, name: str, limit: int) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"{name} must be an array")
|
||||
return [_text(item, name, required=True) for item in value[:limit]]
|
||||
|
||||
|
||||
def _number(value: Any, name: str) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError(f"{name} must be numeric") from error
|
||||
|
||||
|
||||
def _point(value: Any, name: str, dimensions: int = 2) -> list[float] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list) or len(value) < dimensions:
|
||||
raise ValueError(f"{name} must contain at least {dimensions} numbers")
|
||||
output: list[float] = []
|
||||
for index, component in enumerate(value[:dimensions]):
|
||||
parsed = _number(component, f"{name}[{index}]")
|
||||
if parsed is None:
|
||||
raise ValueError(f"{name}[{index}] must be numeric")
|
||||
output.append(parsed)
|
||||
return output
|
||||
|
||||
|
||||
def _confidence(value: Any) -> float | None:
|
||||
parsed = _number(value, "confidence")
|
||||
if parsed is None:
|
||||
return None
|
||||
return max(0.0, min(1.0, parsed))
|
||||
|
||||
|
||||
def _source_images(value: Any) -> list[str]:
|
||||
return _text_list(value, "source_images", LIMITS["views"])
|
||||
|
||||
|
||||
def _normalize_segment(segment: Any) -> dict[str, Any]:
|
||||
if not isinstance(segment, dict):
|
||||
raise ValueError("profile segments must contain objects")
|
||||
kind = _text(segment.get("type"), "segment.type", 32, required=True)
|
||||
if kind not in {"line", "arc", "circle", "polyline", "unknown_curve"}:
|
||||
raise ValueError(f"unsupported image segment type: {kind}")
|
||||
result: dict[str, Any] = {"type": kind}
|
||||
if kind in {"line", "arc"}:
|
||||
result["start"] = _point(segment.get("start"), "segment.start")
|
||||
result["end"] = _point(segment.get("end"), "segment.end")
|
||||
if result["start"] is None or result["end"] is None:
|
||||
raise ValueError(f"{kind} segments require start and end")
|
||||
if kind == "arc":
|
||||
result["center"] = _point(segment.get("center"), "segment.center")
|
||||
result["radius_mm"] = _number(segment.get("radius_mm"), "segment.radius_mm")
|
||||
result["clockwise"] = bool(segment.get("clockwise"))
|
||||
if kind == "circle":
|
||||
result["center"] = _point(segment.get("center"), "segment.center")
|
||||
result["radius_mm"] = _number(segment.get("radius_mm"), "segment.radius_mm")
|
||||
if result["center"] is None or result["radius_mm"] is None:
|
||||
raise ValueError("circle segments require center and radius_mm")
|
||||
if kind in {"polyline", "unknown_curve"}:
|
||||
points = segment.get("points")
|
||||
if not isinstance(points, list) or not points:
|
||||
raise ValueError(f"{kind} segments require points")
|
||||
result["points"] = [_point(point, "segment.points") for point in points[:LIMITS["segments"]]]
|
||||
if any(point is None for point in result["points"]):
|
||||
raise ValueError(f"{kind} segment contains an invalid point")
|
||||
result["image_uv"] = {
|
||||
"start": _point(segment.get("image_start"), "segment.image_start"),
|
||||
"end": _point(segment.get("image_end"), "segment.image_end"),
|
||||
}
|
||||
result["confidence"] = _confidence(segment.get("confidence"))
|
||||
result["notes"] = _text(segment.get("notes"), "segment.notes")
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_profile(profile: Any) -> dict[str, Any]:
|
||||
if not isinstance(profile, dict):
|
||||
raise ValueError("profiles must contain objects")
|
||||
segments = profile.get("segments") or []
|
||||
if not isinstance(segments, list):
|
||||
raise ValueError("profile.segments must be an array")
|
||||
return {
|
||||
"id": _text(profile.get("id"), "profile.id", 80, required=True),
|
||||
"role": _text(profile.get("role"), "profile.role", 40),
|
||||
"plane_hint": _text(profile.get("plane_hint"), "profile.plane_hint"),
|
||||
"closed": bool(profile.get("closed")),
|
||||
"coordinate_space": _text(profile.get("coordinate_space"), "profile.coordinate_space") or "image_uv",
|
||||
"segments": [_normalize_segment(item) for item in segments[:LIMITS["segments"]]],
|
||||
"source_images": _source_images(profile.get("source_images")),
|
||||
"confidence": _confidence(profile.get("confidence")),
|
||||
"uncertain": _text_list(profile.get("uncertain"), "profile.uncertain", 16),
|
||||
"notes": _text(profile.get("notes"), "profile.notes"),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_measurement(measurement: Any) -> dict[str, Any]:
|
||||
if not isinstance(measurement, dict):
|
||||
raise ValueError("measurements must contain objects")
|
||||
source = _text(measurement.get("source"), "measurement.source") or "image"
|
||||
if source not in {"user", "image", "cv", "assumption"}:
|
||||
raise ValueError("measurement.source must be user, image, cv, or assumption")
|
||||
value = _number(measurement.get("value_mm"), "measurement.value_mm")
|
||||
minimum = _number(measurement.get("min_mm"), "measurement.min_mm")
|
||||
maximum = _number(measurement.get("max_mm"), "measurement.max_mm")
|
||||
return {
|
||||
"name": _text(measurement.get("name"), "measurement.name", 120, required=True),
|
||||
"value_mm": value,
|
||||
"min_mm": minimum,
|
||||
"max_mm": maximum,
|
||||
"source": source,
|
||||
"confidence": _confidence(measurement.get("confidence")),
|
||||
"evidence": _text(measurement.get("evidence"), "measurement.evidence"),
|
||||
"source_images": _source_images(measurement.get("source_images")),
|
||||
}
|
||||
|
||||
|
||||
def normalize_image_observation(arguments: dict[str, Any], *, attachment_ids: list[str]) -> dict[str, Any]:
|
||||
"""Normalize the survey tool output while preserving uncertain geometry."""
|
||||
if not isinstance(arguments, dict):
|
||||
raise ValueError("image observation arguments must be an object")
|
||||
raw_ids = [str(item) for item in arguments.get("attachment_ids") or attachment_ids if str(item)]
|
||||
normalized_ids = list(dict.fromkeys(raw_ids or attachment_ids))
|
||||
if not normalized_ids:
|
||||
raise ValueError("image observation requires at least one attachment")
|
||||
views = arguments.get("views") or []
|
||||
profiles = arguments.get("profiles") or []
|
||||
measurements = arguments.get("measurements") or []
|
||||
result: dict[str, Any] = {
|
||||
"schema_version": OBSERVATION_SCHEMA_VERSION,
|
||||
"attachment_ids": normalized_ids,
|
||||
"part_type": _text(arguments.get("part_type"), "part_type", required=True),
|
||||
"visible_features": _text_list(arguments.get("visible_features"), "visible_features", 32),
|
||||
"uncertain_features": _text_list(arguments.get("uncertain_features"), "uncertain_features", LIMITS["uncertainties"]),
|
||||
"views": [],
|
||||
"scale_references": deepcopy(arguments.get("scale_references") or [])[:LIMITS["views"]],
|
||||
"overall_geometry": arguments.get("overall_geometry") if isinstance(arguments.get("overall_geometry"), dict) else {},
|
||||
"surfaces": deepcopy(arguments.get("surfaces") or [])[:LIMITS["surfaces"]],
|
||||
"profiles": [_normalize_profile(item) for item in profiles[:LIMITS["profiles"]]],
|
||||
"holes": deepcopy(arguments.get("holes") or [])[:LIMITS["holes"]],
|
||||
"bends": deepcopy(arguments.get("bends") or [])[:LIMITS["bends"]],
|
||||
"measurements": [_normalize_measurement(item) for item in measurements[:LIMITS["measurements"]]],
|
||||
"uncertainties": _text_list(arguments.get("uncertainties"), "uncertainties", LIMITS["uncertainties"]),
|
||||
"assumptions": _text_list(arguments.get("assumptions"), "assumptions", LIMITS["uncertainties"]),
|
||||
"cv_hints": deepcopy(arguments.get("cv_hints") or [])[:LIMITS["profiles"]],
|
||||
}
|
||||
for item in views[:LIMITS["views"]]:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("views must contain objects")
|
||||
result["views"].append({
|
||||
"attachment_id": _text(item.get("attachment_id"), "view.attachment_id", 120, required=True),
|
||||
"view_role": _text(item.get("view_role"), "view.view_role"),
|
||||
"orientation": _text(item.get("orientation"), "view.orientation"),
|
||||
"visible_regions": _text_list(item.get("visible_regions"), "view.visible_regions", 24),
|
||||
"occluded_regions": _text_list(item.get("occluded_regions"), "view.occluded_regions", 24),
|
||||
"quality": _text(item.get("quality"), "view.quality"),
|
||||
"scale_reference_id": _text(item.get("scale_reference_id"), "view.scale_reference_id", 120),
|
||||
"confidence": _confidence(item.get("confidence")),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def normalize_sketch_candidates(arguments: dict[str, Any], *, attachment_ids: list[str]) -> dict[str, Any]:
|
||||
"""Normalize the second-stage sketch extraction result."""
|
||||
if not isinstance(arguments, dict):
|
||||
raise ValueError("sketch candidate arguments must be an object")
|
||||
profiles = arguments.get("profiles") or arguments.get("sketches") or []
|
||||
base = normalize_image_observation({
|
||||
"attachment_ids": attachment_ids,
|
||||
"part_type": arguments.get("part_type") or "image reference",
|
||||
"visible_features": arguments.get("visible_features") or ["profile candidates"],
|
||||
"uncertain_features": arguments.get("uncertain_features") or [],
|
||||
"profiles": profiles,
|
||||
"measurements": arguments.get("measurements") or [],
|
||||
"uncertainties": arguments.get("uncertainties") or [],
|
||||
"assumptions": arguments.get("assumptions") or [],
|
||||
"cv_hints": arguments.get("cv_hints") or [],
|
||||
}, attachment_ids=attachment_ids)
|
||||
return {
|
||||
"profiles": base["profiles"],
|
||||
"measurements": base["measurements"],
|
||||
"uncertainties": base["uncertainties"],
|
||||
"assumptions": base["assumptions"],
|
||||
"cv_hints": base["cv_hints"],
|
||||
}
|
||||
|
||||
|
||||
def merge_image_observations(survey: dict[str, Any], sketches: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Merge the two stages and keep user-sourced measurements authoritative."""
|
||||
result = deepcopy(survey)
|
||||
result["schema_version"] = OBSERVATION_SCHEMA_VERSION
|
||||
result["profiles"] = sketches.get("profiles") or result.get("profiles") or []
|
||||
existing = {str(item.get("name")): item for item in result.get("measurements") or () if isinstance(item, dict)}
|
||||
for item in sketches.get("measurements") or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = str(item.get("name") or "")
|
||||
prior = existing.get(key)
|
||||
if prior and prior.get("source") == "user" and item.get("source") != "user":
|
||||
continue
|
||||
existing[key] = item
|
||||
result["measurements"] = list(existing.values())
|
||||
result["uncertainties"] = list(dict.fromkeys([
|
||||
*(result.get("uncertainties") or []),
|
||||
*(sketches.get("uncertainties") or []),
|
||||
]))[:LIMITS["uncertainties"]]
|
||||
result["assumptions"] = list(dict.fromkeys([
|
||||
*(result.get("assumptions") or []),
|
||||
*(sketches.get("assumptions") or []),
|
||||
]))[:LIMITS["uncertainties"]]
|
||||
result["cv_hints"] = sketches.get("cv_hints") or result.get("cv_hints") or []
|
||||
return result
|
||||
|
||||
|
||||
def render_image_observation_context(observation: dict[str, Any] | None) -> str:
|
||||
if not isinstance(observation, dict):
|
||||
return ""
|
||||
compact = {
|
||||
key: observation.get(key)
|
||||
for key in (
|
||||
"schema_version", "attachment_ids", "part_type", "views", "overall_geometry",
|
||||
"surfaces", "profiles", "holes", "bends", "measurements", "uncertainties", "assumptions",
|
||||
)
|
||||
if observation.get(key) not in (None, [], {})
|
||||
}
|
||||
return json.dumps(compact, ensure_ascii=False, separators=(",", ":"))
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Optional image metadata and computer-vision hints for image observations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
|
||||
def image_metadata(data: bytes) -> dict[str, Any]:
|
||||
"""Read safe image metadata without changing the original upload."""
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
with Image.open(io.BytesIO(data)) as image:
|
||||
normalized = ImageOps.exif_transpose(image)
|
||||
return {
|
||||
"width": int(normalized.width),
|
||||
"height": int(normalized.height),
|
||||
"format": str(image.format or "").lower(),
|
||||
"orientation": "landscape" if normalized.width >= normalized.height else "portrait",
|
||||
"has_alpha": "A" in normalized.getbands(),
|
||||
}
|
||||
except Exception as error:
|
||||
return {"error": f"image metadata unavailable: {type(error).__name__}"}
|
||||
|
||||
|
||||
def cv_hints(data: bytes) -> dict[str, Any]:
|
||||
"""Return conservative CV hints; OpenCV is intentionally optional."""
|
||||
try:
|
||||
import cv2 # type: ignore
|
||||
import numpy as np # type: ignore
|
||||
except Exception:
|
||||
return {"available": False, "hints": []}
|
||||
try:
|
||||
image = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_GRAYSCALE)
|
||||
if image is None:
|
||||
return {"available": True, "hints": [], "error": "image decode failed"}
|
||||
edges = cv2.Canny(image, 50, 150)
|
||||
lines = cv2.HoughLinesP(edges, 1, 3.141592653589793 / 180, threshold=50, minLineLength=30, maxLineGap=8)
|
||||
line_hints = []
|
||||
for line in (lines[:32] if lines is not None else []):
|
||||
x1, y1, x2, y2 = [int(value) for value in line[0]]
|
||||
line_hints.append({"type": "line", "start_px": [x1, y1], "end_px": [x2, y2]})
|
||||
return {"available": True, "hints": line_hints, "edge_pixels": int((edges > 0).sum())}
|
||||
except Exception as error:
|
||||
return {"available": True, "hints": [], "error": f"cv failed: {type(error).__name__}"}
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
"""Persistent, full-rebuild orchestration for node-by-node CDSL authoring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, materialize_fragment, validate_fragment
|
||||
from app.services.engine_service import QualityVerificationError, build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl
|
||||
from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan
|
||||
from app.services.quality import validate_verification
|
||||
from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status
|
||||
from app.services.storage import WorkspaceStore, write_json
|
||||
from app.services.visual_review import VisualReviewError, review_checkpoint
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
|
||||
|
||||
Completion = Callable[[list[dict[str, Any]], list[dict[str, Any]], ProviderConfig, ProviderModel, str | None], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
PLAN_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "plan_generation_task",
|
||||
"description": "Create the complete immutable requirement list and executable feature DAG before authoring any CDSL.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {"type": "string", "const": "cad.generation-plan.v2"},
|
||||
"plan_id": {"type": "string", "minLength": 1},
|
||||
"requirements": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"source": {"enum": ["explicit", "assumption"]},
|
||||
"priority": {"enum": ["hard", "soft"]},
|
||||
"description": {"type": "string", "minLength": 1},
|
||||
"value": {},
|
||||
"unit": {"type": "string"},
|
||||
"tolerance": {},
|
||||
},
|
||||
"required": ["id", "source", "priority", "description"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"assumptions": {"type": "array", "items": {"type": "string"}},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"intent": {"type": "string"},
|
||||
"atomic_id": {"type": "string", "minLength": 1},
|
||||
"depends_on": {"type": "array", "items": {"type": "string"}},
|
||||
"requires_topology": {"type": "boolean"},
|
||||
"topology_query": {"type": "object"},
|
||||
"requirement_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"verification_rules": {"type": "array", "items": {"type": "object"}},
|
||||
"review_targets": {"type": "array", "items": {"type": "object"}},
|
||||
},
|
||||
"required": ["id", "intent", "atomic_id", "depends_on", "requirement_ids", "verification_rules", "review_targets"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["schema_version", "plan_id", "requirements", "assumptions", "nodes"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
FRAGMENT_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_cdsl_fragment",
|
||||
"description": "Generate only the active plan node's additive CDSL fragment. Never replace or mutate existing CDSL.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {"type": "string", "const": "cad.cdsl-fragment.v1"},
|
||||
"node_id": {"type": "string", "minLength": 1},
|
||||
"base_revision_id": {"type": "string"},
|
||||
"base_cdsl_sha256": {"type": "string", "minLength": 64, "maxLength": 64},
|
||||
"required_snapshot_id": {"type": "string"},
|
||||
"add_sketches": {"type": "array", "items": {"type": "object"}},
|
||||
"add_features": {"type": "array", "items": {"type": "object"}},
|
||||
"verification_rules": {"type": "array", "items": {"type": "object"}},
|
||||
"assumptions": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["schema_version", "node_id", "base_revision_id", "base_cdsl_sha256", "add_sketches", "add_features", "verification_rules", "assumptions"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class IncrementalGenerationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _tool_response(response: dict[str, Any], expected_name: str) -> dict[str, Any]:
|
||||
try:
|
||||
call = response["choices"][0]["message"]["tool_calls"][0]
|
||||
if call["function"]["name"] != expected_name:
|
||||
raise KeyError("wrong tool")
|
||||
result = json.loads(call["function"]["arguments"])
|
||||
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
|
||||
raise IncrementalGenerationError(f"Author did not return a valid {expected_name} call") from error
|
||||
if not isinstance(result, dict):
|
||||
raise IncrementalGenerationError(f"{expected_name} arguments must be an object")
|
||||
return result
|
||||
|
||||
|
||||
def _node_by_id(spec: dict[str, Any], node_id: str) -> dict[str, Any]:
|
||||
node = next((item for item in spec.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None)
|
||||
if node is None:
|
||||
raise IncrementalGenerationError(f"Generation plan has no node {node_id}")
|
||||
return node
|
||||
|
||||
|
||||
def _fragment_node_context(node: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Expose semantic node intent, not backend-owned CDSL implementation IDs."""
|
||||
fields = (
|
||||
"id", "intent", "atomic_id", "depends_on", "requires_topology",
|
||||
"requires_sketch", "topology_query", "requirement_ids",
|
||||
"verification_rules", "review_targets",
|
||||
)
|
||||
return {field: deepcopy(node[field]) for field in fields if field in node}
|
||||
|
||||
|
||||
def _ready_node(spec: dict[str, Any], completed: set[str], has_topology: bool) -> dict[str, Any] | None:
|
||||
for node in spec.get("nodes") or ():
|
||||
if not isinstance(node, dict) or node.get("status") == "completed":
|
||||
continue
|
||||
if all(str(item) in completed for item in node.get("depends_on") or ()) and (not node.get("requires_topology") or has_topology):
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _error_code(error: Exception) -> str:
|
||||
message = str(error)
|
||||
for code in (
|
||||
"SELECTOR_AMBIGUOUS", "SELECTOR_NOT_FOUND", "SELECTOR_GEOMETRY_MISMATCH",
|
||||
"TOPOLOGY_SNAPSHOT_STALE", "TOPOLOGY_REQUIRED", "VERIFICATION_FAILED", "SELECTOR_OWNER_REQUIRED",
|
||||
):
|
||||
if code in message:
|
||||
return code
|
||||
return type(error).__name__.upper()
|
||||
|
||||
|
||||
def _mark_affected_nodes_stale(plan: dict[str, Any], node_ids: list[str], *, reason: str) -> tuple[dict[str, Any], set[str]]:
|
||||
"""Invalidate the union of every affected node's downstream closure."""
|
||||
updated = deepcopy(plan)
|
||||
stale: set[str] = set()
|
||||
for node_id in dict.fromkeys(node_ids):
|
||||
stale.update(descendant_closure(updated, node_id))
|
||||
updated = mark_nodes_stale(updated, node_id, reason=reason)
|
||||
return updated, stale
|
||||
|
||||
|
||||
def _source_image_paths(store: WorkspaceStore, conversation: dict[str, Any]) -> list[Path]:
|
||||
conversation_id = str(conversation.get("conversation_id") or "")
|
||||
paths: list[Path] = []
|
||||
for attachment in conversation.get("attachments") or ():
|
||||
if not isinstance(attachment, dict) or attachment.get("kind") != "image" or not conversation_id:
|
||||
continue
|
||||
try:
|
||||
paths.append(store.conversation_attachment_path(conversation_id, str(attachment.get("path") or "")))
|
||||
except ValueError:
|
||||
continue
|
||||
return paths
|
||||
|
||||
|
||||
class IncrementalGenerationRunner:
|
||||
"""The agent-facing controller. It is deliberately full-rebuild and restart-safe."""
|
||||
|
||||
def __init__(self, settings: Settings, store: WorkspaceStore, complete: Completion) -> None:
|
||||
self.settings = settings
|
||||
self.store = store
|
||||
self._complete = complete
|
||||
|
||||
async def _call(self, messages: list[dict[str, Any]], provider: ProviderConfig, model: ProviderModel, tool: dict[str, Any], name: str) -> dict[str, Any]:
|
||||
response = await self._complete(messages, [tool], provider, model, name)
|
||||
return _tool_response(response, name)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
request: str,
|
||||
conversation: dict[str, Any],
|
||||
provider: ProviderConfig,
|
||||
model: ProviderModel,
|
||||
author_messages: list[dict[str, Any]],
|
||||
part_skills: dict[str, Any] | None = None,
|
||||
references: list[str] | None = None,
|
||||
already_started: bool = False,
|
||||
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
||||
plan_diagnostic_path = ""
|
||||
try:
|
||||
task = self.store.ensure_task(task_id or None, request)
|
||||
task_id = str(task["task_id"])
|
||||
# Configuration is a start gate: visual review is required, never silently skipped.
|
||||
self.settings.resolve_review_model()
|
||||
renderer_ready, renderer_error = renderer_status()
|
||||
if not renderer_ready:
|
||||
raise IncrementalGenerationError(renderer_error)
|
||||
task = self.store.read_task(task_id) if already_started else self.store.start_generation(task_id, request=request)
|
||||
if not isinstance(task, dict):
|
||||
raise IncrementalGenerationError("Generation task is unavailable")
|
||||
yield "generation_plan", {"taskId": task_id, "status": "running"}
|
||||
engine = load_engine(self.settings)
|
||||
persisted_spec = self.store.read_generation_spec(task_id)
|
||||
if persisted_spec is not None:
|
||||
spec = validate_generation_plan(
|
||||
persisted_spec,
|
||||
supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()),
|
||||
task_id=task_id,
|
||||
)
|
||||
yield "generation_plan", {
|
||||
"taskId": task_id, "status": "success", "planId": spec["plan_id"], "resumed": True,
|
||||
"requirements": spec["requirements"],
|
||||
"nodes": [{"id": node["id"], "intent": node["intent"], "status": node.get("status", "planned")} for node in spec["nodes"]],
|
||||
}
|
||||
else:
|
||||
planning_messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Create one complete cad.generation-plan.v2 before creating geometry. "
|
||||
"Turn every user constraint into a requirement with source explicit or assumption; "
|
||||
"use source assumption for missing dimensions and never ask the user questions. "
|
||||
"Every hard requirement must belong to at least one node. Use only runtime-supported atomic ids. "
|
||||
"Do not output expected_feature_ids or expected_sketch_ids: the backend derives all CDSL object ids from node.id."
|
||||
),
|
||||
},
|
||||
*author_messages,
|
||||
]
|
||||
raw_spec = await self._call(planning_messages, provider, model, PLAN_TOOL, "plan_generation_task")
|
||||
try:
|
||||
spec = validate_generation_plan(
|
||||
raw_spec,
|
||||
supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()),
|
||||
task_id=task_id,
|
||||
)
|
||||
except GenerationPlanError as error:
|
||||
plan_diagnostic_path = self.store.write_generation_failure(task_id, {
|
||||
"schema_version": "cad.generation-plan-diagnostic.v1",
|
||||
"stage": "generation_plan_validation",
|
||||
"message": str(error),
|
||||
"raw_plan": raw_spec,
|
||||
})
|
||||
raise
|
||||
self.store.write_generation_spec(task_id, spec)
|
||||
yield "generation_plan", {
|
||||
"taskId": task_id, "status": "success", "planId": spec["plan_id"],
|
||||
"requirements": spec["requirements"], "nodes": [{"id": node["id"], "intent": node["intent"], "status": "planned"} for node in spec["nodes"]],
|
||||
}
|
||||
|
||||
completed: set[str] = {
|
||||
str(node["id"]) for node in spec["nodes"] if node.get("status") == "completed"
|
||||
}
|
||||
last_built: dict[str, Any] | None = None
|
||||
task = self.store.read_task(task_id) or task
|
||||
active_revision_id = str(task.get("active_revision") or "")
|
||||
# A process can stop between build and review. That checkpoint is
|
||||
# not a legal base revision, so recover its parent before resuming.
|
||||
active_record = next(
|
||||
(item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == active_revision_id),
|
||||
None,
|
||||
)
|
||||
if isinstance(active_record, dict) and active_record.get("visibility") == "checkpoint":
|
||||
current_node = str(active_record.get("node_id") or "")
|
||||
if current_node and current_node not in completed:
|
||||
recovered = str(active_record.get("parent_revision_id") or "")
|
||||
self.store.rollback_to_revision(task_id, recovered, branch_id=f"branch_{secrets.token_hex(4)}")
|
||||
active_revision_id = recovered
|
||||
task = self.store.read_task(task_id) or task
|
||||
base_path = self.store.current_cdsl_path(task_id)
|
||||
base_cdsl = json.loads(base_path.read_text(encoding="utf-8")) if base_path and base_path.is_file() else None
|
||||
while True:
|
||||
topology_path = self.store.current_topology_path(task_id)
|
||||
topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None
|
||||
node = _ready_node(spec, completed, bool(topology and topology.get("records")))
|
||||
if node is None:
|
||||
if len(completed) == len(spec["nodes"]):
|
||||
self.store.finish_generation(task_id, lifecycle="completed")
|
||||
if last_built is not None:
|
||||
yield "cad_result", self._result_payload(last_built, lifecycle="completed", checkpoint=False)
|
||||
yield "task_terminal", {"taskId": task_id, "lifecycle": "completed", "revisionId": str((self.store.read_task(task_id) or {}).get("published_revision") or "")}
|
||||
return
|
||||
waiting = [item["id"] for item in spec["nodes"] if item.get("id") not in completed]
|
||||
raise IncrementalGenerationError("No executable plan node is ready: " + ", ".join(waiting))
|
||||
|
||||
node_id = str(node["id"])
|
||||
self.store.set_active_node(task_id, node_id)
|
||||
yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "authoring"}
|
||||
attempts = node.setdefault("attempts", {"authoring": 0, "repair": 0, "replan": 0})
|
||||
feedback = ""
|
||||
while True:
|
||||
attempt_kind = "authoring" if int(attempts.get("authoring") or 0) < self.settings.node_authoring_attempts else "repair"
|
||||
if attempt_kind == "repair" and int(attempts.get("repair") or 0) >= self.settings.node_repair_attempts:
|
||||
if int(attempts.get("replan") or 0) >= self.settings.node_replan_attempts:
|
||||
raise IncrementalGenerationError(f"Node {node_id} exhausted its authoring, repair, and replan budgets: {feedback}")
|
||||
attempts["replan"] = int(attempts.get("replan") or 0) + 1
|
||||
spec = await self._replan(spec, node_id, feedback, provider, model, author_messages, engine)
|
||||
self.store.write_generation_spec(task_id, spec)
|
||||
node = _node_by_id(spec, node_id)
|
||||
node["attempts"] = {"authoring": 0, "repair": 0, "replan": attempts["replan"]}
|
||||
attempts = node["attempts"]
|
||||
yield "rollback", {"taskId": task_id, "nodeId": node_id, "reason": "node_replan"}
|
||||
continue
|
||||
attempts[attempt_kind] = int(attempts.get(attempt_kind) or 0) + 1
|
||||
required_snapshot = str((topology or {}).get("snapshot_id") or "") if node.get("requires_topology") else ""
|
||||
node_requirement_ids = set(node.get("requirement_ids") or ())
|
||||
author_context = {
|
||||
"active_node": _fragment_node_context(node),
|
||||
"requirements": [
|
||||
requirement for requirement in spec["requirements"]
|
||||
if requirement.get("id") in node_requirement_ids
|
||||
],
|
||||
"base_revision_id": active_revision_id,
|
||||
"base_cdsl_sha256": cdsl_sha256(base_cdsl),
|
||||
"required_snapshot_id": required_snapshot,
|
||||
"base_cdsl": base_cdsl,
|
||||
"topology": topology if required_snapshot else None,
|
||||
"previous_failure": feedback,
|
||||
}
|
||||
fragment_messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Author exactly one additive CDSL fragment for the active node. Do not change existing CDSL or generate unsupported topology selectors. "
|
||||
"Do not set output id, sketch_id, or depends_on: the backend assigns them. "
|
||||
"Use owner_node_id instead of owner_feature_id and source_node_ids instead of source_feature_ids when referring to plan nodes."
|
||||
),
|
||||
},
|
||||
*author_messages,
|
||||
{"role": "user", "content": json.dumps(author_context, ensure_ascii=False)},
|
||||
]
|
||||
try:
|
||||
raw_fragment = await self._call(fragment_messages, provider, model, FRAGMENT_TOOL, "generate_cdsl_fragment")
|
||||
fragment = validate_fragment(
|
||||
raw_fragment, plan=spec, node_id=node_id, base_revision_id=active_revision_id,
|
||||
base_cdsl=base_cdsl, required_snapshot_id=required_snapshot,
|
||||
)
|
||||
cdsl = materialize_fragment(base_cdsl, fragment)
|
||||
cdsl, repairs = normalize_cdsl_for_engine(cdsl)
|
||||
validate_cdsl(cdsl, engine)
|
||||
rules = [*node.get("verification_rules", []), *fragment.get("verification_rules", [])]
|
||||
verification = {"rules": rules} if rules else None
|
||||
validate_verification(verification, cdsl)
|
||||
fragment_base_revision = str(fragment["base_revision_id"])
|
||||
built = await asyncio.to_thread(
|
||||
build_revision,
|
||||
settings=self.settings, store=self.store, task_id=task_id, request=request, cdsl=cdsl,
|
||||
reference_ids=references or [], summary=node.get("intent") or node_id,
|
||||
parent_revision_id=active_revision_id or None,
|
||||
operation={"type": "cdsl_fragment", "node_id": node_id},
|
||||
part_skills=part_skills, generation_assumptions=[*spec.get("assumptions", []), *fragment.get("assumptions", [])],
|
||||
verification=verification, node_id=node_id, fragment=fragment,
|
||||
branch_id=str((self.store.read_task(task_id) or {}).get("active_branch_id") or "main"), visibility="checkpoint",
|
||||
)
|
||||
candidate_revision_id = str(built["revision_id"])
|
||||
try:
|
||||
render_dir = self.store.revision_dir(task_id, candidate_revision_id) / "review"
|
||||
manifest = await asyncio.to_thread(
|
||||
render_checkpoint,
|
||||
self.settings,
|
||||
step_path=self.store.artifact_path(task_id, str(built["step_path"])),
|
||||
output_dir=render_dir,
|
||||
review_targets=node.get("review_targets"),
|
||||
)
|
||||
final_checkpoint = len(completed) + 1 == len(spec["nodes"])
|
||||
review_requirements = spec["requirements"] if final_checkpoint else [
|
||||
requirement for requirement in spec["requirements"]
|
||||
if requirement.get("id") in set(node.get("requirement_ids") or ())
|
||||
]
|
||||
review = await review_checkpoint(
|
||||
self.settings, manifest=manifest, requirements=review_requirements, node_id=node_id,
|
||||
deterministic_report={"quality_status": built.get("quality_status"), "verification": built.get("verification_summary", {})},
|
||||
source_images=_source_image_paths(self.store, conversation) if not completed or final_checkpoint else [],
|
||||
final_checkpoint=final_checkpoint,
|
||||
)
|
||||
except (ReviewRenderError, VisualReviewError) as error:
|
||||
self.store.rollback_to_revision(task_id, fragment_base_revision, branch_id=f"branch_{secrets.token_hex(4)}")
|
||||
active_revision_id = fragment_base_revision
|
||||
rollback_path = self.store.current_cdsl_path(task_id)
|
||||
base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None
|
||||
raise error
|
||||
active_revision_id = candidate_revision_id
|
||||
base_cdsl = cdsl
|
||||
manifest_relative = (render_dir / "render-manifest.json").relative_to(self.store.task_dir(task_id)).as_posix()
|
||||
review_relative = (render_dir / "visual-review.json").relative_to(self.store.task_dir(task_id)).as_posix()
|
||||
write_json(render_dir / "visual-review.json", review)
|
||||
self.store.update_revision_metadata(task_id, active_revision_id, {"render_manifest_path": manifest_relative, "visual_review_path": review_relative})
|
||||
yield "render_review", {"taskId": task_id, "revisionId": active_revision_id, "nodeId": node_id, "review": review}
|
||||
if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85:
|
||||
affected_nodes = [
|
||||
str(item) for item in review.get("affected_node_ids") or ()
|
||||
if any(str(candidate.get("id") or "") == str(item) for candidate in spec.get("nodes") or ())
|
||||
] or [node_id]
|
||||
rollback_base = self.store.rollback_anchor_for_nodes(
|
||||
task_id,
|
||||
affected_nodes,
|
||||
fallback_revision_id=fragment_base_revision,
|
||||
)
|
||||
branch = f"branch_{secrets.token_hex(4)}"
|
||||
self.store.rollback_to_revision(task_id, rollback_base, branch_id=branch)
|
||||
spec, stale_nodes = _mark_affected_nodes_stale(
|
||||
spec, affected_nodes, reason="high_confidence_visual_review",
|
||||
)
|
||||
self.store.write_generation_spec(task_id, spec)
|
||||
completed.difference_update(stale_nodes)
|
||||
active_revision_id = rollback_base
|
||||
rollback_path = self.store.current_cdsl_path(task_id)
|
||||
base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None
|
||||
feedback = "High-confidence visual review requires correction: " + "; ".join(review.get("evidence") or [])
|
||||
yield "rollback", {
|
||||
"taskId": task_id, "nodeId": node_id, "revisionId": active_revision_id,
|
||||
"reason": "visual_review", "affectedNodeIds": affected_nodes,
|
||||
}
|
||||
continue
|
||||
completed.add(node_id)
|
||||
node["status"] = "completed"
|
||||
node.pop("stale", None)
|
||||
self.store.write_generation_spec(task_id, spec)
|
||||
last_built = built
|
||||
payload = self._result_payload(built, lifecycle="running", checkpoint=True)
|
||||
yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "success", "revisionId": active_revision_id}
|
||||
yield "cad_result", payload
|
||||
break
|
||||
except (CdslFragmentError, GenerationPlanError, QualityVerificationError, ReviewRenderError, VisualReviewError, ValueError, RuntimeError) as error:
|
||||
feedback = str(error)
|
||||
failure = {
|
||||
"schema_version": "cad.generation-failure.v1",
|
||||
"node_id": node_id,
|
||||
"stage": attempt_kind,
|
||||
"error_code": _error_code(error),
|
||||
"message": feedback,
|
||||
"requirement_ids": list(node.get("requirement_ids") or ()),
|
||||
"selector": {"required_snapshot_id": required_snapshot},
|
||||
"geometry_delta": {},
|
||||
"recommended_rollback_revision": fragment_base_revision if "fragment_base_revision" in locals() else active_revision_id,
|
||||
}
|
||||
failure_path = self.store.write_generation_failure(task_id, failure)
|
||||
yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "error", "attempt": attempt_kind, "message": feedback}
|
||||
# A failed build never becomes the active base; retries are safe and deterministic.
|
||||
continue
|
||||
except Exception as error:
|
||||
failure = {
|
||||
"schema_version": "cad.generation-failure.v1",
|
||||
"message": str(error),
|
||||
"active_node_id": str((self.store.read_task(task_id) or {}).get("active_node_id") or ""),
|
||||
}
|
||||
if plan_diagnostic_path:
|
||||
failure["plan_diagnostic_path"] = plan_diagnostic_path
|
||||
self.store.finish_generation(task_id, lifecycle="failed", failure=failure)
|
||||
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "message": str(error)}
|
||||
|
||||
async def _replan(
|
||||
self,
|
||||
spec: dict[str, Any],
|
||||
node_id: str,
|
||||
feedback: str,
|
||||
provider: ProviderConfig,
|
||||
model: ProviderModel,
|
||||
author_messages: list[dict[str, Any]],
|
||||
engine: Any,
|
||||
) -> dict[str, Any]:
|
||||
raw = await self._call([
|
||||
{"role": "system", "content": "Replan only the failed node and its downstream nodes. Preserve completed node definitions and all requirements."},
|
||||
*author_messages,
|
||||
{"role": "user", "content": json.dumps({"existing_plan": spec, "failed_node_id": node_id, "failure": feedback}, ensure_ascii=False)},
|
||||
], provider, model, PLAN_TOOL, "plan_generation_task")
|
||||
next_spec = validate_generation_plan(raw, supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), task_id=str(spec.get("task_id") or ""))
|
||||
stale = {item["id"] for item in mark_nodes_stale(spec, node_id, reason="replan").get("nodes") or [] if item.get("stale")}
|
||||
prior = {item["id"]: item for item in spec.get("nodes") or []}
|
||||
for node in next_spec["nodes"]:
|
||||
if node["id"] not in stale and node["id"] in prior:
|
||||
old = prior[node["id"]]
|
||||
for key in ("atomic_id", "depends_on", "cdsl_feature_ids"):
|
||||
if node.get(key) != old.get(key):
|
||||
raise IncrementalGenerationError(f"Replan changed non-stale node {node['id']}")
|
||||
return next_spec
|
||||
|
||||
@staticmethod
|
||||
def _result_payload(built: dict[str, Any], *, lifecycle: str, checkpoint: bool) -> dict[str, Any]:
|
||||
return {
|
||||
"taskId": built["task_id"], "revisionId": built["revision_id"],
|
||||
"cdslPath": built["cdsl_path"], "stepPath": built["step_path"], "glbPath": built["glb_path"],
|
||||
"reportPath": built["report_path"], "parametersPath": built.get("parameters_path"),
|
||||
"selectorPath": built.get("selector_path"), "edgesPath": built.get("edges_path"), "topologyPath": built.get("topology_path"),
|
||||
"summary": built.get("summary") or "CDSL checkpoint", "referenceIds": built.get("reference_ids") or [],
|
||||
"engine": built.get("engine") or "cdsl_only", "qualityStatus": built.get("quality_status") or "",
|
||||
"qualityPath": built.get("quality_path"), "assumptions": built.get("generation_assumptions") or [],
|
||||
"snapshotPaths": built.get("snapshot_paths") or [], "snapshotStatus": built.get("snapshot_status") or "unavailable",
|
||||
"lifecycle": lifecycle, "checkpoint": checkpoint,
|
||||
}
|
||||
@@ -9,10 +9,28 @@ from app.settings import Settings
|
||||
|
||||
|
||||
TOKEN_PATTERN = re.compile(r"[a-zA-Z0-9_]+")
|
||||
CN_TOKEN_PATTERN = re.compile(r"[\u4e00-\u9fff]")
|
||||
|
||||
TERM_ALIASES = {
|
||||
"法兰": {"flange", "mounting_plate"},
|
||||
"套筒": {"sleeve", "tube", "cylinder"},
|
||||
"孔": {"hole", "circle", "circles"},
|
||||
"沉头": {"countersunk", "counterbore"},
|
||||
"沉孔": {"counterbore", "counterbored"},
|
||||
"槽": {"slot", "obround"},
|
||||
"底板": {"plate", "rectangle", "mounting_plate"},
|
||||
"轴": {"shaft", "cylinder"},
|
||||
}
|
||||
|
||||
|
||||
def tokens(value: str) -> set[str]:
|
||||
return {token.lower() for token in TOKEN_PATTERN.findall(value)}
|
||||
text = str(value or "").lower()
|
||||
result = {token.lower() for token in TOKEN_PATTERN.findall(text)}
|
||||
result.update(CN_TOKEN_PATTERN.findall(text))
|
||||
for term, aliases in TERM_ALIASES.items():
|
||||
if term in text:
|
||||
result.update(aliases)
|
||||
return result
|
||||
|
||||
|
||||
class CdslLibrary:
|
||||
@@ -38,6 +56,8 @@ class CdslLibrary:
|
||||
"profiles": record.get("profiles", []),
|
||||
"features": record.get("features", []),
|
||||
"summary": record.get("summary", ""),
|
||||
"family": record.get("family", ""),
|
||||
"atomics": record.get("features", []),
|
||||
}
|
||||
for record in self._records()[:limit]
|
||||
]
|
||||
@@ -46,6 +66,8 @@ class CdslLibrary:
|
||||
corpus = " ".join([
|
||||
record.get("part_id", ""),
|
||||
record.get("source_name", ""),
|
||||
record.get("summary", ""),
|
||||
record.get("family", ""),
|
||||
" ".join(record.get("profiles", [])),
|
||||
" ".join(record.get("features", [])),
|
||||
" ".join(record.get("parameters", [])),
|
||||
@@ -60,6 +82,8 @@ class CdslLibrary:
|
||||
"profiles": record.get("profiles", []),
|
||||
"features": record.get("features", []),
|
||||
"summary": record.get("summary", ""),
|
||||
"family": record.get("family", ""),
|
||||
"atomics": record.get("features", []),
|
||||
}
|
||||
for _, record in scored[:limit]
|
||||
]
|
||||
|
||||
@@ -58,7 +58,6 @@ class PartSkill:
|
||||
bridge: str
|
||||
source: str
|
||||
related: tuple[str, ...]
|
||||
capability_translation_rules: tuple[str, ...]
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any]) -> "PartSkill":
|
||||
@@ -72,9 +71,6 @@ class PartSkill:
|
||||
bridge=str(value["bridge"]),
|
||||
source=str(value["source"]),
|
||||
related=tuple(str(item) for item in value.get("related") or []),
|
||||
capability_translation_rules=tuple(
|
||||
str(item) for item in value.get("capability_translation_rules") or []
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -84,6 +80,7 @@ class PartSkillLibrary:
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = Path(root)
|
||||
payload = json.loads((self.root / "catalog.json").read_text(encoding="utf-8"))
|
||||
self.catalog_version = str(payload.get("schema_version") or "1.0")
|
||||
self.max_planning = int(payload.get("max_planning") or 1)
|
||||
self.max_support = int(payload.get("max_support") or 3)
|
||||
self.skills = tuple(PartSkill.from_mapping(item) for item in payload.get("skills") or [])
|
||||
@@ -112,9 +109,10 @@ class PartSkillLibrary:
|
||||
"kind": skill.kind,
|
||||
"category": skill.kind,
|
||||
"title": skill.title,
|
||||
"version": self.catalog_version,
|
||||
"summary": skill.title,
|
||||
"source": skill.source,
|
||||
"bridge": skill.bridge,
|
||||
"capability_translation_rules": list(skill.capability_translation_rules),
|
||||
"selection": selection,
|
||||
"matched_triggers": list(matched),
|
||||
}
|
||||
@@ -207,14 +205,7 @@ class PartSkillLibrary:
|
||||
revision_ids = [str(item) for item in (revision or {}).get("part_skill_ids") or [] if str(item) in self.by_id]
|
||||
if revision_ids:
|
||||
return revision_ids
|
||||
# A valid DesignIntent creates a task before the first runtime build.
|
||||
# Its canonical backend selection must therefore be inheritable too.
|
||||
intent_id = str((task or {}).get("current_design_intent_id") or "")
|
||||
intent = next(
|
||||
(item for item in (task or {}).get("design_intents") or [] if str(item.get("intent_id") or "") == intent_id),
|
||||
None,
|
||||
)
|
||||
return [str(item) for item in (intent or {}).get("part_skill_ids") or [] if str(item) in self.by_id]
|
||||
return []
|
||||
|
||||
def render_context(self, selection: dict[str, Any]) -> str:
|
||||
records = selection.get("skills") or []
|
||||
@@ -233,62 +224,9 @@ class PartSkillLibrary:
|
||||
return "\n".join(sections)
|
||||
|
||||
def audit(self, selection: dict[str, Any], cdsl: dict[str, Any], assumptions: list[str] | None = None) -> dict[str, Any]:
|
||||
features = [item for item in cdsl.get("features") or [] if isinstance(item, dict)]
|
||||
feature_ids = [str(item.get("id")) for item in features if item.get("id")]
|
||||
atomics = [str(item.get("atomic_id")) for item in features]
|
||||
sketches = [item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict)]
|
||||
profiles = [str((item.get("profile") or {}).get("type")) for item in sketches]
|
||||
evidence = {
|
||||
"feature_ids": feature_ids,
|
||||
"atomic_ids": sorted(set(atomics)),
|
||||
"profile_types": sorted(set(profiles)),
|
||||
"features": [
|
||||
{
|
||||
"id": str(item.get("id") or ""),
|
||||
"atomic_id": str(item.get("atomic_id") or ""),
|
||||
"sketch_id": str(item.get("sketch_id") or ""),
|
||||
"params": item.get("params") if isinstance(item.get("params"), dict) else {},
|
||||
}
|
||||
for item in features
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"id": str(item.get("id") or ""),
|
||||
"type": str((item.get("profile") or {}).get("type") or ""),
|
||||
"profile": item.get("profile") if isinstance(item.get("profile"), dict) else {},
|
||||
}
|
||||
for item in sketches
|
||||
],
|
||||
}
|
||||
translations: list[dict[str, Any]] = []
|
||||
for record in selection.get("skills") or []:
|
||||
skill_id = str(record.get("id"))
|
||||
status = "exact"
|
||||
reason = "The selected structural guidance is represented by the submitted CDSL plan."
|
||||
if skill_id == "functional/flange-bolt-circle":
|
||||
if "circles" in profiles and "extrude_cut_blind" in atomics:
|
||||
status, reason = "expanded", "Circular bolt layout is represented by explicit circle geometry because no circular-pattern atomic exists."
|
||||
else:
|
||||
status, reason = "blocked", "No explicit circular bolt layout evidence was found in the CDSL."
|
||||
elif skill_id == "atomic/threaded-hole-creation":
|
||||
status, reason = "approximated", "The current runtime preserves a cylindrical bore but does not generate helical thread topology."
|
||||
elif skill_id == "atomic/fillet-chamfer-last":
|
||||
finishing = [item for item in features if item.get("atomic_id") in {"fillet", "chamfer"}]
|
||||
if not finishing:
|
||||
status, reason = "omitted", "No stable finishing selector was submitted; edge treatment was omitted."
|
||||
elif skill_id == "atomic/pattern-holes-from-datum":
|
||||
if "circles" in profiles or "circle_grid" in profiles:
|
||||
status, reason = "expanded", "The layout is represented by explicit profile circles."
|
||||
elif not any(item in {"pattern_linear", "pattern_mirror"} for item in atomics):
|
||||
status, reason = "blocked", "No supported pattern or explicit circle layout was found."
|
||||
translation: dict[str, Any] = {"skill_id": skill_id, "status": status, "reason": reason, "evidence": evidence}
|
||||
if skill_id == "functional/flange-bolt-circle" and status == "expanded":
|
||||
translation["translation"] = "circular_pattern_to_explicit_circles"
|
||||
elif skill_id == "atomic/threaded-hole-creation":
|
||||
translation["translation"] = "thread_geometry_omitted"
|
||||
elif skill_id == "atomic/fillet-chamfer-last" and status == "omitted":
|
||||
translation["translation"] = "selector_unavailable"
|
||||
translations.append(translation)
|
||||
# Skills are prompt knowledge only. Do not interpret a selected skill
|
||||
# as geometry, capability translation, or an executable model rule.
|
||||
del cdsl
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"request": selection.get("request", ""),
|
||||
@@ -298,6 +236,4 @@ class PartSkillLibrary:
|
||||
"inherited_skill_ids": list(selection.get("inherited_skill_ids") or []),
|
||||
"conflict": selection.get("conflict"),
|
||||
"assumptions": [str(item) for item in assumptions or []],
|
||||
"evidence": evidence,
|
||||
"capability_translations": translations,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Generic, model-family-independent verification for direct CDSL revisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
QUALITY_RULE_TYPES = frozenset({
|
||||
"bbox", "solid_count", "feature_count", "hole_count", "hole_diameter",
|
||||
"hole_center", "overall_length", "overall_width", "overall_height",
|
||||
"overall_diameter", "through_condition",
|
||||
})
|
||||
FEATURE_RULE_TYPES = frozenset({"hole_count", "hole_diameter", "hole_center", "through_condition"})
|
||||
_SEVERITIES = {"blocking", "warning", "informational"}
|
||||
|
||||
|
||||
def _bbox_bounds(engine_result: dict[str, Any], feature_id: str | None = None) -> tuple[list[float], list[float]] | None:
|
||||
"""Return a whole-model or feature-owned runtime bounding box."""
|
||||
candidates: list[Any] = []
|
||||
if feature_id:
|
||||
for record in engine_result.get("topology_records") or []:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
owners = record.get("owner_feature_ids") or []
|
||||
# Runtime records may carry the current operation in feature_id
|
||||
# while owner_feature_ids identify the actual geometry owner. Use
|
||||
# ownership when present so inherited faces do not pollute a
|
||||
# feature-local measurement.
|
||||
matches = feature_id in owners if owners else record.get("feature_id") == feature_id
|
||||
if matches:
|
||||
geometry = record.get("geometry")
|
||||
if isinstance(geometry, dict):
|
||||
candidates.append(geometry.get("bbox_mm"))
|
||||
else:
|
||||
bbox = engine_result.get("bbox_mm")
|
||||
if isinstance(bbox, dict):
|
||||
candidates.append([
|
||||
*(bbox.get("min") or []),
|
||||
*(bbox.get("max") or []),
|
||||
])
|
||||
|
||||
boxes = [
|
||||
value for value in candidates
|
||||
if isinstance(value, list) and len(value) == 6
|
||||
and all(isinstance(item, (int, float)) and math.isfinite(float(item)) for item in value)
|
||||
]
|
||||
if not boxes:
|
||||
return None
|
||||
minimum = [min(box[index] for box in boxes) for index in (0, 1, 2)]
|
||||
maximum = [max(box[index] for box in boxes) for index in (3, 4, 5)]
|
||||
return [float(item) for item in minimum], [float(item) for item in maximum]
|
||||
|
||||
|
||||
def _bbox_value(engine_result: dict[str, Any], feature_id: str | None = None) -> dict[str, Any] | None:
|
||||
bounds = _bbox_bounds(engine_result, feature_id)
|
||||
if bounds is None:
|
||||
return None
|
||||
minimum, maximum = bounds
|
||||
return {
|
||||
"min": minimum,
|
||||
"max": maximum,
|
||||
"dimensions": [maximum[index] - minimum[index] for index in range(3)],
|
||||
}
|
||||
|
||||
|
||||
def _dimensions(engine_result: dict[str, Any]) -> list[float] | None:
|
||||
value = _bbox_value(engine_result)
|
||||
return value["dimensions"] if value else None
|
||||
|
||||
|
||||
def _feature(cdsl: dict[str, Any], feature_id: str | None) -> dict[str, Any] | None:
|
||||
return next((item for item in cdsl.get("features") or [] if isinstance(item, dict) and str(item.get("id")) == feature_id), None)
|
||||
|
||||
|
||||
def _sketch(cdsl: dict[str, Any], sketch_id: str | None) -> dict[str, Any] | None:
|
||||
return next((item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict) and str(item.get("id")) == sketch_id), None)
|
||||
|
||||
|
||||
def _circles(profile: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if profile.get("type") == "circle":
|
||||
center, radius = profile.get("center"), profile.get("radius_mm")
|
||||
if isinstance(center, list) and len(center) >= 2 and isinstance(radius, (int, float)):
|
||||
return [{"center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}]
|
||||
if profile.get("type") != "analytic_contours":
|
||||
return []
|
||||
return [
|
||||
{"center": [float(segment["center"][0]), float(segment["center"][1])], "radius_mm": float(segment["radius_mm"])}
|
||||
for contour in profile.get("contours") or [] if isinstance(contour, dict)
|
||||
for segment in contour.get("segments") or [] if isinstance(segment, dict)
|
||||
and segment.get("type") == "circle"
|
||||
and isinstance(segment.get("center"), list) and len(segment["center"]) >= 2
|
||||
and isinstance(segment.get("radius_mm"), (int, float))
|
||||
]
|
||||
|
||||
|
||||
def _circle_feature_bbox(feature: dict[str, Any] | None, sketch: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Derive a stable world-space bbox for a circular extrude feature."""
|
||||
if not isinstance(feature, dict) or not isinstance(sketch, dict):
|
||||
return None
|
||||
if str(feature.get("atomic_id") or "") != "extrude_add_blind":
|
||||
return None
|
||||
profile = sketch.get("profile")
|
||||
workplane = sketch.get("workplane")
|
||||
params = feature.get("params")
|
||||
if not isinstance(profile, dict) or profile.get("type") != "circle" or not isinstance(workplane, dict):
|
||||
return None
|
||||
center_local = profile.get("center")
|
||||
radius = profile.get("radius_mm")
|
||||
origin = workplane.get("origin_mm")
|
||||
x_dir = workplane.get("x_dir")
|
||||
y_dir = workplane.get("y_dir")
|
||||
normal = workplane.get("normal")
|
||||
distance = (params or {}).get("distance_mm") if isinstance(params, dict) else None
|
||||
if not isinstance(center_local, list) or len(center_local) < 2 or not _finite_number(radius):
|
||||
return None
|
||||
if not all(isinstance(value, list) and len(value) >= 3 for value in (origin, x_dir, y_dir, normal)) or not _finite_number(distance):
|
||||
return None
|
||||
center = [
|
||||
float(origin[index]) + float(center_local[0]) * float(x_dir[index]) + float(center_local[1]) * float(y_dir[index])
|
||||
for index in range(3)
|
||||
]
|
||||
points = []
|
||||
for axis_x in (-1.0, 1.0):
|
||||
for axis_y in (-1.0, 1.0):
|
||||
cross_section = [
|
||||
center[index]
|
||||
+ axis_x * float(radius) * float(x_dir[index])
|
||||
+ axis_y * float(radius) * float(y_dir[index])
|
||||
for index in range(3)
|
||||
]
|
||||
points.append(cross_section)
|
||||
points.append([
|
||||
cross_section[index] + float(distance) * float(normal[index])
|
||||
for index in range(3)
|
||||
])
|
||||
minimum = [min(point[index] for point in points) for index in range(3)]
|
||||
maximum = [max(point[index] for point in points) for index in range(3)]
|
||||
return {
|
||||
"min": minimum,
|
||||
"max": maximum,
|
||||
"dimensions": [maximum[index] - minimum[index] for index in range(3)],
|
||||
}
|
||||
|
||||
|
||||
def _finite_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value))
|
||||
|
||||
|
||||
def _validate_expected(kind: str, expected: Any, index: int) -> None:
|
||||
scalar_types = {
|
||||
"solid_count", "feature_count", "hole_count", "hole_diameter",
|
||||
"overall_length", "overall_width", "overall_height", "overall_diameter",
|
||||
}
|
||||
if kind in scalar_types and not _finite_number(expected):
|
||||
raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}")
|
||||
if kind == "bbox":
|
||||
valid_dimensions = isinstance(expected, list) and len(expected) == 3 and all(_finite_number(value) for value in expected)
|
||||
valid_ranges = isinstance(expected, dict) and set(expected) in ({"min", "max"}, {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"})
|
||||
if valid_ranges and set(expected) == {"min", "max"}:
|
||||
valid_ranges = all(
|
||||
isinstance(expected[key], list)
|
||||
and len(expected[key]) == 3
|
||||
and all(_finite_number(value) for value in expected[key])
|
||||
for key in ("min", "max")
|
||||
)
|
||||
elif valid_ranges:
|
||||
valid_ranges = all(_finite_number(expected[key]) for key in expected)
|
||||
if not valid_dimensions and not valid_ranges:
|
||||
raise ValueError(
|
||||
f"verification.rules[{index}].expected for bbox must be a three-number bbox [dx, dy, dz], "
|
||||
"{min, max}, or {x_min, x_max, y_min, y_max, z_min, z_max}"
|
||||
)
|
||||
if kind == "hole_center" and (
|
||||
not isinstance(expected, list) or len(expected) != 2 or not all(_finite_number(value) for value in expected)
|
||||
):
|
||||
raise ValueError(f"verification.rules[{index}].expected must be a two-number hole center")
|
||||
if kind == "through_condition" and not isinstance(expected, bool):
|
||||
raise ValueError(f"verification.rules[{index}].expected must be boolean for through_condition")
|
||||
|
||||
|
||||
def validate_verification(verification: Any, cdsl: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if verification is None:
|
||||
return []
|
||||
if not isinstance(verification, dict) or set(verification) - {"rules"}:
|
||||
raise ValueError("verification must be an object containing only rules")
|
||||
rules = verification.get("rules", [])
|
||||
if not isinstance(rules, list) or len(rules) > 32:
|
||||
raise ValueError("verification.rules must be an array with at most 32 rules")
|
||||
feature_ids = {str(item.get("id")) for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id")}
|
||||
normalized: list[dict[str, Any]] = []
|
||||
ids: set[str] = set()
|
||||
for index, raw in enumerate(rules):
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"verification.rules[{index}] must be an object")
|
||||
allowed = {"id", "type", "feature", "expected", "tolerance", "severity"}
|
||||
if set(raw) - allowed:
|
||||
raise ValueError(f"verification.rules[{index}] has unsupported fields")
|
||||
rule_id = str(raw.get("id") or "").strip()
|
||||
kind = str(raw.get("type") or "").strip()
|
||||
if not rule_id or rule_id in ids:
|
||||
raise ValueError(f"verification.rules[{index}].id must be unique and non-empty")
|
||||
if kind not in QUALITY_RULE_TYPES:
|
||||
raise ValueError(f"verification.rules[{index}].type is unsupported: {kind}")
|
||||
if "expected" not in raw:
|
||||
raise ValueError(f"verification.rules[{index}].expected is required")
|
||||
_validate_expected(kind, raw["expected"], index)
|
||||
severity = str(raw.get("severity") or "blocking")
|
||||
if severity not in _SEVERITIES:
|
||||
raise ValueError(f"verification.rules[{index}].severity is unsupported")
|
||||
try:
|
||||
tolerance = float(raw.get("tolerance") or 0.0)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError(f"verification.rules[{index}].tolerance must be numeric") from error
|
||||
if not math.isfinite(tolerance) or tolerance < 0:
|
||||
raise ValueError(f"verification.rules[{index}].tolerance must be finite and non-negative")
|
||||
feature = str(raw.get("feature") or "").strip()
|
||||
if kind in FEATURE_RULE_TYPES and not feature:
|
||||
raise ValueError(f"verification.rules[{index}].feature is required for {kind}")
|
||||
if feature and feature not in feature_ids:
|
||||
raise ValueError(f"verification.rules[{index}].feature must reference a CDSL feature ID")
|
||||
ids.add(rule_id)
|
||||
normalized.append({"id": rule_id, "type": kind, "feature": feature, "expected": raw["expected"], "tolerance": tolerance, "severity": severity})
|
||||
return normalized
|
||||
|
||||
|
||||
def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> tuple[Any, str]:
|
||||
kind, target = rule["type"], rule.get("feature") or None
|
||||
feature = _feature(cdsl, target)
|
||||
params = (feature or {}).get("params") if isinstance((feature or {}).get("params"), dict) else {}
|
||||
sketch = _sketch(cdsl, str((feature or {}).get("sketch_id") or ""))
|
||||
profile = (sketch or {}).get("profile") if isinstance(sketch, dict) else {}
|
||||
circles = _circles(profile) if isinstance(profile, dict) else []
|
||||
dimensions = _dimensions(engine_result)
|
||||
if kind == "bbox":
|
||||
derived = _circle_feature_bbox(feature, sketch)
|
||||
if target and derived is not None:
|
||||
return derived, f"cdsl.features.{target}.sketch + params"
|
||||
value = _bbox_value(engine_result, target)
|
||||
source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm"
|
||||
return value, source
|
||||
if kind == "solid_count":
|
||||
return float(engine_result.get("solid_count", 1)), "runtime.solid_count"
|
||||
if kind == "feature_count":
|
||||
return float(len(cdsl.get("features") or [])), "cdsl.features"
|
||||
if kind == "hole_count":
|
||||
return float(len(circles)), f"cdsl.features.{target}.sketch"
|
||||
if kind == "hole_diameter":
|
||||
value = params.get("diameter_mm", params.get("hole_diameter_mm"))
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value), f"cdsl.features.{target}.params"
|
||||
return (circles[0]["radius_mm"] * 2 if circles else None), f"cdsl.features.{target}.sketch"
|
||||
if kind == "hole_center":
|
||||
centers = [circle["center"] for circle in circles]
|
||||
return (centers[0] if len(centers) == 1 else centers), f"cdsl.features.{target}.sketch"
|
||||
if kind == "overall_length":
|
||||
return (max(dimensions) if dimensions else None), "runtime.bbox_mm"
|
||||
if kind == "overall_width":
|
||||
return (dimensions[1] if dimensions and len(dimensions) > 1 else None), "runtime.bbox_mm[y]"
|
||||
if kind == "overall_height":
|
||||
return (dimensions[2] if dimensions and len(dimensions) > 2 else None), "runtime.bbox_mm[z]"
|
||||
if kind == "overall_diameter":
|
||||
if target and circles:
|
||||
return max(circle["radius_mm"] for circle in circles) * 2.0, f"cdsl.features.{target}.sketch"
|
||||
return (min(dimensions) if dimensions else None), "runtime.bbox_mm"
|
||||
if kind == "through_condition":
|
||||
end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {}
|
||||
if end.get("type") in {"through_all", "through_all_both", "through_all_and_blind"}:
|
||||
return True, f"cdsl.features.{target}.params.end_condition"
|
||||
distance = params.get("distance_mm")
|
||||
return bool(isinstance(distance, (int, float)) and dimensions and float(distance) >= min(dimensions) - 1e-6), "cdsl.params + runtime.bbox_mm"
|
||||
return None, "unsupported verification type"
|
||||
|
||||
|
||||
def _matches(expected: Any, actual: Any, tolerance: float) -> bool:
|
||||
if actual is None:
|
||||
return False
|
||||
if isinstance(expected, list):
|
||||
return isinstance(actual, list) and len(expected) == len(actual) and all(
|
||||
_matches(expected_item, actual_item, tolerance) for expected_item, actual_item in zip(expected, actual)
|
||||
)
|
||||
if isinstance(expected, bool):
|
||||
return bool(actual) is expected
|
||||
if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
|
||||
return math.isclose(float(expected), float(actual), abs_tol=tolerance, rel_tol=0.0)
|
||||
return expected == actual
|
||||
|
||||
|
||||
def _matches_bbox(expected: Any, actual: dict[str, Any] | None, tolerance: float) -> bool:
|
||||
if actual is None:
|
||||
return False
|
||||
if isinstance(expected, list):
|
||||
return _matches(expected, actual["dimensions"], tolerance)
|
||||
if not isinstance(expected, dict):
|
||||
return False
|
||||
if set(expected) == {"min", "max"}:
|
||||
return _matches(expected["min"], actual["min"], tolerance) and _matches(expected["max"], actual["max"], tolerance)
|
||||
if set(expected) == {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"}:
|
||||
actual_ranges = {
|
||||
"x_min": actual["min"][0], "x_max": actual["max"][0],
|
||||
"y_min": actual["min"][1], "y_max": actual["max"][1],
|
||||
"z_min": actual["min"][2], "z_max": actual["max"][2],
|
||||
}
|
||||
return all(_matches(expected[key], actual_ranges[key], tolerance) for key in actual_ranges)
|
||||
return False
|
||||
|
||||
|
||||
def evaluate_quality(rules: list[dict[str, Any]], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> dict[str, Any]:
|
||||
results = []
|
||||
for rule in rules:
|
||||
actual, source = _actual(rule, cdsl, engine_result)
|
||||
passed = (
|
||||
_matches_bbox(rule["expected"], actual, rule["tolerance"])
|
||||
if rule["type"] == "bbox"
|
||||
else _matches(rule["expected"], actual, rule["tolerance"])
|
||||
)
|
||||
results.append({**rule, "status": "passed" if passed else ("failed" if actual is not None else "unavailable"), "actual": actual, "source": source})
|
||||
blocking = [result for result in results if result["severity"] == "blocking" and result["status"] != "passed"]
|
||||
warnings = [result for result in results if result["severity"] != "blocking" and result["status"] != "passed"]
|
||||
return {
|
||||
"schema": "cad.quality-report.v1",
|
||||
"schema_version": "1.0",
|
||||
"status": "passed" if not blocking else "failed",
|
||||
"verification_requested": bool(rules),
|
||||
"results": results,
|
||||
"blocking_failures": blocking,
|
||||
"warnings": warnings,
|
||||
"measurements": {"bbox_mm": engine_result.get("bbox_mm"), "solid_count": engine_result.get("solid_count", 1)},
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Deterministic, CPU-only CAD technical renders for visual review.
|
||||
|
||||
OpenCascade computes exact visible/hidden edges from the revision STEP file.
|
||||
Pillow rasterizes the resulting technical drawings. Neither stage needs a web
|
||||
browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence
|
||||
consistent on macOS, Linux, and Windows workers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.settings import Settings
|
||||
|
||||
|
||||
CANONICAL_VIEWS = ("top", "bottom", "front", "back", "left", "right", "isometric")
|
||||
RENDER_SIZE = 2048
|
||||
REVIEW_SIZE = 1024
|
||||
FRAME_PADDING = 0.14
|
||||
BACKGROUND_RGB = (246, 248, 251)
|
||||
VISIBLE_EDGE_RGB = (34, 54, 69)
|
||||
HIDDEN_EDGE_RGB = (142, 157, 170)
|
||||
|
||||
|
||||
class ReviewRenderError(RuntimeError):
|
||||
"""The fixed-view renderer was unavailable or produced incomplete evidence."""
|
||||
|
||||
|
||||
def renderer_status() -> tuple[bool, str]:
|
||||
"""Verify that the pure-Python/OCC renderer dependencies are importable."""
|
||||
try:
|
||||
_render_modules()
|
||||
except ReviewRenderError as error:
|
||||
return False, str(error)
|
||||
return True, ""
|
||||
|
||||
|
||||
def _render_modules() -> tuple[Any, Any, Any]:
|
||||
try:
|
||||
pillow_image = importlib.import_module("PIL.Image")
|
||||
pillow_draw = importlib.import_module("PIL.ImageDraw")
|
||||
import_step = importlib.import_module("build123d").import_step
|
||||
except (ImportError, AttributeError) as error:
|
||||
raise ReviewRenderError(
|
||||
"Python technical renderer is unavailable; install backend requirements (build123d and Pillow)"
|
||||
) from error
|
||||
return pillow_image, pillow_draw, import_step
|
||||
|
||||
|
||||
def _number_list(value: Any, *, size: int) -> list[float] | None:
|
||||
if not isinstance(value, list) or len(value) < size:
|
||||
return None
|
||||
try:
|
||||
values = [float(item) for item in value[:size]]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return values if all(math.isfinite(item) for item in values) else None
|
||||
|
||||
|
||||
def _bounds_center(bounds: list[float]) -> list[float]:
|
||||
return [
|
||||
(bounds[0] + bounds[1]) / 2,
|
||||
(bounds[2] + bounds[3]) / 2,
|
||||
(bounds[4] + bounds[5]) / 2,
|
||||
]
|
||||
|
||||
|
||||
def _shape_bounds(shape: Any) -> list[float]:
|
||||
box = shape.bounding_box()
|
||||
bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)]
|
||||
if not all(math.isfinite(value) for value in bounds):
|
||||
raise ReviewRenderError("STEP review source has invalid bounds")
|
||||
return bounds
|
||||
|
||||
|
||||
def _target_frame(target: dict[str, Any] | None, model_bounds: list[float]) -> tuple[list[float], float]:
|
||||
model_center = _bounds_center(model_bounds)
|
||||
model_extent = max(model_bounds[1] - model_bounds[0], model_bounds[3] - model_bounds[2], model_bounds[5] - model_bounds[4], 1.0)
|
||||
if not isinstance(target, dict):
|
||||
return model_center, 0.0
|
||||
bbox = _number_list(target.get("bbox_mm"), size=6)
|
||||
if bbox and bbox[3] > bbox[0] and bbox[4] > bbox[1] and bbox[5] > bbox[2]:
|
||||
center = [(bbox[0] + bbox[3]) / 2, (bbox[1] + bbox[4]) / 2, (bbox[2] + bbox[5]) / 2]
|
||||
extent = max(bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2], 1.0)
|
||||
return center, min(model_extent, extent * 1.6)
|
||||
center = _number_list(target.get("center_mm"), size=3)
|
||||
try:
|
||||
radius = float(target.get("radius_mm"))
|
||||
except (TypeError, ValueError):
|
||||
radius = 0.0
|
||||
if center and math.isfinite(radius) and radius > 0:
|
||||
return center, min(model_extent, max(radius * 2, 1.0) * 1.6)
|
||||
return model_center, 0.0
|
||||
|
||||
|
||||
def _camera_for(view_id: str, center: list[float]) -> dict[str, Any]:
|
||||
directions = {
|
||||
"top": ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
|
||||
"bottom": ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
|
||||
"front": ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]),
|
||||
"back": ([0.0, 1.0, 0.0], [0.0, 0.0, 1.0]),
|
||||
"left": ([-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
|
||||
"right": ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
|
||||
"isometric": ([1.0, -1.0, 0.8], [0.0, 0.0, 1.0]),
|
||||
}
|
||||
direction, view_up = directions.get(view_id, directions["isometric"])
|
||||
length = math.sqrt(sum(item * item for item in direction)) or 1.0
|
||||
normal = [item / length for item in direction]
|
||||
# Orthographic HLR ignores the distance, but a large deterministic value
|
||||
# makes the intended camera convention explicit in the manifest.
|
||||
position = [center[index] + normal[index] * 100000.0 for index in range(3)]
|
||||
return {"projection": "orthographic", "position": position, "focal_point": center, "view_up": view_up}
|
||||
|
||||
|
||||
def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]:
|
||||
count = max(2, min(1024, int(math.ceil(float(edge.length) / max(spacing, 0.002))) + 1))
|
||||
try:
|
||||
points = edge.positions([index / (count - 1) for index in range(count)])
|
||||
except Exception:
|
||||
points = [edge.position_at(0), edge.position_at(1)]
|
||||
return [(float(point.X), float(point.Y)) for point in points]
|
||||
|
||||
|
||||
def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]:
|
||||
points = [point for edge in edges for point in _edge_points(edge, 0.5)]
|
||||
if not points:
|
||||
raise ReviewRenderError("Hidden-line projection produced no drawable edges")
|
||||
xs, ys = zip(*points)
|
||||
return min(xs), max(xs), min(ys), max(ys)
|
||||
|
||||
|
||||
def _frame_bounds(edges: list[Any], target_extent: float) -> tuple[float, float, float, float]:
|
||||
min_x, max_x, min_y, max_y = _projected_bounds(edges)
|
||||
if target_extent > 0:
|
||||
# HLR maps the look-at target to the projection origin, making this
|
||||
# an exact, deterministic local crop without a GPU clipping plane.
|
||||
half = target_extent / (2 * (1 - 2 * FRAME_PADDING))
|
||||
return -half, half, -half, half
|
||||
center_x, center_y = (min_x + max_x) / 2, (min_y + max_y) / 2
|
||||
extent = max(max_x - min_x, max_y - min_y, 1.0)
|
||||
half = extent / (2 * (1 - 2 * FRAME_PADDING))
|
||||
return center_x - half, center_x + half, center_y - half, center_y + half
|
||||
|
||||
|
||||
def _pixel(point: tuple[float, float], frame: tuple[float, float, float, float], size: int) -> tuple[int, int]:
|
||||
min_x, max_x, min_y, max_y = frame
|
||||
x = round((point[0] - min_x) * (size - 1) / (max_x - min_x))
|
||||
y = round((max_y - point[1]) * (size - 1) / (max_y - min_y))
|
||||
return int(x), int(y)
|
||||
|
||||
|
||||
def _draw_dashed(draw: Any, points: list[tuple[int, int]], *, fill: tuple[int, int, int], width: int) -> None:
|
||||
dash, gap = 16, 10
|
||||
for start, end in zip(points, points[1:]):
|
||||
dx, dy = end[0] - start[0], end[1] - start[1]
|
||||
length = math.hypot(dx, dy)
|
||||
if length <= 0:
|
||||
continue
|
||||
distance = 0.0
|
||||
while distance < length:
|
||||
segment_end = min(length, distance + dash)
|
||||
first = (round(start[0] + dx * distance / length), round(start[1] + dy * distance / length))
|
||||
last = (round(start[0] + dx * segment_end / length), round(start[1] + dy * segment_end / length))
|
||||
draw.line((first, last), fill=fill, width=width)
|
||||
distance += dash + gap
|
||||
|
||||
|
||||
def _rasterize(
|
||||
*,
|
||||
visible: list[Any],
|
||||
hidden: list[Any],
|
||||
frame: tuple[float, float, float, float],
|
||||
output_dir: Path,
|
||||
view_id: str,
|
||||
intentional_crop: bool,
|
||||
) -> dict[str, Any]:
|
||||
pillow_image, pillow_draw, _ = _render_modules()
|
||||
image = pillow_image.new("RGB", (RENDER_SIZE, RENDER_SIZE), BACKGROUND_RGB)
|
||||
mask = pillow_image.new("L", (RENDER_SIZE, RENDER_SIZE), 0)
|
||||
draw = pillow_draw.Draw(image)
|
||||
mask_draw = pillow_draw.Draw(mask)
|
||||
spacing = max((frame[1] - frame[0]) / 1800, 0.01)
|
||||
for edge in hidden:
|
||||
points = [_pixel(point, frame, RENDER_SIZE) for point in _edge_points(edge, spacing)]
|
||||
_draw_dashed(draw, points, fill=HIDDEN_EDGE_RGB, width=3)
|
||||
_draw_dashed(mask_draw, points, fill=128, width=4)
|
||||
for edge in visible:
|
||||
points = [_pixel(point, frame, RENDER_SIZE) for point in _edge_points(edge, spacing)]
|
||||
if len(points) >= 2:
|
||||
draw.line(points, fill=VISIBLE_EDGE_RGB, width=4, joint="curve")
|
||||
mask_draw.line(points, fill=255, width=5, joint="curve")
|
||||
high_path = output_dir / "internal" / f"{view_id}-2x.png"
|
||||
high_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(high_path, optimize=True)
|
||||
output_path = output_dir / f"{view_id}.png"
|
||||
image.resize((REVIEW_SIZE, REVIEW_SIZE), resample=pillow_image.Resampling.LANCZOS).save(output_path, optimize=True)
|
||||
diagnostic_dir = output_dir / "internal" / view_id
|
||||
diagnostic_dir.mkdir(parents=True, exist_ok=True)
|
||||
mask_path = diagnostic_dir / "line-mask.png"
|
||||
mask.save(mask_path)
|
||||
edge_path = diagnostic_dir / "edge.png"
|
||||
mask.save(edge_path)
|
||||
box = mask.getbbox()
|
||||
coverage = (RENDER_SIZE * RENDER_SIZE - mask.histogram()[0]) / (RENDER_SIZE * RENDER_SIZE)
|
||||
pixel_bbox = list(box) if box else []
|
||||
touches_border = bool(box and (box[0] <= 1 or box[1] <= 1 or box[2] >= RENDER_SIZE - 1 or box[3] >= RENDER_SIZE - 1))
|
||||
valid = bool(box and coverage >= 0.00005 and coverage <= 0.20 and (intentional_crop or not touches_border))
|
||||
return {
|
||||
"path": str(output_path),
|
||||
"high_resolution_path": str(high_path),
|
||||
"diagnostics": {
|
||||
"line_mask_path": str(mask_path),
|
||||
"edge_path": str(edge_path),
|
||||
"coverage": coverage,
|
||||
"pixel_bbox": pixel_bbox,
|
||||
"touches_border": touches_border,
|
||||
"intentional_crop": intentional_crop,
|
||||
"visible_edge_count": len(visible),
|
||||
"hidden_edge_count": len(hidden),
|
||||
"valid": valid,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _render_view(
|
||||
*,
|
||||
shape: Any,
|
||||
view_id: str,
|
||||
projection_id: str,
|
||||
target: dict[str, Any] | None,
|
||||
model_bounds: list[float],
|
||||
output_dir: Path,
|
||||
) -> dict[str, Any]:
|
||||
center, target_extent = _target_frame(target, model_bounds)
|
||||
camera = _camera_for(projection_id, center)
|
||||
try:
|
||||
visible, hidden = shape.project_to_viewport(
|
||||
camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"]
|
||||
)
|
||||
except Exception as error:
|
||||
raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
|
||||
visible_edges, hidden_edges = list(visible), list(hidden)
|
||||
frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent)
|
||||
rendered = _rasterize(
|
||||
visible=visible_edges,
|
||||
hidden=hidden_edges,
|
||||
frame=frame,
|
||||
output_dir=output_dir,
|
||||
view_id=view_id,
|
||||
intentional_crop=target_extent > 0,
|
||||
)
|
||||
if not rendered["diagnostics"]["valid"]:
|
||||
raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
|
||||
return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered}
|
||||
|
||||
|
||||
def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str:
|
||||
"""Create compact whole-model evidence for routine reviewer calls."""
|
||||
pillow_image, pillow_draw, _ = _render_modules()
|
||||
canonical = [item for item in views if item["id"] in CANONICAL_VIEWS]
|
||||
if not canonical:
|
||||
return ""
|
||||
tile = 400
|
||||
sheet = pillow_image.new("RGB", (tile * 3, tile * 3), BACKGROUND_RGB)
|
||||
draw = pillow_draw.Draw(sheet)
|
||||
for index, item in enumerate(canonical):
|
||||
image = pillow_image.open(str(item["path"])).convert("RGB").resize((tile, tile), resample=pillow_image.Resampling.LANCZOS)
|
||||
x, y = (index % 3) * tile, (index // 3) * tile
|
||||
sheet.paste(image, (x, y))
|
||||
draw.rectangle((x + 8, y + 8, x + 96, y + 33), fill=(255, 255, 255))
|
||||
draw.text((x + 14, y + 13), str(item["id"]), fill=VISIBLE_EDGE_RGB)
|
||||
path = output_dir / "contact-sheet.jpg"
|
||||
sheet.save(path, quality=88, optimize=True, progressive=True)
|
||||
return str(path)
|
||||
|
||||
|
||||
def render_checkpoint(
|
||||
settings: Settings,
|
||||
*,
|
||||
step_path: Path,
|
||||
output_dir: Path,
|
||||
review_targets: list[dict[str, Any]] | None = None,
|
||||
include_canonical: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Render STEP geometry into stable canonical and bounded node-detail views."""
|
||||
del settings
|
||||
ready, detail = renderer_status()
|
||||
if not ready:
|
||||
raise ReviewRenderError(detail)
|
||||
if not step_path.is_file():
|
||||
raise ReviewRenderError(f"STEP review source is missing: {step_path.name}")
|
||||
_, _, import_step = _render_modules()
|
||||
try:
|
||||
shape = import_step(str(step_path))
|
||||
except Exception as error:
|
||||
raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error
|
||||
bounds = _shape_bounds(shape)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
jobs: list[tuple[str, dict[str, Any] | None]] = []
|
||||
if include_canonical:
|
||||
jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS)
|
||||
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3]))
|
||||
views = [
|
||||
_render_view(
|
||||
shape=shape,
|
||||
view_id=view_id,
|
||||
projection_id="isometric" if view_id.startswith("detail-") else view_id,
|
||||
target=target,
|
||||
model_bounds=bounds,
|
||||
output_dir=output_dir,
|
||||
)
|
||||
for view_id, target in jobs
|
||||
]
|
||||
canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")}
|
||||
if include_canonical and canonical != set(CANONICAL_VIEWS):
|
||||
raise ReviewRenderError("Python review renderer did not produce every canonical view")
|
||||
contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else ""
|
||||
manifest = {
|
||||
"schema_version": "cad.render-manifest.v2",
|
||||
"renderer": "python-occ-hlr-pillow",
|
||||
"source": {"type": "step", "path": str(step_path), "bounds_mm": bounds},
|
||||
"high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"},
|
||||
"review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
|
||||
"contact_sheet_path": contact_sheet_path,
|
||||
"views": views,
|
||||
}
|
||||
(output_dir / "render-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return manifest
|
||||
+377
-91
@@ -12,7 +12,6 @@ from app.settings import Settings
|
||||
|
||||
TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$")
|
||||
CONVERSATION_ID = re.compile(r"^conv_[a-z0-9]{12}$")
|
||||
DESIGN_INTENT_ID = re.compile(r"^intent_[a-z0-9]{12}$")
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
@@ -37,13 +36,6 @@ def safe_conversation_id(conversation_id: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def safe_design_intent_id(intent_id: str) -> str:
|
||||
value = str(intent_id or "").strip()
|
||||
if not DESIGN_INTENT_ID.fullmatch(value):
|
||||
raise ValueError("Invalid design intent id")
|
||||
return value
|
||||
|
||||
|
||||
def safe_relative_path(value: str) -> str:
|
||||
path = Path(str(value or ""))
|
||||
if not value or path.is_absolute() or ".." in path.parts:
|
||||
@@ -88,11 +80,35 @@ 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 write_conversation_planning(self, conversation_id: str, prefix: str, payload: dict[str, Any]) -> str:
|
||||
"""Persist structured intake/planning evidence before a task exists."""
|
||||
conversation = safe_conversation_id(conversation_id)
|
||||
safe_prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", prefix).strip("-") or "planning"
|
||||
relative = Path("planning") / f"{safe_prefix}-{secrets.token_hex(6)}.json"
|
||||
path = self.conversation_dir(conversation) / relative
|
||||
write_json(path, payload)
|
||||
return (Path(conversation) / relative).as_posix()
|
||||
|
||||
def ensure_conversation(
|
||||
self,
|
||||
conversation_id: str | None,
|
||||
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 +118,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,36 +148,95 @@ 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)
|
||||
current = read_json(path)
|
||||
if current:
|
||||
return current
|
||||
return self._migrate_task(current, path)
|
||||
task_dir = self.task_dir(tid)
|
||||
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
|
||||
record = {
|
||||
"schema_version": "1.1",
|
||||
"schema_version": "1.3",
|
||||
"task_id": tid,
|
||||
"request": request,
|
||||
"created_at": now_iso(),
|
||||
"updated_at": now_iso(),
|
||||
"current_revision": "",
|
||||
"current_design_intent_id": "",
|
||||
"design_intents": [],
|
||||
"active_revision": "",
|
||||
"published_revision": "",
|
||||
"lifecycle": "completed",
|
||||
"run_id": "",
|
||||
"generation_spec_path": "",
|
||||
"run_context_path": "",
|
||||
"active_node_id": "",
|
||||
"run_failure_path": "",
|
||||
"revisions": [],
|
||||
}
|
||||
write_json(path, record)
|
||||
return record
|
||||
|
||||
def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]:
|
||||
"""Add run-state fields lazily without rewriting successful history."""
|
||||
changed = False
|
||||
current = str(task.get("current_revision") or "")
|
||||
defaults = {
|
||||
"schema_version": "1.3",
|
||||
"active_revision": current,
|
||||
"published_revision": current,
|
||||
"lifecycle": "completed",
|
||||
"run_id": "",
|
||||
"generation_spec_path": "",
|
||||
"run_context_path": "",
|
||||
"active_node_id": "",
|
||||
"run_failure_path": "",
|
||||
}
|
||||
for key, value in defaults.items():
|
||||
if key not in task:
|
||||
task[key] = value
|
||||
changed = True
|
||||
for revision in task.get("revisions") or ():
|
||||
if not isinstance(revision, dict):
|
||||
continue
|
||||
if "visibility" not in revision:
|
||||
revision["visibility"] = "final" if str(revision.get("revision_id") or "") == str(task["published_revision"] or "") else "checkpoint"
|
||||
changed = True
|
||||
if "branch_id" not in revision:
|
||||
revision["branch_id"] = "main"
|
||||
changed = True
|
||||
if changed:
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(path, task)
|
||||
return task
|
||||
|
||||
def next_revision(self, task_id: str) -> tuple[str, Path]:
|
||||
task = self.ensure_task(task_id, "")
|
||||
revision_id = f"rev_{len(task['revisions']) + 1:03d}"
|
||||
@@ -177,96 +249,302 @@ class WorkspaceStore:
|
||||
task["revisions"].append(revision)
|
||||
if revision.get("status") == "success":
|
||||
task["current_revision"] = revision["revision_id"]
|
||||
task["active_revision"] = revision["revision_id"]
|
||||
if revision.get("visibility") == "final":
|
||||
task["published_revision"] = revision["revision_id"]
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def create_design_intent(
|
||||
self,
|
||||
task_id: str | None,
|
||||
request: str,
|
||||
intent: dict[str, Any],
|
||||
part_skill_selection: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Persist a validated plan and atomically make it the current plan."""
|
||||
def read_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = read_json(self.task_path(task_id))
|
||||
return self._migrate_task(task, self.task_path(task_id)) if isinstance(task, dict) else None
|
||||
|
||||
def start_generation(self, task_id: str, *, request: str, run_id: str | None = None) -> dict[str, Any]:
|
||||
task = self.ensure_task(task_id, request)
|
||||
task.setdefault("design_intents", [])
|
||||
task.setdefault("current_design_intent_id", "")
|
||||
task["schema_version"] = "1.1"
|
||||
previous_id = str(task.get("current_design_intent_id") or "")
|
||||
if previous_id:
|
||||
for record in task["design_intents"]:
|
||||
if str(record.get("intent_id") or "") == previous_id and record.get("status") != "superseded":
|
||||
record["status"] = "superseded"
|
||||
|
||||
intent_id = new_id("intent")
|
||||
created_at = now_iso()
|
||||
relative = Path("planning") / f"design-intent-{intent_id}.json"
|
||||
skill_ids = [str(item) for item in part_skill_selection.get("skill_ids") or [] if str(item)]
|
||||
persisted = dict(intent)
|
||||
persisted.update({
|
||||
"intent_id": intent_id,
|
||||
"created_at": created_at,
|
||||
"part_skill_ids": skill_ids,
|
||||
"part_skill_selection": part_skill_selection,
|
||||
if str(task.get("lifecycle") or "") == "running":
|
||||
raise ValueError("CAD task is already running")
|
||||
task.update({
|
||||
"lifecycle": "running",
|
||||
"run_id": run_id or new_id("run"),
|
||||
"active_node_id": "",
|
||||
"run_failure_path": "",
|
||||
"request": request or task.get("request") or "",
|
||||
"active_revision": str(task.get("current_revision") or ""),
|
||||
"updated_at": now_iso(),
|
||||
})
|
||||
write_json(self.artifact_path(task["task_id"], relative.as_posix()), persisted)
|
||||
record = {
|
||||
"intent_id": intent_id,
|
||||
"status": "accepted" if persisted.get("status") == "ready" else "pending",
|
||||
"created_at": created_at,
|
||||
"path": relative.as_posix(),
|
||||
"part_skill_ids": skill_ids,
|
||||
"base_revision_id": str(persisted.get("base_revision_id") or ""),
|
||||
}
|
||||
task["design_intents"].append(record)
|
||||
task["current_design_intent_id"] = intent_id
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task["task_id"]), task)
|
||||
return {"task_id": task["task_id"], **record, "intent": persisted}
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def read_design_intent(self, task_id: str, intent_id: str | None = None) -> dict[str, Any] | None:
|
||||
task = self.read_task(task_id)
|
||||
if not task:
|
||||
return None
|
||||
chosen = safe_design_intent_id(intent_id) if intent_id else str(task.get("current_design_intent_id") or "")
|
||||
if not chosen:
|
||||
return None
|
||||
record = next(
|
||||
(item for item in task.get("design_intents") or [] if str(item.get("intent_id") or "") == chosen),
|
||||
None,
|
||||
)
|
||||
if not isinstance(record, dict):
|
||||
return None
|
||||
path = self.artifact_path(task_id, str(record.get("path") or ""))
|
||||
intent = read_json(path)
|
||||
if not isinstance(intent, dict):
|
||||
return None
|
||||
return {"task_id": task_id, "record": record, "intent": intent}
|
||||
|
||||
def update_design_intent_status(self, task_id: str, intent_id: str, status: str) -> dict[str, Any]:
|
||||
safe_id = safe_design_intent_id(intent_id)
|
||||
def finish_generation(self, task_id: str, *, lifecycle: str, failure: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
if lifecycle not in {"completed", "failed"}:
|
||||
raise ValueError("Generation lifecycle must be completed or failed")
|
||||
task = self.ensure_task(task_id, "")
|
||||
records = task.setdefault("design_intents", [])
|
||||
record = next((item for item in records if str(item.get("intent_id") or "") == safe_id), None)
|
||||
if not isinstance(record, dict):
|
||||
raise ValueError("Design intent not found")
|
||||
record["status"] = str(status)
|
||||
failure_path = ""
|
||||
if failure:
|
||||
failure_path = "run-failures/" + f"failure_{secrets.token_hex(8)}.json"
|
||||
write_json(self.task_dir(task_id) / failure_path, failure)
|
||||
if lifecycle == "completed":
|
||||
task["published_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
|
||||
for revision in task.get("revisions") or ():
|
||||
if isinstance(revision, dict) and revision.get("revision_id") == task["published_revision"]:
|
||||
revision["visibility"] = "final"
|
||||
task.update({
|
||||
"lifecycle": lifecycle,
|
||||
"active_node_id": "",
|
||||
"run_failure_path": failure_path,
|
||||
"updated_at": now_iso(),
|
||||
})
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def set_active_revision(self, task_id: str, revision_id: str, *, branch_id: str | None = None) -> dict[str, Any]:
|
||||
task = self.ensure_task(task_id, "")
|
||||
if not revision_id:
|
||||
task["active_revision"] = ""
|
||||
task["current_revision"] = ""
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
|
||||
if not isinstance(revision, dict) or revision.get("status") != "success":
|
||||
raise ValueError("Active revision must be a successful revision")
|
||||
task["active_revision"] = revision_id
|
||||
task["current_revision"] = revision_id
|
||||
if branch_id:
|
||||
task["active_branch_id"] = branch_id
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return record
|
||||
return task
|
||||
|
||||
def read_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
return read_json(self.task_path(task_id))
|
||||
def set_active_node(self, task_id: str, node_id: str) -> dict[str, Any]:
|
||||
task = self.ensure_task(task_id, "")
|
||||
task["active_node_id"] = node_id
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def update_revision_metadata(self, task_id: str, revision_id: str, values: dict[str, Any]) -> dict[str, Any]:
|
||||
task = self.ensure_task(task_id, "")
|
||||
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
|
||||
if not isinstance(revision, dict):
|
||||
raise ValueError("Revision does not exist")
|
||||
revision.update(values)
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def rollback_to_revision(self, task_id: str, revision_id: str, *, branch_id: str) -> dict[str, Any]:
|
||||
"""Move the generation head without deleting immutable checkpoint artifacts."""
|
||||
task = self.set_active_revision(task_id, revision_id, branch_id=branch_id)
|
||||
children: dict[str, set[str]] = {}
|
||||
for revision in task.get("revisions") or ():
|
||||
if not isinstance(revision, dict):
|
||||
continue
|
||||
parent = str(revision.get("parent_revision_id") or "")
|
||||
child = str(revision.get("revision_id") or "")
|
||||
if parent and child:
|
||||
children.setdefault(parent, set()).add(child)
|
||||
superseded: set[str] = set()
|
||||
pending = list(children.get(revision_id, set())) if revision_id else [
|
||||
str(item.get("revision_id") or "")
|
||||
for item in task.get("revisions") or ()
|
||||
if isinstance(item, dict) and not str(item.get("parent_revision_id") or "")
|
||||
]
|
||||
while pending:
|
||||
child = pending.pop()
|
||||
if not child or child in superseded:
|
||||
continue
|
||||
superseded.add(child)
|
||||
pending.extend(children.get(child, set()))
|
||||
for revision in task.get("revisions") or ():
|
||||
if isinstance(revision, dict) and str(revision.get("revision_id") or "") in superseded and revision.get("visibility") == "checkpoint":
|
||||
revision["visibility"] = "superseded"
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def rollback_anchor_for_nodes(
|
||||
self,
|
||||
task_id: str,
|
||||
node_ids: list[str],
|
||||
*,
|
||||
fallback_revision_id: str = "",
|
||||
) -> str:
|
||||
"""Return the revision before every affected node's latest checkpoint.
|
||||
|
||||
Returning each affected revision's parent (rather than the revision
|
||||
itself) ensures the faulty node is regenerated. A common ancestor
|
||||
keeps unrelated upstream work intact while permitting a single rollback
|
||||
over any number of affected nodes.
|
||||
"""
|
||||
task = self.read_task(task_id) or {}
|
||||
revisions = [item for item in task.get("revisions") or () if isinstance(item, dict)]
|
||||
by_id = {str(item.get("revision_id") or ""): item for item in revisions}
|
||||
parents: list[str] = []
|
||||
for node_id in dict.fromkeys(str(item) for item in node_ids if str(item)):
|
||||
matching = [item for item in revisions if item.get("status") == "success" and str(item.get("node_id") or "") == node_id]
|
||||
if matching:
|
||||
parents.append(str(matching[-1].get("parent_revision_id") or ""))
|
||||
if not parents:
|
||||
return fallback_revision_id
|
||||
|
||||
def lineage(revision_id: str) -> list[str]:
|
||||
chain = [revision_id]
|
||||
seen = {revision_id}
|
||||
current = revision_id
|
||||
while current:
|
||||
parent = str((by_id.get(current) or {}).get("parent_revision_id") or "")
|
||||
if parent in seen:
|
||||
break
|
||||
chain.append(parent)
|
||||
seen.add(parent)
|
||||
current = parent
|
||||
return chain
|
||||
|
||||
common = set(lineage(parents[0]))
|
||||
for parent in parents[1:]:
|
||||
common.intersection_update(lineage(parent))
|
||||
if not common:
|
||||
return fallback_revision_id
|
||||
return next((revision for revision in lineage(parents[0]) if revision in common), fallback_revision_id)
|
||||
|
||||
def write_generation_failure(self, task_id: str, payload: dict[str, Any]) -> str:
|
||||
"""Persist an attempt-level diagnostic without changing lifecycle."""
|
||||
relative = Path("generation-failures") / f"failure_{secrets.token_hex(8)}.json"
|
||||
write_json(self.task_dir(task_id) / relative, payload)
|
||||
return relative.as_posix()
|
||||
|
||||
def generation_spec_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "generation-spec.json"
|
||||
|
||||
def write_generation_spec(self, task_id: str, spec: dict[str, Any]) -> Path:
|
||||
task = self.ensure_task(task_id, "")
|
||||
path = self.generation_spec_path(task_id)
|
||||
write_json(path, spec)
|
||||
task["generation_spec_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def generation_run_context_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "generation-run-context.json"
|
||||
|
||||
def write_generation_run_context(self, task_id: str, context: dict[str, Any]) -> Path:
|
||||
"""Persist the frozen authoring inputs needed to resume after restart."""
|
||||
task = self.ensure_task(task_id, "")
|
||||
path = self.generation_run_context_path(task_id)
|
||||
write_json(path, context)
|
||||
task["run_context_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def read_generation_run_context(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("run_context_path") or "")
|
||||
context = read_json(self.artifact_path(task_id, relative)) if relative else None
|
||||
return context if isinstance(context, dict) else None
|
||||
|
||||
def running_tasks(self) -> list[dict[str, Any]]:
|
||||
"""Enumerate durable tasks that need a process-local worker."""
|
||||
tasks: list[dict[str, Any]] = []
|
||||
for candidate in self.settings.task_root.glob("cad_*"):
|
||||
if not candidate.is_dir() or not TASK_ID.fullmatch(candidate.name):
|
||||
continue
|
||||
task = self.read_task(candidate.name)
|
||||
if isinstance(task, dict) and task.get("lifecycle") == "running":
|
||||
tasks.append(task)
|
||||
return tasks
|
||||
|
||||
def read_generation_spec(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("generation_spec_path") or "")
|
||||
return read_json(self.artifact_path(task_id, relative)) if relative else None
|
||||
|
||||
def revision_dir(self, task_id: str, revision_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "revisions" / revision_id
|
||||
|
||||
def current_cdsl_path(self, task_id: str) -> Path | None:
|
||||
task = self.read_task(task_id)
|
||||
revision_id = str((task or {}).get("current_revision") or "")
|
||||
revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "")
|
||||
if not revision_id:
|
||||
return None
|
||||
candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json"
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
def latest_repairable_cdsl(self, task_id: str) -> tuple[str, Path] | None:
|
||||
"""Return the latest revision CDSL when a quality failure needs repair."""
|
||||
task = self.read_task(task_id)
|
||||
for revision in reversed((task or {}).get("revisions") or []):
|
||||
revision_id = str(revision.get("revision_id") or "")
|
||||
if not revision_id or str(revision.get("quality_status") or "") != "needs_repair":
|
||||
continue
|
||||
path = self.revision_cdsl_path(task_id, revision_id)
|
||||
if path is not None:
|
||||
return revision_id, path
|
||||
return None
|
||||
|
||||
def feature_plan_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "feature-plan.json"
|
||||
|
||||
def read_feature_plan(self, task_id: str) -> dict[str, Any] | None:
|
||||
safe_task = safe_task_id(task_id)
|
||||
plan = read_json(self.feature_plan_path(safe_task))
|
||||
if not isinstance(plan, dict):
|
||||
return None
|
||||
plan_task = str(plan.get("task_id") or "")
|
||||
if plan_task and plan_task != safe_task:
|
||||
return None
|
||||
return plan
|
||||
|
||||
def write_feature_plan(self, task_id: str, plan: dict[str, Any]) -> Path:
|
||||
safe_task = safe_task_id(task_id)
|
||||
if not isinstance(plan, dict):
|
||||
raise ValueError("Feature plan must be an object")
|
||||
plan_task = str(plan.get("task_id") or "")
|
||||
if plan_task and plan_task != safe_task:
|
||||
raise ValueError("Feature plan task_id does not match its task")
|
||||
plan["task_id"] = safe_task
|
||||
path = self.feature_plan_path(safe_task)
|
||||
write_json(path, plan)
|
||||
return path
|
||||
|
||||
def revision_topology_path(self, task_id: str, revision_id: str) -> Path | None:
|
||||
task = self.read_task(task_id)
|
||||
revision = next(
|
||||
(item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id),
|
||||
None,
|
||||
)
|
||||
if not isinstance(revision, dict):
|
||||
return None
|
||||
relative = str(revision.get("topology_path") or "")
|
||||
if not relative:
|
||||
return None
|
||||
candidate = self.artifact_path(task_id, relative)
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
def current_topology_path(self, task_id: str) -> Path | None:
|
||||
task = self.read_task(task_id)
|
||||
revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "")
|
||||
if not revision_id:
|
||||
return None
|
||||
return self.revision_topology_path(task_id, revision_id)
|
||||
|
||||
def revision_cdsl_path(self, task_id: str, revision_id: str) -> Path | None:
|
||||
task = self.read_task(task_id)
|
||||
revision = next(
|
||||
(item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id),
|
||||
None,
|
||||
)
|
||||
if not isinstance(revision, dict):
|
||||
return None
|
||||
relative = str(revision.get("cdsl_path") or "")
|
||||
if not relative:
|
||||
return None
|
||||
candidate = self.artifact_path(task_id, relative)
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
def artifact_path(self, task_id: str, relative_path: str) -> Path:
|
||||
safe = safe_relative_path(relative_path)
|
||||
root = self.task_dir(task_id).resolve()
|
||||
@@ -274,3 +552,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
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Independent, structured visual review of generated checkpoint renders."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
|
||||
|
||||
VISUAL_REVIEW_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "review_rendered_checkpoint",
|
||||
"description": "Review fixed CAD render views against frozen requirements. Never author or modify CDSL.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"verdict": {"enum": ["pass", "warning", "repair"]},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"affected_node_ids": {"type": "array", "items": {"type": "string"}, "maxItems": 12},
|
||||
"requirement_ids": {"type": "array", "items": {"type": "string"}, "maxItems": 32},
|
||||
"evidence": {"type": "array", "items": {"type": "string"}, "maxItems": 12},
|
||||
},
|
||||
"required": ["verdict", "confidence", "affected_node_ids", "requirement_ids", "evidence"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class VisualReviewError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _image_part(path: Path) -> dict[str, Any]:
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
media = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
|
||||
return {"type": "image_url", "image_url": {"url": f"data:{media};base64,{encoded}"}}
|
||||
|
||||
|
||||
def _selected_review_views(manifest: dict[str, Any], *, final_checkpoint: bool) -> list[dict[str, Any]]:
|
||||
"""Keep each reviewer call bounded while canonical evidence stays archived.
|
||||
|
||||
A contact sheet establishes global context. Up to two planned detail views
|
||||
provide node-specific evidence. The final checkpoint adds full canonical
|
||||
views because it is the only point where those extra image tokens pay off.
|
||||
"""
|
||||
views = [item for item in manifest.get("views") or () if isinstance(item, dict)]
|
||||
by_id = {str(item.get("id") or ""): item for item in views}
|
||||
selected: list[dict[str, Any]] = []
|
||||
contact = Path(str(manifest.get("contact_sheet_path") or ""))
|
||||
if contact.is_file():
|
||||
selected.append({"id": "contact-sheet", "path": str(contact), "camera": {"projection": "mixed"}})
|
||||
for view_id in ("detail-1", "detail-2"):
|
||||
item = by_id.get(view_id)
|
||||
if item is not None:
|
||||
selected.append(item)
|
||||
if final_checkpoint:
|
||||
selected.extend(by_id[view_id] for view_id in ("top", "bottom", "front", "back", "left", "right", "isometric") if view_id in by_id)
|
||||
elif not selected and "isometric" in by_id:
|
||||
selected.append(by_id["isometric"])
|
||||
return selected or views[:1]
|
||||
|
||||
|
||||
async def review_checkpoint(
|
||||
settings: Settings,
|
||||
*,
|
||||
manifest: dict[str, Any],
|
||||
requirements: list[dict[str, Any]],
|
||||
node_id: str,
|
||||
deterministic_report: dict[str, Any],
|
||||
source_images: list[Path] | None = None,
|
||||
final_checkpoint: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
provider, model = settings.resolve_review_model()
|
||||
views = _selected_review_views(manifest, final_checkpoint=final_checkpoint)
|
||||
paths = [Path(str(item.get("path") or "")) for item in views]
|
||||
if not paths or not all(path.is_file() for path in paths):
|
||||
raise VisualReviewError("Review render manifest references missing image files")
|
||||
content: list[dict[str, Any]] = [{
|
||||
"type": "text",
|
||||
"text": json.dumps({
|
||||
"node_id": node_id,
|
||||
"requirements": requirements,
|
||||
"deterministic_report": deterministic_report,
|
||||
"render_manifest": {
|
||||
"renderer": manifest.get("renderer"),
|
||||
"source": manifest.get("source"),
|
||||
"views": [{"id": item.get("id"), "camera": item.get("camera"), "diagnostics": item.get("diagnostics")} for item in views],
|
||||
},
|
||||
"instruction": "Identify visible missing geometry, wrong silhouette, orientation, or proportion. Do not infer hidden dimensions. Return repair only for an observable issue.",
|
||||
}, ensure_ascii=False),
|
||||
}]
|
||||
content.extend(_image_part(path) for path in paths)
|
||||
# Reference images are only supplementary evidence. Keep this bounded so
|
||||
# an attachment-heavy request does not dominate every checkpoint review.
|
||||
for path in (source_images or [])[:2]:
|
||||
if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}:
|
||||
content.append(_image_part(path))
|
||||
tool = json.loads(json.dumps(VISUAL_REVIEW_TOOL))
|
||||
if model.strict_tool_schema:
|
||||
tool["function"]["strict"] = True
|
||||
payload = {
|
||||
"model": model.id,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are an independent CAD visual reviewer. You may only call review_rendered_checkpoint."},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
"tools": [tool],
|
||||
"tool_choice": {"type": "function", "function": {"name": "review_rendered_checkpoint"}},
|
||||
"temperature": 0,
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
|
||||
async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client:
|
||||
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload)
|
||||
if response.status_code >= 400:
|
||||
raise VisualReviewError(f"Visual review request failed ({response.status_code}): {response.text[:500]}")
|
||||
try:
|
||||
call = response.json()["choices"][0]["message"]["tool_calls"][0]
|
||||
if call["function"]["name"] != "review_rendered_checkpoint":
|
||||
raise KeyError("wrong tool")
|
||||
result = json.loads(call["function"]["arguments"])
|
||||
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
|
||||
raise VisualReviewError("Visual reviewer did not return a valid review tool call") from error
|
||||
if not isinstance(result, dict) or result.get("verdict") not in {"pass", "warning", "repair"}:
|
||||
raise VisualReviewError("Visual reviewer returned an invalid verdict")
|
||||
try:
|
||||
confidence = float(result.get("confidence"))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise VisualReviewError("Visual reviewer returned an invalid confidence") from error
|
||||
if not 0 <= confidence <= 1:
|
||||
raise VisualReviewError("Visual reviewer confidence is outside [0, 1]")
|
||||
for key in ("affected_node_ids", "requirement_ids", "evidence"):
|
||||
if not isinstance(result.get(key), list) or not all(isinstance(item, str) for item in result[key]):
|
||||
raise VisualReviewError(f"Visual reviewer returned an invalid {key}")
|
||||
return {"schema_version": "cad.visual-review.v1", "node_id": node_id, "model": model.id, **result}
|
||||
@@ -50,6 +50,13 @@ class Settings:
|
||||
llm_timeout_s: float
|
||||
default_provider_id: str
|
||||
providers: tuple[ProviderConfig, ...]
|
||||
max_repair_attempts: int = 4
|
||||
review_provider_id: str = ""
|
||||
review_model_id: str = ""
|
||||
node_authoring_attempts: int = 2
|
||||
node_repair_attempts: int = 2
|
||||
node_replan_attempts: int = 1
|
||||
incremental_generation: bool = False
|
||||
|
||||
@property
|
||||
def llm_configured(self) -> bool:
|
||||
@@ -69,6 +76,22 @@ class Settings:
|
||||
raise ValueError("The selected model is not enabled for this provider")
|
||||
return provider, model
|
||||
|
||||
def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]:
|
||||
"""Return the independently configured visual reviewer, never an author fallback."""
|
||||
provider_id = self.review_provider_id
|
||||
if not provider_id:
|
||||
raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider")
|
||||
provider = self.provider_for(provider_id)
|
||||
if provider is None:
|
||||
raise ValueError("The configured visual review provider is unavailable")
|
||||
model_id = self.review_model_id or ""
|
||||
if not model_id:
|
||||
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model")
|
||||
model = provider.model(model_id)
|
||||
if model is None or not model.vision:
|
||||
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model")
|
||||
return provider, model
|
||||
|
||||
|
||||
def _enabled_model_ids(value: str) -> set[str]:
|
||||
return {item.strip() for item in value.split(",") if item.strip()}
|
||||
@@ -137,6 +160,13 @@ def get_settings() -> Settings:
|
||||
llm_api_key=default_provider.api_key,
|
||||
llm_model=default_model,
|
||||
llm_timeout_s=float(os.getenv("CDSL_LLM_TIMEOUT_S", "90")),
|
||||
max_repair_attempts=max(0, int(os.getenv("CDSL_MAX_REPAIR_ATTEMPTS", "4"))),
|
||||
default_provider_id=default_provider_id,
|
||||
providers=providers,
|
||||
review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(),
|
||||
review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(),
|
||||
node_authoring_attempts=max(1, int(os.getenv("CDSL_NODE_AUTHORING_ATTEMPTS", "2"))),
|
||||
node_repair_attempts=max(0, int(os.getenv("CDSL_NODE_REPAIR_ATTEMPTS", "2"))),
|
||||
node_replan_attempts=max(0, int(os.getenv("CDSL_NODE_REPLAN_ATTEMPTS", "1"))),
|
||||
incremental_generation=_as_bool(os.getenv("CDSL_INCREMENTAL_GENERATION", "1")),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Compatibility adapters for imported and historical CDSL documents.
|
||||
|
||||
This package is deliberately outside ``cdsl_engine``. It may lower retired
|
||||
source syntax into the engine's generic CDSL contract, but is never imported
|
||||
by the CDSL runtime.
|
||||
"""
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Explicit import/rebuild compatibility for retired CDSL profile macros.
|
||||
|
||||
The CDSL runtime accepts only direct geometric profiles. This adapter is for
|
||||
historical artifacts and importers: it expands a legacy shorthand exactly once
|
||||
into an ``analytic_contours`` profile before generic runtime validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from .legacy_profiles import LEGACY_PROFILE_GENERATORS
|
||||
|
||||
|
||||
class LegacyProfileError(ValueError):
|
||||
"""A legacy profile could not be lowered to generic CDSL geometry."""
|
||||
|
||||
|
||||
def legacy_profile_types() -> tuple[str, ...]:
|
||||
return tuple(sorted(LEGACY_PROFILE_GENERATORS))
|
||||
|
||||
|
||||
def _segment_from_edge(edge: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = str(edge.get("type") or "")
|
||||
if kind == "line":
|
||||
return {
|
||||
"type": "line",
|
||||
"start": list(edge["start_mm"][:2]),
|
||||
"end": list(edge["end_mm"][:2]),
|
||||
}
|
||||
if kind == "arc":
|
||||
segment = {
|
||||
"type": "arc",
|
||||
"start": list(edge["start_mm"][:2]),
|
||||
"end": list(edge["end_mm"][:2]),
|
||||
"center": list(edge["center_mm"][:2]),
|
||||
"radius_mm": float(edge["radius_mm"]),
|
||||
}
|
||||
if "clockwise" in edge:
|
||||
segment["clockwise"] = bool(edge["clockwise"])
|
||||
return segment
|
||||
raise LegacyProfileError(f"Legacy profile emitted unsupported edge type: {kind}")
|
||||
|
||||
|
||||
def _circle_contour(entity: dict[str, Any]) -> dict[str, Any]:
|
||||
radius = entity.get("radius_mm")
|
||||
if not isinstance(radius, (int, float)) or float(radius) <= 0:
|
||||
raise LegacyProfileError("Legacy circle must have radius_mm > 0")
|
||||
center = entity.get("center") or [0.0, 0.0]
|
||||
if not isinstance(center, list) or len(center) < 2:
|
||||
raise LegacyProfileError("Legacy circle must have a two-dimensional center")
|
||||
return {
|
||||
"role": "outer",
|
||||
"closed": True,
|
||||
"segments": [{"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}],
|
||||
}
|
||||
|
||||
|
||||
def _edge_contour(edges: list[dict[str, Any]], *, role: str) -> dict[str, Any]:
|
||||
return {
|
||||
"role": role,
|
||||
"closed": True,
|
||||
"segments": [_segment_from_edge(edge) for edge in edges],
|
||||
}
|
||||
|
||||
|
||||
def lower_legacy_profile(profile: dict[str, Any], *, sketch_id: str = "") -> dict[str, Any]:
|
||||
"""Expand one named legacy profile to a direct analytic contour profile."""
|
||||
kind = str(profile.get("type") or "")
|
||||
generator = LEGACY_PROFILE_GENERATORS.get(kind)
|
||||
if generator is None:
|
||||
raise LegacyProfileError(f"Unsupported legacy profile: {kind!r}")
|
||||
meta: dict[str, Any] = {"id": sketch_id, "_entities": None, "_contour": None}
|
||||
try:
|
||||
entities, contour = generator(copy.deepcopy(profile), meta)
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise LegacyProfileError(f"Could not lower legacy profile {kind!r}: {error}") from error
|
||||
|
||||
contours: list[dict[str, Any]] = []
|
||||
if contour:
|
||||
contours.append(_edge_contour(contour, role="outer"))
|
||||
for region in meta.get("_regions") or []:
|
||||
if not isinstance(region, dict):
|
||||
continue
|
||||
outer = region.get("outer") or []
|
||||
if outer:
|
||||
contours.append(_edge_contour(outer, role="outer"))
|
||||
for hole in region.get("holes") or []:
|
||||
if hole:
|
||||
contours.append(_edge_contour(hole, role="inner"))
|
||||
for entity in entities or []:
|
||||
if entity.get("type") == "circle" and not entity.get("construction"):
|
||||
contours.append(_circle_contour(entity))
|
||||
if not contours:
|
||||
raise LegacyProfileError(f"Legacy profile {kind!r} did not produce a closed contour")
|
||||
return {"type": "analytic_contours", "contours": contours}
|
||||
|
||||
|
||||
def lower_legacy_profiles(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a copy with every self-contained legacy sketch lowered.
|
||||
|
||||
This is opt-in. Calling the runtime directly with a legacy profile still
|
||||
returns an unsupported-profile diagnostic.
|
||||
"""
|
||||
lowered = copy.deepcopy(cdsl)
|
||||
sketches = ((lowered.get("geometry") or {}).get("sketches") or [])
|
||||
for sketch in sketches:
|
||||
if not isinstance(sketch, dict):
|
||||
continue
|
||||
profile = sketch.get("profile")
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
if str(profile.get("type") or "") not in LEGACY_PROFILE_GENERATORS:
|
||||
continue
|
||||
sketch["profile"] = lower_legacy_profile(profile, sketch_id=str(sketch.get("id") or ""))
|
||||
return lowered
|
||||
File diff suppressed because it is too large
Load Diff
+12
-4
@@ -26,9 +26,9 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from .translator import normalize_to_ir
|
||||
except ImportError: # 允许直接 python engine/convert_to_cdsl.py
|
||||
from translator import normalize_to_ir
|
||||
from engine.cdsl_engine.translator import normalize_to_ir
|
||||
except ModuleNotFoundError: # Engine root added to sys.path by product runtime.
|
||||
from cdsl_engine.translator import normalize_to_ir
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
@@ -882,8 +882,16 @@ def convert_sw_json_to_cdsl(
|
||||
},
|
||||
}
|
||||
|
||||
# The import classifier may use historical shape labels while recognizing
|
||||
# a SolidWorks sketch. Lower them before exposing the result as CDSL so
|
||||
# the runtime contract remains line/arc/circle/contour based.
|
||||
try:
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
except ImportError: # pragma: no cover - direct converter invocation
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
|
||||
cdsl = _clean_cdsl_floats(cdsl)
|
||||
return cdsl
|
||||
return lower_legacy_profiles(cdsl)
|
||||
|
||||
|
||||
def op_to_raw(feat: dict, op: dict, all_ops: list) -> dict | None:
|
||||
@@ -6,22 +6,19 @@ The package exposes the CDSL-only rebuild API used by the backend Agent.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .convert_to_cdsl import convert_sw_json_to_cdsl, write_cdsl_outputs
|
||||
from .llm_compiler import compile_cdsl
|
||||
from .llm_engine import run_engine_plan
|
||||
from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run_engine, run_rebuild
|
||||
from .semantic_validation import validate_semantic_cdsl
|
||||
from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches
|
||||
from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches
|
||||
from .runtime import ALL_ATOMIC_IDS, EXECUTORS, RuntimeExecutionError, analyze_cdsl, rebuild_cdsl
|
||||
from .design_intent import DesignIntentError, design_intent_from_cdsl, validate_design_intent, validate_intent_cdsl
|
||||
|
||||
# The package-level runtime contract is the session executor registry. The
|
||||
# older llm_engine dispatcher remains available only for legacy engine packs.
|
||||
SUPPORTED_ATOMIC_IDS = ALL_ATOMIC_IDS
|
||||
SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
|
||||
|
||||
__all__ = [
|
||||
"convert_sw_json_to_cdsl",
|
||||
"write_cdsl_outputs",
|
||||
"compile_cdsl",
|
||||
"compile_cdsl_to_pack",
|
||||
"run_engine_plan",
|
||||
@@ -31,6 +28,7 @@ __all__ = [
|
||||
"compare_with_gold",
|
||||
"resolve_all_sketches",
|
||||
"resolve_required_sketches",
|
||||
"CORE_SHAPE_GENERATORS",
|
||||
"SHAPE_GENERATORS",
|
||||
"SUPPORTED_ATOMIC_IDS",
|
||||
"validate_semantic_cdsl",
|
||||
@@ -39,10 +37,6 @@ __all__ = [
|
||||
"rebuild_cdsl",
|
||||
"analyze_cdsl",
|
||||
"RuntimeExecutionError",
|
||||
"DesignIntentError",
|
||||
"validate_design_intent",
|
||||
"validate_intent_cdsl",
|
||||
"design_intent_from_cdsl",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
@@ -13,6 +13,7 @@ from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
from .semantic_validation import validate_semantic_cdsl
|
||||
|
||||
|
||||
@@ -225,7 +226,7 @@ def analyze_document(
|
||||
"failure_category": "preflight_blocked",
|
||||
}
|
||||
try:
|
||||
cdsl = json.loads(path.read_text(encoding="utf-8"))
|
||||
cdsl = lower_legacy_profiles(json.loads(path.read_text(encoding="utf-8")))
|
||||
report["part_id"] = str(cdsl.get("part_id") or report["part_id"])
|
||||
semantic = validate_semantic_cdsl(cdsl)
|
||||
report["semantic_valid"] = True
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any, Iterable
|
||||
|
||||
from build123d import Axis, Edge, Face, Plane, Solid, Vector, Wire, export_step
|
||||
from build123d import Axis, Compound, Edge, Face, Plane, Solid, Vector, Wire, export_step
|
||||
|
||||
from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature
|
||||
|
||||
@@ -201,13 +201,23 @@ class Build123dGeometryAdapter:
|
||||
def revolve(face: Face, angle_deg: float, axis: AxisSpec) -> Solid:
|
||||
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
|
||||
|
||||
@staticmethod
|
||||
def _body_shape(value: Any) -> Any:
|
||||
"""Normalize boolean results, including disconnected ShapeList values."""
|
||||
if hasattr(value, "bounding_box"):
|
||||
return value
|
||||
shapes = list(value)
|
||||
if not shapes:
|
||||
raise ValueError("Boolean operation produced no shapes")
|
||||
return shapes[0] if len(shapes) == 1 else Compound(shapes)
|
||||
|
||||
@staticmethod
|
||||
def fuse(body: Any | None, solid: Solid) -> Any:
|
||||
return solid if body is None else body.fuse(solid)
|
||||
return solid if body is None else Build123dGeometryAdapter._body_shape(body.fuse(solid))
|
||||
|
||||
@staticmethod
|
||||
def cut(body: Any, tool: Any) -> Any:
|
||||
return body.cut(tool)
|
||||
return Build123dGeometryAdapter._body_shape(body.cut(tool))
|
||||
|
||||
@staticmethod
|
||||
def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
|
||||
|
||||
@@ -249,7 +249,8 @@
|
||||
"stable_id": {"type": "string", "minLength": 1},
|
||||
"owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"geometry": {"type": "object"},
|
||||
"source": {"enum": ["solidworks", "inferred_from_step"]},
|
||||
"source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]},
|
||||
"snapshot_id": {"type": "string", "minLength": 1},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
|
||||
},
|
||||
"required": ["kind", "stable_id", "source", "confidence"],
|
||||
@@ -258,24 +259,18 @@
|
||||
"analyticSegment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"enum": ["line", "arc", "circle", "bspline"]},
|
||||
"type": {"enum": ["line", "arc", "circle"]},
|
||||
"start": {"$ref": "#/$defs/point2"},
|
||||
"end": {"$ref": "#/$defs/point2"},
|
||||
"center": {"$ref": "#/$defs/point2"},
|
||||
"radius_mm": {"$ref": "#/$defs/positive"},
|
||||
"clockwise": {"type": "boolean"},
|
||||
"degree": {"type": "integer", "minimum": 1},
|
||||
"control_points": {"type": "array", "items": {"$ref": "#/$defs/point2"}},
|
||||
"knots": {"type": "array", "items": {"$ref": "#/$defs/number"}},
|
||||
"weights": {"type": "array", "items": {"$ref": "#/$defs/positive"}},
|
||||
"periodic": {"type": "boolean"}
|
||||
"clockwise": {"type": "boolean"}
|
||||
},
|
||||
"required": ["type"],
|
||||
"allOf": [
|
||||
{"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start", "end"]}},
|
||||
{"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start", "end", "center", "radius_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["degree", "control_points", "knots"]}}
|
||||
{"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -334,87 +329,11 @@
|
||||
{"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}}
|
||||
]
|
||||
},
|
||||
"rectangleBounds": {
|
||||
"type": "object",
|
||||
"properties": {"center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}},
|
||||
"allOf": [
|
||||
{"oneOf": [
|
||||
{"required": ["center", "width_mm", "height_mm"], "not": {"anyOf": [{"required": ["min_mm"]}, {"required": ["max_mm"]}]}},
|
||||
{"required": ["min_mm", "max_mm"], "not": {"anyOf": [{"required": ["center"]}, {"required": ["width_mm"]}, {"required": ["height_mm"]}]}}
|
||||
]}
|
||||
]
|
||||
},
|
||||
"rectangleBoundary": {
|
||||
"type": "object",
|
||||
"properties": {"type": {"enum": ["rectangle", "rectangle_with_fillets"]}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}, "fillet_radius_mm": {"$ref": "#/$defs/positive"}},
|
||||
"allOf": [{"$ref": "#/$defs/rectangleBounds"}],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"profile_type": {"enum": ["circle", "annulus", "circles", "circle_grid", "rectangle", "rectangle_with_circles", "rectangle_with_fillets", "obround", "polygon", "ibone", "rectangle_with_symmetric_notches", "revolve_chamfer", "revolve_chamfer_slanted", "circle_with_arc_notches", "circular_sector_slot", "circle_with_radial_tabs", "filleted_rect_side_slots", "d_shape", "partial_ring", "partial_ring_with_arc_island", "radial_slot", "arc_chain", "patterned_cutouts", "compound_patterned_cutouts", "analytic_contours"]},
|
||||
"motif_type": {"enum": ["circle", "square", "rectangle", "obround", "cross", "d_shape_polygon", "regular_hexagon", "skew_hexagon", "triangle", "teardrop_polygon", "trapezoid", "annular_sector_polygon"]},
|
||||
"layout_type": {"enum": ["ring", "angular", "concentric_rings", "disc_grid", "open_arc", "spiral", "cross_lines", "x_field", "twin_strips", "corner_clusters", "diamond_field"]},
|
||||
"motif": {
|
||||
"oneOf": [
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "square"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "width_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "rectangle"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "width_mm", "height_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "obround"}, "length_mm": {"$ref": "#/$defs/positive"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "length_mm", "width_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "cross"}, "size_mm": {"$ref": "#/$defs/positive"}, "arm_width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "size_mm", "arm_width_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "d_shape_polygon"}, "stem_length_mm": {"$ref": "#/$defs/positive"}, "nose_depth_mm": {"$ref": "#/$defs/positive"}, "half_height_mm": {"$ref": "#/$defs/positive"}, "arc_segments": {"$ref": "#/$defs/positiveInteger"}}, "required": ["type", "stem_length_mm", "nose_depth_mm", "half_height_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "regular_hexagon"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "skew_hexagon"}, "nominal_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "nominal_radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "triangle"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "teardrop_polygon"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "shoulder_fraction": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1}, "left_width_mm": {"$ref": "#/$defs/positive"}, "right_width_mm": {"$ref": "#/$defs/positive"}, "tip_height_mm": {"$ref": "#/$defs/positive"}, "bottom_depth_mm": {"$ref": "#/$defs/positive"}, "shoulder_height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type"], "oneOf": [{"required": ["width_mm", "height_mm"], "not": {"anyOf": [{"required": ["left_width_mm"]}, {"required": ["right_width_mm"]}, {"required": ["tip_height_mm"]}, {"required": ["bottom_depth_mm"]}, {"required": ["shoulder_height_mm"]}]}}, {"required": ["left_width_mm", "right_width_mm", "tip_height_mm", "bottom_depth_mm", "shoulder_height_mm"], "not": {"anyOf": [{"required": ["width_mm"]}, {"required": ["height_mm"]}, {"required": ["shoulder_fraction"]}]}}], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "trapezoid"}, "bottom_width_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "bottom_width_mm", "top_width_mm", "height_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "annular_sector_polygon"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "arc_segments": {"$ref": "#/$defs/positiveInteger"}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "half_angle_deg"], "additionalProperties": false}
|
||||
]
|
||||
},
|
||||
"layoutCommon": {
|
||||
"type": "object",
|
||||
"properties": {"orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}
|
||||
},
|
||||
"layout": {
|
||||
"oneOf": [
|
||||
{"type": "object", "properties": {"type": {"const": "ring"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm", "count"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "angular"}, "radius_mm": {"$ref": "#/$defs/number"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "count"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "concentric_rings"}, "rings": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": {"radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}}, "required": ["radius_mm", "count"], "additionalProperties": false}}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "rings"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "disc_grid"}, "count_x": {"$ref": "#/$defs/positiveInteger"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "spacing_x_mm": {"$ref": "#/$defs/positive"}, "spacing_y_mm": {"$ref": "#/$defs/positive"}, "center_mm": {"$ref": "#/$defs/point2"}, "max_center_radius_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "open_arc"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm", "count", "start_angle_deg", "end_angle_deg"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "spiral"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_radius_mm": {"$ref": "#/$defs/positive"}, "radius_step_mm": {"$ref": "#/$defs/number"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "count", "start_radius_mm", "radius_step_mm", "angle_step_deg"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "cross_lines"}, "count_per_axis": {"$ref": "#/$defs/positiveInteger"}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "count_per_axis", "spacing_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "x_field"}, "levels": {"$ref": "#/$defs/positiveInteger"}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "levels", "spacing_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "twin_strips"}, "x_offset_mm": {"$ref": "#/$defs/positive"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "y_start_mm": {"$ref": "#/$defs/number"}, "y_end_mm": {"$ref": "#/$defs/number"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "x_offset_mm", "count_y", "y_start_mm", "y_end_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "corner_clusters"}, "levels_mm": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/positive"}}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "levels_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "diamond_field"}, "manhattan_radius": {"type": "integer", "minimum": 0}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "manhattan_radius", "spacing_mm"], "additionalProperties": false}
|
||||
]
|
||||
},
|
||||
"pattern": {"type": "object", "properties": {"motif": {"$ref": "#/$defs/motif"}, "layout": {"$ref": "#/$defs/layout"}}, "required": ["motif", "layout"], "additionalProperties": false},
|
||||
"profile_type": {"enum": ["circle", "polygon", "analytic_contours"]},
|
||||
"profile": {
|
||||
"oneOf": [
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "annulus"}, "center": {"$ref": "#/$defs/point2"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "inner_radius_mm", "outer_radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "circles"}, "items": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "items"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "circle_grid"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count_x": {"$ref": "#/$defs/positiveInteger"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "spacing_x_mm": {"$ref": "#/$defs/positive"}, "spacing_y_mm": {"$ref": "#/$defs/positive"}, "origin_mm": {"$ref": "#/$defs/point2"}, "center_mm": {"$ref": "#/$defs/point2"}}, "required": ["type", "radius_mm", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], "additionalProperties": false, "oneOf": [{"required": ["origin_mm"], "not": {"required": ["center_mm"]}}, {"required": ["center_mm"], "not": {"required": ["origin_mm"]}}]},
|
||||
{"type": "object", "properties": {"type": {"const": "rectangle"}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}}, "required": ["type"], "allOf": [{"$ref": "#/$defs/rectangleBounds"}], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "rectangle_with_circles"}, "boundary": {"$ref": "#/$defs/rectangleBoundary"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "boundary"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "rectangle_with_fillets"}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}, "fillet_radius_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type"], "allOf": [{"$ref": "#/$defs/rectangleBounds"}], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "obround"}, "center": {"$ref": "#/$defs/point2"}, "length_mm": {"$ref": "#/$defs/positive"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "length_mm", "width_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "ibone"}, "body_width_mm": {"$ref": "#/$defs/positive"}, "body_height_mm": {"$ref": "#/$defs/positive"}, "flange_width_mm": {"$ref": "#/$defs/positive"}, "flange_height_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}, "hole_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "body_width_mm", "body_height_mm", "flange_width_mm", "flange_height_mm", "corner_radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "rectangle_with_symmetric_notches"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "notch": {"type": "object", "properties": {"y_start": {"$ref": "#/$defs/number"}, "y_end": {"$ref": "#/$defs/number"}, "depth_mm": {"$ref": "#/$defs/positive"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["y_start", "y_end", "depth_mm"], "additionalProperties": false}}, "required": ["type", "width_mm", "height_mm", "notch"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "revolve_chamfer"}, "axis_height_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "bottom_width_mm": {"$ref": "#/$defs/positive"}, "wall_inset_mm": {"$ref": "#/$defs/number"}, "step_inset_mm": {"$ref": "#/$defs/number"}, "on_axis_side": {"enum": ["left", "right"]}}, "required": ["type", "axis_height_mm", "top_width_mm", "bottom_width_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "revolve_chamfer_slanted"}, "axis_height_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "wall_inset_mm": {"$ref": "#/$defs/number"}, "wall_height_mm": {"$ref": "#/$defs/positive"}, "wall_width_mm": {"$ref": "#/$defs/positive"}, "on_axis_side": {"enum": ["left", "right"]}}, "required": ["type", "axis_height_mm", "top_width_mm", "wall_height_mm", "wall_width_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "circle_with_arc_notches"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "notch_radius_mm": {"$ref": "#/$defs/positive"}, "notch_angles_deg": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/number"}}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "outer_radius_mm", "notch_radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "circular_sector_slot"}, "arc_radius_mm": {"$ref": "#/$defs/positive"}, "slot_half_width_mm": {"$ref": "#/$defs/positive"}, "chord_half_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "arc_radius_mm", "slot_half_width_mm", "chord_half_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "circle_with_radial_tabs"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "tab_u_half_mm": {"$ref": "#/$defs/positive"}, "tab_v_offset_mm": {"$ref": "#/$defs/number"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "outer_radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "filleted_rect_side_slots"}, "half_width_mm": {"$ref": "#/$defs/positive"}, "half_height_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}, "slot_radius_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "half_width_mm", "half_height_mm", "corner_radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "d_shape"}, "radius_mm": {"$ref": "#/$defs/positive"}, "chord_sign": {"enum": ["left", "right"]}, "chord_x_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "partial_ring"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "partial_ring_with_arc_island"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "island_radius_mm": {"$ref": "#/$defs/positive"}, "island_gap_mm": {"$ref": "#/$defs/positive"}, "center_angles_deg": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/number"}}, "replicas": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": {"center_angle_deg": {"$ref": "#/$defs/number"}}, "required": ["center_angle_deg"], "additionalProperties": false}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "island_radius_mm"], "additionalProperties": false, "oneOf": [{"required": ["center_angles_deg"], "not": {"required": ["replicas"]}}, {"required": ["replicas"], "not": {"required": ["center_angles_deg"]}}]},
|
||||
{"type": "object", "properties": {"type": {"const": "radial_slot"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "start_angle_deg", "end_angle_deg"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "arc_chain"}, "arcs": {"type": "array", "minItems": 2, "items": {"type": "object", "properties": {"center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}}, "required": ["radius_mm", "start_angle_deg", "end_angle_deg"], "additionalProperties": false}}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "arcs"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "patterned_cutouts"}, "motif": {"$ref": "#/$defs/motif"}, "layout": {"$ref": "#/$defs/layout"}}, "required": ["type", "motif", "layout"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "compound_patterned_cutouts"}, "patterns": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/pattern"}}}, "required": ["type", "patterns"], "additionalProperties": false},
|
||||
{"$ref": "#/$defs/analyticProfile"}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
"""Validation for the semantic planning artifact between a request and CDSL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import SchemaError
|
||||
|
||||
|
||||
class DesignIntentError(ValueError):
|
||||
"""A plan-contract violation with a stable error code for the Agent."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _schema_path(engine: Any) -> Path:
|
||||
return Path(str(engine.__file__)).with_name("design_intent_schema.json")
|
||||
|
||||
|
||||
def load_design_intent_schema(engine: Any) -> dict[str, Any]:
|
||||
path = _schema_path(engine)
|
||||
try:
|
||||
schema = json.loads(path.read_text(encoding="utf-8"))
|
||||
Draft202012Validator.check_schema(schema)
|
||||
except (OSError, json.JSONDecodeError, SchemaError) as error:
|
||||
raise RuntimeError("The local DesignIntent JSON Schema is unavailable or invalid") from error
|
||||
return schema
|
||||
|
||||
|
||||
def _location(error: Any) -> str:
|
||||
return "$" + "".join(
|
||||
f"[{item}]" if isinstance(item, int) else f".{item}"
|
||||
for item in error.absolute_path
|
||||
)
|
||||
|
||||
|
||||
def _schema_validate(intent: dict[str, Any], engine: Any) -> None:
|
||||
errors = sorted(
|
||||
Draft202012Validator(load_design_intent_schema(engine)).iter_errors(intent),
|
||||
key=lambda error: (list(error.absolute_path), error.message),
|
||||
)
|
||||
if errors:
|
||||
error = errors[0]
|
||||
raise DesignIntentError(
|
||||
"INVALID_DESIGN_INTENT",
|
||||
f"DesignIntent schema violation at {_location(error)}: {error.message}",
|
||||
)
|
||||
|
||||
|
||||
def _runtime_capabilities(engine: Any) -> tuple[set[str], set[str], dict[str, dict[str, Any]]]:
|
||||
try:
|
||||
profile_schema = json.loads(Path(str(engine.__file__)).with_name("profile_schema.json").read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise RuntimeError("The local CDSL profile schema is unavailable or invalid") from error
|
||||
atomic_ids = set(profile_schema.get("runtime_supported_atomic_ids") or getattr(engine, "SUPPORTED_ATOMIC_IDS", ()))
|
||||
profiles = {
|
||||
name
|
||||
for name, contract in (profile_schema.get("profiles") or {}).items()
|
||||
if isinstance(contract, dict) and contract.get("agent_allowed") is True
|
||||
}
|
||||
atomic_contracts = {
|
||||
str(atomic_id): contract
|
||||
for atomic_id, contract in (profile_schema.get("feature_atomic_ids") or {}).items()
|
||||
if isinstance(contract, dict)
|
||||
}
|
||||
return {str(item) for item in atomic_ids}, {str(item) for item in profiles}, atomic_contracts
|
||||
|
||||
|
||||
def _blocking(intent: dict[str, Any]) -> bool:
|
||||
questions = intent.get("open_questions") or []
|
||||
gaps = intent.get("capability_gaps") or []
|
||||
return any(isinstance(item, dict) and item.get("blocking") is True for item in [*questions, *gaps])
|
||||
|
||||
|
||||
def validate_design_intent(
|
||||
intent: dict[str, Any],
|
||||
engine: Any,
|
||||
*,
|
||||
current_revision_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Validate DesignIntent syntax and semantic ordering against runtime capability."""
|
||||
if not isinstance(intent, dict):
|
||||
raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent must be a JSON object")
|
||||
_schema_validate(intent, engine)
|
||||
|
||||
normalized = deepcopy(intent)
|
||||
mode = str(normalized["mode"])
|
||||
base_revision_id = str(normalized["base_revision_id"] or "")
|
||||
if mode == "create" and base_revision_id:
|
||||
raise DesignIntentError("INVALID_DESIGN_INTENT", "create DesignIntent must not set base_revision_id")
|
||||
if mode == "revise":
|
||||
if not base_revision_id:
|
||||
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",
|
||||
)
|
||||
|
||||
structures = normalized["structures"]
|
||||
ids = [str(item["id"]) for item in structures]
|
||||
feature_ids = [str(item["cdsl_feature_id"]) for item in structures]
|
||||
if len(set(ids)) != len(ids):
|
||||
raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent structure ids must be unique")
|
||||
if len(set(feature_ids)) != len(feature_ids):
|
||||
raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent cdsl_feature_id values must be unique")
|
||||
|
||||
feature_order = [str(item) for item in normalized["feature_order"]]
|
||||
if len(set(feature_order)) != len(feature_order) or set(feature_order) != set(ids):
|
||||
raise DesignIntentError(
|
||||
"INVALID_DESIGN_INTENT",
|
||||
"feature_order must list every DesignIntent structure exactly once",
|
||||
)
|
||||
order = {structure_id: index for index, structure_id in enumerate(feature_order)}
|
||||
atomic_ids, profile_types, atomic_contracts = _runtime_capabilities(engine)
|
||||
for structure in structures:
|
||||
structure_id = str(structure["id"])
|
||||
for dependency in structure["depends_on"]:
|
||||
dependency_id = str(dependency)
|
||||
if dependency_id not in order:
|
||||
raise DesignIntentError(
|
||||
"INVALID_DESIGN_INTENT",
|
||||
f"Structure {structure_id} depends on an unknown structure {dependency_id}",
|
||||
)
|
||||
if order[dependency_id] >= order[structure_id]:
|
||||
raise DesignIntentError(
|
||||
"INVALID_DESIGN_INTENT",
|
||||
f"Structure {structure_id} must depend only on earlier feature_order entries",
|
||||
)
|
||||
strategy = structure["cdsl_strategy"]
|
||||
atomic_id = str(strategy["atomic_id"])
|
||||
if atomic_id not in atomic_ids:
|
||||
raise DesignIntentError(
|
||||
"INVALID_DESIGN_INTENT",
|
||||
f"Structure {structure_id} uses unsupported atomic_id {atomic_id}",
|
||||
)
|
||||
profile_type = str(strategy.get("profile_type") or "")
|
||||
if atomic_contracts.get(atomic_id, {}).get("requires_sketch") and not profile_type:
|
||||
raise DesignIntentError(
|
||||
"INVALID_DESIGN_INTENT",
|
||||
f"Structure {structure_id} uses {atomic_id} and requires a profile_type",
|
||||
)
|
||||
if profile_type and profile_type not in profile_types:
|
||||
raise DesignIntentError(
|
||||
"INVALID_DESIGN_INTENT",
|
||||
f"Structure {structure_id} uses unsupported or agent-disallowed profile_type {profile_type}",
|
||||
)
|
||||
|
||||
if normalized["status"] == "ready" and _blocking(normalized):
|
||||
raise DesignIntentError(
|
||||
"DESIGN_INTENT_BLOCKED",
|
||||
"A DesignIntent with blocking questions or capability gaps must need clarification",
|
||||
)
|
||||
if normalized["status"] == "needs_clarification" and not _blocking(normalized):
|
||||
raise DesignIntentError(
|
||||
"INVALID_DESIGN_INTENT",
|
||||
"needs_clarification requires a blocking question or capability gap",
|
||||
)
|
||||
if normalized["status"] == "ready" and not structures:
|
||||
raise DesignIntentError("INVALID_DESIGN_INTENT", "A ready DesignIntent needs at least one structure")
|
||||
if normalized["status"] == "ready" and mode == "create" and not any(item["role"] == "base" for item in structures):
|
||||
raise DesignIntentError("INVALID_DESIGN_INTENT", "A ready create DesignIntent needs a base structure")
|
||||
nonblocking_gaps = [item for item in normalized["capability_gaps"] if not item["blocking"]]
|
||||
if nonblocking_gaps and not normalized["assumptions"]:
|
||||
raise DesignIntentError(
|
||||
"INVALID_DESIGN_INTENT",
|
||||
"Non-blocking capability gaps require an explicit approximation assumption",
|
||||
)
|
||||
for gap in normalized["capability_gaps"]:
|
||||
if str(gap["structure_id"]) not in set(ids):
|
||||
raise DesignIntentError(
|
||||
"INVALID_DESIGN_INTENT",
|
||||
f"Capability gap references an unknown structure {gap['structure_id']}",
|
||||
)
|
||||
valid_expectation_ids = set(ids) | set(feature_ids)
|
||||
for expectation in normalized["verification_expectations"]:
|
||||
if expectation["type"] == "feature_count" and str(expectation["feature_id"]) not in valid_expectation_ids:
|
||||
raise DesignIntentError(
|
||||
"INVALID_DESIGN_INTENT",
|
||||
f"feature_count expectation references an unknown structure or CDSL feature {expectation['feature_id']}",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _has_selector_evidence(value: Any, role: str) -> bool:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
if role in {"selector", "selectors"} and key == "selectors" and isinstance(child, list) and child:
|
||||
return True
|
||||
if key == role and child not in (None, "", [], {}):
|
||||
if not (isinstance(child, dict) and set(child) == {"unresolved"}):
|
||||
return True
|
||||
if _has_selector_evidence(child, role):
|
||||
return True
|
||||
elif isinstance(value, list):
|
||||
return any(_has_selector_evidence(item, role) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def validate_intent_cdsl(
|
||||
intent: dict[str, Any],
|
||||
cdsl: dict[str, Any],
|
||||
engine: Any,
|
||||
*,
|
||||
current_cdsl: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Ensure an executable CDSL document faithfully realizes one accepted plan."""
|
||||
normalized = validate_design_intent(intent, engine)
|
||||
if normalized["status"] != "ready":
|
||||
raise DesignIntentError("DESIGN_INTENT_BLOCKED", "CDSL generation is blocked until the DesignIntent is ready")
|
||||
if not isinstance(cdsl, dict):
|
||||
raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL must be a JSON object")
|
||||
features = cdsl.get("features")
|
||||
sketches = (cdsl.get("geometry") or {}).get("sketches")
|
||||
if not isinstance(features, list) or not isinstance(sketches, list):
|
||||
raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL must contain features and sketches")
|
||||
feature_by_id = {str(feature.get("id") or ""): feature for feature in features if isinstance(feature, dict)}
|
||||
if len(feature_by_id) != len(features):
|
||||
raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL features must have unique ids")
|
||||
structure_by_id = {str(item["id"]): item for item in normalized["structures"]}
|
||||
expected_feature_ids = [str(structure_by_id[item]["cdsl_feature_id"]) for item in normalized["feature_order"]]
|
||||
if normalized["mode"] == "revise" and isinstance(current_cdsl, dict):
|
||||
base_feature_ids = {
|
||||
str(feature.get("id") or "")
|
||||
for feature in current_cdsl.get("features") or []
|
||||
if isinstance(feature, dict) and feature.get("id")
|
||||
}
|
||||
if not base_feature_ids.issubset(set(expected_feature_ids)):
|
||||
missing = ", ".join(sorted(base_feature_ids - set(expected_feature_ids)))
|
||||
raise DesignIntentError(
|
||||
"INTENT_CDSL_MISMATCH",
|
||||
f"revise DesignIntent must explicitly preserve current features: {missing}",
|
||||
)
|
||||
actual_feature_ids = [str(feature.get("id") or "") for feature in features]
|
||||
if actual_feature_ids != expected_feature_ids:
|
||||
raise DesignIntentError(
|
||||
"INTENT_CDSL_MISMATCH",
|
||||
"CDSL feature ids and order must exactly match DesignIntent feature_order",
|
||||
)
|
||||
sketch_by_id = {str(sketch.get("id") or ""): sketch for sketch in sketches if isinstance(sketch, dict)}
|
||||
feature_for_structure = {
|
||||
str(structure["id"]): feature_by_id[str(structure["cdsl_feature_id"])]
|
||||
for structure in normalized["structures"]
|
||||
}
|
||||
for structure in normalized["structures"]:
|
||||
structure_id = str(structure["id"])
|
||||
feature = feature_for_structure[structure_id]
|
||||
strategy = structure["cdsl_strategy"]
|
||||
if feature.get("atomic_id") != strategy["atomic_id"]:
|
||||
raise DesignIntentError(
|
||||
"INTENT_CDSL_MISMATCH",
|
||||
f"CDSL feature {feature.get('id')} atomic_id does not match structure {structure_id}",
|
||||
)
|
||||
expected_dependencies = {
|
||||
str(structure_by_id[dependency]["cdsl_feature_id"])
|
||||
for dependency in structure["depends_on"]
|
||||
}
|
||||
actual_dependencies = {str(item) for item in feature.get("depends_on") or []}
|
||||
if not expected_dependencies.issubset(actual_dependencies):
|
||||
raise DesignIntentError(
|
||||
"INTENT_CDSL_MISMATCH",
|
||||
f"CDSL feature {feature.get('id')} is missing planned dependencies",
|
||||
)
|
||||
profile_type = str(strategy.get("profile_type") or "")
|
||||
if profile_type:
|
||||
sketch_id = str(feature.get("sketch_id") or "")
|
||||
sketch = sketch_by_id.get(sketch_id)
|
||||
actual_profile_type = str(((sketch or {}).get("profile") or {}).get("type") or "")
|
||||
if actual_profile_type != profile_type:
|
||||
raise DesignIntentError(
|
||||
"INTENT_CDSL_MISMATCH",
|
||||
f"CDSL feature {feature.get('id')} must use profile_type {profile_type}",
|
||||
)
|
||||
for selector_role in strategy["selector_roles"]:
|
||||
if not _has_selector_evidence(feature, str(selector_role)):
|
||||
raise DesignIntentError(
|
||||
"INTENT_CDSL_MISMATCH",
|
||||
f"CDSL feature {feature.get('id')} is missing selector evidence for {selector_role}",
|
||||
)
|
||||
|
||||
|
||||
def design_intent_from_cdsl(cdsl: dict[str, Any], *, request: str, mode: str = "create", base_revision_id: str = "") -> dict[str, Any]:
|
||||
"""Create a conservative audit-only plan for legacy revisions when needed."""
|
||||
sketches = (cdsl.get("geometry") or {}).get("sketches") or []
|
||||
profile_by_sketch_id = {
|
||||
str(sketch.get("id") or ""): str((sketch.get("profile") or {}).get("type") or "")
|
||||
for sketch in sketches
|
||||
if isinstance(sketch, dict)
|
||||
}
|
||||
structures: list[dict[str, Any]] = []
|
||||
for index, feature in enumerate(cdsl.get("features") or []):
|
||||
if not isinstance(feature, dict):
|
||||
continue
|
||||
feature_id = str(feature.get("id") or f"feature_{index + 1}")
|
||||
atomic_id = str(feature.get("atomic_id") or "")
|
||||
profile_type = profile_by_sketch_id.get(str(feature.get("sketch_id") or ""), "")
|
||||
strategy: dict[str, Any] = {
|
||||
"atomic_id": atomic_id,
|
||||
"parameter_roles": sorted(str(key) for key in (feature.get("params") or {}).keys()),
|
||||
"selector_roles": [],
|
||||
}
|
||||
if profile_type:
|
||||
strategy["profile_type"] = profile_type
|
||||
structures.append({
|
||||
"id": feature_id,
|
||||
"cdsl_feature_id": feature_id,
|
||||
"role": "base" if index == 0 else "subtractive" if "cut" in atomic_id or atomic_id.startswith("hole") else "additive",
|
||||
"purpose": str(feature.get("name") or feature_id),
|
||||
"depends_on": [str(item) for item in feature.get("depends_on") or []],
|
||||
"cdsl_strategy": strategy,
|
||||
})
|
||||
return {
|
||||
"schema": "cad.cdsl.design-intent.v1",
|
||||
"schema_version": "1.0",
|
||||
"mode": mode,
|
||||
"request": request,
|
||||
"base_revision_id": base_revision_id,
|
||||
"structures": structures,
|
||||
"feature_order": [item["id"] for item in structures],
|
||||
"assumptions": ["Synthesized from a legacy CDSL revision."],
|
||||
"open_questions": [],
|
||||
"capability_gaps": [],
|
||||
"verification_expectations": [],
|
||||
"status": "ready",
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://cdsl.local/schema/cad.cdsl.design-intent.v1",
|
||||
"title": "CDSL design intent",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {"const": "cad.cdsl.design-intent.v1"},
|
||||
"schema_version": {"const": "1.0"},
|
||||
"mode": {"enum": ["create", "revise"]},
|
||||
"request": {"type": "string", "minLength": 1},
|
||||
"base_revision_id": {"type": "string"},
|
||||
"structures": {"type": "array", "items": {"$ref": "#/$defs/structure"}},
|
||||
"feature_order": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"assumptions": {"type": "array", "items": {"type": "string", "minLength": 1}},
|
||||
"open_questions": {"type": "array", "items": {"$ref": "#/$defs/openQuestion"}},
|
||||
"capability_gaps": {"type": "array", "items": {"$ref": "#/$defs/capabilityGap"}},
|
||||
"verification_expectations": {"type": "array", "items": {"$ref": "#/$defs/verificationExpectation"}},
|
||||
"status": {"enum": ["ready", "needs_clarification"]},
|
||||
"intent_id": {"type": "string", "pattern": "^intent_[a-z0-9]{12}$"},
|
||||
"created_at": {"type": "string", "minLength": 1},
|
||||
"part_skill_ids": {"type": "array", "items": {"type": "string", "minLength": 1}},
|
||||
"part_skill_selection": {"type": "object"}
|
||||
},
|
||||
"required": [
|
||||
"schema",
|
||||
"schema_version",
|
||||
"mode",
|
||||
"request",
|
||||
"base_revision_id",
|
||||
"structures",
|
||||
"feature_order",
|
||||
"assumptions",
|
||||
"open_questions",
|
||||
"capability_gaps",
|
||||
"verification_expectations",
|
||||
"status"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"structure": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"cdsl_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"role": {"enum": ["base", "reference", "additive", "subtractive", "dressup", "pattern"]},
|
||||
"purpose": {"type": "string", "minLength": 1},
|
||||
"depends_on": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"cdsl_strategy": {"$ref": "#/$defs/cdslStrategy"}
|
||||
},
|
||||
"required": ["id", "cdsl_feature_id", "role", "purpose", "depends_on", "cdsl_strategy"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"cdslStrategy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"atomic_id": {"type": "string", "minLength": 1},
|
||||
"profile_type": {"type": "string", "minLength": 1},
|
||||
"parameter_roles": {"type": "array", "items": {"type": "string", "minLength": 1}},
|
||||
"selector_roles": {"type": "array", "items": {"type": "string", "minLength": 1}}
|
||||
},
|
||||
"required": ["atomic_id", "parameter_roles", "selector_roles"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"openQuestion": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"question": {"type": "string", "minLength": 1},
|
||||
"blocking": {"type": "boolean"}
|
||||
},
|
||||
"required": ["id", "question", "blocking"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"capabilityGap": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,100}$"},
|
||||
"structure_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"message": {"type": "string", "minLength": 1},
|
||||
"blocking": {"type": "boolean"}
|
||||
},
|
||||
"required": ["code", "structure_id", "message", "blocking"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"verificationExpectation": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "feature_count"},
|
||||
"feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"expected": {"type": "integer", "minimum": 0}
|
||||
},
|
||||
"required": ["type", "feature_id", "expected"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"type": {"const": "symmetry"}, "axis": {"enum": ["x", "y", "z"]}},
|
||||
"required": ["type", "axis"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "bbox"},
|
||||
"expected_mm": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {"type": "number", "exclusiveMinimum": 0},
|
||||
"y": {"type": "number", "exclusiveMinimum": 0},
|
||||
"z": {"type": "number", "exclusiveMinimum": 0}
|
||||
},
|
||||
"required": ["x", "y", "z"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "expected_mm"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .capabilities import sketch_ids_required_by_contract
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
from .semantic_validation import validate_semantic_cdsl
|
||||
from .sketch_solver import resolve_required_sketches
|
||||
|
||||
@@ -21,7 +22,7 @@ P3_ATOMIC_IDS = frozenset({
|
||||
})
|
||||
P4_ATOMIC_IDS = P3_ATOMIC_IDS | frozenset({"hole_wizard"})
|
||||
P6_ATOMIC_IDS = P4_ATOMIC_IDS | frozenset({"pattern_linear", "pattern_mirror"})
|
||||
P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "circles", "annulus"})
|
||||
P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "polygon"})
|
||||
# Static pool membership asks whether the exported history is an extrude/
|
||||
# revolve history. Whether a first cut has a preceding active body remains a
|
||||
# runtime preflight question, not a reason to erase it from the input pool.
|
||||
@@ -66,6 +67,9 @@ def is_static_phase_ready(cdsl: dict[str, Any], phase: str) -> bool:
|
||||
allowed = _PHASE_ATOMIC_IDS.get(phase)
|
||||
if allowed is None:
|
||||
raise ValueError(f"Unknown CDSL runtime phase {phase!r}")
|
||||
# Corpus pools retain historical profiles on disk but evaluate them after
|
||||
# the importer compatibility lowering used by the batch path.
|
||||
cdsl = lower_legacy_profiles(cdsl)
|
||||
semantic = validate_semantic_cdsl(cdsl)
|
||||
if semantic["unresolved"]:
|
||||
return False
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"schema": "cdsl.engine.schema.v1",
|
||||
"schema_version": "1.3.0",
|
||||
"cdsl_json_schema_file": "cdsl_schema.json",
|
||||
"maintenance_rule": "The semantic CDSL contract is a superset of the current runtime. runtime_supported_atomic_ids and runtime_supported_profiles must stay synchronized with runtime.py EXECUTORS, sketch_solver.py SHAPE_GENERATORS, and the package-level capability tests. Legacy llm_compiler.py and llm_engine.py are not the CDSL-only runtime contract.",
|
||||
"maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.",
|
||||
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
|
||||
"runtime_supported_atomic_ids": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "reference_plane", "reference_axis", "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror"],
|
||||
"runtime_supported_profiles": ["circle", "annulus", "circles", "circle_grid", "rectangle", "rectangle_with_circles", "rectangle_with_fillets", "obround", "polygon", "ibone", "rectangle_with_symmetric_notches", "revolve_chamfer", "revolve_chamfer_slanted", "circle_with_arc_notches", "circular_sector_slot", "circle_with_radial_tabs", "filleted_rect_side_slots", "d_shape", "partial_ring", "partial_ring_with_arc_island", "arc_chain", "radial_slot", "patterned_cutouts", "compound_patterned_cutouts", "analytic_contours", "complex_arc_shape", "unknown_shape"],
|
||||
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours"],
|
||||
"feature_atomic_ids": {
|
||||
"extrude_add_blind": {"summary": "Add the closed profile by one signed extrusion distance.", "required_params": ["distance_mm"], "optional_params": ["reverse"], "requires_sketch": true},
|
||||
"extrude_add_two_sided": {"summary": "Add the closed profile with independently captured forward and reverse terminations.", "required_params": ["distance_mm", "reverse_distance_mm"], "optional_params": ["reverse", "end_condition", "reverse_end_condition"], "requires_sketch": true},
|
||||
@@ -26,202 +26,18 @@
|
||||
},
|
||||
"profiles": {
|
||||
"circle": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A single circular closed profile.",
|
||||
"required": ["radius_mm"],
|
||||
"optional": ["center"],
|
||||
"constraints": ["radius_mm > 0", "center defaults to [0, 0]"]
|
||||
},
|
||||
"annulus": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A concentric ring.",
|
||||
"required": ["inner_radius_mm", "outer_radius_mm"],
|
||||
"optional": ["center"],
|
||||
"constraints": ["0 < inner_radius_mm < outer_radius_mm"]
|
||||
},
|
||||
"circles": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A non-empty list of circles.",
|
||||
"required": ["items"],
|
||||
"item_schema": {"required": ["radius_mm"], "optional": ["center"]}
|
||||
},
|
||||
"circle_grid": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A rectangular grid of equal circles.",
|
||||
"required": ["radius_mm", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"],
|
||||
"optional": ["origin_mm", "center_mm"],
|
||||
"constraints": ["radius_mm > 0", "count_x and count_y are integers >= 1", "use origin_mm or center_mm, not both"]
|
||||
},
|
||||
"rectangle": {
|
||||
"agent_allowed": true,
|
||||
"summary": "An axis-aligned rectangle.",
|
||||
"one_of": [["center", "width_mm", "height_mm"], ["min_mm", "max_mm"]],
|
||||
"constraints": ["width_mm > 0 and height_mm > 0 when using center"]
|
||||
},
|
||||
"rectangle_with_circles": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A rectangle or filleted rectangle with optional internal circles.",
|
||||
"required": ["boundary"],
|
||||
"optional": ["circles"],
|
||||
"nested": {"boundary": "rectangle schema plus optional type=rectangle|rectangle_with_fillets and fillet_radius_mm"}
|
||||
},
|
||||
"rectangle_with_fillets": {
|
||||
"agent_allowed": true,
|
||||
"summary": "An axis-aligned rectangle with corner fillets and optional internal circles.",
|
||||
"one_of": [["center", "width_mm", "height_mm"], ["min_mm", "max_mm"]],
|
||||
"optional": ["fillet_radius_mm", "circles"],
|
||||
"constraints": ["fillet_radius_mm defaults to 0"]
|
||||
},
|
||||
"obround": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A horizontal capsule/slot.",
|
||||
"required": ["length_mm", "width_mm"],
|
||||
"optional": ["center"],
|
||||
"constraints": ["length_mm > 0", "width_mm > 0", "center defaults to [0, 0]"]
|
||||
},
|
||||
"polygon": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A closed straight-edge polygon.",
|
||||
"required": ["vertices"],
|
||||
"constraints": ["vertices contains at least three [u, v] points"]
|
||||
},
|
||||
"ibone": {
|
||||
"agent_allowed": true,
|
||||
"summary": "I-shaped lug with optional four holes.",
|
||||
"required": ["body_width_mm", "body_height_mm", "flange_width_mm", "flange_height_mm", "corner_radius_mm"],
|
||||
"optional": ["hole_radius_mm"]
|
||||
},
|
||||
"rectangle_with_symmetric_notches": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A rectangular plate with four symmetric side notches.",
|
||||
"required": ["width_mm", "height_mm", "notch"],
|
||||
"nested": {"notch": {"required": ["y_start", "y_end", "depth_mm"], "optional": ["inner_radius_mm", "corner_radius_mm"]}}
|
||||
},
|
||||
"revolve_chamfer": {
|
||||
"agent_allowed": true,
|
||||
"summary": "Five-edge trapezoid profile for a revolve cut.",
|
||||
"required": ["axis_height_mm", "top_width_mm", "bottom_width_mm"],
|
||||
"optional": ["wall_inset_mm", "step_inset_mm", "on_axis_side"],
|
||||
"constraints": ["on_axis_side is left or right"]
|
||||
},
|
||||
"revolve_chamfer_slanted": {
|
||||
"agent_allowed": true,
|
||||
"summary": "Five-edge slanted profile for a revolve cut.",
|
||||
"required": ["axis_height_mm", "top_width_mm", "wall_height_mm", "wall_width_mm"],
|
||||
"optional": ["wall_inset_mm", "on_axis_side"],
|
||||
"constraints": ["on_axis_side is left or right"]
|
||||
},
|
||||
"circle_with_arc_notches": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A circular boundary with arc-shaped notches.",
|
||||
"required": ["outer_radius_mm", "notch_radius_mm"],
|
||||
"optional": ["notch_angles_deg", "circles"],
|
||||
"constraints": ["notch_angles_deg defaults to [0, 90, 180, 270]"]
|
||||
},
|
||||
"circular_sector_slot": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A circular-sector boundary with a central rectangular slot.",
|
||||
"required": ["arc_radius_mm", "slot_half_width_mm", "chord_half_mm"],
|
||||
"optional": ["circles"]
|
||||
},
|
||||
"circle_with_radial_tabs": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A circle with two mirrored radial tabs.",
|
||||
"required": ["outer_radius_mm"],
|
||||
"optional": ["tab_u_half_mm", "tab_v_offset_mm", "circles"]
|
||||
},
|
||||
"filleted_rect_side_slots": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A filleted rectangle with one semicircular slot at each side centre.",
|
||||
"required": ["half_width_mm", "half_height_mm", "corner_radius_mm"],
|
||||
"optional": ["slot_radius_mm", "circles"]
|
||||
},
|
||||
"d_shape": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A D-shaped boundary made from one chord and one arc.",
|
||||
"required": ["radius_mm"],
|
||||
"optional": ["chord_sign", "chord_x_mm", "circles"],
|
||||
"constraints": ["chord_sign is left or right"]
|
||||
},
|
||||
"partial_ring": {
|
||||
"agent_allowed": true,
|
||||
"summary": "An annular sector.",
|
||||
"required": ["inner_radius_mm", "outer_radius_mm"],
|
||||
"optional": ["half_angle_deg", "circles"],
|
||||
"constraints": ["0 < inner_radius_mm < outer_radius_mm"]
|
||||
},
|
||||
"partial_ring_with_arc_island": {
|
||||
"agent_allowed": true,
|
||||
"summary": "One or more annular sectors with an arc island.",
|
||||
"required": ["inner_radius_mm", "outer_radius_mm", "island_radius_mm"],
|
||||
"optional": ["half_angle_deg", "island_gap_mm", "center_angles_deg", "replicas"],
|
||||
"nested": {"replicas": {"required": ["center_angle_deg"]}}
|
||||
},
|
||||
"arc_chain": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A closed chain of two or more arcs, commonly used for revolve sections.",
|
||||
"required": ["arcs"],
|
||||
"optional": ["circles"],
|
||||
"item_schema": {"required": ["radius_mm", "start_angle_deg", "end_angle_deg"], "optional": ["center"]},
|
||||
"constraints": ["arcs contains at least two endpoint-connected arcs"]
|
||||
},
|
||||
"radial_slot": {
|
||||
"agent_allowed": true,
|
||||
"summary": "A rounded annular sector slot.",
|
||||
"required": ["inner_radius_mm", "outer_radius_mm", "start_angle_deg", "end_angle_deg"],
|
||||
"optional": ["circles"],
|
||||
"constraints": ["0 < inner_radius_mm < outer_radius_mm"]
|
||||
},
|
||||
"patterned_cutouts": {
|
||||
"agent_allowed": true,
|
||||
"summary": "One procedural cutout motif repeated by one procedural layout.",
|
||||
"required": ["motif", "layout"],
|
||||
"nested": {"motif": "See motif_types", "layout": "See layout_types"}
|
||||
},
|
||||
"compound_patterned_cutouts": {
|
||||
"agent_allowed": true,
|
||||
"summary": "Multiple procedural motif/layout pairs merged into one cutout sketch.",
|
||||
"required": ["patterns"],
|
||||
"nested": {"patterns": "Non-empty list of {motif, layout}; see patterned_cutouts."}
|
||||
},
|
||||
"complex_arc_shape": {
|
||||
"agent_allowed": false,
|
||||
"summary": "Legacy compiler-context fallback only. Never generate it."
|
||||
},
|
||||
"analytic_contours": {
|
||||
"agent_allowed": false,
|
||||
"summary": "Exact analytic line, arc and circle contours emitted by the Evidence v2 converter. The runtime resolves them into closed regions without compiler_context; B-spline currently produces an explicit unsupported diagnostic."
|
||||
},
|
||||
"unknown_shape": {
|
||||
"agent_allowed": false,
|
||||
"summary": "Legacy compiler-context fallback only. Never generate it."
|
||||
"summary": "Closed direct line, arc and circle contours. B-splines are unsupported."
|
||||
}
|
||||
},
|
||||
"motif_types": {
|
||||
"circle": ["radius_mm"],
|
||||
"square": ["width_mm"],
|
||||
"rectangle": ["width_mm", "height_mm"],
|
||||
"obround": ["length_mm", "width_mm"],
|
||||
"cross": ["size_mm", "arm_width_mm"],
|
||||
"d_shape_polygon": ["stem_length_mm", "nose_depth_mm", "half_height_mm"],
|
||||
"regular_hexagon": ["radius_mm"],
|
||||
"skew_hexagon": ["nominal_radius_mm"],
|
||||
"triangle": ["radius_mm"],
|
||||
"teardrop_polygon": ["width_mm", "height_mm"],
|
||||
"trapezoid": ["bottom_width_mm", "top_width_mm", "height_mm"],
|
||||
"annular_sector_polygon": ["inner_radius_mm", "outer_radius_mm", "half_angle_deg"]
|
||||
},
|
||||
"layout_types": {
|
||||
"ring": ["radius_mm", "count"],
|
||||
"angular": ["count"],
|
||||
"concentric_rings": ["rings"],
|
||||
"disc_grid": ["count_x", "count_y", "spacing_x_mm", "spacing_y_mm"],
|
||||
"open_arc": ["radius_mm", "count", "start_angle_deg", "end_angle_deg"],
|
||||
"spiral": ["count", "start_radius_mm", "radius_step_mm", "angle_step_deg"],
|
||||
"cross_lines": ["count_per_axis", "spacing_mm"],
|
||||
"x_field": ["levels", "spacing_mm"],
|
||||
"twin_strips": ["x_offset_mm", "count_y", "y_start_mm", "y_end_mm"],
|
||||
"corner_clusters": ["levels_mm"],
|
||||
"diamond_field": ["manhattan_radius", "spacing_mm"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,25 +15,30 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
|
||||
from .llm_compiler import compile_cdsl
|
||||
from .llm_engine import run_engine_plan
|
||||
from .translator import generate_build123d_code, normalize_to_ir
|
||||
except ImportError: # 允许直接 python rebuild.py
|
||||
from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
|
||||
from llm_compiler import compile_cdsl
|
||||
from llm_engine import run_engine_plan
|
||||
from translator import generate_build123d_code, normalize_to_ir
|
||||
|
||||
|
||||
def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None,
|
||||
force_exact: bool = False) -> dict[str, Any]:
|
||||
def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None,
|
||||
force_exact: bool = False) -> dict[str, Any]:
|
||||
"""主重建入口。
|
||||
|
||||
优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。
|
||||
回退:CDSL + compiler_context 的 translator 路径。
|
||||
"""
|
||||
sketches = cdsl.get("geometry", {}).get("sketches", [])
|
||||
# Compatibility entry point only: new CDSL-only runtime calls do not use
|
||||
# macro profiles and therefore never invoke this adapter.
|
||||
cdsl = lower_legacy_profiles(cdsl)
|
||||
sketches = cdsl.get("geometry", {}).get("sketches", [])
|
||||
all_drawable = bool(sketches) and all(
|
||||
_sketch_is_cdsl_drawable(s) for s in sketches
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ from .runtime_types import (
|
||||
RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry,
|
||||
vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit,
|
||||
)
|
||||
from .sketch_solver import SHAPE_GENERATORS, resolve_required_sketches
|
||||
from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
|
||||
|
||||
|
||||
ALL_ATOMIC_IDS = frozenset({
|
||||
@@ -89,6 +89,7 @@ class ExecutionSession:
|
||||
results: dict[str, FeatureResult] = field(default_factory=dict)
|
||||
replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict)
|
||||
selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
|
||||
active_feature_id: str = ""
|
||||
|
||||
def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None:
|
||||
self.body = body
|
||||
@@ -103,7 +104,9 @@ class ExecutionSession:
|
||||
|
||||
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
|
||||
resolution = self.topology.resolve(selector, active_body_id=self.body_id)
|
||||
self.selector_resolutions.append(resolution.as_dict())
|
||||
evidence = resolution.as_dict()
|
||||
evidence["feature_id"] = self.active_feature_id
|
||||
self.selector_resolutions.append(evidence)
|
||||
return resolution
|
||||
|
||||
def result(self, node: FeaturePlanNode, *, context: PlaneSpec | AxisSpec | None = None, diagnostics: list[RuntimeDiagnostic] | None = None) -> FeatureResult:
|
||||
@@ -743,7 +746,12 @@ def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_overr
|
||||
executor = EXECUTORS.get(node.atomic_id)
|
||||
if executor is None:
|
||||
raise ValueError(f"No executor registered for {node.atomic_id!r}")
|
||||
return executor(node, session, sketch_override)
|
||||
previous_feature_id = session.active_feature_id
|
||||
session.active_feature_id = node.feature_id
|
||||
try:
|
||||
return executor(node, session, sketch_override)
|
||||
finally:
|
||||
session.active_feature_id = previous_feature_id
|
||||
|
||||
|
||||
ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]
|
||||
@@ -824,7 +832,7 @@ def analyze_cdsl(cdsl: dict[str, Any]):
|
||||
resolved = resolve_required_sketches(
|
||||
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
|
||||
)
|
||||
analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=SHAPE_GENERATORS)
|
||||
analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS)
|
||||
return analyzer.analyze(resolved, sketch_errors=sketch_errors)
|
||||
|
||||
|
||||
@@ -834,7 +842,7 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
|
||||
resolved = resolve_required_sketches(
|
||||
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
|
||||
)
|
||||
analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=SHAPE_GENERATORS).analyze(
|
||||
analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze(
|
||||
resolved, sketch_errors=sketch_errors,
|
||||
)
|
||||
if strict and not analysis.runtime_eligible:
|
||||
|
||||
@@ -381,6 +381,13 @@ class SelectorResolution:
|
||||
}
|
||||
if self.record is not None:
|
||||
output["record"] = self.record.public_dict()
|
||||
output["selected"] = self.record.public_dict()
|
||||
score = next(
|
||||
(candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id),
|
||||
None,
|
||||
)
|
||||
if score is not None:
|
||||
output["score"] = score
|
||||
if self.diagnostic is not None:
|
||||
output["diagnostic"] = self.diagnostic.as_dict()
|
||||
return output
|
||||
@@ -589,6 +596,66 @@ class TopologyRegistry:
|
||||
if owner:
|
||||
candidates = [record for record in candidates if owner in record.owners]
|
||||
geometry = normalize_selector_geometry(selector.get("geometry"))
|
||||
if selector.get("snapshot_id") and not owner:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=(),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_owner_required",
|
||||
message="A snapshot selector requires owner_feature_id",
|
||||
detail={"minimum_score": minimum_score},
|
||||
),
|
||||
)
|
||||
stable_id = str(selector.get("stable_id") or "").strip()
|
||||
if stable_id:
|
||||
exact = [record for record in candidates if record.record_id == stable_id]
|
||||
if len(exact) == 1:
|
||||
record = exact[0]
|
||||
# A stable ID is only a lookup accelerator for snapshot-aware
|
||||
# selectors. It cannot revive a B-rep entity whose geometric
|
||||
# signature changed after an upstream rebuild.
|
||||
if selector.get("snapshot_id"):
|
||||
score = self._geometry_score(geometry, record.geometry) if geometry else None
|
||||
if score is None or score < minimum_score:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=({"score": round(float(score or 0), 6), **record.public_dict()},),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_geometry_mismatch",
|
||||
message="The stable selector record no longer matches its geometry signature",
|
||||
detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score},
|
||||
),
|
||||
)
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="resolved",
|
||||
record=record,
|
||||
candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},),
|
||||
)
|
||||
if len(exact) > 1:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="ambiguous",
|
||||
candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_ambiguous",
|
||||
message="More than one runtime topology record has the requested stable_id",
|
||||
detail={"stable_id": stable_id, "candidate_count": len(exact)},
|
||||
),
|
||||
)
|
||||
if selector.get("snapshot_id") and not geometry:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=(),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_geometry_mismatch",
|
||||
message="A snapshot selector requires a geometry signature",
|
||||
detail={"minimum_score": minimum_score},
|
||||
),
|
||||
)
|
||||
scored: list[tuple[float, TopologyRecord]] = []
|
||||
for candidate in candidates:
|
||||
# An owner-qualified context selector is deterministic when it has
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
opencv-python-headless>=4.9,<5
|
||||
@@ -5,3 +5,4 @@ uvicorn[standard]>=0.30,<1
|
||||
build123d
|
||||
python-multipart>=0.0.9,<1
|
||||
jsonschema>=4.23,<5
|
||||
Pillow>=10,<12
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Explicitly remove attachments belonging to conversations without v2 image observations.
|
||||
|
||||
The command is dry-run by default. It only mutates data when ``--apply`` is
|
||||
provided, and it never removes CAD task artifacts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from app.services.storage import read_json, write_json
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
def _has_v2_observation(record: dict) -> bool:
|
||||
for message in record.get("messages") or []:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
for part in message.get("parts") or []:
|
||||
if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis":
|
||||
continue
|
||||
data = part.get("data")
|
||||
if isinstance(data, dict) and str(data.get("schemaVersion") or data.get("schema_version") or "") == "cad.image-observation.v2":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def plan_cleanup(root: Path) -> list[tuple[Path, str]]:
|
||||
planned: list[tuple[Path, str]] = []
|
||||
for conversation_dir in sorted(root.glob("conv_*")):
|
||||
record_path = conversation_dir / "conversation.json"
|
||||
record = read_json(record_path)
|
||||
if not isinstance(record, dict) or _has_v2_observation(record):
|
||||
continue
|
||||
uploads = conversation_dir / "uploads"
|
||||
if uploads.is_dir():
|
||||
planned.append((uploads, "legacy upload directory"))
|
||||
planning = conversation_dir / "planning"
|
||||
if planning.is_dir():
|
||||
planned.append((planning, "legacy planning directory"))
|
||||
if record.get("attachments"):
|
||||
planned.append((record_path, "remove legacy attachment metadata and image-analysis parts"))
|
||||
return planned
|
||||
|
||||
|
||||
def apply_cleanup(root: Path) -> int:
|
||||
changed = 0
|
||||
for conversation_dir in sorted(root.glob("conv_*")):
|
||||
record_path = conversation_dir / "conversation.json"
|
||||
record = read_json(record_path)
|
||||
if not isinstance(record, dict) or _has_v2_observation(record):
|
||||
continue
|
||||
for relative in ("uploads", "planning"):
|
||||
target = conversation_dir / relative
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
changed += 1
|
||||
record["attachments"] = []
|
||||
for message in record.get("messages") or []:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
message["parts"] = [
|
||||
part for part in message.get("parts") or []
|
||||
if not (isinstance(part, dict) and part.get("type") == "data-cad-image-analysis")
|
||||
]
|
||||
write_json(record_path, record)
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Remove legacy conversation attachments")
|
||||
parser.add_argument("--apply", action="store_true", help="perform deletion; default is dry-run")
|
||||
parser.add_argument("--dry-run", action="store_true", help="list deletion targets without changing files")
|
||||
args = parser.parse_args()
|
||||
root = get_settings().conversation_root.resolve()
|
||||
if root.name != "conversations":
|
||||
raise SystemExit(f"Refusing unexpected conversation root: {root}")
|
||||
planned = plan_cleanup(root)
|
||||
for path, reason in planned:
|
||||
print(f"{'DELETE' if args.apply else 'WOULD DELETE'} {path} ({reason})")
|
||||
if not args.apply:
|
||||
print(f"Dry-run: {len(planned)} targets. Re-run with --apply to delete.")
|
||||
return 0
|
||||
print(f"Deleted {apply_cleanup(root)} conversation records/directories.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Remove retired semantic-plan artifacts from the local workspace.
|
||||
|
||||
Run without arguments to list every file and metadata document that would
|
||||
change. Run again with --apply to make the irreversible cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
RETIRED_FILENAMES = {
|
||||
"generation-spec.json",
|
||||
"generation-provenance.json",
|
||||
"acceptance-report.json",
|
||||
}
|
||||
RETIRED_DIAGNOSTIC_PREFIX = "generation_spec_"
|
||||
RETIRED_TOOL_NAMES = {
|
||||
"create_generation_spec",
|
||||
"author_cdsl_from_generation_spec",
|
||||
"compile_generation_spec",
|
||||
"patch_generation_spec",
|
||||
"generate_flange_sleeve_model",
|
||||
}
|
||||
RETIRED_METADATA_KEYS = {
|
||||
"generation_spec_path",
|
||||
"generation_provenance_path",
|
||||
"acceptance_path",
|
||||
"approximations",
|
||||
"current_design_intent_id",
|
||||
"design_intents",
|
||||
"design_intent_id",
|
||||
"design_intent_path",
|
||||
"design_intent_structures",
|
||||
"design_intent_assumptions",
|
||||
"design_intent_capability_gaps",
|
||||
"capability_translations",
|
||||
"evidence",
|
||||
}
|
||||
|
||||
|
||||
def _strip_metadata(value: Any) -> bool:
|
||||
changed = False
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
changed = _strip_metadata(item) or changed
|
||||
return changed
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
for key in list(value):
|
||||
if key in RETIRED_METADATA_KEYS or key.startswith("generation_spec_"):
|
||||
value.pop(key)
|
||||
changed = True
|
||||
operation = value.get("operation")
|
||||
if isinstance(operation, dict) and "spec" in str(operation.get("type") or "").casefold():
|
||||
value["operation"] = {}
|
||||
changed = True
|
||||
for key, child in list(value.items()):
|
||||
if key == "source" and isinstance(child, str) and "generation_spec" in child.casefold():
|
||||
value[key] = "legacy_cdsl"
|
||||
changed = True
|
||||
elif key in {"message", "text"} and isinstance(child, str) and "generationspec" in child.casefold():
|
||||
value[key] = "已清理已弃用的规格流程诊断;当前任务统一使用直接 CDSL 生成。"
|
||||
changed = True
|
||||
else:
|
||||
changed = _strip_metadata(child) or changed
|
||||
return changed
|
||||
|
||||
|
||||
def _retired_diagnostic(path: Path) -> bool:
|
||||
if path.name.startswith(RETIRED_DIAGNOSTIC_PREFIX):
|
||||
return True
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
tool_name = str(payload.get("tool_name") or "")
|
||||
arguments = str(payload.get("arguments") or "")
|
||||
return tool_name in RETIRED_TOOL_NAMES or "generation-spec" in arguments or "generation_spec" in arguments
|
||||
|
||||
|
||||
def cleanup_plan(data_root: Path) -> tuple[list[Path], list[Path]]:
|
||||
files: list[Path] = []
|
||||
metadata: list[Path] = []
|
||||
task_root = data_root / "tasks"
|
||||
conversation_root = data_root / "conversations"
|
||||
if task_root.is_dir():
|
||||
for path in task_root.rglob("*"):
|
||||
if path.is_file() and (
|
||||
path.name in RETIRED_FILENAMES
|
||||
or (path.parent.name == "planning" and path.name.startswith("design-intent-"))
|
||||
):
|
||||
files.append(path)
|
||||
for path in task_root.rglob("*.json"):
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
original = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
||||
_strip_metadata(payload)
|
||||
if json.dumps(payload, sort_keys=True, ensure_ascii=False) != original:
|
||||
metadata.append(path)
|
||||
if conversation_root.is_dir():
|
||||
for path in conversation_root.rglob("*.json"):
|
||||
if _retired_diagnostic(path):
|
||||
files.append(path)
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
original = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
||||
_strip_metadata(payload)
|
||||
if json.dumps(payload, sort_keys=True, ensure_ascii=False) != original:
|
||||
metadata.append(path)
|
||||
file_set = set(files)
|
||||
return sorted(file_set), sorted(set(metadata) - file_set)
|
||||
|
||||
|
||||
def apply_cleanup(data_root: Path) -> tuple[list[Path], list[Path]]:
|
||||
files, metadata = cleanup_plan(data_root)
|
||||
for path in files:
|
||||
path.unlink()
|
||||
for path in metadata:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if _strip_metadata(payload):
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return files, metadata
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Remove retired GenerationSpec and semantic-plan artifacts")
|
||||
parser.add_argument("--data-root", type=Path, default=Path(__file__).resolve().parents[1] / "data")
|
||||
parser.add_argument("--apply", action="store_true", help="Delete the listed artifacts and rewrite metadata")
|
||||
args = parser.parse_args()
|
||||
files, metadata = cleanup_plan(args.data_root)
|
||||
action = "Will remove" if not args.apply else "Removing"
|
||||
for path in files:
|
||||
print(f"{action} artifact: {path}")
|
||||
for path in metadata:
|
||||
print(f"{action} retired metadata: {path}")
|
||||
if not files and not metadata:
|
||||
print("No retired GenerationSpec or semantic-plan artifacts found.")
|
||||
if not args.apply:
|
||||
print("Dry run only. Re-run with --apply to make these changes.")
|
||||
return 0
|
||||
apply_cleanup(args.data_root)
|
||||
print(f"Removed {len(files)} artifacts and rewrote {len(metadata)} metadata documents.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -5,12 +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, engine_capability_manifest, get_repair_step_key, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message
|
||||
from app.services.engine_service import load_engine
|
||||
from app.services.quality import QUALITY_RULE_TYPES
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.storage import WorkspaceStore
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings, get_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):
|
||||
@@ -32,6 +79,38 @@ class ParseToolArgumentsTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ToolArgumentsError, "JSON object"):
|
||||
parse_tool_arguments('["not", "tool arguments"]')
|
||||
|
||||
|
||||
class RepairStepKeyTests(unittest.TestCase):
|
||||
def test_generation_and_patch_share_the_same_feature_step_budget(self) -> None:
|
||||
state = {
|
||||
"phase": "CDSL_REPAIR",
|
||||
"feature_plan": {
|
||||
"plan_id": "plan_1",
|
||||
"nodes": [
|
||||
{"id": "boss", "status": "ready"},
|
||||
{"id": "hole", "status": "waiting_for_selection"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
get_repair_step_key(state, "generate_cdsl_model"),
|
||||
get_repair_step_key(state, "patch_cdsl_model"),
|
||||
)
|
||||
|
||||
def test_completed_nodes_do_not_change_the_active_step_key(self) -> None:
|
||||
state = {
|
||||
"phase": "CDSL_REPAIR",
|
||||
"feature_plan": {
|
||||
"plan_id": "plan_1",
|
||||
"nodes": [
|
||||
{"id": "base", "status": "completed"},
|
||||
{"id": "boss", "status": "ready"},
|
||||
],
|
||||
},
|
||||
}
|
||||
self.assertEqual(get_repair_step_key(state, "generate_cdsl_model"), "plan:plan_1:boss")
|
||||
|
||||
def test_recovers_only_the_known_premature_cdsl_wrapper_close(self) -> None:
|
||||
payload = parse_tool_arguments(
|
||||
'{"cdsl":{"schema":"cad.cdsl.llm.v1"}}, "summary":"fixed envelope"}',
|
||||
@@ -41,6 +120,28 @@ class ParseToolArgumentsTests(unittest.TestCase):
|
||||
self.assertEqual(payload["summary"], "fixed envelope")
|
||||
self.assertEqual(payload["cdsl"], {"schema": "cad.cdsl.llm.v1"})
|
||||
|
||||
def test_recovers_trailing_cdsl_metadata_after_a_complete_envelope(self) -> None:
|
||||
payload = parse_tool_arguments(
|
||||
'{"cdsl":{"schema":"cad.cdsl.llm.v1"},"summary":"fixed envelope",'
|
||||
'"assumptions":["metric"]}, "summary":"repeated envelope",'
|
||||
'"assumptions":["metric"]}',
|
||||
recover_cdsl_wrapper=True,
|
||||
)
|
||||
|
||||
self.assertEqual(payload, {
|
||||
"cdsl": {"schema": "cad.cdsl.llm.v1"},
|
||||
"summary": "fixed envelope",
|
||||
"assumptions": ["metric"],
|
||||
})
|
||||
|
||||
def test_rejects_a_second_cdsl_payload_after_a_complete_envelope(self) -> None:
|
||||
with self.assertRaisesRegex(ToolArgumentsError, "trailing content"):
|
||||
parse_tool_arguments(
|
||||
'{"cdsl":{"schema":"cad.cdsl.llm.v1"},"summary":"original",'
|
||||
'"assumptions":[]}, "cdsl":{"schema":"different"}}',
|
||||
recover_cdsl_wrapper=True,
|
||||
)
|
||||
|
||||
def test_does_not_recover_arbitrary_trailing_tool_content(self) -> None:
|
||||
with self.assertRaisesRegex(ToolArgumentsError, "trailing content"):
|
||||
parse_tool_arguments(
|
||||
@@ -62,14 +163,38 @@ 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_generation_schema_exposes_only_runtime_atomic_ids(self) -> None:
|
||||
settings = get_settings()
|
||||
engine = load_engine(settings)
|
||||
self.assertEqual(
|
||||
set(CDSL_TOOL_SCHEMA["$defs"]["feature_atomic_ids"]["enum"]),
|
||||
set(engine.SUPPORTED_ATOMIC_IDS),
|
||||
)
|
||||
|
||||
def test_verification_schema_exposes_only_implemented_rule_types(self) -> None:
|
||||
generate_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "generate_cdsl_model")
|
||||
verification = generate_tool["function"]["parameters"]["properties"]["verification"]
|
||||
rule_type = verification["properties"]["rules"]["items"]["properties"]["type"]
|
||||
self.assertEqual(set(rule_type["enum"]), set(QUALITY_RULE_TYPES))
|
||||
|
||||
def test_verification_schema_requires_feature_for_feature_scoped_rules(self) -> None:
|
||||
generate_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "generate_cdsl_model")
|
||||
rule = generate_tool["function"]["parameters"]["properties"]["verification"]["properties"]["rules"]["items"]
|
||||
self.assertTrue(any("feature" in branch.get("then", {}).get("required", []) for branch in rule["allOf"]))
|
||||
self.assertIn("overall_width", rule["properties"]["type"]["enum"])
|
||||
self.assertIn("overall_height", rule["properties"]["type"]["enum"])
|
||||
|
||||
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_cdsl_model", "patch_cdsl_model"])
|
||||
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)
|
||||
self.assertIn("verification", generate_tool["function"]["parameters"]["properties"])
|
||||
patch_tool = next(tool for tool in tools if tool["function"]["name"] == "patch_cdsl_model")
|
||||
self.assertIn("base_revision_id", patch_tool["function"]["parameters"]["required"])
|
||||
self.assertFalse(generate_tool["function"]["parameters"]["additionalProperties"])
|
||||
|
||||
def test_default_model_does_not_receive_strict_tool_schema(self) -> None:
|
||||
@@ -77,6 +202,31 @@ class ParseToolArgumentsTests(unittest.TestCase):
|
||||
|
||||
self.assertFalse(any(tool["function"].get("strict") for tool in tools))
|
||||
|
||||
def test_direct_cdsl_tools_exclude_spec_and_template_generators(self) -> None:
|
||||
names = [tool["function"]["name"] for tool in tools_for_model(ProviderModel("default-model"))]
|
||||
self.assertIn("generate_cdsl_model", names)
|
||||
self.assertIn("patch_cdsl_model", names)
|
||||
self.assertNotIn("create_generation_spec", names)
|
||||
self.assertNotIn("author_cdsl_from_generation_spec", names)
|
||||
self.assertNotIn("patch_generation_spec", names)
|
||||
self.assertNotIn("generate_flange_sleeve_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 +393,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), 5)
|
||||
self.assertEqual(len(failures), 5)
|
||||
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, 7)))
|
||||
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(len(agent.responses), 2)
|
||||
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 +804,95 @@ 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()
|
||||
@@ -1,202 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
|
||||
|
||||
def workplane(z: float = 0.0) -> dict[str, list[float]]:
|
||||
return {
|
||||
"origin_mm": [0.0, 0.0, z],
|
||||
"x_dir": [1.0, 0.0, 0.0],
|
||||
"y_dir": [0.0, 1.0, 0.0],
|
||||
"normal": [0.0, 0.0, 1.0],
|
||||
}
|
||||
|
||||
|
||||
def mounting_plate_intent() -> dict:
|
||||
return {
|
||||
"schema": "cad.cdsl.design-intent.v1",
|
||||
"schema_version": "1.0",
|
||||
"mode": "create",
|
||||
"request": "Create a mounting plate with four holes and a center slot",
|
||||
"base_revision_id": "",
|
||||
"structures": [
|
||||
{
|
||||
"id": "base_plate",
|
||||
"cdsl_feature_id": "base_add",
|
||||
"role": "base",
|
||||
"purpose": "Rectangular mounting plate",
|
||||
"depends_on": [],
|
||||
"cdsl_strategy": {
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"profile_type": "rectangle",
|
||||
"parameter_roles": ["width_mm", "height_mm", "thickness_mm"],
|
||||
"selector_roles": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "mounting_holes",
|
||||
"cdsl_feature_id": "hole_cut",
|
||||
"role": "subtractive",
|
||||
"purpose": "Four mounting holes",
|
||||
"depends_on": ["base_plate"],
|
||||
"cdsl_strategy": {
|
||||
"atomic_id": "extrude_cut_blind",
|
||||
"profile_type": "circle_grid",
|
||||
"parameter_roles": ["diameter_mm", "count_x", "count_y"],
|
||||
"selector_roles": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "adjustment_slot",
|
||||
"cdsl_feature_id": "slot_cut",
|
||||
"role": "subtractive",
|
||||
"purpose": "Center adjustment slot",
|
||||
"depends_on": ["mounting_holes"],
|
||||
"cdsl_strategy": {
|
||||
"atomic_id": "extrude_cut_blind",
|
||||
"profile_type": "obround",
|
||||
"parameter_roles": ["length_mm", "width_mm", "depth_mm"],
|
||||
"selector_roles": [],
|
||||
},
|
||||
},
|
||||
],
|
||||
"feature_order": ["base_plate", "mounting_holes", "adjustment_slot"],
|
||||
"assumptions": [],
|
||||
"open_questions": [],
|
||||
"capability_gaps": [],
|
||||
"verification_expectations": [
|
||||
{"type": "feature_count", "feature_id": "mounting_holes", "expected": 4},
|
||||
{"type": "symmetry", "axis": "x"},
|
||||
{"type": "bbox", "expected_mm": {"x": 100, "y": 60, "z": 10}},
|
||||
],
|
||||
"status": "ready",
|
||||
}
|
||||
|
||||
|
||||
def mounting_plate_cdsl() -> dict:
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.0",
|
||||
"kind": "part",
|
||||
"part_id": "mounting-plate",
|
||||
"geometry": {
|
||||
"sketches": [
|
||||
{"id": "base_sketch", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}},
|
||||
{"id": "holes_sketch", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}},
|
||||
{"id": "slot_sketch", "workplane": workplane(10), "profile": {"type": "obround", "center": [0, 0], "length_mm": 32, "width_mm": 10}},
|
||||
],
|
||||
},
|
||||
"features": [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}},
|
||||
{"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}},
|
||||
{"id": "slot_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["hole_cut"], "sketch_id": "slot_sketch", "params": {"distance_mm": 10, "reverse": True}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class DesignIntentValidationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
sys.path.insert(0, str(ROOT / "backend" / "engine"))
|
||||
import cdsl_engine
|
||||
|
||||
cls.engine = cdsl_engine
|
||||
|
||||
def assert_plan_error(self, intent: dict, code: str = "INVALID_DESIGN_INTENT") -> None:
|
||||
with self.assertRaises(self.engine.DesignIntentError) as context:
|
||||
self.engine.validate_design_intent(intent, self.engine)
|
||||
self.assertEqual(context.exception.code, code)
|
||||
|
||||
def test_valid_mounting_plate_plan_and_cdsl_match(self) -> None:
|
||||
intent = mounting_plate_intent()
|
||||
validated = self.engine.validate_design_intent(intent, self.engine)
|
||||
|
||||
self.assertEqual(validated["feature_order"], ["base_plate", "mounting_holes", "adjustment_slot"])
|
||||
self.engine.validate_intent_cdsl(validated, mounting_plate_cdsl(), self.engine)
|
||||
|
||||
def test_revise_requires_current_base_revision(self) -> None:
|
||||
intent = mounting_plate_intent()
|
||||
intent["mode"] = "revise"
|
||||
intent["base_revision_id"] = ""
|
||||
self.assert_plan_error(intent)
|
||||
|
||||
intent["base_revision_id"] = "rev_001"
|
||||
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")
|
||||
|
||||
def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None:
|
||||
duplicate = mounting_plate_intent()
|
||||
duplicate["structures"][1]["id"] = "base_plate"
|
||||
self.assert_plan_error(duplicate)
|
||||
|
||||
cyclic = mounting_plate_intent()
|
||||
cyclic["structures"][0]["depends_on"] = ["mounting_holes"]
|
||||
self.assert_plan_error(cyclic)
|
||||
|
||||
unsupported = mounting_plate_intent()
|
||||
unsupported["structures"][0]["cdsl_strategy"]["atomic_id"] = "thread_add"
|
||||
self.assert_plan_error(unsupported)
|
||||
|
||||
incomplete = mounting_plate_intent()
|
||||
incomplete["feature_order"].pop()
|
||||
self.assert_plan_error(incomplete)
|
||||
|
||||
no_profile = mounting_plate_intent()
|
||||
no_profile["structures"][0]["cdsl_strategy"].pop("profile_type")
|
||||
self.assert_plan_error(no_profile)
|
||||
|
||||
unknown_expectation = mounting_plate_intent()
|
||||
unknown_expectation["verification_expectations"][0]["feature_id"] = "missing"
|
||||
self.assert_plan_error(unknown_expectation)
|
||||
|
||||
def test_blockers_and_nonblocking_approximations_are_explicit(self) -> None:
|
||||
clarification = mounting_plate_intent()
|
||||
clarification["open_questions"] = [{"id": "thickness", "question": "What thickness is required?", "blocking": True}]
|
||||
clarification["status"] = "needs_clarification"
|
||||
self.engine.validate_design_intent(clarification, self.engine)
|
||||
with self.assertRaises(self.engine.DesignIntentError) as context:
|
||||
self.engine.validate_intent_cdsl(clarification, mounting_plate_cdsl(), self.engine)
|
||||
self.assertEqual(context.exception.code, "DESIGN_INTENT_BLOCKED")
|
||||
|
||||
approximation = mounting_plate_intent()
|
||||
approximation["capability_gaps"] = [{
|
||||
"code": "unsupported_thread_geometry",
|
||||
"structure_id": "mounting_holes",
|
||||
"message": "Only a cylindrical bore is available.",
|
||||
"blocking": False,
|
||||
}]
|
||||
self.assert_plan_error(approximation)
|
||||
approximation["assumptions"] = ["Thread geometry is approximated by cylindrical bores."]
|
||||
self.engine.validate_design_intent(approximation, self.engine)
|
||||
|
||||
def test_rejects_cdsl_feature_atomic_profile_and_selector_mismatches(self) -> None:
|
||||
missing_feature = mounting_plate_cdsl()
|
||||
missing_feature["features"].pop()
|
||||
with self.assertRaises(self.engine.DesignIntentError) as context:
|
||||
self.engine.validate_intent_cdsl(mounting_plate_intent(), missing_feature, self.engine)
|
||||
self.assertEqual(context.exception.code, "INTENT_CDSL_MISMATCH")
|
||||
|
||||
atomic = mounting_plate_cdsl()
|
||||
atomic["features"][1]["atomic_id"] = "extrude_add_blind"
|
||||
with self.assertRaises(self.engine.DesignIntentError):
|
||||
self.engine.validate_intent_cdsl(mounting_plate_intent(), atomic, self.engine)
|
||||
|
||||
profile = mounting_plate_cdsl()
|
||||
profile["geometry"]["sketches"][1]["profile"]["type"] = "circles"
|
||||
with self.assertRaises(self.engine.DesignIntentError):
|
||||
self.engine.validate_intent_cdsl(mounting_plate_intent(), profile, self.engine)
|
||||
|
||||
selector_plan = copy.deepcopy(mounting_plate_intent())
|
||||
selector_plan["structures"][2]["cdsl_strategy"]["selector_roles"] = ["host_face"]
|
||||
with self.assertRaises(self.engine.DesignIntentError):
|
||||
self.engine.validate_intent_cdsl(selector_plan, mounting_plate_cdsl(), self.engine)
|
||||
@@ -6,25 +6,50 @@ 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.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
|
||||
|
||||
|
||||
BACKEND = ROOT / "backend"
|
||||
PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills"
|
||||
|
||||
|
||||
class DesignIntentFlowTests(unittest.TestCase):
|
||||
def _workplane(z: float = 0.0) -> dict[str, list[float]]:
|
||||
return {"origin_mm": [0.0, 0.0, z], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]}
|
||||
|
||||
|
||||
def mounting_plate_cdsl() -> dict:
|
||||
holes = [
|
||||
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": center, "radius_mm": 3}]}
|
||||
for center in [[-40, -20], [40, -20], [-40, 20], [40, 20]]
|
||||
]
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.0",
|
||||
"kind": "part",
|
||||
"part_id": "mounting-plate",
|
||||
"geometry": {"sketches": [
|
||||
{"id": "base_sketch", "workplane": _workplane(), "profile": {"type": "polygon", "vertices": [[-50, -30], [50, -30], [50, 30], [-50, 30]]}},
|
||||
{"id": "holes_sketch", "workplane": _workplane(10), "profile": {"type": "analytic_contours", "contours": holes}},
|
||||
]},
|
||||
"features": [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}},
|
||||
{"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class DirectCdslFlowTests(unittest.TestCase):
|
||||
def settings(self, root: Path) -> Settings:
|
||||
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
|
||||
return Settings(
|
||||
@@ -40,85 +65,232 @@ 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": "INTAKE", "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": "PLANNED", "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_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 cylindrical part.", "assumptions": []}),
|
||||
},
|
||||
}],
|
||||
}}],
|
||||
},
|
||||
{
|
||||
"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": [],
|
||||
}),
|
||||
},
|
||||
}],
|
||||
}}],
|
||||
},
|
||||
]
|
||||
|
||||
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 mounting plate")])
|
||||
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,
|
||||
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 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("structures", brief_result)
|
||||
self.assertNotIn("design_intent", captured_build)
|
||||
self.assertEqual(captured_build["parent_revision_id"], "")
|
||||
|
||||
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": "INTAKE", "design_brief": "", "current_model_read": True}
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
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.services.agent_service import AgentService, _validate_snapshot_selectors # noqa: E402
|
||||
from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch # noqa: E402
|
||||
from app.services.engine_service import QualityVerificationError, build_revision # noqa: E402
|
||||
from app.services.library import CdslLibrary # noqa: E402
|
||||
from app.services.part_skills import PartSkillLibrary # noqa: E402
|
||||
from app.services.quality import evaluate_quality, validate_verification # noqa: E402
|
||||
from app.services.storage import WorkspaceStore, write_json # noqa: E402
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
|
||||
|
||||
|
||||
BACKEND = ROOT / "backend"
|
||||
PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills"
|
||||
|
||||
|
||||
def workplane(z: float = 0.0) -> dict[str, list[float]]:
|
||||
return {
|
||||
"origin_mm": [0.0, 0.0, z],
|
||||
"x_dir": [1.0, 0.0, 0.0],
|
||||
"y_dir": [0.0, 1.0, 0.0],
|
||||
"normal": [0.0, 0.0, 1.0],
|
||||
}
|
||||
|
||||
|
||||
def fixture() -> dict:
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.0",
|
||||
"kind": "part",
|
||||
"part_id": "direct-cdsl-fixture",
|
||||
"geometry": {
|
||||
"sketches": [{
|
||||
"id": "base",
|
||||
"workplane": workplane(),
|
||||
"profile": {"type": "polygon", "vertices": [[-10, -5], [10, -5], [10, 5], [-10, 5]]},
|
||||
}],
|
||||
},
|
||||
"features": [{
|
||||
"id": "base_add",
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"depends_on": [],
|
||||
"sketch_id": "base",
|
||||
"params": {"distance_mm": 4},
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def settings(root: Path) -> Settings:
|
||||
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
|
||||
return Settings(
|
||||
task_root=root / "tasks",
|
||||
conversation_root=root / "conversations",
|
||||
library_root=BACKEND / "cdsl_library",
|
||||
engine_root=BACKEND / "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,),
|
||||
)
|
||||
|
||||
|
||||
class PatchTests(unittest.TestCase):
|
||||
def test_rfc6902_patch_updates_cdsl_without_replacing_its_root(self) -> None:
|
||||
document = fixture()
|
||||
patched = apply_cdsl_patch(document, [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}])
|
||||
|
||||
self.assertEqual(patched["features"][0]["params"]["distance_mm"], 8)
|
||||
self.assertEqual(document["features"][0]["params"]["distance_mm"], 4)
|
||||
with self.assertRaisesRegex(CdslPatchError, "complete CDSL"):
|
||||
apply_cdsl_patch(document, [{"op": "replace", "path": "", "value": {}}])
|
||||
|
||||
def test_invalid_patch_path_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(CdslPatchError, "out of range"):
|
||||
apply_cdsl_patch(fixture(), [{"op": "replace", "path": "/features/3/id", "value": "x"}])
|
||||
|
||||
|
||||
class VerificationTests(unittest.TestCase):
|
||||
def test_bbox_accepts_dimension_vector(self) -> None:
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "size", "type": "bbox", "expected": [20, 10, 4]},
|
||||
]}, fixture())
|
||||
report = evaluate_quality(rules, fixture(), {
|
||||
"bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]},
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual(report["results"][0]["actual"]["dimensions"], [20.0, 10.0, 4.0])
|
||||
|
||||
def test_bbox_accepts_coordinate_ranges(self) -> None:
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "range", "type": "bbox", "expected": {
|
||||
"x_min": -10, "x_max": 10, "y_min": -5, "y_max": 5, "z_min": 0, "z_max": 4,
|
||||
}},
|
||||
]}, fixture())
|
||||
report = evaluate_quality(rules, fixture(), {
|
||||
"bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]},
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
|
||||
def test_feature_bbox_uses_owned_topology_records(self) -> None:
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "base_range", "type": "bbox", "feature": "base_add", "expected": [10, 8, 2]},
|
||||
]}, fixture())
|
||||
report = evaluate_quality(rules, fixture(), {
|
||||
"bbox_mm": {"min": [0, 0, 0], "max": [20, 20, 20]},
|
||||
"topology_records": [{
|
||||
"feature_id": "base_add", "owner_feature_ids": ["base_add"],
|
||||
"geometry": {"bbox_mm": [0, 0, 0, 10, 8, 2]},
|
||||
}],
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual(report["results"][0]["source"], "runtime.topology_records[base_add].bbox_mm")
|
||||
|
||||
def test_feature_bbox_excludes_inherited_runtime_records(self) -> None:
|
||||
document = fixture()
|
||||
document["features"].append({
|
||||
"id": "boss",
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"depends_on": ["base_add"],
|
||||
"sketch_id": "base",
|
||||
"params": {"distance_mm": 8},
|
||||
})
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "boss_range", "type": "bbox", "feature": "boss", "expected": [8, 58, 58]},
|
||||
]}, document)
|
||||
report = evaluate_quality(rules, document, {
|
||||
"bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]},
|
||||
"topology_records": [
|
||||
{
|
||||
"feature_id": "boss", "owner_feature_ids": ["base"],
|
||||
"geometry": {"bbox_mm": [0, 0, 0, 128, 70, 90]},
|
||||
},
|
||||
{
|
||||
"feature_id": "boss", "owner_feature_ids": ["boss"],
|
||||
"geometry": {"bbox_mm": [60, -29, 16, 68, 29, 74]},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual(report["results"][0]["actual"]["dimensions"], [8.0, 58.0, 58.0])
|
||||
|
||||
def test_feature_diameter_uses_target_circle(self) -> None:
|
||||
document = fixture()
|
||||
document["features"][0]["id"] = "boss"
|
||||
document["features"][0]["sketch_id"] = "boss_sketch"
|
||||
document["geometry"]["sketches"][0]["id"] = "boss_sketch"
|
||||
document["geometry"]["sketches"][0]["profile"] = {"type": "circle", "center": [0, 0], "radius_mm": 29}
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "boss_dia", "type": "overall_diameter", "feature": "boss", "expected": 58},
|
||||
]}, document)
|
||||
report = evaluate_quality(rules, document, {
|
||||
"bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]},
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual(report["results"][0]["actual"], 58.0)
|
||||
|
||||
def test_overall_width_and_height_are_supported(self) -> None:
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "width", "type": "overall_width", "expected": 70},
|
||||
{"id": "height", "type": "overall_height", "expected": 90},
|
||||
]}, fixture())
|
||||
report = evaluate_quality(rules, fixture(), {
|
||||
"bbox_mm": {"min": [-64, -35, 0], "max": [64, 35, 90]},
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual([item["actual"] for item in report["results"]], [70.0, 90.0])
|
||||
|
||||
def test_feature_scoped_verification_requires_feature(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "feature is required for hole_count"):
|
||||
validate_verification({"rules": [
|
||||
{"id": "holes", "type": "hole_count", "expected": 1},
|
||||
]}, fixture())
|
||||
|
||||
def test_bbox_shape_is_rejected_before_execution(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "expected for bbox"):
|
||||
validate_verification({"rules": [
|
||||
{"id": "bad", "type": "bbox", "expected": {"width": 10}},
|
||||
]}, fixture())
|
||||
|
||||
def test_unknown_feature_reference_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "must reference a CDSL feature ID"):
|
||||
validate_verification({"rules": [{"id": "holes", "type": "hole_count", "feature": "missing", "expected": 4}]}, fixture())
|
||||
|
||||
def test_invalid_expected_value_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "expected for bbox"):
|
||||
validate_verification({"rules": [{"id": "size", "type": "bbox", "expected": [10, 5]}]}, fixture())
|
||||
|
||||
def test_warning_does_not_block_generic_quality(self) -> None:
|
||||
rules = validate_verification({"rules": [
|
||||
{"id": "solids", "type": "solid_count", "expected": 1, "severity": "blocking"},
|
||||
{"id": "length", "type": "overall_length", "expected": 999, "severity": "warning"},
|
||||
]}, fixture())
|
||||
report = evaluate_quality(rules, fixture(), {
|
||||
"solid_count": 1,
|
||||
"bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]},
|
||||
})
|
||||
|
||||
self.assertEqual(report["status"], "passed")
|
||||
self.assertEqual(len(report["blocking_failures"]), 0)
|
||||
self.assertEqual(len(report["warnings"]), 1)
|
||||
|
||||
def test_blocking_failure_requires_repair(self) -> None:
|
||||
rules = validate_verification({"rules": [{"id": "solids", "type": "solid_count", "expected": 2}]}, fixture())
|
||||
report = evaluate_quality(rules, fixture(), {"solid_count": 1, "bbox_mm": {"min": [0, 0, 0], "max": [1, 1, 1]}})
|
||||
|
||||
self.assertEqual(report["status"], "failed")
|
||||
self.assertEqual(report["blocking_failures"][0]["id"], "solids")
|
||||
|
||||
|
||||
class AgentPatchFlowTests(unittest.TestCase):
|
||||
def _seed_revision(self, store: WorkspaceStore) -> tuple[str, str]:
|
||||
task = store.ensure_task(None, "Create a direct CDSL fixture")
|
||||
revision_id = "rev_001"
|
||||
revision_dir = store.task_dir(task["task_id"]) / "revisions" / revision_id
|
||||
revision_dir.mkdir(parents=True)
|
||||
write_json(revision_dir / "model.cdsl.json", fixture())
|
||||
store.update_task(task["task_id"], {
|
||||
"revision_id": revision_id,
|
||||
"status": "success",
|
||||
"cdsl_path": f"revisions/{revision_id}/model.cdsl.json",
|
||||
})
|
||||
return task["task_id"], revision_id
|
||||
|
||||
def test_patch_creates_a_child_revision_from_explicit_parent(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
task_id, parent = self._seed_revision(store)
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_build_revision(**kwargs: object) -> dict[str, object]:
|
||||
captured.update(kwargs)
|
||||
return {"task_id": task_id, "revision_id": "rev_002"}
|
||||
|
||||
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
result, _ = asyncio.run(agent._run_tool(
|
||||
"patch_cdsl_model",
|
||||
{
|
||||
"base_revision_id": parent,
|
||||
"patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}],
|
||||
"summary": "Increase thickness",
|
||||
"assumptions": [],
|
||||
},
|
||||
task_id,
|
||||
"Increase thickness",
|
||||
[],
|
||||
planning_state={"phase": "PLANNED", "current_model_read": True},
|
||||
))
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(captured["parent_revision_id"], parent)
|
||||
self.assertEqual(captured["operation"]["type"], "cdsl_patch")
|
||||
self.assertEqual(captured["cdsl"]["features"][0]["params"]["distance_mm"], 8)
|
||||
|
||||
def test_completed_feature_can_keep_selector_from_historical_snapshot(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
task_id, parent = self._seed_revision(store)
|
||||
parent_dir = store.task_dir(task_id) / "revisions" / parent
|
||||
parent_snapshot_id = f"{task_id}/{parent}"
|
||||
write_json(parent_dir / "model.topology.json", {
|
||||
"schema_version": "cad.topology.v1",
|
||||
"task_id": task_id,
|
||||
"revision_id": parent,
|
||||
"snapshot_id": parent_snapshot_id,
|
||||
"records": [{"record_id": "body:base:edge:0", "kind": "edge", "executable": True, "geometry": {}}],
|
||||
})
|
||||
task = store.read_task(task_id)
|
||||
task["revisions"][0]["topology_path"] = f"revisions/{parent}/model.topology.json"
|
||||
write_json(store.task_path(task_id), task)
|
||||
|
||||
child = "rev_002"
|
||||
child_dir = store.task_dir(task_id) / "revisions" / child
|
||||
child_dir.mkdir(parents=True)
|
||||
write_json(child_dir / "model.cdsl.json", fixture())
|
||||
write_json(child_dir / "model.topology.json", {
|
||||
"schema_version": "cad.topology.v1",
|
||||
"task_id": task_id,
|
||||
"revision_id": child,
|
||||
"snapshot_id": f"{task_id}/{child}",
|
||||
"records": [{"record_id": "body:base:edge:1", "kind": "edge", "executable": True, "geometry": {}}],
|
||||
})
|
||||
store.update_task(task_id, {
|
||||
"revision_id": child,
|
||||
"status": "success",
|
||||
"cdsl_path": f"revisions/{child}/model.cdsl.json",
|
||||
"topology_path": f"revisions/{child}/model.topology.json",
|
||||
})
|
||||
|
||||
document = fixture()
|
||||
document["features"][0]["selectors"] = [{
|
||||
"kind": "edge",
|
||||
"stable_id": "body:base:edge:0",
|
||||
"source": "runtime_snapshot",
|
||||
"snapshot_id": parent_snapshot_id,
|
||||
"confidence": 1.0,
|
||||
}]
|
||||
_validate_snapshot_selectors(store, task_id, document)
|
||||
|
||||
document["features"][0]["selectors"][0]["snapshot_id"] = f"{task_id}/rev_999"
|
||||
with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"):
|
||||
_validate_snapshot_selectors(store, task_id, document)
|
||||
|
||||
def test_topology_inspection_unlocks_waiting_plan_nodes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
task_id, revision_id = self._seed_revision(store)
|
||||
revision_dir = store.task_dir(task_id) / "revisions" / revision_id
|
||||
snapshot_id = f"{task_id}/{revision_id}"
|
||||
write_json(revision_dir / "model.topology.json", {
|
||||
"schema_version": "cad.topology.v1",
|
||||
"task_id": task_id,
|
||||
"revision_id": revision_id,
|
||||
"snapshot_id": snapshot_id,
|
||||
"records": [{
|
||||
"record_id": "body:base_add",
|
||||
"kind": "body",
|
||||
"body_id": "body:base_add",
|
||||
"feature_id": "base_add",
|
||||
"owner_feature_ids": ["base_add"],
|
||||
"geometry": {},
|
||||
"executable": True,
|
||||
}],
|
||||
})
|
||||
task = store.read_task(task_id)
|
||||
task["revisions"][0]["topology_path"] = f"revisions/{revision_id}/model.topology.json"
|
||||
write_json(store.task_path(task_id), task)
|
||||
state = {
|
||||
"phase": "TOPOLOGY_READY",
|
||||
"feature_plan": {
|
||||
"schema_version": "cad.feature-plan.v1",
|
||||
"plan_id": "plan_1",
|
||||
"task_id": task_id,
|
||||
"nodes": [
|
||||
{"id": "base", "atomic_id": "extrude_add_blind", "depends_on": [], "cdsl_feature_ids": ["base_add"]},
|
||||
{"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "cdsl_feature_ids": ["round"]},
|
||||
],
|
||||
},
|
||||
}
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
|
||||
result, _ = asyncio.run(agent._run_tool(
|
||||
"inspect_current_topology", {}, task_id, "Round the edges", [], planning_state=state,
|
||||
))
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(state["feature_plan"]["topology_snapshot_id"], snapshot_id)
|
||||
self.assertEqual(state["feature_plan"]["ready_nodes"], ["round"])
|
||||
|
||||
def test_successful_build_automatically_binds_topology_for_waiting_nodes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
state = {
|
||||
"phase": "PLAN_READY",
|
||||
"feature_plan": {
|
||||
"schema_version": "cad.feature-plan.v1",
|
||||
"plan_id": "plan_1",
|
||||
"task_id": "",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "base",
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"depends_on": [],
|
||||
"cdsl_feature_ids": ["base_add"],
|
||||
"status": "ready",
|
||||
},
|
||||
{
|
||||
"id": "round",
|
||||
"atomic_id": "fillet",
|
||||
"depends_on": ["base"],
|
||||
"cdsl_feature_ids": ["round"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
result, _ = asyncio.run(agent._run_tool(
|
||||
"generate_cdsl_model",
|
||||
{"cdsl": fixture(), "summary": "Base feature", "assumptions": []},
|
||||
None,
|
||||
"Create a base with a later fillet",
|
||||
[],
|
||||
planning_state=state,
|
||||
))
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["required_action"], "patch_cdsl_model")
|
||||
self.assertEqual(result["plan_status"]["ready_nodes"], ["round"])
|
||||
self.assertEqual(result["plan_status"]["waiting_nodes"], [])
|
||||
self.assertEqual(state["feature_plan"]["topology_snapshot_id"], "{}/{}".format(result["task_id"], result["revision_id"]))
|
||||
|
||||
def test_bad_patch_creates_no_revision(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
task_id, parent = self._seed_revision(store)
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
with self.assertRaisesRegex(ValueError, "INVALID_CDSL_PATCH"):
|
||||
asyncio.run(agent._run_tool(
|
||||
"patch_cdsl_model",
|
||||
{"base_revision_id": parent, "patches": [{"op": "replace", "path": "/missing", "value": 8}], "summary": "bad", "assumptions": []},
|
||||
task_id,
|
||||
"bad patch",
|
||||
[],
|
||||
planning_state={"phase": "PLANNED"},
|
||||
))
|
||||
|
||||
task = store.read_task(task_id)
|
||||
self.assertEqual([item["revision_id"] for item in task["revisions"]], [parent])
|
||||
|
||||
def test_patch_rejects_non_current_parent_revision(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
task_id, parent = self._seed_revision(store)
|
||||
child = "rev_002"
|
||||
child_dir = store.task_dir(task_id) / "revisions" / child
|
||||
child_dir.mkdir(parents=True)
|
||||
write_json(child_dir / "model.cdsl.json", fixture())
|
||||
store.update_task(task_id, {
|
||||
"revision_id": child,
|
||||
"status": "success",
|
||||
"cdsl_path": f"revisions/{child}/model.cdsl.json",
|
||||
})
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"):
|
||||
asyncio.run(agent._run_tool(
|
||||
"patch_cdsl_model",
|
||||
{"base_revision_id": parent, "patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}], "summary": "stale", "assumptions": []},
|
||||
task_id,
|
||||
"stale patch",
|
||||
[],
|
||||
planning_state={"phase": "PLANNED"},
|
||||
))
|
||||
|
||||
def test_complete_replacement_uses_current_revision_as_parent(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
task_id, parent = self._seed_revision(store)
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_build_revision(**kwargs: object) -> dict[str, object]:
|
||||
captured.update(kwargs)
|
||||
return {"task_id": task_id, "revision_id": "rev_002"}
|
||||
|
||||
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
|
||||
result, _ = asyncio.run(agent._run_tool(
|
||||
"generate_cdsl_model",
|
||||
{"cdsl": fixture(), "summary": "Replacement", "assumptions": []},
|
||||
task_id,
|
||||
"replace geometry",
|
||||
[],
|
||||
planning_state={"phase": "PLANNED", "current_model_read": True},
|
||||
))
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(captured["parent_revision_id"], parent)
|
||||
self.assertEqual(captured["operation"]["type"], "cdsl_replacement")
|
||||
|
||||
def test_blocking_quality_failure_is_repairable_and_readable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
with self.assertRaises(QualityVerificationError) as context:
|
||||
build_revision(
|
||||
settings=config,
|
||||
store=store,
|
||||
task_id=None,
|
||||
request="Create fixture",
|
||||
cdsl=fixture(),
|
||||
reference_ids=[],
|
||||
summary="fixture",
|
||||
verification={"rules": [{"id": "wrong-solid-count", "type": "solid_count", "expected": 2}]},
|
||||
)
|
||||
|
||||
error = context.exception
|
||||
self.assertTrue(error.task_id)
|
||||
self.assertEqual(error.revision_id, "rev_001")
|
||||
task = store.read_task(error.task_id)
|
||||
self.assertEqual(task["revisions"][0]["status"], "needs_repair")
|
||||
self.assertEqual(task["revisions"][0]["quality_status"], "needs_repair")
|
||||
|
||||
agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT))
|
||||
read, _ = asyncio.run(agent._run_tool(
|
||||
"read_current_cdsl", {}, error.task_id, "Repair fixture", [], planning_state={"phase": "INTAKE"}
|
||||
))
|
||||
self.assertTrue(read["ok"])
|
||||
self.assertEqual(read["revision_id"], error.revision_id)
|
||||
|
||||
def test_runtime_build_failure_does_not_leave_a_revision(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = settings(Path(directory))
|
||||
store = WorkspaceStore(config)
|
||||
|
||||
class BrokenEngine:
|
||||
def run_cdsl_only(self, _cdsl: dict, _step: Path) -> dict:
|
||||
raise RuntimeError("intentional runtime failure")
|
||||
|
||||
with patch("app.services.engine_service.load_engine", return_value=BrokenEngine()), patch(
|
||||
"app.services.engine_service.validate_cdsl", return_value=None
|
||||
), self.assertRaisesRegex(RuntimeError, "intentional runtime failure"):
|
||||
build_revision(
|
||||
settings=config,
|
||||
store=store,
|
||||
task_id=None,
|
||||
request="broken fixture",
|
||||
cdsl=fixture(),
|
||||
reference_ids=[],
|
||||
summary="broken",
|
||||
)
|
||||
|
||||
tasks = list(config.task_root.glob("cad_*"))
|
||||
self.assertEqual(len(tasks), 1)
|
||||
task = store.read_task(tasks[0].name)
|
||||
self.assertEqual(task["revisions"], [])
|
||||
|
||||
|
||||
class CleanupAndBoundaryTests(unittest.TestCase):
|
||||
def test_cleanup_is_dry_run_then_removes_only_retired_artifacts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
data_root = Path(directory) / "data"
|
||||
task_dir = data_root / "tasks" / "cad_aaaaaaaaaaaa" / "revisions" / "rev_001"
|
||||
conversation_dir = data_root / "conversations" / "conv_aaaaaaaaaaaa" / "diagnostics"
|
||||
task_dir.mkdir(parents=True)
|
||||
conversation_dir.mkdir(parents=True)
|
||||
write_json(task_dir / "generation-spec.json", {"schema": "cad.generation-spec.v1"})
|
||||
write_json(task_dir / "model.cdsl.json", fixture())
|
||||
(task_dir / "model.step").write_bytes(b"step")
|
||||
(task_dir / "model.glb").write_bytes(b"glb")
|
||||
write_json(data_root / "tasks" / "cad_aaaaaaaaaaaa" / "task.json", {
|
||||
"generation_spec_path": "revisions/rev_001/generation-spec.json",
|
||||
"current_design_intent_id": "intent_aaaaaaaaaaaa",
|
||||
"revisions": [{"acceptance_path": "revisions/rev_001/acceptance-report.json"}],
|
||||
})
|
||||
write_json(conversation_dir / "generation_spec_validation_deadbeef.json", {"kind": "old"})
|
||||
|
||||
script = BACKEND / "scripts" / "remove_generation_spec_artifacts.py"
|
||||
dry = subprocess.run([sys.executable, str(script), "--data-root", str(data_root)], check=True, capture_output=True, text=True)
|
||||
self.assertIn("Dry run only", dry.stdout)
|
||||
self.assertTrue((task_dir / "generation-spec.json").is_file())
|
||||
|
||||
subprocess.run([sys.executable, str(script), "--data-root", str(data_root), "--apply"], check=True, capture_output=True, text=True)
|
||||
self.assertFalse((task_dir / "generation-spec.json").exists())
|
||||
self.assertFalse((conversation_dir / "generation_spec_validation_deadbeef.json").exists())
|
||||
self.assertTrue((task_dir / "model.cdsl.json").is_file())
|
||||
self.assertTrue((task_dir / "model.step").is_file())
|
||||
self.assertTrue((task_dir / "model.glb").is_file())
|
||||
cleaned = json.loads((data_root / "tasks" / "cad_aaaaaaaaaaaa" / "task.json").read_text(encoding="utf-8"))
|
||||
self.assertNotIn("generation_spec_path", cleaned)
|
||||
self.assertNotIn("current_design_intent_id", cleaned)
|
||||
self.assertNotIn("acceptance_path", cleaned["revisions"][0])
|
||||
|
||||
def test_engine_does_not_import_application_or_dispatch_part_families(self) -> None:
|
||||
engine_root = BACKEND / "engine" / "cdsl_engine"
|
||||
source = "\n".join(path.read_text(encoding="utf-8") for path in engine_root.rglob("*.py"))
|
||||
lowered = source.casefold()
|
||||
|
||||
self.assertNotIn("from app", lowered)
|
||||
self.assertNotIn("import app", lowered)
|
||||
for business_family in ("mounting_bracket", "mounting_plate", "flange_sleeve", "bearing_housing", "hex_nut"):
|
||||
self.assertNotIn(business_family, lowered)
|
||||
@@ -21,6 +21,13 @@ def _workplane() -> dict:
|
||||
return {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
|
||||
|
||||
|
||||
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
|
||||
return {"type": "polygon", "vertices": [
|
||||
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
|
||||
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
|
||||
]}
|
||||
|
||||
|
||||
class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
def _base_block(self) -> dict:
|
||||
return {
|
||||
@@ -28,7 +35,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "base", "workplane": _workplane(),
|
||||
"profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10},
|
||||
"profile": _rectangle([-5, -5], [5, 5]),
|
||||
}]},
|
||||
"features": [{
|
||||
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
@@ -186,6 +193,37 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
self.assertEqual(resolution.status, "ambiguous")
|
||||
self.assertEqual(resolution.diagnostic.code, "selector_ambiguous")
|
||||
|
||||
def test_selector_resolver_prefers_exact_runtime_stable_id(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
geometry = {"curve_type": "circle", "center_mm": [0, 0, 0]}
|
||||
registry.register(TopologyRecord("body:b:edge:18", "edge", "boss", "body:b", geometry))
|
||||
registry.register(TopologyRecord("body:b:edge:20", "edge", "boss", "body:b", geometry))
|
||||
|
||||
resolution = registry.resolve({
|
||||
"kind": "edge",
|
||||
"stable_id": "body:b:edge:18",
|
||||
"owner_feature_id": "boss",
|
||||
"snapshot_id": "cad_test/rev_001",
|
||||
"geometry": geometry,
|
||||
}, active_body_id="body:b")
|
||||
|
||||
self.assertEqual(resolution.status, "resolved")
|
||||
self.assertEqual(resolution.record.record_id, "body:b:edge:18")
|
||||
|
||||
def test_snapshot_stable_id_rejects_geometry_that_changed(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
registry.register(TopologyRecord(
|
||||
"body:b:edge:18", "edge", "boss", "body:b",
|
||||
{"curve_type": "circle", "center_mm": [0, 0, 0]},
|
||||
))
|
||||
resolution = registry.resolve({
|
||||
"kind": "edge", "stable_id": "body:b:edge:18", "owner_feature_id": "boss",
|
||||
"snapshot_id": "cad_test/rev_001",
|
||||
"geometry": {"curve_type": "circle", "center_mm": [1, 0, 0]},
|
||||
}, active_body_id="body:b")
|
||||
self.assertEqual(resolution.status, "not_found")
|
||||
self.assertEqual(resolution.diagnostic.code, "selector_geometry_mismatch")
|
||||
|
||||
def test_selector_resolver_normalizes_legacy_solidworks_plane_evidence(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
registry.register(TopologyRecord(
|
||||
@@ -490,7 +528,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "mirror-test",
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [
|
||||
{"id": "base", "workplane": _workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}},
|
||||
{"id": "base", "workplane": _workplane(), "profile": _rectangle([-5, -5], [5, 5])},
|
||||
{"id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "center": [2, 0], "radius_mm": 1}},
|
||||
]},
|
||||
"features": [
|
||||
@@ -518,7 +556,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"geometry": {"sketches": [{
|
||||
"id": "circles", "workplane": _workplane(),
|
||||
"profile": {"type": "circles", "items": [{"center": [2, 3], "radius_mm": 1}]},
|
||||
"profile": {"type": "circle", "center": [2, 3], "radius_mm": 1},
|
||||
}]},
|
||||
"features": [],
|
||||
}
|
||||
@@ -761,7 +799,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "profile", "workplane": _workplane(),
|
||||
"profile": {"type": "rectangle", "min_mm": [2, 1], "max_mm": [4, 2]},
|
||||
"profile": _rectangle([2, 1], [4, 2]),
|
||||
}]},
|
||||
"features": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.services.feature_plan import FeaturePlanError, compute_node_statuses, validate_feature_plan # noqa: E402
|
||||
|
||||
|
||||
class FeaturePlanTests(unittest.TestCase):
|
||||
def plan(self) -> dict:
|
||||
return {
|
||||
"schema_version": "cad.feature-plan.v1",
|
||||
"plan_id": "plan_test",
|
||||
"task_id": "cad_test",
|
||||
"nodes": [
|
||||
{"id": "base", "atomic_id": "extrude_add_blind", "depends_on": []},
|
||||
{"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "requires_topology": True},
|
||||
],
|
||||
}
|
||||
|
||||
def test_rejects_cycles_and_missing_dependencies(self) -> None:
|
||||
cyclic = self.plan()
|
||||
cyclic["nodes"][0]["depends_on"] = ["round"]
|
||||
with self.assertRaises(FeaturePlanError):
|
||||
validate_feature_plan(cyclic, supported_atomic_ids={"extrude_add_blind", "fillet"})
|
||||
missing = self.plan()
|
||||
missing["nodes"][1]["depends_on"] = ["missing"]
|
||||
with self.assertRaises(FeaturePlanError):
|
||||
validate_feature_plan(missing, supported_atomic_ids={"extrude_add_blind", "fillet"})
|
||||
|
||||
def test_ready_prefix_waits_for_topology(self) -> None:
|
||||
statuses = compute_node_statuses(self.plan(), cdsl={"features": []})
|
||||
self.assertEqual(statuses["ready_nodes"], ["base"])
|
||||
self.assertEqual(statuses["waiting_nodes"], [])
|
||||
|
||||
def test_built_prefix_unlocks_topology_dependent_node(self) -> None:
|
||||
statuses = compute_node_statuses(
|
||||
self.plan(),
|
||||
cdsl={"features": [{"id": "base"}]},
|
||||
topology={"records": [{"record_id": "body:base", "kind": "body", "executable": True}]},
|
||||
)
|
||||
self.assertEqual(statuses["completed_nodes"], ["base"])
|
||||
self.assertEqual(statuses["waiting_nodes"], ["round"])
|
||||
|
||||
def test_inspected_snapshot_unlocks_topology_dependent_node(self) -> None:
|
||||
plan = self.plan()
|
||||
plan["topology_snapshot_id"] = "cad_test/rev_001"
|
||||
statuses = compute_node_statuses(
|
||||
plan,
|
||||
cdsl={"features": [{"id": "base"}]},
|
||||
topology={
|
||||
"snapshot_id": "cad_test/rev_001",
|
||||
"records": [{"record_id": "body:base", "kind": "body", "executable": True}],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(statuses["ready_nodes"], ["round"])
|
||||
self.assertEqual(statuses["waiting_nodes"], [])
|
||||
|
||||
def test_dependency_must_appear_before_dependent_node(self) -> None:
|
||||
plan = self.plan()
|
||||
plan["nodes"] = [plan["nodes"][1], plan["nodes"][0]]
|
||||
with self.assertRaisesRegex(FeaturePlanError, "appear after dependency"):
|
||||
validate_feature_plan(plan, supported_atomic_ids={"extrude_add_blind", "fillet"})
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.services.image_observation import (
|
||||
merge_image_observations,
|
||||
normalize_image_observation,
|
||||
normalize_sketch_candidates,
|
||||
render_image_observation_context,
|
||||
)
|
||||
from app.services.image_processing import cv_hints, image_metadata
|
||||
from app.services.agent_service import tools_for_model
|
||||
from app.services.agent_service import AgentService
|
||||
from app.services.attachments import attachment_record
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.storage import WorkspaceStore
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
from app.models.contracts import ChatMessage, MessagePart
|
||||
|
||||
|
||||
def test_observation_keeps_multiview_profiles_and_measurement_sources() -> None:
|
||||
result = normalize_image_observation({
|
||||
"part_type": "bent bracket",
|
||||
"visible_features": ["plate", "irregular opening"],
|
||||
"uncertain_features": ["inner bend radius"],
|
||||
"views": [{"attachment_id": "upload_a", "view_role": "front", "confidence": 0.8}],
|
||||
"profiles": [{
|
||||
"id": "opening_01",
|
||||
"role": "cutout",
|
||||
"closed": True,
|
||||
"source_images": ["upload_a"],
|
||||
"segments": [
|
||||
{"type": "line", "start": [0, 0], "end": [10, 0]},
|
||||
{"type": "arc", "start": [10, 0], "end": [10, 4], "center": [8, 2], "radius_mm": 2},
|
||||
{"type": "polyline", "points": [[10, 4], [5, 8], [0, 4]]},
|
||||
],
|
||||
}],
|
||||
"measurements": [{"name": "plate_thickness", "value_mm": 1.2, "source": "user"}],
|
||||
}, attachment_ids=["upload_a"])
|
||||
|
||||
assert result["schema_version"] == "cad.image-observation.v2"
|
||||
assert result["profiles"][0]["segments"][1]["type"] == "arc"
|
||||
assert result["measurements"][0]["source"] == "user"
|
||||
assert "opening_01" in render_image_observation_context(result)
|
||||
|
||||
|
||||
def test_sketch_merge_does_not_replace_user_measurement() -> None:
|
||||
survey = normalize_image_observation({
|
||||
"part_type": "bracket",
|
||||
"visible_features": ["plate"],
|
||||
"uncertain_features": [],
|
||||
"views": [{"attachment_id": "upload_a"}],
|
||||
"measurements": [{"name": "thickness", "value_mm": 1.2, "source": "user"}],
|
||||
}, attachment_ids=["upload_a"])
|
||||
sketches = normalize_sketch_candidates({
|
||||
"profiles": [],
|
||||
"measurements": [{"name": "thickness", "value_mm": 1.6, "source": "image"}],
|
||||
"uncertainties": ["bend radius"],
|
||||
}, attachment_ids=["upload_a"])
|
||||
merged = merge_image_observations(survey, sketches)
|
||||
|
||||
assert merged["measurements"][0]["value_mm"] == 1.2
|
||||
assert merged["uncertainties"] == ["bend radius"]
|
||||
|
||||
|
||||
def test_image_metadata_and_cv_degrade_without_required_cv_support() -> None:
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", (32, 16), "white").save(output, format="PNG")
|
||||
metadata = image_metadata(output.getvalue())
|
||||
assert metadata["width"] == 32
|
||||
assert metadata["height"] == 16
|
||||
assert "available" in cv_hints(output.getvalue())
|
||||
|
||||
|
||||
def test_image_tool_stages_expose_only_the_required_tool() -> None:
|
||||
model = ProviderModel("vision", vision=True)
|
||||
assert [tool["function"]["name"] for tool in tools_for_model(model, image_stage="survey")] == ["analyze_image_reference"]
|
||||
assert [tool["function"]["name"] for tool in tools_for_model(model, image_stage="sketch", include_image_analysis=False, include_image_sketches=True)] == ["extract_image_sketch_candidates"]
|
||||
|
||||
|
||||
def test_agent_runs_survey_then_sketch_stage_before_normal_tools() -> None:
|
||||
class TwoStageAgent(AgentService):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.required: list[str | None] = []
|
||||
self.responses = [
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "survey", "type": "function", "function": {"name": "analyze_image_reference", "arguments": json.dumps({
|
||||
"part_type": "bracket", "visible_features": ["plate"], "uncertain_features": [],
|
||||
"views": [{"attachment_id": "upload_a", "view_role": "front"}], "profiles": [],
|
||||
"measurements": [], "uncertainties": [],
|
||||
})},
|
||||
}]}}]},
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
|
||||
"id": "sketch", "type": "function", "function": {"name": "extract_image_sketch_candidates", "arguments": json.dumps({
|
||||
"profiles": [{"id": "opening", "role": "cutout", "closed": True, "segments": [{"type": "line", "start": [0, 0], "end": [2, 0]}]}],
|
||||
"measurements": [], "uncertainties": [],
|
||||
})},
|
||||
}]}}]},
|
||||
{"choices": [{"message": {"role": "assistant", "content": "继续建模。", "tool_calls": []}}]},
|
||||
]
|
||||
|
||||
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
|
||||
self.required.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 TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
backend_root = Path(__file__).resolve().parents[1]
|
||||
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "key", (ProviderModel("vision", vision=True),))
|
||||
settings = Settings(root / "tasks", root / "conversations", backend_root / "cdsl_library", backend_root / "engine" / "cdsl_engine", "", "", "", 5, "test", (provider,))
|
||||
store = WorkspaceStore(settings)
|
||||
conversation_id = "conv_000000000001"
|
||||
store.ensure_conversation(conversation_id)
|
||||
relative, _ = store.write_conversation_upload(conversation_id, "part.png", b"png")
|
||||
store.add_conversation_attachment(conversation_id, attachment_record(conversation_id, "part.png", "image/png", relative, b"png", "image"))
|
||||
agent = TwoStageAgent(settings, store, CdslLibrary(settings))
|
||||
message = ChatMessage(id="user", role="user", parts=[MessagePart(type="text", text="根据图片继续建模")])
|
||||
|
||||
async def collect() -> list[dict[str, object]]:
|
||||
events = []
|
||||
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())
|
||||
assert agent.required[:2] == ["analyze_image_reference", "extract_image_sketch_candidates"]
|
||||
assert any(event.get("observationStage") == "complete" for event in events)
|
||||
@@ -0,0 +1,340 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
import json
|
||||
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 fastapi import HTTPException # noqa: E402
|
||||
from app import main as api # noqa: E402
|
||||
from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, validate_fragment # noqa: E402
|
||||
from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan # noqa: E402
|
||||
from app.services.incremental_generation import IncrementalGenerationRunner # noqa: E402
|
||||
from app.services.review_renderer import CANONICAL_VIEWS, REVIEW_SIZE, RENDER_SIZE, render_checkpoint # noqa: E402
|
||||
from app.services.storage import WorkspaceStore # noqa: E402
|
||||
from app.services.visual_review import _selected_review_views # noqa: E402
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
|
||||
|
||||
|
||||
def plan() -> dict:
|
||||
return {
|
||||
"schema_version": "cad.generation-plan.v2",
|
||||
"plan_id": "incremental_test",
|
||||
"requirements": [
|
||||
{"id": "req_base", "source": "explicit", "priority": "hard", "description": "base solid"},
|
||||
{"id": "req_round", "source": "explicit", "priority": "hard", "description": "edge round"},
|
||||
],
|
||||
"assumptions": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "base", "intent": "base", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"requirement_ids": ["req_base"], "verification_rules": [], "review_targets": [],
|
||||
},
|
||||
{
|
||||
"id": "round", "intent": "round", "atomic_id": "fillet", "depends_on": ["base"],
|
||||
"requires_topology": True,
|
||||
"requirement_ids": ["req_round"], "verification_rules": [], "review_targets": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class GenerationPlanTests(unittest.TestCase):
|
||||
def test_hard_requirements_and_backend_outputs_are_owned(self) -> None:
|
||||
invalid = plan()
|
||||
invalid["nodes"][1]["requirement_ids"] = []
|
||||
with self.assertRaisesRegex(GenerationPlanError, "Hard requirements"):
|
||||
validate_generation_plan(invalid, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
|
||||
|
||||
generated = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
|
||||
self.assertEqual(generated["id_strategy"], "backend-derived-v1")
|
||||
self.assertEqual(len(generated["nodes"][0]["cdsl_feature_ids"]), 1)
|
||||
self.assertEqual(len(generated["nodes"][0]["cdsl_sketch_ids"]), 1)
|
||||
self.assertEqual(generated["nodes"][1]["cdsl_sketch_ids"], [])
|
||||
# Legacy fields from an untrusted model output cannot choose CDSL IDs.
|
||||
supplied = plan()
|
||||
supplied["nodes"][0]["expected_feature_ids"] = ["model_chosen_id"]
|
||||
self.assertEqual(
|
||||
validate_generation_plan(supplied, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")["nodes"][0]["cdsl_feature_ids"],
|
||||
generated["nodes"][0]["cdsl_feature_ids"],
|
||||
)
|
||||
|
||||
def test_upstream_invalidation_marks_all_descendants_stale(self) -> None:
|
||||
spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
|
||||
spec["nodes"][0]["status"] = "completed"
|
||||
spec["nodes"][1]["status"] = "completed"
|
||||
self.assertEqual(descendant_closure(spec, "base"), {"base", "round"})
|
||||
stale = mark_nodes_stale(spec, "base", reason="topology_changed")
|
||||
self.assertTrue(all(node["stale"] for node in stale["nodes"]))
|
||||
self.assertTrue(all(node["status"] == "planned" for node in stale["nodes"]))
|
||||
|
||||
|
||||
class FragmentTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
|
||||
|
||||
def base_fragment(self) -> dict:
|
||||
return {
|
||||
"schema_version": "cad.cdsl-fragment.v1", "node_id": "base", "base_revision_id": "",
|
||||
"base_cdsl_sha256": cdsl_sha256(None), "add_sketches": [{}],
|
||||
"add_features": [{}], "verification_rules": [], "assumptions": [],
|
||||
}
|
||||
|
||||
def test_fragment_assigns_ids_dependencies_and_atomic_from_plan(self) -> None:
|
||||
accepted = validate_fragment(self.base_fragment(), plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
|
||||
base = self.spec["nodes"][0]
|
||||
self.assertEqual(accepted["add_sketches"][0]["id"], base["cdsl_sketch_ids"][0])
|
||||
self.assertEqual(accepted["add_features"][0]["id"], base["cdsl_feature_ids"][0])
|
||||
self.assertEqual(accepted["add_features"][0]["sketch_id"], base["cdsl_sketch_ids"][0])
|
||||
self.assertEqual(accepted["add_features"][0]["atomic_id"], "extrude_add_blind")
|
||||
self.assertEqual(accepted["add_features"][0]["depends_on"], [])
|
||||
model_ids = self.base_fragment()
|
||||
model_ids["add_sketches"][0]["id"] = "model_sketch"
|
||||
model_ids["add_features"][0].update({"id": "model_feature", "atomic_id": "fillet", "sketch_id": "model_sketch"})
|
||||
overwritten = validate_fragment(model_ids, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
|
||||
self.assertEqual(overwritten["add_features"][0]["id"], base["cdsl_feature_ids"][0])
|
||||
self.assertEqual(overwritten["add_features"][0]["atomic_id"], "extrude_add_blind")
|
||||
|
||||
def test_fragment_rejects_wrong_hash_and_output_shape(self) -> None:
|
||||
wrong_hash = self.base_fragment()
|
||||
wrong_hash["base_cdsl_sha256"] = "0" * 64
|
||||
with self.assertRaisesRegex(CdslFragmentError, "sha256"):
|
||||
validate_fragment(wrong_hash, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
|
||||
unexpected_sketch = self.base_fragment()
|
||||
unexpected_sketch["add_sketches"].append({"id": "extra"})
|
||||
with self.assertRaisesRegex(CdslFragmentError, "one new sketch"):
|
||||
validate_fragment(unexpected_sketch, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
|
||||
|
||||
def test_topology_fragment_requires_active_snapshot_owner_and_geometry(self) -> None:
|
||||
base = self.spec["nodes"][0]
|
||||
round_node = self.spec["nodes"][1]
|
||||
base_cdsl = {
|
||||
"geometry": {"sketches": [{"id": base["cdsl_sketch_ids"][0]}]},
|
||||
"features": [{"id": base["cdsl_feature_ids"][0], "atomic_id": "extrude_add_blind"}],
|
||||
}
|
||||
fragment = {
|
||||
"schema_version": "cad.cdsl-fragment.v1", "node_id": "round", "base_revision_id": "rev_001",
|
||||
"base_cdsl_sha256": cdsl_sha256(base_cdsl), "required_snapshot_id": "cad_test/rev_001",
|
||||
"add_sketches": [],
|
||||
"add_features": [{
|
||||
"selectors": [{
|
||||
"kind": "edge", "stable_id": "body:feature_base:edge:0", "owner_node_id": "base",
|
||||
"snapshot_id": "cad_test/rev_001", "geometry": {"curve_type": "line", "length_mm": 10},
|
||||
}],
|
||||
}],
|
||||
"verification_rules": [], "assumptions": [],
|
||||
}
|
||||
accepted = validate_fragment(
|
||||
fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl,
|
||||
required_snapshot_id="cad_test/rev_001",
|
||||
)
|
||||
self.assertEqual(accepted["node_id"], "round")
|
||||
self.assertEqual(accepted["add_features"][0]["id"], round_node["cdsl_feature_ids"][0])
|
||||
self.assertEqual(accepted["add_features"][0]["depends_on"], [base["cdsl_feature_ids"][0]])
|
||||
self.assertEqual(accepted["add_features"][0]["selectors"][0]["owner_feature_id"], base["cdsl_feature_ids"][0])
|
||||
fragment["add_features"][0]["selectors"][0].pop("geometry")
|
||||
with self.assertRaisesRegex(CdslFragmentError, "geometry signature"):
|
||||
validate_fragment(
|
||||
fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl,
|
||||
required_snapshot_id="cad_test/rev_001",
|
||||
)
|
||||
|
||||
|
||||
class StorageAndRunnerTests(unittest.TestCase):
|
||||
def settings(self, root: Path) -> Settings:
|
||||
provider = ProviderConfig("author", "Author", "https://example.invalid/v1", "secret", (ProviderModel("author-model"),))
|
||||
return Settings(
|
||||
task_root=root / "tasks", conversation_root=root / "conversations", library_root=root / "library",
|
||||
engine_root=ROOT / "backend" / "engine" / "cdsl_engine", llm_base_url="", llm_api_key="", llm_model="author-model",
|
||||
llm_timeout_s=1, default_provider_id="author", providers=(provider,), incremental_generation=True,
|
||||
)
|
||||
|
||||
def test_rollback_anchor_rewinds_before_affected_nodes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = WorkspaceStore(self.settings(Path(directory)))
|
||||
task = store.ensure_task(None, "test")
|
||||
task_id = task["task_id"]
|
||||
for revision_id, parent, node_id in (("rev_001", "", "base"), ("rev_002", "rev_001", "middle"), ("rev_003", "rev_002", "tip")):
|
||||
store.update_task(task_id, {"revision_id": revision_id, "status": "success", "parent_revision_id": parent, "node_id": node_id, "visibility": "checkpoint"})
|
||||
self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["middle", "tip"], fallback_revision_id="rev_002"), "rev_001")
|
||||
self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["base"], fallback_revision_id="rev_001"), "")
|
||||
store.rollback_to_revision(task_id, "rev_002", branch_id="branch_repair")
|
||||
revisions = {item["revision_id"]: item for item in (store.read_task(task_id) or {})["revisions"]}
|
||||
self.assertEqual(revisions["rev_001"]["visibility"], "checkpoint")
|
||||
self.assertEqual(revisions["rev_002"]["visibility"], "checkpoint")
|
||||
self.assertEqual(revisions["rev_003"]["visibility"], "superseded")
|
||||
|
||||
def test_run_context_is_persisted_for_worker_recovery(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = WorkspaceStore(self.settings(Path(directory)))
|
||||
task = store.ensure_task(None, "test")
|
||||
store.write_generation_run_context(task["task_id"], {
|
||||
"schema_version": "cad.generation-run-context.v1",
|
||||
"request": "test", "conversation_id": "conv_abcdef123456",
|
||||
"provider_id": "author", "model_id": "author-model", "author_messages": [], "part_skills": {},
|
||||
})
|
||||
recovered = store.read_generation_run_context(task["task_id"])
|
||||
self.assertEqual(recovered and recovered["request"], "test")
|
||||
task = store.start_generation(task["task_id"], request="test")
|
||||
self.assertEqual([item["task_id"] for item in store.running_tasks()], [task["task_id"]])
|
||||
|
||||
def test_missing_visual_review_configuration_fails_the_run(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = self.settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
task = store.ensure_task(None, "test")
|
||||
|
||||
async def complete(*_args: object) -> dict:
|
||||
raise AssertionError("author must not be called before visual configuration validation")
|
||||
|
||||
runner = IncrementalGenerationRunner(settings, store, complete)
|
||||
|
||||
async def collect() -> list[tuple[str, dict]]:
|
||||
return [item async for item in runner.run(
|
||||
task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []},
|
||||
provider=settings.providers[0], model=settings.providers[0].models[0], author_messages=[],
|
||||
)]
|
||||
|
||||
events = asyncio.run(collect())
|
||||
self.assertEqual(events[-1][0], "task_terminal")
|
||||
self.assertEqual(events[-1][1]["lifecycle"], "failed")
|
||||
self.assertEqual((store.read_task(task["task_id"]) or {})["lifecycle"], "failed")
|
||||
|
||||
def test_invalid_plan_is_preserved_in_failure_diagnostics(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
provider = ProviderConfig(
|
||||
"author", "Author", "https://example.invalid/v1", "secret",
|
||||
(ProviderModel("author-model", vision=True),),
|
||||
)
|
||||
settings = replace(
|
||||
self.settings(root), providers=(provider,), review_provider_id="author", review_model_id="author-model",
|
||||
)
|
||||
store = WorkspaceStore(settings)
|
||||
task = store.ensure_task(None, "test")
|
||||
raw_plan = {"schema_version": "cad.generation-plan.v2", "plan_id": "broken", "requirements": []}
|
||||
|
||||
async def complete(*_args: object) -> dict:
|
||||
return {
|
||||
"choices": [{"message": {"tool_calls": [{"function": {
|
||||
"name": "plan_generation_task", "arguments": json.dumps(raw_plan),
|
||||
}}]}}],
|
||||
}
|
||||
|
||||
runner = IncrementalGenerationRunner(settings, store, complete)
|
||||
engine = type("Engine", (), {"SUPPORTED_ATOMIC_IDS": ("extrude_add_blind",)})()
|
||||
|
||||
async def collect() -> list[tuple[str, dict]]:
|
||||
return [item async for item in runner.run(
|
||||
task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []},
|
||||
provider=provider, model=provider.models[0], author_messages=[],
|
||||
)]
|
||||
|
||||
with patch("app.services.incremental_generation.load_engine", return_value=engine), patch(
|
||||
"app.services.incremental_generation.renderer_status", return_value=(True, "")
|
||||
):
|
||||
events = asyncio.run(collect())
|
||||
|
||||
self.assertEqual(events[-1], ("task_terminal", {
|
||||
"taskId": task["task_id"], "lifecycle": "failed",
|
||||
"message": "Generation plan requires a non-empty requirements array",
|
||||
}))
|
||||
failed_task = store.read_task(task["task_id"]) or {}
|
||||
failure_path = root / "tasks" / task["task_id"] / str(failed_task["run_failure_path"])
|
||||
with failure_path.open(encoding="utf-8") as handle:
|
||||
failure = json.load(handle)
|
||||
diagnostic_path = root / "tasks" / task["task_id"] / str(failure["plan_diagnostic_path"])
|
||||
with diagnostic_path.open(encoding="utf-8") as handle:
|
||||
diagnostic = json.load(handle)
|
||||
self.assertEqual(diagnostic["stage"], "generation_plan_validation")
|
||||
self.assertEqual(diagnostic["raw_plan"], raw_plan)
|
||||
|
||||
|
||||
class ArtifactAccessTests(unittest.TestCase):
|
||||
def test_checkpoint_allows_only_the_active_glb_until_publication(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
store = WorkspaceStore(StorageAndRunnerTests().settings(Path(directory)))
|
||||
task = store.ensure_task(None, "test")
|
||||
task_id = task["task_id"]
|
||||
revision_id, revision_dir = store.next_revision(task_id)
|
||||
glb = revision_dir / "model.glb"
|
||||
step = revision_dir / "model.step"
|
||||
glb.write_bytes(b"glb")
|
||||
step.write_bytes(b"step")
|
||||
store.update_task(task_id, {
|
||||
"revision_id": revision_id,
|
||||
"status": "success",
|
||||
"cdsl_path": f"revisions/{revision_id}/model.cdsl.json",
|
||||
"glb_path": f"revisions/{revision_id}/model.glb",
|
||||
"step_path": f"revisions/{revision_id}/model.step",
|
||||
"report_path": f"revisions/{revision_id}/rebuild-report.json",
|
||||
"visibility": "checkpoint",
|
||||
})
|
||||
previous_store = api.store
|
||||
api.store = store
|
||||
try:
|
||||
response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.glb"))
|
||||
self.assertEqual(response.media_type, "model/gltf-binary")
|
||||
with self.assertRaises(HTTPException) as rejected:
|
||||
asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step"))
|
||||
self.assertEqual(rejected.exception.status_code, 403)
|
||||
store.finish_generation(task_id, lifecycle="completed")
|
||||
response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
finally:
|
||||
api.store = previous_store
|
||||
|
||||
|
||||
class TechnicalRenderTests(unittest.TestCase):
|
||||
def test_cpu_renderer_emits_fixed_views_and_keeps_detail_separate(self) -> None:
|
||||
from build123d import Box, export_step
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
step_path = root / "box.step"
|
||||
export_step(Box(30, 20, 10), step_path)
|
||||
manifest = render_checkpoint(
|
||||
StorageAndRunnerTests().settings(root),
|
||||
step_path=step_path,
|
||||
output_dir=root / "review",
|
||||
review_targets=[{"bbox_mm": [-5, -5, -5, 5, 5, 5]}],
|
||||
)
|
||||
self.assertEqual(manifest["renderer"], "python-occ-hlr-pillow")
|
||||
views = {item["id"]: item for item in manifest["views"]}
|
||||
self.assertEqual(set(CANONICAL_VIEWS), set(views) & set(CANONICAL_VIEWS))
|
||||
self.assertIn("detail-1", views)
|
||||
self.assertNotEqual(views["isometric"]["path"], views["detail-1"]["path"])
|
||||
self.assertTrue(all(Path(views[view_id]["path"]).is_file() for view_id in CANONICAL_VIEWS))
|
||||
self.assertTrue(all(views[view_id]["diagnostics"]["valid"] for view_id in CANONICAL_VIEWS))
|
||||
self.assertTrue(views["detail-1"]["diagnostics"]["intentional_crop"])
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(views["isometric"]["path"]) as image:
|
||||
self.assertEqual(image.size, (REVIEW_SIZE, REVIEW_SIZE))
|
||||
with Image.open(views["isometric"]["high_resolution_path"]) as image:
|
||||
self.assertEqual(image.size, (RENDER_SIZE, RENDER_SIZE))
|
||||
|
||||
def test_routine_visual_review_uses_compact_evidence(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
contact = root / "contact-sheet.jpg"
|
||||
contact.write_bytes(b"jpg")
|
||||
manifest = {
|
||||
"contact_sheet_path": str(contact),
|
||||
"views": [{"id": view_id, "path": str(root / f"{view_id}.png")} for view_id in (*CANONICAL_VIEWS, "detail-1", "detail-2")],
|
||||
}
|
||||
routine = _selected_review_views(manifest, final_checkpoint=False)
|
||||
final = _selected_review_views(manifest, final_checkpoint=True)
|
||||
self.assertEqual([item["id"] for item in routine], ["contact-sheet", "detail-1", "detail-2"])
|
||||
self.assertEqual({item["id"] for item in final}, {"contact-sheet", "detail-1", "detail-2", *CANONICAL_VIEWS})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -14,7 +13,6 @@ sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.models.contracts import ChatMessage, MessagePart # noqa: E402
|
||||
from app.services.agent_service import AgentService, TOOL_SCHEMAS, system_prompt # noqa: E402
|
||||
from app.services.engine_service import build_revision, load_engine, validate_cdsl # 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
|
||||
@@ -46,10 +44,49 @@ def document(part_id: str, sketches: list[dict], features: list[dict]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def rectangle(center: list[float], width: float, height: float) -> dict:
|
||||
half_width, half_height = width / 2, height / 2
|
||||
return {"type": "polygon", "vertices": [
|
||||
[center[0] - half_width, center[1] - half_height], [center[0] + half_width, center[1] - half_height],
|
||||
[center[0] + half_width, center[1] + half_height], [center[0] - half_width, center[1] + half_height],
|
||||
]}
|
||||
|
||||
|
||||
def circles(items: list[dict]) -> dict:
|
||||
return {"type": "analytic_contours", "contours": [
|
||||
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]}
|
||||
for item in items
|
||||
]}
|
||||
|
||||
|
||||
def circle_grid(radius: float, count_x: int, count_y: int, spacing_x: float, spacing_y: float) -> dict:
|
||||
return circles([
|
||||
{"center": [(column - (count_x - 1) / 2) * spacing_x, (row - (count_y - 1) / 2) * spacing_y], "radius_mm": radius}
|
||||
for row in range(count_y) for column in range(count_x)
|
||||
])
|
||||
|
||||
|
||||
def obround(length: float, width: float) -> dict:
|
||||
radius, left, right = width / 2, -length / 2 + width / 2, length / 2 - width / 2
|
||||
return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [
|
||||
{"type": "line", "start": [right, radius], "end": [left, radius]},
|
||||
{"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0], "radius_mm": radius},
|
||||
{"type": "line", "start": [left, -radius], "end": [right, -radius]},
|
||||
{"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0], "radius_mm": radius},
|
||||
]}]}
|
||||
|
||||
|
||||
def annulus(inner_radius: float, outer_radius: float) -> dict:
|
||||
return circles([
|
||||
{"center": [0, 0], "radius_mm": outer_radius},
|
||||
{"center": [0, 0], "radius_mm": inner_radius},
|
||||
])
|
||||
|
||||
|
||||
def mounting_plate_fixture() -> dict:
|
||||
return document("golden-mounting-plate", [
|
||||
{"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 60}},
|
||||
{"id": "grid", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 50, "spacing_y_mm": 30, "center_mm": [0, 0]}},
|
||||
{"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 80, 60)},
|
||||
{"id": "grid", "workplane": workplane(10), "profile": circle_grid(3, 2, 2, 50, 30)},
|
||||
], [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 10}, "sketch_id": "base"},
|
||||
{"id": "mount_pattern", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 10, "reverse": True}, "sketch_id": "grid"},
|
||||
@@ -60,10 +97,10 @@ def mounting_plate_fixture() -> dict:
|
||||
def mounting_bracket_fixture() -> dict:
|
||||
web_plane = {"origin_mm": [0, -20, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1], "normal": [0, 1, 0]}
|
||||
return document("golden-mounting-bracket", [
|
||||
{"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 40}},
|
||||
{"id": "web", "workplane": web_plane, "profile": {"type": "rectangle", "center": [0, -15], "width_mm": 50, "height_mm": 30}},
|
||||
{"id": "slot", "workplane": workplane(6), "profile": {"type": "obround", "center": [0, 0], "length_mm": 24, "width_mm": 8}},
|
||||
{"id": "symmetric_holes", "workplane": workplane(6), "profile": {"type": "circles", "items": [{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}]}},
|
||||
{"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 80, 40)},
|
||||
{"id": "web", "workplane": web_plane, "profile": rectangle([0, -15], 50, 30)},
|
||||
{"id": "slot", "workplane": workplane(6), "profile": obround(24, 8)},
|
||||
{"id": "symmetric_holes", "workplane": workplane(6), "profile": circles([{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}])},
|
||||
], [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 6}, "sketch_id": "base"},
|
||||
{"id": "web_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 6}, "sketch_id": "web"},
|
||||
@@ -74,8 +111,8 @@ def mounting_bracket_fixture() -> dict:
|
||||
|
||||
def flange_fixture() -> dict:
|
||||
return document("golden-flange", [
|
||||
{"id": "base", "workplane": workplane(), "profile": {"type": "annulus", "inner_radius_mm": 10, "outer_radius_mm": 40}},
|
||||
{"id": "bolt_circle", "workplane": workplane(12), "profile": {"type": "circles", "items": [{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}]}},
|
||||
{"id": "base", "workplane": workplane(), "profile": annulus(10, 40)},
|
||||
{"id": "bolt_circle", "workplane": workplane(12), "profile": circles([{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}])},
|
||||
], [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 12}, "sketch_id": "base"},
|
||||
{"id": "bolt_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 12, "reverse": True}, "sketch_id": "bolt_circle"},
|
||||
@@ -94,9 +131,9 @@ def shaft_fixture() -> dict:
|
||||
|
||||
def bearing_housing_fixture() -> dict:
|
||||
return document("golden-bearing-housing", [
|
||||
{"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}},
|
||||
{"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 100, 60)},
|
||||
{"id": "housing", "workplane": workplane(8), "profile": {"type": "circle", "radius_mm": 28}},
|
||||
{"id": "base_holes", "workplane": workplane(8), "profile": {"type": "circle_grid", "radius_mm": 4, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}},
|
||||
{"id": "base_holes", "workplane": workplane(8), "profile": circle_grid(4, 2, 2, 80, 40)},
|
||||
], [
|
||||
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 8}, "sketch_id": "base"},
|
||||
{"id": "housing_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 25}, "sketch_id": "housing"},
|
||||
@@ -115,16 +152,6 @@ def hex_nut_fixture() -> dict:
|
||||
])
|
||||
|
||||
|
||||
GOLDEN_CASES = (
|
||||
("带沉孔和孔阵列的安装底板", mounting_plate_fixture, {"planning/mounting-plate", "atomic/counterbored-hole-creation", "atomic/pattern-holes-from-datum"}, {"atomic/pattern-holes-from-datum": "expanded"}),
|
||||
("带槽和对称孔的安装支架", mounting_bracket_fixture, {"planning/mounting-bracket", "functional/slotted-adjustment-feature", "functional/symmetric-feature-layout"}, {"atomic/pattern-holes-from-datum": "expanded"}),
|
||||
("带螺栓圆的法兰", flange_fixture, {"planning/flange", "functional/flange-bolt-circle"}, {"functional/flange-bolt-circle": "expanded"}),
|
||||
("带回转体同轴孔的阶梯轴", shaft_fixture, {"planning/simple-shaft", "functional/axisymmetric-revolve-strategy", "atomic/coaxial-bore-rule"}, {}),
|
||||
("带轴承座孔和底座孔的轴承座", bearing_housing_fixture, {"planning/bearing-housing", "functional/bearing-bore-seat"}, {}),
|
||||
("M6 六角螺母", hex_nut_fixture, {"planning/hexagonal-nut", "atomic/threaded-hole-creation"}, {"atomic/threaded-hole-creation": "approximated"}),
|
||||
)
|
||||
|
||||
|
||||
class PartSkillLibraryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.library = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
@@ -166,24 +193,19 @@ class PartSkillLibraryTests(unittest.TestCase):
|
||||
self.assertTrue((PART_SKILL_ROOT / skill["bridge"]).is_file())
|
||||
self.assertTrue((PART_SKILL_ROOT / skill["source"]).is_file())
|
||||
self.assertTrue(skill["triggers"])
|
||||
self.assertTrue(skill["capability_translation_rules"])
|
||||
self.assertNotIn("capability_translation_rules", skill)
|
||||
self.assertEqual(categories, {"planning": 6, "functional": 7, "atomic": 8})
|
||||
self.assertTrue((PART_SKILL_ROOT / "LICENSE").is_file())
|
||||
self.assertTrue((PART_SKILL_ROOT / "PROVENANCE.md").is_file())
|
||||
|
||||
def test_audit_records_exact_omitted_and_blocked_capability_states(self) -> None:
|
||||
def test_audit_records_selection_and_assumptions_without_interpreting_geometry(self) -> None:
|
||||
fixture = mounting_plate_fixture()
|
||||
exact = self.library.audit(self.library.select("安装底板"), fixture)
|
||||
finishing = self.library.audit(self.library.select("edge treatment"), fixture)
|
||||
blocked = self.library.audit(self.library.select("flange bolt circle"), mounting_plate_fixture())
|
||||
|
||||
exact_statuses = {item["skill_id"]: item["status"] for item in exact["capability_translations"]}
|
||||
finishing_statuses = {item["skill_id"]: item for item in finishing["capability_translations"]}
|
||||
blocked_statuses = {item["skill_id"]: item["status"] for item in blocked["capability_translations"]}
|
||||
self.assertEqual(exact_statuses["planning/mounting-plate"], "exact")
|
||||
self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["status"], "omitted")
|
||||
self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["translation"], "selector_unavailable")
|
||||
self.assertEqual(blocked_statuses["functional/flange-bolt-circle"], "blocked")
|
||||
audit = self.library.audit(self.library.select("安装底板"), fixture, ["孔位置由用户确认"])
|
||||
self.assertIn("planning/mounting-plate", audit["skill_ids"])
|
||||
self.assertEqual(audit["assumptions"], ["孔位置由用户确认"])
|
||||
self.assertTrue(all(item.get("version") and item.get("summary") for item in audit["skills"]))
|
||||
self.assertNotIn("capability_translations", audit)
|
||||
self.assertNotIn("evidence", audit)
|
||||
|
||||
|
||||
class AgentPartSkillTests(unittest.TestCase):
|
||||
@@ -211,7 +233,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_direct_cdsl_tools(self) -> None:
|
||||
library = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
selection = library.select("Create a flange with a bolt circle")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -220,7 +242,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", "extract_image_sketch_candidates", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"])
|
||||
|
||||
def test_generation_persists_assumptions_and_selected_skills(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -230,27 +252,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": "INTAKE", "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"])
|
||||
@@ -280,66 +300,5 @@ class AgentPartSkillTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class PartSkillBuildAndGoldenTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.settings = AgentPartSkillTests._settings(Path(tempfile.mkdtemp()))
|
||||
cls.engine = load_engine(cls.settings)
|
||||
cls.library = PartSkillLibrary(PART_SKILL_ROOT)
|
||||
|
||||
def test_success_and_failure_revisions_always_write_part_skill_audit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = AgentPartSkillTests._settings(Path(directory))
|
||||
store = WorkspaceStore(settings)
|
||||
selection = self.library.select("带螺栓圆的法兰")
|
||||
audit = self.library.audit(selection, flange_fixture(), ["Explicit circle expansion"])
|
||||
success = build_revision(
|
||||
settings=settings, store=store, task_id=None, request="带螺栓圆的法兰", cdsl=flange_fixture(),
|
||||
reference_ids=["flange-reference"], summary="flange", part_skills=audit,
|
||||
generation_assumptions=["Explicit circle expansion"],
|
||||
)
|
||||
success_audit = json.loads(store.artifact_path(success["task_id"], success["part_skills_path"]).read_text(encoding="utf-8"))
|
||||
success_report = json.loads(store.artifact_path(success["task_id"], success["report_path"]).read_text(encoding="utf-8"))
|
||||
self.assertEqual(success_audit["skill_ids"], audit["skill_ids"])
|
||||
self.assertIn("generation_context", success_report)
|
||||
|
||||
invalid = copy.deepcopy(flange_fixture())
|
||||
invalid["features"][0]["atomic_id"] = "not_supported"
|
||||
failed_audit = self.library.audit(selection, invalid, [])
|
||||
with self.assertRaisesRegex(ValueError, "CDSL schema violation"):
|
||||
build_revision(
|
||||
settings=settings, store=store, task_id=success["task_id"], request="bad flange", cdsl=invalid,
|
||||
reference_ids=[], summary="bad", part_skills=failed_audit, generation_assumptions=[],
|
||||
)
|
||||
task = store.read_task(success["task_id"])
|
||||
failed = task["revisions"][-1]
|
||||
self.assertEqual(failed["status"], "failed")
|
||||
self.assertTrue(store.artifact_path(task["task_id"], failed["part_skills_path"]).is_file())
|
||||
report = json.loads(store.artifact_path(task["task_id"], failed["report_path"]).read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["generation_context"]["part_skill_ids"], audit["skill_ids"])
|
||||
legacy_report = {"engine_result": {"engine": "cdsl_only"}}
|
||||
self.assertEqual(legacy_report.get("generation_context", {}), {})
|
||||
|
||||
def test_golden_part_families_validate_preflight_rebuild_and_audit(self) -> None:
|
||||
for request, factory, expected_skills, expected_statuses in GOLDEN_CASES:
|
||||
with self.subTest(request=request):
|
||||
cdsl = factory()
|
||||
selection = self.library.select(request)
|
||||
self.assertTrue(expected_skills.issubset(selection["skill_ids"]))
|
||||
validate_cdsl(cdsl, self.engine)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
step_path = Path(directory) / "model.step"
|
||||
result = self.engine.run_cdsl_only(copy.deepcopy(cdsl), step_path)
|
||||
self.assertEqual(result["engine"], "cdsl_only")
|
||||
self.assertTrue(step_path.is_file())
|
||||
self.assertGreater(step_path.stat().st_size, 0)
|
||||
audit = self.library.audit(selection, cdsl, ["golden fixture"])
|
||||
statuses = {item["skill_id"]: item["status"] for item in audit["capability_translations"]}
|
||||
for skill_id, status in expected_statuses.items():
|
||||
self.assertEqual(statuses[skill_id], status)
|
||||
self.assertTrue(audit["evidence"]["features"])
|
||||
self.assertTrue(audit["evidence"]["profiles"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -5,7 +5,7 @@ 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.settings import get_settings
|
||||
|
||||
|
||||
@@ -22,21 +22,38 @@ class ProfileSchemaTests(unittest.TestCase):
|
||||
def test_schema_and_registered_profiles_stay_in_sync(self) -> None:
|
||||
self.assertEqual(set(self.schema["runtime_supported_profiles"]), set(self.engine.SHAPE_GENERATORS))
|
||||
|
||||
def test_legacy_profile_macros_require_explicit_adapter_lowering(self) -> None:
|
||||
legacy = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "legacy-rectangle",
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "base", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
|
||||
"profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10},
|
||||
}]},
|
||||
"features": [{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base", "params": {"distance_mm": 2}}],
|
||||
}
|
||||
with self.assertRaisesRegex(ValueError, "CDSL schema violation"):
|
||||
validate_cdsl(legacy, self.engine)
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
|
||||
lowered = lower_legacy_profiles(legacy)
|
||||
self.assertEqual(lowered["geometry"]["sketches"][0]["profile"]["type"], "analytic_contours")
|
||||
validate_cdsl(lowered, self.engine)
|
||||
|
||||
def test_schema_and_executable_atomic_operations_stay_in_sync(self) -> None:
|
||||
self.assertEqual(set(self.schema["runtime_supported_atomic_ids"]), set(self.engine.SUPPORTED_ATOMIC_IDS))
|
||||
self.assertTrue(set(self.engine.SUPPORTED_ATOMIC_IDS).issubset(self.schema["feature_atomic_ids"]))
|
||||
self.assertEqual(set(self.engine.SUPPORTED_ATOMIC_IDS), set(self.schema["feature_atomic_ids"]))
|
||||
self.assertEqual(
|
||||
set(self.cdsl_schema["$defs"]["feature_atomic_ids"]["enum"]),
|
||||
set(self.schema["feature_atomic_ids"]),
|
||||
set(self.engine.SUPPORTED_ATOMIC_IDS),
|
||||
)
|
||||
|
||||
def test_machine_schema_and_human_contract_stay_in_sync(self) -> None:
|
||||
self.assertEqual(
|
||||
set(self.cdsl_schema["$defs"]["profile_type"]["enum"]),
|
||||
set(self.schema["profiles"]) - {"complex_arc_shape", "unknown_shape"},
|
||||
set(self.schema["runtime_supported_profiles"]),
|
||||
)
|
||||
self.assertEqual(set(self.cdsl_schema["$defs"]["motif_type"]["enum"]), set(self.schema["motif_types"]))
|
||||
self.assertEqual(set(self.cdsl_schema["$defs"]["layout_type"]["enum"]), set(self.schema["layout_types"]))
|
||||
self.assertEqual(set(self.schema["profiles"]), set(self.schema["runtime_supported_profiles"]))
|
||||
|
||||
def test_rejects_unsupported_atomic_operation_before_rebuild(self) -> None:
|
||||
cdsl = {
|
||||
@@ -88,6 +105,52 @@ 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_cdsl_only_rebuild_preserves_its_actual_failure(self) -> None:
|
||||
cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
@@ -112,11 +175,16 @@ class ProfileSchemaTests(unittest.TestCase):
|
||||
self.assertNotIn("engine.run_rebuild(cdsl_copy, step_path)", build_revision_source)
|
||||
|
||||
def test_all_official_samples_match_the_engine_schema(self) -> None:
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
|
||||
samples = sorted(self.settings.library_root.glob("samples/**/model.cdsl.json"))
|
||||
self.assertGreater(len(samples), 0)
|
||||
for sample_path in samples:
|
||||
with self.subTest(sample=sample_path.parent.name):
|
||||
validate_cdsl(json.loads(sample_path.read_text(encoding="utf-8")), self.engine)
|
||||
legacy_sample = json.loads(sample_path.read_text(encoding="utf-8"))
|
||||
lowered = lower_legacy_profiles(legacy_sample)
|
||||
validate_cdsl(lowered, self.engine)
|
||||
self.assertTrue(self.engine.analyze_cdsl(lowered).runtime_eligible)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.services.engine_service import topology_snapshot, topology_sidecars # noqa: E402
|
||||
|
||||
|
||||
class TopologySnapshotTests(unittest.TestCase):
|
||||
def result(self) -> dict:
|
||||
return {
|
||||
"topology_records": [
|
||||
{
|
||||
"record_id": "body:base:edge:0",
|
||||
"kind": "edge",
|
||||
"feature_id": "base",
|
||||
"body_id": "body:base",
|
||||
"owner_feature_ids": ["base"],
|
||||
"geometry": {"curve_type": "line", "length_mm": 10},
|
||||
},
|
||||
{
|
||||
"record_id": "body:base",
|
||||
"kind": "body",
|
||||
"feature_id": "base",
|
||||
"body_id": "body:base",
|
||||
"geometry": {},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
def test_snapshot_preserves_runtime_edges(self) -> None:
|
||||
snapshot = topology_snapshot(self.result(), task_id="cad_test", revision_id="rev_001")
|
||||
self.assertEqual(snapshot["snapshot_id"], "cad_test/rev_001")
|
||||
self.assertEqual(snapshot["records"][0]["record_id"], "body:base:edge:0")
|
||||
self.assertTrue(snapshot["records"][0]["executable"])
|
||||
|
||||
def test_sidecars_are_derived_from_runtime_records(self) -> None:
|
||||
snapshot = topology_snapshot(self.result())
|
||||
selector, edges = topology_sidecars(self.result(), snapshot=snapshot)
|
||||
self.assertEqual(edges["edges"][0]["record_id"], "body:base:edge:0")
|
||||
self.assertEqual(selector["edges"][0]["source"], "runtime_snapshot")
|
||||
|
||||
def test_snapshot_excludes_superseded_body_records(self) -> None:
|
||||
result = {
|
||||
"feature_results": [
|
||||
{"feature_id": "base", "body_id": "body:base"},
|
||||
{"feature_id": "cut", "body_id": "body:cut"},
|
||||
],
|
||||
"topology_records": [
|
||||
{"record_id": "body:base", "kind": "body", "body_id": "body:base", "feature_id": "base"},
|
||||
{"record_id": "body:base:edge:0", "kind": "edge", "body_id": "body:base", "feature_id": "base"},
|
||||
{"record_id": "body:cut", "kind": "body", "body_id": "body:cut", "feature_id": "cut"},
|
||||
{"record_id": "body:cut:edge:0", "kind": "edge", "body_id": "body:cut", "feature_id": "cut"},
|
||||
],
|
||||
}
|
||||
|
||||
snapshot = topology_snapshot(result)
|
||||
|
||||
self.assertEqual(snapshot["body_id"], "body:cut")
|
||||
self.assertEqual([record["record_id"] for record in snapshot["records"]], ["body:cut", "body:cut:edge:0"])
|
||||
|
||||
def test_preview_faces_are_audited_but_not_executable(self) -> None:
|
||||
snapshot = topology_snapshot(
|
||||
{"topology_records": []},
|
||||
task_id="cad_test",
|
||||
revision_id="rev_001",
|
||||
preview={"topology_faces": [{"id": "preview_face", "surface_type": "plane", "center": [0, 0, 1], "normal": [0, 0, 1]}]},
|
||||
)
|
||||
self.assertEqual(snapshot["records"][0]["record_id"], "preview_face")
|
||||
self.assertTrue(snapshot["records"][0]["synthetic"])
|
||||
self.assertFalse(snapshot["records"][0]["executable"])
|
||||
Generated
-342
@@ -31,7 +31,6 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/three": "^0.185.4",
|
||||
"puppeteer-core": "^25.8.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5"
|
||||
@@ -1491,35 +1490,6 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.2.1.tgz",
|
||||
"integrity": "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"modern-tar": "^0.8.0",
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"browsers": "lib/main-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"proxy-agent": ">=8.0.1",
|
||||
"yauzl": "^2.10.0 || ^3.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"proxy-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"yauzl": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.3.tgz",
|
||||
@@ -3455,32 +3425,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
@@ -3557,72 +3501,12 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
|
||||
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"devtools-protocol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz",
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -3664,21 +3548,6 @@
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1666840",
|
||||
"resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz",
|
||||
"integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.24.5",
|
||||
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
|
||||
@@ -3735,16 +3604,6 @@
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
|
||||
@@ -3776,29 +3635,6 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
@@ -4118,23 +3954,6 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/modern-tar": {
|
||||
"version": "0.8.4",
|
||||
"resolved": "https://registry.npmmirror.com/modern-tar/-/modern-tar-0.8.4.tgz",
|
||||
"integrity": "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-6.0.1.tgz",
|
||||
@@ -4315,24 +4134,6 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.8.0.tgz",
|
||||
"integrity": "sha512-LDOrawV8vfCVk+yLj2ozvajNP4Sv3OV9y3Tpiyy2g2Z+aQlbcozP6KJfI4iSBq7YQER+86ihEtPa5ioiZyWxMQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.2.1",
|
||||
"chromium-bidi": "17.0.2",
|
||||
"devtools-protocol": "0.0.1666840",
|
||||
"typed-query-selector": "^2.12.2",
|
||||
"webdriver-bidi-protocol": "0.4.2",
|
||||
"ws": "^8.21.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/radix-ui": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.6.7.tgz",
|
||||
@@ -4614,39 +4415,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-8.2.2.tgz",
|
||||
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
@@ -4767,13 +4535,6 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-query-selector": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
|
||||
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -4910,109 +4671,6 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
|
||||
"integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-18.1.0.tgz",
|
||||
"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^8.2.1",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/three": "^0.185.4",
|
||||
"puppeteer-core": "^25.8.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { backendFetch, readBackendError } from "@/lib/backend";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
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/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 });
|
||||
|
||||
+3
-3
@@ -3,9 +3,9 @@ import { backendFetch, readBackendError } from "@/lib/backend";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.formData();
|
||||
const response = await backendFetch("/v1/uploads", { method: "POST", body });
|
||||
export async function GET(_: NextRequest, { params }: { params: Promise<{ taskId: string }> }) {
|
||||
const { taskId } = await params;
|
||||
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/quality`);
|
||||
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
|
||||
return NextResponse.json(await response.json());
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -395,30 +394,86 @@ button:disabled {
|
||||
.config-warning { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-error-border); background: var(--ui-error-bg); color: var(--ui-error-text); font-size: 12px; padding: 8px 12px; }
|
||||
.studio-main { display: flex; min-height: 0; flex: 1; }
|
||||
.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); }
|
||||
.preview-pane { position: relative; min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
|
||||
.generation-status { position: absolute; z-index: 30; top: 12px; right: 12px; width: min(260px, calc(100% - 24px)); max-height: min(42vh, 360px); overflow: auto; border: 1px solid var(--ui-border); border-radius: 6px; background: var(--ui-glass-popover); box-shadow: var(--ui-shadow-soft); backdrop-filter: blur(12px); color: var(--ui-text); padding: 10px; font-size: 12px; }
|
||||
.generation-status-heading { display: flex; align-items: center; gap: 6px; color: var(--ui-text-strong); font-weight: 650; }
|
||||
.generation-status-active { margin: 6px 0 8px; color: var(--ui-accent-text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
|
||||
.generation-status ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
||||
.generation-status li { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--ui-border-muted); padding-top: 4px; color: var(--ui-text-muted); }
|
||||
.generation-status li[data-status="completed"] small { color: var(--ui-success); }
|
||||
.generation-status li[data-status="planned"] small { color: var(--ui-text-subtle); }
|
||||
.generation-status li[data-status="failed"] small { color: var(--ui-error); }
|
||||
.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; }
|
||||
.cad-quality-status { font-weight: 600; }
|
||||
.quality-accepted { color: #2f8f5b; }
|
||||
.quality-built_with_warnings, .quality-historical { color: #a66a1f; }
|
||||
.quality-needs_repair, .quality-blocked, .quality-failed { color: #b44545; }
|
||||
.cad-result-notes { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 6px; margin: 8px 0 0 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; }
|
||||
.cad-result-notes strong { color: var(--ui-text); }
|
||||
.cad-quality-results { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 6px; margin: 8px 0 0 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; }
|
||||
.cad-quality-results > strong { color: var(--ui-text); }
|
||||
.cad-quality-results ul { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
|
||||
.cad-quality-results li { display: flex; flex-wrap: wrap; gap: 6px; overflow-wrap: anywhere; }
|
||||
.quality-rule-passed { color: #2f8f5b; }
|
||||
.quality-rule-failed, .quality-rule-unavailable { color: #b44545; }
|
||||
.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; } }
|
||||
|
||||
@@ -6,13 +6,14 @@ import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { AlertCircle, Box, Loader2, Moon, Sun } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { latestSuccessfulResult } from "@/lib/cad-artifacts";
|
||||
import { activeCheckpointPreview, latestSuccessfulResult } from "@/lib/cad-artifacts";
|
||||
import { normalizeCadMessages } from "@/lib/cad-messages";
|
||||
import type { ViewerSelectionContext } from "@/lib/viewer-selection";
|
||||
import type {
|
||||
BackendConfig,
|
||||
CadError,
|
||||
CadAttachment,
|
||||
CadProgress,
|
||||
CadResult,
|
||||
CadUIMessage,
|
||||
ConversationRecord,
|
||||
@@ -34,10 +35,13 @@ 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");
|
||||
const [viewerSelection, setViewerSelection] = useState<ViewerSelectionContext | null>(null);
|
||||
const [taskRunning, setTaskRunning] = useState(false);
|
||||
const [taskRecord, setTaskRecord] = useState<TaskRecord | null>(null);
|
||||
|
||||
const syncUrl = useCallback((conversation: string, task: string) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -72,10 +76,12 @@ export function AgentStudio() {
|
||||
|
||||
const taskId = urlTaskId || conversation.current_task_id || "";
|
||||
let restored: CadResult | null = null;
|
||||
let restoredTask: TaskRecord | null = null;
|
||||
if (taskId) {
|
||||
const taskResponse = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { cache: "no-store" });
|
||||
if (taskResponse.ok) {
|
||||
restored = latestSuccessfulResult((await taskResponse.json()) as TaskRecord);
|
||||
restoredTask = (await taskResponse.json()) as TaskRecord;
|
||||
restored = activeCheckpointPreview(restoredTask) ?? latestSuccessfulResult(restoredTask);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +95,8 @@ export function AgentStudio() {
|
||||
setProviderId(defaultProvider?.id || "");
|
||||
setModelId(defaultProvider?.models.find((model) => model.id === nextConfig.default_model)?.id || defaultProvider?.models[0]?.id || "");
|
||||
setCadResult(restored);
|
||||
setTaskRunning(restoredTask?.lifecycle === "running");
|
||||
setTaskRecord(restoredTask);
|
||||
setLoadState("ready");
|
||||
syncUrl(nextConversationId, taskId);
|
||||
} catch (error) {
|
||||
@@ -126,6 +134,7 @@ export function AgentStudio() {
|
||||
setViewerSelection(null);
|
||||
setSelectedTaskId(result.taskId);
|
||||
setLastError("");
|
||||
if (result.lifecycle) setTaskRunning(result.lifecycle === "running");
|
||||
if (conversationId) {
|
||||
syncUrl(conversationId, result.taskId);
|
||||
void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, {
|
||||
@@ -136,48 +145,79 @@ export function AgentStudio() {
|
||||
}
|
||||
}, [conversationId, syncUrl]);
|
||||
|
||||
const handleTaskState = useCallback((progress: CadProgress) => {
|
||||
const taskId = String(progress.taskId || "");
|
||||
if (taskId) {
|
||||
setSelectedTaskId(taskId);
|
||||
if (conversationId) {
|
||||
syncUrl(conversationId, taskId);
|
||||
void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentTaskId: taskId }),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (progress.step === "generation_plan" && progress.status === "running") setTaskRunning(true);
|
||||
if (progress.step === "task_terminal") setTaskRunning(progress.lifecycle === "running");
|
||||
}, [conversationId, syncUrl]);
|
||||
|
||||
const handleError = useCallback((error: CadError) => {
|
||||
setLastError(error.message);
|
||||
if (error.stage === "generation") setTaskRunning(false);
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskRunning || !selectedTaskId) return;
|
||||
let cancelled = false;
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${encodeURIComponent(selectedTaskId)}`, { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
const next = await response.json() as TaskRecord;
|
||||
if (cancelled) return;
|
||||
setTaskRecord(next);
|
||||
const running = next.lifecycle === "running";
|
||||
setTaskRunning(running);
|
||||
if (running) {
|
||||
const preview = activeCheckpointPreview(next);
|
||||
if (preview) setCadResult(preview);
|
||||
} else {
|
||||
const restored = latestSuccessfulResult(next);
|
||||
if (restored) setCadResult(restored);
|
||||
}
|
||||
} catch {
|
||||
// Keep the persisted lock until a later poll can prove a terminal state.
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const timer = window.setInterval(() => void refresh(), 2000);
|
||||
return () => { cancelled = true; window.clearInterval(timer); };
|
||||
}, [selectedTaskId, taskRunning]);
|
||||
|
||||
if (loadState === "loading") {
|
||||
return <StudioLoading />;
|
||||
@@ -197,6 +237,7 @@ export function AgentStudio() {
|
||||
initialMessages={initialMessages}
|
||||
onCadResult={handleResult}
|
||||
onCadError={handleError}
|
||||
onCadProgress={handleTaskState}
|
||||
>
|
||||
<StudioShell
|
||||
config={config}
|
||||
@@ -204,6 +245,7 @@ export function AgentStudio() {
|
||||
lastError={lastError}
|
||||
attachments={attachments}
|
||||
uploading={uploading}
|
||||
uploadError={uploadError}
|
||||
onUpload={handleUpload}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
@@ -214,6 +256,8 @@ export function AgentStudio() {
|
||||
onCadResult={handleResult}
|
||||
onCadError={handleError}
|
||||
onSelectionChange={setViewerSelection}
|
||||
taskRunning={taskRunning}
|
||||
taskRecord={taskRecord}
|
||||
/>
|
||||
</AgentRuntime>
|
||||
);
|
||||
@@ -228,6 +272,7 @@ function AgentRuntime({
|
||||
initialMessages,
|
||||
onCadResult,
|
||||
onCadError,
|
||||
onCadProgress,
|
||||
children,
|
||||
}: {
|
||||
conversationId: string;
|
||||
@@ -238,6 +283,7 @@ function AgentRuntime({
|
||||
initialMessages: CadUIMessage[];
|
||||
onCadResult: (result: CadResult) => void;
|
||||
onCadError: (error: CadError) => void;
|
||||
onCadProgress: (progress: CadProgress) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const conversationRef = useRef(conversationId);
|
||||
@@ -247,6 +293,7 @@ function AgentRuntime({
|
||||
const viewerSelectionRef = useRef(viewerSelection);
|
||||
const onCadResultRef = useRef(onCadResult);
|
||||
const onCadErrorRef = useRef(onCadError);
|
||||
const onCadProgressRef = useRef(onCadProgress);
|
||||
conversationRef.current = conversationId;
|
||||
taskRef.current = selectedTaskId;
|
||||
providerRef.current = providerId;
|
||||
@@ -254,6 +301,7 @@ function AgentRuntime({
|
||||
viewerSelectionRef.current = viewerSelection;
|
||||
onCadResultRef.current = onCadResult;
|
||||
onCadErrorRef.current = onCadError;
|
||||
onCadProgressRef.current = onCadProgress;
|
||||
|
||||
const transport = useMemo(() => new DefaultChatTransport<CadUIMessage>({
|
||||
api: "/api/chat",
|
||||
@@ -278,6 +326,9 @@ function AgentRuntime({
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
onData: (part) => {
|
||||
if (part.type === "data-cad-progress") {
|
||||
onCadProgressRef.current(part.data as CadProgress);
|
||||
}
|
||||
if (part.type === "data-cad-result") {
|
||||
onCadResultRef.current(part.data as CadResult);
|
||||
taskRef.current = (part.data as CadResult).taskId;
|
||||
@@ -425,6 +476,7 @@ function StudioShell({
|
||||
lastError,
|
||||
attachments,
|
||||
uploading,
|
||||
uploadError,
|
||||
onUpload,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
@@ -435,12 +487,15 @@ function StudioShell({
|
||||
onCadResult,
|
||||
onCadError,
|
||||
onSelectionChange,
|
||||
taskRunning,
|
||||
taskRecord,
|
||||
}: {
|
||||
config: BackendConfig | null;
|
||||
cadResult: CadResult | null;
|
||||
lastError: string;
|
||||
attachments: CadAttachment[];
|
||||
uploading: boolean;
|
||||
uploadError: string;
|
||||
onUpload: (files: FileList | null) => void;
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
@@ -451,8 +506,10 @@ function StudioShell({
|
||||
onCadResult: (result: CadResult) => void;
|
||||
onCadError: (error: CadError) => void;
|
||||
onSelectionChange: (selection: ViewerSelectionContext | null) => void;
|
||||
taskRunning: boolean;
|
||||
taskRecord: TaskRecord | null;
|
||||
}) {
|
||||
const running = useAuiState((state) => state.thread.isRunning);
|
||||
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
|
||||
const provider = config?.providers.find((item) => item.id === providerId);
|
||||
const handleViewerError = useCallback((message: string) => {
|
||||
onCadError({ stage: "viewer", message });
|
||||
@@ -462,24 +519,43 @@ function StudioShell({
|
||||
<header className="app-header">
|
||||
<div className="app-brand"><Box size={16} /><strong>CDSL CAD Studio</strong>{cadResult ? <span className="task-badge">{cadResult.taskId}</span> : null}</div>
|
||||
<div className="app-controls">
|
||||
<select aria-label="模型提供商" value={providerId} onChange={(event) => { const id = event.target.value; onProviderChange(id); onModelChange(config?.providers.find((item) => item.id === id)?.models[0]?.id || ""); }}>
|
||||
<select aria-label="模型提供商" value={providerId} disabled={running} onChange={(event) => { const id = event.target.value; onProviderChange(id); onModelChange(config?.providers.find((item) => item.id === id)?.models[0]?.id || ""); }}>
|
||||
{config?.providers.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
|
||||
</select>
|
||||
<select aria-label="模型" value={modelId} onChange={(event) => onModelChange(event.target.value)}>
|
||||
<select aria-label="模型" value={modelId} disabled={running} onChange={(event) => onModelChange(event.target.value)}>
|
||||
{provider?.models.map((model) => <option key={model.id} value={model.id}>{model.id}{model.vision ? " · Vision" : ""}</option>)}
|
||||
</select>
|
||||
<button className="theme-button" type="button" title="切换亮暗主题" onClick={onToggleTheme}>{theme === "light" ? <Moon size={16} /> : <Sun size={16} />}</button>
|
||||
<button className="theme-button" type="button" title="切换亮暗主题" disabled={running} onClick={onToggleTheme}>{theme === "light" ? <Moon size={16} /> : <Sun size={16} />}</button>
|
||||
</div>
|
||||
</header>
|
||||
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span>未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。</span></div> : null}
|
||||
{config?.incremental_generation && !config.review_configured ? <div className="config-warning"><AlertCircle size={16} /><span>视觉复核未配置,增量任务会拒绝启动:{config.review_error || "请配置独立视觉模型与 Chromium。"}</span></div> : null}
|
||||
<div className="studio-main">
|
||||
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} onUpload={onUpload} /></aside>
|
||||
<section className="preview-pane"><CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={handleViewerError} onSelectionChange={onSelectionChange} /></section>
|
||||
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} /></aside>
|
||||
<section className="preview-pane">
|
||||
<GenerationStatus task={taskRecord} />
|
||||
<CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={handleViewerError} onSelectionChange={onSelectionChange} />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerationStatus({ task }: { task: TaskRecord | null }) {
|
||||
const plan = task?.generation_plan;
|
||||
const nodes = Array.isArray(plan?.nodes) ? plan.nodes.filter((node): node is Record<string, unknown> => Boolean(node && typeof node === "object")) : [];
|
||||
if (task?.lifecycle !== "running") return null;
|
||||
return (
|
||||
<aside className="generation-status" aria-live="polite">
|
||||
<div className="generation-status-heading"><Loader2 className="spin" size={14} /><span>生成检查点</span></div>
|
||||
<div className="generation-status-active">{task.active_node_id || "正在生成计划"}</div>
|
||||
{nodes.length ? <ul>{nodes.map((node) => <li key={String(node.id || "node")} data-status={String(node.status || "planned")}>
|
||||
<span>{String(node.id || "node")}</span><small>{String(node.status || "planned")}</small>
|
||||
</li>)}</ul> : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function StudioLoading() {
|
||||
return (
|
||||
<main className="boot-screen">
|
||||
|
||||
@@ -1,59 +1,133 @@
|
||||
"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, 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, taskRunning = false, onUpload }: {
|
||||
attachments: CadAttachment[];
|
||||
uploading: boolean;
|
||||
uploadError: string;
|
||||
taskRunning?: boolean;
|
||||
onUpload: (files: FileList | null) => void;
|
||||
}) {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const dragDepth = useRef(0);
|
||||
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
|
||||
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} />
|
||||
<Composer fileInput={fileInput} uploading={uploading} taskRunning={taskRunning} 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);
|
||||
function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void }) {
|
||||
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
|
||||
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={running ? "CAD 正在生成,任务结束后可继续对话" : "描述要生成或修改的 CAD 模型..."} submitMode="enter" rows={4} disabled={running} />
|
||||
<div className="composer-footer">
|
||||
<span><Check size={14} aria-hidden="true" /> Enter 发送,Shift + Enter 换行</span>
|
||||
<div className="composer-actions">
|
||||
{running ? (
|
||||
<span className="text-[11px] text-[var(--ui-text-subtle)]"><Loader2 className="mr-1 inline spin" size={13} aria-hidden="true" />生成中</span>
|
||||
) : (
|
||||
<>
|
||||
<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,9 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, Download, Loader2 } from "lucide-react";
|
||||
import { AlertTriangle, Box, Check, Download, Loader2, Ruler } from "lucide-react";
|
||||
import { useEffect, useState } from "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,63 +11,180 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function CadResultPart({ data }: { data: CadResult }) {
|
||||
const downloads: Array<[string, string]> = [
|
||||
const downloads: Array<[string, string]> = data.checkpoint ? [] : [
|
||||
["STEP", data.stepPath],
|
||||
["CDSL", data.cdslPath],
|
||||
["GLB", data.glbPath],
|
||||
["REPORT", data.reportPath],
|
||||
["报告", data.reportPath],
|
||||
];
|
||||
if (data.designIntentPath) downloads.push(["DESIGN INTENT", data.designIntentPath]);
|
||||
if (!data.checkpoint && data.qualityPath) downloads.push(["质量报告", data.qualityPath]);
|
||||
if (!data.checkpoint && data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]);
|
||||
const qualityLabels: Record<string, string> = {
|
||||
accepted: "验收通过",
|
||||
built_with_warnings: "构建完成,有警告",
|
||||
needs_repair: "需要修复",
|
||||
blocked: "已阻塞",
|
||||
failed: "失败",
|
||||
};
|
||||
const quality = String(data.qualityStatus || "");
|
||||
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>
|
||||
{quality ? <span className={`cad-quality-status quality-${quality}`}>{qualityLabels[quality] || quality}</span> : null}
|
||||
</div>
|
||||
{data.assumptions?.length ? <div className="cad-result-notes"><strong>假设</strong><span>{data.assumptions.join(";")}</span></div> : null}
|
||||
{data.referenceIds.length ? <div className="cad-result-notes"><strong>参考</strong><span>{data.referenceIds.join(";")}</span></div> : null}
|
||||
{!data.checkpoint ? <QualitySummary data={data} /> : null}
|
||||
{data.snapshotStatus && data.snapshotStatus !== "unavailable" ? <div className="cad-result-notes"><strong>快照</strong><span>{data.snapshotStatus}</span></div> : null}
|
||||
{downloads.length ? <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> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type QualityResult = { id?: string; status?: string; severity?: string; expected?: unknown; actual?: unknown };
|
||||
type QualityPayload = { quality?: { results?: QualityResult[] }; quality_status?: string };
|
||||
|
||||
function QualitySummary({ data }: { data: CadResult }) {
|
||||
const [quality, setQuality] = useState<QualityPayload | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data.qualityPath) {
|
||||
setQuality(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void fetch(`/api/tasks/${encodeURIComponent(data.taskId)}/quality`, { signal: controller.signal })
|
||||
.then((response) => response.ok ? response.json() as Promise<QualityPayload> : null)
|
||||
.then((payload) => { if (!controller.signal.aborted) setQuality(payload); })
|
||||
.catch(() => { if (!controller.signal.aborted) setQuality(null); });
|
||||
return () => controller.abort();
|
||||
}, [data.qualityPath, data.revisionId, data.taskId]);
|
||||
|
||||
const results = quality?.quality?.results || [];
|
||||
if (!results.length) return null;
|
||||
const label: Record<string, string> = { passed: "通过", failed: "未通过", unavailable: "不可用" };
|
||||
return (
|
||||
<div className="cad-quality-results" aria-label="通用验证结果">
|
||||
<strong>验证</strong>
|
||||
<ul>
|
||||
{results.map((result, index) => (
|
||||
<li key={`${result.id || "rule"}-${index}`} className={`quality-rule-${result.status || "unavailable"}`}>
|
||||
<span>{result.id || "rule"}</span>
|
||||
<span>{label[result.status || ""] || result.status || "不可用"}</span>
|
||||
{result.severity && result.severity !== "blocking" ? <span>{result.severity}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CadErrorPart({ data }: { data: CadError }) {
|
||||
export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) {
|
||||
const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? [];
|
||||
const profileCount = data.profiles?.length || 0;
|
||||
const holeCount = data.holes?.length || 0;
|
||||
const viewCount = data.views?.length || data.attachmentIds.length;
|
||||
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>
|
||||
<section className="cad-message cad-image-analysis" aria-label="图像分析">
|
||||
<div className="cad-message-heading"><Ruler size={14} aria-hidden="true" /><span>{data.observationStage === "survey" ? "图片勘测" : data.observationStage === "sketch" ? "草图候选" : "图像分析"}</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}
|
||||
{(data.views?.length || profileCount || holeCount || data.measurements?.length || data.assumptions?.length) ? (
|
||||
<details className="image-analysis-details">
|
||||
<summary>勘测详情</summary>
|
||||
<div className="image-analysis-section">
|
||||
<span>视角与几何</span>
|
||||
<p>{viewCount} 个视角,{profileCount} 个轮廓,{holeCount} 个孔/切除特征</p>
|
||||
</div>
|
||||
{data.profiles?.length ? <div className="image-analysis-section">
|
||||
<span>草图候选</span>
|
||||
<ul className="image-dimension-list">
|
||||
{data.profiles.slice(0, 12).map((profile) => <li key={profile.id}>
|
||||
<strong>{profile.id}</strong>
|
||||
<span>{profile.segments?.length || 0} 段,{profile.closed ? "闭合" : "未确认闭合"}{profile.confidence == null ? "" : `,置信度 ${Math.round(profile.confidence * 100)}%`}</span>
|
||||
</li>)}
|
||||
</ul>
|
||||
</div> : null}
|
||||
{data.measurements?.length ? <div className="image-analysis-section">
|
||||
<span>测量记录</span>
|
||||
<ul className="image-dimension-list">
|
||||
{data.measurements.slice(0, 16).map((measurement, index) => <li key={`${measurement.name}-${index}`}>
|
||||
<strong>{measurement.name}</strong>
|
||||
<span>{measurement.value_mm == null ? "待确认" : `${measurement.value_mm} mm`} · {measurement.source || "image"}</span>
|
||||
</li>)}
|
||||
</ul>
|
||||
</div> : null}
|
||||
{data.uncertainties?.length ? <div className="image-analysis-section">
|
||||
<span>勘测不确定项</span>
|
||||
<p>{data.uncertainties.slice(0, 16).join(";")}</p>
|
||||
</div> : null}
|
||||
</details>
|
||||
) : 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-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,11 +51,19 @@ 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,
|
||||
topologyPath: typeof payload.topology_path === "string" ? payload.topology_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"),
|
||||
qualityStatus: String(payload.quality_status || ""),
|
||||
qualityPath: typeof payload.quality_path === "string" ? payload.quality_path : undefined,
|
||||
assumptions: Array.isArray(payload.generation_assumptions) ? payload.generation_assumptions.map(String) : [],
|
||||
warnings: Array.isArray(payload.warnings) ? payload.warnings.map(String) : [],
|
||||
repairAttempts: Number(payload.repair_attempts || 0),
|
||||
snapshotPaths: Array.isArray(payload.snapshot_paths) ? payload.snapshot_paths.map(String) : [],
|
||||
snapshotStatus: String(payload.snapshot_status || "unavailable"),
|
||||
checkpoint: Boolean(payload.checkpoint),
|
||||
lifecycle: typeof payload.lifecycle === "string" ? payload.lifecycle : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,14 +228,32 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
|
||||
setLoadState((current) => current.kind === "ready" ? current : { kind: "loading" });
|
||||
const glbUrl = encodeArtifactUrl(result.taskId, result.glbPath);
|
||||
const selectorUrl = result.selectorPath ? encodeArtifactUrl(result.taskId, result.selectorPath) : "";
|
||||
const topologyUrl = result.topologyPath ? encodeArtifactUrl(result.taskId, result.topologyPath) : "";
|
||||
void Promise.all([
|
||||
loadRenderGlb(glbUrl),
|
||||
selectorUrl ? loadRenderJson(selectorUrl).catch(() => null) : Promise.resolve(null),
|
||||
topologyUrl ? loadRenderJson(topologyUrl).catch(() => null) : Promise.resolve(null),
|
||||
])
|
||||
.then(([meshData, selectorSidecar]) => {
|
||||
.then(([meshData, selectorSidecar, topologySnapshot]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const selectorRuntime = selectorSidecar && typeof selectorSidecar === "object"
|
||||
? buildCdslSelectorRuntime(selectorSidecar, meshData)
|
||||
const topologyMatchesRevision = topologySnapshot && typeof topologySnapshot === "object"
|
||||
&& String((topologySnapshot as Record<string, unknown>).task_id || "") === result.taskId
|
||||
&& String((topologySnapshot as Record<string, unknown>).revision_id || "") === result.revisionId;
|
||||
const rawTopologyRecords = topologyMatchesRevision ? (topologySnapshot as Record<string, unknown>).records : null;
|
||||
const topologyRecords = Array.isArray(rawTopologyRecords)
|
||||
? rawTopologyRecords.filter((record): record is Record<string, unknown> => Boolean(record && typeof record === "object"))
|
||||
: [];
|
||||
const selectorPayload = selectorSidecar && typeof selectorSidecar === "object"
|
||||
? {
|
||||
...(selectorSidecar as Record<string, unknown>),
|
||||
edges: Array.isArray((selectorSidecar as Record<string, unknown>).edges)
|
||||
&& ((selectorSidecar as Record<string, unknown>).edges as unknown[]).length
|
||||
? (selectorSidecar as Record<string, unknown>).edges
|
||||
: topologyRecords.filter((record) => record.kind === "edge" && record.executable !== false),
|
||||
}
|
||||
: null;
|
||||
const selectorRuntime = selectorPayload
|
||||
? buildCdslSelectorRuntime(selectorPayload as Parameters<typeof buildCdslSelectorRuntime>[0], meshData)
|
||||
: null;
|
||||
setLoadState({ kind: "ready", meshData, selectorRuntime });
|
||||
setHoveredReferenceId("");
|
||||
@@ -249,7 +275,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
|
||||
onError(error instanceof Error ? error.message : "CAD Viewer asset loading failed");
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId]);
|
||||
}, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId, result?.topologyPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reveal) return;
|
||||
@@ -258,7 +284,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
|
||||
}, [reveal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!result) {
|
||||
if (!result || result.checkpoint || isGenerating) {
|
||||
setParameters([]);
|
||||
setShowParameters(false);
|
||||
return;
|
||||
@@ -277,7 +303,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
|
||||
if (!controller.signal.aborted) setParameterError(error instanceof Error ? error.message : "无法读取参数");
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result?.revisionId, result?.taskId]);
|
||||
}, [isGenerating, result?.checkpoint, result?.revisionId, result?.taskId]);
|
||||
|
||||
const submitEdit = useCallback(async (operation: string, picks: Record<string, unknown>[]) => {
|
||||
if (!result || !operation || !picks.length) return;
|
||||
@@ -375,7 +401,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
|
||||
}, [loadState, onSelectionChange, result]);
|
||||
|
||||
const commitParameters = useCallback(async (values: Record<string, number>) => {
|
||||
if (!result || !Object.keys(values).length) return;
|
||||
if (!result || result.checkpoint || isGenerating || !Object.keys(values).length) return;
|
||||
const parameterId = Object.keys(values)[0];
|
||||
setParameterPending(parameterId);
|
||||
setParameterError("");
|
||||
@@ -391,13 +417,17 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
|
||||
} finally {
|
||||
setParameterPending("");
|
||||
}
|
||||
}, [onResult, result]);
|
||||
}, [isGenerating, onResult, result]);
|
||||
|
||||
const viewerTheme = useMemo(() => ({ ...VIEWER_THEME, colorMode: theme }), [theme]);
|
||||
const activeToolDefinition = activeTool ? cadEditToolForOperation(activeTool) : null;
|
||||
const pickableFaces = useMemo(
|
||||
() => loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [],
|
||||
[loadState]
|
||||
() => !isGenerating && !result?.checkpoint && loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [],
|
||||
[isGenerating, loadState, result?.checkpoint]
|
||||
);
|
||||
const pickableEdges = useMemo(
|
||||
() => !isGenerating && !result?.checkpoint && loadState.kind === "ready" ? loadState.selectorRuntime?.edges || [] : [],
|
||||
[isGenerating, loadState, result?.checkpoint]
|
||||
);
|
||||
if (loadState.kind === "empty") return <ViewerState icon={<Box size={20} />} text="3D 预览等待模型" />;
|
||||
if (loadState.kind === "loading") return <ViewerState icon={<Loader2 className="spin" size={20} />} text="加载 CAD Viewer 资产..." />;
|
||||
@@ -424,10 +454,10 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
|
||||
selectedReferenceIds={selectedReferenceIds}
|
||||
selectorRuntime={loadState.selectorRuntime}
|
||||
pickableFaces={pickableFaces}
|
||||
pickableEdges={[]}
|
||||
pickableEdges={pickableEdges}
|
||||
onHoverReferenceChange={setHoveredReferenceId}
|
||||
onActivateReference={handleActivateReference}
|
||||
editPointPickEnabled={Boolean(activeTool)}
|
||||
editPointPickEnabled={Boolean(activeTool) && !isGenerating && !result?.checkpoint}
|
||||
activeEditToolId={activeTool}
|
||||
editToolPickKind={cadEditToolNextPickKind(activeTool, editPicks.length)}
|
||||
editToolPicks={editPicks}
|
||||
@@ -440,16 +470,16 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
|
||||
onAiSelectionComplete={onAiSelectionComplete}
|
||||
/>
|
||||
<AiSelectionOverlay draft={aiSelectionDraft} />
|
||||
<EditToolPickOverlay
|
||||
{!isGenerating && !result?.checkpoint ? <EditToolPickOverlay
|
||||
activeToolId={activeTool}
|
||||
picks={editPicks}
|
||||
hoverPick={editHoverPick}
|
||||
parameters={editParameters}
|
||||
/>
|
||||
/> : null}
|
||||
<EmbeddedCadEditToolbar
|
||||
activeToolId={activeTool}
|
||||
aiSelectionMode={selectionMode}
|
||||
disabled={!result || editPending}
|
||||
disabled={!result || editPending || isGenerating || Boolean(result?.checkpoint)}
|
||||
unavailableToolIds={["add_chamfer", "add_fillet"]}
|
||||
onSelectTool={(tool) => {
|
||||
setActiveTool(tool);
|
||||
@@ -468,13 +498,13 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
|
||||
}}
|
||||
/>
|
||||
<EmbeddedCadViewToolbar
|
||||
disabled={!result}
|
||||
disabled={!result || isGenerating}
|
||||
onResetView={() => viewerRef.current?.zoomToFit?.()}
|
||||
onScreenshot={() => void viewerRef.current?.captureScreenshot?.({ filename: "cdsl-cad.png" })}
|
||||
onParameters={() => setShowParameters(true)}
|
||||
/>
|
||||
{editPending || isGenerating ? <div className="viewer-loading"><Loader2 className="spin" size={16} /><span>{editPending ? "正在应用 CDSL 编辑..." : "正在生成 CDSL 模型..."}</span></div> : null}
|
||||
{activeToolDefinition ? (
|
||||
{activeToolDefinition && !isGenerating && !result?.checkpoint ? (
|
||||
<div className="absolute bottom-3 left-3 z-30 w-[236px] border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-3 text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-soft)] backdrop-blur" data-viewer-interaction-overlay="true">
|
||||
<div className="mb-2 text-xs font-semibold">{activeToolDefinition.label}</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -538,7 +568,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
|
||||
onClose={() => setShowParameters(false)}
|
||||
onCommit={(id, value) => void commitParameters({ [id]: value })}
|
||||
onReset={(values) => void commitParameters(values)}
|
||||
downloads={[
|
||||
downloads={result.checkpoint ? [] : [
|
||||
{ label: "STEP", description: "CAD exchange", url: encodeArtifactUrl(result.taskId, result.stepPath) },
|
||||
{ label: "CDSL", description: "Editable model", url: encodeArtifactUrl(result.taskId, result.cdslPath) },
|
||||
{ label: "GLB", description: "Preview mesh", url: encodeArtifactUrl(result.taskId, result.glbPath) },
|
||||
|
||||
@@ -6,11 +6,8 @@ export function encodeArtifactUrl(taskId: string, artifactPath: string) {
|
||||
return `/api/tasks/${encodedTask}/artifacts/${encodedPath}`;
|
||||
}
|
||||
|
||||
export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null {
|
||||
if (!task) return null;
|
||||
const current =
|
||||
task.revisions.find((revision) => revision.revision_id === task.current_revision) ??
|
||||
[...task.revisions].reverse().find((revision) => revision.status === "success");
|
||||
function resultForRevision(task: TaskRecord, revisionId: string, checkpoint: boolean): CadResult | null {
|
||||
const current = task.revisions.find((revision) => revision.revision_id === revisionId);
|
||||
if (
|
||||
!current ||
|
||||
current.status !== "success" ||
|
||||
@@ -31,10 +28,33 @@ 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,
|
||||
topologyPath: current.topology_path,
|
||||
summary: current.summary || "CDSL CAD model",
|
||||
referenceIds: current.reference_ids || [],
|
||||
engine: current.engine || "cdsl_only",
|
||||
qualityStatus: current.quality_status || "",
|
||||
qualityPath: current.quality_path,
|
||||
assumptions: current.generation_assumptions || current.assumptions || [],
|
||||
warnings: current.warnings || [],
|
||||
repairAttempts: current.repair_attempts || 0,
|
||||
snapshotPaths: current.snapshot_manifest_path ? [current.snapshot_manifest_path] : [],
|
||||
snapshotStatus: current.snapshot_status || "unavailable",
|
||||
checkpoint,
|
||||
lifecycle: task.lifecycle || "completed",
|
||||
};
|
||||
}
|
||||
|
||||
export function activeCheckpointPreview(task: TaskRecord | null): CadResult | null {
|
||||
if (!task || task.lifecycle !== "running") return null;
|
||||
const revisionId = task.preview_revision || task.active_revision || task.current_revision;
|
||||
if (!revisionId) return null;
|
||||
return resultForRevision(task, revisionId, true);
|
||||
}
|
||||
|
||||
export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null {
|
||||
if (!task) return null;
|
||||
const current =
|
||||
task.revisions.find((revision) => revision.revision_id === (task.published_revision || task.current_revision)) ??
|
||||
[...task.revisions].reverse().find((revision) => revision.status === "success" && revision.visibility !== "checkpoint");
|
||||
return current ? resultForRevision(task, current.revision_id, current.visibility === "checkpoint") : null;
|
||||
}
|
||||
|
||||
@@ -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 [{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { backendEventToUiChunk } from "./cad-stream";
|
||||
import { latestSuccessfulResult } from "./cad-artifacts";
|
||||
import { activeCheckpointPreview, latestSuccessfulResult } from "./cad-artifacts";
|
||||
import { messagesForBackend } from "./cad-messages";
|
||||
import type { CadUIMessage } from "./cad-types";
|
||||
|
||||
@@ -14,6 +14,37 @@ 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("keeps progressive revisions as separate data parts", () => {
|
||||
const first = backendEventToUiChunk({ event: "cad_result", data: { taskId: "cad_abc", revisionId: "rev_001" } }, "text_1");
|
||||
const second = backendEventToUiChunk({ event: "cad_result", data: { taskId: "cad_abc", revisionId: "rev_002" } }, "text_1");
|
||||
assert.notEqual(first?.id, second?.id);
|
||||
});
|
||||
|
||||
test("maps visual repair review into a blocking progress state", () => {
|
||||
const chunk = backendEventToUiChunk({
|
||||
event: "render_review",
|
||||
data: { taskId: "cad_abc", nodeId: "round", review: { verdict: "repair", confidence: 0.92, evidence: ["missing round"] } },
|
||||
}, "text_1");
|
||||
assert.equal(chunk?.type, "data-cad-progress");
|
||||
assert.deepEqual("data" in chunk! ? chunk.data : null, {
|
||||
step: "render_review", label: "视觉复核", status: "error", message: "missing round", taskId: "cad_abc", nodeId: "round",
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -27,6 +58,34 @@ test("restores the latest successful task revision for the viewer", () => {
|
||||
assert.equal(result?.summary, "done");
|
||||
});
|
||||
|
||||
test("prefers the published revision over an active checkpoint", () => {
|
||||
const result = latestSuccessfulResult({
|
||||
task_id: "cad_abc",
|
||||
current_revision: "rev_002",
|
||||
active_revision: "rev_002",
|
||||
published_revision: "rev_001",
|
||||
lifecycle: "running",
|
||||
revisions: [
|
||||
{ revision_id: "rev_001", status: "success", visibility: "final", cdsl_path: "a", step_path: "b", glb_path: "c", report_path: "d" },
|
||||
{ revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" },
|
||||
],
|
||||
});
|
||||
assert.equal(result?.revisionId, "rev_001");
|
||||
assert.equal(result?.checkpoint, false);
|
||||
});
|
||||
|
||||
test("restores an active checkpoint only while the task is running", () => {
|
||||
const result = activeCheckpointPreview({
|
||||
task_id: "cad_abc", current_revision: "rev_002", active_revision: "rev_002", published_revision: "rev_001", lifecycle: "running",
|
||||
revisions: [
|
||||
{ revision_id: "rev_001", status: "success", visibility: "final", cdsl_path: "a", step_path: "b", glb_path: "c", report_path: "d" },
|
||||
{ revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" },
|
||||
],
|
||||
});
|
||||
assert.equal(result?.revisionId, "rev_002");
|
||||
assert.equal(result?.checkpoint, true);
|
||||
});
|
||||
|
||||
test("strips non-text and non-CAD parts before sending to FastAPI", () => {
|
||||
const messages: CadUIMessage[] = [{
|
||||
id: "m1",
|
||||
|
||||
@@ -19,10 +19,37 @@ export function backendEventToUiChunk(
|
||||
data: item.data,
|
||||
};
|
||||
}
|
||||
if (["generation_plan", "checkpoint", "render_review", "rollback", "task_terminal"].includes(item.event)) {
|
||||
const review = item.data.review && typeof item.data.review === "object"
|
||||
? item.data.review as Record<string, unknown>
|
||||
: null;
|
||||
const status = item.event === "task_terminal"
|
||||
? (String(item.data.lifecycle || "") === "failed" ? "error" : "success")
|
||||
: item.event === "render_review" && String(review?.verdict || "") === "repair" && Number(review?.confidence || 0) >= 0.85
|
||||
? "error"
|
||||
: String(item.data.status || "running");
|
||||
return {
|
||||
type: "data-cad-progress",
|
||||
id: `${item.event}_${String(item.data.taskId || Date.now())}_${String(item.data.nodeId || "")}`,
|
||||
data: { step: item.event, label: ({
|
||||
generation_plan: "生成计划", checkpoint: "构建检查点", render_review: "视觉复核", rollback: "回滚检查点", task_terminal: "生成任务",
|
||||
} as Record<string, string>)[item.event], status, message: String(
|
||||
item.data.message || item.data.reason || (review?.evidence instanceof Array ? review.evidence.join(";") : ""),
|
||||
),
|
||||
...(item.data.taskId ? { taskId: String(item.data.taskId) } : {}),
|
||||
...(item.data.nodeId ? { nodeId: String(item.data.nodeId) } : {}),
|
||||
...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.event === "cad_result") {
|
||||
const taskId = String(item.data.taskId || "");
|
||||
const revisionId = String(item.data.revisionId || Date.now());
|
||||
return {
|
||||
type: "data-cad-result",
|
||||
id: `result_${String(item.data.taskId || Date.now())}`,
|
||||
// Each revision is a distinct progressive result. Reusing only the task
|
||||
// id makes the AI SDK reconcile intermediate revisions into one part.
|
||||
id: `result_${taskId}_${revisionId}`,
|
||||
data: item.data,
|
||||
};
|
||||
}
|
||||
@@ -33,5 +60,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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ export type CadProgress = {
|
||||
label: string;
|
||||
status: "running" | "success" | "error" | string;
|
||||
message?: string;
|
||||
taskId?: string;
|
||||
nodeId?: string;
|
||||
lifecycle?: "running" | "completed" | "failed" | string;
|
||||
};
|
||||
|
||||
export type CadResult = {
|
||||
@@ -17,11 +20,19 @@ export type CadResult = {
|
||||
parametersPath?: string;
|
||||
selectorPath?: string;
|
||||
edgesPath?: string;
|
||||
designIntentId?: string;
|
||||
designIntentPath?: string;
|
||||
topologyPath?: string;
|
||||
summary: string;
|
||||
referenceIds: string[];
|
||||
engine: string;
|
||||
qualityStatus?: "accepted" | "built_with_warnings" | "needs_repair" | "blocked" | "failed" | string;
|
||||
qualityPath?: string;
|
||||
assumptions?: string[];
|
||||
warnings?: string[];
|
||||
repairAttempts?: number;
|
||||
snapshotPaths?: string[];
|
||||
snapshotStatus?: string;
|
||||
checkpoint?: boolean;
|
||||
lifecycle?: "running" | "completed" | "failed" | string;
|
||||
};
|
||||
|
||||
export type CadError = {
|
||||
@@ -29,10 +40,63 @@ export type CadError = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type CadImageDimension = {
|
||||
id: string;
|
||||
label: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type CadImageSegment = {
|
||||
type: "line" | "arc" | "circle" | "polyline" | "unknown_curve" | string;
|
||||
start?: number[];
|
||||
end?: number[];
|
||||
center?: number[];
|
||||
radius_mm?: number | null;
|
||||
points?: number[][];
|
||||
confidence?: number | null;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type CadImageProfile = {
|
||||
id: string;
|
||||
role?: string;
|
||||
plane_hint?: string;
|
||||
closed?: boolean;
|
||||
segments?: CadImageSegment[];
|
||||
source_images?: string[];
|
||||
confidence?: number | null;
|
||||
uncertain?: string[];
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type CadImageAnalysis = {
|
||||
observationStage?: "survey" | "sketch" | "complete" | string;
|
||||
schemaVersion?: string;
|
||||
attachmentIds: string[];
|
||||
partType: string;
|
||||
visibleFeatures: string[];
|
||||
uncertainFeatures: string[];
|
||||
dimensionCandidates?: CadImageDimension[];
|
||||
// Legacy conversations stored this field before dimensions became optional.
|
||||
requiredDimensions?: CadImageDimension[];
|
||||
views?: Array<{ attachment_id?: string; view_role?: string; orientation?: string; quality?: string; confidence?: number | null }>;
|
||||
overallGeometry?: Record<string, unknown>;
|
||||
surfaces?: Array<Record<string, unknown>>;
|
||||
profiles?: CadImageProfile[];
|
||||
holes?: Array<Record<string, unknown>>;
|
||||
bends?: Array<Record<string, unknown>>;
|
||||
measurements?: Array<{ name: string; value_mm?: number | null; source?: string; confidence?: number | null; evidence?: string }>;
|
||||
uncertainties?: string[];
|
||||
assumptions?: string[];
|
||||
cvHints?: Array<Record<string, unknown>>;
|
||||
artifactPath?: string;
|
||||
};
|
||||
|
||||
export type CadDataParts = {
|
||||
"cad-progress": CadProgress;
|
||||
"cad-result": CadResult;
|
||||
"cad-error": CadError;
|
||||
"cad-image-analysis": CadImageAnalysis;
|
||||
};
|
||||
|
||||
export type CadUIMessage = UIMessage<unknown, CadDataParts>;
|
||||
@@ -46,7 +110,7 @@ export type ConversationRecord = {
|
||||
|
||||
export type CadAttachment = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
conversation_id: string;
|
||||
name: string;
|
||||
kind: "image" | "document";
|
||||
path: string;
|
||||
@@ -54,6 +118,10 @@ export type CadAttachment = {
|
||||
size: number;
|
||||
sha256: string;
|
||||
extracted_path?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
orientation?: string;
|
||||
format?: string;
|
||||
};
|
||||
|
||||
export type TaskRevision = {
|
||||
@@ -66,18 +134,34 @@ export type TaskRevision = {
|
||||
parameters_path?: string;
|
||||
selector_path?: string;
|
||||
edges_path?: string;
|
||||
design_intent_id?: string;
|
||||
design_intent_path?: string;
|
||||
topology_path?: string;
|
||||
summary?: string;
|
||||
reference_ids?: string[];
|
||||
engine?: string;
|
||||
error?: string;
|
||||
quality_status?: string;
|
||||
quality_path?: string;
|
||||
assumptions?: string[];
|
||||
generation_assumptions?: string[];
|
||||
warnings?: string[];
|
||||
repair_attempts?: number;
|
||||
snapshot_manifest_path?: string;
|
||||
snapshot_status?: string;
|
||||
visibility?: "checkpoint" | "final" | "superseded" | string;
|
||||
node_id?: string;
|
||||
render_manifest_path?: string;
|
||||
visual_review_path?: string;
|
||||
};
|
||||
|
||||
export type TaskRecord = {
|
||||
task_id: string;
|
||||
current_revision: string;
|
||||
current_design_intent_id?: string;
|
||||
active_revision?: string;
|
||||
published_revision?: string;
|
||||
lifecycle?: "running" | "completed" | "failed" | string;
|
||||
active_node_id?: string;
|
||||
preview_revision?: string;
|
||||
generation_plan?: Record<string, unknown> | null;
|
||||
revisions: TaskRevision[];
|
||||
};
|
||||
|
||||
@@ -92,4 +176,8 @@ export type BackendConfig = {
|
||||
model: string;
|
||||
configured: boolean;
|
||||
library_samples: number;
|
||||
max_repair_attempts?: number;
|
||||
incremental_generation?: boolean;
|
||||
review_configured?: boolean;
|
||||
review_error?: string;
|
||||
};
|
||||
|
||||
@@ -41,3 +41,22 @@ test("uses backend face triangle ranges when they are available", () => {
|
||||
assert.deepEqual([...runtime.proxy.faceIds], [0, 1]);
|
||||
assert.deepEqual([...runtime.proxy.faceRuns], [0, 0, 0, 1, 0, 0, 0, 1, 1, 1]);
|
||||
});
|
||||
|
||||
test("keeps face row lookup separate from edge row lookup", () => {
|
||||
const runtime = buildCdslSelectorRuntime({
|
||||
references: [
|
||||
{ id: "face_000", selectorType: "face", center: [0, 0, 0], normal: [0, 0, 1], frame: { origin_mm: [0, 0, 0], normal: [0, 0, 1] } },
|
||||
],
|
||||
edges: [{
|
||||
record_id: "edge_000",
|
||||
geometry: { start_mm: [0, 0, 0], end_mm: [1, 0, 0], center_mm: [0.5, 0, 0], bbox_mm: [0, 0, 0, 1, 0, 0], curve_type: "line" },
|
||||
}],
|
||||
}, {
|
||||
vertices: new Float32Array(9),
|
||||
indices: new Uint32Array([0, 1, 2]),
|
||||
});
|
||||
|
||||
assert.equal(runtime.faceReferenceByRowIndex.get(0)?.id, "face_000");
|
||||
assert.equal(runtime.edgeReferenceByRowIndex.get(0)?.id, "edge_000");
|
||||
assert.deepEqual(runtime.faces.map((reference) => reference.id), ["face_000"]);
|
||||
});
|
||||
|
||||
@@ -11,9 +11,20 @@ type SidecarReference = {
|
||||
surface_type?: string;
|
||||
triangle_start?: number;
|
||||
triangle_count?: number;
|
||||
executable?: boolean;
|
||||
snapshot_id?: string;
|
||||
};
|
||||
|
||||
type Sidecar = { references?: SidecarReference[] };
|
||||
type SidecarEdge = {
|
||||
record_id?: string;
|
||||
selectorType?: string;
|
||||
executable?: boolean;
|
||||
snapshot_id?: string;
|
||||
geometry?: Record<string, unknown>;
|
||||
owner_feature_ids?: string[];
|
||||
};
|
||||
|
||||
type Sidecar = { references?: SidecarReference[]; edges?: SidecarEdge[] };
|
||||
|
||||
const GLB_CAD_UNIT_SCALE = 1000;
|
||||
const FACE_ID_NONE = 0xffffffff;
|
||||
@@ -96,7 +107,7 @@ function triangleRuns(faceIds: Uint32Array, occurrenceRow: number) {
|
||||
export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
|
||||
const sourceReferences = Array.isArray(sidecar?.references) ? sidecar.references : [];
|
||||
const faces = sourceReferences
|
||||
.filter((reference) => String(reference?.selectorType || "").toLowerCase() === "face")
|
||||
.filter((reference) => String(reference?.selectorType || "").toLowerCase() === "face" && reference?.executable !== false)
|
||||
.map((reference, rowIndex) => {
|
||||
const frame = sourceFrame(reference);
|
||||
if (!frame) return null;
|
||||
@@ -141,6 +152,73 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
|
||||
const edgeLines: number[] = [];
|
||||
const edgeIndices: number[] = [];
|
||||
const edgeIds: number[] = [];
|
||||
const edgeReferences = (Array.isArray(sidecar?.edges) ? sidecar.edges : [])
|
||||
.filter((record) => record?.executable !== false)
|
||||
.map((record, rowIndex) => {
|
||||
const geometry = record.geometry || {};
|
||||
const bbox = Array.isArray(geometry.bbox_mm) && geometry.bbox_mm.length === 6
|
||||
? geometry.bbox_mm.map(Number)
|
||||
: null;
|
||||
const center = Array.isArray(geometry.center_mm) && geometry.center_mm.length >= 3
|
||||
? geometry.center_mm.slice(0, 3).map(Number)
|
||||
: null;
|
||||
const start = Array.isArray(geometry.start_mm) && geometry.start_mm.length >= 3
|
||||
? geometry.start_mm.slice(0, 3).map(Number)
|
||||
: null;
|
||||
const end = Array.isArray(geometry.end_mm) && geometry.end_mm.length >= 3
|
||||
? geometry.end_mm.slice(0, 3).map(Number)
|
||||
: null;
|
||||
let points: number[][] = start && end ? [start, end] : [];
|
||||
if (!points.length && center && bbox && String(geometry.curve_type || "").toLowerCase() === "circle") {
|
||||
const extents = [bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2]];
|
||||
const normalAxis = extents.indexOf(Math.min(...extents));
|
||||
const axes = [0, 1, 2].filter((axis) => axis !== normalAxis);
|
||||
const radius = Math.max(extents[axes[0]], extents[axes[1]]) / 2;
|
||||
points = Array.from({ length: 33 }, (_, index) => {
|
||||
const angle = (index / 32) * Math.PI * 2;
|
||||
const point = [...center];
|
||||
point[axes[0]] += Math.cos(angle) * radius;
|
||||
point[axes[1]] += Math.sin(angle) * radius;
|
||||
return point;
|
||||
});
|
||||
}
|
||||
if (points.length < 2 || !points.every((point) => point.every(Number.isFinite))) return null;
|
||||
const segmentStart = edgeIndices.length / 2;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const startIndex = edgeLines.length / 3;
|
||||
edgeLines.push(...previewVector(points[index - 1] as Vector3), ...previewVector(points[index] as Vector3));
|
||||
edgeIndices.push(startIndex, startIndex + 1);
|
||||
edgeIds.push(rowIndex);
|
||||
}
|
||||
const edgeId = String(record.record_id || `edge_${rowIndex}`);
|
||||
const previewCenter = center ? previewVector(center as Vector3) : null;
|
||||
return {
|
||||
id: edgeId,
|
||||
selectorType: "edge",
|
||||
normalizedSelector: edgeId,
|
||||
displaySelector: edgeId,
|
||||
label: String(geometry.curve_type || "edge"),
|
||||
summary: String(geometry.curve_type || edgeId),
|
||||
shortSummary: String(geometry.curve_type || edgeId),
|
||||
partId: "glb:0",
|
||||
rowIndex,
|
||||
pickData: {
|
||||
selectorType: "edge",
|
||||
center: previewCenter,
|
||||
sourceBoundsMm: bbox,
|
||||
bbox: bbox ? mappedBBox({ min: bbox.slice(0, 3), max: bbox.slice(3, 6) }) : null,
|
||||
segmentStart,
|
||||
segmentCount: points.length - 1,
|
||||
curveType: String(geometry.curve_type || "edge"),
|
||||
cdslCoordinateSystem: "build123d_y_up_glb",
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
|
||||
const vertices = meshData?.vertices;
|
||||
const indices = meshData?.indices;
|
||||
const triangleCount = Math.floor((indices?.length || 0) / 3);
|
||||
@@ -186,7 +264,7 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
|
||||
}
|
||||
|
||||
const faceRuns = triangleRuns(faceIds, 0);
|
||||
const references = faces;
|
||||
const references = [...faces, ...edgeReferences];
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
surfaceEdgeRendering: false,
|
||||
@@ -196,19 +274,21 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
|
||||
bbox: meshData?.bounds || null,
|
||||
occurrences: [{ id: "glb:0" }],
|
||||
shapes: [],
|
||||
faces: references,
|
||||
edges: [],
|
||||
faces,
|
||||
edges: edgeReferences,
|
||||
vertices: [],
|
||||
references,
|
||||
referenceMap: new Map(references.map((reference) => [reference.id, reference])),
|
||||
referenceByNormalizedSelector: new Map(references.map((reference) => [reference.normalizedSelector, reference])),
|
||||
referenceByDisplaySelector: new Map(references.map((reference) => [reference.displaySelector, reference])),
|
||||
faceReferenceByRowIndex: new Map(references.map((reference) => [reference.rowIndex, reference])),
|
||||
edgeReferenceByRowIndex: new Map(),
|
||||
// Face and edge rows are separate namespaces. Mapping all references here
|
||||
// lets edge rows overwrite face rows and makes face clicks unselectable.
|
||||
faceReferenceByRowIndex: new Map(faces.map((reference) => [reference.rowIndex, reference])),
|
||||
edgeReferenceByRowIndex: new Map(edgeReferences.map((reference) => [reference.rowIndex, reference])),
|
||||
vertexReferenceByRowIndex: new Map(),
|
||||
occurrenceIdByRowIndex: new Map([[0, "glb:0"]]),
|
||||
faceReferenceMap: new Map(references.map((reference) => [reference.id, reference])),
|
||||
edgeReferenceMap: new Map(),
|
||||
faceReferenceMap: new Map(faces.map((reference) => [reference.id, reference])),
|
||||
edgeReferenceMap: new Map(edgeReferences.map((reference) => [reference.id, reference])),
|
||||
vertexReferenceMap: new Map(),
|
||||
singleOccurrenceId: "glb:0",
|
||||
proxy: {
|
||||
@@ -217,9 +297,9 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
|
||||
faceIds,
|
||||
faceRuns,
|
||||
faceRunColumns: ["occurrenceRow", "primitiveIndex", "triangleStart", "triangleCount", "faceRow"],
|
||||
edgePositions: new Float32Array(0),
|
||||
edgeIndices: new Uint32Array(0),
|
||||
edgeIds: new Uint32Array(0),
|
||||
edgePositions: new Float32Array(edgeLines),
|
||||
edgeIndices: new Uint32Array(edgeIndices),
|
||||
edgeIds: new Uint32Array(edgeIds),
|
||||
faceEdgeRows: [],
|
||||
edgeFaceRows: [],
|
||||
},
|
||||
|
||||
@@ -128,6 +128,7 @@ function compactEntity(
|
||||
selector: String(selectedEntity?.selector || reference?.displaySelector || reference?.normalizedSelector || id).trim(),
|
||||
label: String(reference?.label || selectedEntity?.surfaceType || referenceData.surfaceType || "face").trim(),
|
||||
selectorType: String(selectedEntity?.selectorType || reference?.selectorType || "face").trim(),
|
||||
snapshotId: String(selectedEntity?.snapshotId || reference?.snapshot_id || "").trim(),
|
||||
surfaceType: String(selectedEntity?.surfaceType || referenceData.surfaceType || "unknown").trim(),
|
||||
centerMm: referenceData.centerMm,
|
||||
normal: referenceData.normal,
|
||||
|
||||
Reference in New Issue
Block a user