处理一些冲突
This commit is contained in:
@@ -51,3 +51,16 @@ Help an AI agent generate CAD models through the repository's CDSL engine.
|
||||
value shapes in `cdsl_schema.json`. In particular, hole positions must be
|
||||
objects such as `{"mm": [u_mm, v_mm, w_mm]}`, never bare coordinate arrays.
|
||||
- Preserve the original CDSL and write revisions as separate artifacts.
|
||||
|
||||
## CADFS Engine Maintenance
|
||||
|
||||
When changing CADFS lowering, the CDSL schema, runtime, geometry adapter,
|
||||
selector binding, or their tests, update
|
||||
`cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md` in the same change.
|
||||
|
||||
- Record the end-to-end capability state, remaining semantic boundary, and
|
||||
regression evidence. A successful rebuild is not geometric similarity.
|
||||
- Update a capability to completed only when lowering, contract, execution,
|
||||
geometry, and regression coverage all exist.
|
||||
- The progress ledger is local development state and is intentionally ignored
|
||||
by Git. Do not add it to a commit.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
只使用当前工具 schema、`operation_contract` 和服务端提供的 topology/reference token;它们高于本指引、示例和任何经验。每次只完成被调度的一个原子操作,不编造 atom、字段、selector 或能力。参数须为有限 mm/deg 数值。已有可执行 checkpoint 是应保留的最佳结果;能力缺口、未验证项和视觉疑点要如实交给服务端证据流程,不能用虚构几何掩盖。
|
||||
@@ -0,0 +1 @@
|
||||
先区分显式事实、图像观察、工程默认值和未知项。提取单位、外形、功能面、孔/槽、配合关系、关键尺寸及可验证目标。默认值只能补足常见零件的非关键构造,不能把未说明尺寸伪装成用户要求或确定性验收值。只有安全、配合、合规或可建模性确实取决于一个缺失事实时,才提出一个聚焦澄清;其余不确定性记录为假设或风险。
|
||||
@@ -0,0 +1 @@
|
||||
把尺寸当作模型契约:先识别主控的长度、宽度、厚度、直径、中心距、节距、数量、半径和角度,再从它们推导重复位置、对称偏移和余量。所有尺寸明确使用 mm,角度使用 deg;长度、直径、深度、节距和圆角半径必须为合理正有限值。阵列优先由中心线、数量、节距、半径或角度推导,避免难以追溯的点坐标常数。提交前以包围盒、比例、壁厚/材料余量和目标特征数量做常识检查。
|
||||
@@ -0,0 +1 @@
|
||||
世界坐标默认是右手 mm。根 `XY` 与 `+Z` 挤出只用于当前 contract 允许的根挤出。原点按功能基准选取:对称件取中心,板件取占地中心,轴对称件取轴线,存在安装或配合接口时取安装面、配合轴或明确接口基准。`workplane.origin_mm` 是局部 `(0,0)` 的世界点,`x_dir` 是局部 `+X`,`normal` 指向正挤出;草图局部坐标不是世界坐标。孔 `positions[].mm` 是宿主面上的绝对世界点,不是面内偏移。非根特征依据当前 contract、测量宿主和 token 建立 frame,不能猜测最后生成面或全局平面。`reference_plane`/`reference_axis` 是唯一支持的命名定位上下文;先创建基准,再创建依赖它的旋转、镜像、阵列和草图。位置必须由基准、中心线、偏移、节距或半径导出;定位失败时改 frame、偏移或方向,不修改已发布工件。
|
||||
@@ -0,0 +1 @@
|
||||
优先把零件身份和主控尺寸写进稳定根特征:根体、主要增材体、主要切除、孔/槽、重复特征、最后的圆角/倒角。每个节点只承担一个原子意图,依赖边只表示直接几何前提。默认形成连通单体;确需多体时必须由目标和 contract 支持。避免把视觉装饰、细小倒角或易碎布尔放在主形体之前。重规划时保留已完成节点和可执行检查点,只替换最小必要子图。
|
||||
@@ -0,0 +1 @@
|
||||
轮廓必须闭合、不自交、无零长或重叠边,并清楚区分外环和内环。先验证 workplane 的原点、`x_dir`、`normal` 与局部轮廓方向;翻转方向使用 contract 允许的字段,不凭视觉猜测。切除从实际材料面进入,深度覆盖目标材料并满足当前预检;避免刚好停在共面边界。对薄壁、近相切、重叠工具和零厚度结果保持余量。切除失败先检查宿主、方向、深度和轮廓,再考虑更换建模顺序。
|
||||
@@ -0,0 +1 @@
|
||||
宿主特征只能使用当前 revision 的测量 topology 和服务端给出的不透明 selector token;不得按边/面列表下标、历史名称或“最后一个面”猜选。选择前核对 token 的 kind、中心、法向、包围盒和 surface_type 是否覆盖预期材料区域。布尔、孔、阵列、圆角后拓扑可能变化,旧 token 和 reference 不可假定仍有效;依赖新拓扑时重新观察。reference token 只按当前 contract 放入允许槽位。选择不确定时请求 topology,而不是提交模糊 selector。
|
||||
@@ -0,0 +1 @@
|
||||
对称和重复优先通过 `pattern_linear`、`pattern_mirror` 及其 contract 参数表达。先完成一个正确的源特征,再用中心面、中心线、方向、数量、节距、半径或角度定义重复关系;不要用零散手填坐标代替可追溯模式。镜像平面和阵列方向应来自已建立的 datum 或当前测量 token。阵列前确认源特征、间距和数量不会重叠、越界或使材料变成零厚度。
|
||||
@@ -0,0 +1 @@
|
||||
圆角和倒角仅在主形体、切除和孔稳定后执行,并只选择唯一、当前有效的边 token;禁止“所有边”式回退。半径/距离必须小于邻近材料可容纳范围,避免相邻圆角相交。布尔操作避开共面终止、近相切和重复工具重叠;若风险高,优先以更稳定的主轮廓、顺序或足够余量表达。失败时不要重复原片段,先诊断受影响的面、边、深度和拓扑。
|
||||
@@ -0,0 +1 @@
|
||||
确定性几何事实与视觉审查职责不同:包围盒、实体数、孔深或贯穿状态只能证明已测量的 claim,不能证明整体设计语义。使用当前 contract、预检结果、claim evidence、render manifest 和 recent failures 作决定。视觉不符时给出具体的形状、位置、方向或比例差异作为修复依据,不能把它伪装成确定性通过。仅在几何改变后重新审查;STEP/checkpoint 是主工件,GLB 和渲染是派生审查证据,不能替代 CAD 几何。
|
||||
@@ -0,0 +1 @@
|
||||
修复先读错误和证据,定位最小责任点,再改最小的 CDSL/计划部分并重新执行依赖检查。常见原因包括开环/自交轮廓、零或负尺寸、切除方向或深度错误、错误 host frame、布尔后的旧 selector、过大圆角和直径/半径混淆。不要原样重试已失败片段。运行时不支持的能力应作为风险或缺口保留并继续发布最佳可执行模型,不能发明新 atom 或删除有效 checkpoint。
|
||||
@@ -0,0 +1,63 @@
|
||||
# CDSL Author Guidance Corpus
|
||||
|
||||
This corpus is a Chinese-first, non-authoritative author aid. The runtime
|
||||
operation contract, fragment schema, topology/reference tokens, preflight and
|
||||
verifier evidence always win over these Markdown files. The manifest maps
|
||||
only workflow phase, scheduled atomic operation and repair state; it never
|
||||
classifies the user's part request.
|
||||
|
||||
## Source Migration
|
||||
|
||||
| Source reference | CDSL target sections | Intentionally excluded |
|
||||
| --- | --- | --- |
|
||||
| `cad-brief.md` | `01`, `02`, `09` | Python/file workflow |
|
||||
| `parameters.md` | `02`, `07` | sidecars, animation, viewer control |
|
||||
| `positioning.md` | `03`, `06`, `op-reference` | assemblies, joints, `Location`, imported STEP placement |
|
||||
| `build123d-modeling.md` | `03` through `08`, operation appendices | build123d APIs, labels, colors and assembly source |
|
||||
| `build123d-modeling.zh-CN.md` | all Chinese terminology and rule review | a duplicate competing rule set |
|
||||
| `inspection-and-validation.md` | `09`, `10` | CLI paths and selector syntax |
|
||||
| `snapshot-review.md` | `09` | renderer commands |
|
||||
| `repair-loop.md` | `10`, `05`, `06`, `08` | build123d-only remediation syntax |
|
||||
| `step-generation.md` | `00`, `09` | Python generator commands |
|
||||
| `supported-exports.md` | `09` | mesh tolerance and exporter-specific flags |
|
||||
|
||||
## Selection Contract
|
||||
|
||||
- Requirements authoring selects `00` to `03`.
|
||||
- Feature planning selects `00`, `02`, `03`, `04`, `07`, and `08`.
|
||||
- A scheduled feature selects `00`, `03` to `06`, `08`, and its current
|
||||
operation appendix.
|
||||
- Repair selects `00`, `03`, `06`, `09`, `10`, and its operation appendix.
|
||||
- Final validation selects `00`, `09`, and `10`.
|
||||
|
||||
At a bounded prompt budget, contract, coordinate/datum, and the scheduled
|
||||
operation appendix are mandatory. Other sections are included in stable
|
||||
priority order. A malformed corpus or an unsupported operation registry
|
||||
falls back to the original short author prompt and records fallback metadata
|
||||
with the author usage record.
|
||||
|
||||
## Evaluation Commands
|
||||
|
||||
Run the six matched control scenarios three times each, first without and
|
||||
then with guidance:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance off \
|
||||
--scenario rectangular_mounting_plate --scenario circular_flange_pcd \
|
||||
--scenario obround_slot_plate --scenario rounded_rectangular_pocket \
|
||||
--scenario double_hole_linkage_arm --scenario l_bracket
|
||||
|
||||
PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance on \
|
||||
--scenario rectangular_mounting_plate --scenario circular_flange_pcd \
|
||||
--scenario obround_slot_plate --scenario rounded_rectangular_pocket \
|
||||
--scenario double_hole_linkage_arm --scenario l_bracket
|
||||
|
||||
PYTHONPATH=backend python -m app.cad_agent.evals.live --compare-guidance-reports CONTROL/report.json TREATMENT/report.json
|
||||
```
|
||||
|
||||
The comparator excludes declared validation gaps and engine-declared
|
||||
`unsupported_*` capability gaps from prompt quality metrics, checks paired
|
||||
model/runtime/contract/budget equivalence, and
|
||||
requires the treatment's checkpoint/completion rates not to regress, median
|
||||
author calls to stay within 10 percent, and either schema/decision or CDSL
|
||||
expression failures to improve.
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema_version": "cdsl.author-guidance.manifest.v1",
|
||||
"version": "2026-09-03.1",
|
||||
"sections": [
|
||||
{"id": "00-author-contract", "file": "00-author-contract.md", "title": "00 Author Contract", "priority": 100, "mandatory": true},
|
||||
{"id": "01-brief-and-assumptions", "file": "01-brief-and-assumptions.md", "title": "01 Brief And Assumptions", "priority": 70, "mandatory": false},
|
||||
{"id": "02-parameters-and-derived-dimensions", "file": "02-parameters-and-derived-dimensions.md", "title": "02 Parameters And Derived Dimensions", "priority": 80, "mandatory": false},
|
||||
{"id": "03-coordinate-system-and-datums", "file": "03-coordinate-system-and-datums.md", "title": "03 Coordinate System And Datums", "priority": 100, "mandatory": true},
|
||||
{"id": "04-construction-and-feature-order", "file": "04-construction-and-feature-order.md", "title": "04 Construction And Feature Order", "priority": 70, "mandatory": false},
|
||||
{"id": "05-profiles-workplanes-and-cuts", "file": "05-profiles-workplanes-and-cuts.md", "title": "05 Profiles Workplanes And Cuts", "priority": 90, "mandatory": false},
|
||||
{"id": "06-hosted-features-selectors-and-topology", "file": "06-hosted-features-selectors-and-topology.md", "title": "06 Hosted Features Selectors And Topology", "priority": 90, "mandatory": false},
|
||||
{"id": "07-patterns-symmetry-and-repetition", "file": "07-patterns-symmetry-and-repetition.md", "title": "07 Patterns Symmetry And Repetition", "priority": 60, "mandatory": false},
|
||||
{"id": "08-finishing-and-boolean-risk", "file": "08-finishing-and-boolean-risk.md", "title": "08 Finishing And Boolean Risk", "priority": 60, "mandatory": false},
|
||||
{"id": "09-evidence-visual-review-and-validation", "file": "09-evidence-visual-review-and-validation.md", "title": "09 Evidence Visual Review And Validation", "priority": 80, "mandatory": false},
|
||||
{"id": "10-repair-and-best-effort", "file": "10-repair-and-best-effort.md", "title": "10 Repair And Best Effort", "priority": 80, "mandatory": false},
|
||||
{"id": "op-extrude-add", "file": "op-extrude-add.md", "title": "Operation Appendix Extrude Add", "priority": 100, "mandatory": true},
|
||||
{"id": "op-extrude-cut", "file": "op-extrude-cut.md", "title": "Operation Appendix Extrude Cut", "priority": 100, "mandatory": true},
|
||||
{"id": "op-loft", "file": "op-loft.md", "title": "Operation Appendix Loft", "priority": 100, "mandatory": true},
|
||||
{"id": "op-revolve", "file": "op-revolve.md", "title": "Operation Appendix Revolve", "priority": 100, "mandatory": true},
|
||||
{"id": "op-hole", "file": "op-hole.md", "title": "Operation Appendix Hole", "priority": 100, "mandatory": true},
|
||||
{"id": "op-reference", "file": "op-reference.md", "title": "Operation Appendix Reference", "priority": 100, "mandatory": true},
|
||||
{"id": "op-pattern", "file": "op-pattern.md", "title": "Operation Appendix Pattern", "priority": 100, "mandatory": true},
|
||||
{"id": "op-finish", "file": "op-finish.md", "title": "Operation Appendix Finish", "priority": 100, "mandatory": true},
|
||||
{"id": "op-sphere", "file": "op-sphere.md", "title": "Operation Appendix Sphere", "priority": 100, "mandatory": true},
|
||||
{"id": "op-primitives", "file": "op-primitives.md", "title": "Operation Appendix Primitives", "priority": 100, "mandatory": true},
|
||||
{"id": "op-thread", "file": "op-thread.md", "title": "Operation Appendix Thread", "priority": 100, "mandatory": true}
|
||||
],
|
||||
"phase_sections": {
|
||||
"DEFAULT": ["00-author-contract", "03-coordinate-system-and-datums", "09-evidence-visual-review-and-validation"],
|
||||
"DRAFTING_REQUIREMENTS_DOCUMENT": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"],
|
||||
"DRAFTING_COMPLETION_TARGET": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"],
|
||||
"COMPILING_REQUIREMENTS": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"],
|
||||
"COMPILING_FEATURE_PLAN": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"],
|
||||
"REPLANNING_FEATURE_SUBGRAPH": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"],
|
||||
"FEATURE_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "05-profiles-workplanes-and-cuts", "06-hosted-features-selectors-and-topology", "08-finishing-and-boolean-risk"],
|
||||
"AWAITING_ACTION": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
|
||||
"ACTION_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"]
|
||||
},
|
||||
"repair_sections": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
|
||||
"final_sections": ["00-author-contract", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
|
||||
"operation_sections": {
|
||||
"extrude_add_blind": ["op-extrude-add"],
|
||||
"extrude_add_two_sided": ["op-extrude-add"],
|
||||
"extrude_cut_blind": ["op-extrude-cut"],
|
||||
"extrude_cut_two_sided": ["op-extrude-cut"],
|
||||
"extrude_cut_through": ["op-extrude-cut"],
|
||||
"loft_add": ["op-loft"],
|
||||
"revolve_add": ["op-revolve"],
|
||||
"revolve_cut": ["op-revolve"],
|
||||
"hole_blind": ["op-hole"],
|
||||
"hole_counterbore": ["op-hole"],
|
||||
"hole_countersink": ["op-hole"],
|
||||
"hole_wizard": ["op-hole"],
|
||||
"reference_plane": ["op-reference"],
|
||||
"reference_axis": ["op-reference"],
|
||||
"pattern_linear": ["op-pattern"],
|
||||
"pattern_mirror": ["op-pattern"],
|
||||
"pattern_circular": ["op-pattern"],
|
||||
"fillet": ["op-finish"],
|
||||
"chamfer": ["op-finish"],
|
||||
"sphere_add": ["op-sphere"],
|
||||
"box_add": ["op-primitives"],
|
||||
"cylinder_add": ["op-primitives"],
|
||||
"thread_add": ["op-thread"],
|
||||
"thread_cut": ["op-thread"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
`extrude_add_blind` 和 `extrude_add_two_sided` 必须使用闭合草图和 contract 允许的正距离。根挤出遵守根 `XY` datum;后续增材先确认草图 frame 与已有实体的连接。双向挤出分别核对两个方向的长度与材料范围;`reverse` 只用于当前 frame 的方向修正,不能代替错误的 workplane。
|
||||
@@ -0,0 +1 @@
|
||||
`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。`extrude_cut_two_sided` 必须分别提供正向和反向的距离与终止条件,不能以单侧深度近似双向切除。`extrude_cut_through` 只接受明确的 `end_condition`,由现有主体跨度决定穿透距离,不能伪造盲向深度。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。
|
||||
@@ -0,0 +1 @@
|
||||
`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。先完成影响这些边的布尔、孔和阵列,再按 contract 使用正半径或距离。局部材料不足、相邻过渡相交或 token 已失效时,不用全局边选择兜底;保留主体并报告该收尾特征的风险。
|
||||
@@ -0,0 +1 @@
|
||||
孔 atom 需要当前宿主面的有效 selector token。`positions[].mm` 使用该宿主面上的绝对世界坐标,先核对点在面区域内与法向方向。直径、深度、沉孔/沉头参数以 contract 为准,深度覆盖预期材料;多孔共享一个原子操作时保持同一规格和同一宿主。不要把点写成面局部偏移或裸数组。
|
||||
@@ -0,0 +1 @@
|
||||
`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。不要以挤出替代放样,也不要把 selector 选中的实体面当作放样截面,除非 operation contract 明确支持。
|
||||
@@ -0,0 +1 @@
|
||||
`pattern_linear` 只复制当前 contract 允许且存在的源 feature reference;方向是明确世界/基准方向,数量和 spacing 为合理值。`pattern_mirror` 使用存在的镜像 plane reference,先确认源与平面关系以及复制后不会重叠或意外合并。pattern 不代替新的宿主选择;下游特征若依赖新面,重新读取 topology。
|
||||
@@ -0,0 +1 @@
|
||||
`box_add` 和 `cylinder_add` 是世界坐标原生图元。按 operation contract 提供正尺寸以及明确的 `center_mm` 或 axis。仅在目标确为长方体或圆柱体时使用;由轮廓驱动的几何保留草图、放样等历史表达。
|
||||
@@ -0,0 +1 @@
|
||||
`reference_plane` 用有限非零 `normal` 和与其不平行的 `x_dir` 定义局部 frame;`origin_mm` 是世界点。`reference_axis` 用有限非零 `direction` 和世界原点定义。它们只建立可追溯 datum,不直接制造实体;先于依赖它的旋转、镜像、阵列或定位特征,并依照 contract 的 reference token 规则引用。
|
||||
@@ -0,0 +1 @@
|
||||
`revolve_add` 和 `revolve_cut` 的轴必须由明确 datum 或 contract 中的世界坐标轴表达,并按预检要求位于正确的草图关系中。核对 axis origin、direction、角度和 `reverse`;完整回转避免轮廓跨轴造成自交,局部回转避免与现有材料近相切。切除回转仍必须覆盖目标材料。
|
||||
@@ -0,0 +1 @@
|
||||
`sphere_add` 用明确的世界中心和正有限半径定义。确认它与目标实体的连接意图:需要单体时应有足够相交,独立体仅在需求允许多体时使用。球体位置从 datum 或主尺寸导出,不把视图坐标误当世界坐标。
|
||||
@@ -0,0 +1 @@
|
||||
`thread_add` 和 `thread_cut` 需要明确的 axis、正的大小径、螺距和长度,且各参数必须物理一致。螺纹切除必须有已有宿主实体;需求为实体螺纹时,不能以光滑孔替代。
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Bounded, file-backed author guidance for the CDSL workflow.
|
||||
|
||||
The corpus is deliberately non-authoritative: contracts, schemas, topology
|
||||
tokens, and server preflight always remain the executable source of truth.
|
||||
Loading errors return an empty selection so authoring continues with the
|
||||
pre-guidance prompt instead of turning documentation into an availability
|
||||
dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.state import TaskPhase
|
||||
from app.cad_agent.ports import AuthorGuidanceSelection
|
||||
|
||||
|
||||
_MIN_CHARS = 1_200
|
||||
_MAX_CHARS = 6_000
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Section:
|
||||
section_id: str
|
||||
title: str
|
||||
priority: int
|
||||
mandatory: bool
|
||||
content: str
|
||||
|
||||
@property
|
||||
def block(self) -> str:
|
||||
return f"## {self.title}\n{self.content.strip()}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Corpus:
|
||||
version: str
|
||||
sections: dict[str, _Section]
|
||||
phase_sections: dict[str, tuple[str, ...]]
|
||||
repair_sections: tuple[str, ...]
|
||||
final_sections: tuple[str, ...]
|
||||
operation_sections: dict[str, tuple[str, ...]]
|
||||
|
||||
|
||||
class FileAuthorGuidance:
|
||||
"""Read and select the checked-in corpus deterministically.
|
||||
|
||||
Selection depends exclusively on workflow state and the runtime operation
|
||||
registry. It intentionally receives neither the user's request nor image
|
||||
observations, so it cannot become an implicit part-family classifier.
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path, *, enabled: bool = True, max_chars: int = 3_600) -> None:
|
||||
self.root = root
|
||||
self.enabled = enabled
|
||||
self.max_chars = min(_MAX_CHARS, max(_MIN_CHARS, max_chars))
|
||||
self._corpus: _Corpus | None = None
|
||||
self._load_error = ""
|
||||
|
||||
def select(
|
||||
self,
|
||||
*,
|
||||
phase: TaskPhase,
|
||||
atomic_id: str,
|
||||
repair_required: bool,
|
||||
supported_atomic_ids: tuple[str, ...],
|
||||
) -> AuthorGuidanceSelection:
|
||||
if not self.enabled:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_disabled")
|
||||
corpus = self._load()
|
||||
if corpus is None:
|
||||
return AuthorGuidanceSelection(fallback_reason=self._load_error or "guidance_unavailable")
|
||||
supported = set(supported_atomic_ids)
|
||||
if set(corpus.operation_sections) != supported:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_operation_coverage_mismatch")
|
||||
if atomic_id and atomic_id not in supported:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_unknown_atomic_id")
|
||||
|
||||
if repair_required:
|
||||
requested = list(corpus.repair_sections)
|
||||
elif phase == TaskPhase.FINAL_VALIDATION:
|
||||
requested = list(corpus.final_sections)
|
||||
else:
|
||||
requested = list(corpus.phase_sections.get(phase.value, corpus.phase_sections.get("DEFAULT", ())))
|
||||
if atomic_id:
|
||||
requested.extend(corpus.operation_sections[atomic_id])
|
||||
requested = list(dict.fromkeys(requested))
|
||||
if not requested:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_no_matching_sections")
|
||||
|
||||
mandatory = [section_id for section_id in requested if corpus.sections[section_id].mandatory]
|
||||
optional = [section_id for section_id in requested if not corpus.sections[section_id].mandatory]
|
||||
optional.sort(key=lambda section_id: (-corpus.sections[section_id].priority, requested.index(section_id)))
|
||||
selected: list[str] = []
|
||||
text = ""
|
||||
for section_id in [*mandatory, *optional]:
|
||||
block = corpus.sections[section_id].block
|
||||
candidate = block if not text else f"{text}\n\n{block}"
|
||||
if len(candidate) <= self.max_chars:
|
||||
text = candidate
|
||||
selected.append(section_id)
|
||||
elif section_id in mandatory:
|
||||
# Do not silently drop contract, datum, or operation guidance.
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_required_sections_exceed_budget")
|
||||
return AuthorGuidanceSelection(
|
||||
version=corpus.version,
|
||||
section_ids=tuple(selected),
|
||||
content=text,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
def _load(self) -> _Corpus | None:
|
||||
if self._corpus is not None:
|
||||
return self._corpus
|
||||
if self._load_error:
|
||||
return None
|
||||
try:
|
||||
manifest_path = self.root / "manifest.json"
|
||||
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("manifest is not an object")
|
||||
version = raw.get("version")
|
||||
if raw.get("schema_version") != "cdsl.author-guidance.manifest.v1" or not isinstance(version, str) or not version:
|
||||
raise ValueError("manifest version is invalid")
|
||||
raw_sections = raw.get("sections")
|
||||
if not isinstance(raw_sections, list) or not raw_sections:
|
||||
raise ValueError("manifest sections are invalid")
|
||||
sections: dict[str, _Section] = {}
|
||||
root = self.root.resolve()
|
||||
for item in raw_sections:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("section declaration is invalid")
|
||||
section_id = item.get("id")
|
||||
filename = item.get("file")
|
||||
title = item.get("title")
|
||||
priority = item.get("priority")
|
||||
mandatory = item.get("mandatory", False)
|
||||
if (
|
||||
not isinstance(section_id, str) or not section_id
|
||||
or not isinstance(filename, str) or not filename
|
||||
or not isinstance(title, str) or not title
|
||||
or not isinstance(priority, int) or isinstance(priority, bool)
|
||||
or not isinstance(mandatory, bool)
|
||||
or section_id in sections
|
||||
):
|
||||
raise ValueError("section metadata is invalid")
|
||||
path = (self.root / filename).resolve()
|
||||
if root not in path.parents or not path.is_file():
|
||||
raise ValueError("section file is unavailable")
|
||||
content = path.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
raise ValueError("section content is empty")
|
||||
sections[section_id] = _Section(section_id, title, priority, mandatory, content)
|
||||
|
||||
def identifiers(value: Any, field: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item in sections for item in value):
|
||||
raise ValueError(f"{field} is invalid")
|
||||
return tuple(dict.fromkeys(value))
|
||||
|
||||
raw_phases = raw.get("phase_sections")
|
||||
if not isinstance(raw_phases, dict) or "DEFAULT" not in raw_phases:
|
||||
raise ValueError("phase sections are invalid")
|
||||
phase_sections = {
|
||||
phase: identifiers(section_ids, f"phase {phase}")
|
||||
for phase, section_ids in raw_phases.items()
|
||||
if isinstance(phase, str)
|
||||
}
|
||||
if len(phase_sections) != len(raw_phases):
|
||||
raise ValueError("phase name is invalid")
|
||||
operation_sections = {
|
||||
atomic_id: identifiers(section_ids, f"operation {atomic_id}")
|
||||
for atomic_id, section_ids in (raw.get("operation_sections") or {}).items()
|
||||
if isinstance(atomic_id, str)
|
||||
}
|
||||
if not operation_sections or len(operation_sections) != len(raw.get("operation_sections") or {}):
|
||||
raise ValueError("operation sections are invalid")
|
||||
self._corpus = _Corpus(
|
||||
version=version,
|
||||
sections=sections,
|
||||
phase_sections=phase_sections,
|
||||
repair_sections=identifiers(raw.get("repair_sections"), "repair sections"),
|
||||
final_sections=identifiers(raw.get("final_sections"), "final sections"),
|
||||
operation_sections=operation_sections,
|
||||
)
|
||||
return self._corpus
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as error:
|
||||
self._load_error = f"guidance_load_failed:{type(error).__name__}"
|
||||
return None
|
||||
@@ -46,13 +46,67 @@ class StructuredModelGateway:
|
||||
|
||||
async def call_tool(self, *, messages: list[dict[str, Any]], tool: dict[str, Any], provider_id: str, model_id: str, required_tool_name: str) -> dict[str, Any]:
|
||||
provider, model = self._provider_model(provider_id, model_id)
|
||||
payload = self._payload(provider, model.id, messages, [tool], required_tool_name)
|
||||
response = await self._request(provider, payload)
|
||||
response, compatibility_mode = await self._request_tool_call(
|
||||
provider, model.id, messages, [tool], required_tool_name,
|
||||
)
|
||||
try:
|
||||
return self._normalized_response(provider, response)
|
||||
normalized = self._normalized_response(provider, response)
|
||||
normalized["usage"]["structured_compatibility_mode"] = compatibility_mode
|
||||
return normalized
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise StructuredModelError("Provider response cannot be normalized as a structured tool-call result") from error
|
||||
|
||||
async def _request_tool_call(
|
||||
self,
|
||||
provider: ProviderConfig,
|
||||
model_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
required_tool_name: str,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
"""Keep forced tool choice unless a provider explicitly rejects thinking mode.
|
||||
|
||||
Some OpenAI-compatible gateways reject an otherwise valid forced
|
||||
``tool_choice`` whenever reasoning is enabled or implicit. This is a
|
||||
request compatibility issue rather than an author error. First retry
|
||||
without the client-requested reasoning option; only if that exact
|
||||
rejection remains do we allow automatic choice among the single tool
|
||||
already exposed to the model. The workflow's one-call/schema checks
|
||||
still reject free-form or wrong-tool responses.
|
||||
"""
|
||||
try:
|
||||
return await self._request(
|
||||
provider,
|
||||
self._payload(provider, model_id, messages, tools, required_tool_name),
|
||||
), "forced"
|
||||
except StructuredModelError as first_error:
|
||||
if not self._thinking_tool_choice_rejection(first_error):
|
||||
raise
|
||||
try:
|
||||
return await self._request(
|
||||
provider,
|
||||
self._payload(
|
||||
provider, model_id, messages, tools, required_tool_name,
|
||||
include_reasoning=False,
|
||||
),
|
||||
), "reasoning_disabled"
|
||||
except StructuredModelError as second_error:
|
||||
if not self._thinking_tool_choice_rejection(second_error):
|
||||
raise
|
||||
return await self._request(
|
||||
provider,
|
||||
self._payload(
|
||||
provider, model_id, messages, tools, required_tool_name,
|
||||
include_reasoning=False,
|
||||
force_tool_name=False,
|
||||
),
|
||||
), "single_tool_auto"
|
||||
|
||||
@staticmethod
|
||||
def _thinking_tool_choice_rejection(error: StructuredModelError) -> bool:
|
||||
message = str(error).casefold()
|
||||
return "thinking" in message and ("tool_choice" in message or "tool choice" in message)
|
||||
|
||||
async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
failures: list[dict[str, str]] = []
|
||||
usage = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
@@ -190,20 +244,31 @@ class StructuredModelGateway:
|
||||
})
|
||||
return values
|
||||
|
||||
def _payload(self, provider: ProviderConfig, model_id: str, messages: list[dict[str, Any]], tools: list[dict[str, Any]], required_tool_name: str) -> dict[str, Any]:
|
||||
def _payload(
|
||||
self,
|
||||
provider: ProviderConfig,
|
||||
model_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
required_tool_name: str,
|
||||
*,
|
||||
include_reasoning: bool = True,
|
||||
force_tool_name: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if len(tools) != 1 or not required_tool_name:
|
||||
raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.")
|
||||
request_options = provider.request_options if include_reasoning else {}
|
||||
if provider.api_style == "responses":
|
||||
payload: dict[str, Any] = {
|
||||
"model": model_id, "input": self._responses_input(messages), "tools": self._responses_tools(tools),
|
||||
"tool_choice": {"type": "function", "name": required_tool_name},
|
||||
**provider.request_options,
|
||||
"tool_choice": {"type": "function", "name": required_tool_name} if force_tool_name else "auto",
|
||||
**request_options,
|
||||
}
|
||||
return payload
|
||||
return {
|
||||
"model": model_id, "messages": messages, "tools": tools,
|
||||
"tool_choice": {"type": "function", "function": {"name": required_tool_name}},
|
||||
"temperature": 0, **provider.request_options,
|
||||
"tool_choice": {"type": "function", "function": {"name": required_tool_name}} if force_tool_name else "auto",
|
||||
"temperature": 0, **request_options,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -33,7 +33,17 @@ from app.cad_agent.domain.feature_plan import FeaturePlan, plan_hash
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState, retry_resume_event, transition
|
||||
from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, ModelGateway, ReviewGateway, TaskRepository
|
||||
from app.cad_agent.ports import (
|
||||
AdapterUnavailable,
|
||||
ArtifactStore,
|
||||
AuthorGuidance,
|
||||
AuthorGuidanceSelection,
|
||||
CadRuntime,
|
||||
ModelGateway,
|
||||
NullAuthorGuidance,
|
||||
ReviewGateway,
|
||||
TaskRepository,
|
||||
)
|
||||
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
@@ -116,6 +126,7 @@ class WorkflowCoordinator:
|
||||
review_gateway: ReviewGateway,
|
||||
requirements: RequirementsCommandHandler,
|
||||
actions: ActionCommandHandler,
|
||||
author_guidance: AuthorGuidance | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.repository = repository
|
||||
@@ -125,6 +136,7 @@ class WorkflowCoordinator:
|
||||
self.review_gateway = review_gateway
|
||||
self.requirements = requirements
|
||||
self.actions = actions
|
||||
self.author_guidance = author_guidance or NullAuthorGuidance()
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
@@ -1078,14 +1090,14 @@ class WorkflowCoordinator:
|
||||
"message": message[:1000],
|
||||
}
|
||||
|
||||
async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, int]] | WorkflowError:
|
||||
async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, Any]] | WorkflowError:
|
||||
if len(tools) != 1:
|
||||
raise RuntimeError("Workflow state must expose exactly one author tool.")
|
||||
tool = tools[0]
|
||||
name = str((tool.get("function") or {}).get("name") or "")
|
||||
if not name:
|
||||
raise RuntimeError("Workflow exposed an unnamed author tool.")
|
||||
messages = self._author_context(task_id, feedback)
|
||||
messages, guidance = self._author_context(task_id, feedback)
|
||||
try:
|
||||
response = await self.model_gateway.call_tool(
|
||||
messages=messages, tool=tool, provider_id=author.provider_id,
|
||||
@@ -1103,10 +1115,26 @@ class WorkflowCoordinator:
|
||||
"model_id": author.model_id,
|
||||
"retry_reason": "provider_unavailable",
|
||||
"cache_hit": False,
|
||||
**guidance.usage_metadata(),
|
||||
})
|
||||
return WorkflowError(ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error)[:1000], retryable=True)
|
||||
call = validate_one_tool_call(response["tool_calls"], name)
|
||||
if isinstance(call, WorkflowError):
|
||||
# A provider response is still a billable author attempt even when
|
||||
# it violates the one-tool-call protocol. Keep guidance audit
|
||||
# metadata on that record so A/B reports do not silently omit the
|
||||
# failures this corpus is intended to reduce.
|
||||
self.repository.record_usage(task_id, {
|
||||
**response["usage"],
|
||||
"context_chars": len(json.dumps(messages, ensure_ascii=False)),
|
||||
"tool": name,
|
||||
"provider_id": author.provider_id,
|
||||
"model_id": author.model_id,
|
||||
"raw_arguments_hash": "",
|
||||
"retry_reason": "invalid_tool_call",
|
||||
"cache_hit": False,
|
||||
**guidance.usage_metadata(),
|
||||
})
|
||||
self._record_rejected_tool_calls(
|
||||
task_id,
|
||||
actor="author",
|
||||
@@ -1132,6 +1160,7 @@ class WorkflowCoordinator:
|
||||
"raw_arguments_hash": raw_arguments_hash(raw),
|
||||
"retry_reason": "",
|
||||
"cache_hit": False,
|
||||
**guidance.usage_metadata(),
|
||||
}
|
||||
self.repository.record_usage(task_id, usage)
|
||||
return name, raw, usage
|
||||
@@ -1400,10 +1429,10 @@ class WorkflowCoordinator:
|
||||
return []
|
||||
return [self._tool("record_geometry_conclusion", StatelessGeometryConclusion)]
|
||||
|
||||
def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], AuthorGuidanceSelection]:
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None:
|
||||
return []
|
||||
return [], AuthorGuidanceSelection(fallback_reason="task_state_unavailable")
|
||||
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
|
||||
content = {
|
||||
"protocol": "cad.v3.2.feature-dag",
|
||||
@@ -1557,8 +1586,17 @@ class WorkflowCoordinator:
|
||||
"feature_plan_hash": state.feature_plan_hash,
|
||||
"feature_node_statuses": self._feature_node_statuses(task_id, None),
|
||||
})
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}]
|
||||
return [*messages, *feedback[-2:]]
|
||||
guidance = self.author_guidance.select(
|
||||
phase=state.phase,
|
||||
atomic_id=state.pending_feature.atomic_id if state.pending_feature is not None else "",
|
||||
repair_required=state.repair_required,
|
||||
supported_atomic_ids=self.runtime.supported_atomic_ids(),
|
||||
)
|
||||
system = "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."
|
||||
if guidance.content:
|
||||
system += "\n\nThe following is non-authoritative CDSL author guidance. The current tool schema, operation contract, and server facts take precedence.\n\n" + guidance.content
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": system}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}]
|
||||
return [*messages, *feedback[-2:]], guidance
|
||||
|
||||
def _user_clarifications(self, task_id: str) -> list[dict[str, str]]:
|
||||
clarifications: list[dict[str, str]] = []
|
||||
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||
import shutil
|
||||
|
||||
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
||||
from app.cad_agent.adapters.author_guidance import FileAuthorGuidance
|
||||
from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
|
||||
from app.cad_agent.adapters.runtime import ProfileCadRuntime
|
||||
from app.cad_agent.adapters.review_gateway import RenderedReviewGateway
|
||||
@@ -17,7 +18,7 @@ from app.cad_agent.application.outbox import OutboxDispatcher
|
||||
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.settings import Settings
|
||||
from app.settings import BACKEND_ROOT, Settings
|
||||
from app.services.storage import WorkspaceStore
|
||||
|
||||
|
||||
@@ -65,5 +66,10 @@ def compose_v3(settings: Settings) -> V3Services:
|
||||
RenderedReviewGateway(models),
|
||||
requirements,
|
||||
actions,
|
||||
FileAuthorGuidance(
|
||||
BACKEND_ROOT / "agent" / "skills" / "cdsl-author-guidance",
|
||||
enabled=settings.agent_author_guidance_enabled,
|
||||
max_chars=settings.agent_author_guidance_max_chars,
|
||||
),
|
||||
)
|
||||
return V3Services(repository, artifacts, workflow, models, outbox)
|
||||
|
||||
@@ -15,6 +15,7 @@ from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from statistics import median
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
@@ -100,12 +101,15 @@ def _arguments() -> argparse.Namespace:
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
parser.add_argument("--scenario", help="Run one fixture scenario by its stable ID for targeted regression validation.")
|
||||
parser.add_argument("--scenario", action="append", dest="scenarios", help="Run a fixture scenario by stable ID. Repeat this option to select a comparison set.")
|
||||
parser.add_argument("--repetitions", type=int, help="Run every selected scenario this many times.")
|
||||
parser.add_argument("--author-guidance", choices=("on", "off"), help="Override CDSL author guidance for this run.")
|
||||
parser.add_argument("--compare-guidance-reports", nargs=2, type=Path, metavar=("CONTROL", "TREATMENT"), help="Compare matched --author-guidance off/on report.json files without invoking providers.")
|
||||
parser.add_argument("--baseline-report", type=Path, help="Measured pre-v3 token baseline JSON for a release run.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _fixture(suite: str, scenario_id: str | None = None) -> list[dict[str, Any]]:
|
||||
def _fixture(suite: str, scenario_ids: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
fixture_name = "comprehensive.json" if suite == "comprehensive" else "release.json"
|
||||
value = json.loads((Path(__file__).parent / "fixtures" / fixture_name).read_text(encoding="utf-8"))
|
||||
if fixture_name == "comprehensive.json":
|
||||
@@ -122,11 +126,14 @@ def _fixture(suite: str, scenario_id: str | None = None) -> list[dict[str, Any]]
|
||||
raise ValueError("Comprehensive fixture is not synchronized with its source document.")
|
||||
values = [item for item in value.get("scenarios") or () if isinstance(item, dict)]
|
||||
values = values[:2] if suite == "smoke" else values
|
||||
if scenario_id is None:
|
||||
if not scenario_ids:
|
||||
return values
|
||||
selected = [item for item in values if str(item.get("id") or "") == scenario_id]
|
||||
if not selected:
|
||||
raise ValueError(f"Unknown scenario {scenario_id!r} for suite {suite!r}")
|
||||
requested = list(dict.fromkeys(scenario_ids))
|
||||
available = {str(item.get("id") or "") for item in values}
|
||||
unknown = [scenario_id for scenario_id in requested if scenario_id not in available]
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown scenario {unknown[0]!r} for suite {suite!r}")
|
||||
selected = [item for item in values if str(item.get("id") or "") in set(requested)]
|
||||
return selected
|
||||
|
||||
|
||||
@@ -567,12 +574,180 @@ def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]:
|
||||
return {"schema_version": "cad.live-eval-artifact-manifest.v1", "artifact_root": str(artifact_root), "files": files}
|
||||
|
||||
|
||||
def _guidance_metadata(usage: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Summarize author-only guidance audit metadata without retaining prompts."""
|
||||
records = [
|
||||
item for item in usage.get("records") or ()
|
||||
if isinstance(item, dict) and item.get("role") != "reviewer"
|
||||
]
|
||||
sections = sorted({
|
||||
section_id
|
||||
for item in records
|
||||
for section_id in item.get("guidance_section_ids") or ()
|
||||
if isinstance(section_id, str)
|
||||
})
|
||||
versions = sorted({
|
||||
str(item.get("guidance_version") or "")
|
||||
for item in records
|
||||
if str(item.get("guidance_version") or "")
|
||||
})
|
||||
return {
|
||||
"enabled": bool(records) and any(item.get("guidance_enabled") is True for item in records),
|
||||
"versions": versions,
|
||||
"section_ids": sections,
|
||||
"chars_total": sum(int(item.get("guidance_chars") or 0) for item in records),
|
||||
"fallback_reasons": sorted({
|
||||
str(item.get("guidance_fallback_reason") or "")
|
||||
for item in records
|
||||
if str(item.get("guidance_fallback_reason") or "")
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
def _has_unsupported_capability(row: dict[str, Any]) -> bool:
|
||||
"""Recognize engine-declared unsupported capability without hiding model errors."""
|
||||
for event in row.get("ledger") or ():
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
for failure in event.get("operation_failures") or ():
|
||||
if isinstance(failure, dict) and "unsupported_" in str(failure.get("message") or ""):
|
||||
return True
|
||||
if "unsupported_" in str(event.get("message") or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _guidance_metric_rows(report: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
results = [item for item in report.get("results") or () if isinstance(item, dict)]
|
||||
capability_gaps = [
|
||||
item for item in results
|
||||
if item.get("outcome") == "validation_capability_gap" or _has_unsupported_capability(item)
|
||||
]
|
||||
return [item for item in results if item not in capability_gaps], capability_gaps
|
||||
|
||||
|
||||
def _guidance_metrics(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
if not rows:
|
||||
return {"eligible_runs": 0}
|
||||
author_calls = [
|
||||
sum(1 for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
|
||||
for row in rows
|
||||
]
|
||||
context_chars = [
|
||||
sum(int(item.get("context_chars") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
|
||||
for row in rows
|
||||
]
|
||||
prompt_tokens = [
|
||||
sum(int(item.get("prompt_tokens") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
|
||||
for row in rows
|
||||
]
|
||||
failure_layers: dict[str, int] = {}
|
||||
for row in rows:
|
||||
layer = str((row.get("failure_attribution") or {}).get("layer") or "passed")
|
||||
failure_layers[layer] = failure_layers.get(layer, 0) + 1
|
||||
def rate(predicate: Any) -> float:
|
||||
return sum(1 for row in rows if predicate(row)) / len(rows)
|
||||
return {
|
||||
"eligible_runs": len(rows),
|
||||
"executable_checkpoint_rate": rate(lambda row: bool(row.get("revision_ids"))),
|
||||
"completion_rate": rate(lambda row: str((row.get("projection") or {}).get("phase") or "") == "COMPLETED"),
|
||||
"deterministic_claim_success_rate": rate(lambda row: bool((row.get("checks") or {}).get("deterministic_claims_pass"))),
|
||||
"schema_or_decision_rejections": sum(int(row.get("schema_rejection_count") or 0) for row in rows),
|
||||
"cdsl_expression_failures": sum(1 for row in rows if str((row.get("failure_attribution") or {}).get("layer") or "") == "cdsl_expression"),
|
||||
"median_author_calls": median(author_calls),
|
||||
"median_author_context_chars": median(context_chars),
|
||||
"total_author_prompt_tokens": sum(prompt_tokens),
|
||||
"median_author_prompt_tokens": median(prompt_tokens),
|
||||
"failure_layers": dict(sorted(failure_layers.items())),
|
||||
}
|
||||
|
||||
|
||||
def compare_guidance_reports(control: dict[str, Any], treatment: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Compare paired guidance-off/on runs without treating engine gaps as prompt results."""
|
||||
control_rows, control_gaps = _guidance_metric_rows(control)
|
||||
treatment_rows, treatment_gaps = _guidance_metric_rows(treatment)
|
||||
control_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in control_rows}
|
||||
treatment_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in treatment_rows}
|
||||
paired = sorted(set(control_by_key).intersection(treatment_by_key))
|
||||
control_only = sorted(set(control_by_key).difference(treatment_by_key))
|
||||
treatment_only = sorted(set(treatment_by_key).difference(control_by_key))
|
||||
control_pairs = [control_by_key[key] for key in paired]
|
||||
treatment_pairs = [treatment_by_key[key] for key in paired]
|
||||
control_metrics = _guidance_metrics(control_pairs)
|
||||
treatment_metrics = _guidance_metrics(treatment_pairs)
|
||||
same_runtime = (
|
||||
control.get("author") == treatment.get("author")
|
||||
and control.get("reviewer") == treatment.get("reviewer")
|
||||
and control.get("runtime_profile_sha256") == treatment.get("runtime_profile_sha256")
|
||||
and control.get("operation_contracts") == treatment.get("operation_contracts")
|
||||
)
|
||||
control_guidance = bool((control.get("author_guidance") or {}).get("enabled"))
|
||||
treatment_guidance = bool((treatment.get("author_guidance") or {}).get("enabled"))
|
||||
same_budgets = all(
|
||||
control_by_key[key].get("scenario_budget") == treatment_by_key[key].get("scenario_budget")
|
||||
for key in paired
|
||||
)
|
||||
calls_control = control_metrics.get("median_author_calls")
|
||||
calls_treatment = treatment_metrics.get("median_author_calls")
|
||||
calls_within_limit = (
|
||||
isinstance(calls_control, (int, float))
|
||||
and isinstance(calls_treatment, (int, float))
|
||||
and calls_treatment <= calls_control * 1.10
|
||||
)
|
||||
improved = (
|
||||
treatment_metrics.get("schema_or_decision_rejections", 0) < control_metrics.get("schema_or_decision_rejections", 0)
|
||||
or treatment_metrics.get("cdsl_expression_failures", 0) < control_metrics.get("cdsl_expression_failures", 0)
|
||||
)
|
||||
gates = {
|
||||
"complete_pairing": bool(paired) and not control_only and not treatment_only,
|
||||
"control_off_treatment_on": not control_guidance and treatment_guidance,
|
||||
"same_author_reviewer_runtime_and_contracts": same_runtime,
|
||||
"same_per_scenario_budgets": same_budgets,
|
||||
"checkpoint_rate_not_lower": treatment_metrics.get("executable_checkpoint_rate", -1) >= control_metrics.get("executable_checkpoint_rate", 0),
|
||||
"completion_rate_not_lower": treatment_metrics.get("completion_rate", -1) >= control_metrics.get("completion_rate", 0),
|
||||
"median_author_calls_within_ten_percent": calls_within_limit,
|
||||
"model_or_cdsl_failure_improved": improved,
|
||||
}
|
||||
return {
|
||||
"schema_version": "cad.author-guidance-comparison.v1",
|
||||
"status": "passed" if all(gates.values()) else "failed",
|
||||
"gates": gates,
|
||||
"paired_runs": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in paired],
|
||||
"unpaired_runs": {
|
||||
"control_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in control_only],
|
||||
"treatment_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in treatment_only],
|
||||
},
|
||||
"control": control_metrics,
|
||||
"treatment": treatment_metrics,
|
||||
"excluded_capability_gaps": {
|
||||
"control": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in control_gaps],
|
||||
"treatment": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in treatment_gaps],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def compare_guidance_report_paths(control_path: Path, treatment_path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
control = json.loads(control_path.read_text(encoding="utf-8"))
|
||||
treatment = json.loads(treatment_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
return {"status": "failed", "error": f"GUIDANCE_COMPARISON_INPUT_INVALID: {type(error).__name__}"}
|
||||
if not isinstance(control, dict) or not isinstance(treatment, dict):
|
||||
return {"status": "failed", "error": "GUIDANCE_COMPARISON_INPUT_INVALID: report must be an object"}
|
||||
return compare_guidance_reports(control, treatment)
|
||||
|
||||
|
||||
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
|
||||
try:
|
||||
scenarios = _fixture(arguments.suite, arguments.scenario)
|
||||
scenarios = _fixture(arguments.suite, arguments.scenarios)
|
||||
except ValueError as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
|
||||
repetitions = arguments.repetitions if arguments.repetitions is not None else 3 if arguments.suite == "release" else 1
|
||||
if repetitions < 1:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": "--repetitions must be at least 1"}
|
||||
settings = get_settings()
|
||||
if arguments.author_guidance is not None:
|
||||
settings = replace(settings, agent_author_guidance_enabled=arguments.author_guidance == "on")
|
||||
try:
|
||||
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
|
||||
if arguments.review_provider or arguments.review_model:
|
||||
@@ -628,7 +803,7 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An
|
||||
}
|
||||
results: list[dict[str, Any]] = []
|
||||
for scenario in scenarios:
|
||||
for repetition in range(1, 4 if arguments.suite == "release" else 2):
|
||||
for repetition in range(1, repetitions + 1):
|
||||
services.workflow.config = replace(
|
||||
services.workflow.config,
|
||||
# State transitions include local candidate recovery, so the
|
||||
@@ -716,6 +891,13 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An
|
||||
"terminal": terminal, "projection": projection, "usage": usage, "checks": checks,
|
||||
"acceptance_coverage": acceptance,
|
||||
"failure_attribution": failure_attribution,
|
||||
"guidance": _guidance_metadata(usage),
|
||||
"scenario_budget": {
|
||||
"max_author_turns": int(scenario["max_author_turns"]),
|
||||
"max_reviewer_turns": int(scenario["max_reviewer_turns"]),
|
||||
"max_total_calls": int(scenario["max_total_calls"]),
|
||||
"max_total_tokens": int(scenario["max_total_tokens"]),
|
||||
},
|
||||
"rejection_codes": _rejection_codes(events),
|
||||
"schema_rejection_count": len(_rejection_codes(events)),
|
||||
"retry_count": sum(1 for event in events if (event.get("payload") or {}).get("status") == "error"),
|
||||
@@ -745,6 +927,10 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An
|
||||
"author": {"provider": author_provider.id, "model": author_model.id},
|
||||
"author_request_identity": current_author_identity,
|
||||
"reviewer": {"provider": review_provider.id, "model": review_model.id},
|
||||
"author_guidance": {
|
||||
"enabled": settings.agent_author_guidance_enabled,
|
||||
"max_chars": settings.agent_author_guidance_max_chars,
|
||||
},
|
||||
"author_capability": author_capability,
|
||||
"reviewer_capability": reviewer_capability,
|
||||
"structured_output_mode": {
|
||||
@@ -759,6 +945,7 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An
|
||||
"verifier_schema_hash": verifier_schema_hash,
|
||||
"token_comparison": token_comparison,
|
||||
"baseline_report": str(baseline_path) if baseline_path else "",
|
||||
"repetitions": repetitions,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
@@ -768,7 +955,11 @@ def main() -> int:
|
||||
report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
report_root.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
result = asyncio.run(_run(arguments, report_root))
|
||||
result = (
|
||||
compare_guidance_report_paths(*arguments.compare_guidance_reports)
|
||||
if arguments.compare_guidance_reports
|
||||
else asyncio.run(_run(arguments, report_root))
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
# Let an explicit operator interruption retain its normal CLI
|
||||
# semantics. An external kill cannot be reported reliably either.
|
||||
|
||||
@@ -5,13 +5,60 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .domain.state import TaskState
|
||||
from .domain.state import TaskPhase, TaskState
|
||||
|
||||
|
||||
class AdapterUnavailable(RuntimeError):
|
||||
"""A bounded external-service outage; handlers must preserve checkpoints."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorGuidanceSelection:
|
||||
"""Non-authoritative author context selected from the local guidance corpus."""
|
||||
|
||||
version: str = ""
|
||||
section_ids: tuple[str, ...] = ()
|
||||
content: str = ""
|
||||
enabled: bool = False
|
||||
fallback_reason: str = ""
|
||||
|
||||
def usage_metadata(self) -> dict[str, object]:
|
||||
return {
|
||||
"guidance_version": self.version,
|
||||
"guidance_section_ids": list(self.section_ids),
|
||||
"guidance_chars": len(self.content),
|
||||
"guidance_enabled": self.enabled,
|
||||
"guidance_fallback_reason": self.fallback_reason,
|
||||
}
|
||||
|
||||
|
||||
class AuthorGuidance(Protocol):
|
||||
"""Select bounded local author guidance without interpreting user intent."""
|
||||
|
||||
def select(
|
||||
self,
|
||||
*,
|
||||
phase: TaskPhase,
|
||||
atomic_id: str,
|
||||
repair_required: bool,
|
||||
supported_atomic_ids: tuple[str, ...],
|
||||
) -> AuthorGuidanceSelection: ...
|
||||
|
||||
|
||||
class NullAuthorGuidance:
|
||||
"""Compatibility default that retains the pre-guidance author prompt."""
|
||||
|
||||
def select(
|
||||
self,
|
||||
*,
|
||||
phase: TaskPhase,
|
||||
atomic_id: str,
|
||||
repair_required: bool,
|
||||
supported_atomic_ids: tuple[str, ...],
|
||||
) -> AuthorGuidanceSelection:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_not_configured")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InvocationRecord:
|
||||
invocation_id: str
|
||||
|
||||
@@ -71,6 +71,8 @@ class Settings:
|
||||
agent_consecutive_no_progress_limit: int = 6
|
||||
agent_format_error_repeat_limit: int = 3
|
||||
agent_context_char_limit: int = 14000
|
||||
agent_author_guidance_enabled: bool = True
|
||||
agent_author_guidance_max_chars: int = 3600
|
||||
agent_render_cache: bool = True
|
||||
autonomous_generation: bool = True
|
||||
resume_running_tasks_on_startup: bool = True
|
||||
@@ -211,6 +213,8 @@ def get_settings() -> Settings:
|
||||
agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))),
|
||||
agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))),
|
||||
agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))),
|
||||
agent_author_guidance_enabled=_env_flag("CDSL_AGENT_AUTHOR_GUIDANCE_ENABLED", True),
|
||||
agent_author_guidance_max_chars=min(6000, max(1200, int(os.getenv("CDSL_AGENT_AUTHOR_GUIDANCE_MAX_CHARS", "3600")))),
|
||||
agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True),
|
||||
autonomous_generation=True,
|
||||
# Production instances recover durable runs by default. Test workers
|
||||
|
||||
@@ -66,9 +66,15 @@ class Build123dGeometryAdapter:
|
||||
|
||||
@staticmethod
|
||||
def _wire(edges: list[dict[str, Any]]) -> Wire:
|
||||
# 将边字典列表(直线/圆弧)组装成 build123d 的 Wire 线框。
|
||||
# 将边字典列表(直线/圆弧/插值 B 样条)组装成 build123d 的 Wire 线框。
|
||||
built: list[Edge] = []
|
||||
for edge in edges:
|
||||
if edge.get("type") == "bspline":
|
||||
points = [_vector(point) for point in edge.get("points_mm") or []]
|
||||
if len(points) < 3:
|
||||
raise ValueError("bspline contour edge needs at least 3 points")
|
||||
built.append(Edge.make_spline(points, periodic=False))
|
||||
continue
|
||||
start = _vector(edge["start_mm"])
|
||||
end = _vector(edge["end_mm"])
|
||||
if edge.get("type") == "arc" and edge.get("center_mm") is not None:
|
||||
@@ -134,20 +140,38 @@ class Build123dGeometryAdapter:
|
||||
result: list[Face] = []
|
||||
for region in regions:
|
||||
outer = region.get("outer") or []
|
||||
if len(outer) < 2:
|
||||
# 闭合插值样条仅有一条边;直线/圆弧轮廓通常由多条边组成。
|
||||
if len(outer) < 1:
|
||||
continue
|
||||
face = Face(self._wire(outer))
|
||||
holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 2]
|
||||
holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 1]
|
||||
result.append(face.make_holes(holes) if holes else face)
|
||||
return result
|
||||
# 2. 退化:仅有单组轮廓边时,直接作为外轮廓建面。
|
||||
edges = sketch.get("contour_edges_mm") or []
|
||||
if len(edges) >= 2:
|
||||
if len(edges) >= 1:
|
||||
return [Face(self._wire(edges))]
|
||||
# 3. 最终回退:由工作平面与实体圆生成面(圆环/孔洞处理见 _faces_from_circles)。
|
||||
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
return self._faces_from_circles(sketch.get("entities") or [], plane)
|
||||
|
||||
def loft(self, sketches: list[dict[str, Any]]) -> Solid:
|
||||
"""由多条简单闭合草图轮廓生成实体放样。"""
|
||||
wires: list[Wire] = []
|
||||
for index, sketch in enumerate(sketches):
|
||||
# Solid.make_loft 接收 Wire;复用 faces_for_sketch 保持放样、
|
||||
# 拉伸和回转的 profile resolver 一致。多区域/内环的截面对应关系
|
||||
# 尚未由 CDSL 表达,必须显式拒绝而非猜测。
|
||||
faces = self.faces_for_sketch(sketch)
|
||||
if len(faces) != 1:
|
||||
raise ValueError(f"loft profile {index} must resolve to exactly one closed region")
|
||||
if faces[0].inner_wires():
|
||||
raise ValueError(f"loft profile {index} must not contain inner loops")
|
||||
wires.append(faces[0].outer_wire())
|
||||
if len(wires) < 2:
|
||||
raise ValueError("loft requires at least two profile sketches")
|
||||
return Solid.make_loft(wires)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any:
|
||||
"""规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。"""
|
||||
@@ -189,14 +213,22 @@ class Build123dGeometryAdapter:
|
||||
"""
|
||||
# 1. 采样点到目标的最远命中距离决定穿透余量;没有任何采样点命中
|
||||
# 说明 profile 与目标面无交叠,无法裁剪(保留 extent_target_not_reached)。
|
||||
# through_next 的 target 是当前主体,必须先从其面中选出实际命中的
|
||||
# 下一终止面,不能把整个 body 当作待拉伸的 Face。
|
||||
unit = _vector(direction).normalized()
|
||||
hits = [
|
||||
Build123dGeometryAdapter._forward_intersection_distance(target, point, unit)
|
||||
for point in Build123dGeometryAdapter.profile_sample_points(face)
|
||||
]
|
||||
distances = [value for value in hits if value is not None]
|
||||
if not distances:
|
||||
points = Build123dGeometryAdapter.profile_sample_points(face)
|
||||
targets = [target] if isinstance(target, Face) else list(target.faces())
|
||||
candidates = []
|
||||
for candidate in targets:
|
||||
hits = [Build123dGeometryAdapter._forward_intersection_distance(candidate, point, unit) for point in points]
|
||||
distances = [value for value in hits if value is not None]
|
||||
if distances:
|
||||
candidates.append((len(distances), min(distances), candidate, distances))
|
||||
if not candidates:
|
||||
raise ValueError("extent target is not reached by the profile")
|
||||
# 覆盖最多 profile 采样点的面就是本次实体的下一终止面;覆盖数相同
|
||||
# 时取最近的正向交点,确保相邻面交界处的选择稳定。
|
||||
_, _, target, distances = max(candidates, key=lambda item: (item[0], -item[1]))
|
||||
margin = max(distances) + 2.0
|
||||
# 2. 穿透拉伸 profile,同时把目标面向回推生成体层,二者求交即裁剪体。
|
||||
# build123d 的布尔交方法名是 intersect(不是 OCC 的 common),
|
||||
|
||||
@@ -22,21 +22,22 @@ _SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_")
|
||||
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
|
||||
_OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"})
|
||||
_PRIMARY_ATOMICS = frozenset({
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
|
||||
"extrude_cut_through",
|
||||
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
|
||||
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
|
||||
})
|
||||
_HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
|
||||
_ACTIVE_BODY_REQUIRED = frozenset({
|
||||
"extrude_cut_blind", "extrude_cut_through", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
|
||||
"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through",
|
||||
"revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
|
||||
# thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空
|
||||
# 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。
|
||||
"thread_cut",
|
||||
})
|
||||
_BODY_MUTATING_ATOMICS = frozenset({
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
|
||||
"extrude_cut_through",
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
|
||||
"extrude_cut_through", "loft_add",
|
||||
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
|
||||
"thread_add", "thread_cut", "bend_add", *_HOLE_ATOMICS, "fillet", "chamfer",
|
||||
})
|
||||
@@ -148,27 +149,32 @@ def _schema_contract() -> dict[str, dict[str, Any]]:
|
||||
|
||||
|
||||
def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
|
||||
"""Return only sketches consumed by a feature with a sketch contract.
|
||||
"""Return only sketches consumed by executable feature contracts.
|
||||
|
||||
CAD documents commonly preserve construction or abandoned sketches whose
|
||||
contours are incomplete. They are semantic data, but must not make an
|
||||
otherwise independent feature history ineligible for execution.
|
||||
otherwise independent feature history ineligible for execution. Most
|
||||
operations declare ``sketch_id``; loft declares its ordered section set
|
||||
in ``params.profile_sketch_ids``.
|
||||
"""
|
||||
contracts = _schema_contract()
|
||||
return frozenset(
|
||||
str(feature["sketch_id"])
|
||||
for feature in cdsl.get("features") or ()
|
||||
if feature.get("sketch_id") is not None
|
||||
and (contracts.get(str(feature.get("atomic_id") or "")) or {}).get("requires_sketch")
|
||||
)
|
||||
required: set[str] = set()
|
||||
for feature in cdsl.get("features") or ():
|
||||
atomic_id = str(feature.get("atomic_id") or "")
|
||||
if feature.get("sketch_id") is not None and (contracts.get(atomic_id) or {}).get("requires_sketch"):
|
||||
required.add(str(feature["sketch_id"]))
|
||||
if atomic_id == "loft_add":
|
||||
for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or ():
|
||||
required.add(str(sketch_id))
|
||||
return frozenset(required)
|
||||
|
||||
|
||||
def _has_closed_region(sketch: dict[str, Any]) -> bool:
|
||||
"""Mirror the adapter's input contract without importing the geometry kernel."""
|
||||
regions = sketch.get("contour_regions_mm") or []
|
||||
if any(len(region.get("outer") or []) >= 2 for region in regions if isinstance(region, dict)):
|
||||
if any(len(region.get("outer") or []) >= 1 for region in regions if isinstance(region, dict)):
|
||||
return True
|
||||
if len(sketch.get("contour_edges_mm") or []) >= 2:
|
||||
if len(sketch.get("contour_edges_mm") or []) >= 1:
|
||||
return True
|
||||
return any(
|
||||
entity.get("type") == "circle" and not entity.get("construction")
|
||||
@@ -178,6 +184,18 @@ def _has_closed_region(sketch: dict[str, Any]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _has_single_loft_region(sketch: dict[str, Any]) -> bool:
|
||||
"""Whether a resolved profile maps exactly to one solid loft section."""
|
||||
regions = sketch.get("contour_regions_mm") or []
|
||||
if regions:
|
||||
return len(regions) == 1 and not (regions[0].get("holes") or [])
|
||||
circles = [
|
||||
entity for entity in sketch.get("entities") or []
|
||||
if isinstance(entity, dict) and entity.get("type") == "circle" and not entity.get("construction")
|
||||
]
|
||||
return len(circles) == 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityAnalysis:
|
||||
plan: tuple[FeaturePlanNode, ...]
|
||||
@@ -295,6 +313,54 @@ class CapabilityAnalyzer:
|
||||
sketch_id=node.sketch_id,
|
||||
atomic_id=node.atomic_id,
|
||||
))
|
||||
if node.atomic_id == "loft_add":
|
||||
profile_ids = params.get("profile_sketch_ids")
|
||||
if not isinstance(profile_ids, list) or len(profile_ids) < 2:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_loft_profiles",
|
||||
"Loft requires at least two profile sketch ids",
|
||||
))
|
||||
elif len({str(sketch_id) for sketch_id in profile_ids}) != len(profile_ids):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_loft_profiles",
|
||||
"Loft profile sketch ids must be distinct",
|
||||
))
|
||||
else:
|
||||
for sketch_id in profile_ids:
|
||||
sketch_id = str(sketch_id)
|
||||
profile = sketches.get(sketch_id)
|
||||
if profile is None:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_loft_profile",
|
||||
"Loft profile sketch does not exist", sketch_id=sketch_id,
|
||||
))
|
||||
continue
|
||||
profile_type = str((profile.get("profile") or {}).get("type") or "")
|
||||
required.append(f"loft_profile:{profile_type}")
|
||||
resolution_error = sketch_errors.get(sketch_id)
|
||||
if resolution_error:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "profile_resolution_failed",
|
||||
"A loft profile sketch could not be resolved into executable regions",
|
||||
sketch_id=sketch_id, reason=resolution_error,
|
||||
))
|
||||
elif profile_type not in self.profile_types:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_profile",
|
||||
"The current runtime cannot resolve a loft profile",
|
||||
sketch_id=sketch_id, profile_type=profile_type,
|
||||
))
|
||||
elif not _has_closed_region(profile):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "profile_no_closed_region",
|
||||
"A loft profile contains no closed region", sketch_id=sketch_id,
|
||||
))
|
||||
elif not _has_single_loft_region(profile):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_loft_profile_regions",
|
||||
"Loft currently requires exactly one outer profile without holes",
|
||||
sketch_id=sketch_id,
|
||||
))
|
||||
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
|
||||
# #2 draft:extrudeParams.draft 在 cdsl_schema.json 中被允许,
|
||||
# 但 runtime 的拉伸执行器(build123d Solid.extrude)没有锥形
|
||||
@@ -332,7 +398,7 @@ class CapabilityAnalyzer:
|
||||
node.feature_id, "missing_offset_distance",
|
||||
"Offset-from-surface requires a non-zero captured offset distance",
|
||||
))
|
||||
if node.atomic_id == "extrude_add_two_sided":
|
||||
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}:
|
||||
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
|
||||
reverse_type = reverse_condition.get("type")
|
||||
required.append(f"extent:reverse:{reverse_type}")
|
||||
@@ -463,7 +529,8 @@ class CapabilityAnalyzer:
|
||||
if node.atomic_id in _BODY_MUTATING_ATOMICS:
|
||||
body_available = True
|
||||
body_producers = {
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
|
||||
"extrude_cut_through", "loft_add",
|
||||
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
|
||||
"thread_add", "bend_add",
|
||||
# thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
|
||||
|
||||
@@ -108,6 +108,20 @@
|
||||
"required": ["end_condition"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"loftParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_sketch_ids": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 16,
|
||||
"uniqueItems": true,
|
||||
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
|
||||
}
|
||||
},
|
||||
"required": ["profile_sketch_ids"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"revolveParams": {
|
||||
"type": "object",
|
||||
"properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}},
|
||||
@@ -367,18 +381,20 @@
|
||||
"analyticSegment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"enum": ["line", "arc", "circle"]},
|
||||
"type": {"enum": ["line", "arc", "circle", "bspline"]},
|
||||
"start": {"$ref": "#/$defs/point2"},
|
||||
"end": {"$ref": "#/$defs/point2"},
|
||||
"center": {"$ref": "#/$defs/point2"},
|
||||
"radius_mm": {"$ref": "#/$defs/positive"},
|
||||
"points": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}},
|
||||
"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": "circle"}}}, "then": {"required": ["center", "radius_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -421,7 +437,7 @@
|
||||
"required": ["type", "contours"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_through", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
|
||||
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "loft_add", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
|
||||
"feature": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -442,6 +458,8 @@
|
||||
{"if": {"properties": {"atomic_id": {"const": "extrude_add_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_through"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeCutThroughParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "sphere_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sphereParams"}}}},
|
||||
|
||||
@@ -18,7 +18,7 @@ from .sketch_solver import resolve_required_sketches
|
||||
|
||||
P3_ATOMIC_IDS = frozenset({
|
||||
"reference_plane", "reference_axis", "extrude_add_blind", "extrude_add_two_sided",
|
||||
"extrude_cut_blind", "revolve_add", "revolve_cut",
|
||||
"extrude_cut_blind", "extrude_cut_two_sided", "revolve_add", "revolve_cut",
|
||||
})
|
||||
P4_ATOMIC_IDS = P3_ATOMIC_IDS | frozenset({"hole_wizard"})
|
||||
P6_ATOMIC_IDS = P4_ATOMIC_IDS | frozenset({"pattern_linear", "pattern_mirror"})
|
||||
@@ -27,7 +27,7 @@ P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "polygon"})
|
||||
# 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.
|
||||
_P3_PRIMARY_ATOMICS = frozenset({
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut",
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "revolve_add", "revolve_cut",
|
||||
})
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ def _p3_profile_ready(cdsl: dict[str, Any]) -> bool:
|
||||
if profile.get("type") not in P3_PROFILE_TYPES:
|
||||
return False
|
||||
if not (
|
||||
any(len(region.get("outer") or []) >= 2 for region in sketch.get("contour_regions_mm") or () if isinstance(region, dict))
|
||||
or len(sketch.get("contour_edges_mm") or ()) >= 2
|
||||
any(len(region.get("outer") or []) >= 1 for region in sketch.get("contour_regions_mm") or () if isinstance(region, dict))
|
||||
or len(sketch.get("contour_edges_mm") or ()) >= 1
|
||||
or any(
|
||||
entity.get("type") == "circle" and not entity.get("construction") and float(entity.get("radius_mm") or 0.0) > 0
|
||||
for entity in sketch.get("entities") or ()
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"schema": "cdsl.engine.schema.v1",
|
||||
"schema_version": "1.3.1",
|
||||
"schema_version": "1.3.2",
|
||||
"cdsl_json_schema_file": "cdsl_schema.json",
|
||||
"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_profiles": ["circle", "polygon", "analytic_contours"],
|
||||
"operation_contracts": {
|
||||
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
|
||||
"loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]},
|
||||
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
|
||||
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
|
||||
"extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
|
||||
"extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
|
||||
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]},
|
||||
"revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
|
||||
"hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"]},
|
||||
@@ -72,7 +74,7 @@
|
||||
"constraints": ["vertices contains at least three [u, v] points"]
|
||||
},
|
||||
"analytic_contours": {
|
||||
"summary": "Closed executable line, arc and circle contours. B-splines are unsupported in contours; imported construction B-splines are retained as non-executable audit geometry."
|
||||
"summary": "Closed executable line, arc, circle and interpolation B-spline contours. Executable B-splines preserve ordered interpolation points and must be closed; imported construction B-splines remain non-executable audit geometry."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
|
||||
|
||||
|
||||
ALL_ATOMIC_IDS = frozenset({
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
|
||||
"extrude_cut_through",
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
|
||||
"extrude_cut_through", "loft_add",
|
||||
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
|
||||
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
|
||||
"reference_plane", "reference_axis",
|
||||
@@ -83,6 +83,7 @@ class GeometryAdapter(Protocol):
|
||||
def body_solids(self, body: Any) -> list[Any]: ...
|
||||
def body_geometry(self, body: Any) -> dict[str, Any]: ...
|
||||
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
|
||||
def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
|
||||
def extrude(self, face: Any, direction: Vector3) -> Any: ...
|
||||
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
|
||||
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
|
||||
@@ -238,7 +239,7 @@ def _targeted_extent_vector(
|
||||
except ValueError as error:
|
||||
message = str(error)
|
||||
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
|
||||
if condition == "up_to_surface":
|
||||
if condition in {"up_to_surface", "through_next"}:
|
||||
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
|
||||
# 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体
|
||||
# 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。
|
||||
@@ -246,6 +247,7 @@ def _targeted_extent_vector(
|
||||
# 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都
|
||||
# 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛
|
||||
# "not reached",保持显式拒绝。
|
||||
# through_next 从当前主体中选取实际命中的下一张面;
|
||||
# up_to_vertex/up_to_body/offset_from_surface 无 face 可构造
|
||||
# 裁剪体层,仍保持显式拒绝。
|
||||
return ExtentVector(vector_scale(direction, 1.0), trim_to=target)
|
||||
@@ -273,7 +275,7 @@ def _side_extent_vectors(
|
||||
) -> list[ExtentVector]:
|
||||
"""Resolve one directional extent without borrowing the opposite side.
|
||||
|
||||
``extrude_add_two_sided`` calls this once for each independently captured
|
||||
``extrude_add_two_sided`` and ``extrude_cut_two_sided`` call this once for each independently captured
|
||||
termination. The regular one-sided executor also uses it for all simple
|
||||
termination modes, keeping the geometry adapter interface uniform.
|
||||
"""
|
||||
@@ -321,7 +323,7 @@ def _extent_vectors(
|
||||
end_condition = params.get("end_condition") or {"type": "blind"}
|
||||
condition = end_condition.get("type", "blind")
|
||||
distance = abs(float(params.get("distance_mm") or 0.0))
|
||||
if node.atomic_id == "extrude_add_two_sided":
|
||||
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}:
|
||||
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
|
||||
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
|
||||
if reverse_distance <= 0:
|
||||
@@ -459,6 +461,21 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
|
||||
return session.result(node)
|
||||
|
||||
|
||||
def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||||
# 放样截面不占用 feature.sketch_id;按有序 profile_sketch_ids 取已解析
|
||||
# 草图,并由 adapter 统一校验单闭环、无内环等内核输入约束。
|
||||
profile_ids = node.params.get("profile_sketch_ids") or []
|
||||
profiles: list[dict[str, Any]] = []
|
||||
for sketch_id in profile_ids:
|
||||
sketch = session.sketches.get(str(sketch_id))
|
||||
if sketch is None:
|
||||
raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved")
|
||||
profiles.append(sketch)
|
||||
solid = session.adapter.loft(profiles)
|
||||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||||
# 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。
|
||||
|
||||
@@ -782,6 +799,11 @@ def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any
|
||||
for point_key in ("start_mm", "end_mm", "center_mm"):
|
||||
if point_key in value:
|
||||
value[point_key] = [float(value[point_key][index]) + components[index] for index in range(3)]
|
||||
if "points_mm" in value:
|
||||
value["points_mm"] = [
|
||||
[float(point[index]) + components[index] for index in range(3)]
|
||||
for point in value["points_mm"]
|
||||
]
|
||||
for child in value.values():
|
||||
translate(child)
|
||||
elif isinstance(value, list):
|
||||
@@ -791,6 +813,30 @@ def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any
|
||||
return output
|
||||
|
||||
|
||||
def _transformed_loft_profiles(
|
||||
node: FeaturePlanNode,
|
||||
params: dict[str, Any],
|
||||
instance_id: str,
|
||||
session: ExecutionSession,
|
||||
transform: Callable[[dict[str, Any]], dict[str, Any]],
|
||||
) -> None:
|
||||
"""为 pattern replay 创建放样截面的变换副本。"""
|
||||
if node.atomic_id != "loft_add":
|
||||
return
|
||||
profile_ids = params.get("profile_sketch_ids") or []
|
||||
transformed_ids: list[str] = []
|
||||
for index, sketch_id in enumerate(profile_ids):
|
||||
source = session.sketches.get(str(sketch_id))
|
||||
if source is None:
|
||||
raise ValueError(f"loft profile sketch {sketch_id!r} has no replay definition")
|
||||
transformed_id = f"{instance_id}.profile.{index}"
|
||||
# 不复用原 profile:pattern 中的每个截面都必须与 source feature
|
||||
# 使用相同的平移、镜像或旋转,才能保持放样的真实空间位置。
|
||||
session.sketches[transformed_id] = transform(source)
|
||||
transformed_ids.append(transformed_id)
|
||||
params["profile_sketch_ids"] = transformed_ids
|
||||
|
||||
|
||||
def _owner_plane_frame(session: ExecutionSession, selector: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""解析 selector 的 owner 特征(reference_plane)注册的显式平面 frame。
|
||||
|
||||
@@ -831,6 +877,10 @@ def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, s
|
||||
# box_add/sphere_add 以世界坐标几何中心定位;平移重放必须随实例移动该中心,
|
||||
# 否则阵列副本会静默重合在原位置。
|
||||
params["center_mm"] = [float(center[index]) + components[index] for index in range(3)]
|
||||
_transformed_loft_profiles(
|
||||
node, params, instance_id, session,
|
||||
lambda sketch: _translated_sketch(sketch, offset),
|
||||
)
|
||||
mirror_plane = params.get("mirror_plane")
|
||||
if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror":
|
||||
# #6 pattern 引用重解析:镜像面是 reference_plane 引用,随实例平移
|
||||
@@ -938,6 +988,12 @@ def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]
|
||||
point = value.get(point_key)
|
||||
if isinstance(point, list) and len(point) == 2:
|
||||
value[point_key] = [float(point[0]), -float(point[1])]
|
||||
if isinstance(value.get("points"), list):
|
||||
value["points"] = [
|
||||
[float(point[0]), -float(point[1])]
|
||||
for point in value["points"]
|
||||
if isinstance(point, list) and len(point) == 2
|
||||
]
|
||||
for child in value.values():
|
||||
mirror_local_coordinates(child)
|
||||
elif isinstance(value, list):
|
||||
@@ -954,6 +1010,8 @@ def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]
|
||||
for point_key in ("start_mm", "end_mm", "center_mm"):
|
||||
if point_key in value:
|
||||
value[point_key] = _reflect_point(value[point_key], plane)
|
||||
if "points_mm" in value:
|
||||
value["points_mm"] = [_reflect_point(point, plane) for point in value["points_mm"]]
|
||||
if value.get("normal"):
|
||||
value["normal"] = _reflect_point(value["normal"], plane, vector=True)
|
||||
for child in value.values():
|
||||
@@ -1013,6 +1071,10 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, se
|
||||
# 世界轴对齐,跨坐标平面镜像后仍保持朝向(斜镜像面在 _execute_mirror_pattern
|
||||
# 中已被显式拒绝)。
|
||||
params["center_mm"] = _reflect_point(center, plane)
|
||||
_transformed_loft_profiles(
|
||||
node, params, instance_id, session,
|
||||
lambda sketch: _mirrored_sketch(sketch, plane),
|
||||
)
|
||||
mirror_plane = params.get("mirror_plane")
|
||||
if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror":
|
||||
# #6 pattern 引用重解析:镜像重放 mirror source 时,其镜像面引用
|
||||
@@ -1121,7 +1183,7 @@ def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) ->
|
||||
# 环形阵列实例的草图:工作平面 frame(原点为点、x/y/normal 为向量)绕轴旋转;
|
||||
# 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。与
|
||||
# _translated_sketch 对"世界坐标轮廓点"的处理对称,这里把 start/end/center
|
||||
# 世界坐标点绕轴旋转。
|
||||
# 世界坐标点和圆弧法向绕轴旋转。
|
||||
output = deepcopy(sketch)
|
||||
workplane = output.get("workplane") or {}
|
||||
if workplane.get("origin_mm"):
|
||||
@@ -1136,6 +1198,10 @@ def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) ->
|
||||
for point_key in ("start_mm", "end_mm", "center_mm"):
|
||||
if point_key in value:
|
||||
value[point_key] = _rotated_point(value[point_key], axis, angle_rad)
|
||||
if "points_mm" in value:
|
||||
value["points_mm"] = [_rotated_point(point, axis, angle_rad) for point in value["points_mm"]]
|
||||
if "normal" in value:
|
||||
value["normal"] = list(_rotated_vector(tuple(float(v) for v in value["normal"]), axis, angle_rad))
|
||||
for child in value.values():
|
||||
rotate(child)
|
||||
elif isinstance(value, list):
|
||||
@@ -1187,6 +1253,10 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle
|
||||
center = params.get("center_mm")
|
||||
if isinstance(center, list) and len(center) == 3:
|
||||
params["center_mm"] = _rotated_point(center, axis, angle_rad)
|
||||
_transformed_loft_profiles(
|
||||
node, params, instance_id, session,
|
||||
lambda sketch: _rotated_sketch(sketch, axis, angle_rad),
|
||||
)
|
||||
if node.atomic_id in {"pattern_mirror", "pattern_circular"}:
|
||||
# pattern 引用旋转重解析:镜像面 / 内层源随本实例一起旋转,否则嵌套
|
||||
# pattern 作为 circular source 时重放会退化成错误几何(见 _translated_node)。
|
||||
@@ -1300,6 +1370,11 @@ def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch:
|
||||
return _shape_from_primary(node, session, sketch=sketch)
|
||||
|
||||
|
||||
def _loft_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_loft_add(node, session)
|
||||
|
||||
|
||||
def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_reference_plane(node, session)
|
||||
@@ -1367,7 +1442,9 @@ EXECUTORS: dict[str, ExecutorFunction] = {
|
||||
"extrude_add_blind": _primary_executor,
|
||||
"extrude_add_two_sided": _primary_executor,
|
||||
"extrude_cut_blind": _primary_executor,
|
||||
"extrude_cut_two_sided": _primary_executor,
|
||||
"extrude_cut_through": _primary_executor,
|
||||
"loft_add": _loft_executor,
|
||||
"revolve_add": _primary_executor,
|
||||
"revolve_cut": _primary_executor,
|
||||
"hole_blind": _hole_executor,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Core CDSL sketch resolver.
|
||||
|
||||
The runtime accepts only direct geometric descriptions: circles, straight-edge
|
||||
polygons, and closed analytic line/arc/circle contours. Semantic shapes and
|
||||
historical profile macros belong to the importer compatibility layer and must
|
||||
be lowered before this module is invoked.
|
||||
polygons, and closed analytic line/arc/circle/B-spline contours. Semantic
|
||||
shapes and historical profile macros belong to the importer compatibility
|
||||
layer and must be lowered before this module is invoked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -81,6 +81,11 @@ def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
|
||||
if edge["type"] == "arc":
|
||||
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
|
||||
output["normal"] = list(normal)
|
||||
elif edge["type"] == "bspline":
|
||||
output["points_mm"] = [
|
||||
_to_3d(workplane, point[0], point[1])
|
||||
for point in edge["points_mm"]
|
||||
]
|
||||
transformed.append(output)
|
||||
return transformed
|
||||
|
||||
@@ -118,6 +123,8 @@ def _reverse(edge: _Ctx) -> _Ctx:
|
||||
output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"]
|
||||
if output.get("type") == "arc" and "clockwise" in output:
|
||||
output["clockwise"] = not bool(output["clockwise"])
|
||||
if output.get("type") == "bspline":
|
||||
output["points_mm"] = list(reversed(output["points_mm"]))
|
||||
return output
|
||||
|
||||
|
||||
@@ -176,7 +183,16 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
if kind == "circle":
|
||||
return _circle_edges(segment)
|
||||
if kind == "bspline":
|
||||
raise ValueError("analytic_contours: bspline requires an explicit approximation capability")
|
||||
points = segment.get("points") or []
|
||||
if len(points) < 3:
|
||||
raise ValueError("analytic_contours: bspline needs at least 3 interpolation points")
|
||||
converted = [_point(point) for point in points]
|
||||
return [{
|
||||
"type": "bspline",
|
||||
"start_mm": converted[0],
|
||||
"end_mm": converted[-1],
|
||||
"points_mm": converted,
|
||||
}]
|
||||
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
|
||||
|
||||
|
||||
@@ -185,6 +201,9 @@ def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
|
||||
for edge in edges:
|
||||
start = edge["start_mm"]
|
||||
points.append((float(start[0]), float(start[1])))
|
||||
if edge.get("type") == "bspline":
|
||||
points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1])
|
||||
continue
|
||||
if edge.get("type") != "arc":
|
||||
continue
|
||||
center, end = edge["center_mm"], edge["end_mm"]
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.cad_agent.adapters.author_guidance import FileAuthorGuidance # noqa: E402
|
||||
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator # noqa: E402
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError # noqa: E402
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState # noqa: E402
|
||||
|
||||
|
||||
GUIDANCE_ROOT = ROOT / "backend" / "agent" / "skills" / "cdsl-author-guidance"
|
||||
PROFILE = ROOT / "backend" / "engine" / "cdsl_engine" / "profile_schema.json"
|
||||
|
||||
|
||||
def atomic_ids() -> tuple[str, ...]:
|
||||
return tuple(json.loads(PROFILE.read_text(encoding="utf-8"))["operation_contracts"])
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self, state: TaskState) -> None:
|
||||
self.state = state
|
||||
self.usage_records: list[dict] = []
|
||||
|
||||
def get_state(self, _task_id: str) -> TaskState:
|
||||
return self.state
|
||||
|
||||
def ledger_events(self, _task_id: str) -> list[dict]:
|
||||
return []
|
||||
|
||||
def record_usage(self, _task_id: str, payload: dict) -> None:
|
||||
self.usage_records.append(payload)
|
||||
|
||||
def record_tool_audit(self, _task_id: str, _payload: dict) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _Artifacts:
|
||||
def read_source_requirements(self, _task_id: str) -> str:
|
||||
return "Create a symmetric mounting plate."
|
||||
|
||||
def read_json(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Runtime:
|
||||
def supported_atomic_ids(self) -> tuple[str, ...]:
|
||||
return atomic_ids()
|
||||
|
||||
|
||||
class AuthorGuidanceTests(unittest.TestCase):
|
||||
def test_manifest_covers_every_runtime_atomic_and_keeps_coordinate_core_at_minimum_budget(self) -> None:
|
||||
guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=1_200)
|
||||
covered: set[str] = set()
|
||||
for atomic_id in atomic_ids():
|
||||
selection = guidance.select(
|
||||
phase=TaskPhase.FEATURE_PENDING,
|
||||
atomic_id=atomic_id,
|
||||
repair_required=False,
|
||||
supported_atomic_ids=atomic_ids(),
|
||||
)
|
||||
self.assertTrue(selection.enabled, selection.fallback_reason)
|
||||
self.assertIn("00-author-contract", selection.section_ids)
|
||||
self.assertIn("03-coordinate-system-and-datums", selection.section_ids)
|
||||
self.assertLessEqual(len(selection.content), 1_200)
|
||||
self.assertIn("世界坐标", selection.content)
|
||||
covered.update(section_id for section_id in selection.section_ids if section_id.startswith("op-"))
|
||||
self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-loft", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere", "op-primitives", "op-thread"})
|
||||
|
||||
def test_phase_repair_and_budget_selection_are_stable(self) -> None:
|
||||
guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600)
|
||||
planning = guidance.select(
|
||||
phase=TaskPhase.COMPILING_FEATURE_PLAN,
|
||||
atomic_id="",
|
||||
repair_required=False,
|
||||
supported_atomic_ids=atomic_ids(),
|
||||
)
|
||||
repair = guidance.select(
|
||||
phase=TaskPhase.AWAITING_ACTION,
|
||||
atomic_id="fillet",
|
||||
repair_required=True,
|
||||
supported_atomic_ids=atomic_ids(),
|
||||
)
|
||||
self.assertEqual(planning.section_ids[:2], ("00-author-contract", "03-coordinate-system-and-datums"))
|
||||
self.assertIn("02-parameters-and-derived-dimensions", planning.section_ids)
|
||||
self.assertEqual(repair.section_ids[:3], ("00-author-contract", "03-coordinate-system-and-datums", "op-finish"))
|
||||
self.assertIn("10-repair-and-best-effort", repair.section_ids)
|
||||
|
||||
def test_disabled_missing_and_invalid_corpus_fall_back_without_authoring_failure(self) -> None:
|
||||
common = {
|
||||
"phase": TaskPhase.FEATURE_PENDING,
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"repair_required": False,
|
||||
"supported_atomic_ids": atomic_ids(),
|
||||
}
|
||||
self.assertEqual(FileAuthorGuidance(GUIDANCE_ROOT, enabled=False).select(**common).fallback_reason, "guidance_disabled")
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:FileNotFoundError")
|
||||
(root / "manifest.json").write_text("{}", encoding="utf-8")
|
||||
self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:ValueError")
|
||||
|
||||
def test_author_context_receives_guidance_but_keeps_the_existing_tool_instruction(self) -> None:
|
||||
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1)
|
||||
workflow = WorkflowCoordinator(
|
||||
WorkflowConfig(max_turns=8, format_error_limit=2),
|
||||
_Repository(state),
|
||||
_Artifacts(),
|
||||
_Runtime(),
|
||||
object(),
|
||||
object(),
|
||||
object(),
|
||||
object(),
|
||||
FileAuthorGuidance(GUIDANCE_ROOT),
|
||||
)
|
||||
messages, selection = workflow._author_context(state.task_id, [])
|
||||
system = str(messages[0]["content"])
|
||||
self.assertTrue(selection.enabled)
|
||||
self.assertIn("Use exactly one offered structured tool call", system)
|
||||
self.assertIn("Coordinate System And Datums", system)
|
||||
self.assertIn("世界坐标", system)
|
||||
|
||||
def test_invalid_author_tool_call_retains_guidance_usage_metadata(self) -> None:
|
||||
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1)
|
||||
repository = _Repository(state)
|
||||
class _Models:
|
||||
async def call_tool(self, **_kwargs: object) -> dict:
|
||||
return {"tool_calls": [], "usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4}}
|
||||
workflow = WorkflowCoordinator(
|
||||
WorkflowConfig(max_turns=8, format_error_limit=2),
|
||||
repository,
|
||||
_Artifacts(),
|
||||
_Runtime(),
|
||||
_Models(),
|
||||
object(),
|
||||
object(),
|
||||
object(),
|
||||
FileAuthorGuidance(GUIDANCE_ROOT),
|
||||
)
|
||||
tool = {"type": "function", "function": {"name": "write_requirements_document", "parameters": {"type": "object"}}}
|
||||
result = asyncio.run(workflow._author_turn(state.task_id, ModelIdentity("provider", "model"), [tool], []))
|
||||
self.assertIsInstance(result, WorkflowError)
|
||||
self.assertEqual(result.code, ErrorCode.AUTHOR_FORMAT_INVALID)
|
||||
self.assertEqual(repository.usage_records[0]["guidance_enabled"], True)
|
||||
self.assertIn("03-coordinate-system-and-datums", repository.usage_records[0]["guidance_section_ids"])
|
||||
self.assertEqual(repository.usage_records[0]["retry_reason"], "invalid_tool_call")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from engine.cdsl_engine.runtime import rebuild_cdsl
|
||||
|
||||
|
||||
def _spline_profile(sketch_id: str, z: float) -> dict:
|
||||
return {
|
||||
"id": sketch_id,
|
||||
"workplane": {"origin_mm": [0, 0, z], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
|
||||
"profile": {
|
||||
"type": "analytic_contours",
|
||||
"contours": [{
|
||||
"role": "outer",
|
||||
"closed": True,
|
||||
"segments": [{
|
||||
"type": "bspline",
|
||||
"start": [0, 0],
|
||||
"end": [0, 0],
|
||||
"points": [[0, 0], [20, 0], [25, 10], [15, 18], [0, 10], [0, 0]],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class LoftGeometryTests(unittest.TestCase):
|
||||
def test_open_bspline_segment_can_close_with_other_contour_edges(self):
|
||||
cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.1.0",
|
||||
"kind": "part",
|
||||
"part_id": "open-bspline-contour",
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "profile",
|
||||
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
|
||||
"profile": {"type": "analytic_contours", "contours": [{
|
||||
"role": "outer", "closed": True,
|
||||
"segments": [
|
||||
{"type": "line", "start": [0, 0], "end": [0, 10]},
|
||||
{
|
||||
"type": "bspline", "start": [0, 10], "end": [0, 0],
|
||||
"points": [[0, 10], [4, 10], [4, 0], [0, 0]],
|
||||
},
|
||||
],
|
||||
}]},
|
||||
}]},
|
||||
"features": [{
|
||||
"id": "add", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"sketch_id": "profile", "params": {"distance_mm": 5},
|
||||
}],
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "open-bspline-contour.step")
|
||||
self.assertGreater(rebuilt["volume_mm3"], 1.0)
|
||||
|
||||
def test_bspline_loft_and_mirror_replay_produce_step(self):
|
||||
cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.1.0",
|
||||
"kind": "part",
|
||||
"part_id": "bspline-loft-mirror",
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [_spline_profile("lower", 0), _spline_profile("upper", 20)]},
|
||||
"features": [
|
||||
{
|
||||
"id": "mirror_plane",
|
||||
"atomic_id": "reference_plane",
|
||||
"depends_on": [],
|
||||
"params": {"plane": {"origin_mm": [35, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}},
|
||||
"execution_status": "supported",
|
||||
},
|
||||
{
|
||||
"id": "loft",
|
||||
"atomic_id": "loft_add",
|
||||
"depends_on": [],
|
||||
"params": {"profile_sketch_ids": ["lower", "upper"]},
|
||||
"execution_status": "supported",
|
||||
},
|
||||
{
|
||||
"id": "mirror",
|
||||
"atomic_id": "pattern_mirror",
|
||||
"depends_on": ["mirror_plane", "loft"],
|
||||
"params": {
|
||||
"source_feature_ids": ["loft"],
|
||||
"mirror_plane": {
|
||||
"kind": "plane",
|
||||
"owner_feature_id": "mirror_plane",
|
||||
"stable_id": "mirror-plane",
|
||||
"source": "runtime_snapshot",
|
||||
"confidence": 1.0,
|
||||
},
|
||||
},
|
||||
"selectors": [{
|
||||
"kind": "plane",
|
||||
"owner_feature_id": "mirror_plane",
|
||||
"stable_id": "mirror-plane",
|
||||
"source": "runtime_snapshot",
|
||||
"confidence": 1.0,
|
||||
}],
|
||||
"execution_status": "supported",
|
||||
},
|
||||
],
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "loft.step")
|
||||
self.assertGreater(rebuilt["volume_mm3"], 1.0)
|
||||
self.assertEqual(rebuilt["solid_count"], 2)
|
||||
self.assertEqual([item["feature_id"] for item in rebuilt["feature_results"]], ["mirror_plane", "loft", "mirror.m.loft", "mirror"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -316,6 +316,55 @@ class PatternTransformContractTests(unittest.TestCase):
|
||||
|
||||
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 72 + 24, places=5)
|
||||
|
||||
def test_circular_pattern_rotates_analytic_arc_normals_with_the_sketch(self) -> None:
|
||||
"""倾斜圆草图的环形阵列必须保持每条圆弧处于旋转后的工作平面。
|
||||
|
||||
``analytic_contours`` 在运行时会预先展开为世界坐标的圆弧边。环形阵列
|
||||
不能只旋转边端点和圆心:Build123d 依据 edge.normal 计算三点圆弧中点,
|
||||
法向遗留在源草图平面会让中点偏离 wire 平面,最终报
|
||||
``Cannot build face(s): wires not planar``。该 fixture 使用绕 Z 轴的
|
||||
垂直圆草图,保证每个实例都实际变换该法向。
|
||||
"""
|
||||
cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "circular-arc-normal", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [
|
||||
{
|
||||
"id": "base", "workplane": _workplane(origin=[0, 0, 0], normal=[0, 0, 1]),
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
|
||||
},
|
||||
{
|
||||
"id": "boss", "workplane": _workplane(origin=[0, -10, 0], normal=[0, 1, 0]),
|
||||
"profile": {"type": "analytic_contours", "contours": [{
|
||||
"role": "outer", "closed": True,
|
||||
"segments": [{"type": "circle", "center": [0, -5], "radius_mm": 1}],
|
||||
}]},
|
||||
},
|
||||
]},
|
||||
"features": [
|
||||
{
|
||||
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"sketch_id": "base", "params": {"distance_mm": 10},
|
||||
},
|
||||
{
|
||||
"id": "boss_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
|
||||
"sketch_id": "boss", "params": {"distance_mm": 2},
|
||||
},
|
||||
{
|
||||
"id": "repeat", "atomic_id": "pattern_circular", "depends_on": ["boss_add"],
|
||||
"params": {
|
||||
"source_feature_ids": ["boss_add"],
|
||||
"axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]},
|
||||
"pattern_count": 4, "sweep_angle_deg": 360,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
rebuilt = rebuild_cdsl(cdsl, Path(directory) / "circular-arc-normal.step")
|
||||
self.assertGreater(rebuilt["volume_mm3"], math.pi * 1000)
|
||||
self.assertEqual(rebuilt["solid_count"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -918,6 +918,25 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
analysis = analyze_cdsl(cdsl)
|
||||
self.assertIn("missing_parameter", [item.code for item in analysis.feature_results[0].blockers])
|
||||
|
||||
def test_two_sided_cut_uses_independent_forward_and_reverse_distances(self) -> None:
|
||||
from cdsl_engine.runtime import rebuild_cdsl
|
||||
|
||||
cdsl = self._base_block()
|
||||
cdsl["geometry"]["sketches"].append({
|
||||
"id": "cut", "workplane": {**_workplane(), "origin_mm": [0, 0, 5]},
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 1},
|
||||
})
|
||||
cdsl["features"].append({
|
||||
"id": "cut_1", "atomic_id": "extrude_cut_two_sided", "depends_on": ["base_add"],
|
||||
"params": {
|
||||
"distance_mm": 3, "reverse_distance_mm": 5,
|
||||
"end_condition": {"type": "blind"}, "reverse_end_condition": {"type": "blind"},
|
||||
}, "sketch_id": "cut",
|
||||
})
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
result = rebuild_cdsl(cdsl, Path(directory) / "two-sided-cut.step")
|
||||
self.assertAlmostEqual(result["volume_mm3"], 1000.0 - 8.0 * 3.141592653589793, places=5)
|
||||
|
||||
def test_revolve_can_resolve_an_owner_qualified_reference_axis(self) -> None:
|
||||
from cdsl_engine.runtime import rebuild_cdsl
|
||||
|
||||
@@ -1038,6 +1057,22 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
result = rebuild_cdsl(cdsl, root / f"{name}.step")
|
||||
self.assertAlmostEqual(result["volume_mm3"], 1000 - expected_depth * 3.141592653589793, places=5)
|
||||
|
||||
def test_through_next_trims_a_partially_overlapping_profile_to_the_next_body_face(self) -> None:
|
||||
from cdsl_engine.runtime import rebuild_cdsl
|
||||
|
||||
base = self._base_block()
|
||||
base["geometry"]["sketches"].append({
|
||||
"id": "partial", "workplane": {"origin_mm": [0, 0, 12], "x_dir": [1, 0, 0], "normal": [0, 0, -1]},
|
||||
"profile": {"type": "polygon", "vertices": [[3, -3], [9, -3], [9, 3], [3, 3]]},
|
||||
})
|
||||
base["features"].append({
|
||||
"id": "partial_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
|
||||
"sketch_id": "partial", "params": {"distance_mm": 0, "end_condition": {"type": "through_next"}},
|
||||
})
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
result = rebuild_cdsl(base, Path(directory) / "partial-through-next.step")
|
||||
self.assertAlmostEqual(result["volume_mm3"], 1000 + 24, places=5)
|
||||
|
||||
def test_up_to_body_extent_uses_a_uniquely_resolved_body_record(self) -> None:
|
||||
from cdsl_engine.runtime import rebuild_cdsl
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.cad_agent.evals.live import _fixture, compare_guidance_reports # noqa: E402
|
||||
|
||||
|
||||
def _result(*, scenario: str, repetition: int, author_calls: int, context_chars: int, schema_rejections: int, failure_layer: str = "") -> dict:
|
||||
return {
|
||||
"scenario": scenario,
|
||||
"repetition": repetition,
|
||||
"outcome": "passed",
|
||||
"revision_ids": ["revision_001"],
|
||||
"projection": {"phase": "COMPLETED"},
|
||||
"checks": {"deterministic_claims_pass": True},
|
||||
"schema_rejection_count": schema_rejections,
|
||||
"failure_attribution": {"layer": failure_layer} if failure_layer else None,
|
||||
"scenario_budget": {"max_author_turns": 8, "max_reviewer_turns": 2, "max_total_calls": 10, "max_total_tokens": 1000},
|
||||
"usage": {"records": [{"context_chars": context_chars} for _ in range(author_calls)]},
|
||||
}
|
||||
|
||||
|
||||
def _report(results: list[dict]) -> dict:
|
||||
return {
|
||||
"author": {"provider": "author", "model": "model"},
|
||||
"reviewer": {"provider": "reviewer", "model": "review"},
|
||||
"runtime_profile_sha256": "a" * 64,
|
||||
"operation_contracts": [{"atomic_id": "extrude_add_blind", "contract_hash": "b" * 64}],
|
||||
"author_guidance": {"enabled": False, "max_chars": 3600},
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
class LiveGuidanceComparisonTests(unittest.TestCase):
|
||||
def test_fixture_accepts_multiple_stable_scenarios_in_fixture_order(self) -> None:
|
||||
selected = _fixture("comprehensive", ["l_bracket", "circular_flange_pcd"])
|
||||
self.assertEqual([item["id"] for item in selected], ["circular_flange_pcd", "l_bracket"])
|
||||
|
||||
def test_comparison_enforces_matched_budget_and_quality_gates(self) -> None:
|
||||
control = _report([
|
||||
_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=2, failure_layer="cdsl_expression"),
|
||||
_result(scenario="part_a", repetition=2, author_calls=10, context_chars=1000, schema_rejections=1),
|
||||
])
|
||||
treatment = _report([
|
||||
_result(scenario="part_a", repetition=1, author_calls=11, context_chars=1300, schema_rejections=0),
|
||||
_result(scenario="part_a", repetition=2, author_calls=11, context_chars=1300, schema_rejections=0),
|
||||
])
|
||||
treatment["author_guidance"]["enabled"] = True
|
||||
comparison = compare_guidance_reports(control, treatment)
|
||||
self.assertEqual(comparison["status"], "passed")
|
||||
self.assertTrue(comparison["gates"]["median_author_calls_within_ten_percent"])
|
||||
self.assertTrue(comparison["gates"]["model_or_cdsl_failure_improved"])
|
||||
|
||||
def test_comparison_excludes_explicit_unsupported_runtime_capability(self) -> None:
|
||||
control = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=1)])
|
||||
treatment = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1200, schema_rejections=0)])
|
||||
treatment["author_guidance"]["enabled"] = True
|
||||
treatment["results"][0]["ledger"] = [{"operation_failures": [{"message": "unsupported_draft"}]}]
|
||||
comparison = compare_guidance_reports(control, treatment)
|
||||
self.assertEqual(comparison["treatment"]["eligible_runs"], 0)
|
||||
self.assertEqual(comparison["excluded_capability_gaps"]["treatment"], [{"scenario": "part_a", "repetition": 1}])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.cad_agent.adapters.structured_llm import StructuredModelError, StructuredModelGateway # noqa: E402
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
|
||||
|
||||
|
||||
def _settings(*, api_style: str = "responses", reasoning_effort: str = "medium") -> Settings:
|
||||
provider = ProviderConfig(
|
||||
"provider", "Provider", "https://example.invalid/v1", "key",
|
||||
(ProviderModel("model"),), reasoning_effort=reasoning_effort, api_style=api_style,
|
||||
)
|
||||
return Settings(
|
||||
task_root=ROOT / "tmp-tasks",
|
||||
conversation_root=ROOT / "tmp-conversations",
|
||||
library_root=ROOT / "backend" / "cdsl_library",
|
||||
engine_root=ROOT / "backend" / "engine" / "cdsl_engine",
|
||||
llm_base_url=provider.base_url,
|
||||
llm_api_key=provider.api_key,
|
||||
llm_model="model",
|
||||
llm_timeout_s=1,
|
||||
default_provider_id="provider",
|
||||
providers=(provider,),
|
||||
)
|
||||
|
||||
|
||||
def _tool() -> dict:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_document",
|
||||
"description": "Write one document.",
|
||||
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _response(api_style: str) -> dict:
|
||||
if api_style == "responses":
|
||||
return {
|
||||
"output": [{"type": "function_call", "name": "write_document", "arguments": "{}"}],
|
||||
"usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8},
|
||||
}
|
||||
return {
|
||||
"choices": [{"message": {"tool_calls": [{"function": {"name": "write_document", "arguments": "{}"}}]}}],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
|
||||
}
|
||||
|
||||
|
||||
class StructuredModelGatewayCompatibilityTests(unittest.TestCase):
|
||||
def test_thinking_tool_choice_retries_without_reasoning_before_relaxing_tool_choice(self) -> None:
|
||||
gateway = StructuredModelGateway(_settings())
|
||||
payloads: list[dict] = []
|
||||
|
||||
async def request(_provider: object, payload: dict) -> dict:
|
||||
payloads.append(payload)
|
||||
if len(payloads) == 1:
|
||||
raise StructuredModelError("Provider rejected structured request (400): Thinking mode does not support this tool_choice")
|
||||
return _response("responses")
|
||||
|
||||
gateway._request = request # type: ignore[method-assign]
|
||||
result = asyncio.run(gateway.call_tool(
|
||||
messages=[{"role": "system", "content": "Call the tool."}],
|
||||
tool=_tool(), provider_id="provider", model_id="model", required_tool_name="write_document",
|
||||
))
|
||||
|
||||
self.assertEqual(len(payloads), 2)
|
||||
self.assertEqual(payloads[0]["tool_choice"], {"type": "function", "name": "write_document"})
|
||||
self.assertEqual(payloads[0]["reasoning"], {"effort": "medium"})
|
||||
self.assertEqual(payloads[1]["tool_choice"], {"type": "function", "name": "write_document"})
|
||||
self.assertNotIn("reasoning", payloads[1])
|
||||
self.assertEqual(result["usage"]["structured_compatibility_mode"], "reasoning_disabled")
|
||||
|
||||
def test_persistent_thinking_rejection_uses_auto_with_the_same_single_tool(self) -> None:
|
||||
gateway = StructuredModelGateway(_settings(api_style="chat_completions", reasoning_effort=""))
|
||||
payloads: list[dict] = []
|
||||
|
||||
async def request(_provider: object, payload: dict) -> dict:
|
||||
payloads.append(payload)
|
||||
if len(payloads) < 3:
|
||||
raise StructuredModelError("Thinking mode does not support this tool_choice")
|
||||
return _response("chat_completions")
|
||||
|
||||
gateway._request = request # type: ignore[method-assign]
|
||||
result = asyncio.run(gateway.call_tool(
|
||||
messages=[{"role": "system", "content": "Call the tool."}],
|
||||
tool=_tool(), provider_id="provider", model_id="model", required_tool_name="write_document",
|
||||
))
|
||||
|
||||
self.assertEqual(len(payloads), 3)
|
||||
self.assertEqual(payloads[0]["tool_choice"]["function"]["name"], "write_document")
|
||||
self.assertEqual(payloads[1]["tool_choice"]["function"]["name"], "write_document")
|
||||
self.assertEqual(payloads[2]["tool_choice"], "auto")
|
||||
self.assertEqual(len(payloads[2]["tools"]), 1)
|
||||
self.assertEqual(result["usage"]["structured_compatibility_mode"], "single_tool_auto")
|
||||
|
||||
def test_unrelated_provider_rejection_is_not_retried(self) -> None:
|
||||
gateway = StructuredModelGateway(_settings())
|
||||
payloads: list[dict] = []
|
||||
|
||||
async def request(_provider: object, payload: dict) -> dict:
|
||||
payloads.append(payload)
|
||||
raise StructuredModelError("Provider rejected structured request (400): invalid model")
|
||||
|
||||
gateway._request = request # type: ignore[method-assign]
|
||||
with self.assertRaisesRegex(StructuredModelError, "invalid model"):
|
||||
asyncio.run(gateway.call_tool(
|
||||
messages=[{"role": "system", "content": "Call the tool."}],
|
||||
tool=_tool(), provider_id="provider", model_id="model", required_tool_name="write_document",
|
||||
))
|
||||
self.assertEqual(len(payloads), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user