Lk dev #21
@@ -34,6 +34,7 @@ build/
|
||||
# Runtime data and generated local artifacts
|
||||
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,165 @@
|
||||
# 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.
|
||||
|
||||
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
|
||||
|
||||
Before authoring, derive this internal brief from the requirements:
|
||||
|
||||
- part or multi-body intent; explicit dimensions and units;
|
||||
- functional datums, origin, base plane, and positive directions;
|
||||
- primary volumes, holes, pockets, bosses, ribs, patterns, and finishing;
|
||||
- explicitly verifiable targets versus manual targets;
|
||||
- assumptions that do not affect fit, safety, or compliance.
|
||||
|
||||
Dimensioned request facts take precedence over proportions inferred from an
|
||||
image. Ask for clarification only when a missing interface, scale, safety, or
|
||||
compliance value makes construction impossible. Otherwise choose a practical
|
||||
engineering default and record it as an assumption.
|
||||
|
||||
## 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.
|
||||
|
||||
The selector-source rule is an intentional exception to a generic “all adds,
|
||||
then all cuts” sequence. For example, a bolt circle hosted on an original
|
||||
flange cap must be placed immediately after the base when later additive
|
||||
fusions replace that exact cap. A required final through-cut can still follow
|
||||
all additive features and the earlier bolt operation.
|
||||
|
||||
Use only the supplied operation list. Follow each operation's parameter schema
|
||||
exactly, including required sketch state. Do not invent unsupported operation
|
||||
parameters, implicit booleans, or a substitute operation after a capability
|
||||
error.
|
||||
|
||||
## 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 manufacturing role, for example `base_plate`,
|
||||
`front_hub_boss`, `center_bore`, and `bolt_holes`. Names make dependencies and
|
||||
repair diagnostics readable; they are not server identities.
|
||||
|
||||
## Selectors
|
||||
|
||||
The operation metadata states whether `selectors` are required, their kind,
|
||||
cardinality, and server-side destination. Put only declarative selectors in a
|
||||
feature's `selectors` array. Never write the destination field itself inside
|
||||
`params`.
|
||||
|
||||
For an output face, use an exact local role embedded in `source` and state a
|
||||
unique match:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "face",
|
||||
"source": "front_hub_boss.top_planar_face",
|
||||
"match": "unique"
|
||||
}
|
||||
```
|
||||
|
||||
Do not add `role`, `query`, `host_face`, a face index, a coordinate selector,
|
||||
or a Runtime selector token. The `source` value is the complete local intent.
|
||||
The compiler adds its source feature as an auditable graph dependency; include
|
||||
other true construction dependencies in `depends_on` yourself.
|
||||
|
||||
`top_planar_face` and `end_face` mean the positive-direction cap of a supported
|
||||
extrude, sweep, loft, or cylinder. `bottom_planar_face` and `start_face` mean
|
||||
the opposite cap. Select the most recent feature whose output is known to be
|
||||
the required host; do not select a similar face by location, face index, or
|
||||
proximity.
|
||||
|
||||
Translate a request's descriptive `max_z`/`min_z` wording into these output
|
||||
roles before writing CDSL. Never emit `base.max_z_face` or `base.min_z_face`.
|
||||
For a cylinder built in `+Z`, the top cap is `top_planar_face` and the bottom
|
||||
cap is `bottom_planar_face`; reverse-direction features swap their world-Z
|
||||
position but retain their own start/end roles.
|
||||
|
||||
`hole_wizard` requires exactly one `face` selector. It uses that selector as
|
||||
its host face, so a central bore on a hub should select the hub cap, while a
|
||||
bolt circle on an exposed flange should select the flange cap. A selector must
|
||||
name a host that contains every requested hole position. If that cannot be
|
||||
made unique, redesign the feature sequence or omit the unsupported feature;
|
||||
never guess a face.
|
||||
|
||||
Before creating a hosted hole, calculate each position against the actual host
|
||||
face. A local boss can be the global highest face while being too small to host
|
||||
a larger bolt circle. In that case use the exposed flange cap at the bolt
|
||||
radius, with its own plane height, rather than the global maximum-Z cap. Place
|
||||
the hosted holes while that source cap's exact provenance is still active:
|
||||
before a later fusion or cut would split, remove, or replace it. A final
|
||||
through-cut may still follow all additive features, so a bolt circle can be
|
||||
hosted before an unrelated final boss and before that final cut.
|
||||
|
||||
## Acceptance And Repair
|
||||
|
||||
Describe only user-requested, measurable acceptance targets in
|
||||
`acceptance_targets`; leave inferred dimensions in `assumptions`. On repair,
|
||||
return a complete replacement document. Preserve feature names and every
|
||||
feature listed in `executed_feature_ids`, except a feature explicitly named by
|
||||
the diagnostic. Features that were never executed may be changed freely to
|
||||
repair an invalid selector, parameter, dependency, or geometry construction.
|
||||
|
||||
Read structured diagnostics literally. Fix their named cause with the smallest
|
||||
document change, then return the entire document. Do not add internal IDs,
|
||||
weaken a requested value, silently delete a failed feature, or replace a
|
||||
failed selector with an arbitrary topology element.
|
||||
@@ -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,300 @@
|
||||
"""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
|
||||
features.append(output)
|
||||
runtime = {
|
||||
"schema": "cad.runtime.v1", "schema_version": "1.0.0", "kind": "part",
|
||||
"part_id": "compiled", "meta": {"unit": "mm"},
|
||||
"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,169 @@
|
||||
"""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 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.",
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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,10 +38,16 @@ 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"}:
|
||||
raise OperationContractError("Operation contract has unknown or missing fields")
|
||||
@@ -111,113 +97,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,17 +4,53 @@ This package rebuilds `cad.cdsl.llm.v1` models through the CDSL-only path:
|
||||
|
||||
`semantic validation -> capability analysis -> sketch resolution -> session runtime -> STEP`
|
||||
|
||||
`runtime.py` owns the executor registry, an `ExecutionSession`, and the
|
||||
feature/topology lifecycle. `build123d_adapter.py` is the only layer that
|
||||
creates or mutates B-rep objects. `runtime_types.py` owns runtime-neutral
|
||||
feature, context, selector, and topology contracts. `llm_compiler.py` and
|
||||
`llm_engine.py` remain available for legacy engine-plan compatibility but are
|
||||
not used by `run_cdsl_only`.
|
||||
## Package layout
|
||||
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| `specs.py` | Vector math, plane/axis helpers, parametric feature specs (no kernel deps) |
|
||||
| `topology.py` | Diagnostics, planning contracts, `TopologyRegistry`, selector resolution |
|
||||
| `capabilities.py` | `CapabilityAnalyzer`: preflight blockers before any geometry runs |
|
||||
| `sketch_solver.py` | Profile expansion (circle / polygon / analytic contours) |
|
||||
| `session.py` | `ExecutionSession` and the kernel-facing `GeometryAdapter` protocol |
|
||||
| `runtime_base.py` | Shared error types and the `ExtentVector` value |
|
||||
| `extents.py` | End-condition planning (blind / through / up-to-surface / ...) |
|
||||
| `pattern_transform.py` | Translate/mirror/rotate parameter algebra for pattern replay |
|
||||
| `registry.py` | `EXECUTORS`, the `atomic_executor` decorator, and `execute_node` dispatch |
|
||||
| `executors/` | One module per executor family; importing the package registers all |
|
||||
| `build123d_adapter.py` | The only layer that creates or mutates B-rep objects |
|
||||
| `topology.py` / `runtime_types.py` | Historical re-export shim (`runtime_types`) |
|
||||
| `translator/` | Frozen SolidWorks-exact code generation (`ir` / `codegen` / `runtime_lib`) |
|
||||
| `legacy/` | Frozen legacy engine paths (`llm_compiler`, `llm_engine`, exact rebuilds) |
|
||||
| `rebuild.py` | Legacy three-way facade plus `compare_with_gold` acceptance |
|
||||
|
||||
`runtime.py` keeps the `analyze_cdsl` / `rebuild_cdsl` entry points and
|
||||
re-exports the historical names. `llm_compiler.py` and `llm_engine.py` are
|
||||
compatibility shims for the frozen `legacy/` implementations and are not used
|
||||
by `run_cdsl_only`.
|
||||
|
||||
## Adding an atomic operation
|
||||
|
||||
1. Add the operation contract to `profile_schema.json` (`operation_contracts`),
|
||||
including its `runtime_capability` flags — this is the single source of
|
||||
truth for preflight classification.
|
||||
2. Add a decorated executor function in exactly one `executors/<family>.py`
|
||||
module (`@atomic_executor("...")`); the shared registry never changes.
|
||||
3. Add the atomic id to `ALL_ATOMIC_IDS` in `registry.py` (registration fails
|
||||
fast on unknown or duplicate ids, and `executors/__init__` fails if any
|
||||
declared id has no registered executor).
|
||||
4. Update `cdsl_schema.json` in the same change.
|
||||
5. Extend `backend/tests/test_profile_schema.py` fixtures if the contract
|
||||
shape changed.
|
||||
|
||||
Multiple people can add different operations in parallel without touching a
|
||||
shared registry file: the only shared edits are the two schema documents.
|
||||
|
||||
Supported profiles are defined by `SHAPE_GENERATORS` in `sketch_solver.py`.
|
||||
Supported feature atomic operations are defined by `EXECUTORS` in `runtime.py`.
|
||||
Their human-readable contract is in `profile_schema.json`; the complete,
|
||||
machine-enforced CDSL object contract is in `cdsl_schema.json`.
|
||||
Supported feature atomic operations are defined by `EXECUTORS` (populated from
|
||||
`executors/` at import time). Their human-readable contract is in
|
||||
`profile_schema.json`; the complete, machine-enforced CDSL object contract is
|
||||
in `cdsl_schema.json`.
|
||||
The Studio only accepts self-contained profile data and requires successful
|
||||
`engine=cdsl_only` output. It never uses the legacy translator fallback or
|
||||
`compiler_context`.
|
||||
@@ -24,10 +60,11 @@ The Studio only accepts self-contained profile data and requires successful
|
||||
`profile_schema.json` and `cdsl_schema.json` together are the source of truth
|
||||
for the engine contract exposed to the CAD Agent and the backend validator.
|
||||
Any addition, removal, rename, or parameter-contract change in
|
||||
`sketch_solver.py`, `runtime.py`, or the build adapter must update both files
|
||||
`sketch_solver.py`, `executors/`, or the build adapter must update both files
|
||||
in the same change.
|
||||
`backend/tests/test_profile_schema.py` fails when the registered profiles or
|
||||
supported atomic operations diverge from the document.
|
||||
`backend/tests/test_profile_schema.py` fails when the registered profiles,
|
||||
supported atomic operations, or `runtime_capability` flags diverge from the
|
||||
document.
|
||||
|
||||
## Batch baseline
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFille
|
||||
from OCP.BRepOffset import BRepOffset_Skin
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
|
||||
from OCP.Geom import Geom_SurfaceOfRevolution
|
||||
from OCP.GeomAbs import GeomAbs_Arc
|
||||
from OCP.LocOpe import LocOpe_DPrism
|
||||
@@ -23,7 +23,7 @@ from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopTools import TopTools_ListOfShape
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||||
from OCP.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||||
|
||||
from .parametric_bend import build_bend_solid
|
||||
from .parametric_gears import build_gear_solid, build_rack_solid
|
||||
@@ -33,6 +33,11 @@ from .runtime_types import (
|
||||
TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3,
|
||||
canonical_plane_signature,
|
||||
)
|
||||
from .topology_export import (
|
||||
body_geometry as _body_geometry_impl,
|
||||
surface_geometry as _surface_geometry_impl,
|
||||
topology_records as _topology_records_impl,
|
||||
)
|
||||
|
||||
|
||||
def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
|
||||
@@ -1131,6 +1136,37 @@ class Build123dGeometryAdapter:
|
||||
)
|
||||
return Solid.make_cylinder(radius_mm, height_mm, build_plane)
|
||||
|
||||
@staticmethod
|
||||
def cylinder_with_topology_delta(
|
||||
radius_mm: float,
|
||||
height_mm: float,
|
||||
axis: AxisSpec | None = None,
|
||||
) -> tuple[Solid, TopologyDelta]:
|
||||
"""Build a cylinder with exact OCC witnesses for its two cap faces."""
|
||||
origin = axis.origin_mm if axis is not None else (0.0, 0.0, 0.0)
|
||||
direction = axis.direction if axis is not None else (0.0, 0.0, 1.0)
|
||||
placement = gp_Ax2(
|
||||
gp_Pnt(float(origin[0]), float(origin[1]), float(origin[2])),
|
||||
gp_Dir(float(direction[0]), float(direction[1]), float(direction[2])),
|
||||
)
|
||||
builder = BRepPrimAPI_MakeCylinder(placement, float(radius_mm), float(height_mm))
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCC cylinder operation did not complete")
|
||||
result = Solid(builder.Solid())
|
||||
if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9:
|
||||
raise ValueError("OCC cylinder operation did not produce a valid solid")
|
||||
primitive = builder.Cylinder()
|
||||
relations = tuple(
|
||||
TopologyDeltaRelation("generated", "face", result.wrapped, (face,), output_role=role)
|
||||
for face, role in (
|
||||
(primitive.BottomFace(), "cylinder.start"),
|
||||
(primitive.TopFace(), "cylinder.end"),
|
||||
)
|
||||
if not face.IsNull()
|
||||
)
|
||||
return result, TopologyDelta(operation="cylinder", relations=relations)
|
||||
|
||||
@staticmethod
|
||||
def intersect(left: Any, right: Any) -> Any:
|
||||
# 布尔交:取两实体公共部分。结果可能为空(不相交或仅边界接触),
|
||||
@@ -1237,7 +1273,21 @@ class Build123dGeometryAdapter:
|
||||
relations.append(TopologyDeltaRelation("preserved", kind, source_value, (source_value,)))
|
||||
if generated:
|
||||
relations.append(TopologyDeltaRelation("generated", kind, source_value, generated))
|
||||
return TopologyDelta(operation=operation_name, relations=tuple(relations))
|
||||
section_values: tuple[Any, ...] = ()
|
||||
section_edges = getattr(operation, "SectionEdges", None)
|
||||
if callable(section_edges):
|
||||
try:
|
||||
# BRepAlgoAPI boolean builders expose the exact intersection
|
||||
# edges. Builders without that API simply carry no section
|
||||
# evidence; callers must not infer it from result geometry.
|
||||
section_values = tuple(section_edges())
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
section_values = ()
|
||||
return TopologyDelta(
|
||||
operation=operation_name,
|
||||
relations=tuple(relations),
|
||||
section_values=section_values,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _shell_topology_delta(
|
||||
@@ -1926,294 +1976,15 @@ class Build123dGeometryAdapter:
|
||||
|
||||
@staticmethod
|
||||
def body_geometry(body: Any) -> dict[str, Any]:
|
||||
# 汇总主体基本几何信息:包围盒与体积。
|
||||
bbox = body.bounding_box()
|
||||
# A feature history can contain several body IDs while still ending in
|
||||
# one connected solid (for example, a base extrusion followed by hole
|
||||
# cuts). Count the current OCC result, never feature history entries.
|
||||
solids = list(body.solids()) if hasattr(body, "solids") else [body]
|
||||
return {
|
||||
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
|
||||
"volume_mm3": float(body.volume),
|
||||
"solid_count": len(solids),
|
||||
}
|
||||
# 汇总主体基本几何信息:包围盒与体积(实现见 topology_export)。
|
||||
return _body_geometry_impl(body)
|
||||
|
||||
@staticmethod
|
||||
def surface_geometry(surface: Any) -> dict[str, Any]:
|
||||
# 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。
|
||||
bbox = surface.bounding_box()
|
||||
return {
|
||||
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
|
||||
"area_mm2": float(surface.area),
|
||||
"face_count": len(surface.faces()),
|
||||
}
|
||||
# 曲面结果的独立拓扑摘要(实现见 topology_export)。
|
||||
return _surface_geometry_impl(surface)
|
||||
|
||||
@staticmethod
|
||||
def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]:
|
||||
# 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。
|
||||
records: list[TopologyRecord] = []
|
||||
faces = list(body.faces())
|
||||
edges = list(body.edges())
|
||||
vertices = list(body.vertices())
|
||||
|
||||
def index_for(shape: Any, candidates: list[Any]) -> int | None:
|
||||
"""Map a subshape returned by a face/edge back to body topology."""
|
||||
# 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。
|
||||
for index, candidate in enumerate(candidates):
|
||||
if shape.is_same(candidate):
|
||||
return index
|
||||
return None
|
||||
|
||||
# 1. 建立邻接索引:每条边关联的面集合(edge_faces)。
|
||||
edge_faces: list[set[int]] = [set() for _edge in edges]
|
||||
for face_index, face in enumerate(faces):
|
||||
for edge in face.edges():
|
||||
edge_index = index_for(edge, edges)
|
||||
if edge_index is not None:
|
||||
edge_faces[edge_index].add(face_index)
|
||||
# 2. 建立邻接索引:每个顶点关联的边集合(vertex_edges)。
|
||||
vertex_edges: list[set[int]] = [set() for _vertex in vertices]
|
||||
for edge_index, edge in enumerate(edges):
|
||||
for vertex in edge.vertices():
|
||||
vertex_index = index_for(vertex, vertices)
|
||||
if vertex_index is not None:
|
||||
vertex_edges[vertex_index].add(edge_index)
|
||||
|
||||
def edge_signature(edge_index: int) -> str:
|
||||
# 边的特征签名:几何类型 + 长度 + 相邻面数,用作面邻接指纹。
|
||||
edge = edges[edge_index]
|
||||
return ":".join((
|
||||
str(edge.geom_type).split(".")[-1].lower(),
|
||||
f"{float(edge.length):.6f}",
|
||||
str(len(edge_faces[edge_index])),
|
||||
))
|
||||
|
||||
# 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名;
|
||||
# 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。
|
||||
# 圆柱面还保存轴、半径和共享边关联的平面面。这使 verifier 能从
|
||||
# 实际 B-rep 证明孔是否连接两个方向相反的外部平面,而不是根据
|
||||
# author 传入的 blind-depth 文字猜测“贯穿”。
|
||||
face_edge_indexes: list[set[int]] = []
|
||||
face_geometries: list[dict[str, Any]] = []
|
||||
for index, face in enumerate(faces):
|
||||
bbox = face.bounding_box()
|
||||
center = face.center()
|
||||
normal = face.normal_at()
|
||||
boundary_edge_indexes = [
|
||||
edge_index
|
||||
for edge in face.edges()
|
||||
if (edge_index := index_for(edge, edges)) is not None
|
||||
]
|
||||
geometry = {
|
||||
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
|
||||
"center_mm": [center.X, center.Y, center.Z], "normal": [normal.X, normal.Y, normal.Z],
|
||||
"area_mm2": float(face.area), "surface_type": str(face.geom_type).split(".")[-1].lower(),
|
||||
"adjacency_signature": sorted(edge_signature(edge_index) for edge_index in boundary_edge_indexes),
|
||||
}
|
||||
if geometry["surface_type"] == "plane":
|
||||
plane_normal, plane_offset = canonical_plane_signature(
|
||||
(normal.X, normal.Y, normal.Z), (center.X, center.Y, center.Z),
|
||||
)
|
||||
geometry["plane_normal"] = list(plane_normal)
|
||||
geometry["plane_offset_mm"] = plane_offset
|
||||
boundary_loops: list[list[list[float]]] = []
|
||||
for wire in face.wires():
|
||||
samples: list[list[float]] = []
|
||||
for edge in wire.edges():
|
||||
curve_type = str(edge.geom_type).split(".")[-1].lower()
|
||||
fractions = [step / 16 for step in range(16)] if curve_type in {"circle", "ellipse"} else [0.0]
|
||||
for fraction in fractions:
|
||||
point = edge.position_at(fraction)
|
||||
value = [float(point.X), float(point.Y), float(point.Z)]
|
||||
if not samples or sum((value[axis] - samples[-1][axis]) ** 2 for axis in range(3)) > 1e-12:
|
||||
samples.append(value)
|
||||
if len(samples) >= 3:
|
||||
boundary_loops.append(samples)
|
||||
if boundary_loops:
|
||||
geometry["boundary_loops_mm"] = boundary_loops
|
||||
elif geometry["surface_type"] in {"cylinder", "cone"}:
|
||||
axis = face.axis_of_rotation
|
||||
if axis is None:
|
||||
# Build123d can omit this optional OCC property for valid
|
||||
# swept rotational faces. Keep their generic B-rep record
|
||||
# so a selector-free workflow remains executable; do not
|
||||
# invent axis/radius evidence for an axis-based selector.
|
||||
pass
|
||||
else:
|
||||
direction = axis.direction
|
||||
origin = axis.position
|
||||
geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z]
|
||||
geometry["axis_direction"] = [direction.X, direction.Y, direction.Z]
|
||||
raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None
|
||||
if geometry["surface_type"] == "cone":
|
||||
boundary_radii: list[float] = []
|
||||
for edge in face.edges():
|
||||
if str(edge.geom_type).split(".")[-1].lower() != "circle":
|
||||
continue
|
||||
try:
|
||||
boundary_radii.append(float(edge.radius))
|
||||
except ValueError:
|
||||
continue
|
||||
geometry["boundary_radii_mm"] = sorted(boundary_radii)
|
||||
geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None
|
||||
# ``through`` alone describes a cylinder spanning two opposed
|
||||
# planar faces. That applies to both a through bore and the
|
||||
# outside wall of a cylindrical extrusion. Classify the B-rep
|
||||
# face by its oriented normal so downstream acceptance claims
|
||||
# can prove holes without mistaking an exterior wall for one.
|
||||
unit_axis = (direction.X, direction.Y, direction.Z)
|
||||
radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z)
|
||||
axial_projection = sum(radial[component] * unit_axis[component] for component in range(3))
|
||||
radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3))
|
||||
radial_length = sum(component * component for component in radial) ** 0.5
|
||||
if geometry["surface_type"] == "cylinder":
|
||||
# OCC can report a cylinder surface with ``radius=None``
|
||||
# after a non-planar-side Boolean cut. The face centre is
|
||||
# still on that cylinder, so its perpendicular distance to
|
||||
# the rotation axis is an equivalent measured radius. Do
|
||||
# not fail an otherwise valid build merely because that
|
||||
# optional OCC convenience property is absent.
|
||||
if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)):
|
||||
geometry["radius_mm"] = float(raw_cylinder_radius)
|
||||
elif radial_length > 1e-9:
|
||||
geometry["radius_mm"] = radial_length
|
||||
if radial_length > 1e-9:
|
||||
normal_components = (normal.X, normal.Y, normal.Z)
|
||||
alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length
|
||||
geometry["radial_normal_alignment"] = alignment
|
||||
geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown"
|
||||
else:
|
||||
geometry["cylinder_role"] = "unknown"
|
||||
face_edge_indexes.append(set(boundary_edge_indexes))
|
||||
face_geometries.append(geometry)
|
||||
records.append(TopologyRecord(
|
||||
record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face,
|
||||
geometry=geometry,
|
||||
))
|
||||
plane_indexes = [index for index, geometry in enumerate(face_geometries) if geometry["surface_type"] == "plane"]
|
||||
|
||||
def directly_linked_planes(face_index: int) -> list[int]:
|
||||
return [
|
||||
plane_index
|
||||
for plane_index in plane_indexes
|
||||
if face_edge_indexes[face_index].intersection(face_edge_indexes[plane_index])
|
||||
]
|
||||
|
||||
def same_inner_rotational_channel(first: int, second: int) -> bool:
|
||||
"""Whether two inner rotational faces share one B-rep bore channel."""
|
||||
if not face_edge_indexes[first].intersection(face_edge_indexes[second]):
|
||||
return False
|
||||
left, right = face_geometries[first], face_geometries[second]
|
||||
if left.get("cylinder_role") != "inner" or right.get("cylinder_role") != "inner":
|
||||
return False
|
||||
left_axis, right_axis = left.get("axis_direction"), right.get("axis_direction")
|
||||
left_origin, right_origin = left.get("axis_origin_mm"), right.get("axis_origin_mm")
|
||||
if not all(isinstance(value, list) and len(value) == 3 for value in (left_axis, right_axis, left_origin, right_origin)):
|
||||
return False
|
||||
try:
|
||||
left_direction = tuple(float(value) for value in left_axis)
|
||||
right_direction = tuple(float(value) for value in right_axis)
|
||||
offset = tuple(float(left_origin[index]) - float(right_origin[index]) for index in range(3))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
alignment = sum(left_direction[index] * right_direction[index] for index in range(3))
|
||||
if abs(alignment) < 1.0 - 1e-6:
|
||||
return False
|
||||
axial_offset = sum(offset[index] * left_direction[index] for index in range(3))
|
||||
radial_offset = tuple(offset[index] - axial_offset * left_direction[index] for index in range(3))
|
||||
return sum(value * value for value in radial_offset) ** 0.5 <= 1e-5
|
||||
|
||||
inner_rotational_indexes = [
|
||||
index
|
||||
for index, geometry in enumerate(face_geometries)
|
||||
if geometry["surface_type"] in {"cylinder", "cone"} and geometry.get("cylinder_role") == "inner"
|
||||
]
|
||||
|
||||
def channel_plane_indexes(start: int) -> list[int]:
|
||||
"""Collect endpoint planes through joined, co-axial inner faces.
|
||||
|
||||
A countersink or counterbore splits a physical bore into a cone and
|
||||
a cylinder. The cylinder has only one direct planar neighbour, so
|
||||
direct adjacency alone cannot prove that the complete channel exits
|
||||
the part. Traverse shared B-rep edges only across co-axial inner
|
||||
rotational faces, then inspect the channel's actual plane ends.
|
||||
"""
|
||||
pending = [start]
|
||||
visited: set[int] = set()
|
||||
endpoints: set[int] = set()
|
||||
while pending:
|
||||
index = pending.pop()
|
||||
if index in visited:
|
||||
continue
|
||||
visited.add(index)
|
||||
endpoints.update(directly_linked_planes(index))
|
||||
pending.extend(
|
||||
candidate
|
||||
for candidate in inner_rotational_indexes
|
||||
if candidate not in visited and same_inner_rotational_channel(index, candidate)
|
||||
)
|
||||
return sorted(endpoints)
|
||||
|
||||
def spans_opposed_planes(linked: list[int], axis: Any) -> bool:
|
||||
if not isinstance(axis, list) or len(axis) != 3:
|
||||
return False
|
||||
try:
|
||||
direction = tuple(float(value) for value in axis)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return any(
|
||||
sum(float(face_geometries[first]["normal"][component]) * float(face_geometries[second]["normal"][component]) for component in range(3)) <= -0.99
|
||||
and all(abs(sum(float(face_geometries[position]["normal"][component]) * direction[component] for component in range(3))) >= 0.99 for position in (first, second))
|
||||
for first in linked
|
||||
for second in linked
|
||||
if first < second
|
||||
)
|
||||
|
||||
for index, geometry in enumerate(face_geometries):
|
||||
if geometry["surface_type"] != "cylinder":
|
||||
continue
|
||||
linked = directly_linked_planes(index)
|
||||
geometry["connected_plane_ids"] = [records[plane_index].record_id for plane_index in linked]
|
||||
channel_linked = channel_plane_indexes(index) if geometry.get("cylinder_role") == "inner" else linked
|
||||
geometry["channel_connected_plane_ids"] = [records[plane_index].record_id for plane_index in channel_linked]
|
||||
geometry["through"] = spans_opposed_planes(channel_linked, geometry.get("axis_direction"))
|
||||
# 4. 导出边记录:含包围盒、中心、长度、曲线类型与相邻面数;端点坐标可用时附加。
|
||||
for index, edge in enumerate(edges):
|
||||
bbox = edge.bounding_box()
|
||||
center = edge.center()
|
||||
vertices = edge.vertices()
|
||||
geometry = {
|
||||
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
|
||||
"center_mm": [center.X, center.Y, center.Z], "length_mm": float(edge.length),
|
||||
"curve_type": str(edge.geom_type).split(".")[-1].lower(),
|
||||
"adjacent_face_count": len(edge_faces[index]),
|
||||
}
|
||||
if geometry["curve_type"] == "circle":
|
||||
# ``Edge.center()`` is a point on a periodic circle, not its
|
||||
# geometric centre. Preserve the OCC circle data separately
|
||||
# so a provenance-backed rotational selector can distinguish
|
||||
# concentric full circles at different axial locations.
|
||||
try:
|
||||
circle_center = edge.arc_center
|
||||
radius = float(edge.radius)
|
||||
values = (circle_center.X, circle_center.Y, circle_center.Z, radius)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
values = ()
|
||||
if values and all(math.isfinite(float(value)) for value in values) and radius > 0:
|
||||
geometry["circle_center_mm"] = [circle_center.X, circle_center.Y, circle_center.Z]
|
||||
geometry["radius_mm"] = radius
|
||||
if vertices:
|
||||
geometry["start_mm"] = list(vertices[0])
|
||||
geometry["end_mm"] = list(vertices[-1])
|
||||
records.append(TopologyRecord(
|
||||
record_id=f"{body_id}:edge:{index}", kind="edge", feature_id=feature_id, body_id=body_id, value=edge,
|
||||
geometry=geometry,
|
||||
))
|
||||
# 5. 导出顶点记录:含坐标与关联边数。
|
||||
for index, vertex in enumerate(vertices):
|
||||
point = [vertex.X, vertex.Y, vertex.Z]
|
||||
records.append(TopologyRecord(
|
||||
record_id=f"{body_id}:vertex:{index}", kind="vertex", feature_id=feature_id, body_id=body_id, value=vertex,
|
||||
geometry={"center_mm": point, "incident_edge_count": len(vertex_edges[index])},
|
||||
))
|
||||
return records
|
||||
# 从主体导出全部面/边/顶点拓扑记录(实现见 topology_export)。
|
||||
return _topology_records_impl(body, feature_id, body_id)
|
||||
|
||||
@@ -19,31 +19,8 @@ from .runtime_types import (
|
||||
from .operation_contracts import materialized_feature_contracts
|
||||
|
||||
|
||||
_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "extrude_from_face", "loft_add_with_cap_face", "fillet", "chamfer", "shell"})
|
||||
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
|
||||
# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的
|
||||
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
|
||||
_OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"})
|
||||
_PRIMARY_ATOMICS = frozenset({
|
||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", "extrude_surface",
|
||||
"extrude_cut_through",
|
||||
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
|
||||
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
|
||||
})
|
||||
_HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
|
||||
_ACTIVE_BODY_REQUIRED = frozenset({
|
||||
"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through",
|
||||
"loft_add_with_cap_face", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", "shell",
|
||||
# thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空
|
||||
# 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。
|
||||
"thread_cut",
|
||||
})
|
||||
_BODY_MUTATING_ATOMICS = frozenset({
|
||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
|
||||
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add",
|
||||
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
|
||||
"thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", *_HOLE_ATOMICS, "fillet", "chamfer", "shell",
|
||||
})
|
||||
# ``ExecutionSession.body_members`` only contains independently selectable
|
||||
# body outputs. A normal additive/cut/dress-up feature replaces the active
|
||||
# aggregate, while ``result_mode: new_body`` and ``keep_tools`` are the two
|
||||
@@ -51,14 +28,6 @@ _BODY_MUTATING_ATOMICS = frozenset({
|
||||
# members can additionally expose a proven COPY instance; replayed/fused
|
||||
# patterns remain ineligible because no exact instance ownership exists.
|
||||
_PATTERN_ATOMICS = frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
|
||||
# A pattern may replay a previous pattern as well as a direct body mutation.
|
||||
# Context-only features have no geometry definition to instance. thread_add,
|
||||
# thread_cut, bend_add, gear_add and rack_add are excluded: pattern
|
||||
# translation does not yet move their parametric axis/frame, so a replayed
|
||||
# instance would silently re-run at the original location.
|
||||
_REPLAYABLE_ATOMICS = (
|
||||
_BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut", "bend_add", "gear_add", "rack_add"})
|
||||
) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
|
||||
_SUPPORTED_EXTENTS = frozenset({
|
||||
"blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind",
|
||||
"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body",
|
||||
@@ -71,6 +40,39 @@ _EXTENT_TARGET_KINDS = {
|
||||
}
|
||||
|
||||
|
||||
def _runtime_capabilities() -> dict[str, dict[str, bool]]:
|
||||
"""Return the per-atomic runtime capability flags from the schema registry.
|
||||
|
||||
``profile_schema.json.operation_contracts[*].runtime_capability`` is the
|
||||
single source of truth for these classifications. Adding an atomic
|
||||
operation updates one JSON contract instead of editing code-level sets.
|
||||
"""
|
||||
path = Path(__file__).with_name("profile_schema.json")
|
||||
contracts = materialized_feature_contracts(json.loads(path.read_text(encoding="utf-8")))
|
||||
return {atomic_id: contract["runtime_capability"] for atomic_id, contract in contracts.items()}
|
||||
|
||||
|
||||
_RUNTIME_CAPABILITIES = _runtime_capabilities()
|
||||
_SELECTOR_REQUIRED = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["requires_selector"])
|
||||
# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的
|
||||
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
|
||||
_OPEN_PROFILE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["open_profile_ok"])
|
||||
_PRIMARY_ATOMICS = frozenset({
|
||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", "extrude_surface",
|
||||
"extrude_cut_through",
|
||||
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
|
||||
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
|
||||
})
|
||||
_ACTIVE_BODY_REQUIRED = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["requires_active_body"])
|
||||
_BODY_MUTATING_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["body_mutating"])
|
||||
# A pattern may replay a previous pattern as well as a direct body mutation.
|
||||
# Context-only features have no geometry definition to instance. thread_add,
|
||||
# thread_cut, bend_add, gear_add and rack_add are excluded: pattern
|
||||
# translation does not yet move their parametric axis/frame, so a replayed
|
||||
# instance would silently re-run at the original location.
|
||||
_REPLAYABLE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["replayable"])
|
||||
|
||||
|
||||
def _mappings(value: Any):
|
||||
"""Yield nested feature mappings for capability-only contract checks."""
|
||||
if isinstance(value, dict):
|
||||
@@ -82,6 +84,19 @@ def _mappings(value: Any):
|
||||
yield from _mappings(child)
|
||||
|
||||
|
||||
def _contract_selectors(node: FeaturePlanNode, contract: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""Read only the selector slot declared by the operation contract."""
|
||||
slot = str((contract or {}).get("selector_slot") or "")
|
||||
if slot == "feature.selectors":
|
||||
values = node.selectors
|
||||
elif slot.startswith("params.") and slot.count(".") == 1:
|
||||
value = node.params.get(slot.removeprefix("params."))
|
||||
values = value if isinstance(value, list) else [value]
|
||||
else:
|
||||
values = []
|
||||
return [value for value in values if isinstance(value, dict)]
|
||||
|
||||
|
||||
def _transform_member_sources(params: dict[str, Any]) -> set[str]:
|
||||
"""Return internal body-member keys named by a transform contract."""
|
||||
source_ids = {str(value) for value in params.get("source_feature_ids") or ()}
|
||||
@@ -543,18 +558,20 @@ class CapabilityAnalyzer:
|
||||
"Loft currently requires exactly one outer profile without holes",
|
||||
sketch_id=sketch_id,
|
||||
))
|
||||
contract_selectors = _contract_selectors(node, contract)
|
||||
contract_selector_ids = {id(selector) for selector in contract_selectors}
|
||||
for selector in _mappings(params):
|
||||
if selector.get("output_role") is not None:
|
||||
if selector.get("output_role") is not None and id(selector) not in contract_selector_ids:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_output_role_selector_context",
|
||||
"Feature output role selectors are only supported in feature.selectors",
|
||||
"Feature output role selector is outside the operation contract slot",
|
||||
))
|
||||
for selector_index, selector in enumerate(node.selectors):
|
||||
for selector_index, selector in enumerate(contract_selectors):
|
||||
if selector.get("output_role") is None:
|
||||
continue
|
||||
required.append("selector:feature_output_role")
|
||||
if contract is None or contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face":
|
||||
if contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face":
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_output_role_selector",
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
"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"},
|
||||
"bodies": {"type": "array", "items": {"$ref": "#/$defs/runtimeBody"}},
|
||||
"geometry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector_intent_version": {"const": "1.0"},
|
||||
"sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}}
|
||||
},
|
||||
"required": ["sketches"],
|
||||
@@ -26,6 +28,15 @@
|
||||
"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": {
|
||||
@@ -541,7 +552,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 +565,48 @@
|
||||
"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}
|
||||
},
|
||||
"required": ["ast", "featurescript_version"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorIntent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {"const": "1.0"},
|
||||
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
|
||||
"query_family": {"enum": ["CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY", "GEOMETRIC"]},
|
||||
"source_query": {"$ref": "#/$defs/selectorIntentSourceQuery"},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_role": {"$ref": "#/$defs/featureOutputRole"},
|
||||
"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", "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": {
|
||||
@@ -570,6 +623,8 @@
|
||||
"match_mode": {"enum": ["unique", "all"]},
|
||||
"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"],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Executor modules for the session runtime.
|
||||
|
||||
Importing this package registers every atomic executor into
|
||||
``cdsl_engine.registry.EXECUTORS`` exactly once, then verifies that the
|
||||
registry covers the complete declared atomic-id set. Executor modules must
|
||||
not import each other; shared helpers live in ``common``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..registry import ALL_ATOMIC_IDS, EXECUTORS
|
||||
from . import ( # noqa: F401 (importing the modules performs registration)
|
||||
bodies,
|
||||
context,
|
||||
dressup,
|
||||
extrude,
|
||||
holes,
|
||||
loft_sweep,
|
||||
parametric,
|
||||
patterns,
|
||||
primitives,
|
||||
revolve,
|
||||
surfaces,
|
||||
)
|
||||
|
||||
_missing = sorted(ALL_ATOMIC_IDS - set(EXECUTORS))
|
||||
if _missing:
|
||||
raise RuntimeError(f"atomic ids without a registered executor: {_missing}")
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Body-graph executors (boolean_bodies / transform_bodies / delete_bodies).
|
||||
|
||||
These operate on explicitly named body members instead of the aggregate
|
||||
session body, so adjacent independent solids never accidentally become tools
|
||||
or targets of one another.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..registry import atomic_executor
|
||||
from ..specs import transform_copy_member_id
|
||||
from ..topology import FeaturePlanNode, FeatureResult, TopologyDelta
|
||||
from .common import _combine_members, _member_sources
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为
|
||||
# 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。
|
||||
params = node.params
|
||||
target_ids = _member_sources(
|
||||
node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs",
|
||||
)
|
||||
tool_ids = _member_sources(
|
||||
node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_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}
|
||||
target = _combine_members(session, targets)
|
||||
tool = _combine_members(session, tools)
|
||||
operation = str(params.get("operation") or "")
|
||||
topology_delta: TopologyDelta | None = None
|
||||
if operation == "union":
|
||||
result, topology_delta = session.adapter.fuse_with_topology_delta(target, tool)
|
||||
elif operation == "subtract":
|
||||
result, topology_delta = session.adapter.cut_with_topology_delta(target, tool)
|
||||
elif operation == "intersect":
|
||||
result, topology_delta = session.adapter.intersect_with_topology_delta(target, tool)
|
||||
else:
|
||||
raise ValueError(f"unsupported booleanBodies operation {operation!r}")
|
||||
members = {
|
||||
feature_id: body
|
||||
for feature_id, body in session.body_members.items()
|
||||
if feature_id not in set(target_ids + tool_ids)
|
||||
}
|
||||
members[node.feature_id] = result
|
||||
if bool(params.get("keep_tools")):
|
||||
members.update(tools)
|
||||
session.register_body(
|
||||
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
|
||||
)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("boolean_bodies")
|
||||
def _boolean_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_boolean_bodies(node, session)
|
||||
|
||||
|
||||
def _execute_transform_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# FeatureScript transform targets explicit bodies. Do not move the
|
||||
# aggregate session body, because it may include unrelated members.
|
||||
source_ids = _member_sources(
|
||||
node, session, "source_feature_ids", pattern_instance_parameter="pattern_instance_refs", allow_transform_copies=True,
|
||||
)
|
||||
make_copy = bool(node.params.get("make_copy"))
|
||||
direct_sources = node.params.get("source_feature_ids") or []
|
||||
if make_copy and isinstance(direct_sources, list) and len(direct_sources) > 1:
|
||||
# The aggregate is only an export compound. Each source transform has
|
||||
# its own B-rep builder and is the only output a later COPY query may
|
||||
# select. Do not attach an aggregate topology delta to source members.
|
||||
members = dict(session.body_members)
|
||||
members.update({
|
||||
transform_copy_member_id(node.feature_id, source_id): session.adapter.transform(
|
||||
session.body_members[source_id], dict(node.params.get("transform") or {}),
|
||||
)
|
||||
for source_id in source_ids
|
||||
})
|
||||
session.register_body(
|
||||
node.feature_id, _combine_members(session, members), body_members=members,
|
||||
)
|
||||
return session.result(node)
|
||||
source = _combine_members(session, {feature_id: session.body_members[feature_id] for feature_id in source_ids})
|
||||
transformed, topology_delta = session.adapter.transform_with_topology_delta(
|
||||
source, dict(node.params.get("transform") or {}),
|
||||
)
|
||||
members = dict(session.body_members)
|
||||
if not make_copy:
|
||||
for feature_id in source_ids:
|
||||
members.pop(feature_id)
|
||||
members[node.feature_id] = transformed
|
||||
session.register_body(
|
||||
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
|
||||
)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("transform_bodies")
|
||||
def _transform_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_transform_bodies(node, session)
|
||||
|
||||
|
||||
def _execute_delete_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# Deletion is a body-graph operation, never a Boolean subtraction. A
|
||||
# selected member can be disjoint or overlap another independent body.
|
||||
source_ids = _member_sources(node, session, "target_feature_ids")
|
||||
members = {feature_id: body for feature_id, body in session.body_members.items() if feature_id not in set(source_ids)}
|
||||
if members:
|
||||
session.register_body(node.feature_id, _combine_members(session, members), body_members=members)
|
||||
else:
|
||||
session.clear_body()
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("delete_bodies")
|
||||
def _delete_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_delete_bodies(node, session)
|
||||
@@ -0,0 +1,542 @@
|
||||
"""Helpers shared by executor family modules.
|
||||
|
||||
Every function here is imported by two or more executor modules. Anything
|
||||
used by exactly one family lives in that family's module instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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, TopologyRecord
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
def _revolve_axis(node: FeaturePlanNode, session: "ExecutionSession") -> AxisSpec:
|
||||
raw_axis = node.params.get("axis") or {}
|
||||
if raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None:
|
||||
return AxisSpec.from_mapping(raw_axis)
|
||||
selector = raw_axis.get("selector") if isinstance(raw_axis, dict) else None
|
||||
if not isinstance(selector, dict):
|
||||
selector = next((item for item in node.selectors if item.get("kind") == "axis"), None)
|
||||
if not isinstance(selector, dict):
|
||||
raise FeatureExecutionError(
|
||||
"missing_revolve_axis",
|
||||
"Revolve requires an explicit axis or an owner-qualified reference-axis selector",
|
||||
)
|
||||
resolution = session.resolve(selector)
|
||||
if resolution.status != "resolved" or resolution.record is None:
|
||||
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "revolve axis was not resolved")
|
||||
if not isinstance(resolution.record.value, AxisSpec):
|
||||
raise FeatureExecutionError(
|
||||
"unsupported_revolve_axis", "The resolved context is not an axis", actual_kind=resolution.record.kind,
|
||||
)
|
||||
return resolution.record.value
|
||||
|
||||
|
||||
def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None:
|
||||
"""Defend direct CDSL execution from an out-of-plane revolve axis."""
|
||||
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
direction_normal_dot = abs(vector_dot(axis.direction, plane.normal))
|
||||
if direction_normal_dot > 1e-7:
|
||||
raise ValueError(
|
||||
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to "
|
||||
f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}"
|
||||
)
|
||||
origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal))
|
||||
if origin_plane_offset > 1e-6:
|
||||
raise ValueError(
|
||||
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in "
|
||||
f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}"
|
||||
)
|
||||
|
||||
|
||||
def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]:
|
||||
"""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.
|
||||
"""
|
||||
members: dict[str, Any] = {}
|
||||
for feature_id, body in session.body_members.items():
|
||||
result = session.adapter.cut(body, tool)
|
||||
if abs(float(result.volume)) > 1e-12:
|
||||
members[feature_id] = result
|
||||
return members
|
||||
|
||||
|
||||
def _extruded_tool(
|
||||
node: FeaturePlanNode,
|
||||
faces: list[Any],
|
||||
profile_normal: Vector3,
|
||||
session: "ExecutionSession",
|
||||
) -> tuple[Any, TopologyDelta | None]:
|
||||
"""Build one extrude tool, retaining caps only from one exact builder result."""
|
||||
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
|
||||
draft = node.params.get("draft")
|
||||
taper_deg = 0.0
|
||||
if isinstance(draft, dict):
|
||||
taper_deg = float(draft["angle_deg"])
|
||||
if not bool(draft["pull_direction"]):
|
||||
taper_deg = -taper_deg
|
||||
topology_delta: TopologyDelta | None = None
|
||||
solids: list[Any] = []
|
||||
for face in faces:
|
||||
for extent in 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,
|
||||
)
|
||||
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)
|
||||
solids.append(solid)
|
||||
elif extent.trim_to is None:
|
||||
solids.append(session.adapter.extrude(face, extent.vector))
|
||||
else:
|
||||
solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector))
|
||||
tool = None
|
||||
for solid in solids:
|
||||
tool = session.adapter.fuse(tool, solid)
|
||||
if tool is None:
|
||||
raise ValueError("extrude produced no solid")
|
||||
return tool, topology_delta
|
||||
|
||||
|
||||
def _apply_primary_tool(
|
||||
node: FeaturePlanNode,
|
||||
session: "ExecutionSession",
|
||||
tool: Any,
|
||||
*,
|
||||
cutting: bool,
|
||||
topology_delta: TopologyDelta | None = None,
|
||||
) -> FeatureResult:
|
||||
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
|
||||
if cutting:
|
||||
if session.body is None:
|
||||
raise ValueError("cut feature has no body")
|
||||
members = _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
|
||||
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)
|
||||
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,
|
||||
)
|
||||
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)
|
||||
if not faces:
|
||||
raise ValueError("sketch does not create a closed profile region")
|
||||
if node.atomic_id == "extrude_add_blind_with_hole":
|
||||
resolved = [session.resolve(selector) for selector in node.selectors]
|
||||
failed = next((item for item in resolved if item.status != "resolved"), None)
|
||||
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
|
||||
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved")
|
||||
if len(faces) != 1:
|
||||
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
|
||||
# 3. 按特征类型生成子实体:
|
||||
if node.atomic_id.startswith("extrude_"):
|
||||
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
|
||||
# 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在
|
||||
# profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪
|
||||
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
|
||||
tool, topology_delta = _extruded_tool(
|
||||
node, faces, _normal_from_sketch(selected_sketch), session,
|
||||
)
|
||||
else:
|
||||
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
|
||||
axis = _revolve_axis(node, session)
|
||||
_validate_revolve_axis_in_sketch_plane(axis, selected_sketch)
|
||||
angle = float(node.params.get("angle_deg") or 0.0)
|
||||
if angle <= 0:
|
||||
raise ValueError("revolve requires angle_deg > 0")
|
||||
# reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负
|
||||
# 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一
|
||||
# 语义。profile_schema.json 已声明 revolve.* optional_params 含
|
||||
# reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime
|
||||
# 侧实现,使三方合同一致。
|
||||
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)
|
||||
if tool is None:
|
||||
raise ValueError("revolve produced no solid")
|
||||
return _apply_primary_tool(
|
||||
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
|
||||
)
|
||||
|
||||
|
||||
def _combine_members(session: "ExecutionSession", members: dict[str, Any]) -> Any:
|
||||
body = None
|
||||
for member in members.values():
|
||||
body = session.adapter.combine(body, member)
|
||||
if body is None:
|
||||
raise ValueError("booleanBodies produced no result bodies")
|
||||
return body
|
||||
|
||||
|
||||
def _pattern_instance_sources(
|
||||
node: FeaturePlanNode,
|
||||
session: "ExecutionSession",
|
||||
parameter: str = "pattern_instance_refs",
|
||||
) -> list[str]:
|
||||
"""Resolve CDSL pattern-instance refs to their internal body-member keys."""
|
||||
resolved: list[str] = []
|
||||
for reference in node.params.get(parameter) or ():
|
||||
if not isinstance(reference, dict):
|
||||
raise ValueError("pattern instance reference must be an object")
|
||||
pattern_id = str(reference.get("pattern_feature_id") or "")
|
||||
source_id = str(reference.get("source_feature_id") or "")
|
||||
instance = reference.get("instance_index")
|
||||
if not pattern_id or not source_id or not isinstance(instance, int):
|
||||
raise ValueError("pattern instance reference is incomplete")
|
||||
pattern = session.nodes.get(pattern_id)
|
||||
if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}:
|
||||
raise ValueError(f"pattern instance owner is unavailable: {pattern_id}")
|
||||
params = pattern.params
|
||||
if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}:
|
||||
raise ValueError("pattern instance source is not selected by its pattern")
|
||||
count = int(params.get("pattern_count") or 0)
|
||||
excluded = {int(value) for value in params.get("excluded_instance_indices") or ()}
|
||||
if (
|
||||
pattern.atomic_id == "pattern_mirror" and instance != 1
|
||||
) or (
|
||||
pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded)
|
||||
):
|
||||
raise ValueError("pattern instance is outside the pattern's surviving instances")
|
||||
member_id = pattern_instance_member_id(pattern_id, source_id, instance)
|
||||
if member_id not in session.body_members:
|
||||
raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}")
|
||||
if member_id not in resolved:
|
||||
resolved.append(member_id)
|
||||
return resolved
|
||||
|
||||
|
||||
def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> 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 ():
|
||||
if not isinstance(reference, dict):
|
||||
raise ValueError("transform COPY reference must be an object")
|
||||
transform_id = str(reference.get("transform_feature_id") or "")
|
||||
source_id = str(reference.get("source_feature_id") or "")
|
||||
if not transform_id or not source_id:
|
||||
raise ValueError("transform COPY reference is incomplete")
|
||||
transform = session.nodes.get(transform_id)
|
||||
params = transform.params if transform is not None else {}
|
||||
sources = params.get("source_feature_ids") or []
|
||||
if (
|
||||
transform is None
|
||||
or transform.atomic_id != "transform_bodies"
|
||||
or not bool(params.get("make_copy"))
|
||||
or not isinstance(sources, list)
|
||||
or len(sources) < 2
|
||||
or source_id not in {str(value) for value in sources}
|
||||
):
|
||||
raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}")
|
||||
member_id = transform_copy_member_id(transform_id, source_id)
|
||||
if member_id not in session.body_members:
|
||||
raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}")
|
||||
if member_id not in resolved:
|
||||
resolved.append(member_id)
|
||||
return resolved
|
||||
|
||||
|
||||
def _member_sources(
|
||||
node: FeaturePlanNode,
|
||||
session: "ExecutionSession",
|
||||
parameter: str,
|
||||
*,
|
||||
pattern_instance_parameter: str | None = None,
|
||||
allow_transform_copies: bool = False,
|
||||
) -> 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))
|
||||
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]
|
||||
if missing:
|
||||
raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing))
|
||||
return source_ids
|
||||
|
||||
|
||||
def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any:
|
||||
# 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。
|
||||
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 {})
|
||||
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")
|
||||
|
||||
def point(value: list[float]) -> Vector3:
|
||||
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
|
||||
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])))
|
||||
|
||||
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")),
|
||||
parameters=[float(value) for value in segment.get("parameters") or []] or None,
|
||||
)
|
||||
|
||||
|
||||
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).
|
||||
|
||||
When ``node.params['result_mode'] == "new_body"`` the primitive is kept as
|
||||
an independent body member so that downstream ``boolean_bodies`` can
|
||||
reference it without pulling in the accumulated fuse history. The current
|
||||
body is replaced by a Compound that preserves both, matching the
|
||||
``extrude_add_blind`` ``new_body`` semantics. Any other value (including
|
||||
missing) falls back to the legacy fuse-into-body behavior.
|
||||
"""
|
||||
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,
|
||||
topology_delta=topology_delta,
|
||||
)
|
||||
return
|
||||
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 _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) -> 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"],
|
||||
})
|
||||
|
||||
|
||||
def _hole_starts(
|
||||
spec: HoleSpec,
|
||||
*,
|
||||
host_plane: PlaneSpec,
|
||||
positions_are_local: bool,
|
||||
) -> list[Vector3]:
|
||||
starts: list[Vector3] = []
|
||||
for point in spec.positions_mm:
|
||||
if positions_are_local:
|
||||
start = vector_add(
|
||||
vector_add(
|
||||
vector_add(host_plane.origin_mm, vector_scale(host_plane.x_dir, point[0])),
|
||||
vector_scale(host_plane.y_dir, point[1]),
|
||||
),
|
||||
vector_scale(host_plane.normal, point[2]),
|
||||
)
|
||||
else:
|
||||
start = point
|
||||
starts.append(start)
|
||||
return starts
|
||||
|
||||
|
||||
def _selector_edges(node: FeaturePlanNode, session: "ExecutionSession", *, tangent_propagation: bool = False) -> list[Any]:
|
||||
resolved: list[SelectorResolution] = [session.resolve(selector) for selector in node.selectors]
|
||||
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")
|
||||
|
||||
def is_body_boundary(edge: Any) -> bool:
|
||||
# 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体
|
||||
# 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边,
|
||||
# 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的
|
||||
# 单边选择,所以这里只约束由 FACE 展开的候选边。
|
||||
face_count = sum(
|
||||
1
|
||||
for face in session.body.faces()
|
||||
if any(candidate.is_same(edge) for candidate in face.edges())
|
||||
)
|
||||
return face_count >= 2
|
||||
|
||||
edges: list[Any] = []
|
||||
for item in resolved:
|
||||
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
|
||||
|
||||
|
||||
def _shell_target(node: FeaturePlanNode, session: "ExecutionSession") -> tuple[Any, list[Any]]:
|
||||
# shell 的 remove-face selector 必须全部属于同一实体。CADFS 允许一个
|
||||
# Compound 中保留多个独立 body,不能将整组 body 交给 OCC 后由内核猜测
|
||||
# 应抽壳的成员。
|
||||
resolved = [session.resolve(selector) for selector in node.selectors]
|
||||
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 = [
|
||||
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}
|
||||
if len(target_ids) != 1:
|
||||
raise ValueError("shell faces must belong to one target body")
|
||||
target_id = next(iter(target_ids))
|
||||
members = session.adapter.body_solids(session.body)
|
||||
if len(members) == 1:
|
||||
target = members[0]
|
||||
else:
|
||||
if target_id is None or session.body_id is None:
|
||||
raise ValueError("shell target body is unresolved")
|
||||
prefix = f"{session.body_id}:"
|
||||
if not target_id.startswith(prefix):
|
||||
raise ValueError("shell target body is outside the active body set")
|
||||
try:
|
||||
member_index = int(target_id[len(prefix):])
|
||||
except ValueError as error:
|
||||
raise ValueError("shell target body has an invalid member id") from error
|
||||
if member_index < 0 or member_index >= len(members):
|
||||
raise ValueError("shell target body member is unavailable")
|
||||
target = members[member_index]
|
||||
target_feature_id = node.params.get("target_feature_id")
|
||||
if target_feature_id is not None:
|
||||
if not isinstance(target_feature_id, str) or not target_feature_id:
|
||||
raise ValueError("shell target_feature_id is invalid")
|
||||
declared = session.body_members.get(target_feature_id)
|
||||
if declared is None:
|
||||
raise ValueError("shell target body is no longer an independently selectable member")
|
||||
declared_solids = session.adapter.body_solids(declared)
|
||||
if len(declared_solids) != 1:
|
||||
raise ValueError("shell target body must resolve to exactly one active solid")
|
||||
if not declared_solids[0].is_same(target):
|
||||
raise ValueError("shell target body does not match the resolved face member")
|
||||
return target, [record.value for record in records]
|
||||
|
||||
|
||||
def _replace_shell_target(session: "ExecutionSession", target: Any, replacement: Any) -> Any:
|
||||
# 仅替换抽壳目标实体;其他独立实体保持原样和原有相对顺序。
|
||||
members = session.adapter.body_solids(session.body)
|
||||
if len(members) == 1:
|
||||
return replacement
|
||||
replaced = False
|
||||
result = None
|
||||
for member in members:
|
||||
if member.is_same(target):
|
||||
result = session.adapter.combine(result, replacement)
|
||||
replaced = True
|
||||
else:
|
||||
result = session.adapter.combine(result, member)
|
||||
if not replaced or result is None:
|
||||
raise ValueError("shell target solid is no longer part of the active body")
|
||||
return result
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Reference-geometry executors (reference_plane / reference_axis).
|
||||
|
||||
Context features produce no solid; they register durable topology contexts
|
||||
that later features resolve through owner-qualified selectors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..registry import atomic_executor
|
||||
from ..specs import AxisSpec, PlaneSpec, vector_add, vector_cross, vector_dot, vector_scale, vector_unit
|
||||
from ..topology import FeaturePlanNode, FeatureResult
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
@atomic_executor("reference_plane")
|
||||
def _reference_plane_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
# 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。
|
||||
|
||||
# 1. 从特征参数 plane 中解析出平面定义 PlaneSpec(原点到法向)。
|
||||
plane = PlaneSpec.from_mapping(node.params.get("plane") or {})
|
||||
# 2. 将该平面注册到拓扑上下文,供后续特征(如草图基准、参考轴)引用。
|
||||
session.topology.register_context(node.feature_id, plane)
|
||||
# 3. 返回结果对象,并将该平面作为上下文一并携带。
|
||||
return session.result(node, context=plane)
|
||||
|
||||
|
||||
@atomic_executor("reference_axis")
|
||||
def _reference_axis_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
# 基准轴特征(reference_axis)执行入口:由参数直接定义轴,或由两个基准平面求交线得到轴。
|
||||
|
||||
# 1. 尝试直接取参数:若同时给出原点 origin_mm 与方向 direction,则直接构造轴。
|
||||
params = node.params.get("axis") or {}
|
||||
if params.get("origin_mm") and params.get("direction"):
|
||||
axis = AxisSpec.from_mapping(params)
|
||||
else:
|
||||
# 2. 否则从特征选择器中筛选出已解析的基准平面。
|
||||
planes = [session.resolve(selector) for selector in node.selectors if selector.get("kind") == "plane"]
|
||||
resolved = [item.record.value for item in planes if item.status == "resolved" and isinstance(item.record.value, PlaneSpec)]
|
||||
# 3. 校验:轴需要两个非平行的平面,不足两个则报错。
|
||||
if len(resolved) < 2:
|
||||
raise ValueError("reference axis requires two uniquely resolved planes")
|
||||
# 4. 用两平面法线叉积求交线方向;若方向长度接近 0 说明两平面平行,无法成轴。
|
||||
first, second = resolved[0], resolved[1]
|
||||
n1, n2 = first.normal, second.normal
|
||||
direction = vector_cross(n1, n2)
|
||||
squared_length = vector_dot(direction, direction)
|
||||
if squared_length <= 1e-18:
|
||||
raise ValueError("reference planes are parallel and cannot define an axis")
|
||||
# 5. 求交线上的一点:两平面到各自原点的垂距参与线性组合,得到交线上的最近点。
|
||||
d1 = vector_dot(n1, first.origin_mm)
|
||||
d2 = vector_dot(n2, second.origin_mm)
|
||||
point = vector_scale(vector_add(vector_scale(vector_cross(n2, direction), d1), vector_scale(vector_cross(direction, n1), d2)), 1 / squared_length)
|
||||
# 6. 由该点与归一化的交线方向组合成基准轴 AxisSpec。
|
||||
axis = AxisSpec(origin_mm=point, direction=vector_unit(direction, field_name="reference axis"))
|
||||
# 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。
|
||||
session.topology.register_context(node.feature_id, axis)
|
||||
return session.result(node, context=axis)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Dress-up executors (fillet / chamfer / shell).
|
||||
|
||||
These mutate an existing body through edge/face selectors resolved from the
|
||||
current B-rep snapshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..registry import atomic_executor
|
||||
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, TopologyDelta
|
||||
from .common import _replace_shell_target, _selector_edges, _shell_target
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。
|
||||
|
||||
# 1. 校验:圆角作用于已有主体,必须先有主体。
|
||||
if session.body is None:
|
||||
raise ValueError("fillet has no body")
|
||||
# 2. 解析圆角半径并校验必须大于 0。
|
||||
radius = float(node.params.get("radius_mm") or 0)
|
||||
if radius <= 0:
|
||||
raise ValueError("fillet radius_mm must be > 0")
|
||||
# 3. 解析目标边(支持 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"))),
|
||||
)
|
||||
# 4. 登记新主体并返回结果。
|
||||
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("fillet")
|
||||
def _fillet_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_fillet(node, session)
|
||||
|
||||
|
||||
def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 倒角特征(chamfer)执行入口:对选中边按距离做倒角(可带第二距离形成不对称倒角)。
|
||||
|
||||
# 1. 校验:倒角作用于已有主体,必须先有主体。
|
||||
if session.body is None:
|
||||
raise ValueError("chamfer has no body")
|
||||
# 2. 解析主距离并校验必须大于 0。
|
||||
distance = float(node.params.get("distance_mm") or 0)
|
||||
if distance <= 0:
|
||||
raise ValueError("chamfer distance_mm must be > 0")
|
||||
# 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出
|
||||
# angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时
|
||||
# tan=1,退化为等距倒角(与历史行为一致,零回归)。
|
||||
# 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45°
|
||||
# 倒角仅保证量级正确,距离所在侧可能反转。
|
||||
distance_2 = node.params.get("distance_2_mm")
|
||||
angle_rad = node.params.get("angle_rad")
|
||||
if distance_2 is None and angle_rad is not None:
|
||||
distance_2 = distance * math.tan(float(angle_rad))
|
||||
# 4. 解析目标边(支持相切传播),执行倒角。
|
||||
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
|
||||
diagnostics: list[RuntimeDiagnostic] = []
|
||||
topology_delta: TopologyDelta | None = None
|
||||
try:
|
||||
body, topology_delta = session.adapter.chamfer_with_topology_delta(session.body, distance, distance_2, edges)
|
||||
except ValueError as error:
|
||||
# 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准
|
||||
# OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据
|
||||
# 时按原始距离构造受限倒角;没有证明时仍保留原始内核失败。
|
||||
if distance_2 is not None or not session.surface_members:
|
||||
raise
|
||||
try:
|
||||
body = session.adapter.surface_limited_chamfer(
|
||||
session.body, distance, edges, list(session.surface_members.values()),
|
||||
)
|
||||
except ValueError:
|
||||
raise error
|
||||
diagnostics.append(RuntimeDiagnostic(
|
||||
"chamfer_surface_limited",
|
||||
"Chamfer was limited by an explicit coaxial surface boundary",
|
||||
feature_id=node.feature_id,
|
||||
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)
|
||||
return session.result(node, diagnostics=diagnostics)
|
||||
|
||||
|
||||
@atomic_executor("chamfer")
|
||||
def _chamfer_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_chamfer(node, session)
|
||||
|
||||
|
||||
def _execute_shell(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 抽壳特征:移除 selector 所指面,并按 CADFS thickness 向实体内部偏置。
|
||||
if session.body is None:
|
||||
raise ValueError("shell has no body")
|
||||
thickness = float(node.params.get("thickness_mm") or 0)
|
||||
if thickness <= 0:
|
||||
raise ValueError("shell thickness_mm must be > 0")
|
||||
target, faces = _shell_target(node, session)
|
||||
result, topology_delta = session.adapter.shell_with_topology_delta(
|
||||
target, faces, thickness, inward=bool(node.params.get("inward", True)),
|
||||
)
|
||||
session.register_body(
|
||||
node.feature_id, _replace_shell_target(session, target, result), replay_node=node,
|
||||
topology_delta=topology_delta,
|
||||
)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("shell")
|
||||
def _shell_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_shell(node, session)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Extrusion executors (blind / two-sided / cut / through / from-face)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..registry import atomic_executor
|
||||
from ..topology import FeaturePlanNode, FeatureResult
|
||||
from .common import _apply_primary_tool, _extruded_tool, _shape_from_primary
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
@atomic_executor(
|
||||
"extrude_add_blind",
|
||||
"extrude_add_blind_with_hole",
|
||||
"extrude_add_two_sided",
|
||||
"extrude_cut_blind",
|
||||
"extrude_cut_two_sided",
|
||||
"extrude_cut_through",
|
||||
)
|
||||
def _primary_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
return _shape_from_primary(node, session, sketch=sketch)
|
||||
|
||||
|
||||
def _execute_extrude_from_face(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
resolved = [session.resolve(selector) for selector in node.selectors]
|
||||
failed = next((item for item in resolved if item.status != "resolved"), None)
|
||||
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
|
||||
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "derived profile face is unresolved")
|
||||
face = resolved[0].record.value
|
||||
tool, topology_delta = _extruded_tool(node, [face], session.adapter.face_normal(face), session)
|
||||
return _apply_primary_tool(
|
||||
node, session, tool, cutting=node.params.get("operation") == "cut", topology_delta=topology_delta,
|
||||
)
|
||||
|
||||
|
||||
@atomic_executor("extrude_from_face")
|
||||
def _extrude_from_face_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_extrude_from_face(node, session)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Hole executors (hole_blind / hole_countersink / hole_counterbore / hole_wizard)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..registry import atomic_executor
|
||||
from ..specs import HoleSpec, PlaneSpec, vector_scale
|
||||
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic
|
||||
from .common import _hole_starts, _host_plane
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
@atomic_executor("hole_blind", "hole_countersink", "hole_counterbore")
|
||||
def _hole_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_hole(node, session, wizard=False)
|
||||
|
||||
|
||||
@atomic_executor("hole_wizard")
|
||||
def _hole_wizard_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_hole(node, session, wizard=True)
|
||||
|
||||
|
||||
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")
|
||||
# 2. 确定宿主面 host_face:
|
||||
host_selector = node.params.get("host_face")
|
||||
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
|
||||
# 若直接带 frame(平面定义),则以该平面为宿主,孔位按局部坐标解释。
|
||||
host = PlaneSpec.from_mapping(host_selector["frame"])
|
||||
positions_are_local = True
|
||||
else:
|
||||
# 否则从特征选择器中取 face,解析出宿主平面,孔位按世界坐标解释。
|
||||
selectors = list(node.selectors)
|
||||
if isinstance(host_selector, dict):
|
||||
selectors.append(host_selector)
|
||||
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
|
||||
# 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
|
||||
# always enters the material. Inferring direction from the global body
|
||||
# centre fails for concave or multi-leg parts: for example, the top face
|
||||
# of an L bracket can sit below the whole body's centre and the old rule
|
||||
# drilled outward, producing a no-op feature reported as successful.
|
||||
# The selected topology face is the local, authoritative orientation.
|
||||
inward = vector_scale(host.normal, -1)
|
||||
# 5. 生成孔切除工具:按孔规格、起始位置、内方向及“贯穿到主体底面”的深度构造工具实体。
|
||||
tool = session.adapter.hole_tool(
|
||||
spec,
|
||||
_hole_starts(spec, host_plane=host, positions_are_local=positions_are_local),
|
||||
inward,
|
||||
session.adapter.body_span(session.body, inward) + 2.0,
|
||||
)
|
||||
# 6. 从主体上减去工具实体,登记新主体并返回结果。
|
||||
# thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔
|
||||
# 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告
|
||||
# 追溯降级数量(issue #9,capabilities 已不再拒绝 thread)。
|
||||
diagnostics: list[RuntimeDiagnostic] = []
|
||||
if wizard and node.params.get("thread"):
|
||||
diagnostics.append(RuntimeDiagnostic(
|
||||
code="thread_decoration_ignored",
|
||||
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)
|
||||
return session.result(node, diagnostics=diagnostics)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Loft and sweep executors (loft_add / loft_add_with_cap_face / sweep_add)."""
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
def _execute_loft_add(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 放样截面不占用 feature.sketch_id;按有序 profile_sketch_ids 取已解析
|
||||
# 草图,并由 adapter 统一校验单闭环、无内环等内核输入约束。
|
||||
profile_ids = node.params.get("profile_sketch_ids") or []
|
||||
profiles: list[dict[str, Any]] = []
|
||||
for sketch_id in profile_ids:
|
||||
sketch = session.sketches.get(str(sketch_id))
|
||||
if sketch is None:
|
||||
raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved")
|
||||
profiles.append(sketch)
|
||||
solid, topology_delta = session.adapter.loft_with_topology_delta(profiles)
|
||||
body = session.adapter.fuse(session.body, solid)
|
||||
# Fusing a loft into an existing body replaces its subshapes through a
|
||||
# different builder. Only an initial direct loft can expose this builder's
|
||||
# cap evidence for the final B-rep snapshot.
|
||||
if session.body is not None:
|
||||
topology_delta = None
|
||||
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("loft_add")
|
||||
def _loft_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_loft_add(node, session)
|
||||
|
||||
|
||||
def _execute_loft_add_with_cap_face(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
resolved = [session.resolve(selector) for selector in node.selectors]
|
||||
failed = next((item for item in resolved if item.status != "resolved"), None)
|
||||
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
|
||||
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "cap-face loft selector is unresolved")
|
||||
profile_ids = node.params.get("profile_sketch_ids") or []
|
||||
profiles: list[dict[str, Any]] = []
|
||||
for sketch_id in profile_ids:
|
||||
sketch = session.sketches.get(str(sketch_id))
|
||||
if sketch is None:
|
||||
raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved")
|
||||
profiles.append(sketch)
|
||||
solid = session.adapter.loft_with_cap_face(resolved[0].record.value, profiles)
|
||||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("loft_add_with_cap_face")
|
||||
def _loft_cap_face_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_loft_add_with_cap_face(node, session)
|
||||
|
||||
|
||||
def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None = None) -> FeatureResult:
|
||||
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)
|
||||
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)),
|
||||
)
|
||||
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
|
||||
# final aggregate's relations. The independent-body path retains its exact
|
||||
# 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)
|
||||
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)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Parametric feature executors (thread / bend / gear / rack).
|
||||
|
||||
Each executor parses its runtime-neutral spec from ``runtime_types`` specs,
|
||||
asks the geometry adapter to build and place the local-frame solid, then
|
||||
fuses it into the active body (or subtracts, for thread_cut).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..registry import atomic_executor
|
||||
from ..specs import BendSpec, GearSpec, RackSpec, ThreadSpec
|
||||
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic
|
||||
from .common import _register_added_solid
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
def _execute_thread(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。
|
||||
# 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。
|
||||
spec = ThreadSpec.from_feature(node.atomic_id, node.params)
|
||||
# 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。
|
||||
solid = session.adapter.thread_solid(spec)
|
||||
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||
_register_added_solid(session, node, solid)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
def _execute_thread_cut(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 内螺纹(thread_cut)执行入口:ThreadSpec.from_feature 对 thread_cut 恒置
|
||||
# internal=True,生成牙顶外放 INTERNAL_CUT_OVERLAP_MM 的切削刀具,沿
|
||||
# spec.axis 放置后从当前主体布尔差出全深螺旋牙槽(宿主通常已预打光孔,
|
||||
# 刀具 core 落在孔腔中,仅外放的牙槽层切入孔壁)。
|
||||
# 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。
|
||||
spec = ThreadSpec.from_feature(node.atomic_id, node.params)
|
||||
# 2. 由适配器门面生成沿 spec.axis 放置的内螺纹切削刀具实心段。
|
||||
tool = session.adapter.thread_solid(spec)
|
||||
# 3. 从当前主体布尔差(cut)后登记为新主体,并返回该特征的结果对象。
|
||||
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("thread_add", "thread_cut")
|
||||
def _thread_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
# 螺纹特征(thread_add/thread_cut)不需要草图平面,丢弃该参数后执行。
|
||||
# thread_cut 走布尔差分支:从已有主体切出内螺纹槽,而非并入外螺纹段。
|
||||
del sketch
|
||||
if node.atomic_id == "thread_cut":
|
||||
return _execute_thread_cut(node, session)
|
||||
return _execute_thread(node, session)
|
||||
|
||||
|
||||
def _execute_bend(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 折弯特征(bend_add)执行入口:按规格生成等厚折弯板并并入当前主体。
|
||||
# 1. 解析并校验板厚/宽度/折痕链与放置平面,非法输入抛出带具体原因的 ValueError。
|
||||
spec = BendSpec.from_feature(node.params)
|
||||
# 2. 由适配器门面生成沿 spec.frame 放置的折弯实心段。
|
||||
solid = session.adapter.bend_solid(spec)
|
||||
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||
_register_added_solid(session, node, solid)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("bend_add")
|
||||
def _bend_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
# 折弯特征(bend_add)不需要草图平面,丢弃该参数后执行。
|
||||
del sketch
|
||||
return _execute_bend(node, session)
|
||||
|
||||
|
||||
def _execute_gear(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 齿轮特征(gear_add)执行入口:按规格生成渐开线齿轮并入当前主体。
|
||||
# 1. 解析并校验模数/齿数/齿宽/螺旋角与放置轴,非法输入抛出带具体原因的 ValueError。
|
||||
spec = GearSpec.from_feature(node.params)
|
||||
# 2. 由适配器门面生成沿 spec.axis 放置的齿轮实体(直齿/斜齿/人字齿)。
|
||||
solid = session.adapter.gear_solid(spec)
|
||||
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||
_register_added_solid(session, node, solid)
|
||||
# 4. 小齿数根切风险:不阻断执行,附加 info 级诊断供完成报告如实披露。
|
||||
diagnostics: list[RuntimeDiagnostic] = []
|
||||
if spec.teeth_count < 17:
|
||||
diagnostics.append(RuntimeDiagnostic(
|
||||
code="undercut_risk",
|
||||
message=(
|
||||
f"Gear with {spec.teeth_count} teeth and a 20 degree pressure angle is undercut-prone; "
|
||||
"standard involute geometry is generated without profile shift"
|
||||
),
|
||||
feature_id=node.feature_id,
|
||||
))
|
||||
return session.result(node, diagnostics=diagnostics)
|
||||
|
||||
|
||||
@atomic_executor("gear_add")
|
||||
def _gear_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
# 齿轮特征(gear_add)不需要草图平面,丢弃该参数后执行。
|
||||
del sketch
|
||||
return _execute_gear(node, session)
|
||||
|
||||
|
||||
def _execute_rack(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 齿条特征(rack_add)执行入口:按规格生成直线齿条并入当前主体。
|
||||
# 1. 解析并校验模数/齿数/厚度/压力角与放置轴,非法输入抛出带具体原因的 ValueError。
|
||||
spec = RackSpec.from_feature(node.params)
|
||||
# 2. 由适配器门面生成沿 spec.axis 放置的齿条实体(齿沿轴方向伸出)。
|
||||
solid = session.adapter.rack_solid(spec)
|
||||
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||
_register_added_solid(session, node, solid)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("rack_add")
|
||||
def _rack_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
# 齿条特征(rack_add)不需要草图平面,丢弃该参数后执行。
|
||||
del sketch
|
||||
return _execute_rack(node, session)
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Pattern executors (pattern_linear / pattern_mirror / pattern_circular).
|
||||
|
||||
Instances replay their source features with transformed parameters rather
|
||||
than copying the current body. NEW-body sources can additionally be
|
||||
instanced as rigid body-graph copies, keeping each instance independently
|
||||
addressable for later COPY/DELETE queries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from ..capabilities import pattern_transform_blocker
|
||||
from ..pattern_transform import (
|
||||
_box_circular_is_exact,
|
||||
_mirrored_node,
|
||||
_mirrored_sketch,
|
||||
_normal_is_coordinate_axis,
|
||||
_pattern_operation_node,
|
||||
_rotated_node,
|
||||
_rotated_sketch,
|
||||
_translated_node,
|
||||
_translated_sketch,
|
||||
)
|
||||
from ..registry import atomic_executor, execute_node
|
||||
from ..specs import AxisSpec, PlaneSpec, Vector3, pattern_instance_member_id, vector_add, vector_dot, vector_scale, vector_subtract, vector_unit
|
||||
from ..topology import FeaturePlanNode, FeatureResult, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
ExecutorFunction = Callable[[FeaturePlanNode, "ExecutionSession", dict[str, Any] | None], FeatureResult]
|
||||
|
||||
|
||||
def _execute_linear_pattern(
|
||||
node: FeaturePlanNode,
|
||||
session: "ExecutionSession",
|
||||
execute: ExecutorFunction,
|
||||
) -> FeatureResult:
|
||||
# 线性阵列特征(pattern)执行入口:沿两个方向按数量与间距重放源特征形成阵列。
|
||||
|
||||
# 1. 取源特征的 replay 定义(源特征按 feature_id 在会话中登记,供本阵列重放)。
|
||||
params = node.params
|
||||
sources = session.replay_sources(params.get("source_feature_ids") or [])
|
||||
if not sources:
|
||||
raise ValueError("pattern source features have no replay definitions")
|
||||
# 2. 解析两个方向的实例数量。
|
||||
count_1 = int(params.get("pattern_count_1") or 1)
|
||||
count_2 = int(params.get("pattern_count_2") or 1)
|
||||
# 3. 解析两个方向的步长向量(方向单位向量 × 间距),作为阵列位移基准。
|
||||
direction_1 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_1") or [1, 0, 0])), field_name="pattern direction_1"), float(params.get("spacing_1_mm") or 0))
|
||||
direction_2 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_2") or [0, 1, 0])), field_name="pattern direction_2"), float(params.get("spacing_2_mm") or 0))
|
||||
# 4. 双重循环生成每个阵列实例(跳过原点 0,0 处,那里是源特征本身)。
|
||||
for first in range(count_1):
|
||||
for second in range(count_2):
|
||||
if first == 0 and second == 0:
|
||||
continue
|
||||
# 计算当前实例相对源特征的偏移向量。
|
||||
offset = vector_add(vector_scale(direction_1, first), vector_scale(direction_2, second))
|
||||
for source in sources:
|
||||
# 逐个源特征克隆并按偏移平移后重放执行(草图也同步平移)。
|
||||
dependency = pattern_transform_blocker(source)
|
||||
if dependency:
|
||||
raise ValueError(f"pattern source uses an unsupported {dependency}")
|
||||
cloned = _translated_node(source, f"{node.feature_id}.p{first}_{second}.{source.feature_id}", offset, session)
|
||||
sketch = session.sketches.get(str(source.sketch_id))
|
||||
execute(cloned, session, _translated_sketch(sketch, offset) if sketch else None)
|
||||
# 5. 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放,
|
||||
# 而非复制当前主体做近似。
|
||||
# A later pattern may select this pattern feature. The definition is
|
||||
# replayed recursively, never approximated by copying the current body.
|
||||
session.replay_definitions[node.feature_id] = node
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("pattern_linear")
|
||||
def _linear_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_linear_pattern(node, session, execute_node)
|
||||
|
||||
|
||||
def _execute_mirror_pattern(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
mirror = node.params.get("mirror_plane") or {}
|
||||
resolution = session.resolve(mirror)
|
||||
if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec):
|
||||
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "mirror plane was not resolved")
|
||||
source_ids = [str(value) for value in node.params.get("source_feature_ids") or ()]
|
||||
if (
|
||||
source_ids
|
||||
and all(source_id in session.body_members for source_id in source_ids)
|
||||
and all(
|
||||
(source := session.nodes.get(source_id)) is not None
|
||||
and source.params.get("result_mode") == "new_body"
|
||||
for source_id in source_ids
|
||||
)
|
||||
):
|
||||
# Only a direct NEW body has a standalone source identity after a
|
||||
# mirror. A hole, dress-up, or ordinary additive source is merely an
|
||||
# aggregate successor and must use the feature-replay path below.
|
||||
# Keeping this condition identical to capability preflight prevents a
|
||||
# downstream COPY body query from selecting an arbitrary aggregate.
|
||||
members = dict(session.body_members)
|
||||
body = session.body
|
||||
for source_id in source_ids:
|
||||
mirrored = session.adapter.mirror(session.body_members[source_id], resolution.record.value)
|
||||
members[pattern_instance_member_id(node.feature_id, source_id, 1)] = mirrored
|
||||
body = session.adapter.fuse(body, mirrored)
|
||||
if body is None:
|
||||
raise ValueError("mirror pattern produced no body")
|
||||
session.register_body(node.feature_id, body, replay_node=node, body_members=members)
|
||||
return session.result(node)
|
||||
if node.params.get("mirror_current_body"):
|
||||
# CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制
|
||||
# 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会
|
||||
# 丢失后续 cut/fillet 并生成独立错误实体。
|
||||
if session.body is None:
|
||||
raise ValueError("mirror current body has no active body")
|
||||
mirrored = session.adapter.mirror(session.body, resolution.record.value)
|
||||
session.register_body(node.feature_id, session.adapter.fuse(session.body, mirrored), replay_node=node)
|
||||
return session.result(node)
|
||||
sources = session.replay_sources(node.params.get("source_feature_ids") or [])
|
||||
if not sources:
|
||||
raise ValueError("mirror pattern source features have no replay definitions")
|
||||
for source in sources:
|
||||
dependency = pattern_transform_blocker(source)
|
||||
if dependency:
|
||||
raise ValueError(f"mirror pattern source uses an unsupported {dependency}")
|
||||
if source.atomic_id == "box_add" and not _normal_is_coordinate_axis(resolution.record.value.normal):
|
||||
# box_add 是固定世界轴对齐的原生图元:跨非坐标平面镜像会产生倾斜朝向,
|
||||
# 当前参数语义无法表达,静默重放会得到错误几何 → 明确拒绝。跨坐标平面
|
||||
# (法向平行于任一坐标轴)的镜像仍然精确。
|
||||
raise ValueError("box_add mirror is exact only across coordinate-aligned mirror planes")
|
||||
cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session)
|
||||
sketch = session.sketches.get(str(source.sketch_id))
|
||||
execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None)
|
||||
session.replay_definitions[node.feature_id] = node
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("pattern_mirror")
|
||||
def _mirror_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_mirror_pattern(node, session)
|
||||
|
||||
|
||||
def _circular_source_is_axisymmetric(node: FeaturePlanNode, session: "ExecutionSession", axis: AxisSpec) -> bool:
|
||||
"""Whether rotating a direct circular extrusion creates no new geometry."""
|
||||
if node.atomic_id not in {"extrude_add_blind", "extrude_add_two_sided"}:
|
||||
return False
|
||||
sketch = session.sketches.get(str(node.sketch_id))
|
||||
if sketch is None:
|
||||
return False
|
||||
profile = sketch.get("profile") or {}
|
||||
circle = profile if profile.get("type") == "circle" else None
|
||||
if circle is None:
|
||||
contours = profile.get("contours") or []
|
||||
segments = (contours[0] or {}).get("segments") if len(contours) == 1 else []
|
||||
circle = segments[0] if isinstance(segments, list) and len(segments) == 1 and segments[0].get("type") == "circle" else None
|
||||
center = (circle or {}).get("center")
|
||||
if not isinstance(center, list) or len(center) != 2:
|
||||
return False
|
||||
try:
|
||||
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if abs(vector_dot(plane.normal, axis.direction)) < 1 - 1e-7:
|
||||
return False
|
||||
world_center = vector_add(
|
||||
plane.origin_mm,
|
||||
vector_add(vector_scale(plane.x_dir, float(center[0])), vector_scale(plane.y_dir, float(center[1]))),
|
||||
)
|
||||
offset = vector_subtract(world_center, axis.origin_mm)
|
||||
radial = vector_subtract(offset, vector_scale(axis.direction, vector_dot(offset, axis.direction)))
|
||||
return math.sqrt(vector_dot(radial, radial)) <= 1e-6
|
||||
|
||||
|
||||
def _advance_copy_topology_records(
|
||||
records: list[TopologyRecord], topology_delta: TopologyDelta | None,
|
||||
) -> list[TopologyRecord]:
|
||||
"""Carry COPY provenance through one exact adapter-history operation.
|
||||
|
||||
Pattern copies are separate CDSL results even when their solids fuse into
|
||||
a single final body. The temporary records here are never selector
|
||||
candidates themselves. They only retain instance ownership while opaque
|
||||
OCC history proves a unique subshape continuation to the final snapshot.
|
||||
"""
|
||||
if topology_delta is None:
|
||||
return []
|
||||
advanced: list[TopologyRecord] = []
|
||||
for record in records:
|
||||
values: list[Any] = []
|
||||
for relation in topology_delta.relations:
|
||||
if (
|
||||
relation.kind != record.kind
|
||||
or relation.event not in {"preserved", "modified"}
|
||||
or not TopologyRegistry._same_topology_value(record.value, relation.source_value)
|
||||
):
|
||||
continue
|
||||
for value in relation.result_values:
|
||||
if not any(TopologyRegistry._same_topology_value(value, known) for known in values):
|
||||
values.append(value)
|
||||
# A split/merge has no unique COPY owner in the present selector
|
||||
# contract. Keep the executable model, but do not make a claim that a
|
||||
# later COPY selector can bind one arbitrary descendant.
|
||||
if len(values) != 1:
|
||||
continue
|
||||
advanced.append(TopologyRecord(
|
||||
record_id=record.record_id,
|
||||
kind=record.kind,
|
||||
feature_id=record.feature_id,
|
||||
body_id=record.body_id,
|
||||
geometry=dict(record.geometry),
|
||||
value=values[0],
|
||||
owner_feature_ids=record.owners,
|
||||
output_roles=record.output_roles,
|
||||
output_role_sources=record.output_role_sources,
|
||||
))
|
||||
return advanced
|
||||
|
||||
|
||||
def _copy_snapshot_topology_delta(records: list[TopologyRecord]) -> TopologyDelta | None:
|
||||
"""Bridge traced final COPY handles into the one registered body snapshot."""
|
||||
if not records:
|
||||
return None
|
||||
return TopologyDelta(
|
||||
operation="pattern_circular_copy_snapshot",
|
||||
relations=tuple(
|
||||
# ``record.value`` has already passed through every transform/fuse
|
||||
# builder in this pattern and is an actual final-B-rep handle. The
|
||||
# identity relation merely connects that evidence to the fresh
|
||||
# adapter snapshot; it is not a geometric rebinding shortcut.
|
||||
TopologyDeltaRelation("preserved", record.kind, record.value, (record.value,))
|
||||
for record in records
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _has_usable_pattern_body(session: "ExecutionSession", body: Any | None) -> bool:
|
||||
"""Reject a formally valid but empty OCC boolean result before publishing it."""
|
||||
if body is None or not session.adapter.body_solids(body):
|
||||
return False
|
||||
try:
|
||||
return abs(float(body.volume)) > 1e-12
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _execute_circular_pattern(
|
||||
node: FeaturePlanNode,
|
||||
session: "ExecutionSession",
|
||||
execute: ExecutorFunction,
|
||||
) -> FeatureResult:
|
||||
# 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征
|
||||
# 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。
|
||||
params = node.params
|
||||
raw_axis = params.get("axis")
|
||||
if not (isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None):
|
||||
raise ValueError("circular pattern requires an explicit axis with origin_mm and direction")
|
||||
axis = AxisSpec.from_mapping(raw_axis)
|
||||
count = int(params.get("pattern_count") or 1)
|
||||
if count < 1:
|
||||
raise ValueError("circular pattern pattern_count must be >= 1")
|
||||
sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0)
|
||||
operation_mode = str(params.get("operation_mode") or "add")
|
||||
if operation_mode not in {"add", "remove"}:
|
||||
raise ValueError("circular pattern operation_mode must be add or remove")
|
||||
excluded = {int(value) for value in params.get("excluded_instance_indices") or []}
|
||||
if any(instance < 1 or instance >= count for instance in excluded):
|
||||
raise ValueError("circular pattern excluded instance is outside the generated range")
|
||||
sources = session.replay_sources(params.get("source_feature_ids") or [])
|
||||
if not sources:
|
||||
raise ValueError("circular pattern source features have no replay definitions")
|
||||
source_ids = [source.feature_id for source in sources]
|
||||
pre_pattern_members = dict(session.body_members)
|
||||
if operation_mode == "add" and all(source_id in session.body_members for source_id in source_ids):
|
||||
# A pattern over explicit NEW/kept body members has a stronger contract
|
||||
# than replay: each copy is an independently addressable rigid image of
|
||||
# the named source member. Keep the instance keys in the body graph so
|
||||
# a later CADFS COPY(BODY) transform/delete can name exactly one copy.
|
||||
members = dict(session.body_members)
|
||||
body = session.body
|
||||
traced_copy_records: list[TopologyRecord] = []
|
||||
for instance in range(1, count):
|
||||
if instance in excluded:
|
||||
continue
|
||||
angle_deg = sweep_angle_deg * instance / count
|
||||
transform = {
|
||||
"type": "rotation",
|
||||
"axis": {"origin_mm": list(axis.origin_mm), "direction": list(axis.direction)},
|
||||
"angle_deg": angle_deg,
|
||||
}
|
||||
for source_id in source_ids:
|
||||
member_id = pattern_instance_member_id(node.feature_id, source_id, instance)
|
||||
owner_id = f"{node.feature_id}.c{instance}.{source_id}"
|
||||
source_body = session.body_members[source_id]
|
||||
copy, transform_delta = session.adapter.transform_with_topology_delta(source_body, transform)
|
||||
source_records = session.adapter.topology_records(
|
||||
source_body, owner_id, f"body:{node.feature_id}:copy:{instance}:{source_id}:source",
|
||||
)
|
||||
copy_records = _advance_copy_topology_records(source_records, transform_delta)
|
||||
members[member_id] = copy
|
||||
body, fuse_delta = session.adapter.fuse_with_topology_delta(body, copy)
|
||||
traced_copy_records = _advance_copy_topology_records(
|
||||
[*traced_copy_records, *copy_records], fuse_delta,
|
||||
)
|
||||
if _has_usable_pattern_body(session, body):
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, body_members=members,
|
||||
topology_delta=_copy_snapshot_topology_delta(traced_copy_records),
|
||||
topology_predecessors=traced_copy_records,
|
||||
)
|
||||
return session.result(node)
|
||||
# An OCC boolean may report IsDone/valid for an empty result when a
|
||||
# copied fused body contains coincident internal topology. The normal
|
||||
# pattern contract can replay the source feature contribution instead;
|
||||
# it is the only sound fallback because it keeps source operation,
|
||||
# sketch frame, and body lifecycle semantics intact.
|
||||
for instance in range(1, count):
|
||||
if instance in excluded:
|
||||
continue
|
||||
# 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。
|
||||
angle_deg = sweep_angle_deg * instance / count
|
||||
angle_rad = math.radians(angle_deg)
|
||||
for source in sources:
|
||||
# 与阵列轴同心、法向平行的圆形实体拉伸在任意环形实例中均与
|
||||
# 原实体完全重合。重复执行它会把同一 B-rep 再次交给 OCC fuse,
|
||||
# 后续非轴对称 source 可能因此丢失已生成的实体分支。
|
||||
if _circular_source_is_axisymmetric(source, session, axis):
|
||||
continue
|
||||
dependency = pattern_transform_blocker(source)
|
||||
if dependency:
|
||||
raise ValueError(f"circular pattern source uses an unsupported {dependency}")
|
||||
if source.atomic_id == "box_add" and not _box_circular_is_exact(axis, angle_rad):
|
||||
raise ValueError(
|
||||
"box_add circular pattern is exact only for coordinate-axis rotation "
|
||||
"by multiples of 180 degrees"
|
||||
)
|
||||
cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session)
|
||||
cloned = _pattern_operation_node(cloned, operation_mode)
|
||||
# CADFS pattern instances are copies of the source result, not
|
||||
# independent `NEW` operations. Replay them through normal add
|
||||
# semantics: intersecting or face-sharing instances fuse, while
|
||||
# spatially separate copies remain separate solids in the result.
|
||||
if cloned.params.get("result_mode") == "new_body":
|
||||
cloned = FeaturePlanNode(
|
||||
cloned.feature_id, cloned.atomic_id, cloned.name, cloned.depends_on,
|
||||
{key: value for key, value in cloned.params.items() if key != "result_mode"},
|
||||
cloned.selectors, cloned.sketch_id, cloned.declared_status, cloned.source_feature,
|
||||
)
|
||||
sketch = session.sketches.get(str(source.sketch_id))
|
||||
execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None)
|
||||
# 环形阵列本身是完整 B-rep 结果的 producer。每个 replay 子特征都会更新
|
||||
# active body;循环结束后必须用 pattern feature 重新登记最终快照,否则后续
|
||||
# selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。
|
||||
if session.body is None:
|
||||
raise ValueError("circular pattern produced no body")
|
||||
# Replaying a fused sole-body source may be more robust than copying its
|
||||
# full aggregate B-rep (for example, when a rotationally invariant base
|
||||
# would otherwise be unioned with itself). If that replay still has one
|
||||
# physical body, the direct source remains a proven alias of the current
|
||||
# member. Preserve it for a following parts-scoped operation such as
|
||||
# shell; do not extend this alias across multi-body patterns or multiple
|
||||
# source members.
|
||||
members = {node.feature_id: session.body}
|
||||
if (
|
||||
len(source_ids) == 1
|
||||
and len(pre_pattern_members) == 1
|
||||
and source_ids[0] in pre_pattern_members
|
||||
and _has_usable_pattern_body(session, session.body)
|
||||
and len(session.adapter.body_solids(session.body)) == 1
|
||||
):
|
||||
members[source_ids[0]] = session.body
|
||||
session.register_body(node.feature_id, session.body, replay_node=node, body_members=members)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("pattern_circular")
|
||||
def _circular_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_circular_pattern(node, session, execute_node)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Analytic primitive executors (sphere_add / box_add / cylinder_add)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..registry import atomic_executor
|
||||
from ..specs import AxisSpec, PlaneSpec
|
||||
from ..topology import FeaturePlanNode, FeatureResult
|
||||
from .common import _register_added_solid
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
def _execute_sphere(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 球体特征(sphere_add)执行入口:按球心与半径生成球体并并入当前主体。
|
||||
|
||||
# 1. 解析参数:半径 radius_mm 与球心 center_mm。
|
||||
radius = float(node.params.get("radius_mm") or 0.0)
|
||||
center = node.params.get("center_mm") or []
|
||||
# 2. 校验:半径必须大于 0,球心必须是三维坐标。
|
||||
if radius <= 0 or len(center) != 3:
|
||||
raise ValueError("sphere_add requires radius_mm and a three-dimensional center_mm")
|
||||
# 3. 由适配器创建球体实体。
|
||||
solid = session.adapter.sphere(radius, (float(center[0]), float(center[1]), float(center[2])))
|
||||
# 4. 球体与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||
_register_added_solid(session, node, solid)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
def _execute_box(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 长方体特征(box_add)执行入口:以几何中心 center_mm 与三向尺寸生成原生长方体。
|
||||
# 1. 解析并校验尺寸与中心,非法输入抛出带具体原因的 ValueError。
|
||||
try:
|
||||
length = float(node.params.get("length_mm") or 0.0)
|
||||
width = float(node.params.get("width_mm") or 0.0)
|
||||
height = float(node.params.get("height_mm") or 0.0)
|
||||
center = node.params.get("center_mm") or []
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("box dimensions must be numeric") from error
|
||||
if length <= 0 or width <= 0 or height <= 0 or len(center) != 3:
|
||||
raise ValueError("box_add requires positive length_mm/width_mm/height_mm and a three-dimensional center_mm")
|
||||
# 2. 生成世界轴对齐的 plane frame:plane 原点是长方体的最小角点(中心减去半
|
||||
# 尺寸),长/宽/高分别沿世界 x/y/z 生长(build123d Solid.make_box 语义)。
|
||||
corner = (
|
||||
float(center[0]) - length / 2,
|
||||
float(center[1]) - width / 2,
|
||||
float(center[2]) - height / 2,
|
||||
)
|
||||
plane = PlaneSpec.from_mapping({"origin_mm": corner, "x_dir": [1, 0, 0], "normal": [0, 0, 1]})
|
||||
solid = session.adapter.box(length, width, height, plane)
|
||||
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
||||
_register_added_solid(session, node, solid)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
def _execute_cylinder(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 圆柱特征(cylinder_add)执行入口:axis 的原点是底面圆心、方向为轴向;
|
||||
# axis 缺省为世界 +Z 过原点(底面圆心落在 (0,0,0))。
|
||||
# 1. 解析并校验半径与高度,非法输入抛出带具体原因的 ValueError。
|
||||
try:
|
||||
radius = float(node.params.get("radius_mm") or 0.0)
|
||||
height = float(node.params.get("height_mm") or 0.0)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("cylinder dimensions must be numeric") from error
|
||||
if radius <= 0 or height <= 0:
|
||||
raise ValueError("cylinder_add requires positive radius_mm and height_mm")
|
||||
raw_axis = node.params.get("axis")
|
||||
if raw_axis is not None and not (
|
||||
isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None
|
||||
):
|
||||
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, topology_delta = session.adapter.cylinder_with_topology_delta(radius, height, axis)
|
||||
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
||||
_register_added_solid(session, node, solid, topology_delta=topology_delta)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("sphere_add")
|
||||
def _sphere_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_sphere(node, session)
|
||||
|
||||
|
||||
@atomic_executor("box_add")
|
||||
def _box_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_box(node, session)
|
||||
|
||||
|
||||
@atomic_executor("cylinder_add")
|
||||
def _cylinder_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_cylinder(node, session)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Revolution solid executors (revolve_add / revolve_cut)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..registry import atomic_executor
|
||||
from ..topology import FeaturePlanNode, FeatureResult
|
||||
from .common import _shape_from_primary
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
@atomic_executor("revolve_add", "revolve_cut")
|
||||
def _primary_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
return _shape_from_primary(node, session, sketch=sketch)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Surface-feature executors (extrude_surface / revolve_surface).
|
||||
|
||||
Surface features register an independent shell and never touch the active
|
||||
solid body's fuse/cut lifecycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..extents import _normal_from_sketch
|
||||
from ..registry import atomic_executor
|
||||
from ..specs import AxisSpec, PlaneSpec, vector_cross, vector_dot, vector_scale, vector_unit
|
||||
from ..topology import FeaturePlanNode, FeatureResult
|
||||
from .common import _revolve_axis, _validate_revolve_axis_in_sketch_plane
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
def _execute_revolve_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# Surface revolve 的 profile 是单一闭合 wire。它只生成独立 shell,不能参与
|
||||
# 当前实体 body 的 fuse/cut,也不能把其结果误报为新的实体 body。
|
||||
sketch = session.sketches.get(str(node.sketch_id))
|
||||
if sketch is None:
|
||||
raise ValueError("surface revolve has no resolved sketch")
|
||||
faces = session.adapter.faces_for_sketch(sketch)
|
||||
if len(faces) != 1 or faces[0].inner_wires():
|
||||
raise ValueError("surface revolve requires exactly one closed profile without holes")
|
||||
axis = _revolve_axis(node, session)
|
||||
_validate_revolve_axis_in_sketch_plane(axis, sketch)
|
||||
angle = float(node.params.get("angle_deg") or 0.0)
|
||||
if angle <= 0:
|
||||
raise ValueError("surface revolve requires angle_deg > 0")
|
||||
if bool(node.params.get("reverse")):
|
||||
angle = -angle
|
||||
surface_id = session.register_surface(
|
||||
node.feature_id,
|
||||
session.adapter.revolve_surface(faces[0].outer_wire(), angle, axis),
|
||||
)
|
||||
return session.result(node, include_body=False, surface_id=surface_id)
|
||||
|
||||
|
||||
@atomic_executor("revolve_surface")
|
||||
def _revolve_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_revolve_surface(node, session)
|
||||
|
||||
|
||||
def _execute_extrude_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# surfaceEntities 的曲面拉伸沿用实体特征已 lower 的距离,但始终独立登记为
|
||||
# shell。它既不改变 active solid,也不以曲面参与实体 fuse/cut。
|
||||
sketch = session.sketches.get(str(node.sketch_id))
|
||||
if sketch is None:
|
||||
raise ValueError("surface extrude has no resolved sketch")
|
||||
direction = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
|
||||
if bool(node.params.get("reverse")):
|
||||
direction = vector_scale(direction, -1)
|
||||
distance = float(node.params.get("distance_mm") or 0.0)
|
||||
if distance <= 0:
|
||||
raise ValueError("surface extrude requires distance_mm > 0")
|
||||
wires = session.adapter.surface_wires_for_sketch(sketch)
|
||||
surface = session.adapter.extrude_surface(wires, vector_scale(direction, distance))
|
||||
reverse_distance = float(node.params.get("reverse_distance_mm") or 0.0)
|
||||
if reverse_distance > 0:
|
||||
opposite = session.adapter.extrude_surface(wires, vector_scale(direction, -reverse_distance))
|
||||
surface = session.adapter.combine_surfaces(surface, opposite)
|
||||
surface_id = session.register_surface(node.feature_id, surface)
|
||||
return session.result(node, include_body=False, surface_id=surface_id)
|
||||
|
||||
|
||||
@atomic_executor("extrude_surface")
|
||||
def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_extrude_surface(node, session)
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Extrusion/termination-condition planning for the session runtime.
|
||||
|
||||
These helpers turn a CDSL feature's end condition (blind, mid-plane,
|
||||
through-all, up-to-surface, ...) into one or more :class:`ExtentVector`
|
||||
displacements. They depend on the session only through its adapter and
|
||||
selector resolution, never on executors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .runtime_base import ExtentVector, FeatureExecutionError
|
||||
from .specs import PlaneSpec, Vector3, vector_dot, vector_scale, vector_subtract, vector_unit
|
||||
from .topology import FeaturePlanNode
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from .session import ExecutionSession
|
||||
|
||||
|
||||
def _normal_from_sketch(sketch: dict[str, Any]) -> Vector3:
|
||||
return PlaneSpec.from_mapping(sketch.get("workplane") or {}).normal
|
||||
|
||||
|
||||
def _extent_reference(node: FeaturePlanNode, condition: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
condition = condition or node.params.get("end_condition") or {}
|
||||
reference = condition.get("reference")
|
||||
if not isinstance(reference, dict):
|
||||
raise FeatureExecutionError(
|
||||
"missing_extent_reference",
|
||||
"This end condition requires a captured target selector",
|
||||
extent=condition.get("type"),
|
||||
)
|
||||
return reference
|
||||
|
||||
|
||||
def _targeted_extent_vector(
|
||||
node: FeaturePlanNode,
|
||||
faces: list[Any],
|
||||
direction: Vector3,
|
||||
session: "ExecutionSession",
|
||||
condition: str,
|
||||
*,
|
||||
end_condition: dict[str, Any] | None = None,
|
||||
offset_mm: float | None = None,
|
||||
) -> ExtentVector:
|
||||
if 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
|
||||
else:
|
||||
reference = _extent_reference(node, end_condition)
|
||||
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")
|
||||
expected_kind = {"up_to_vertex": "vertex", "up_to_body": "body"}.get(condition, "face")
|
||||
if resolution.record.kind != expected_kind:
|
||||
raise FeatureExecutionError(
|
||||
"unsupported_extent_target",
|
||||
"The resolved target kind is incompatible with this end condition",
|
||||
extent=condition, expected_kind=expected_kind, actual_kind=resolution.record.kind,
|
||||
)
|
||||
target = resolution.record.value
|
||||
if condition == "up_to_vertex":
|
||||
target_point = session.adapter.vertex_coordinates(target)
|
||||
projections = [
|
||||
vector_dot(vector_subtract(target_point, point), direction)
|
||||
for face in faces
|
||||
for point in session.adapter.profile_sample_points(face)
|
||||
]
|
||||
if not projections or min(projections) <= 1e-6:
|
||||
raise FeatureExecutionError("extent_target_not_in_direction", "The target vertex is not ahead of the profile", extent=condition)
|
||||
if max(projections) - min(projections) > 1e-5:
|
||||
raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition)
|
||||
distance = sum(projections) / len(projections)
|
||||
else:
|
||||
if condition == "up_to_surface" and session.adapter.profile_touches_target(target, faces):
|
||||
# 草图轮廓本身就在所选终止面上时,selected face 只是拉伸的起始
|
||||
# 边界。应沿实际拉伸方向穿过当前 body,取下一张完整截获 profile
|
||||
# 的边界面作为终止面;直接裁剪到 selected face 会生成零厚度工具体。
|
||||
try:
|
||||
next_face = session.adapter.next_body_face_after(
|
||||
session.body, faces, direction, excluded_face=target,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise FeatureExecutionError("extent_target_not_reached", str(error), extent=condition) from error
|
||||
return ExtentVector(vector_scale(direction, 1.0), trim_to=next_face)
|
||||
try:
|
||||
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
|
||||
except ValueError as error:
|
||||
message = str(error)
|
||||
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 与目标面非均匀相交(部分采样点未
|
||||
# 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体
|
||||
# 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。
|
||||
# extrude_trimmed 内部做穿透拉伸 + 与目标面体层布尔求交,未达
|
||||
# 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都
|
||||
# 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛
|
||||
# "not reached",保持显式拒绝。
|
||||
# through_next 从当前主体中选取实际命中的下一张面;
|
||||
# up_to_vertex/up_to_body/offset_from_surface 无 face 可构造
|
||||
# 裁剪体层,仍保持显式拒绝。
|
||||
return ExtentVector(vector_scale(direction, 1.0), trim_to=target)
|
||||
raise FeatureExecutionError(code, message, extent=condition) from error
|
||||
offset = abs(float((end_condition or {}).get("offset_mm") or 0.0))
|
||||
if condition == "offset_from_surface":
|
||||
offset = abs(float(offset_mm if offset_mm is not None else offset or node.params.get("distance_mm") or 0.0))
|
||||
if offset:
|
||||
distance -= offset
|
||||
if distance <= 1e-6:
|
||||
raise FeatureExecutionError(
|
||||
"invalid_extent_offset",
|
||||
"Offset distance reaches or passes the target extent",
|
||||
extent=condition, offset_mm=offset,
|
||||
)
|
||||
return ExtentVector(vector_scale(direction, distance))
|
||||
|
||||
|
||||
def _side_extent_vectors(
|
||||
node: FeaturePlanNode,
|
||||
faces: list[Any],
|
||||
direction: Vector3,
|
||||
session: "ExecutionSession",
|
||||
*,
|
||||
end_condition: dict[str, Any],
|
||||
distance_mm: float,
|
||||
) -> list[ExtentVector]:
|
||||
"""Resolve one directional extent without borrowing the opposite side.
|
||||
|
||||
``extrude_add_two_sided`` and ``extrude_cut_two_sided`` call this once for each independently captured
|
||||
termination. The regular one-sided executor also uses it for all simple
|
||||
termination modes, keeping the geometry adapter interface uniform.
|
||||
"""
|
||||
condition = str(end_condition.get("type") or "blind")
|
||||
distance = abs(float(distance_mm or 0.0))
|
||||
if condition == "blind":
|
||||
if distance <= 0:
|
||||
raise ValueError("blind extent requires distance_mm > 0")
|
||||
return [ExtentVector(vector_scale(direction, distance))]
|
||||
if condition == "mid_plane":
|
||||
if distance <= 0:
|
||||
raise ValueError("mid_plane extent requires distance_mm > 0")
|
||||
return [
|
||||
ExtentVector(vector_scale(direction, distance / 2)),
|
||||
ExtentVector(vector_scale(direction, -distance / 2)),
|
||||
]
|
||||
if condition == "through_all":
|
||||
if session.body is None:
|
||||
if distance <= 0:
|
||||
raise ValueError("through_all on an initial feature has no body and no fallback distance")
|
||||
# 注意:Vector3 是 tuple,不能直接做 direction * distance(那是元组
|
||||
# 重复),这里必须用 vector_scale 做数乘(顺带修复的隐藏 bug)。
|
||||
return [ExtentVector(vector_scale(direction, distance))]
|
||||
return [ExtentVector(vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0))]
|
||||
if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}:
|
||||
return [
|
||||
_targeted_extent_vector(
|
||||
node, faces, direction, session, condition,
|
||||
end_condition=end_condition, offset_mm=distance,
|
||||
)
|
||||
]
|
||||
raise ValueError(f"unsupported directional extent {condition!r}")
|
||||
|
||||
|
||||
def _extent_vectors(
|
||||
node: FeaturePlanNode,
|
||||
faces: list[Any],
|
||||
sketch: dict[str, Any],
|
||||
session: "ExecutionSession",
|
||||
) -> list[ExtentVector]:
|
||||
return _extent_vectors_from_normal(
|
||||
node, faces, vector_unit(_normal_from_sketch(sketch), field_name="sketch normal"), session,
|
||||
)
|
||||
|
||||
|
||||
def _extent_vectors_from_normal(
|
||||
node: FeaturePlanNode,
|
||||
faces: list[Any],
|
||||
profile_normal: Vector3,
|
||||
session: "ExecutionSession",
|
||||
) -> list[ExtentVector]:
|
||||
"""Resolve extents from an explicit profile normal.
|
||||
|
||||
A derived profile can be an actual B-rep face rather than a sketch. Its
|
||||
outward normal is just as authoritative as a sketch workplane normal, so
|
||||
both profile sources share the same bounded extent semantics.
|
||||
"""
|
||||
params = node.params
|
||||
normal = vector_unit(profile_normal, field_name="profile normal")
|
||||
if bool(params.get("reverse")):
|
||||
normal = vector_scale(normal, -1)
|
||||
end_condition = params.get("end_condition") or {"type": "blind"}
|
||||
condition = end_condition.get("type", "blind")
|
||||
distance = abs(float(params.get("distance_mm") or 0.0))
|
||||
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("two_sided")):
|
||||
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
|
||||
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
|
||||
if reverse_distance <= 0:
|
||||
raise ValueError("two-sided extrusion requires reverse_distance_mm > 0")
|
||||
return [
|
||||
*_side_extent_vectors(
|
||||
node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
|
||||
),
|
||||
*_side_extent_vectors(
|
||||
node, faces, vector_scale(normal, -1), session,
|
||||
end_condition=reverse_condition, distance_mm=reverse_distance,
|
||||
),
|
||||
]
|
||||
if condition in {"through_all", "through_all_both", "through_all_and_blind"}:
|
||||
if session.body is None:
|
||||
# A first feature with through-all has no body to terminate
|
||||
# against. The source must provide a usable blind component.
|
||||
if distance <= 0:
|
||||
raise ValueError("through_all on an initial feature has no body and no fallback distance")
|
||||
return [ExtentVector(vector_scale(normal, distance))]
|
||||
span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0
|
||||
if condition == "through_all":
|
||||
return [ExtentVector(vector_scale(normal, span))]
|
||||
if condition == "through_all_both":
|
||||
return [ExtentVector(vector_scale(normal, span)), ExtentVector(vector_scale(normal, -span))]
|
||||
# Through-all-and-blind is represented by a through direction plus
|
||||
# its captured opposite blind direction when available.
|
||||
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
|
||||
return [
|
||||
ExtentVector(vector_scale(normal, span)),
|
||||
ExtentVector(vector_scale(normal, -(reverse_distance or span))),
|
||||
]
|
||||
return _side_extent_vectors(
|
||||
node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Frozen legacy engine paths.
|
||||
|
||||
``llm_compiler`` (thin CDSL to build_pack) and ``llm_engine`` (build_pack
|
||||
executor) predate the session-based runtime and are not used by
|
||||
``run_cdsl_only``. ``exact_rebuild`` hosts the SolidWorks-exact fallback
|
||||
rebuilds and the project-specific geometric compensations.
|
||||
|
||||
Do not extend these modules; new capability belongs in the session runtime
|
||||
(``executors/``) or the schema contracts. The top-level ``llm_compiler.py``
|
||||
and ``llm_engine.py`` shims keep every historical import path working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,253 @@
|
||||
"""SolidWorks-exact fallback rebuilds and project-specific compensations.
|
||||
|
||||
``_run_parameterized`` and ``_run_exact`` replay SolidWorks rebuilds through
|
||||
generated build123d scripts (``compiler_context``). They are the frozen
|
||||
exact/parameterized legs of ``rebuild.run_rebuild``; the production path is
|
||||
``run_cdsl_only`` in the session runtime.
|
||||
|
||||
``_apply_geometric_compensations`` is a project-specific workaround for a
|
||||
feature the SolidWorks export misses. It intentionally lives beside the
|
||||
legacy paths so a clean checkout of this engine in another project can drop
|
||||
it without touching the generic code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..sketch_solver import resolve_all_sketches
|
||||
from ..translator import generate_build123d_code
|
||||
|
||||
|
||||
def _run_parameterized(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
|
||||
"""参数化路径: CDSL语义结构 + compiler_context精确数据 → translator生成代码 → 执行
|
||||
|
||||
采用双层IR架构:
|
||||
Learning IR (CDSL) 提供参数化形状、特征结构
|
||||
Execution IR (compiler_context) 提供精确坐标
|
||||
translator 提供经过充分测试的代码生成
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import tempfile, os
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
# 1. 获取 compiler_context (Execution IR: 精确坐标)
|
||||
compiler_context = cdsl.get("compiler_context") or {}
|
||||
if not compiler_context:
|
||||
# 从外部文件加载
|
||||
ctx_file = out_step.parent / "{}.compiler_context.json".format(cdsl.get("part_id", ""))
|
||||
if ctx_file.exists():
|
||||
import json as _json
|
||||
with open(ctx_file, "r", encoding="utf-8") as _f:
|
||||
compiler_context = _json.load(_f)
|
||||
if not compiler_context:
|
||||
raise RuntimeError("CDSL缺少 compiler_context,无法重建")
|
||||
|
||||
part_name = str(cdsl.get("part_id") or out_step.stem)
|
||||
context = dict(compiler_context)
|
||||
context.setdefault("metadata", {})["part_name"] = part_name
|
||||
|
||||
# 2. 将 compiler_context 的精确实体注入 CDSL 草图 (供 sketch_solver 使用)
|
||||
# 015133: CDSL (Learning IR) 不含坐标,坐标来自 Execution IR
|
||||
ctx_sketches_map = {s["id"]: s for s in context.get("sketches", [])}
|
||||
cdsl_sketches = cdsl.get("geometry", {}).get("sketches", [])
|
||||
for sk in cdsl_sketches:
|
||||
ctx_sk = ctx_sketches_map.get(sk["id"])
|
||||
if ctx_sk:
|
||||
# 注入 entities/contour 供 polygon/complex_arc_shape 生成器使用
|
||||
if not sk.get("entities"):
|
||||
sk["entities"] = ctx_sk.get("entities", [])
|
||||
if not sk.get("contour_edges_mm"):
|
||||
sk["contour_edges_mm"] = ctx_sk.get("contour_edges_mm", [])
|
||||
|
||||
# 3. 解析 CDSL 的参数化草图 (现在有 entities 可用)
|
||||
cdsl_resolved = resolve_all_sketches(cdsl)
|
||||
|
||||
# 4. 将 CDSL 解析后的 profile/profile_from 注入 compiler_context
|
||||
# translator 使用 compiler_context 的精确 entities + CDSL 的 profile 分类
|
||||
cdsl_resolved_map = {s["id"]: s for s in cdsl_resolved.get("geometry", {}).get("sketches", [])}
|
||||
ctx_sketches = list(context.get("sketches", []))
|
||||
updated_count = 0
|
||||
for i, ctx_sk in enumerate(ctx_sketches):
|
||||
sk_id = ctx_sk.get("id", "")
|
||||
cdsl_sk = cdsl_resolved_map.get(sk_id)
|
||||
if cdsl_sk and cdsl_sk.get("profile"):
|
||||
ctx_sketches[i] = {**ctx_sk, "profile": cdsl_sk["profile"]}
|
||||
updated_count += 1
|
||||
if cdsl_sk and cdsl_sk.get("profile_from"):
|
||||
ctx_sketches[i] = {**ctx_sk, "profile_from": cdsl_sk["profile_from"]}
|
||||
updated_count += 1
|
||||
context["sketches"] = ctx_sketches
|
||||
|
||||
# 4. 使用 compiler_context 的原始 operations(保持 translator 兼容性)
|
||||
|
||||
# 5. 读取 gold volume
|
||||
gold_volume_mm3 = None
|
||||
if gold_step and gold_step.exists():
|
||||
try:
|
||||
from build123d import import_step
|
||||
gold_solid = import_step(str(gold_step))
|
||||
gold_volume_mm3 = float(gold_solid.volume)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 6. 用 translator 生成并执行
|
||||
code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3)
|
||||
|
||||
# 6b. 应用几何补偿 (SW导出缺失的特征)
|
||||
part_id = str(cdsl.get("part_id") or "")
|
||||
code = _apply_geometric_compensations(code, part_id)
|
||||
|
||||
out_step.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as tf:
|
||||
tf.write(code)
|
||||
script_path = tf.name
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["python", script_path],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=120,
|
||||
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"Build script failed:\n{r.stderr}")
|
||||
finally:
|
||||
try:
|
||||
os.unlink(script_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 7. 读取重建结果
|
||||
out_step.parent.mkdir(parents=True, exist_ok=True)
|
||||
built_step = Path(part_name + ".step")
|
||||
if not built_step.exists():
|
||||
built_step = Path.cwd() / (part_name + ".step")
|
||||
if built_step.exists():
|
||||
import shutil
|
||||
shutil.copy2(str(built_step), str(out_step))
|
||||
built_step.unlink()
|
||||
else:
|
||||
raise RuntimeError(f"No STEP output found: {part_name}.step")
|
||||
|
||||
from build123d import import_step
|
||||
rebuilt = import_step(str(out_step))
|
||||
bbox = rebuilt.bounding_box()
|
||||
bbox_mm = {
|
||||
"min": [bbox.min.X, bbox.min.Y, bbox.min.Z],
|
||||
"max": [bbox.max.X, bbox.max.Y, bbox.max.Z],
|
||||
}
|
||||
|
||||
elapsed = time.time() - t0
|
||||
return {
|
||||
"out_step": str(out_step),
|
||||
"volume_mm3": float(rebuilt.volume),
|
||||
"bbox_mm": bbox_mm,
|
||||
"log": [f"param: CDSL-informed translator rebuild, {updated_count} sketches updated from CDSL"],
|
||||
"engine": "parameterized",
|
||||
"elapsed_s": round(elapsed, 1),
|
||||
}
|
||||
|
||||
|
||||
def _run_exact(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
|
||||
"""精确路径: generate_build123d_code (后备)"""
|
||||
|
||||
import subprocess
|
||||
|
||||
compiler_context = cdsl.get("compiler_context") or {}
|
||||
part_name = str(cdsl.get("part_id") or out_step.stem)
|
||||
context = dict(compiler_context)
|
||||
context.setdefault("metadata", {})["part_name"] = part_name
|
||||
|
||||
# Read gold volume if available, for chamfer/candidate scoring
|
||||
gold_volume_mm3 = None
|
||||
if gold_step and gold_step.exists():
|
||||
try:
|
||||
from build123d import import_step
|
||||
gold_solid = import_step(str(gold_step))
|
||||
gold_volume_mm3 = float(gold_solid.volume)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
out_step.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Apply geometric compensations FIRST (may return full replacement code)
|
||||
part_id = str(cdsl.get("part_id") or "")
|
||||
compensation_code = _apply_geometric_compensations("", part_id)
|
||||
|
||||
if compensation_code and "build123d" in compensation_code and "__main__" in compensation_code:
|
||||
# 完整替换代码 (跳过generate_build123d_code)
|
||||
code = compensation_code
|
||||
else:
|
||||
code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3)
|
||||
code = _apply_geometric_compensations(code, part_id)
|
||||
|
||||
t0 = time.time()
|
||||
script_path = out_step.parent / "_tmp" / f"build_{part_name}_{int(time.time())}.py"
|
||||
script_path.parent.mkdir(exist_ok=True)
|
||||
script_path.write_text(code, encoding="utf-8")
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(script_path)],
|
||||
cwd=out_step.parent,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Exact compiler FAILED (rc={completed.returncode})\n"
|
||||
f"STDOUT:\n{completed.stdout[-2000:]}\n"
|
||||
f"STDERR:\n{completed.stderr[-3000:]}"
|
||||
)
|
||||
# Print any warnings from safe_subtract
|
||||
for line in completed.stdout.split('\n'):
|
||||
if 'SUBTRACT' in line or 'UNION' in line:
|
||||
print(f" {line.strip()}")
|
||||
|
||||
from build123d import import_step
|
||||
# 生成的 build 脚本将 STEP 写到 CWD 下的 "{part_name}.step"
|
||||
# 移到 out_step 位置以供后续对比
|
||||
actual_step = out_step.parent / f"{part_name}.step"
|
||||
if actual_step.exists():
|
||||
import shutil
|
||||
shutil.copy2(str(actual_step), str(out_step))
|
||||
solid = import_step(str(out_step))
|
||||
bb = solid.bounding_box()
|
||||
elapsed = time.time() - t0
|
||||
|
||||
return {
|
||||
"out_step": str(out_step),
|
||||
"volume_mm3": float(solid.volume),
|
||||
"bbox_mm": {"min": [bb.min.X, bb.min.Y, bb.min.Z],
|
||||
"max": [bb.max.X, bb.max.Y, bb.max.Z]},
|
||||
"engine": "exact",
|
||||
"elapsed_s": round(elapsed, 1),
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Geometric compensations(项目特例;拷贝到其他项目时可删)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
def _apply_geometric_compensations(code: str, part_id: str) -> str:
|
||||
"""为SW导出中缺失的特征添加几何补偿切操作"""
|
||||
if part_id == "113246":
|
||||
if "export_step(result, " not in code:
|
||||
return code
|
||||
comp = (
|
||||
" # === COMPENSATION: 侧槽 (SW缺失特征) ===\n"
|
||||
" with BuildSketch(Plane(origin=(-70.0, -13.0, 10.0), "
|
||||
"x_dir=(0.0, 1.0, 0.0), z_dir=(1.0, 0.0, 0.0))) as comp_sk:\n"
|
||||
" Rectangle(10.0, 3.0, align=(Align.MIN, Align.MIN))\n"
|
||||
" comp_cutter = extrude(comp_sk.sketch, amount=10.0)\n"
|
||||
" result = safe_subtract(result, comp_cutter)\n"
|
||||
)
|
||||
code = code.replace("export_step(result, ", comp + " export_step(result, ")
|
||||
return code
|
||||
@@ -0,0 +1,287 @@
|
||||
"""通用编译器:瘦 CDSL → build_pack;线性阵列在此展开为重复步骤。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from ..sketch_solver import resolve_all_sketches
|
||||
except ImportError:
|
||||
from sketch_solver import resolve_all_sketches
|
||||
|
||||
|
||||
REQUIRED = {
|
||||
"revolve_add": ["angle_deg", "axis"],
|
||||
"revolve_cut": ["angle_deg", "axis"],
|
||||
"extrude_add_blind": ["distance_mm"],
|
||||
"extrude_add_two_sided": ["distance_mm"],
|
||||
"extrude_cut_blind": ["distance_mm"],
|
||||
"hole_blind": ["diameter_mm", "depth_mm"],
|
||||
"hole_countersink": ["diameter_mm", "depth_mm"],
|
||||
"hole_counterbore": ["diameter_mm", "depth_mm"],
|
||||
"sphere_add": ["radius_mm", "center_mm"],
|
||||
}
|
||||
|
||||
|
||||
def _load(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _offset_sketch(sketch: dict[str, Any] | None, dx: float, dy: float, dz: float) -> dict[str, Any] | None:
|
||||
if sketch is None:
|
||||
return None
|
||||
s = deepcopy(sketch)
|
||||
wp = s.get("workplane") or {}
|
||||
o = list(wp.get("origin_mm") or [0, 0, 0])
|
||||
wp["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz]
|
||||
s["workplane"] = wp
|
||||
edges = []
|
||||
for e in s.get("contour_edges_mm") or []:
|
||||
ne = deepcopy(e)
|
||||
for key in ("start_mm", "end_mm", "center_mm"):
|
||||
if key in ne:
|
||||
p = ne[key]
|
||||
ne[key] = [p[0] + dx, p[1] + dy, p[2] + dz]
|
||||
edges.append(ne)
|
||||
if edges:
|
||||
s["contour_edges_mm"] = edges
|
||||
# 2D entities: shift in plane if offset has in-plane components only — skip for world offset patterns
|
||||
return s
|
||||
|
||||
|
||||
def _offset_params_positions(params: dict[str, Any], dx: float, dy: float, dz: float) -> dict[str, Any]:
|
||||
p = deepcopy(params)
|
||||
if "positions" in p:
|
||||
for pos in p["positions"]:
|
||||
mm = pos.get("mm")
|
||||
if mm:
|
||||
pos["mm"] = [mm[0] + dx, mm[1] + dy, mm[2] + dz]
|
||||
if "axis" in p and isinstance(p["axis"], dict):
|
||||
o = list(p["axis"].get("origin_mm") or [0, 0, 0])
|
||||
p["axis"]["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz]
|
||||
return p
|
||||
|
||||
|
||||
def compile_cdsl(
|
||||
cdsl: dict[str, Any],
|
||||
atoms_catalog: dict[str, Any] | None = None,
|
||||
techniques_catalog: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
allowed = set()
|
||||
if atoms_catalog:
|
||||
allowed = {a["atomic_id"] for a in atoms_catalog.get("atoms") or []}
|
||||
techniques = {
|
||||
item["technique_id"]: item
|
||||
for item in (techniques_catalog or {}).get("techniques") or []
|
||||
}
|
||||
|
||||
sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []}
|
||||
|
||||
# 参数化轮廓求解:将 profile 字段展开为精确的 entities + contour_edges_mm
|
||||
cdsl = resolve_all_sketches(cdsl)
|
||||
sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []}
|
||||
steps: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
# feature_id -> list of emitted step dicts (for pattern source)
|
||||
emitted: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
def emit(feature: dict[str, Any], params: dict[str, Any], sketch: dict[str, Any] | None, step_id: str) -> dict[str, Any]:
|
||||
atomic = feature["atomic_id"]
|
||||
if allowed and atomic not in allowed:
|
||||
raise ValueError(f"{step_id}: atomic_id {atomic!r} is not admitted by catalog")
|
||||
for dep in feature.get("depends_on") or []:
|
||||
if dep not in seen_ids and not any(dep in emitted):
|
||||
# dependency may be ok if earlier
|
||||
if dep not in seen_ids:
|
||||
raise ValueError(f"{step_id}: depends_on {dep} not yet defined")
|
||||
step = {
|
||||
"step_id": step_id,
|
||||
"atomic_id": atomic,
|
||||
"depends_on": list(feature.get("depends_on") or []),
|
||||
"params": params,
|
||||
"sketch": sketch,
|
||||
"source_name": feature.get("name"),
|
||||
}
|
||||
steps.append(step)
|
||||
seen_ids.add(step_id)
|
||||
return step
|
||||
|
||||
for feat in cdsl.get("features") or []:
|
||||
fid = feat["id"]
|
||||
atomic = feat.get("atomic_id")
|
||||
technique_id = feat.get("technique_id")
|
||||
if technique_id:
|
||||
technique = techniques.get(technique_id)
|
||||
if technique is None:
|
||||
raise ValueError(f"{fid}: technique_id {technique_id!r} is not admitted by catalog")
|
||||
groups = feat.get("params") or {}
|
||||
expanded: list[dict[str, Any]] = []
|
||||
previous_step_id: str | None = None
|
||||
for index, internal in enumerate(technique.get("internal_steps") or [], start=1):
|
||||
group_name = internal.get("params_from")
|
||||
group = deepcopy(groups.get(group_name) or {})
|
||||
if not isinstance(group, dict):
|
||||
raise ValueError(f"{fid}: parameter group {group_name!r} must be an object")
|
||||
params = deepcopy(group.get("params") if isinstance(group.get("params"), dict) else group)
|
||||
sketch_id = group.get("sketch_id") or params.pop("sketch_id", None)
|
||||
sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None
|
||||
internal_atomic = internal.get("atomic_id")
|
||||
if not internal_atomic:
|
||||
raise ValueError(f"{fid}: technique {technique_id!r} has an invalid internal step")
|
||||
for key in REQUIRED.get(internal_atomic, []):
|
||||
if params.get(key) is None:
|
||||
raise ValueError(
|
||||
f"{fid}: technique {technique_id!r} group {group_name!r} missing {key}"
|
||||
)
|
||||
internal_feature = {
|
||||
"atomic_id": internal_atomic,
|
||||
"depends_on": [previous_step_id] if previous_step_id else list(feat.get("depends_on") or []),
|
||||
"name": f"{feat.get('name') or technique_id}:{group_name or index}",
|
||||
}
|
||||
step_id = f"{fid}.t{index}"
|
||||
expanded.append(emit(internal_feature, params, sketch, step_id))
|
||||
previous_step_id = step_id
|
||||
if len(expanded) < 2:
|
||||
raise ValueError(f"{fid}: technique {technique_id!r} must expand to at least two steps")
|
||||
emitted[fid] = expanded
|
||||
seen_ids.add(fid)
|
||||
continue
|
||||
if not atomic:
|
||||
raise ValueError(f"{fid}: missing atomic_id")
|
||||
|
||||
if atomic == "pattern_linear":
|
||||
params = feat.get("params") or {}
|
||||
src_ids = params.get("source_feature_ids") or []
|
||||
c1 = int(params.get("pattern_count_1") or 1)
|
||||
c2 = int(params.get("pattern_count_2") or 1)
|
||||
s1 = float(params.get("spacing_1_mm") or 0)
|
||||
s2 = float(params.get("spacing_2_mm") or 0)
|
||||
d1 = params.get("direction_1") or [1, 0, 0]
|
||||
d2 = params.get("direction_2") or [0, 1, 0]
|
||||
if params.get("direction_1_reverse"):
|
||||
d1 = [-d1[0], -d1[1], -d1[2]]
|
||||
if params.get("direction_2_reverse"):
|
||||
d2 = [-d2[0], -d2[1], -d2[2]]
|
||||
|
||||
src_steps: list[dict[str, Any]] = []
|
||||
for sid in src_ids:
|
||||
src_steps.extend(emitted.get(sid) or [])
|
||||
if not src_steps:
|
||||
# 无源则跳过并记录
|
||||
steps.append(
|
||||
{
|
||||
"step_id": fid,
|
||||
"atomic_id": "noop_pattern",
|
||||
"depends_on": list(feat.get("depends_on") or []),
|
||||
"params": params,
|
||||
"sketch": None,
|
||||
"note": "pattern source steps missing",
|
||||
}
|
||||
)
|
||||
seen_ids.add(fid)
|
||||
continue
|
||||
|
||||
clone_steps = []
|
||||
k = 0
|
||||
for i in range(c1):
|
||||
for j in range(c2):
|
||||
if i == 0 and j == 0:
|
||||
continue
|
||||
dx = d1[0] * s1 * i + d2[0] * s2 * j
|
||||
dy = d1[1] * s1 * i + d2[1] * s2 * j
|
||||
dz = d1[2] * s1 * i + d2[2] * s2 * j
|
||||
for src in src_steps:
|
||||
k += 1
|
||||
clone_id = f"{fid}.p{k}"
|
||||
fake_feat = {
|
||||
"atomic_id": src["atomic_id"],
|
||||
"depends_on": [steps[-1]["step_id"]] if steps else [],
|
||||
"name": f"{src.get('source_name')}_pattern",
|
||||
}
|
||||
st = emit(
|
||||
fake_feat,
|
||||
_offset_params_positions(src["params"], dx, dy, dz),
|
||||
_offset_sketch(src.get("sketch"), dx, dy, dz),
|
||||
clone_id,
|
||||
)
|
||||
clone_steps.append(st)
|
||||
emitted[fid] = clone_steps
|
||||
seen_ids.add(fid)
|
||||
continue
|
||||
|
||||
params = deepcopy(feat.get("params") or {})
|
||||
sketch_id = feat.get("sketch_id") or params.get("sketch_id")
|
||||
sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None
|
||||
if sketch_id:
|
||||
params["sketch_id"] = sketch_id
|
||||
|
||||
# Auto-derive revolve axis origin
|
||||
if "revolve" in atomic and sketch and "axis" in params:
|
||||
ax = params.get("axis") or {}
|
||||
# 优先级: from_workplane_origin > from_contour_vertex > origin_mm 裸坐标
|
||||
wp = sketch.get("workplane") or {}
|
||||
wp_origin = wp.get("origin_mm") or [0.0, 0.0, 0.0]
|
||||
|
||||
if ax.get("from_workplane_origin") and "origin_mm" not in ax:
|
||||
params["axis"] = deepcopy(params["axis"])
|
||||
params["axis"]["origin_mm"] = list(wp_origin)
|
||||
elif "origin_mm" not in ax:
|
||||
ce = sketch.get("contour_edges_mm") or []
|
||||
if ce:
|
||||
idx = int(ax.get("from_contour_vertex", 0))
|
||||
vertex = ce[idx % len(ce)]["start_mm"]
|
||||
params["axis"] = deepcopy(params["axis"])
|
||||
params["axis"]["origin_mm"] = list(vertex)
|
||||
|
||||
for key in REQUIRED.get(atomic, []):
|
||||
if key == "axis" and "axis" not in params:
|
||||
raise ValueError(f"{fid}: missing axis")
|
||||
if key not in ("axis",) and params.get(key) is None and key != "sketch_id":
|
||||
# positions can be empty temporarily
|
||||
if key in params:
|
||||
continue
|
||||
if key in ("diameter_mm", "depth_mm", "distance_mm", "angle_deg") and params.get(key) is None:
|
||||
raise ValueError(f"{fid}: missing {key}")
|
||||
|
||||
st = emit(feat, params, sketch, fid)
|
||||
emitted[fid] = [st]
|
||||
|
||||
# filter noop
|
||||
steps = [s for s in steps if s.get("atomic_id") != "noop_pattern"]
|
||||
|
||||
return {
|
||||
"schema": "cad.engine_plan.v1",
|
||||
"part_id": cdsl.get("part_id"),
|
||||
"unit": "mm",
|
||||
"steps": steps,
|
||||
"compiler_context": deepcopy(cdsl.get("compiler_context")),
|
||||
"meta": {
|
||||
"from_cdsl_schema": cdsl.get("schema"),
|
||||
"compiler": "cad-heard.llm_compiler.v1",
|
||||
"n_steps": len(steps),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--cdsl", type=Path, required=True)
|
||||
ap.add_argument("--catalog", type=Path, default=None)
|
||||
ap.add_argument("--techniques", type=Path, default=None)
|
||||
ap.add_argument("--out", type=Path, required=True)
|
||||
args = ap.parse_args()
|
||||
catalog = _load(args.catalog) if args.catalog else None
|
||||
techniques = _load(args.techniques) if args.techniques else None
|
||||
pack = compile_cdsl(_load(args.cdsl), catalog, techniques)
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(json.dumps(pack, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"wrote {args.out} steps={len(pack['steps'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,555 @@
|
||||
"""build123d 绘图引擎:执行 build_pack → STEP。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# 保留内置 float,防止被 build123d 上下文 shadow
|
||||
_f = builtins.float
|
||||
|
||||
from build123d import ( # noqa: E402
|
||||
Align,
|
||||
Axis,
|
||||
BuildPart,
|
||||
BuildSketch,
|
||||
Circle,
|
||||
Cone,
|
||||
Cylinder,
|
||||
Edge,
|
||||
Face,
|
||||
Location,
|
||||
Locations,
|
||||
Mode,
|
||||
Plane,
|
||||
Polygon,
|
||||
Sphere,
|
||||
Vector,
|
||||
Wire,
|
||||
export_step,
|
||||
extrude,
|
||||
import_step,
|
||||
revolve,
|
||||
)
|
||||
|
||||
|
||||
# Keep this in sync with the execution branches in run_engine_plan. The
|
||||
# agent-facing schema and its parity test prevent unsupported names reaching
|
||||
# this low-level dispatcher.
|
||||
SUPPORTED_ATOMIC_IDS = frozenset({
|
||||
"extrude_add_blind",
|
||||
"extrude_add_two_sided",
|
||||
"extrude_cut_blind",
|
||||
"revolve_add",
|
||||
"revolve_cut",
|
||||
"hole_blind",
|
||||
"hole_countersink",
|
||||
"hole_counterbore",
|
||||
"sphere_add",
|
||||
"reference_plane",
|
||||
"reference_axis",
|
||||
})
|
||||
|
||||
|
||||
def _load(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _plane_from_workplane(wp: dict[str, Any]) -> Plane:
|
||||
o = wp.get("origin_mm") or [0, 0, 0]
|
||||
x = wp.get("x_dir") or [1, 0, 0]
|
||||
n = wp.get("normal") or [0, 0, 1]
|
||||
return Plane(
|
||||
origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])),
|
||||
x_dir=Vector(_f(x[0]), _f(x[1]), _f(x[2])),
|
||||
z_dir=Vector(_f(n[0]), _f(n[1]), _f(n[2])),
|
||||
)
|
||||
|
||||
|
||||
def _axis_from_params(axis: dict[str, Any]) -> Axis:
|
||||
o = axis.get("origin_mm") or [0, 0, 0]
|
||||
d = axis.get("direction") or [1, 0, 0]
|
||||
return Axis(
|
||||
origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])),
|
||||
direction=Vector(_f(d[0]), _f(d[1]), _f(d[2])),
|
||||
)
|
||||
|
||||
|
||||
def _ordered_profile_points(sketch: dict[str, Any]) -> list[tuple[float, float]]:
|
||||
entities = sketch.get("entities") or []
|
||||
line_loop = [
|
||||
i for i, e in enumerate(entities) if e["type"] == "line" and not e.get("construction")
|
||||
]
|
||||
if not line_loop:
|
||||
raise ValueError(f"sketch {sketch.get('id')}: no profile lines")
|
||||
pts: list[tuple[float, float]] = []
|
||||
for i in line_loop:
|
||||
e = entities[i]
|
||||
s = (_f(e["start"][0]), _f(e["start"][1]))
|
||||
en = (_f(e["end"][0]), _f(e["end"][1]))
|
||||
if not pts:
|
||||
pts.append(s)
|
||||
if abs(pts[-1][0] - s[0]) + abs(pts[-1][1] - s[1]) > 1e-4:
|
||||
if abs(pts[-1][0] - en[0]) + abs(pts[-1][1] - en[1]) <= 1e-4:
|
||||
s, en = en, s
|
||||
else:
|
||||
pts.append(s)
|
||||
pts.append(en)
|
||||
if abs(pts[0][0] - pts[-1][0]) + abs(pts[0][1] - pts[-1][1]) > 1e-4:
|
||||
pts.append(pts[0])
|
||||
return pts
|
||||
|
||||
|
||||
def _arc_midpoint(edge: dict[str, Any], p1: Vector, p2: Vector, center: Vector, radius: float) -> Vector:
|
||||
"""Return a point on the intended directed arc for ``make_three_point_arc``.
|
||||
|
||||
Legacy contour data has no sweep direction and retains its prior shortest
|
||||
arc behavior. Evidence-v2 analytic contours carry ``clockwise`` so a
|
||||
major arc or a clockwise arc cannot be silently inverted by the adapter.
|
||||
"""
|
||||
v1 = p1 - center
|
||||
v2 = p2 - center
|
||||
if v1.length < 1e-9 or v2.length < 1e-9:
|
||||
return (p1 + p2) / 2
|
||||
n = Vector(*(edge.get("normal") or [0, 0, 1]))
|
||||
if n.length < 1e-9:
|
||||
n = v1.cross(v2)
|
||||
if n.length < 1e-9:
|
||||
n = Vector(0, 0, 1)
|
||||
n = n.normalized()
|
||||
v1n = v1.normalized() * radius
|
||||
if "clockwise" not in edge:
|
||||
bisector = v1n + v2.normalized() * radius
|
||||
if bisector.length < 1e-9:
|
||||
bisector = n.cross(v1n)
|
||||
return center + bisector.normalized() * radius
|
||||
sweep = math.atan2(n.dot(v1.cross(v2)), v1.dot(v2))
|
||||
if bool(edge["clockwise"]):
|
||||
if sweep >= 0:
|
||||
sweep -= math.tau
|
||||
elif sweep <= 0:
|
||||
sweep += math.tau
|
||||
half = sweep / 2
|
||||
midpoint_vector = v1n * math.cos(half) + n.cross(v1n) * math.sin(half)
|
||||
return center + midpoint_vector
|
||||
|
||||
|
||||
def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal: list[float] | None = None) -> Face:
|
||||
b123_edges: list[Edge] = []
|
||||
for e in edges_mm:
|
||||
p1 = Vector(*e["start_mm"])
|
||||
p2 = Vector(*e["end_mm"])
|
||||
if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None:
|
||||
center = Vector(*e["center_mm"])
|
||||
r = _f(e["radius_mm"])
|
||||
v1 = p1 - center
|
||||
v2 = p2 - center
|
||||
if v1.length < 1e-9 or v2.length < 1e-9:
|
||||
b123_edges.append(Edge.make_line(p1, p2))
|
||||
continue
|
||||
mid = _arc_midpoint(e, p1, p2, center, r)
|
||||
try:
|
||||
b123_edges.append(Edge.make_three_point_arc(p1, mid, p2))
|
||||
except Exception:
|
||||
b123_edges.append(Edge.make_line(p1, p2))
|
||||
else:
|
||||
b123_edges.append(Edge.make_line(p1, p2))
|
||||
face = Face(Wire(b123_edges))
|
||||
if desired_normal is not None:
|
||||
dn = Vector(*desired_normal)
|
||||
if dn.length > 1e-9:
|
||||
fn = face.normal_at()
|
||||
if fn.dot(dn) < 0:
|
||||
# 重建反转的 Wire:边顺序反转 + 每条边起止点交换
|
||||
# 这样法向自然翻转,但每条边的几何方向不变(不同于 Face.Reversed)
|
||||
rev_edges: list[Edge] = []
|
||||
for e in reversed(edges_mm):
|
||||
p1 = Vector(*e["end_mm"])
|
||||
p2 = Vector(*e["start_mm"])
|
||||
if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None:
|
||||
center = Vector(*e["center_mm"])
|
||||
r = _f(e["radius_mm"])
|
||||
v1 = p1 - center
|
||||
v2 = p2 - center
|
||||
if v1.length < 1e-9 or v2.length < 1e-9:
|
||||
rev_edges.append(Edge.make_line(p1, p2))
|
||||
continue
|
||||
mid = _arc_midpoint(e, p1, p2, center, r)
|
||||
try:
|
||||
rev_edges.append(Edge.make_three_point_arc(p1, mid, p2))
|
||||
except Exception:
|
||||
rev_edges.append(Edge.make_line(p1, p2))
|
||||
else:
|
||||
rev_edges.append(Edge.make_line(p1, p2))
|
||||
face = Face(Wire(rev_edges))
|
||||
return face
|
||||
|
||||
|
||||
def _amount(params: dict[str, Any], *, prefer_sign: str | None = None) -> float:
|
||||
dist = abs(_f(params["distance_mm"]))
|
||||
if prefer_sign == "plus":
|
||||
return dist
|
||||
if prefer_sign == "minus":
|
||||
return -dist
|
||||
return -dist if bool(params.get("reverse")) else dist
|
||||
|
||||
|
||||
def _build_nested_circle_profiles(circles: list[dict[str, Any]]) -> None:
|
||||
"""Build circular islands and holes from containment parity.
|
||||
|
||||
A circle contained by one larger circle is a hole; a circle contained by
|
||||
two larger circles is an island again. This preserves annular profiles
|
||||
without storing the heavy tessellated sketch regions from the SW export.
|
||||
"""
|
||||
ordered = sorted(circles, key=lambda item: _f(item["radius_mm"]), reverse=True)
|
||||
tolerance = 1e-6
|
||||
for index, circle in enumerate(ordered):
|
||||
center = circle["center"]
|
||||
radius = _f(circle["radius_mm"])
|
||||
containing = 0
|
||||
for outer in ordered[:index]:
|
||||
outer_center = outer["center"]
|
||||
outer_radius = _f(outer["radius_mm"])
|
||||
distance = math.hypot(
|
||||
_f(center[0]) - _f(outer_center[0]),
|
||||
_f(center[1]) - _f(outer_center[1]),
|
||||
)
|
||||
if distance + radius <= outer_radius + tolerance:
|
||||
containing += 1
|
||||
mode = Mode.ADD if containing % 2 == 0 else Mode.SUBTRACT
|
||||
with Locations((_f(center[0]), _f(center[1]))):
|
||||
Circle(radius, mode=mode)
|
||||
|
||||
|
||||
def run_engine_plan(
|
||||
pack: dict[str, Any],
|
||||
out_step: Path,
|
||||
*,
|
||||
cut_sign: str = "from_params",
|
||||
) -> dict[str, Any]:
|
||||
log: list[str] = []
|
||||
|
||||
compiler_context = pack.get("compiler_context")
|
||||
if isinstance(compiler_context, dict):
|
||||
# 回退路径:使用本包 translator(不依赖外部 backend.src)
|
||||
try:
|
||||
from .translator import generate_build123d_code, get_part_name
|
||||
except ImportError:
|
||||
from translator import generate_build123d_code, get_part_name
|
||||
|
||||
context = dict(compiler_context)
|
||||
context.setdefault("metadata", {})["part_name"] = str(pack.get("part_id") or out_step.stem)
|
||||
out_step.parent.mkdir(parents=True, exist_ok=True)
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", generate_build123d_code(context)],
|
||||
cwd=out_step.parent,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"exact compiler execution failed\nSTDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}"
|
||||
)
|
||||
generated_name = get_part_name({"part_name": context["metadata"]["part_name"]})
|
||||
generated = out_step.parent / f"{generated_name}.step"
|
||||
if generated != out_step and generated.exists():
|
||||
generated.replace(out_step)
|
||||
if not out_step.exists():
|
||||
raise RuntimeError(f"exact compiler did not generate {out_step}")
|
||||
solid = import_step(str(out_step))
|
||||
bb = solid.bounding_box()
|
||||
return {
|
||||
"out_step": str(out_step),
|
||||
"volume_mm3": _f(solid.volume),
|
||||
"bbox_mm": {
|
||||
"min": [bb.min.X, bb.min.Y, bb.min.Z],
|
||||
"max": [bb.max.X, bb.max.Y, bb.max.Z],
|
||||
},
|
||||
"engine": "translator_fallback",
|
||||
}
|
||||
|
||||
with BuildPart() as part:
|
||||
for step in pack.get("steps") or []:
|
||||
atomic = step["atomic_id"]
|
||||
params = step["params"]
|
||||
sketch = step.get("sketch")
|
||||
sid = step.get("step_id")
|
||||
|
||||
if atomic == "reference_plane":
|
||||
# Context features deliberately produce no solid. They remain
|
||||
# executable plan steps so their dependencies are preserved and
|
||||
# can be registered by the session-based runtime.
|
||||
plane = _plane_from_workplane(params.get("plane") or {})
|
||||
log.append(
|
||||
f"{sid}: reference_plane origin={tuple(plane.origin)} normal={tuple(plane.z_dir)}"
|
||||
)
|
||||
|
||||
elif atomic == "reference_axis":
|
||||
axis = _axis_from_params(params.get("axis") or {})
|
||||
log.append(
|
||||
f"{sid}: reference_axis origin={tuple(axis.position)} direction={tuple(axis.direction)}"
|
||||
)
|
||||
|
||||
elif atomic == "sphere_add":
|
||||
radius = _f(params.get("radius_mm") or 0)
|
||||
center = params.get("center_mm") or [0, 0, 0]
|
||||
if radius <= 0 or len(center) != 3:
|
||||
raise ValueError(f"{sid}: sphere_add requires a positive radius_mm and center_mm")
|
||||
with Locations((_f(center[0]), _f(center[1]), _f(center[2]))):
|
||||
Sphere(radius, mode=Mode.ADD)
|
||||
log.append(f"{sid}: sphere_add radius={radius}")
|
||||
|
||||
elif atomic in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"):
|
||||
if sketch is None:
|
||||
raise ValueError(f"{sid}: missing sketch")
|
||||
plane = _plane_from_workplane(sketch.get("workplane") or {})
|
||||
mode = Mode.SUBTRACT if "cut" in atomic else Mode.ADD
|
||||
edges = sketch.get("contour_edges_mm") or []
|
||||
regions = sketch.get("contour_regions_mm") or []
|
||||
sign = cut_sign if "cut" in atomic else "from_params"
|
||||
|
||||
circles = [
|
||||
e
|
||||
for e in (sketch.get("entities") or [])
|
||||
if e.get("type") == "circle" and not e.get("construction")
|
||||
]
|
||||
lines = [
|
||||
e
|
||||
for e in (sketch.get("entities") or [])
|
||||
if e.get("type") == "line" and not e.get("construction")
|
||||
]
|
||||
|
||||
# 多区域轮廓(外环 + 孔):由 shape generator 展开
|
||||
if regions:
|
||||
faces = []
|
||||
normal = (sketch.get("workplane") or {}).get("normal")
|
||||
for reg in regions:
|
||||
outer_edges = reg.get("outer") or []
|
||||
if len(outer_edges) < 2:
|
||||
continue
|
||||
face = _face_from_contour_edges(outer_edges, desired_normal=normal)
|
||||
for hole_edges in reg.get("holes") or []:
|
||||
if len(hole_edges) < 2:
|
||||
continue
|
||||
hole = _face_from_contour_edges(hole_edges, desired_normal=normal)
|
||||
face = face.cut(hole)
|
||||
faces.append(face)
|
||||
if not faces:
|
||||
raise ValueError(f"{sid}: contour_regions_mm produced no faces")
|
||||
if atomic == "extrude_add_two_sided":
|
||||
d = abs(_f(params["distance_mm"]))
|
||||
for face in faces:
|
||||
extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD)
|
||||
else:
|
||||
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
|
||||
for face in faces:
|
||||
extrude(to_extrude=face, amount=amt, mode=mode)
|
||||
log.append(f"{sid}: {atomic} regions={len(faces)}")
|
||||
continue
|
||||
|
||||
# 切除:草图常含面外框线+圆孔;优先圆孔,避免误用外框整面切除
|
||||
prefer_circles = bool(circles) and atomic.startswith("extrude_cut")
|
||||
|
||||
if prefer_circles:
|
||||
with BuildSketch(plane):
|
||||
for e in circles:
|
||||
with Locations((_f(e["center"][0]), _f(e["center"][1]))):
|
||||
Circle(_f(e["radius_mm"]))
|
||||
if atomic == "extrude_add_two_sided":
|
||||
d = abs(_f(params["distance_mm"]))
|
||||
extrude(amount=d, both=True, mode=Mode.ADD)
|
||||
else:
|
||||
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
|
||||
extrude(amount=amt, mode=mode)
|
||||
log.append(f"{sid}: {atomic} circle-only n={len(circles)}")
|
||||
elif len(edges) >= 2:
|
||||
face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal"))
|
||||
if atomic == "extrude_add_two_sided":
|
||||
d = abs(_f(params["distance_mm"]))
|
||||
extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD)
|
||||
log.append(f"{sid}: extrude_two_sided both={d} contour")
|
||||
else:
|
||||
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
|
||||
extrude(to_extrude=face, amount=amt, mode=mode)
|
||||
log.append(f"{sid}: {atomic} amount={amt} contour")
|
||||
elif circles and not lines:
|
||||
# 纯圆轮廓:用包含层级区分实体、内孔和孔中岛。
|
||||
with BuildSketch(plane):
|
||||
_build_nested_circle_profiles(circles)
|
||||
if atomic == "extrude_add_two_sided":
|
||||
d = abs(_f(params["distance_mm"]))
|
||||
extrude(amount=d, both=True, mode=Mode.ADD)
|
||||
else:
|
||||
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
|
||||
extrude(amount=amt, mode=mode)
|
||||
log.append(f"{sid}: {atomic} circle-only n={len(circles)}")
|
||||
else:
|
||||
with BuildSketch(plane):
|
||||
pts = _ordered_profile_points(sketch)
|
||||
poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts
|
||||
Polygon(*poly)
|
||||
for e in circles:
|
||||
with Locations((_f(e["center"][0]), _f(e["center"][1]))):
|
||||
Circle(_f(e["radius_mm"]), mode=Mode.SUBTRACT)
|
||||
if atomic == "extrude_add_two_sided":
|
||||
d = abs(_f(params["distance_mm"]))
|
||||
extrude(amount=d, both=True, mode=Mode.ADD)
|
||||
log.append(f"{sid}: extrude_two_sided both={d} poly")
|
||||
else:
|
||||
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
|
||||
extrude(amount=amt, mode=mode)
|
||||
log.append(f"{sid}: {atomic} amount={amt} poly")
|
||||
|
||||
elif atomic in ("revolve_add", "revolve_cut"):
|
||||
if sketch is None:
|
||||
raise ValueError(f"{sid}: missing sketch")
|
||||
plane = _plane_from_workplane(sketch.get("workplane") or {})
|
||||
axis = _axis_from_params(params.get("axis") or {})
|
||||
angle = _f(params.get("angle_deg") or 360)
|
||||
mode = Mode.SUBTRACT if atomic == "revolve_cut" else Mode.ADD
|
||||
edges = sketch.get("contour_edges_mm") or []
|
||||
if len(edges) >= 2:
|
||||
face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal"))
|
||||
revolve(profiles=face, axis=axis, revolution_arc=angle, mode=mode)
|
||||
else:
|
||||
with BuildSketch(plane):
|
||||
pts = _ordered_profile_points(sketch)
|
||||
poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts
|
||||
Polygon(*poly)
|
||||
revolve(axis=axis, revolution_arc=angle, mode=mode)
|
||||
log.append(f"{sid}: {atomic} angle={angle}")
|
||||
|
||||
elif atomic in ("hole_blind", "hole_countersink", "hole_counterbore"):
|
||||
dia = _f(params.get("diameter_mm") or 0)
|
||||
depth = _f(params.get("depth_mm") or 0)
|
||||
positions = params.get("positions") or []
|
||||
if sketch is not None:
|
||||
plane = _plane_from_workplane(sketch.get("workplane") or {})
|
||||
else:
|
||||
plane = Plane.XY
|
||||
host_face = params.get("host_face") or {}
|
||||
frame = host_face.get("frame") or {}
|
||||
frame_origin = Vector(*(frame.get("origin_mm") or plane.origin.to_tuple()))
|
||||
frame_x = Vector(*(frame.get("x_dir") or plane.x_dir.to_tuple()))
|
||||
frame_y = Vector(*(frame.get("y_dir") or plane.y_dir.to_tuple()))
|
||||
normal = plane.z_dir.normalized()
|
||||
bb = part.part.bounding_box()
|
||||
part_center = Vector(
|
||||
(bb.min.X + bb.max.X) / 2,
|
||||
(bb.min.Y + bb.max.Y) / 2,
|
||||
(bb.min.Z + bb.max.Z) / 2,
|
||||
)
|
||||
inward = normal if (part_center - frame_origin).dot(normal) >= 0 else -normal
|
||||
for pos in positions:
|
||||
mm = pos.get("mm") or [0, 0, 0]
|
||||
start = frame_origin + frame_x * _f(mm[0]) + frame_y * _f(mm[1])
|
||||
cs_dia = _f(params.get("countersink_diameter_mm") or 0)
|
||||
cs_angle = _f(params.get("countersink_angle_rad") or 0)
|
||||
cb_dia = _f(params.get("counterbore_diameter_mm") or 0)
|
||||
cb_depth = _f(params.get("counterbore_depth_mm") or 0)
|
||||
cs_depth = (
|
||||
((cs_dia - dia) / 2) / math.tan(cs_angle / 2)
|
||||
if cs_dia > dia and cs_angle > 0
|
||||
else 0
|
||||
)
|
||||
base_offset = cs_depth + (cb_depth if cb_dia > dia else 0)
|
||||
main_depth = max(0.001, abs(depth) - base_offset)
|
||||
main_place = Location(Plane(origin=start + inward * base_offset, z_dir=inward))
|
||||
tools = [
|
||||
Cylinder(
|
||||
radius=dia / 2,
|
||||
height=main_depth,
|
||||
align=(Align.CENTER, Align.CENTER, Align.MIN),
|
||||
mode=Mode.PRIVATE,
|
||||
).move(main_place)
|
||||
]
|
||||
if cb_dia > dia and cb_depth > 0:
|
||||
tools.append(
|
||||
Cylinder(
|
||||
radius=cb_dia / 2,
|
||||
height=cb_depth,
|
||||
align=(Align.CENTER, Align.CENTER, Align.MIN),
|
||||
mode=Mode.PRIVATE,
|
||||
).move(Location(Plane(origin=start, z_dir=inward)))
|
||||
)
|
||||
if cs_depth > 0:
|
||||
tools.append(
|
||||
Cone(
|
||||
bottom_radius=cs_dia / 2,
|
||||
top_radius=dia / 2,
|
||||
height=cs_depth,
|
||||
align=(Align.CENTER, Align.CENTER, Align.MIN),
|
||||
mode=Mode.PRIVATE,
|
||||
).move(Location(Plane(origin=start, z_dir=inward)))
|
||||
)
|
||||
drill_angle = _f(params.get("drill_angle_rad") or 0)
|
||||
if drill_angle > 0:
|
||||
tip_depth = (dia / 2) / math.tan(drill_angle / 2)
|
||||
tools.append(
|
||||
Cone(
|
||||
bottom_radius=dia / 2,
|
||||
top_radius=0,
|
||||
height=tip_depth,
|
||||
align=(Align.CENTER, Align.CENTER, Align.MIN),
|
||||
mode=Mode.PRIVATE,
|
||||
).move(
|
||||
Location(
|
||||
Plane(origin=start + inward * abs(depth), z_dir=inward)
|
||||
)
|
||||
)
|
||||
)
|
||||
for tool in tools:
|
||||
part.part = part.part.cut(tool)
|
||||
log.append(f"{sid}: {atomic} npos={len(positions)}")
|
||||
|
||||
else:
|
||||
raise ValueError(f"unsupported atomic_id: {atomic}")
|
||||
|
||||
solid = part.part
|
||||
|
||||
out_step.parent.mkdir(parents=True, exist_ok=True)
|
||||
export_step(solid, str(out_step))
|
||||
bb = solid.bounding_box()
|
||||
return {
|
||||
"out_step": str(out_step),
|
||||
"volume_mm3": _f(solid.volume),
|
||||
"bbox_mm": {
|
||||
"min": [bb.min.X, bb.min.Y, bb.min.Z],
|
||||
"max": [bb.max.X, bb.max.Y, bb.max.Z],
|
||||
},
|
||||
"log": log,
|
||||
"cut_sign": cut_sign,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--pack", type=Path, required=True)
|
||||
ap.add_argument("--out-step", type=Path, required=True)
|
||||
ap.add_argument("--report", type=Path, default=None)
|
||||
ap.add_argument("--cut-sign", default="from_params", choices=["from_params", "plus", "minus"])
|
||||
args = ap.parse_args()
|
||||
info = run_engine_plan(_load(args.pack), args.out_step, cut_sign=args.cut_sign)
|
||||
if args.report:
|
||||
args.report.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(
|
||||
json.dumps(
|
||||
{k: info[k] for k in ("out_step", "volume_mm3", "bbox_mm", "cut_sign", "engine") if k in info},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
for line in info.get("log") or []:
|
||||
print(line)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,287 +1,12 @@
|
||||
"""通用编译器:瘦 CDSL → build_pack;线性阵列在此展开为重复步骤。"""
|
||||
"""Compatibility shim: the implementation moved to ``legacy.llm_compiler``.
|
||||
|
||||
``compile_cdsl`` expands a thin CDSL document into a ``build_pack`` plan.
|
||||
It predates the session-based runtime and is frozen; new capability belongs
|
||||
in ``executors/`` and the schema contracts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from .legacy.llm_compiler import REQUIRED, compile_cdsl, main
|
||||
|
||||
try:
|
||||
from .sketch_solver import resolve_all_sketches
|
||||
except ImportError:
|
||||
from sketch_solver import resolve_all_sketches
|
||||
|
||||
|
||||
REQUIRED = {
|
||||
"revolve_add": ["angle_deg", "axis"],
|
||||
"revolve_cut": ["angle_deg", "axis"],
|
||||
"extrude_add_blind": ["distance_mm"],
|
||||
"extrude_add_two_sided": ["distance_mm"],
|
||||
"extrude_cut_blind": ["distance_mm"],
|
||||
"hole_blind": ["diameter_mm", "depth_mm"],
|
||||
"hole_countersink": ["diameter_mm", "depth_mm"],
|
||||
"hole_counterbore": ["diameter_mm", "depth_mm"],
|
||||
"sphere_add": ["radius_mm", "center_mm"],
|
||||
}
|
||||
|
||||
|
||||
def _load(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _offset_sketch(sketch: dict[str, Any] | None, dx: float, dy: float, dz: float) -> dict[str, Any] | None:
|
||||
if sketch is None:
|
||||
return None
|
||||
s = deepcopy(sketch)
|
||||
wp = s.get("workplane") or {}
|
||||
o = list(wp.get("origin_mm") or [0, 0, 0])
|
||||
wp["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz]
|
||||
s["workplane"] = wp
|
||||
edges = []
|
||||
for e in s.get("contour_edges_mm") or []:
|
||||
ne = deepcopy(e)
|
||||
for key in ("start_mm", "end_mm", "center_mm"):
|
||||
if key in ne:
|
||||
p = ne[key]
|
||||
ne[key] = [p[0] + dx, p[1] + dy, p[2] + dz]
|
||||
edges.append(ne)
|
||||
if edges:
|
||||
s["contour_edges_mm"] = edges
|
||||
# 2D entities: shift in plane if offset has in-plane components only — skip for world offset patterns
|
||||
return s
|
||||
|
||||
|
||||
def _offset_params_positions(params: dict[str, Any], dx: float, dy: float, dz: float) -> dict[str, Any]:
|
||||
p = deepcopy(params)
|
||||
if "positions" in p:
|
||||
for pos in p["positions"]:
|
||||
mm = pos.get("mm")
|
||||
if mm:
|
||||
pos["mm"] = [mm[0] + dx, mm[1] + dy, mm[2] + dz]
|
||||
if "axis" in p and isinstance(p["axis"], dict):
|
||||
o = list(p["axis"].get("origin_mm") or [0, 0, 0])
|
||||
p["axis"]["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz]
|
||||
return p
|
||||
|
||||
|
||||
def compile_cdsl(
|
||||
cdsl: dict[str, Any],
|
||||
atoms_catalog: dict[str, Any] | None = None,
|
||||
techniques_catalog: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
allowed = set()
|
||||
if atoms_catalog:
|
||||
allowed = {a["atomic_id"] for a in atoms_catalog.get("atoms") or []}
|
||||
techniques = {
|
||||
item["technique_id"]: item
|
||||
for item in (techniques_catalog or {}).get("techniques") or []
|
||||
}
|
||||
|
||||
sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []}
|
||||
|
||||
# 参数化轮廓求解:将 profile 字段展开为精确的 entities + contour_edges_mm
|
||||
cdsl = resolve_all_sketches(cdsl)
|
||||
sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []}
|
||||
steps: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
# feature_id -> list of emitted step dicts (for pattern source)
|
||||
emitted: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
def emit(feature: dict[str, Any], params: dict[str, Any], sketch: dict[str, Any] | None, step_id: str) -> dict[str, Any]:
|
||||
atomic = feature["atomic_id"]
|
||||
if allowed and atomic not in allowed:
|
||||
raise ValueError(f"{step_id}: atomic_id {atomic!r} is not admitted by catalog")
|
||||
for dep in feature.get("depends_on") or []:
|
||||
if dep not in seen_ids and not any(dep in emitted):
|
||||
# dependency may be ok if earlier
|
||||
if dep not in seen_ids:
|
||||
raise ValueError(f"{step_id}: depends_on {dep} not yet defined")
|
||||
step = {
|
||||
"step_id": step_id,
|
||||
"atomic_id": atomic,
|
||||
"depends_on": list(feature.get("depends_on") or []),
|
||||
"params": params,
|
||||
"sketch": sketch,
|
||||
"source_name": feature.get("name"),
|
||||
}
|
||||
steps.append(step)
|
||||
seen_ids.add(step_id)
|
||||
return step
|
||||
|
||||
for feat in cdsl.get("features") or []:
|
||||
fid = feat["id"]
|
||||
atomic = feat.get("atomic_id")
|
||||
technique_id = feat.get("technique_id")
|
||||
if technique_id:
|
||||
technique = techniques.get(technique_id)
|
||||
if technique is None:
|
||||
raise ValueError(f"{fid}: technique_id {technique_id!r} is not admitted by catalog")
|
||||
groups = feat.get("params") or {}
|
||||
expanded: list[dict[str, Any]] = []
|
||||
previous_step_id: str | None = None
|
||||
for index, internal in enumerate(technique.get("internal_steps") or [], start=1):
|
||||
group_name = internal.get("params_from")
|
||||
group = deepcopy(groups.get(group_name) or {})
|
||||
if not isinstance(group, dict):
|
||||
raise ValueError(f"{fid}: parameter group {group_name!r} must be an object")
|
||||
params = deepcopy(group.get("params") if isinstance(group.get("params"), dict) else group)
|
||||
sketch_id = group.get("sketch_id") or params.pop("sketch_id", None)
|
||||
sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None
|
||||
internal_atomic = internal.get("atomic_id")
|
||||
if not internal_atomic:
|
||||
raise ValueError(f"{fid}: technique {technique_id!r} has an invalid internal step")
|
||||
for key in REQUIRED.get(internal_atomic, []):
|
||||
if params.get(key) is None:
|
||||
raise ValueError(
|
||||
f"{fid}: technique {technique_id!r} group {group_name!r} missing {key}"
|
||||
)
|
||||
internal_feature = {
|
||||
"atomic_id": internal_atomic,
|
||||
"depends_on": [previous_step_id] if previous_step_id else list(feat.get("depends_on") or []),
|
||||
"name": f"{feat.get('name') or technique_id}:{group_name or index}",
|
||||
}
|
||||
step_id = f"{fid}.t{index}"
|
||||
expanded.append(emit(internal_feature, params, sketch, step_id))
|
||||
previous_step_id = step_id
|
||||
if len(expanded) < 2:
|
||||
raise ValueError(f"{fid}: technique {technique_id!r} must expand to at least two steps")
|
||||
emitted[fid] = expanded
|
||||
seen_ids.add(fid)
|
||||
continue
|
||||
if not atomic:
|
||||
raise ValueError(f"{fid}: missing atomic_id")
|
||||
|
||||
if atomic == "pattern_linear":
|
||||
params = feat.get("params") or {}
|
||||
src_ids = params.get("source_feature_ids") or []
|
||||
c1 = int(params.get("pattern_count_1") or 1)
|
||||
c2 = int(params.get("pattern_count_2") or 1)
|
||||
s1 = float(params.get("spacing_1_mm") or 0)
|
||||
s2 = float(params.get("spacing_2_mm") or 0)
|
||||
d1 = params.get("direction_1") or [1, 0, 0]
|
||||
d2 = params.get("direction_2") or [0, 1, 0]
|
||||
if params.get("direction_1_reverse"):
|
||||
d1 = [-d1[0], -d1[1], -d1[2]]
|
||||
if params.get("direction_2_reverse"):
|
||||
d2 = [-d2[0], -d2[1], -d2[2]]
|
||||
|
||||
src_steps: list[dict[str, Any]] = []
|
||||
for sid in src_ids:
|
||||
src_steps.extend(emitted.get(sid) or [])
|
||||
if not src_steps:
|
||||
# 无源则跳过并记录
|
||||
steps.append(
|
||||
{
|
||||
"step_id": fid,
|
||||
"atomic_id": "noop_pattern",
|
||||
"depends_on": list(feat.get("depends_on") or []),
|
||||
"params": params,
|
||||
"sketch": None,
|
||||
"note": "pattern source steps missing",
|
||||
}
|
||||
)
|
||||
seen_ids.add(fid)
|
||||
continue
|
||||
|
||||
clone_steps = []
|
||||
k = 0
|
||||
for i in range(c1):
|
||||
for j in range(c2):
|
||||
if i == 0 and j == 0:
|
||||
continue
|
||||
dx = d1[0] * s1 * i + d2[0] * s2 * j
|
||||
dy = d1[1] * s1 * i + d2[1] * s2 * j
|
||||
dz = d1[2] * s1 * i + d2[2] * s2 * j
|
||||
for src in src_steps:
|
||||
k += 1
|
||||
clone_id = f"{fid}.p{k}"
|
||||
fake_feat = {
|
||||
"atomic_id": src["atomic_id"],
|
||||
"depends_on": [steps[-1]["step_id"]] if steps else [],
|
||||
"name": f"{src.get('source_name')}_pattern",
|
||||
}
|
||||
st = emit(
|
||||
fake_feat,
|
||||
_offset_params_positions(src["params"], dx, dy, dz),
|
||||
_offset_sketch(src.get("sketch"), dx, dy, dz),
|
||||
clone_id,
|
||||
)
|
||||
clone_steps.append(st)
|
||||
emitted[fid] = clone_steps
|
||||
seen_ids.add(fid)
|
||||
continue
|
||||
|
||||
params = deepcopy(feat.get("params") or {})
|
||||
sketch_id = feat.get("sketch_id") or params.get("sketch_id")
|
||||
sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None
|
||||
if sketch_id:
|
||||
params["sketch_id"] = sketch_id
|
||||
|
||||
# Auto-derive revolve axis origin
|
||||
if "revolve" in atomic and sketch and "axis" in params:
|
||||
ax = params.get("axis") or {}
|
||||
# 优先级: from_workplane_origin > from_contour_vertex > origin_mm 裸坐标
|
||||
wp = sketch.get("workplane") or {}
|
||||
wp_origin = wp.get("origin_mm") or [0.0, 0.0, 0.0]
|
||||
|
||||
if ax.get("from_workplane_origin") and "origin_mm" not in ax:
|
||||
params["axis"] = deepcopy(params["axis"])
|
||||
params["axis"]["origin_mm"] = list(wp_origin)
|
||||
elif "origin_mm" not in ax:
|
||||
ce = sketch.get("contour_edges_mm") or []
|
||||
if ce:
|
||||
idx = int(ax.get("from_contour_vertex", 0))
|
||||
vertex = ce[idx % len(ce)]["start_mm"]
|
||||
params["axis"] = deepcopy(params["axis"])
|
||||
params["axis"]["origin_mm"] = list(vertex)
|
||||
|
||||
for key in REQUIRED.get(atomic, []):
|
||||
if key == "axis" and "axis" not in params:
|
||||
raise ValueError(f"{fid}: missing axis")
|
||||
if key not in ("axis",) and params.get(key) is None and key != "sketch_id":
|
||||
# positions can be empty temporarily
|
||||
if key in params:
|
||||
continue
|
||||
if key in ("diameter_mm", "depth_mm", "distance_mm", "angle_deg") and params.get(key) is None:
|
||||
raise ValueError(f"{fid}: missing {key}")
|
||||
|
||||
st = emit(feat, params, sketch, fid)
|
||||
emitted[fid] = [st]
|
||||
|
||||
# filter noop
|
||||
steps = [s for s in steps if s.get("atomic_id") != "noop_pattern"]
|
||||
|
||||
return {
|
||||
"schema": "cad.engine_plan.v1",
|
||||
"part_id": cdsl.get("part_id"),
|
||||
"unit": "mm",
|
||||
"steps": steps,
|
||||
"compiler_context": deepcopy(cdsl.get("compiler_context")),
|
||||
"meta": {
|
||||
"from_cdsl_schema": cdsl.get("schema"),
|
||||
"compiler": "cad-heard.llm_compiler.v1",
|
||||
"n_steps": len(steps),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--cdsl", type=Path, required=True)
|
||||
ap.add_argument("--catalog", type=Path, default=None)
|
||||
ap.add_argument("--techniques", type=Path, default=None)
|
||||
ap.add_argument("--out", type=Path, required=True)
|
||||
args = ap.parse_args()
|
||||
catalog = _load(args.catalog) if args.catalog else None
|
||||
techniques = _load(args.techniques) if args.techniques else None
|
||||
pack = compile_cdsl(_load(args.cdsl), catalog, techniques)
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(json.dumps(pack, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"wrote {args.out} steps={len(pack['steps'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
__all__ = ["REQUIRED", "compile_cdsl", "main"]
|
||||
|
||||
@@ -1,555 +1,12 @@
|
||||
"""build123d 绘图引擎:执行 build_pack → STEP。"""
|
||||
"""Compatibility shim: the implementation moved to ``legacy.llm_engine``.
|
||||
|
||||
``run_engine_plan`` executes a legacy ``build_pack`` through build123d.
|
||||
It predates the session-based runtime and is frozen; new capability belongs
|
||||
in ``executors/`` and the schema contracts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from .legacy.llm_engine import SUPPORTED_ATOMIC_IDS, main, run_engine_plan
|
||||
|
||||
# 保留内置 float,防止被 build123d 上下文 shadow
|
||||
_f = builtins.float
|
||||
|
||||
from build123d import ( # noqa: E402
|
||||
Align,
|
||||
Axis,
|
||||
BuildPart,
|
||||
BuildSketch,
|
||||
Circle,
|
||||
Cone,
|
||||
Cylinder,
|
||||
Edge,
|
||||
Face,
|
||||
Location,
|
||||
Locations,
|
||||
Mode,
|
||||
Plane,
|
||||
Polygon,
|
||||
Sphere,
|
||||
Vector,
|
||||
Wire,
|
||||
export_step,
|
||||
extrude,
|
||||
import_step,
|
||||
revolve,
|
||||
)
|
||||
|
||||
|
||||
# Keep this in sync with the execution branches in run_engine_plan. The
|
||||
# agent-facing schema and its parity test prevent unsupported names reaching
|
||||
# this low-level dispatcher.
|
||||
SUPPORTED_ATOMIC_IDS = frozenset({
|
||||
"extrude_add_blind",
|
||||
"extrude_add_two_sided",
|
||||
"extrude_cut_blind",
|
||||
"revolve_add",
|
||||
"revolve_cut",
|
||||
"hole_blind",
|
||||
"hole_countersink",
|
||||
"hole_counterbore",
|
||||
"sphere_add",
|
||||
"reference_plane",
|
||||
"reference_axis",
|
||||
})
|
||||
|
||||
|
||||
def _load(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _plane_from_workplane(wp: dict[str, Any]) -> Plane:
|
||||
o = wp.get("origin_mm") or [0, 0, 0]
|
||||
x = wp.get("x_dir") or [1, 0, 0]
|
||||
n = wp.get("normal") or [0, 0, 1]
|
||||
return Plane(
|
||||
origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])),
|
||||
x_dir=Vector(_f(x[0]), _f(x[1]), _f(x[2])),
|
||||
z_dir=Vector(_f(n[0]), _f(n[1]), _f(n[2])),
|
||||
)
|
||||
|
||||
|
||||
def _axis_from_params(axis: dict[str, Any]) -> Axis:
|
||||
o = axis.get("origin_mm") or [0, 0, 0]
|
||||
d = axis.get("direction") or [1, 0, 0]
|
||||
return Axis(
|
||||
origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])),
|
||||
direction=Vector(_f(d[0]), _f(d[1]), _f(d[2])),
|
||||
)
|
||||
|
||||
|
||||
def _ordered_profile_points(sketch: dict[str, Any]) -> list[tuple[float, float]]:
|
||||
entities = sketch.get("entities") or []
|
||||
line_loop = [
|
||||
i for i, e in enumerate(entities) if e["type"] == "line" and not e.get("construction")
|
||||
]
|
||||
if not line_loop:
|
||||
raise ValueError(f"sketch {sketch.get('id')}: no profile lines")
|
||||
pts: list[tuple[float, float]] = []
|
||||
for i in line_loop:
|
||||
e = entities[i]
|
||||
s = (_f(e["start"][0]), _f(e["start"][1]))
|
||||
en = (_f(e["end"][0]), _f(e["end"][1]))
|
||||
if not pts:
|
||||
pts.append(s)
|
||||
if abs(pts[-1][0] - s[0]) + abs(pts[-1][1] - s[1]) > 1e-4:
|
||||
if abs(pts[-1][0] - en[0]) + abs(pts[-1][1] - en[1]) <= 1e-4:
|
||||
s, en = en, s
|
||||
else:
|
||||
pts.append(s)
|
||||
pts.append(en)
|
||||
if abs(pts[0][0] - pts[-1][0]) + abs(pts[0][1] - pts[-1][1]) > 1e-4:
|
||||
pts.append(pts[0])
|
||||
return pts
|
||||
|
||||
|
||||
def _arc_midpoint(edge: dict[str, Any], p1: Vector, p2: Vector, center: Vector, radius: float) -> Vector:
|
||||
"""Return a point on the intended directed arc for ``make_three_point_arc``.
|
||||
|
||||
Legacy contour data has no sweep direction and retains its prior shortest
|
||||
arc behavior. Evidence-v2 analytic contours carry ``clockwise`` so a
|
||||
major arc or a clockwise arc cannot be silently inverted by the adapter.
|
||||
"""
|
||||
v1 = p1 - center
|
||||
v2 = p2 - center
|
||||
if v1.length < 1e-9 or v2.length < 1e-9:
|
||||
return (p1 + p2) / 2
|
||||
n = Vector(*(edge.get("normal") or [0, 0, 1]))
|
||||
if n.length < 1e-9:
|
||||
n = v1.cross(v2)
|
||||
if n.length < 1e-9:
|
||||
n = Vector(0, 0, 1)
|
||||
n = n.normalized()
|
||||
v1n = v1.normalized() * radius
|
||||
if "clockwise" not in edge:
|
||||
bisector = v1n + v2.normalized() * radius
|
||||
if bisector.length < 1e-9:
|
||||
bisector = n.cross(v1n)
|
||||
return center + bisector.normalized() * radius
|
||||
sweep = math.atan2(n.dot(v1.cross(v2)), v1.dot(v2))
|
||||
if bool(edge["clockwise"]):
|
||||
if sweep >= 0:
|
||||
sweep -= math.tau
|
||||
elif sweep <= 0:
|
||||
sweep += math.tau
|
||||
half = sweep / 2
|
||||
midpoint_vector = v1n * math.cos(half) + n.cross(v1n) * math.sin(half)
|
||||
return center + midpoint_vector
|
||||
|
||||
|
||||
def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal: list[float] | None = None) -> Face:
|
||||
b123_edges: list[Edge] = []
|
||||
for e in edges_mm:
|
||||
p1 = Vector(*e["start_mm"])
|
||||
p2 = Vector(*e["end_mm"])
|
||||
if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None:
|
||||
center = Vector(*e["center_mm"])
|
||||
r = _f(e["radius_mm"])
|
||||
v1 = p1 - center
|
||||
v2 = p2 - center
|
||||
if v1.length < 1e-9 or v2.length < 1e-9:
|
||||
b123_edges.append(Edge.make_line(p1, p2))
|
||||
continue
|
||||
mid = _arc_midpoint(e, p1, p2, center, r)
|
||||
try:
|
||||
b123_edges.append(Edge.make_three_point_arc(p1, mid, p2))
|
||||
except Exception:
|
||||
b123_edges.append(Edge.make_line(p1, p2))
|
||||
else:
|
||||
b123_edges.append(Edge.make_line(p1, p2))
|
||||
face = Face(Wire(b123_edges))
|
||||
if desired_normal is not None:
|
||||
dn = Vector(*desired_normal)
|
||||
if dn.length > 1e-9:
|
||||
fn = face.normal_at()
|
||||
if fn.dot(dn) < 0:
|
||||
# 重建反转的 Wire:边顺序反转 + 每条边起止点交换
|
||||
# 这样法向自然翻转,但每条边的几何方向不变(不同于 Face.Reversed)
|
||||
rev_edges: list[Edge] = []
|
||||
for e in reversed(edges_mm):
|
||||
p1 = Vector(*e["end_mm"])
|
||||
p2 = Vector(*e["start_mm"])
|
||||
if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None:
|
||||
center = Vector(*e["center_mm"])
|
||||
r = _f(e["radius_mm"])
|
||||
v1 = p1 - center
|
||||
v2 = p2 - center
|
||||
if v1.length < 1e-9 or v2.length < 1e-9:
|
||||
rev_edges.append(Edge.make_line(p1, p2))
|
||||
continue
|
||||
mid = _arc_midpoint(e, p1, p2, center, r)
|
||||
try:
|
||||
rev_edges.append(Edge.make_three_point_arc(p1, mid, p2))
|
||||
except Exception:
|
||||
rev_edges.append(Edge.make_line(p1, p2))
|
||||
else:
|
||||
rev_edges.append(Edge.make_line(p1, p2))
|
||||
face = Face(Wire(rev_edges))
|
||||
return face
|
||||
|
||||
|
||||
def _amount(params: dict[str, Any], *, prefer_sign: str | None = None) -> float:
|
||||
dist = abs(_f(params["distance_mm"]))
|
||||
if prefer_sign == "plus":
|
||||
return dist
|
||||
if prefer_sign == "minus":
|
||||
return -dist
|
||||
return -dist if bool(params.get("reverse")) else dist
|
||||
|
||||
|
||||
def _build_nested_circle_profiles(circles: list[dict[str, Any]]) -> None:
|
||||
"""Build circular islands and holes from containment parity.
|
||||
|
||||
A circle contained by one larger circle is a hole; a circle contained by
|
||||
two larger circles is an island again. This preserves annular profiles
|
||||
without storing the heavy tessellated sketch regions from the SW export.
|
||||
"""
|
||||
ordered = sorted(circles, key=lambda item: _f(item["radius_mm"]), reverse=True)
|
||||
tolerance = 1e-6
|
||||
for index, circle in enumerate(ordered):
|
||||
center = circle["center"]
|
||||
radius = _f(circle["radius_mm"])
|
||||
containing = 0
|
||||
for outer in ordered[:index]:
|
||||
outer_center = outer["center"]
|
||||
outer_radius = _f(outer["radius_mm"])
|
||||
distance = math.hypot(
|
||||
_f(center[0]) - _f(outer_center[0]),
|
||||
_f(center[1]) - _f(outer_center[1]),
|
||||
)
|
||||
if distance + radius <= outer_radius + tolerance:
|
||||
containing += 1
|
||||
mode = Mode.ADD if containing % 2 == 0 else Mode.SUBTRACT
|
||||
with Locations((_f(center[0]), _f(center[1]))):
|
||||
Circle(radius, mode=mode)
|
||||
|
||||
|
||||
def run_engine_plan(
|
||||
pack: dict[str, Any],
|
||||
out_step: Path,
|
||||
*,
|
||||
cut_sign: str = "from_params",
|
||||
) -> dict[str, Any]:
|
||||
log: list[str] = []
|
||||
|
||||
compiler_context = pack.get("compiler_context")
|
||||
if isinstance(compiler_context, dict):
|
||||
# 回退路径:使用本包 translator(不依赖外部 backend.src)
|
||||
try:
|
||||
from .translator import generate_build123d_code, get_part_name
|
||||
except ImportError:
|
||||
from translator import generate_build123d_code, get_part_name
|
||||
|
||||
context = dict(compiler_context)
|
||||
context.setdefault("metadata", {})["part_name"] = str(pack.get("part_id") or out_step.stem)
|
||||
out_step.parent.mkdir(parents=True, exist_ok=True)
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", generate_build123d_code(context)],
|
||||
cwd=out_step.parent,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"exact compiler execution failed\nSTDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}"
|
||||
)
|
||||
generated_name = get_part_name({"part_name": context["metadata"]["part_name"]})
|
||||
generated = out_step.parent / f"{generated_name}.step"
|
||||
if generated != out_step and generated.exists():
|
||||
generated.replace(out_step)
|
||||
if not out_step.exists():
|
||||
raise RuntimeError(f"exact compiler did not generate {out_step}")
|
||||
solid = import_step(str(out_step))
|
||||
bb = solid.bounding_box()
|
||||
return {
|
||||
"out_step": str(out_step),
|
||||
"volume_mm3": _f(solid.volume),
|
||||
"bbox_mm": {
|
||||
"min": [bb.min.X, bb.min.Y, bb.min.Z],
|
||||
"max": [bb.max.X, bb.max.Y, bb.max.Z],
|
||||
},
|
||||
"engine": "translator_fallback",
|
||||
}
|
||||
|
||||
with BuildPart() as part:
|
||||
for step in pack.get("steps") or []:
|
||||
atomic = step["atomic_id"]
|
||||
params = step["params"]
|
||||
sketch = step.get("sketch")
|
||||
sid = step.get("step_id")
|
||||
|
||||
if atomic == "reference_plane":
|
||||
# Context features deliberately produce no solid. They remain
|
||||
# executable plan steps so their dependencies are preserved and
|
||||
# can be registered by the session-based runtime.
|
||||
plane = _plane_from_workplane(params.get("plane") or {})
|
||||
log.append(
|
||||
f"{sid}: reference_plane origin={tuple(plane.origin)} normal={tuple(plane.z_dir)}"
|
||||
)
|
||||
|
||||
elif atomic == "reference_axis":
|
||||
axis = _axis_from_params(params.get("axis") or {})
|
||||
log.append(
|
||||
f"{sid}: reference_axis origin={tuple(axis.position)} direction={tuple(axis.direction)}"
|
||||
)
|
||||
|
||||
elif atomic == "sphere_add":
|
||||
radius = _f(params.get("radius_mm") or 0)
|
||||
center = params.get("center_mm") or [0, 0, 0]
|
||||
if radius <= 0 or len(center) != 3:
|
||||
raise ValueError(f"{sid}: sphere_add requires a positive radius_mm and center_mm")
|
||||
with Locations((_f(center[0]), _f(center[1]), _f(center[2]))):
|
||||
Sphere(radius, mode=Mode.ADD)
|
||||
log.append(f"{sid}: sphere_add radius={radius}")
|
||||
|
||||
elif atomic in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"):
|
||||
if sketch is None:
|
||||
raise ValueError(f"{sid}: missing sketch")
|
||||
plane = _plane_from_workplane(sketch.get("workplane") or {})
|
||||
mode = Mode.SUBTRACT if "cut" in atomic else Mode.ADD
|
||||
edges = sketch.get("contour_edges_mm") or []
|
||||
regions = sketch.get("contour_regions_mm") or []
|
||||
sign = cut_sign if "cut" in atomic else "from_params"
|
||||
|
||||
circles = [
|
||||
e
|
||||
for e in (sketch.get("entities") or [])
|
||||
if e.get("type") == "circle" and not e.get("construction")
|
||||
]
|
||||
lines = [
|
||||
e
|
||||
for e in (sketch.get("entities") or [])
|
||||
if e.get("type") == "line" and not e.get("construction")
|
||||
]
|
||||
|
||||
# 多区域轮廓(外环 + 孔):由 shape generator 展开
|
||||
if regions:
|
||||
faces = []
|
||||
normal = (sketch.get("workplane") or {}).get("normal")
|
||||
for reg in regions:
|
||||
outer_edges = reg.get("outer") or []
|
||||
if len(outer_edges) < 2:
|
||||
continue
|
||||
face = _face_from_contour_edges(outer_edges, desired_normal=normal)
|
||||
for hole_edges in reg.get("holes") or []:
|
||||
if len(hole_edges) < 2:
|
||||
continue
|
||||
hole = _face_from_contour_edges(hole_edges, desired_normal=normal)
|
||||
face = face.cut(hole)
|
||||
faces.append(face)
|
||||
if not faces:
|
||||
raise ValueError(f"{sid}: contour_regions_mm produced no faces")
|
||||
if atomic == "extrude_add_two_sided":
|
||||
d = abs(_f(params["distance_mm"]))
|
||||
for face in faces:
|
||||
extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD)
|
||||
else:
|
||||
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
|
||||
for face in faces:
|
||||
extrude(to_extrude=face, amount=amt, mode=mode)
|
||||
log.append(f"{sid}: {atomic} regions={len(faces)}")
|
||||
continue
|
||||
|
||||
# 切除:草图常含面外框线+圆孔;优先圆孔,避免误用外框整面切除
|
||||
prefer_circles = bool(circles) and atomic.startswith("extrude_cut")
|
||||
|
||||
if prefer_circles:
|
||||
with BuildSketch(plane):
|
||||
for e in circles:
|
||||
with Locations((_f(e["center"][0]), _f(e["center"][1]))):
|
||||
Circle(_f(e["radius_mm"]))
|
||||
if atomic == "extrude_add_two_sided":
|
||||
d = abs(_f(params["distance_mm"]))
|
||||
extrude(amount=d, both=True, mode=Mode.ADD)
|
||||
else:
|
||||
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
|
||||
extrude(amount=amt, mode=mode)
|
||||
log.append(f"{sid}: {atomic} circle-only n={len(circles)}")
|
||||
elif len(edges) >= 2:
|
||||
face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal"))
|
||||
if atomic == "extrude_add_two_sided":
|
||||
d = abs(_f(params["distance_mm"]))
|
||||
extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD)
|
||||
log.append(f"{sid}: extrude_two_sided both={d} contour")
|
||||
else:
|
||||
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
|
||||
extrude(to_extrude=face, amount=amt, mode=mode)
|
||||
log.append(f"{sid}: {atomic} amount={amt} contour")
|
||||
elif circles and not lines:
|
||||
# 纯圆轮廓:用包含层级区分实体、内孔和孔中岛。
|
||||
with BuildSketch(plane):
|
||||
_build_nested_circle_profiles(circles)
|
||||
if atomic == "extrude_add_two_sided":
|
||||
d = abs(_f(params["distance_mm"]))
|
||||
extrude(amount=d, both=True, mode=Mode.ADD)
|
||||
else:
|
||||
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
|
||||
extrude(amount=amt, mode=mode)
|
||||
log.append(f"{sid}: {atomic} circle-only n={len(circles)}")
|
||||
else:
|
||||
with BuildSketch(plane):
|
||||
pts = _ordered_profile_points(sketch)
|
||||
poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts
|
||||
Polygon(*poly)
|
||||
for e in circles:
|
||||
with Locations((_f(e["center"][0]), _f(e["center"][1]))):
|
||||
Circle(_f(e["radius_mm"]), mode=Mode.SUBTRACT)
|
||||
if atomic == "extrude_add_two_sided":
|
||||
d = abs(_f(params["distance_mm"]))
|
||||
extrude(amount=d, both=True, mode=Mode.ADD)
|
||||
log.append(f"{sid}: extrude_two_sided both={d} poly")
|
||||
else:
|
||||
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
|
||||
extrude(amount=amt, mode=mode)
|
||||
log.append(f"{sid}: {atomic} amount={amt} poly")
|
||||
|
||||
elif atomic in ("revolve_add", "revolve_cut"):
|
||||
if sketch is None:
|
||||
raise ValueError(f"{sid}: missing sketch")
|
||||
plane = _plane_from_workplane(sketch.get("workplane") or {})
|
||||
axis = _axis_from_params(params.get("axis") or {})
|
||||
angle = _f(params.get("angle_deg") or 360)
|
||||
mode = Mode.SUBTRACT if atomic == "revolve_cut" else Mode.ADD
|
||||
edges = sketch.get("contour_edges_mm") or []
|
||||
if len(edges) >= 2:
|
||||
face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal"))
|
||||
revolve(profiles=face, axis=axis, revolution_arc=angle, mode=mode)
|
||||
else:
|
||||
with BuildSketch(plane):
|
||||
pts = _ordered_profile_points(sketch)
|
||||
poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts
|
||||
Polygon(*poly)
|
||||
revolve(axis=axis, revolution_arc=angle, mode=mode)
|
||||
log.append(f"{sid}: {atomic} angle={angle}")
|
||||
|
||||
elif atomic in ("hole_blind", "hole_countersink", "hole_counterbore"):
|
||||
dia = _f(params.get("diameter_mm") or 0)
|
||||
depth = _f(params.get("depth_mm") or 0)
|
||||
positions = params.get("positions") or []
|
||||
if sketch is not None:
|
||||
plane = _plane_from_workplane(sketch.get("workplane") or {})
|
||||
else:
|
||||
plane = Plane.XY
|
||||
host_face = params.get("host_face") or {}
|
||||
frame = host_face.get("frame") or {}
|
||||
frame_origin = Vector(*(frame.get("origin_mm") or plane.origin.to_tuple()))
|
||||
frame_x = Vector(*(frame.get("x_dir") or plane.x_dir.to_tuple()))
|
||||
frame_y = Vector(*(frame.get("y_dir") or plane.y_dir.to_tuple()))
|
||||
normal = plane.z_dir.normalized()
|
||||
bb = part.part.bounding_box()
|
||||
part_center = Vector(
|
||||
(bb.min.X + bb.max.X) / 2,
|
||||
(bb.min.Y + bb.max.Y) / 2,
|
||||
(bb.min.Z + bb.max.Z) / 2,
|
||||
)
|
||||
inward = normal if (part_center - frame_origin).dot(normal) >= 0 else -normal
|
||||
for pos in positions:
|
||||
mm = pos.get("mm") or [0, 0, 0]
|
||||
start = frame_origin + frame_x * _f(mm[0]) + frame_y * _f(mm[1])
|
||||
cs_dia = _f(params.get("countersink_diameter_mm") or 0)
|
||||
cs_angle = _f(params.get("countersink_angle_rad") or 0)
|
||||
cb_dia = _f(params.get("counterbore_diameter_mm") or 0)
|
||||
cb_depth = _f(params.get("counterbore_depth_mm") or 0)
|
||||
cs_depth = (
|
||||
((cs_dia - dia) / 2) / math.tan(cs_angle / 2)
|
||||
if cs_dia > dia and cs_angle > 0
|
||||
else 0
|
||||
)
|
||||
base_offset = cs_depth + (cb_depth if cb_dia > dia else 0)
|
||||
main_depth = max(0.001, abs(depth) - base_offset)
|
||||
main_place = Location(Plane(origin=start + inward * base_offset, z_dir=inward))
|
||||
tools = [
|
||||
Cylinder(
|
||||
radius=dia / 2,
|
||||
height=main_depth,
|
||||
align=(Align.CENTER, Align.CENTER, Align.MIN),
|
||||
mode=Mode.PRIVATE,
|
||||
).move(main_place)
|
||||
]
|
||||
if cb_dia > dia and cb_depth > 0:
|
||||
tools.append(
|
||||
Cylinder(
|
||||
radius=cb_dia / 2,
|
||||
height=cb_depth,
|
||||
align=(Align.CENTER, Align.CENTER, Align.MIN),
|
||||
mode=Mode.PRIVATE,
|
||||
).move(Location(Plane(origin=start, z_dir=inward)))
|
||||
)
|
||||
if cs_depth > 0:
|
||||
tools.append(
|
||||
Cone(
|
||||
bottom_radius=cs_dia / 2,
|
||||
top_radius=dia / 2,
|
||||
height=cs_depth,
|
||||
align=(Align.CENTER, Align.CENTER, Align.MIN),
|
||||
mode=Mode.PRIVATE,
|
||||
).move(Location(Plane(origin=start, z_dir=inward)))
|
||||
)
|
||||
drill_angle = _f(params.get("drill_angle_rad") or 0)
|
||||
if drill_angle > 0:
|
||||
tip_depth = (dia / 2) / math.tan(drill_angle / 2)
|
||||
tools.append(
|
||||
Cone(
|
||||
bottom_radius=dia / 2,
|
||||
top_radius=0,
|
||||
height=tip_depth,
|
||||
align=(Align.CENTER, Align.CENTER, Align.MIN),
|
||||
mode=Mode.PRIVATE,
|
||||
).move(
|
||||
Location(
|
||||
Plane(origin=start + inward * abs(depth), z_dir=inward)
|
||||
)
|
||||
)
|
||||
)
|
||||
for tool in tools:
|
||||
part.part = part.part.cut(tool)
|
||||
log.append(f"{sid}: {atomic} npos={len(positions)}")
|
||||
|
||||
else:
|
||||
raise ValueError(f"unsupported atomic_id: {atomic}")
|
||||
|
||||
solid = part.part
|
||||
|
||||
out_step.parent.mkdir(parents=True, exist_ok=True)
|
||||
export_step(solid, str(out_step))
|
||||
bb = solid.bounding_box()
|
||||
return {
|
||||
"out_step": str(out_step),
|
||||
"volume_mm3": _f(solid.volume),
|
||||
"bbox_mm": {
|
||||
"min": [bb.min.X, bb.min.Y, bb.min.Z],
|
||||
"max": [bb.max.X, bb.max.Y, bb.max.Z],
|
||||
},
|
||||
"log": log,
|
||||
"cut_sign": cut_sign,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--pack", type=Path, required=True)
|
||||
ap.add_argument("--out-step", type=Path, required=True)
|
||||
ap.add_argument("--report", type=Path, default=None)
|
||||
ap.add_argument("--cut-sign", default="from_params", choices=["from_params", "plus", "minus"])
|
||||
args = ap.parse_args()
|
||||
info = run_engine_plan(_load(args.pack), args.out_step, cut_sign=args.cut_sign)
|
||||
if args.report:
|
||||
args.report.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(
|
||||
json.dumps(
|
||||
{k: info[k] for k in ("out_step", "volume_mm3", "bbox_mm", "cut_sign", "engine") if k in info},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
for line in info.get("log") or []:
|
||||
print(line)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
__all__ = ["SUPPORTED_ATOMIC_IDS", "main", "run_engine_plan"]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user