Compare commits
11 Commits
cf9cd0366a
...
ganjihong
| Author | SHA1 | Date | |
|---|---|---|---|
| dbc18429bb | |||
| 273b4b5c7b | |||
| 09a25cdc6b | |||
| 48cec45fee | |||
| de21ee8a5b | |||
| c6d3bfddec | |||
| 994d06aaea | |||
| adafac9f08 | |||
| d8fc2a9207 | |||
| 21a5185926 | |||
| 387b99e255 |
@@ -32,8 +32,10 @@ build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Runtime data and generated local artifacts
|
||||
output/
|
||||
backend/data/
|
||||
backend/live-evals/
|
||||
cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md
|
||||
data/cadfs-sample/
|
||||
json_to_cdsl/input/
|
||||
onshape_to_cdsl/input/
|
||||
|
||||
@@ -29,3 +29,104 @@
|
||||
## 改动评审指引
|
||||
|
||||
修改本系统时,优先保证执行确定性、部分结果可持久化、失败可诊断,以及终止行为有边界。避免加入语义审查关卡、要求复制 ID 的协议,或会在没有改善可执行模型的情况下无限消耗调用的重试机制。
|
||||
|
||||
## CADFS 全量能力开发
|
||||
|
||||
CADFS 到 CDSL 到 STEP 的工作是在实现通用绘图引擎和通用 converter,不是为回归样本
|
||||
编写修复脚本。能力实现必须以 FeatureScript 语义、显式 CDSL contract、body
|
||||
生命周期和内核拓扑为边界,并对同类输入普遍成立。
|
||||
|
||||
- 禁止按 `sample_id`、路径、特定 feature ID、特定坐标、尺寸或 gold STEP 测量值分支;
|
||||
禁止从原 STEP 回填 FeatureScript 未提供的参数,或为某模型硬编码 selector、profile、
|
||||
偏移量、布尔策略和默认尺寸。
|
||||
- 不得将不支持或不唯一的语义伪装成盲拉伸、当前 body、默认 union、任意相近拓扑元素
|
||||
或静默跳过。必须保留最佳可执行前缀和 STEP,并给出稳定、可归因的能力诊断。
|
||||
- 优先在 schema、semantic validation、runtime state、body graph 和 adapter 的
|
||||
kernel-level topology delta 中实现能力;lowering 层只能表达源语义,不能承担样本化
|
||||
的几何补丁。受限算法必须有通用、可验证的适用条件和拒绝路径。
|
||||
- 一项能力只有在 lowering、CDSL contract、runtime/adapter、selector/body 语义、
|
||||
单元测试和多个真实语料回归均具备后才能标为完成。操作名称覆盖不等于参数、拓扑来源、
|
||||
几何类型或 body 生命周期覆盖。
|
||||
- 严格比较用于诊断;工程验收遵循 `rp.passed`。不得降低 RP 阈值、关闭诊断、跳过
|
||||
feature 或修改 source 参数来换取通过。source STEP 损坏或历史/STEP 精度不一致必须
|
||||
作为有证据的 source exception 单独报告。
|
||||
- 每次涉及 CADFS lowering、CDSL schema、runtime、adapter、selector 或测试的改动,
|
||||
都必须同步更新 `cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`;全量能力
|
||||
计划以 `cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md` 为准。
|
||||
|
||||
## CADFS 修改、文档与回归纪律
|
||||
|
||||
以下规则适用于每一次 CADFS converter、CDSL engine、selector、比较器、回归集或相关
|
||||
测试的修改;它们是仓库规则,不依赖当前对话上下文。
|
||||
|
||||
### 修改前与实现方式
|
||||
|
||||
- 先用完整 FeatureScript history、`candidate.cdsl.json`、`bound.cdsl.json`、
|
||||
`diagnostics.json`、`rebuild.json`、`comparison.json` 和 source/rebuild 工件定位失败层:
|
||||
converter/lowering、schema/semantic validation、selector binding、runtime/adapter、
|
||||
source STEP/FeatureScript 不一致,或 comparison infrastructure。没有证据不得把失败
|
||||
归因于任一层,也不得先改阈值或样本数据。
|
||||
- 修改必须遵循目标模块已有的命名、分层、格式、注释语言和错误处理模式。优先扩展既有
|
||||
contract、helper 和架构边界;没有明确收益时不得进行无关重构或引入平行实现。
|
||||
- 先实现可复用的几何和拓扑语义,再将 CADFS source lower 到该 contract。任何只为单个
|
||||
形状、数值、feature history 或截图成立的逻辑均视为缺陷,不得提交。
|
||||
- 内核无法完成某个输入时,保留有界的失败和诊断;不得用较小的 dress-up 半径、替代孔型、
|
||||
固定 extent、隐式 fuse 或未经来源证明的几何补偿伪造结果。
|
||||
- 当前实现若被证明确实违反 source/contract 语义、无法表达全量已出现的通用输入、
|
||||
受内核 API 的结构性限制,或反复需要样本化补丁,应停止继续叠加补丁并评估替代方案。
|
||||
替换前必须具备可复现失败、根因证据、与成熟开源/官方 API 或最小原型的对照、对
|
||||
CDSL/body/selector 兼容性的影响评估,以及受影响回归的迁移计划。
|
||||
- 不得因单个样本、偶发 OCC 失败、一次性能波动或主观偏好轻易重写成熟路径。只有新方案
|
||||
能以更少特例、更完整的通用语义和可验证的回归证据解决结构性问题时,才替换旧方案;
|
||||
替换过程中保留旧工件和比较基线,分阶段迁移并记录回滚边界。
|
||||
|
||||
### 文档同步
|
||||
|
||||
- 每次实现、修复、扩展或确认某项 CADFS 能力后,必须在同一工作变更中更新
|
||||
`cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`:记录能力状态、通用语义、
|
||||
已验证边界、未覆盖边界、受影响样本/能力矩阵和测试/比较证据;完成项必须勾选,
|
||||
未完成项不得因单个样本通过而勾选。
|
||||
- 上述 `.local.md` 是本地工作台账,必须保持在 `.gitignore` 中,绝不加入 Git 提交。
|
||||
每次 CADFS 相关代码变更后都要同步,即使最终只得到失败诊断或发现 source exception。
|
||||
- 新增能力、能力范围、验收口径、全量快照或实施优先级变化时,同步更新受版本控制的
|
||||
`cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md`。它定义全量能力矩阵和路线;
|
||||
`CADFS_RECONSTRUCTION_TARGET.md` 记录核心回归与当前近期目标,两者不得冲突。
|
||||
- 从全量 output 发现新的操作、参数变体、拓扑来源、body lifecycle 或 comparison failure
|
||||
类型时,必须先登记为待办和能力矩阵条目,再选择多个真实样本回归;不得只加入一个
|
||||
“代表模型”就宣称覆盖完成。
|
||||
- 文档中的计数、样本 ID、状态和通过标准必须来自当前工件。必须区分 strict、RP 工程相似、
|
||||
几何拒绝、可执行前缀、转换/运行时失败、比较超时和 source exception。
|
||||
|
||||
### 回归与可视证据
|
||||
|
||||
- 每次代码修改至少运行受影响的原子/单元测试和真实语料样本;涉及共享 runtime、
|
||||
selector、body lifecycle 或 comparison 时,还必须运行核心 17、相应扩展能力矩阵和
|
||||
可控的全量 shard。报告未运行的范围和原因,不能将缓存旧工件当作新代码的证据。
|
||||
- 对用户要求查看或人工判定的样本,生成并保留 source STEP 与 rebuild STEP 的一致视角
|
||||
对比截图;截图只辅助人工审查,最终分类仍以完整 history、B-rep 比较和诊断为准。
|
||||
- 任何失败都保留最后可执行 STEP、GLB(如可生成)、comparison/diagnostic 工件和前缀
|
||||
信息。不可执行或不相似不允许清理、覆盖或隐藏已有可用工件。
|
||||
- 提交前执行与改动比例相称的测试、`git diff --check`,并确认本地能力台账未被 staged。
|
||||
除非用户明确要求,不得提交、push、覆盖用户未提交修改或变更回归基线。
|
||||
|
||||
### 开源实现与 SimpleCADAPI 参考
|
||||
|
||||
- 在自行设计 shell、sweep、loft、boolean、fillet/chamfer、transform、topology tracking、
|
||||
body graph 或比较基础设施前,必须先检索成熟的开源实现、官方 OCCT/OCP API 和已有
|
||||
项目依赖;优先复用经过测试的算法、内核调用模式或小范围实现,而不是重新发明基础
|
||||
B-rep 算法。外部代码的许可证、版本兼容性、异常语义和维护状态必须先核实。
|
||||
- 本地首选参考是 `/Users/lk/Downloads/SimpleCADAPI-master 4`。重点阅读其
|
||||
`src/simplecadapi/topology/tracking.py`、`kernel/ocp_booleans.py`、
|
||||
`kernel/ocp_topology.py`、`kernel/ocp_transforms.py` 及对应 tests/docs:其中的
|
||||
OCC builder history、`Modified`/`Generated`/`IsDeleted`、section edges、same-domain
|
||||
cleanup、显式 shape transform、shell/sweep/loft 调用边界是当前 engine 的优先参考。
|
||||
- 借鉴必须经过本项目的 CDSL schema、runtime body graph、selector provenance 和
|
||||
回归测试适配。不得直接替换本项目的 CDSL contract,也不得照搬其“强制单一 Solid”
|
||||
的 union/cut 语义,因为 CADFS 需要保留独立 body、copy、keep tools 和 pattern
|
||||
instance 生命周期。
|
||||
- 每次因开源参考新增或调整能力时,在本地能力台账记录参考来源、采用的通用语义、
|
||||
未采用部分及理由、许可证/依赖影响和本项目回归证据。无法安全采用时,也应记录
|
||||
评估结论,避免后续重复实现或重复调研。
|
||||
- 发现现有方案方向错误或外部方案明显更适合时,可替换而不是继续打补丁;但必须满足
|
||||
“修改前与实现方式”中的结构性根因和对照验证门槛,并在台账记录替换理由、迁移影响、
|
||||
保留的 contract、回归结果和可回滚边界。
|
||||
|
||||
+1
-14
@@ -2,7 +2,7 @@
|
||||
# CDSL_DEFAULT_PROVIDER=deepseek
|
||||
# CDSL_DEFAULT_MODEL=deepseek-v4-flash
|
||||
CDSL_DEFAULT_PROVIDER=openai
|
||||
CDSL_DEFAULT_MODEL=gpt-5.4-mini
|
||||
CDSL_DEFAULT_MODEL=gpt-5.5
|
||||
|
||||
# DeepSeek. Fill in your own API key below.
|
||||
CDSL_LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
@@ -11,9 +11,6 @@ CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp
|
||||
CDSL_LLM_TIMEOUT_S=90
|
||||
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
|
||||
|
||||
# Final autonomous-task publication uses this independent vision reviewer.
|
||||
CDSL_REVIEW_PROVIDER=openai
|
||||
CDSL_REVIEW_MODEL=gpt-5.5
|
||||
|
||||
# Optional OpenAI provider. Comma-separate enabled models; list vision models
|
||||
# separately so image attachments can be routed safely.
|
||||
@@ -30,13 +27,3 @@ CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1
|
||||
CDSL_KIMI_API_KEY=
|
||||
CDSL_KIMI_MODELS=moonshot-v1-8k
|
||||
CDSL_KIMI_VISION_MODELS=
|
||||
|
||||
# Autonomous CDSL agent limits. These protect an active modelling head, not
|
||||
# the total feature count of a CAD task.
|
||||
CDSL_AGENT_TOOL_CALLS_PER_CYCLE=12
|
||||
CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD=3
|
||||
CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT=6
|
||||
CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT=3
|
||||
CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT=6
|
||||
CDSL_AGENT_CONTEXT_CHAR_LIMIT=14000
|
||||
CDSL_AGENT_RENDER_CACHE=true
|
||||
|
||||
+20
-37
@@ -20,53 +20,36 @@ CDSL_OPENAI_REASONING_EFFORT=medium
|
||||
```
|
||||
|
||||
Use `low`, `medium`, or `high` according to the latency/cost versus quality
|
||||
tradeoff. The setting is sent as Chat Completions' `reasoning_effort` field to
|
||||
authoring, streaming, and visual-review requests. Leave it empty to use the
|
||||
tradeoff. The setting is sent as the provider's `reasoning_effort` field to
|
||||
requirements analysis and Authoring CDSL generation. Leave it empty to use the
|
||||
provider/model default. The selected OpenAI-compatible endpoint must support
|
||||
the requested value.
|
||||
|
||||
## Autonomous CDSL Agent Configuration
|
||||
|
||||
The autonomous agent writes one frozen free-form `requirements.md`, then
|
||||
observes, measures, renders and appends one CDSL feature at a time. Its author
|
||||
uses normal function calls; no provider strict JSON Schema capability or
|
||||
complete modelling DAG is required. Candidate fragments are rebuilt in a
|
||||
staging directory through `cdsl_only` before a checkpoint can be committed.
|
||||
Each task has one bounded workflow:
|
||||
|
||||
Final publication requires a separately configured vision-capable review model
|
||||
and the Python OpenCascade/Pillow technical renderer. The agent may build and
|
||||
inspect intermediate checkpoints without image review; a final run fails
|
||||
closed if its independent review configuration is unavailable.
|
||||
```text
|
||||
request analysis -> complete cad.author.v1 -> server compilation -> runtime build
|
||||
-> at most two complete repairs -> final or best-effort publication
|
||||
```
|
||||
|
||||
The model outputs only local body/feature names and declarative selectors.
|
||||
The server validates strict schemas, allocates Runtime CDSL identities,
|
||||
compiles references, executes dependencies, and preserves the last executable
|
||||
prefix. STEP is the primary artifact; GLB and the CPU-only OpenCascade/Pillow
|
||||
render bundle are generated from the same published revision.
|
||||
|
||||
```dotenv
|
||||
# Must name one configured provider and one model listed in that provider's
|
||||
# CDSL_<PROVIDER>_VISION_MODELS setting. It is intentionally not inferred
|
||||
# from the authoring model.
|
||||
CDSL_REVIEW_PROVIDER=deepseek
|
||||
CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp
|
||||
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
|
||||
|
||||
# Install Python rendering dependencies. The renderer reads the revision STEP
|
||||
# file and creates canonical images without a browser or GPU driver.
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Limits apply to the current checkpoint head, never to total task complexity.
|
||||
CDSL_AGENT_TOOL_CALLS_PER_CYCLE=12
|
||||
CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD=3
|
||||
CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT=6
|
||||
CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT=6
|
||||
CDSL_AGENT_CONTEXT_CHAR_LIMIT=24000
|
||||
CDSL_AGENT_RENDER_CACHE=true
|
||||
```
|
||||
|
||||
The author chooses each coherent 1-6 feature batch. Every rebuilt batch is
|
||||
rendered and independently reviewed before it can become a checkpoint; only
|
||||
an accepted reviewer verdict advances the working model. Every checkpoint is rebuilt from its fully materialized CDSL through the
|
||||
`cdsl_only` runtime. Checkpoint GLB files are preview-only; STEP, CDSL, and
|
||||
reports are available only after the task reaches `COMPLETED`.
|
||||
|
||||
The backend assigns feature and sketch IDs, appends causal dependencies and
|
||||
expands only opaque current-snapshot selector tokens. It does not compile
|
||||
geometry templates or correct workplanes, profiles, sizes, directions or
|
||||
boolean semantics authored by the model. Failed candidates remain auditable
|
||||
but never become revisions.
|
||||
The initial generation plus two repairs are the only model calls allowed after
|
||||
requirements analysis. A repair returns a complete replacement Authoring CDSL
|
||||
and may only alter diagnosed features. Runtime selector ambiguity, missing
|
||||
selectors, and unavailable dependencies produce stable diagnostics rather than
|
||||
topology guesses. Requirement compliance is reported independently as
|
||||
`pass`, `fail`, `pending`, or `not_applicable`; a partial executable model is
|
||||
still published after the repair budget is exhausted.
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# Authoring CDSL Modeling Guide
|
||||
|
||||
Write one complete `cad.author.v1` document. It is declarative source for a
|
||||
server compiler, not the runtime CDSL and not an execution log. Return only
|
||||
the schema-valid object requested by the tool.
|
||||
|
||||
## Contract Boundary
|
||||
|
||||
Use lower-case local `name` values for bodies and features. They are symbols
|
||||
within this document only. Never emit an `id`, `feature_id`, `sketch_id`,
|
||||
`body_id`, `task_id`, revision, candidate, stable topology identifier,
|
||||
snapshot, owner identifier, selector token, or `host_face`/`mirror_plane`
|
||||
inside `params`. The server allocates identities and injects selector values
|
||||
into the destination stated by the operation contract.
|
||||
|
||||
Literal request language such as `max_z`, `min_z`, `host face`, or a face name
|
||||
is design intent, never a CDSL field or output role. Never emit `host_face`,
|
||||
`max_z`, `min_z`, `max_z_face`, `min_z_face`, `top_face`, or `bottom_face` in
|
||||
any JSON value, including `selector.source`. A selector is only `{kind,
|
||||
source, match}`, and `source` must use an exact role advertised by the producer
|
||||
operation.
|
||||
|
||||
Treat `requirements`, prior documents, and diagnostics as semantic input, not
|
||||
CDSL templates. Their keys are descriptive labels. Copy geometry, values, and
|
||||
relations only into fields allowed by the active schema and operation contract.
|
||||
Thus `host_face: "max_z"` conveys placement intent, never output strings;
|
||||
derive a selector from advertised roles or use explicit coordinates.
|
||||
|
||||
Use millimetres and a right-handed coordinate system unless the request says
|
||||
otherwise. Put every reasonable but unstated design choice in `assumptions`.
|
||||
Do not turn an assumed dimension into a deterministic acceptance target.
|
||||
|
||||
## Modeling Brief
|
||||
|
||||
Identify explicit dimensions, datums, directions, primary volumes, removals,
|
||||
repetitions, finish features, targets, and noncritical assumptions. Explicit
|
||||
dimensions take precedence over inferred proportions. Clarify only when an
|
||||
omitted interface, scale, safety, or compliance value prevents construction;
|
||||
otherwise record a practical default in `assumptions`.
|
||||
|
||||
## Construction Order
|
||||
|
||||
Choose the simplest supported construction whose parameters directly express
|
||||
the requested dimensions. Use a stable order:
|
||||
|
||||
1. establish the body and functional coordinate frame;
|
||||
2. create primary additive volume(s);
|
||||
3. create any selector-hosted feature before its named source output is
|
||||
changed by a fuse, cut, shell, pattern, or finishing operation;
|
||||
4. add remaining bosses, ribs, and other major additive geometry;
|
||||
5. make remaining pockets, bores, and through features;
|
||||
6. apply patterns, then fillets and chamfers last.
|
||||
|
||||
Every feature must list each feature it actually uses in `depends_on`.
|
||||
Prefer one complete profile-driven feature for a planar silhouette. Use
|
||||
primitives when their axis, radius, and height directly express the part.
|
||||
For through cuts, choose the operation's through extent and make the tool
|
||||
cross the material; never rely on coincident faces or a guessed nearby face.
|
||||
Delay dress-up operations because they can alter downstream topology.
|
||||
|
||||
## Operation Semantics
|
||||
|
||||
Apply the following rules from the supplied operation contracts. They describe
|
||||
the CDSL language itself and do not prescribe a particular part design.
|
||||
|
||||
### Hard Emission Boundaries
|
||||
|
||||
Do not emit `boolean_bodies` in `cad.author.v1`. Its body operand lifecycle is
|
||||
not expressed by document-local names, so it cannot provide a reliable
|
||||
model-facing construction path. Express additions with an additive operation
|
||||
whose declared result mode supports the intended connected result, and express
|
||||
removals with a cut operation and a sketch.
|
||||
|
||||
Prefer self-contained operations. An unavailable selector makes a hosted
|
||||
operation unavailable even if requested; use a selector-free construction that
|
||||
preserves geometry and record the method limitation. Every selector must pass
|
||||
the exact-role and active-source checks.
|
||||
|
||||
- A primitive axis starts at `origin_mm` and grows in the supplied positive
|
||||
`direction`. Heights and distances are positive magnitudes. A direction of
|
||||
`[0,0,-1]` therefore models travel toward `-Z` without a negative height.
|
||||
- A sketch has exactly one `profile`. A circle is one closed contour and a
|
||||
polygon is one simple, non-self-intersecting closed loop. Never concatenate
|
||||
disconnected circles or multiple polygon loops into one profile, and never
|
||||
repeat a loop's start point to begin another loop.
|
||||
- Model repeats as one valid seed plus `pattern_circular` or `pattern_linear`;
|
||||
`source_feature_ids` contains local names and the count includes the seed.
|
||||
If a repeated removal has no live host selector, use a selector-free cut seed
|
||||
plus circular `operation_mode: "remove"`. Otherwise use one valid feature
|
||||
per unrelated instance with its true dependency.
|
||||
- Treat every operation schema as independent. Do not copy a parameter name,
|
||||
enum value, or field shape from a similar operation into another operation;
|
||||
emit only fields present in that feature's supplied `params_schema`.
|
||||
- To extend an already connected solid, use an additive operation with the
|
||||
explicit `result_mode: "fuse"` when its contract provides that mode. The new
|
||||
profile must intersect the prior solid, and the feature depends on the prior
|
||||
feature. Do not turn touching or overlapping primitive additions into
|
||||
independent bodies followed by a boolean union.
|
||||
- A primitive operation whose authoring schema has no `result_mode: "fuse"`
|
||||
must be treated as a body-producing operation, not as a guaranteed extension
|
||||
of an existing connected result. Use such an operation as an initial or
|
||||
explicitly independent construction only. When the requested result must be
|
||||
one connected body, continue it through an additive operation that explicitly
|
||||
supports fusion rather than joining primitive outputs later with a boolean.
|
||||
- Use a through-cut operation with a profile that intersects the material for
|
||||
a through removal. Build all intended material before a removal that must
|
||||
cross it. Do not make a temporary tool body and call `boolean_bodies` for a
|
||||
removal expressible as a cut operation.
|
||||
- Do not introduce an auxiliary feature solely to construct or delete a tool
|
||||
for an operation that the contract already represents directly. A deletion
|
||||
is a state-changing operation, never a no-op cleanup step, and downstream
|
||||
dependencies must not rely on deleted geometry.
|
||||
- Use a selector-hosted operation only when its required source output role is
|
||||
available and the target geometry unambiguously lies on that output. Supply
|
||||
every required selector and parameter from the operation contract; otherwise
|
||||
choose a self-contained operation whose coordinates express the intent.
|
||||
- A face-hosted extrusion reuses its host boundary. For any different boundary,
|
||||
use a sketch-profile operation at an explicit workplane.
|
||||
- Selector provenance is live. Do not select a source changed by an intervening
|
||||
fuse, cut, shell, pattern, or finish. A through-cut has no cap role for a
|
||||
later host; schedule host-dependent features before it or use coordinates.
|
||||
|
||||
For `max_z`/`min_z` placement intent, use an owner role such as
|
||||
`source_feature.top_planar_face` or `source_feature.bottom_planar_face`, never
|
||||
`max_z_face` or `min_z_face`; prefer explicit coordinates when available.
|
||||
|
||||
Use only supplied operations and their exact parameter schemas. Do not invent
|
||||
parameters, implicit booleans, or substitutes after a capability error.
|
||||
|
||||
## Semantic Annotation (meta and intent)
|
||||
|
||||
Every document carries semantic annotations for downstream training. They are
|
||||
purely descriptive: the compiler carries them through verbatim, geometry never
|
||||
depends on them, and they are never acceptance targets.
|
||||
|
||||
Document level — include `meta` with at least one of:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"description": "R8 rounded square bushing, 100×100×12, central Ø45 bore",
|
||||
"function": "Spacer sleeve over a Ø45 shaft; rounded corners for handling"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `description`: one sentence naming the part with its key specifications
|
||||
(≤60 characters). `function`: what the part does and where it fits
|
||||
(≤200 characters). Narratives are written in the request language; keys and
|
||||
controlled labels are English `snake_case`.
|
||||
|
||||
Feature level — attach `intent` to every feature:
|
||||
|
||||
```json
|
||||
{
|
||||
"intent": {
|
||||
"label": "shaft_passage",
|
||||
"summary": "Ø45 central through bore, concentric with the outer contour",
|
||||
"why": "The fitting face of the sleeve; diameter follows the mating shaft",
|
||||
"ties_to_requirement": "R2",
|
||||
"provenance": "authored"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `label`: one controlled vocabulary term in `snake_case` — for example
|
||||
`housing_blank`, `shaft_passage`, `fastener_hole`, `bolt_circle`,
|
||||
`tap_hole`, `counterbore_seat`, `countersink_seat`, `locating_pin_hole`,
|
||||
`bearing_seat`, `press_fit_boss`, `mounting_boss`, `mounting_foot`,
|
||||
`lifting_eye`, `slot_adjustment`, `coolant_channel`, `lubrication_gallery`,
|
||||
`vent_hole`, `drain_port`, `fluid_inlet`, `process_corner_relief`,
|
||||
`weld_prep`, `machining_setup_tab`, `inspection_access`,
|
||||
`stress_relief_fillet`, `stiffening_rib`, `weight_relief`,
|
||||
`mass_saving_pocket`, `load_path_flange`, `wall_thickness_transition`,
|
||||
`gear_teeth`, `rack_teeth`, `thread_drive`, `cam_track`, `bend_wing`,
|
||||
`cosmetic_surface`, `datum_plane_feature`, `datum_axis_feature`. The list
|
||||
is open: an accurate new `snake_case` term is valid, but prefer vocabulary.
|
||||
- `summary` (required, ≤80 characters): what it is plus the key parameters.
|
||||
Use parameterized wording (`M8`, `Ø75`, `R8`), never a restatement of the
|
||||
request prose.
|
||||
- `why` (optional, ≤400 characters): the functional reason this feature exists.
|
||||
- `ties_to_requirement` (optional): the requirement id from the task's
|
||||
requirements contract that this feature implements. Include it whenever the
|
||||
task carries requirement ids and the feature plainly implements one of them;
|
||||
omit it when the task has no requirement ids. Never invent an id.
|
||||
- `provenance`: always `"authored"` when the model writes it.
|
||||
- Do not invent fields inside `intent`, and never treat a mismatch between an
|
||||
annotation and geometry as acceptable — the label must match the feature
|
||||
actually constructed.
|
||||
- Geometry metrics (`meta.derived_metrics`: volumes, hole counts, per-feature
|
||||
deltas) are computed and backfilled by the server after rebuild. Never
|
||||
author or estimate them; if a document already carries `derived_metrics`,
|
||||
leave it untouched.
|
||||
|
||||
## Sketches And Coordinates
|
||||
|
||||
Keep a sketch to exactly `workplane` and `profile`. The workplane declares its
|
||||
origin, `x_dir`, and normal. Profile coordinates are local to that workplane.
|
||||
For primitive axes, `origin_mm` is the start-cap center and `direction` is the
|
||||
positive build direction. For a selector-hosted hole, position coordinates are
|
||||
world coordinates unless the operation contract explicitly says otherwise.
|
||||
|
||||
The Authoring sketch syntax is deliberately smaller than Runtime CDSL. For
|
||||
every sketch operation, emit exactly this shape. `profile` is singular,
|
||||
circles use the requested `diameter_mm`, and the local center is `center_mm`:
|
||||
|
||||
```json
|
||||
{
|
||||
"workplane": {
|
||||
"origin_mm": [0, 0, 12],
|
||||
"x_dir": [1, 0, 0],
|
||||
"normal": [0, 0, 1]
|
||||
},
|
||||
"profile": {
|
||||
"type": "circle",
|
||||
"diameter_mm": 56,
|
||||
"center_mm": [0, 0]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Do not write `profiles`, `plane`, `support`, `radius_mm`, `center`, or any
|
||||
other key inside an Authoring sketch. The compiler derives Runtime radius and
|
||||
sketch identity. Use a primitive such as `cylinder_add` when its axis, radius,
|
||||
and height directly express the requested geometry and no sketch is needed.
|
||||
|
||||
Name features for their geometric role, for example `primary_add`,
|
||||
`secondary_add`, `through_cut`, and `repeated_cut`. Names make dependencies and
|
||||
repair diagnostics readable; they are not server identities.
|
||||
|
||||
## Selectors
|
||||
|
||||
Operation metadata defines selector need, kind, cardinality, and server-side
|
||||
destination. Put only declarative selectors in `selectors`, never the
|
||||
destination field in `params`.
|
||||
|
||||
For an output face, use an exact local role embedded in `source` and state a
|
||||
unique match:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "face",
|
||||
"source": "source_feature.top_planar_face",
|
||||
"match": "unique"
|
||||
}
|
||||
```
|
||||
|
||||
Do not add `role`, `query`, `host_face`, a face index, coordinate selector, or
|
||||
Runtime token. The compiler adds the selector source dependency; declare other
|
||||
true construction dependencies yourself.
|
||||
|
||||
`top_planar_face`/`end_face` mean a positive-direction cap;
|
||||
`bottom_planar_face`/`start_face` mean the opposite cap. Select an exact known
|
||||
host, never a similar face by location, index, or proximity.
|
||||
|
||||
For a cap, `source` MUST match
|
||||
`^[a-z][a-z0-9_]{0,63}\.(top_planar_face|bottom_planar_face|end_face|start_face)$`.
|
||||
No other role spelling exists in Authoring CDSL. If the producer does not
|
||||
advertise one of these roles, it cannot be the selector source.
|
||||
|
||||
Translate descriptive maximum/minimum placement to an appropriate cap role;
|
||||
never copy it into `source`. A `+Z` cylinder uses `top_planar_face` for its
|
||||
top cap and `bottom_planar_face` for its bottom cap.
|
||||
|
||||
`hole_wizard` requires one `face` selector. It must be unique, planar, active,
|
||||
and contain every position. Execute it before its source is replaced; otherwise
|
||||
use a supported self-contained construction or report the limitation.
|
||||
|
||||
## Acceptance And Repair
|
||||
|
||||
Put only user-requested measurable targets in `acceptance_targets`; leave
|
||||
inferred dimensions in `assumptions`. On repair return a complete document,
|
||||
preserve executed features unless diagnosed, and change unexecuted features
|
||||
only as needed. Read diagnostics literally; do not add IDs, weaken requested
|
||||
values, silently delete a failed feature, or guess a selector.
|
||||
|
||||
## Pre-Emission Check
|
||||
|
||||
Before returning the document, check every feature against these invariants:
|
||||
|
||||
1. The operation is in the supplied whitelist, its params use only the
|
||||
supplied closed schema, and its sketch/selector presence matches the
|
||||
operation contract. The document contains no `boolean_bodies` feature.
|
||||
2. Each reference is a local name, each dependency is necessary, and no
|
||||
operation relies on a deleted, transient, or merely assumed body.
|
||||
3. Each sketch contains one valid profile and a complete right-handed
|
||||
workplane; each dimension is positive where the contract requires a
|
||||
magnitude.
|
||||
4. Each selector source has the exact `<feature>.<output_role>` form. Its role
|
||||
is one advertised by that producer operation, not a descriptive synonym,
|
||||
coordinate extreme, or inferred face name. For a cap, verify the required
|
||||
four-role source regex character-for-character.
|
||||
5. Every requested removal intersects its intended material, every repeated
|
||||
feature has a valid seed reference, and every connected addition uses an
|
||||
operation whose contract explicitly supports the chosen result mode.
|
||||
6. Every feature carries an `intent` with a truthful `label`, a parameterized
|
||||
`summary` of at most 80 characters, and `provenance: "authored"`; the
|
||||
document carries `meta` with at least one of `description`/`function`.
|
||||
|
||||
If any invariant is false, revise the construction before emitting the single
|
||||
complete `cad.author.v1` document.
|
||||
@@ -1 +0,0 @@
|
||||
以当前 schema、`operation_contract` 与 topology token 为准;每次只执行一个原子操作,不编造字段或选择器。数值须为有限 mm/deg。保留最后可执行 checkpoint,并如实报告未满足项。
|
||||
@@ -1 +0,0 @@
|
||||
先区分显式事实、图像观察、工程默认值和未知项。提取单位、外形、功能面、孔/槽、配合关系、关键尺寸及可验证目标。默认值只能补足常见零件的非关键构造,不能把未说明尺寸伪装成用户要求或确定性验收值。只有安全、配合、合规或可建模性确实取决于一个缺失事实时,才提出一个聚焦澄清;其余不确定性记录为假设或风险。
|
||||
@@ -1 +0,0 @@
|
||||
把尺寸当作模型契约:先识别主控的长度、宽度、厚度、直径、中心距、节距、数量、半径和角度,再从它们推导重复位置、对称偏移和余量。所有尺寸明确使用 mm,角度使用 deg;长度、直径、深度、节距和圆角半径必须为合理正有限值。阵列优先由中心线、数量、节距、半径或角度推导,避免难以追溯的点坐标常数。提交前以包围盒、比例、壁厚/材料余量和目标特征数量做常识检查。
|
||||
@@ -1 +0,0 @@
|
||||
世界坐标为右手 mm;根特征只在 contract 允许时用 `XY`/`+Z`。`workplane.origin_mm`、`x_dir`、`normal` 定义局部 frame;孔位是世界坐标。后续特征仅用已验证 datum/token,不能猜测最后生成面。
|
||||
@@ -1 +0,0 @@
|
||||
优先把零件身份和主控尺寸写进稳定根特征:根体、主要增材体、主要切除、孔/槽、重复特征、最后的圆角/倒角。每个节点只承担一个原子意图,依赖边只表示直接几何前提。默认形成连通单体;确需多体时必须由目标和 contract 支持。避免把视觉装饰、细小倒角或易碎布尔放在主形体之前。重规划时保留已完成节点和可执行检查点,只替换最小必要子图。
|
||||
@@ -1 +0,0 @@
|
||||
轮廓必须闭合、不自交、无零长或重叠边,并清楚区分外环和内环。先验证 workplane 的原点、`x_dir`、`normal` 与局部轮廓方向;翻转方向使用 contract 允许的字段,不凭视觉猜测。切除从实际材料面进入,深度覆盖目标材料并满足当前预检;避免刚好停在共面边界。对薄壁、近相切、重叠工具和零厚度结果保持余量。切除失败先检查宿主、方向、深度和轮廓,再考虑更换建模顺序。
|
||||
@@ -1 +0,0 @@
|
||||
宿主特征只能使用当前 revision 的测量 topology 和服务端给出的不透明 selector token;不得按边/面列表下标、历史名称或“最后一个面”猜选。选择前核对 token 的 kind、中心、法向、包围盒和 surface_type 是否覆盖预期材料区域。布尔、孔、阵列、圆角后拓扑可能变化,旧 token 和 reference 不可假定仍有效;依赖新拓扑时重新观察。reference token 只按当前 contract 放入允许槽位。选择不确定时请求 topology,而不是提交模糊 selector。
|
||||
@@ -1 +0,0 @@
|
||||
对称和重复优先通过 `pattern_linear`、`pattern_mirror` 及其 contract 参数表达。先完成一个正确的源特征,再用中心面、中心线、方向、数量、节距、半径或角度定义重复关系;不要用零散手填坐标代替可追溯模式。镜像平面和阵列方向应来自已建立的 datum 或当前测量 token。阵列前确认源特征、间距和数量不会重叠、越界或使材料变成零厚度。
|
||||
@@ -1 +0,0 @@
|
||||
圆角和倒角仅在主形体、切除和孔稳定后执行,并只选择唯一、当前有效的边 token;禁止“所有边”式回退。半径/距离必须小于邻近材料可容纳范围,避免相邻圆角相交。布尔操作避开共面终止、近相切和重复工具重叠;若风险高,优先以更稳定的主轮廓、顺序或足够余量表达。失败时不要重复原片段,先诊断受影响的面、边、深度和拓扑。
|
||||
@@ -1 +0,0 @@
|
||||
确定性几何事实与视觉审查职责不同:包围盒、实体数、孔深或贯穿状态只能证明已测量的 claim,不能证明整体设计语义。使用当前 contract、预检结果、claim evidence、render manifest 和 recent failures 作决定。视觉不符时给出具体的形状、位置、方向或比例差异作为修复依据,不能把它伪装成确定性通过。仅在几何改变后重新审查;STEP/checkpoint 是主工件,GLB 和渲染是派生审查证据,不能替代 CAD 几何。
|
||||
@@ -1 +0,0 @@
|
||||
修复先读错误和证据,定位最小责任点,再改最小的 CDSL/计划部分并重新执行依赖检查。常见原因包括开环/自交轮廓、零或负尺寸、切除方向或深度错误、错误 host frame、布尔后的旧 selector、过大圆角和直径/半径混淆。不要原样重试已失败片段。运行时不支持的能力应作为风险或缺口保留并继续发布最佳可执行模型,不能发明新 atom 或删除有效 checkpoint。
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"schema_version": "cdsl.author-guidance.manifest.v1",
|
||||
"version": "2026-09-09.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},
|
||||
{"id": "op-bend", "file": "op-bend.md", "title": "Operation Appendix Bend", "priority": 100, "mandatory": true},
|
||||
{"id": "op-gear", "file": "op-gear.md", "title": "Operation Appendix Gear", "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_blind_with_hole": ["op-extrude-add"],
|
||||
"extrude_add_two_sided": ["op-extrude-add"],
|
||||
"extrude_from_face": ["op-extrude-add"],
|
||||
"extrude_surface": ["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"],
|
||||
"loft_add_with_cap_face": ["op-loft"],
|
||||
"revolve_add": ["op-revolve"],
|
||||
"revolve_cut": ["op-revolve"],
|
||||
"revolve_surface": ["op-revolve"],
|
||||
"sweep_add": ["op-loft"],
|
||||
"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"],
|
||||
"shell": ["op-finish"],
|
||||
"boolean_bodies": ["op-finish"],
|
||||
"transform_bodies": ["op-finish"],
|
||||
"delete_bodies": ["op-finish"],
|
||||
"sphere_add": ["op-sphere"],
|
||||
"box_add": ["op-primitives"],
|
||||
"cylinder_add": ["op-primitives"],
|
||||
"thread_add": ["op-thread"],
|
||||
"thread_cut": ["op-thread"],
|
||||
"bend_add": ["op-bend"],
|
||||
"gear_add": ["op-gear"],
|
||||
"rack_add": ["op-gear"]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
`bend_add` 以中面折线链定义等厚钣金折弯:正的 `thickness_mm`、`width_mm` 和至少一翼的 `chain`;非末翼必须给 `bend_angle_deg`(内角,0 < angle < 180,90 为直角折弯),可选 `inner_radius_mm`(>= 0,外半径恒为内半径加板厚)与 `side`(+1 / -1,折弯方向)。`frame` 可选,缺省为世界 XY 平面(首翼沿 +X,厚度沿法向)。相邻折弯圆角会消耗直段长度(约 `inner_radius + thickness/2` 的切线距离),每翼剩余直段必须为正,否则规格非法。仅用于意图明确的钣金折弯;单翼链退化为平板,普通平板轮廓应保留草图历史表达而非用 bend_add 替代。
|
||||
@@ -1 +0,0 @@
|
||||
`extrude_add_blind` 和 `extrude_add_two_sided` 必须使用闭合草图和 contract 允许的正距离。根挤出遵守根 `XY` datum;后续增材先确认草图 frame 与已有实体的连接。双向挤出分别核对两个方向的长度与材料范围;`reverse` 只用于当前 frame 的方向修正,不能代替错误的 workplane。
|
||||
@@ -1 +0,0 @@
|
||||
`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。`extrude_cut_two_sided` 必须分别提供正向和反向的距离与终止条件,不能以单侧深度近似双向切除。`extrude_cut_through` 只接受明确的 `end_condition`,由现有主体跨度决定穿透距离,不能伪造盲向深度。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。
|
||||
@@ -1 +0,0 @@
|
||||
`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。`shell` 必须保留显式目标 body 与待移除 face token。`boolean_bodies`、`transform_bodies`、`delete_bodies` 只操作 contract 指定且仍独立存在的 body;不以当前 body 或隐式 union 兜底。失败时保留主体并报告风险。
|
||||
@@ -1 +0,0 @@
|
||||
`gear_add` 与 `rack_add` 是渐开线齿轮/齿条的原生图元:正的 `module_mm`、`teeth_count`(齿轮 8–200,齿条 1–2000)与明确的 `axis`。齿轮 `axis.origin_mm` 是 `z=0` 端面圆心、`axis.direction` 为齿轴;齿条 `axis.direction` 是齿的伸出方向、`axis.origin_mm` 是长度起点端面与厚度起始面及齿谷平面的交点,有效长度精确等于 `teeth_count * pi * module`。`helix_angle_rad > 0` 生成斜齿(螺旋沿轴推进);加 `herringbone: true` 得到人字齿(双螺旋在齿宽中点对称反转,无退刀槽)。齿数低于 17 时存在根切风险,引擎照常生成并附诊断说明,不静默修正;如需无根切请提高齿数或改用变位设计(当前不支持变位)。齿轮孔、键槽等后续特征用 hole/extrude_cut 沿同一 axis 叠加。
|
||||
@@ -1 +0,0 @@
|
||||
孔 atom 需要当前宿主面的有效 selector token。`positions[].mm` 使用该宿主面上的绝对世界坐标,先核对点在面区域内与法向方向。直径、深度、沉孔/沉头参数以 contract 为准,深度覆盖预期材料;多孔共享一个原子操作时保持同一规格和同一宿主。不要把点写成面局部偏移或裸数组。
|
||||
@@ -1 +0,0 @@
|
||||
`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。`loft_add_with_cap_face` 只能使用 contract 许可的 cap-face token。`sweep_add` 必须保留闭合截面与显式、非退化路径,不以放样或挤出替代。不要把 selector 选中的实体面当作放样截面,除非 contract 明确支持。
|
||||
@@ -1 +0,0 @@
|
||||
`pattern_linear` 只复制当前 contract 允许且存在的源 feature reference;方向是明确世界/基准方向,数量和 spacing 为合理值。`pattern_mirror` 使用存在的镜像 plane reference,先确认源与平面关系以及复制后不会重叠或意外合并。pattern 不代替新的宿主选择;下游特征若依赖新面,重新读取 topology。
|
||||
@@ -1 +0,0 @@
|
||||
`box_add` 和 `cylinder_add` 是世界坐标原生图元。按 operation contract 提供正尺寸以及明确的 `center_mm` 或 axis。仅在目标确为长方体或圆柱体时使用;由轮廓驱动的几何保留草图、放样等历史表达。
|
||||
@@ -1 +0,0 @@
|
||||
`reference_plane` 用有限非零 `normal` 和与其不平行的 `x_dir` 定义局部 frame;`origin_mm` 是世界点。`reference_axis` 用有限非零 `direction` 和世界原点定义。它们只建立可追溯 datum,不直接制造实体;先于依赖它的旋转、镜像、阵列或定位特征,并依照 contract 的 reference token 规则引用。
|
||||
@@ -1 +0,0 @@
|
||||
`revolve_add` 和 `revolve_cut` 的轴必须由明确 datum 或 contract 中的世界坐标轴表达,并按预检要求位于正确的草图关系中。核对 axis origin、direction、角度和 `reverse`;完整回转避免轮廓跨轴造成自交,局部回转避免与现有材料近相切。切除回转仍必须覆盖目标材料。
|
||||
@@ -1 +0,0 @@
|
||||
`sphere_add` 用明确的世界中心和正有限半径定义。确认它与目标实体的连接意图:需要单体时应有足够相交,独立体仅在需求允许多体时使用。球体位置从 datum 或主尺寸导出,不把视图坐标误当世界坐标。
|
||||
@@ -1 +0,0 @@
|
||||
`thread_add` 和 `thread_cut` 需要明确的 axis、正的大小径、螺距和长度,且各参数必须物理一致。螺纹切除必须有已有宿主实体;需求为实体螺纹时,不能以光滑孔替代。
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Autonomous CAD protocol v3.
|
||||
"""Single-stage Authoring CDSL protocol.
|
||||
|
||||
The package intentionally separates policy from I/O. Delivery code composes
|
||||
these modules with adapters; it must not bypass the command handlers.
|
||||
"""
|
||||
|
||||
PROTOCOL_VERSION = "3.0"
|
||||
PROTOCOL_VERSION = "cad.single-stage.v1"
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Infrastructure adapters for the v3 ports."""
|
||||
"""Infrastructure adapters for the single-stage ports."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Immutable v3 file artifacts with staging manifests and atomic publication."""
|
||||
"""Immutable single-stage artifacts with staging manifests and atomic publication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,10 +8,9 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.ports import CandidateStage
|
||||
from app.cad_agent.ports import StagingRevision
|
||||
|
||||
|
||||
_SAFE_RELATIVE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$")
|
||||
@@ -27,7 +26,7 @@ class FileArtifactStore:
|
||||
|
||||
def task_dir(self, task_id: str) -> Path:
|
||||
if not re.fullmatch(r"cad_[a-z0-9]{12}", task_id):
|
||||
raise ValueError("Invalid v3 task id")
|
||||
raise ValueError("Invalid CAD task id")
|
||||
return self.root / task_id
|
||||
|
||||
def artifact_path(self, task_id: str, relative_path: str) -> Path:
|
||||
@@ -43,7 +42,7 @@ class FileArtifactStore:
|
||||
) -> None:
|
||||
root = self.task_dir(task_id)
|
||||
(root / "documents").mkdir(parents=True, exist_ok=True)
|
||||
(root / "actions").mkdir(exist_ok=True)
|
||||
(root / "events").mkdir(exist_ok=True)
|
||||
(root / "revisions").mkdir(exist_ok=True)
|
||||
(root / ".staging").mkdir(exist_ok=True)
|
||||
source = root / "source-requirements.md"
|
||||
@@ -67,14 +66,14 @@ class FileArtifactStore:
|
||||
if images:
|
||||
self.write_json_once(task_id, "documents/source-images.json", {"schema_version": "cad.source-images.v1", "images": images})
|
||||
|
||||
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
|
||||
def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
|
||||
"""Mirror committed SQLite events into an append-only JSONL audit log.
|
||||
|
||||
SQLite remains authoritative. Replaying this method after an
|
||||
interruption appends only missing committed sequences and rejects a
|
||||
divergent line instead of rewriting audit history.
|
||||
"""
|
||||
path = self._path(task_id, "actions/action-ledger.jsonl")
|
||||
path = self._path(task_id, "events/event-ledger.jsonl")
|
||||
existing: dict[int, dict[str, Any]] = {}
|
||||
if path.is_file():
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
@@ -83,18 +82,18 @@ class FileArtifactStore:
|
||||
value = json.loads(raw)
|
||||
sequence = value.get("sequence") if isinstance(value, dict) else None
|
||||
if not isinstance(sequence, int) or sequence < 1:
|
||||
raise ValueError("Action ledger contains an invalid sequence")
|
||||
raise ValueError("Event ledger contains an invalid sequence")
|
||||
existing[sequence] = value
|
||||
missing: list[str] = []
|
||||
for event in events:
|
||||
sequence = event.get("sequence")
|
||||
if not isinstance(sequence, int) or sequence < 1:
|
||||
raise ValueError("Action ledger event has an invalid sequence")
|
||||
entry = {"schema_version": "cad.action-ledger.v1", **event}
|
||||
raise ValueError("Event ledger event has an invalid sequence")
|
||||
entry = {"schema_version": "cad.event-ledger.v1", **event}
|
||||
previous = existing.get(sequence)
|
||||
if previous is not None:
|
||||
if previous != entry:
|
||||
raise ValueError("Action ledger diverges from committed SQLite event")
|
||||
raise ValueError("Event ledger diverges from committed SQLite event")
|
||||
continue
|
||||
missing.append(json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
if missing:
|
||||
@@ -103,7 +102,7 @@ class FileArtifactStore:
|
||||
handle.writelines(missing)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
return "actions/action-ledger.jsonl"
|
||||
return "events/event-ledger.jsonl"
|
||||
|
||||
def write_source_index(
|
||||
self,
|
||||
@@ -136,9 +135,8 @@ class FileArtifactStore:
|
||||
"""Return immutable source paragraphs and attachment blocks.
|
||||
|
||||
Source identifiers are assigned only after this method returns, so
|
||||
callers cannot choose IDs. Attachment metadata is deliberately a
|
||||
closed, server-provided projection: it lets a reviewer trace the
|
||||
source without treating upload metadata as arbitrary LLM input.
|
||||
callers cannot choose IDs. Attachment metadata is a closed,
|
||||
server-provided projection rather than arbitrary model input.
|
||||
"""
|
||||
supplied = source_blocks
|
||||
if supplied is None:
|
||||
@@ -195,19 +193,6 @@ class FileArtifactStore:
|
||||
if isinstance(item, dict) and item.get("path") and self._path(task_id, str(item["path"])).is_file()
|
||||
]
|
||||
|
||||
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
|
||||
return self.read_json(task_id, artifact_path or "documents/requirements-spec.json")
|
||||
|
||||
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
|
||||
# State points to the immutable artifact used as program input. The
|
||||
# fixed name is a read-only convenience view for terminal tasks.
|
||||
return self.read_json(task_id, artifact_path or "requirements-contract.json")
|
||||
|
||||
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str:
|
||||
if not invocation_id:
|
||||
return self.write_json_once(task_id, "requirements-contract.json", payload)
|
||||
return self._write_invocation_json(task_id, "requirements-contract", payload, invocation_id)
|
||||
|
||||
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None:
|
||||
path = self._path(task_id, relative_path)
|
||||
if not path.is_file():
|
||||
@@ -225,36 +210,20 @@ class FileArtifactStore:
|
||||
self._write_once(path, text)
|
||||
return relative_path
|
||||
|
||||
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
|
||||
return self.read_json(task_id, f"revisions/{revision_id}/model.cdsl.json") if revision_id else None
|
||||
|
||||
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
|
||||
return self.read_json(task_id, f"revisions/{revision_id}/model.topology.json") if revision_id else None
|
||||
|
||||
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage:
|
||||
def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision:
|
||||
root = self.task_dir(task_id) / ".staging"
|
||||
stable_id = "stage_" + sha256(idempotency_key.encode("utf-8")).hexdigest()[:20]
|
||||
directory = root / stable_id
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
self._write_json_once(directory / "input.json", payload)
|
||||
return CandidateStage(stable_id, str(directory.resolve()))
|
||||
|
||||
def stage_output_dir(self, task_id: str, stage_id: str) -> str:
|
||||
return str(self._stage_path(task_id, stage_id, ""))
|
||||
return StagingRevision(stable_id, str(directory.resolve()))
|
||||
|
||||
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str:
|
||||
path = self._stage_path(task_id, stage_id, relative_path)
|
||||
self._write_json_once(path, payload)
|
||||
return relative_path
|
||||
|
||||
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None:
|
||||
path = self._stage_path(task_id, stage_id, relative_path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
|
||||
def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
|
||||
source = self._stage_path(task_id, stage_id, "")
|
||||
target = self._path(task_id, f"revisions/{revision_id}")
|
||||
if target.exists():
|
||||
@@ -262,9 +231,8 @@ class FileArtifactStore:
|
||||
if manifest is None:
|
||||
raise RuntimeError("Published revision has no valid manifest")
|
||||
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
|
||||
# Render/rebuild reports contain paths for the reviewer. Rebase those
|
||||
# paths while artifacts are still mutable staging output, so they point
|
||||
# at the revision after the atomic directory rename.
|
||||
# Rebase report paths while artifacts are still mutable staging output,
|
||||
# so they point at the revision after the atomic directory rename.
|
||||
self._rebase_staged_paths(source, target)
|
||||
manifest = self._create_manifest(source)
|
||||
self._write_json_once(source / "manifest.json", manifest)
|
||||
@@ -272,39 +240,6 @@ class FileArtifactStore:
|
||||
os.replace(source, target)
|
||||
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
|
||||
|
||||
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None:
|
||||
"""Find a manifest-verified candidate already renamed before its DB CAS.
|
||||
|
||||
Publishing artifacts and advancing SQLite cannot share a transaction.
|
||||
The stage id persisted inside ``candidate.json`` makes a post-rename
|
||||
recovery deterministic and prevents another build or revision.
|
||||
"""
|
||||
revisions = self.task_dir(task_id) / "revisions"
|
||||
if not revisions.is_dir():
|
||||
return None
|
||||
for revision in sorted(revisions.iterdir()):
|
||||
if not revision.is_dir() or self._manifest(revision) is None:
|
||||
continue
|
||||
candidate_path = revision / "candidate.json"
|
||||
if not candidate_path.is_file():
|
||||
continue
|
||||
try:
|
||||
candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(candidate, dict) and candidate.get("stage_id") == stage_id:
|
||||
return revision.name, candidate
|
||||
return None
|
||||
|
||||
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None:
|
||||
root = self.task_dir(task_id) / ".staging"
|
||||
if not root.is_dir():
|
||||
return
|
||||
for directory in root.iterdir():
|
||||
if not directory.is_dir() or directory.name in referenced_stage_ids:
|
||||
continue
|
||||
shutil.rmtree(directory)
|
||||
|
||||
def _path(self, task_id: str, relative_path: str) -> Path:
|
||||
if relative_path and (not _SAFE_RELATIVE.fullmatch(relative_path) or ".." in Path(relative_path).parts):
|
||||
raise ValueError("Invalid artifact relative path")
|
||||
@@ -316,7 +251,7 @@ class FileArtifactStore:
|
||||
|
||||
def _stage_path(self, task_id: str, stage_id: str, relative_path: str) -> Path:
|
||||
if not re.fullmatch(r"stage_[a-f0-9]{20}", stage_id):
|
||||
raise ValueError("Invalid candidate stage id")
|
||||
raise ValueError("Invalid staging revision id")
|
||||
root = self.task_dir(task_id).resolve()
|
||||
stage = (root / ".staging" / stage_id).resolve()
|
||||
if root not in stage.parents:
|
||||
@@ -351,13 +286,6 @@ class FileArtifactStore:
|
||||
temporary.write_bytes(data)
|
||||
os.replace(temporary, path)
|
||||
|
||||
def _write_invocation_json(self, task_id: str, stem: str, payload: dict[str, Any], invocation_id: str) -> str:
|
||||
digest = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12]
|
||||
safe_invocation = re.sub(r"[^A-Za-z0-9_-]", "", invocation_id)[:32]
|
||||
if not safe_invocation:
|
||||
raise ValueError("Artifact invocation ID is invalid")
|
||||
return self.write_json_once(task_id, f"documents/{stem}-{digest}-{safe_invocation}.json", payload)
|
||||
|
||||
@classmethod
|
||||
def _write_json_once(cls, path: Path, payload: dict[str, Any]) -> None:
|
||||
cls._write_once(path, json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n")
|
||||
@@ -371,8 +299,8 @@ class FileArtifactStore:
|
||||
relative = path.relative_to(directory).as_posix()
|
||||
files[relative] = sha256(path.read_bytes()).hexdigest()
|
||||
if not files:
|
||||
raise RuntimeError("Candidate staging directory has no artifacts")
|
||||
return {"schema_version": "cad.v3.artifact-manifest.v1", "files": files}
|
||||
raise RuntimeError("Staging revision has no artifacts")
|
||||
return {"schema_version": "cad.single-stage.artifact-manifest.v1", "files": files}
|
||||
|
||||
@staticmethod
|
||||
def _manifest(directory: Path) -> dict[str, Any] | None:
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
"""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
|
||||
@@ -1,4 +1,4 @@
|
||||
"""In-process idempotent event delivery for the v3 delivery boundary."""
|
||||
"""In-process idempotent event delivery for the single-stage protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Independent rendered-review adapter for protocol v3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
|
||||
from app.cad_agent.ports import AdapterUnavailable
|
||||
|
||||
|
||||
class RenderedReviewGateway:
|
||||
def __init__(self, models: StructuredModelGateway) -> None:
|
||||
self.models = models
|
||||
|
||||
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]:
|
||||
name = str((tool.get("function") or {}).get("name") or "")
|
||||
if not name:
|
||||
raise RuntimeError("Review tool is missing a name")
|
||||
public_payload = {key: value for key, value in payload.items() if key != "reference_image_paths"}
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(public_payload, ensure_ascii=False)}]
|
||||
if kind in {"image_observation", "final"}:
|
||||
for raw_path in payload.get("reference_image_paths") or ():
|
||||
path = Path(str(raw_path))
|
||||
if path.is_file():
|
||||
content.append(self._image_part(path))
|
||||
if kind in {"candidate", "final"}:
|
||||
manifest = payload.get("render_manifest") if isinstance(payload.get("render_manifest"), dict) else {}
|
||||
for path in self._evidence_paths(manifest):
|
||||
content.append(self._image_part(path))
|
||||
return await self.models.call_tool(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are an independent CAD reviewer. Inspect supplied deterministic facts and rendered images. Return only the specified structured tool call."},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
tool=tool,
|
||||
provider_id=provider_id,
|
||||
model_id=model_id,
|
||||
required_tool_name=name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _evidence_paths(manifest: dict[str, Any]) -> list[Path]:
|
||||
selected: list[Path] = []
|
||||
contact = Path(str(manifest.get("contact_sheet_path") or ""))
|
||||
if contact.is_file():
|
||||
selected.append(contact)
|
||||
wanted = {"top", "front", "right", "isometric"}
|
||||
for item in manifest.get("views") or ():
|
||||
if not isinstance(item, dict) or str(item.get("id") or "") not in wanted:
|
||||
continue
|
||||
path = Path(str(item.get("path") or ""))
|
||||
if path.is_file():
|
||||
selected.append(path)
|
||||
if not selected:
|
||||
raise AdapterUnavailable("RENDER_SERVICE_UNAVAILABLE: review render evidence is unavailable")
|
||||
return selected[:5]
|
||||
|
||||
@staticmethod
|
||||
def _image_part(path: Path) -> dict[str, Any]:
|
||||
media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
|
||||
return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
"""SQLite repository: the sole mutable state authority for protocol v3."""
|
||||
"""SQLite authority for ``cad.single-stage.v1`` tasks.
|
||||
|
||||
There is no reader or migration for the deleted feature-DAG protocol. Opening
|
||||
a legacy task database drops its task data before creating this schema.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
@@ -10,11 +13,13 @@ from threading import RLock
|
||||
from typing import Any, Iterator
|
||||
|
||||
from app.cad_agent.domain.errors import ErrorCode
|
||||
from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState
|
||||
from app.cad_agent.ports import InvocationRecord
|
||||
|
||||
|
||||
class SqliteTaskRepository:
|
||||
PROTOCOL_VERSION = "cad.single-stage.v1"
|
||||
|
||||
def __init__(self, database_path: Path) -> None:
|
||||
self.database_path = database_path
|
||||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -35,271 +40,151 @@ class SqliteTaskRepository:
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
# Protocol 3.2 intentionally has no migration path from the
|
||||
# structured-only / review-loop task model. Deployment starts with
|
||||
# an empty task database, as those tasks do not have immutable
|
||||
# Markdown source artifacts to compile from.
|
||||
existing = connection.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").fetchone()
|
||||
if existing is not None and "'3.2'" not in str(existing[0] or ""):
|
||||
existing = connection.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'").fetchone()
|
||||
if existing is not None and self.PROTOCOL_VERSION not in str(existing[0] or ""):
|
||||
self.protocol_reset = True
|
||||
connection.executescript("""
|
||||
DROP TABLE IF EXISTS outbox;
|
||||
DROP TABLE IF EXISTS tool_audits;
|
||||
DROP TABLE IF EXISTS usage_records;
|
||||
DROP TABLE IF EXISTS invocations;
|
||||
DROP TABLE IF EXISTS ledger;
|
||||
DROP TABLE IF EXISTS model_capabilities;
|
||||
DROP TABLE IF EXISTS tasks;
|
||||
""")
|
||||
connection.executescript(
|
||||
"""
|
||||
# This database is dedicated to CAD task state. An old
|
||||
# protocol has no safe reader or migration, so remove every
|
||||
# persisted task table and let SQLite remove their indexes.
|
||||
tables = connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
for row in tables:
|
||||
name = str(row[0]).replace('"', '""')
|
||||
connection.execute(f'DROP TABLE IF EXISTS "{name}"')
|
||||
connection.executescript(f"""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.2'),
|
||||
request TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
state_version INTEGER NOT NULL,
|
||||
active_revision TEXT NOT NULL DEFAULT '',
|
||||
pending_action_json TEXT,
|
||||
candidate_id TEXT NOT NULL DEFAULT '',
|
||||
candidate_stage_id TEXT NOT NULL DEFAULT '',
|
||||
repair_required INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
retry_from_phase TEXT NOT NULL DEFAULT '',
|
||||
requirements_spec_path TEXT NOT NULL DEFAULT '',
|
||||
requirements_document_path TEXT NOT NULL DEFAULT '',
|
||||
completion_target_path TEXT NOT NULL DEFAULT '',
|
||||
modeling_plan_path TEXT NOT NULL DEFAULT '',
|
||||
feature_plan_path TEXT NOT NULL DEFAULT '',
|
||||
feature_plan_hash TEXT NOT NULL DEFAULT '',
|
||||
feature_stage_id TEXT NOT NULL DEFAULT '',
|
||||
protocol_version TEXT NOT NULL CHECK(protocol_version = '{self.PROTOCOL_VERSION}'),
|
||||
request TEXT NOT NULL, phase TEXT NOT NULL, state_version INTEGER NOT NULL,
|
||||
active_revision TEXT NOT NULL DEFAULT '', repair_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT, retry_from_phase TEXT NOT NULL DEFAULT '',
|
||||
requirements_path TEXT NOT NULL DEFAULT '', authoring_path TEXT NOT NULL DEFAULT '',
|
||||
runtime_cdsl_path TEXT NOT NULL DEFAULT '', compile_audit_path TEXT NOT NULL DEFAULT '',
|
||||
diagnostics_path TEXT NOT NULL DEFAULT '', completion_path TEXT NOT NULL DEFAULT '',
|
||||
clarification_path TEXT NOT NULL DEFAULT '',
|
||||
requirements_contract_path TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invocations (
|
||||
invocation_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
idempotency_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
result_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(task_id, idempotency_key)
|
||||
invocation_id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
idempotency_key TEXT NOT NULL, status TEXT NOT NULL, result_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(task_id,idempotency_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ledger (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL,
|
||||
published_at TEXT
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL, published_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS usage_records (
|
||||
usage_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
usage_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
usage_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
usage_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tool_audits (
|
||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
audit_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
audit_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS model_capabilities (
|
||||
provider_id TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
schema_hash TEXT NOT NULL,
|
||||
supported INTEGER NOT NULL,
|
||||
report_json TEXT NOT NULL,
|
||||
checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(provider_id, model_id, schema_hash)
|
||||
provider_id TEXT NOT NULL, model_id TEXT NOT NULL, schema_hash TEXT NOT NULL,
|
||||
supported INTEGER NOT NULL, report_json TEXT NOT NULL, checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(provider_id,model_id,schema_hash)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
def create_task(self, task_id: str, request: str) -> TaskState:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.2', ?, ?, 0)",
|
||||
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value),
|
||||
"INSERT OR IGNORE INTO tasks(task_id,protocol_version,request,phase,state_version) VALUES (?,?,?,?,0)",
|
||||
(task_id, self.PROTOCOL_VERSION, request, TaskPhase.ANALYZING_REQUEST.value),
|
||||
)
|
||||
state = self.get_state(task_id)
|
||||
if state is None:
|
||||
raise RuntimeError("SQLite task insert was not visible")
|
||||
raise RuntimeError("Task was not persisted")
|
||||
return state
|
||||
|
||||
def get_state(self, task_id: str) -> TaskState | None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
||||
return self._state(row) if row is not None else None
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
return self._state(row) if row else None
|
||||
|
||||
def get_task_projection(self, task_id: str) -> dict[str, Any] | None:
|
||||
state = self.get_state(task_id)
|
||||
if state is None:
|
||||
return None
|
||||
events = self.ledger_events(task_id)
|
||||
revisions = [
|
||||
{
|
||||
"revision_id": item["revision_id"], "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED and item["revision_id"] == state.active_revision else "checkpoint",
|
||||
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": "" if item.get("preview_unavailable") else f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""),
|
||||
}
|
||||
for item in events
|
||||
if item.get("event") in {"accepted", "feature_node_verified"} and isinstance(item.get("revision_id"), str)
|
||||
]
|
||||
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_compiled"), {})
|
||||
verification_warnings = [
|
||||
str(item) for item in frozen.get("verification_warnings") or () if str(item)
|
||||
] if isinstance(frozen, dict) else []
|
||||
status_event = next((
|
||||
item for item in reversed(events)
|
||||
if item.get("event") in {
|
||||
"requirements_waiting_for_user", "waiting_retry", "call_budget_exhausted",
|
||||
"no_progress_limit",
|
||||
"candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure",
|
||||
"failed_author_format", "runtime_contract_invalid",
|
||||
"completed_best_effort",
|
||||
}
|
||||
), {}) if state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {}
|
||||
questions = [str(item) for item in status_event.get("questions") or () if str(item)] if isinstance(status_event, dict) else []
|
||||
issues = [str(item) for item in status_event.get("issues") or () if str(item)] if isinstance(status_event, dict) else []
|
||||
# A later repair can end at authoring or compilation after an earlier
|
||||
# build already published an executable prefix. Its terminal event
|
||||
# intentionally contains diagnostics rather than a duplicate path
|
||||
# map, so keep the most recent event that actually delivered a
|
||||
# revision. Otherwise the completed projection loses the existing
|
||||
# STEP/GLB links exactly when best-effort publication matters most.
|
||||
result: dict[str, Any] = {}
|
||||
paths: dict[str, Any] = {}
|
||||
for item in reversed(events):
|
||||
if item.get("event") not in {
|
||||
"build_completed", "published_best_effort", "build_failed",
|
||||
}:
|
||||
continue
|
||||
candidate_paths = item.get("paths")
|
||||
if isinstance(candidate_paths, dict) and candidate_paths:
|
||||
result, paths = item, candidate_paths
|
||||
break
|
||||
return {
|
||||
"schema_version": "3.2",
|
||||
"task_id": state.task_id,
|
||||
"phase": state.phase.value,
|
||||
"lifecycle": self._lifecycle(state.phase),
|
||||
"state_version": state.version,
|
||||
"active_revision": state.active_revision,
|
||||
"current_revision": state.active_revision,
|
||||
"schema_version": self.PROTOCOL_VERSION, "task_id": task_id, "phase": state.phase.value,
|
||||
"lifecycle": self._lifecycle(state.phase), "state_version": state.version,
|
||||
"active_revision": state.active_revision, "current_revision": state.active_revision,
|
||||
"published_revision": state.active_revision if state.phase == TaskPhase.COMPLETED else "",
|
||||
"pending_action": self._pending_payload(state.pending_action),
|
||||
"active_candidate_id": state.candidate_id,
|
||||
"repair_required": state.repair_required,
|
||||
"repair_count": state.repair_count, "repair_budget": 2,
|
||||
"last_error": state.last_error.value if state.last_error else "",
|
||||
"retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "",
|
||||
"requirements_spec_path": state.requirements_spec_path,
|
||||
"requirements_document_path": state.requirements_document_path,
|
||||
"completion_target_path": state.completion_target_path,
|
||||
"modeling_plan_path": state.modeling_plan_path,
|
||||
"feature_plan_path": state.feature_plan_path,
|
||||
"feature_plan_hash": state.feature_plan_hash,
|
||||
"current_feature_node_id": state.pending_feature.node_id if state.pending_feature else "",
|
||||
"pending_feature": self._pending_payload(state.pending_feature),
|
||||
"feature_nodes": self._feature_node_projection(events),
|
||||
"clarification_path": state.clarification_path,
|
||||
"requirements_contract_path": state.requirements_contract_path,
|
||||
"verification_status": (
|
||||
"completed_with_risks" if state.phase == TaskPhase.COMPLETED and (verification_warnings or state.last_error == ErrorCode.BEST_EFFORT_COMPLETED)
|
||||
else "verified" if state.phase == TaskPhase.COMPLETED
|
||||
else "pending"
|
||||
),
|
||||
"verification_warnings": verification_warnings,
|
||||
"message": str(status_event.get("message") or "") if isinstance(status_event, dict) else "",
|
||||
"questions": questions,
|
||||
"issues": issues,
|
||||
"blocker_type": (
|
||||
"requirements_ambiguity" if state.phase == TaskPhase.WAITING_FOR_USER
|
||||
else str(status_event.get("event") or "") if isinstance(status_event, dict) else ""
|
||||
),
|
||||
"user_action_required": state.phase == TaskPhase.WAITING_FOR_USER and bool(questions),
|
||||
"action_ledger_summary": events[-12:],
|
||||
"revisions": revisions,
|
||||
"requirements_path": state.requirements_path, "authoring_path": state.authoring_path,
|
||||
"runtime_cdsl_path": state.runtime_cdsl_path, "compile_audit_path": state.compile_audit_path,
|
||||
"diagnostics_path": state.diagnostics_path, "completion_path": state.completion_path,
|
||||
"message": str(result.get("message") or ""),
|
||||
"revisions": ([{
|
||||
"revision_id": state.active_revision, "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED else "checkpoint",
|
||||
"cdsl_path": paths.get("model.cdsl.json", ""), "step_path": paths.get("model.step", ""),
|
||||
"glb_path": paths.get("model.glb", ""), "report_path": paths.get("rebuild-report.json", ""),
|
||||
}] if state.active_revision else []),
|
||||
"action_ledger_summary": events[-16:],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _feature_node_projection(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return ledger-backed execution evidence; API adds immutable plan fields."""
|
||||
nodes: dict[str, dict[str, Any]] = {}
|
||||
for event in events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if not node_id:
|
||||
continue
|
||||
item = nodes.setdefault(node_id, {"node_id": node_id, "status": "pending", "attempt": 0})
|
||||
if event.get("event") == "feature_node_scheduled":
|
||||
item.update({"status": "running", "atomic_id": str(event.get("atomic_id") or ""), "priority": event.get("priority"), "claim_ids": event.get("claim_ids") or [], "depends_on": event.get("depends_on") or []})
|
||||
elif event.get("event") == "feature_node_failed":
|
||||
item.update({"status": "failed" if event.get("terminal") else "pending", "attempt": int(event.get("attempt") or 0), "failure_class": str(event.get("failure_class") or ""), "error": str(event.get("message") or "")})
|
||||
elif event.get("event") == "feature_node_verified":
|
||||
item.update({"status": "done", "revision_id": str(event.get("revision_id") or ""), "feature_id": str(event.get("feature_id") or ""), "evidence": event.get("claim_results") or []})
|
||||
elif event.get("event") == "feature_node_invalidated" and item.get("status") != "done":
|
||||
item["status"] = "invalidated"
|
||||
return list(nodes.values())
|
||||
|
||||
def ledger_events(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute("SELECT sequence, event_json, created_at FROM ledger WHERE task_id = ? ORDER BY sequence", (task_id,)).fetchall()
|
||||
return [{"sequence": row["sequence"], "at": row["created_at"], **json.loads(row["event_json"])} for row in rows]
|
||||
rows = connection.execute("SELECT sequence,event_json,created_at FROM ledger WHERE task_id=? ORDER BY sequence", (task_id,)).fetchall()
|
||||
return [{"sequence": int(row["sequence"]), "at": str(row["created_at"]), **json.loads(row["event_json"])} for row in rows]
|
||||
|
||||
def invocation_records(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT invocation_id, idempotency_key, status, result_json, created_at, updated_at FROM invocations WHERE task_id = ? ORDER BY created_at, invocation_id",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"invocation_id": str(row["invocation_id"]), "idempotency_key": str(row["idempotency_key"]),
|
||||
"status": str(row["status"]), "result": json.loads(row["result_json"]) if row["result_json"] else None,
|
||||
"created_at": str(row["created_at"]), "updated_at": str(row["updated_at"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def compare_and_swap(
|
||||
self,
|
||||
state: TaskState,
|
||||
*,
|
||||
events: list[dict[str, Any]] = (),
|
||||
invocation_id: str | None = None,
|
||||
invocation_result: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
def compare_and_swap(self, state: TaskState, *, events: list[dict[str, Any]] = (), invocation_id: str | None = None, invocation_result: dict[str, Any] | None = None) -> bool:
|
||||
if (invocation_id is None) != (invocation_result is None):
|
||||
raise ValueError("Invocation completion requires both invocation_id and invocation_result")
|
||||
previous_version = state.version - 1
|
||||
if previous_version < 0:
|
||||
raise ValueError("State version must advance exactly once")
|
||||
raise ValueError("Invocation completion requires both values")
|
||||
previous = state.version - 1
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
cursor = connection.execute(
|
||||
"""UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?,
|
||||
candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_spec_path = ?,
|
||||
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, feature_plan_path = ?, feature_plan_hash = ?, feature_stage_id = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE task_id = ? AND state_version = ?""",
|
||||
(
|
||||
state.phase.value, state.version, state.active_revision,
|
||||
json.dumps(self._pending_payload(state.pending_action), ensure_ascii=True) if state.pending_action else None,
|
||||
state.candidate_id, state.candidate_stage_id,
|
||||
int(state.repair_required), state.last_error.value if state.last_error else None,
|
||||
state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_spec_path,
|
||||
state.requirements_document_path, state.completion_target_path, state.modeling_plan_path,
|
||||
state.feature_plan_path, state.feature_plan_hash, state.feature_stage_id,
|
||||
state.clarification_path, state.requirements_contract_path,
|
||||
state.task_id, previous_version,
|
||||
),
|
||||
)
|
||||
cursor = connection.execute("""
|
||||
UPDATE tasks SET phase=?,state_version=?,active_revision=?,repair_count=?,last_error=?,retry_from_phase=?,
|
||||
requirements_path=?,authoring_path=?,runtime_cdsl_path=?,compile_audit_path=?,diagnostics_path=?,completion_path=?,clarification_path=?,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE task_id=? AND state_version=?
|
||||
""", (
|
||||
state.phase.value, state.version, state.active_revision, state.repair_count,
|
||||
state.last_error.value if state.last_error else None, state.retry_from_phase.value if state.retry_from_phase else "",
|
||||
state.requirements_path, state.authoring_path, state.runtime_cdsl_path, state.compile_audit_path,
|
||||
state.diagnostics_path, state.completion_path, state.clarification_path, state.task_id, previous,
|
||||
))
|
||||
if cursor.rowcount != 1:
|
||||
connection.execute("ROLLBACK")
|
||||
return False
|
||||
for event in events:
|
||||
encoded = json.dumps(event, ensure_ascii=True, sort_keys=True)
|
||||
connection.execute("INSERT INTO ledger(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded))
|
||||
connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded))
|
||||
if invocation_id is not None and invocation_result is not None:
|
||||
finished = connection.execute(
|
||||
"""UPDATE invocations
|
||||
SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE invocation_id = ? AND task_id = ? AND status = 'processing'""",
|
||||
(json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id),
|
||||
)
|
||||
if finished.rowcount != 1:
|
||||
raise ValueError("Invocation is not an active record for this state transition")
|
||||
for item in events:
|
||||
encoded = json.dumps(item, ensure_ascii=True, sort_keys=True)
|
||||
connection.execute("INSERT INTO ledger(task_id,event_json) VALUES (?,?)", (state.task_id, encoded))
|
||||
connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (state.task_id, encoded))
|
||||
if invocation_id is not None:
|
||||
updated = connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND task_id=? AND status='processing'", (json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id))
|
||||
if updated.rowcount != 1:
|
||||
raise ValueError("Invocation is not active")
|
||||
connection.execute("COMMIT")
|
||||
return True
|
||||
except Exception:
|
||||
@@ -310,144 +195,92 @@ class SqliteTaskRepository:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
existing = connection.execute("SELECT * FROM invocations WHERE task_id = ? AND idempotency_key = ?", (task_id, idempotency_key)).fetchone()
|
||||
if existing is not None:
|
||||
row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND idempotency_key=?", (task_id, idempotency_key)).fetchone()
|
||||
if row is None:
|
||||
connection.execute("INSERT INTO invocations(invocation_id,task_id,idempotency_key,status) VALUES (?,?,?,'processing')", (invocation_id, task_id, idempotency_key))
|
||||
connection.execute("COMMIT")
|
||||
return self._invocation(existing)
|
||||
connection.execute("INSERT INTO invocations(invocation_id, task_id, idempotency_key, status) VALUES (?, ?, ?, 'processing')", (invocation_id, task_id, idempotency_key))
|
||||
return InvocationRecord(invocation_id, idempotency_key, "processing")
|
||||
connection.execute("COMMIT")
|
||||
return InvocationRecord(invocation_id, idempotency_key, "processing")
|
||||
return self._invocation(row)
|
||||
except Exception:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def get_invocation(self, task_id: str, invocation_id: str) -> InvocationRecord | None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM invocations WHERE task_id = ? AND invocation_id = ?",
|
||||
(task_id, invocation_id),
|
||||
).fetchone()
|
||||
return self._invocation(row) if row is not None else None
|
||||
row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND invocation_id=?", (task_id, invocation_id)).fetchone()
|
||||
return self._invocation(row) if row else None
|
||||
|
||||
def finish_invocation(self, invocation_id: str, result: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute("SELECT status, result_json FROM invocations WHERE invocation_id = ?", (invocation_id,)).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("Unknown invocation")
|
||||
if str(row["status"]) == "finished":
|
||||
# A winning concurrent command has already recorded the only
|
||||
# durable result for this idempotency key. Never overwrite it.
|
||||
return
|
||||
cursor = connection.execute(
|
||||
"UPDATE invocations SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP WHERE invocation_id = ? AND status = 'processing'",
|
||||
(json.dumps(result, ensure_ascii=True), invocation_id),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise ValueError("Invocation could not be completed")
|
||||
connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND status='processing'", (json.dumps(result, ensure_ascii=True), invocation_id))
|
||||
|
||||
def append_outbox(self, task_id: str, event: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True)))
|
||||
connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
def pending_outbox(self, limit: int = 100, *, task_id: str | None = None) -> list[dict[str, Any]]:
|
||||
query = "SELECT event_id,task_id,event_json FROM outbox WHERE published_at IS NULL"
|
||||
args: tuple[Any, ...] = ()
|
||||
if task_id is not None:
|
||||
query += " AND task_id=?"
|
||||
args += (task_id,)
|
||||
query += " ORDER BY event_id LIMIT ?"
|
||||
args += (limit,)
|
||||
with self._lock, self._connection() as connection:
|
||||
if task_id is None:
|
||||
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL ORDER BY event_id LIMIT ?", (limit,)).fetchall()
|
||||
else:
|
||||
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL AND task_id = ? ORDER BY event_id LIMIT ?", (task_id, limit)).fetchall()
|
||||
return [{"event_id": row["event_id"], "task_id": row["task_id"], **json.loads(row["event_json"])} for row in rows]
|
||||
rows = connection.execute(query, args).fetchall()
|
||||
return [{"event_id": int(row["event_id"]), "task_id": str(row["task_id"]), **json.loads(row["event_json"])} for row in rows]
|
||||
|
||||
def mark_outbox_published(self, event_id: int) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("UPDATE outbox SET published_at = CURRENT_TIMESTAMP WHERE event_id = ? AND published_at IS NULL", (event_id,))
|
||||
connection.execute("UPDATE outbox SET published_at=CURRENT_TIMESTAMP WHERE event_id=? AND published_at IS NULL", (event_id,))
|
||||
|
||||
def record_usage(self, task_id: str, payload: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("INSERT INTO usage_records(task_id, usage_json) VALUES (?, ?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
|
||||
connection.execute("INSERT INTO usage_records(task_id,usage_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
def record_tool_audit(self, task_id: str, payload: dict[str, Any]) -> None:
|
||||
"""Persist a diagnostic structured-output audit separately from usage."""
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO tool_audits(task_id, audit_json) VALUES (?, ?)",
|
||||
(task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)),
|
||||
)
|
||||
connection.execute("INSERT INTO tool_audits(task_id,audit_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
def tool_audits(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT audit_id, audit_json, created_at FROM tool_audits WHERE task_id = ? ORDER BY audit_id",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])}
|
||||
for row in rows
|
||||
]
|
||||
rows = connection.execute("SELECT audit_id,audit_json,created_at FROM tool_audits WHERE task_id=? ORDER BY audit_id", (task_id,)).fetchall()
|
||||
return [{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])} for row in rows]
|
||||
|
||||
def usage_summary(self, task_id: str) -> dict[str, Any]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id = ? ORDER BY usage_id", (task_id,)).fetchall()
|
||||
values = [json.loads(row["usage_json"]) for row in rows]
|
||||
return {
|
||||
"calls": len(values),
|
||||
"prompt_tokens": sum(int(item.get("prompt_tokens") or 0) for item in values if isinstance(item, dict)),
|
||||
"completion_tokens": sum(int(item.get("completion_tokens") or 0) for item in values if isinstance(item, dict)),
|
||||
"context_chars": sum(int(item.get("context_chars") or 0) for item in values if isinstance(item, dict)),
|
||||
"records": values,
|
||||
}
|
||||
rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id=? ORDER BY usage_id", (task_id,)).fetchall()
|
||||
return {"calls": len(rows), "records": [json.loads(row["usage_json"]) for row in rows]}
|
||||
|
||||
def running_task_ids(self) -> list[str]:
|
||||
phases = tuple(phase.value for phase in TaskPhase if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER, TaskPhase.WAITING_RETRY})
|
||||
placeholders = ", ".join("?" for _ in phases)
|
||||
terminal = tuple(item.value for item in (TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER))
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute(f"SELECT task_id FROM tasks WHERE phase IN ({placeholders}) ORDER BY created_at", phases).fetchall()
|
||||
rows = connection.execute("SELECT task_id FROM tasks WHERE phase NOT IN (?,?,?,?) ORDER BY created_at", terminal).fetchall()
|
||||
return [str(row["task_id"]) for row in rows]
|
||||
|
||||
def model_capability(self, provider_id: str, model_id: str, schema_hash: str) -> dict[str, Any] | None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute("SELECT supported, report_json, checked_at FROM model_capabilities WHERE provider_id = ? AND model_id = ? AND schema_hash = ?", (provider_id, model_id, schema_hash)).fetchone()
|
||||
return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": row["checked_at"]} if row else None
|
||||
row = connection.execute("SELECT supported,report_json,checked_at FROM model_capabilities WHERE provider_id=? AND model_id=? AND schema_hash=?", (provider_id, model_id, schema_hash)).fetchone()
|
||||
return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": str(row["checked_at"])} if row else None
|
||||
|
||||
def record_model_capability(self, provider_id: str, model_id: str, schema_hash: str, report: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("""INSERT INTO model_capabilities(provider_id, model_id, schema_hash, supported, report_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider_id, model_id, schema_hash) DO UPDATE SET supported = excluded.supported, report_json = excluded.report_json, checked_at = CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True)))
|
||||
connection.execute("""INSERT INTO model_capabilities(provider_id,model_id,schema_hash,supported,report_json) VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(provider_id,model_id,schema_hash) DO UPDATE SET supported=excluded.supported,report_json=excluded.report_json,checked_at=CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
@staticmethod
|
||||
def _state(row: sqlite3.Row) -> TaskState:
|
||||
raw_pending = json.loads(row["pending_action_json"]) if row["pending_action_json"] else None
|
||||
pending = PendingAction(
|
||||
action_id=raw_pending["action_id"], working_head=raw_pending["working_head"], intent=raw_pending["intent"],
|
||||
requirement_ids=tuple(raw_pending["requirement_ids"]), atomic_id=raw_pending["atomic_id"],
|
||||
expected_change=raw_pending["expected_change"], contract_hash=raw_pending["contract_hash"], idempotency_key=raw_pending["idempotency_key"],
|
||||
node_id=str(raw_pending.get("node_id") or ""), plan_hash=str(raw_pending.get("plan_hash") or ""),
|
||||
claim_ids=tuple(raw_pending.get("claim_ids") or ()), depends_on_node_ids=tuple(raw_pending.get("depends_on_node_ids") or ()),
|
||||
) if isinstance(raw_pending, dict) else None
|
||||
return TaskState(
|
||||
task_id=str(row["task_id"]), phase=TaskPhase(str(row["phase"])), version=int(row["state_version"]),
|
||||
active_revision=str(row["active_revision"] or ""), pending_action=pending,
|
||||
candidate_id=str(row["candidate_id"] or ""), candidate_stage_id=str(row["candidate_stage_id"] or ""),
|
||||
repair_required=bool(row["repair_required"]),
|
||||
active_revision=str(row["active_revision"]), repair_count=int(row["repair_count"]),
|
||||
last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None,
|
||||
retry_from_phase=TaskPhase(str(row["retry_from_phase"])) if row["retry_from_phase"] else None,
|
||||
requirements_spec_path=str(row["requirements_spec_path"] or ""),
|
||||
requirements_document_path=str(row["requirements_document_path"] or ""),
|
||||
completion_target_path=str(row["completion_target_path"] or ""),
|
||||
modeling_plan_path=str(row["modeling_plan_path"] or ""),
|
||||
feature_plan_path=str(row["feature_plan_path"] or ""),
|
||||
feature_plan_hash=str(row["feature_plan_hash"] or ""),
|
||||
feature_stage_id=str(row["feature_stage_id"] or ""),
|
||||
clarification_path=str(row["clarification_path"] or ""),
|
||||
requirements_contract_path=str(row["requirements_contract_path"] or ""),
|
||||
requirements_path=str(row["requirements_path"]), authoring_path=str(row["authoring_path"]),
|
||||
runtime_cdsl_path=str(row["runtime_cdsl_path"]), compile_audit_path=str(row["compile_audit_path"]),
|
||||
diagnostics_path=str(row["diagnostics_path"]), completion_path=str(row["completion_path"]), clarification_path=str(row["clarification_path"]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _pending_payload(pending: PendingAction | None) -> dict[str, Any] | None:
|
||||
if pending is None:
|
||||
return None
|
||||
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key, "node_id": pending.node_id, "plan_hash": pending.plan_hash, "claim_ids": list(pending.claim_ids), "depends_on_node_ids": list(pending.depends_on_node_ids)}
|
||||
|
||||
@staticmethod
|
||||
def _invocation(row: sqlite3.Row) -> InvocationRecord:
|
||||
return InvocationRecord(str(row["invocation_id"]), str(row["idempotency_key"]), str(row["status"]), json.loads(row["result_json"]) if row["result_json"] else None)
|
||||
@@ -460,6 +293,6 @@ class SqliteTaskRepository:
|
||||
return "failed"
|
||||
if phase == TaskPhase.CANCELLED:
|
||||
return "cancelled"
|
||||
if phase in {TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}:
|
||||
return phase.value.lower()
|
||||
if phase == TaskPhase.WAITING_FOR_USER:
|
||||
return "waiting_for_user"
|
||||
return "running"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The sole OpenAI-compatible structured-output adapter for protocol v3.
|
||||
"""The sole OpenAI-compatible structured-output adapter for Authoring CDSL.
|
||||
|
||||
Raw tool arguments are intentionally preserved. Callers must run their
|
||||
Pydantic/JSON-Schema canonical validator before causing any state transition.
|
||||
@@ -170,39 +170,12 @@ class StructuredModelGateway:
|
||||
|
||||
@staticmethod
|
||||
def _conformance_messages(tool: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Give capability probes the same selector fact production exposes.
|
||||
|
||||
A required opaque selector is not inferable from a generic request for
|
||||
a minimal example. Production fragment turns expose a topology token
|
||||
before the fragment tool, so the probe must do the same while still
|
||||
letting the provider generate every other schema field itself.
|
||||
"""
|
||||
"""Probe the two standalone structured documents used by this protocol."""
|
||||
function = tool.get("function") if isinstance(tool.get("function"), dict) else {}
|
||||
name = str(function.get("name") or "")
|
||||
parameters = function.get("parameters") if isinstance(function.get("parameters"), dict) else {}
|
||||
feature = parameters.get("properties", {}).get("feature") if isinstance(parameters.get("properties"), dict) else None
|
||||
feature_properties = feature.get("properties") if isinstance(feature, dict) and isinstance(feature.get("properties"), dict) else {}
|
||||
feature_required = feature.get("required") if isinstance(feature, dict) and isinstance(feature.get("required"), list) else []
|
||||
selector_required = "selector_tokens" in feature_required and isinstance(feature_properties.get("selector_tokens"), dict)
|
||||
instruction = "Return exactly the required function call with a minimal valid example. The active JSON Schema is authoritative."
|
||||
if selector_required:
|
||||
atomic_id = feature_properties.get("atomic_id", {}).get("const") if isinstance(feature_properties.get("atomic_id"), dict) else "..."
|
||||
instruction += (
|
||||
" The current topology exposes opaque selector token `sel_conformance`. "
|
||||
f"Use root shape {{\"feature\":{{\"atomic_id\":{json.dumps(atomic_id)},"
|
||||
"\"selector_tokens\":[\"sel_conformance\"],\"params\":{...}}}}. "
|
||||
"Because feature.selector_tokens is required, it is author input and must not be moved into params."
|
||||
)
|
||||
if name == "review_candidate":
|
||||
instruction += (
|
||||
" This is a candidate-review probe: include every required top-level and nested field; "
|
||||
"in particular provide a verdict and claim_coverage. Use accept and pass only where the schema permits them."
|
||||
)
|
||||
elif name == "review_final":
|
||||
instruction += (
|
||||
" This is a final-review probe: include every required top-level and nested field; "
|
||||
"in particular provide a verdict and claim_coverage. Use pass only where the schema permits it."
|
||||
)
|
||||
if name == "write_authoring_cdsl":
|
||||
instruction += " Return cad.author.v1 with document-local names only; do not include runtime IDs, stable selectors, snapshots, or selector tokens."
|
||||
return [{"role": "system", "content": instruction}]
|
||||
|
||||
def _provider_model(self, provider_id: str, model_id: str) -> tuple[ProviderConfig, ProviderModel]:
|
||||
@@ -256,7 +229,7 @@ class StructuredModelGateway:
|
||||
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.")
|
||||
raise StructuredModelError("The Authoring 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] = {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Adapter exposing the pure verifier registry through the application port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.verifier_registry import VerifierRegistry
|
||||
|
||||
|
||||
class RegistryVerifierExecutor:
|
||||
def __init__(self, registry: VerifierRegistry) -> None:
|
||||
self.registry = registry
|
||||
|
||||
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
for claim in claims:
|
||||
result = self.registry.evaluate(str(claim["claim_kind"]), claim["expected"], facts)
|
||||
results.append({"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": self.registry.definition(str(claim["claim_kind"])).deterministic, **result})
|
||||
return results
|
||||
@@ -1 +1 @@
|
||||
"""Application command handlers and context assembly for protocol v3."""
|
||||
"""Application workflow and compiler for the Authoring CDSL protocol."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
"""Compile model-facing Authoring CDSL into server-owned runtime CDSL."""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from .authoring_contract import AuthoringDocument, validate_finite, validation_error_code
|
||||
|
||||
|
||||
class AuthoringCompileError(ValueError):
|
||||
def __init__(self, code: str, message: str, *, path: str = "") -> None:
|
||||
self.code, self.path = code, path
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AuthoringCompiler:
|
||||
def __init__(self, operation_contract: Callable[[str], dict[str, Any]], *, version: str = "1") -> None:
|
||||
self.operation_contract = operation_contract
|
||||
self.version = version
|
||||
|
||||
def compile(self, raw: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
try:
|
||||
validate_finite(raw)
|
||||
doc = AuthoringDocument.model_validate(raw)
|
||||
except Exception as error:
|
||||
raise AuthoringCompileError(validation_error_code(error), str(error)) from error
|
||||
source_features = [feature for body in doc.bodies for feature in body.features]
|
||||
dependencies, implicit_selector_dependencies = self._effective_dependencies(doc)
|
||||
ordered = self._topological(doc, dependencies)
|
||||
# Identity follows the source document, while execution follows the
|
||||
# dependency graph. A dependency reordering must never renumber IDs.
|
||||
feature_ids = {feature.name: f"feature_{index:03d}" for index, feature in enumerate(source_features, 1)}
|
||||
body_ids = {body.name: f"body_{index:03d}" for index, body in enumerate(doc.bodies, 1)}
|
||||
features: list[dict[str, Any]] = []
|
||||
sketches: list[dict[str, Any]] = []
|
||||
sketch_index = 0
|
||||
source_positions = {feature.name: index for index, feature in enumerate(source_features, 1)}
|
||||
sketch_ids_by_feature = {
|
||||
feature.name: f"sketch_{source_positions[feature.name]:03d}"
|
||||
for feature in source_features
|
||||
if feature.sketch is not None
|
||||
}
|
||||
for feature in ordered:
|
||||
try:
|
||||
contract = self.operation_contract(feature.operation)
|
||||
except Exception as error:
|
||||
raise AuthoringCompileError("OPERATION_UNSUPPORTED", str(error), path=f"features.{feature.name}.operation") from error
|
||||
params = self._references(
|
||||
deepcopy(feature.params), contract, feature_ids, feature.name,
|
||||
sketch_ids_by_feature=sketch_ids_by_feature,
|
||||
)
|
||||
self._validate_params(contract, params, feature.name)
|
||||
selectors = [self._selector(item, feature_ids, source_features) for item in feature.selectors]
|
||||
selector_policy = contract.get("selector_policy") or {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0}
|
||||
fragment_shape = contract.get("fragment_shape") or {"sketch": "forbidden", "selector_tokens": "forbidden"}
|
||||
required_selectors = fragment_shape["selector_tokens"] == "required"
|
||||
runtime_feature_selectors: list[dict[str, Any]] = []
|
||||
if required_selectors and not selector_policy["min_items"] <= len(selectors) <= selector_policy["max_items"]:
|
||||
raise AuthoringCompileError("SELECTOR_NOT_FOUND", f"operation {feature.operation} requires {selector_policy['min_items']}..{selector_policy['max_items']} selectors", path=f"features.{feature.name}.selectors")
|
||||
if not required_selectors and selectors:
|
||||
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} does not accept selectors", path=f"features.{feature.name}.selectors")
|
||||
if required_selectors and any(item["kind"] != selector_policy["token_kind"] for item in selectors):
|
||||
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} requires {selector_policy['token_kind']} selectors", path=f"features.{feature.name}.selectors")
|
||||
if required_selectors:
|
||||
slot = str(selector_policy["slot"])
|
||||
if slot == "feature.selectors":
|
||||
runtime_feature_selectors = selectors
|
||||
elif slot.startswith("params."):
|
||||
parameter = slot.removeprefix("params.")
|
||||
if "." in parameter:
|
||||
raise AuthoringCompileError(
|
||||
"OPERATION_UNSUPPORTED",
|
||||
f"runtime has no safe selector binding for {slot}",
|
||||
path=f"features.{feature.name}.selectors",
|
||||
)
|
||||
if parameter in params:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_FORBIDDEN_FIELD",
|
||||
f"{parameter} is server-injected from selectors",
|
||||
path=f"features.{feature.name}.params.{parameter}",
|
||||
)
|
||||
params[parameter] = selectors[0] if len(selectors) == 1 else selectors
|
||||
else:
|
||||
raise AuthoringCompileError(
|
||||
"OPERATION_UNSUPPORTED",
|
||||
f"runtime has no safe selector binding for {slot}",
|
||||
path=f"features.{feature.name}.selectors",
|
||||
)
|
||||
output = {
|
||||
"id": feature_ids[feature.name], "atomic_id": feature.operation,
|
||||
"depends_on": [feature_ids[name] for name in dependencies[feature.name]],
|
||||
"params": params,
|
||||
**({"selectors": runtime_feature_selectors} if runtime_feature_selectors else {}),
|
||||
}
|
||||
needs_sketch = fragment_shape["sketch"] == "required"
|
||||
if needs_sketch and feature.sketch is None:
|
||||
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} requires sketch", path=f"features.{feature.name}.sketch")
|
||||
if not needs_sketch and feature.sketch is not None:
|
||||
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} does not accept sketch", path=f"features.{feature.name}.sketch")
|
||||
if feature.sketch is not None:
|
||||
sketch_index += 1
|
||||
sketch_id = f"sketch_{source_positions[feature.name]:03d}"
|
||||
sketches.append({"id": sketch_id, **self._runtime_sketch(feature.sketch.model_dump(mode="json"))})
|
||||
output["sketch_id"] = sketch_id
|
||||
if feature.intent is not None:
|
||||
# Semantic annotation only: carried through for training data,
|
||||
# never consumed by runtime geometry or validation semantics.
|
||||
output["intent"] = feature.intent.model_dump(mode="json", exclude_none=True)
|
||||
features.append(output)
|
||||
document_meta: dict[str, Any] = {"unit": "mm"}
|
||||
if doc.meta is not None:
|
||||
document_meta.update(doc.meta.model_dump(mode="json", exclude_none=True))
|
||||
runtime = {
|
||||
"schema": "cad.runtime.v1", "schema_version": "1.0.0", "kind": "part",
|
||||
"part_id": "compiled", "meta": document_meta,
|
||||
"bodies": [
|
||||
{"id": body_ids[body.name], "name": body.name}
|
||||
for body in doc.bodies
|
||||
],
|
||||
"geometry": {"sketches": sketches}, "features": features,
|
||||
}
|
||||
digest = sha256(self._canonical_json(raw)).hexdigest()
|
||||
return runtime, {
|
||||
"schema_version": "cad.author.compile-audit.v1", "compiler_version": self.version,
|
||||
"source_sha256": digest, "body_ids": body_ids, "feature_ids": feature_ids,
|
||||
"sketch_ids": [item["id"] for item in sketches],
|
||||
"implicit_selector_dependencies": implicit_selector_dependencies,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _effective_dependencies(doc: AuthoringDocument) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
|
||||
"""Make every declared selector source an auditable graph dependency.
|
||||
|
||||
A selector is already an explicit local source reference. Requiring the
|
||||
author to repeat that same edge in a second field only creates a
|
||||
formatting failure; it does not add CAD intent. The compiler therefore
|
||||
adds the direct source edge deterministically and records it in the
|
||||
audit. It never selects a substitute topology element.
|
||||
"""
|
||||
by_name = {item.name: item for body in doc.bodies for item in body.features}
|
||||
dependencies: dict[str, list[str]] = {}
|
||||
implicit: dict[str, list[str]] = {}
|
||||
for feature in by_name.values():
|
||||
values = list(feature.depends_on)
|
||||
additions: list[str] = []
|
||||
for selector in feature.selectors:
|
||||
source = selector.source.split(".", 1)[0]
|
||||
if source not in by_name:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_REFERENCE_INVALID",
|
||||
f"unknown selector source: {selector.source}",
|
||||
path=f"features.{feature.name}.selectors",
|
||||
)
|
||||
if source not in values:
|
||||
values.append(source)
|
||||
additions.append(source)
|
||||
dependencies[feature.name] = values
|
||||
if additions:
|
||||
implicit[feature.name] = additions
|
||||
return dependencies, implicit
|
||||
|
||||
@staticmethod
|
||||
def _topological(doc: AuthoringDocument, dependencies: dict[str, list[str]]) -> list[Any]:
|
||||
by_name = {item.name: item for body in doc.bodies for item in body.features}
|
||||
result: list[Any] = []
|
||||
visiting: set[str] = set()
|
||||
done: set[str] = set()
|
||||
def visit(name: str) -> None:
|
||||
if name in visiting:
|
||||
raise AuthoringCompileError("AUTHOR_CYCLE", f"cyclic feature dependency: {name}")
|
||||
if name in done:
|
||||
return
|
||||
visiting.add(name)
|
||||
for dependency in dependencies[name]:
|
||||
visit(dependency)
|
||||
visiting.remove(name); done.add(name); result.append(by_name[name])
|
||||
for body in doc.bodies:
|
||||
for feature in body.features:
|
||||
visit(feature.name)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _selector(selector: Any, feature_ids: dict[str, str], source_features: list[Any]) -> dict[str, Any]:
|
||||
source = selector.source.split(".", 1)[0]
|
||||
if source not in feature_ids:
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"unknown selector source: {selector.source}")
|
||||
source_feature = next(item for item in source_features if item.name == source)
|
||||
role = selector.source.split(".", 1)[1]
|
||||
role = AuthoringCompiler._runtime_role(role, source_feature.operation)
|
||||
return {"kind": selector.kind, "output_role": role, "owner_feature_id": feature_ids[source], "source": "runtime_snapshot", "confidence": 1.0, "match_mode": selector.match}
|
||||
|
||||
@staticmethod
|
||||
def _runtime_sketch(sketch: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Lower the small Authoring sketch language to the generic runtime form."""
|
||||
profile = sketch["profile"]
|
||||
if profile["type"] == "circle":
|
||||
profile = {
|
||||
"type": "circle",
|
||||
"center": profile["center_mm"],
|
||||
"radius_mm": float(profile["diameter_mm"]) / 2.0,
|
||||
}
|
||||
return {"workplane": sketch["workplane"], "profile": profile}
|
||||
|
||||
@staticmethod
|
||||
def _runtime_role(role: str, operation: str) -> str:
|
||||
if role in {"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end", "shell.offset_face", "shell.closing_descendant", "shell.body_face"}:
|
||||
return role
|
||||
family = (
|
||||
"extrude" if operation.startswith("extrude") else
|
||||
"sweep" if operation.startswith("sweep") else
|
||||
"loft" if operation.startswith("loft") else
|
||||
"cylinder" if operation == "cylinder_add" else ""
|
||||
)
|
||||
if role in {"top_planar_face", "end_face"} and family:
|
||||
return family + ".end"
|
||||
if role in {"bottom_planar_face", "start_face"} and family:
|
||||
return family + ".start"
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"selector output role {role!r} is not available from {operation}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_params(contract: dict[str, Any], params: dict[str, Any], feature_name: str) -> None:
|
||||
schema = contract.get("author_params_schema") or {"type": "object"}
|
||||
errors = list(Draft202012Validator(schema).iter_errors(params))
|
||||
if errors:
|
||||
first = errors[0]
|
||||
path = ".".join(str(item) for item in first.absolute_path)
|
||||
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", first.message, path=f"features.{feature_name}.params.{path}".rstrip("."))
|
||||
|
||||
@staticmethod
|
||||
def _references(
|
||||
params: dict[str, Any],
|
||||
contract: dict[str, Any],
|
||||
feature_ids: dict[str, str],
|
||||
feature_name: str,
|
||||
*,
|
||||
sketch_ids_by_feature: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
params = AuthoringCompiler._rewrite_named_references(
|
||||
params, feature_ids, sketch_ids_by_feature, feature_name,
|
||||
)
|
||||
policy = contract.get("reference_policy") or {"mode": "none"}
|
||||
if policy["mode"] != "snapshot_bound" or policy.get("slot") == "feature.selectors":
|
||||
return params
|
||||
key = str(policy["slot"]).removeprefix("params.")
|
||||
value = params.get(key)
|
||||
if isinstance(value, list):
|
||||
names = value
|
||||
elif value is None and policy["min_items"] == 0:
|
||||
names = []
|
||||
elif isinstance(value, str):
|
||||
names = [value]
|
||||
else:
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} must contain local feature names", path=f"features.{feature_name}.params.{key}")
|
||||
runtime_ids = set(feature_ids.values())
|
||||
if not all(isinstance(item, str) and item in runtime_ids for item in names):
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} contains an unknown local feature name", path=f"features.{feature_name}.params.{key}")
|
||||
if not policy["min_items"] <= len(names) <= policy["max_items"]:
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} has invalid item count", path=f"features.{feature_name}.params.{key}")
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_named_references(
|
||||
value: Any,
|
||||
feature_ids: dict[str, str],
|
||||
sketch_ids_by_feature: dict[str, str],
|
||||
consuming_feature: str,
|
||||
key: str = "",
|
||||
) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
item_key: AuthoringCompiler._rewrite_named_references(
|
||||
item_value, feature_ids, sketch_ids_by_feature, consuming_feature, item_key,
|
||||
)
|
||||
for item_key, item_value in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
AuthoringCompiler._rewrite_named_references(
|
||||
item, feature_ids, sketch_ids_by_feature, consuming_feature, key,
|
||||
)
|
||||
for item in value
|
||||
]
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if key == "profile_sketch_ids":
|
||||
if value not in sketch_ids_by_feature:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_REFERENCE_INVALID", f"unknown local sketch source: {value}",
|
||||
path=f"features.{consuming_feature}.params.{key}",
|
||||
)
|
||||
return sketch_ids_by_feature[value]
|
||||
if key.endswith("_feature_id") or key.endswith("_feature_ids"):
|
||||
if value not in feature_ids:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_REFERENCE_INVALID", f"unknown local feature reference: {value}",
|
||||
path=f"features.{consuming_feature}.params.{key}",
|
||||
)
|
||||
return feature_ids[value]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Strict model-facing Authoring CDSL contract.
|
||||
|
||||
This is intentionally separate from the runtime CDSL: model output contains
|
||||
only document-local names and declarative references. Runtime identities are
|
||||
allocated by :mod:`authoring_compiler`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
_NAME = r"^[a-z][a-z0-9_]{0,63}$"
|
||||
_FORBIDDEN = {
|
||||
"id",
|
||||
"task_id", "revision_id", "candidate_id", "action_id", "requirement_id",
|
||||
"claim_id", "evidence_id", "feature_id", "sketch_id", "body_id",
|
||||
"stable_id", "snapshot_id", "owner_feature_id", "working_head",
|
||||
"selector_token", "selector_tokens", "selector token", "selector-token", "selectorToken",
|
||||
"host_face", "mirror_plane",
|
||||
}
|
||||
|
||||
|
||||
def validation_error_code(error: Exception) -> str:
|
||||
"""Map strict model validation failures to a stable public diagnostic."""
|
||||
return "AUTHOR_FORBIDDEN_FIELD" if "AUTHOR_FORBIDDEN_FIELD:" in str(error) else "AUTHOR_SCHEMA_INVALID"
|
||||
|
||||
|
||||
class AuthorModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def reject_internal_fields(cls, value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
found = sorted(
|
||||
key for key in value
|
||||
if isinstance(key, str)
|
||||
and (key in _FORBIDDEN or key.lower().replace("-", "_").replace(" ", "_") in _FORBIDDEN)
|
||||
)
|
||||
if found:
|
||||
raise ValueError(f"AUTHOR_FORBIDDEN_FIELD: {found[0]}")
|
||||
for nested in value.values():
|
||||
cls.reject_internal_fields(nested)
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
cls.reject_internal_fields(item)
|
||||
return value
|
||||
|
||||
|
||||
class AuthorWorkplane(AuthorModel):
|
||||
"""A fully explicit local sketch frame in world millimetres."""
|
||||
|
||||
origin_mm: list[float] = Field(min_length=3, max_length=3)
|
||||
x_dir: list[float] = Field(min_length=3, max_length=3)
|
||||
normal: list[float] = Field(min_length=3, max_length=3)
|
||||
|
||||
|
||||
class AuthorCircleProfile(AuthorModel):
|
||||
"""A declarative circle expressed with the user-facing diameter."""
|
||||
|
||||
type: Literal["circle"]
|
||||
diameter_mm: float = Field(gt=0)
|
||||
center_mm: list[float] = Field(default_factory=lambda: [0.0, 0.0], min_length=2, max_length=2)
|
||||
|
||||
|
||||
class AuthorPolygonProfile(AuthorModel):
|
||||
"""A closed polygon in the local sketch workplane."""
|
||||
|
||||
type: Literal["polygon"]
|
||||
vertices: list[list[float]] = Field(min_length=3)
|
||||
|
||||
@field_validator("vertices")
|
||||
@classmethod
|
||||
def require_planar_points(cls, value: list[list[float]]) -> list[list[float]]:
|
||||
if any(len(point) != 2 for point in value):
|
||||
raise ValueError("polygon vertices must have exactly two coordinates")
|
||||
return value
|
||||
|
||||
|
||||
class AuthorSketch(AuthorModel):
|
||||
"""The only authoring sketch form currently accepted by the compiler."""
|
||||
|
||||
workplane: AuthorWorkplane
|
||||
profile: AuthorCircleProfile | AuthorPolygonProfile
|
||||
|
||||
|
||||
class SelectorIntent(AuthorModel):
|
||||
"""A local feature-output reference, never a Runtime selector token."""
|
||||
|
||||
kind: Literal["face", "edge", "axis", "plane", "vertex", "body"]
|
||||
source: str = Field(
|
||||
min_length=3,
|
||||
max_length=160,
|
||||
description="A local feature output in the form <feature_name>.<output_role>.",
|
||||
)
|
||||
match: Literal["unique", "all"] = "unique"
|
||||
|
||||
@field_validator("source")
|
||||
@classmethod
|
||||
def require_feature_output_reference(cls, value: str) -> str:
|
||||
feature, separator, role = value.partition(".")
|
||||
if not separator or not re.fullmatch(_NAME, feature) or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,80}", role):
|
||||
raise ValueError("selector source must be <feature_name>.<output_role>")
|
||||
return value
|
||||
|
||||
|
||||
class FeatureIntent(AuthorModel):
|
||||
"""Feature-level semantic annotation for training data.
|
||||
|
||||
Mirrors ``$defs/featureIntent`` in ``cdsl_schema.json``: purely
|
||||
descriptive, never read by the compiler for geometry decisions.
|
||||
"""
|
||||
|
||||
label: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9_]{2,63}$")
|
||||
summary: str = Field(min_length=1, max_length=80)
|
||||
why: str | None = Field(default=None, min_length=1, max_length=400)
|
||||
ties_to_requirement: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,80}$")
|
||||
provenance: Literal["authored", "annotated", "imported"]
|
||||
|
||||
|
||||
class DocumentMeta(AuthorModel):
|
||||
"""Document-level semantic annotation (part description and function)."""
|
||||
|
||||
description: str | None = Field(default=None, min_length=1, max_length=60)
|
||||
function: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_at_least_one(self) -> "DocumentMeta":
|
||||
if self.description is None and self.function is None:
|
||||
raise ValueError("meta requires at least one of description or function")
|
||||
return self
|
||||
|
||||
|
||||
class AuthorFeature(AuthorModel):
|
||||
name: str = Field(pattern=_NAME)
|
||||
operation: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$")
|
||||
params: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Only parameters from this feature operation's supplied params_schema.",
|
||||
)
|
||||
depends_on: list[str] = Field(default_factory=list, max_length=32)
|
||||
selectors: list[SelectorIntent] = Field(default_factory=list, max_length=32)
|
||||
sketch: AuthorSketch | None = Field(
|
||||
default=None,
|
||||
description="For sketch operations: exactly {workplane, profile}. Circle profiles use diameter_mm and center_mm.",
|
||||
)
|
||||
intent: FeatureIntent | None = Field(
|
||||
default=None,
|
||||
description="Optional semantic annotation for training; never consumed by geometry.",
|
||||
)
|
||||
|
||||
|
||||
class AuthorBody(AuthorModel):
|
||||
name: str = Field(pattern=_NAME)
|
||||
features: list[AuthorFeature] = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class AuthoringDocument(AuthorModel):
|
||||
schema_version: str = Field(default="cad.author.v1", pattern=r"^cad\.author\.v1$")
|
||||
units: str = Field(default="mm", pattern=r"^mm$")
|
||||
coordinate_system: str = Field(default="right_handed", pattern=r"^[a-z][a-z0-9_-]{0,40}$")
|
||||
assumptions: list[str] = Field(default_factory=list, max_length=64)
|
||||
meta: DocumentMeta | None = Field(
|
||||
default=None,
|
||||
description="Optional document-level semantic annotation (description/function).",
|
||||
)
|
||||
bodies: list[AuthorBody] = Field(min_length=1, max_length=32)
|
||||
acceptance_targets: list[dict[str, Any]] = Field(default_factory=list, max_length=128)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_symbols(self) -> "AuthoringDocument":
|
||||
validate_finite(self.model_dump(mode="python"))
|
||||
bodies = [b.name for b in self.bodies]
|
||||
if len(bodies) != len(set(bodies)):
|
||||
raise ValueError("duplicate body name")
|
||||
names: set[str] = set()
|
||||
for body in self.bodies:
|
||||
for feature in body.features:
|
||||
if feature.name in names:
|
||||
raise ValueError(f"duplicate feature name: {feature.name}")
|
||||
names.add(feature.name)
|
||||
for body in self.bodies:
|
||||
for feature in body.features:
|
||||
if len(feature.depends_on) != len(set(feature.depends_on)):
|
||||
raise ValueError(f"duplicate dependency: {feature.name}")
|
||||
if any(dep not in names for dep in feature.depends_on):
|
||||
missing = next(dep for dep in feature.depends_on if dep not in names)
|
||||
raise ValueError(f"unknown feature reference: {missing}")
|
||||
return self
|
||||
|
||||
|
||||
def validate_finite(value: Any, path: str = "$") -> None:
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
raise ValueError(f"non-finite number at {path}")
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
validate_finite(item, f"{path}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
validate_finite(item, f"{path}[{index}]")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Prompt material for the model-facing Authoring CDSL contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_GUIDANCE = Path(__file__).resolve().parents[4] / "agent" / "skills" / "cad-authoring" / "SKILL.md"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_authoring_guidance() -> str:
|
||||
try:
|
||||
return _GUIDANCE.read_text(encoding="utf-8")[:12_000]
|
||||
except OSError:
|
||||
return "Use cad.author.v1 only. Never create runtime IDs or selector tokens."
|
||||
@@ -6,62 +6,25 @@ from hashlib import sha256
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.cad_agent.application.llm_contracts import (
|
||||
EmptyCommand,
|
||||
ImageObservation,
|
||||
MarkdownDocument,
|
||||
StatelessCandidateReview,
|
||||
StatelessGeometryConclusion,
|
||||
StatelessRollbackCheckpoint,
|
||||
StatelessTopologyRequest,
|
||||
compiled_requirements_schema,
|
||||
stateless_final_review_schema,
|
||||
stateless_next_action_schema,
|
||||
)
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan
|
||||
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.cad_agent.application.authoring_contract import AuthoringDocument
|
||||
from app.cad_agent.application.workflow import RequirementsAnalysis
|
||||
from app.cad_agent.ports import CadRuntime, ModelGateway
|
||||
|
||||
|
||||
CapabilityRole = Literal["author", "reviewer"]
|
||||
CapabilityRole = Literal["author"]
|
||||
|
||||
|
||||
def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict[str, Any]]:
|
||||
if role == "reviewer":
|
||||
return [
|
||||
_tool("observe_images", ImageObservation.model_json_schema()),
|
||||
# Compatibility conformance probe; regular DAG execution never
|
||||
# calls a per-node reviewer.
|
||||
_tool("review_candidate", StatelessCandidateReview.model_json_schema()),
|
||||
_tool("review_final", stateless_final_review_schema(1)),
|
||||
]
|
||||
atomic_ids = list(runtime.supported_atomic_ids())
|
||||
if not atomic_ids:
|
||||
if not runtime.supported_atomic_ids():
|
||||
raise RuntimeError("Runtime has no operations for conformance")
|
||||
tools = [
|
||||
_tool("write_requirements_document", MarkdownDocument.model_json_schema()),
|
||||
_tool("write_completion_target", MarkdownDocument.model_json_schema()),
|
||||
_tool("compile_requirements_spec", compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)),
|
||||
_tool("write_feature_plan", FeaturePlan.model_json_schema()),
|
||||
# Compatibility probe only; production v3.2 workflow never exposes it.
|
||||
_tool("write_modeling_plan", MarkdownDocument.model_json_schema()),
|
||||
_tool("inspect_topology", StatelessTopologyRequest.model_json_schema()),
|
||||
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
|
||||
_tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()),
|
||||
_tool("complete_task", EmptyCommand.model_json_schema()),
|
||||
return [
|
||||
_tool("analyze_requirements", RequirementsAnalysis.model_json_schema()),
|
||||
_tool("write_authoring_cdsl", AuthoringDocument.model_json_schema()),
|
||||
]
|
||||
for atomic_id in atomic_ids:
|
||||
contract = runtime.operation_contract(atomic_id)
|
||||
tools.append(_tool(
|
||||
f"conformance_{atomic_id}",
|
||||
fragment_schema(contract, selector_tokens=["sel_conformance"], reference_tokens=["ref_conformance"]),
|
||||
))
|
||||
return tools
|
||||
|
||||
|
||||
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
|
||||
payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools}
|
||||
payload = {"protocol": "cad.single-stage.v1", "role": role, "tools": tools}
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
"""Canonical schemas for every v3 LLM state-changing command.
|
||||
|
||||
Providers may parse structured output first, but this module validates the raw
|
||||
tool arguments a second time before a command reaches a handler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from typing import Annotated, Any, Literal, TypeVar
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, ValidationError, model_validator
|
||||
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
|
||||
|
||||
class StrictDto(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
ShortText = Annotated[str, Field(min_length=1, max_length=360)]
|
||||
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")]
|
||||
|
||||
|
||||
class AcceptanceClaimInput(StrictDto):
|
||||
claim_kind: Identifier
|
||||
expected: dict[str, JsonValue] = Field(min_length=0, max_length=24)
|
||||
|
||||
|
||||
class SpecRequirementInput(StrictDto):
|
||||
statement: Annotated[str, Field(min_length=1, max_length=1000)]
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class MarkdownDocument(StrictDto):
|
||||
"""A frozen human-readable design artifact, never an executable payload."""
|
||||
markdown: Annotated[str, Field(min_length=1, max_length=16_000)]
|
||||
|
||||
|
||||
class CompiledRequirementInput(StrictDto):
|
||||
"""One verifier bundle for one server-parsed checklist item.
|
||||
|
||||
The checklist text, ordering, source bindings, and all identifiers are
|
||||
intentionally absent: the service owns them after Markdown is frozen.
|
||||
"""
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class CompiledRequirementsSpec(StrictDto):
|
||||
requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
# Kept only so an interrupted process with an already imported old tool schema
|
||||
# fails at the workflow boundary instead of failing module import. New v3.1
|
||||
# tasks never expose or accept this aggregate specification.
|
||||
class RequirementsSpec(StrictDto):
|
||||
outcome: Literal["ready"]
|
||||
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=32)
|
||||
requirements: list[SpecRequirementInput] = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class RequirementsClarification(StrictDto):
|
||||
outcome: Literal["clarification"]
|
||||
source_quotes: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field(min_length=2, max_length=4)
|
||||
question: Annotated[str, Field(min_length=1, max_length=500)]
|
||||
|
||||
|
||||
class RequirementsAuthorOutput(RootModel[Annotated[RequirementsSpec | RequirementsClarification, Field(discriminator="outcome")]]):
|
||||
pass
|
||||
|
||||
|
||||
class EmptyCommand(StrictDto):
|
||||
pass
|
||||
|
||||
|
||||
class NextAction(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
intent: ShortText
|
||||
# The server binds this list from every frozen checklist target. It is not
|
||||
# author input, so a five-item UI-era limit must not reject a valid task.
|
||||
requirement_ids: list[Identifier] = Field(min_length=1, max_length=64)
|
||||
atomic_id: Identifier
|
||||
expected_change: ShortText
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _requirement_ids_are_unique(self) -> "NextAction":
|
||||
if len(self.requirement_ids) != len(set(self.requirement_ids)):
|
||||
raise ValueError("requirement_ids must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
class StatelessNextAction(StrictDto):
|
||||
intent: ShortText
|
||||
operation: Identifier
|
||||
expected_change: ShortText
|
||||
|
||||
|
||||
class TopologyRequest(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
||||
limit: int = Field(default=16, ge=1, le=64)
|
||||
|
||||
|
||||
class GeometryConclusion(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
evidence_refs: list[Identifier] = Field(min_length=1, max_length=16)
|
||||
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
decision: Literal["return_to_action_selection", "rollback"]
|
||||
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _evidence_refs_are_unique(self) -> "GeometryConclusion":
|
||||
if len(self.evidence_refs) != len(set(self.evidence_refs)):
|
||||
raise ValueError("evidence_refs must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
class RollbackCheckpoint(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
checkpoint_token: Identifier
|
||||
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
|
||||
|
||||
class StatelessTopologyRequest(StrictDto):
|
||||
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
||||
limit: int = Field(default=16, ge=1, le=64)
|
||||
|
||||
|
||||
class StatelessGeometryConclusion(StrictDto):
|
||||
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
decision: Literal["return_to_action_selection", "rollback"]
|
||||
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
|
||||
|
||||
|
||||
class StatelessRollbackCheckpoint(StrictDto):
|
||||
checkpoint_token: Identifier
|
||||
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
|
||||
|
||||
class ClaimCoverage(StrictDto):
|
||||
claim_id: Identifier
|
||||
status: Literal["pass", "pending", "fail", "not_applicable"]
|
||||
evidence_refs: list[Identifier] = Field(default_factory=list, max_length=16)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _evidence_refs_are_unique(self) -> "ClaimCoverage":
|
||||
if len(self.evidence_refs) != len(set(self.evidence_refs)):
|
||||
raise ValueError("evidence_refs must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
class CandidateReview(StrictDto):
|
||||
candidate_id: Identifier = Field(description="Server-issued candidate ID from the review facts.")
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the review facts.")]
|
||||
verdict: Literal["accept", "reject"] = Field(description="Required independent decision. Set accept only when the supplied candidate evidence supports every covered claim; otherwise set reject.")
|
||||
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied candidate facts.")
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class StatelessCandidateReview(StrictDto):
|
||||
verdict: Literal["accept", "reject"]
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class VisualClaimDecision(StrictDto):
|
||||
status: Literal["pass", "fail"]
|
||||
evidence: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
|
||||
|
||||
class StatelessFinalReview(StrictDto):
|
||||
verdict: Literal["pass", "repair"]
|
||||
visual_claims: list[VisualClaimDecision] = Field(default_factory=list, max_length=128)
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class ImageMeasurement(StrictDto):
|
||||
name: Annotated[str, Field(min_length=1, max_length=160)]
|
||||
value: float | None = None
|
||||
unit: Literal["mm", "degree", "count", "unknown"] = "unknown"
|
||||
evidence: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
confidence: float = Field(ge=0, le=1)
|
||||
|
||||
|
||||
class ImageObservation(StrictDto):
|
||||
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||
visible_features: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||
measurements: list[ImageMeasurement] = Field(default_factory=list, max_length=128)
|
||||
view_directions: list[Annotated[str, Field(min_length=1, max_length=120)]] = Field(default_factory=list, max_length=16)
|
||||
uncertainties: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||
|
||||
|
||||
class FinalReview(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")]
|
||||
verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.")
|
||||
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied final-review facts.")
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]:
|
||||
schema = RequirementsAuthorOutput.model_json_schema()
|
||||
requirement = schema.get("$defs", {}).get("SpecRequirementInput")
|
||||
if isinstance(requirement, dict):
|
||||
claims = requirement.get("properties", {}).get("acceptance_claims")
|
||||
if isinstance(claims, dict):
|
||||
claims["items"] = deepcopy(claim_one_of)
|
||||
return schema
|
||||
|
||||
|
||||
def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]:
|
||||
schema = CompiledRequirementsSpec.model_json_schema()
|
||||
definitions = schema.get("$defs", {})
|
||||
requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None
|
||||
if isinstance(requirement, dict):
|
||||
claims = requirement.get("properties", {}).get("acceptance_claims")
|
||||
if isinstance(claims, dict):
|
||||
claims["items"] = deepcopy(claim_one_of)
|
||||
requirements = schema.get("properties", {}).get("requirements")
|
||||
if isinstance(requirements, dict):
|
||||
requirements["minItems"] = target_count
|
||||
requirements["maxItems"] = target_count
|
||||
return schema
|
||||
|
||||
|
||||
def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError:
|
||||
"""Drop harmless compiler chatter before strict requirements validation.
|
||||
|
||||
``compile_requirements_spec`` is a compiler stage: the service only needs
|
||||
the ordered verifier bundles for the frozen checklist items. Real models
|
||||
sometimes add explanatory fields such as a top-level ``assumptions`` or
|
||||
per-item ``statement`` even when the dynamic tool schema forbids them. Those
|
||||
fields are not executable and are not part of the frozen contract, so they
|
||||
should not abort a task before modeling starts.
|
||||
|
||||
The verifier ``expected`` payload is intentionally not sanitized here. It
|
||||
remains governed by the registry's strict per-claim schema because those
|
||||
values drive deterministic validation.
|
||||
"""
|
||||
value = canonical_json_object(raw_arguments_json)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
requirements = value.get("requirements")
|
||||
sanitized: dict[str, Any] = {}
|
||||
if isinstance(requirements, list):
|
||||
sanitized_requirements: list[Any] = []
|
||||
for requirement in requirements:
|
||||
if not isinstance(requirement, dict):
|
||||
sanitized_requirements.append(requirement)
|
||||
continue
|
||||
item: dict[str, Any] = {}
|
||||
if "assumptions" in requirement:
|
||||
item["assumptions"] = requirement["assumptions"]
|
||||
if "acceptance_claims" in requirement:
|
||||
claims = requirement["acceptance_claims"]
|
||||
if isinstance(claims, list):
|
||||
item["acceptance_claims"] = [
|
||||
{key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim}
|
||||
if isinstance(claim, dict) else claim
|
||||
for claim in claims
|
||||
]
|
||||
else:
|
||||
item["acceptance_claims"] = claims
|
||||
sanitized_requirements.append(item)
|
||||
sanitized["requirements"] = sanitized_requirements
|
||||
else:
|
||||
sanitized["requirements"] = requirements
|
||||
return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]:
|
||||
schema = StatelessNextAction.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["operation"] = {"enum": atomic_ids}
|
||||
return schema
|
||||
|
||||
|
||||
def stateless_final_review_schema(visual_claim_count: int) -> dict[str, Any]:
|
||||
schema = StatelessFinalReview.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
visual = properties.get("visual_claims") if isinstance(properties, dict) else None
|
||||
if isinstance(visual, dict):
|
||||
visual["minItems"] = visual_claim_count
|
||||
visual["maxItems"] = visual_claim_count
|
||||
return schema
|
||||
|
||||
|
||||
def stateless_rollback_checkpoint_schema(checkpoint_tokens: list[str]) -> dict[str, Any]:
|
||||
schema = StatelessRollbackCheckpoint.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
|
||||
return schema
|
||||
|
||||
|
||||
def topology_request_schema(working_head: str) -> dict[str, Any]:
|
||||
schema = TopologyRequest.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["working_head"] = {"const": working_head}
|
||||
return schema
|
||||
|
||||
|
||||
def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]:
|
||||
"""Bind a rollback request to immutable checkpoints in the active lineage."""
|
||||
schema = RollbackCheckpoint.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["working_head"] = {"const": working_head}
|
||||
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
|
||||
return schema
|
||||
|
||||
|
||||
def _bind_claim_coverage_ids(schema: dict[str, Any], claim_ids: list[str]) -> None:
|
||||
definitions = schema.get("$defs", {})
|
||||
coverage = definitions.get("ClaimCoverage") if isinstance(definitions, dict) else None
|
||||
if not isinstance(coverage, dict):
|
||||
return
|
||||
properties = coverage.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["claim_id"] = {"enum": claim_ids}
|
||||
|
||||
|
||||
Dto = TypeVar("Dto", bound=StrictDto)
|
||||
|
||||
|
||||
def raw_arguments_hash(raw_arguments_json: str) -> str:
|
||||
return hashlib.sha256(raw_arguments_json.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def json_depth(value: Any, current: int = 0) -> int:
|
||||
if isinstance(value, dict):
|
||||
return max([current, *(json_depth(item, current + 1) for item in value.values())])
|
||||
if isinstance(value, list):
|
||||
return max([current, *(json_depth(item, current + 1) for item in value)])
|
||||
return current
|
||||
|
||||
|
||||
def canonical_json_object(raw_arguments_json: str, *, max_bytes: int = 48_000, max_depth: int = 16) -> dict[str, Any] | WorkflowError:
|
||||
"""Bound and parse raw arguments before any schema-specific validation."""
|
||||
if len(raw_arguments_json.encode("utf-8")) > max_bytes:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the byte limit.")
|
||||
try:
|
||||
value = json.loads(raw_arguments_json)
|
||||
except json.JSONDecodeError as error:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments are not valid JSON.", field_errors=({"path": "/", "message": error.msg},))
|
||||
if not isinstance(value, dict):
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must be a JSON object.")
|
||||
if json_depth(value) > max_depth:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the nesting-depth limit.")
|
||||
if _contains_non_finite_number(value):
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must not contain NaN or infinite numbers.")
|
||||
return value
|
||||
|
||||
|
||||
def _contains_non_finite_number(value: Any) -> bool:
|
||||
if isinstance(value, float):
|
||||
return not math.isfinite(value)
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_non_finite_number(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_non_finite_number(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def canonical_validate_schema(raw_arguments_json: str, schema: dict[str, Any]) -> WorkflowError | None:
|
||||
"""Revalidate raw arguments against the current dynamic JSON Schema."""
|
||||
value = canonical_json_object(raw_arguments_json)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
errors = [
|
||||
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
|
||||
for error in sorted(Draft202012Validator(schema).iter_errors(value), key=lambda item: (list(item.absolute_path), item.message))
|
||||
]
|
||||
if errors:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the active dynamic schema.", field_errors=tuple(errors))
|
||||
return None
|
||||
|
||||
|
||||
def canonical_validate(raw_arguments_json: str, model: type[Dto], *, max_bytes: int = 48_000, max_depth: int = 16) -> Dto | WorkflowError:
|
||||
"""Parse raw tool arguments once and return field-level DTO failures safely."""
|
||||
value = canonical_json_object(raw_arguments_json, max_bytes=max_bytes, max_depth=max_depth)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
try:
|
||||
return model.model_validate(value)
|
||||
except ValidationError as error:
|
||||
fields = tuple({"path": "/" + "/".join(str(part) for part in issue["loc"]), "message": issue["msg"]} for issue in error.errors())
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the canonical schema.", field_errors=fields)
|
||||
|
||||
|
||||
def validate_one_tool_call(tool_calls: list[dict[str, Any]], allowed_name: str) -> tuple[str, str] | WorkflowError:
|
||||
"""Require exactly one known tool call; no provider parser is trusted."""
|
||||
if len(tool_calls) != 1:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Exactly one tool call is required.")
|
||||
function = tool_calls[0].get("function") if isinstance(tool_calls[0], dict) else None
|
||||
name = str(function.get("name") or "") if isinstance(function, dict) else ""
|
||||
raw = str(function.get("arguments") or "") if isinstance(function, dict) else ""
|
||||
if name != allowed_name:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "The returned tool is not allowed in this workflow state.", details={"expected_tool": allowed_name, "actual_tool": name})
|
||||
return name, raw
|
||||
@@ -1,670 +0,0 @@
|
||||
"""Immutable Markdown-first requirements artifacts and compiled contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
|
||||
from app.cad_agent.application.results import Accepted, Rejected
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
|
||||
from app.cad_agent.domain.verifier_registry import VerifierRegistry
|
||||
from app.cad_agent.ports import ArtifactStore, TaskRepository
|
||||
|
||||
|
||||
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
|
||||
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
|
||||
_CENTERED_BORE_MARKERS = ("centered", "concentric", "coaxial", "中心", "同心", "同轴")
|
||||
_BORE_MARKERS = ("bore", "hole", "孔")
|
||||
_OBROUND_SLOT_MARKERS = ("oblong", "slot", "slotted", "腰形", "长圆", "调节槽")
|
||||
|
||||
|
||||
class RequirementsCommandHandler:
|
||||
"""Persist frozen documents and compile their checklist into a contract.
|
||||
|
||||
The model never names targets or internal objects during compilation. The
|
||||
service derives those values strictly from the immutable checklist.
|
||||
"""
|
||||
|
||||
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry, *, atomic_ids: Callable[[], tuple[str, ...]] | None = None) -> None:
|
||||
self.repository = repository
|
||||
self.artifacts = artifacts
|
||||
self.registry = registry
|
||||
self.atomic_ids = atomic_ids or (lambda: ())
|
||||
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
|
||||
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
|
||||
|
||||
def register_evaluation_contract_oracle(self, task_id: str, required_claims: list[dict[str, Any]], *, validation_capability_gaps: list[dict[str, Any]] | None = None) -> None:
|
||||
self._evaluation_contract_oracles[task_id] = deepcopy(required_claims)
|
||||
self._evaluation_capability_gaps[task_id] = [
|
||||
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
|
||||
for item in validation_capability_gaps or () if isinstance(item, dict)
|
||||
]
|
||||
|
||||
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
|
||||
claims = self._evaluation_contract_oracles.get(task_id)
|
||||
return None if claims is None else {
|
||||
"evaluation_only": True,
|
||||
"required_claims": deepcopy(claims),
|
||||
"known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def document_schema() -> dict[str, Any]:
|
||||
return MarkdownDocument.model_json_schema()
|
||||
|
||||
def compiler_schema(self, task_id: str) -> dict[str, Any]:
|
||||
return compiled_requirements_schema(
|
||||
self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS),
|
||||
len(self._checklist_items(task_id)),
|
||||
)
|
||||
|
||||
def feature_plan_schema(self, task_id: str) -> dict[str, Any]:
|
||||
"""Return the plan tool schema bound to the persisted planning state.
|
||||
|
||||
A model is allowed to choose node content, but it must not guess the
|
||||
immutable lineage identifiers of a plan revision. Binding those values
|
||||
as enums prevents a requirements-contract hash (or a stale plan hash)
|
||||
from being mistaken for ``parent_plan_hash``.
|
||||
"""
|
||||
schema = FeaturePlan.model_json_schema()
|
||||
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
||||
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), dict) else {}
|
||||
node = definitions.get("FeatureNode") if isinstance(definitions, dict) else None
|
||||
node_properties = node.get("properties") if isinstance(node, dict) else None
|
||||
atomic = node_properties.get("atomic_id") if isinstance(node_properties, dict) else None
|
||||
if isinstance(atomic, dict):
|
||||
atomic["enum"] = list(self.atomic_ids())
|
||||
state = self.repository.get_state(task_id)
|
||||
previous: FeaturePlan | None = None
|
||||
if state is not None and state.feature_plan_path:
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
previous = FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
previous = None
|
||||
parent_hash = plan_hash(previous) if previous is not None else ""
|
||||
replacements = sorted(self._required_replacements(previous, task_id)) if previous is not None else []
|
||||
parent = properties.get("parent_plan_hash") if isinstance(properties, dict) else None
|
||||
if isinstance(parent, dict):
|
||||
parent["enum"] = [parent_hash]
|
||||
replaced = properties.get("replaces_node_ids") if isinstance(properties, dict) else None
|
||||
if isinstance(replaced, dict):
|
||||
replaced.update({
|
||||
"type": "array",
|
||||
"uniqueItems": True,
|
||||
"minItems": len(replacements),
|
||||
"maxItems": len(replacements),
|
||||
"items": {"enum": replacements},
|
||||
})
|
||||
contract = self.artifacts.read_requirements_contract(
|
||||
task_id,
|
||||
state.requirements_contract_path if state is not None else "",
|
||||
) or {}
|
||||
deterministic_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") == "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
visual_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
node_claim_ids = node_properties.get("claim_ids") if isinstance(node_properties, dict) else None
|
||||
if isinstance(node_claim_ids, dict):
|
||||
# Authors occasionally repeat a visual claim on the node that
|
||||
# creates the feature as well as in final_claim_ids. Accept that
|
||||
# harmless reference at the tool boundary; submit_feature_plan()
|
||||
# removes it before the immutable DAG is validated and written.
|
||||
node_claim_ids["items"] = {"enum": [*deterministic_claim_ids, *visual_claim_ids]}
|
||||
final_claim_ids = properties.get("final_claim_ids") if isinstance(properties, dict) else None
|
||||
if isinstance(final_claim_ids, dict):
|
||||
final_claim_ids["items"] = {"enum": visual_claim_ids}
|
||||
final_claim_ids["maxItems"] = len(visual_claim_ids)
|
||||
return schema
|
||||
|
||||
def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document)
|
||||
|
||||
def submit_completion_target(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target)
|
||||
|
||||
def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
# Compatibility shim for callers compiled against v3.1. The v3.2
|
||||
# coordinator never offers this method to an LLM.
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.COMPILING_FEATURE_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
|
||||
|
||||
def submit_feature_plan(self, task_id: str, plan: FeaturePlan, *, invocation_id: str) -> Accepted | Rejected:
|
||||
"""Freeze a validated initial plan or full subgraph plan revision."""
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase not in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature plan is not expected in the current workflow phase."))
|
||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
||||
if not isinstance(contract, dict):
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements contract is unavailable for feature planning."))
|
||||
plan = self._normalize_feature_plan_visual_references(plan, contract)
|
||||
plan = self._assign_unowned_global_health_claims(plan, contract)
|
||||
previous: FeaturePlan | None = None
|
||||
completed: dict[str, str] = {}
|
||||
if state.feature_plan_path:
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
previous = FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan artifact is invalid.", retryable=True))
|
||||
completed = FeatureScheduler(previous, self.repository.ledger_events(task_id)).completed_node_hashes()
|
||||
required_replacements = self._required_replacements(previous, task_id) if previous is not None else set()
|
||||
errors = validate_feature_plan(plan, contract, self.atomic_ids(), previous_plan=previous, completed_node_hashes=completed, required_replacements=required_replacements)
|
||||
if errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan does not satisfy the frozen contract.", field_errors=tuple(errors)))
|
||||
digest = plan_hash(plan)
|
||||
path = f"plans/feature-plan-{digest}.json"
|
||||
event = "feature_plan_written" if previous is None else "feature_plan_revised"
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, plan.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
try:
|
||||
written = self.artifacts.write_json_once(task_id, path, plan.model_dump(mode="json"))
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
next_state = transition(state, event, feature_plan_path=written, feature_plan_hash=digest)
|
||||
result = Accepted({"phase": next_state.phase.value, "path": written, "plan_hash": digest, "node_count": len(plan.nodes)})
|
||||
events: list[dict[str, Any]] = [{
|
||||
"event": event,
|
||||
"invocation_id": invocation_id,
|
||||
"plan_path": written,
|
||||
"plan_hash": digest,
|
||||
"parent_plan_hash": plan.parent_plan_hash,
|
||||
"replaces_node_ids": plan.replaces_node_ids,
|
||||
}]
|
||||
if previous is not None:
|
||||
old_nodes = {node.node_id: node for node in previous.nodes}
|
||||
old_hash = plan_hash(previous)
|
||||
events.extend({
|
||||
"event": "feature_node_invalidated",
|
||||
"node_id": node_id,
|
||||
"node_hash": node_hash(old_nodes[node_id]),
|
||||
"plan_hash": old_hash,
|
||||
"replacement_plan_hash": digest,
|
||||
} for node_id in plan.replaces_node_ids)
|
||||
if not self._commit(next_state, events, invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _normalize_feature_plan_visual_references(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
||||
"""Drop non-owning visual references from feature nodes.
|
||||
|
||||
A node's ``claim_ids`` drive synchronous deterministic acceptance.
|
||||
Visual claims are owned solely by ``final_claim_ids`` and have no
|
||||
node-local verifier. Retaining a repeated visual ID therefore adds
|
||||
no behavior and turns an otherwise valid plan into a schema retry.
|
||||
The contract validation below still requires every visual claim to be
|
||||
present exactly once in ``final_claim_ids``.
|
||||
"""
|
||||
visual_claim_ids = {
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
}
|
||||
if not visual_claim_ids or not any(
|
||||
claim_id in visual_claim_ids
|
||||
for node in plan.nodes
|
||||
for claim_id in node.claim_ids
|
||||
):
|
||||
return plan
|
||||
normalized = plan.model_copy(deep=True)
|
||||
for node in normalized.nodes:
|
||||
node.claim_ids = [claim_id for claim_id in node.claim_ids if claim_id not in visual_claim_ids]
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _assign_unowned_global_health_claims(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
||||
"""Bind global solid-health claims to the unique root body feature.
|
||||
|
||||
``single_connected_body`` and ``solid_count_equals`` are checked as
|
||||
global health on every feature checkpoint. When a plan has exactly
|
||||
one root additive feature, their node owner is consequently
|
||||
determined without choosing any geometry strategy. This prevents an
|
||||
otherwise complete plan from failing merely because an author omitted
|
||||
the redundant ownership annotation.
|
||||
"""
|
||||
claims = {
|
||||
str(claim.get("claim_id") or ""): str(claim.get("claim_kind") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str)
|
||||
}
|
||||
assigned = {claim_id for node in plan.nodes for claim_id in node.claim_ids}
|
||||
unowned = [
|
||||
claim_id for claim_id, claim_kind in claims.items()
|
||||
if claim_id not in assigned and claim_kind in {"single_connected_body", "solid_count_equals"}
|
||||
]
|
||||
roots = [
|
||||
node for node in plan.nodes
|
||||
if not node.depends_on and node.atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add", "sphere_add"}
|
||||
]
|
||||
if not unowned or len(roots) != 1:
|
||||
return plan
|
||||
normalized = plan.model_copy(deep=True)
|
||||
root_id = roots[0].node_id
|
||||
for node in normalized.nodes:
|
||||
if node.node_id == root_id:
|
||||
node.claim_ids = [*node.claim_ids, *unowned]
|
||||
break
|
||||
return normalized
|
||||
|
||||
def _required_replacements(self, plan: FeaturePlan, task_id: str) -> set[str]:
|
||||
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
||||
statuses = scheduler.statuses()
|
||||
failed = {node_id for node_id, status in statuses.items() if status == "failed"}
|
||||
if not failed:
|
||||
return set()
|
||||
children: dict[str, set[str]] = {node.node_id: set() for node in plan.nodes}
|
||||
for node in plan.nodes:
|
||||
for dependency in node.depends_on:
|
||||
children.setdefault(dependency, set()).add(node.node_id)
|
||||
result = set(failed)
|
||||
pending = list(failed)
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
for child in children.get(current, set()):
|
||||
if statuses.get(child) != "done" and child not in result:
|
||||
result.add(child)
|
||||
pending.append(child)
|
||||
return result
|
||||
|
||||
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected:
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != TaskPhase.COMPILING_REQUIREMENTS:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase."))
|
||||
targets = self._checklist_items(task_id)
|
||||
if len(output.requirements) != len(targets):
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},)))
|
||||
normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets)
|
||||
field_errors = [
|
||||
*self._claim_errors(normalized_output),
|
||||
*self._relationship_claim_errors(normalized_output, targets),
|
||||
]
|
||||
if field_errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation contains an unreadable or non-executable acceptance target.", field_errors=tuple(field_errors)))
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_compilation", state.working_head, normalized_output.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
source_ids = list(self.artifacts.read_source_index(task_id))
|
||||
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
|
||||
warnings = [
|
||||
*[str(value) for value in observation.get("uncertainties") or () if str(value)],
|
||||
*compiler_warnings,
|
||||
]
|
||||
requirements: list[dict[str, Any]] = []
|
||||
claim_position = 1
|
||||
for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1):
|
||||
claims: list[dict[str, Any]] = []
|
||||
for claim in compiled.acceptance_claims:
|
||||
definition = self.registry.definition(claim.claim_kind)
|
||||
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"})
|
||||
claim_position += 1
|
||||
requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims})
|
||||
spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]}
|
||||
contract = {"schema_version": "cad.requirements-contract.v3.2", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
|
||||
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
try:
|
||||
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
|
||||
contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id)
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path)
|
||||
result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)})
|
||||
if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
def write_completion_result(self, task_id: str, state: TaskState, *, claim_results: list[dict[str, Any]], review: dict[str, Any]) -> str:
|
||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
|
||||
by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)}
|
||||
visual = iter(review.get("visual_claims") or ())
|
||||
rows = ["# Completion Result", "", "## Checklist", ""]
|
||||
for requirement in contract.get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
statuses: list[str] = []
|
||||
evidence: list[str] = []
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if not isinstance(claim, dict):
|
||||
continue
|
||||
result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {})
|
||||
statuses.append(str(result.get("status") or "unknown"))
|
||||
value = result.get("evidence")
|
||||
if value:
|
||||
evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True))
|
||||
rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}")
|
||||
rows.extend(f" - Evidence: {value}" for value in evidence)
|
||||
return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n")
|
||||
|
||||
def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, str]]]) -> Accepted | Rejected:
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != phase:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase."))
|
||||
errors = validator(document.markdown)
|
||||
if errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors)))
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
try:
|
||||
written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n")
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written}
|
||||
next_state = transition(state, event, **kwargs)
|
||||
result = Accepted({"phase": next_state.phase.value, "path": written})
|
||||
if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
def _checklist_items(self, task_id: str) -> list[str]:
|
||||
path = self.artifacts.task_dir(task_id) / "completion-target.md"
|
||||
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))]
|
||||
|
||||
def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]:
|
||||
errors: list[dict[str, str]] = []
|
||||
for requirement_index, requirement in enumerate(output.requirements):
|
||||
for claim_index, claim in enumerate(requirement.acceptance_claims):
|
||||
try:
|
||||
messages = self.registry.validate_expected(claim.claim_kind, claim.expected)
|
||||
except ValueError:
|
||||
messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
|
||||
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def _relationship_claim_errors(output: CompiledRequirementsSpec, targets: list[str]) -> list[dict[str, str]]:
|
||||
"""Require explicit coverage for an unambiguous centered-bore target.
|
||||
|
||||
The compiler remains free to choose claims for open-ended CAD prose.
|
||||
A checklist item that explicitly says a bore is centered/concentric is
|
||||
different: dropping that relationship leaves a measurable user fact
|
||||
with no acceptance owner. The service only checks its presence here;
|
||||
the registry validates its numeric parameters independently.
|
||||
"""
|
||||
errors: list[dict[str, str]] = []
|
||||
for index, (target, requirement) in enumerate(zip(targets, output.requirements, strict=True)):
|
||||
lowered = target.casefold()
|
||||
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
||||
continue
|
||||
if not any(marker in lowered for marker in _BORE_MARKERS):
|
||||
continue
|
||||
if any(
|
||||
claim.claim_kind == "concentric_bore_to_outer_cylinder"
|
||||
for claim in requirement.acceptance_claims
|
||||
):
|
||||
continue
|
||||
errors.append({
|
||||
"path": f"/requirements/{index}/acceptance_claims",
|
||||
"message": "A centered or concentric bore requires concentric_bore_to_outer_cylinder coverage.",
|
||||
})
|
||||
return errors
|
||||
|
||||
def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]:
|
||||
normalized = output.model_copy(deep=True)
|
||||
warnings: list[str] = []
|
||||
for target, requirement in zip(targets, normalized.requirements, strict=True):
|
||||
normalized_claims = []
|
||||
for claim in requirement.acceptance_claims:
|
||||
if self._is_slot_misclassified_as_corner_bore_pattern(claim, target):
|
||||
warnings.append(
|
||||
f"rectangular_corner_through_bore_pattern for checklist item '{target}' describes an obround slot, not four circular bores, so it was compiled as visual review."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if self._is_unbacked_coaxial_bore_group(normalized, claim):
|
||||
warnings.append(
|
||||
f"coaxial_through_bore_group for checklist item '{target}' has no matching multi-bore target, so it was compiled as visual review. "
|
||||
"Use concentric_bore_to_outer_cylinder for one central bore and one outer cylinder."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if claim.claim_kind in _RECORD_BOUND_CLAIMS:
|
||||
warnings.append(
|
||||
f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target):
|
||||
self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim)
|
||||
warnings.append(
|
||||
f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope."
|
||||
)
|
||||
continue
|
||||
claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected)
|
||||
normalized_claims.append(claim)
|
||||
requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "visual",
|
||||
"expected": {"description": target[:360]},
|
||||
})]
|
||||
self._derive_centered_bore_claims(normalized, targets)
|
||||
return normalized, list(dict.fromkeys(warnings))
|
||||
|
||||
@staticmethod
|
||||
def _is_slot_misclassified_as_corner_bore_pattern(claim: Any, target: str) -> bool:
|
||||
"""Keep a circular-hole verifier from accepting or rejecting a slot.
|
||||
|
||||
``rectangular_corner_through_bore_pattern`` measures four complete
|
||||
cylindrical bores at equal edge offsets. An obround slot has two arc
|
||||
ends and straight flanks; treating its stated length as an edge offset
|
||||
produces an unsatisfiable contract even when the CAD is correct.
|
||||
"""
|
||||
return (
|
||||
getattr(claim, "claim_kind", "") == "rectangular_corner_through_bore_pattern"
|
||||
and any(marker in target.casefold() for marker in _OBROUND_SLOT_MARKERS)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _derive_centered_bore_claims(output: CompiledRequirementsSpec, targets: list[str]) -> None:
|
||||
"""Attach a measurable concentricity claim when its inputs are frozen.
|
||||
|
||||
The requirements compiler receives a Markdown checklist, not runtime
|
||||
geometry IDs. Once it has already compiled an external cylindrical
|
||||
diameter and an explicitly centred bore diameter, their relationship
|
||||
is a service-owned mechanical consequence. Requiring an author to
|
||||
remember the internal verifier name makes a complete user request
|
||||
fail for a bookkeeping omission rather than a CAD decision.
|
||||
"""
|
||||
outer_diameters: list[float] = []
|
||||
for requirement in output.requirements:
|
||||
for claim in requirement.acceptance_claims:
|
||||
expected = claim.expected
|
||||
diameter = expected.get("diameter_mm") if isinstance(expected, dict) else None
|
||||
if claim.claim_kind == "outer_cylindrical_surface" and isinstance(diameter, (int, float)) and float(diameter) > 0:
|
||||
outer_diameters.append(float(diameter))
|
||||
if not outer_diameters:
|
||||
return
|
||||
outer_diameter = max(outer_diameters)
|
||||
bore_claim_kinds = frozenset({"through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth"})
|
||||
for target, requirement in zip(targets, output.requirements, strict=True):
|
||||
lowered = target.casefold()
|
||||
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
||||
continue
|
||||
if not any(marker in lowered for marker in _BORE_MARKERS):
|
||||
continue
|
||||
if any(claim.claim_kind == "concentric_bore_to_outer_cylinder" for claim in requirement.acceptance_claims):
|
||||
continue
|
||||
bore_diameter = next((
|
||||
float(claim.expected["diameter_mm"])
|
||||
for claim in requirement.acceptance_claims
|
||||
if claim.claim_kind in bore_claim_kinds
|
||||
and isinstance(claim.expected, dict)
|
||||
and isinstance(claim.expected.get("diameter_mm"), (int, float))
|
||||
and float(claim.expected["diameter_mm"]) > 0
|
||||
), None)
|
||||
if bore_diameter is None:
|
||||
continue
|
||||
requirement.acceptance_claims.append(AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "concentric_bore_to_outer_cylinder",
|
||||
"expected": {
|
||||
"bore_diameter_mm": bore_diameter,
|
||||
"outer_diameter_mm": outer_diameter,
|
||||
"tolerance_mm": 0.01,
|
||||
},
|
||||
}))
|
||||
|
||||
@staticmethod
|
||||
def _is_unbacked_coaxial_bore_group(output: CompiledRequirementsSpec, claim: Any) -> bool:
|
||||
"""Reject a bore-group verifier when the contract has no such group.
|
||||
|
||||
``coaxial_through_bore_group`` measures multiple inner bores of one
|
||||
diameter. It cannot prove a lone central bore is concentric with an
|
||||
external cylindrical wall. This is a mechanical consistency check:
|
||||
some through-bore claim must request at least the group count.
|
||||
"""
|
||||
if getattr(claim, "claim_kind", "") != "coaxial_through_bore_group":
|
||||
return False
|
||||
expected = getattr(claim, "expected", {})
|
||||
if not isinstance(expected, dict):
|
||||
return True
|
||||
diameter = expected.get("diameter_mm")
|
||||
count = expected.get("count")
|
||||
if not isinstance(diameter, (int, float)) or not isinstance(count, int):
|
||||
return True
|
||||
for requirement in output.requirements:
|
||||
for candidate in requirement.acceptance_claims:
|
||||
candidate_expected = getattr(candidate, "expected", {})
|
||||
if (
|
||||
getattr(candidate, "claim_kind", "") == "through_cylindrical_bore"
|
||||
and isinstance(candidate_expected, dict)
|
||||
and isinstance(candidate_expected.get("diameter_mm"), (int, float))
|
||||
and isinstance(candidate_expected.get("count"), int)
|
||||
and abs(float(candidate_expected["diameter_mm"]) - float(diameter)) <= 1e-9
|
||||
and int(candidate_expected["count"]) >= count
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool:
|
||||
if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z":
|
||||
return False
|
||||
if RequirementsCommandHandler._target_describes_finished_envelope(target):
|
||||
return False
|
||||
return any(
|
||||
getattr(item, "claim_kind", "") == "outer_cylindrical_surface"
|
||||
for item in claims
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _target_describes_finished_envelope(target: str) -> bool:
|
||||
lowered = target.lower()
|
||||
return any(token in lowered for token in (
|
||||
"overall",
|
||||
"total",
|
||||
"finished part",
|
||||
"entire part",
|
||||
"whole part",
|
||||
"bounding box",
|
||||
"envelope",
|
||||
"总",
|
||||
"整体",
|
||||
"成品",
|
||||
"全高",
|
||||
"包围盒",
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None:
|
||||
value = bbox_claim.expected.get("value")
|
||||
if not isinstance(value, (int, float)):
|
||||
return
|
||||
for item in claims:
|
||||
if getattr(item, "claim_kind", "") != "outer_cylindrical_surface":
|
||||
continue
|
||||
expected = getattr(item, "expected", None)
|
||||
if not isinstance(expected, dict) or "axial_span_mm" in expected:
|
||||
continue
|
||||
expected["axial_span_mm"] = value
|
||||
if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)):
|
||||
expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"]
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _validate_requirements_document(markdown: str) -> list[dict[str, str]]:
|
||||
# Markdown is a human-facing semantic artifact. Its content is frozen
|
||||
# verbatim and is not executable, so headings are guidance for the
|
||||
# author rather than a server-enforced protocol.
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _validate_completion_target(markdown: str) -> list[dict[str, str]]:
|
||||
values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))]
|
||||
errors: list[dict[str, str]] = []
|
||||
if not values:
|
||||
errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."})
|
||||
if len(values) != len(set(values)):
|
||||
errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."})
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]:
|
||||
return []
|
||||
|
||||
def _replay(self, task_id: str, invocation_id: str) -> Accepted | None:
|
||||
invocation = self.repository.get_invocation(task_id, invocation_id)
|
||||
return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None
|
||||
|
||||
@staticmethod
|
||||
def _restore(payload: dict[str, Any]) -> Accepted:
|
||||
return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload)
|
||||
|
||||
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool:
|
||||
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload})
|
||||
|
||||
@staticmethod
|
||||
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _stale() -> WorkflowError:
|
||||
return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.")
|
||||
|
||||
def _park_for_storage_retry(self, state: TaskState, message: str) -> Rejected:
|
||||
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
|
||||
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.STORAGE_FAILURE.value, "message": message[:1000]}])
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True))
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Typed results returned by all command handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.errors import WorkflowError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Accepted:
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rejected:
|
||||
error: WorkflowError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Waiting:
|
||||
error: WorkflowError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FailedInternal:
|
||||
correlation_id: str
|
||||
message: str
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Deterministic execution service for the single-stage Authoring protocol.
|
||||
|
||||
The service is deliberately synchronous and side-effect bounded so the HTTP
|
||||
workflow can call it after one author response (and at most two repairs).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.ports import AdapterUnavailable
|
||||
|
||||
class SingleStageExecutor:
|
||||
MAX_REPAIRS = 2
|
||||
|
||||
def __init__(self, repository: Any, artifacts: Any, runtime: Any) -> None:
|
||||
self.repository, self.artifacts, self.runtime = repository, artifacts, runtime
|
||||
|
||||
def compile(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
|
||||
"""Compile once and persist immutable compiler inputs and output."""
|
||||
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
|
||||
raise ValueError("repair budget exhausted")
|
||||
runtime_cdsl, audit = self.runtime.compile_authoring(authoring)
|
||||
digest = self._digest(authoring)
|
||||
attempt = repair_count + 1
|
||||
runtime_path = f"documents/runtime-cdsl-attempt-{attempt:02d}-{digest[:8]}.json"
|
||||
audit_path = f"documents/compile-audit-attempt-{attempt:02d}-{digest[:8]}.json"
|
||||
self.artifacts.write_json_once(task_id, runtime_path, runtime_cdsl)
|
||||
self.artifacts.write_json_once(task_id, audit_path, audit)
|
||||
return {"runtime": runtime_cdsl, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": digest}
|
||||
|
||||
def build(self, task_id: str, authoring: dict[str, Any], runtime_cdsl: dict[str, Any], audit: dict[str, Any], *, repair_count: int, digest: str = "") -> dict[str, Any]:
|
||||
"""Build one compiled CDSL document and publish any executable prefix."""
|
||||
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
|
||||
raise ValueError("repair budget exhausted")
|
||||
digest = digest or self._digest(authoring)
|
||||
stage = self.artifacts.start_staging_revision(task_id, f"single_{repair_count}_{digest}", {
|
||||
"schema_version": "cad.single-stage.v1", "authoring": authoring,
|
||||
"runtime": runtime_cdsl, "compile_audit": audit,
|
||||
})
|
||||
try:
|
||||
rebuilt, failures = self.runtime.rebuild_best_effort(runtime_cdsl, stage.output_dir, task_id, stage.stage_id)
|
||||
except (OSError, AdapterUnavailable):
|
||||
raise
|
||||
except Exception as error:
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
|
||||
"schema_version": "cad.build-diagnostics.v1",
|
||||
"diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}],
|
||||
})
|
||||
return {"status": "failed", "repair_count": repair_count, "diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}], "compile_audit": audit}
|
||||
diagnostics = self._decorate_diagnostics(failures, audit)
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
|
||||
"schema_version": "cad.build-diagnostics.v1",
|
||||
"diagnostics": diagnostics,
|
||||
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
|
||||
})
|
||||
if rebuilt:
|
||||
revision = f"rev_{digest}"
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "staging-manifest.json", {
|
||||
"schema_version": "cad.single-stage.staging-manifest.v1",
|
||||
"stage_id": stage.stage_id,
|
||||
"revision_id": revision,
|
||||
"repair_count": repair_count,
|
||||
"source_sha256": audit.get("source_sha256", ""),
|
||||
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
|
||||
})
|
||||
paths = self.artifacts.publish_staging_revision(task_id, stage.stage_id, revision)
|
||||
else:
|
||||
paths = {}
|
||||
return {"status": "published_best_effort" if diagnostics else "completed", "repair_count": repair_count, "paths": paths, "revision_id": revision if rebuilt else "", "executed_feature_ids": rebuilt.get("executed_feature_ids", []), "diagnostics": diagnostics, "compile_audit": audit}
|
||||
|
||||
def execute(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
|
||||
"""Compatibility convenience for direct callers and focused tests."""
|
||||
compiled = self.compile(task_id, authoring, repair_count=repair_count)
|
||||
return {
|
||||
**self.build(task_id, authoring, compiled["runtime"], compiled["compile_audit"], repair_count=repair_count, digest=compiled["digest"]),
|
||||
"runtime_path": compiled["runtime_path"], "audit_path": compiled["audit_path"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _digest(authoring: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(authoring, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
return sha256(encoded).hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def _decorate_diagnostics(failures: list[dict[str, Any]], audit: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
names = {
|
||||
str(feature_id): str(name)
|
||||
for name, feature_id in (audit.get("feature_ids") or {}).items()
|
||||
if isinstance(name, str) and isinstance(feature_id, str)
|
||||
}
|
||||
result: list[dict[str, Any]] = []
|
||||
for failure in failures:
|
||||
if not isinstance(failure, dict):
|
||||
continue
|
||||
feature_id = str(failure.get("feature_id") or "")
|
||||
result.append({**failure, "feature_name": names.get(feature_id, "")})
|
||||
return result
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
"""Protocol v3 composition root. This is the only layer joining adapters."""
|
||||
"""Single-stage Authoring CDSL composition root."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,70 +6,58 @@ 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
|
||||
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
|
||||
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
|
||||
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
|
||||
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
||||
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 BACKEND_ROOT, Settings
|
||||
from app.cad_agent.application.workflow import WorkflowConfig, WorkflowCoordinator
|
||||
from app.cad_agent.application.single_stage import SingleStageExecutor
|
||||
from app.settings import Settings
|
||||
from app.services.storage import WorkspaceStore
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class V3Services:
|
||||
class CadServices:
|
||||
repository: SqliteTaskRepository
|
||||
artifacts: FileArtifactStore
|
||||
workflow: WorkflowCoordinator
|
||||
models: StructuredModelGateway
|
||||
outbox: OutboxDispatcher
|
||||
single_stage: SingleStageExecutor
|
||||
|
||||
|
||||
def compose_v3(settings: Settings) -> V3Services:
|
||||
repository = SqliteTaskRepository(settings.task_root.parent / "autonomous-cad-v3.sqlite3")
|
||||
if repository.protocol_reset and settings.task_root.exists():
|
||||
# Protocol 3.1 has no valid interpretation for structured-only task
|
||||
# artifacts, so clear that task root together with its old database.
|
||||
def compose_cad_services(settings: Settings) -> CadServices:
|
||||
database_root = settings.task_root.parent
|
||||
legacy_database = database_root / "autonomous-cad-v3.sqlite3"
|
||||
removed_legacy_database = False
|
||||
if legacy_database.exists():
|
||||
# The removed coordinator persisted incompatible task/action state in
|
||||
# its own database. Delete it as part of the deliberate destructive
|
||||
# migration, including SQLite sidecars if a worker stopped mid-write.
|
||||
for candidate in (legacy_database, *(database_root / f"{legacy_database.name}{suffix}" for suffix in ("-wal", "-shm"))):
|
||||
if candidate.exists():
|
||||
candidate.unlink()
|
||||
removed_legacy_database = True
|
||||
repository = SqliteTaskRepository(database_root / "autonomous-cad-single-stage.sqlite3")
|
||||
protocol_reset = repository.protocol_reset or removed_legacy_database
|
||||
if protocol_reset and settings.task_root.exists():
|
||||
# Old task artifacts have no valid interpretation under the Authoring
|
||||
# protocol, so clear them together with the task database.
|
||||
shutil.rmtree(settings.task_root)
|
||||
if repository.protocol_reset:
|
||||
if protocol_reset:
|
||||
WorkspaceStore(settings).clear_current_task_references()
|
||||
artifacts = FileArtifactStore(settings.task_root)
|
||||
runtime = ProfileCadRuntime(settings)
|
||||
registry = default_registry()
|
||||
verifier = RegistryVerifierExecutor(registry)
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids)
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, verifier)
|
||||
fallbacks = tuple(
|
||||
ModelIdentity(provider.id, model.id)
|
||||
for provider in settings.providers
|
||||
if provider.configured
|
||||
for model in provider.models[:1]
|
||||
)
|
||||
models = StructuredModelGateway(settings)
|
||||
outbox = OutboxDispatcher(repository, IdempotentInProcessPublisher())
|
||||
single_stage = SingleStageExecutor(repository, artifacts, runtime)
|
||||
workflow = WorkflowCoordinator(
|
||||
WorkflowConfig(
|
||||
max_turns=max(8, settings.agent_tool_calls_per_cycle * 8),
|
||||
format_error_limit=settings.agent_format_error_repeat_limit,
|
||||
author_fallbacks=fallbacks,
|
||||
),
|
||||
WorkflowConfig(),
|
||||
repository,
|
||||
artifacts,
|
||||
runtime,
|
||||
models,
|
||||
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,
|
||||
),
|
||||
single_stage,
|
||||
)
|
||||
return V3Services(repository, artifacts, workflow, models, outbox)
|
||||
return CadServices(repository, artifacts, workflow, models, outbox, single_stage)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Pure domain objects and policies for protocol v3."""
|
||||
"""Pure domain objects and policies for the Authoring CDSL protocol."""
|
||||
|
||||
from .errors import ErrorCode, WorkflowError
|
||||
from .state import TaskPhase, TaskState, transition
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"""Canonical partial matching for acceptance-claim expectations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def contains_expected(actual: Any, required: Any) -> bool:
|
||||
"""Allow an actual claim to add provider-chosen optional fields only."""
|
||||
if isinstance(required, dict):
|
||||
return isinstance(actual, dict) and all(
|
||||
key in actual and contains_expected(actual[key], value)
|
||||
for key, value in required.items()
|
||||
)
|
||||
if isinstance(required, list):
|
||||
return isinstance(actual, list) and len(actual) == len(required) and all(
|
||||
contains_expected(actual_item, required_item)
|
||||
for actual_item, required_item in zip(actual, required, strict=True)
|
||||
)
|
||||
return actual == required
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Typed, serializable failures used across the v3 workflow."""
|
||||
"""Typed, serializable failures used across the single-stage workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,29 +9,25 @@ from typing import Any
|
||||
|
||||
class ErrorCode(StrEnum):
|
||||
CANCELLED = "CANCELLED"
|
||||
AUTHOR_FORMAT_INVALID = "AUTHOR_FORMAT_INVALID"
|
||||
AUTHOR_DECISION_REJECTED = "AUTHOR_DECISION_REJECTED"
|
||||
STALE_WORKING_HEAD = "STALE_WORKING_HEAD"
|
||||
FAILED_AUTHOR_FORMAT = "FAILED_AUTHOR_FORMAT"
|
||||
RUNTIME_PRECONDITION_FAILED = "RUNTIME_PRECONDITION_FAILED"
|
||||
RUNTIME_CONTRACT_INVALID = "RUNTIME_CONTRACT_INVALID"
|
||||
CANDIDATE_BUILD_FAILED = "CANDIDATE_BUILD_FAILED"
|
||||
CANDIDATE_REVIEW_REJECTED = "CANDIDATE_REVIEW_REJECTED"
|
||||
VERIFIER_UNAVAILABLE = "VERIFIER_UNAVAILABLE"
|
||||
CLAIM_VERIFICATION_FAILED = "CLAIM_VERIFICATION_FAILED"
|
||||
MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED"
|
||||
MODEL_PROTOCOL_CHECK_PENDING = "MODEL_PROTOCOL_CHECK_PENDING"
|
||||
AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE"
|
||||
REVIEW_SERVICE_UNAVAILABLE = "REVIEW_SERVICE_UNAVAILABLE"
|
||||
RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE"
|
||||
STORAGE_FAILURE = "STORAGE_FAILURE"
|
||||
CALL_BUDGET_EXHAUSTED = "CALL_BUDGET_EXHAUSTED"
|
||||
REQUIREMENTS_SPEC_INVALID = "REQUIREMENTS_SPEC_INVALID"
|
||||
NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT"
|
||||
RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE"
|
||||
BEST_EFFORT_COMPLETED = "BEST_EFFORT_COMPLETED"
|
||||
FAILED_INTERNAL = "FAILED_INTERNAL"
|
||||
WAITING_FOR_USER = "WAITING_FOR_USER"
|
||||
AUTHOR_FORBIDDEN_FIELD = "AUTHOR_FORBIDDEN_FIELD"
|
||||
AUTHOR_SCHEMA_INVALID = "AUTHOR_SCHEMA_INVALID"
|
||||
AUTHOR_REFERENCE_INVALID = "AUTHOR_REFERENCE_INVALID"
|
||||
AUTHOR_CYCLE = "AUTHOR_CYCLE"
|
||||
OPERATION_UNSUPPORTED = "OPERATION_UNSUPPORTED"
|
||||
SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND"
|
||||
SELECTOR_AMBIGUOUS = "SELECTOR_AMBIGUOUS"
|
||||
SELECTOR_DEPENDENCY_UNAVAILABLE = "SELECTOR_DEPENDENCY_UNAVAILABLE"
|
||||
SELECTOR_KIND_MISMATCH = "SELECTOR_KIND_MISMATCH"
|
||||
ENGINE_EXECUTION_FAILED = "ENGINE_EXECUTION_FAILED"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"""Immutable feature-DAG planning contracts and deterministic scheduling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class _StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
class FeatureNode(_StrictModel):
|
||||
node_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
|
||||
priority: int = Field(ge=0, le=100_000)
|
||||
intent: str = Field(min_length=1, max_length=360)
|
||||
atomic_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
|
||||
depends_on: list[str] = Field(default_factory=list, max_length=64)
|
||||
claim_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
expected_change: str = Field(min_length=1, max_length=360)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _unique_references(self) -> "FeatureNode":
|
||||
if len(self.depends_on) != len(set(self.depends_on)):
|
||||
raise ValueError("depends_on must not contain duplicates")
|
||||
if len(self.claim_ids) != len(set(self.claim_ids)):
|
||||
raise ValueError("claim_ids must not contain duplicates")
|
||||
if self.node_id in self.depends_on:
|
||||
raise ValueError("a feature node cannot depend on itself")
|
||||
return self
|
||||
|
||||
|
||||
class FeaturePlan(_StrictModel):
|
||||
schema_version: str = Field(pattern=r"^cad\.v3\.2\.feature-plan\.v1$")
|
||||
parent_plan_hash: str = Field(default="", pattern=r"^(|[a-f0-9]{64})$")
|
||||
replaces_node_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
nodes: list[FeatureNode] = Field(min_length=1, max_length=256)
|
||||
final_claim_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _unique_plan_fields(self) -> "FeaturePlan":
|
||||
node_ids = [node.node_id for node in self.nodes]
|
||||
priorities = [node.priority for node in self.nodes]
|
||||
if len(node_ids) != len(set(node_ids)):
|
||||
raise ValueError("node_id values must be unique")
|
||||
if len(priorities) != len(set(priorities)):
|
||||
raise ValueError("priority values must be unique")
|
||||
if len(self.replaces_node_ids) != len(set(self.replaces_node_ids)):
|
||||
raise ValueError("replaces_node_ids must not contain duplicates")
|
||||
if len(self.final_claim_ids) != len(set(self.final_claim_ids)):
|
||||
raise ValueError("final_claim_ids must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
def plan_hash(plan: FeaturePlan | dict[str, Any]) -> str:
|
||||
payload = plan.model_dump(mode="json") if isinstance(plan, FeaturePlan) else plan
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def node_hash(node: FeatureNode | dict[str, Any]) -> str:
|
||||
payload = node.model_dump(mode="json") if isinstance(node, FeatureNode) else node
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def contract_claims(contract: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for requirement in contract.get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str):
|
||||
result[claim["claim_id"]] = claim
|
||||
return result
|
||||
|
||||
|
||||
def validate_feature_plan(
|
||||
plan: FeaturePlan,
|
||||
contract: dict[str, Any],
|
||||
atomic_ids: set[str] | frozenset[str] | tuple[str, ...],
|
||||
*,
|
||||
previous_plan: FeaturePlan | None = None,
|
||||
completed_node_hashes: dict[str, str] | None = None,
|
||||
required_replacements: set[str] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Return stable, tool-facing validation errors for a frozen plan."""
|
||||
errors: list[dict[str, str]] = []
|
||||
nodes = {node.node_id: node for node in plan.nodes}
|
||||
known_atoms = set(atomic_ids)
|
||||
claims = contract_claims(contract)
|
||||
assigned: dict[str, str] = {}
|
||||
|
||||
for index, node in enumerate(plan.nodes):
|
||||
prefix = f"/nodes/{index}"
|
||||
if node.atomic_id not in known_atoms:
|
||||
errors.append({"path": f"{prefix}/atomic_id", "message": "atomic_id is not supported by the runtime"})
|
||||
for dependency in node.depends_on:
|
||||
if dependency not in nodes:
|
||||
errors.append({"path": f"{prefix}/depends_on", "message": f"unknown dependency '{dependency}'"})
|
||||
for claim_id in node.claim_ids:
|
||||
claim = claims.get(claim_id)
|
||||
if claim is None:
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
|
||||
continue
|
||||
if claim.get("verification_mode") != "deterministic":
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"visual claim '{claim_id}' belongs in final_claim_ids"})
|
||||
owner = assigned.setdefault(claim_id, node.node_id)
|
||||
if owner != node.node_id:
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"claim '{claim_id}' is already owned by '{owner}'"})
|
||||
|
||||
final = set(plan.final_claim_ids)
|
||||
for claim_id in plan.final_claim_ids:
|
||||
claim = claims.get(claim_id)
|
||||
if claim is None:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
|
||||
elif claim.get("verification_mode") == "deterministic":
|
||||
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' must belong to one node"})
|
||||
|
||||
for claim_id, claim in claims.items():
|
||||
deterministic = claim.get("verification_mode") == "deterministic"
|
||||
if deterministic and claim_id not in assigned:
|
||||
errors.append({"path": "/nodes", "message": f"deterministic claim '{claim_id}' has no owner"})
|
||||
if not deterministic and claim_id not in final:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"visual claim '{claim_id}' has no final-review owner"})
|
||||
if deterministic and claim_id in final:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' has two owners"})
|
||||
|
||||
errors.extend(_cycle_errors(nodes))
|
||||
if previous_plan is None:
|
||||
if plan.parent_plan_hash:
|
||||
errors.append({"path": "/parent_plan_hash", "message": "initial plan cannot have a parent_plan_hash"})
|
||||
if plan.replaces_node_ids:
|
||||
errors.append({"path": "/replaces_node_ids", "message": "initial plan cannot replace nodes"})
|
||||
else:
|
||||
errors.extend(_revision_errors(plan, previous_plan, completed_node_hashes or {}, required_replacements or set()))
|
||||
return errors
|
||||
|
||||
|
||||
def _cycle_errors(nodes: dict[str, FeatureNode]) -> list[dict[str, str]]:
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
errors: list[dict[str, str]] = []
|
||||
|
||||
def walk(current: str) -> None:
|
||||
if current in visiting:
|
||||
errors.append({"path": "/nodes", "message": "feature dependencies contain a cycle"})
|
||||
return
|
||||
if current in visited:
|
||||
return
|
||||
visiting.add(current)
|
||||
for dependency in nodes[current].depends_on:
|
||||
if dependency in nodes:
|
||||
walk(dependency)
|
||||
visiting.remove(current)
|
||||
visited.add(current)
|
||||
|
||||
for node_id in nodes:
|
||||
walk(node_id)
|
||||
return errors[:1]
|
||||
|
||||
|
||||
def _revision_errors(plan: FeaturePlan, previous: FeaturePlan, completed: dict[str, str], required_replacements: set[str]) -> list[dict[str, str]]:
|
||||
errors: list[dict[str, str]] = []
|
||||
old_nodes = {node.node_id: node for node in previous.nodes}
|
||||
next_nodes = {node.node_id: node for node in plan.nodes}
|
||||
if plan.parent_plan_hash != plan_hash(previous):
|
||||
errors.append({"path": "/parent_plan_hash", "message": "parent_plan_hash does not match the active plan"})
|
||||
replaced = set(plan.replaces_node_ids)
|
||||
if required_replacements and replaced != required_replacements:
|
||||
errors.append({"path": "/replaces_node_ids", "message": "failed node and its unresolved downstream subgraph must be replaced together"})
|
||||
for node_id, frozen_hash in completed.items():
|
||||
node = next_nodes.get(node_id)
|
||||
if node is None:
|
||||
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was removed"})
|
||||
elif node_hash(node) != frozen_hash:
|
||||
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was modified"})
|
||||
if node_id in replaced:
|
||||
errors.append({"path": "/replaces_node_ids", "message": f"completed node '{node_id}' cannot be replaced"})
|
||||
for node_id, old_node in old_nodes.items():
|
||||
if node_id in replaced:
|
||||
continue
|
||||
current = next_nodes.get(node_id)
|
||||
if current is None:
|
||||
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was removed outside the replacement subgraph"})
|
||||
elif node_hash(current) != node_hash(old_node):
|
||||
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was modified outside the replacement subgraph"})
|
||||
for node_id in replaced:
|
||||
if node_id not in old_nodes:
|
||||
errors.append({"path": "/replaces_node_ids", "message": f"unknown replaced node '{node_id}'"})
|
||||
if node_id in next_nodes:
|
||||
errors.append({"path": "/nodes", "message": f"replacement must use a new node_id, found '{node_id}'"})
|
||||
return errors
|
||||
|
||||
|
||||
class FeatureScheduler:
|
||||
"""Derive a plan's runnable node from immutable ledger evidence."""
|
||||
|
||||
def __init__(self, plan: FeaturePlan, events: list[dict[str, Any]]) -> None:
|
||||
self.plan = plan
|
||||
self.events = events
|
||||
self._nodes = {node.node_id: node for node in plan.nodes}
|
||||
|
||||
def statuses(self) -> dict[str, str]:
|
||||
states: dict[str, str] = {node_id: "pending" for node_id in self._nodes}
|
||||
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
|
||||
for event in self.events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if node_id not in states or event.get("node_hash") != expected_hashes[node_id]:
|
||||
continue
|
||||
if event.get("event") == "feature_node_verified":
|
||||
states[node_id] = "done"
|
||||
elif event.get("event") == "feature_node_invalidated" and states[node_id] != "done":
|
||||
states[node_id] = "invalidated"
|
||||
elif event.get("event") == "feature_node_failed" and states[node_id] != "done":
|
||||
states[node_id] = "failed" if bool(event.get("terminal")) else "pending"
|
||||
elif event.get("event") == "feature_node_scheduled" and states[node_id] == "pending":
|
||||
states[node_id] = "running"
|
||||
for node in self.plan.nodes:
|
||||
if states[node.node_id] in {"done", "failed", "invalidated"}:
|
||||
continue
|
||||
dependency_states = [states.get(dependency, "blocked") for dependency in node.depends_on]
|
||||
if any(value in {"failed", "invalidated", "blocked"} for value in dependency_states):
|
||||
states[node.node_id] = "blocked"
|
||||
elif all(value == "done" for value in dependency_states):
|
||||
states[node.node_id] = "ready" if states[node.node_id] != "running" else "running"
|
||||
return states
|
||||
|
||||
def next_ready(self) -> FeatureNode | None:
|
||||
statuses = self.statuses()
|
||||
ready = [node for node in self.plan.nodes if statuses[node.node_id] == "ready"]
|
||||
return min(ready, key=lambda node: node.priority) if ready else None
|
||||
|
||||
def all_done(self) -> bool:
|
||||
return all(value == "done" for value in self.statuses().values())
|
||||
|
||||
def feature_ids(self) -> dict[str, str]:
|
||||
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
|
||||
result: dict[str, str] = {}
|
||||
for event in self.events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
feature_id = str(event.get("feature_id") or "")
|
||||
if event.get("event") == "feature_node_verified" and node_id in expected_hashes and event.get("node_hash") == expected_hashes[node_id] and feature_id:
|
||||
result[node_id] = feature_id
|
||||
return result
|
||||
|
||||
def completed_node_hashes(self) -> dict[str, str]:
|
||||
statuses = self.statuses()
|
||||
return {
|
||||
node_id: node_hash(self._nodes[node_id])
|
||||
for node_id, status in statuses.items()
|
||||
if status == "done"
|
||||
}
|
||||
|
||||
def failure_count(self, node_id: str, failure_class: str) -> int:
|
||||
expected = node_hash(self._nodes[node_id])
|
||||
return sum(
|
||||
1
|
||||
for event in self.events
|
||||
if event.get("event") == "feature_node_failed"
|
||||
and event.get("node_id") == node_id
|
||||
and event.get("node_hash") == expected
|
||||
and event.get("failure_class") == failure_class
|
||||
)
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Versioned runtime operation contracts and dynamic fragment schemas."""
|
||||
"""Versioned runtime operation contracts for the Authoring compiler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
@@ -15,25 +14,6 @@ class OperationContractError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
SEMANTIC_PREFLIGHT_NAMES = frozenset({
|
||||
"sketch_workplane",
|
||||
"profile_non_self_intersecting",
|
||||
"host_face_exists",
|
||||
"hole_positions_on_host_plane",
|
||||
"cut_exit_distance",
|
||||
"requires_active_solid",
|
||||
"revolve_axis_on_sketch",
|
||||
"reference_plane_nonzero_normal",
|
||||
"reference_axis_nonzero_direction",
|
||||
"selected_edges_exist",
|
||||
"source_features_exist",
|
||||
"mirror_plane_exists",
|
||||
"loft_profiles_exist",
|
||||
"loft_profiles_closed",
|
||||
"loft_profiles_single_region",
|
||||
})
|
||||
|
||||
|
||||
def canonical_hash(value: dict[str, Any]) -> str:
|
||||
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -58,12 +38,19 @@ def _is_closed(schema: Any) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def is_authoring_schema_closed(schema: Any) -> bool:
|
||||
"""Whether an operation can safely be exposed in the Authoring whitelist."""
|
||||
return _is_closed(schema)
|
||||
|
||||
|
||||
def validate_operation_contract(contract: dict[str, Any]) -> None:
|
||||
required = {
|
||||
"atomic_id", "contract_version", "fragment_shape", "author_params_schema", "selector_policy",
|
||||
"server_injected_paths", "reference_policy", "semantic_preflight", "candidate_verifiers",
|
||||
"runtime_capability",
|
||||
}
|
||||
if not required.issubset(contract) or set(contract) - required - {"contract_hash", "registry_revision"}:
|
||||
optional = {"contract_hash", "registry_revision", "nested_selector_policies"}
|
||||
if not required.issubset(contract) or set(contract) - required - optional:
|
||||
raise OperationContractError("Operation contract has unknown or missing fields")
|
||||
if not isinstance(contract["atomic_id"], str) or not contract["atomic_id"]:
|
||||
raise OperationContractError("Operation contract atomic_id is invalid")
|
||||
@@ -111,113 +98,43 @@ def validate_operation_contract(contract: dict[str, Any]) -> None:
|
||||
if reference["mode"] == "snapshot_bound":
|
||||
if set(reference) != {"mode", "slot", "token_kind", "min_items", "max_items", "snapshot_bound"}:
|
||||
raise OperationContractError("Snapshot-bound reference policy is invalid")
|
||||
if not isinstance(reference["slot"], str) or not reference["slot"].startswith("params.") or reference["token_kind"] != "feature" or not isinstance(reference["min_items"], int) or not isinstance(reference["max_items"], int) or not 1 <= reference["min_items"] <= reference["max_items"] <= 64 or reference["snapshot_bound"] is not True:
|
||||
raise OperationContractError("Snapshot-bound reference policy has invalid bounds")
|
||||
reference_name = reference["slot"].removeprefix("params.")
|
||||
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
|
||||
reference_schema = properties.get(reference_name)
|
||||
required_params = params.get("required") if isinstance(params.get("required"), list) else []
|
||||
slot = reference["slot"]
|
||||
if (
|
||||
not isinstance(reference_schema, dict)
|
||||
or reference_schema.get("type") != "array"
|
||||
or not isinstance(reference_schema.get("items"), dict)
|
||||
or reference_name not in required_params
|
||||
not isinstance(slot, str)
|
||||
or (slot != "feature.selectors" and not slot.startswith("params."))
|
||||
or reference["token_kind"] not in {"face", "edge", "plane", "axis", "body", "feature"}
|
||||
or not isinstance(reference["min_items"], int)
|
||||
or not isinstance(reference["max_items"], int)
|
||||
or not 0 <= reference["min_items"] <= reference["max_items"] <= 64
|
||||
or reference["snapshot_bound"] is not True
|
||||
):
|
||||
raise OperationContractError("Snapshot-bound reference slot must be a required author array")
|
||||
raise OperationContractError("Snapshot-bound reference policy has invalid bounds")
|
||||
if slot == "feature.selectors":
|
||||
if shape["selector_tokens"] != "required" or selector["token_kind"] != reference["token_kind"]:
|
||||
raise OperationContractError("Feature selector reference policy disagrees with selector policy")
|
||||
else:
|
||||
reference_name = slot.removeprefix("params.")
|
||||
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
|
||||
reference_schema = properties.get(reference_name)
|
||||
if not isinstance(reference_schema, dict) or reference_schema.get("type") not in {"array", "string"}:
|
||||
raise OperationContractError("Snapshot-bound reference slot is absent from author params schema")
|
||||
if reference_schema.get("type") == "string" and reference["max_items"] > 1:
|
||||
raise OperationContractError("Scalar snapshot-bound reference must allow at most one item")
|
||||
if not isinstance(contract["semantic_preflight"], list) or not all(isinstance(item, str) and item for item in contract["semantic_preflight"]):
|
||||
raise OperationContractError("Operation semantic preflight is invalid")
|
||||
if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]) or set(contract["semantic_preflight"]) - SEMANTIC_PREFLIGHT_NAMES:
|
||||
raise OperationContractError("Operation semantic preflight names are unknown or duplicated")
|
||||
if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]):
|
||||
raise OperationContractError("Operation semantic preflight names are duplicated")
|
||||
if not isinstance(contract["candidate_verifiers"], list) or not all(isinstance(item, str) and item for item in contract["candidate_verifiers"]):
|
||||
raise OperationContractError("Operation candidate_verifiers are invalid")
|
||||
if len(set(contract["candidate_verifiers"])) != len(contract["candidate_verifiers"]):
|
||||
raise OperationContractError("Operation candidate verifiers are duplicated")
|
||||
|
||||
|
||||
def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> dict[str, Any]:
|
||||
"""Build the one-operation schema exposed for one pending action."""
|
||||
validate_operation_contract(contract)
|
||||
shape = contract["fragment_shape"]
|
||||
params = deepcopy(contract["author_params_schema"])
|
||||
reference = contract["reference_policy"]
|
||||
if reference["mode"] == "snapshot_bound":
|
||||
slot = str(reference["slot"]).removeprefix("params.")
|
||||
items = params.get("properties", {}).get(slot, {}).get("items") if isinstance(params.get("properties"), dict) else None
|
||||
if not isinstance(items, dict):
|
||||
raise OperationContractError("Snapshot-bound reference slot is absent from author params schema")
|
||||
items.clear()
|
||||
items.update({"enum": reference_tokens or []})
|
||||
feature_properties: dict[str, Any] = {
|
||||
"atomic_id": {"const": contract["atomic_id"]},
|
||||
"params": params,
|
||||
runtime_capability = contract["runtime_capability"]
|
||||
required_capability_flags = {
|
||||
"body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok",
|
||||
}
|
||||
feature_required = ["atomic_id", "params"]
|
||||
if shape["selector_tokens"] == "required":
|
||||
policy = contract["selector_policy"]
|
||||
feature_properties["selector_tokens"] = {
|
||||
"type": "array", "items": {"enum": selector_tokens}, "minItems": policy["min_items"],
|
||||
"maxItems": policy["max_items"], "uniqueItems": True,
|
||||
"description": (
|
||||
"Required author input. Copy the opaque selector token returned by the current topology "
|
||||
"snapshot here; do not omit it and do not put a host face in params. The server resolves this "
|
||||
"token into the host selector after schema validation."
|
||||
),
|
||||
}
|
||||
feature_required.append("selector_tokens")
|
||||
feature = {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"One atomic feature. selector_tokens, when present, is an author-supplied topology token array "
|
||||
"rather than a server-filled params field."
|
||||
),
|
||||
"properties": feature_properties,
|
||||
"required": feature_required,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
properties: dict[str, Any] = {"feature": feature}
|
||||
required = ["feature"]
|
||||
if shape["sketch"] == "required":
|
||||
properties["sketch"] = _sketch_schema(root_xy_datum=root_xy_datum and contract["atomic_id"] in {"extrude_add_blind", "extrude_add_two_sided"})
|
||||
required.insert(0, "sketch")
|
||||
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": properties, "required": required, "additionalProperties": False}
|
||||
Draft202012Validator.check_schema(schema)
|
||||
return schema
|
||||
|
||||
|
||||
def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> list[dict[str, str]]:
|
||||
schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens, root_xy_datum=root_xy_datum)
|
||||
return [
|
||||
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
|
||||
for error in sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda item: (list(item.absolute_path), item.message))
|
||||
]
|
||||
|
||||
|
||||
def _point(size: int) -> dict[str, Any]:
|
||||
return {"type": "array", "items": {"type": "number"}, "minItems": size, "maxItems": size}
|
||||
|
||||
|
||||
def _sketch_schema(*, root_xy_datum: bool = False) -> dict[str, Any]:
|
||||
point2 = _point(2)
|
||||
point3 = _point(3)
|
||||
workplane = {
|
||||
"type": "object",
|
||||
"description": "origin_mm is the world position of sketch local (0,0); profile coordinates are local to this plane. normal is positive extrusion direction and x_dir is local +X in world coordinates.",
|
||||
"properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)},
|
||||
"required": ["origin_mm", "x_dir", "normal"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
if root_xy_datum:
|
||||
workplane["description"] += " Root extrusion uses fixed world XY datum: origin X/Y are 0, normal is +Z, x_dir is +X. Only origin Z is task-defined."
|
||||
workplane["properties"] = {
|
||||
"origin_mm": {"type": "array", "prefixItems": [{"const": 0}, {"const": 0}, {"type": "number"}], "items": False, "minItems": 3, "maxItems": 3},
|
||||
"x_dir": {"const": [1, 0, 0]},
|
||||
"normal": {"const": [0, 0, 1]},
|
||||
}
|
||||
profile = {
|
||||
"oneOf": [
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "radius_mm"], "additionalProperties": False},
|
||||
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)}}, "required": ["type", "vertices"], "additionalProperties": False},
|
||||
{"type": "object", "properties": {"type": {"const": "analytic_contours"}, "contours": {"type": "array", "minItems": 1, "maxItems": 8, "items": {"type": "object", "properties": {"role": {"enum": ["outer", "inner"]}, "closed": {"const": True}, "segments": {"type": "array", "minItems": 1, "items": {"oneOf": [{"type": "object", "properties": {"type": {"const": "line"}, "start": deepcopy(point2), "end": deepcopy(point2)}, "required": ["type", "start", "end"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "center", "radius_mm"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "arc"}, "start": deepcopy(point2), "end": deepcopy(point2), "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}, "clockwise": {"type": "boolean"}}, "required": ["type", "start", "end", "center", "radius_mm"], "additionalProperties": False}]}}}, "required": ["role", "closed", "segments"], "additionalProperties": False}}}, "required": ["type", "contours"], "additionalProperties": False},
|
||||
]
|
||||
}
|
||||
return {"type": "object", "properties": {"workplane": workplane, "profile": profile}, "required": ["workplane", "profile"], "additionalProperties": False}
|
||||
if (
|
||||
not isinstance(runtime_capability, dict)
|
||||
or set(runtime_capability) != required_capability_flags
|
||||
or not all(isinstance(value, bool) for value in runtime_capability.values())
|
||||
):
|
||||
raise OperationContractError("Operation runtime capability is invalid")
|
||||
|
||||
@@ -1,56 +1,24 @@
|
||||
"""Finite workflow state machine. This module has no persistence imports."""
|
||||
"""Finite state for the single-stage Authoring CDSL protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import StrEnum
|
||||
|
||||
from .errors import ErrorCode, WorkflowError
|
||||
from .errors import ErrorCode
|
||||
|
||||
|
||||
class TaskPhase(StrEnum):
|
||||
DRAFTING_REQUIREMENTS_DOCUMENT = "DRAFTING_REQUIREMENTS_DOCUMENT"
|
||||
DRAFTING_COMPLETION_TARGET = "DRAFTING_COMPLETION_TARGET"
|
||||
COMPILING_REQUIREMENTS = "COMPILING_REQUIREMENTS"
|
||||
COMPILING_FEATURE_PLAN = "COMPILING_FEATURE_PLAN"
|
||||
SCHEDULING_FEATURE = "SCHEDULING_FEATURE"
|
||||
FEATURE_PENDING = "FEATURE_PENDING"
|
||||
FEATURE_BUILDING = "FEATURE_BUILDING"
|
||||
REPLANNING_FEATURE_SUBGRAPH = "REPLANNING_FEATURE_SUBGRAPH"
|
||||
# Legacy v3.1 phases remain readable only so an interrupted process can
|
||||
# fail cleanly during the protocol reset. New v3.2 tasks never enter them.
|
||||
DRAFTING_MODELING_PLAN = "DRAFTING_MODELING_PLAN"
|
||||
AWAITING_ACTION = "AWAITING_ACTION"
|
||||
ACTION_PENDING = "ACTION_PENDING"
|
||||
CANDIDATE_BUILDING = "CANDIDATE_BUILDING"
|
||||
CANDIDATE_REVIEW = "CANDIDATE_REVIEW"
|
||||
FINAL_VALIDATION = "FINAL_VALIDATION"
|
||||
WAITING_RETRY = "WAITING_RETRY"
|
||||
ANALYZING_REQUEST = "ANALYZING_REQUEST"
|
||||
AUTHORING_CDSL = "AUTHORING_CDSL"
|
||||
COMPILING_CDSL = "COMPILING_CDSL"
|
||||
BUILDING = "BUILDING"
|
||||
REPAIRING = "REPAIRING"
|
||||
PUBLISHING_BEST_EFFORT = "PUBLISHING_BEST_EFFORT"
|
||||
WAITING_FOR_USER = "WAITING_FOR_USER"
|
||||
CANCELLED = "CANCELLED"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PendingAction:
|
||||
action_id: str
|
||||
working_head: str
|
||||
intent: str
|
||||
requirement_ids: tuple[str, ...]
|
||||
atomic_id: str
|
||||
expected_change: str
|
||||
contract_hash: str
|
||||
idempotency_key: str
|
||||
node_id: str = ""
|
||||
plan_hash: str = ""
|
||||
claim_ids: tuple[str, ...] = ()
|
||||
depends_on_node_ids: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# The stored JSON key remains ``pending_action_json`` only in old artifacts.
|
||||
# v3.2 code uses this alias to make the ownership boundary explicit.
|
||||
PendingFeature = PendingAction
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -59,167 +27,84 @@ class TaskState:
|
||||
phase: TaskPhase
|
||||
version: int
|
||||
active_revision: str = ""
|
||||
pending_action: PendingAction | None = None
|
||||
candidate_id: str = ""
|
||||
candidate_stage_id: str = ""
|
||||
repair_required: bool = False
|
||||
repair_count: int = 0
|
||||
last_error: ErrorCode | None = None
|
||||
retry_from_phase: TaskPhase | None = None
|
||||
requirements_spec_path: str = ""
|
||||
requirements_document_path: str = ""
|
||||
completion_target_path: str = ""
|
||||
modeling_plan_path: str = ""
|
||||
feature_plan_path: str = ""
|
||||
feature_plan_hash: str = ""
|
||||
feature_stage_id: str = ""
|
||||
requirements_path: str = ""
|
||||
authoring_path: str = ""
|
||||
runtime_cdsl_path: str = ""
|
||||
compile_audit_path: str = ""
|
||||
diagnostics_path: str = ""
|
||||
completion_path: str = ""
|
||||
clarification_path: str = ""
|
||||
requirements_contract_path: str = ""
|
||||
|
||||
@property
|
||||
def pending_feature(self) -> PendingFeature | None:
|
||||
return self.pending_action
|
||||
|
||||
@property
|
||||
def working_head(self) -> str:
|
||||
return f"{self.task_id}:{self.active_revision or 'root'}:v{self.version}"
|
||||
|
||||
|
||||
# Legal state transitions. Events are intentionally terse persistence-neutral
|
||||
# names used by command handlers and architecture tests.
|
||||
_TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
|
||||
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
|
||||
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "requirements_document_written"): TaskPhase.DRAFTING_COMPLETION_TARGET,
|
||||
(TaskPhase.DRAFTING_COMPLETION_TARGET, "completion_target_written"): TaskPhase.COMPILING_REQUIREMENTS,
|
||||
(TaskPhase.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.COMPILING_FEATURE_PLAN,
|
||||
(TaskPhase.COMPILING_FEATURE_PLAN, "feature_plan_written"): TaskPhase.SCHEDULING_FEATURE,
|
||||
# Retained for direct v3.1 handler callers only. The v3.2 workflow never
|
||||
# exposes this event or accepts a Markdown plan from a model.
|
||||
(TaskPhase.COMPILING_FEATURE_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.REPLANNING_FEATURE_SUBGRAPH, "feature_plan_revised"): TaskPhase.SCHEDULING_FEATURE,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "feature_scheduled"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "final_requested"): TaskPhase.FINAL_VALIDATION,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_started"): TaskPhase.FEATURE_BUILDING,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_verified"): TaskPhase.SCHEDULING_FEATURE,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.DRAFTING_MODELING_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
|
||||
# User clarifications are durable task evidence. Resume on the same task
|
||||
# so its frozen request remains authoritative
|
||||
# instead of turning a clarification into a new CAD request.
|
||||
(TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
|
||||
(TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING,
|
||||
(TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.AWAITING_ACTION, "rollback"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.ACTION_PENDING, "candidate_started"): TaskPhase.CANDIDATE_BUILDING,
|
||||
# Runtime semantic preflight happens before candidate staging. It must
|
||||
# leave the checkpoint untouched and return control to the author for a
|
||||
# fresh observation/action decision, never masquerade as a format error.
|
||||
(TaskPhase.ACTION_PENDING, "runtime_precondition_rejected"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.ACTION_PENDING, "diagnosis_recorded"): TaskPhase.ACTION_PENDING,
|
||||
(TaskPhase.ACTION_PENDING, "diagnosis_return_to_action_selection"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.CANDIDATE_BUILDING, "candidate_built"): TaskPhase.CANDIDATE_REVIEW,
|
||||
(TaskPhase.CANDIDATE_BUILDING, "candidate_rejected"): TaskPhase.ACTION_PENDING,
|
||||
(TaskPhase.CANDIDATE_REVIEW, "candidate_accepted"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.CANDIDATE_REVIEW, "candidate_rejected"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.AWAITING_ACTION, "final_requested"): TaskPhase.FINAL_VALIDATION,
|
||||
(TaskPhase.FINAL_VALIDATION, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED,
|
||||
(TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.ANALYZING_REQUEST, "analysis_written"): TaskPhase.AUTHORING_CDSL,
|
||||
(TaskPhase.ANALYZING_REQUEST, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
|
||||
(TaskPhase.WAITING_FOR_USER, "clarification_received"): TaskPhase.ANALYZING_REQUEST,
|
||||
(TaskPhase.AUTHORING_CDSL, "authoring_written"): TaskPhase.COMPILING_CDSL,
|
||||
(TaskPhase.AUTHORING_CDSL, "repair_required"): TaskPhase.REPAIRING,
|
||||
(TaskPhase.COMPILING_CDSL, "compiled"): TaskPhase.BUILDING,
|
||||
(TaskPhase.COMPILING_CDSL, "repair_required"): TaskPhase.REPAIRING,
|
||||
(TaskPhase.BUILDING, "build_completed"): TaskPhase.PUBLISHING_BEST_EFFORT,
|
||||
(TaskPhase.BUILDING, "repair_required"): TaskPhase.REPAIRING,
|
||||
(TaskPhase.REPAIRING, "repair_started"): TaskPhase.AUTHORING_CDSL,
|
||||
(TaskPhase.PUBLISHING_BEST_EFFORT, "published"): TaskPhase.COMPLETED,
|
||||
(TaskPhase.PUBLISHING_BEST_EFFORT, "failed"): TaskPhase.FAILED,
|
||||
}
|
||||
_TRANSITIONS.update({
|
||||
(phase, "best_effort_completed"): TaskPhase.COMPLETED
|
||||
for phase in TaskPhase
|
||||
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
|
||||
})
|
||||
_TRANSITIONS.update({
|
||||
(phase, "failed"): TaskPhase.FAILED
|
||||
for phase in TaskPhase
|
||||
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
|
||||
})
|
||||
_TRANSITIONS.update({
|
||||
(phase, "cancelled"): TaskPhase.CANCELLED
|
||||
for phase in TaskPhase
|
||||
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
|
||||
})
|
||||
_RETRY_RESUMABLE_PHASES = frozenset({
|
||||
TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
|
||||
TaskPhase.DRAFTING_COMPLETION_TARGET,
|
||||
TaskPhase.COMPILING_REQUIREMENTS,
|
||||
TaskPhase.COMPILING_FEATURE_PLAN,
|
||||
TaskPhase.SCHEDULING_FEATURE,
|
||||
TaskPhase.FEATURE_PENDING,
|
||||
TaskPhase.FEATURE_BUILDING,
|
||||
TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
TaskPhase.DRAFTING_MODELING_PLAN,
|
||||
TaskPhase.AWAITING_ACTION,
|
||||
TaskPhase.ACTION_PENDING,
|
||||
TaskPhase.CANDIDATE_BUILDING,
|
||||
TaskPhase.CANDIDATE_REVIEW,
|
||||
TaskPhase.FINAL_VALIDATION,
|
||||
})
|
||||
_TRANSITIONS.update({
|
||||
(TaskPhase.WAITING_RETRY, f"resume_{phase.value.lower()}"): phase
|
||||
for phase in _RETRY_RESUMABLE_PHASES
|
||||
})
|
||||
_TRANSITIONS.update({
|
||||
(phase, "waiting_retry"): TaskPhase.WAITING_RETRY
|
||||
for phase in TaskPhase
|
||||
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY}
|
||||
})
|
||||
for _phase in TaskPhase:
|
||||
if _phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
_TRANSITIONS[(_phase, "failed")] = TaskPhase.FAILED
|
||||
_TRANSITIONS[(_phase, "cancelled")] = TaskPhase.CANCELLED
|
||||
if _phase in {
|
||||
TaskPhase.AUTHORING_CDSL, TaskPhase.COMPILING_CDSL,
|
||||
TaskPhase.BUILDING, TaskPhase.REPAIRING,
|
||||
}:
|
||||
_TRANSITIONS[(_phase, "publish_best_effort")] = TaskPhase.PUBLISHING_BEST_EFFORT
|
||||
|
||||
|
||||
def legal_transitions() -> dict[tuple[TaskPhase, str], TaskPhase]:
|
||||
"""Return a copy of the protocol transition table for architecture tests."""
|
||||
return dict(_TRANSITIONS)
|
||||
|
||||
|
||||
def retry_resume_event(state: TaskState) -> str | None:
|
||||
"""Return the only lossless resume event for a parked service failure."""
|
||||
if state.phase != TaskPhase.WAITING_RETRY or state.retry_from_phase not in _RETRY_RESUMABLE_PHASES:
|
||||
return None
|
||||
return f"resume_{state.retry_from_phase.value.lower()}"
|
||||
|
||||
|
||||
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, feature_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, requirements_document_path: str | None = None, completion_target_path: str | None = None, modeling_plan_path: str | None = None, feature_plan_path: str | None = None, feature_plan_hash: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
|
||||
"""Apply one legal transition and advance optimistic-concurrency version."""
|
||||
target = _TRANSITIONS.get((state.phase, event))
|
||||
def transition(
|
||||
state: TaskState,
|
||||
event: str,
|
||||
*,
|
||||
active_revision: str | None = None,
|
||||
repair_count: int | None = None,
|
||||
error: ErrorCode | None = None,
|
||||
requirements_path: str | None = None,
|
||||
authoring_path: str | None = None,
|
||||
runtime_cdsl_path: str | None = None,
|
||||
compile_audit_path: str | None = None,
|
||||
diagnostics_path: str | None = None,
|
||||
completion_path: str | None = None,
|
||||
clarification_path: str | None = None,
|
||||
) -> TaskState:
|
||||
target = state.retry_from_phase if state.phase == TaskPhase.FAILED and event == "resume" else _TRANSITIONS.get((state.phase, event))
|
||||
if target is None:
|
||||
raise ValueError(f"Illegal v3 transition: {state.phase.value} --{event}--> ?")
|
||||
if state.phase == TaskPhase.WAITING_RETRY and event.startswith("resume_") and state.retry_from_phase != target:
|
||||
raise ValueError("WAITING_RETRY resume event does not match its persisted source phase")
|
||||
next_pending = state.pending_action if pending_action is ... else pending_action
|
||||
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
next_pending = None
|
||||
raise ValueError(f"Illegal single-stage transition: {state.phase.value} --{event}--> ?")
|
||||
next_repairs = state.repair_count if repair_count is None else repair_count
|
||||
if not 0 <= next_repairs <= 2:
|
||||
raise ValueError("Single-stage repair count must be within [0, 2]")
|
||||
return replace(
|
||||
state,
|
||||
phase=target,
|
||||
version=state.version + 1,
|
||||
active_revision=state.active_revision if active_revision is None else active_revision,
|
||||
pending_action=next_pending,
|
||||
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
|
||||
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
|
||||
repair_required=state.repair_required if repair_required is None else repair_required,
|
||||
repair_count=next_repairs,
|
||||
last_error=error,
|
||||
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None,
|
||||
requirements_spec_path=state.requirements_spec_path if requirements_spec_path is None else requirements_spec_path,
|
||||
requirements_document_path=state.requirements_document_path if requirements_document_path is None else requirements_document_path,
|
||||
completion_target_path=state.completion_target_path if completion_target_path is None else completion_target_path,
|
||||
modeling_plan_path=state.modeling_plan_path if modeling_plan_path is None else modeling_plan_path,
|
||||
feature_plan_path=state.feature_plan_path if feature_plan_path is None else feature_plan_path,
|
||||
feature_plan_hash=state.feature_plan_hash if feature_plan_hash is None else feature_plan_hash,
|
||||
feature_stage_id="" if target in {TaskPhase.SCHEDULING_FEATURE, TaskPhase.FEATURE_PENDING, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.feature_stage_id if feature_stage_id is None else feature_stage_id,
|
||||
retry_from_phase=state.phase if target == TaskPhase.FAILED and error in {
|
||||
ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, ErrorCode.STORAGE_FAILURE,
|
||||
ErrorCode.RUNTIME_EXECUTION_FAILURE,
|
||||
} else None,
|
||||
requirements_path=state.requirements_path if requirements_path is None else requirements_path,
|
||||
authoring_path=state.authoring_path if authoring_path is None else authoring_path,
|
||||
runtime_cdsl_path=state.runtime_cdsl_path if runtime_cdsl_path is None else runtime_cdsl_path,
|
||||
compile_audit_path=state.compile_audit_path if compile_audit_path is None else compile_audit_path,
|
||||
diagnostics_path=state.diagnostics_path if diagnostics_path is None else diagnostics_path,
|
||||
completion_path=state.completion_path if completion_path is None else completion_path,
|
||||
clarification_path=state.clarification_path if clarification_path is None else clarification_path,
|
||||
requirements_contract_path=state.requirements_contract_path if requirements_contract_path is None else requirements_contract_path,
|
||||
)
|
||||
|
||||
|
||||
def reject_stale_head(state: TaskState, supplied_head: str) -> WorkflowError | None:
|
||||
if supplied_head != state.working_head:
|
||||
return WorkflowError(
|
||||
ErrorCode.STALE_WORKING_HEAD,
|
||||
"The command was bound to an obsolete working head.",
|
||||
details={"expected_working_head": state.working_head, "supplied_working_head": supplied_head},
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Token Baseline Format
|
||||
|
||||
`python -m app.cad_agent.evals.live --suite release --require-live` requires
|
||||
`--baseline-report` to point at a measured protocol-2 report. It is not a
|
||||
fixture or an estimate. Record three independent runs of every release
|
||||
scenario with the same author request configuration and runtime profile.
|
||||
|
||||
| Field | Requirement |
|
||||
| --- | --- |
|
||||
| `schema_version` | `cad.token-baseline.v1` |
|
||||
| `protocol_version` | `2.0` |
|
||||
| `author` | Exact `provider`, `model`, `api_style`, `reasoning_effort`, and `sampling` object from the v3 report |
|
||||
| `runtime_profile_sha256` | Exact v3 `runtime_profile_sha256` |
|
||||
| `measurement` | `prompt_tokens` when provider usage exists; otherwise `context_chars` |
|
||||
| `scenarios[]` | One entry for every versioned release scenario, including its request SHA-256 |
|
||||
| `repetitions[]` | Entries `1`, `2`, and `3`, with non-negative `author_metric` and `plan_review_metric`, plus completion, final-review, and deterministic-claim booleans |
|
||||
|
||||
The comparison uses `author_metric + plan_review_metric` for the v2 median and
|
||||
the v3 author metric only. It fails closed when provenance differs, any
|
||||
repetition is absent, completion/review/claim rates drop, plan-review calls
|
||||
remain, or the median reduction is below 30%.
|
||||
@@ -1 +1 @@
|
||||
"""Live, non-mocked protocol v3 release evaluations."""
|
||||
"""Local evaluation helpers for the single-stage Authoring protocol."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Create a resumable live-evaluation task without starting its workflow."""
|
||||
"""Create an isolated single-stage task without starting its workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,12 +9,12 @@ from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.cad_agent.composition import compose_cad_services
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Create one isolated live CAD task for resume_one_step.")
|
||||
parser = argparse.ArgumentParser(description="Create one isolated Authoring CDSL evaluation task.")
|
||||
parser.add_argument("--report-root", required=True, type=Path)
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--task-id")
|
||||
@@ -25,7 +25,7 @@ def main() -> int:
|
||||
arguments = _arguments()
|
||||
root = arguments.report_root.resolve()
|
||||
settings = get_settings()
|
||||
services = compose_v3(replace(
|
||||
services = compose_cad_services(replace(
|
||||
settings,
|
||||
task_root=root / "artifacts",
|
||||
conversation_root=root / "conversations",
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
{
|
||||
"schema_version": "cad.comprehensive-prompt-fixtures.v1",
|
||||
"source_document": "docs/cad-agent-v3-comprehensive-prompt-test-target.md",
|
||||
"source_document_sha256": "79fbfeba41b2bf1fa37d2e90df1c4f5f06460f725f8898ae4238676bfea82d8b",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "rectangular_mounting_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个CNC矩形安装板,长100毫米,宽60毫米,厚8毫米,四角圆角R6,四角各有一个直径8毫米的贯穿孔,孔中心距左右边10毫米、上下边10毫米,中心有一个直径30毫米的贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 8, "count": 4, "edge_offset_mm": 10}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "corner_radius", "description": "R6 outer corner radii"}
|
||||
],
|
||||
"max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000
|
||||
},
|
||||
{
|
||||
"id": "circular_flange_pcd",
|
||||
"units": "mm",
|
||||
"request": "生成一个圆形法兰盘,外径120毫米,厚度12毫米,中心贯穿孔直径40毫米,在直径90毫米的分度圆上均布6个直径8毫米的贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 6}},
|
||||
{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 8, "count": 6, "pitch_radius_mm": 45, "concentric_bore_diameter_mm": 40}}
|
||||
],
|
||||
"required_atomic_ids": [],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind", "pattern_linear"],
|
||||
"required_any_atomic_id_groups": [["extrude_add_blind", "revolve_add"]],
|
||||
"validation_capability_gaps": [],
|
||||
"max_author_turns": 52, "max_reviewer_turns": 20, "max_total_calls": 72, "max_wall_seconds": 1500, "max_total_tokens": 320000
|
||||
},
|
||||
{
|
||||
"id": "square_flange",
|
||||
"units": "mm",
|
||||
"request": "生成一个方形安装法兰,长100毫米,宽100毫米,厚度12毫米,四角圆角R8,中心贯穿孔直径45毫米,四角各有一个直径10毫米的安装孔,孔中心距相邻两边各15毫米。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 45, "count": 1}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 10, "count": 4}},
|
||||
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 10, "count": 4, "edge_offset_mm": 15}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "corner_radius", "description": "R8 outer corner radii"}
|
||||
],
|
||||
"max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000
|
||||
},
|
||||
{
|
||||
"id": "counterbored_mounting_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个矩形安装板,长120毫米,宽80毫米,厚度15毫米,四角各有一个直径9毫米的贯穿孔,每个孔顶部带直径16毫米、深5毫米的圆柱沉孔,孔中心距相邻边各12毫米。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth", "rectangular_corner_through_bore_pattern"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 15}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}},
|
||||
{"claim_kind": "cylindrical_bore", "expected": {"diameter_mm": 16, "count": 4}},
|
||||
{"claim_kind": "cylindrical_bore_depth", "expected": {"diameter_mm": 16, "count": 4, "depth_mm": 5}},
|
||||
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 9, "count": 4, "edge_offset_mm": 12}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_counterbore", "hole_wizard"],
|
||||
"validation_capability_gaps": [],
|
||||
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||
},
|
||||
{
|
||||
"id": "countersunk_cover_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个盖板,长100毫米,宽70毫米,厚度8毫米,四角圆角R5,四角各有一个直径6.5毫米的贯穿孔,孔顶部带90度沉头,沉头最大直径12毫米。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "conical_bore"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 70}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 6.5, "count": 4}},
|
||||
{"claim_kind": "conical_bore", "expected": {"small_diameter_mm": 6.5, "large_diameter_mm": 12, "included_angle_deg": 90, "count": 4}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_countersink", "hole_wizard"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "corner_radius", "description": "R5 outer corner radii"}
|
||||
],
|
||||
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||
},
|
||||
{
|
||||
"id": "obround_slot_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个连接板,长140毫米,宽50毫米,厚度10毫米,两端各有一个长圆形贯穿槽,槽总长30毫米、宽12毫米,槽中心距板端20毫米,槽的长轴沿板长度方向。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "slot_dimensions", "description": "two through obround slots with 30 x 12 dimensions"},
|
||||
{"id": "slot_position_orientation", "description": "20 mm end offset and lengthwise major-axis orientation"}
|
||||
],
|
||||
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||
},
|
||||
{
|
||||
"id": "t_slot_test_block",
|
||||
"units": "mm",
|
||||
"request": "生成一个T形槽试块,长100毫米,宽50毫米,高25毫米,在顶面中心沿长度方向加工一条T形槽,槽口宽10毫米、深8毫米,槽底宽20毫米、总深15毫米,槽贯穿试块两端。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "t_slot_cross_section", "description": "10 x 8 slot mouth, 20 mm lower width and 15 mm total depth"},
|
||||
{"id": "t_slot_through_orientation", "description": "top-centred T-slot through both lengthwise ends"}
|
||||
],
|
||||
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||
},
|
||||
{
|
||||
"id": "rounded_rectangular_pocket",
|
||||
"units": "mm",
|
||||
"request": "生成一个矩形底板,长120毫米,宽80毫米,厚20毫米,在顶面中心加工一个长80毫米、宽45毫米、深12毫米的矩形口袋,口袋四角圆角R6,底部保留8毫米厚度。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 20}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "pocket_dimensions", "description": "centred 80 x 45 x 12 pocket with 8 mm remaining floor"},
|
||||
{"id": "pocket_corner_radius", "description": "R6 internal pocket corners"}
|
||||
],
|
||||
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||
},
|
||||
{
|
||||
"id": "two_level_pocket_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个CNC加工板,长140毫米,宽100毫米,厚25毫米。顶面中心先加工一个长100毫米、宽70毫米、深8毫米的矩形口袋,再在第一级口袋中心加工一个长60毫米、宽35毫米、额外深7毫米的第二级口袋,所有内角圆角R5。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "pocket_levels", "description": "100 x 70 x 8 first pocket and centred 60 x 35 x 7 additional-depth second pocket"},
|
||||
{"id": "pocket_corner_radii", "description": "R5 on every internal corner"}
|
||||
],
|
||||
"max_author_turns": 80, "max_reviewer_turns": 30, "max_total_calls": 110, "max_wall_seconds": 2400, "max_total_tokens": 320000
|
||||
},
|
||||
{
|
||||
"id": "cross_drilled_valve_block",
|
||||
"units": "mm",
|
||||
"request": "生成一个阀块试件,长80毫米,宽60毫米,高50毫米。沿长度方向加工一个直径20毫米的贯穿孔,沿宽度方向加工一个直径12毫米的贯穿孔,两个孔的轴线在零件中心相交。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "orthogonal_intersecting_through_bores"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 50}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 1}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 1}},
|
||||
{"claim_kind": "orthogonal_intersecting_through_bores", "expected": {"first_diameter_mm": 20, "second_diameter_mm": 12, "first_axis": "x", "second_axis": "y"}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [],
|
||||
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||
},
|
||||
{
|
||||
"id": "double_hole_linkage_arm",
|
||||
"units": "mm",
|
||||
"request": "生成一个机械臂双孔连杆,两个销孔中心距120毫米,连杆厚度12毫米,两端外圆直径40毫米,两个销孔直径16毫米,中间杆身最小宽度24毫米,轮廓平滑相切,中部设置三个直径14毫米的减重贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 12}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 14, "count": 3}},
|
||||
{"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 16, "adjacent_distances_mm": [120], "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "linkage_outline", "description": "40 mm end circles, 24 mm minimum web width and smooth tangency"},
|
||||
{"id": "lightening_holes", "description": "three 14 mm central through holes"}
|
||||
],
|
||||
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||
},
|
||||
{
|
||||
"id": "three_hole_linkage",
|
||||
"units": "mm",
|
||||
"request": "生成一个三孔机械连杆,三个孔的中心位于同一直线上,相邻孔中心距分别为60毫米和80毫米,三个孔直径均为12毫米,连杆厚度10毫米,每个孔周围外圆直径32毫米,各段外轮廓平滑连接。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 10}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 3}},
|
||||
{"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 12, "adjacent_distances_mm": [60, 80], "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "linkage_outer_circles", "description": "32 mm outer circles with smoothly connected segments"}
|
||||
],
|
||||
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||
},
|
||||
{
|
||||
"id": "l_bracket",
|
||||
"units": "mm",
|
||||
"request": "生成一个整体式L形角码,水平底板长80毫米、宽50毫米、厚8毫米,竖直板高60毫米、宽50毫米、厚8毫米,两板成90度。水平板上有两个直径8毫米贯穿孔,竖直板上有两个直径8毫米贯穿孔,孔左右对称。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "l_bracket_dimensions", "description": "base and upright dimensions, thicknesses and 90 degree relationship"},
|
||||
{"id": "l_bracket_hole_symmetry", "description": "two symmetric 8 mm through holes on each plate"}
|
||||
],
|
||||
"max_author_turns": 88, "max_reviewer_turns": 32, "max_total_calls": 120, "max_wall_seconds": 2700, "max_total_tokens": 360000
|
||||
},
|
||||
{
|
||||
"id": "ribbed_l_bracket",
|
||||
"units": "mm",
|
||||
"request": "生成一个整体式L形机械支架,底板长100毫米、宽60毫米、厚10毫米,竖板高80毫米、宽60毫米、厚10毫米,两板成90度。底板和竖板之间设置两个厚度8毫米的三角加强筋。底板有四个直径9毫米贯穿孔,竖板中心有一个直径30毫米贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "bracket_dimensions", "description": "100 x 60 x 10 base and 80 x 60 x 10 upright at 90 degrees"},
|
||||
{"id": "triangular_ribs", "description": "two triangular 8 mm thick ribs"},
|
||||
{"id": "bracket_hole_placement", "description": "four base 9 mm holes and centred upright 30 mm hole"}
|
||||
],
|
||||
"max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000
|
||||
},
|
||||
{
|
||||
"id": "u_bearing_support",
|
||||
"units": "mm",
|
||||
"request": "生成一个U形轴承支座,底板长100毫米、宽60毫米、厚12毫米,两侧竖耳厚12毫米、高55毫米,两个竖耳内侧间距40毫米。两个竖耳上各有一个直径20毫米的同轴贯穿孔,孔轴线距底板上表面35毫米。底板四角各有一个直径8毫米安装孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 2}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
|
||||
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 20, "count": 2}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "u_support_dimensions", "description": "base and ear dimensions, ear spacing and 35 mm axis height"},
|
||||
{"id": "base_hole_positions", "description": "four 8 mm base mounting-hole positions"}
|
||||
],
|
||||
"max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000
|
||||
},
|
||||
{
|
||||
"id": "double_lug_mount",
|
||||
"units": "mm",
|
||||
"request": "生成一个双耳连接座,底座长90毫米、宽60毫米、厚12毫米,底座上有两个平行耳板,每个耳板厚10毫米、高50毫米,两耳板内侧间距30毫米。两个耳板上各有一个直径16毫米的同轴贯穿销孔,孔中心距底座上表面32毫米。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}},
|
||||
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 16, "count": 2}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "lug_dimensions", "description": "base, lug thickness/height, 30 mm inside spacing and 32 mm bore-axis height"}
|
||||
],
|
||||
"max_author_turns": 92, "max_reviewer_turns": 34, "max_total_calls": 126, "max_wall_seconds": 2700, "max_total_tokens": 380000
|
||||
},
|
||||
{
|
||||
"id": "stepped_shaft",
|
||||
"units": "mm",
|
||||
"request": "生成一根阶梯轴,总长120毫米。第一段直径30毫米、长40毫米;第二段直径24毫米、长50毫米;第三段直径18毫米、长30毫米。所有轴肩过渡圆角R2,两端倒角1毫米乘45度。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 120}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 24, "count": 1}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 18, "count": 1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["revolve_add"],
|
||||
"required_any_atomic_ids": ["fillet", "chamfer"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "shaft_segment_lengths", "description": "the 30 mm, 24 mm and 18 mm shaft sections have axial lengths 40 mm, 50 mm and 30 mm"},
|
||||
{"id": "shaft_finish_features", "description": "R2 shoulders and 1 x 45 degree end chamfers"}
|
||||
],
|
||||
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||
},
|
||||
{
|
||||
"id": "keyed_stepped_shaft",
|
||||
"units": "mm",
|
||||
"request": "生成一根阶梯传动轴,总长140毫米,中间轴段直径30毫米、长70毫米,两端轴段直径20毫米、各长35毫米。中间轴段沿轴向加工一条平键槽,键槽宽8毫米、深3.3毫米、长50毫米,所有轴肩圆角R2。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 140}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "count": 2}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["revolve_add"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind", "fillet"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "shaft_segment_lengths", "description": "the 20 mm end sections are 35 mm each and the 30 mm middle section is 70 mm"},
|
||||
{"id": "keyway", "description": "8 x 3.3 x 50 axial keyway on the middle shaft section"},
|
||||
{"id": "shoulder_fillet", "description": "R2 on all shaft shoulders"}
|
||||
],
|
||||
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||
},
|
||||
{
|
||||
"id": "flanged_sleeve",
|
||||
"units": "mm",
|
||||
"request": "生成一个机械套筒,外径50毫米,内孔直径30毫米,总长60毫米。套筒一端带外径70毫米、厚度10毫米的法兰,法兰上在直径56毫米分度圆上均布4个直径7毫米贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern", "outer_cylindrical_surface"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 60}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 70}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 50, "count": 1, "axial_span_mm": 50}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 70, "count": 1, "axial_span_mm": 10}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 7, "count": 4}},
|
||||
{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 7, "count": 4, "pitch_radius_mm": 28, "concentric_bore_diameter_mm": 30}}
|
||||
],
|
||||
"required_atomic_ids": ["revolve_add"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "flange_at_one_end", "description": "the 70 mm flange occurs at exactly one end of the coaxial sleeve, not at an interior position"}
|
||||
],
|
||||
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||
},
|
||||
{
|
||||
"id": "chamfered_bushing",
|
||||
"units": "mm",
|
||||
"request": "生成一个圆柱轴套,外径40毫米,内孔直径25毫米,长度50毫米,两端外边缘倒角1.5毫米乘45度,两端内孔边缘倒角1毫米乘45度。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 40}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 50}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 25, "count": 1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["revolve_add"],
|
||||
"required_any_atomic_ids": ["chamfer"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "inside_outside_chamfers", "description": "both 1.5 x 45 degree external and 1 x 45 degree internal chamfers"}
|
||||
],
|
||||
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
{
|
||||
"schema_version": "cad.live-eval-fixtures.v1",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "rectangular_plate",
|
||||
"units": "mm",
|
||||
"request": "Create one connected rectangular plate, 80 mm long, 50 mm wide, and 8 mm thick. Use millimetres. Verify the single solid and all three bounding-box dimensions.",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8, "tolerance_mm": 0.01}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"max_author_turns": 32,
|
||||
"max_reviewer_turns": 12,
|
||||
"max_total_calls": 44,
|
||||
"max_wall_seconds": 900,
|
||||
"max_total_tokens": 120000
|
||||
},
|
||||
{
|
||||
"id": "simple_flange",
|
||||
"units": "mm",
|
||||
"request": "Create one connected round flange in millimetres: outer diameter 120 mm, thickness 12 mm, and a centered 40 mm through bore. Verify the single solid, thickness, and through bore topology.",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"max_author_turns": 40,
|
||||
"max_reviewer_turns": 16,
|
||||
"max_total_calls": 56,
|
||||
"max_wall_seconds": 1200,
|
||||
"max_total_tokens": 160000
|
||||
},
|
||||
{
|
||||
"id": "ribbed_mounting_plate",
|
||||
"units": "mm",
|
||||
"request": "Create one connected mounting plate in millimetres with a rectangular base, a vertical reinforcing rib, and four equally spaced mounting through holes. State reasonable dimensions as assumptions, then verify a single connected solid and the four-hole pattern.",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "circular_hole_pattern", "through_cylindrical_bore"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind", "hole_blind"],
|
||||
"max_author_turns": 56,
|
||||
"max_reviewer_turns": 20,
|
||||
"max_total_calls": 76,
|
||||
"max_wall_seconds": 1500,
|
||||
"max_total_tokens": 200000
|
||||
},
|
||||
{
|
||||
"id": "selector_finish",
|
||||
"units": "mm",
|
||||
"request": "Create one connected rectangular plate with an added rounded or chamfered edge selected from the current topology. Use millimetres, state assumptions, and verify the final single solid.",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["fillet", "chamfer"],
|
||||
"max_author_turns": 40,
|
||||
"max_reviewer_turns": 16,
|
||||
"max_total_calls": 56,
|
||||
"max_wall_seconds": 1200,
|
||||
"max_total_tokens": 160000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,990 +0,0 @@
|
||||
"""Run the real provider, engine, reviewer and v3 state machine end to end.
|
||||
|
||||
``--require-live`` deliberately fails closed: missing credentials, blocked
|
||||
network, a skipped model capability, a timeout, or any scenario failure emits
|
||||
``LIVE_EVAL_BLOCKED``/failure details and exits non-zero.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from statistics import median
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.capabilities import verify_model_capability
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.cad_agent.domain.claim_matching import contains_expected
|
||||
from app.cad_agent.domain.operation_contract import canonical_hash
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.cad_agent.evals.token_baseline import (
|
||||
TokenBaselineError,
|
||||
author_request_identity,
|
||||
compare_token_baseline,
|
||||
load_token_baseline,
|
||||
profile_sha256,
|
||||
validate_token_baseline_provenance,
|
||||
)
|
||||
from app.settings import BACKEND_ROOT, get_settings
|
||||
|
||||
|
||||
_LEGACY_CANDIDATE_EVIDENCE_FILES = frozenset({
|
||||
"candidate.json",
|
||||
"candidate-review.json",
|
||||
"model.cdsl.json",
|
||||
"model.step",
|
||||
"model.glb",
|
||||
"model.topology.json",
|
||||
"rebuild-report.json",
|
||||
"renders/render-manifest.json",
|
||||
"renders/contact-sheet.jpg",
|
||||
})
|
||||
|
||||
# A v3.2 Feature DAG publishes one atomic checkpoint after local verification.
|
||||
# It deliberately has no candidate review or technical render bundle; the
|
||||
# final review owns that later evidence. GLB is optional because a preview
|
||||
# conversion outage must not invalidate an otherwise sound STEP checkpoint.
|
||||
_FEATURE_NODE_EVIDENCE_FILES = frozenset({
|
||||
"input.json",
|
||||
"model.cdsl.json",
|
||||
"model.step",
|
||||
"model.topology.json",
|
||||
"node-verification.json",
|
||||
"rebuild-report.json",
|
||||
})
|
||||
|
||||
_FAILURE_LAYERS = frozenset({
|
||||
"model_format_or_decision",
|
||||
"v3_contract_or_verifier",
|
||||
"cdsl_expression",
|
||||
"engine_execution",
|
||||
"independent_visual_review",
|
||||
"configuration_or_network",
|
||||
})
|
||||
|
||||
_ERROR_FAILURE_LAYERS = {
|
||||
"AUTHOR_FORMAT_INVALID": "model_format_or_decision",
|
||||
"AUTHOR_DECISION_REJECTED": "model_format_or_decision",
|
||||
"STALE_WORKING_HEAD": "model_format_or_decision",
|
||||
"FAILED_AUTHOR_FORMAT": "model_format_or_decision",
|
||||
"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED": "model_format_or_decision",
|
||||
"RUNTIME_PRECONDITION_FAILED": "v3_contract_or_verifier",
|
||||
"RUNTIME_CONTRACT_INVALID": "v3_contract_or_verifier",
|
||||
"VERIFIER_UNAVAILABLE": "v3_contract_or_verifier",
|
||||
"CLAIM_VERIFICATION_FAILED": "cdsl_expression",
|
||||
"CANDIDATE_BUILD_FAILED": "engine_execution",
|
||||
"CANDIDATE_REVIEW_REJECTED": "independent_visual_review",
|
||||
"AUTHOR_TRANSPORT_UNAVAILABLE": "configuration_or_network",
|
||||
"REVIEW_SERVICE_UNAVAILABLE": "configuration_or_network",
|
||||
"RENDER_SERVICE_UNAVAILABLE": "configuration_or_network",
|
||||
"STORAGE_FAILURE": "configuration_or_network",
|
||||
"LIVE_EVAL_TIMEOUT": "configuration_or_network",
|
||||
"FAILED_INTERNAL": "engine_execution",
|
||||
}
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run live protocol-v3 CAD evaluations.")
|
||||
parser.add_argument("--suite", choices=("smoke", "release", "comprehensive"), default="smoke")
|
||||
parser.add_argument("--require-live", action="store_true")
|
||||
parser.add_argument("--allow-skip", action="store_true")
|
||||
parser.add_argument("--author-provider")
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
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_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":
|
||||
source_document = str(value.get("source_document") or "")
|
||||
expected_digest = str(value.get("source_document_sha256") or "")
|
||||
source_path = (BACKEND_ROOT.parent / source_document).resolve()
|
||||
workspace_root = BACKEND_ROOT.parent.resolve()
|
||||
if (
|
||||
not source_document
|
||||
or workspace_root not in source_path.parents
|
||||
or not source_path.is_file()
|
||||
or sha256(source_path.read_bytes()).hexdigest() != expected_digest
|
||||
):
|
||||
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 not scenario_ids:
|
||||
return values
|
||||
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
|
||||
|
||||
|
||||
def _rejection_codes(events: list[dict[str, Any]]) -> list[str]:
|
||||
codes: list[str] = []
|
||||
for event in events:
|
||||
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
|
||||
code = result.get("code") if isinstance(result, dict) else None
|
||||
if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "STALE_WORKING_HEAD"}:
|
||||
codes.append(str(code))
|
||||
return codes
|
||||
|
||||
|
||||
def _acceptance_coverage(
|
||||
scenario: dict[str, Any],
|
||||
requirements_contract: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether a fixture's acceptance contract is representable today.
|
||||
|
||||
Comprehensive prompts deliberately include manufacturing relationships that
|
||||
may not yet have a deterministic verifier. A healthy-looking solid is
|
||||
not evidence for those relationships, so they remain explicit capability
|
||||
gaps instead of silently passing a scenario.
|
||||
"""
|
||||
actual = [
|
||||
{
|
||||
"claim_kind": str(claim.get("claim_kind") or ""),
|
||||
"expected": claim.get("expected") if isinstance(claim.get("expected"), dict) else {},
|
||||
}
|
||||
for requirement in (requirements_contract or {}).get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
]
|
||||
required = {str(value) for value in scenario.get("required_claim_kinds") or ()}
|
||||
expected_claims = [
|
||||
{
|
||||
"claim_kind": str(item.get("claim_kind") or ""),
|
||||
"expected": item.get("expected") if isinstance(item.get("expected"), dict) else {},
|
||||
}
|
||||
for item in scenario.get("required_claims") or ()
|
||||
if isinstance(item, dict) and isinstance(item.get("claim_kind"), str)
|
||||
]
|
||||
expected_kinds = {claim["claim_kind"] for claim in expected_claims}
|
||||
expected_claims.extend(
|
||||
{"claim_kind": claim_kind, "expected": {}}
|
||||
for claim_kind in sorted(required - expected_kinds)
|
||||
)
|
||||
gaps = [
|
||||
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
|
||||
for item in scenario.get("validation_capability_gaps") or ()
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
missing = [
|
||||
claim for claim in expected_claims
|
||||
if not any(
|
||||
actual_claim["claim_kind"] == claim["claim_kind"]
|
||||
and _contains_business_expected(actual_claim["expected"], claim["expected"])
|
||||
for actual_claim in actual
|
||||
)
|
||||
]
|
||||
return {
|
||||
"required_claim_kinds": sorted(required),
|
||||
"covered_claim_kinds": sorted(required.intersection({claim["claim_kind"] for claim in actual})),
|
||||
"required_claims": expected_claims,
|
||||
"missing_claims": missing,
|
||||
"validation_capability_gaps": gaps,
|
||||
"complete": not missing and not gaps,
|
||||
}
|
||||
|
||||
|
||||
def _contains_business_expected(actual: Any, required: Any) -> bool:
|
||||
"""Match fixture business values without coupling to verifier tolerances.
|
||||
|
||||
Tolerances are executable verifier parameters chosen within the schema's
|
||||
safe range. They are not a separate user requirement and should not make
|
||||
a valid generated contract fail release evaluation merely because the
|
||||
author used the registry default instead of the fixture's tighter value.
|
||||
"""
|
||||
if isinstance(actual, dict) and isinstance(required, dict):
|
||||
return all(
|
||||
key in actual
|
||||
and _contains_business_expected(actual[key], value)
|
||||
for key, value in required.items()
|
||||
if key not in {"tolerance_mm", "tolerance"}
|
||||
)
|
||||
return contains_expected(actual, required)
|
||||
|
||||
|
||||
def _contains_expected(actual: Any, required: Any) -> bool:
|
||||
"""Match canonical claim values in release evaluation."""
|
||||
return contains_expected(actual, required)
|
||||
|
||||
|
||||
def _capability_block_reason(*reports: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Classify a failed conformance probe without confusing outages for gaps.
|
||||
|
||||
A provider transport failure means the probe did not establish either
|
||||
support or non-support. Only a complete response that violates one of the
|
||||
exposed tool contracts is evidence of a model structured-output limit.
|
||||
"""
|
||||
messages = [
|
||||
str(failure.get("message") or "").casefold()
|
||||
for report in reports
|
||||
for failure in report.get("failures") or ()
|
||||
if isinstance(failure, dict)
|
||||
]
|
||||
unavailable_markers = (
|
||||
"transport unavailable",
|
||||
"connection",
|
||||
"network",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"temporarily unavailable",
|
||||
)
|
||||
if any(any(marker in message for marker in unavailable_markers) for message in messages):
|
||||
return "MODEL_CAPABILITY_PROBE_UNAVAILABLE", "configuration_or_network"
|
||||
return "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED", "model_format_or_decision"
|
||||
|
||||
|
||||
def _artifact_evidence_complete(
|
||||
artifact_root: Path,
|
||||
revisions: list[str],
|
||||
active_revision: str,
|
||||
artifact_manifest: dict[str, Any] | None,
|
||||
ledger: list[dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Require every published CAD decision to retain its reviewable evidence.
|
||||
|
||||
A non-empty report manifest is not enough: a task could otherwise report
|
||||
only a rendered contract view while silently losing the STEP, topology, or
|
||||
node verification used to accept a revision. Checkpoint manifests protect
|
||||
immutable build inputs and outputs; the report manifest additionally
|
||||
protects the frozen contract and final independent review written later.
|
||||
"""
|
||||
if not revisions or not active_revision:
|
||||
return False
|
||||
report_files = {
|
||||
str(item.get("path") or "")
|
||||
for item in (artifact_manifest or {}).get("files") or ()
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
required_report_files = {
|
||||
"requirements-contract.json",
|
||||
f"reviews/final/{active_revision}/final-review.json",
|
||||
}
|
||||
if artifact_manifest is not None and not required_report_files.issubset(report_files):
|
||||
return False
|
||||
if not all((artifact_root / path).is_file() for path in required_report_files):
|
||||
return False
|
||||
feature_revisions = {
|
||||
str(item.get("revision_id") or "")
|
||||
for item in ledger
|
||||
if isinstance(item, dict) and item.get("event") == "feature_node_verified"
|
||||
}
|
||||
for revision_id in sorted(set(revisions)):
|
||||
revision_root = artifact_root / "revisions" / revision_id
|
||||
evidence_files = (
|
||||
_FEATURE_NODE_EVIDENCE_FILES
|
||||
if revision_id in feature_revisions
|
||||
else _LEGACY_CANDIDATE_EVIDENCE_FILES
|
||||
)
|
||||
required_paths = {revision_root / relative for relative in evidence_files}
|
||||
manifest_path = revision_root / "manifest.json"
|
||||
if not manifest_path.is_file() or not all(path.is_file() for path in required_paths):
|
||||
return False
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
declared = manifest.get("files") if isinstance(manifest, dict) else None
|
||||
if not isinstance(declared, dict) or not evidence_files.issubset(declared):
|
||||
return False
|
||||
for relative, digest in declared.items():
|
||||
path = revision_root / str(relative)
|
||||
if not isinstance(digest, str) or len(digest) != 64 or not path.is_file():
|
||||
return False
|
||||
if sha256(path.read_bytes()).hexdigest() != digest:
|
||||
return False
|
||||
if artifact_manifest is not None:
|
||||
expected_report_paths = {f"revisions/{revision_id}/{relative}" for relative in evidence_files}
|
||||
if not expected_report_paths.issubset(report_files):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _failure_attribution(
|
||||
*,
|
||||
checks: dict[str, bool],
|
||||
outcome: str,
|
||||
events: list[dict[str, Any]],
|
||||
projection: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return one evidence-backed failure layer required by the test target."""
|
||||
if outcome == "passed":
|
||||
return None
|
||||
codes: list[str] = []
|
||||
for event in events:
|
||||
payload = event.get("payload") if isinstance(event, dict) and isinstance(event.get("payload"), dict) else {}
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
|
||||
code = result.get("code") if isinstance(result, dict) else ""
|
||||
if isinstance(code, str) and code:
|
||||
codes.append(code)
|
||||
last_error = projection.get("last_error") if isinstance(projection, dict) else ""
|
||||
if isinstance(last_error, str) and last_error:
|
||||
codes.append(last_error)
|
||||
for code in reversed(codes):
|
||||
layer = _ERROR_FAILURE_LAYERS.get(code)
|
||||
if layer:
|
||||
return {
|
||||
"layer": layer,
|
||||
"reason_code": code,
|
||||
"evidence_codes": list(dict.fromkeys(codes)),
|
||||
"failed_checks": [name for name, passed in checks.items() if not passed],
|
||||
}
|
||||
failed_checks = [name for name, passed in checks.items() if not passed]
|
||||
if outcome == "validation_capability_gap" or any(name in {"required_claims", "acceptance_contract_coverage"} for name in failed_checks):
|
||||
layer, reason = "v3_contract_or_verifier", "VALIDATION_CAPABILITY_GAP"
|
||||
elif any(name in {"deterministic_claims_pass", "required_operations", "required_operation_alternative"} for name in failed_checks):
|
||||
layer, reason = "cdsl_expression", "CAD_ACCEPTANCE_GATE_FAILED"
|
||||
elif any(name in {"token_budget", "call_budget", "author_turn_budget", "reviewer_turn_budget"} for name in failed_checks):
|
||||
layer, reason = "configuration_or_network", "EVALUATION_BUDGET_EXCEEDED"
|
||||
elif "raw_argument_audit" in failed_checks:
|
||||
layer, reason = "v3_contract_or_verifier", "RAW_ARGUMENT_AUDIT_FAILED"
|
||||
elif any(name in {"immutable_artifacts", "artifact_manifest", "required_artifact_evidence", "action_ledger"} for name in failed_checks):
|
||||
layer, reason = "configuration_or_network", "ARTIFACT_EVIDENCE_INCOMPLETE"
|
||||
else:
|
||||
layer, reason = "model_format_or_decision", "WORKFLOW_TERMINAL_STATE_MISMATCH"
|
||||
return {
|
||||
"layer": layer,
|
||||
"reason_code": reason,
|
||||
"evidence_codes": list(dict.fromkeys(codes)),
|
||||
"failed_checks": failed_checks,
|
||||
}
|
||||
|
||||
|
||||
def _run_checks(
|
||||
scenario: dict[str, Any],
|
||||
projection: dict[str, Any],
|
||||
usage: dict[str, Any],
|
||||
requirements_contract: dict[str, Any] | None,
|
||||
events: list[dict[str, Any]],
|
||||
artifact_root: Path,
|
||||
ledger: list[dict[str, Any]] | None = None,
|
||||
tool_audits: list[dict[str, Any]] | None = None,
|
||||
artifact_manifest: dict[str, Any] | None = None,
|
||||
) -> dict[str, bool]:
|
||||
records = usage.get("records") if isinstance(usage.get("records"), list) else []
|
||||
author_calls = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"]
|
||||
reviewer_calls = [item for item in records if isinstance(item, dict) and item.get("role") == "reviewer"]
|
||||
total_tokens = int(usage.get("prompt_tokens") or 0) + int(usage.get("completion_tokens") or 0)
|
||||
audit_ledger = ledger if ledger is not None else [item for item in projection.get("action_ledger_summary") or () if isinstance(item, dict)]
|
||||
published = [
|
||||
item for item in audit_ledger
|
||||
if isinstance(item, dict) and item.get("event") in {"accepted", "feature_node_verified"}
|
||||
]
|
||||
revisions = [str(item.get("revision_id") or "") for item in published]
|
||||
scheduled_atomic_ids = {
|
||||
str(item.get("node_id") or ""): str(item.get("atomic_id") or "")
|
||||
for item in audit_ledger
|
||||
if isinstance(item, dict) and item.get("event") == "feature_node_scheduled"
|
||||
}
|
||||
operation_ids = {
|
||||
str(
|
||||
item.get("actual_atomic_id")
|
||||
or item.get("atomic_id")
|
||||
or scheduled_atomic_ids.get(str(item.get("node_id") or ""), "")
|
||||
)
|
||||
for item in published
|
||||
}
|
||||
required_operation_groups = [
|
||||
{str(atomic_id) for atomic_id in group if isinstance(atomic_id, str) and atomic_id}
|
||||
for group in scenario.get("required_any_atomic_id_groups") or ()
|
||||
if isinstance(group, list)
|
||||
]
|
||||
active_revision = str(projection.get("active_revision") or "")
|
||||
manifest = artifact_root / "revisions" / active_revision / "manifest.json"
|
||||
jsonl = artifact_root / "actions" / "action-ledger.jsonl"
|
||||
final_claims = next((item.get("claim_results") for item in reversed(audit_ledger) if isinstance(item, dict) and item.get("event") == "completed" and isinstance(item.get("claim_results"), list)), [])
|
||||
audit_records = tool_audits if isinstance(tool_audits, list) else []
|
||||
audit_valid = bool(audit_records) and all(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get("raw_arguments_hash"), str)
|
||||
and len(item["raw_arguments_hash"]) == 64
|
||||
and item.get("canonical_schema_valid") is True
|
||||
and isinstance(item.get("state_binding"), dict)
|
||||
and bool(item["state_binding"].get("phase"))
|
||||
and item["state_binding"].get("binding_valid") is True
|
||||
and item.get("single_allowed_call") is True
|
||||
for item in audit_records
|
||||
)
|
||||
acceptance = _acceptance_coverage(scenario, requirements_contract)
|
||||
artifact_evidence = _artifact_evidence_complete(
|
||||
artifact_root,
|
||||
revisions,
|
||||
active_revision,
|
||||
artifact_manifest,
|
||||
audit_ledger,
|
||||
)
|
||||
return {
|
||||
"expected_terminal_phase": projection.get("phase") == scenario.get("expected_phase", "COMPLETED"),
|
||||
"token_budget": total_tokens <= int(scenario["max_total_tokens"]),
|
||||
"call_budget": len(records) <= int(scenario["max_total_calls"]),
|
||||
"author_turn_budget": len(author_calls) <= int(scenario["max_author_turns"]),
|
||||
"reviewer_turn_budget": len(reviewer_calls) <= int(scenario["max_reviewer_turns"]),
|
||||
"required_claims": not acceptance["missing_claims"],
|
||||
"acceptance_contract_coverage": acceptance["complete"],
|
||||
"required_operations": {str(value) for value in scenario.get("required_atomic_ids") or ()}.issubset(operation_ids),
|
||||
"required_operation_alternative": (
|
||||
(not scenario.get("required_any_atomic_ids") or bool({str(value) for value in scenario["required_any_atomic_ids"]}.intersection(operation_ids)))
|
||||
and all(group.intersection(operation_ids) for group in required_operation_groups)
|
||||
),
|
||||
"immutable_artifacts": bool(active_revision) and manifest.is_file(),
|
||||
"required_artifact_evidence": artifact_evidence,
|
||||
"artifact_manifest": (
|
||||
artifact_manifest is None
|
||||
or (
|
||||
isinstance(artifact_manifest.get("files"), list)
|
||||
and bool(artifact_manifest["files"])
|
||||
and all(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get("path"), str)
|
||||
and isinstance(item.get("sha256"), str)
|
||||
and len(item["sha256"]) == 64
|
||||
for item in artifact_manifest["files"]
|
||||
)
|
||||
)
|
||||
),
|
||||
"action_ledger": jsonl.is_file(),
|
||||
"unique_revisions": len(revisions) == len(set(revisions)),
|
||||
"deterministic_claims_pass": bool(final_claims) and all(
|
||||
not isinstance(item, dict) or not item.get("deterministic") or item.get("status") == "pass"
|
||||
for item in final_claims
|
||||
),
|
||||
"raw_argument_audit": audit_valid if tool_audits is not None else bool(records) and all(isinstance(item, dict) and isinstance(item.get("raw_arguments_hash"), str) and len(item["raw_arguments_hash"]) == 64 for item in records),
|
||||
"no_schema_or_decision_rejections": not _rejection_codes(events),
|
||||
}
|
||||
|
||||
|
||||
def _git_revision() -> str:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"], cwd=BACKEND_ROOT.parent, capture_output=True,
|
||||
text=True, check=True, timeout=5,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _event_audit(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Persist event metadata without prompts, tool arguments, or tool facts."""
|
||||
audit: list[dict[str, Any]] = []
|
||||
for item in events:
|
||||
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
|
||||
audit.append({
|
||||
"name": str(item.get("name") or ""), "tool": str(payload.get("tool") or ""),
|
||||
"status": str(payload.get("status") or ""), "code": str(result.get("code") or payload.get("code") or ""),
|
||||
"raw_arguments_hash": str(usage.get("raw_arguments_hash") or ""),
|
||||
"field_errors": result.get("field_errors") if isinstance(result.get("field_errors"), list) else [],
|
||||
})
|
||||
return audit
|
||||
|
||||
|
||||
def _report_tool_audits(audits: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return report-safe audit metadata without argument values or prompts."""
|
||||
return [
|
||||
{
|
||||
"audit_id": item.get("audit_id"),
|
||||
"at": item.get("at"),
|
||||
"actor": item.get("actor"),
|
||||
"tool": item.get("tool"),
|
||||
"raw_arguments_hash": item.get("raw_arguments_hash"),
|
||||
"canonical_schema_valid": item.get("canonical_schema_valid"),
|
||||
"field_errors": item.get("field_errors") if isinstance(item.get("field_errors"), list) else [],
|
||||
"state_binding": item.get("state_binding") if isinstance(item.get("state_binding"), dict) else {},
|
||||
"returned_tool": item.get("returned_tool"),
|
||||
"single_allowed_call": item.get("single_allowed_call"),
|
||||
}
|
||||
for item in audits
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
|
||||
|
||||
def _ledger_identifiers(ledger: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||
"""Expose audit identifiers explicitly without copying provider payloads."""
|
||||
candidate_ids = sorted({
|
||||
str(item.get("candidate_id"))
|
||||
for item in ledger
|
||||
if isinstance(item.get("candidate_id"), str) and item.get("candidate_id")
|
||||
})
|
||||
revision_ids = sorted({
|
||||
str(item.get("revision_id"))
|
||||
for item in ledger
|
||||
if isinstance(item.get("revision_id"), str) and item.get("revision_id")
|
||||
})
|
||||
return {"candidate_ids": candidate_ids, "revision_ids": revision_ids}
|
||||
|
||||
|
||||
def _final_claim_results(ledger: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
for item in reversed(ledger):
|
||||
results = item.get("claim_results")
|
||||
if item.get("event") == "completed" and isinstance(results, list):
|
||||
return [value for value in results if isinstance(value, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _redacted_correlation_ids(task_id: str, invocations: list[dict[str, Any]]) -> list[str]:
|
||||
"""Keep run linkage without publishing task or provider correlation values."""
|
||||
return [
|
||||
canonical_hash({"task_id": task_id, "invocation_id": str(item.get("invocation_id") or "")})[:20]
|
||||
for item in invocations
|
||||
if isinstance(item, dict) and item.get("invocation_id")
|
||||
]
|
||||
|
||||
|
||||
def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]:
|
||||
"""Hash reviewable CAD evidence without copying source or prompt content."""
|
||||
allowed_exact = {
|
||||
"requirements-contract.json",
|
||||
"requirements.md",
|
||||
"completion-target.md",
|
||||
"completion-result.md",
|
||||
}
|
||||
allowed_prefixes = ("actions/", "revisions/", "reviews/", "documents/requirements-")
|
||||
files: list[dict[str, str]] = []
|
||||
if artifact_root.is_dir():
|
||||
for path in sorted(artifact_root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(artifact_root).as_posix()
|
||||
if relative not in allowed_exact and not relative.startswith(allowed_prefixes):
|
||||
continue
|
||||
files.append({"path": relative, "sha256": sha256(path.read_bytes()).hexdigest()})
|
||||
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.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:
|
||||
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
|
||||
if review_provider.id == author_provider.id and review_model.id == author_model.id:
|
||||
raise ValueError("Reviewer must differ from author")
|
||||
else:
|
||||
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
|
||||
except ValueError as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
|
||||
baseline_path = arguments.baseline_report.resolve() if arguments.baseline_report else None
|
||||
if arguments.suite == "release" and baseline_path is None:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": "TOKEN_BASELINE_REQUIRED: release requires a provenance-checked pre-v3 token baseline report."}
|
||||
try:
|
||||
baseline = load_token_baseline(baseline_path) if baseline_path else None
|
||||
current_author_identity = author_request_identity(author_provider, author_model)
|
||||
runtime_profile_hash = profile_sha256(settings.engine_root / "profile_schema.json")
|
||||
except TokenBaselineError as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {error}"}
|
||||
if baseline is not None:
|
||||
baseline_errors = validate_token_baseline_provenance(
|
||||
baseline,
|
||||
scenarios=scenarios,
|
||||
author_identity=current_author_identity,
|
||||
runtime_profile_hash=runtime_profile_hash,
|
||||
)
|
||||
if baseline_errors:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {baseline_errors[0]}", "baseline_errors": baseline_errors}
|
||||
isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations")
|
||||
services = compose_v3(isolated)
|
||||
contracts = [
|
||||
{
|
||||
"atomic_id": atomic_id, "contract_hash": services.workflow.runtime.operation_contract(atomic_id)["contract_hash"],
|
||||
"contract_version": services.workflow.runtime.operation_contract(atomic_id)["contract_version"],
|
||||
"registry_revision": services.workflow.runtime.operation_contract(atomic_id)["registry_revision"],
|
||||
}
|
||||
for atomic_id in services.workflow.runtime.supported_atomic_ids()
|
||||
]
|
||||
verifier_schema_hash = canonical_hash(default_registry().expected_one_of_schema())
|
||||
try:
|
||||
author_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=author_provider.id, model_id=author_model.id, role="author", force=True)
|
||||
reviewer_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=review_provider.id, model_id=review_model.id, role="reviewer", force=True)
|
||||
except Exception as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)[:1000]}
|
||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||
error, failure_layer = _capability_block_reason(author_capability, reviewer_capability)
|
||||
return {
|
||||
"status": "LIVE_EVAL_BLOCKED",
|
||||
"error": error,
|
||||
"failure_layer": failure_layer,
|
||||
"author_capability": author_capability,
|
||||
"reviewer_capability": reviewer_capability,
|
||||
}
|
||||
results: list[dict[str, Any]] = []
|
||||
for scenario in scenarios:
|
||||
for repetition in range(1, repetitions + 1):
|
||||
services.workflow.config = replace(
|
||||
services.workflow.config,
|
||||
# State transitions include local candidate recovery, so the
|
||||
# loop guard is deliberately independent from the externally
|
||||
# measured model-call budgets below.
|
||||
max_turns=max(8, int(scenario["max_total_calls"]) * 3),
|
||||
max_author_turns=int(scenario["max_author_turns"]),
|
||||
max_reviewer_turns=int(scenario["max_reviewer_turns"]),
|
||||
max_model_calls=int(scenario["max_total_calls"]),
|
||||
)
|
||||
task_id = f"cad_{secrets.token_hex(6)}"
|
||||
oracle_claims = [item for item in scenario.get("required_claims") or () if isinstance(item, dict)]
|
||||
if oracle_claims:
|
||||
services.workflow.requirements.register_evaluation_contract_oracle(
|
||||
task_id,
|
||||
oracle_claims,
|
||||
validation_capability_gaps=[item for item in scenario.get("validation_capability_gaps") or () if isinstance(item, dict)],
|
||||
)
|
||||
services.workflow.create_task(task_id, str(scenario["request"]))
|
||||
events: list[dict[str, Any]] = []
|
||||
started = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with asyncio.timeout(int(scenario["max_wall_seconds"])):
|
||||
async for name, payload in services.workflow.run(
|
||||
task_id=task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
):
|
||||
events.append({"name": name, "payload": payload})
|
||||
except TimeoutError:
|
||||
events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT"}})
|
||||
projection = services.repository.get_task_projection(task_id) or {}
|
||||
usage = services.repository.usage_summary(task_id)
|
||||
ledger = services.repository.ledger_events(task_id)
|
||||
invocations = services.repository.invocation_records(task_id)
|
||||
tool_audits = services.repository.tool_audits(task_id)
|
||||
terminal = next((item["payload"] for item in reversed(events) if item["name"] == "task_terminal"), {})
|
||||
artifact_root = (isolated.task_root / task_id).resolve()
|
||||
artifact_manifest = _safe_artifact_manifest(artifact_root)
|
||||
finished = datetime.now(timezone.utc)
|
||||
checks = _run_checks(
|
||||
scenario,
|
||||
projection,
|
||||
usage,
|
||||
services.artifacts.read_requirements_contract(
|
||||
task_id,
|
||||
services.repository.get_state(task_id).requirements_contract_path
|
||||
if services.repository.get_state(task_id) is not None
|
||||
else "",
|
||||
),
|
||||
events,
|
||||
artifact_root,
|
||||
ledger,
|
||||
tool_audits,
|
||||
artifact_manifest,
|
||||
)
|
||||
success = all(checks.values())
|
||||
acceptance = _acceptance_coverage(
|
||||
scenario,
|
||||
services.artifacts.read_requirements_contract(
|
||||
task_id,
|
||||
services.repository.get_state(task_id).requirements_contract_path
|
||||
if services.repository.get_state(task_id) is not None
|
||||
else "",
|
||||
),
|
||||
)
|
||||
outcome = (
|
||||
"passed"
|
||||
if success
|
||||
else "validation_capability_gap"
|
||||
if not acceptance["complete"]
|
||||
and all(value for key, value in checks.items() if key != "acceptance_contract_coverage")
|
||||
else "failed"
|
||||
)
|
||||
failure_attribution = _failure_attribution(
|
||||
checks=checks,
|
||||
outcome=outcome,
|
||||
events=events,
|
||||
projection=projection,
|
||||
)
|
||||
results.append({
|
||||
"scenario": scenario["id"], "repetition": repetition, "success": success, "outcome": outcome,
|
||||
"started_at": started.isoformat(), "finished_at": finished.isoformat(),
|
||||
"duration_ms": round((finished - started).total_seconds() * 1000),
|
||||
"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"),
|
||||
"rollback_count": sum(1 for item in ledger if item.get("event") == "rollback"),
|
||||
**_ledger_identifiers(ledger),
|
||||
"final_claim_results": _final_claim_results(ledger),
|
||||
"redacted_correlation_ids": _redacted_correlation_ids(task_id, invocations),
|
||||
"tool_audits": _report_tool_audits(tool_audits),
|
||||
"artifact_root": str(artifact_root), "action_ledger_path": str(artifact_root / "actions" / "action-ledger.jsonl"),
|
||||
"artifact_manifest": artifact_manifest,
|
||||
"ledger": ledger, "invocations": invocations, "event_audit": _event_audit(events),
|
||||
})
|
||||
token_comparison = (
|
||||
compare_token_baseline(
|
||||
baseline,
|
||||
scenarios=scenarios,
|
||||
v3_results=results,
|
||||
author_identity=current_author_identity,
|
||||
runtime_profile_hash=runtime_profile_hash,
|
||||
)
|
||||
if baseline is not None
|
||||
else {"schema_version": "cad.token-comparison.v1", "status": "not_required"}
|
||||
)
|
||||
token_gate = all(token_comparison.get("checks", {}).values()) if baseline is not None else True
|
||||
return {
|
||||
"status": "passed" if results and all(item["success"] for item in results) and token_gate else "failed",
|
||||
"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": {
|
||||
"author": str(author_capability.get("mode") or ""),
|
||||
"reviewer": str(reviewer_capability.get("mode") or ""),
|
||||
},
|
||||
"git_revision": _git_revision(),
|
||||
"protocol_version": "3.2",
|
||||
"runtime_profile_sha256": runtime_profile_hash,
|
||||
"operation_contracts": contracts,
|
||||
"verifier_registry_version": "cad.verifier-registry.v1",
|
||||
"verifier_schema_hash": verifier_schema_hash,
|
||||
"token_comparison": token_comparison,
|
||||
"baseline_report": str(baseline_path) if baseline_path else "",
|
||||
"repetitions": repetitions,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
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 = (
|
||||
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.
|
||||
raise
|
||||
except BaseException as error:
|
||||
# A live evaluation may fail before it creates a task (for example
|
||||
# during conformance). Its report is still the release gate's audit
|
||||
# artifact, so an unexpected evaluator failure must not disappear.
|
||||
result = {
|
||||
"status": "LIVE_EVAL_BLOCKED",
|
||||
"error": f"UNEXPECTED_LIVE_EVAL_ERROR: {type(error).__name__}: {str(error)[:900]}",
|
||||
"failure_layer": "configuration_or_network",
|
||||
}
|
||||
if result["status"] == "LIVE_EVAL_BLOCKED" and arguments.allow_skip and not arguments.require_live:
|
||||
result["skip_reason"] = result.get("error", "live provider access is unavailable")
|
||||
result["status"] = "skipped"
|
||||
result.update({"suite": arguments.suite, "require_live": arguments.require_live, "report_root": str(report_root.resolve())})
|
||||
(report_root / "report.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({"status": result["status"], "report": str((report_root / "report.json").resolve())}, ensure_ascii=False))
|
||||
if result["status"] == "passed":
|
||||
return 0
|
||||
if result["status"] == "skipped":
|
||||
return 0
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Advance one persisted event for a task in an isolated live-evaluation root.
|
||||
|
||||
Long real-provider evaluations can outlive a command host's execution window.
|
||||
This utility takes exactly one event from ``WorkflowCoordinator.run`` and
|
||||
closes the async generator after that event has been durably handled. Repeated
|
||||
invocations therefore resume the same task without re-running already
|
||||
persisted nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Advance one event for an isolated live CAD evaluation task.")
|
||||
parser.add_argument("--report-root", required=True, type=Path)
|
||||
parser.add_argument("--task-id", required=True)
|
||||
parser.add_argument("--author-provider")
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def _advance(arguments: argparse.Namespace) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
|
||||
if arguments.review_provider or arguments.review_model:
|
||||
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
|
||||
else:
|
||||
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
|
||||
report_root = arguments.report_root.resolve()
|
||||
services = compose_v3(replace(
|
||||
settings,
|
||||
task_root=report_root / "artifacts",
|
||||
conversation_root=report_root / "conversations",
|
||||
))
|
||||
before = services.repository.get_state(arguments.task_id)
|
||||
if before is None:
|
||||
raise ValueError(f"Unknown task {arguments.task_id!r} in {report_root}")
|
||||
runner = services.workflow.run(
|
||||
task_id=arguments.task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
)
|
||||
try:
|
||||
name, payload = await anext(runner)
|
||||
except StopAsyncIteration:
|
||||
name, payload = "workflow_exhausted", {}
|
||||
finally:
|
||||
await runner.aclose()
|
||||
after = services.repository.get_state(arguments.task_id)
|
||||
return {
|
||||
"task_id": arguments.task_id,
|
||||
"event": {"name": name, "payload": payload},
|
||||
"before": {"phase": before.phase.value, "version": before.version},
|
||||
"after": {
|
||||
"phase": after.phase.value if after is not None else "",
|
||||
"version": after.version if after is not None else -1,
|
||||
"active_revision": after.active_revision if after is not None else "",
|
||||
"last_error": after.last_error.value if after is not None and after.last_error else "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
try:
|
||||
result = asyncio.run(_advance(arguments))
|
||||
except BaseException as error:
|
||||
result = {"error": f"{type(error).__name__}: {str(error)[:1000]}"}
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 2
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Aggregate local results for the `cad.single-stage.v1` evaluation suite.
|
||||
|
||||
The evaluator accepts already-recorded task projections and never calls a
|
||||
model. This keeps rate reporting reproducible and separates it from live
|
||||
provider experiments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
def summarize(records: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Return first-pass, final, compliance, and cost counters.
|
||||
|
||||
A record may contain ``attempts`` with ``schema_valid`` and
|
||||
``executable`` booleans, final lifecycle/revision information, requirement
|
||||
target states, usage records, and an elapsed duration in milliseconds.
|
||||
Missing fields are reported as zero rather than guessed.
|
||||
"""
|
||||
values = [item for item in records if isinstance(item, dict)]
|
||||
first_schema = first_executable = final_executable = 0
|
||||
targets: dict[str, int] = {key: 0 for key in ("pass", "fail", "pending", "not_applicable")}
|
||||
calls = prompt_tokens = completion_tokens = duration_ms = 0
|
||||
for record in values:
|
||||
attempts = record.get("attempts") if isinstance(record.get("attempts"), list) else []
|
||||
first = attempts[0] if attempts and isinstance(attempts[0], dict) else {}
|
||||
first_schema += int(bool(first.get("schema_valid")))
|
||||
first_executable += int(bool(first.get("executable")))
|
||||
final_executable += int(bool(record.get("published_revision") or record.get("active_revision")))
|
||||
for target in record.get("requirement_targets") or ():
|
||||
state = str(target.get("status") or "pending") if isinstance(target, dict) else "pending"
|
||||
targets[state if state in targets else "pending"] += 1
|
||||
usage = record.get("usage") if isinstance(record.get("usage"), dict) else {}
|
||||
usage_records = usage.get("records") if isinstance(usage.get("records"), list) else []
|
||||
calls += len(usage_records)
|
||||
for item in usage_records:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
prompt_tokens += int(item.get("prompt_tokens") or 0)
|
||||
completion_tokens += int(item.get("completion_tokens") or 0)
|
||||
duration_ms += int(record.get("duration_ms") or 0)
|
||||
total = len(values)
|
||||
rate = lambda count: count / total if total else 0.0
|
||||
return {
|
||||
"schema_version": "cad.single-stage.eval-summary.v1",
|
||||
"tasks": total,
|
||||
"first_pass_schema_rate": rate(first_schema),
|
||||
"first_pass_executable_rate": rate(first_executable),
|
||||
"final_executable_rate": rate(final_executable),
|
||||
"requirement_targets": targets,
|
||||
"calls": calls,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
"""Provenance-checked token baseline comparison for the v3 release gate.
|
||||
|
||||
The target intentionally does not accept estimated tokens or a hand-written
|
||||
percentage. A v2 run must record the same fixed requests and author request
|
||||
configuration before it can be compared with a v3 release report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
from typing import Any
|
||||
|
||||
from app.settings import ProviderConfig, ProviderModel
|
||||
|
||||
|
||||
BASELINE_SCHEMA_VERSION = "cad.token-baseline.v1"
|
||||
BASELINE_PROTOCOL_VERSION = "2.0"
|
||||
MINIMUM_REPETITIONS = 3
|
||||
TOKEN_REDUCTION_TARGET = 0.30
|
||||
MEASUREMENTS = {"prompt_tokens", "context_chars"}
|
||||
|
||||
|
||||
class TokenBaselineError(ValueError):
|
||||
"""A baseline cannot prove the release token target."""
|
||||
|
||||
|
||||
def request_sha256(request: str) -> str:
|
||||
return sha256(request.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def profile_sha256(profile_path: Path) -> str:
|
||||
try:
|
||||
return sha256(profile_path.read_bytes()).hexdigest()
|
||||
except OSError as error:
|
||||
raise TokenBaselineError("Runtime profile is unavailable for token-baseline provenance.") from error
|
||||
|
||||
|
||||
def author_request_identity(provider: ProviderConfig, model: ProviderModel) -> dict[str, Any]:
|
||||
"""Capture every author setting that can affect prompt-token comparison."""
|
||||
return {
|
||||
"provider": provider.id,
|
||||
"model": model.id,
|
||||
"api_style": provider.api_style,
|
||||
"reasoning_effort": provider.reasoning_effort,
|
||||
# Chat Completions is explicitly deterministic in StructuredModelGateway.
|
||||
# Responses models use their configured/provider-default sampling mode.
|
||||
"sampling": {"temperature": 0 if provider.api_style == "chat_completions" else None},
|
||||
}
|
||||
|
||||
|
||||
def load_token_baseline(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise TokenBaselineError("Token baseline report is missing or is not valid JSON.") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise TokenBaselineError("Token baseline report must be a JSON object.")
|
||||
if payload.get("schema_version") != BASELINE_SCHEMA_VERSION:
|
||||
raise TokenBaselineError(f"Token baseline must use {BASELINE_SCHEMA_VERSION}.")
|
||||
if payload.get("protocol_version") != BASELINE_PROTOCOL_VERSION:
|
||||
raise TokenBaselineError("Token baseline must be a measured pre-v3 (2.0) report.")
|
||||
if payload.get("measurement") not in MEASUREMENTS:
|
||||
raise TokenBaselineError("Token baseline measurement must be prompt_tokens or context_chars.")
|
||||
return payload
|
||||
|
||||
|
||||
def validate_token_baseline_provenance(
|
||||
baseline: dict[str, Any],
|
||||
*,
|
||||
scenarios: list[dict[str, Any]],
|
||||
author_identity: dict[str, Any],
|
||||
runtime_profile_hash: str,
|
||||
) -> list[str]:
|
||||
"""Reject an incomparable baseline before any billable v3 API call."""
|
||||
errors: list[str] = []
|
||||
if baseline.get("schema_version") != BASELINE_SCHEMA_VERSION:
|
||||
errors.append(f"schema_version must equal {BASELINE_SCHEMA_VERSION}")
|
||||
if baseline.get("protocol_version") != BASELINE_PROTOCOL_VERSION:
|
||||
errors.append("protocol_version must equal 2.0")
|
||||
if baseline.get("author") != author_identity:
|
||||
errors.append("author provider/model/API mode/reasoning/sampling does not match the v3 release")
|
||||
if baseline.get("runtime_profile_sha256") != runtime_profile_hash:
|
||||
errors.append("runtime profile hash does not match the v3 release")
|
||||
measurement = str(baseline.get("measurement") or "")
|
||||
if measurement not in MEASUREMENTS:
|
||||
errors.append("measurement must be prompt_tokens or context_chars")
|
||||
baseline_scenarios = baseline.get("scenarios")
|
||||
if not isinstance(baseline_scenarios, list):
|
||||
return [*errors, "scenarios must be an array"]
|
||||
by_scenario = {
|
||||
str(item.get("scenario") or ""): item
|
||||
for item in baseline_scenarios
|
||||
if isinstance(item, dict) and item.get("scenario")
|
||||
}
|
||||
for scenario in scenarios:
|
||||
scenario_id = str(scenario.get("id") or "")
|
||||
expected_request_hash = request_sha256(str(scenario.get("request") or ""))
|
||||
baseline_scenario = by_scenario.get(scenario_id)
|
||||
if not isinstance(baseline_scenario, dict):
|
||||
errors.append(f"missing baseline scenario {scenario_id}")
|
||||
continue
|
||||
if baseline_scenario.get("request_sha256") != expected_request_hash:
|
||||
errors.append(f"baseline request hash differs for {scenario_id}")
|
||||
repetitions = baseline_scenario.get("repetitions")
|
||||
if not isinstance(repetitions, list):
|
||||
errors.append(f"baseline {scenario_id} repetitions must be an array")
|
||||
continue
|
||||
by_repetition = {
|
||||
int(item.get("repetition")): item
|
||||
for item in repetitions
|
||||
if isinstance(item, dict) and isinstance(item.get("repetition"), int)
|
||||
}
|
||||
expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1))
|
||||
if not expected_repetitions.issubset(by_repetition):
|
||||
errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
|
||||
continue
|
||||
for repetition in expected_repetitions:
|
||||
run = by_repetition[repetition]
|
||||
if _non_negative_int(run.get("author_metric")) is None or _non_negative_int(run.get("plan_review_metric")) is None:
|
||||
errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics")
|
||||
for field in ("completed", "final_review_passed", "deterministic_claims_pass"):
|
||||
if not isinstance(run.get(field), bool):
|
||||
errors.append(f"baseline {scenario_id} repetition {repetition} has non-boolean {field}")
|
||||
return errors
|
||||
|
||||
|
||||
def compare_token_baseline(
|
||||
baseline: dict[str, Any],
|
||||
*,
|
||||
scenarios: list[dict[str, Any]],
|
||||
v3_results: list[dict[str, Any]],
|
||||
author_identity: dict[str, Any],
|
||||
runtime_profile_hash: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a complete, fail-closed comparison for the release report."""
|
||||
errors = validate_token_baseline_provenance(
|
||||
baseline, scenarios=scenarios, author_identity=author_identity,
|
||||
runtime_profile_hash=runtime_profile_hash,
|
||||
)
|
||||
measurement = str(baseline.get("measurement") or "")
|
||||
|
||||
baseline_scenarios = baseline.get("scenarios")
|
||||
if not isinstance(baseline_scenarios, list):
|
||||
errors.append("scenarios must be an array")
|
||||
baseline_scenarios = []
|
||||
by_scenario = {
|
||||
str(item.get("scenario") or ""): item
|
||||
for item in baseline_scenarios
|
||||
if isinstance(item, dict) and item.get("scenario")
|
||||
}
|
||||
current_by_scenario: dict[str, list[dict[str, Any]]] = {}
|
||||
for result in v3_results:
|
||||
if isinstance(result, dict) and isinstance(result.get("scenario"), str):
|
||||
current_by_scenario.setdefault(result["scenario"], []).append(result)
|
||||
|
||||
baseline_tokens: list[int] = []
|
||||
current_tokens: list[int] = []
|
||||
baseline_completed: list[bool] = []
|
||||
current_completed: list[bool] = []
|
||||
baseline_final_review: list[bool] = []
|
||||
current_final_review: list[bool] = []
|
||||
baseline_deterministic: list[bool] = []
|
||||
current_deterministic: list[bool] = []
|
||||
per_scenario: list[dict[str, Any]] = []
|
||||
|
||||
for scenario in scenarios:
|
||||
scenario_id = str(scenario.get("id") or "")
|
||||
expected_request_hash = request_sha256(str(scenario.get("request") or ""))
|
||||
baseline_scenario = by_scenario.get(scenario_id)
|
||||
if not isinstance(baseline_scenario, dict):
|
||||
errors.append(f"missing baseline scenario {scenario_id}")
|
||||
continue
|
||||
if baseline_scenario.get("request_sha256") != expected_request_hash:
|
||||
errors.append(f"baseline request hash differs for {scenario_id}")
|
||||
continue
|
||||
repetitions = baseline_scenario.get("repetitions")
|
||||
if not isinstance(repetitions, list) or len(repetitions) < MINIMUM_REPETITIONS:
|
||||
errors.append(f"baseline {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions")
|
||||
continue
|
||||
current = current_by_scenario.get(scenario_id, [])
|
||||
if len(current) < MINIMUM_REPETITIONS:
|
||||
errors.append(f"v3 {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions")
|
||||
continue
|
||||
baseline_by_repetition = {
|
||||
int(item.get("repetition")) : item
|
||||
for item in repetitions
|
||||
if isinstance(item, dict) and isinstance(item.get("repetition"), int)
|
||||
}
|
||||
current_by_repetition = {
|
||||
int(item.get("repetition")): item
|
||||
for item in current
|
||||
if isinstance(item.get("repetition"), int)
|
||||
}
|
||||
expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1))
|
||||
if not expected_repetitions.issubset(baseline_by_repetition):
|
||||
errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
|
||||
continue
|
||||
if not expected_repetitions.issubset(current_by_repetition):
|
||||
errors.append(f"v3 {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
|
||||
continue
|
||||
|
||||
scenario_baseline_tokens: list[int] = []
|
||||
scenario_current_tokens: list[int] = []
|
||||
for repetition in sorted(expected_repetitions):
|
||||
baseline_run = baseline_by_repetition[repetition]
|
||||
author_tokens = _non_negative_int(baseline_run.get("author_metric"))
|
||||
plan_review_tokens = _non_negative_int(baseline_run.get("plan_review_metric"))
|
||||
if author_tokens is None or plan_review_tokens is None:
|
||||
errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics")
|
||||
continue
|
||||
current_run = current_by_repetition[repetition]
|
||||
usage = current_run.get("usage") if isinstance(current_run.get("usage"), dict) else {}
|
||||
v3_author_tokens = _v3_author_metric(usage, measurement)
|
||||
if v3_author_tokens is None:
|
||||
errors.append(f"v3 {scenario_id} repetition {repetition} has invalid author {measurement} usage")
|
||||
continue
|
||||
scenario_baseline_tokens.append(author_tokens + plan_review_tokens)
|
||||
scenario_current_tokens.append(v3_author_tokens)
|
||||
baseline_completed.append(bool(baseline_run.get("completed")))
|
||||
current_completed.append(str((current_run.get("projection") or {}).get("phase") or "") == "COMPLETED")
|
||||
baseline_final_review.append(bool(baseline_run.get("final_review_passed")))
|
||||
current_final_review.append(str((current_run.get("terminal") or {}).get("lifecycle") or "") == "completed")
|
||||
baseline_deterministic.append(bool(baseline_run.get("deterministic_claims_pass")))
|
||||
checks = current_run.get("checks") if isinstance(current_run.get("checks"), dict) else {}
|
||||
current_deterministic.append(bool(checks.get("deterministic_claims_pass")))
|
||||
if len(scenario_baseline_tokens) == MINIMUM_REPETITIONS and len(scenario_current_tokens) == MINIMUM_REPETITIONS:
|
||||
baseline_tokens.extend(scenario_baseline_tokens)
|
||||
current_tokens.extend(scenario_current_tokens)
|
||||
per_scenario.append({
|
||||
"scenario": scenario_id,
|
||||
"baseline_median_metric": median(scenario_baseline_tokens),
|
||||
"v3_median_author_metric": median(scenario_current_tokens),
|
||||
})
|
||||
|
||||
baseline_median = median(baseline_tokens) if baseline_tokens else None
|
||||
current_median = median(current_tokens) if current_tokens else None
|
||||
reduction = (1 - current_median / baseline_median) if baseline_median and current_median is not None else None
|
||||
baseline_valid = not errors
|
||||
checks = {
|
||||
"baseline_valid": baseline_valid,
|
||||
"median_metric_reduction": baseline_valid and reduction is not None and reduction >= TOKEN_REDUCTION_TARGET,
|
||||
"completion_rate_not_lower": baseline_valid and _rate(current_completed) >= _rate(baseline_completed) if baseline_completed and current_completed else False,
|
||||
"final_review_rate_not_lower": baseline_valid and _rate(current_final_review) >= _rate(baseline_final_review) if baseline_final_review and current_final_review else False,
|
||||
"deterministic_claim_rate_not_lower": baseline_valid and _rate(current_deterministic) >= _rate(baseline_deterministic) if baseline_deterministic and current_deterministic else False,
|
||||
"plan_review_calls_removed": baseline_valid and all(
|
||||
not str(record.get("tool") or "").startswith("modeling_plan")
|
||||
for result in v3_results if isinstance(result, dict)
|
||||
for record in ((result.get("usage") or {}).get("records") or [])
|
||||
if isinstance(record, dict)
|
||||
),
|
||||
}
|
||||
return {
|
||||
"schema_version": "cad.token-comparison.v1",
|
||||
"baseline_protocol_version": baseline.get("protocol_version"),
|
||||
"measurement": measurement,
|
||||
"baseline_median_metric": baseline_median,
|
||||
"v3_median_author_metric": current_median,
|
||||
"median_metric_reduction": reduction,
|
||||
"target_median_metric_reduction": TOKEN_REDUCTION_TARGET,
|
||||
"baseline_completion_rate": _rate(baseline_completed),
|
||||
"v3_completion_rate": _rate(current_completed),
|
||||
"baseline_final_review_rate": _rate(baseline_final_review),
|
||||
"v3_final_review_rate": _rate(current_final_review),
|
||||
"baseline_deterministic_claim_rate": _rate(baseline_deterministic),
|
||||
"v3_deterministic_claim_rate": _rate(current_deterministic),
|
||||
"per_scenario": per_scenario,
|
||||
"errors": errors,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def _non_negative_int(value: Any) -> int | None:
|
||||
return value if isinstance(value, int) and value >= 0 else None
|
||||
|
||||
|
||||
def _v3_author_metric(usage: dict[str, Any], measurement: str) -> int | None:
|
||||
records = usage.get("records")
|
||||
if not isinstance(records, list):
|
||||
return None
|
||||
author_records = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"]
|
||||
if not author_records:
|
||||
return None
|
||||
if measurement == "prompt_tokens" and not all(item.get("usage_available") is True for item in author_records):
|
||||
return None
|
||||
field = "prompt_tokens" if measurement == "prompt_tokens" else "context_chars"
|
||||
values = [
|
||||
_non_negative_int(item.get(field))
|
||||
for item in author_records
|
||||
]
|
||||
return sum(value for value in values if value is not None) if all(value is not None for value in values) else None
|
||||
|
||||
|
||||
def _rate(values: list[bool]) -> float | None:
|
||||
return sum(values) / len(values) if values else None
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Run real-provider CAD tasks with a usable-model success criterion.
|
||||
|
||||
This evaluator is intentionally narrower than the release gate in ``live.py``.
|
||||
It answers one operational question: can the current workflow reliably finish
|
||||
ordinary prompts with a downloadable/previewable checkpoint, even if some
|
||||
acceptance claims remain best-effort warnings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.settings import BACKEND_ROOT, get_settings
|
||||
|
||||
|
||||
DEFAULT_PROMPTS = (
|
||||
"生成一个 80 mm x 50 mm x 8 mm 的简单矩形板,使用毫米,输出一个单一实体。",
|
||||
"生成一个简单法兰,外径 100 mm,厚度 10 mm,中间有 30 mm 通孔,使用毫米。",
|
||||
"生成一个圆柱垫块,直径 60 mm,高度 20 mm,中间有 20 mm 通孔,使用毫米。",
|
||||
)
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run real CAD generation prompts and require usable model artifacts.")
|
||||
parser.add_argument("--author-provider")
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
parser.add_argument("--prompt", action="append", help="Prompt to run. Repeat for multiple prompts. Defaults to three simple CAD prompts.")
|
||||
parser.add_argument("--max-wall-seconds", type=int, default=1200)
|
||||
parser.add_argument("--max-model-calls", type=int, default=60)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _result_code(events: list[dict[str, Any]], projection: dict[str, Any]) -> str:
|
||||
terminal = next((item["payload"] for item in reversed(events) if item.get("name") == "task_terminal"), {})
|
||||
for source in (terminal, projection):
|
||||
code = source.get("code") or source.get("last_error") if isinstance(source, dict) else ""
|
||||
if isinstance(code, str) and code:
|
||||
return code
|
||||
return ""
|
||||
|
||||
|
||||
def _artifact_ok(artifact_root: Path, revision_id: str) -> bool:
|
||||
if not revision_id:
|
||||
return False
|
||||
revision_root = artifact_root / "revisions" / revision_id
|
||||
return all((revision_root / name).is_file() for name in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json"))
|
||||
|
||||
|
||||
def _classify_failure(code: str, projection: dict[str, Any]) -> str:
|
||||
if str(projection.get("lifecycle") or "") == "waiting_retry":
|
||||
return "configuration_or_service"
|
||||
if code in {"FAILED_INTERNAL", "RUNTIME_CONTRACT_INVALID", "REQUIREMENTS_SPEC_INVALID", "STORAGE_FAILURE", "RENDER_SERVICE_UNAVAILABLE"}:
|
||||
return "code_or_flow"
|
||||
if code in {"AUTHOR_TRANSPORT_UNAVAILABLE", "REVIEW_SERVICE_UNAVAILABLE", "MODEL_PROTOCOL_CHECK_PENDING"}:
|
||||
return "configuration_or_service"
|
||||
if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "CANDIDATE_BUILD_FAILED", "CLAIM_VERIFICATION_FAILED", "CANDIDATE_REVIEW_REJECTED", "NO_PROGRESS_LIMIT", "BEST_EFFORT_COMPLETED"}:
|
||||
return "model_output_or_best_effort"
|
||||
return "unknown"
|
||||
|
||||
|
||||
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
try:
|
||||
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
|
||||
if arguments.review_provider or arguments.review_model:
|
||||
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
|
||||
else:
|
||||
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
|
||||
except ValueError as error:
|
||||
return {"status": "blocked", "error": str(error), "results": []}
|
||||
isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations")
|
||||
services = compose_v3(isolated)
|
||||
services.workflow.config = replace(
|
||||
services.workflow.config,
|
||||
max_turns=max(8, arguments.max_model_calls * 3),
|
||||
max_model_calls=arguments.max_model_calls,
|
||||
)
|
||||
prompts = tuple(arguments.prompt or DEFAULT_PROMPTS)
|
||||
results: list[dict[str, Any]] = []
|
||||
for index, prompt in enumerate(prompts, start=1):
|
||||
task_id = f"cad_{secrets.token_hex(6)}"
|
||||
services.workflow.create_task(task_id, prompt)
|
||||
events: list[dict[str, Any]] = []
|
||||
started = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with asyncio.timeout(arguments.max_wall_seconds):
|
||||
async for name, payload in services.workflow.run(
|
||||
task_id=task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
):
|
||||
events.append({"name": name, "payload": payload})
|
||||
except TimeoutError:
|
||||
events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT", "message": "Task timed out."}})
|
||||
projection = services.repository.get_task_projection(task_id) or {}
|
||||
artifact_root = (isolated.task_root / task_id).resolve()
|
||||
revision_id = str(projection.get("active_revision") or projection.get("current_revision") or "")
|
||||
code = _result_code(events, projection)
|
||||
usable = str(projection.get("lifecycle") or "") == "completed" and _artifact_ok(artifact_root, revision_id)
|
||||
results.append({
|
||||
"index": index,
|
||||
"task_id": task_id,
|
||||
"prompt": prompt,
|
||||
"success": usable,
|
||||
"failure_layer": "" if usable else _classify_failure(code, projection),
|
||||
"code": code,
|
||||
"phase": projection.get("phase"),
|
||||
"lifecycle": projection.get("lifecycle"),
|
||||
"active_revision": revision_id,
|
||||
"verification_status": projection.get("verification_status"),
|
||||
"artifact_root": str(artifact_root),
|
||||
"duration_ms": round((datetime.now(timezone.utc) - started).total_seconds() * 1000),
|
||||
"usage": services.repository.usage_summary(task_id),
|
||||
"event_audit": [
|
||||
{
|
||||
"name": item.get("name"),
|
||||
"status": (item.get("payload") or {}).get("status"),
|
||||
"lifecycle": (item.get("payload") or {}).get("lifecycle"),
|
||||
"code": ((item.get("payload") or {}).get("result") or {}).get("code") if isinstance((item.get("payload") or {}).get("result"), dict) else (item.get("payload") or {}).get("code"),
|
||||
"tool": (item.get("payload") or {}).get("tool"),
|
||||
}
|
||||
for item in events
|
||||
],
|
||||
})
|
||||
return {
|
||||
"status": "passed" if results and all(item["success"] for item in results) else "failed",
|
||||
"schema_version": "cad.usable-smoke.v1",
|
||||
"author": {"provider": author_provider.id, "model": author_model.id},
|
||||
"reviewer": {"provider": review_provider.id, "model": review_model.id},
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
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))
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except BaseException as error:
|
||||
result = {"status": "blocked", "error": f"UNEXPECTED_USABLE_SMOKE_ERROR: {type(error).__name__}: {str(error)[:1000]}", "results": []}
|
||||
result.update({"report_root": str(report_root.resolve())})
|
||||
report_path = report_root / "usable-smoke-report.json"
|
||||
report_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({"status": result["status"], "report": str(report_path.resolve())}, ensure_ascii=False))
|
||||
return 0 if result["status"] == "passed" else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -5,60 +5,13 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .domain.state import TaskPhase, TaskState
|
||||
from .domain.state import 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
|
||||
@@ -68,7 +21,7 @@ class InvocationRecord:
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CandidateStage:
|
||||
class StagingRevision:
|
||||
stage_id: str
|
||||
output_dir: str
|
||||
|
||||
@@ -106,38 +59,21 @@ class TaskRepository(Protocol):
|
||||
|
||||
class ArtifactStore(Protocol):
|
||||
def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> None: ...
|
||||
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ...
|
||||
def write_source_index(self, task_id: str, request: str) -> dict[str, str]: ...
|
||||
def read_source_index(self, task_id: str) -> dict[str, str]: ...
|
||||
def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ...
|
||||
def read_source_requirements(self, task_id: str) -> str: ...
|
||||
def source_image_paths(self, task_id: str) -> list[str]: ...
|
||||
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
|
||||
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
|
||||
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: ...
|
||||
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: ...
|
||||
def write_json_once(self, task_id: str, relative_path: str, payload: dict[str, Any]) -> str: ...
|
||||
def write_text_once(self, task_id: str, relative_path: str, text: str) -> str: ...
|
||||
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ...
|
||||
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ...
|
||||
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage: ...
|
||||
def stage_output_dir(self, task_id: str, stage_id: str) -> str: ...
|
||||
def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision: ...
|
||||
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str: ...
|
||||
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None: ...
|
||||
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ...
|
||||
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None: ...
|
||||
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None: ...
|
||||
def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ...
|
||||
|
||||
|
||||
class CadRuntime(Protocol):
|
||||
def supported_atomic_ids(self) -> tuple[str, ...]: ...
|
||||
def operation_contract(self, atomic_id: str) -> dict[str, Any]: ...
|
||||
def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]: ...
|
||||
def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]: ...
|
||||
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False, depends_on_feature_ids: tuple[str, ...] | list[str] = ()) -> tuple[dict[str, Any], dict[str, Any]]: ...
|
||||
def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
|
||||
def create_preview(self, output_dir: str) -> dict[str, Any]: ...
|
||||
def render_review_bundle(self, output_dir: str) -> dict[str, Any]: ...
|
||||
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
|
||||
def compile_authoring(self, document: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: ...
|
||||
def rebuild_best_effort(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]: ...
|
||||
|
||||
|
||||
@@ -146,13 +82,5 @@ class ModelGateway(Protocol):
|
||||
async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class ReviewGateway(Protocol):
|
||||
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class VerifierExecutor(Protocol):
|
||||
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]: ...
|
||||
|
||||
|
||||
class EventPublisher(Protocol):
|
||||
async def publish(self, event: dict[str, Any]) -> None: ...
|
||||
|
||||
+22
-123
@@ -7,13 +7,11 @@ from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.models.contracts import ChatRequest, ConversationPatch
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler
|
||||
from app.services.agent_service import AgentService
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id
|
||||
from app.services.attachments import attachment_record, classify_upload, extract_document_text
|
||||
from app.services.image_processing import image_metadata
|
||||
from app.services.review_renderer import renderer_status
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
@@ -57,12 +55,6 @@ async def config() -> dict[str, Any]:
|
||||
for model in provider.models
|
||||
],
|
||||
})
|
||||
try:
|
||||
settings.resolve_review_model()
|
||||
renderer_ready, renderer_detail = renderer_status()
|
||||
review_error = "" if renderer_ready else renderer_detail
|
||||
except ValueError as error:
|
||||
review_error = str(error)
|
||||
return {
|
||||
"default_provider": settings.default_provider_id,
|
||||
"default_model": settings.llm_model,
|
||||
@@ -71,8 +63,6 @@ async def config() -> dict[str, Any]:
|
||||
"configured": settings.llm_configured,
|
||||
"library_samples": library.count(),
|
||||
"autonomous_generation": settings.autonomous_generation,
|
||||
"review_configured": not review_error,
|
||||
"review_error": review_error,
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +113,7 @@ async def upload_conversation_attachment(
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
active_task_id = str(current.get("current_task_id") or "")
|
||||
active_task = agent.v3.repository.get_task_projection(active_task_id) if active_task_id else None
|
||||
active_task = agent.cad.repository.get_task_projection(active_task_id) if active_task_id else None
|
||||
if str((active_task or {}).get("lifecycle") or "") == "running":
|
||||
raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state")
|
||||
kind = classify_upload(filename, file.content_type or "", len(data))
|
||||
@@ -145,101 +135,25 @@ async def upload_conversation_attachment(
|
||||
async def read_task(task_id: str) -> JSONResponse:
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
task = agent.v3.repository.get_task_projection(safe_id)
|
||||
task = agent.cad.repository.get_task_projection(safe_id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
|
||||
state = agent.v3.repository.get_state(safe_id)
|
||||
task["requirements_spec"] = agent.v3.artifacts.read_requirements_spec(safe_id, state.requirements_spec_path) if state is not None else None
|
||||
task["requirements_contract"] = agent.v3.artifacts.read_requirements_contract(safe_id, state.requirements_contract_path) if state is not None else None
|
||||
task["claim_summary"] = _claim_summary(task["requirements_contract"], task.get("action_ledger_summary"))
|
||||
task["checklist_progress"] = _checklist_progress(task["requirements_contract"], task["claim_summary"])
|
||||
requirements_path = agent.v3.artifacts.artifact_path(safe_id, state.requirements_document_path) if state is not None and state.requirements_document_path else None
|
||||
task["requirements_markdown"] = requirements_path.read_text(encoding="utf-8") if requirements_path and requirements_path.is_file() else None
|
||||
target_path = agent.v3.artifacts.artifact_path(safe_id, state.completion_target_path) if state is not None and state.completion_target_path else None
|
||||
plan_path = agent.v3.artifacts.artifact_path(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
|
||||
result_path = agent.v3.artifacts.task_dir(safe_id) / "completion-result.md"
|
||||
task["completion_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path and target_path.is_file() else None
|
||||
task["completion_target_path"] = state.completion_target_path if target_path and target_path.is_file() else ""
|
||||
task["feature_plan"] = agent.v3.artifacts.read_json(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
|
||||
task["feature_plan_path"] = state.feature_plan_path if plan_path and plan_path.is_file() else ""
|
||||
task["feature_plan_hash"] = state.feature_plan_hash if state is not None else ""
|
||||
if isinstance(task["feature_plan"], dict):
|
||||
plan = FeaturePlan.model_validate(task["feature_plan"])
|
||||
statuses = FeatureScheduler(plan, agent.v3.repository.ledger_events(safe_id)).statuses()
|
||||
node_evidence = {str(item.get("node_id") or ""): item for item in task.get("feature_nodes") or () if isinstance(item, dict)}
|
||||
task["feature_nodes"] = [
|
||||
{
|
||||
"node_id": str(node.get("node_id") or ""), "intent": str(node.get("intent") or ""),
|
||||
"atomic_id": str(node.get("atomic_id") or ""), "priority": node.get("priority"),
|
||||
"depends_on": node.get("depends_on") or [], "claim_ids": node.get("claim_ids") or [],
|
||||
"status": statuses.get(str(node.get("node_id") or ""), "pending"),
|
||||
**node_evidence.get(str(node.get("node_id") or ""), {}),
|
||||
}
|
||||
for node in task["feature_plan"].get("nodes") or () if isinstance(node, dict)
|
||||
]
|
||||
task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path.is_file() else None
|
||||
task["completion_result_path"] = "completion-result.md" if result_path.is_file() else ""
|
||||
task["usage"] = agent.v3.repository.usage_summary(safe_id)
|
||||
task["preview_revision"] = str(task.get("active_revision") or "")
|
||||
state = agent.cad.repository.get_state(safe_id)
|
||||
task["requirements_analysis"] = agent.cad.artifacts.read_json(safe_id, state.requirements_path) if state and state.requirements_path else None
|
||||
task["authoring_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.authoring_path) if state and state.authoring_path else None
|
||||
task["runtime_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.runtime_cdsl_path) if state and state.runtime_cdsl_path else None
|
||||
task["compile_audit"] = agent.cad.artifacts.read_json(safe_id, state.compile_audit_path) if state and state.compile_audit_path else None
|
||||
task["diagnostics"] = agent.cad.artifacts.read_json(safe_id, state.diagnostics_path) if state and state.diagnostics_path else None
|
||||
task["claim_report"] = agent.cad.artifacts.read_json(safe_id, "documents/claim-report.json")
|
||||
result_path = agent.cad.artifacts.artifact_path(safe_id, state.completion_path) if state and state.completion_path else None
|
||||
task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path and result_path.is_file() else None
|
||||
task["usage"] = agent.cad.repository.usage_summary(safe_id)
|
||||
return JSONResponse(task)
|
||||
|
||||
|
||||
def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[str, Any]]:
|
||||
"""Project frozen claims with the newest committed verification evidence.
|
||||
|
||||
This is API-only data derived from SQLite-backed ledger entries and the
|
||||
state-referenced frozen contract. It never parses Markdown or exposes a
|
||||
staged candidate as a task checkpoint.
|
||||
"""
|
||||
latest: dict[str, dict[str, Any]] = {}
|
||||
for event in reversed(ledger if isinstance(ledger, list) else []):
|
||||
if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed", "feature_node_verified", "final_visual_reviewed"}:
|
||||
continue
|
||||
values = event.get("claim_results")
|
||||
if not isinstance(values, list):
|
||||
values = event.get("coverage")
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
for value in values:
|
||||
if isinstance(value, dict) and isinstance(value.get("claim_id"), str) and value["claim_id"] not in latest:
|
||||
latest[value["claim_id"]] = value
|
||||
result: list[dict[str, Any]] = []
|
||||
for requirement in (contract or {}).get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if not isinstance(claim, dict) or not isinstance(claim.get("claim_id"), str):
|
||||
continue
|
||||
evidence = latest.get(claim["claim_id"], {})
|
||||
result.append({
|
||||
"requirement_id": str(requirement.get("requirement_id") or ""),
|
||||
"claim_id": claim["claim_id"],
|
||||
"claim_kind": str(claim.get("claim_kind") or ""),
|
||||
"deterministic": claim.get("verification_mode") == "deterministic",
|
||||
"status": str(evidence.get("status") or "pending"),
|
||||
"evidence": evidence.get("evidence") if isinstance(evidence.get("evidence"), dict) else {},
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _checklist_progress(contract: dict[str, Any] | None, claims: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Expose server-evaluated checklist progress without parsing Markdown."""
|
||||
by_claim = {str(item.get("claim_id") or ""): str(item.get("status") or "pending") for item in claims if isinstance(item, dict)}
|
||||
progress: list[dict[str, Any]] = []
|
||||
for requirement in (contract or {}).get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
statuses = [by_claim.get(str(claim.get("claim_id") or ""), "pending") for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)]
|
||||
progress.append({
|
||||
"requirement_id": str(requirement.get("requirement_id") or ""),
|
||||
"statement": str(requirement.get("statement") or ""),
|
||||
"status": "pass" if statuses and all(status == "pass" for status in statuses) else "fail" if "fail" in statuses or "unavailable" in statuses else "pending",
|
||||
})
|
||||
return progress
|
||||
|
||||
|
||||
@app.delete("/v1/tasks/{task_id}")
|
||||
async def cancel_task(task_id: str) -> JSONResponse:
|
||||
try:
|
||||
@@ -281,19 +195,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
path = agent.v3.artifacts.artifact_path(safe_id, artifact_path)
|
||||
path = agent.cad.artifacts.artifact_path(safe_id, artifact_path)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
task = agent.v3.repository.get_task_projection(safe_id) or {}
|
||||
task = agent.cad.repository.get_task_projection(safe_id) or {}
|
||||
parts = artifact_path.split("/")
|
||||
revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else ""
|
||||
published_revision = str(task.get("active_revision") or "") if str(task.get("lifecycle") or "") == "completed" else ""
|
||||
published_revision = str(task.get("published_revision") or "")
|
||||
active_revision = str(task.get("active_revision") or task.get("current_revision") or "")
|
||||
if not revision_id:
|
||||
# The task directory also contains candidate staging, agent audit and
|
||||
# frozen-input files. None of those are a public artifact surface.
|
||||
if artifact_path == "completion-result.md" and str(task.get("completion_path") or "") == artifact_path:
|
||||
return FileResponse(path, filename=path.name)
|
||||
raise HTTPException(status_code=403, detail="This task artifact is not public")
|
||||
|
||||
if revision_id == published_revision:
|
||||
@@ -302,30 +216,15 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
f"revisions/{revision_id}/model.step",
|
||||
f"revisions/{revision_id}/model.glb",
|
||||
f"revisions/{revision_id}/rebuild-report.json",
|
||||
f"revisions/{revision_id}/build-diagnostics.json",
|
||||
f"revisions/{revision_id}/renders/render-manifest.json",
|
||||
}
|
||||
if artifact_path not in published_paths:
|
||||
raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable")
|
||||
return FileResponse(path, filename=path.name)
|
||||
|
||||
v32_checkpoint_paths = {
|
||||
f"revisions/{revision_id}/model.cdsl.json",
|
||||
f"revisions/{revision_id}/model.step",
|
||||
f"revisions/{revision_id}/model.glb",
|
||||
f"revisions/{revision_id}/rebuild-report.json",
|
||||
}
|
||||
verified_revisions = {
|
||||
str(item.get("revision_id") or "")
|
||||
for item in task.get("revisions") or ()
|
||||
if isinstance(item, dict) and item.get("status") == "success"
|
||||
}
|
||||
# Each v3.2 node revision is immutable and manifest-published. Make those
|
||||
# checkpoints inspectable from the DAG while keeping every staging input,
|
||||
# rejected candidate and arbitrary task artifact private.
|
||||
if task.get("schema_version") == "3.2" and revision_id in verified_revisions and artifact_path in v32_checkpoint_paths:
|
||||
return FileResponse(path, media_type="model/gltf-binary" if path.suffix == ".glb" else None, headers={"Content-Disposition": "inline" if path.suffix == ".glb" else f"attachment; filename={path.name}"})
|
||||
|
||||
# A legacy running task may render its active checkpoint in the browser,
|
||||
# but cannot expose any other checkpoint artifact or failed-task preview.
|
||||
# A running task can render its active executable prefix, but its staging
|
||||
# inputs and diagnostics remain private until publication.
|
||||
if (
|
||||
str(task.get("lifecycle") or "") != "running"
|
||||
or revision_id != active_revision
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""HTTP/SSE delivery adapter for the protocol v3 workflow."""
|
||||
|
||||
"""HTTP/SSE delivery adapter for the single-stage CAD protocol."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -10,8 +9,7 @@ import secrets
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.application.capabilities import cached_model_capability, verify_model_capability
|
||||
from app.cad_agent.composition import V3Services, compose_v3
|
||||
from app.cad_agent.composition import CadServices, compose_cad_services
|
||||
from app.cad_agent.domain.errors import ErrorCode
|
||||
from app.cad_agent.domain.state import TaskPhase, transition
|
||||
from app.models.contracts import ChatMessage
|
||||
@@ -25,480 +23,174 @@ def text_from_message(message: ChatMessage) -> str:
|
||||
return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip()
|
||||
|
||||
|
||||
def _response_language(text: str) -> str:
|
||||
cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff")
|
||||
latin = sum(1 for char in text if char.isascii() and char.isalpha())
|
||||
return "Chinese" if cjk >= 2 and cjk >= latin * 0.15 else "English"
|
||||
|
||||
|
||||
_EVENT_LABELS = {
|
||||
"image_observation": "参考图片观察",
|
||||
"requirements_document_ready": "需求文档已冻结",
|
||||
"completion_target_ready": "完成目标已冻结",
|
||||
"requirements_compiled": "需求合同已编译",
|
||||
"modeling_plan_ready": "建模计划已冻结",
|
||||
"completion_result_ready": "完成结果已就绪",
|
||||
"model_protocol_check": "模型协议检查",
|
||||
"action_selection": "动作选择",
|
||||
"tool_call": "建模工具",
|
||||
"candidate_result": "候选构建",
|
||||
"candidate_review": "候选独立复核",
|
||||
"final_review": "最终独立复核",
|
||||
"requirements_ready": "需求分析",
|
||||
"authoring_cdsl_ready": "完整 CDSL",
|
||||
"cdsl_compiled": "CDSL 编译",
|
||||
"build_result": "CAD 构建",
|
||||
"repair_started": "CDSL 修复",
|
||||
"task_terminal": "生成任务",
|
||||
"state_changed": "任务状态",
|
||||
}
|
||||
|
||||
|
||||
def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
def _progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
lifecycle = str(payload.get("lifecycle") or "")
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||
status = "error" if lifecycle == "failed" or str(payload.get("status") or "") == "error" or str(result.get("status") or "") in {"rejected", "repair"} else "waiting" if lifecycle in {"waiting_for_user", "waiting_retry"} else "success" if lifecycle == "completed" else str(payload.get("status") or "running")
|
||||
status = "error" if lifecycle == "failed" or payload.get("status") in {"failed", "repair_required"} else "waiting" if lifecycle == "waiting_for_user" else "success" if lifecycle == "completed" else "running"
|
||||
return {**payload, "step": name, "label": _EVENT_LABELS.get(name, name), "status": status}
|
||||
|
||||
|
||||
def _upsert_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None:
|
||||
part_id = str(part.get("id") or "")
|
||||
if part_id:
|
||||
for index, existing in enumerate(parts):
|
||||
if str(existing.get("id") or "") == part_id:
|
||||
parts[index] = part
|
||||
return
|
||||
parts.append(part)
|
||||
|
||||
|
||||
class AgentService:
|
||||
"""Delivery boundary: no CAD state transitions or provider calls live here."""
|
||||
|
||||
def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None:
|
||||
self.settings = settings
|
||||
self.store = store # Conversation/attachment store, not v3 CAD state.
|
||||
self.library = library
|
||||
self.v3: V3Services = compose_v3(settings)
|
||||
self.settings, self.store, self.library = settings, store, library
|
||||
self.cad: CadServices = compose_cad_services(settings)
|
||||
self._autonomous_runs: dict[str, asyncio.Task[None]] = {}
|
||||
|
||||
async def resume_running_tasks(self) -> None:
|
||||
task_ids = self.v3.repository.running_task_ids()
|
||||
try:
|
||||
author_provider, author_model = self.settings.resolve_model(None, None)
|
||||
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||
except ValueError as error:
|
||||
await self._park_startup_tasks(
|
||||
task_ids,
|
||||
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
|
||||
str(error),
|
||||
retryable=False,
|
||||
)
|
||||
return
|
||||
try:
|
||||
author_capability, reviewer_capability = await asyncio.gather(
|
||||
verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=author_provider.id,
|
||||
model_id=author_model.id,
|
||||
role="author",
|
||||
),
|
||||
verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=review_provider.id,
|
||||
model_id=review_model.id,
|
||||
role="reviewer",
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
# Startup recovery must never bypass a production capability gate.
|
||||
# Connectivity failures are recoverable, but they may not leave a
|
||||
# task falsely marked running without a worker.
|
||||
await self._park_startup_tasks(
|
||||
task_ids,
|
||||
ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE,
|
||||
str(error),
|
||||
retryable=True,
|
||||
)
|
||||
return
|
||||
if author_capability.get("probe_unavailable") or reviewer_capability.get("probe_unavailable"):
|
||||
await self._park_startup_tasks(
|
||||
task_ids,
|
||||
ErrorCode.MODEL_PROTOCOL_CHECK_PENDING,
|
||||
"Model protocol check is temporarily unavailable.",
|
||||
retryable=True,
|
||||
)
|
||||
return
|
||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||
await self._park_startup_tasks(
|
||||
task_ids,
|
||||
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
|
||||
"Selected author or reviewer did not pass the v3 conformance suite.",
|
||||
retryable=False,
|
||||
)
|
||||
return
|
||||
if not self.settings.resume_running_tasks_on_startup:
|
||||
return
|
||||
for task_id in task_ids:
|
||||
if task_id in self._autonomous_runs:
|
||||
continue
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(
|
||||
self._consume_discarding(task_id, ModelIdentity(author_provider.id, author_model.id), ModelIdentity(review_provider.id, review_model.id)),
|
||||
name=f"resume-autonomous-cad-v3-{task_id}",
|
||||
)
|
||||
|
||||
async def _park_startup_tasks(
|
||||
self,
|
||||
task_ids: list[str],
|
||||
error: ErrorCode,
|
||||
message: str,
|
||||
*,
|
||||
retryable: bool,
|
||||
) -> None:
|
||||
"""Make a failed startup gate durable instead of leaving phantom runs."""
|
||||
event_name = "waiting_retry" if retryable else "failed_model_capability"
|
||||
for task_id in task_ids:
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
if state is None or task_id in self._autonomous_runs:
|
||||
continue
|
||||
try:
|
||||
next_state = transition(
|
||||
state,
|
||||
"waiting_retry" if retryable else "failed",
|
||||
error=error,
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
if self.v3.repository.compare_and_swap(next_state, events=[{
|
||||
"event": event_name,
|
||||
"code": error.value,
|
||||
"message": message[:1000],
|
||||
"startup_recovery": True,
|
||||
}]):
|
||||
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||
|
||||
async def _consume_discarding(self, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> None:
|
||||
try:
|
||||
async for _name, _payload in self.v3.workflow.run(task_id=task_id, author=author, reviewer=reviewer):
|
||||
# A resumed task has no active SSE client, but its durable
|
||||
# state events must still leave the transactional outbox.
|
||||
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||
provider, model = self.settings.resolve_model(None, None)
|
||||
except ValueError:
|
||||
return
|
||||
author = ModelIdentity(provider.id, model.id)
|
||||
for task_id in self.cad.repository.running_task_ids():
|
||||
if task_id not in self._autonomous_runs:
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, author), name=f"resume-cad-single-stage-{task_id}")
|
||||
|
||||
async def _consume_discarding(self, task_id: str, author: ModelIdentity) -> None:
|
||||
try:
|
||||
async for _name, _payload in self.cad.workflow.run(task_id=task_id, author=author):
|
||||
await self.cad.outbox.dispatch_pending(task_id=task_id)
|
||||
finally:
|
||||
# Flush a final transition emitted immediately before the worker
|
||||
# exits, such as WAITING_RETRY or FAILED_INTERNAL.
|
||||
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||
await self.cad.outbox.dispatch_pending(task_id=task_id)
|
||||
self._autonomous_runs.pop(task_id, None)
|
||||
|
||||
async def cancel(self, task_id: str) -> dict[str, Any] | None:
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
state = self.cad.repository.get_state(task_id)
|
||||
if state is None:
|
||||
return None
|
||||
if state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
cancelled = transition(state, "cancelled", error=ErrorCode.CANCELLED)
|
||||
if self.v3.repository.compare_and_swap(cancelled, events=[{
|
||||
"event": "task_cancelled",
|
||||
"from_phase": state.phase.value,
|
||||
"active_revision": state.active_revision,
|
||||
"checkpoint_preserved": bool(state.active_revision),
|
||||
}]):
|
||||
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||
# Persist the terminal transition before interrupting the coroutine.
|
||||
# A concurrent worker will fail its optimistic CAS instead of reviving
|
||||
# the task after the caller has requested cancellation.
|
||||
self.cad.repository.compare_and_swap(cancelled, events=[{"event": "task_cancelled", "active_revision": state.active_revision}])
|
||||
await self.cad.outbox.dispatch_pending(task_id=task_id)
|
||||
running = self._autonomous_runs.pop(task_id, None)
|
||||
if running and not running.done():
|
||||
running.cancel()
|
||||
return self.v3.repository.get_task_projection(task_id)
|
||||
return self.cad.repository.get_task_projection(task_id)
|
||||
|
||||
async def resume_retry(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""Explicitly restart one durably parked infrastructure retry.
|
||||
|
||||
``WAITING_FOR_USER`` is deliberately excluded: it requires new user
|
||||
input, while this endpoint is only the controlled recovery route for
|
||||
bounded provider/render/storage failures.
|
||||
"""
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
state = self.cad.repository.get_state(task_id)
|
||||
if state is None:
|
||||
return None
|
||||
if state.phase.value != "WAITING_RETRY":
|
||||
raise ValueError("Only a WAITING_RETRY CAD task can be resumed through this endpoint")
|
||||
running = self._autonomous_runs.get(task_id)
|
||||
if running is not None and not running.done():
|
||||
if state.phase != TaskPhase.FAILED or state.retry_from_phase is None:
|
||||
raise ValueError("Only a retryable failed CAD task can be resumed")
|
||||
if task_id in self._autonomous_runs and not self._autonomous_runs[task_id].done():
|
||||
raise ValueError("The CAD task is already running")
|
||||
author_provider, author_model = self.settings.resolve_model(None, None)
|
||||
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||
author_capability = await verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=author_provider.id,
|
||||
model_id=author_model.id,
|
||||
role="author",
|
||||
)
|
||||
reviewer_capability = await verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=review_provider.id,
|
||||
model_id=review_model.id,
|
||||
role="reviewer",
|
||||
)
|
||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||
raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite")
|
||||
if not self.v3.workflow.resume(task_id):
|
||||
raise ValueError("The CAD task no longer has a recoverable retry checkpoint")
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(
|
||||
self._consume_discarding(
|
||||
task_id,
|
||||
ModelIdentity(author_provider.id, author_model.id),
|
||||
ModelIdentity(review_provider.id, review_model.id),
|
||||
),
|
||||
name=f"resume-autonomous-cad-v3-{task_id}",
|
||||
)
|
||||
return self.v3.repository.get_task_projection(task_id)
|
||||
provider, model = self.settings.resolve_model(None, None)
|
||||
if not self.cad.workflow.resume(task_id):
|
||||
raise ValueError("The CAD task no longer has a retry checkpoint")
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, ModelIdentity(provider.id, model.id)), name=f"resume-cad-single-stage-{task_id}")
|
||||
return self.cad.repository.get_task_projection(task_id)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
conversation_id: str | None,
|
||||
selected_task_id: str | None,
|
||||
provider_id: str | None = None,
|
||||
model_id: str | None = None,
|
||||
viewer_context: list[dict[str, Any]] | None = None,
|
||||
) -> AsyncIterator[bytes]:
|
||||
async def stream(self, messages: list[ChatMessage], conversation_id: str | None, selected_task_id: str | None, provider_id: str | None = None, model_id: str | None = None, viewer_context: list[dict[str, Any]] | None = None) -> AsyncIterator[bytes]:
|
||||
del viewer_context
|
||||
latest_user = next((message for message in reversed(messages) if message.role == "user"), None)
|
||||
if latest_user is None or not text_from_message(latest_user):
|
||||
latest = next((item for item in reversed(messages) if item.role == "user"), None)
|
||||
if latest is None or not text_from_message(latest):
|
||||
yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."})
|
||||
yield event("done", {})
|
||||
return
|
||||
request = text_from_message(latest_user)
|
||||
request = text_from_message(latest)
|
||||
conversation = self.store.ensure_conversation(conversation_id)
|
||||
selected = str(selected_task_id or conversation.get("current_task_id") or "")
|
||||
current = self.v3.repository.get_task_projection(selected) if selected else None
|
||||
resumed_task_id = ""
|
||||
if current and str(current.get("lifecycle") or "") == "waiting_for_user":
|
||||
state = self.v3.repository.get_state(selected)
|
||||
if state is not None:
|
||||
terminal = self.v3.workflow.waiting_for_user_terminal(selected, state)
|
||||
fields = [
|
||||
{"path": "/requirements/clarification", "message": str(question)}
|
||||
for question in terminal.get("questions") or ()
|
||||
if str(question).strip()
|
||||
]
|
||||
fields.extend(
|
||||
{"path": "/requirements", "message": str(issue)}
|
||||
for issue in terminal.get("issues") or ()
|
||||
if str(issue).strip()
|
||||
)
|
||||
if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id):
|
||||
yield event("cad_error", {
|
||||
"stage": "request",
|
||||
"message": "This parked CAD task cannot apply the supplied clarification. " + str(terminal["message"]),
|
||||
"fieldErrors": fields,
|
||||
"taskId": selected,
|
||||
"blockerType": terminal.get("blockerType"),
|
||||
"userActionRequired": terminal.get("userActionRequired"),
|
||||
})
|
||||
yield event("done", {})
|
||||
return
|
||||
resumed_task_id = selected
|
||||
if current and str(current.get("lifecycle") or "") == "running":
|
||||
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
|
||||
current = self.cad.repository.get_task_projection(selected) if selected else None
|
||||
resumed = False
|
||||
if current and current.get("lifecycle") == "waiting_for_user":
|
||||
resumed = self.cad.workflow.resume_with_user_clarification(selected, request, message_id=latest.id)
|
||||
if not resumed:
|
||||
yield event("cad_error", {"stage": "request", "message": "The CAD task cannot apply this clarification."})
|
||||
yield event("done", {})
|
||||
return
|
||||
if current and current.get("lifecycle") == "running":
|
||||
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成后才能继续。"})
|
||||
yield event("done", {})
|
||||
return
|
||||
# A terminal task is immutable; follow-up text creates a new task.
|
||||
task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}"
|
||||
if not resumed_task_id:
|
||||
task_id = selected if resumed else f"cad_{secrets.token_hex(6)}"
|
||||
if not resumed:
|
||||
try:
|
||||
source_blocks, image_inputs = self._task_inputs(conversation, request)
|
||||
self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
|
||||
self.cad.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
|
||||
except ValueError as error:
|
||||
yield event("cad_error", {"stage": "request", "message": str(error)})
|
||||
yield event("done", {})
|
||||
return
|
||||
conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id)
|
||||
yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."})
|
||||
|
||||
self.store.append_conversation_message(conversation["conversation_id"], latest.model_dump(), task_id)
|
||||
yield event("progress", _progress("task_started", {"taskId": task_id, "message": "CAD task started."}))
|
||||
try:
|
||||
author_provider, author_model = self.settings.resolve_model(provider_id, model_id)
|
||||
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||
provider, model = self.settings.resolve_model(provider_id, model_id)
|
||||
except ValueError as error:
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
if state is not None:
|
||||
state = self.cad.repository.get_state(task_id)
|
||||
if state:
|
||||
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
|
||||
self.v3.repository.compare_and_swap(failed, events=[{
|
||||
"event": "model_configuration_invalid",
|
||||
"message": str(error)[:1000],
|
||||
"issues": [str(error)[:1000]],
|
||||
}])
|
||||
terminal = {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": str(error), "userActionRequired": False}
|
||||
yield event("task_terminal", terminal)
|
||||
self.cad.repository.compare_and_swap(failed, events=[{"event": "model_configuration_invalid", "message": str(error)}])
|
||||
yield event("cad_error", {"stage": "configuration", "message": str(error)})
|
||||
yield event("done", {})
|
||||
return
|
||||
|
||||
author = ModelIdentity(provider.id, model.id)
|
||||
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
|
||||
parts: list[dict[str, Any]] = []
|
||||
sequence = 0
|
||||
|
||||
async def consume() -> None:
|
||||
nonlocal sequence
|
||||
|
||||
async def dispatch_state_events() -> None:
|
||||
"""Publish state notifications only through the outbox.
|
||||
|
||||
The dispatcher preserves at-least-once semantics. The SSE
|
||||
event ID derives from the durable outbox row, so reconnecting
|
||||
clients can safely de-duplicate a delivery replay.
|
||||
"""
|
||||
nonlocal sequence
|
||||
for outbox_event in await self.v3.outbox.dispatch_pending(task_id=task_id):
|
||||
sequence += 1
|
||||
event_name = str(outbox_event.get("event") or "state_changed")
|
||||
payload = {
|
||||
"taskId": task_id,
|
||||
"eventId": f"outbox_{outbox_event['event_id']}",
|
||||
"sequence": sequence,
|
||||
"timestamp": now_iso(),
|
||||
"outboxEvent": event_name,
|
||||
"message": event_name.replace("_", " "),
|
||||
}
|
||||
await queue.put(("progress", _visible_progress("state_changed", payload)))
|
||||
|
||||
try:
|
||||
capability_terminal = await self._ensure_task_capabilities(
|
||||
task_id,
|
||||
ModelIdentity(author_provider.id, author_model.id),
|
||||
ModelIdentity(review_provider.id, review_model.id),
|
||||
queue,
|
||||
)
|
||||
if capability_terminal is not None:
|
||||
sequence += 1
|
||||
capability_terminal = {**capability_terminal, "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso()}
|
||||
_upsert_part(parts, {"type": "data-cad-progress", "id": capability_terminal["eventId"], "data": _visible_progress("task_terminal", capability_terminal)})
|
||||
await queue.put(("task_terminal", capability_terminal))
|
||||
return
|
||||
async for name, payload in self.v3.workflow.run(
|
||||
task_id=task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
):
|
||||
sequence += 1
|
||||
decorated = {**payload, "taskId": task_id, "eventId": str(payload.get("eventId") or f"{task_id}_{sequence}_{name}"), "sequence": sequence, "timestamp": now_iso()}
|
||||
_upsert_part(parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)})
|
||||
if name == "task_terminal" and str(decorated.get("lifecycle") or "") == "failed":
|
||||
_upsert_part(parts, {"type": "data-cad-error", "id": f"{decorated['eventId']}_error", "data": {
|
||||
"stage": "generation",
|
||||
"message": str(decorated.get("message") or "CAD autonomous generation failed."),
|
||||
"tool": str(decorated.get("tool") or ""),
|
||||
"fieldErrors": decorated.get("field_errors") if isinstance(decorated.get("field_errors"), list) else [],
|
||||
}})
|
||||
async for name, payload in self.cad.workflow.run(task_id=task_id, author=author):
|
||||
decorated = {**payload, "taskId": task_id, "eventId": f"{task_id}_{secrets.token_hex(4)}", "timestamp": now_iso()}
|
||||
parts.append({"type": "data-cad-progress", "id": decorated["eventId"], "data": _progress(name, decorated)})
|
||||
await queue.put((name, decorated))
|
||||
await dispatch_state_events()
|
||||
except Exception as error:
|
||||
sequence += 1
|
||||
terminal = {"taskId": task_id, "lifecycle": "failed", "code": "FAILED_INTERNAL", "message": str(error)[:1000], "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso(), "userActionRequired": False}
|
||||
_upsert_part(parts, {"type": "data-cad-progress", "id": terminal["eventId"], "data": _visible_progress("task_terminal", terminal)})
|
||||
_upsert_part(parts, {"type": "data-cad-error", "id": f"{terminal['eventId']}_error", "data": {"stage": "generation", "message": terminal["message"], "fieldErrors": []}})
|
||||
await queue.put(("task_terminal", terminal))
|
||||
await self.cad.outbox.dispatch_pending(task_id=task_id)
|
||||
if name == "task_terminal" and decorated.get("lifecycle") == "completed":
|
||||
projection = self.cad.repository.get_task_projection(task_id) or {}
|
||||
result = self._result_payload(task_id, projection)
|
||||
if result is not None:
|
||||
await queue.put(("cad_result", result))
|
||||
finally:
|
||||
self.store.append_conversation_message(conversation["conversation_id"], {"id": f"assistant_{secrets.token_hex(8)}", "role": "assistant", "parts": parts}, task_id)
|
||||
self._autonomous_runs.pop(task_id, None)
|
||||
await queue.put(None)
|
||||
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cad-v3-{task_id}")
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"cad-single-stage-{task_id}")
|
||||
while True:
|
||||
try:
|
||||
item = await asyncio.wait_for(queue.get(), timeout=15)
|
||||
except asyncio.TimeoutError:
|
||||
yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()})
|
||||
continue
|
||||
item = await queue.get()
|
||||
if item is None:
|
||||
break
|
||||
name, payload = item
|
||||
if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed":
|
||||
yield event("cad_error", {
|
||||
"stage": "generation",
|
||||
"message": str(payload.get("message") or "CAD autonomous generation failed."),
|
||||
"tool": str(payload.get("tool") or ""),
|
||||
"fieldErrors": payload.get("field_errors") if isinstance(payload.get("field_errors"), list) else [],
|
||||
})
|
||||
if name == "task_terminal" and payload.get("lifecycle") == "failed":
|
||||
yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD generation failed."), "code": payload.get("code")})
|
||||
yield event(name, payload)
|
||||
yield event("done", {})
|
||||
|
||||
async def _ensure_task_capabilities(
|
||||
self,
|
||||
task_id: str,
|
||||
author: ModelIdentity,
|
||||
reviewer: ModelIdentity,
|
||||
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None],
|
||||
) -> dict[str, Any] | None:
|
||||
roles = (("author", author), ("reviewer", reviewer))
|
||||
cached = {
|
||||
role: cached_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
provider_id=model.provider_id,
|
||||
model_id=model.model_id,
|
||||
role=role,
|
||||
)
|
||||
for role, model in roles
|
||||
}
|
||||
unsupported = [role for role, result in cached.items() if result is not None and not result.get("supported")]
|
||||
if unsupported:
|
||||
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
|
||||
missing = [(role, model) for role, model in roles if cached[role] is None]
|
||||
if not missing:
|
||||
@staticmethod
|
||||
def _result_payload(task_id: str, projection: dict[str, Any]) -> dict[str, Any] | None:
|
||||
revision_id = str(projection.get("published_revision") or projection.get("active_revision") or "")
|
||||
revisions = projection.get("revisions") if isinstance(projection.get("revisions"), list) else []
|
||||
revision = next((item for item in revisions if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
|
||||
if not isinstance(revision, dict):
|
||||
return None
|
||||
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
if state is None:
|
||||
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "Task state is unavailable.", "userActionRequired": False}
|
||||
waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING)
|
||||
if not self.v3.repository.compare_and_swap(waiting, events=[{
|
||||
"event": "model_protocol_check_pending",
|
||||
"code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value,
|
||||
"message": "模型协议检查中,完成后将自动继续。",
|
||||
}]):
|
||||
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STALE_WORKING_HEAD.value, "message": "Task state changed before the model protocol check started.", "userActionRequired": False}
|
||||
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "waiting", "lifecycle": "waiting_retry", "message": "模型协议检查中,完成后将自动继续。"}))
|
||||
try:
|
||||
results = await asyncio.gather(*(
|
||||
verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=model.provider_id,
|
||||
model_id=model.model_id,
|
||||
role=role,
|
||||
)
|
||||
for role, model in missing
|
||||
))
|
||||
except Exception as error:
|
||||
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用:{str(error)[:500]}", "userActionRequired": False}
|
||||
unavailable = [role for (role, _model), result in zip(missing, results, strict=True) if result.get("probe_unavailable")]
|
||||
if unavailable:
|
||||
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用({', '.join(unavailable)}),可稍后重试。", "userActionRequired": False}
|
||||
unsupported = [role for (role, _model), result in zip(missing, results, strict=True) if not result.get("supported")]
|
||||
if unsupported:
|
||||
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
|
||||
if not self.v3.workflow.resume(task_id):
|
||||
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Model protocol check completed but the task could not resume.", "userActionRequired": False}
|
||||
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "success", "lifecycle": "running", "message": "模型协议检查完成,继续生成。"}))
|
||||
return None
|
||||
|
||||
def _fail_capability(self, task_id: str, message: str) -> dict[str, Any]:
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
if state is not None and state.phase not in {TaskPhase.FAILED, TaskPhase.COMPLETED, TaskPhase.CANCELLED}:
|
||||
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
|
||||
self.v3.repository.compare_and_swap(failed, events=[{
|
||||
"event": "model_protocol_unsupported",
|
||||
"message": message,
|
||||
"issues": [message],
|
||||
}])
|
||||
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": message, "userActionRequired": False}
|
||||
required = ("cdsl_path", "step_path", "glb_path", "report_path")
|
||||
if not all(isinstance(revision.get(path), str) and revision[path] for path in required):
|
||||
return None
|
||||
return {
|
||||
"taskId": task_id,
|
||||
"revisionId": revision_id,
|
||||
"cdslPath": revision["cdsl_path"],
|
||||
"stepPath": revision["step_path"],
|
||||
"glbPath": revision["glb_path"],
|
||||
"reportPath": revision["report_path"],
|
||||
"summary": str(revision.get("summary") or "CDSL CAD model"),
|
||||
"referenceIds": list(revision.get("reference_ids") or []),
|
||||
"engine": str(revision.get("engine") or "cdsl_only"),
|
||||
"lifecycle": str(projection.get("lifecycle") or "completed"),
|
||||
}
|
||||
|
||||
def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
||||
"""Freeze message paragraphs and attachment blocks before task creation."""
|
||||
blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()]
|
||||
image_inputs: list[dict[str, str]] = []
|
||||
conversation_id = str(conversation.get("conversation_id") or "")
|
||||
@@ -509,51 +201,24 @@ class AgentService:
|
||||
continue
|
||||
if str(attachment.get("conversation_id") or "") != conversation_id:
|
||||
raise ValueError("Attachment does not belong to this conversation")
|
||||
attachment_id = str(attachment.get("id") or "")
|
||||
relative_path = str(attachment.get("path") or "")
|
||||
expected_digest = str(attachment.get("sha256") or "")
|
||||
if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", expected_digest):
|
||||
attachment_id, relative_path, digest = str(attachment.get("id") or ""), str(attachment.get("path") or ""), str(attachment.get("sha256") or "")
|
||||
if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", digest):
|
||||
raise ValueError("Attachment metadata is incomplete")
|
||||
binary_path = self.store.conversation_attachment_path(conversation_id, relative_path)
|
||||
if not binary_path.is_file():
|
||||
raise ValueError(f"Attachment is missing: {attachment.get('name') or attachment_id}")
|
||||
if sha256(binary_path.read_bytes()).hexdigest() != expected_digest:
|
||||
raise ValueError(f"Attachment checksum mismatch: {attachment.get('name') or attachment_id}")
|
||||
binary = self.store.conversation_attachment_path(conversation_id, relative_path)
|
||||
if not binary.is_file() or sha256(binary.read_bytes()).hexdigest() != digest:
|
||||
raise ValueError(f"Attachment is unavailable: {attachment.get('name') or attachment_id}")
|
||||
kind = str(attachment.get("kind") or "")
|
||||
if kind == "document":
|
||||
extracted_path = str(attachment.get("extracted_path") or "")
|
||||
if not extracted_path:
|
||||
raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}")
|
||||
text_path = self.store.conversation_attachment_path(conversation_id, extracted_path)
|
||||
text_path = self.store.conversation_attachment_path(conversation_id, str(attachment.get("extracted_path") or ""))
|
||||
if not text_path.is_file():
|
||||
raise ValueError(f"Attachment text is missing: {attachment.get('name') or attachment_id}")
|
||||
raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}")
|
||||
text = text_path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
elif kind == "image":
|
||||
# The binary is integrity-checked above. Do not claim that a
|
||||
# text-only author has interpreted visual content; the source
|
||||
# block is still a stable, reviewable attachment reference.
|
||||
text = (
|
||||
f"Visual attachment {attachment.get('name') or attachment_id} "
|
||||
f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). "
|
||||
"It is a visual reference and requires explicit visual verification."
|
||||
)
|
||||
image_inputs.append({
|
||||
"path": str(binary_path),
|
||||
"mime": str(attachment.get("mime") or "image/*"),
|
||||
"sha256": expected_digest,
|
||||
})
|
||||
text = f"Visual attachment {attachment.get('name') or attachment_id} (SHA-256 {digest})."
|
||||
image_inputs.append({"path": str(binary), "mime": str(attachment.get("mime") or "image/*"), "sha256": digest})
|
||||
else:
|
||||
raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}")
|
||||
if not text:
|
||||
raise ValueError(f"Attachment source is empty: {attachment.get('name') or attachment_id}")
|
||||
blocks.append({
|
||||
"text": text,
|
||||
"attachment": {
|
||||
"attachment_id": attachment_id,
|
||||
"name": str(attachment.get("name") or attachment_id),
|
||||
"kind": kind,
|
||||
"mime": str(attachment.get("mime") or "application/octet-stream"),
|
||||
"sha256": expected_digest,
|
||||
},
|
||||
})
|
||||
blocks.append({"text": text, "attachment": {"attachment_id": attachment_id, "name": str(attachment.get("name") or attachment_id), "kind": kind, "mime": str(attachment.get("mime") or "application/octet-stream"), "sha256": digest}})
|
||||
return blocks, image_inputs
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Derived-metrics backfill for CDSL documents (training-grade, server-owned).
|
||||
|
||||
The metrics describe the *reconstructed geometry* of a document and are
|
||||
written exclusively by the server after a successful rebuild. Models never
|
||||
author them; the fields are excluded from validation ordering (backfilled
|
||||
after validation) so they can never influence acceptance.
|
||||
|
||||
Counting conventions:
|
||||
- ``hole_count`` : inner cylindrical walls on the final body (cylinder faces
|
||||
whose ``cylinder_role`` is ``inner``). Countersink/counterbore bores add
|
||||
their own walls; no coaxial clustering is applied in v1.
|
||||
- ``fillet_count``: torus faces (rolling-ball blends) on the final body.
|
||||
- ``face_type_distribution``: final-body faces by ``surface_type``.
|
||||
- ``per_feature`` : prefix re-execution sampling. Prefix ``i`` re-runs the
|
||||
first ``i`` features; sampling stops at the first non-executable prefix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.services.engine_service import topology_snapshot
|
||||
|
||||
_METRIC_SNAPSHOT_KEYS = (
|
||||
"volume_mm3", "surface_area_mm2", "bbox_mm", "solid_count",
|
||||
"face_count", "edge_count", "vertex_count", "hole_count",
|
||||
"fillet_count", "face_type_distribution",
|
||||
)
|
||||
_DELTA_KEYS = ("volume_mm3", "surface_area_mm2", "face_count", "hole_count")
|
||||
|
||||
#: Prefix sampling above this feature count degrades to final-only metrics,
|
||||
#: keeping the O(n^2) re-execution cost bounded on very large documents.
|
||||
MAX_PER_FEATURE_SAMPLES = 64
|
||||
|
||||
|
||||
def _face_stats(topology: dict[str, Any]) -> dict[str, Any]:
|
||||
faces = [
|
||||
record for record in (topology.get("records") or [])
|
||||
if isinstance(record, dict) and record.get("kind") == "face"
|
||||
]
|
||||
distribution: Counter[str] = Counter()
|
||||
hole_walls = 0
|
||||
torus = 0
|
||||
for face in faces:
|
||||
geometry = face.get("geometry") or {}
|
||||
surface_type = str(geometry.get("surface_type") or "unknown")
|
||||
distribution[surface_type] += 1
|
||||
if surface_type == "cylinder" and geometry.get("cylinder_role") == "inner":
|
||||
hole_walls += 1
|
||||
if surface_type == "torus":
|
||||
torus += 1
|
||||
return {
|
||||
"face_type_distribution": dict(sorted(distribution.items())),
|
||||
"hole_count": hole_walls,
|
||||
"fillet_count": torus,
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_from_engine_result(engine_result: dict[str, Any], topology: dict[str, Any]) -> dict[str, Any]:
|
||||
records = [r for r in (topology.get("records") or []) if isinstance(r, dict)]
|
||||
faces = [r for r in records if r.get("kind") == "face"]
|
||||
area = sum(float((f.get("geometry") or {}).get("area_mm2") or 0.0) for f in faces)
|
||||
snapshot = {
|
||||
"volume_mm3": float(engine_result.get("volume_mm3") or 0.0),
|
||||
"surface_area_mm2": area or float(engine_result.get("surface_area_mm2") or 0.0),
|
||||
"bbox_mm": [float(value) for value in (engine_result.get("bbox_mm") or {}).get("min", [])]
|
||||
+ [float(value) for value in (engine_result.get("bbox_mm") or {}).get("max", [])],
|
||||
"solid_count": int(engine_result.get("solid_count") or 0),
|
||||
"face_count": len(faces),
|
||||
"edge_count": sum(1 for r in records if r.get("kind") == "edge"),
|
||||
"vertex_count": sum(1 for r in records if r.get("kind") == "vertex"),
|
||||
}
|
||||
snapshot.update(_face_stats(topology))
|
||||
return {key: snapshot[key] for key in _METRIC_SNAPSHOT_KEYS}
|
||||
|
||||
|
||||
def _delta(after: dict[str, Any], before: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"volume_mm3": float(after["volume_mm3"]) - float(before["volume_mm3"]),
|
||||
"surface_area_mm2": float(after["surface_area_mm2"]) - float(before["surface_area_mm2"]),
|
||||
"face_count": int(after["face_count"]) - int(before["face_count"]),
|
||||
"hole_count": int(after["hole_count"]) - int(before["hole_count"]),
|
||||
}
|
||||
|
||||
|
||||
def derive_metrics(
|
||||
cdsl: dict[str, Any],
|
||||
engine_result: dict[str, Any],
|
||||
topology: dict[str, Any],
|
||||
*,
|
||||
engine_build: str = "",
|
||||
prefix_runner: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the ``meta.derived_metrics`` payload for a successfully rebuilt document.
|
||||
|
||||
``prefix_runner`` re-executes a prefix of the document's features and
|
||||
returns the same shape as ``engine_result``; when omitted, ``per_feature``
|
||||
degrades to status entries without geometric snapshots.
|
||||
"""
|
||||
features = cdsl.get("features") or []
|
||||
final = _snapshot_from_engine_result(engine_result, topology)
|
||||
zero = {
|
||||
"volume_mm3": 0.0, "surface_area_mm2": 0.0,
|
||||
"face_count": 0, "hole_count": 0,
|
||||
}
|
||||
|
||||
per_feature: list[dict[str, Any]] = []
|
||||
if prefix_runner is not None and 1 <= len(features) <= MAX_PER_FEATURE_SAMPLES:
|
||||
previous = zero
|
||||
for index in range(1, len(features) + 1):
|
||||
prefix_doc = deepcopy(cdsl)
|
||||
prefix_doc["features"] = deepcopy(features[:index])
|
||||
try:
|
||||
prefix_result = prefix_runner(prefix_doc)
|
||||
topology_prefix = topology_snapshot(prefix_result)
|
||||
after = _snapshot_from_engine_result(prefix_result, topology_prefix)
|
||||
status = str(next(
|
||||
(fr.get("status") for fr in prefix_result.get("feature_results") or []
|
||||
if fr.get("feature_id") == features[index - 1].get("id")),
|
||||
"executed",
|
||||
))
|
||||
except Exception:
|
||||
break # prefix chain broken; keep samples collected so far
|
||||
per_feature.append({
|
||||
"feature_id": str(features[index - 1].get("id") or ""),
|
||||
"atomic_id": str(features[index - 1].get("atomic_id") or ""),
|
||||
"status": status,
|
||||
"after": after,
|
||||
"delta": _delta(after, previous),
|
||||
})
|
||||
previous = after
|
||||
|
||||
histogram = Counter(str(feature.get("atomic_id") or "") for feature in features)
|
||||
metrics: dict[str, Any] = {
|
||||
"engine": str(engine_result.get("engine") or ""),
|
||||
"final": final,
|
||||
"per_feature": per_feature,
|
||||
"atomic_histogram": dict(sorted(histogram.items())),
|
||||
}
|
||||
if engine_build:
|
||||
metrics["engine_build"] = engine_build
|
||||
return metrics
|
||||
|
||||
|
||||
def make_prefix_runner(run_cdsl_only: Callable[[dict[str, Any], Path], dict[str, Any]]):
|
||||
"""Wrap an engine ``run_cdsl_only`` entry point for prefix sampling."""
|
||||
def runner(prefix_doc: dict[str, Any]) -> dict[str, Any]:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
return run_cdsl_only(prefix_doc, Path(tmp) / "prefix.step")
|
||||
return runner
|
||||
@@ -47,7 +47,7 @@ def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]:
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
|
||||
if not isinstance(schema, dict) or not isinstance(schema.get("operation_contracts"), dict):
|
||||
raise RuntimeError("The local engine schema has no v3 operation contract registry")
|
||||
raise RuntimeError("The local engine schema has no operation contract registry")
|
||||
return schema
|
||||
|
||||
|
||||
@@ -108,10 +108,11 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
|
||||
|
||||
|
||||
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
def validate_cdsl_shape(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
"""Validate the static Runtime CDSL contract without resolving topology."""
|
||||
if not isinstance(cdsl, dict):
|
||||
raise ValueError("CDSL must be a JSON object")
|
||||
if cdsl.get("schema") != "cad.cdsl.llm.v1":
|
||||
if cdsl.get("schema") not in {"cad.cdsl.llm.v1", "cad.runtime.v1"}:
|
||||
raise ValueError("Unsupported CDSL schema")
|
||||
_validate_cdsl_json_schema(cdsl, engine)
|
||||
part_id = str(cdsl.get("part_id") or "")
|
||||
@@ -125,6 +126,14 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
sketches = cdsl.get("geometry", {}).get("sketches")
|
||||
if not isinstance(features, list) or not features or not isinstance(sketches, list):
|
||||
raise ValueError("CDSL requires a feature list and a geometry.sketches array")
|
||||
if cdsl.get("schema") == "cad.runtime.v1":
|
||||
bodies = cdsl.get("bodies")
|
||||
if not isinstance(bodies, list) or not bodies:
|
||||
raise ValueError("Runtime CDSL requires server-assigned bodies")
|
||||
body_ids = [str(body.get("id") or "") for body in bodies if isinstance(body, dict)]
|
||||
body_names = [str(body.get("name") or "") for body in bodies if isinstance(body, dict)]
|
||||
if len(body_ids) != len(bodies) or len(body_ids) != len(set(body_ids)) or len(body_names) != len(set(body_names)):
|
||||
raise ValueError("Runtime CDSL bodies must have unique server IDs and local names")
|
||||
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
|
||||
semantic_contract = _engine_schema(engine)
|
||||
try:
|
||||
@@ -185,6 +194,11 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
raise ValueError("Polygon profiles require vertices")
|
||||
elif profile_type not in engine.SHAPE_GENERATORS:
|
||||
raise ValueError(f"Unsupported CDSL profile: {profile_type}")
|
||||
|
||||
|
||||
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
"""Validate static CDSL and the current executable topology semantics."""
|
||||
validate_cdsl_shape(cdsl, engine)
|
||||
try:
|
||||
analysis = engine.analyze_cdsl(copy.deepcopy(cdsl))
|
||||
except Exception as error:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Deterministic, CPU-only CAD technical renders for visual review.
|
||||
"""Deterministic, CPU-only CAD technical render bundles.
|
||||
|
||||
OpenCascade computes exact visible/hidden edges from the revision STEP file.
|
||||
Pillow rasterizes the resulting technical drawings. Neither stage needs a web
|
||||
browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence
|
||||
browser, OpenGL, a desktop session, nor a GPU, which keeps published artifacts
|
||||
consistent on macOS, Linux, and Windows workers.
|
||||
"""
|
||||
|
||||
@@ -26,7 +26,7 @@ VISIBLE_EDGE_RGB = (34, 54, 69)
|
||||
HIDDEN_EDGE_RGB = (142, 157, 170)
|
||||
|
||||
|
||||
class ReviewRenderError(RuntimeError):
|
||||
class RenderBundleError(RuntimeError):
|
||||
"""The fixed-view renderer was unavailable or produced incomplete evidence."""
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ def renderer_status() -> tuple[bool, str]:
|
||||
"""Verify that the pure-Python/OCC renderer dependencies are importable."""
|
||||
try:
|
||||
_render_modules()
|
||||
except ReviewRenderError as error:
|
||||
except RenderBundleError as error:
|
||||
return False, str(error)
|
||||
return True, ""
|
||||
|
||||
@@ -45,7 +45,7 @@ def _render_modules() -> tuple[Any, Any, Any]:
|
||||
pillow_draw = importlib.import_module("PIL.ImageDraw")
|
||||
import_step = importlib.import_module("build123d").import_step
|
||||
except (ImportError, AttributeError) as error:
|
||||
raise ReviewRenderError(
|
||||
raise RenderBundleError(
|
||||
"Python technical renderer is unavailable; install backend requirements (build123d and Pillow)"
|
||||
) from error
|
||||
return pillow_image, pillow_draw, import_step
|
||||
@@ -73,7 +73,7 @@ def _shape_bounds(shape: Any) -> list[float]:
|
||||
box = shape.bounding_box()
|
||||
bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)]
|
||||
if not all(math.isfinite(value) for value in bounds):
|
||||
raise ReviewRenderError("STEP review source has invalid bounds")
|
||||
raise RenderBundleError("STEP render source has invalid bounds")
|
||||
return bounds
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]:
|
||||
def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]:
|
||||
points = [point for edge in edges for point in _edge_points(edge, 0.5)]
|
||||
if not points:
|
||||
raise ReviewRenderError("Hidden-line projection produced no drawable edges")
|
||||
raise RenderBundleError("Hidden-line projection produced no drawable edges")
|
||||
xs, ys = zip(*points)
|
||||
return min(xs), max(xs), min(ys), max(ys)
|
||||
|
||||
@@ -242,7 +242,7 @@ def _render_view(
|
||||
camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"]
|
||||
)
|
||||
except Exception as error:
|
||||
raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
|
||||
raise RenderBundleError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
|
||||
visible_edges, hidden_edges = list(visible), list(hidden)
|
||||
frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent)
|
||||
rendered = _rasterize(
|
||||
@@ -254,12 +254,12 @@ def _render_view(
|
||||
intentional_crop=target_extent > 0,
|
||||
)
|
||||
if not rendered["diagnostics"]["valid"]:
|
||||
raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
|
||||
raise RenderBundleError(f"Render bundle quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
|
||||
return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered}
|
||||
|
||||
|
||||
def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str:
|
||||
"""Create compact whole-model evidence for routine reviewer calls."""
|
||||
"""Create compact whole-model images for published CAD artifacts."""
|
||||
pillow_image, pillow_draw, _ = _render_modules()
|
||||
canonical = [item for item in views if item["id"] in CANONICAL_VIEWS]
|
||||
if not canonical:
|
||||
@@ -283,27 +283,27 @@ def render_checkpoint(
|
||||
*,
|
||||
step_path: Path,
|
||||
output_dir: Path,
|
||||
review_targets: list[dict[str, Any]] | None = None,
|
||||
detail_targets: list[dict[str, Any]] | None = None,
|
||||
include_canonical: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Render STEP geometry into stable canonical and bounded node-detail views."""
|
||||
del settings
|
||||
ready, detail = renderer_status()
|
||||
if not ready:
|
||||
raise ReviewRenderError(detail)
|
||||
raise RenderBundleError(detail)
|
||||
if not step_path.is_file():
|
||||
raise ReviewRenderError(f"STEP review source is missing: {step_path.name}")
|
||||
raise RenderBundleError(f"STEP render source is missing: {step_path.name}")
|
||||
_, _, import_step = _render_modules()
|
||||
try:
|
||||
shape = import_step(str(step_path))
|
||||
except Exception as error:
|
||||
raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error
|
||||
raise RenderBundleError(f"Unable to read STEP render source: {error}") from error
|
||||
bounds = _shape_bounds(shape)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
jobs: list[tuple[str, dict[str, Any] | None]] = []
|
||||
if include_canonical:
|
||||
jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS)
|
||||
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3]))
|
||||
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((detail_targets or [])[:3]))
|
||||
views = [
|
||||
_render_view(
|
||||
shape=shape,
|
||||
@@ -317,14 +317,14 @@ def render_checkpoint(
|
||||
]
|
||||
canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")}
|
||||
if include_canonical and canonical != set(CANONICAL_VIEWS):
|
||||
raise ReviewRenderError("Python review renderer did not produce every canonical view")
|
||||
raise RenderBundleError("Python render bundle generator did not produce every canonical view")
|
||||
contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else ""
|
||||
manifest = {
|
||||
"schema_version": "cad.render-manifest.v2",
|
||||
"renderer": "python-occ-hlr-pillow",
|
||||
"source": {"type": "step", "path": str(step_path), "bounds_mm": bounds},
|
||||
"high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"},
|
||||
"review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
|
||||
"render_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
|
||||
"contact_sheet_path": contact_sheet_path,
|
||||
"views": views,
|
||||
}
|
||||
@@ -343,22 +343,22 @@ def render_section(
|
||||
"""Create an actual OpenCascade section drawing, not a clipped viewport.
|
||||
|
||||
It intentionally uses the same deterministic Pillow raster path as the
|
||||
seven canonical review views. The output contains compact contour evidence
|
||||
suitable for a multimodal author without sending a STEP file or full B-rep.
|
||||
seven canonical render views. The output contains compact contour evidence
|
||||
suitable for inspection without sending a STEP file or full B-rep.
|
||||
"""
|
||||
del settings
|
||||
ready, detail = renderer_status()
|
||||
if not ready:
|
||||
raise ReviewRenderError(detail)
|
||||
raise RenderBundleError(detail)
|
||||
if not step_path.is_file():
|
||||
raise ReviewRenderError(f"STEP section source is missing: {step_path.name}")
|
||||
raise RenderBundleError(f"STEP section source is missing: {step_path.name}")
|
||||
origin = _number_list(origin_mm, size=3)
|
||||
direction = _number_list(normal, size=3)
|
||||
if origin is None or direction is None:
|
||||
raise ReviewRenderError("Section origin_mm and normal must each contain three finite numbers")
|
||||
raise RenderBundleError("Section origin_mm and normal must each contain three finite numbers")
|
||||
length = math.sqrt(sum(value * value for value in direction))
|
||||
if length <= 1e-9:
|
||||
raise ReviewRenderError("Section normal must not be zero")
|
||||
raise RenderBundleError("Section normal must not be zero")
|
||||
normal_unit = [value / length for value in direction]
|
||||
_, _, import_step = _render_modules()
|
||||
try:
|
||||
@@ -372,9 +372,9 @@ def render_section(
|
||||
section = b3d.section(shape, section_by=plane)
|
||||
edges = list(section.edges())
|
||||
except Exception as error:
|
||||
raise ReviewRenderError(f"OpenCascade section operation failed: {error}") from error
|
||||
raise RenderBundleError(f"OpenCascade section operation failed: {error}") from error
|
||||
if not edges:
|
||||
raise ReviewRenderError("Section plane does not intersect the model")
|
||||
raise RenderBundleError("Section plane does not intersect the model")
|
||||
|
||||
# Choose a deterministic right-handed in-plane frame. Projecting exact
|
||||
# OCC section edges into this frame preserves holes and internal contours.
|
||||
@@ -406,7 +406,7 @@ def render_section(
|
||||
if len(line) >= 2:
|
||||
projected.append(line)
|
||||
if not projected:
|
||||
raise ReviewRenderError("Section operation produced no drawable contours")
|
||||
raise RenderBundleError("Section operation produced no drawable contours")
|
||||
xs = [point[0] for line in projected for point in line]
|
||||
ys = [point[1] for line in projected for point in line]
|
||||
minimum_x, maximum_x, minimum_y, maximum_y = min(xs), max(xs), min(ys), max(ys)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Conversation and attachment storage for the v3 delivery boundary.
|
||||
"""Conversation and attachment storage for the CAD delivery boundary.
|
||||
|
||||
CAD task state deliberately does not live here. Protocol v3 owns mutable task
|
||||
CAD task state deliberately does not live here. The Authoring protocol owns mutable task
|
||||
state in ``SqliteTaskRepository`` and immutable task artifacts in
|
||||
``FileArtifactStore``.
|
||||
"""
|
||||
|
||||
@@ -65,15 +65,6 @@ class Settings:
|
||||
llm_timeout_s: float
|
||||
default_provider_id: str
|
||||
providers: tuple[ProviderConfig, ...]
|
||||
review_provider_id: str = ""
|
||||
review_model_id: str = ""
|
||||
agent_tool_calls_per_cycle: int = 12
|
||||
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
|
||||
|
||||
@@ -102,29 +93,6 @@ class Settings:
|
||||
raise ValueError("The selected model is not enabled for this provider")
|
||||
return provider, model
|
||||
|
||||
def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]:
|
||||
"""Return the independently configured visual reviewer, never an author fallback."""
|
||||
provider_id = self.review_provider_id
|
||||
if not provider_id:
|
||||
raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider")
|
||||
provider = self.provider_for(provider_id)
|
||||
if provider is None:
|
||||
raise ValueError("The configured visual review provider is unavailable")
|
||||
model_id = self.review_model_id or ""
|
||||
if not model_id:
|
||||
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model")
|
||||
model = provider.model(model_id)
|
||||
if model is None or not model.vision:
|
||||
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model")
|
||||
return provider, model
|
||||
|
||||
def resolve_independent_review_model(self, author_provider: ProviderConfig, author_model: ProviderModel) -> tuple[ProviderConfig, ProviderModel]:
|
||||
"""Require the candidate judge to be a separately configured model."""
|
||||
provider, model = self.resolve_review_model()
|
||||
if provider.id == author_provider.id and model.id == author_model.id:
|
||||
raise ValueError("CDSL_REVIEW_PROVIDER/CDSL_REVIEW_MODEL must differ from the autonomous author model")
|
||||
return provider, model
|
||||
|
||||
|
||||
def _reasoning_effort(value: str) -> str:
|
||||
effort = value.strip().lower()
|
||||
@@ -207,15 +175,6 @@ def get_settings() -> Settings:
|
||||
llm_timeout_s=llm_timeout_s,
|
||||
default_provider_id=default_provider_id,
|
||||
providers=providers,
|
||||
review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(),
|
||||
review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(),
|
||||
agent_tool_calls_per_cycle=max(1, int(os.getenv("CDSL_AGENT_TOOL_CALLS_PER_CYCLE", "12"))),
|
||||
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
|
||||
# can disable this before startup to guarantee they touch only tasks
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,18 @@
|
||||
"description": "Complete self-contained CDSL. Runtime-supported operations can be rebuilt by the local CDSL-only engine; deferred operations are retained for future engine implementations.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {"const": "cad.cdsl.llm.v1"},
|
||||
"schema": {"enum": ["cad.cdsl.llm.v1", "cad.runtime.v1"]},
|
||||
"schema_version": {"type": "string"},
|
||||
"kind": {"type": "string", "minLength": 1},
|
||||
"part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"},
|
||||
"meta": {"type": "object"},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"derived_metrics": {"$ref": "#/$defs/derivedMetrics"}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"bodies": {"type": "array", "items": {"$ref": "#/$defs/runtimeBody"}},
|
||||
"geometry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23,9 +30,85 @@
|
||||
"required": ["schema", "geometry", "features"],
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"metricSnapshot": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"volume_mm3": {"type": "number"},
|
||||
"surface_area_mm2": {"type": "number"},
|
||||
"bbox_mm": {"type": "array", "items": {"type": "number"}, "minItems": 6, "maxItems": 6},
|
||||
"solid_count": {"type": "integer", "minimum": 0},
|
||||
"face_count": {"type": "integer", "minimum": 0},
|
||||
"edge_count": {"type": "integer", "minimum": 0},
|
||||
"vertex_count": {"type": "integer", "minimum": 0},
|
||||
"hole_count": {"type": "integer", "minimum": 0},
|
||||
"fillet_count": {"type": "integer", "minimum": 0},
|
||||
"face_type_distribution": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}}
|
||||
},
|
||||
"required": ["volume_mm3", "surface_area_mm2", "bbox_mm", "solid_count", "face_count", "edge_count", "vertex_count", "hole_count", "fillet_count", "face_type_distribution"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"metricDelta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"volume_mm3": {"type": "number"},
|
||||
"surface_area_mm2": {"type": "number"},
|
||||
"face_count": {"type": "integer"},
|
||||
"hole_count": {"type": "integer"}
|
||||
},
|
||||
"required": ["volume_mm3", "surface_area_mm2", "face_count", "hole_count"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"derivedMetrics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"engine": {"type": "string", "minLength": 1},
|
||||
"engine_build": {"type": "string", "minLength": 1},
|
||||
"generated_at": {"type": "string", "minLength": 1},
|
||||
"final": {"$ref": "#/$defs/metricSnapshot"},
|
||||
"per_feature": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"feature_id": {"type": "string", "minLength": 1},
|
||||
"atomic_id": {"$ref": "#/$defs/feature_atomic_ids"},
|
||||
"status": {"type": "string", "minLength": 1},
|
||||
"after": {"$ref": "#/$defs/metricSnapshot"},
|
||||
"delta": {"$ref": "#/$defs/metricDelta"}
|
||||
},
|
||||
"required": ["feature_id", "atomic_id", "status", "after", "delta"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"atomic_histogram": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}}
|
||||
},
|
||||
"required": ["engine", "final", "per_feature", "atomic_histogram"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"featureIntent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,63}$"},
|
||||
"summary": {"type": "string", "minLength": 1, "maxLength": 80},
|
||||
"why": {"type": "string", "minLength": 1, "maxLength": 400},
|
||||
"ties_to_requirement": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,80}$"},
|
||||
"provenance": {"enum": ["authored", "annotated", "imported"]}
|
||||
},
|
||||
"required": ["summary", "provenance"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"number": {"type": "number"},
|
||||
"positive": {"type": "number", "exclusiveMinimum": 0},
|
||||
"positiveInteger": {"type": "integer", "minimum": 1},
|
||||
"runtimeBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "pattern": "^body_[0-9]{3}$"},
|
||||
"name": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}
|
||||
},
|
||||
"required": ["id", "name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"point2": {"type": "array", "items": {"$ref": "#/$defs/number"}, "minItems": 2, "maxItems": 2},
|
||||
"point3": {"type": "array", "items": {"$ref": "#/$defs/number"}, "minItems": 3, "maxItems": 3},
|
||||
"circleItem": {
|
||||
@@ -85,12 +168,23 @@
|
||||
"properties": {
|
||||
"type": {"type": "string", "minLength": 1},
|
||||
"solidworks_code": {"type": "integer"},
|
||||
"reference": {"$ref": "#/$defs/selectorRef"},
|
||||
"reference": {"oneOf": [{"$ref": "#/$defs/selectorRef"}, {"$ref": "#/$defs/sourceVertexDatumRef"}]},
|
||||
"offset_mm": {"type": "number", "minimum": 0}
|
||||
},
|
||||
"required": ["type", "solidworks_code"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"sourceVertexDatumRef": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"const": "source_vertex"},
|
||||
"source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"point_mm": {"$ref": "#/$defs/point3"}
|
||||
},
|
||||
"required": ["kind", "source_sketch_id", "source_entity_id", "point_mm"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorOrUnresolved": {
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/selectorRef"},
|
||||
@@ -130,6 +224,14 @@
|
||||
"maxItems": 16,
|
||||
"uniqueItems": true,
|
||||
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
|
||||
},
|
||||
"initial_output_roles": {"type": "boolean"},
|
||||
"cap_output_profile_sources": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
"uniqueItems": true,
|
||||
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
|
||||
}
|
||||
},
|
||||
"required": ["profile_sketch_ids"],
|
||||
@@ -150,20 +252,92 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"sweepPath": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workplane": {"$ref": "#/$defs/workplane"},
|
||||
"segment": {"$ref": "#/$defs/analyticSegment"}
|
||||
},
|
||||
"required": ["workplane", "segment"],
|
||||
"additionalProperties": false
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workplane": {"$ref": "#/$defs/workplane"},
|
||||
"segment": {"$ref": "#/$defs/analyticSegment"},
|
||||
"segments": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {"$ref": "#/$defs/analyticSegment"}
|
||||
}
|
||||
},
|
||||
"required": ["workplane"],
|
||||
"oneOf": [
|
||||
{"required": ["segment"], "not": {"required": ["segments"]}},
|
||||
{"required": ["segments"], "not": {"required": ["segment"]}}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"segments": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {"$ref": "#/$defs/spatialSweepSegment"}
|
||||
}
|
||||
},
|
||||
"required": ["segments"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"sweepParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"$ref": "#/$defs/sweepPath"},
|
||||
"is_frenet": {"type": "boolean"},
|
||||
"result_mode": {"enum": ["fuse", "new_body"]}
|
||||
"result_mode": {"enum": ["fuse", "new_body"]},
|
||||
"initial_output_roles": {"type": "boolean"},
|
||||
"cap_output_contract": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"profile_entity": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"path_entity": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"path_reversed": {"type": "boolean"}
|
||||
},
|
||||
"required": ["profile_source", "profile_entity", "path_source", "path_entity", "path_reversed"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"swept_face_contract": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"profile_entities": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"path_entity": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"path_reversed": {"type": "boolean"}
|
||||
},
|
||||
"required": ["profile_source", "profile_entities", "path_source", "path_entity", "path_reversed"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"swept_edge_contract": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"profile_entities": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"minItems": 2,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"path_entity": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"path_reversed": {"type": "boolean"}
|
||||
},
|
||||
"required": ["profile_source", "profile_entities", "path_source", "path_entity", "path_reversed"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
@@ -395,14 +569,17 @@
|
||||
"operation": {"enum": ["union", "subtract", "intersect"]},
|
||||
"target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"target_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
|
||||
"target_transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
|
||||
"tool_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"tool_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
|
||||
"keep_tools": {"type": "boolean"}
|
||||
"tool_transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
|
||||
"keep_tools": {"type": "boolean"},
|
||||
"targetless_body_set": {"type": "boolean"}
|
||||
},
|
||||
"required": ["operation"],
|
||||
"allOf": [
|
||||
{"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}]},
|
||||
{"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}]}
|
||||
{"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}, {"required": ["target_transform_copy_refs"]}]},
|
||||
{"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}, {"required": ["tool_transform_copy_refs"]}]}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -443,12 +620,22 @@
|
||||
"required": ["transform_feature_id", "source_feature_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"transformSourceMemberAlias": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"active_member_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
|
||||
},
|
||||
"required": ["source_feature_id", "active_member_feature_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"transformBodiesParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
|
||||
"transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
|
||||
"source_member_aliases": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformSourceMemberAlias"}},
|
||||
"transform": {"$ref": "#/$defs/bodyTransform"},
|
||||
"make_copy": {"type": "boolean"}
|
||||
},
|
||||
@@ -523,6 +710,22 @@
|
||||
"required": ["axis"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"referencePointParams": {
|
||||
"type": "object",
|
||||
"properties": {"point_mm": {"$ref": "#/$defs/point3"}},
|
||||
"required": ["point_mm"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"assignVariableParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 256},
|
||||
"value": {"type": "number"},
|
||||
"value_kind": {"enum": ["any", "length"]}
|
||||
},
|
||||
"required": ["name", "value", "value_kind"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"holeWizardParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -532,6 +735,7 @@
|
||||
"end_condition": {"$ref": "#/$defs/endCondition"},
|
||||
"positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}},
|
||||
"host_face": {"$ref": "#/$defs/hostFace"},
|
||||
"scope_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"thread": {"type": "object"},
|
||||
"countersink": {"type": "object"},
|
||||
"counterbore": {"type": "object"}
|
||||
@@ -541,7 +745,7 @@
|
||||
},
|
||||
"featureOutputRole": {
|
||||
"enum": [
|
||||
"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end",
|
||||
"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end",
|
||||
"shell.offset_face", "shell.closing_descendant", "shell.body_face"
|
||||
]
|
||||
},
|
||||
@@ -554,6 +758,300 @@
|
||||
"required": ["owner_feature_id", "output_role"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorIntentSourceQuery": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ast": {},
|
||||
"featurescript_version": {"type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+)*$"},
|
||||
"standard_library": {"type": "string", "minLength": 1},
|
||||
"standard_library_version": {"type": "string", "minLength": 1},
|
||||
"standard_library_imports": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "minLength": 1},
|
||||
"version": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["ast"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorIntentQueryExprNode": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "set"},
|
||||
"operator": {"enum": ["union", "intersection", "subtraction"]},
|
||||
"operands": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "operator", "operands"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "filter"},
|
||||
"filter": {"enum": ["adjacent", "owner_body", "body_type", "construction"]},
|
||||
"input": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "filter", "input", "arguments"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "topology_query"},
|
||||
"owner": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"topology_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"entity_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "owner", "topology_type", "entity_type", "arguments"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "created_by"},
|
||||
"owner": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "owner", "arguments"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "source_entity"},
|
||||
"sketch": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"entity_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"entity": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "sketch", "entity_type", "entity", "arguments"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "sketch_region"},
|
||||
"sketch": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "sketch", "arguments"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "opaque_call"},
|
||||
"name": {"type": "string", "minLength": 1},
|
||||
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "name", "arguments"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "list"},
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "items"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "map"},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {"type": "string"},
|
||||
"value": {"$ref": "#/$defs/selectorIntentQueryExprNode"}
|
||||
},
|
||||
"required": ["key", "value"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["node", "entries"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "literal"},
|
||||
"value": {}
|
||||
},
|
||||
"required": ["node", "value"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"selectorIntentQueryExpr": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {"const": "1.0"},
|
||||
"root": {"$ref": "#/$defs/selectorIntentQueryExprNode"}
|
||||
},
|
||||
"required": ["version", "root"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorIntent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {"const": "1.0"},
|
||||
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
|
||||
"query_family": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,79}$"},
|
||||
"source_query": {"$ref": "#/$defs/selectorIntentSourceQuery"},
|
||||
"query_expr": {"$ref": "#/$defs/selectorIntentQueryExpr"},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"source_entities": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"intersection_sources": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query_family": {"enum": ["CAP_FACE", "SWEPT_FACE"]},
|
||||
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"output_role": {"enum": ["extrude.start", "extrude.end"]},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["query_family", "owner_feature_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"blend_sources": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"edge": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query_family": {"const": "CAP_EDGE"},
|
||||
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"lineage_role": {"enum": ["extrude.start", "extrude.end"]}
|
||||
},
|
||||
"required": ["query_family", "owner_feature_id", "source_entity", "lineage_role"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"face": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query_family": {"enum": ["CAP_FACE", "SWEPT_FACE"]},
|
||||
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"output_role": {"enum": ["extrude.start", "extrude.end"]},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["query_family", "owner_feature_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["edge", "face"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"blend_face_source": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query_family": {"const": "CAP_EDGE"},
|
||||
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"lineage_role": {"enum": ["extrude.start", "extrude.end"]}
|
||||
},
|
||||
"required": ["query_family", "owner_feature_id", "source_entity", "lineage_role"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_role": {"$ref": "#/$defs/featureOutputRole"},
|
||||
"consumer_contract": {"enum": ["direct_prism_cap_face_workplane", "primary_add_shell_union_continuation", "primary_add_up_to_surface_union_continuation", "primary_add_dressup_union_continuation", "symmetric_direct_prism_two_sided_up_to_surface_cap_pair", "symmetric_direct_prism_shell_swept_face_up_to_surface_pair", "immediate_retained_source_prism_swept_face_up_to_surface", "shell_retained_direct_prism_cap_offset_face_profile", "direct_prism_shell_offset_edge_tdd", "direct_prism_shell_offset_edge_vertex"]},
|
||||
"lineage_role": {"enum": ["extrude.start", "extrude.end"]},
|
||||
"body_member_contract": {"enum": ["direct_new_body"]},
|
||||
"query_set_contract": {"enum": ["proven_operand_union", "proven_operand_intersection", "proven_operand_subtraction"]},
|
||||
"owner_body_contract": {"enum": ["exact_input_owner"]},
|
||||
"copy_contract": {"enum": ["primary_cut_cap_edge", "primary_cut_cap_face_workplane", "primary_cut_swept_face_workplane"]},
|
||||
"set_kind": {"enum": ["face", "edge"]},
|
||||
"body_scope": {"enum": ["active_member"]},
|
||||
"empty_policy": {"enum": ["reject"]},
|
||||
"multiple_policy": {"enum": ["all", "one"]},
|
||||
"derivation_policy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allowed": {"type": "array", "minItems": 1, "items": {"enum": ["continuation", "fragment", "merge", "intersection", "boundary", "replacement"]}},
|
||||
"multiplicity": {"enum": ["one", "all_fragments", "source_qualified", "none"]}
|
||||
},
|
||||
"required": ["allowed", "multiplicity"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"evidence": {"enum": ["kernel_history", "operation_role", "active_body_member", "feature_script_query", "explicit_datum", "geometry_hint"]},
|
||||
"disambiguation": {"type": "object"}
|
||||
},
|
||||
"required": ["version", "query_family", "source_query", "derivation_policy", "evidence"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorRef": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -568,12 +1066,16 @@
|
||||
"snapshot_id": {"type": "string", "minLength": 1},
|
||||
"binding_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"match_mode": {"enum": ["unique", "all"]},
|
||||
"query_input": {"$ref": "#/$defs/selectorRef"},
|
||||
"query_operands": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
|
||||
"matched_selectors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorRef"}},
|
||||
"intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
|
||||
"selector_intent_version": {"const": "1.0"},
|
||||
"selector_intent": {"$ref": "#/$defs/selectorIntent"},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
|
||||
},
|
||||
"required": ["kind", "source", "confidence"],
|
||||
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}],
|
||||
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}, {"required": ["selector_intent"]}],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"analyticSegment": {
|
||||
@@ -594,6 +1096,7 @@
|
||||
"clockwise": {"type": "boolean"},
|
||||
"start_tangent": {"$ref": "#/$defs/point2"},
|
||||
"end_tangent": {"$ref": "#/$defs/point2"}
|
||||
,"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}
|
||||
},
|
||||
"required": ["type"],
|
||||
"allOf": [
|
||||
@@ -606,6 +1109,33 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"spatialSweepSegment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"enum": ["line", "arc", "bspline"]},
|
||||
"start_mm": {"$ref": "#/$defs/point3"},
|
||||
"end_mm": {"$ref": "#/$defs/point3"},
|
||||
"center_mm": {"$ref": "#/$defs/point3"},
|
||||
"normal": {"$ref": "#/$defs/point3"},
|
||||
"radius_mm": {"$ref": "#/$defs/positive"},
|
||||
"points_mm": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/point3"}},
|
||||
"parameters": {"type": "array", "minItems": 2, "items": {"type": "number"}},
|
||||
"periodic": {"type": "boolean"},
|
||||
"clockwise": {"type": "boolean"},
|
||||
"start_tangent_mm": {"$ref": "#/$defs/point3"},
|
||||
"end_tangent_mm": {"$ref": "#/$defs/point3"},
|
||||
"source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}
|
||||
},
|
||||
"required": ["type", "source_sketch_id", "source_entity_id"],
|
||||
"allOf": [
|
||||
{"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start_mm", "end_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start_mm", "end_mm", "center_mm", "normal", "radius_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["points_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "bspline"}, "points_mm": {"maxItems": 2}}}, "then": {"required": ["start_tangent_mm", "end_tangent_mm", "parameters"], "properties": {"periodic": {"const": false}}}}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"constructionBsplineSegment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -629,7 +1159,8 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {"enum": ["outer", "inner", "open", "unknown"]},
|
||||
"closed": {"type": "boolean"},
|
||||
"closed": {"type": "boolean"},
|
||||
"surface_wire": {"type": "boolean"},
|
||||
"segments": {"type": "array", "items": {"$ref": "#/$defs/analyticSegment"}}
|
||||
},
|
||||
"required": ["role", "closed", "segments"],
|
||||
@@ -658,10 +1189,24 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"anchor_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"external_anchor_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"side": {"enum": [-1, 1]},
|
||||
"intersection_index": {"type": "integer", "minimum": 0}
|
||||
},
|
||||
"required": ["anchor_entity_id", "side"],
|
||||
"required": ["side"],
|
||||
"oneOf": [
|
||||
{"required": ["anchor_entity_id"]},
|
||||
{"required": ["external_anchor_id"]}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"imprintExternalAnchor": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"selector": {"$ref": "#/$defs/selectorRef"}
|
||||
},
|
||||
"required": ["id", "selector"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"imprintSelection": {
|
||||
@@ -678,13 +1223,37 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "planar_imprint"},
|
||||
"source_entities": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/imprintSourceEntity"}},
|
||||
"selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}}
|
||||
"source_entities": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSourceEntity"}},
|
||||
"selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}},
|
||||
"external_anchors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintExternalAnchor"}}
|
||||
},
|
||||
"required": ["type", "source_entities", "selections"],
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"not": {"required": ["external_anchors"]}},
|
||||
"then": {"properties": {"source_entities": {"minItems": 2}}}
|
||||
}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face", "extrude_surface", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
|
||||
"multiSourceRegionsProfile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "multi_source_regions"},
|
||||
"source_sketch_ids": {"type": "array", "minItems": 2, "items": {"type": "string", "minLength": 1, "maxLength": 160}, "uniqueItems": true},
|
||||
"profiles": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/directProfile"}}
|
||||
},
|
||||
"required": ["type", "source_sketch_ids", "profiles"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"directProfile": {
|
||||
"oneOf": [
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
|
||||
{"$ref": "#/$defs/analyticProfile"}
|
||||
]
|
||||
},
|
||||
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face", "extrude_surface", "loft_add", "loft_add_with_cap_face", "loft_surface", "sweep_add", "sweep_cut", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "reference_point", "assign_variable", "hole_wizard"]},
|
||||
"feature": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -696,7 +1265,8 @@
|
||||
"params": {"type": "object"},
|
||||
"execution_status": {"enum": ["supported", "deferred"]},
|
||||
"selectors": {"type": "array", "items": {"$ref": "#/$defs/selectorRef"}},
|
||||
"unresolved": {"type": "array", "items": {"type": "string", "minLength": 1}}
|
||||
"unresolved": {"type": "array", "items": {"type": "string", "minLength": 1}},
|
||||
"intent": {"$ref": "#/$defs/featureIntent"}
|
||||
},
|
||||
"required": ["id", "atomic_id", "depends_on", "params"],
|
||||
"additionalProperties": false,
|
||||
@@ -710,7 +1280,9 @@
|
||||
{"if": {"properties": {"atomic_id": {"const": "extrude_from_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeFromFaceParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "loft_add_with_cap_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftCapFaceParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "loft_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "sweep_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "sweep_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}},
|
||||
{"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": "revolve_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveSurfaceParams"}}}},
|
||||
@@ -737,21 +1309,24 @@
|
||||
{"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "reference_plane"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePlaneParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "reference_axis"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referenceAxisParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "reference_point"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePointParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "assign_variable"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/assignVariableParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}}
|
||||
]
|
||||
},
|
||||
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]},
|
||||
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "multi_source_regions", "planar_imprint"]},
|
||||
"profile": {
|
||||
"oneOf": [
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
|
||||
{"$ref": "#/$defs/analyticProfile"},
|
||||
{"$ref": "#/$defs/multiSourceRegionsProfile"},
|
||||
{"$ref": "#/$defs/planarImprintProfile"}
|
||||
]
|
||||
},
|
||||
"sketch": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "name": {"type": "string"}, "workplane": {"$ref": "#/$defs/workplane"}, "profile": {"$ref": "#/$defs/profile"}, "role": {"enum": ["profile", "reference"]}, "attachment": {"$ref": "#/$defs/selectorRef"}, "profile_from": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "name": {"type": "string"}, "source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160}, "workplane": {"$ref": "#/$defs/workplane"}, "profile": {"$ref": "#/$defs/profile"}, "role": {"enum": ["profile", "reference"]}, "attachment": {"$ref": "#/$defs/selectorRef"}, "profile_from": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"required": ["id", "workplane", "profile"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
|
||||
@@ -24,9 +24,11 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession")
|
||||
params = node.params
|
||||
target_ids = _member_sources(
|
||||
node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs",
|
||||
allow_transform_copies=True, transform_copy_parameter="target_transform_copy_refs",
|
||||
)
|
||||
tool_ids = _member_sources(
|
||||
node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs",
|
||||
allow_transform_copies=True, transform_copy_parameter="tool_transform_copy_refs",
|
||||
)
|
||||
targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids}
|
||||
tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids}
|
||||
@@ -49,6 +51,12 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession")
|
||||
}
|
||||
members[node.feature_id] = result
|
||||
if bool(params.get("keep_tools")):
|
||||
# In a targetless FeatureScript body-set operation every selected
|
||||
# member is semantically a tool. Lowering chooses the first source
|
||||
# member only to satisfy CDSL's binary executor shape, so retain that
|
||||
# left operand too when the explicit targetless contract requests it.
|
||||
if bool(params.get("targetless_body_set")):
|
||||
members.update(targets)
|
||||
members.update(tools)
|
||||
session.register_body(
|
||||
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
|
||||
|
||||
@@ -6,13 +6,14 @@ used by exactly one family lives in that family's module instead.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from ..extents import _extent_vectors_from_normal, _normal_from_sketch
|
||||
from ..runtime_base import ExtentVector, FeatureExecutionError
|
||||
from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit
|
||||
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta
|
||||
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
@@ -57,20 +58,76 @@ def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any
|
||||
)
|
||||
|
||||
|
||||
def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]:
|
||||
def _cut_explicit_body_members(
|
||||
session: "ExecutionSession", tool: Any,
|
||||
) -> tuple[dict[str, Any], tuple[TopologyDelta, ...]]:
|
||||
"""Apply a cut to each independently owned body without erasing ownership.
|
||||
|
||||
A CADFS NEW body stays independently addressable even when a later REMOVE
|
||||
feature affects several active bodies. Cutting the aggregate first loses
|
||||
that identity, so this path uses the equivalent per-member set difference
|
||||
and drops only members that the tool removes completely.
|
||||
and drops only members that the tool removes completely. Each non-empty
|
||||
member result retains its own OCC builder history; callers may compose
|
||||
those disjoint exact relations into one operation-wide delta.
|
||||
"""
|
||||
members: dict[str, Any] = {}
|
||||
deltas: list[TopologyDelta] = []
|
||||
for feature_id, body in session.body_members.items():
|
||||
result = session.adapter.cut(body, tool)
|
||||
if abs(float(result.volume)) > 1e-12:
|
||||
result, delta = session.adapter.cut_with_topology_delta(body, tool)
|
||||
if delta is not None:
|
||||
deltas.append(delta)
|
||||
if result is not None and abs(float(result.volume)) > 1e-12:
|
||||
members[feature_id] = result
|
||||
return members
|
||||
return members, tuple(deltas)
|
||||
|
||||
|
||||
def _compose_member_cut_deltas(deltas: tuple[TopologyDelta, ...]) -> TopologyDelta | None:
|
||||
"""Combine exact independent member-cut histories without inventing links."""
|
||||
if not deltas or any(delta.history_status != "proven" for delta in deltas):
|
||||
return None
|
||||
return TopologyDelta(
|
||||
operation="subtract",
|
||||
relations=tuple(relation for delta in deltas for relation in delta.relations),
|
||||
section_values=tuple(value for delta in deltas for value in delta.section_values),
|
||||
section_relations=tuple(relation for delta in deltas for relation in delta.section_relations),
|
||||
blend_relations=tuple(relation for delta in deltas for relation in delta.blend_relations),
|
||||
history_reason="per_member_exact_cut_history",
|
||||
)
|
||||
|
||||
|
||||
def _can_register_primary_cut_tool_history(
|
||||
session: "ExecutionSession",
|
||||
tool: Any,
|
||||
topology_delta: TopologyDelta | None,
|
||||
topology_anchors: list[TopologyRecord] | None,
|
||||
) -> bool:
|
||||
"""Return whether a primary REMOVE can retain a transient tool snapshot.
|
||||
|
||||
The implicit CADFS primary boolean normally has no independently active
|
||||
tool body. It may retain its direct-prism input snapshot whenever the
|
||||
tool, profile anchors, and prism builder history are singular and exact.
|
||||
The target may contain several independent members: that changes the cut
|
||||
result cardinality but not the identity of the one transient tool input.
|
||||
This gate does not make any multi-member selector executable; a later
|
||||
resolver must still prove its complete operation-wide relation and active
|
||||
member. The cut builder can independently prove a target-side continuation
|
||||
even when a trimmed or fallback tool has no direct-prism history.
|
||||
"""
|
||||
if (
|
||||
session.body is None
|
||||
or topology_delta is None
|
||||
or topology_delta.operation != "extrude"
|
||||
or topology_delta.history_status != "proven"
|
||||
or not topology_delta.relations
|
||||
or not topology_anchors
|
||||
or len(session.adapter.body_solids(tool)) != 1
|
||||
):
|
||||
return False
|
||||
return all(
|
||||
anchor.kind in {"edge", "vertex"}
|
||||
and (anchor.source_entity is not None or anchor.source_entities)
|
||||
for anchor in topology_anchors
|
||||
)
|
||||
|
||||
|
||||
def _extruded_tool(
|
||||
@@ -78,8 +135,11 @@ def _extruded_tool(
|
||||
faces: list[Any],
|
||||
profile_normal: Vector3,
|
||||
session: "ExecutionSession",
|
||||
*,
|
||||
record_multiface_prism_history: bool = False,
|
||||
use_operation_wide_prism_history: bool = False,
|
||||
) -> tuple[Any, TopologyDelta | None]:
|
||||
"""Build one extrude tool, retaining caps only from one exact builder result."""
|
||||
"""Build an extrude tool, retaining complete direct builder history."""
|
||||
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
|
||||
draft = node.params.get("draft")
|
||||
taper_deg = 0.0
|
||||
@@ -87,20 +147,59 @@ def _extruded_tool(
|
||||
taper_deg = float(draft["angle_deg"])
|
||||
if not bool(draft["pull_direction"]):
|
||||
taper_deg = -taper_deg
|
||||
topology_delta: TopologyDelta | None = None
|
||||
if (
|
||||
use_operation_wide_prism_history
|
||||
and draft is None
|
||||
and len(faces) > 1
|
||||
and len(extents) == 1
|
||||
and extents[0].trim_to is None
|
||||
):
|
||||
composed = session.adapter.extrude_faces_with_composed_topology_delta(faces, extents[0].vector)
|
||||
if composed is not None:
|
||||
return composed
|
||||
topology_deltas: list[tuple[int, TopologyDelta]] = []
|
||||
solids: list[Any] = []
|
||||
exact_two_sided_prism = (
|
||||
node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}
|
||||
and draft is None
|
||||
and len(faces) == 1
|
||||
and len(extents) == 2
|
||||
and all(extent.trim_to is None for extent in extents)
|
||||
)
|
||||
for face in faces:
|
||||
for extent in extents:
|
||||
for extent_index, extent in enumerate(extents):
|
||||
if draft is not None:
|
||||
if len(faces) == 1 and len(extents) == 1:
|
||||
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
|
||||
face, extent.vector, taper_deg,
|
||||
)
|
||||
if topology_delta is not None:
|
||||
topology_deltas.append((extent_index, topology_delta))
|
||||
solids.append(solid)
|
||||
else:
|
||||
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
|
||||
elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1:
|
||||
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
|
||||
elif extent.trim_to is None and (len(extents) == 1 or exact_two_sided_prism) and (
|
||||
len(faces) == 1 or record_multiface_prism_history
|
||||
):
|
||||
# Each independently constructed profile face has its own OCC
|
||||
# prism history only when the adapter retained direct source
|
||||
# anchors for every participating profile path. Other complex
|
||||
# multi-face profiles keep the established general-extrude
|
||||
# path; forcing them through MakePrism can make a previously
|
||||
# executable profile invalid without adding usable evidence.
|
||||
try:
|
||||
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
|
||||
except ValueError:
|
||||
# Keep the established executable profile result when a
|
||||
# selected IMPRINT region is valid as a face but cannot be
|
||||
# a standalone valid prism. Its later fuse may still be
|
||||
# valid. There is no complete builder witness in this
|
||||
# case, so the entire multi-region topology delta is
|
||||
# withheld below instead of mixing proven and guessed
|
||||
# source anchors.
|
||||
solid = session.adapter.extrude(face, extent.vector)
|
||||
else:
|
||||
topology_deltas.append((extent_index, topology_delta))
|
||||
solids.append(solid)
|
||||
elif extent.trim_to is None:
|
||||
solids.append(session.adapter.extrude(face, extent.vector))
|
||||
@@ -111,7 +210,24 @@ def _extruded_tool(
|
||||
tool = session.adapter.fuse(tool, solid)
|
||||
if tool is None:
|
||||
raise ValueError("extrude produced no solid")
|
||||
return tool, topology_delta
|
||||
if len(topology_deltas) != len(solids):
|
||||
return tool, None
|
||||
relations: list[TopologyDeltaRelation] = []
|
||||
for extent_index, topology_delta in topology_deltas:
|
||||
for relation in topology_delta.relations:
|
||||
# Both prism builders start on the source plane. Its two source
|
||||
# caps are internal to the fused two-sided result. The reverse
|
||||
# extent's far ``LastShape`` is the FeatureScript start cap; map
|
||||
# that exact builder handle before the registry checks final
|
||||
# membership. No source-plane or geometry-derived relation is
|
||||
# promoted to a CAP role.
|
||||
if exact_two_sided_prism and extent_index == 1 and relation.output_role == "extrude.end":
|
||||
relation = replace(relation, output_role="extrude.start")
|
||||
relations.append(relation)
|
||||
return tool, TopologyDelta(
|
||||
operation="extrude",
|
||||
relations=tuple(relations),
|
||||
)
|
||||
|
||||
|
||||
def _apply_primary_tool(
|
||||
@@ -121,43 +237,128 @@ def _apply_primary_tool(
|
||||
*,
|
||||
cutting: bool,
|
||||
topology_delta: TopologyDelta | None = None,
|
||||
topology_anchors: list[TopologyRecord] | None = None,
|
||||
) -> FeatureResult:
|
||||
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
|
||||
topology_predecessors: list[TopologyRecord] | None = None
|
||||
if cutting:
|
||||
if session.body is None:
|
||||
raise ValueError("cut feature has no body")
|
||||
members = _cut_explicit_body_members(session, tool)
|
||||
members, member_cut_deltas = _cut_explicit_body_members(session, tool)
|
||||
if not members:
|
||||
session.clear_body()
|
||||
return session.result(node)
|
||||
body = session.adapter.cut(session.body, tool)
|
||||
topology_delta = None
|
||||
tool_delta = topology_delta
|
||||
tool_anchors = list(topology_anchors or ())
|
||||
retain_transient_tool = _can_register_primary_cut_tool_history(
|
||||
session, tool, tool_delta, topology_anchors,
|
||||
)
|
||||
if len(session.body_members) == 1:
|
||||
member_id = next(iter(session.body_members))
|
||||
body = members[member_id]
|
||||
cut_delta = member_cut_deltas[0] if len(member_cut_deltas) == 1 else None
|
||||
# The target-side boolean history is independent of the source
|
||||
# tool's construction history. A trimmed tool cannot support a
|
||||
# source-qualified section query, but its exact BRepAlgoAPI_Cut
|
||||
# Modified/Preserved facts can still prove a unique continuation
|
||||
# of the active target face or edge.
|
||||
topology_anchors = None
|
||||
if cut_delta is not None:
|
||||
if retain_transient_tool:
|
||||
try:
|
||||
topology_predecessors = session.register_transient_prism_tool(
|
||||
node.feature_id,
|
||||
tool,
|
||||
topology_delta=tool_delta,
|
||||
topology_anchors=tool_anchors,
|
||||
)
|
||||
except ValueError:
|
||||
# Retain cut history as partial diagnostic evidence. The
|
||||
# absent transient source records prevent it from proving
|
||||
# a source-qualified section edge.
|
||||
topology_predecessors = None
|
||||
topology_delta = cut_delta
|
||||
else:
|
||||
topology_delta = None
|
||||
members = {member_id: body}
|
||||
else:
|
||||
if retain_transient_tool:
|
||||
try:
|
||||
topology_predecessors = session.register_transient_prism_tool(
|
||||
node.feature_id,
|
||||
tool,
|
||||
topology_delta=tool_delta,
|
||||
topology_anchors=tool_anchors,
|
||||
)
|
||||
except ValueError:
|
||||
# A transient tool is optional evidence. Keep the exact
|
||||
# per-member target history when its tool snapshot cannot
|
||||
# be registered completely.
|
||||
topology_predecessors = None
|
||||
body = session.adapter.combine(None, next(iter(members.values())))
|
||||
for member in list(members.values())[1:]:
|
||||
body = session.adapter.combine(body, member)
|
||||
topology_delta = _compose_member_cut_deltas(member_cut_deltas)
|
||||
topology_anchors = None
|
||||
elif node.params.get("result_mode") == "new_body":
|
||||
body = session.adapter.combine(session.body, tool)
|
||||
members = {**session.body_members, node.feature_id: tool}
|
||||
else:
|
||||
body = session.adapter.fuse(session.body, tool)
|
||||
# An ADD can replace both the prior active solid and its direct prism
|
||||
# tool. Retain a selector relation only when both snapshots are
|
||||
# singular and OCC supplies the exact union history. The direct-prism
|
||||
# snapshot remains transient: its source anchors cannot be selected
|
||||
# until the union proves a complete successor in the active result.
|
||||
can_trace_add = (
|
||||
session.body is not None
|
||||
and topology_delta is not None
|
||||
and topology_delta.operation == "extrude"
|
||||
and topology_delta.history_status == "proven"
|
||||
and bool(topology_delta.relations)
|
||||
and bool(topology_anchors)
|
||||
and len(session.adapter.body_solids(session.body)) == 1
|
||||
and len(session.adapter.body_solids(tool)) == 1
|
||||
)
|
||||
if can_trace_add:
|
||||
fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, tool)
|
||||
if fuse_delta is not None:
|
||||
topology_predecessors = session.register_transient_prism_tool(
|
||||
node.feature_id,
|
||||
tool,
|
||||
topology_delta=topology_delta,
|
||||
topology_anchors=list(topology_anchors or ()),
|
||||
)
|
||||
body = fused
|
||||
topology_delta = fuse_delta
|
||||
topology_anchors = None
|
||||
else:
|
||||
body = fused
|
||||
topology_delta = None
|
||||
topology_anchors = None
|
||||
else:
|
||||
body = session.adapter.fuse(session.body, tool)
|
||||
members = {node.feature_id: body}
|
||||
# A fuse rebuilds subshape identity. Builder evidence belongs only to
|
||||
# an unchanged standalone/new-body prism snapshot.
|
||||
if session.body is not None:
|
||||
topology_delta = None
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
|
||||
topology_predecessors=topology_predecessors, topology_anchors=topology_anchors,
|
||||
)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, sketch: dict[str, Any] | None = None) -> FeatureResult:
|
||||
# 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。
|
||||
|
||||
|
||||
# 1. 取草图:优先使用外部传入的 sketch_override(阵列/镜像等重放场景),
|
||||
# 否则按 sketch_id 从会话草图表中取原始草图。
|
||||
selected_sketch = sketch or session.sketches.get(str(node.sketch_id))
|
||||
if selected_sketch is None:
|
||||
raise ValueError("primary feature has no resolved sketch")
|
||||
# 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。
|
||||
faces = session.adapter.faces_for_sketch(selected_sketch)
|
||||
support_face = session.sketch_attachment_faces.get(str(node.sketch_id))
|
||||
external_anchor_edges = session.sketch_imprint_external_edges.get(str(node.sketch_id))
|
||||
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(
|
||||
selected_sketch, support_face=support_face, external_anchor_edges=external_anchor_edges,
|
||||
)
|
||||
if not faces:
|
||||
raise ValueError("sketch does not create a closed profile region")
|
||||
if node.atomic_id == "extrude_add_blind_with_hole":
|
||||
@@ -169,6 +370,17 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
raise ValueError("profile hole extrusion requires exactly one outer sketch region")
|
||||
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
|
||||
topology_delta: TopologyDelta | None = None
|
||||
topology_anchors: list[TopologyRecord] = []
|
||||
profile = selected_sketch.get("profile") or {}
|
||||
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
|
||||
direct_all_circle_profile = isinstance(contours, list) and bool(contours) and all(
|
||||
isinstance(contour, dict)
|
||||
and bool(contour.get("closed"))
|
||||
and len(contour.get("segments") or []) == 1
|
||||
and (contour.get("segments") or [{}])[0].get("type") == "circle"
|
||||
for contour in contours
|
||||
)
|
||||
planar_imprint_profile = profile.get("type") == "planar_imprint"
|
||||
# 3. 按特征类型生成子实体:
|
||||
if node.atomic_id.startswith("extrude_"):
|
||||
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
|
||||
@@ -177,7 +389,35 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
|
||||
tool, topology_delta = _extruded_tool(
|
||||
node, faces, _normal_from_sketch(selected_sketch), session,
|
||||
# A multi-region prism gets per-region builder history only when
|
||||
# every profile region has at least one exact source boundary.
|
||||
# This covers direct circles and the adapter's bounded IMPRINT
|
||||
# splitter path, without turning arbitrary multi-face profiles
|
||||
# into a different construction algorithm.
|
||||
record_multiface_prism_history=(direct_all_circle_profile or planar_imprint_profile)
|
||||
and len(source_anchor_specs) >= len(faces),
|
||||
use_operation_wide_prism_history=planar_imprint_profile
|
||||
and len(source_anchor_specs) >= len(faces),
|
||||
)
|
||||
if topology_delta is not None:
|
||||
for index, spec in enumerate(source_anchor_specs):
|
||||
kind = spec.get("kind")
|
||||
value = spec.get("value")
|
||||
if kind not in {"edge", "vertex"} or value is None:
|
||||
continue
|
||||
source_entity = spec.get("source_entity")
|
||||
source_entities = tuple(spec.get("source_entities") or ())
|
||||
if source_entity is None and not source_entities:
|
||||
continue
|
||||
topology_anchors.append(TopologyRecord(
|
||||
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
|
||||
kind=kind,
|
||||
feature_id=node.feature_id,
|
||||
geometry={},
|
||||
value=value,
|
||||
source_entity=source_entity if isinstance(source_entity, tuple) else None,
|
||||
source_entities=source_entities,
|
||||
))
|
||||
else:
|
||||
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
|
||||
axis = _revolve_axis(node, session)
|
||||
@@ -193,12 +433,47 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
if bool(node.params.get("reverse")):
|
||||
angle = -angle
|
||||
tool = None
|
||||
for solid in (session.adapter.revolve(face, angle, axis) for face in faces):
|
||||
tool = session.adapter.fuse(tool, solid)
|
||||
can_record_revolve_history = (
|
||||
node.atomic_id == "revolve_add"
|
||||
and node.params.get("result_mode") == "new_body"
|
||||
and len(faces) == 1
|
||||
)
|
||||
for face in faces:
|
||||
if can_record_revolve_history:
|
||||
try:
|
||||
solid, topology_delta = session.adapter.revolve_with_topology_delta(face, angle, axis)
|
||||
except ValueError:
|
||||
# Retain the established executable revolve when OCC
|
||||
# cannot expose a complete builder-history witness.
|
||||
solid = session.adapter.revolve(face, angle, axis)
|
||||
topology_delta = None
|
||||
else:
|
||||
solid = session.adapter.revolve(face, angle, axis)
|
||||
tool = solid if tool is None else session.adapter.fuse(tool, solid)
|
||||
if tool is None:
|
||||
raise ValueError("revolve produced no solid")
|
||||
if topology_delta is not None:
|
||||
for index, spec in enumerate(source_anchor_specs):
|
||||
kind = spec.get("kind")
|
||||
value = spec.get("value")
|
||||
if kind not in {"edge", "vertex"} or value is None:
|
||||
continue
|
||||
source_entity = spec.get("source_entity")
|
||||
source_entities = tuple(spec.get("source_entities") or ())
|
||||
if source_entity is None and not source_entities:
|
||||
continue
|
||||
topology_anchors.append(TopologyRecord(
|
||||
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
|
||||
kind=kind,
|
||||
feature_id=node.feature_id,
|
||||
geometry={},
|
||||
value=value,
|
||||
source_entity=source_entity if isinstance(source_entity, tuple) else None,
|
||||
source_entities=source_entities,
|
||||
))
|
||||
return _apply_primary_tool(
|
||||
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
|
||||
topology_anchors=topology_anchors,
|
||||
)
|
||||
|
||||
|
||||
@@ -248,10 +523,14 @@ def _pattern_instance_sources(
|
||||
return resolved
|
||||
|
||||
|
||||
def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]:
|
||||
def _transform_copy_sources(
|
||||
node: FeaturePlanNode,
|
||||
session: "ExecutionSession",
|
||||
parameter: str = "transform_copy_refs",
|
||||
) -> list[str]:
|
||||
"""Resolve source-qualified outputs of preceding multi-body COPY transforms."""
|
||||
resolved: list[str] = []
|
||||
for reference in node.params.get("transform_copy_refs") or ():
|
||||
for reference in node.params.get(parameter) or ():
|
||||
if not isinstance(reference, dict):
|
||||
raise ValueError("transform COPY reference must be an object")
|
||||
transform_id = str(reference.get("transform_feature_id") or "")
|
||||
@@ -285,12 +564,13 @@ def _member_sources(
|
||||
*,
|
||||
pattern_instance_parameter: str | None = None,
|
||||
allow_transform_copies: bool = False,
|
||||
transform_copy_parameter: str = "transform_copy_refs",
|
||||
) -> list[str]:
|
||||
source_ids = [str(value) for value in node.params.get(parameter) or []]
|
||||
if pattern_instance_parameter is not None:
|
||||
source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter))
|
||||
if allow_transform_copies:
|
||||
source_ids.extend(_transform_copy_sources(node, session))
|
||||
source_ids.extend(_transform_copy_sources(node, session, transform_copy_parameter))
|
||||
if not source_ids:
|
||||
raise ValueError(f"{node.atomic_id} requires explicit {parameter}")
|
||||
missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members]
|
||||
@@ -304,37 +584,119 @@ def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any:
|
||||
path = node.params.get("path") or {}
|
||||
if not isinstance(path, dict):
|
||||
raise ValueError("sweep path must be an object")
|
||||
plane = PlaneSpec.from_mapping(path.get("workplane") or {})
|
||||
spatial = path.get("workplane") is None
|
||||
plane = None if spatial else PlaneSpec.from_mapping(path.get("workplane") or {})
|
||||
segment = path.get("segment") or {}
|
||||
if not isinstance(segment, dict):
|
||||
raise ValueError("sweep path segment must be an object")
|
||||
kind = str(segment.get("type") or "")
|
||||
if kind == "line":
|
||||
local_points = [segment.get("start"), segment.get("end")]
|
||||
elif kind == "bspline":
|
||||
local_points = segment.get("points") or []
|
||||
else:
|
||||
raise ValueError(f"unsupported sweep path segment {kind!r}")
|
||||
if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points):
|
||||
raise ValueError("sweep path requires two-dimensional points")
|
||||
segments = path.get("segments")
|
||||
|
||||
def point(value: list[float]) -> Vector3:
|
||||
def local_point(value: Any) -> Vector3:
|
||||
if plane is None:
|
||||
raise ValueError("planar sweep path requires a workplane")
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
raise ValueError("sweep path requires two-dimensional points")
|
||||
return vector_add(
|
||||
plane.origin_mm,
|
||||
vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))),
|
||||
)
|
||||
|
||||
def tangent(value: Any) -> Vector3 | None:
|
||||
if value is None:
|
||||
return None
|
||||
def local_vector(value: Any) -> Vector3:
|
||||
if plane is None:
|
||||
raise ValueError("planar sweep path requires a workplane")
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
raise ValueError("sweep path tangent must contain two coordinates")
|
||||
return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1])))
|
||||
|
||||
if segments is not None:
|
||||
if segment:
|
||||
raise ValueError("sweep path cannot mix segment and segments")
|
||||
if not isinstance(segments, list) or len(segments) < 2:
|
||||
raise ValueError("sweep segmented path requires at least two segments")
|
||||
materialized: list[dict[str, Any]] = []
|
||||
for index, source in enumerate(segments):
|
||||
if not isinstance(source, dict):
|
||||
raise ValueError("sweep path segment must be an object")
|
||||
kind = str(source.get("type") or "")
|
||||
if kind not in {"line", "arc", "bspline"}:
|
||||
raise ValueError(f"unsupported sweep path segment {kind!r}")
|
||||
target: dict[str, Any] = {"type": kind}
|
||||
if spatial:
|
||||
if kind in {"line", "arc"}:
|
||||
target["start_mm"] = source.get("start_mm")
|
||||
target["end_mm"] = source.get("end_mm")
|
||||
if kind == "arc":
|
||||
target["center_mm"] = source.get("center_mm")
|
||||
target["normal"] = source.get("normal")
|
||||
target["radius_mm"] = source.get("radius_mm")
|
||||
target["clockwise"] = bool(source.get("clockwise", False))
|
||||
if kind == "bspline":
|
||||
target["points_mm"] = source.get("points_mm")
|
||||
if source.get("start_tangent_mm") is not None:
|
||||
target["start_tangent_mm"] = source.get("start_tangent_mm")
|
||||
if source.get("end_tangent_mm") is not None:
|
||||
target["end_tangent_mm"] = source.get("end_tangent_mm")
|
||||
if source.get("parameters") is not None:
|
||||
target["parameters"] = [float(value) for value in source.get("parameters") or []]
|
||||
if source.get("periodic") is not None:
|
||||
target["periodic"] = bool(source.get("periodic"))
|
||||
materialized.append(target)
|
||||
continue
|
||||
if kind in {"line", "arc"}:
|
||||
target["start_mm"] = local_point(source.get("start"))
|
||||
target["end_mm"] = local_point(source.get("end"))
|
||||
if kind == "arc":
|
||||
target["center_mm"] = local_point(source.get("center"))
|
||||
target["normal"] = plane.normal
|
||||
target["clockwise"] = bool(source.get("clockwise", False))
|
||||
if kind == "bspline":
|
||||
points = source.get("points")
|
||||
if not isinstance(points, list) or len(points) < 2:
|
||||
raise ValueError("sweep B-spline path requires at least two interpolation points")
|
||||
target["points_mm"] = [local_point(value) for value in points]
|
||||
if source.get("start_tangent") is not None:
|
||||
target["start_tangent_mm"] = local_vector(source.get("start_tangent"))
|
||||
if source.get("end_tangent") is not None:
|
||||
target["end_tangent_mm"] = local_vector(source.get("end_tangent"))
|
||||
if source.get("parameters") is not None:
|
||||
target["parameters"] = [float(value) for value in source.get("parameters") or []]
|
||||
if source.get("periodic") is not None:
|
||||
target["periodic"] = bool(source.get("periodic"))
|
||||
materialized.append(target)
|
||||
return session.adapter.sweep_path_segments(materialized)
|
||||
|
||||
if spatial:
|
||||
raise ValueError("spatial sweep path requires captured segments")
|
||||
if not isinstance(segment, dict):
|
||||
raise ValueError("sweep path segment must be an object")
|
||||
kind = str(segment.get("type") or "")
|
||||
if kind == "line":
|
||||
local_points = [segment.get("start"), segment.get("end")]
|
||||
elif kind == "circle":
|
||||
return session.adapter.sweep_circle_path(
|
||||
local_point(segment.get("center")),
|
||||
plane.x_dir,
|
||||
plane.normal,
|
||||
radius_mm=float(segment["radius_mm"]),
|
||||
)
|
||||
elif kind == "arc":
|
||||
return session.adapter.sweep_arc_path(
|
||||
local_point(segment.get("start")),
|
||||
local_point(segment.get("end")),
|
||||
local_point(segment.get("center")),
|
||||
plane.normal,
|
||||
radius_mm=float(segment["radius_mm"]),
|
||||
clockwise=bool(segment["clockwise"]),
|
||||
)
|
||||
elif kind == "bspline":
|
||||
local_points = segment.get("points") or []
|
||||
else:
|
||||
raise ValueError(f"unsupported sweep path segment {kind!r}")
|
||||
if len(local_points) < 2:
|
||||
raise ValueError("sweep path requires two-dimensional points")
|
||||
|
||||
return session.adapter.sweep_path(
|
||||
[point(value) for value in local_points],
|
||||
start_tangent=tangent(segment.get("start_tangent")),
|
||||
end_tangent=tangent(segment.get("end_tangent")),
|
||||
[local_point(value) for value in local_points],
|
||||
start_tangent=local_vector(segment["start_tangent"]) if segment.get("start_tangent") is not None else None,
|
||||
end_tangent=local_vector(segment["end_tangent"]) if segment.get("end_tangent") is not None else None,
|
||||
parameters=[float(value) for value in segment.get("parameters") or []] or None,
|
||||
)
|
||||
|
||||
@@ -343,6 +705,8 @@ def _register_added_solid(
|
||||
session: "ExecutionSession",
|
||||
node: FeaturePlanNode,
|
||||
solid: Any,
|
||||
*,
|
||||
topology_delta: TopologyDelta | None = None,
|
||||
) -> None:
|
||||
"""Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend).
|
||||
|
||||
@@ -356,21 +720,62 @@ def _register_added_solid(
|
||||
if node.params.get("result_mode") == "new_body":
|
||||
combined = session.adapter.combine(session.body, solid)
|
||||
members = {**session.body_members, node.feature_id: solid}
|
||||
session.register_body(node.feature_id, combined, replay_node=node, body_members=members)
|
||||
session.register_body(
|
||||
node.feature_id, combined, replay_node=node, body_members=members,
|
||||
topology_delta=topology_delta,
|
||||
)
|
||||
return
|
||||
fused = session.adapter.fuse(session.body, solid)
|
||||
session.register_body(node.feature_id, fused, replay_node=node)
|
||||
if session.body is None:
|
||||
session.register_body(node.feature_id, solid, replay_node=node, topology_delta=topology_delta)
|
||||
return
|
||||
if topology_delta is None:
|
||||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
||||
return
|
||||
# Preserve primitive output roles only through the exact OCC fuse history.
|
||||
# The transient records provide source handles, not selectable snapshots.
|
||||
role_records = _direct_output_role_records(session, node, solid, topology_delta)
|
||||
fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, solid)
|
||||
session.register_body(
|
||||
node.feature_id, fused, replay_node=node, topology_delta=fuse_delta,
|
||||
topology_predecessors=role_records,
|
||||
)
|
||||
|
||||
|
||||
def _host_plane(resolution: SelectorResolution) -> PlaneSpec:
|
||||
def _direct_output_role_records(
|
||||
session: "ExecutionSession",
|
||||
node: FeaturePlanNode,
|
||||
solid: Any,
|
||||
topology_delta: TopologyDelta,
|
||||
) -> list[TopologyRecord]:
|
||||
"""Attach only builder-proven output roles to a transient primitive snapshot."""
|
||||
records = session.adapter.topology_records(solid, node.feature_id, f"transient:{node.feature_id}")
|
||||
result: list[TopologyRecord] = []
|
||||
for record in records:
|
||||
roles = {
|
||||
relation.output_role
|
||||
for relation in topology_delta.relations
|
||||
if relation.output_role is not None
|
||||
and relation.kind == record.kind
|
||||
and any(session.topology._same_topology_value(record.value, value) for value in relation.result_values)
|
||||
}
|
||||
if roles:
|
||||
result.append(TopologyRecord(
|
||||
record_id=record.record_id,
|
||||
kind=record.kind,
|
||||
feature_id=record.feature_id,
|
||||
body_id=record.body_id,
|
||||
geometry=record.geometry,
|
||||
value=record.value,
|
||||
owner_feature_ids=(node.feature_id,),
|
||||
output_roles=tuple(sorted(roles)),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def _host_plane(resolution: SelectorResolution, adapter: Any) -> PlaneSpec:
|
||||
if resolution.record is None:
|
||||
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved")
|
||||
geometry = resolution.record.geometry
|
||||
return PlaneSpec.from_mapping({
|
||||
"origin_mm": geometry["center_mm"],
|
||||
"x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0],
|
||||
"normal": geometry["normal"],
|
||||
})
|
||||
return adapter.planar_face_workplane(resolution.record.value)
|
||||
|
||||
|
||||
def _hole_starts(
|
||||
@@ -415,10 +820,12 @@ def _selector_edges(node: FeaturePlanNode, session: "ExecutionSession", *, tange
|
||||
|
||||
edges: list[Any] = []
|
||||
for item in resolved:
|
||||
if item.record.kind == "edge":
|
||||
edges.append(item.record.value)
|
||||
elif item.record.kind == "face":
|
||||
edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge))
|
||||
records = item.records or ((item.record,) if item.record is not None else ())
|
||||
for record in records:
|
||||
if record.kind == "edge":
|
||||
edges.append(record.value)
|
||||
elif record.kind == "face":
|
||||
edges.extend(edge for edge in record.value.edges() if is_body_boundary(edge))
|
||||
if not edges:
|
||||
raise ValueError("selectors did not resolve any edges")
|
||||
return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges
|
||||
@@ -432,7 +839,11 @@ def _shell_target(node: FeaturePlanNode, session: "ExecutionSession") -> tuple[A
|
||||
failed = next((item for item in resolved if item.status != "resolved"), None)
|
||||
if failed:
|
||||
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
|
||||
records = [item.record for item in resolved if item.record is not None]
|
||||
records = [
|
||||
record
|
||||
for item in resolved
|
||||
for record in (item.records or ((item.record,) if item.record is not None else ()))
|
||||
]
|
||||
if not records or any(record.kind != "face" for record in records):
|
||||
raise ValueError("shell selectors must resolve to faces")
|
||||
target_ids = {record.body_id for record in records}
|
||||
|
||||
@@ -6,6 +6,7 @@ that later features resolve through owner-qualified selectors.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..registry import atomic_executor
|
||||
@@ -61,3 +62,28 @@ def _reference_axis_executor(node: FeaturePlanNode, session: "ExecutionSession",
|
||||
# 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。
|
||||
session.topology.register_context(node.feature_id, axis)
|
||||
return session.result(node, context=axis)
|
||||
|
||||
|
||||
@atomic_executor("reference_point")
|
||||
def _reference_point_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
"""Execute an explicit source-datum point without changing model topology."""
|
||||
del sketch
|
||||
point = node.params.get("point_mm")
|
||||
if (
|
||||
not isinstance(point, list)
|
||||
or len(point) != 3
|
||||
or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in point)
|
||||
):
|
||||
raise ValueError("reference point requires one finite three-dimensional point")
|
||||
return session.result(node, include_body=False)
|
||||
|
||||
|
||||
@atomic_executor("assign_variable")
|
||||
def _assign_variable_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
"""Replay a source variable declaration after lowering has consumed it."""
|
||||
del sketch
|
||||
name = node.params.get("name")
|
||||
value = node.params.get("value")
|
||||
if not isinstance(name, str) or not name or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||
raise ValueError("assign_variable requires one finite named value")
|
||||
return session.result(node, include_body=False)
|
||||
|
||||
@@ -17,6 +17,60 @@ if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
def _single_member_dressup_members(
|
||||
node: FeaturePlanNode,
|
||||
session: "ExecutionSession",
|
||||
body: Any,
|
||||
selected_edges: list[Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Preserve unchanged body members after one exact-member dress-up.
|
||||
|
||||
The adapter only reports Compound history when every selected edge maps to
|
||||
one source solid. Keep the same proof at the body-graph layer: all other
|
||||
members must appear unchanged in the result and exactly one result solid
|
||||
must remain for the changed member. Otherwise aggregate replay remains
|
||||
valid, but no member-lifecycle transfer is asserted.
|
||||
"""
|
||||
if not selected_edges or len(session.body_members) < 2:
|
||||
return None
|
||||
source_members: dict[str, Any] = {}
|
||||
for member_id, member in session.body_members.items():
|
||||
solids = session.adapter.body_solids(member)
|
||||
if len(solids) != 1:
|
||||
return None
|
||||
source_members[member_id] = solids[0]
|
||||
selected_members = {
|
||||
member_id
|
||||
for edge in selected_edges
|
||||
for member_id, member in source_members.items()
|
||||
if any(edge.is_same(candidate) for candidate in member.edges())
|
||||
}
|
||||
if len(selected_members) != 1:
|
||||
return None
|
||||
changed_member_id = next(iter(selected_members))
|
||||
result_solids = session.adapter.body_solids(body)
|
||||
unchanged: dict[str, Any] = {}
|
||||
matched_result_indexes: set[int] = set()
|
||||
for member_id, member in source_members.items():
|
||||
if member_id == changed_member_id:
|
||||
continue
|
||||
matches = [
|
||||
index for index, result in enumerate(result_solids)
|
||||
if session.topology._same_topology_value(member, result)
|
||||
]
|
||||
if len(matches) != 1 or matches[0] in matched_result_indexes:
|
||||
return None
|
||||
matched_result_indexes.add(matches[0])
|
||||
unchanged[member_id] = result_solids[matches[0]]
|
||||
changed = [
|
||||
result for index, result in enumerate(result_solids)
|
||||
if index not in matched_result_indexes
|
||||
]
|
||||
if len(changed) != 1:
|
||||
return None
|
||||
return {**unchanged, node.feature_id: changed[0]}
|
||||
|
||||
|
||||
def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。
|
||||
|
||||
@@ -28,11 +82,15 @@ def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> Featu
|
||||
if radius <= 0:
|
||||
raise ValueError("fillet radius_mm must be > 0")
|
||||
# 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。
|
||||
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
|
||||
body, topology_delta = session.adapter.fillet_with_topology_delta(
|
||||
session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
|
||||
session.body, radius, edges,
|
||||
)
|
||||
# 4. 登记新主体并返回结果。
|
||||
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
|
||||
body_members=_single_member_dressup_members(node, session, body, edges),
|
||||
)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@@ -86,7 +144,10 @@ def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> Feat
|
||||
detail={"distance_mm": distance, "surface_count": len(session.surface_members)},
|
||||
))
|
||||
# 5. 登记新主体并返回结果。
|
||||
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
|
||||
body_members=_single_member_dressup_members(node, session, body, edges),
|
||||
)
|
||||
return session.result(node, diagnostics=diagnostics)
|
||||
|
||||
|
||||
|
||||
@@ -27,10 +27,21 @@ def _hole_wizard_executor(node: FeaturePlanNode, session: "ExecutionSession", sk
|
||||
|
||||
def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: bool = False) -> FeatureResult:
|
||||
# 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。
|
||||
|
||||
|
||||
# 1. 校验:孔是切除操作,必须先有主体。
|
||||
if session.body is None:
|
||||
raise ValueError("hole feature has no body")
|
||||
scope_feature_id = node.params.get("scope_feature_id")
|
||||
scoped_body = None
|
||||
if scope_feature_id is not None:
|
||||
if not isinstance(scope_feature_id, str) or not scope_feature_id:
|
||||
raise ValueError("hole scope_feature_id is invalid")
|
||||
scoped_body = session.body_members.get(scope_feature_id)
|
||||
if scoped_body is None:
|
||||
raise ValueError("hole scope body is no longer an independently selectable member")
|
||||
scoped_solids = session.adapter.body_solids(scoped_body)
|
||||
if not scoped_solids:
|
||||
raise ValueError("hole scope body has no active solid")
|
||||
# 2. 确定宿主面 host_face:
|
||||
host_selector = node.params.get("host_face")
|
||||
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
|
||||
@@ -45,8 +56,15 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
|
||||
selector = next((item for item in selectors if item.get("kind") == "face"), None)
|
||||
if selector is None:
|
||||
raise ValueError("hole requires host_face selector or frame")
|
||||
host = _host_plane(session.resolve(selector))
|
||||
positions_are_local = False
|
||||
host = _host_plane(session.resolve(selector), session.adapter)
|
||||
intent = selector.get("selector_intent") if isinstance(selector, dict) else None
|
||||
# Existing selector-hosted holes store world positions. Only the new
|
||||
# runtime-attached COPY(CAP_FACE) sketch preserves local coordinates
|
||||
# until its exact host relation has materialized.
|
||||
positions_are_local = (
|
||||
isinstance(intent, dict)
|
||||
and intent.get("copy_contract") == "primary_cut_cap_face_workplane"
|
||||
)
|
||||
# 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。
|
||||
spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard)
|
||||
# 4. A host-face normal is an outward B-rep orientation, so its inverse
|
||||
@@ -61,7 +79,7 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
|
||||
spec,
|
||||
_hole_starts(spec, host_plane=host, positions_are_local=positions_are_local),
|
||||
inward,
|
||||
session.adapter.body_span(session.body, inward) + 2.0,
|
||||
session.adapter.body_span(scoped_body or session.body, inward) + 2.0,
|
||||
)
|
||||
# 6. 从主体上减去工具实体,登记新主体并返回结果。
|
||||
# thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔
|
||||
@@ -74,5 +92,22 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
|
||||
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
|
||||
feature_id=node.feature_id,
|
||||
))
|
||||
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
|
||||
if scope_feature_id is None:
|
||||
result_body = session.adapter.cut(session.body, tool)
|
||||
members = None
|
||||
else:
|
||||
# CADFS ``scope`` identifies the target body member. Never apply the
|
||||
# cutter to the aggregate merely because unrelated live members share
|
||||
# the exported part; unchanged members remain exact body-graph nodes.
|
||||
result_body = session.adapter.cut(scoped_body, tool)
|
||||
members = {**session.body_members, scope_feature_id: result_body}
|
||||
body = None
|
||||
for member in members.values():
|
||||
body = session.adapter.combine(body, member)
|
||||
if body is None:
|
||||
raise ValueError("hole scope cut produced no active body")
|
||||
result_body = body
|
||||
session.register_body(
|
||||
node.feature_id, result_body, replay_node=node, body_members=members,
|
||||
)
|
||||
return session.result(node, diagnostics=diagnostics)
|
||||
|
||||
@@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..registry import atomic_executor
|
||||
from ..topology import FeaturePlanNode, FeatureResult
|
||||
from .common import _sweep_path
|
||||
from ..topology import FeaturePlanNode, FeatureResult, TopologyRecord
|
||||
from .common import _apply_primary_tool, _sweep_path
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
@@ -66,13 +66,18 @@ def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketc
|
||||
profile = sketch or session.sketches.get(str(node.sketch_id))
|
||||
if profile is None:
|
||||
raise ValueError("sweep has no resolved profile sketch")
|
||||
faces = session.adapter.faces_for_sketch(profile)
|
||||
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(profile)
|
||||
if len(faces) != 1:
|
||||
raise ValueError("sweep requires exactly one closed profile region")
|
||||
solid, topology_delta = session.adapter.sweep_with_topology_delta(
|
||||
faces[0], _sweep_path(node, session),
|
||||
is_frenet=bool(node.params.get("is_frenet", False)),
|
||||
)
|
||||
if node.atomic_id == "sweep_cut":
|
||||
# The PipeShell is a transient cutting tool. Its own builder history
|
||||
# cannot become selector provenance after the BRepAlgoAPI_Cut; only
|
||||
# the cut's exact target-side delta may survive registration.
|
||||
return _apply_primary_tool(node, session, solid, cutting=True)
|
||||
is_new_body = node.params.get("result_mode") == "new_body"
|
||||
body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid)
|
||||
# A union rebuilds topology, so the pipe-shell builder cannot prove the
|
||||
@@ -80,10 +85,37 @@ def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketc
|
||||
# subshape identity and may expose evidence for the new member.
|
||||
if session.body is not None and not is_new_body:
|
||||
topology_delta = None
|
||||
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||
topology_anchors: list[TopologyRecord] = []
|
||||
if topology_delta is not None:
|
||||
for index, spec in enumerate(source_anchor_specs):
|
||||
kind = spec.get("kind")
|
||||
if kind not in {"edge", "vertex"} or spec.get("value") is None:
|
||||
continue
|
||||
source_entity = spec.get("source_entity")
|
||||
source_entities = tuple(spec.get("source_entities") or ())
|
||||
if not isinstance(source_entity, tuple) and not source_entities:
|
||||
continue
|
||||
topology_anchors.append(TopologyRecord(
|
||||
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
|
||||
kind=kind,
|
||||
feature_id=node.feature_id,
|
||||
geometry={},
|
||||
value=spec["value"],
|
||||
source_entity=source_entity if isinstance(source_entity, tuple) else None,
|
||||
source_entities=source_entities,
|
||||
))
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
|
||||
topology_anchors=topology_anchors,
|
||||
)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("sweep_add")
|
||||
def _sweep_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
return _execute_sweep_add(node, session, sketch)
|
||||
|
||||
|
||||
@atomic_executor("sweep_cut")
|
||||
def _sweep_cut_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
return _execute_sweep_add(node, session, sketch)
|
||||
|
||||
@@ -40,7 +40,7 @@ def _execute_linear_pattern(
|
||||
execute: ExecutorFunction,
|
||||
) -> FeatureResult:
|
||||
# 线性阵列特征(pattern)执行入口:沿两个方向按数量与间距重放源特征形成阵列。
|
||||
|
||||
|
||||
# 1. 取源特征的 replay 定义(源特征按 feature_id 在会话中登记,供本阵列重放)。
|
||||
params = node.params
|
||||
sources = session.replay_sources(params.get("source_feature_ids") or [])
|
||||
|
||||
@@ -73,9 +73,9 @@ def _execute_cylinder(node: FeaturePlanNode, session: "ExecutionSession") -> Fea
|
||||
raise ValueError("cylinder_add axis must define origin_mm and direction")
|
||||
axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None
|
||||
# 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。
|
||||
solid = session.adapter.cylinder(radius, height, axis)
|
||||
solid, topology_delta = session.adapter.cylinder_with_topology_delta(radius, height, axis)
|
||||
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
||||
_register_added_solid(session, node, solid)
|
||||
_register_added_solid(session, node, solid, topology_delta=topology_delta)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
|
||||
@@ -73,3 +73,21 @@ def _execute_extrude_surface(node: FeaturePlanNode, session: "ExecutionSession")
|
||||
def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_extrude_surface(node, session)
|
||||
|
||||
|
||||
def _execute_loft_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
profile_ids = node.params.get("profile_sketch_ids") or []
|
||||
profiles: list[dict[str, Any]] = []
|
||||
for sketch_id in profile_ids:
|
||||
profile = session.sketches.get(str(sketch_id))
|
||||
if profile is None:
|
||||
raise ValueError(f"surface loft profile sketch {sketch_id!r} is not resolved")
|
||||
profiles.append(profile)
|
||||
surface_id = session.register_surface(node.feature_id, session.adapter.loft_surface(profiles))
|
||||
return session.result(node, include_body=False, surface_id=surface_id)
|
||||
|
||||
|
||||
@atomic_executor("loft_surface")
|
||||
def _loft_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_loft_surface(node, session)
|
||||
|
||||
@@ -8,6 +8,7 @@ selector resolution, never on executors.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .runtime_base import ExtentVector, FeatureExecutionError
|
||||
@@ -44,12 +45,29 @@ def _targeted_extent_vector(
|
||||
end_condition: dict[str, Any] | None = None,
|
||||
offset_mm: float | None = None,
|
||||
) -> ExtentVector:
|
||||
if session.body is None:
|
||||
reference = _extent_reference(node, end_condition) if condition != "through_next" else None
|
||||
source_vertex_point: Vector3 | None = None
|
||||
if condition == "up_to_vertex" and isinstance(reference, dict) and reference.get("kind") == "source_vertex":
|
||||
raw_point = reference.get("point_mm")
|
||||
if not (
|
||||
isinstance(raw_point, list)
|
||||
and len(raw_point) == 3
|
||||
and all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in raw_point)
|
||||
):
|
||||
raise FeatureExecutionError(
|
||||
"invalid_source_vertex_extent",
|
||||
"The source-vertex extent datum must contain one finite 3D point",
|
||||
extent=condition,
|
||||
)
|
||||
source_vertex_point = (float(raw_point[0]), float(raw_point[1]), float(raw_point[2]))
|
||||
elif session.body is None:
|
||||
raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition)
|
||||
if condition == "through_next":
|
||||
target = session.body
|
||||
elif source_vertex_point is not None:
|
||||
target = None
|
||||
else:
|
||||
reference = _extent_reference(node, end_condition)
|
||||
assert isinstance(reference, dict)
|
||||
resolution = session.resolve(reference)
|
||||
if resolution.status != "resolved" or resolution.record is None:
|
||||
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved")
|
||||
@@ -62,7 +80,7 @@ def _targeted_extent_vector(
|
||||
)
|
||||
target = resolution.record.value
|
||||
if condition == "up_to_vertex":
|
||||
target_point = session.adapter.vertex_coordinates(target)
|
||||
target_point = source_vertex_point or session.adapter.vertex_coordinates(target)
|
||||
projections = [
|
||||
vector_dot(vector_subtract(target_point, point), direction)
|
||||
for face in faces
|
||||
@@ -89,6 +107,19 @@ def _targeted_extent_vector(
|
||||
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
|
||||
except ValueError as error:
|
||||
message = str(error)
|
||||
if condition == "up_to_surface" and message == "extent target is not reached by every profile ray":
|
||||
# Do not reinterpret a partially hit finite face: that path
|
||||
# has explicit trimmed-solid semantics below. Only a wholly
|
||||
# unreachable planar face may terminate on its supporting
|
||||
# plane, and the adapter proves one positive, uniform
|
||||
# profile-to-plane distance before returning it.
|
||||
if not session.adapter.target_has_forward_intersection(target, faces, direction):
|
||||
try:
|
||||
distance = session.adapter.uniform_planar_supporting_surface_distance(target, faces, direction)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
return ExtentVector(vector_scale(direction, distance))
|
||||
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
|
||||
if condition in {"up_to_surface", "through_next"}:
|
||||
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
|
||||
|
||||
@@ -31,12 +31,14 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
|
||||
params_schema = raw.get("author_params_schema")
|
||||
injected_paths = raw.get("server_injected_paths")
|
||||
selector_policy = raw.get("selector_policy")
|
||||
nested_selector_policies = raw.get("nested_selector_policies") or {}
|
||||
runtime_capability = raw.get("runtime_capability")
|
||||
if (
|
||||
not isinstance(shape, dict)
|
||||
or not isinstance(params_schema, dict)
|
||||
or not isinstance(injected_paths, list)
|
||||
or not isinstance(selector_policy, dict)
|
||||
or not isinstance(nested_selector_policies, dict)
|
||||
):
|
||||
raise ValueError(f"operation contract is incomplete for {atomic_id}")
|
||||
if not isinstance(runtime_capability, dict) or not all(
|
||||
@@ -52,6 +54,8 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
|
||||
raise ValueError(f"operation author params are invalid for {atomic_id}")
|
||||
if not all(isinstance(name, str) and name in properties for name in required):
|
||||
raise ValueError(f"operation required params are invalid for {atomic_id}")
|
||||
if not all(isinstance(path, str) and isinstance(policy, dict) for path, policy in nested_selector_policies.items()):
|
||||
raise ValueError(f"operation nested selector policies are invalid for {atomic_id}")
|
||||
materialized_required = list(dict.fromkeys(required))
|
||||
for path in injected_paths:
|
||||
if not isinstance(path, str):
|
||||
@@ -67,6 +71,7 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
|
||||
"requires_sketch": shape.get("sketch") == "required",
|
||||
"selector_slot": selector_policy.get("slot"),
|
||||
"selector_token_kind": selector_policy.get("token_kind"),
|
||||
"nested_selector_policies": dict(nested_selector_policies),
|
||||
"runtime_capability": dict(runtime_capability),
|
||||
}
|
||||
return derived
|
||||
|
||||
@@ -409,6 +409,28 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle
|
||||
for key in ("x_dir", "y_dir", "normal"):
|
||||
if path_plane.get(key):
|
||||
path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad))
|
||||
elif isinstance(path, dict) and isinstance(path.get("segments"), list):
|
||||
# A source-only spatial sweep path has no common workplane. Its
|
||||
# captured points and directions are absolute, so replayed circular
|
||||
# pattern instances must rotate each geometric field independently.
|
||||
for segment in path["segments"]:
|
||||
if not isinstance(segment, dict):
|
||||
continue
|
||||
for key in ("start_mm", "end_mm", "center_mm"):
|
||||
value = segment.get(key)
|
||||
if isinstance(value, list) and len(value) == 3:
|
||||
segment[key] = _rotated_point(value, axis, angle_rad)
|
||||
points = segment.get("points_mm")
|
||||
if isinstance(points, list):
|
||||
segment["points_mm"] = [
|
||||
_rotated_point(point, axis, angle_rad)
|
||||
for point in points
|
||||
if isinstance(point, list) and len(point) == 3
|
||||
]
|
||||
for key in ("normal", "start_tangent_mm", "end_tangent_mm"):
|
||||
value = segment.get(key)
|
||||
if isinstance(value, list) and len(value) == 3:
|
||||
segment[key] = list(_rotated_vector(tuple(float(v) for v in value), axis, angle_rad))
|
||||
host = params.get("host_face")
|
||||
host_frame = host.get("frame") if isinstance(host, dict) else None
|
||||
positions_are_local = isinstance(host_frame, dict) and all(
|
||||
|
||||
@@ -4,17 +4,19 @@
|
||||
"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", "planar_imprint"],
|
||||
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "multi_source_regions", "planar_imprint"],
|
||||
"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"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"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"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"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"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_or_primary_add_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_from_face": {"atomic_id":"extrude_from_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"operation":{"enum":["add","cut"]},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"two_sided":{"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},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm","operation"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["derived_profile_face","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.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"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"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":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.1","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"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"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":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"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"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"loft_surface": {"atomic_id":"loft_surface","contract_version":"1.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":2,"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":["surface_shell"],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"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","open_path"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"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"},"result_mode":{"enum":["fuse","new_body"]},"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"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"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"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
|
||||
"sweep_cut": {"atomic_id":"sweep_cut","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"}},"required":["path"],"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","open_path"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"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"},"result_mode":{"enum":["fuse","new_body"]},"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},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"symmetric_direct_prism_two_sided_up_to_surface_cap_pair","requires_immediate_owner":true},"params.reverse_end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"symmetric_direct_prism_two_sided_up_to_surface_cap_pair","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"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},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_or_primary_add_blind_extrude_cap","requires_immediate_owner":true}},"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"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
|
||||
"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"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
|
||||
"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"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"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"},"result_mode":{"enum":["fuse","new_body"]}},"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"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
@@ -33,6 +35,8 @@
|
||||
"thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"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},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","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"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"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","reference_plane_nonzero_normal"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"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}},"required":["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","reference_axis_nonzero_direction"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"reference_point": {"atomic_id":"reference_point","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"point_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["point_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":["reference_point_finite"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"assign_variable": {"atomic_id":"assign_variable","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":256},"value":{"type":"number"},"value_kind":{"enum":["any","length"]}},"required":["name","value","value_kind"],"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":["assign_variable_finite"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"hole_wizard": {
|
||||
"runtime_capability": {"body_mutating": true, "requires_active_body": true, "replayable": true, "requires_selector": false, "open_profile_ok": false},
|
||||
"atomic_id": "hole_wizard",
|
||||
@@ -67,11 +71,11 @@
|
||||
"fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"tangent_propagation":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"shell": {"atomic_id":"shell","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"inward":{"type":"boolean"},"target_feature_id":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"}},"required":["thickness_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_id","token_kind":"body","min_items":0,"max_items":1,"snapshot_bound":true},"semantic_preflight":["requires_active_solid","selected_faces_exist","shell_target_body_exists"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.2","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"},"targetless_body_set":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"transform_bodies": {
|
||||
"runtime_capability": {"body_mutating": false, "requires_active_body": false, "replayable": false, "requires_selector": false, "open_profile_ok": false},
|
||||
"atomic_id": "transform_bodies",
|
||||
"contract_version": "3.3",
|
||||
"contract_version": "3.4",
|
||||
"fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "forbidden"},
|
||||
"author_params_schema": {
|
||||
"type": "object",
|
||||
@@ -79,6 +83,7 @@
|
||||
"source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "minItems": 1, "maxItems": 16, "uniqueItems": true},
|
||||
"pattern_instance_refs": {"type": "array", "items": {"type": "object", "properties": {"pattern_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "instance_index": {"type": "integer", "minimum": 1}}, "required": ["pattern_feature_id", "source_feature_id", "instance_index"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true},
|
||||
"transform_copy_refs": {"type": "array", "items": {"type": "object", "properties": {"transform_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["transform_feature_id", "source_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true},
|
||||
"source_member_aliases": {"type": "array", "items": {"type": "object", "properties": {"source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "active_member_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["source_feature_id", "active_member_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 16, "uniqueItems": true},
|
||||
"transform": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -129,6 +134,9 @@
|
||||
"analytic_contours": {
|
||||
"summary": "Closed executable line, arc, circle, ellipse and interpolation B-spline contours. An ellipse retains its local center, radii and major-axis direction. Executable B-splines preserve ordered interpolation points; periodic contours repeat their first point only to state closure and are interpolated from unique points. A non-periodic two-point B-spline is permitted only with explicit, strictly increasing parameters and both endpoint tangents, giving the OCP interpolation contract sufficient curvature information; a two-point B-spline without those fields is invalid rather than a line fallback. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry."
|
||||
},
|
||||
"multi_source_regions": {
|
||||
"summary": "A direct qUnion of two or more closed qSketchRegion sources on exactly one shared workplane. Every child profile is solved as an independent region set before union, so cross-source nested contours are never reclassified as holes. This is profile geometry only and carries no single-source topology anchor."
|
||||
},
|
||||
"planar_imprint": {
|
||||
"summary": "An exact planar arrangement derived from one sketch's original analytic entities. Each selected region retains an IMPRINT source edge, its face side, and optional INTERSECT vertex order and fragment side. The adapter splits a bounded support face with OCC and rejects non-unique or unbounded selections; it never samples curves into a polygon or substitutes an unrelated sketch contour."
|
||||
}
|
||||
|
||||
@@ -1,343 +1,343 @@
|
||||
"""
|
||||
CDSL → STEP 重建管道
|
||||
====================
|
||||
优先: CDSL → capability planner → session runtime → STEP (engine=cdsl_only)
|
||||
回退: CDSL + compiler_context → translator
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
|
||||
from .llm_compiler import compile_cdsl
|
||||
from .llm_engine import run_engine_plan
|
||||
from .translator import generate_build123d_code, normalize_to_ir
|
||||
from .legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized
|
||||
except ImportError: # 允许直接 python rebuild.py
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
|
||||
from llm_compiler import compile_cdsl
|
||||
from llm_engine import run_engine_plan
|
||||
from translator import generate_build123d_code, normalize_to_ir
|
||||
from legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized
|
||||
|
||||
|
||||
def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None,
|
||||
force_exact: bool = False) -> dict[str, Any]:
|
||||
"""主重建入口。
|
||||
|
||||
优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。
|
||||
回退:CDSL + compiler_context 的 translator 路径。
|
||||
"""
|
||||
# Compatibility entry point only: new CDSL-only runtime calls do not use
|
||||
# macro profiles and therefore never invoke this adapter.
|
||||
cdsl = lower_legacy_profiles(cdsl)
|
||||
sketches = cdsl.get("geometry", {}).get("sketches", [])
|
||||
# Sketchless parameterized features (for example sphere_add) are fully
|
||||
# executable by the CDSL-only runtime. ``all([])`` deliberately keeps
|
||||
# that path available rather than forcing an unavailable legacy fallback.
|
||||
all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches)
|
||||
|
||||
cdsl_only_error: Exception | None = None
|
||||
if all_drawable and not force_exact:
|
||||
try:
|
||||
return run_cdsl_only(cdsl, out_step, gold_step=gold_step)
|
||||
except Exception as e:
|
||||
cdsl_only_error = e
|
||||
|
||||
# 加载 compiler_context(后备路径)
|
||||
ctx = None
|
||||
if ctx_file and ctx_file.exists():
|
||||
with open(ctx_file, "r", encoding="utf-8") as f:
|
||||
ctx = json.load(f)
|
||||
if ctx is None:
|
||||
part_id = cdsl.get("part_id", "")
|
||||
sw_json = out_step.parent / f"{part_id}.solidworks_rebuild_extract.json"
|
||||
if sw_json.exists():
|
||||
with open(sw_json, "r", encoding="utf-8") as f:
|
||||
sw_data = json.load(f)
|
||||
ir = normalize_to_ir(sw_data)
|
||||
ctx = {
|
||||
"version": ir.get("version", "ir-0.1"),
|
||||
"metadata": ir.get("metadata", {}),
|
||||
"sketches": ir.get("sketches", []),
|
||||
"operations": ir.get("operations", []),
|
||||
"references": ir.get("references", []),
|
||||
"validation_hints": ir.get("validation_hints", {}),
|
||||
}
|
||||
if ctx is None and not (cdsl.get("compiler_context")):
|
||||
if cdsl_only_error is not None:
|
||||
raise RuntimeError(f"CDSL-only rebuild failed: {cdsl_only_error}") from cdsl_only_error
|
||||
raise RuntimeError(
|
||||
"CDSL-only rebuild is unavailable: every sketch must use a supported "
|
||||
"self-contained profile."
|
||||
)
|
||||
|
||||
if ctx is not None:
|
||||
cdsl["compiler_context"] = ctx
|
||||
|
||||
has_profiled = any(
|
||||
s.get("profile") or s.get("profile_from") or s.get("entities") or s.get("contour_edges_mm")
|
||||
for s in sketches
|
||||
)
|
||||
if has_profiled and not force_exact:
|
||||
try:
|
||||
return _run_parameterized(cdsl, out_step, gold_step=gold_step)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f" [WARN] parameterized path failed: {e}, falling back to exact")
|
||||
|
||||
return _run_exact(cdsl, out_step, gold_step)
|
||||
|
||||
|
||||
def _sketch_is_cdsl_drawable(sketch: dict[str, Any]) -> bool:
|
||||
"""草图是否可仅凭 CDSL profile 展开(不靠 compiler_context 注坐标)。"""
|
||||
if sketch.get("profile_from"):
|
||||
return True
|
||||
profile = sketch.get("profile")
|
||||
if not profile:
|
||||
return bool(sketch.get("entities") or sketch.get("contour_edges_mm") or sketch.get("contour_regions_mm"))
|
||||
ptype = profile.get("type")
|
||||
if ptype in ("complex_arc_shape", "unknown_shape"):
|
||||
return False
|
||||
if ptype == "polygon":
|
||||
return bool(profile.get("vertices"))
|
||||
return ptype in SHAPE_GENERATORS
|
||||
|
||||
|
||||
def run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
|
||||
"""Pure semantic CDSL path with no compiler_context fallback."""
|
||||
t0 = time.time()
|
||||
try:
|
||||
from .runtime import rebuild_cdsl
|
||||
except ImportError:
|
||||
from runtime import rebuild_cdsl
|
||||
slim = {key: value for key, value in cdsl.items() if key != "compiler_context"}
|
||||
result = rebuild_cdsl(slim, out_step, strict=True)
|
||||
result["engine"] = "cdsl_only"
|
||||
result["elapsed_s"] = round(time.time() - t0, 1)
|
||||
result.setdefault("log", []).append("cdsl_only: capability planner + session runtime (no compiler_context)")
|
||||
if gold_step and gold_step.exists():
|
||||
result["gold_step"] = str(gold_step)
|
||||
return result
|
||||
|
||||
|
||||
def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
pack = compile_cdsl({k: v for k, v in cdsl.items() if k != "compiler_context"})
|
||||
pack.pop("compiler_context", None)
|
||||
return pack
|
||||
|
||||
|
||||
def run_engine(pack: dict[str, Any], out_step: Path) -> dict[str, Any]:
|
||||
return run_engine_plan(pack, out_step)
|
||||
|
||||
|
||||
def compare_with_gold(gold_step: Path, rebuilt_step: Path) -> dict[str, Any]:
|
||||
from build123d import import_step
|
||||
import math, random, time
|
||||
gold = import_step(str(gold_step))
|
||||
rebuilt = import_step(str(rebuilt_step))
|
||||
gv = float(gold.volume)
|
||||
rv = float(rebuilt.volume)
|
||||
rel_err = abs(rv - gv) / gv * 100 if gv > 0 else 0
|
||||
gb = gold.bounding_box()
|
||||
rb = rebuilt.bounding_box()
|
||||
bbox_delta = max(
|
||||
abs(gb.min.X - rb.min.X), abs(gb.min.Y - rb.min.Y),
|
||||
abs(gb.min.Z - rb.min.Z), abs(gb.max.X - rb.max.X),
|
||||
abs(gb.max.Y - rb.max.Y), abs(gb.max.Z - rb.max.Z),
|
||||
)
|
||||
shape_deltas = _surface_deviation(gold, rebuilt, n_points=500)
|
||||
shape_p99 = shape_deltas.get("shape_p99_delta_mm", 999)
|
||||
shape_median = shape_deltas.get("shape_median_delta_mm", 999)
|
||||
over_pct = shape_deltas.get("shape_over_0.5mm_pct", 100)
|
||||
|
||||
# 形状一致性分级(形状为主,体积/包围盒仅作参考)
|
||||
if shape_p99 <= 1.0:
|
||||
shape_grade = "A" # 完美形状匹配
|
||||
elif shape_p99 <= 6.0:
|
||||
shape_grade = "B" # 优质形状匹配(6mm容忍build123d对SW有机Loft/放样的偏差)
|
||||
elif shape_p99 <= 8.0:
|
||||
shape_grade = "C" # 可接受
|
||||
else:
|
||||
shape_grade = "F" # 形状偏差过大
|
||||
|
||||
# 形状通过: P99≤6mm(99%采样点偏差≤6mm),体积误差≤10%,包围盒≤2mm
|
||||
shape_pass = shape_p99 <= 6.0
|
||||
vol_sane = rel_err <= 10.0
|
||||
bbox_sane = bbox_delta <= 2.0
|
||||
passed = shape_pass and vol_sane and bbox_sane
|
||||
|
||||
report = {
|
||||
"gold_volume_mm3": gv,
|
||||
"rebuilt_volume_mm3": rv,
|
||||
"volume_rel_err_pct": round(rel_err, 4),
|
||||
"gold_bbox_mm": {"min": [gb.min.X, gb.min.Y, gb.min.Z],
|
||||
"max": [gb.max.X, gb.max.Y, gb.max.Z]},
|
||||
"rebuilt_bbox_mm": {"min": [rb.min.X, rb.min.Y, rb.min.Z],
|
||||
"max": [rb.max.X, rb.max.Y, rb.max.Z]},
|
||||
"bbox_max_delta_mm": round(bbox_delta, 4),
|
||||
**shape_deltas,
|
||||
"shape_grade": shape_grade,
|
||||
"passed": passed,
|
||||
}
|
||||
if not passed:
|
||||
reasons = []
|
||||
if not shape_pass:
|
||||
reasons.append(f"shape_p99={shape_p99:.1f}mm > 6mm")
|
||||
if not vol_sane:
|
||||
reasons.append(f"vol_err={rel_err:.1f}% > 10%")
|
||||
if not bbox_sane:
|
||||
reasons.append(f"bbox_delta={bbox_delta:.1f}mm > 2.0mm")
|
||||
report["fail_reasons"] = " | ".join(reasons)
|
||||
return report
|
||||
|
||||
|
||||
def _surface_deviation(gold, rebuilt, n_points: int = 500) -> dict[str, Any]:
|
||||
"""用BRepExtrema计算gold和rebuilt表面顶点间的精确距离偏差"""
|
||||
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex
|
||||
from OCP.gp import gp_Pnt
|
||||
import random, math
|
||||
|
||||
random.seed(42)
|
||||
|
||||
def sample_points(solid, max_n):
|
||||
pts = []
|
||||
for v in solid.vertices():
|
||||
pts.append((float(v.X), float(v.Y), float(v.Z)))
|
||||
for e in solid.edges():
|
||||
try:
|
||||
c = e.center()
|
||||
pts.append((float(c.X), float(c.Y), float(c.Z)))
|
||||
except Exception:
|
||||
pass
|
||||
if len(pts) > max_n:
|
||||
pts = random.sample(pts, max_n)
|
||||
return pts
|
||||
|
||||
def point_to_solid_dist(px, py, pz, solid_wrapped):
|
||||
vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(px, py, pz)).Vertex()
|
||||
ds = BRepExtrema_DistShapeShape()
|
||||
ds.LoadS1(vertex)
|
||||
ds.LoadS2(solid_wrapped)
|
||||
ds.Perform()
|
||||
if ds.IsDone() and ds.NbSolution() > 0:
|
||||
return ds.Value()
|
||||
return float('inf')
|
||||
|
||||
gw = gold.wrapped
|
||||
rw = rebuilt.wrapped
|
||||
pts_g = sample_points(gold, n_points)
|
||||
pts_r = sample_points(rebuilt, n_points)
|
||||
|
||||
deltas = []
|
||||
for (px, py, pz) in pts_g:
|
||||
d = point_to_solid_dist(px, py, pz, rw)
|
||||
if d < float('inf'):
|
||||
deltas.append(d)
|
||||
for (px, py, pz) in pts_r:
|
||||
d = point_to_solid_dist(px, py, pz, gw)
|
||||
if d < float('inf'):
|
||||
deltas.append(d)
|
||||
|
||||
if not deltas:
|
||||
return {"shape_mean_delta_mm": 0.0, "shape_max_delta_mm": 0.0,
|
||||
"shape_median_delta_mm": 0.0, "shape_n_samples": 0}
|
||||
|
||||
deltas.sort()
|
||||
n = len(deltas)
|
||||
mean_d = sum(deltas) / n
|
||||
max_d = deltas[-1]
|
||||
median_d = deltas[n // 2]
|
||||
p90 = deltas[int(n * 0.9)] if n > 10 else max_d
|
||||
p95 = deltas[int(n * 0.95)] if n > 20 else max_d
|
||||
p99 = deltas[int(n * 0.99)] if n > 100 else max_d
|
||||
|
||||
over_01mm = sum(1 for d in deltas if d > 0.01)
|
||||
over_05mm = sum(1 for d in deltas if d > 0.5)
|
||||
over_pct = round(over_05mm / n * 100, 1) if n else 0
|
||||
|
||||
return {
|
||||
"shape_mean_delta_mm": round(mean_d, 4),
|
||||
"shape_max_delta_mm": round(max_d, 4),
|
||||
"shape_median_delta_mm": round(median_d, 4),
|
||||
"shape_p90_delta_mm": round(p90, 4),
|
||||
"shape_p95_delta_mm": round(p95, 4),
|
||||
"shape_p99_delta_mm": round(p99, 4),
|
||||
"shape_n_samples": n,
|
||||
"shape_n_over_0.01mm": over_01mm,
|
||||
"shape_n_over_0.5mm": over_05mm,
|
||||
"shape_over_0.5mm_pct": over_pct,
|
||||
}
|
||||
|
||||
|
||||
# 保留旧版本的_sample_surface_points清理掉
|
||||
# (下面的不再需要,新逻辑已在_surface_deviation中实现)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CLI(便携:显式路径,无项目目录假设)
|
||||
# ===========================================================================
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser(description="CDSL -> STEP rebuild (portable engine)")
|
||||
ap.add_argument("--cdsl", type=Path, required=True, help="CDSL JSON path")
|
||||
ap.add_argument("--out", type=Path, required=True, help="output STEP path")
|
||||
ap.add_argument("--gold", type=Path, default=None, help="optional gold STEP")
|
||||
ap.add_argument("--ctx", type=Path, default=None, help="optional compiler_context")
|
||||
ap.add_argument("--force-exact", action="store_true")
|
||||
ap.add_argument("--report", type=Path, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
cdsl = json.loads(args.cdsl.read_text(encoding="utf-8"))
|
||||
out_step = args.out
|
||||
out_step.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Rebuild: {args.cdsl} -> {out_step}")
|
||||
try:
|
||||
result = run_rebuild(
|
||||
cdsl, out_step, ctx_file=args.ctx, gold_step=args.gold, force_exact=args.force_exact
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"REBUILD ERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
print(f" engine={result.get('engine')} volume={result['volume_mm3']:.2f} mm3")
|
||||
report = {
|
||||
"cdsl_path": str(args.cdsl),
|
||||
"rebuilt_step": str(out_step),
|
||||
"engine_result": result,
|
||||
}
|
||||
status = "OK"
|
||||
if args.gold and args.gold.exists():
|
||||
comp = compare_with_gold(args.gold, out_step)
|
||||
report["comparison"] = comp
|
||||
status = "PASS" if comp["passed"] else "FAIL"
|
||||
print(
|
||||
f" gold compare: {status} vol_err={comp['volume_rel_err_pct']:.2f}% "
|
||||
f"shape={comp.get('shape_grade')} p99={comp.get('shape_p99_delta_mm')}"
|
||||
)
|
||||
report_path = args.report or out_step.with_suffix(".rebuild_report.json")
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
||||
print(f"Report: {report_path}")
|
||||
print(f"Final: {status}")
|
||||
if args.gold and args.gold.exists() and not report.get("comparison", {}).get("passed", True):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"""
|
||||
CDSL → STEP 重建管道
|
||||
====================
|
||||
优先: CDSL → capability planner → session runtime → STEP (engine=cdsl_only)
|
||||
回退: CDSL + compiler_context → translator
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
|
||||
from .llm_compiler import compile_cdsl
|
||||
from .llm_engine import run_engine_plan
|
||||
from .translator import generate_build123d_code, normalize_to_ir
|
||||
from .legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized
|
||||
except ImportError: # 允许直接 python rebuild.py
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
|
||||
from llm_compiler import compile_cdsl
|
||||
from llm_engine import run_engine_plan
|
||||
from translator import generate_build123d_code, normalize_to_ir
|
||||
from legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized
|
||||
|
||||
|
||||
def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None,
|
||||
force_exact: bool = False) -> dict[str, Any]:
|
||||
"""主重建入口。
|
||||
|
||||
优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。
|
||||
回退:CDSL + compiler_context 的 translator 路径。
|
||||
"""
|
||||
# Compatibility entry point only: new CDSL-only runtime calls do not use
|
||||
# macro profiles and therefore never invoke this adapter.
|
||||
cdsl = lower_legacy_profiles(cdsl)
|
||||
sketches = cdsl.get("geometry", {}).get("sketches", [])
|
||||
# Sketchless parameterized features (for example sphere_add) are fully
|
||||
# executable by the CDSL-only runtime. ``all([])`` deliberately keeps
|
||||
# that path available rather than forcing an unavailable legacy fallback.
|
||||
all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches)
|
||||
|
||||
cdsl_only_error: Exception | None = None
|
||||
if all_drawable and not force_exact:
|
||||
try:
|
||||
return run_cdsl_only(cdsl, out_step, gold_step=gold_step)
|
||||
except Exception as e:
|
||||
cdsl_only_error = e
|
||||
|
||||
# 加载 compiler_context(后备路径)
|
||||
ctx = None
|
||||
if ctx_file and ctx_file.exists():
|
||||
with open(ctx_file, "r", encoding="utf-8") as f:
|
||||
ctx = json.load(f)
|
||||
if ctx is None:
|
||||
part_id = cdsl.get("part_id", "")
|
||||
sw_json = out_step.parent / f"{part_id}.solidworks_rebuild_extract.json"
|
||||
if sw_json.exists():
|
||||
with open(sw_json, "r", encoding="utf-8") as f:
|
||||
sw_data = json.load(f)
|
||||
ir = normalize_to_ir(sw_data)
|
||||
ctx = {
|
||||
"version": ir.get("version", "ir-0.1"),
|
||||
"metadata": ir.get("metadata", {}),
|
||||
"sketches": ir.get("sketches", []),
|
||||
"operations": ir.get("operations", []),
|
||||
"references": ir.get("references", []),
|
||||
"validation_hints": ir.get("validation_hints", {}),
|
||||
}
|
||||
if ctx is None and not (cdsl.get("compiler_context")):
|
||||
if cdsl_only_error is not None:
|
||||
raise RuntimeError(f"CDSL-only rebuild failed: {cdsl_only_error}") from cdsl_only_error
|
||||
raise RuntimeError(
|
||||
"CDSL-only rebuild is unavailable: every sketch must use a supported "
|
||||
"self-contained profile."
|
||||
)
|
||||
|
||||
if ctx is not None:
|
||||
cdsl["compiler_context"] = ctx
|
||||
|
||||
has_profiled = any(
|
||||
s.get("profile") or s.get("profile_from") or s.get("entities") or s.get("contour_edges_mm")
|
||||
for s in sketches
|
||||
)
|
||||
if has_profiled and not force_exact:
|
||||
try:
|
||||
return _run_parameterized(cdsl, out_step, gold_step=gold_step)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f" [WARN] parameterized path failed: {e}, falling back to exact")
|
||||
|
||||
return _run_exact(cdsl, out_step, gold_step)
|
||||
|
||||
|
||||
def _sketch_is_cdsl_drawable(sketch: dict[str, Any]) -> bool:
|
||||
"""草图是否可仅凭 CDSL profile 展开(不靠 compiler_context 注坐标)。"""
|
||||
if sketch.get("profile_from"):
|
||||
return True
|
||||
profile = sketch.get("profile")
|
||||
if not profile:
|
||||
return bool(sketch.get("entities") or sketch.get("contour_edges_mm") or sketch.get("contour_regions_mm"))
|
||||
ptype = profile.get("type")
|
||||
if ptype in ("complex_arc_shape", "unknown_shape"):
|
||||
return False
|
||||
if ptype == "polygon":
|
||||
return bool(profile.get("vertices"))
|
||||
return ptype in SHAPE_GENERATORS
|
||||
|
||||
|
||||
def run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
|
||||
"""Pure semantic CDSL path with no compiler_context fallback."""
|
||||
t0 = time.time()
|
||||
try:
|
||||
from .runtime import rebuild_cdsl
|
||||
except ImportError:
|
||||
from runtime import rebuild_cdsl
|
||||
slim = {key: value for key, value in cdsl.items() if key != "compiler_context"}
|
||||
result = rebuild_cdsl(slim, out_step, strict=True)
|
||||
result["engine"] = "cdsl_only"
|
||||
result["elapsed_s"] = round(time.time() - t0, 1)
|
||||
result.setdefault("log", []).append("cdsl_only: capability planner + session runtime (no compiler_context)")
|
||||
if gold_step and gold_step.exists():
|
||||
result["gold_step"] = str(gold_step)
|
||||
return result
|
||||
|
||||
|
||||
def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
pack = compile_cdsl({k: v for k, v in cdsl.items() if k != "compiler_context"})
|
||||
pack.pop("compiler_context", None)
|
||||
return pack
|
||||
|
||||
|
||||
def run_engine(pack: dict[str, Any], out_step: Path) -> dict[str, Any]:
|
||||
return run_engine_plan(pack, out_step)
|
||||
|
||||
|
||||
def compare_with_gold(gold_step: Path, rebuilt_step: Path) -> dict[str, Any]:
|
||||
from build123d import import_step
|
||||
import math, random, time
|
||||
gold = import_step(str(gold_step))
|
||||
rebuilt = import_step(str(rebuilt_step))
|
||||
gv = float(gold.volume)
|
||||
rv = float(rebuilt.volume)
|
||||
rel_err = abs(rv - gv) / gv * 100 if gv > 0 else 0
|
||||
gb = gold.bounding_box()
|
||||
rb = rebuilt.bounding_box()
|
||||
bbox_delta = max(
|
||||
abs(gb.min.X - rb.min.X), abs(gb.min.Y - rb.min.Y),
|
||||
abs(gb.min.Z - rb.min.Z), abs(gb.max.X - rb.max.X),
|
||||
abs(gb.max.Y - rb.max.Y), abs(gb.max.Z - rb.max.Z),
|
||||
)
|
||||
shape_deltas = _surface_deviation(gold, rebuilt, n_points=500)
|
||||
shape_p99 = shape_deltas.get("shape_p99_delta_mm", 999)
|
||||
shape_median = shape_deltas.get("shape_median_delta_mm", 999)
|
||||
over_pct = shape_deltas.get("shape_over_0.5mm_pct", 100)
|
||||
|
||||
# 形状一致性分级(形状为主,体积/包围盒仅作参考)
|
||||
if shape_p99 <= 1.0:
|
||||
shape_grade = "A" # 完美形状匹配
|
||||
elif shape_p99 <= 6.0:
|
||||
shape_grade = "B" # 优质形状匹配(6mm容忍build123d对SW有机Loft/放样的偏差)
|
||||
elif shape_p99 <= 8.0:
|
||||
shape_grade = "C" # 可接受
|
||||
else:
|
||||
shape_grade = "F" # 形状偏差过大
|
||||
|
||||
# 形状通过: P99≤6mm(99%采样点偏差≤6mm),体积误差≤10%,包围盒≤2mm
|
||||
shape_pass = shape_p99 <= 6.0
|
||||
vol_sane = rel_err <= 10.0
|
||||
bbox_sane = bbox_delta <= 2.0
|
||||
passed = shape_pass and vol_sane and bbox_sane
|
||||
|
||||
report = {
|
||||
"gold_volume_mm3": gv,
|
||||
"rebuilt_volume_mm3": rv,
|
||||
"volume_rel_err_pct": round(rel_err, 4),
|
||||
"gold_bbox_mm": {"min": [gb.min.X, gb.min.Y, gb.min.Z],
|
||||
"max": [gb.max.X, gb.max.Y, gb.max.Z]},
|
||||
"rebuilt_bbox_mm": {"min": [rb.min.X, rb.min.Y, rb.min.Z],
|
||||
"max": [rb.max.X, rb.max.Y, rb.max.Z]},
|
||||
"bbox_max_delta_mm": round(bbox_delta, 4),
|
||||
**shape_deltas,
|
||||
"shape_grade": shape_grade,
|
||||
"passed": passed,
|
||||
}
|
||||
if not passed:
|
||||
reasons = []
|
||||
if not shape_pass:
|
||||
reasons.append(f"shape_p99={shape_p99:.1f}mm > 6mm")
|
||||
if not vol_sane:
|
||||
reasons.append(f"vol_err={rel_err:.1f}% > 10%")
|
||||
if not bbox_sane:
|
||||
reasons.append(f"bbox_delta={bbox_delta:.1f}mm > 2.0mm")
|
||||
report["fail_reasons"] = " | ".join(reasons)
|
||||
return report
|
||||
|
||||
|
||||
def _surface_deviation(gold, rebuilt, n_points: int = 500) -> dict[str, Any]:
|
||||
"""用BRepExtrema计算gold和rebuilt表面顶点间的精确距离偏差"""
|
||||
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex
|
||||
from OCP.gp import gp_Pnt
|
||||
import random, math
|
||||
|
||||
random.seed(42)
|
||||
|
||||
def sample_points(solid, max_n):
|
||||
pts = []
|
||||
for v in solid.vertices():
|
||||
pts.append((float(v.X), float(v.Y), float(v.Z)))
|
||||
for e in solid.edges():
|
||||
try:
|
||||
c = e.center()
|
||||
pts.append((float(c.X), float(c.Y), float(c.Z)))
|
||||
except Exception:
|
||||
pass
|
||||
if len(pts) > max_n:
|
||||
pts = random.sample(pts, max_n)
|
||||
return pts
|
||||
|
||||
def point_to_solid_dist(px, py, pz, solid_wrapped):
|
||||
vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(px, py, pz)).Vertex()
|
||||
ds = BRepExtrema_DistShapeShape()
|
||||
ds.LoadS1(vertex)
|
||||
ds.LoadS2(solid_wrapped)
|
||||
ds.Perform()
|
||||
if ds.IsDone() and ds.NbSolution() > 0:
|
||||
return ds.Value()
|
||||
return float('inf')
|
||||
|
||||
gw = gold.wrapped
|
||||
rw = rebuilt.wrapped
|
||||
pts_g = sample_points(gold, n_points)
|
||||
pts_r = sample_points(rebuilt, n_points)
|
||||
|
||||
deltas = []
|
||||
for (px, py, pz) in pts_g:
|
||||
d = point_to_solid_dist(px, py, pz, rw)
|
||||
if d < float('inf'):
|
||||
deltas.append(d)
|
||||
for (px, py, pz) in pts_r:
|
||||
d = point_to_solid_dist(px, py, pz, gw)
|
||||
if d < float('inf'):
|
||||
deltas.append(d)
|
||||
|
||||
if not deltas:
|
||||
return {"shape_mean_delta_mm": 0.0, "shape_max_delta_mm": 0.0,
|
||||
"shape_median_delta_mm": 0.0, "shape_n_samples": 0}
|
||||
|
||||
deltas.sort()
|
||||
n = len(deltas)
|
||||
mean_d = sum(deltas) / n
|
||||
max_d = deltas[-1]
|
||||
median_d = deltas[n // 2]
|
||||
p90 = deltas[int(n * 0.9)] if n > 10 else max_d
|
||||
p95 = deltas[int(n * 0.95)] if n > 20 else max_d
|
||||
p99 = deltas[int(n * 0.99)] if n > 100 else max_d
|
||||
|
||||
over_01mm = sum(1 for d in deltas if d > 0.01)
|
||||
over_05mm = sum(1 for d in deltas if d > 0.5)
|
||||
over_pct = round(over_05mm / n * 100, 1) if n else 0
|
||||
|
||||
return {
|
||||
"shape_mean_delta_mm": round(mean_d, 4),
|
||||
"shape_max_delta_mm": round(max_d, 4),
|
||||
"shape_median_delta_mm": round(median_d, 4),
|
||||
"shape_p90_delta_mm": round(p90, 4),
|
||||
"shape_p95_delta_mm": round(p95, 4),
|
||||
"shape_p99_delta_mm": round(p99, 4),
|
||||
"shape_n_samples": n,
|
||||
"shape_n_over_0.01mm": over_01mm,
|
||||
"shape_n_over_0.5mm": over_05mm,
|
||||
"shape_over_0.5mm_pct": over_pct,
|
||||
}
|
||||
|
||||
|
||||
# 保留旧版本的_sample_surface_points清理掉
|
||||
# (下面的不再需要,新逻辑已在_surface_deviation中实现)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CLI(便携:显式路径,无项目目录假设)
|
||||
# ===========================================================================
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser(description="CDSL -> STEP rebuild (portable engine)")
|
||||
ap.add_argument("--cdsl", type=Path, required=True, help="CDSL JSON path")
|
||||
ap.add_argument("--out", type=Path, required=True, help="output STEP path")
|
||||
ap.add_argument("--gold", type=Path, default=None, help="optional gold STEP")
|
||||
ap.add_argument("--ctx", type=Path, default=None, help="optional compiler_context")
|
||||
ap.add_argument("--force-exact", action="store_true")
|
||||
ap.add_argument("--report", type=Path, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
cdsl = json.loads(args.cdsl.read_text(encoding="utf-8"))
|
||||
out_step = args.out
|
||||
out_step.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Rebuild: {args.cdsl} -> {out_step}")
|
||||
try:
|
||||
result = run_rebuild(
|
||||
cdsl, out_step, ctx_file=args.ctx, gold_step=args.gold, force_exact=args.force_exact
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"REBUILD ERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
print(f" engine={result.get('engine')} volume={result['volume_mm3']:.2f} mm3")
|
||||
report = {
|
||||
"cdsl_path": str(args.cdsl),
|
||||
"rebuilt_step": str(out_step),
|
||||
"engine_result": result,
|
||||
}
|
||||
status = "OK"
|
||||
if args.gold and args.gold.exists():
|
||||
comp = compare_with_gold(args.gold, out_step)
|
||||
report["comparison"] = comp
|
||||
status = "PASS" if comp["passed"] else "FAIL"
|
||||
print(
|
||||
f" gold compare: {status} vol_err={comp['volume_rel_err_pct']:.2f}% "
|
||||
f"shape={comp.get('shape_grade')} p99={comp.get('shape_p99_delta_mm')}"
|
||||
)
|
||||
report_path = args.report or out_step.with_suffix(".rebuild_report.json")
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
||||
print(f"Report: {report_path}")
|
||||
print(f"Final: {status}")
|
||||
if args.gold and args.gold.exists() and not report.get("comparison", {}).get("passed", True):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -29,10 +29,10 @@ class AtomicExecutor(Protocol):
|
||||
#: at import time instead of surfacing as an unknown-atomic blocker later.
|
||||
ALL_ATOMIC_IDS = frozenset({
|
||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
|
||||
"extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add",
|
||||
"extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "loft_surface", "sweep_add", "sweep_cut",
|
||||
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
|
||||
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
|
||||
"reference_plane", "reference_axis",
|
||||
"reference_plane", "reference_axis", "reference_point", "assign_variable",
|
||||
"hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror",
|
||||
"pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies",
|
||||
"thread_add", "thread_cut",
|
||||
|
||||
@@ -20,6 +20,7 @@ working.
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -27,6 +28,7 @@ from . import executors # noqa: F401 (importing performs executor registration
|
||||
from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
|
||||
# Historical private name still imported by the test suite.
|
||||
from .executors.primitives import _execute_box # noqa: F401
|
||||
from .executors.common import _selector_edges # noqa: F401
|
||||
from .extents import (
|
||||
_extent_reference,
|
||||
_extent_vectors,
|
||||
@@ -73,6 +75,9 @@ from .topology import (
|
||||
SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry,
|
||||
)
|
||||
|
||||
# Historical private imports used by integration tests and local diagnostics.
|
||||
_execute_node = execute_node
|
||||
|
||||
__all__ = [
|
||||
"ALL_ATOMIC_IDS",
|
||||
"EXECUTORS",
|
||||
@@ -85,7 +90,10 @@ __all__ = [
|
||||
"GeometryAdapter",
|
||||
"RuntimeDiagnostic",
|
||||
"RuntimeExecutionError",
|
||||
"IncrementalCdslExecution",
|
||||
"analyze_cdsl",
|
||||
"prepare_cdsl_execution",
|
||||
"finalize_cdsl_execution",
|
||||
"execute_node",
|
||||
"rebuild_cdsl",
|
||||
]
|
||||
@@ -101,8 +109,83 @@ def analyze_cdsl(cdsl: dict[str, Any]):
|
||||
return analyzer.analyze(resolved, sketch_errors=sketch_errors)
|
||||
|
||||
|
||||
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
|
||||
"""Rebuild CDSL through session-scoped atomic executors only."""
|
||||
def _preflight_error(analysis: Any) -> ValueError | None:
|
||||
if analysis.runtime_eligible:
|
||||
return None
|
||||
first = next((result for result in analysis.feature_results if not result.executable), None)
|
||||
if first is None:
|
||||
return ValueError(analysis.document_blockers[0].code)
|
||||
if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
|
||||
return ValueError(f"unsupported atomic_id: {first.atomic_id}")
|
||||
detail = "; ".join(blocker.code for blocker in first.blockers)
|
||||
return ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
|
||||
|
||||
|
||||
def _execution_diagnostic(
|
||||
error: Exception,
|
||||
node: FeaturePlanNode,
|
||||
session: ExecutionSession,
|
||||
) -> RuntimeDiagnostic:
|
||||
failed_resolution = next(
|
||||
(item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
|
||||
)
|
||||
if isinstance(error, FeatureExecutionError):
|
||||
return RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
|
||||
if failed_resolution and failed_resolution.get("diagnostic"):
|
||||
diagnostic = failed_resolution["diagnostic"]
|
||||
return RuntimeDiagnostic(
|
||||
diagnostic["code"], diagnostic["message"], feature_id=node.feature_id,
|
||||
detail=diagnostic.get("detail") or {},
|
||||
)
|
||||
return RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IncrementalCdslExecution:
|
||||
"""One prepared CDSL replay with feature-at-a-time execution.
|
||||
|
||||
CADFS selector binding consumes this object before each feature executes.
|
||||
It therefore observes the exact session topology snapshots generated by
|
||||
the one real kernel replay rather than rebuilding a growing prefix for
|
||||
every selector. The generic runtime also uses it through ``rebuild_cdsl``.
|
||||
"""
|
||||
|
||||
resolved_cdsl: dict[str, Any]
|
||||
analysis: Any
|
||||
session: ExecutionSession
|
||||
diagnostics: list[RuntimeDiagnostic] = field(default_factory=list)
|
||||
next_index: int = 0
|
||||
|
||||
def execute_next(self, *, strict: bool = True) -> FeatureResult | None:
|
||||
if self.next_index >= len(self.analysis.plan):
|
||||
raise ValueError("CDSL execution plan is already complete")
|
||||
node = self.analysis.plan[self.next_index]
|
||||
preflight = self.analysis.feature_results[self.next_index]
|
||||
self.next_index += 1
|
||||
if not preflight.executable:
|
||||
self.diagnostics.extend(preflight.blockers)
|
||||
if strict:
|
||||
detail = "; ".join(blocker.code for blocker in preflight.blockers)
|
||||
raise ValueError(f"Feature {node.feature_id} is not runtime eligible: {detail}")
|
||||
return None
|
||||
try:
|
||||
if node.sketch_id is not None:
|
||||
self.session.resolve_sketch_attachment(str(node.sketch_id), feature_id=node.feature_id)
|
||||
return execute_node(node, self.session)
|
||||
except Exception as error:
|
||||
diagnostic = _execution_diagnostic(error, node, self.session)
|
||||
self.diagnostics.append(diagnostic)
|
||||
if strict:
|
||||
raise RuntimeExecutionError(diagnostic, list(self.session.selector_resolutions)) from error
|
||||
return None
|
||||
|
||||
def execute_all(self, *, strict: bool = True) -> None:
|
||||
while self.next_index < len(self.analysis.plan):
|
||||
self.execute_next(strict=strict)
|
||||
|
||||
|
||||
def prepare_cdsl_execution(cdsl: dict[str, Any]) -> IncrementalCdslExecution:
|
||||
"""Resolve CDSL once and return a reusable sequential execution session."""
|
||||
sketch_errors: dict[str, str] = {}
|
||||
resolved = resolve_required_sketches(
|
||||
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
|
||||
@@ -110,45 +193,16 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
|
||||
analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze(
|
||||
resolved, sketch_errors=sketch_errors,
|
||||
)
|
||||
if strict and not analysis.runtime_eligible:
|
||||
first = next((result for result in analysis.feature_results if not result.executable), None)
|
||||
if first is None:
|
||||
raise ValueError(analysis.document_blockers[0].code)
|
||||
if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
|
||||
raise ValueError(f"unsupported atomic_id: {first.atomic_id}")
|
||||
detail = "; ".join(blocker.code for blocker in first.blockers)
|
||||
raise ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
|
||||
session = ExecutionSession(
|
||||
sketches={str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or []},
|
||||
nodes={node.feature_id: node for node in analysis.plan},
|
||||
)
|
||||
diagnostics: list[RuntimeDiagnostic] = []
|
||||
for node, preflight in zip(analysis.plan, analysis.feature_results):
|
||||
if not preflight.executable:
|
||||
diagnostics.extend(preflight.blockers)
|
||||
if strict:
|
||||
break
|
||||
continue
|
||||
try:
|
||||
execute_node(node, session)
|
||||
except Exception as error:
|
||||
failed_resolution = next(
|
||||
(item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
|
||||
)
|
||||
diagnostic = (
|
||||
RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
|
||||
if isinstance(error, FeatureExecutionError)
|
||||
else
|
||||
RuntimeDiagnostic(
|
||||
failed_resolution["diagnostic"]["code"], failed_resolution["diagnostic"]["message"],
|
||||
feature_id=node.feature_id, detail=failed_resolution["diagnostic"].get("detail") or {},
|
||||
)
|
||||
if failed_resolution and failed_resolution.get("diagnostic")
|
||||
else RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
|
||||
)
|
||||
diagnostics.append(diagnostic)
|
||||
if strict:
|
||||
raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error
|
||||
return IncrementalCdslExecution(resolved, analysis, session)
|
||||
|
||||
|
||||
def finalize_cdsl_execution(execution: IncrementalCdslExecution, out_step: Path) -> dict[str, Any]:
|
||||
"""Export the current executable checkpoint from an incremental replay."""
|
||||
session = execution.session
|
||||
output = session.body
|
||||
surface_geometry: dict[str, Any] | None = None
|
||||
if output is None:
|
||||
@@ -177,8 +231,19 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
|
||||
"surface_face_count": int(surface_geometry["face_count"]) if surface_geometry is not None else 0,
|
||||
"surface_area_mm2": float(surface_geometry["area_mm2"]) if surface_geometry is not None else 0.0,
|
||||
"feature_results": [result.as_dict() for result in session.results.values()],
|
||||
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
|
||||
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in execution.diagnostics],
|
||||
"topology_records": [record.public_dict() for record in session.topology.records()],
|
||||
"topology_deltas": list(session.topology.topology_deltas()),
|
||||
"selector_resolution": session.selector_resolutions,
|
||||
}
|
||||
|
||||
|
||||
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
|
||||
"""Rebuild CDSL through session-scoped atomic executors only."""
|
||||
execution = prepare_cdsl_execution(cdsl)
|
||||
if strict:
|
||||
error = _preflight_error(execution.analysis)
|
||||
if error is not None:
|
||||
raise error
|
||||
execution.execute_all(strict=strict)
|
||||
return finalize_cdsl_execution(execution, out_step)
|
||||
|
||||
@@ -43,6 +43,9 @@ from .topology import (
|
||||
SelectorResolution,
|
||||
TopologyDelta,
|
||||
TopologyDeltaRelation,
|
||||
TopologyBlendRelation,
|
||||
TopologySectionRelation,
|
||||
TopologyLineage,
|
||||
TopologyRecord,
|
||||
TopologyRegistry,
|
||||
)
|
||||
@@ -63,6 +66,9 @@ __all__ = [
|
||||
"ThreadSpec",
|
||||
"TopologyDelta",
|
||||
"TopologyDeltaRelation",
|
||||
"TopologyBlendRelation",
|
||||
"TopologySectionRelation",
|
||||
"TopologyLineage",
|
||||
"TopologyRecord",
|
||||
"TopologyRegistry",
|
||||
"Vector3",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,443 @@
|
||||
{
|
||||
"_meta": {
|
||||
"title": "CDSL 语义词表几何签名(初稿 v0.1)",
|
||||
"protocol": "cad.cdsl.llm.v1",
|
||||
"date": "2026-09-14",
|
||||
"role": "三层关联机制的字典层:label ↔ 几何期望的机器可读绑定。签名是期望(expectation),不是模板(template)。",
|
||||
"checking_semantics": "签名检查只产生警告与训练数据分级,永不阻塞建模与重建(与 AGENTS.md 一致:不把语义分歧当程序错误)。",
|
||||
"input_dependency": "metric_expectations 依赖重建管线输出的逐特征指标(derived_metrics per_feature delta)。缺失 delta 时,退化检查为:metric_expectations 中 *_delta 键跳过,其余照查。",
|
||||
"inheritance": "label 可带 parent,检查时先应用父签名再应用自身(并集)。composite=true 表示该语义典型地由特征组合实现(如打孔+阵列),检查时沿 depends_on 链聚合 delta。",
|
||||
"out_of_vocabulary": "词表外 label 无签名,程序侧零校验(开放词表的设计使然);训练侧应引导收敛到词表内。",
|
||||
"known_limitation": "纯用途差异在几何上不可分:coolant_channel 与 lubrication_gallery、cosmetic_surface 与 stress_relief_fillet 的几何签名几乎相同,其区分依赖 intent.why 与上下文,不依赖本签名。",
|
||||
"cross_field_reference": "param_expectations 的值可以引用另一参数路径(如 {\">\": \"diameter_mm\"}),匹配器解析为跨字段比较。",
|
||||
"signature_version": "0.1",
|
||||
"vocabulary_count": 37
|
||||
},
|
||||
"signatures": {
|
||||
"bolt_circle": {
|
||||
"parent": "fastener_hole",
|
||||
"composite": true,
|
||||
"allowed_atomics": ["pattern_circular", "pattern_linear", "hole_wizard", "hole_blind"],
|
||||
"param_expectations": {
|
||||
"pattern_count": {">=": 3},
|
||||
"positions": {">=": 3}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"hole_count_delta": {">=": 3},
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"relations": ["instances_same_diameter", "instances_on_common_circle"],
|
||||
"notes_zh": "一组等径紧固孔沿公共圆周分布。典型实现:hole_wizard(1孔)+pattern_circular 阵列,或一次多 positions。检查时沿 depends_on 聚合打孔与阵列两特征的 delta。"
|
||||
},
|
||||
"fastener_hole": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
|
||||
"param_expectations": {
|
||||
"end_condition.type": {"in": ["through_all", "through_all_both", "blind"]}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"hole_count_delta": {">=": 1},
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "紧固件过孔(光孔)。词表父节点:tap_hole / bolt_circle / 沉头类均为其子型或伴生型。"
|
||||
},
|
||||
"tap_hole": {
|
||||
"parent": "fastener_hole",
|
||||
"allowed_atomics": ["hole_wizard", "thread_cut"],
|
||||
"param_expectations": {
|
||||
"thread": {"required_if_atomic": "hole_wizard"}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"hole_count_delta": {">=": 1},
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true,
|
||||
"creates_thread_helix": false
|
||||
},
|
||||
"notes_zh": "螺纹孔。注意:hole_wizard 的 thread 装饰当前被引擎降级为光孔(thread_decoration_ignored 诊断),creates_thread_helix 恒为 false,签名如实反映现状;thread_cut 才产生真实螺旋几何。"
|
||||
},
|
||||
"counterbore_seat": {
|
||||
"parent": "fastener_hole",
|
||||
"allowed_atomics": ["hole_counterbore", "hole_wizard"],
|
||||
"param_expectations": {
|
||||
"counterbore_diameter_mm": {">": "diameter_mm"},
|
||||
"counterbore_depth_mm": {">": 0}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true,
|
||||
"creates_planar_shoulder": true
|
||||
},
|
||||
"notes_zh": "沉头柱坑:主孔 + 同轴大直径浅坑,坑底形成环形平面肩。"
|
||||
},
|
||||
"countersink_seat": {
|
||||
"parent": "fastener_hole",
|
||||
"allowed_atomics": ["hole_countersink", "hole_wizard"],
|
||||
"param_expectations": {
|
||||
"countersink_diameter_mm": {">": "diameter_mm"},
|
||||
"countersink_angle_rad": {">": 0, "<": 3.141592653589793}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cone_face": true
|
||||
},
|
||||
"notes_zh": "沉头锥坑:主孔 + 锥形扩口,锥面是与柱面区分的强拓扑指纹。"
|
||||
},
|
||||
"locating_pin_hole": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"hole_count_delta": {">=": 1},
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "定位销孔。配合公差与双孔位置度是设计要点,但公差不在 CDSL 当前表达能力内,机器只能查到 存在性/数量;配合语义写 why。"
|
||||
},
|
||||
"bearing_seat": {
|
||||
"allowed_atomics": ["extrude_add_blind", "cylinder_add", "revolve_add", "hole_wizard"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {},
|
||||
"topology_expectations": {
|
||||
"creates_cylindrical_fit_surface": true
|
||||
},
|
||||
"notes_zh": "轴承位(轴颈或座孔)。功能型语义,几何签名天然宽:内核是圆柱配合面(外圆或内孔),方向(增/减材)与安装形式有关。尺寸配合关系写 why。"
|
||||
},
|
||||
"shaft_passage": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
|
||||
"param_expectations": {
|
||||
"end_condition.type": {"in": ["through_all", "through_all_both"]}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"hole_count_delta": {">=": 1},
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"relations": ["axis_aligned_with_body_center_or_axis"],
|
||||
"notes_zh": "过轴通孔。贯穿是强约束(签名可查);同心是典型但非必然(签名标注为关系提示)。"
|
||||
},
|
||||
"press_fit_boss": {
|
||||
"allowed_atomics": ["extrude_add_blind", "cylinder_add"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_outer_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "压配合凸台:增材圆柱特征。过盈量等配合信息写在 why,几何只可查 增材+圆柱面。"
|
||||
},
|
||||
"alignment_datum": {
|
||||
"allowed_atomics": ["reference_plane", "reference_axis", "extrude_add_blind", "hole_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">=": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "对中/对位基准结构。可以是参考几何(零材料变化)也可以是小凸台/销孔;签名宽,依赖 why。"
|
||||
},
|
||||
"mounting_boss": {
|
||||
"allowed_atomics": ["extrude_add_blind", "cylinder_add"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_outer_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "安装凸台:增材圆柱特征,常带后续紧固孔(组合语义)。"
|
||||
},
|
||||
"mounting_foot": {
|
||||
"allowed_atomics": ["extrude_add_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_planar_faces": true
|
||||
},
|
||||
"notes_zh": "安装底脚:增材板状特征,形成安装平面。"
|
||||
},
|
||||
"lifting_eye": {
|
||||
"allowed_atomics": ["extrude_add_blind", "revolve_add", "sweep_add"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "吊环/吊耳:增材结构带贯穿吊孔(吊索穿过)。孔的存在是较强指纹。"
|
||||
},
|
||||
"slot_adjustment": {
|
||||
"allowed_atomics": ["extrude_cut_blind", "extrude_cut_through", "hole_wizard", "hole_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_planar_walls": true
|
||||
},
|
||||
"notes_zh": "调整长孔/滑槽:允许位置调节的细长切口。典型实现:长圆 polygon 切除,或两孔+直切。"
|
||||
},
|
||||
"coolant_channel": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through", "sweep_add"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "冷却通道。与 lubrication_gallery 几何签名几乎相同(能力边界案例),区分靠 why 与介质上下文。"
|
||||
},
|
||||
"lubrication_gallery": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through", "sweep_add"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "润滑油路。同上,几何上与冷却通道不可分。"
|
||||
},
|
||||
"vent_hole": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"hole_count_delta": {">=": 1},
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "排气孔:小直径贯穿孔,通常贯穿(呼吸/排气的功能要求)。"
|
||||
},
|
||||
"drain_port": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "排液口。位置在最低点是设计要点但不在几何签名能力内,写 why。"
|
||||
},
|
||||
"fluid_inlet": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through", "extrude_add_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "进液口。可能是孔(减材)也可能是接管凸台+孔(增减组合),签名宽。"
|
||||
},
|
||||
"process_corner_relief": {
|
||||
"allowed_atomics": ["extrude_cut_blind", "extrude_cut_through", "chamfer", "fillet"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "工艺让位(避让刀具/相邻件干涉)。材料切除量小是典型特征。"
|
||||
},
|
||||
"weld_prep": {
|
||||
"allowed_atomics": ["chamfer"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_cone_face": true
|
||||
},
|
||||
"notes_zh": "焊接坡口:倒角原子直接映射(坡口即倒角),锥面是强指纹。"
|
||||
},
|
||||
"machining_setup_tab": {
|
||||
"allowed_atomics": ["extrude_add_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "装夹工艺台:临时增材,后续工序去除。生命周期语义写在 why(几何本身与普通小凸台不可分)。"
|
||||
},
|
||||
"inspection_access": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "测量/探针可达孔。"
|
||||
},
|
||||
"stress_relief_fillet": {
|
||||
"allowed_atomics": ["fillet"],
|
||||
"param_expectations": {
|
||||
"radius_mm": {">": 0}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0},
|
||||
"face_count_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_torus_face": true
|
||||
},
|
||||
"notes_zh": "应力缓解圆角。torus 面(圆角面)+面数增加是修饰操作生效的强指纹;与 cosmetic_surface 的区别是用途而非几何。"
|
||||
},
|
||||
"stiffening_rib": {
|
||||
"allowed_atomics": ["extrude_add_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_planar_faces": true
|
||||
},
|
||||
"notes_zh": "加强筋:增材薄壁特征。薄(相对壁厚)是定义的一部分但属跨字段相对约束,初稿不做机器检查。"
|
||||
},
|
||||
"weight_relief": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_blind", "extrude_cut_through"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "减重(孔/槽)。通常多孔或多腔(volume delta 显著为负)。"
|
||||
},
|
||||
"mass_saving_pocket": {
|
||||
"allowed_atomics": ["extrude_cut_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_planar_faces": true
|
||||
},
|
||||
"notes_zh": "减重腔:盲切腔体,不留穿。与 weight_relief 的区别是形式(腔 vs 孔阵)。"
|
||||
},
|
||||
"load_path_flange": {
|
||||
"allowed_atomics": ["extrude_add_blind", "revolve_add"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "承载法兰/接盘。功能型语义,签名宽。"
|
||||
},
|
||||
"wall_thickness_transition": {
|
||||
"allowed_atomics": ["shell", "extrude_add_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "壁厚过渡。实现多样(抽壳/变截面拉伸),签名宽。"
|
||||
},
|
||||
"gear_teeth": {
|
||||
"allowed_atomics": ["gear_add"],
|
||||
"param_expectations": {
|
||||
"module_mm": {">": 0},
|
||||
"teeth_count": {">=": 8}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_involute_or_bspline_faces": true
|
||||
},
|
||||
"notes_zh": "轮齿:gear_add 原子专属。渐开线/自由曲面齿面是强指纹(亦可用于检测 gear_add 是否真的生成几何)。"
|
||||
},
|
||||
"rack_teeth": {
|
||||
"allowed_atomics": ["rack_add"],
|
||||
"param_expectations": {
|
||||
"module_mm": {">": 0}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_involute_or_bspline_faces": true
|
||||
},
|
||||
"notes_zh": "齿条齿:rack_add 原子专属。"
|
||||
},
|
||||
"thread_drive": {
|
||||
"allowed_atomics": ["thread_add"],
|
||||
"param_expectations": {
|
||||
"pitch_mm": {">": 0}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_thread_helix": true
|
||||
},
|
||||
"notes_zh": "传动螺纹(外螺纹实体段):thread_add 专属,螺旋面是强指纹。"
|
||||
},
|
||||
"cam_track": {
|
||||
"allowed_atomics": ["sweep_add", "extrude_cut_through", "extrude_cut_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {},
|
||||
"topology_expectations": {
|
||||
"creates_bspline_curve_geometry": true
|
||||
},
|
||||
"notes_zh": "凸轮轨道:曲线扫掠或曲线槽,B 样条路径/曲线是其指纹。"
|
||||
},
|
||||
"bend_wing": {
|
||||
"allowed_atomics": ["bend_add"],
|
||||
"param_expectations": {
|
||||
"chain": {">=": 1}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_cylindrical_bend_face": true
|
||||
},
|
||||
"notes_zh": "折弯翼:bend_add 原子专属,折弯内/外圆柱面是指纹。"
|
||||
},
|
||||
"cosmetic_surface": {
|
||||
"allowed_atomics": ["fillet", "chamfer"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_torus_face": true
|
||||
},
|
||||
"notes_zh": "外观修饰(倒圆/倒角)。与 stress_relief_fillet 几何签名几乎相同(能力边界案例),区别写 why。"
|
||||
},
|
||||
"datum_plane_feature": {
|
||||
"allowed_atomics": ["reference_plane"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"==": 0},
|
||||
"face_count_delta": {"==": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "设计基准面:零材料变化 + 零拓扑变化 + 特定原子,是全部签名中最确定的一条(强指纹)。"
|
||||
},
|
||||
"datum_axis_feature": {
|
||||
"allowed_atomics": ["reference_axis"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"==": 0},
|
||||
"face_count_delta": {"==": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "设计基准轴:同上,强指纹。"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user