diff --git a/.gitignore b/.gitignore index 0747fbf3..d7fa50d5 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index 67adbaf4..001202bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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、回归结果和可回滚边界。 diff --git a/backend/.env b/backend/.env index a92ffe62..08eb7c71 100644 --- a/backend/.env +++ b/backend/.env @@ -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 diff --git a/backend/README.md b/backend/README.md index d2b5371d..2aac94a7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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__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. diff --git a/backend/agent/skills/cad-authoring/SKILL.md b/backend/agent/skills/cad-authoring/SKILL.md new file mode 100644 index 00000000..5644d30b --- /dev/null +++ b/backend/agent/skills/cad-authoring/SKILL.md @@ -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. diff --git a/backend/agent/skills/cdsl-author-guidance/00-author-contract.md b/backend/agent/skills/cdsl-author-guidance/00-author-contract.md deleted file mode 100644 index 32fe74e1..00000000 --- a/backend/agent/skills/cdsl-author-guidance/00-author-contract.md +++ /dev/null @@ -1 +0,0 @@ -以当前 schema、`operation_contract` 与 topology token 为准;每次只执行一个原子操作,不编造字段或选择器。数值须为有限 mm/deg。保留最后可执行 checkpoint,并如实报告未满足项。 diff --git a/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md b/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md deleted file mode 100644 index 1c850d90..00000000 --- a/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md +++ /dev/null @@ -1 +0,0 @@ -先区分显式事实、图像观察、工程默认值和未知项。提取单位、外形、功能面、孔/槽、配合关系、关键尺寸及可验证目标。默认值只能补足常见零件的非关键构造,不能把未说明尺寸伪装成用户要求或确定性验收值。只有安全、配合、合规或可建模性确实取决于一个缺失事实时,才提出一个聚焦澄清;其余不确定性记录为假设或风险。 diff --git a/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md b/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md deleted file mode 100644 index 035276fe..00000000 --- a/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md +++ /dev/null @@ -1 +0,0 @@ -把尺寸当作模型契约:先识别主控的长度、宽度、厚度、直径、中心距、节距、数量、半径和角度,再从它们推导重复位置、对称偏移和余量。所有尺寸明确使用 mm,角度使用 deg;长度、直径、深度、节距和圆角半径必须为合理正有限值。阵列优先由中心线、数量、节距、半径或角度推导,避免难以追溯的点坐标常数。提交前以包围盒、比例、壁厚/材料余量和目标特征数量做常识检查。 diff --git a/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md b/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md deleted file mode 100644 index ff1b0c06..00000000 --- a/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md +++ /dev/null @@ -1 +0,0 @@ -世界坐标为右手 mm;根特征只在 contract 允许时用 `XY`/`+Z`。`workplane.origin_mm`、`x_dir`、`normal` 定义局部 frame;孔位是世界坐标。后续特征仅用已验证 datum/token,不能猜测最后生成面。 diff --git a/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md b/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md deleted file mode 100644 index 28e2331d..00000000 --- a/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md +++ /dev/null @@ -1 +0,0 @@ -优先把零件身份和主控尺寸写进稳定根特征:根体、主要增材体、主要切除、孔/槽、重复特征、最后的圆角/倒角。每个节点只承担一个原子意图,依赖边只表示直接几何前提。默认形成连通单体;确需多体时必须由目标和 contract 支持。避免把视觉装饰、细小倒角或易碎布尔放在主形体之前。重规划时保留已完成节点和可执行检查点,只替换最小必要子图。 diff --git a/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md b/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md deleted file mode 100644 index 289c3ecf..00000000 --- a/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md +++ /dev/null @@ -1 +0,0 @@ -轮廓必须闭合、不自交、无零长或重叠边,并清楚区分外环和内环。先验证 workplane 的原点、`x_dir`、`normal` 与局部轮廓方向;翻转方向使用 contract 允许的字段,不凭视觉猜测。切除从实际材料面进入,深度覆盖目标材料并满足当前预检;避免刚好停在共面边界。对薄壁、近相切、重叠工具和零厚度结果保持余量。切除失败先检查宿主、方向、深度和轮廓,再考虑更换建模顺序。 diff --git a/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md b/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md deleted file mode 100644 index 56109b58..00000000 --- a/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md +++ /dev/null @@ -1 +0,0 @@ -宿主特征只能使用当前 revision 的测量 topology 和服务端给出的不透明 selector token;不得按边/面列表下标、历史名称或“最后一个面”猜选。选择前核对 token 的 kind、中心、法向、包围盒和 surface_type 是否覆盖预期材料区域。布尔、孔、阵列、圆角后拓扑可能变化,旧 token 和 reference 不可假定仍有效;依赖新拓扑时重新观察。reference token 只按当前 contract 放入允许槽位。选择不确定时请求 topology,而不是提交模糊 selector。 diff --git a/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md b/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md deleted file mode 100644 index 6a1fa24b..00000000 --- a/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md +++ /dev/null @@ -1 +0,0 @@ -对称和重复优先通过 `pattern_linear`、`pattern_mirror` 及其 contract 参数表达。先完成一个正确的源特征,再用中心面、中心线、方向、数量、节距、半径或角度定义重复关系;不要用零散手填坐标代替可追溯模式。镜像平面和阵列方向应来自已建立的 datum 或当前测量 token。阵列前确认源特征、间距和数量不会重叠、越界或使材料变成零厚度。 diff --git a/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md b/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md deleted file mode 100644 index 3ab5a436..00000000 --- a/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md +++ /dev/null @@ -1 +0,0 @@ -圆角和倒角仅在主形体、切除和孔稳定后执行,并只选择唯一、当前有效的边 token;禁止“所有边”式回退。半径/距离必须小于邻近材料可容纳范围,避免相邻圆角相交。布尔操作避开共面终止、近相切和重复工具重叠;若风险高,优先以更稳定的主轮廓、顺序或足够余量表达。失败时不要重复原片段,先诊断受影响的面、边、深度和拓扑。 diff --git a/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md b/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md deleted file mode 100644 index 8183f587..00000000 --- a/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md +++ /dev/null @@ -1 +0,0 @@ -确定性几何事实与视觉审查职责不同:包围盒、实体数、孔深或贯穿状态只能证明已测量的 claim,不能证明整体设计语义。使用当前 contract、预检结果、claim evidence、render manifest 和 recent failures 作决定。视觉不符时给出具体的形状、位置、方向或比例差异作为修复依据,不能把它伪装成确定性通过。仅在几何改变后重新审查;STEP/checkpoint 是主工件,GLB 和渲染是派生审查证据,不能替代 CAD 几何。 diff --git a/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md b/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md deleted file mode 100644 index 145d9f5f..00000000 --- a/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md +++ /dev/null @@ -1 +0,0 @@ -修复先读错误和证据,定位最小责任点,再改最小的 CDSL/计划部分并重新执行依赖检查。常见原因包括开环/自交轮廓、零或负尺寸、切除方向或深度错误、错误 host frame、布尔后的旧 selector、过大圆角和直径/半径混淆。不要原样重试已失败片段。运行时不支持的能力应作为风险或缺口保留并继续发布最佳可执行模型,不能发明新 atom 或删除有效 checkpoint。 diff --git a/backend/agent/skills/cdsl-author-guidance/README.md b/backend/agent/skills/cdsl-author-guidance/README.md deleted file mode 100644 index 96da5a74..00000000 --- a/backend/agent/skills/cdsl-author-guidance/README.md +++ /dev/null @@ -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. diff --git a/backend/agent/skills/cdsl-author-guidance/manifest.json b/backend/agent/skills/cdsl-author-guidance/manifest.json deleted file mode 100644 index cd4de229..00000000 --- a/backend/agent/skills/cdsl-author-guidance/manifest.json +++ /dev/null @@ -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"] - } -} diff --git a/backend/agent/skills/cdsl-author-guidance/op-bend.md b/backend/agent/skills/cdsl-author-guidance/op-bend.md deleted file mode 100644 index 8e9918b6..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-bend.md +++ /dev/null @@ -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 替代。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md b/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md deleted file mode 100644 index 754cacb0..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md +++ /dev/null @@ -1 +0,0 @@ -`extrude_add_blind` 和 `extrude_add_two_sided` 必须使用闭合草图和 contract 允许的正距离。根挤出遵守根 `XY` datum;后续增材先确认草图 frame 与已有实体的连接。双向挤出分别核对两个方向的长度与材料范围;`reverse` 只用于当前 frame 的方向修正,不能代替错误的 workplane。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md b/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md deleted file mode 100644 index f88a19b2..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md +++ /dev/null @@ -1 +0,0 @@ -`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。`extrude_cut_two_sided` 必须分别提供正向和反向的距离与终止条件,不能以单侧深度近似双向切除。`extrude_cut_through` 只接受明确的 `end_condition`,由现有主体跨度决定穿透距离,不能伪造盲向深度。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-finish.md b/backend/agent/skills/cdsl-author-guidance/op-finish.md deleted file mode 100644 index 296f7647..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-finish.md +++ /dev/null @@ -1 +0,0 @@ -`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。`shell` 必须保留显式目标 body 与待移除 face token。`boolean_bodies`、`transform_bodies`、`delete_bodies` 只操作 contract 指定且仍独立存在的 body;不以当前 body 或隐式 union 兜底。失败时保留主体并报告风险。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-gear.md b/backend/agent/skills/cdsl-author-guidance/op-gear.md deleted file mode 100644 index bc47d341..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-gear.md +++ /dev/null @@ -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 叠加。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-hole.md b/backend/agent/skills/cdsl-author-guidance/op-hole.md deleted file mode 100644 index 455e5be8..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-hole.md +++ /dev/null @@ -1 +0,0 @@ -孔 atom 需要当前宿主面的有效 selector token。`positions[].mm` 使用该宿主面上的绝对世界坐标,先核对点在面区域内与法向方向。直径、深度、沉孔/沉头参数以 contract 为准,深度覆盖预期材料;多孔共享一个原子操作时保持同一规格和同一宿主。不要把点写成面局部偏移或裸数组。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-loft.md b/backend/agent/skills/cdsl-author-guidance/op-loft.md deleted file mode 100644 index 30303069..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-loft.md +++ /dev/null @@ -1 +0,0 @@ -`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。`loft_add_with_cap_face` 只能使用 contract 许可的 cap-face token。`sweep_add` 必须保留闭合截面与显式、非退化路径,不以放样或挤出替代。不要把 selector 选中的实体面当作放样截面,除非 contract 明确支持。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-pattern.md b/backend/agent/skills/cdsl-author-guidance/op-pattern.md deleted file mode 100644 index 8bf48548..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-pattern.md +++ /dev/null @@ -1 +0,0 @@ -`pattern_linear` 只复制当前 contract 允许且存在的源 feature reference;方向是明确世界/基准方向,数量和 spacing 为合理值。`pattern_mirror` 使用存在的镜像 plane reference,先确认源与平面关系以及复制后不会重叠或意外合并。pattern 不代替新的宿主选择;下游特征若依赖新面,重新读取 topology。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-primitives.md b/backend/agent/skills/cdsl-author-guidance/op-primitives.md deleted file mode 100644 index 09e10d85..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-primitives.md +++ /dev/null @@ -1 +0,0 @@ -`box_add` 和 `cylinder_add` 是世界坐标原生图元。按 operation contract 提供正尺寸以及明确的 `center_mm` 或 axis。仅在目标确为长方体或圆柱体时使用;由轮廓驱动的几何保留草图、放样等历史表达。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-reference.md b/backend/agent/skills/cdsl-author-guidance/op-reference.md deleted file mode 100644 index 20e1e31f..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-reference.md +++ /dev/null @@ -1 +0,0 @@ -`reference_plane` 用有限非零 `normal` 和与其不平行的 `x_dir` 定义局部 frame;`origin_mm` 是世界点。`reference_axis` 用有限非零 `direction` 和世界原点定义。它们只建立可追溯 datum,不直接制造实体;先于依赖它的旋转、镜像、阵列或定位特征,并依照 contract 的 reference token 规则引用。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-revolve.md b/backend/agent/skills/cdsl-author-guidance/op-revolve.md deleted file mode 100644 index 161997dc..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-revolve.md +++ /dev/null @@ -1 +0,0 @@ -`revolve_add` 和 `revolve_cut` 的轴必须由明确 datum 或 contract 中的世界坐标轴表达,并按预检要求位于正确的草图关系中。核对 axis origin、direction、角度和 `reverse`;完整回转避免轮廓跨轴造成自交,局部回转避免与现有材料近相切。切除回转仍必须覆盖目标材料。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-sphere.md b/backend/agent/skills/cdsl-author-guidance/op-sphere.md deleted file mode 100644 index 99c82b4b..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-sphere.md +++ /dev/null @@ -1 +0,0 @@ -`sphere_add` 用明确的世界中心和正有限半径定义。确认它与目标实体的连接意图:需要单体时应有足够相交,独立体仅在需求允许多体时使用。球体位置从 datum 或主尺寸导出,不把视图坐标误当世界坐标。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-thread.md b/backend/agent/skills/cdsl-author-guidance/op-thread.md deleted file mode 100644 index 38e932da..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-thread.md +++ /dev/null @@ -1 +0,0 @@ -`thread_add` 和 `thread_cut` 需要明确的 axis、正的大小径、螺距和长度,且各参数必须物理一致。螺纹切除必须有已有宿主实体;需求为实体螺纹时,不能以光滑孔替代。 diff --git a/backend/app/cad_agent/__init__.py b/backend/app/cad_agent/__init__.py index e1c19aa3..edece57e 100644 --- a/backend/app/cad_agent/__init__.py +++ b/backend/app/cad_agent/__init__.py @@ -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" diff --git a/backend/app/cad_agent/adapters/__init__.py b/backend/app/cad_agent/adapters/__init__.py index 9743a47b..3a9d210e 100644 --- a/backend/app/cad_agent/adapters/__init__.py +++ b/backend/app/cad_agent/adapters/__init__.py @@ -1 +1 @@ -"""Infrastructure adapters for the v3 ports.""" +"""Infrastructure adapters for the single-stage ports.""" diff --git a/backend/app/cad_agent/adapters/artifact_store.py b/backend/app/cad_agent/adapters/artifact_store.py index 4267a0d5..2816d5ea 100644 --- a/backend/app/cad_agent/adapters/artifact_store.py +++ b/backend/app/cad_agent/adapters/artifact_store.py @@ -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: diff --git a/backend/app/cad_agent/adapters/author_guidance.py b/backend/app/cad_agent/adapters/author_guidance.py deleted file mode 100644 index 4cae1ef5..00000000 --- a/backend/app/cad_agent/adapters/author_guidance.py +++ /dev/null @@ -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 diff --git a/backend/app/cad_agent/adapters/event_publisher.py b/backend/app/cad_agent/adapters/event_publisher.py index 34e24d8a..0a6c5df9 100644 --- a/backend/app/cad_agent/adapters/event_publisher.py +++ b/backend/app/cad_agent/adapters/event_publisher.py @@ -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 diff --git a/backend/app/cad_agent/adapters/review_gateway.py b/backend/app/cad_agent/adapters/review_gateway.py deleted file mode 100644 index 6711d323..00000000 --- a/backend/app/cad_agent/adapters/review_gateway.py +++ /dev/null @@ -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')}"}} diff --git a/backend/app/cad_agent/adapters/runtime.py b/backend/app/cad_agent/adapters/runtime.py index 65658289..4d6de80d 100644 --- a/backend/app/cad_agent/adapters/runtime.py +++ b/backend/app/cad_agent/adapters/runtime.py @@ -3,22 +3,20 @@ from __future__ import annotations from copy import deepcopy -from hashlib import sha256 import json import math from pathlib import Path from typing import Any from app.cad_agent.domain.operation_contract import ( - SEMANTIC_PREFLIGHT_NAMES, OperationContractError, canonical_hash, - validate_fragment, + is_authoring_schema_closed, validate_operation_contract, ) -from app.cad_agent.domain.verifier_registry import default_registry -from app.services.engine_service import load_engine, topology_snapshot, validate_cdsl -from app.services.review_renderer import ReviewRenderError, render_checkpoint +from app.cad_agent.ports import AdapterUnavailable +from app.services.engine_service import load_engine, topology_snapshot, validate_cdsl, validate_cdsl_shape +from app.services.render_bundle import RenderBundleError, render_checkpoint from app.settings import Settings from vendor.cdsl_preview_runtime import step_to_glb @@ -27,56 +25,43 @@ class RuntimeAdapterError(RuntimeError): pass +class RuntimeServiceUnavailable(AdapterUnavailable): + """A renderer or artifact service outage that must not spend a repair.""" + + class ProfileCadRuntime: """Adapter that owns engine imports; application code sees only its port.""" def __init__(self, settings: Settings) -> None: self.settings = settings self.engine = load_engine(settings) - self._semantic_preflight_handlers = { - "sketch_workplane": self._preflight_sketch_workplane, - "profile_non_self_intersecting": self._preflight_profile_non_self_intersecting, - "host_face_exists": self._preflight_host_face_exists, - "hole_positions_on_host_plane": self._preflight_hole_positions_on_host_plane, - "cut_exit_distance": self._preflight_cut_exit_distance, - "requires_active_solid": self._preflight_requires_active_solid, - "revolve_axis_on_sketch": self._preflight_revolve_axis_on_sketch, - "reference_plane_nonzero_normal": self._preflight_reference_plane_nonzero_normal, - "reference_axis_nonzero_direction": self._preflight_reference_axis_nonzero_direction, - "selected_edges_exist": self._preflight_selected_edges_exist, - "source_features_exist": self._preflight_source_features_exist, - "mirror_plane_exists": self._preflight_mirror_plane_exists, - "loft_profiles_exist": self._preflight_loft_profiles_exist, - "loft_profiles_closed": self._preflight_loft_profiles_closed, - "loft_profiles_single_region": self._preflight_loft_profiles_single_region, - } schema_path = Path(str(self.engine.__file__)).with_name("profile_schema.json") try: self._profile = json.loads(schema_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile schema is unavailable") from error - self._contracts = self._profile.get("operation_contracts") - if not isinstance(self._contracts, dict): - raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no v3 operation contracts") - declared = {str(item) for item in self._contracts} + profile_contracts = self._profile.get("operation_contracts") + if not isinstance(profile_contracts, dict): + raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no operation contracts") + declared = {str(item) for item in profile_contracts} registered = {str(item) for item in getattr(self.engine, "SUPPORTED_ATOMIC_IDS", ())} if declared != registered: raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: runtime and profile operation registries disagree") - verifier_kinds = set(default_registry().claim_kinds) - for contract in self._contracts.values(): + self._contracts: dict[str, dict[str, Any]] = {} + for atomic_id, contract in profile_contracts.items(): + if not isinstance(contract, dict): + raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: operation contract is not an object") + # A legacy opaque operation schema is still executable internally, + # but cannot enter the sole LLM-facing Authoring protocol. + if not is_authoring_schema_closed(contract.get("author_params_schema")): + continue try: validate_operation_contract(contract) except OperationContractError as error: raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: {error}") from error - unknown_preflights = set(contract["semantic_preflight"]) - set(self._semantic_preflight_handlers) - unknown_verifiers = set(contract["candidate_verifiers"]) - verifier_kinds - if unknown_preflights or unknown_verifiers: - raise RuntimeAdapterError( - "RUNTIME_CONTRACT_INVALID: operation contract references an unavailable " - f"{'preflight' if unknown_preflights else 'candidate verifier'}" - ) - if set(self._semantic_preflight_handlers) != SEMANTIC_PREFLIGHT_NAMES: - raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: runtime preflight registry is incomplete") + self._contracts[str(atomic_id)] = contract + if not self._contracts: + raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no strict authoring operations") def supported_atomic_ids(self) -> tuple[str, ...]: return tuple(sorted(self._contracts)) @@ -90,170 +75,21 @@ class ProfileCadRuntime: result["registry_revision"] = str(self._profile.get("schema_version") or "") return result - def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]: - if not isinstance(topology, dict): - return {} - snapshot_id = str(topology.get("snapshot_id") or "") - if not snapshot_id: - return {} - result: dict[str, dict[str, Any]] = {} - for record in topology.get("records") or (): - if not isinstance(record, dict) or not record.get("executable"): - continue - record_id = str(record.get("record_id") or "") - kind = str(record.get("kind") or "") - if not record_id or kind not in {"face", "edge", "plane", "axis", "body", "vertex"}: - continue - token = "sel_" + sha256(f"{snapshot_id}|{record_id}".encode("utf-8")).hexdigest()[:16] - geometry = deepcopy(record.get("geometry") or {}) - owners = record.get("owner_feature_ids") or [record.get("feature_id") or ""] - result[token] = { - "token": token, - "kind": kind, - "snapshot_id": snapshot_id, - "selector": {"kind": kind, "stable_id": record_id, "owner_feature_id": str(owners[0] or ""), "geometry": geometry, "source": "runtime_snapshot", "snapshot_id": snapshot_id, "confidence": 1.0}, - "geometry": geometry, - } - return result + def compile_authoring(self, document: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """Compile model-facing Authoring CDSL using server-owned contracts.""" + from app.cad_agent.application.authoring_compiler import AuthoringCompiler + from app.cad_agent.application.authoring_compiler import AuthoringCompileError - def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]: - """Return opaque, head-scoped feature references for pattern contracts.""" - result: dict[str, str] = {} - document_hash = canonical_hash(cdsl) if isinstance(cdsl, dict) else "root" - for feature in (cdsl or {}).get("features") or (): - if not isinstance(feature, dict) or not isinstance(feature.get("id"), str) or not feature["id"]: - continue - feature_id = feature["id"] - result["ref_" + sha256(f"{document_hash}|{feature_id}".encode("utf-8")).hexdigest()[:16]] = feature_id - return result - - 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]]: - # ActionCommandHandler validates the exposed schema first, but this - # adapter is also used during crash recovery. Keep the runtime boundary - # self-contained so a corrupted/replayed staged payload cannot produce - # a candidate merely by bypassing that handler-level validation. + runtime, audit = AuthoringCompiler(self.operation_contract).compile(document) try: - validate_operation_contract(contract) - except OperationContractError as error: - raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: {error}") from error - current = self.operation_contract(str(contract.get("atomic_id") or "")) - if ( - contract.get("contract_hash") != current["contract_hash"] - or contract.get("registry_revision") != current["registry_revision"] - ): - raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: operation contract is not the current verified registry entry") - selector_shape = str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") - selector_kind = str((contract.get("selector_policy") or {}).get("token_kind") or "") - allowed_selectors = [ - token - for token, value in selector_tokens.items() - if selector_shape == "required" - and isinstance(value, dict) - and value.get("kind") == selector_kind - ] - errors = validate_fragment( - contract, - fragment, - selector_tokens=allowed_selectors, - reference_tokens=list(reference_tokens), - root_xy_datum=not bool((base_cdsl or {}).get("features")), - ) - if errors: - raise RuntimeAdapterError( - "RUNTIME_PRECONDITION_FAILED: fragment no longer matches the active operation schema: " - + errors[0]["message"] - ) - materialized = deepcopy(fragment) - reference = contract["reference_policy"] - if reference["mode"] == "snapshot_bound": - slot = str(reference["slot"]).removeprefix("params.") - supplied = materialized.get("feature", {}).get("params", {}).get(slot, []) - if not isinstance(supplied, list) or not all(token in reference_tokens for token in supplied): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference token is absent or stale") - materialized["feature"]["params"][slot] = [reference_tokens[token] for token in supplied] - through_normalizations = self._normalize_required_through_cut_depth( - materialized, - contract, - selector_tokens, - require_through=require_through, - ) - cut_support_normal = self._semantic_preflight( - materialized, - contract, - selector_tokens, - base_cdsl, - require_through=require_through, - ) - direction_normalizations = self._normalize_extrude_cut_direction( - materialized, - contract, - cut_support_normal, - ) - document = deepcopy(base_cdsl) if isinstance(base_cdsl, dict) else { - "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "agent_preflight", "geometry": {"sketches": []}, "features": [], - } - geometry = document.setdefault("geometry", {}) - sketches = geometry.setdefault("sketches", []) if isinstance(geometry, dict) else None - features = document.setdefault("features", []) - if not isinstance(sketches, list) or not isinstance(features, list): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: base CDSL collections are invalid") - existing_feature_ids = { - str(item.get("id") or "") - for item in features - if isinstance(item, dict) and str(item.get("id") or "") - } - direct_dependencies = tuple(str(value) for value in depends_on_feature_ids) - if len(direct_dependencies) != len(set(direct_dependencies)) or any(value not in existing_feature_ids for value in direct_dependencies): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: plan dependency feature is absent from the active checkpoint") - index = len(features) + 1 - feature = materialized["feature"] - output = {"id": f"feature_{index:03d}", "atomic_id": contract["atomic_id"], "params": deepcopy(feature["params"]), "depends_on": list(direct_dependencies)} - if contract["fragment_shape"]["sketch"] == "required": - sketch_id = f"sketch_{len(sketches) + 1:03d}" - sketch = materialized["sketch"] - sketches.append({"id": sketch_id, "workplane": deepcopy(sketch["workplane"]), "profile": deepcopy(sketch["profile"])}) - output["sketch_id"] = sketch_id - selected = [selector_tokens[token]["selector"] for token in feature.get("selector_tokens", [])] - slot = contract["selector_policy"]["slot"] - if slot == "params.host_face": - # A topology selector is authoritative while accepting the - # action, but a later pattern replays this feature after its own - # cut may have split the selected B-rep face. Lower the accepted - # planar selector to a concrete, world-aligned host frame and - # transform the author-supplied world coordinates into that - # frame. The token remains in the fragment audit for provenance; - # the materialized CDSL is stable under replay. - output["params"]["host_face"], output["params"]["positions"] = self._materialized_hole_host_frame( - selector_tokens[str(feature["selector_tokens"][0])], - output["params"].get("positions"), - ) - elif slot == "params.mirror_plane": - output["params"]["mirror_plane"] = selected[0] - elif slot == "feature.selectors": - output["selectors"] = selected - features.append(output) - try: - self._validate_finite_tree(document) - self._validate_materialized_runtime_types(output) - validate_cdsl(document, self.engine) + self._validate_finite_tree(runtime) + validate_cdsl_shape(runtime, self.engine) except Exception as error: - message = str(error) - # A JSON-schema failure after server materialization is a registry - # drift: the author could not have supplied the missing server - # field. Engine capability analysis, however, can reject a - # schema-valid author sketch (for example an unresolvable analytic - # contour). That is a recoverable action precondition failure, - # not a deployment defect. - if "CDSL engine runtime preflight failed:" in message: - raise RuntimeAdapterError( - "RUNTIME_PRECONDITION_FAILED: materialized fragment is not executable by the engine: " - + message - ) from error - raise RuntimeAdapterError( - "RUNTIME_CONTRACT_INVALID: materialized fragment violates the engine CDSL schema: " - + message + raise AuthoringCompileError( + "RUNTIME_CONTRACT_INVALID", + f"compiled Runtime CDSL is not executable: {error}", ) from error - return document, {"schema_version": "cad.v3.2.fragment-audit.v1", "atomic_id": contract["atomic_id"], "fragment_hash": canonical_hash(fragment), "contract_hash": contract["contract_hash"], "assigned_feature_ids": [output["id"]], "depends_on_feature_ids": list(direct_dependencies), "assigned_sketch_ids": [output["sketch_id"]] if output.get("sketch_id") else [], "selector_snapshot_id": next(iter(selector_tokens.values()), {}).get("snapshot_id", ""), "selector_tokens": list(feature.get("selector_tokens", [])), "reference_snapshot_id": canonical_hash(reference_tokens), "reference_tokens": list(fragment.get("feature", {}).get("params", {}).get(str(reference.get("slot") or "").removeprefix("params."), [])) if reference["mode"] == "snapshot_bound" else [], "server_normalizations": [*through_normalizations, *direction_normalizations]} + return runtime, audit def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: """Build the exact geometry checkpoint required by every DAG node. @@ -298,7 +134,7 @@ class ProfileCadRuntime: self._write_json(report_path, report) return {"preview": preview, "path": "model.glb"} - def render_review_bundle(self, output_dir: str) -> dict[str, Any]: + def render_bundle(self, output_dir: str) -> dict[str, Any]: root = Path(output_dir) manifest = render_checkpoint(self.settings, step_path=root / "model.step", output_dir=root / "renders") report_path = root / "rebuild-report.json" @@ -308,20 +144,20 @@ class ProfileCadRuntime: return manifest def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: - # Legacy v3.1 compatibility path. New DAG nodes use build_checkpoint. + """Build one complete checkpoint and derive all publishable artifacts.""" built = self.build_checkpoint(cdsl, output_dir, task_id, revision_id) root = Path(output_dir) try: preview = self.create_preview(output_dir) - manifest = self.render_review_bundle(output_dir) + manifest = self.render_bundle(output_dir) report = json.loads((root / "rebuild-report.json").read_text(encoding="utf-8")) return {**built, "report": report, "preview": preview["preview"], "render_manifest": manifest, "paths": {**built["paths"], "glb": "model.glb", "render_manifest": "renders/render-manifest.json"}} except OSError: # Artifact writes are a recoverable infrastructure outage. Let the # application handler park the same candidate stage for replay. raise - except ReviewRenderError as error: - raise RuntimeAdapterError(f"RENDER_SERVICE_UNAVAILABLE: {error}") from error + except RenderBundleError as error: + raise RuntimeServiceUnavailable(f"RENDER_SERVICE_UNAVAILABLE: {error}") from error except Exception as error: raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error @@ -355,6 +191,7 @@ class ProfileCadRuntime: unavailable = [value for value in dependencies if value not in accepted_ids] if unavailable: failures.append({ + "code": "SELECTOR_DEPENDENCY_UNAVAILABLE" if feature.get("selectors") else "DEPENDENCY_UNAVAILABLE", "feature_index": index, "feature_id": feature_id, "message": "Feature was skipped because an earlier dependency did not execute.", @@ -364,14 +201,19 @@ class ProfileCadRuntime: candidate = self._feature_subset(cdsl, [*accepted, feature]) try: latest = self.rebuild(candidate, output_dir, task_id, revision_id) + except (OSError, AdapterUnavailable): + # Preview/render/storage failures are service faults. Preserve + # the staging input and let the workflow replay this exact + # document without spending an Authoring repair. + raise except Exception as error: - failures.append({"feature_index": index, "feature_id": feature_id, "message": str(error)[:1000]}) + failures.append(self._build_failure(index, feature_id, feature, error)) continue accepted.append(deepcopy(feature)) accepted_ids.add(feature_id) if not accepted: if failures: - raise RuntimeAdapterError(str(failures[0].get("message") or "RUNTIME_EXECUTION_FAILURE: no feature could be rebuilt")) + return {}, failures raise RuntimeAdapterError("RUNTIME_EXECUTION_FAILURE: CDSL document has no executable features") # A failed later attempt may have left partial files in the stage. # Rebuild the retained feature set once so all published artifacts are @@ -379,6 +221,46 @@ class ProfileCadRuntime: latest = self.rebuild(self._feature_subset(cdsl, accepted), output_dir, task_id, revision_id) return {**latest, "executed_feature_ids": sorted(accepted_ids)}, failures + @staticmethod + def _build_failure(index: int, feature_id: str, feature: dict[str, Any], error: Exception) -> dict[str, Any]: + diagnostic = getattr(error, "diagnostic", None) + message = str(getattr(diagnostic, "message", "") or error) + diagnostic_code = str(getattr(diagnostic, "code", "") or "") + if not diagnostic_code: + diagnostic_code = next(( + code for code in ( + "selector_output_role_not_found", "selector_geometry_mismatch", + "selector_output_role_ambiguous", "selector_relation_non_unique", + "selector_output_role_owner_required", "selector_output_role_active_body_required", + "selector_owner_required", "selector_output_role_mixed_evidence", + "unsupported_output_role_selector", "invalid_output_role_selector", + ) + if code in message + ), "") + selector_code = { + "selector_output_role_not_found": "SELECTOR_NOT_FOUND", + "selector_geometry_mismatch": "SELECTOR_NOT_FOUND", + "selector_output_role_ambiguous": "SELECTOR_AMBIGUOUS", + "selector_relation_non_unique": "SELECTOR_AMBIGUOUS", + "selector_output_role_owner_required": "SELECTOR_DEPENDENCY_UNAVAILABLE", + "selector_output_role_active_body_required": "SELECTOR_DEPENDENCY_UNAVAILABLE", + "selector_owner_required": "SELECTOR_DEPENDENCY_UNAVAILABLE", + "selector_output_role_mixed_evidence": "SELECTOR_KIND_MISMATCH", + "unsupported_output_role_selector": "SELECTOR_KIND_MISMATCH", + "invalid_output_role_selector": "SELECTOR_KIND_MISMATCH", + }.get(diagnostic_code, "ENGINE_EXECUTION_FAILED") + provenance = getattr(error, "selector_resolutions", None) + return { + "code": selector_code, + "runtime_code": diagnostic_code or "execution_failed", + "feature_index": index, + "feature_id": feature_id, + "atomic_id": str(feature.get("atomic_id") or ""), + "input_summary": {"params": sorted((feature.get("params") or {}).keys()), "selector_count": len(feature.get("selectors") or [])}, + "message": message[:1000], + "selector_provenance": provenance if isinstance(provenance, list) else [], + } + @staticmethod def _feature_subset(cdsl: dict[str, Any], features: list[dict[str, Any]]) -> dict[str, Any]: document = deepcopy(cdsl) @@ -392,458 +274,10 @@ class ProfileCadRuntime: } return document - def _semantic_preflight(self, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], base_cdsl: dict[str, Any] | None, *, require_through: bool) -> list[float] | None: - if fragment.get("feature", {}).get("atomic_id") != contract.get("atomic_id"): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: atomic_id does not match active contract") - policy = contract["selector_policy"] - supplied = fragment.get("feature", {}).get("selector_tokens", []) - if contract["fragment_shape"]["selector_tokens"] == "required": - if not all(token in selector_tokens and selector_tokens[token]["kind"] == policy["token_kind"] for token in supplied): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selector token is absent, stale, or has the wrong kind") - for name in contract["semantic_preflight"]: - self._semantic_preflight_handlers[name](fragment, selector_tokens, base_cdsl, require_through) - if contract.get("atomic_id") == "extrude_cut_blind": - return self._preflight_extrude_cut_contacts_material(fragment, selector_tokens) - return None - - def _normalize_extrude_cut_direction( - self, - fragment: dict[str, Any], - contract: dict[str, Any], - support_normal: list[float] | None, - ) -> list[dict[str, Any]]: - """Aim a surface-attached cut into the measured material half-space. - - A sketch extrusion has no host selector. Once preflight proves that its - profile lies on an oriented boundary face, the only executable blind - cut direction is the material side of that face. This is a coordinate - normalization, not a planning decision, and is recorded in the audit. - """ - if contract.get("atomic_id") != "extrude_cut_blind" or not self._valid_vector3(support_normal, require_nonzero=True): - return [] - sketch = fragment.get("sketch") if isinstance(fragment, dict) else None - workplane = sketch.get("workplane") if isinstance(sketch, dict) else None - normal = workplane.get("normal") if isinstance(workplane, dict) else None - params = fragment.get("feature", {}).get("params") if isinstance(fragment.get("feature"), dict) else None - if not self._valid_vector3(normal, require_nonzero=True) or not isinstance(params, dict): - return [] - unit_normal = [float(component) / self._norm(normal) for component in normal] - unit_support = [float(component) / self._norm(support_normal) for component in support_normal] - alignment = self._dot(unit_normal, unit_support) - if abs(abs(alignment) - 1.0) > 1e-6: - return [] - materialized_reverse = alignment > 0 - submitted_reverse = bool(params.get("reverse", False)) - params["reverse"] = materialized_reverse - return [{ - "path": "feature.params.reverse", - "submitted": submitted_reverse, - "materialized": materialized_reverse, - "reason": "surface-attached cut must travel into the measured material half-space", - "support_normal": unit_support, - }] - - def _normalize_required_through_cut_depth( - self, - fragment: dict[str, Any], - contract: dict[str, Any], - selector_tokens: dict[str, dict[str, Any]], - *, - require_through: bool, - ) -> list[dict[str, Any]]: - """Add the minimum deterministic exit allowance for a through cut. - - The author owns nominal feature geometry. For a through requirement, - however, the runtime owns the executable end condition: this engine - needs a strictly greater cut distance than the measured host span. - Recording the adjustment makes the operational allowance visible - without treating it as a user-specified blind-cut depth. - """ - if not require_through or contract.get("atomic_id") != "extrude_cut_blind": - return [] - params = fragment.get("feature", {}).get("params", {}) - sketch = fragment.get("sketch") if isinstance(fragment.get("sketch"), dict) else {} - workplane = sketch.get("workplane") if isinstance(sketch, dict) else {} - normal = workplane.get("normal") if isinstance(workplane, dict) else None - thickness = self._span_from_bbox(self._active_body_bbox(selector_tokens), normal) - distance = params.get("distance_mm") if isinstance(params, dict) else None - if not isinstance(distance, (int, float)) or thickness is None: - return [] - required_distance = float(thickness) + 0.01 - if float(distance) > float(thickness) + 1e-6: - return [] - params["distance_mm"] = required_distance - return [{ - "path": "/feature/params/distance_mm", - "submitted_mm": float(distance), - "materialized_mm": required_distance, - "reason": "required through-cut exit allowance", - }] - - def _preflight_sketch_workplane(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - sketch = fragment.get("sketch") - workplane = sketch.get("workplane") if isinstance(sketch, dict) else None - if not isinstance(workplane, dict): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch workplane is unavailable") - normal, x_dir = workplane.get("normal"), workplane.get("x_dir") - if not isinstance(normal, list) or not isinstance(x_dir, list) or self._norm(normal) <= 1e-9 or self._norm(x_dir) <= 1e-9 or self._norm(self._cross(normal, x_dir)) <= 1e-9: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch workplane vectors are degenerate") - - def _preflight_profile_non_self_intersecting(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - sketch = fragment.get("sketch") - profile = sketch.get("profile") if isinstance(sketch, dict) else None - if not isinstance(profile, dict): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch profile is unavailable") - if profile.get("type") == "polygon": - vertices = profile.get("vertices") - if not isinstance(vertices, list) or len(vertices) < 3 or self._polygon_self_intersects(vertices): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: polygon profile self-intersects") - elif profile.get("type") == "analytic_contours": - self._preflight_analytic_contours(profile) - - def _preflight_extrude_cut_contacts_material(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]]) -> list[float] | None: - """Reject an extrude cut whose start profile floats above the solid. - - Sketch cuts do not carry a host-face selector, so a model can place a - correct 2-D profile on the top of an unrelated boss. The engine then - rebuilds successfully but leaves the body unchanged. Detect the common - no-contact form using the current planar topology and return a useful - coordinate diagnosis before allocating a stage or running the kernel. - """ - sketch = fragment.get("sketch") if isinstance(fragment, dict) else None - workplane = sketch.get("workplane") if isinstance(sketch, dict) else None - profile = sketch.get("profile") if isinstance(sketch, dict) else None - origin = workplane.get("origin_mm") if isinstance(workplane, dict) else None - normal = workplane.get("normal") if isinstance(workplane, dict) else None - x_dir = workplane.get("x_dir") if isinstance(workplane, dict) else None - if not self._valid_vector3(origin) or not self._valid_vector3(normal, require_nonzero=True) or not self._valid_vector3(x_dir, require_nonzero=True) or not isinstance(profile, dict): - return None - unit_normal = [float(component) / self._norm(normal) for component in normal] - x_projection = self._dot(x_dir, unit_normal) - raw_x = [float(x_dir[index]) - x_projection * unit_normal[index] for index in range(3)] - if self._norm(raw_x) <= 1e-9: - return None - unit_x = [value / self._norm(raw_x) for value in raw_x] - unit_y = self._cross(unit_normal, unit_x) - local_points = self._profile_probe_points(profile) - if not local_points: - return None - world_points = [ - [ - float(origin[index]) + local[0] * unit_x[index] + local[1] * unit_y[index] - for index in range(3) - ] - for local in local_points - ] - tolerance = 1e-5 - matching_faces: list[dict[str, Any]] = [] - available_heights: list[float] = [] - for value in selectors.values(): - geometry = value.get("geometry") if isinstance(value, dict) else None - face_normal = geometry.get("normal") if isinstance(geometry, dict) else None - center = geometry.get("center_mm") if isinstance(geometry, dict) else None - loops = geometry.get("boundary_loops_mm") if isinstance(geometry, dict) else None - if ( - not isinstance(geometry, dict) - or geometry.get("surface_type") != "plane" - or not self._valid_vector3(face_normal, require_nonzero=True) - or not self._valid_vector3(center) - or not isinstance(loops, list) - or not loops - ): - continue - unit_face_normal = [float(component) / self._norm(face_normal) for component in face_normal] - if abs(abs(self._dot(unit_normal, unit_face_normal)) - 1.0) > 1e-6: - continue - available_heights.append(self._dot([float(center[index]) for index in range(3)], unit_normal)) - if abs(self._dot([float(origin[index]) - float(center[index]) for index in range(3)], unit_normal)) <= tolerance: - matching_faces.append(geometry) - if not matching_faces: - return None - for face in matching_faces: - if any( - self._point_in_planar_face(point, face["boundary_loops_mm"], unit_normal, tolerance) - for point in world_points - ): - face_normal = face.get("normal") - return [float(component) for component in face_normal] if self._valid_vector3(face_normal, require_nonzero=True) else None - plane_coordinate = self._dot([float(value) for value in origin], unit_normal) - heights = ", ".join(f"{value:g}" for value in sorted(set(round(value, 6) for value in available_heights))[:8]) - raise RuntimeAdapterError( - "RUNTIME_PRECONDITION_FAILED: extrude-cut profile does not contact material on its start plane " - f"(plane coordinate {plane_coordinate:g}; available parallel planar faces: [{heights}]); " - "place the sketch on the material face containing the intended cut profile" - ) - - @staticmethod - def _profile_probe_points(profile: dict[str, Any]) -> list[tuple[float, float]]: - """Return inexpensive local points sufficient for contact preflight.""" - points: list[tuple[float, float]] = [] - - def append(value: Any) -> None: - point = ProfileCadRuntime._point2(value) - if point is not None: - points.append(point) - - profile_type = profile.get("type") - if profile_type == "circle": - append(profile.get("center")) - elif profile_type == "rectangle": - append(profile.get("center")) - elif profile_type == "polygon": - vertices = profile.get("vertices") - if isinstance(vertices, list): - for vertex in vertices: - append(vertex) - elif profile_type == "analytic_contours": - contours = profile.get("contours") - if isinstance(contours, list): - for contour in contours: - segments = contour.get("segments") if isinstance(contour, dict) else None - if not isinstance(segments, list): - continue - contour_points: list[tuple[float, float]] = [] - for segment in segments: - if not isinstance(segment, dict): - continue - for key in ("start", "end", "center"): - point = ProfileCadRuntime._point2(segment.get(key)) - if point is not None: - points.append(point) - contour_points.append(point) - if contour_points: - points.append(( - sum(point[0] for point in contour_points) / len(contour_points), - sum(point[1] for point in contour_points) / len(contour_points), - )) - return points - - def _preflight_host_face_exists(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - supplied = fragment.get("feature", {}).get("selector_tokens", []) - if len(supplied) != 1 or not isinstance(selectors.get(supplied[0]), dict) or selectors[supplied[0]].get("kind") != "face": - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: host face is absent or stale") - - def _materialized_hole_host_frame( - self, - selected: dict[str, Any], - positions: Any, - ) -> tuple[dict[str, Any], list[dict[str, list[float]]]]: - """Lower a verified planar host selector into replay-stable CDSL data. - - Hole position inputs are world coordinates at the author boundary. - The engine's frame form uses local coordinates, so both values must - be converted together. Keeping only the selector makes a later - pattern replay depend on a face that the source cut has already - subdivided, which is neither stable nor geometrically meaningful. - """ - geometry = selected.get("geometry") if isinstance(selected, dict) else None - center = geometry.get("center_mm") if isinstance(geometry, dict) else None - normal = geometry.get("normal") if isinstance(geometry, dict) else None - if not self._valid_vector3(center) or not self._valid_vector3(normal, require_nonzero=True): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected host face has no usable plane frame") - if not isinstance(positions, list) or not positions: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole positions are unavailable for host-frame materialization") - origin = [float(value) for value in center] - unit_normal = [float(value) / self._norm(normal) for value in normal] - seed = [1.0, 0.0, 0.0] if abs(unit_normal[0]) < 0.9 else [0.0, 1.0, 0.0] - x_raw = [seed[index] - self._dot(seed, unit_normal) * unit_normal[index] for index in range(3)] - x_length = self._norm(x_raw) - if x_length <= 1e-9: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected host face cannot define a stable x direction") - x_dir = [value / x_length for value in x_raw] - y_dir = self._cross(unit_normal, x_dir) - local_positions: list[dict[str, list[float]]] = [] - for position in positions: - point = position.get("mm") if isinstance(position, dict) else None - if not self._valid_vector3(point): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not a finite 3D point") - offset = [float(point[index]) - origin[index] for index in range(3)] - local_positions.append({"mm": [ - self._dot(offset, x_dir), - self._dot(offset, y_dir), - self._dot(offset, unit_normal), - ]}) - return ( - {"frame": {"origin_mm": origin, "x_dir": x_dir, "y_dir": y_dir, "normal": unit_normal}}, - local_positions, - ) - - def _preflight_hole_positions_on_host_plane(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - positions = fragment.get("feature", {}).get("params", {}).get("positions") - if not isinstance(positions, list) or not positions or not all(isinstance(item, dict) and isinstance(item.get("mm"), list) and len(item["mm"]) == 3 for item in positions): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole positions are invalid for the host plane") - supplied = fragment.get("feature", {}).get("selector_tokens", []) - host = selectors.get(supplied[0]) if len(supplied) == 1 else None - geometry = host.get("geometry") if isinstance(host, dict) else None - normal = geometry.get("normal") if isinstance(geometry, dict) else None - center = geometry.get("center_mm") if isinstance(geometry, dict) else None - bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None - if not isinstance(geometry, dict) or geometry.get("surface_type") != "plane" or not self._valid_vector3(normal, require_nonzero=True) or not self._valid_vector3(center): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole host must be an observable planar face") - unit_normal = [float(component) / self._norm(normal) for component in normal] - tolerance = 1e-5 - if not self._valid_bbox(bbox): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: host face has no usable topology bounds") - for position in positions: - point = position["mm"] - if not self._valid_vector3(point): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not a finite 3D point") - offset = [float(point[index]) - float(center[index]) for index in range(3)] - if abs(self._dot(offset, unit_normal)) > tolerance: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not on the selected host plane") - if any(float(point[index]) < float(bbox[index]) - tolerance or float(point[index]) > float(bbox[index + 3]) + tolerance for index in range(3)): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is outside the selected host-face bounds") - boundary_loops = geometry.get("boundary_loops_mm") - if isinstance(boundary_loops, list) and boundary_loops and not self._point_in_planar_face(point, boundary_loops, unit_normal, tolerance): - if not self._counterbore_reuses_existing_pilot(fragment, selectors, point, unit_normal, tolerance): - host_z = float(center[2]) if isinstance(center, list) and len(center) == 3 else float("nan") - coordinate = ", ".join(f"{float(value):g}" for value in point) - raise RuntimeAdapterError( - "RUNTIME_PRECONDITION_FAILED: hole position " - f"[{coordinate}] lies outside the selected host face material boundary (host center z={host_z:g}); " - "select a planar host face that contains every requested hole center" - ) - - def _counterbore_reuses_existing_pilot( - self, - fragment: dict[str, Any], - selectors: dict[str, dict[str, Any]], - point: list[float], - host_normal: list[float], - tolerance: float, - ) -> bool: - """Allow a counterbore to start from an existing coaxial pilot bore. - - A top face becomes annular after a through bore, so the pilot centre - is deliberately outside its material boundary. Counterboring that - pilot is nevertheless a standard valid operation. The exception is - deliberately narrow: it only applies to a matching inner cylindrical - bore whose axis is normal to the selected host plane and whose open - end contains the requested start point. - """ - feature = fragment.get("feature") if isinstance(fragment, dict) else None - params = feature.get("params") if isinstance(feature, dict) and isinstance(feature.get("params"), dict) else {} - pilot_diameter = params.get("diameter_mm") - counterbore_diameter = params.get("counterbore_diameter_mm") - if ( - not isinstance(feature, dict) - or feature.get("atomic_id") != "hole_counterbore" - or not isinstance(pilot_diameter, (int, float)) - or not isinstance(counterbore_diameter, (int, float)) - or float(pilot_diameter) <= 0 - or float(counterbore_diameter) <= float(pilot_diameter) - ): - return False - diameter_tolerance = max(tolerance, abs(float(pilot_diameter)) * 1e-6) - for value in selectors.values(): - geometry = value.get("geometry") if isinstance(value, dict) else None - if ( - not isinstance(geometry, dict) - or geometry.get("surface_type") != "cylinder" - or geometry.get("cylinder_role") != "inner" - or not bool(geometry.get("through")) - ): - continue - radius = geometry.get("radius_mm") - axis_origin = geometry.get("axis_origin_mm") - axis_direction = geometry.get("axis_direction") - bbox = geometry.get("bbox_mm") - if ( - not isinstance(radius, (int, float)) - or abs(2 * float(radius) - float(pilot_diameter)) > diameter_tolerance - or not self._valid_vector3(axis_origin) - or not self._valid_vector3(axis_direction, require_nonzero=True) - or not self._valid_bbox(bbox) - ): - continue - unit_axis = [float(component) / self._norm(axis_direction) for component in axis_direction] - if abs(abs(self._dot(unit_axis, host_normal)) - 1.0) > 1e-6: - continue - offset = [float(point[index]) - float(axis_origin[index]) for index in range(3)] - axial = self._dot(offset, unit_axis) - radial = [offset[index] - axial * unit_axis[index] for index in range(3)] - if self._norm(radial) > tolerance: - continue - if any(float(point[index]) < float(bbox[index]) - tolerance or float(point[index]) > float(bbox[index + 3]) + tolerance for index in range(3)): - continue - return True - return False - - def _preflight_analytic_contours(self, profile: dict[str, Any]) -> None: - contours = profile.get("contours") - if not isinstance(contours, list) or not contours: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: analytic profile requires at least one contour") - tolerance = 1e-7 - for contour_index, contour in enumerate(contours): - segments = contour.get("segments") if isinstance(contour, dict) else None - if not isinstance(segments, list) or not segments: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is empty") - if any(segment.get("type") == "circle" for segment in segments if isinstance(segment, dict)): - if len(segments) != 1 or segments[0].get("type") != "circle": - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} cannot mix a full circle with other segments") - continue - endpoints: list[tuple[tuple[float, float], tuple[float, float]]] = [] - for segment_index, segment in enumerate(segments): - if not isinstance(segment, dict) or segment.get("type") not in {"line", "arc"}: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} has an unsupported segment") - start = self._point2(segment.get("start")) - end = self._point2(segment.get("end")) - if start is None or end is None: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} segment {segment_index} has non-finite endpoints") - if self._distance2(start, end) <= tolerance: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} segment {segment_index} is degenerate") - if segment.get("type") == "arc": - center = self._point2(segment.get("center")) - radius = segment.get("radius_mm") - if center is None or not isinstance(radius, (int, float)) or isinstance(radius, bool) or not math.isfinite(float(radius)) or float(radius) <= 0: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} arc {segment_index} has an invalid circle") - arc_tolerance = max(tolerance, float(radius) * 1e-7) - if abs(self._distance2(start, center) - float(radius)) > arc_tolerance or abs(self._distance2(end, center) - float(radius)) > arc_tolerance: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} arc {segment_index} endpoints are not on the declared circle") - endpoints.append((start, end)) - for segment_index in range(1, len(endpoints)): - if self._distance2(endpoints[segment_index - 1][1], endpoints[segment_index][0]) > tolerance: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is discontinuous before segment {segment_index}") - if contour.get("closed") is True and self._distance2(endpoints[-1][1], endpoints[0][0]) > tolerance: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is not closed") - line_segments = [segment for segment, raw in zip(endpoints, segments) if raw.get("type") == "line"] - for first, (a, b) in enumerate(line_segments): - for second, (c, d) in enumerate(line_segments): - if second <= first + 1 or (first == 0 and second == len(line_segments) - 1 and contour.get("closed") is True): - continue - if self._segments_intersect(a, b, c, d): - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} has an obvious self-intersection") - - @classmethod - def _validate_materialized_runtime_types(cls, feature: dict[str, Any]) -> None: - atomic_id = str(feature.get("atomic_id") or "") - if not atomic_id.startswith("hole_"): - return - params = feature.get("params") if isinstance(feature.get("params"), dict) else {} - host = params.get("host_face") if isinstance(params.get("host_face"), dict) else None - frame = host.get("frame") if isinstance(host, dict) and isinstance(host.get("frame"), dict) else None - if not isinstance(frame, dict): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole host_face.frame is unavailable") - for field in ("origin_mm", "x_dir", "y_dir", "normal"): - if not cls._valid_vector3(frame.get(field), require_nonzero=field != "origin_mm"): - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: materialized hole host_face.frame.{field} is invalid") - x_dir, y_dir, normal = frame["x_dir"], frame["y_dir"], frame["normal"] - if abs(cls._dot(x_dir, y_dir)) > 1e-6 or abs(cls._dot(x_dir, normal)) > 1e-6 or abs(cls._dot(y_dir, normal)) > 1e-6: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole host frame is not orthogonal") - positions = params.get("positions") - if not isinstance(positions, list) or not positions: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole positions are unavailable") - for index, position in enumerate(positions): - point = position.get("mm") if isinstance(position, dict) else None - if not cls._valid_vector3(point) or abs(float(point[2])) > 1e-5: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: materialized hole position {index} is invalid in the host frame") - @staticmethod def _validate_finite_tree(value: Any, path: str = "$") -> None: - if value is None: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: {path} must not be null") if isinstance(value, float) and not math.isfinite(value): - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: {path} must be finite") + raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: non-finite number at {path}") if isinstance(value, dict): for key, child in value.items(): ProfileCadRuntime._validate_finite_tree(child, f"{path}.{key}") @@ -851,278 +285,6 @@ class ProfileCadRuntime: for index, child in enumerate(value): ProfileCadRuntime._validate_finite_tree(child, f"{path}[{index}]") - @staticmethod - def _point2(value: Any) -> tuple[float, float] | None: - if not isinstance(value, list) or len(value) != 2: - return None - if not all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value): - return None - return float(value[0]), float(value[1]) - - @staticmethod - def _distance2(left: tuple[float, float], right: tuple[float, float]) -> float: - return math.hypot(left[0] - right[0], left[1] - right[1]) - - @classmethod - def _point_in_planar_face(cls, point: list[float], loops: list[Any], normal: list[float], tolerance: float) -> bool: - drop_axis = max(range(3), key=lambda index: abs(float(normal[index]))) - - def project(value: Any) -> tuple[float, float] | None: - if not cls._valid_vector3(value): - return None - coordinates = [float(value[index]) for index in range(3) if index != drop_axis] - return coordinates[0], coordinates[1] - - projected_point = project(point) - if projected_point is None: - return False - polygons: list[list[tuple[float, float]]] = [] - for loop in loops: - polygon = [project(vertex) for vertex in loop] if isinstance(loop, list) else [] - if len(polygon) >= 3 and all(vertex is not None for vertex in polygon): - polygons.append([vertex for vertex in polygon if vertex is not None]) - if not polygons: - return False - - def area(polygon: list[tuple[float, float]]) -> float: - return abs(sum( - polygon[index][0] * polygon[(index + 1) % len(polygon)][1] - - polygon[(index + 1) % len(polygon)][0] * polygon[index][1] - for index in range(len(polygon)) - )) / 2 - - def contains(polygon: list[tuple[float, float]]) -> bool: - x, y = projected_point - inside = False - for index, first in enumerate(polygon): - second = polygon[(index + 1) % len(polygon)] - if cls._distance_to_segment_2d(projected_point, first, second) <= tolerance: - return True - if (first[1] > y) != (second[1] > y): - crossing_x = (second[0] - first[0]) * (y - first[1]) / (second[1] - first[1]) + first[0] - if x < crossing_x: - inside = not inside - return inside - - outer = max(polygons, key=area) - return contains(outer) and not any(contains(inner) for inner in polygons if inner is not outer) - - @staticmethod - def _distance_to_segment_2d(point: tuple[float, float], start: tuple[float, float], end: tuple[float, float]) -> float: - dx, dy = end[0] - start[0], end[1] - start[1] - length_squared = dx * dx + dy * dy - if length_squared <= 1e-18: - return math.hypot(point[0] - start[0], point[1] - start[1]) - fraction = max(0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length_squared)) - return math.hypot(point[0] - (start[0] + fraction * dx), point[1] - (start[1] + fraction * dy)) - - def _preflight_cut_exit_distance(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, require_through: bool) -> None: - if not require_through: - return - params = fragment.get("feature", {}).get("params", {}) - depth = params.get("depth_mm", params.get("distance_mm")) - supplied = fragment.get("feature", {}).get("selector_tokens", []) - if not isinstance(depth, (int, float)): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: cut depth is unavailable") - if supplied: - host = selectors[supplied[0]]["geometry"] - bbox = host.get("bbox_mm") if isinstance(host, dict) else None - normal = host.get("normal") or host.get("plane_normal") if isinstance(host, dict) else None - thickness = self._span_from_bbox(bbox, normal) - else: - bbox = self._active_body_bbox(selectors) - normal = (fragment.get("sketch") or {}).get("workplane", {}).get("normal") - thickness = self._span_from_bbox(bbox, normal) - if thickness is None: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: cannot prove through-cut exit distance from the active topology") - if float(depth) <= thickness + 1e-6: - raise RuntimeAdapterError( - "RUNTIME_PRECONDITION_FAILED: " - f"cut depth {float(depth):g} mm must exceed measured host-body thickness {thickness:g} mm" - ) - - def _preflight_requires_active_solid(self, _fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - # A committed active CDSL document exists only after a candidate with - # a solid has been accepted. Reference and subtraction operations - # therefore cannot be the first feature of a part. - if not isinstance(base, dict) or not base.get("features"): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: operation requires an active solid checkpoint") - - def _preflight_revolve_axis_on_sketch(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - axis = fragment.get("feature", {}).get("params", {}).get("axis") - direction = axis.get("direction") if isinstance(axis, dict) else None - origin = axis.get("origin_mm") if isinstance(axis, dict) else None - workplane = fragment.get("sketch", {}).get("workplane") if isinstance(fragment.get("sketch"), dict) else None - plane_origin = workplane.get("origin_mm") if isinstance(workplane, dict) else None - plane_normal = workplane.get("normal") if isinstance(workplane, dict) else None - if not self._valid_vector3(direction, require_nonzero=True) or not self._valid_vector3(origin) or not self._valid_vector3(plane_origin) or not self._valid_vector3(plane_normal, require_nonzero=True): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis or sketch plane is invalid") - unit_direction = [float(component) / self._norm(direction) for component in direction] - unit_normal = [float(component) / self._norm(plane_normal) for component in plane_normal] - if abs(self._dot(unit_direction, unit_normal)) > 1e-6: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis is not parallel to the sketch plane") - axis_offset = [float(origin[index]) - float(plane_origin[index]) for index in range(3)] - if abs(self._dot(axis_offset, unit_normal)) > 1e-5: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis is not on the sketch plane") - - def _preflight_reference_plane_nonzero_normal(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - plane = fragment.get("feature", {}).get("params", {}).get("plane") - normal = plane.get("normal") if isinstance(plane, dict) else None - if not isinstance(normal, list) or self._norm(normal) <= 1e-9: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference plane normal is degenerate") - - def _preflight_reference_axis_nonzero_direction(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - axis = fragment.get("feature", {}).get("params", {}).get("axis") - direction = axis.get("direction") if isinstance(axis, dict) else None - if not isinstance(direction, list) or self._norm(direction) <= 1e-9: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference axis direction is degenerate") - - def _preflight_selected_edges_exist(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - supplied = fragment.get("feature", {}).get("selector_tokens", []) - if not supplied or not all(isinstance(selectors.get(token), dict) and selectors[token].get("kind") == "edge" for token in supplied): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected edge is absent or stale") - - def _preflight_source_features_exist(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - params = fragment.get("feature", {}).get("params", {}) - known = {str(feature.get("id") or "") for feature in ((base or {}).get("features") or ()) if isinstance(feature, dict)} - if not set(params.get("source_feature_ids") or ()).issubset(known): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: source feature is not part of current head") - - def _preflight_mirror_plane_exists(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - supplied = fragment.get("feature", {}).get("selector_tokens", []) - if len(supplied) != 1 or not isinstance(selectors.get(supplied[0]), dict) or selectors[supplied[0]].get("kind") != "plane": - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: mirror plane is absent or stale") - - def _loft_profile_sketches(self, fragment: dict[str, Any], base: dict[str, Any] | None) -> list[dict[str, Any]]: - """Resolve ``profile_sketch_ids`` against the base document's sketches.""" - ids = [str(value) for value in fragment.get("feature", {}).get("params", {}).get("profile_sketch_ids") or ()] - sketches = (base or {}).get("geometry", {}).get("sketches") if isinstance((base or {}).get("geometry"), dict) else None - by_id = { - str(sketch.get("id") or ""): sketch - for sketch in (sketches or ()) - if isinstance(sketch, dict) - } - resolved: list[dict[str, Any]] = [] - for sketch_id in ids: - sketch = by_id.get(sketch_id) - if not isinstance(sketch, dict): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile sketch is not part of current head") - resolved.append(sketch) - if not resolved: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft has no profile sketches") - return resolved - - def _preflight_loft_profiles_exist(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - self._loft_profile_sketches(fragment, base) - - def _preflight_loft_profiles_closed(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - # circle/polygon 草图类型闭合性由 schema 保证;analytic_contours 需要 - # 每条参与轮廓显式 closed。 - for sketch in self._loft_profile_sketches(fragment, base): - profile = sketch.get("profile") if isinstance(sketch.get("profile"), dict) else None - if profile is None: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile sketch has no profile") - if profile.get("type") == "analytic_contours": - contours = profile.get("contours") - if not isinstance(contours, list) or not contours: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile has no contours") - for contour in contours: - if not isinstance(contour, dict) or not contour.get("closed"): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile contour is not closed") - - def _preflight_loft_profiles_single_region(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - # 每个放样截面必须是单连通区域:analytic_contours 只允许恰好一条 - # outer 闭合轮廓,不得携带 inner 环或 open 段。 - for sketch in self._loft_profile_sketches(fragment, base): - profile = sketch.get("profile") if isinstance(sketch.get("profile"), dict) else None - if profile is None: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile sketch has no profile") - if profile.get("type") == "analytic_contours": - contours = [contour for contour in (profile.get("contours") or []) if isinstance(contour, dict)] - outer = [contour for contour in contours if contour.get("role") == "outer" and contour.get("closed")] - if len(outer) != 1 or len(outer) != len(contours): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile must be a single closed region") - - @staticmethod - def _polygon_self_intersects(vertices: list[Any]) -> bool: - points = [(float(point[0]), float(point[1])) for point in vertices if isinstance(point, list) and len(point) == 2] - if len(points) != len(vertices): - return True - segments = list(zip(points, [*points[1:], points[0]])) - for first, (a, b) in enumerate(segments): - for second, (c, d) in enumerate(segments): - if second <= first + 1 or (first == 0 and second == len(segments) - 1): - continue - if ProfileCadRuntime._segments_intersect(a, b, c, d): - return True - return False - - @staticmethod - def _segments_intersect(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float], d: tuple[float, float]) -> bool: - def orientation(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> float: - return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]) - - left = orientation(a, b, c) - right = orientation(a, b, d) - low = orientation(c, d, a) - high = orientation(c, d, b) - return (left > 0) != (right > 0) and (low > 0) != (high > 0) - - @staticmethod - def _active_body_bbox(selector_tokens: dict[str, dict[str, Any]]) -> list[float] | None: - for token in selector_tokens.values(): - if token.get("kind") != "body": - continue - geometry = token.get("geometry") - bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None - if isinstance(bbox, list) and len(bbox) == 6 and all(isinstance(value, (int, float)) for value in bbox): - return [float(value) for value in bbox] - return None - - @staticmethod - def _span_from_bbox(bbox: Any, normal: Any) -> float | None: - if not isinstance(bbox, list) or len(bbox) != 6 or not isinstance(normal, list) or len(normal) != 3: - return None - if not all(isinstance(value, (int, float)) for value in [*bbox, *normal]): - return None - length = math.sqrt(sum(float(value) ** 2 for value in normal)) - if length <= 1e-9: - return None - return sum( - abs(float(normal[index]) / length) * abs(float(bbox[index + 3]) - float(bbox[index])) - for index in range(3) - ) - - @staticmethod - def _norm(vector: list[float]) -> float: - return math.sqrt(sum(float(item) ** 2 for item in vector)) - - @staticmethod - def _dot(left: list[float], right: list[float]) -> float: - return sum(float(left[index]) * float(right[index]) for index in range(3)) - - @staticmethod - def _valid_vector3(value: Any, *, require_nonzero: bool = False) -> bool: - valid = ( - isinstance(value, list) - and len(value) == 3 - and all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value) - ) - return valid and (not require_nonzero or ProfileCadRuntime._norm(value) > 1e-9) - - @staticmethod - def _valid_bbox(value: Any) -> bool: - return ( - isinstance(value, list) - and len(value) == 6 - and all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value) - and all(float(value[index]) <= float(value[index + 3]) for index in range(3)) - ) - - @staticmethod - def _cross(a: list[float], b: list[float]) -> list[float]: - return [float(a[1]) * float(b[2]) - float(a[2]) * float(b[1]), float(a[2]) * float(b[0]) - float(a[0]) * float(b[2]), float(a[0]) * float(b[1]) - float(a[1]) * float(b[0])] - @staticmethod def _write_json(path: Path, payload: dict[str, Any]) -> None: path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/backend/app/cad_agent/adapters/sqlite_repository.py b/backend/app/cad_agent/adapters/sqlite_repository.py index 7ec0d95e..b5cc84c4 100644 --- a/backend/app/cad_agent/adapters/sqlite_repository.py +++ b/backend/app/cad_agent/adapters/sqlite_repository.py @@ -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" diff --git a/backend/app/cad_agent/adapters/structured_llm.py b/backend/app/cad_agent/adapters/structured_llm.py index 53532ef8..f786905b 100644 --- a/backend/app/cad_agent/adapters/structured_llm.py +++ b/backend/app/cad_agent/adapters/structured_llm.py @@ -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] = { diff --git a/backend/app/cad_agent/adapters/verifier.py b/backend/app/cad_agent/adapters/verifier.py deleted file mode 100644 index aafd0e2f..00000000 --- a/backend/app/cad_agent/adapters/verifier.py +++ /dev/null @@ -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 diff --git a/backend/app/cad_agent/application/__init__.py b/backend/app/cad_agent/application/__init__.py index 8eb3caf7..210f4a3d 100644 --- a/backend/app/cad_agent/application/__init__.py +++ b/backend/app/cad_agent/application/__init__.py @@ -1 +1 @@ -"""Application command handlers and context assembly for protocol v3.""" +"""Application workflow and compiler for the Authoring CDSL protocol.""" diff --git a/backend/app/cad_agent/application/action_handlers.py b/backend/app/cad_agent/application/action_handlers.py deleted file mode 100644 index 0f7294ba..00000000 --- a/backend/app/cad_agent/application/action_handlers.py +++ /dev/null @@ -1,1529 +0,0 @@ -"""Action selection, candidate, review, and completion command handlers.""" - -from __future__ import annotations - -from hashlib import sha256 -import json -import secrets -from typing import Any - -from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash -from app.cad_agent.application.llm_contracts import ( - CandidateReview, - FinalReview, - GeometryConclusion, - NextAction, - RollbackCheckpoint, -) -from app.cad_agent.application.results import Accepted, Rejected -from app.cad_agent.domain.errors import ErrorCode, WorkflowError -from app.cad_agent.domain.operation_contract import canonical_hash, validate_fragment -from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState, reject_stale_head, transition -from app.cad_agent.ports import ArtifactStore, CadRuntime, TaskRepository, VerifierExecutor - - -class ActionCommandHandler: - def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, runtime: CadRuntime, verifiers: VerifierExecutor) -> None: - self.repository = repository - self.artifacts = artifacts - self.runtime = runtime - self.verifiers = verifiers - - def available_atomic_ids(self, task_id: str, state: TaskState | None = None) -> tuple[str, ...]: - """Return operations whose mandatory snapshot inputs exist now.""" - state = state or self.repository.get_state(task_id) - topology = self.artifacts.read_topology(task_id, state.active_revision) if state is not None else None - selector_tokens = self.runtime.selector_tokens(topology) - active_cdsl = self.artifacts.read_active_cdsl(task_id, state.active_revision) if state is not None else None - reference_tokens = self.runtime.reference_tokens(active_cdsl) - has_active_solid = isinstance(active_cdsl, dict) and bool(active_cdsl.get("features")) - available: list[str] = [] - for atomic_id in self.runtime.supported_atomic_ids(): - contract = self.runtime.operation_contract(atomic_id) - shape = contract.get("fragment_shape") if isinstance(contract.get("fragment_shape"), dict) else {} - if str(shape.get("selector_tokens") or "forbidden") == "required": - policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {} - required_kind = str(policy.get("token_kind") or "") - if not any(token.get("kind") == required_kind for token in selector_tokens.values() if isinstance(token, dict)): - continue - reference_policy = contract.get("reference_policy") if isinstance(contract.get("reference_policy"), dict) else {} - if reference_policy.get("mode") == "snapshot_bound": - minimum = int(reference_policy.get("min_items") or 1) - if len(reference_tokens) < minimum: - continue - if "requires_active_solid" in (contract.get("semantic_preflight") or ()) and not has_active_solid: - continue - available.append(atomic_id) - return tuple(available) - - def schedule_next_feature(self, task_id: str) -> Accepted | Rejected: - """Select the server-owned next ready node by fixed plan priority.""" - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.SCHEDULING_FEATURE: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Feature scheduling is not expected in the current workflow state.")) - plan = self._feature_plan(task_id, state) - if plan is None: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan is unavailable.", retryable=True)) - scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id)) - if scheduler.all_done(): - claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - deterministic_failures = [ - item for item in claim_results - if item.get("deterministic") and item.get("status") != "pass" - ] - if deterministic_failures: - next_state = transition( - state, - "feature_replan", - error=ErrorCode.CLAIM_VERIFICATION_FAILED, - ) - result = { - "status": "replan_required", - "phase": next_state.phase.value, - "claim_results": claim_results, - } - if not self.repository.compare_and_swap(next_state, events=[{ - "event": "feature_plan_completion_failed", - "plan_hash": state.feature_plan_hash, - "revision_id": state.active_revision, - "claim_results": claim_results, - "failed_claim_ids": [str(item.get("claim_id") or "") for item in deterministic_failures], - "message": "All feature nodes completed, but final deterministic validation failed.", - }]): - return Rejected(self._stale()) - return Accepted(result) - next_state = transition(state, "final_requested") - result = {"status": "final_validation", "phase": next_state.phase.value, "claim_results": claim_results} - if not self.repository.compare_and_swap(next_state, events=[{ - "event": "feature_plan_complete", - "plan_hash": state.feature_plan_hash, - "revision_id": state.active_revision, - "claim_results": claim_results, - }], invocation_id=None, invocation_result=None): - return Rejected(self._stale()) - return Accepted(result) - node = scheduler.next_ready() - if node is None: - return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan has no runnable node; revise its unresolved subgraph.")) - contract = self.runtime.operation_contract(node.atomic_id) - requirement_ids = tuple(sorted(self._requirement_ids_for_claims(task_id, tuple(node.claim_ids)))) - action_id = "feature_" + sha256(f"{state.feature_plan_hash}|{node.node_id}|{state.active_revision}".encode("utf-8")).hexdigest()[:16] - pending = PendingAction( - action_id=action_id, - working_head=state.working_head, - intent=node.intent, - requirement_ids=requirement_ids, - atomic_id=node.atomic_id, - expected_change=node.expected_change, - contract_hash=contract["contract_hash"], - idempotency_key=sha256(f"{task_id}|{action_id}|{state.working_head}".encode("utf-8")).hexdigest(), - node_id=node.node_id, - plan_hash=state.feature_plan_hash, - claim_ids=tuple(node.claim_ids), - depends_on_node_ids=tuple(node.depends_on), - ) - next_state = transition(state, "feature_scheduled", pending_action=pending) - event = { - "event": "feature_node_scheduled", "node_id": node.node_id, "node_hash": node_hash(node), - "plan_hash": state.feature_plan_hash, "action_id": action_id, "atomic_id": node.atomic_id, - "depends_on": node.depends_on, "claim_ids": node.claim_ids, "priority": node.priority, - } - if not self.repository.compare_and_swap(next_state, events=[event]): - return Rejected(self._stale()) - return Accepted({"status": "scheduled", "node_id": node.node_id, "atomic_id": node.atomic_id, "phase": next_state.phase.value}) - - def submit_feature_fragment(self, task_id: str, fragment: dict[str, Any], *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - action = state.pending_feature if state else None - if state is None or state.phase != TaskPhase.FEATURE_PENDING or action is None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature fragment requires one scheduled feature node.")) - plan = self._feature_plan(task_id, state) - if plan is None or action.plan_hash != state.feature_plan_hash: - return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The scheduled feature belongs to an obsolete plan.")) - node = next((item for item in plan.nodes if item.node_id == action.node_id), None) - if node is None or node.atomic_id != action.atomic_id or tuple(node.claim_ids) != action.claim_ids: - return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The scheduled feature no longer matches the active plan.")) - contract = self.runtime.operation_contract(action.atomic_id) - if contract["contract_hash"] != action.contract_hash: - return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The operation contract changed while the feature was scheduled.")) - base = self.artifacts.read_active_cdsl(task_id, state.active_revision) - selectors = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)) - references = self.runtime.reference_tokens(base) - selector_policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {} - selector_kind = str(selector_policy.get("token_kind") or "") - allowed_selectors = [ - token for token, value in selectors.items() - if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") == "required" - and isinstance(value, dict) and value.get("kind") == selector_kind - ] - errors = validate_fragment(contract, fragment, selector_tokens=allowed_selectors, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision)) - if errors: - return self._feature_failure(task_id, state, action, ErrorCode.AUTHOR_FORMAT_INVALID, "fragment_preflight", "CDSL fragment violates the scheduled operation schema.", invocation_id=invocation_id, field_errors=tuple(errors)) - scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id)) - feature_ids = scheduler.feature_ids() - direct_feature_ids = tuple(feature_ids.get(dependency, "") for dependency in action.depends_on_node_ids) - if any(not value for value in direct_feature_ids): - return self._feature_failure(task_id, state, action, ErrorCode.RUNTIME_PRECONDITION_FAILED, "dependency", "A direct dependency has no verified runtime feature.", invocation_id=invocation_id) - fragment_hash = canonical_hash(fragment) - key = self._key(task_id, "feature_fragment", state.working_head, {"node_id": action.node_id, "fragment": fragment}) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - try: - cdsl, audit = self.runtime.materialize_fragment( - base, fragment, contract, selectors, references, - require_through=self._claims_require_through(task_id, action.claim_ids), - depends_on_feature_ids=direct_feature_ids, - ) - except Exception as error: - return self._feature_failure(task_id, state, action, self._runtime_error(error).code, "fragment_preflight", str(error), invocation_id=invocation_id, invocation=invocation) - stage_id = "feature_stage_" + sha256(key.encode("utf-8")).hexdigest()[:16] - try: - stage = self.artifacts.start_candidate_stage(task_id, key, { - "schema_version": "cad.v3.2.feature-input.v1", "stage_id": stage_id, - "node_id": action.node_id, "node_hash": node_hash(node), "plan_hash": state.feature_plan_hash, - "action_id": action.action_id, "fragment": fragment, "fragment_audit": audit, - }) - except OSError as error: - return self._park_retry(state, ErrorCode.STORAGE_FAILURE, event="feature_stage_storage_failure", message=str(error), details={"node_id": action.node_id}) - building = transition(state, "feature_started", feature_stage_id=stage.stage_id) - if not self.repository.compare_and_swap(building, events=[{ - "event": "feature_node_building", "node_id": action.node_id, "node_hash": node_hash(node), - "plan_hash": state.feature_plan_hash, "stage_id": stage.stage_id, "fragment_hash": fragment_hash, - }]): - return Rejected(self._stale()) - try: - rebuilt = self.runtime.build_checkpoint(cdsl, stage.output_dir, task_id, stage_id) - try: - preview = self.runtime.create_preview(stage.output_dir) - rebuilt["paths"]["glb"] = preview["path"] - except Exception as preview_error: - preview = {"preview_unavailable": str(preview_error)[:500]} - claim_results = self._evaluate_claims(task_id, rebuilt, claim_ids=set(action.claim_ids)) - operation_results = self._operation_candidate_results( - action, contract, cdsl, rebuilt, parent_facts=self._facts(task_id, state.active_revision), - require_through=self._claims_require_through(task_id, action.claim_ids), - ) - global_failures = [item for item in self._evaluate_claims(task_id, rebuilt) if item.get("claim_kind") in {"solid_count_equals", "single_connected_body"} and item.get("status") != "pass"] - blockers = [ - # A claim is assigned to exactly one runtime-atomic node in - # the Feature Plan. Unlike unassigned future-work claims, - # an assigned claim may not remain pending when that node is - # published: doing so would turn a missing feature into a - # permanent, apparently successful checkpoint. - *[item for item in claim_results if item.get("status") != "pass"], - *[item for item in operation_results if item.get("status") != "pass"], - *global_failures, - ] - if blockers: - self.artifacts.write_stage_json(task_id, stage.stage_id, "node-verification.json", { - "schema_version": "cad.v3.2.node-verification.v1", "node_id": action.node_id, - "claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers, - }) - return self._feature_failure(task_id, building, action, ErrorCode.CLAIM_VERIFICATION_FAILED, "node_validation", "The feature did not satisfy its local deterministic acceptance.", invocation_id=invocation_id, invocation=invocation, details={"stage_id": stage.stage_id, "blockers": blockers}) - verification = { - "schema_version": "cad.v3.2.node-verification.v1", "node_id": action.node_id, - "node_hash": node_hash(node), "plan_hash": state.feature_plan_hash, - "feature_id": audit["assigned_feature_ids"][0], "claim_results": claim_results, - "operation_verifier_results": operation_results, "health": rebuilt["health"], "preview": preview, - } - self.artifacts.write_stage_json(task_id, stage.stage_id, "node-verification.json", verification) - revision_id = self._next_revision(task_id) - paths = self.artifacts.publish_candidate(task_id, stage.stage_id, revision_id) - next_state = transition(building, "feature_verified", active_revision=revision_id) - result = {"status": "verified", "node_id": action.node_id, "revision_id": revision_id, "paths": paths, "claim_results": claim_results} - event = { - "event": "feature_node_verified", "node_id": action.node_id, "node_hash": node_hash(node), - "plan_hash": state.feature_plan_hash, "feature_id": audit["assigned_feature_ids"][0], - "atomic_id": action.atomic_id, - "revision_id": revision_id, "parent_revision": state.active_revision, "stage_id": stage.stage_id, - "claim_results": claim_results, "preview_unavailable": preview.get("preview_unavailable", ""), - } - if not self._commit_invocation(next_state, [event], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - except OSError as error: - return self._park_retry(building, ErrorCode.STORAGE_FAILURE, event="feature_build_storage_failure", message=str(error), details={"node_id": action.node_id, "stage_id": stage.stage_id}) - except Exception as error: - return self._feature_failure(task_id, building, action, self._runtime_error(error).code, "engine_build", str(error), invocation_id=invocation_id, invocation=invocation, details={"stage_id": stage.stage_id}) - - def recover_feature_build(self, task_id: str) -> Accepted | Rejected: - """Make a crashed build retryable without losing its scheduled node.""" - state = self.repository.get_state(task_id) - action = state.pending_feature if state else None - if state is None or state.phase != TaskPhase.FEATURE_BUILDING or action is None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "There is no recoverable feature build.")) - next_state = transition(state, "feature_retry") - if not self.repository.compare_and_swap(next_state, events=[{ - "event": "feature_build_recovered", "node_id": action.node_id, "plan_hash": action.plan_hash, - "stage_id": state.feature_stage_id, "message": "Interrupted node build returned to the same scheduled feature.", - }]): - return Rejected(self._stale()) - return Accepted({"status": "retry", "node_id": action.node_id, "phase": next_state.phase.value}) - - def _feature_failure(self, task_id: str, state: TaskState, action: PendingAction, code: ErrorCode, failure_class: str, message: str, *, invocation_id: str, field_errors: tuple[dict[str, Any], ...] = (), invocation: Any = None, details: dict[str, Any] | None = None) -> Rejected: - plan = self._feature_plan(task_id, state) - node = next((item for item in (plan.nodes if plan else []) if item.node_id == action.node_id), None) - if node is None: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature node is unavailable.", retryable=True)) - scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id)) - attempt = scheduler.failure_count(action.node_id, failure_class) + 1 - terminal = attempt >= 2 - event = { - "event": "feature_node_failed", "node_id": action.node_id, "node_hash": node_hash(node), - "plan_hash": state.feature_plan_hash, "failure_class": failure_class, "attempt": attempt, - "terminal": terminal, "code": code.value, "normalized_error_code": code.value, - "atomic_id": action.atomic_id, "checkpoint_revision": state.active_revision, - "message": message[:1000], **(details or {}), - } - next_state = transition(state, "feature_replan" if terminal else "feature_retry", error=code) - result = {"status": "replan_required" if terminal else "retry", "node_id": action.node_id, "attempt": attempt, "failure_class": failure_class} - if invocation is not None: - committed = self._commit_invocation(next_state, [event], invocation, result) - else: - committed = self.repository.compare_and_swap(next_state, events=[event]) - if not committed: - return Rejected(self._stale()) - return Rejected(WorkflowError(code, message, field_errors=field_errors, details={**(details or {}), "node_id": action.node_id, "attempt": attempt, "replan_required": terminal})) - - def propose_next_action(self, task_id: str, proposal: NextAction, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A next action is not allowed in the current workflow state.")) - if not self.repair_action_ready(task_id, state): - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Record a current geometry conclusion before selecting a repair action.")) - stale = reject_stale_head(state, proposal.working_head) - if stale: - return Rejected(stale) - contract = self._requirements_contract(task_id, state) - if not isinstance(contract, dict): - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements contract is not frozen.")) - requirement_ids = {str(item.get("requirement_id") or "") for item in contract.get("requirements") or () if isinstance(item, dict)} - if not set(proposal.requirement_ids).issubset(requirement_ids): - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Action references a requirement outside the frozen contract.")) - if proposal.atomic_id not in self.runtime.supported_atomic_ids(): - return Rejected(WorkflowError(ErrorCode.RUNTIME_CONTRACT_INVALID, "Action selects an operation absent from the verified runtime registry.")) - if proposal.atomic_id not in self.available_atomic_ids(task_id, state): - return Rejected(WorkflowError( - ErrorCode.AUTHOR_DECISION_REJECTED, - "Action requires selector or feature-reference facts unavailable from the current geometry snapshot.", - )) - operation = self.runtime.operation_contract(proposal.atomic_id) - key = self._key(task_id, "next_action", state.working_head, proposal.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - action = PendingAction( - action_id=f"act_{state.version + 1:03d}", working_head=state.working_head, intent=proposal.intent, - requirement_ids=tuple(proposal.requirement_ids), atomic_id=proposal.atomic_id, expected_change=proposal.expected_change, - contract_hash=str(operation["contract_hash"]), idempotency_key=key, - ) - next_state = transition(state, "action_proposed", pending_action=action) - payload = {"action_id": action.action_id, "working_head": action.working_head, "atomic_id": action.atomic_id, "contract_hash": action.contract_hash} - if not self._commit_invocation(next_state, [{"event": "proposed", **payload, "intent": action.intent, "requirement_ids": list(action.requirement_ids), "expected_change": action.expected_change}], invocation, payload): - return Rejected(self._stale()) - return Accepted(payload) - - def diagnostic_evidence_refs(self, task_id: str, state: TaskState | None = None) -> tuple[str, ...]: - """Return the server-generated evidence references for a repair turn. - - These are opaque names for current facts and committed audit evidence, - never model-provided paths or feature IDs. The command handler checks - them again so a schema from an earlier turn cannot authorize a write. - """ - state = state or self.repository.get_state(task_id) - if state is None: - return () - refs = ["evidence_current_state"] - if state.active_revision: - refs.extend(["evidence_current_model", "evidence_current_claims"]) - for event in self.repository.ledger_events(task_id)[-16:]: - if event.get("event") in { - "candidate_rejected", "candidate_build_failed", "candidate_recovery_failed", - "candidate_recovered_rejected", "candidate_operation_skipped", "final_review_repair", - } or (event.get("event") == "accepted" and event.get("repair_required")): - sequence = event.get("sequence") - if isinstance(sequence, int): - refs.append(f"evidence_ledger_{sequence}") - return tuple(dict.fromkeys(refs)) - - def repair_diagnostics(self, task_id: str, state: TaskState | None = None) -> list[dict[str, Any]]: - """Return bounded, server-measured evidence from failed candidates. - - A repair turn runs against the last accepted checkpoint, while its - useful facts often live in an immutable rejected candidate stage. An - opaque ledger reference alone forces the author to rediscover those - facts or repeat a failed construction. This projection deliberately - exposes only compact build/verification outcomes, never the rejected - fragment or a server-selected next operation. - """ - state = state or self.repository.get_state(task_id) - if state is None or not state.repair_required: - return [] - relevant = { - "candidate_rejected", - "candidate_build_failed", - "candidate_recovery_failed", - "candidate_recovered_rejected", - "candidate_operation_skipped", - "runtime_precondition_rejected", - "final_review_repair", - "accepted", - } - diagnostics: list[dict[str, Any]] = [] - for event in reversed(self.repository.ledger_events(task_id)): - if event.get("event") not in relevant: - continue - sequence = event.get("sequence") - item: dict[str, Any] = { - "event": str(event.get("event") or ""), - "evidence_ref": f"evidence_ledger_{sequence}" if isinstance(sequence, int) else "evidence_current_state", - } - for field in ("candidate_id", "action_id"): - value = event.get(field) - if isinstance(value, str) and value: - item[field] = value - message = event.get("message") - if isinstance(message, str) and message: - item["message"] = message[:500] - for field in ("issues", "failed_checklist_items"): - values = event.get(field) - if isinstance(values, list): - item[field] = [str(value)[:500] for value in values[:8] if isinstance(value, str)] - failures = event.get("operation_failures") - if isinstance(failures, list): - item["operation_failures"] = [ - {key: str(value)[:500] for key, value in failure.items() if key in {"feature_id", "message"}} - for failure in failures[:8] - if isinstance(failure, dict) - ] - fragment_hash = event.get("fragment_hash") - if isinstance(fragment_hash, str) and fragment_hash: - item["fragment_hash"] = fragment_hash - stage_id = event.get("stage_id") - candidate = None - if isinstance(stage_id, str) and stage_id: - try: - candidate = self.artifacts.read_stage_json(task_id, stage_id, "candidate.json") - except (OSError, ValueError, json.JSONDecodeError): - # The ledger remains authoritative if an abandoned stage - # is unavailable; diagnostic projection must not turn an - # existing repair into a new service failure. - candidate = None - if isinstance(candidate, dict): - atomic_id = candidate.get("actual_atomic_id") - if isinstance(atomic_id, str) and atomic_id: - item["atomic_id"] = atomic_id - health = candidate.get("health") if isinstance(candidate.get("health"), dict) else {} - bbox = health.get("bbox_mm") if isinstance(health.get("bbox_mm"), dict) else {} - measured = { - key: health[key] - for key in ("solid_count", "feature_count", "volume_mm3") - if isinstance(health.get(key), (int, float)) - } - if isinstance(bbox.get("dimensions"), list): - measured["bbox_dimensions_mm"] = bbox["dimensions"][:3] - if measured: - item["measured"] = measured - claim_results = candidate.get("claim_results") - if isinstance(claim_results, list): - failures: list[dict[str, Any]] = [] - for claim in claim_results: - if not isinstance(claim, dict) or claim.get("status") not in {"fail", "unavailable"}: - continue - evidence = claim.get("evidence") if isinstance(claim.get("evidence"), dict) else {} - failures.append({ - "claim_id": str(claim.get("claim_id") or ""), - "claim_kind": str(claim.get("claim_kind") or ""), - "status": str(claim.get("status") or ""), - "evidence": evidence, - }) - if failures: - item["failed_claims"] = failures[:8] - operation_results = candidate.get("operation_verifier_results") - if isinstance(operation_results, list): - operation_blockers: list[dict[str, Any]] = [] - for result in operation_results: - if not isinstance(result, dict) or result.get("status") == "pass": - continue - evidence = result.get("evidence") if isinstance(result.get("evidence"), dict) else {} - operation_blockers.append({ - "claim_id": str(result.get("claim_id") or ""), - "claim_kind": str(result.get("claim_kind") or ""), - "status": str(result.get("status") or ""), - "evidence": evidence, - }) - if operation_blockers: - # An operation may be rejected because it produced no - # net change. That result is often pending relative - # to the full requirements contract, but it is still - # a definite blocker for this specific action. - item["operation_blockers"] = operation_blockers[:8] - review = None - if isinstance(stage_id, str) and stage_id: - try: - review = self.artifacts.read_stage_json(task_id, stage_id, "candidate-review.json") - except (OSError, ValueError, json.JSONDecodeError): - review = None - if isinstance(review, dict): - evidence = review.get("evidence") - issues = review.get("issues") - if isinstance(evidence, list): - item["review_evidence"] = [str(value)[:500] for value in evidence[:8] if isinstance(value, str)] - if isinstance(issues, list): - item["review_issues"] = [str(value)[:500] for value in issues[:8] if isinstance(value, str)] - diagnostics.append(item) - if len(diagnostics) == 3: - break - return diagnostics - - def checkpoint_tokens(self, task_id: str, state: TaskState | None = None) -> dict[str, str]: - """Map opaque rollback tokens to the current linear checkpoint lineage.""" - state = state or self.repository.get_state(task_id) - if state is None: - return {} - accepted = [ - event for event in self.repository.ledger_events(task_id) - if event.get("event") == "accepted" and isinstance(event.get("revision_id"), str) - ] - by_revision = {str(event["revision_id"]): event for event in accepted} - if state.active_revision and state.active_revision not in by_revision: - return {} - lineage: list[str] = [] - cursor = state.active_revision - while cursor: - event = by_revision.get(cursor) - if event is None or cursor in lineage: - return {} - lineage.append(cursor) - parent = event.get("parent_revision") - if isinstance(parent, str): - cursor = parent - continue - # v3 events written before parent_revision existed were linear. - position = accepted.index(event) - cursor = str(accepted[position - 1]["revision_id"]) if position else "" - lineage.reverse() - return {"checkpoint_root": "", **{f"checkpoint_{revision}": revision for revision in lineage}} - - def rollback_available(self, task_id: str, state: TaskState | None = None) -> bool: - state = state or self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None: - return False - has_earlier_checkpoint = any( - revision != state.active_revision - for revision in self.checkpoint_tokens(task_id, state).values() - ) - return has_earlier_checkpoint and any( - event.get("event") == "geometry_conclusion" - and event.get("decision") == "rollback" - and event.get("working_head") == state.working_head - for event in reversed(self.repository.ledger_events(task_id)) - ) - - def repair_action_ready(self, task_id: str, state: TaskState | None = None) -> bool: - """Require a structured diagnosis before repairing failed geometry. - - A rollback is itself a resolved repair decision. For a new feature, - the author must instead record a return-to-action-selection conclusion - after the latest failed candidate. The ordering matters: a diagnosis - used to select a prior repair action cannot authorize retries after - that new action fails on the same checkpoint. - """ - state = state or self.repository.get_state(task_id) - if state is None or not state.repair_required: - return True - repair_triggers = { - "candidate_rejected", - "candidate_build_failed", - "candidate_recovery_failed", - "candidate_recovered_rejected", - "final_review_repair", - } - for event in reversed(self.repository.ledger_events(task_id)): - event_name = event.get("event") - if event_name == "rollback": - return True - if event_name == "geometry_conclusion" and event.get("decision") == "return_to_action_selection": - return True - if event_name in repair_triggers: - return False - return False - - def record_geometry_conclusion(self, task_id: str, conclusion: GeometryConclusion, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - if state is None or state.phase not in {TaskPhase.ACTION_PENDING, TaskPhase.AWAITING_ACTION}: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Geometry diagnosis is only available while repairing or selecting an action.")) - stale = reject_stale_head(state, conclusion.working_head) - if stale: - return Rejected(stale) - available = set(self.diagnostic_evidence_refs(task_id, state)) - if not set(conclusion.evidence_refs).issubset(available): - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Geometry conclusion references evidence outside the current server snapshot.")) - if conclusion.decision == "rollback" and state.phase != TaskPhase.AWAITING_ACTION: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Return to action selection before requesting a checkpoint rollback.")) - key = self._key(task_id, "geometry_conclusion", state.working_head, conclusion.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - if conclusion.decision == "return_to_action_selection" and state.phase == TaskPhase.ACTION_PENDING: - next_state = transition(state, "diagnosis_return_to_action_selection", repair_required=True) - else: - next_state = transition(state, "diagnosis_recorded", repair_required=True) - event = { - "event": "geometry_conclusion", - "working_head_before": state.working_head, - "working_head": next_state.working_head, - "evidence_refs": list(conclusion.evidence_refs), - "root_cause": conclusion.root_cause, - "decision": conclusion.decision, - "corrective_intent": conclusion.corrective_intent or "", - } - result = {"decision": conclusion.decision, "phase": next_state.phase.value, "working_head": next_state.working_head} - if not self._commit_invocation(next_state, [event], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def rollback_checkpoint(self, task_id: str, rollback: RollbackCheckpoint, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Checkpoint rollback requires an idle action-selection state.")) - stale = reject_stale_head(state, rollback.working_head) - if stale: - return Rejected(stale) - if not self.rollback_available(task_id, state): - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Record a current geometry conclusion with decision=rollback before rolling back.")) - target = self.checkpoint_tokens(task_id, state).get(rollback.checkpoint_token) - if target is None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Rollback checkpoint token is not in the active lineage.")) - if target == state.active_revision: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Rollback must select an earlier checkpoint.")) - key = self._key(task_id, "rollback", state.working_head, rollback.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - next_state = transition(state, "rollback", active_revision=target, repair_required=True) - event = { - "event": "rollback", - "working_head_before": state.working_head, - "working_head": next_state.working_head, - "checkpoint_token": rollback.checkpoint_token, - "rollback_from_revision": state.active_revision, - "rollback_to_revision": target, - "reason": rollback.reason, - } - result = {"status": "rolled_back", "active_revision": target, "working_head": next_state.working_head} - if not self._commit_invocation(next_state, [event], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def submit_cdsl_fragment(self, task_id: str, fragment: dict[str, Any], *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - if state is not None and state.phase == TaskPhase.FEATURE_PENDING: - return self.submit_feature_fragment(task_id, fragment, invocation_id=invocation_id) - action = state.pending_action if state else None - if state is None or state.phase != TaskPhase.ACTION_PENDING or action is None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A CDSL fragment requires one pending action.")) - contract = self.runtime.operation_contract(action.atomic_id) - if contract["contract_hash"] != action.contract_hash: - return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The pending operation contract changed; read the current contract again.")) - base = self.artifacts.read_active_cdsl(task_id, state.active_revision) - topology = self.artifacts.read_topology(task_id, state.active_revision) - selectors = self.runtime.selector_tokens(topology) - references = self.runtime.reference_tokens(base) - selector_policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {} - expected_selector_kind = str(selector_policy.get("token_kind") or "") - allowed_selector_tokens = ( - [token for token, value in selectors.items() if isinstance(value, dict) and value.get("kind") == expected_selector_kind] - if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") == "required" - else [] - ) - errors = validate_fragment( - contract, - fragment, - selector_tokens=allowed_selector_tokens, - reference_tokens=list(references), - root_xy_datum=not bool(state.active_revision), - ) - if errors: - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "CDSL fragment violates the active operation schema.", field_errors=tuple(errors))) - fragment_hash = canonical_hash(fragment) - exact_fingerprint = canonical_hash({ - "active_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - }) - prior_exact = next(( - event for event in reversed(self.repository.ledger_events(task_id)) - if event.get("failure_exact_fingerprint") == exact_fingerprint - ), None) - if prior_exact is not None: - return Rejected(WorkflowError( - ErrorCode.RUNTIME_PRECONDITION_FAILED, - "This exact CDSL fragment was already proven to fail at the current checkpoint; choose a different fragment or operation path.", - details={ - "duplicate_fragment": True, - "active_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "failure_exact_fingerprint": exact_fingerprint, - "normalized_error_code": str(prior_exact.get("normalized_error_code") or ErrorCode.RUNTIME_PRECONDITION_FAILED.value), - }, - )) - key = self._key(task_id, "fragment", action.working_head, {"action_id": action.action_id, "fragment": fragment}) - try: - require_through = self._action_requires_through(task_id, action.requirement_ids) - cdsl, audit = self.runtime.materialize_fragment(base, fragment, contract, selectors, references, require_through=require_through) - except Exception as error: - runtime_error = self._runtime_error(error) - return Rejected(WorkflowError( - runtime_error.code, - runtime_error.message, - field_errors=runtime_error.field_errors, - retryable=runtime_error.retryable, - details={ - **runtime_error.details, - "active_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "failure_exact_fingerprint": exact_fingerprint, - "normalized_error_code": runtime_error.code.value, - }, - )) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - candidate_id = "candidate_" + sha256(key.encode("utf-8")).hexdigest()[:16] - try: - stage = self.artifacts.start_candidate_stage(task_id, key, {"schema_version": "cad.v3.candidate-input.v1", "idempotency_key": key, "candidate_id": candidate_id, "action_id": action.action_id, "working_head": action.working_head, "contract_hash": action.contract_hash, "fragment": fragment, "fragment_audit": audit}) - except OSError as error: - return self._park_for_storage_retry( - state, - event="candidate_stage_storage_failure", - message=str(error), - ) - building = transition(state, "candidate_started", candidate_id=candidate_id, candidate_stage_id=stage.stage_id) - if not self.repository.compare_and_swap(building, events=[{"event": "candidate_building", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "fragment_hash": audit["fragment_hash"]}]): - return Rejected(self._stale()) - try: - rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, stage.output_dir, task_id, candidate_id) - attempted_feature_ids = {str(value) for value in audit.get("assigned_feature_ids") or () if isinstance(value, str)} - executed_feature_ids = {str(value) for value in rebuilt.get("executed_feature_ids") or () if isinstance(value, str)} - if attempted_feature_ids and not attempted_feature_ids.intersection(executed_feature_ids): - next_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED) - result = {"candidate_id": candidate_id, "status": "skipped", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value, "operation_failures": operation_failures} - if not self._commit_invocation(next_state, [{ - "event": "candidate_operation_skipped", - "candidate_id": candidate_id, - "stage_id": stage.stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - "checkpoint_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "operation_failures": operation_failures, - "message": "The submitted feature did not execute; earlier executable features were retained.", - }], invocation, result): - return Rejected(self._stale()) - return Rejected(WorkflowError( - ErrorCode.CANDIDATE_BUILD_FAILED, - "The submitted feature could not execute; the previous executable checkpoint was retained.", - details={"operation_failures": operation_failures}, - )) - try: - claim_results = self._evaluate_claims(task_id, rebuilt) - except Exception as error: - failed_state = transition(building, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID) - result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value} - if not self._commit_invocation(failed_state, [{ - "event": "requirements_contract_execution_failed", - "candidate_id": candidate_id, - "stage_id": stage.stage_id, - "action_id": action.action_id, - "message": str(error)[:1000], - }], invocation, result): - return Rejected(self._stale()) - return Rejected(WorkflowError( - ErrorCode.REQUIREMENTS_SPEC_INVALID, - "The frozen requirements contract could not be evaluated; no CAD repair was attempted.", - details={"diagnostic": str(error)[:1000]}, - )) - operation_results = self._operation_candidate_results( - action, - contract, - cdsl, - rebuilt, - parent_facts=self._facts(task_id, state.active_revision), - require_through=require_through, - ) - blockers = [ - *self._candidate_blockers(task_id, action.requirement_ids, claim_results), - *[item for item in operation_results if item.get("status") != "pass"], - ] - candidate = {"schema_version": "cad.v3.candidate.v1", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "working_head": action.working_head, "actual_atomic_id": action.atomic_id, "fragment_hash": audit["fragment_hash"], "selector_snapshot_id": audit["selector_snapshot_id"], "claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers, "operation_failures": operation_failures, "executed_feature_ids": rebuilt.get("executed_feature_ids") or [], "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"]} - self.artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", candidate) - review = transition(building, "candidate_built", candidate_id=candidate_id, candidate_stage_id=stage.stage_id) - result = {"candidate_id": candidate_id, "stage_id": stage.stage_id, "status": "awaiting_review", "claim_results": claim_results, "operation_failures": operation_failures} - if not self._commit_invocation(review, [{"event": "candidate_built", "candidate_id": candidate_id, "action_id": action.action_id, "claim_results": claim_results, "operation_failures": operation_failures}], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - except OSError as error: - return self._park_retry( - building, - ErrorCode.STORAGE_FAILURE, - event="candidate_build_storage_failure", - message=str(error), - details={"candidate_id": candidate_id, "stage_id": stage.stage_id}, - ) - except Exception as error: - if "RENDER_SERVICE_UNAVAILABLE" in str(error): - return self._park_retry( - building, - ErrorCode.RENDER_SERVICE_UNAVAILABLE, - event="candidate_render_failure", - message=str(error), - details={"candidate_id": candidate_id, "stage_id": stage.stage_id}, - ) - if "RUNTIME_EXECUTION_FAILURE" in str(error): - return self._park_retry( - building, - ErrorCode.RUNTIME_EXECUTION_FAILURE, - event="candidate_runtime_execution_failure", - message=str(error), - details={"candidate_id": candidate_id, "stage_id": stage.stage_id, "checkpoint_revision": state.active_revision}, - ) - failed_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED) - result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value} - if not self._commit_invocation(failed_state, [{ - "event": "candidate_build_failed", - "candidate_id": candidate_id, - "stage_id": stage.stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - "checkpoint_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "normalized_error_code": ErrorCode.CANDIDATE_BUILD_FAILED.value, - "failure_exact_fingerprint": exact_fingerprint, - "message": str(error)[:1000], - }], invocation, result): - return Rejected(self._stale()) - return Rejected(WorkflowError(ErrorCode.CANDIDATE_BUILD_FAILED, "Candidate build failed; the checkpoint remains unchanged.", details={"diagnostic": str(error)[:1000]})) - - def recover_candidate_build(self, task_id: str) -> Accepted | Rejected: - """Resume one persisted candidate build without inventing another action. - - A complete staging directory is converted to the next state directly. - If a process stopped mid-build, the same stage/idempotency key is used - and an already-written CDSL document is rebuilt in place. - """ - state = self.repository.get_state(task_id) - action = state.pending_action if state else None - if state is None or state.phase != TaskPhase.CANDIDATE_BUILDING or action is None or not state.candidate_stage_id: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "There is no recoverable candidate build.")) - try: - candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") - if isinstance(candidate, dict): - return self._advance_recovered_candidate(task_id, state, candidate) - source = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "input.json") - if not isinstance(source, dict) or source.get("candidate_id") != state.candidate_id: - return self._park_for_storage_retry( - state, - event="candidate_recovery_storage_failure", - message="Candidate recovery input is unavailable.", - ) - report = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "rebuild-report.json") - topology = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "model.topology.json") - cdsl = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "model.cdsl.json") - if isinstance(report, dict) and isinstance(topology, dict) and isinstance(cdsl, dict): - rebuilt = { - "health": report.get("health") or {}, "topology": topology, "report": report, - "render_manifest": report.get("render_manifest") or {}, - "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json", "render_manifest": "renders/render-manifest.json"}, - } - else: - base = self.artifacts.read_active_cdsl(task_id, state.active_revision) - fragment = source.get("fragment") - contract = self.runtime.operation_contract(action.atomic_id) - if not isinstance(fragment, dict) or contract.get("contract_hash") != action.contract_hash: - raise RuntimeError("RUNTIME_PRECONDITION_FAILED: candidate recovery contract is stale") - selectors = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)) - references = self.runtime.reference_tokens(base) - cdsl, _audit = self.runtime.materialize_fragment(base, fragment, contract, selectors, references, require_through=self._action_requires_through(task_id, action.requirement_ids)) - rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, self.artifacts.stage_output_dir(task_id, state.candidate_stage_id), task_id, state.candidate_id) - if "operation_failures" not in locals(): - operation_failures = [] - claim_results = self._evaluate_claims(task_id, rebuilt) - audit = source.get("fragment_audit") if isinstance(source.get("fragment_audit"), dict) else {} - contract = self.runtime.operation_contract(action.atomic_id) - operation_results = self._operation_candidate_results( - action, - contract, - cdsl, - rebuilt, - parent_facts=self._facts(task_id, state.active_revision), - require_through=self._action_requires_through(task_id, action.requirement_ids), - ) - candidate = { - "schema_version": "cad.v3.candidate.v1", "candidate_id": state.candidate_id, - "stage_id": state.candidate_stage_id, "action_id": action.action_id, - "working_head": action.working_head, "actual_atomic_id": action.atomic_id, - "fragment_hash": audit.get("fragment_hash") or canonical_hash(source.get("fragment") or {}), - "selector_snapshot_id": audit.get("selector_snapshot_id") or "", "claim_results": claim_results, - "operation_verifier_results": operation_results, - "operation_failures": operation_failures, - "blockers": [ - *self._candidate_blockers(task_id, action.requirement_ids, claim_results), - *[item for item in operation_results if item.get("status") != "pass"], - ], - "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"], - } - self.artifacts.write_stage_json(task_id, state.candidate_stage_id, "candidate.json", candidate) - return self._advance_recovered_candidate(task_id, state, candidate) - except OSError as error: - return self._park_for_storage_retry(state, event="candidate_recovery_storage_failure", message=str(error)) - except Exception as error: - if "RENDER_SERVICE_UNAVAILABLE" in str(error): - return self._park_retry( - state, - ErrorCode.RENDER_SERVICE_UNAVAILABLE, - event="candidate_recovery_render_failure", - message=str(error), - details={"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id}, - ) - if "RUNTIME_EXECUTION_FAILURE" in str(error): - return self._park_retry( - state, - ErrorCode.RUNTIME_EXECUTION_FAILURE, - event="candidate_recovery_runtime_execution_failure", - message=str(error), - details={"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id, "checkpoint_revision": state.active_revision}, - ) - failed = transition(state, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED) - self.repository.compare_and_swap(failed, events=[{ - "event": "candidate_recovery_failed", - "candidate_id": state.candidate_id, - "stage_id": state.candidate_stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - "message": str(error)[:1000], - }]) - return Accepted({"candidate_id": state.candidate_id, "status": "failed", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value, "diagnostic": str(error)[:1000]}) - - def record_candidate_review(self, task_id: str, review: CandidateReview, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - action = state.pending_action if state else None - if state is None or state.phase != TaskPhase.CANDIDATE_REVIEW or action is None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Candidate review is not expected in the current workflow phase.")) - if review.candidate_id != state.candidate_id or review.working_head != action.working_head: - return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Candidate review references a stale candidate or head.")) - candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") - published = None if isinstance(candidate, dict) else self.artifacts.find_published_candidate(task_id, state.candidate_stage_id) - published_revision = published[0] if published else "" - if published: - candidate = published[1] - if not isinstance(candidate, dict): - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate review evidence is unavailable.", retryable=True)) - actual = candidate.get("claim_results") if isinstance(candidate.get("claim_results"), list) else [] - expected_ids = {str(item.get("claim_id") or "") for item in actual if isinstance(item, dict)} - submitted = {item.claim_id: item.status for item in review.claim_coverage} - submitted_ids = set(submitted) - if submitted_ids != expected_ids or len(submitted_ids) != len(review.claim_coverage): - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Candidate review must cover exactly the current claim set.")) - mismatched_deterministic = [ - str(item.get("claim_id") or "") - for item in actual - if isinstance(item, dict) - and item.get("deterministic") - and submitted.get(str(item.get("claim_id") or "")) != item.get("status") - ] - if mismatched_deterministic: - return Rejected(WorkflowError( - ErrorCode.AUTHOR_FORMAT_INVALID, - "Candidate review must report the server's deterministic claim status exactly.", - details={"claim_ids": mismatched_deterministic}, - )) - deterministic_fail = [item for item in actual if isinstance(item, dict) and item.get("deterministic") and item.get("status") in {"fail", "unavailable"}] - key = self._key(task_id, "candidate_review", action.working_head, review.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - review_payload = review.model_dump(mode="json") - try: - if published_revision: - self.artifacts.write_json_once(task_id, f"revisions/{published_revision}/candidate-review.json", review_payload) - else: - self.artifacts.write_stage_json(task_id, state.candidate_stage_id, "candidate-review.json", review_payload) - except OSError as error: - return self._park_for_storage_retry( - state, - event="candidate_review_storage_failure", - message=str(error), - ) - operation_failures = candidate.get("operation_failures") if isinstance(candidate.get("operation_failures"), list) else [] - candidate_blockers = candidate.get("blockers") if isinstance(candidate.get("blockers"), list) else [] - needs_repair = review.verdict != "accept" or bool(deterministic_fail) or bool(operation_failures) or bool(candidate_blockers) - revision_id = self._next_revision(task_id) - if published_revision and published_revision != revision_id: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Published candidate revision does not match the current checkpoint lineage.", retryable=True)) - try: - paths = self.artifacts.publish_candidate(task_id, state.candidate_stage_id, revision_id) if not published_revision else {} - except OSError as error: - return self._park_for_storage_retry( - state, - event="candidate_publish_storage_failure", - message=str(error), - ) - next_state = transition(state, "candidate_accepted", active_revision=revision_id, repair_required=needs_repair, error=ErrorCode.CLAIM_VERIFICATION_FAILED if needs_repair else None) - event = {"event": "accepted", "action_id": action.action_id, "working_head_before": action.working_head, "parent_revision": state.active_revision, "revision_id": revision_id, "actual_atomic_id": action.atomic_id, "fragment_hash": candidate.get("fragment_hash"), "selector_snapshot_id": candidate.get("selector_snapshot_id"), "candidate_id": state.candidate_id, "review_path": f"revisions/{revision_id}/candidate-review.json", "coverage": actual, "issues": list(review.issues), "operation_failures": operation_failures, "repair_required": needs_repair} - result = {"candidate_id": state.candidate_id, "revision_id": revision_id, "paths": paths, "status": "accepted_with_issues" if needs_repair else "accepted"} - if not self._commit_invocation(next_state, [event], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def recover_candidate_review(self, task_id: str) -> Accepted | Rejected | None: - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.CANDIDATE_REVIEW or not state.candidate_stage_id: - return None - raw = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate-review.json") - if raw is None: - published = self.artifacts.find_published_candidate(task_id, state.candidate_stage_id) - raw = self.artifacts.read_json(task_id, f"revisions/{published[0]}/candidate-review.json") if published else None - if raw is None: - return None - try: - review = CandidateReview.model_validate(raw) - except ValueError as error: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Persisted candidate review is invalid.", details={"diagnostic": str(error)[:1000]})) - return self.record_candidate_review(task_id, review, invocation_id=f"{task_id}_recover_candidate_{secrets.token_hex(8)}") - - def complete_task(self, task_id: str, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - if state is None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Task completion is not available for an unknown task.")) - # Completion idempotency is revision-scoped. A duplicate request after - # final validation or publication replays the existing result and can - # never start a second final rebuild/review. - key = self._key(task_id, "complete", state.active_revision, {}) - if state.phase == TaskPhase.FINAL_VALIDATION and state.active_revision: - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Final validation is already in progress for this revision.")) - if state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None or state.repair_required: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Task completion is not available until there is no pending action or repair.")) - claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - deterministic = [item for item in claim_results if item.get("deterministic")] - if not state.active_revision or any(item.get("status") != "pass" for item in deterministic): - return Rejected(WorkflowError(ErrorCode.CLAIM_VERIFICATION_FAILED, "All deterministic claims must pass before final review.", details={"claim_results": claim_results})) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - next_state = transition(state, "final_requested") - result = {"revision_id": state.active_revision, "working_head": next_state.working_head, "claim_results": claim_results} - if not self._commit_invocation(next_state, [{"event": "final_validation_requested", "revision_id": state.active_revision, "claim_results": claim_results}], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def recover_final_review(self, task_id: str) -> Accepted | Rejected | None: - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.FINAL_VALIDATION or not state.active_revision: - return None - raw = self.artifacts.read_json(task_id, self._final_review_path(state.active_revision)) - if raw is None: - return None - try: - review = FinalReview.model_validate(raw) - except ValueError as error: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Persisted final review is invalid.", details={"diagnostic": str(error)[:1000]})) - return self.record_final_review(task_id, review, invocation_id=f"{task_id}_recover_final_{secrets.token_hex(8)}") - - def _advance_recovered_candidate(self, task_id: str, state: TaskState, candidate: dict[str, Any]) -> Accepted | Rejected: - action = state.pending_action - if action is None or candidate.get("candidate_id") != state.candidate_id or candidate.get("action_id") != action.action_id: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate recovery evidence does not match the pending action.", retryable=True)) - claim_results = candidate.get("claim_results") if isinstance(candidate.get("claim_results"), list) else [] - operation_results = candidate.get("operation_verifier_results") if isinstance(candidate.get("operation_verifier_results"), list) else [] - blockers = [ - *self._candidate_blockers(task_id, action.requirement_ids, claim_results), - *[item for item in operation_results if isinstance(item, dict) and item.get("status") != "pass"], - ] - key = str((self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "input.json") or {}).get("idempotency_key") or "") - invocation = self.repository.begin_invocation(task_id, f"{task_id}_recover_build_{secrets.token_hex(8)}", key) if key else None - if invocation is not None and invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - review = transition(state, "candidate_built", candidate_id=state.candidate_id, candidate_stage_id=state.candidate_stage_id) - result = {"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id, "status": "awaiting_review", "claim_results": claim_results} - committed = self._commit_invocation(review, [{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}], invocation, result) if invocation is not None else self.repository.compare_and_swap(review, events=[{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}]) - if not committed: - return Rejected(self._stale()) - return Accepted(result) - - def finalize_best_effort(self, task_id: str, *, reason: ErrorCode, invocation_id: str) -> Accepted | Rejected: - """Publish the last executable checkpoint when further repair is bounded. - - This is deliberately separate from strict final validation. It does - not claim that unmet acceptance targets passed; it makes the usable - model and its measured gaps durable instead of converting a planning - dead-end into a failed task with no deliverable. - """ - state = self.repository.get_state(task_id) - if state is None or not state.active_revision: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Best-effort completion requires an executable checkpoint.")) - if state.phase == TaskPhase.COMPLETED: - return Accepted({"status": "completed", "revision_id": state.active_revision}) - key = self._key(task_id, "best_effort_complete", state.active_revision, {"reason": reason.value}) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - issues = [ - f"{item.get('claim_kind')}: {item.get('status')}" - for item in claim_results - if item.get("status") != "pass" - ] - next_state = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False) - result = {"status": "completed_with_warnings", "revision_id": state.active_revision, "claim_results": claim_results, "issues": issues} - if not self._commit_invocation(next_state, [{ - "event": "completed_best_effort", - "revision_id": state.active_revision, - "termination_code": reason.value, - "message": "Further CAD repair was bounded; the last executable checkpoint was published.", - "issues": issues, - "claim_results": claim_results, - "completion_result_path": "completion-result.md", - }], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def record_final_review(self, task_id: str, review: FinalReview, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.FINAL_VALIDATION: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Final review is not expected in the current workflow phase.")) - stale = reject_stale_head(state, review.working_head) - if stale: - return Rejected(stale) - claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - expected_ids = {str(item.get("claim_id") or "") for item in claim_results} - submitted = {item.claim_id: item.status for item in review.claim_coverage} - submitted_ids = set(submitted) - if submitted_ids != expected_ids or len(submitted_ids) != len(review.claim_coverage): - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Final review must cover exactly the final claim set.")) - mismatched_deterministic = [ - str(item.get("claim_id") or "") - for item in claim_results - if item.get("deterministic") - and submitted.get(str(item.get("claim_id") or "")) != item.get("status") - ] - if mismatched_deterministic: - return Rejected(WorkflowError( - ErrorCode.AUTHOR_FORMAT_INVALID, - "Final review must report the server's deterministic claim status exactly.", - details={"claim_ids": mismatched_deterministic}, - )) - deterministic_fail = [item for item in claim_results if item.get("deterministic") and item.get("status") != "pass"] - review_coverage = submitted - visual_not_passed = [ - item - for item in claim_results - if not item.get("deterministic") and review_coverage.get(str(item.get("claim_id") or "")) != "pass" - ] - key = self._key(task_id, "final_review", state.working_head, review.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - final_review_path = self._final_review_path(state.active_revision) - try: - self.artifacts.write_json_once(task_id, final_review_path, review.model_dump(mode="json")) - except OSError as error: - return self._park_for_storage_retry( - state, - event="final_review_storage_failure", - message=str(error), - ) - if review.verdict != "pass" or deterministic_fail or visual_not_passed: - accepted_fragment = next(( - event.get("fragment_hash") for event in reversed(self.repository.ledger_events(task_id)) - if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision - ), "") - atomic_id = next(( - str(event.get("actual_atomic_id") or "") for event in reversed(self.repository.ledger_events(task_id)) - if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision - ), "") - exact_fingerprint = canonical_hash({ - "active_revision": state.active_revision, - "atomic_id": atomic_id, - "fragment_hash": accepted_fragment, - }) if accepted_fragment and atomic_id else "" - is_dag = bool(state.feature_plan_hash) - next_state = transition( - state, - "feature_replan" if is_dag else "final_repair", - repair_required=not is_dag, - error=ErrorCode.CLAIM_VERIFICATION_FAILED if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED, - ) - result = {"status": "repair", "revision_id": state.active_revision} - if not self._commit_invocation(next_state, [{ - "event": "final_visual_reviewed" if is_dag else "final_review_repair", - "revision_id": state.active_revision, - **({"plan_hash": state.feature_plan_hash} if is_dag else {}), - "claim_results": claim_results, - "review_claim_coverage": [item.model_dump(mode="json") for item in review.claim_coverage], - "visual_not_passed": [str(item.get("claim_id") or "") for item in visual_not_passed], - "issues": list(review.issues), - "evidence": list(review.evidence), - "failed_checklist_items": [ - str(requirement.get("statement") or "") - for requirement in (self._requirements_contract(task_id) or {}).get("requirements") or () - if isinstance(requirement, dict) - and any(str(claim.get("claim_id") or "") in {str(item.get("claim_id") or "") for item in deterministic_fail + visual_not_passed} for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)) - ], - "atomic_id": atomic_id, - "fragment_hash": accepted_fragment, - "failure_exact_fingerprint": exact_fingerprint, - "normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED.value, - }], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - next_state = transition(state, "final_accepted") - result = {"status": "completed", "revision_id": state.active_revision} - if not self._commit_invocation(next_state, [{"event": "final_visual_reviewed", "revision_id": state.active_revision, "final_review_path": final_review_path, "completion_result_path": "completion-result.md", "claim_results": claim_results}], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def _evaluate_claims(self, task_id: str, facts: dict[str, Any], *, claim_ids: set[str] | None = None) -> list[dict[str, Any]]: - contract = self._requirements_contract(task_id) or {} - claims = [ - claim 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_ids is None or str(claim.get("claim_id") or "") in claim_ids) - ] - return self.verifiers.evaluate(claims, facts) - - def _feature_plan(self, task_id: str, state: TaskState | None = None) -> FeaturePlan | None: - state = state or self.repository.get_state(task_id) - if state is None or not state.feature_plan_path: - return None - raw = self.artifacts.read_json(task_id, state.feature_plan_path) - try: - return FeaturePlan.model_validate(raw) - except ValueError: - return None - - def _requirement_ids_for_claims(self, task_id: str, claim_ids: tuple[str, ...]) -> set[str]: - wanted = set(claim_ids) - contract = self._requirements_contract(task_id) or {} - return { - str(requirement.get("requirement_id") or "") - for requirement in contract.get("requirements") or () - if isinstance(requirement, dict) - and any(str(claim.get("claim_id") or "") in wanted for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)) - } - - def _claims_require_through(self, task_id: str, claim_ids: tuple[str, ...]) -> bool: - wanted = set(claim_ids) - contract = self._requirements_contract(task_id) or {} - return any( - str(claim.get("claim_id") or "") in wanted and claim.get("claim_kind") == "through_cylindrical_bore" - for requirement in contract.get("requirements") or () if isinstance(requirement, dict) - for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict) - ) - - def claim_summary(self, task_id: str, state: TaskState) -> list[dict[str, Any]]: - """Return bounded current claim facts for author context assembly.""" - return [ - { - "claim_id": str(item.get("claim_id") or ""), - "claim_kind": str(item.get("claim_kind") or ""), - "deterministic": bool(item.get("deterministic")), - "status": str(item.get("status") or "pending"), - "evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {}, - } - for item in self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - if isinstance(item, dict) - ] - - def model_summary(self, task_id: str, state: TaskState) -> dict[str, Any]: - """Expose measurements, not raw topology, in recurring author turns.""" - if not state.active_revision: - return {"revision_id": "", "available": False} - facts = self._facts(task_id, state.active_revision) - health = facts.get("health") if isinstance(facts.get("health"), dict) else {} - topology = facts.get("topology") if isinstance(facts.get("topology"), dict) else {} - counts: dict[str, int] = {} - inner_cylindrical_bores: list[dict[str, Any]] = [] - for record in topology.get("records") or (): - if isinstance(record, dict) and isinstance(record.get("kind"), str): - kind = record["kind"] - counts[kind] = counts.get(kind, 0) + 1 - geometry = record.get("geometry") if isinstance(record.get("geometry"), dict) else {} - radius = geometry.get("radius_mm") - axis_origin = geometry.get("axis_origin_mm") - if ( - geometry.get("surface_type") == "cylinder" - and geometry.get("cylinder_role") == "inner" - and isinstance(radius, (int, float)) - and isinstance(axis_origin, list) - and len(axis_origin) == 3 - and all(isinstance(value, (int, float)) for value in axis_origin) - ): - inner_cylindrical_bores.append({ - "diameter_mm": round(float(radius) * 2, 6), - "axis_origin_mm": [round(float(value), 6) for value in axis_origin], - "through": bool(geometry.get("through")), - }) - bbox = health.get("bbox_mm") if isinstance(health.get("bbox_mm"), dict) else {} - return { - "revision_id": state.active_revision, - "available": bool(health or topology), - "solid_count": health.get("solid_count"), - "bbox_dimensions_mm": bbox.get("dimensions"), - "topology_snapshot_id": str(topology.get("snapshot_id") or ""), - "topology_counts": counts, - # These are compact measured facts, not author-controlled CDSL. - # They make duplicate or misplaced hole repairs observable without - # injecting the full topology snapshot into every author turn. - "inner_cylindrical_bores": inner_cylindrical_bores[:16], - } - - def _facts(self, task_id: str, revision_id: str) -> dict[str, Any]: - if not revision_id: - return {} - report = self.artifacts.read_json(task_id, f"revisions/{revision_id}/rebuild-report.json") or {} - return {"health": report.get("health") or {}, "topology": self.artifacts.read_topology(task_id, revision_id) or {}, "report": report} - - def _action_requires_through(self, task_id: str, requirement_ids: tuple[str, ...]) -> bool: - contract = self._requirements_contract(task_id) or {} - return any(claim.get("claim_kind") == "through_cylindrical_bore" for requirement in contract.get("requirements") or () if isinstance(requirement, dict) and requirement.get("requirement_id") in requirement_ids for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)) - - def _candidate_blockers(self, task_id: str, action_requirements: tuple[str, ...], results: list[dict[str, Any]]) -> list[dict[str, Any]]: - # Global connected-body failure always rejects; action-linked claims - # must not already be false, while future-work claims may remain pending. - return [item for item in results if item.get("status") in {"fail", "unavailable"} and (item.get("claim_kind") in {"solid_count_equals", "single_connected_body"} or item.get("claim_id", "") in self._claim_ids_for_requirements(task_id, action_requirements))] - - def _operation_candidate_results( - self, - action: PendingAction, - contract: dict[str, Any], - cdsl: dict[str, Any], - rebuilt: dict[str, Any], - *, - parent_facts: dict[str, Any], - require_through: bool, - ) -> list[dict[str, Any]]: - """Run the verified operation-level acceptance checks after rebuild. - - Requirement claims prove the user contract. These checks separately - prove that an operation declared in the runtime registry actually made - the kind of change it advertises, even when a user did not include a - matching claim. A blind-hole action does not require the through-bore - verifier unless its linked requirement explicitly requests through - topology. - """ - features = cdsl.get("features") if isinstance(cdsl.get("features"), list) else [] - feature = next( - (item for item in reversed(features) if isinstance(item, dict) and item.get("atomic_id") == action.atomic_id), - {}, - ) - params = feature.get("params") if isinstance(feature, dict) and isinstance(feature.get("params"), dict) else {} - claims: list[dict[str, Any]] = [] - for claim_kind in contract.get("candidate_verifiers") or (): - if claim_kind == "through_cylindrical_bore" and not require_through: - continue - expected: dict[str, Any] - if claim_kind in {"cylindrical_bore", "through_cylindrical_bore"}: - # A counterbore can reuse an already-existing pilot bore. In - # that case the material change is the larger cylindrical - # recess, not an additional instance of the pilot diameter. - # Measuring the pilot would count the parent bore as a new - # feature and make a valid counterbore checkpoint fail. - diameter = ( - params.get("counterbore_diameter_mm") - if action.atomic_id == "hole_counterbore" and claim_kind == "cylindrical_bore" - else params.get("diameter_mm") - ) - positions = params.get("positions") - if not isinstance(diameter, (int, float)): - return [{"claim_id": f"operation_{action.action_id}_{claim_kind}", "claim_kind": claim_kind, "deterministic": True, "status": "unavailable", "evidence": {"reason": "operation has no measurable bore diameter"}}] - increment = len(positions) if isinstance(positions, list) else 1 - prior_count = self._existing_bore_count(claim_kind, float(diameter), parent_facts) - expected = { - "diameter_mm": float(diameter), - # Candidate topology represents the full model, not only - # the feature just submitted. Therefore the operation - # proof must compare against the parent checkpoint plus - # this action's declared number of positions. - "count": prior_count + increment, - "tolerance_mm": 0.01, - } - elif claim_kind in {"single_connected_body", "volume_decreased"}: - expected = {} - else: - return [{"claim_id": f"operation_{action.action_id}_{claim_kind}", "claim_kind": str(claim_kind), "deterministic": True, "status": "unavailable", "evidence": {"reason": "operation verifier has no runtime expected-value binding"}}] - claims.append({"claim_id": f"operation_{action.action_id}_{claim_kind}", "claim_kind": claim_kind, "expected": expected}) - facts = { - "health": rebuilt.get("health") or {}, - "parent_health": parent_facts.get("health") or {}, - "topology": rebuilt.get("topology") or {}, - "report": rebuilt.get("report") or {}, - } - results = self.verifiers.evaluate(claims, facts) - for result, claim in zip(results, claims, strict=True): - expected = claim.get("expected") if isinstance(claim.get("expected"), dict) else {} - if claim.get("claim_kind") not in {"cylindrical_bore", "through_cylindrical_bore"}: - continue - evidence = result.get("evidence") if isinstance(result.get("evidence"), dict) else {} - evidence = dict(evidence) - requested_total = expected.get("count") - if isinstance(requested_total, int): - positions = params.get("positions") - increment = len(positions) if isinstance(positions, list) else 1 - evidence.update({ - "parent_matching_count": requested_total - increment, - "expected_increment": increment, - "expected_total_count": requested_total, - }) - result["evidence"] = evidence - return results - - def _existing_bore_count(self, claim_kind: str, diameter_mm: float, parent_facts: dict[str, Any]) -> int: - """Measure same-diameter bores in the parent with registry semantics. - - The registry owns topology coalescing, including periodic faces from a - single analytic circle. Asking it for one instance provides an exact - count for populated parent geometry without duplicating B-rep logic in - the workflow layer. - """ - topology = parent_facts.get("topology") if isinstance(parent_facts.get("topology"), dict) else {} - if not isinstance(topology.get("records"), list) or not topology["records"]: - return 0 - result = self.verifiers.evaluate([{ - "claim_id": "operation_parent_bore_count", - "claim_kind": claim_kind, - "expected": {"diameter_mm": diameter_mm, "count": 1, "tolerance_mm": 0.01}, - }], parent_facts)[0] - evidence = result.get("evidence") if isinstance(result.get("evidence"), dict) else {} - actual_count = evidence.get("actual_count") - if isinstance(actual_count, int): - return actual_count - matched = evidence.get("matched_cylindrical_faces") - if isinstance(matched, list): - return len(matched) - through = evidence.get("through_bores") - if isinstance(through, list): - return len(through) - return 0 - - def _claim_ids_for_requirements(self, task_id: str, requirement_ids: tuple[str, ...]) -> set[str]: - contract = self._requirements_contract(task_id) or {} - return { - str(claim.get("claim_id") or "") - for requirement in contract.get("requirements") or () - if isinstance(requirement, dict) and requirement.get("requirement_id") in requirement_ids - for claim in requirement.get("acceptance_claims") or () - if isinstance(claim, dict) - } - - def _next_revision(self, task_id: str) -> str: - """Allocate a monotonically increasing immutable revision ID. - - Rollback changes the active lineage but never reuses an artifact - directory. The ledger is the authority for already allocated IDs, - including a directory published just before an interrupted CAS. - """ - values = [ - int(str(event.get("revision_id") or "").removeprefix("rev_")) - for event in self.repository.ledger_events(task_id) - if event.get("event") in {"accepted", "feature_node_verified"} - and str(event.get("revision_id") or "").startswith("rev_") - and str(event.get("revision_id") or "").removeprefix("rev_").isdigit() - ] - return f"rev_{max(values, default=0) + 1:03d}" - - def _requirements_contract(self, task_id: str, state: TaskState | None = None) -> dict[str, Any] | None: - state = state or self.repository.get_state(task_id) - if state is None or not state.requirements_contract_path: - return None - return self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) - - @staticmethod - def _final_review_path(revision_id: str) -> str: - # Revisions are manifest-sealed when a candidate becomes a checkpoint. - # Final review evidence therefore has its own immutable namespace. - return f"reviews/final/{revision_id}/final-review.json" - - def _commit_invocation(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: dict[str, Any]) -> bool: - """Commit state/outbox and its idempotent result in one SQLite transaction.""" - return self.repository.compare_and_swap( - state, - events=events, - invocation_id=invocation.invocation_id, - invocation_result=result, - ) - - @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("utf-8")).hexdigest() - - @staticmethod - def _failure_class_fingerprint(active_revision: str, atomic_id: str, code: ErrorCode) -> str: - return sha256(f"{active_revision}|{atomic_id}|{code.value}".encode("utf-8")).hexdigest() - - def _failure_class_events(self, task_id: str, fingerprint: str) -> list[dict[str, Any]]: - return [ - event for event in self.repository.ledger_events(task_id) - if event.get("failure_class_fingerprint") == fingerprint - ] - - @staticmethod - def _stale() -> WorkflowError: - return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.") - - @staticmethod - def _runtime_error(error: Exception) -> WorkflowError: - message = str(error) - code = ( - ErrorCode.RUNTIME_PRECONDITION_FAILED - if "RUNTIME_PRECONDITION_FAILED" in message - else ErrorCode.RUNTIME_CONTRACT_INVALID - if "RUNTIME_CONTRACT_INVALID" in message or "CDSL schema violation" in message - else ErrorCode.RUNTIME_EXECUTION_FAILURE - if "RUNTIME_EXECUTION_FAILURE" in message - else ErrorCode.AUTHOR_FORMAT_INVALID - ) - return WorkflowError(code, message[:1000]) - - def _park_for_storage_retry(self, state: TaskState, *, event: str, message: str) -> Rejected: - """Persist a recoverable artifact failure without changing CAD evidence.""" - return self._park_retry(state, ErrorCode.STORAGE_FAILURE, event=event, message=message) - - def _park_retry( - self, - state: TaskState, - code: ErrorCode, - *, - event: str, - message: str, - details: dict[str, Any] | None = None, - ) -> Rejected: - waiting = transition(state, "waiting_retry", error=code) - payload = { - "event": event, - "code": code.value, - "message": message[:1000], - **(details or {}), - } - if not self.repository.compare_and_swap(waiting, events=[{ - **payload, - }]): - return Rejected(self._stale()) - return Rejected(WorkflowError( - code, - "Candidate render evidence is temporarily unavailable; the checkpoint is preserved." - if code == ErrorCode.RENDER_SERVICE_UNAVAILABLE - else "The CAD runtime failed after validation; retry will resume from the preserved checkpoint." - if code == ErrorCode.RUNTIME_EXECUTION_FAILURE - else "Candidate artifact storage is temporarily unavailable; the checkpoint is preserved.", - retryable=True, - )) diff --git a/backend/app/cad_agent/application/authoring_compiler.py b/backend/app/cad_agent/application/authoring_compiler.py new file mode 100644 index 00000000..6b2c36a5 --- /dev/null +++ b/backend/app/cad_agent/application/authoring_compiler.py @@ -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") diff --git a/backend/app/cad_agent/application/authoring_contract.py b/backend/app/cad_agent/application/authoring_contract.py new file mode 100644 index 00000000..677736ef --- /dev/null +++ b/backend/app/cad_agent/application/authoring_contract.py @@ -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 ..", + ) + 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 .") + 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}]") diff --git a/backend/app/cad_agent/application/authoring_guidance.py b/backend/app/cad_agent/application/authoring_guidance.py new file mode 100644 index 00000000..6eb49eb4 --- /dev/null +++ b/backend/app/cad_agent/application/authoring_guidance.py @@ -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." diff --git a/backend/app/cad_agent/application/capabilities.py b/backend/app/cad_agent/application/capabilities.py index 509be1fa..2956dfbb 100644 --- a/backend/app/cad_agent/application/capabilities.py +++ b/backend/app/cad_agent/application/capabilities.py @@ -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() diff --git a/backend/app/cad_agent/application/llm_contracts.py b/backend/app/cad_agent/application/llm_contracts.py deleted file mode 100644 index b19b727d..00000000 --- a/backend/app/cad_agent/application/llm_contracts.py +++ /dev/null @@ -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 diff --git a/backend/app/cad_agent/application/requirements.py b/backend/app/cad_agent/application/requirements.py deleted file mode 100644 index a7dba993..00000000 --- a/backend/app/cad_agent/application/requirements.py +++ /dev/null @@ -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)) diff --git a/backend/app/cad_agent/application/results.py b/backend/app/cad_agent/application/results.py deleted file mode 100644 index 7a793f69..00000000 --- a/backend/app/cad_agent/application/results.py +++ /dev/null @@ -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 diff --git a/backend/app/cad_agent/application/single_stage.py b/backend/app/cad_agent/application/single_stage.py new file mode 100644 index 00000000..e81e1d1c --- /dev/null +++ b/backend/app/cad_agent/application/single_stage.py @@ -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 diff --git a/backend/app/cad_agent/application/workflow.py b/backend/app/cad_agent/application/workflow.py index 73e9839b..b081fc59 100644 --- a/backend/app/cad_agent/application/workflow.py +++ b/backend/app/cad_agent/application/workflow.py @@ -1,53 +1,26 @@ -"""LLM turn coordinator for protocol v3. +"""Single-stage coordinator for Authoring CDSL generation. -It selects only the structured schema visible from persisted state. Feature -selection is server-owned: the scheduler chooses one atomic DAG node and the -author can submit only that node's fragment. +Each task makes one request-analysis call and one full Authoring CDSL call. +Only structured authoring/compile/runtime failures can request a replacement, +and the replacement budget is fixed at two. """ - from __future__ import annotations import json import secrets -from hashlib import sha256 from dataclasses import dataclass -from typing import Any, AsyncIterator, TypeVar +from hashlib import sha256 +from typing import Any, AsyncIterator -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field -from app.cad_agent.application.action_handlers import ActionCommandHandler -from app.cad_agent.application.llm_contracts import ( - CandidateReview, CompiledRequirementsSpec, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, MarkdownDocument, NextAction, - RollbackCheckpoint, StatelessCandidateReview, - StatelessFinalReview, StatelessGeometryConclusion, StatelessNextAction, StatelessRollbackCheckpoint, - StatelessTopologyRequest, TopologyRequest, - canonical_json_object, canonical_validate, canonical_validate_schema, - sanitize_compiled_requirements_arguments, - stateless_final_review_schema, - stateless_next_action_schema, stateless_rollback_checkpoint_schema, - raw_arguments_hash, validate_one_tool_call, -) -from app.cad_agent.application.requirements import RequirementsCommandHandler -from app.cad_agent.application.results import Accepted, Rejected, Waiting -from app.cad_agent.domain.feature_plan import FeaturePlan, plan_hash -from app.cad_agent.domain.errors import ErrorCode, WorkflowError -from app.cad_agent.domain.operation_contract import fragment_schema -from app.cad_agent.domain.state import TaskPhase, TaskState, retry_resume_event, transition -from app.cad_agent.ports import ( - AdapterUnavailable, - ArtifactStore, - AuthorGuidance, - AuthorGuidanceSelection, - CadRuntime, - ModelGateway, - NullAuthorGuidance, - ReviewGateway, - TaskRepository, -) - - -T = TypeVar("T", bound=BaseModel) -_FEATURE_REPLAN_FAILURE_LIMIT = 3 +from app.cad_agent.application.authoring_compiler import AuthoringCompileError +from app.cad_agent.application.authoring_contract import AuthoringDocument, validation_error_code +from app.cad_agent.application.authoring_guidance import load_authoring_guidance +from app.cad_agent.application.single_stage import SingleStageExecutor +from app.cad_agent.domain.errors import ErrorCode +from app.cad_agent.domain.state import TaskPhase, TaskState, transition +from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, ModelGateway, TaskRepository @dataclass(frozen=True, slots=True) @@ -58,2264 +31,487 @@ class ModelIdentity: @dataclass(frozen=True, slots=True) class WorkflowConfig: - max_turns: int - format_error_limit: int - author_fallbacks: tuple[ModelIdentity, ...] = () - max_author_turns: int | None = None - max_reviewer_turns: int | None = None - max_model_calls: int | None = None + max_repairs: int = 2 -@dataclass(slots=True) -class _ModelCallBudget: - """Task-scoped model-call caps, including calls from a prior resume.""" +class _RequirementsTarget(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + kind: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$") + expected: dict[str, Any] = Field(default_factory=dict) + verification: str = Field(default="manual", pattern=r"^(deterministic|manual)$") - max_author_turns: int | None - max_reviewer_turns: int | None - max_model_calls: int | None - author_calls: int = 0 - reviewer_calls: int = 0 - @classmethod - def from_usage(cls, config: WorkflowConfig, records: list[dict[str, Any]]) -> "_ModelCallBudget": - reviewer_calls = sum(1 for record in records if record.get("role") == "reviewer") - return cls( - max_author_turns=config.max_author_turns, - max_reviewer_turns=config.max_reviewer_turns, - max_model_calls=config.max_model_calls, - author_calls=len(records) - reviewer_calls, - reviewer_calls=reviewer_calls, - ) - - @property - def total_calls(self) -> int: - return self.author_calls + self.reviewer_calls - - def exhausted(self, actor: str) -> bool: - return ( - (actor == "author" and self.max_author_turns is not None and self.author_calls >= self.max_author_turns) - or (actor == "reviewer" and self.max_reviewer_turns is not None and self.reviewer_calls >= self.max_reviewer_turns) - or (self.max_model_calls is not None and self.total_calls >= self.max_model_calls) - ) - - def record_attempt(self, actor: str) -> None: - if actor == "reviewer": - self.reviewer_calls += 1 - else: - self.author_calls += 1 - - def payload(self) -> dict[str, int | None]: - return { - "author_calls": self.author_calls, - "reviewer_calls": self.reviewer_calls, - "total_calls": self.total_calls, - "max_author_turns": self.max_author_turns, - "max_reviewer_turns": self.max_reviewer_turns, - "max_model_calls": self.max_model_calls, - } +class RequirementsAnalysis(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + schema_version: str = Field(default="cad.requirements.v1", pattern=r"^cad\.requirements\.v1$") + explicit_requirements: list[str] = Field(min_length=1, max_length=64) + assumptions: list[str] = Field(default_factory=list, max_length=64) + acceptance_targets: list[_RequirementsTarget] = Field(default_factory=list, max_length=128) + manual_targets: list[str] = Field(default_factory=list, max_length=64) + clarification_question: str | None = Field(default=None, min_length=1, max_length=500) class WorkflowCoordinator: - def __init__( - self, - config: WorkflowConfig, - repository: TaskRepository, - artifacts: ArtifactStore, - runtime: CadRuntime, - model_gateway: ModelGateway, - review_gateway: ReviewGateway, - requirements: RequirementsCommandHandler, - actions: ActionCommandHandler, - author_guidance: AuthorGuidance | None = None, - ) -> None: + def __init__(self, config: WorkflowConfig, repository: TaskRepository, artifacts: ArtifactStore, runtime: CadRuntime, model_gateway: ModelGateway, executor: SingleStageExecutor) -> None: self.config = config self.repository = repository self.artifacts = artifacts self.runtime = runtime self.model_gateway = model_gateway - self.review_gateway = review_gateway - self.requirements = requirements - self.actions = actions - self.author_guidance = author_guidance or NullAuthorGuidance() + self.executor = executor - def create_task( - self, - task_id: str, - request: str, - *, - source_blocks: list[dict[str, Any]] | None = None, - image_inputs: list[dict[str, str]] | None = None, - ) -> TaskState: - # The immutable source artifact is safe to create before SQLite state: - # an interrupted creation leaves only an unreferenced directory, never - # a runnable task without its source index. + def create_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> TaskState: self.artifacts.initialize_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs) return self.repository.create_task(task_id, request) def resume(self, task_id: str) -> bool: state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.WAITING_RETRY: + if state is None or state.phase != TaskPhase.FAILED or state.retry_from_phase is None: return False - event = retry_resume_event(state) - if not event: - return False - next_state = transition(state, event) - return self.repository.compare_and_swap(next_state, events=[ - { - "event": "workflow_resumed", - "from_phase": state.phase.value, - "retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "", - "to_phase": next_state.phase.value, - }, - ]) + resumed = transition(state, "resume") + return self.repository.compare_and_swap(resumed, events=[{"event": "task_resumed", "from_phase": state.retry_from_phase.value}]) def resume_with_user_clarification(self, task_id: str, clarification: str, *, message_id: str) -> bool: - """Resume a requirements pause on the same task with durable input.""" state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.WAITING_FOR_USER: + if state is None or state.phase != TaskPhase.WAITING_FOR_USER or not clarification.strip(): return False - clarification_request = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None - if not isinstance(clarification_request, dict) or not str(clarification_request.get("question") or "").strip(): - return False - text = clarification.strip() - if not text: - return False - digest = sha256(f"{message_id}:{text}".encode("utf-8")).hexdigest()[:16] - clarification_path = f"documents/user-clarification-{digest}.json" - try: - self.artifacts.write_json_once(task_id, clarification_path, { - "schema_version": "cad.user-clarification.v1", - "task_id": task_id, - "message_id": message_id, - "text": text, - }) - except OSError: - return False - resumed = transition(state, "requirements_clarified", clarification_path="") - return self.repository.compare_and_swap(resumed, events=[{ - "event": "user_clarification_received", - "message_id": message_id, - "clarification_path": clarification_path, - "response_sha256": sha256(text.encode("utf-8")).hexdigest(), - }]) + digest = sha256(f"{message_id}:{clarification}".encode()).hexdigest()[:16] + path = self.artifacts.write_json_once(task_id, f"documents/user-clarification-{digest}.json", {"schema_version": "cad.user-clarification.v1", "message_id": message_id, "text": clarification.strip()}) + resumed = transition(state, "clarification_received", clarification_path=path) + return self.repository.compare_and_swap(resumed, events=[{"event": "clarification_received", "clarification_path": path}]) - async def run(self, *, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> AsyncIterator[tuple[str, dict[str, Any]]]: - feedback: list[dict[str, Any]] = [] - format_errors: dict[str, int] = {} - transport_attempted: set[str] = set() - # Observations are request-scoped, read-only facts. A restarted run - # deliberately has to fetch them again before authoring a selector- - # bound feature. - action_observations: dict[str, set[str]] = {} - active_author = author - max_turns = self.config.max_turns - call_budget = _ModelCallBudget.from_usage( - self.config, - self.repository.usage_summary(task_id).get("records", []), - ) - try: - initial = self.repository.get_state(task_id) - if initial is not None: - referenced = {initial.candidate_stage_id} if initial.candidate_stage_id else set() - # Candidate rejection/build-failure evidence is itself an - # immutable repair input. Once the state transition clears - # ``candidate_stage_id``, retain that stage through restart - # based on its committed ledger reference. - referenced.update( - str(event["stage_id"]) - for event in self.repository.ledger_events(task_id) - if event.get("event") in { - "candidate_rejected", "candidate_build_failed", "candidate_recovery_failed", "candidate_recovered_rejected", - } - and isinstance(event.get("stage_id"), str) - and event["stage_id"] - ) - try: - self.artifacts.recover_staged_candidates(task_id, referenced) - except Exception as error: - yield self._storage_failure(task_id, str(error)) - return - for _turn in range(max_turns): - try: - self._sync_action_ledger(task_id) - except Exception as error: - yield self._storage_failure(task_id, str(error)) - return - state = self.repository.get_state(task_id) - if state is None: - yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "V3 task state is unavailable."} - return - if state.phase == TaskPhase.COMPLETED: - yield "task_terminal", self._projected_terminal(task_id, state) - return - if state.phase == TaskPhase.CANCELLED: - yield "task_terminal", { - "taskId": task_id, - "lifecycle": "cancelled", - "revisionId": state.active_revision, - "code": ErrorCode.CANCELLED.value, - } - return + def waiting_for_user_terminal(self, task_id: str, state: TaskState | None = None) -> dict[str, Any]: + state = state or self.repository.get_state(task_id) + details = self.artifacts.read_json(task_id, state.clarification_path) if state and state.clarification_path else {} + question = str((details or {}).get("question") or "CAD generation needs clarification.") + return {"taskId": task_id, "lifecycle": "waiting_for_user", "message": question, "questions": [question], "userActionRequired": True} + + async def run(self, *, task_id: str, author: ModelIdentity) -> AsyncIterator[tuple[str, dict[str, Any]]]: + while True: + state = self.repository.get_state(task_id) + if state is None: + return + if state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: + yield "task_terminal", self._terminal(state) + return + try: + self.artifacts.sync_event_ledger(task_id, self.repository.ledger_events(task_id)) if state.phase == TaskPhase.WAITING_FOR_USER: yield "task_terminal", self.waiting_for_user_terminal(task_id, state) return - if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY}: - yield "task_terminal", self._projected_terminal(task_id, state) - return - if state.phase == TaskPhase.FEATURE_BUILDING: - recovered = self.actions.recover_feature_build(task_id) - yield "feature_result", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True} - if isinstance(recovered, Rejected): - yield self._service_failure(task_id, state, recovered.error) - return + if state.phase == TaskPhase.ANALYZING_REQUEST: + result = await self._analyze(task_id, state, author) + yield result continue - if state.phase == TaskPhase.CANDIDATE_BUILDING: - recovered = self.actions.recover_candidate_build(task_id) - yield "candidate_result", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True} - if isinstance(recovered, Rejected): - yield self._service_failure(task_id, state, recovered.error) - return + if state.phase == TaskPhase.AUTHORING_CDSL: + result = await self._author(task_id, state, author) + yield result continue - if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT: - image_paths = self.artifacts.source_image_paths(task_id) - if image_paths and self.artifacts.read_json(task_id, "documents/image-observation.json") is None: - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer") - if terminal: - yield terminal - return - call_budget.record_attempt("reviewer") - observation = await self._observe_images(task_id, reviewer, image_paths) - if isinstance(observation, WorkflowError): - if observation.code == ErrorCode.AUTHOR_FORMAT_INVALID: - observation = WorkflowError( - ErrorCode.REVIEW_SERVICE_UNAVAILABLE, - "Image observation did not return the required structured format.", - field_errors=observation.field_errors, - retryable=True, - ) - yield self._service_failure(task_id, state, observation) - return - try: - self.artifacts.write_json_once(task_id, "documents/image-observation.json", { - "schema_version": "cad.image-observation.v3", - **observation.model_dump(mode="json"), - }) - except OSError as error: - yield self._storage_failure(task_id, str(error)) - return - observed_state = transition(state, "image_observed") - self.repository.compare_and_swap(observed_state, events=[{ - "event": "image_observation_ready", - "path": "documents/image-observation.json", - "image_count": len(image_paths), - }]) - yield "image_observation", {"taskId": task_id, "status": "success", "path": "documents/image-observation.json"} - continue - document_schema = self.requirements.document_schema() - tools = [self._tool("write_requirements_document", document_schema)] - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author") - if terminal: - yield terminal - return - call_budget.record_attempt("author") - result = await self._author_turn(task_id, active_author, tools, feedback) - if isinstance(result, WorkflowError): - if result.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()} - if terminal: - yield terminal - return - continue - active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted) - if terminal: - yield terminal - return - continue - name, raw, usage = result - validation = canonical_validate(raw, MarkdownDocument) - dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - invocation_id = self._invocation_id(task_id) - command = self.requirements.submit_requirements_document(task_id, validation, invocation_id=invocation_id) - if isinstance(command, Rejected): - terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback) - yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - event_payload = self._event(task_id, name, self._result_payload(command), "success", usage) - event_payload["markdown"] = validation.markdown - yield "requirements_document_ready", event_payload - feedback = [] + if state.phase == TaskPhase.COMPILING_CDSL: + result = self._compile(task_id, state) + yield result continue - if state.phase in {TaskPhase.DRAFTING_COMPLETION_TARGET, TaskPhase.DRAFTING_MODELING_PLAN}: - document_schema = self.requirements.document_schema() - tool_name = "write_completion_target" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "write_modeling_plan" - tools = [self._tool(tool_name, document_schema)] - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author") - if terminal: - yield terminal - return - call_budget.record_attempt("author") - result = await self._author_turn(task_id, active_author, tools, feedback) - if isinstance(result, WorkflowError): - if result.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()} - if terminal: - yield terminal - return - continue - active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted) - if terminal: - yield terminal - return - continue - name, raw, usage = result - validation = canonical_validate(raw, MarkdownDocument) - dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool=tool_name) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.requirements.submit_completion_target(task_id, validation, invocation_id=self._invocation_id(task_id)) if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else self.requirements.submit_modeling_plan(task_id, validation, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool=tool_name) - yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - event_payload = self._event(task_id, name, self._result_payload(command), "success", usage) - event_payload["markdown"] = validation.markdown - yield ("completion_target_ready" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "modeling_plan_ready"), event_payload - feedback = [] + if state.phase == TaskPhase.BUILDING: + result = self._build(task_id, state) + yield result continue - if state.phase == TaskPhase.COMPILING_REQUIREMENTS: - compiler_schema = self.requirements.compiler_schema(task_id) - tools = [self._tool("compile_requirements_spec", compiler_schema)] - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author") - if terminal: - yield terminal - return - call_budget.record_attempt("author") - result = await self._author_turn(task_id, active_author, tools, feedback) - if isinstance(result, WorkflowError): - if result.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()} - if terminal: - yield terminal - return - continue - active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted) - if terminal: - yield terminal - return + if state.phase == TaskPhase.REPAIRING: + started = transition(state, "repair_started", repair_count=state.repair_count + 1) + if not self.repository.compare_and_swap(started, events=[{"event": "repair_started", "repair_count": started.repair_count}]): continue - name, raw, usage = result - sanitized_raw = sanitize_compiled_requirements_arguments(raw) - if isinstance(sanitized_raw, WorkflowError): - validation: CompiledRequirementsSpec | WorkflowError = sanitized_raw - else: - validation = canonical_validate(sanitized_raw, CompiledRequirementsSpec) - dynamic_error = canonical_validate_schema(sanitized_raw, compiler_schema) if not isinstance(validation, WorkflowError) and isinstance(sanitized_raw, str) else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool="compile_requirements_spec") - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.requirements.submit_compiled_spec(task_id, validation, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool="compile_requirements_spec") - yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "requirements_compiled", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] + yield "repair_started", {"taskId": task_id, "repairCount": started.repair_count, "repairBudget": self.config.max_repairs} continue - if state.phase in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}: - exhausted = self._feature_replan_exhausted(task_id, state) - if exhausted is not None: - failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT) - self.repository.compare_and_swap(failed, events=[{ - "event": "feature_plan_no_progress_limit", - "code": ErrorCode.NO_PROGRESS_LIMIT.value, - "message": "The same atomic feature exhausted its cross-plan replan budget.", - "checkpoint_preserved": bool(state.active_revision), - "revision_id": state.active_revision, - **exhausted, - }]) - yield "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "revisionId": state.active_revision, - "code": ErrorCode.NO_PROGRESS_LIMIT.value, - "message": "The same atomic feature repeatedly failed after local replanning; the last executable checkpoint remains available.", - } - return - plan_schema = self.requirements.feature_plan_schema(task_id) - tools = [self._tool("write_feature_plan", plan_schema)] - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author") - if terminal: - yield terminal - return - call_budget.record_attempt("author") - result = await self._author_turn(task_id, active_author, tools, feedback) - if isinstance(result, WorkflowError): - if result.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "write_feature_plan", result, format_errors, feedback) - yield "feature_plan", {"taskId": task_id, "status": "error", "result": result.payload()} - if terminal: - yield terminal - return - continue - active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted) - if terminal: - yield terminal - return - continue - name, raw, usage = result - validation = canonical_validate(raw, FeaturePlan) - dynamic_error = canonical_validate_schema(raw, plan_schema) if not isinstance(validation, WorkflowError) else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "feature_plan", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.requirements.submit_feature_plan(task_id, validation, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool="write_feature_plan") - yield "feature_plan", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "feature_plan_ready", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] + if state.phase == TaskPhase.PUBLISHING_BEST_EFFORT: + result = self._publish(task_id, state) + yield result continue - if state.phase == TaskPhase.SCHEDULING_FEATURE: - command = self.actions.schedule_next_feature(task_id) - if isinstance(command, Rejected): - yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": command.error.code.value, "message": command.error.message} - return - yield "feature_scheduled", {"taskId": task_id, "status": "success", "result": self._result_payload(command)} - continue - if state.phase == TaskPhase.FEATURE_PENDING: - observed = action_observations.setdefault(state.working_head, set()) - tools = self._action_tools(task_id, state, observed) - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author") - if terminal: - yield terminal - return - call_budget.record_attempt("author") - result = await self._author_turn(task_id, active_author, tools, feedback) - if isinstance(result, WorkflowError): - if result.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback) - yield "feature_result", {"taskId": task_id, "status": "error", "result": result.payload()} - if terminal: - yield terminal - return - continue - active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted) - if terminal: - yield terminal - return - continue - name, raw, usage = result - if name == "inspect_topology": - validation = canonical_validate(raw, StatelessTopologyRequest) - if isinstance(validation, WorkflowError): - yield "feature_result", self._event(task_id, name, validation.payload(), "error", usage) - continue - payload = self._topology_payload(task_id, state, validation.kind, validation.limit) - observed.add("topology") - yield "tool_call", self._event(task_id, name, payload, "success", usage) - feedback = [{"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}] - continue - fragment = canonical_json_object(raw) - if isinstance(fragment, WorkflowError): - yield "feature_result", self._event(task_id, name, fragment.payload(), "error", usage) - feedback = [self._feedback(fragment)] - continue - command = self.actions.submit_feature_fragment(task_id, fragment, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - after = self.repository.get_state(task_id) - yield "feature_result", self._event(task_id, name, command.error.payload(), "error", usage) - if after is not None and after.version != state.version: - feedback = [self._feedback(command.error)] - continue - terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) - if terminal: - yield terminal - return - continue - yield "feature_result", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] - continue - if state.phase == TaskPhase.AWAITING_ACTION: - contract = self._requirements_contract(task_id, state) or {} - requirement_ids = [str(item.get("requirement_id") or "") for item in contract.get("requirements") or () if isinstance(item, dict) and item.get("requirement_id")] - action_schema = stateless_next_action_schema(list(self.actions.available_atomic_ids(task_id, state))) - tools = self._recovery_tools(task_id, state) - if not tools: - if self._can_complete(task_id, state): - tools = [self._tool("complete_task", EmptyCommand)] - elif self.actions.repair_action_ready(task_id, state): - tools = [self._tool("propose_next_action", action_schema)] - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author") - if terminal: - yield terminal - return - call_budget.record_attempt("author") - result = await self._author_turn(task_id, active_author, tools, feedback) - if isinstance(result, WorkflowError): - if result.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()} - if terminal: - yield terminal - return - continue - active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted) - if terminal: - yield terminal - return - continue - name, raw, usage = result - if name == "complete_task": - validation = canonical_validate(raw, EmptyCommand) - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.actions.complete_task(task_id, invocation_id=self._invocation_id(task_id)) - elif name == "record_geometry_conclusion": - validation = canonical_validate(raw, StatelessGeometryConclusion) - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - validation = GeometryConclusion( - working_head=state.working_head, - evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)), - root_cause=validation.root_cause, - decision=validation.decision, - corrective_intent=validation.corrective_intent, - ) - command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id)) - elif name == "rollback_checkpoint": - rollback_schema = stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state))) - validation = canonical_validate(raw, StatelessRollbackCheckpoint) - dynamic_error = canonical_validate_schema(raw, rollback_schema) if not isinstance(validation, WorkflowError) else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - validation = RollbackCheckpoint(working_head=state.working_head, checkpoint_token=validation.checkpoint_token, reason=validation.reason) - command = self.actions.rollback_checkpoint(task_id, validation, invocation_id=self._invocation_id(task_id)) - else: - validation = canonical_validate(raw, StatelessNextAction) - dynamic_error = canonical_validate_schema(raw, action_schema) if not isinstance(validation, WorkflowError) else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - validation = NextAction( - working_head=state.working_head, - intent=validation.intent, - requirement_ids=requirement_ids, - atomic_id=validation.operation, - expected_change=validation.expected_change, - ) - command = self.actions.propose_next_action(task_id, validation, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) - yield "action_selection", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "action_selection", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] - continue - if state.phase == TaskPhase.ACTION_PENDING: - observed = action_observations.setdefault(state.working_head, set()) - tools = self._recovery_tools(task_id, state) - if not tools and self.actions.repair_action_ready(task_id, state): - tools = self._action_tools(task_id, state, observed) - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author") - if terminal: - yield terminal - return - call_budget.record_attempt("author") - result = await self._author_turn(task_id, active_author, tools, feedback) - if isinstance(result, WorkflowError): - if result.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()} - if terminal: - yield terminal - return - continue - active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted) - if terminal: - yield terminal - return - continue - name, raw, usage = result - if name == "record_geometry_conclusion": - validation = canonical_validate(raw, StatelessGeometryConclusion) - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - validation = GeometryConclusion( - working_head=state.working_head, - evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)), - root_cause=validation.root_cause, - decision=validation.decision, - corrective_intent=validation.corrective_intent, - ) - command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) - yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "tool_call", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] - continue - if name == "inspect_topology": - validation = canonical_validate(raw, StatelessTopologyRequest) - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - payload = self._topology_payload(task_id, state, validation.kind, validation.limit) - observed.add("topology") - yield "tool_call", self._event(task_id, name, payload, "success", usage) - feedback = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:] - continue - fragment = canonical_json_object(raw) - if isinstance(fragment, WorkflowError): - validation_error = fragment - terminal = self._format_failure(task_id, state, name, validation_error, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation_error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.actions.submit_cdsl_fragment(task_id, fragment, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - # A candidate build may reject and advance back to - # ACTION_PENDING. That is repair work, not a no-side- - # effect schema/state retry. - after = self.repository.get_state(task_id) - if after is not None and after.version != state.version: - feedback = [self._feedback(command.error)] - yield "candidate_result", self._event(task_id, name, command.error.payload(), "error", usage) - continue - terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) - yield "candidate_result", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "candidate_result", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] - continue - if state.phase == TaskPhase.CANDIDATE_REVIEW: - recovered = self.actions.recover_candidate_review(task_id) - if recovered is not None: - yield "candidate_review", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True} - if isinstance(recovered, Rejected): - yield self._service_failure(task_id, state, recovered.error) - return - continue - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer") - if terminal: - yield terminal - return - call_budget.record_attempt("reviewer") - review = await self._review_candidate(task_id, reviewer, state, feedback) - if isinstance(review, WorkflowError): - if review.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "review_candidate", review, format_errors, feedback, actor="reviewer") - yield "candidate_review", {"taskId": task_id, "status": "error", "result": review.payload()} - if terminal: - yield terminal - return - continue - yield self._service_failure(task_id, state, review) - return - candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") or {} - action = state.pending_action - if action is None: - yield self._storage_failure(task_id, "Candidate action is unavailable during review.") - return - coverage = [] - for item in candidate.get("claim_results") or (): - if not isinstance(item, dict): - continue - status = str(item.get("status") or "pending") - coverage.append({ - "claim_id": str(item.get("claim_id") or ""), - "status": status if status in {"pass", "pending", "fail", "not_applicable"} else "fail", - "evidence_refs": [], - }) - review = CandidateReview( - candidate_id=state.candidate_id, - working_head=action.working_head, - verdict=review.verdict, - claim_coverage=coverage, - evidence=review.evidence, - issues=review.issues, - ) - command = self.actions.record_candidate_review(task_id, review, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, "review_candidate", command.error, format_errors, feedback, actor="reviewer") - yield "candidate_review", {"taskId": task_id, "status": "error", "result": command.error.payload()} - if terminal: - yield terminal - return - continue - yield "candidate_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)} - continue - if state.phase == TaskPhase.FINAL_VALIDATION: - facts = self.actions._facts(task_id, state.active_revision) - if not (facts.get("report") or {}).get("render_manifest"): - try: - self.runtime.render_review_bundle(str(self.artifacts.artifact_path(task_id, f"revisions/{state.active_revision}"))) - except Exception as error: - yield self._service_failure(task_id, state, WorkflowError(ErrorCode.RENDER_SERVICE_UNAVAILABLE, str(error)[:1000], retryable=True)) - return - yield "final_render_ready", {"taskId": task_id, "revisionId": state.active_revision, "status": "success"} - recovered = self.actions.recover_final_review(task_id) - if recovered is not None: - if isinstance(recovered, Accepted) and recovered.payload.get("status") == "completed": - completed_state = self.repository.get_state(task_id) - if completed_state is not None: - try: - self._ensure_recovered_completion_result(task_id, completed_state) - except OSError as error: - yield self._storage_failure(task_id, str(error)) - return - yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"} - yield "final_review", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True} - if isinstance(recovered, Rejected): - yield self._service_failure(task_id, state, recovered.error) - return - continue - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer") - if terminal: - yield terminal - return - call_budget.record_attempt("reviewer") - review = await self._review_final(task_id, reviewer, state, feedback) - if isinstance(review, WorkflowError): - if review.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "review_final", review, format_errors, feedback, actor="reviewer") - yield "final_review", {"taskId": task_id, "status": "error", "result": review.payload()} - if terminal: - yield terminal - return - continue - yield self._service_failure(task_id, state, review) - return - facts = self.actions._facts(task_id, state.active_revision) - claim_results = self.actions._evaluate_claims(task_id, facts) - visual_decisions = iter(review.visual_claims) - coverage = [] - for item in claim_results: - if item.get("deterministic"): - status = str(item.get("status") or "fail") - status = status if status in {"pass", "pending", "fail", "not_applicable"} else "fail" - else: - status = next(visual_decisions).status - coverage.append({"claim_id": str(item.get("claim_id") or ""), "status": status, "evidence_refs": []}) - stateless_review = review - review = FinalReview( - working_head=state.working_head, - verdict=review.verdict, - claim_coverage=coverage, - evidence=review.evidence, - issues=review.issues, - ) - will_complete = ( - stateless_review.verdict == "pass" - and all(item.get("status") == "pass" for item in claim_results if item.get("deterministic")) - and all(item.status == "pass" for item in stateless_review.visual_claims) - ) - if will_complete: - try: - self.requirements.write_completion_result( - task_id, - state, - claim_results=claim_results, - review=stateless_review.model_dump(mode="json"), - ) - except OSError as error: - yield self._storage_failure(task_id, str(error)) - return - command = self.actions.record_final_review(task_id, review, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, "review_final", command.error, format_errors, feedback, actor="reviewer") - yield "final_review", {"taskId": task_id, "status": "error", "result": command.error.payload()} - if terminal: - yield terminal - return - continue - if isinstance(command, Accepted) and command.payload.get("status") == "completed": - yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"} - yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)} - continue - state = self.repository.get_state(task_id) - if state is not None and state.feature_plan_hash: - failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT) - self.repository.compare_and_swap(failed, events=[{ - "event": "feature_plan_no_progress_limit", "code": ErrorCode.NO_PROGRESS_LIMIT.value, - "message": "The feature DAG reached its bounded turn limit.", - "checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision, - }]) - yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision, "code": ErrorCode.NO_PROGRESS_LIMIT.value, "message": "The feature DAG reached its bounded turn limit; the last executable checkpoint remains available."} return - terminal = self._best_effort_terminal(task_id, state, ErrorCode.NO_PROGRESS_LIMIT, "The workflow reached its bounded turn limit.") if state else None - if terminal: - yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"} - yield terminal - return - if state: - failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL) - self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": "Workflow exceeded its finite turn limit."}]) - yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Workflow exceeded its finite turn limit."} - except OSError as error: - yield self._storage_failure(task_id, str(error)) - except Exception as error: - state = self.repository.get_state(task_id) - if state is not None and state.feature_plan_hash and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: - failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL) - self.repository.compare_and_swap(failed, events=[{ - "event": "feature_plan_internal_failure", "message": str(error)[:1000], - "checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision, - }]) - yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision, "code": ErrorCode.FAILED_INTERNAL.value, "message": str(error)[:1000]} - return - terminal = self._best_effort_terminal(task_id, state, ErrorCode.FAILED_INTERNAL, str(error)[:1000]) if state else None - if terminal: - yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"} - yield terminal - return - if state and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: - failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL) - self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": str(error)[:1000]}]) - yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": str(error)[:1000]} - finally: - # The database ledger is authoritative. A failed mirror write is - # retried by the next active workflow pass; do not replace a - # completed/cancelled durable result with a filesystem exception. - try: - self._sync_action_ledger(task_id) - except Exception: - pass + except AdapterUnavailable as error: + self._fail(task_id, state, ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error), retryable=True) + except OSError as error: + self._fail(task_id, state, ErrorCode.STORAGE_FAILURE, str(error), retryable=True) + except Exception as error: # A coordinator failure must have a durable terminal record. + self._fail(task_id, state, ErrorCode.FAILED_INTERNAL, str(error), retryable=False) - def _sync_action_ledger(self, task_id: str) -> None: - self.artifacts.sync_action_ledger(task_id, self.repository.ledger_events(task_id)) - - def _call_budget_terminal( - self, - task_id: str, - state: TaskState, - budget: _ModelCallBudget, - *, - actor: str, - ) -> tuple[str, dict[str, Any]] | None: - if not budget.exhausted(actor): - return None - details = budget.payload() - details["next_actor"] = actor - if state.feature_plan_hash: - failed = transition(state, "failed", error=ErrorCode.CALL_BUDGET_EXHAUSTED) - self.repository.compare_and_swap(failed, events=[{ - "event": "call_budget_exhausted", "code": ErrorCode.CALL_BUDGET_EXHAUSTED.value, - "message": "Configured model-call budget is exhausted before the feature DAG converged.", - "checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision, **details, - }]) - return "task_terminal", { - "taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision, - "code": ErrorCode.CALL_BUDGET_EXHAUSTED.value, - "message": "Configured model-call budget is exhausted before the feature DAG converged; the last executable checkpoint remains available.", - "budget": details, "blockerType": "call_budget_exhausted", "userActionRequired": False, - } - terminal = self._best_effort_terminal( - task_id, - state, - ErrorCode.CALL_BUDGET_EXHAUSTED, - "Configured model-call budget is exhausted; publishing the last executable checkpoint.", - budget=details, - ) - if terminal: - return terminal - failed = transition(state, "failed", error=ErrorCode.CALL_BUDGET_EXHAUSTED) - self.repository.compare_and_swap(failed, events=[{ - "event": "call_budget_exhausted", - "code": ErrorCode.CALL_BUDGET_EXHAUSTED.value, - "message": "Configured model-call budget is exhausted before the workflow converged.", - **details, - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": ErrorCode.CALL_BUDGET_EXHAUSTED.value, - "message": "Configured model-call budget is exhausted before the workflow converged.", - "budget": details, - "blockerType": "call_budget_exhausted", - "userActionRequired": False, - } - - def _best_effort_terminal( - self, - task_id: str, - state: TaskState, - reason: ErrorCode, - message: str, - *, - budget: dict[str, Any] | None = None, - ) -> tuple[str, dict[str, Any]] | None: - if not state.active_revision or state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: - return None - claim_results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision)) - visual_claims = [ - {"status": "not_reviewed", "evidence": "Final review was not reached before bounded completion."} - for item in claim_results - if not item.get("deterministic") - ] + async def _analyze(self, task_id: str, state: TaskState, author: ModelIdentity) -> tuple[str, dict[str, Any]]: + if state.requirements_path: + next_state = transition(state, "analysis_written") + self.repository.compare_and_swap(next_state, events=[{"event": "requirements_reused", "path": state.requirements_path}]) + return "requirements_ready", {"taskId": task_id, "path": state.requirements_path, "reused": True} + source = self.artifacts.read_source_requirements(task_id) try: - self.requirements.write_completion_result( + analysis = RequirementsAnalysis.model_validate(await self._tool_call( + task_id, author, "analyze_requirements", RequirementsAnalysis.model_json_schema(), + "Extract explicit CAD requirements, safe assumptions, and verification targets. Do not invent strict dimensions that the request did not specify.", + source, + )) + except (AuthoringCompileError, ValueError) as error: + diagnostic_path = self.artifacts.write_json_once(task_id, "documents/requirements-analysis-diagnostic.json", { + "schema_version": "cad.requirements-diagnostic.v1", + "diagnostics": [{"code": "AUTHOR_SCHEMA_INVALID", "message": str(error)[:1000]}], + }) + failed = transition(state, "failed", diagnostics_path=diagnostic_path, error=ErrorCode.AUTHOR_SCHEMA_INVALID) + self.repository.compare_and_swap(failed, events=[{"event": "requirements_analysis_failed", "diagnostics_path": diagnostic_path}]) + return "task_terminal", self._terminal(failed) + if analysis.clarification_question: + path = self.artifacts.write_json_once(task_id, "documents/clarification-request.json", {"question": analysis.clarification_question}) + waiting = transition(state, "waiting_for_user", clarification_path=path, requirements_path="") + self.repository.compare_and_swap(waiting, events=[{"event": "requirements_waiting_for_user", "question": analysis.clarification_question}]) + return "task_terminal", self.waiting_for_user_terminal(task_id, waiting) + path = self.artifacts.write_json_once(task_id, "documents/requirements-analysis.json", analysis.model_dump(mode="json")) + next_state = transition(state, "analysis_written", requirements_path=path) + self.repository.compare_and_swap(next_state, events=[{"event": "requirements_analyzed", "path": path, "assumptions": analysis.assumptions}]) + return "requirements_ready", {"taskId": task_id, "path": path, "assumptions": analysis.assumptions} + + async def _author(self, task_id: str, state: TaskState, author: ModelIdentity) -> tuple[str, dict[str, Any]]: + requirements = self.artifacts.read_json(task_id, state.requirements_path) or {} + previous = self.artifacts.read_json(task_id, state.authoring_path) if state.authoring_path else None + diagnostics = self.artifacts.read_json(task_id, state.diagnostics_path) if state.diagnostics_path else None + operation_schemas = { + atomic_id: self._author_operation_contract(self.runtime.operation_contract(atomic_id)) + for atomic_id in self.runtime.supported_atomic_ids() + } + repair_instruction = "" if state.repair_count == 0 else "Return a complete replacement document. Preserve only features confirmed in executed_feature_ids unless the diagnostic identifies that feature. Features that did not execute may be corrected. Keep local names unless the diagnostic identifies a name conflict. Never add IDs, stable selectors, snapshots, or tokens." + content = json.dumps({"requirements": requirements, "supported_operations": operation_schemas, "previous_authoring": previous, "diagnostics": diagnostics}, ensure_ascii=False) + try: + raw = await self._tool_call( + task_id, author, "write_authoring_cdsl", AuthoringDocument.model_json_schema(), + "Write only cad.author.v1. Use local body and feature names. The service creates all runtime IDs. " + repair_instruction + "\n\n" + load_authoring_guidance(), + content, + ) + document = AuthoringDocument.model_validate(raw).model_dump(mode="json") + if previous is not None and state.repair_count: + self._validate_repair_document(previous, document, diagnostics, self.artifacts.read_json(task_id, state.compile_audit_path) if state.compile_audit_path else None) + except (AuthoringCompileError, ValueError) as error: + return self._repair_or_stop( task_id, state, - claim_results=claim_results, - review={"visual_claims": visual_claims}, + validation_error_code(error), + str(error), + self._author_validation_details(error), ) - except OSError: - return None - completed = self.actions.finalize_best_effort(task_id, reason=reason, invocation_id=self._invocation_id(task_id)) - if isinstance(completed, Rejected): - return None - return "task_terminal", { - "taskId": task_id, - "lifecycle": "completed", - "revisionId": state.active_revision, - "code": ErrorCode.BEST_EFFORT_COMPLETED.value, - "message": message, - "issues": completed.payload.get("issues") or [], - "verificationStatus": "completed_with_risks", - "verificationWarnings": completed.payload.get("issues") or [], - "completionResultPath": "completion-result.md", - "budget": budget or {}, - "userActionRequired": False, - } + path = self.artifacts.write_json_once(task_id, f"documents/authoring-cdsl-attempt-{state.repair_count + 1:02d}.json", document) + next_state = transition(state, "authoring_written", authoring_path=path) + self.repository.compare_and_swap(next_state, events=[{"event": "authoring_cdsl_written", "path": path, "repair_count": state.repair_count}]) + return "authoring_cdsl_ready", {"taskId": task_id, "path": path, "repairCount": state.repair_count} - def _storage_failure(self, task_id: str, message: str) -> tuple[str, dict[str, Any]]: - """Park a nonterminal task when a durable artifact operation fails.""" - state = self.repository.get_state(task_id) - if state is not None and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}: - 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 "task_terminal", { - "taskId": task_id, - "lifecycle": "waiting_retry", - "code": ErrorCode.STORAGE_FAILURE.value, - "message": message[:1000], - } - lifecycle = state.phase.value.lower() if state is not None else "failed" - return "task_terminal", { - "taskId": task_id, - "lifecycle": lifecycle, - "code": ErrorCode.STORAGE_FAILURE.value, - "message": message[:1000], - } - - async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, Any]] | WorkflowError: - if len(tools) != 1: - raise RuntimeError("Workflow state must expose exactly one author tool.") - tool = tools[0] - name = str((tool.get("function") or {}).get("name") or "") - if not name: - raise RuntimeError("Workflow exposed an unnamed author tool.") - messages, guidance = self._author_context(task_id, feedback) + def _compile(self, task_id: str, state: TaskState) -> tuple[str, dict[str, Any]]: + authoring = self.artifacts.read_json(task_id, state.authoring_path) + if authoring is None: + raise RuntimeError("Authoring CDSL artifact is unavailable") try: - response = await self.model_gateway.call_tool( - messages=messages, tool=tool, provider_id=author.provider_id, - model_id=author.model_id, required_tool_name=name, - ) - except AdapterUnavailable as error: - self.repository.record_usage(task_id, { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - "usage_available": False, - "context_chars": len(json.dumps(messages, ensure_ascii=False)), - "tool": name, - "provider_id": author.provider_id, - "model_id": author.model_id, - "retry_reason": "provider_unavailable", - "cache_hit": False, - **guidance.usage_metadata(), - }) - return WorkflowError(ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error)[:1000], retryable=True) - call = validate_one_tool_call(response["tool_calls"], name) - if isinstance(call, WorkflowError): - # A provider response is still a billable author attempt even when - # it violates the one-tool-call protocol. Keep guidance audit - # metadata on that record so A/B reports do not silently omit the - # failures this corpus is intended to reduce. - self.repository.record_usage(task_id, { - **response["usage"], - "context_chars": len(json.dumps(messages, ensure_ascii=False)), - "tool": name, - "provider_id": author.provider_id, - "model_id": author.model_id, - "raw_arguments_hash": "", - "retry_reason": "invalid_tool_call", - "cache_hit": False, - **guidance.usage_metadata(), - }) - self._record_rejected_tool_calls( - task_id, - actor="author", - expected_tool=name, - tool_calls=response["tool_calls"], - schema=(tool.get("function") or {}).get("parameters"), - ) - return call - _name, raw = call - self._record_tool_audit( - task_id, - actor="author", - tool=name, - raw_arguments=raw, - schema=(tool.get("function") or {}).get("parameters"), - ) - usage = { - **response["usage"], - "context_chars": len(json.dumps(messages, ensure_ascii=False)), - "tool": name, - "provider_id": author.provider_id, - "model_id": author.model_id, - "raw_arguments_hash": raw_arguments_hash(raw), - "retry_reason": "", - "cache_hit": False, - **guidance.usage_metadata(), - } - self.repository.record_usage(task_id, usage) - return name, raw, usage + compiled = self.executor.compile(task_id, authoring, repair_count=state.repair_count) + except AuthoringCompileError as error: + return self._repair_or_stop(task_id, state, error.code, str(error), {"path": error.path}) + next_state = transition(state, "compiled", runtime_cdsl_path=compiled["runtime_path"], compile_audit_path=compiled["audit_path"]) + self.repository.compare_and_swap(next_state, events=[{"event": "cdsl_compiled", "runtime_path": compiled["runtime_path"], "compile_audit_path": compiled["audit_path"]}]) + return "cdsl_compiled", {"taskId": task_id, "runtimePath": compiled["runtime_path"], "compileAuditPath": compiled["audit_path"]} - async def _review_candidate(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessCandidateReview | WorkflowError: - candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") - action = state.pending_action - if not isinstance(candidate, dict) or action is None: - return WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate review facts are unavailable.", retryable=True) - return await self._review_tool(task_id, reviewer, "review_candidate", StatelessCandidateReview, { - "requirements": self._public_requirements(self._requirements_contract(task_id, state)), - "action": {"intent": action.intent, "expected_change": action.expected_change, "operation": action.atomic_id}, - "candidate_facts": self._public_candidate(candidate), - "render_manifest": candidate.get("render_manifest") or {}, - "instruction": "Review only whether the current checkpoint correctly performs the stated action. Deterministic facts are authoritative. Do not return task, action, candidate, requirement, claim, revision, head, or evidence identifiers.", - }, feedback=feedback) + def _build(self, task_id: str, state: TaskState) -> tuple[str, dict[str, Any]]: + authoring = self.artifacts.read_json(task_id, state.authoring_path) + runtime_cdsl = self.artifacts.read_json(task_id, state.runtime_cdsl_path) + audit = self.artifacts.read_json(task_id, state.compile_audit_path) + if authoring is None or runtime_cdsl is None or audit is None: + raise RuntimeError("Compiled CDSL artifacts are unavailable") + result = self.executor.build(task_id, authoring, runtime_cdsl, audit, repair_count=state.repair_count) + diagnostics = result.get("diagnostics") if isinstance(result.get("diagnostics"), list) else [] + diagnostic_path = self.artifacts.write_json_once(task_id, f"documents/diagnostics-attempt-{state.repair_count + 1:02d}.json", {"diagnostics": diagnostics, "executed_feature_ids": result.get("executed_feature_ids", [])}) + revision = str(result.get("revision_id") or "") + if diagnostics: + if state.repair_count < self.config.max_repairs: + repairing = transition(state, "repair_required", active_revision=revision or state.active_revision, diagnostics_path=diagnostic_path, error=ErrorCode.ENGINE_EXECUTION_FAILED) + self.repository.compare_and_swap(repairing, events=[{"event": "build_failed", "paths": result.get("paths", {}), "diagnostics": diagnostics, "revision_id": revision}]) + return "build_result", {"taskId": task_id, "status": "repair_required", "paths": result.get("paths", {}), "diagnostics": diagnostics, "revisionId": revision} + publishing = transition(state, "build_completed", active_revision=revision or state.active_revision, diagnostics_path=diagnostic_path, error=ErrorCode.BEST_EFFORT_COMPLETED) + self.repository.compare_and_swap(publishing, events=[{"event": "published_best_effort", "paths": result.get("paths", {}), "diagnostics": diagnostics, "revision_id": revision}]) + return "build_result", {"taskId": task_id, "status": "published_best_effort", "paths": result.get("paths", {}), "diagnostics": diagnostics, "revisionId": revision} + publishing = transition(state, "build_completed", active_revision=revision, diagnostics_path=diagnostic_path) + self.repository.compare_and_swap(publishing, events=[{"event": "build_completed", "paths": result.get("paths", {}), "revision_id": revision, "executed_feature_ids": result.get("executed_feature_ids", [])}]) + return "build_result", {"taskId": task_id, "status": "completed", "paths": result.get("paths", {}), "revisionId": revision} - async def _observe_images(self, task_id: str, reviewer: ModelIdentity, image_paths: list[str]) -> ImageObservation | WorkflowError: - return await self._review_tool(task_id, reviewer, "observe_images", ImageObservation, { - "source_requirements": self.artifacts.read_source_requirements(task_id), - "reference_image_paths": image_paths, - "instruction": ( - "Inspect every supplied reference image once. Describe visible part geometry, view directions, readable dimensions, holes and profiles, confidence, assumptions, and uncertainties. " - "Do not create CAD operations and do not return attachment or runtime identifiers." - ), - }) - - async def _review_final(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessFinalReview | WorkflowError: - facts = self.actions._facts(task_id, state.active_revision) - results = self.actions._evaluate_claims(task_id, facts) - visual_claims = [item for item in results if not item.get("deterministic")] - return await self._review_tool(task_id, reviewer, "review_final", StatelessFinalReview, { - "source_requirements": self.artifacts.read_source_requirements(task_id), - "requirements": self._public_requirements(self._requirements_contract(task_id, state)), - "deterministic_results": [self._public_claim_result(item) for item in results if item.get("deterministic")], - "visual_claims": [self._public_claim_result(item) for item in visual_claims], - "render_manifest": (facts.get("report") or {}).get("render_manifest") or {}, - "reference_image_paths": self.artifacts.source_image_paths(task_id), - "instruction": "Review the final CAD renders against the original reference images and the ordered visual claims. Return exactly one visual_claims decision for each supplied visual claim, in the same order. Deterministic results are final. Do not return any runtime identifiers.", - }, schema=stateless_final_review_schema(len(visual_claims)), feedback=feedback) - - async def _review_tool(self, task_id: str, reviewer: ModelIdentity, name: str, model_type: type[T], payload: dict[str, Any], *, schema: dict[str, Any] | None = None, feedback: list[dict[str, Any]] | None = None) -> T | WorkflowError: - if feedback: - payload = {**payload, "previous_schema_or_state_error": str(feedback[-1].get("content") or "")[:2_000]} - try: - tool = self._tool(name, schema or model_type) - kind = "image_observation" if name == "observe_images" else "candidate" if name == "review_candidate" else "final" - response = await self.review_gateway.review(kind=kind, payload=payload, tool=tool, provider_id=reviewer.provider_id, model_id=reviewer.model_id) - except AdapterUnavailable as error: - error_code = ( - ErrorCode.RENDER_SERVICE_UNAVAILABLE - if str(error).startswith("RENDER_SERVICE_UNAVAILABLE:") - else ErrorCode.REVIEW_SERVICE_UNAVAILABLE - ) - self.repository.record_usage(task_id, { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - "usage_available": False, - "context_chars": len(json.dumps(payload, ensure_ascii=False)), - "tool": name, - "provider_id": reviewer.provider_id, - "model_id": reviewer.model_id, - "role": "reviewer", - "retry_reason": error_code.value.lower(), - "cache_hit": False, - }) - return WorkflowError(error_code, str(error)[:1000], retryable=True) - check = validate_one_tool_call(response["tool_calls"], name) - if isinstance(check, WorkflowError): - self._record_rejected_tool_calls( - task_id, - actor="reviewer", - expected_tool=name, - tool_calls=response["tool_calls"], - schema=(tool.get("function") or {}).get("parameters"), - ) - return check - _tool_name, raw = check - self._record_tool_audit( - task_id, - actor="reviewer", - tool=name, - raw_arguments=raw, - schema=(tool.get("function") or {}).get("parameters"), - ) - self.repository.record_usage(task_id, { - **response["usage"], - "context_chars": len(json.dumps(payload, ensure_ascii=False)), - "tool": name, - "provider_id": reviewer.provider_id, - "model_id": reviewer.model_id, - "role": "reviewer", - "raw_arguments_hash": raw_arguments_hash(raw), - "retry_reason": "", - "cache_hit": False, - }) - validated = canonical_validate(raw, model_type) - if isinstance(validated, WorkflowError): - return validated - dynamic_error = canonical_validate_schema(raw, schema) if schema is not None else None - return dynamic_error or validated # type: ignore[return-value] - - def _record_tool_audit( - self, - task_id: str, - *, - actor: str, - tool: str, - raw_arguments: str, - schema: Any, - returned_tool: str | None = None, - single_allowed_call: bool = True, - ) -> None: - """Persist the raw-output validation and current state-binding facts. - - Provider parsers are not a trust boundary. This stores a redacted, - diagnostic copy locally while release reports expose only the hash and - validation/binding summary. - """ - state = self.repository.get_state(task_id) - schema_error = canonical_validate_schema(raw_arguments, schema) if isinstance(schema, dict) else WorkflowError( - ErrorCode.RUNTIME_CONTRACT_INVALID, - "The exposed tool has no JSON Schema.", - ) - parsed = canonical_json_object(raw_arguments) - supplied_head = parsed.get("working_head") if isinstance(parsed, dict) else None - topology = self.artifacts.read_topology(task_id, state.active_revision) if state and state.active_revision else None - pending = state.pending_action if state else None - contract_current = True - if pending is not None: - try: - contract_current = self.runtime.operation_contract(pending.atomic_id).get("contract_hash") == pending.contract_hash - except Exception: - contract_current = False - schema_head = self._schema_working_head(schema) - expected_head = schema_head or (state.working_head if state is not None else "") - # Candidate reviews are scoped to the action checkpoint that was used - # to build the candidate. The state has already advanced by the time - # the review runs, so current-state equality would reject a valid, - # schema-bound response. The dynamic schema is server-generated and - # canonical-validated above, making its const the authority here. - working_head_matches = not isinstance(supplied_head, str) or (bool(expected_head) and supplied_head == expected_head) - binding_valid = bool(state) and single_allowed_call and schema_error is None and working_head_matches and contract_current - self.repository.record_tool_audit(task_id, { - "schema_version": "cad.v3.tool-audit.v2", - "actor": actor, - "tool": tool, - "raw_arguments_hash": raw_arguments_hash(raw_arguments), - "redacted_arguments": self._redact_tool_arguments(parsed) if isinstance(parsed, dict) else None, - "canonical_schema_valid": schema_error is None, - "field_errors": list(schema_error.field_errors) if isinstance(schema_error, WorkflowError) else [], - "state_binding": { - "phase": state.phase.value if state else "", - "working_head": state.working_head if state else "", - "expected_working_head": expected_head, - "working_head_binding_source": "schema_const" if schema_head else "current_state", - "supplied_working_head": str(supplied_head or ""), - "working_head_matches": working_head_matches, - "active_revision": state.active_revision if state else "", - "contract_hash": pending.contract_hash if pending else "", - "contract_hash_current": contract_current, - "selector_snapshot_id": str((topology or {}).get("snapshot_id") or ""), - "binding_valid": binding_valid, - }, - "returned_tool": returned_tool if returned_tool is not None else tool, - "single_allowed_call": single_allowed_call, - }) - - @staticmethod - def _schema_working_head(schema: Any) -> str | None: - """Return a server-bound head from a dynamic top-level tool schema.""" - properties = schema.get("properties") if isinstance(schema, dict) else None - working_head = properties.get("working_head") if isinstance(properties, dict) else None - value = working_head.get("const") if isinstance(working_head, dict) else None - return value if isinstance(value, str) else None - - def _record_rejected_tool_calls( - self, - task_id: str, - *, - actor: str, - expected_tool: str, - tool_calls: list[dict[str, Any]], - schema: Any, - ) -> None: - """Audit every returned call even when the one-call gate rejects it.""" - if not tool_calls: - self._record_tool_audit( - task_id, - actor=actor, - tool=expected_tool, - raw_arguments="", - schema=schema, - returned_tool="", - single_allowed_call=False, - ) - return - for call in tool_calls: - function = call.get("function") if isinstance(call, dict) and isinstance(call.get("function"), dict) else {} - name = str(function.get("name") or "") - arguments = function.get("arguments") - raw = arguments if isinstance(arguments, str) else "" - self._record_tool_audit( - task_id, - actor=actor, - tool=expected_tool, - raw_arguments=raw, - schema=schema, - returned_tool=name, - single_allowed_call=False, - ) - - @staticmethod - def _redact_tool_arguments(value: Any) -> Any: - if isinstance(value, list): - return [WorkflowCoordinator._redact_tool_arguments(item) for item in value] - if not isinstance(value, dict): - return value - sensitive = {"api_key", "authorization", "credential", "credentials", "secret", "token", "password"} - return { - str(key): "[REDACTED]" if str(key).casefold() in sensitive else WorkflowCoordinator._redact_tool_arguments(item) - for key, item in value.items() - } - - def _action_tools(self, task_id: str, state: TaskState, observed: set[str] | None = None) -> list[dict[str, Any]]: - action = state.pending_action - if action is None: - return [] - contract = self.runtime.operation_contract(action.atomic_id) - selector_shape = str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") - seen = observed or set() - topology = self.artifacts.read_topology(task_id, state.active_revision) - tokens = self.runtime.selector_tokens(topology) - eligible_tokens = self._selector_tokens_for_contract(contract, tokens) - if selector_shape == "required" and len(eligible_tokens) > 16 and "topology" not in seen: - return [self._tool("inspect_topology", StatelessTopologyRequest)] - references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision)) - description = "Submit exactly one CDSL feature for the pending action." - if not state.active_revision and action.atomic_id in {"extrude_add_blind", "extrude_add_two_sided"}: - description += " Root extrusion uses the world XY datum: workplane.origin_mm must be [0, 0, Z], normal [0, 0, 1], and x_dir [1, 0, 0]. Profile coordinates are local to that plane." - if action.atomic_id.startswith("hole_"): - description += ( - " Every feature.params.positions[].mm value is an absolute world-space mm point on the selected host face. " - "It is not a host-face-local offset; the server converts the world point to the selected face frame." - ) - if selector_shape == "required": - description += ( - " The root payload shape is {\"feature\":{\"atomic_id\":\"...\"," - "\"selector_tokens\":[\"opaque topology token\"],\"params\":{...}}}. " - "feature.selector_tokens is required author input: copy one of the opaque tokens from " - "inspect_topology; the server resolves it to the host after validation." - ) - fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))}} - return [fragment] - - def _recovery_tools(self, task_id: str, state: TaskState) -> list[dict[str, Any]]: - if not state.repair_required: - return [] - if self.actions.rollback_available(task_id, state): - return [self._tool("rollback_checkpoint", stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state))))] - if self.actions.repair_action_ready(task_id, state): - return [] - evidence_refs = list(self.actions.diagnostic_evidence_refs(task_id, state)) - if not evidence_refs: - return [] - return [self._tool("record_geometry_conclusion", StatelessGeometryConclusion)] - - def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], AuthorGuidanceSelection]: - state = self.repository.get_state(task_id) - if state is None: - return [], AuthorGuidanceSelection(fallback_reason="task_state_unavailable") - if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT: - content = { - "protocol": "cad.v3.2.feature-dag", - "source_requirements": self.artifacts.read_source_requirements(task_id), - "image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"), - "user_clarifications": self._user_clarifications(task_id), - "instruction": ( - "Write the frozen engineering-expanded requirements Markdown using every required heading. Clearly separate explicit user facts from engineering defaults. " - "Conventional functional geometry is allowed for underspecified common parts, but never contradict explicit text or the image observation. Do not include runtime identifiers." - ), - } - elif state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET: - content = { - "protocol": "cad.v3.2.feature-dag", - "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), - "instruction": "Write # Completion Target with unique - [ ] checklist items. Each item must describe one independently observable final feature or condition. Do not add requirements not present in the frozen requirements document and do not include runtime identifiers.", - } - elif state.phase == TaskPhase.COMPILING_REQUIREMENTS: - content = { - "protocol": "cad.v3.2.feature-dag", - "source_requirements": self.artifacts.read_source_requirements(task_id), - "image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"), - "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), - "completion_target_markdown": self._read_markdown(task_id, state.completion_target_path), - "verifier_registry": self.requirements.registry.expected_one_of_schema(), - "instruction": "Compile exactly one ordered verifier bundle for each checklist item. The checklist text and all IDs are service-owned: output only assumptions and acceptance claims. Use deterministic verifiers for measurable defaults recorded in Markdown; use visual only for non-measurable appearance. An obround, slotted, or long-slot feature is not a rectangular_corner_through_bore_pattern: keep it visual unless a dedicated slot verifier is available. Every explicit centered, concentric, or coaxial bore must have concentric_bore_to_outer_cylinder coverage. coaxial_through_bore_group is only for two or more same-diameter inner bores. For one central bore concentric with an external cylinder, use concentric_bore_to_outer_cylinder with bore_diameter_mm and outer_diameter_mm.", - } - elif state.phase in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}: - active_plan = self.artifacts.read_json(task_id, state.feature_plan_path) if state.feature_plan_path else None - contract = self._requirements_contract(task_id, state) 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") - ) - required_replacements: list[str] = [] - parent_plan_hash = "" - if isinstance(active_plan, dict): - try: - parsed_plan = FeaturePlan.model_validate(active_plan) - parent_plan_hash = plan_hash(parsed_plan) - required_replacements = sorted(self.requirements._required_replacements(parsed_plan, task_id)) - except ValueError: - pass - content = { - "protocol": "cad.v3.2.feature-dag", - "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), - "completion_target_markdown": self._read_markdown(task_id, state.completion_target_path), - "compiled_contract": contract, - "claim_ownership_binding": { - "node_claim_ids_must_be_drawn_only_from": deterministic_claim_ids, - "final_claim_ids_must_equal": visual_claim_ids, - "visual_only_nodes_must_use_empty_claim_ids": True, - }, - "supported_atomic_ids": list(self.runtime.supported_atomic_ids()), - "active_feature_plan": active_plan, - "plan_lineage_binding": { - "parent_plan_hash": parent_plan_hash, - "replaces_node_ids": required_replacements, - }, - "feature_node_statuses": self._feature_node_statuses(task_id, active_plan), - "replanning_evidence": self._replanning_evidence(task_id, state), - "instruction": ( - "Write the complete feature-plan JSON. Each node is exactly one runtime atomic feature. " - "Every deterministic frozen claim must belong to exactly one node; every visual claim must be in final_claim_ids. " - "Never place a visual claim in any node claim_ids. Nodes for ribs, chamfers, slots, or other visual-only work are valid with claim_ids: []. " - "Use dependency edges only for direct geometric prerequisites and unique fixed priorities. " - "The tool schema binds parent_plan_hash and replaces_node_ids to the service values: copy those exact values, never use the requirements-contract hash. " - "For a revision, preserve completed nodes byte-for-byte and use new IDs for replacements." - ), - } - else: - contract = self._requirements_contract(task_id, state) or {} - action = state.pending_feature - feature_node_turn = state.phase == TaskPhase.FEATURE_PENDING and action is not None - compact = [{ - "requirement_id": item.get("requirement_id"), - "statement": item.get("statement"), - "acceptance_claims": [ - { - "claim_kind": claim.get("claim_kind"), - "expected": claim.get("expected"), - } - for claim in item.get("acceptance_claims") or () - if isinstance(claim, dict) - ], - } for item in contract.get("requirements") or () if isinstance(item, dict)] - if feature_node_turn: - owned_requirements = set(action.requirement_ids) - compact = [ - item for item in compact - if str(item.get("requirement_id") or "") in owned_requirements - ] - operation = self.runtime.operation_contract(action.atomic_id) if action is not None else None - selector_shape = str((operation or {}).get("fragment_shape", {}).get("selector_tokens") or "forbidden") - instruction = "The service has scheduled one atomic feature from the immutable DAG. Submit exactly that feature's CDSL fragment." - if state.repair_required: - instruction = "A prior candidate or final review requires repair. Use the current server evidence to record a geometry conclusion, then either choose a new action or, after a rollback conclusion, request an earlier checkpoint. The service will not choose CAD operations for you." - operation_payload = self._operation_payload(task_id, state) if action is not None else None - selector_summary = [] - sketch_workplane_candidates = [] - if action is not None and selector_shape == "required": - tokens = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)) - allowed = set(self._selector_tokens_for_contract(operation or {}, tokens)) - selector_summary = [ - {"token": token, "kind": value.get("kind"), "geometry": {key: value.get("geometry", {}).get(key) for key in ("center_mm", "normal", "bbox_mm", "surface_type") if key in value.get("geometry", {})}} - for token, value in tokens.items() if token in allowed - ][:16] - instruction = "The exact operation contract and eligible selector summary are attached. Submit one fragment; call inspect_topology only when the selector summary is marked truncated." - if ( - feature_node_turn - and state.active_revision - and str((operation or {}).get("fragment_shape", {}).get("sketch") or "forbidden") == "required" - ): - sketch_workplane_candidates = self._sketch_workplane_candidates( - self.artifacts.read_topology(task_id, state.active_revision) - ) - if sketch_workplane_candidates: - instruction += ( - " sketch_workplane_candidates are measured planar material faces. " - "For a blind cut, select a plane whose material region covers the intended profile; " - "do not choose a newer or higher face merely because it is the last feature." - ) - current_claims = self.actions.claim_summary(task_id, state) - if feature_node_turn: - current_claims = [ - item for item in current_claims - if str(item.get("claim_id") or "") in set(action.claim_ids) - ] - content = {"protocol": "cad.v3.2.feature-dag", "coordinate_protocol": self._coordinate_protocol(state), "phase": state.phase.value, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in current_claims], "model_summary": self.actions.model_summary(task_id, state), "pending_feature": self._public_pending_context(state), "direct_upstream_facts": self._direct_upstream_facts(task_id, state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "sketch_workplane_candidates": sketch_workplane_candidates, "recent_failures": self._recent_failure_constraints(task_id, state), "instruction": instruction} - if not feature_node_turn: - content.update({ - "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), - "completion_target_markdown": self._read_markdown(task_id, state.completion_target_path), - "feature_plan": self.artifacts.read_json(task_id, state.feature_plan_path) if state.feature_plan_path else None, - "feature_plan_hash": state.feature_plan_hash, - "feature_node_statuses": self._feature_node_statuses(task_id, None), - }) - guidance = self.author_guidance.select( - phase=state.phase, - atomic_id=state.pending_feature.atomic_id if state.pending_feature is not None else "", - repair_required=state.repair_required, - supported_atomic_ids=self.runtime.supported_atomic_ids(), - ) - system = "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON." - if guidance.content: - system += "\n\nThe following is non-authoritative CDSL author guidance. The current tool schema, operation contract, and server facts take precedence.\n\n" + guidance.content - messages: list[dict[str, Any]] = [{"role": "system", "content": system}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}] - return [*messages, *feedback[-2:]], guidance - - def _user_clarifications(self, task_id: str) -> list[dict[str, str]]: - clarifications: list[dict[str, str]] = [] - for event in self.repository.ledger_events(task_id): - if event.get("event") != "user_clarification_received": - continue - path = str(event.get("clarification_path") or "") - payload = self.artifacts.read_json(task_id, path) if path else None - text = str((payload or {}).get("text") or "").strip() - if text: - clarifications.append({"message_id": str((payload or {}).get("message_id") or ""), "text": text}) - return clarifications - - def _read_markdown(self, task_id: str, relative_path: str) -> str: - if not relative_path: - return "" - try: - path = self.artifacts.artifact_path(task_id, relative_path) - return path.read_text(encoding="utf-8") if path.is_file() else "" - except (OSError, ValueError): - return "" - - def _feature_node_statuses(self, task_id: str, plan_payload: dict[str, Any] | None) -> dict[str, str]: - try: - from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler - state = self.repository.get_state(task_id) - plan = FeaturePlan.model_validate(plan_payload) if isinstance(plan_payload, dict) else FeaturePlan.model_validate(self.artifacts.read_json(task_id, state.feature_plan_path) if state and state.feature_plan_path else None) - return FeatureScheduler(plan, self.repository.ledger_events(task_id)).statuses() - except (ValueError, TypeError, OSError): - return {} - - def _direct_upstream_facts(self, task_id: str, state: TaskState) -> list[dict[str, Any]]: - """Project only verified direct dependencies for a scheduled node. - - The current model summary describes the working head. This additional - projection lets the author distinguish the exact prerequisite nodes - without exposing arbitrary historical topology or old failed stages. - """ - pending = state.pending_feature - if pending is None or not pending.depends_on_node_ids: - return [] - verified: dict[str, dict[str, Any]] = {} - for event in self.repository.ledger_events(task_id): - if event.get("event") == "feature_node_verified": - node_id = str(event.get("node_id") or "") - if node_id in pending.depends_on_node_ids: - verified[node_id] = event - result: list[dict[str, Any]] = [] - for node_id in pending.depends_on_node_ids: - event = verified.get(node_id) - if event is None: - continue - revision_id = str(event.get("revision_id") or "") - verification = self.artifacts.read_json(task_id, f"revisions/{revision_id}/node-verification.json") if revision_id else None - evidence = verification if isinstance(verification, dict) else {} - result.append({ - "node_id": node_id, - "revision_id": revision_id, - "feature_id": str(event.get("feature_id") or evidence.get("feature_id") or ""), - "claim_results": [ - { - "claim_id": str(item.get("claim_id") or ""), - "status": str(item.get("status") or ""), - "claim_kind": str(item.get("claim_kind") or ""), - "evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {}, - } - for item in evidence.get("claim_results") or () - if isinstance(item, dict) - ], - "operation_verifier_results": [ - { - "claim_kind": str(item.get("claim_kind") or ""), - "status": str(item.get("status") or ""), - "evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {}, - } - for item in evidence.get("operation_verifier_results") or () - if isinstance(item, dict) - ], - "health": evidence.get("health") if isinstance(evidence.get("health"), dict) else {}, - }) - return result - - @staticmethod - def _sketch_workplane_candidates(topology: dict[str, Any] | None, *, limit: int = 12) -> list[dict[str, Any]]: - """Expose compact, measured planes for sketch operations without selectors. - - Sketch extrusions deliberately do not use B-rep selector tokens. The - author still needs enough geometry to choose a material face instead of - placing a profile on the most recently created boss. - """ - records = topology.get("records") if isinstance(topology, dict) else None - if not isinstance(records, list): - return [] - candidates: list[dict[str, Any]] = [] - seen: set[tuple[float, ...]] = set() - for record in records: - geometry = record.get("geometry") if isinstance(record, dict) else None - center = geometry.get("center_mm") if isinstance(geometry, dict) else None - normal = geometry.get("normal") if isinstance(geometry, dict) else None - bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None - if ( - not isinstance(geometry, dict) - or geometry.get("surface_type") != "plane" - or not isinstance(center, list) - or not isinstance(normal, list) - or len(center) != 3 - or len(normal) != 3 - or not isinstance(bbox, list) - or len(bbox) != 6 - ): - continue - try: - point = [round(float(value), 4) for value in center] - direction = [round(float(value), 4) for value in normal] - bounds = [round(float(value), 4) for value in bbox] - except (TypeError, ValueError): - continue - key = tuple(direction + point + bounds) - if key in seen: - continue - seen.add(key) - footprint = abs((bounds[3] - bounds[0]) * (bounds[4] - bounds[1])) - candidates.append({ - "point_mm": point, - "normal": direction, - "bbox_mm": bounds, - "footprint_bbox_area_mm2": round(footprint, 4), - }) - # Favor outward horizontal faces, then the broadest support surface. - # A base top commonly supports an outer slot while a taller boss does - # not; ordering by Z would teach the opposite choice. - candidates.sort(key=lambda item: ( - -float(item["normal"][2]), - -float(item["footprint_bbox_area_mm2"]), - -float(item["point_mm"][2]), - item["bbox_mm"], - )) - return candidates[:limit] - - def _replanning_evidence(self, task_id: str, state: TaskState) -> list[dict[str, Any]]: - """Provide bounded server evidence that led to this local replan.""" - evidence: list[dict[str, Any]] = [] - for event in reversed(self.repository.ledger_events(task_id)): - if event.get("plan_hash") != state.feature_plan_hash: - continue - if event.get("event") == "feature_node_failed": - evidence.append({ - "kind": "node_failure", - "node_id": str(event.get("node_id") or ""), - "failure_class": str(event.get("failure_class") or ""), - "attempt": int(event.get("attempt") or 0), - "message": str(event.get("message") or "")[:500], - "blockers": (event.get("blockers") or [])[:8] if isinstance(event.get("blockers"), list) else [], - }) - elif event.get("event") == "final_visual_reviewed" and event.get("visual_not_passed"): - evidence.append({ - "kind": "final_visual_review", - "claim_ids": [str(value) for value in event.get("visual_not_passed") or () if str(value)], - "issues": [str(value)[:500] for value in event.get("issues") or () if str(value)], - "evidence": [str(value)[:500] for value in event.get("evidence") or () if str(value)], - }) - elif event.get("event") == "feature_plan_completion_failed": - evidence.append({ - "kind": "final_deterministic_validation", - "claim_ids": [str(value) for value in event.get("failed_claim_ids") or () if str(value)], - "message": str(event.get("message") or "")[:500], - "claim_results": [ - self._public_claim_result(item) - for item in event.get("claim_results") or () - if isinstance(item, dict) and item.get("deterministic") and item.get("status") != "pass" - ], - }) - if len(evidence) == 8: - break - return evidence - - def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]: - """Expose the persisted requirement question when a task is parked. - - The clarification artifact is the durable source of the single human - decision needed to continue this task. - """ - clarification = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None - question = str((clarification or {}).get("question") or "").strip() - questions = [question] if question else [] - if not questions: - raise RuntimeError("WAITING_FOR_USER requires at least one answerable requirements question") - message = f"Requirements need a user decision. {questions[0]}" - payload: dict[str, Any] = { - "taskId": task_id, - "lifecycle": TaskPhase.WAITING_FOR_USER.value.lower(), - "revisionId": state.active_revision, - "code": state.last_error.value if state.last_error else ErrorCode.WAITING_FOR_USER.value, - "message": message, - "questions": questions, - "clarificationPath": state.clarification_path, - "blockerType": "requirements_ambiguity", - "userActionRequired": True, - } - return payload - - def _requirements_contract(self, task_id: str, state: TaskState | None) -> dict[str, Any] | None: - if state is None or not state.requirements_contract_path: - return None - return self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) - - def _ensure_recovered_completion_result(self, task_id: str, state: TaskState) -> None: - result_path = self.artifacts.artifact_path(task_id, "completion-result.md") - if result_path.is_file(): - return - facts = self.actions._facts(task_id, state.active_revision) - claim_results = self.actions._evaluate_claims(task_id, facts) - raw_review = self.artifacts.read_json(task_id, f"reviews/final/{state.active_revision}/final-review.json") or {} - coverage = { - str(item.get("claim_id") or ""): item - for item in raw_review.get("claim_coverage") or () - if isinstance(item, dict) - } - visual_claims = [ - { - "status": str(coverage.get(str(item.get("claim_id") or ""), {}).get("status") or "fail"), - "evidence": "; ".join(str(value) for value in raw_review.get("evidence") or ()) or "Recovered final review decision.", - } - for item in claim_results - if not item.get("deterministic") - ] - self.requirements.write_completion_result( - task_id, - state, - claim_results=claim_results, - review={"visual_claims": visual_claims}, - ) - - @staticmethod - def _public_requirements(contract: dict[str, Any] | None) -> list[dict[str, Any]]: - return [ - { - "statement": str(item.get("statement") or ""), - "assumptions": list(item.get("assumptions") or []), - "acceptance_claims": [ - { - "claim_kind": str(claim.get("claim_kind") or ""), - "expected": claim.get("expected") or {}, - "verification_mode": str(claim.get("verification_mode") or ""), - } - for claim in item.get("acceptance_claims") or () - if isinstance(claim, dict) - ], - } - for item in (contract or {}).get("requirements") or () - if isinstance(item, dict) - ] - - @staticmethod - def _public_claim_result(item: dict[str, Any]) -> dict[str, Any]: - return { - key: value - for key, value in item.items() - if key not in {"claim_id", "requirement_id", "evidence_refs"} - } - - @classmethod - def _public_candidate(cls, candidate: dict[str, Any]) -> dict[str, Any]: - return { - "claim_results": [cls._public_claim_result(item) for item in candidate.get("claim_results") or () if isinstance(item, dict)], - "operation_verifier_results": [cls._public_claim_result(item) for item in candidate.get("operation_verifier_results") or () if isinstance(item, dict)], - "health": candidate.get("health") or {}, - "model_summary": candidate.get("model_summary") or {}, - } - - @staticmethod - def _public_pending_context(state: TaskState) -> dict[str, Any] | None: - action = state.pending_action - return { - "node_id": action.node_id, - "plan_hash": action.plan_hash, - "intent": action.intent, - "operation": action.atomic_id, - "expected_change": action.expected_change, - "claim_ids": list(action.claim_ids), - "depends_on_node_ids": list(action.depends_on_node_ids), - } if action else None - - @staticmethod - def _coordinate_protocol(state: TaskState) -> dict[str, str]: - protocol = { - "system": "world_mm_right_handed", - "sketch_mapping": "workplane.origin_mm is the world position of sketch local (0,0); profile points such as circle.center are sketch-local.", - "vectors": "normal is positive extrusion direction; x_dir is sketch local +X expressed in world coordinates.", - "hosted_features": "For existing solids, use the selected max_z/min_z face and its supplied normal; do not infer or hand-copy a world-space offset.", - } + def _publish(self, task_id: str, state: TaskState) -> tuple[str, dict[str, Any]]: if not state.active_revision: - protocol["root_extrusion"] = "Root extrude_add_blind and extrude_add_two_sided are bound to world XY: origin=[0,0,Z], normal=[0,0,1], x_dir=[1,0,0]. Requirements decide Z only; never place a Z offset in Y." - return protocol - - @staticmethod - def _public_operation_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(payload, dict): - return None - return { - "operation": payload.get("atomic_id"), - "contract": payload.get("contract"), - "fragment_schema": payload.get("fragment_schema"), - } - - def _projected_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]: - projection = self.repository.get_task_projection(task_id) or {} - return { - "taskId": task_id, - "lifecycle": str(projection.get("lifecycle") or state.phase.value.lower()), - "revisionId": state.active_revision, - "code": state.last_error.value if state.last_error else "", - "message": str(projection.get("message") or ("CAD generation completed." if state.phase == TaskPhase.COMPLETED else "CAD generation stopped.")), - "questions": projection.get("questions") or [], - "issues": projection.get("issues") or [], - "blockerType": str(projection.get("blocker_type") or ""), - "userActionRequired": bool(projection.get("user_action_required")), - "verificationStatus": str(projection.get("verification_status") or "verified"), - "verificationWarnings": projection.get("verification_warnings") or [], - } - - def _operation_payload(self, task_id: str, state: TaskState) -> dict[str, Any]: - action = state.pending_action - assert action is not None - contract = self.runtime.operation_contract(action.atomic_id) - topology = self.artifacts.read_topology(task_id, state.active_revision) - tokens = self.runtime.selector_tokens(topology) - references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision)) - return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))} - - def _recent_failure_constraints(self, task_id: str, state: TaskState) -> list[dict[str, Any]]: - constraints: list[dict[str, Any]] = [] - for event in reversed(self.repository.ledger_events(task_id)): - is_current_feature_failure = ( - event.get("event") == "feature_node_failed" - and event.get("plan_hash") == state.feature_plan_hash - and state.pending_feature is not None - and event.get("node_id") == state.pending_feature.node_id - ) - if not is_current_feature_failure and event.get("checkpoint_revision") != state.active_revision: - continue - normalized_error_code = str(event.get("normalized_error_code") or event.get("code") or "") - if not normalized_error_code: - continue - constraints.append({ - "atomic_id": str(event.get("atomic_id") or ""), - "normalized_error_code": normalized_error_code, - "fragment_hash": str(event.get("fragment_hash") or ""), - "prohibited_exact_fingerprint": str(event.get("failure_exact_fingerprint") or ""), - "attempt": int(event.get("attempt") or 1), - "message": str(event.get("message") or event.get("reason") or "")[:360], - }) - if len(constraints) == 4: - break - return constraints - - def _feature_replan_exhausted(self, task_id: str, state: TaskState) -> dict[str, Any] | None: - """Bound repeated replacement plans at one immutable checkpoint. - - Node IDs must change on every subgraph revision, so a node-local retry - counter alone cannot stop a planner from replacing the same failed - atomic operation forever. A successful feature creates a new revision; - therefore checkpoint + atomic operation is a stable, narrow boundary - for this cross-plan budget. - """ - if state.phase != TaskPhase.REPLANNING_FEATURE_SUBGRAPH: - return None - terminal = [ - event for event in self.repository.ledger_events(task_id) - if event.get("event") == "feature_node_failed" - and bool(event.get("terminal")) - and str(event.get("checkpoint_revision") or "") == state.active_revision - ] - if not terminal: - return None - atomic_id = str(terminal[-1].get("atomic_id") or "") - if not atomic_id: - return None - matching = [ - event for event in terminal - if str(event.get("atomic_id") or "") == atomic_id - ] - if len(matching) < _FEATURE_REPLAN_FAILURE_LIMIT: - return None - return { - "atomic_id": atomic_id, - "checkpoint_revision": state.active_revision, - "terminal_failure_count": len(matching), - "limit": _FEATURE_REPLAN_FAILURE_LIMIT, - "node_ids": [str(event.get("node_id") or "") for event in matching], - } - - @staticmethod - def _selector_tokens_for_contract(contract: dict[str, Any], tokens: dict[str, dict[str, Any]]) -> list[str]: - """Narrow dynamic selector enums to the current contract's kind.""" - if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") != "required": - return [] - policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {} - kind = str(policy.get("token_kind") or "") - return [token for token, value in tokens.items() if isinstance(value, dict) and value.get("kind") == kind] - - def _topology_payload(self, task_id: str, state: TaskState, kind: str | None, limit: int) -> dict[str, Any]: - tokens = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)) - values = [{"token": token, "kind": item["kind"], "geometry": {key: item["geometry"].get(key) for key in ("center_mm", "normal", "plane_normal", "bbox_mm", "radius_mm", "surface_type") if key in item["geometry"]}} for token, item in tokens.items() if not kind or item["kind"] == kind] - return { - "working_head": state.working_head, - "coordinate_system": "world_mm", - "tokens": values[:limit], - } - - def _can_complete(self, task_id: str, state: TaskState) -> bool: - if not state.active_revision or state.repair_required: - return False - results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision)) - return bool(results) and all(item.get("status") == "pass" for item in results if item.get("deterministic")) - - def _transport_or_failure(self, task_id: str, state: TaskState, error: WorkflowError, author: ModelIdentity, attempted: set[str]) -> tuple[ModelIdentity, tuple[str, dict[str, Any]] | None]: - if error.code != ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE: - failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL) - self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": error.message}]) - return author, ("task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": error.message}) - attempted.add(author.provider_id) - # The adapter already performs bounded exponential retries for the - # active provider. The workflow permits one, and only one, provider - # failover before parking the durable task for explicit recovery. - fallback = next((candidate for candidate in self.config.author_fallbacks if candidate.provider_id not in attempted), None) if len(attempted) == 1 else None - if fallback is not None: - self.repository.append_outbox(task_id, {"event": "author_provider_failover", "from_provider": author.provider_id, "to_provider": fallback.provider_id}) - return fallback, None - waiting = transition(state, "waiting_retry", error=ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE) - self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE.value, "message": error.message}]) - return author, ("task_terminal", {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE.value, "message": error.message}) - - def _service_failure(self, task_id: str, state: TaskState, error: WorkflowError) -> tuple[str, dict[str, Any]]: - waiting = transition(state, "waiting_retry", error=error.code) - self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": error.code.value, "message": error.message}]) - return "task_terminal", {"taskId": task_id, "lifecycle": "waiting_retry", "code": error.code.value, "message": error.message} - - def _model_rejection_or_service_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None: - """Bound no-side-effect model rejections; park real service failures.""" - if error.code == ErrorCode.NO_PROGRESS_LIMIT: - current = self.repository.get_state(task_id) or state - terminal = self._best_effort_terminal( - task_id, - current, - ErrorCode.NO_PROGRESS_LIMIT, - "Further attempts repeated an already failed CAD path; publishing the last executable checkpoint.", - ) - if terminal: - return terminal - feedback[:] = [self._feedback(error)] - return None - if error.code == ErrorCode.RUNTIME_CONTRACT_INVALID: - # Registry integrity is a deployment defect. Retrying a model with - # the same broken contract cannot repair it and must never consume - # the author-format budget. - terminal = self._best_effort_terminal(task_id, state, error.code, error.message) - if terminal: - return terminal - failed = transition(state, "failed", error=error.code) - self.repository.compare_and_swap(failed, events=[{ - "event": "runtime_contract_invalid", - "tool": name, - "message": error.message, - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": error.code.value, - "message": error.message, - } - if error.code == ErrorCode.REQUIREMENTS_SPEC_INVALID: - # A frozen verifier contract is service-owned input at this stage. - # CAD retries cannot repair it, so preserve the terminal diagnosis - # instead of parking the task or blaming the author fragment. - current = self.repository.get_state(task_id) - if current is not None and current.phase != TaskPhase.FAILED: - failed = transition(current, "failed", error=error.code) - self.repository.compare_and_swap(failed, events=[{ - "event": "requirements_contract_execution_failed", - "tool": name, - "message": error.message, - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": error.code.value, - "message": error.message, - "blockerType": "requirements_contract_invalid", - "userActionRequired": False, - "issues": [str(error.details.get("diagnostic") or error.message)], - } - if error.code == ErrorCode.RUNTIME_PRECONDITION_FAILED: - # A schema-valid fragment can still be impossible on the current - # geometry. This is not an author-format failure: preserve the - # accepted checkpoint, discard only the pending action and let - # the author make a fresh, evidence-backed choice. - if state.phase == TaskPhase.ACTION_PENDING: - action = state.pending_action - atomic_id = str(error.details.get("atomic_id") or (action.atomic_id if action else "")) - checkpoint_revision = str(error.details.get("active_revision") or state.active_revision) - normalized_code = str(error.details.get("normalized_error_code") or error.code.value) - failure_class_fingerprint = sha256( - f"{checkpoint_revision}|{atomic_id}|{normalized_code}".encode("utf-8") - ).hexdigest() - prior = [ - event for event in self.repository.ledger_events(task_id) - if event.get("failure_class_fingerprint") == failure_class_fingerprint - ] - if len(prior) >= 2: - next_state = transition(state, "runtime_precondition_rejected", pending_action=None, error=ErrorCode.NO_PROGRESS_LIMIT) - self.repository.compare_and_swap(next_state, events=[{ - "event": "no_progress_limit", - "tool": name, - "code": ErrorCode.NO_PROGRESS_LIMIT.value, - "message": error.message, - "checkpoint_revision": checkpoint_revision, - "atomic_id": atomic_id, - "normalized_error_code": normalized_code, - "failure_class_fingerprint": failure_class_fingerprint, - }]) - terminal = self._best_effort_terminal( - task_id, - next_state, - ErrorCode.NO_PROGRESS_LIMIT, - "The same operation failure made no progress; publishing the last executable checkpoint.", - ) - if terminal: - return terminal - feedback[:] = [self._feedback(error)] - return None - next_state = transition( - state, - "runtime_precondition_rejected", - pending_action=None, - error=ErrorCode.RUNTIME_PRECONDITION_FAILED, - ) - self.repository.compare_and_swap(next_state, events=[{ - "event": "runtime_precondition_rejected", - "tool": name, - "code": error.code.value, - "message": error.message, - "checkpoint_revision": checkpoint_revision, - "atomic_id": atomic_id, - "fragment_hash": error.details.get("fragment_hash"), - "failure_exact_fingerprint": error.details.get("failure_exact_fingerprint"), - "normalized_error_code": normalized_code, - "failure_class_fingerprint": failure_class_fingerprint, - "attempt": len(prior) + 1, - }]) - feedback_error = error - if prior: - feedback_error = WorkflowError( - error.code, - error.message + " A different fragment or operation path is required; do not repeat the proven failure class.", - field_errors=error.field_errors, - details={**error.details, "prohibited_failure_class": failure_class_fingerprint}, - ) - feedback[:] = [self._feedback(feedback_error)] - return None - # A precondition result outside fragment submission is an invalid - # workflow implementation state, not a provider/service outage. - terminal = self._best_effort_terminal(task_id, state, error.code, error.message) - if terminal: - return terminal - failed = transition(state, "failed", error=error.code) - self.repository.compare_and_swap(failed, events=[{ - "event": "runtime_precondition_rejected", - "tool": name, - "code": error.code.value, - "message": error.message, - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": error.code.value, - "message": error.message, - } - if error.code in { - ErrorCode.CANDIDATE_BUILD_FAILED, - ErrorCode.CLAIM_VERIFICATION_FAILED, - ErrorCode.CANDIDATE_REVIEW_REJECTED, - }: - # The handler already preserved the last checkpoint and recorded - # the field/runtime diagnostic. Continue with that evidence; a - # planning miss is not an infrastructure terminal condition. - feedback[:] = [self._feedback(error)] - return None - model_rejection_codes = { - ErrorCode.AUTHOR_FORMAT_INVALID, - ErrorCode.AUTHOR_DECISION_REJECTED, - ErrorCode.STALE_WORKING_HEAD, - } - if error.code in model_rejection_codes: - return self._format_failure(task_id, state, name, error, counters, feedback, actor=actor) - return self._service_failure(task_id, state, error) - - def _format_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None: - counters[name] = counters.get(name, 0) + 1 - feedback[:] = [self._feedback(error)] - if counters[name] < self.config.format_error_limit: - return None - if state.feature_plan_hash: - failed = transition(state, "failed", error=ErrorCode.FAILED_AUTHOR_FORMAT) - self.repository.compare_and_swap(failed, events=[{ - "event": "failed_author_format", "tool": name, "field_errors": list(error.field_errors), - "checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision, - }]) - return "task_terminal", { - "taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision, - "code": ErrorCode.FAILED_AUTHOR_FORMAT.value, - "message": f"{actor.capitalize()} repeatedly failed the canonical schema; the last executable checkpoint remains available.", - "tool": name, "field_errors": list(error.field_errors), - } - terminal = self._best_effort_terminal( + failed = transition(state, "failed", error=ErrorCode.ENGINE_EXECUTION_FAILED) + self.repository.compare_and_swap(failed, events=[{"event": "no_executable_model", "message": "No executable CDSL prefix could be published."}]) + return "task_terminal", self._terminal(failed) + requirements = self.artifacts.read_json(task_id, state.requirements_path) or {} + diagnostics = self.artifacts.read_json(task_id, state.diagnostics_path) or {} + authoring = self.artifacts.read_json(task_id, state.authoring_path) or {} + claim_report_path = self.artifacts.write_json_once( task_id, - state, - ErrorCode.FAILED_AUTHOR_FORMAT, - f"{actor.capitalize()} repeatedly failed the canonical schema; publishing the last executable checkpoint.", + "documents/claim-report.json", + self._claim_report(requirements), ) - if terminal: - return terminal - failed = transition(state, "failed", error=ErrorCode.FAILED_AUTHOR_FORMAT) - self.repository.compare_and_swap(failed, events=[{"event": "failed_author_format", "tool": name, "field_errors": list(error.field_errors)}]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": ErrorCode.FAILED_AUTHOR_FORMAT.value, - "message": f"{actor.capitalize()} repeatedly failed the same canonical schema or state contract.", - "tool": name, - "field_errors": list(error.field_errors), + report = self._completion_report(state, requirements, authoring, diagnostics) + path = self.artifacts.write_text_once(task_id, "completion-result.md", report) + completed = transition(state, "published", completion_path=path) + self.repository.compare_and_swap(completed, events=[{"event": "task_published", "revision_id": state.active_revision, "completion_path": path, "claim_report_path": claim_report_path, "best_effort": bool(diagnostics.get("diagnostics"))}]) + return "task_terminal", self._terminal(completed) + + def _repair_or_stop(self, task_id: str, state: TaskState, code: str, message: str, details: dict[str, Any]) -> tuple[str, dict[str, Any]]: + diagnostic = { + "code": code, + "message": message, + **details, } + diagnostic.setdefault("repair_hint", self._repair_hint(code, str(diagnostic.get("path") or ""))) + diagnostic_path = self.artifacts.write_json_once(task_id, f"documents/diagnostics-attempt-{state.repair_count + 1:02d}.json", {"diagnostics": [diagnostic]}) + error = self._error_code(code) + if state.repair_count < self.config.max_repairs: + repairing = transition(state, "repair_required", diagnostics_path=diagnostic_path, error=error) + self.repository.compare_and_swap(repairing, events=[{"event": "compile_failed", "code": code, "message": message, "diagnostics_path": diagnostic_path}]) + return "cdsl_compiled", {"taskId": task_id, "status": "repair_required", "code": code, "message": message} + if state.active_revision: + publishing = transition( + state, + "publish_best_effort", + diagnostics_path=diagnostic_path, + error=ErrorCode.BEST_EFFORT_COMPLETED, + ) + self.repository.compare_and_swap(publishing, events=[{ + "event": "repair_budget_exhausted", + "code": code, + "message": message, + "diagnostics_path": diagnostic_path, + "revision_id": state.active_revision, + }]) + return "build_result", { + "taskId": task_id, + "status": "published_best_effort", + "code": code, + "message": message, + "revisionId": state.active_revision, + } + failed = transition(state, "failed", diagnostics_path=diagnostic_path, error=error) + self.repository.compare_and_swap(failed, events=[{"event": "compile_failed", "code": code, "message": message, "diagnostics_path": diagnostic_path}]) + return "task_terminal", self._terminal(failed) - def _requirements_format_failure( - self, - task_id: str, - state: TaskState, - error: WorkflowError, - counters: dict[str, int], - feedback: list[dict[str, Any]], - *, - tool: str = "compile_requirements_spec", - ) -> tuple[str, dict[str, Any]] | None: - key = "requirements_spec" - counters[key] = counters.get(key, 0) + 1 - feedback[:] = [self._feedback(error)] - if counters[key] < 2: - return None - failed = transition(state, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID) - self.repository.compare_and_swap(failed, events=[{ - "event": "requirements_spec_invalid", - "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value, - "message": error.message, - "field_errors": list(error.field_errors), - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value, - "message": "Requirements specification remained unreadable after one field-level correction.", - "tool": tool, - "field_errors": list(error.field_errors), - "userActionRequired": False, - } - - def _requirements_rejection( - self, - task_id: str, - state: TaskState, - error: WorkflowError, - counters: dict[str, int], - feedback: list[dict[str, Any]], - *, - tool: str = "compile_requirements_spec", - ) -> tuple[str, dict[str, Any]] | None: - if error.retryable or error.code == ErrorCode.STORAGE_FAILURE: - return self._service_failure(task_id, state, error) - if tool == "write_feature_plan": - # A plan revision is authored against an already-frozen contract. - # Its field errors must use the plan tool's own retry budget, not - # the requirements compiler's shared counter. Otherwise one - # earlier requirements correction can make the first replan - # attempt terminal, even though the state remains perfectly - # recoverable in REPLANNING_FEATURE_SUBGRAPH. - return self._format_failure(task_id, state, tool, error, counters, feedback) - return self._requirements_format_failure(task_id, state, error, counters, feedback, tool=tool) - - @staticmethod - def _tool(name: str, model: type[BaseModel] | dict[str, Any]) -> dict[str, Any]: - parameters = model if isinstance(model, dict) else model.model_json_schema() - return {"type": "function", "function": {"name": name, "description": name.replace("_", " "), "parameters": parameters}} - - @staticmethod - def _result_payload(result: Accepted | Rejected | Waiting) -> dict[str, Any]: - if isinstance(result, Accepted): - return result.payload - return result.error.payload() - - @staticmethod - def _feedback(error: WorkflowError) -> dict[str, Any]: - return {"role": "user", "content": json.dumps({"schema_or_state_error": error.payload(), "instruction": "Correct only the reported fields and return one allowed tool call."}, ensure_ascii=False)} - - @staticmethod - def _pending_context(state: TaskState) -> dict[str, Any] | None: - action = state.pending_action - return {"action_id": action.action_id, "working_head": action.working_head, "intent": action.intent, "requirement_ids": list(action.requirement_ids), "atomic_id": action.atomic_id, "expected_change": action.expected_change, "contract_hash": action.contract_hash} if action else None - - @staticmethod - def _tool_feedback(content: str) -> dict[str, Any] | None: + async def _tool_call(self, task_id: str, author: ModelIdentity, name: str, schema: dict[str, Any], system: str, user: str) -> dict[str, Any]: + response = await self.model_gateway.call_tool( + messages=[{"role": "system", "content": system}, {"role": "user", "content": user}], + tool={"type": "function", "function": {"name": name, "description": "Return one schema-valid object.", "parameters": schema}}, + provider_id=author.provider_id, model_id=author.model_id, required_tool_name=name, + ) + usage = response.get("usage") if isinstance(response.get("usage"), dict) else {} + self.repository.record_usage(task_id, {"role": "author", "tool": name, **usage}) + calls = response.get("tool_calls") if isinstance(response.get("tool_calls"), list) else [] + if len(calls) != 1 or not isinstance(calls[0], dict): + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", "provider did not return exactly one tool call") + function = calls[0].get("function") if isinstance(calls[0].get("function"), dict) else {} + if function.get("name") != name: + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", "provider returned an unexpected tool") try: - value = json.loads(content) - except json.JSONDecodeError: - return None - return value if isinstance(value, dict) else None + value = json.loads(str(function.get("arguments") or "")) + except json.JSONDecodeError as error: + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", "provider returned invalid JSON") from error + if not isinstance(value, dict): + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", "provider did not return an object") + self.repository.record_tool_audit(task_id, {"tool": name, "arguments_sha256": sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()}) + return value @staticmethod - def _event(task_id: str, tool: str, result: dict[str, Any], status: str, usage: dict[str, Any]) -> dict[str, Any]: - return {"taskId": task_id, "tool": tool, "status": status, "result": result, "usage": usage} + def _author_operation_contract(contract: dict[str, Any]) -> dict[str, Any]: + """Expose only author-owned operation facts, including selector shape.""" + selector = contract.get("selector_policy") or {} + fragment = contract.get("fragment_shape") or {} + sketch_mode = fragment.get("sketch") + return { + "params_schema": contract.get("author_params_schema") or {}, + "sketch": sketch_mode, + "authoring_sketch_template": ( + { + "workplane": { + "origin_mm": [0, 0, 0], + "x_dir": [1, 0, 0], + "normal": [0, 0, 1], + }, + "profile": { + "type": "circle", + "diameter_mm": 10, + "center_mm": [0, 0], + }, + } + if sketch_mode == "required" else None + ), + "selector": { + "required": fragment.get("selector_tokens") == "required", + "kind": selector.get("token_kind"), + "min_items": selector.get("min_items"), + "max_items": selector.get("max_items"), + "destination": selector.get("slot"), + "source_syntax": ".", + }, + } @staticmethod - def _invocation_id(task_id: str) -> str: - return f"{task_id}_inv_{secrets.token_hex(8)}" + def _validate_repair_document( + previous: dict[str, Any], replacement: dict[str, Any], diagnostics: dict[str, Any] | None, + compile_audit: dict[str, Any] | None, + ) -> None: + """Keep successful features fixed across complete-document repairs.""" + targeted = { + str(item.get("feature_name") or "") + for item in (diagnostics or {}).get("diagnostics") or () + if isinstance(item, dict) and item.get("feature_name") + } + for item in (diagnostics or {}).get("diagnostics") or (): + if not isinstance(item, dict): + continue + path = str(item.get("path") or "") + if path.startswith("features."): + targeted.add(path.split(".", 2)[1]) + feature_ids = (compile_audit or {}).get("feature_ids") if isinstance(compile_audit, dict) else {} + ids_to_names = { + str(feature_id): str(name) + for name, feature_id in (feature_ids or {}).items() + if isinstance(name, str) and isinstance(feature_id, str) + } + executed = { + ids_to_names[feature_id] + for feature_id in (diagnostics or {}).get("executed_feature_ids") or () + if isinstance(feature_id, str) and feature_id in ids_to_names + } + old_features = { + str(feature.get("name") or ""): feature + for body in previous.get("bodies") or () if isinstance(body, dict) + for feature in body.get("features") or () if isinstance(feature, dict) + } + new_features = { + str(feature.get("name") or ""): feature + for body in replacement.get("bodies") or () if isinstance(body, dict) + for feature in body.get("features") or () if isinstance(feature, dict) + } + for name in executed: + if name in targeted: + continue + old_feature = old_features.get(name) + if new_features.get(name) != old_feature: + raise AuthoringCompileError( + "AUTHOR_SCHEMA_INVALID", + f"repair changed successful feature {name}", + path=f"features.{name}", + ) + + def _fail(self, task_id: str, state: TaskState, code: ErrorCode, message: str, *, retryable: bool) -> None: + current = self.repository.get_state(task_id) + if current is None or current.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: + return + failed = transition(current, "failed", error=code) + self.repository.compare_and_swap(failed, events=[{"event": "service_failure", "code": code.value, "message": message[:1000], "retryable": retryable}]) + + @staticmethod + def _error_code(value: str) -> ErrorCode: + try: + return ErrorCode(value) + except ValueError: + return ErrorCode.AUTHOR_SCHEMA_INVALID + + @staticmethod + def _author_validation_details(error: Exception) -> dict[str, Any]: + """Preserve one exact Pydantic location for a complete-document repair.""" + errors = getattr(error, "errors", None) + if not callable(errors): + return {"path": getattr(error, "path", "")} + values = errors() + if not values or not isinstance(values[0], dict): + return {"path": getattr(error, "path", "")} + location = values[0].get("loc") + path = ".".join(str(item) for item in location) if isinstance(location, tuple) else "" + return {"path": path, "schema_error": str(values[0].get("msg") or "")} + + @staticmethod + def _repair_hint(code: str, path: str) -> str: + if ".sketch" in path or "sketch" in path: + return ( + "Use exactly sketch.workplane {origin_mm, x_dir, normal} and " + "sketch.profile. A circle is {type: circle, diameter_mm, center_mm}; " + "do not use profiles, plane, support, radius_mm, or center." + ) + if ".selectors" in path or code.startswith("SELECTOR_"): + return ( + "Use one declarative selector {kind, source: '.', " + "match: 'unique'}. Do not add role, query, host_face, face indexes, or tokens." + ) + if code == "AUTHOR_FORBIDDEN_FIELD": + return "Remove the forbidden runtime identity or server-injected field. Use only local names and declarative selectors." + if code == "AUTHOR_REFERENCE_INVALID": + return "Reference an existing local feature name and exact output role; the compiler records the selector source as a dependency." + return "Return the complete document with the named diagnostic corrected. Preserve executed features unless the diagnostic targets them." + + @staticmethod + def _claim_report(requirements: dict[str, Any]) -> dict[str, Any]: + targets = requirements.get("acceptance_targets") if isinstance(requirements.get("acceptance_targets"), list) else [] + claims = [ + { + "target": str(target.get("kind") or "target"), + "status": "pending", + "verification": str(target.get("verification") or "manual"), + } + for target in targets + if isinstance(target, dict) + ] + manual = requirements.get("manual_targets") if isinstance(requirements.get("manual_targets"), list) else [] + claims.extend({"target": item, "status": "pending", "verification": "manual"} for item in manual if isinstance(item, str)) + if not claims: + claims.append({"target": "no deterministic target declared", "status": "not_applicable", "verification": "manual"}) + return {"schema_version": "cad.requirement-claim-report.v1", "claims": claims} + + @staticmethod + def _completion_report( + state: TaskState, + requirements: dict[str, Any], + authoring: dict[str, Any], + diagnostics: dict[str, Any], + ) -> str: + lines = ["# CAD Generation Result", "", f"Published revision: `{state.active_revision}`", "", "## Generated Model", ""] + bodies = authoring.get("bodies") if isinstance(authoring.get("bodies"), list) else [] + for body in bodies: + if not isinstance(body, dict): + continue + lines.append(f"- body: {body.get('name', 'body')}") + features = body.get("features") if isinstance(body.get("features"), list) else [] + for feature in features: + if isinstance(feature, dict): + lines.append(f"- feature: {feature.get('name', 'feature')} ({feature.get('operation', 'operation')})") + lines.extend(["", "## Requested Requirements", ""]) + lines.extend(f"- {item}" for item in requirements.get("explicit_requirements", []) if isinstance(item, str)) + lines.extend(["", "## Requirement Compliance", ""]) + targets = requirements.get("acceptance_targets") if isinstance(requirements.get("acceptance_targets"), list) else [] + if targets: + lines.extend(f"- pending: {item.get('kind', 'target')}" for item in targets if isinstance(item, dict)) + else: + lines.append("- not_applicable: no deterministic acceptance target was declared") + lines.extend(f"- pending: {item}" for item in requirements.get("manual_targets", []) if isinstance(item, str)) + lines.extend(["", "## Assumptions", ""]) + lines.extend(f"- {item}" for item in requirements.get("assumptions", []) if isinstance(item, str)) + lines.extend(["", "## Execution", ""]) + failures = diagnostics.get("diagnostics") if isinstance(diagnostics.get("diagnostics"), list) else [] + if failures: + lines.append("The executable prefix was published with unresolved operations:") + lines.extend(f"- {item.get('code', 'ENGINE_EXECUTION_FAILED')}: {item.get('feature_name') or item.get('feature_id') or 'document'}: {item.get('message', item)}" for item in failures if isinstance(item, dict)) + else: + lines.append("The complete compiled CDSL executed successfully.") + lines.extend(["", "## Planning Limitations", ""]) + lines.append("Requirement compliance is reported separately from executable publication. Pending targets require deterministic measurement or user review.") + lines.extend(["", "## Delivery Artifacts", ""]) + root = f"revisions/{state.active_revision}" + lines.extend([ + f"- STEP: {root}/model.step", + f"- GLB: {root}/model.glb", + f"- Render bundle: {root}/renders/render-manifest.json", + f"- Runtime CDSL: {root}/model.cdsl.json", + f"- Build diagnostics: {state.diagnostics_path or root + '/build-diagnostics.json'}", + "- Requirement compliance: documents/claim-report.json", + f"- Authoring CDSL: {state.authoring_path}", + f"- Compile audit: {state.compile_audit_path}", + ]) + lines.extend(["", "## Repair Budget", ""]) + reason = "all compiled features executed" if not failures else "published the best executable prefix after unresolved diagnostics" + lines.append(f"- repair calls used: {state.repair_count}/2") + lines.append(f"- stop reason: {reason}") + return "\n".join(lines) + "\n" + + @staticmethod + def _terminal(state: TaskState) -> dict[str, Any]: + lifecycle = "completed" if state.phase == TaskPhase.COMPLETED else "cancelled" if state.phase == TaskPhase.CANCELLED else "failed" + return {"taskId": state.task_id, "lifecycle": lifecycle, "revisionId": state.active_revision, "repairCount": state.repair_count, "completionPath": state.completion_path, "diagnosticsPath": state.diagnostics_path, "code": state.last_error.value if state.last_error else "", "message": "CAD result published." if lifecycle == "completed" else "CAD generation stopped.", "userActionRequired": False} diff --git a/backend/app/cad_agent/composition.py b/backend/app/cad_agent/composition.py index 13287c9c..de88461e 100644 --- a/backend/app/cad_agent/composition.py +++ b/backend/app/cad_agent/composition.py @@ -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) diff --git a/backend/app/cad_agent/domain/__init__.py b/backend/app/cad_agent/domain/__init__.py index abb758e6..9c2c8fcf 100644 --- a/backend/app/cad_agent/domain/__init__.py +++ b/backend/app/cad_agent/domain/__init__.py @@ -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 diff --git a/backend/app/cad_agent/domain/claim_matching.py b/backend/app/cad_agent/domain/claim_matching.py deleted file mode 100644 index 3a40aab8..00000000 --- a/backend/app/cad_agent/domain/claim_matching.py +++ /dev/null @@ -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 diff --git a/backend/app/cad_agent/domain/errors.py b/backend/app/cad_agent/domain/errors.py index a585b7c7..04159faa 100644 --- a/backend/app/cad_agent/domain/errors.py +++ b/backend/app/cad_agent/domain/errors.py @@ -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) diff --git a/backend/app/cad_agent/domain/feature_plan.py b/backend/app/cad_agent/domain/feature_plan.py deleted file mode 100644 index 494d2f69..00000000 --- a/backend/app/cad_agent/domain/feature_plan.py +++ /dev/null @@ -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 - ) diff --git a/backend/app/cad_agent/domain/operation_contract.py b/backend/app/cad_agent/domain/operation_contract.py index 3569e4fd..1b37029a 100644 --- a/backend/app/cad_agent/domain/operation_contract.py +++ b/backend/app/cad_agent/domain/operation_contract.py @@ -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,6 +38,11 @@ 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", @@ -111,113 +96,33 @@ 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, - } - 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} diff --git a/backend/app/cad_agent/domain/state.py b/backend/app/cad_agent/domain/state.py index d017247e..957e7c1c 100644 --- a/backend/app/cad_agent/domain/state.py +++ b/backend/app/cad_agent/domain/state.py @@ -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 diff --git a/backend/app/cad_agent/evals/TOKEN_BASELINE.md b/backend/app/cad_agent/evals/TOKEN_BASELINE.md deleted file mode 100644 index 1eb07d03..00000000 --- a/backend/app/cad_agent/evals/TOKEN_BASELINE.md +++ /dev/null @@ -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%. diff --git a/backend/app/cad_agent/evals/__init__.py b/backend/app/cad_agent/evals/__init__.py index 2a0ae0f0..f5e60eb3 100644 --- a/backend/app/cad_agent/evals/__init__.py +++ b/backend/app/cad_agent/evals/__init__.py @@ -1 +1 @@ -"""Live, non-mocked protocol v3 release evaluations.""" +"""Local evaluation helpers for the single-stage Authoring protocol.""" diff --git a/backend/app/cad_agent/evals/create_isolated_task.py b/backend/app/cad_agent/evals/create_isolated_task.py index cf0c236e..8ee80979 100644 --- a/backend/app/cad_agent/evals/create_isolated_task.py +++ b/backend/app/cad_agent/evals/create_isolated_task.py @@ -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", diff --git a/backend/app/cad_agent/evals/fixtures/comprehensive.json b/backend/app/cad_agent/evals/fixtures/comprehensive.json deleted file mode 100644 index af596fc2..00000000 --- a/backend/app/cad_agent/evals/fixtures/comprehensive.json +++ /dev/null @@ -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 - } - ] -} diff --git a/backend/app/cad_agent/evals/fixtures/release.json b/backend/app/cad_agent/evals/fixtures/release.json deleted file mode 100644 index 23867fba..00000000 --- a/backend/app/cad_agent/evals/fixtures/release.json +++ /dev/null @@ -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 - } - ] -} diff --git a/backend/app/cad_agent/evals/live.py b/backend/app/cad_agent/evals/live.py deleted file mode 100644 index 593767b1..00000000 --- a/backend/app/cad_agent/evals/live.py +++ /dev/null @@ -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()) diff --git a/backend/app/cad_agent/evals/resume_one_step.py b/backend/app/cad_agent/evals/resume_one_step.py deleted file mode 100644 index 72456d84..00000000 --- a/backend/app/cad_agent/evals/resume_one_step.py +++ /dev/null @@ -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()) diff --git a/backend/app/cad_agent/evals/single_stage.py b/backend/app/cad_agent/evals/single_stage.py new file mode 100644 index 00000000..a002032d --- /dev/null +++ b/backend/app/cad_agent/evals/single_stage.py @@ -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, + } diff --git a/backend/app/cad_agent/evals/token_baseline.py b/backend/app/cad_agent/evals/token_baseline.py deleted file mode 100644 index b9f2a47b..00000000 --- a/backend/app/cad_agent/evals/token_baseline.py +++ /dev/null @@ -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 diff --git a/backend/app/cad_agent/evals/usable_smoke.py b/backend/app/cad_agent/evals/usable_smoke.py deleted file mode 100644 index 39b9bda4..00000000 --- a/backend/app/cad_agent/evals/usable_smoke.py +++ /dev/null @@ -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()) diff --git a/backend/app/cad_agent/ports.py b/backend/app/cad_agent/ports.py index dc88cff5..9672943b 100644 --- a/backend/app/cad_agent/ports.py +++ b/backend/app/cad_agent/ports.py @@ -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: ... diff --git a/backend/app/main.py b/backend/app/main.py index d9e025d0..b1321793 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index 3d8b80c6..eeff302d 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -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 diff --git a/backend/app/services/engine_service.py b/backend/app/services/engine_service.py index 2ea2d5a1..78f9a1fd 100644 --- a/backend/app/services/engine_service.py +++ b/backend/app/services/engine_service.py @@ -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: diff --git a/backend/app/services/review_renderer.py b/backend/app/services/render_bundle.py similarity index 91% rename from backend/app/services/review_renderer.py rename to backend/app/services/render_bundle.py index 1c57d6cc..47eb8415 100644 --- a/backend/app/services/review_renderer.py +++ b/backend/app/services/render_bundle.py @@ -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) diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index 51f1f5b1..05b849df 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -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``. """ diff --git a/backend/app/settings.py b/backend/app/settings.py index 49a6c07d..80273c85 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -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 diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 9fce3d51..1daa9c0f 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -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 @@ -1131,6 +1131,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 +1268,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( diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index f5ff8c76..fe09e454 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -82,6 +82,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 +556,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", diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index cbab8ca2..fc597dba 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -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"], diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index aa7cb729..56b9198a 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -114,6 +114,7 @@ class GeometryAdapter(Protocol): def cut(self, body: Any, tool: Any) -> Any: ... def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ... def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... + def cylinder_with_topology_delta(self, radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> tuple[Any, TopologyDelta]: ... def thread_solid(self, spec: ThreadSpec) -> Any: ... def bend_solid(self, spec: BendSpec) -> Any: ... def gear_solid(self, spec: GearSpec) -> Any: ... @@ -232,11 +233,15 @@ class ExecutionSession: binding_feature_id = selector.get("binding_feature_id") active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))] - failures = [item for item in resolved if item.status != "resolved" or item.record is None] + failures = [item for item in resolved if item.status != "resolved" or (item.record is None and not item.records)] if failures: detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved" raise FeatureExecutionError("intersection_selector_component_unresolved", detail) - return [item.record for item in resolved if item.record is not None] + return [ + record + for item in resolved + for record in (item.records or ((item.record,) if item.record is not None else ())) + ] def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution: components = selector.get("intersection_of") @@ -1108,6 +1113,8 @@ def _register_added_solid( session: ExecutionSession, node: FeaturePlanNode, solid: Any, + *, + topology_delta: TopologyDelta | None = None, ) -> None: """Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend). @@ -1121,10 +1128,57 @@ def _register_added_solid( if node.params.get("result_mode") == "new_body": combined = session.adapter.combine(session.body, solid) members = {**session.body_members, node.feature_id: solid} - session.register_body(node.feature_id, combined, replay_node=node, body_members=members) + session.register_body( + node.feature_id, combined, replay_node=node, body_members=members, + topology_delta=topology_delta, + ) return - fused = session.adapter.fuse(session.body, solid) - session.register_body(node.feature_id, fused, replay_node=node) + if session.body is None: + session.register_body(node.feature_id, solid, replay_node=node, topology_delta=topology_delta) + return + if topology_delta is None: + session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + return + # Preserve primitive output roles only through the exact OCC fuse history. + # The temporary records are not selectable snapshots; they provide the + # source handles that let TopologyRegistry carry a role to the final body. + 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 _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: @@ -1187,9 +1241,9 @@ def _execute_cylinder(node: FeaturePlanNode, session: ExecutionSession) -> Featu raise ValueError("cylinder_add axis must define origin_mm and direction") axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None # 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。 - solid = session.adapter.cylinder(radius, height, axis) + solid, topology_delta = session.adapter.cylinder_with_topology_delta(radius, height, axis) # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) + _register_added_solid(session, node, solid, topology_delta=topology_delta) return session.result(node) @@ -1395,10 +1449,12 @@ def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent edges: list[Any] = [] for item in resolved: - if item.record.kind == "edge": - edges.append(item.record.value) - elif item.record.kind == "face": - edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge)) + records = item.records or ((item.record,) if item.record is not None else ()) + for record in records: + if record.kind == "edge": + edges.append(record.value) + elif record.kind == "face": + edges.extend(edge for edge in record.value.edges() if is_body_boundary(edge)) if not edges: raise ValueError("selectors did not resolve any edges") return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges @@ -1412,7 +1468,11 @@ def _shell_target(node: FeaturePlanNode, session: ExecutionSession) -> tuple[Any failed = next((item for item in resolved if item.status != "resolved"), None) if failed: raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") - records = [item.record for item in resolved if item.record is not None] + records = [ + record + for item in resolved + for record in (item.records or ((item.record,) if item.record is not None else ())) + ] if not records or any(record.kind != "face" for record in records): raise ValueError("shell selectors must resolve to faces") target_ids = {record.body_id for record in records} diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index d2b06446..770e64bc 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -768,6 +768,13 @@ class TopologyDeltaRelation: source_value: Any result_values: tuple[Any, ...] = () output_role: str | None = None + derivation: str | None = None + source_role: str | None = None + result_role: str | None = None + source_slot: str | None = None + result_slot: str | None = None + coverage: str = "complete" + status: str = "proven" def __post_init__(self) -> None: if self.event not in {"preserved", "modified", "generated", "deleted"}: @@ -778,6 +785,21 @@ class TopologyDeltaRelation: raise ValueError("deleted topology delta relations cannot have result values or an output role") if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role): raise ValueError("topology delta output_role must be a non-empty string when provided") + derivation = self.derivation + if derivation is None: + derivation = { + "preserved": "continuation", + "modified": "fragment" if len(self.result_values) > 1 else "continuation", + "generated": "boundary", + "deleted": "replacement", + }[self.event] + object.__setattr__(self, "derivation", derivation) + if derivation not in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"}: + raise ValueError(f"unsupported topology derivation {derivation!r}") + if self.coverage not in {"complete", "partial", "none"}: + raise ValueError(f"unsupported topology coverage {self.coverage!r}") + if self.status not in {"proven", "unknown", "rejected"}: + raise ValueError(f"unsupported topology relation status {self.status!r}") @dataclass(frozen=True) @@ -791,6 +813,38 @@ class TopologyDelta: operation: str relations: tuple[TopologyDeltaRelation, ...] = () + # Boolean section edges are not ordinary source continuations. They are + # retained independently so INTERSECT selectors can require this kernel + # evidence instead of matching a nearby edge geometrically. + section_values: tuple[Any, ...] = () + + +@dataclass(frozen=True) +class TopologyLineage: + """An N:M semantic edge backed by an adapter history relation.""" + + source_record_ids: tuple[str, ...] + result_record_ids: tuple[str, ...] + derivation: str + evidence: str + coverage: str + status: str + operation: str + output_role: str | None = None + + def as_dict(self) -> dict[str, Any]: + result = { + "source_record_ids": list(self.source_record_ids), + "result_record_ids": list(self.result_record_ids), + "derivation": self.derivation, + "evidence": self.evidence, + "coverage": self.coverage, + "status": self.status, + "operation": self.operation, + } + if self.output_role is not None: + result["output_role"] = self.output_role + return result @dataclass(frozen=True) @@ -798,13 +852,29 @@ class SelectorResolution: selector: dict[str, Any] status: str record: TopologyRecord | None = None + # ``records`` carries policy-authorized 1:N selector results. ``record`` + # remains the compatibility field for a unique selection and context use. + records: tuple[TopologyRecord, ...] = () candidates: tuple[dict[str, Any], ...] = () diagnostic: RuntimeDiagnostic | None = None + @property + def resolution_mode(self) -> str: + if self.status == "resolved": + if self.records: + return "kernel_lineage" + if self.selector.get("output_role"): + return "operation_role" + if self.selector.get("selector_intent"): + return "kernel_lineage" + return "geometry" + return "unresolved" + def as_dict(self) -> dict[str, Any]: output = { "selector": self.selector, "status": self.status, + "resolution_mode": self.resolution_mode, "candidates": list(self.candidates), } if self.record is not None: @@ -816,8 +886,15 @@ class SelectorResolution: ) if score is not None: output["score"] = score + if self.records: + output["records"] = [record.public_dict() for record in self.records] if self.diagnostic is not None: output["diagnostic"] = self.diagnostic.as_dict() + if self.status == "resolved": + output["evidence"] = { + "source_records": [record.record_id for record in (self.records or ((self.record,) if self.record else ()))], + "result_records": [record.record_id for record in (self.records or ((self.record,) if self.record else ()))], + } return output @@ -829,6 +906,7 @@ class TopologyRegistry: self._by_feature: dict[str, list[TopologyRecord]] = {} self._active_body_id: str | None = None self._topology_deltas: list[dict[str, Any]] = [] + self._lineage: list[TopologyLineage] = [] # #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer # 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何 # 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的 @@ -849,6 +927,10 @@ class TopologyRegistry: """Return serializable evidence derived from exact adapter history.""" return tuple(self._topology_deltas) + def lineage(self) -> tuple[TopologyLineage, ...]: + """Return kernel-backed N:M lineage without heuristic successors.""" + return tuple(self._lineage) + def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord: kind = "plane" if isinstance(context, PlaneSpec) else "axis" record = TopologyRecord( @@ -983,10 +1065,25 @@ class TopologyRegistry: if successor_id not in known: known.append(successor_id) if delta_evidence is not None: + operation_lineage = [ + TopologyLineage( + source_record_ids=tuple(item["source_record_ids"]), + result_record_ids=tuple(item["result_record_ids"]), + derivation=str(item["derivation"]), + evidence="kernel_history", + coverage=str(item["coverage"]), + status=str(item["lineage_status"]), + operation=topology_delta.operation, + output_role=item.get("output_role"), + ) + for item in delta_evidence + ] + self._lineage.extend(operation_lineage) self._topology_deltas.append({ "feature_id": feature_id, "operation": topology_delta.operation, "relations": delta_evidence, + "lineage": [lineage.as_dict() for lineage in operation_lineage], }) # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 @@ -1076,7 +1173,14 @@ class TopologyRegistry: "source_record_ids": [record.record_id for record in sources], "result_record_ids": [record.record_id for record in outputs], "proof": "kernel_history", + "derivation": relation.derivation, + "coverage": relation.coverage if len(outputs) == len(relation.result_values) else "partial", + "lineage_status": relation.status, } + for field_name in ("source_role", "result_role", "source_slot", "result_slot"): + value = getattr(relation, field_name) + if value is not None: + item[field_name] = value if relation.output_role is not None: item["output_role"] = relation.output_role role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1 @@ -1106,6 +1210,24 @@ class TopologyRegistry: else: relation_links.append(None) evidence.append(item) + if topology_delta.section_values: + section_outputs = [ + record for record in current + if record.kind == "edge" + and any(cls._same_topology_value(record.value, value) for value in topology_delta.section_values) + ] + evidence.append({ + "event": "generated", + "kind": "edge", + "source_record_ids": [], + "result_record_ids": [record.record_id for record in section_outputs], + "proof": "kernel_history", + "derivation": "intersection", + "coverage": "complete" if len(section_outputs) == len(topology_delta.section_values) else "partial", + "lineage_status": "proven" if len(section_outputs) == len(topology_delta.section_values) else "unknown", + "section_edge": True, + "status": "recorded_section_edge", + }) predecessors = { result_id: next(iter(source_ids)) for result_id, source_ids in candidate_sources.items() @@ -1125,6 +1247,8 @@ class TopologyRegistry: item["status"] = "non_unique_or_incomplete" else: item["status"] = "recorded_without_owner_transfer" + if item["coverage"] != "complete" or relation.status != "proven": + item["lineage_status"] = "unknown" return ( predecessors, successors, @@ -1134,6 +1258,45 @@ class TopologyRegistry: evidence, ) + def _intent_lineage_successors( + self, + source_record_id: str, + *, + allowed: set[str], + active_body_id: str | None, + ) -> list[TopologyRecord]: + """Follow only complete, proven CDSL lineage edges to active records.""" + pending = [source_record_id] + visited = {source_record_id} + result_ids: set[str] = set() + while pending: + current = pending.pop() + for edge in self._lineage: + if ( + current not in edge.source_record_ids + or edge.derivation not in allowed + or edge.evidence != "kernel_history" + or edge.coverage != "complete" + or edge.status != "proven" + ): + continue + for record_id in edge.result_record_ids: + if record_id not in visited: + visited.add(record_id) + pending.append(record_id) + result_ids.add(record_id) + active: list[TopologyRecord] = [] + for record in self._records: + if record.record_id not in result_ids: + continue + if active_body_id is not None and not ( + record.body_id == active_body_id + or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) + ): + continue + active.append(record) + return active + @staticmethod def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: try: @@ -1330,6 +1493,8 @@ class TopologyRegistry: ) -> SelectorResolution: kind = selector.get("kind") owner = selector.get("owner_feature_id") + intent = selector.get("selector_intent") + provenance_intent = isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC" candidates = [record for record in self._records if record.kind == kind] if active_body_id and kind in {"face", "edge", "vertex", "body"}: # #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体 @@ -1500,6 +1665,49 @@ class TopologyRegistry: record.body_id == active_body_id or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) ) + if not is_active and provenance_intent: + policy = intent.get("derivation_policy") or {} + allowed = { + value for value in policy.get("allowed") or () + if value in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"} + } + successors = self._intent_lineage_successors( + record.record_id, + allowed=allowed, + active_body_id=active_body_id, + ) + if len(successors) == 1: + record = successors[0] + is_active = True + elif len(successors) > 1: + if policy.get("multiplicity") == "all_fragments" and "fragment" in allowed: + return SelectorResolution( + selector=selector, + status="resolved", + records=tuple(successors), + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), + ) + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), + diagnostic=RuntimeDiagnostic( + code="selector_relation_non_unique", + message="The proven topology lineage has more than one active result", + detail={"stable_id": stable_id, "candidate_count": len(successors), "multiplicity": policy.get("multiplicity")}, + ), + ) + else: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_kernel_history_missing", + message="No complete proven lineage reaches an active topology record", + detail={"stable_id": stable_id, "allowed": sorted(allowed)}, + ), + ) if not is_active: successors = [ candidate for candidate in stable_records @@ -1579,6 +1787,24 @@ class TopologyRegistry: detail={"minimum_score": minimum_score}, ), ) + if provenance_intent: + evidence = intent.get("evidence") + code = "selector_geometry_only" if evidence == "geometry_hint" else "selector_kernel_history_missing" + message = ( + "A FeatureScript provenance selector cannot resolve from geometry alone" + if code == "selector_geometry_only" + else "The FeatureScript selector has no stable active record or complete kernel history" + ) + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code=code, + message=message, + detail={"query_family": intent.get("query_family")}, + ), + ) scored: list[tuple[float, TopologyRecord]] = [] for candidate in candidates: # An owner-qualified context selector is deterministic when it has diff --git a/backend/engine/cdsl_engine/semantic_validation.py b/backend/engine/cdsl_engine/semantic_validation.py index 6b300977..81d8ba17 100644 --- a/backend/engine/cdsl_engine/semantic_validation.py +++ b/backend/engine/cdsl_engine/semantic_validation.py @@ -32,6 +32,66 @@ def _mappings(value: Any): yield from _mappings(child) +def _contract_selectors(feature: dict[str, Any], contract: dict[str, Any]) -> list[dict[str, Any]]: + """Return the selectors at the operation contract's declared slot.""" + slot = str(contract.get("selector_slot") or "") + if slot == "feature.selectors": + values = feature.get("selectors") or [] + elif slot.startswith("params.") and slot.count(".") == 1: + value = (feature.get("params") or {}).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 _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: int) -> None: + """Enforce the provenance boundary before the runtime can bind a selector.""" + intent = selector.get("selector_intent") + if intent is None: + if selector.get("selector_intent_version") is not None: + raise ValueError(f"Feature {feature_id} selector {index} declares an intent version without selector_intent") + return + if selector.get("selector_intent_version") not in {None, "1.0"}: + raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent version") + if not isinstance(intent, dict) or intent.get("version") != "1.0": + raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent") + if intent.get("kind") not in {None, selector.get("kind")}: + raise ValueError(f"Feature {feature_id} selector {index} intent kind differs from selector kind") + family = intent.get("query_family") + derived = {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY"} + if family in derived and not selector.get("owner_feature_id"): + raise ValueError(f"Feature {feature_id} selector {index} derived intent requires owner_feature_id") + policy = intent.get("derivation_policy") or {} + allowed = policy.get("allowed") or [] + if not allowed or any(value not in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"} for value in allowed): + raise ValueError(f"Feature {feature_id} selector {index} has an invalid lineage derivation policy") + if policy.get("multiplicity") not in {"one", "all_fragments", "source_qualified", "none"}: + raise ValueError(f"Feature {feature_id} selector {index} has an invalid lineage multiplicity") + if policy.get("multiplicity") == "all_fragments" and "fragment" not in allowed: + raise ValueError(f"Feature {feature_id} selector {index} all_fragments policy requires fragment lineage") + if family in derived and intent.get("evidence") == "geometry_hint": + raise ValueError(f"Feature {feature_id} selector {index} cannot use geometry_hint for FeatureScript provenance") + if selector.get("owner_match_required") and intent.get("evidence") == "geometry_hint": + raise ValueError(f"Feature {feature_id} selector {index} owner match cannot use geometry fallback") + source_query = intent.get("source_query") or {} + version = source_query.get("featurescript_version") if isinstance(source_query, dict) else None + if not isinstance(version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version): + raise ValueError(f"Feature {feature_id} selector {index} has an unknown FeatureScript query version") + if intent.get("output_role") is not None and intent.get("output_role") != selector.get("output_role"): + raise ValueError(f"Feature {feature_id} selector {index} intent output role differs from selector output role") + forbidden = {"runtime_id", "record_id", "topology_record_id", "task_id", "revision_id"} + stack = [intent] + while stack: + value = stack.pop() + if isinstance(value, dict): + if forbidden.intersection(value): + raise ValueError(f"Feature {feature_id} selector {index} intent contains a runtime identifier") + stack.extend(value.values()) + elif isinstance(value, list): + stack.extend(value) + + @lru_cache(maxsize=1) def _schema() -> dict[str, Any]: path = Path(__file__).with_name("cdsl_schema.json") @@ -69,7 +129,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: """ 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") schema_error = _schema_error(cdsl) if schema_error: @@ -124,15 +184,21 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: if feature.get("execution_status") == "deferred": deferred.append(fid) contract = contracts.get(str(feature.get("atomic_id") or "")) or {} - feature_selectors = feature.get("selectors") or [] + feature_selectors = _contract_selectors(feature, contract) output_role_selector_ids = {id(selector) for selector in feature_selectors if isinstance(selector, dict)} + selector_intent_ids = { + id(selector.get("selector_intent")) + for selector in feature_selectors + if isinstance(selector, dict) and isinstance(selector.get("selector_intent"), dict) + } for index, selector in enumerate(feature_selectors): + _validate_selector_intent(selector, fid, index) owner = selector.get("owner_feature_id") binding_owner = selector.get("binding_feature_id") if owner is not None and owner not in feature_ids and binding_owner not in feature_ids: raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id") if selector.get("output_role") is not None: - if contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face": + if not contract.get("selector_slot") or contract.get("selector_token_kind") != "face": raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role") if selector.get("kind") != "face" or not owner or owner not in feature_ids: raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id") @@ -174,8 +240,9 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: selector.get("output_role") is not None and selector.get("kind") is not None and id(selector) not in output_role_selector_ids + and id(selector) not in selector_intent_ids ): - raise ValueError(f"Feature {fid} output role selectors are only supported in feature.selectors") + raise ValueError(f"Feature {fid} output role selector is outside its operation contract slot") if feature.get("atomic_id") == "shell": target_feature_id = (feature.get("params") or {}).get("target_feature_id") if target_feature_id is not None and target_feature_id not in feature_ids: diff --git a/backend/tests/test_agent_service.py b/backend/tests/test_agent_service.py new file mode 100644 index 00000000..f3ee0bbe --- /dev/null +++ b/backend/tests/test_agent_service.py @@ -0,0 +1,80 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository +from app.cad_agent.domain.state import transition +from app.services.agent_service import AgentService + + +def test_result_payload_contains_all_published_artifacts() -> None: + payload = AgentService._result_payload("cad_abcdefghijkl", { + "lifecycle": "completed", + "published_revision": "rev_prefix", + "revisions": [{ + "revision_id": "rev_prefix", + "cdsl_path": "revisions/rev_prefix/model.cdsl.json", + "step_path": "revisions/rev_prefix/model.step", + "glb_path": "revisions/rev_prefix/model.glb", + "report_path": "revisions/rev_prefix/rebuild-report.json", + }], + }) + + assert payload == { + "taskId": "cad_abcdefghijkl", + "revisionId": "rev_prefix", + "cdslPath": "revisions/rev_prefix/model.cdsl.json", + "stepPath": "revisions/rev_prefix/model.step", + "glbPath": "revisions/rev_prefix/model.glb", + "reportPath": "revisions/rev_prefix/rebuild-report.json", + "summary": "CDSL CAD model", + "referenceIds": [], + "engine": "cdsl_only", + "lifecycle": "completed", + } + + +def test_best_effort_projection_retains_prefix_artifact_paths() -> None: + with TemporaryDirectory() as temporary: + repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") + state = repository.create_task("cad_abcdefghijkl", "make a plate") + state = transition(state, "analysis_written", requirements_path="documents/requirements-analysis.json") + assert repository.compare_and_swap(state) + state = transition(state, "authoring_written", authoring_path="documents/authoring-cdsl-attempt-01.json") + assert repository.compare_and_swap(state) + state = transition(state, "compiled", runtime_cdsl_path="documents/runtime-cdsl-attempt-01.json") + assert repository.compare_and_swap(state) + state = transition(state, "repair_required", active_revision="rev_prefix") + assert repository.compare_and_swap(state, events=[{ + "event": "build_failed", + "revision_id": "rev_prefix", + "paths": { + "model.cdsl.json": "revisions/rev_prefix/model.cdsl.json", + "model.step": "revisions/rev_prefix/model.step", + "model.glb": "revisions/rev_prefix/model.glb", + "rebuild-report.json": "revisions/rev_prefix/rebuild-report.json", + }, + }]) + state = transition(state, "repair_started", repair_count=1) + assert repository.compare_and_swap(state) + state = transition(state, "repair_required") + assert repository.compare_and_swap(state) + state = transition(state, "repair_started", repair_count=2) + assert repository.compare_and_swap(state) + state = transition(state, "publish_best_effort") + assert repository.compare_and_swap(state, events=[{"event": "repair_budget_exhausted"}]) + state = transition(state, "published") + assert repository.compare_and_swap(state) + + projection = repository.get_task_projection("cad_abcdefghijkl") + assert projection is not None + result = AgentService._result_payload("cad_abcdefghijkl", projection) + assert result is not None + assert result["revisionId"] == "rev_prefix" + assert result["stepPath"] == "revisions/rev_prefix/model.step" + + +def test_result_payload_rejects_incomplete_artifact_sets() -> None: + assert AgentService._result_payload("cad_abcdefghijkl", { + "published_revision": "rev_prefix", + "revisions": [{"revision_id": "rev_prefix", "step_path": "revisions/rev_prefix/model.step"}], + }) is None diff --git a/backend/tests/test_author_guidance.py b/backend/tests/test_author_guidance.py deleted file mode 100644 index fe734dfd..00000000 --- a/backend/tests/test_author_guidance.py +++ /dev/null @@ -1,158 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -from pathlib import Path -import sys -import tempfile -import unittest - - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "backend")) - -from app.cad_agent.adapters.author_guidance import FileAuthorGuidance # noqa: E402 -from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator # noqa: E402 -from app.cad_agent.domain.errors import ErrorCode, WorkflowError # noqa: E402 -from app.cad_agent.domain.state import TaskPhase, TaskState # noqa: E402 - - -GUIDANCE_ROOT = ROOT / "backend" / "agent" / "skills" / "cdsl-author-guidance" -PROFILE = ROOT / "backend" / "engine" / "cdsl_engine" / "profile_schema.json" - - -def atomic_ids() -> tuple[str, ...]: - return tuple(json.loads(PROFILE.read_text(encoding="utf-8"))["operation_contracts"]) - - -class _Repository: - def __init__(self, state: TaskState) -> None: - self.state = state - self.usage_records: list[dict] = [] - - def get_state(self, _task_id: str) -> TaskState: - return self.state - - def ledger_events(self, _task_id: str) -> list[dict]: - return [] - - def record_usage(self, _task_id: str, payload: dict) -> None: - self.usage_records.append(payload) - - def record_tool_audit(self, _task_id: str, _payload: dict) -> None: - pass - - -class _Artifacts: - def read_source_requirements(self, _task_id: str) -> str: - return "Create a symmetric mounting plate." - - def read_json(self, *_args: object) -> None: - return None - - -class _Runtime: - def supported_atomic_ids(self) -> tuple[str, ...]: - return atomic_ids() - - -class AuthorGuidanceTests(unittest.TestCase): - def test_manifest_covers_every_runtime_atomic_and_keeps_coordinate_core_at_minimum_budget(self) -> None: - guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=1_200) - covered: set[str] = set() - for atomic_id in atomic_ids(): - selection = guidance.select( - phase=TaskPhase.FEATURE_PENDING, - atomic_id=atomic_id, - repair_required=False, - supported_atomic_ids=atomic_ids(), - ) - self.assertTrue(selection.enabled, selection.fallback_reason) - self.assertIn("00-author-contract", selection.section_ids) - self.assertIn("03-coordinate-system-and-datums", selection.section_ids) - self.assertLessEqual(len(selection.content), 1_200) - self.assertIn("世界坐标", selection.content) - covered.update(section_id for section_id in selection.section_ids if section_id.startswith("op-")) - self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-loft", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere", "op-primitives", "op-thread", "op-bend", "op-gear"}) - - def test_phase_repair_and_budget_selection_are_stable(self) -> None: - guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600) - planning = guidance.select( - phase=TaskPhase.COMPILING_FEATURE_PLAN, - atomic_id="", - repair_required=False, - supported_atomic_ids=atomic_ids(), - ) - repair = guidance.select( - phase=TaskPhase.AWAITING_ACTION, - atomic_id="fillet", - repair_required=True, - supported_atomic_ids=atomic_ids(), - ) - self.assertEqual(planning.section_ids[:2], ("00-author-contract", "03-coordinate-system-and-datums")) - self.assertIn("02-parameters-and-derived-dimensions", planning.section_ids) - self.assertEqual(repair.section_ids[:3], ("00-author-contract", "03-coordinate-system-and-datums", "op-finish")) - self.assertIn("10-repair-and-best-effort", repair.section_ids) - - def test_disabled_missing_and_invalid_corpus_fall_back_without_authoring_failure(self) -> None: - common = { - "phase": TaskPhase.FEATURE_PENDING, - "atomic_id": "extrude_add_blind", - "repair_required": False, - "supported_atomic_ids": atomic_ids(), - } - self.assertEqual(FileAuthorGuidance(GUIDANCE_ROOT, enabled=False).select(**common).fallback_reason, "guidance_disabled") - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:FileNotFoundError") - (root / "manifest.json").write_text("{}", encoding="utf-8") - self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:ValueError") - - def test_author_context_receives_guidance_but_keeps_the_existing_tool_instruction(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1) - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=8, format_error_limit=2), - _Repository(state), - _Artifacts(), - _Runtime(), - object(), - object(), - object(), - object(), - FileAuthorGuidance(GUIDANCE_ROOT), - ) - messages, selection = workflow._author_context(state.task_id, []) - system = str(messages[0]["content"]) - self.assertTrue(selection.enabled) - self.assertIn("Use exactly one offered structured tool call", system) - self.assertIn("Coordinate System And Datums", system) - self.assertIn("世界坐标", system) - - def test_invalid_author_tool_call_retains_guidance_usage_metadata(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1) - repository = _Repository(state) - class _Models: - async def call_tool(self, **_kwargs: object) -> dict: - return {"tool_calls": [], "usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4}} - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=8, format_error_limit=2), - repository, - _Artifacts(), - _Runtime(), - _Models(), - object(), - object(), - object(), - FileAuthorGuidance(GUIDANCE_ROOT), - ) - tool = {"type": "function", "function": {"name": "write_requirements_document", "parameters": {"type": "object"}}} - result = asyncio.run(workflow._author_turn(state.task_id, ModelIdentity("provider", "model"), [tool], [])) - self.assertIsInstance(result, WorkflowError) - self.assertEqual(result.code, ErrorCode.AUTHOR_FORMAT_INVALID) - self.assertEqual(repository.usage_records[0]["guidance_enabled"], True) - self.assertIn("03-coordinate-system-and-datums", repository.usage_records[0]["guidance_section_ids"]) - self.assertEqual(repository.usage_records[0]["retry_reason"], "invalid_tool_call") - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/test_authoring_contract.py b/backend/tests/test_authoring_contract.py new file mode 100644 index 00000000..742d69a8 --- /dev/null +++ b/backend/tests/test_authoring_contract.py @@ -0,0 +1,81 @@ +import math + +import pytest + +from app.cad_agent.application.authoring_contract import AuthoringDocument +from app.cad_agent.application.authoring_compiler import AuthoringCompiler, AuthoringCompileError + + +def test_model_cannot_submit_runtime_identity(): + with pytest.raises(ValueError, match="AUTHOR_FORBIDDEN_FIELD"): + AuthoringDocument.model_validate({"feature_id": "feature_001", "bodies": []}) + + +@pytest.mark.parametrize("field", ["task_id", "revision_id", "stable_id", "selector_tokens", "selector token", "host_face", "mirror_plane"]) +def test_model_cannot_submit_any_internal_identity_variant(field): + with pytest.raises(ValueError, match="AUTHOR_FORBIDDEN_FIELD"): + AuthoringDocument.model_validate({field: "internal", "bodies": []}) + + +def test_authoring_rejects_non_mm_and_non_finite_values(): + with pytest.raises(ValueError): + AuthoringDocument.model_validate({"units": "in", "bodies": []}) + with pytest.raises(ValueError, match="non-finite"): + AuthoringDocument.model_validate({"bodies": [{"name": "main", "features": [{"name": "base", "operation": "box_add", "params": {"length_mm": math.nan}}]}]}) + + +@pytest.mark.parametrize("sketch", [ + { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profiles": [{"type": "circle", "diameter_mm": 20}], + }, + { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "circle", "radius_mm": 10}, + }, +]) +def test_authoring_rejects_runtime_or_ambiguous_sketch_shapes(sketch): + with pytest.raises(ValueError): + AuthoringDocument.model_validate({ + "bodies": [{"name": "main", "features": [{ + "name": "base", "operation": "extrude_add_blind", "params": {"distance_mm": 8}, "sketch": sketch, + }]}], + }) + + +def test_authoring_rejects_selector_fields_that_are_not_declarative_source_intent(): + with pytest.raises(ValueError): + AuthoringDocument.model_validate({ + "bodies": [{"name": "main", "features": [{ + "name": "base", "operation": "cylinder_add", "params": {}, + "selectors": [{"kind": "face", "source": "other.top_planar_face", "role": "host_face"}], + }]}], + }) + + +def test_compiler_assigns_deterministic_ids_and_orders_dependencies(): + document = {"bodies": [{"name": "main", "features": [ + {"name": "hole", "operation": "hole_blind", "depends_on": ["base"]}, + {"name": "base", "operation": "extrude_add_blind"}, + ]}]} + runtime, audit = AuthoringCompiler(lambda operation: {"atomic_id": operation}).compile(document) + assert [item["id"] for item in runtime["features"]] == ["feature_002", "feature_001"] + assert runtime["features"][1]["depends_on"] == ["feature_002"] + assert audit["feature_ids"] == {"hole": "feature_001", "base": "feature_002"} + + +def test_compiler_rejects_unknown_operation(): + with pytest.raises(AuthoringCompileError) as error: + AuthoringCompiler(lambda _: (_ for _ in ()).throw(ValueError("unknown"))).compile({ + "bodies": [{"name": "main", "features": [{"name": "base", "operation": "bad"}]}] + }) + assert error.value.code == "OPERATION_UNSUPPORTED" + + +def test_compiler_preserves_the_forbidden_field_error_code(): + with pytest.raises(AuthoringCompileError) as error: + AuthoringCompiler(lambda operation: {"atomic_id": operation}).compile({ + "bodies": [{"name": "main", "features": [{"name": "base", "operation": "box_add", "params": {}}]}], + "feature_id": "feature_001", + }) + assert error.value.code == "AUTHOR_FORBIDDEN_FIELD" diff --git a/backend/tests/test_authoring_runtime.py b/backend/tests/test_authoring_runtime.py new file mode 100644 index 00000000..a07736f9 --- /dev/null +++ b/backend/tests/test_authoring_runtime.py @@ -0,0 +1,219 @@ +import math +from pathlib import Path +from tempfile import TemporaryDirectory + +from app.cad_agent.adapters.artifact_store import FileArtifactStore +from app.cad_agent.adapters.runtime import ProfileCadRuntime +from app.cad_agent.ports import AdapterUnavailable +from app.cad_agent.application.single_stage import SingleStageExecutor +from app.services.engine_service import validate_cdsl, validate_cdsl_shape +from app.settings import get_settings + + +def _base_feature() -> dict: + return { + "name": "base", + "operation": "extrude_add_blind", + "params": {"distance_mm": 8, "result_mode": "new_body"}, + "sketch": { + "workplane": { + "origin_mm": [0, 0, 0], + "x_dir": [1, 0, 0], + "normal": [0, 0, 1], + }, + "profile": { + "type": "polygon", + "vertices": [[-40, -25], [40, -25], [40, 25], [-40, 25]], + }, + }, + } + + +def _authoring(*features: dict) -> dict: + return { + "schema_version": "cad.author.v1", + "units": "mm", + "bodies": [{"name": "main", "features": list(features)}], + } + + +def test_authoring_compiles_to_runtime_cdsl_and_publishes_one_revision() -> None: + runtime = ProfileCadRuntime(get_settings()) + authoring = _authoring(_base_feature()) + runtime_cdsl, audit = runtime.compile_authoring(authoring) + + assert runtime_cdsl["schema"] == "cad.runtime.v1" + assert runtime_cdsl["bodies"] == [{"id": "body_001", "name": "main"}] + assert runtime_cdsl["features"][0]["id"] == "feature_001" + assert audit["feature_ids"] == {"base": "feature_001"} + validate_cdsl_shape(runtime_cdsl, runtime.engine) + validate_cdsl(runtime_cdsl, runtime.engine) + + with TemporaryDirectory() as temporary: + artifacts = FileArtifactStore(Path(temporary) / "artifacts") + task_id = "cad_abcdefghijkl" + artifacts.initialize_task(task_id, "make a rectangular plate") + result = SingleStageExecutor(None, artifacts, runtime).execute(task_id, authoring) + + assert result["status"] == "completed" + revision = str(result["revision_id"]) + assert result["executed_feature_ids"] == ["feature_001"] + for relative in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json", "renders/render-manifest.json"): + assert artifacts.artifact_path(task_id, f"revisions/{revision}/{relative}").is_file() + + +def test_selector_failure_keeps_the_successful_prefix() -> None: + runtime = ProfileCadRuntime(get_settings()) + invalid_fillet = { + "name": "bad_fillet", + "operation": "fillet", + "depends_on": ["base"], + "params": {"radius_mm": 1}, + # extrude.end is a face output. Using it for an edge-only operation + # is a deliberate selector-kind failure, not a geometry fallback. + "selectors": [{"kind": "edge", "source": "base.top_planar_face"}], + } + runtime_cdsl, _audit = runtime.compile_authoring(_authoring(_base_feature(), invalid_fillet)) + + with TemporaryDirectory() as temporary: + built, diagnostics = runtime.rebuild_best_effort( + runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_selector_failure", + ) + + assert built["executed_feature_ids"] == ["feature_001"] + assert diagnostics[0]["feature_id"] == "feature_002" + assert diagnostics[0]["code"] == "SELECTOR_KIND_MISMATCH" + + +def test_cylinder_cap_selector_is_compiled_into_hole_host_face_and_executes() -> None: + runtime = ProfileCadRuntime(get_settings()) + base = { + "name": "base_flange", + "operation": "cylinder_add", + "params": { + "radius_mm": 30, + "height_mm": 10, + "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + }, + } + bore = { + "name": "center_bore", + "operation": "hole_wizard", + "params": { + "hole_type": "simple", + "diameter_mm": 12, + "depth_mm": 10, + "end_condition": {"type": "through_all", "solidworks_code": 1}, + "positions": [{"mm": [0, 0, 10]}], + }, + "selectors": [{"kind": "face", "source": "base_flange.top_planar_face", "match": "unique"}], + } + runtime_cdsl, _audit = runtime.compile_authoring(_authoring(base, bore)) + compiled_bore = runtime_cdsl["features"][1] + assert compiled_bore["depends_on"] == ["feature_001"] + assert "selectors" not in compiled_bore + assert compiled_bore["params"]["host_face"] == { + "kind": "face", + "output_role": "cylinder.end", + "owner_feature_id": "feature_001", + "source": "runtime_snapshot", + "confidence": 1.0, + "match_mode": "unique", + } + + with TemporaryDirectory() as temporary: + built, diagnostics = runtime.rebuild_best_effort( + runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_cylinder_host", + ) + + assert diagnostics == [] + assert built["executed_feature_ids"] == ["feature_001", "feature_002"] + + +def test_authoring_circle_diameter_is_lowered_to_runtime_radius() -> None: + runtime = ProfileCadRuntime(get_settings()) + authoring = _authoring({ + "name": "round_base", + "operation": "extrude_add_blind", + "params": {"distance_mm": 8, "result_mode": "new_body"}, + "sketch": { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "circle", "diameter_mm": 20, "center_mm": [0, 0]}, + }, + }) + compiled, _audit = runtime.compile_authoring(authoring) + assert compiled["geometry"]["sketches"][0]["profile"] == { + "type": "circle", "center": [0.0, 0.0], "radius_mm": 10.0, + } + + +def test_flange_bolt_host_is_built_before_source_topology_is_replaced() -> None: + """An exposed base cap remains a valid bolt host before later fusions/cuts.""" + runtime = ProfileCadRuntime(get_settings()) + circle_sketch = lambda z, normal, diameter: { + "workplane": {"origin_mm": [0, 0, z], "x_dir": [1, 0, 0], "normal": normal}, + "profile": {"type": "circle", "diameter_mm": diameter, "center_mm": [0, 0]}, + } + bolt_positions = [ + {"mm": [43 * math.cos(math.radians(angle)), 43 * math.sin(math.radians(angle)), 12]} + for angle in range(0, 360, 45) + ] + authoring = _authoring( + { + "name": "base_flange", "operation": "cylinder_add", + "params": {"radius_mm": 60, "height_mm": 12, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}}, + }, + { + "name": "rear_shallow_pad", "operation": "extrude_add_blind", "depends_on": ["bolt_holes"], + "params": {"distance_mm": 4, "result_mode": "fuse", "reverse": False}, + "sketch": circle_sketch(0, [0, 0, -1], 105), + }, + { + "name": "rear_guide_boss", "operation": "extrude_add_blind", "depends_on": ["rear_shallow_pad"], + "params": {"distance_mm": 12, "result_mode": "fuse", "reverse": False}, + "sketch": circle_sketch(0, [0, 0, -1], 38), + }, + { + "name": "bolt_holes", "operation": "hole_wizard", + "params": { + "hole_type": "counterbore", "diameter_mm": 8, "depth_mm": 12, + "end_condition": {"type": "through_all_both", "solidworks_code": 7}, + "counterbore": {"diameter_mm": 10, "depth_mm": 4}, "positions": bolt_positions, + }, + "selectors": [{"kind": "face", "source": "base_flange.top_planar_face", "match": "unique"}], + }, + { + "name": "front_hub_boss", "operation": "extrude_add_blind", "depends_on": ["bolt_holes"], + "params": {"distance_mm": 14, "result_mode": "fuse", "reverse": False}, + "sketch": circle_sketch(12, [0, 0, 1], 56), + }, + { + "name": "center_through_cut", "operation": "extrude_cut_through", "depends_on": ["front_hub_boss", "rear_guide_boss"], + "params": {"end_condition": {"type": "through_all_both", "solidworks_code": 7}}, + "sketch": circle_sketch(0, [0, 0, 1], 32), + }, + ) + compiled, audit = runtime.compile_authoring(authoring) + assert audit["implicit_selector_dependencies"] == {"bolt_holes": ["base_flange"]} + + with TemporaryDirectory() as temporary: + rebuilt = runtime.rebuild(compiled, temporary, "cad_abcdefghijkl", "flange_source_order") + + assert rebuilt["health"]["feature_count"] == 6 + + +def test_service_failure_during_prefix_export_is_not_reclassified_as_a_model_error(monkeypatch) -> None: + runtime = ProfileCadRuntime(get_settings()) + runtime_cdsl, _audit = runtime.compile_authoring(_authoring(_base_feature())) + + def unavailable(*_args, **_kwargs): + raise AdapterUnavailable("renderer unavailable") + + monkeypatch.setattr(runtime, "rebuild", unavailable) + with TemporaryDirectory() as temporary: + try: + runtime.rebuild_best_effort(runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_service_failure") + except AdapterUnavailable as error: + assert "renderer unavailable" in str(error) + else: + raise AssertionError("service failure was incorrectly converted into a model repair diagnostic") diff --git a/backend/tests/test_cad_agent_v3.py b/backend/tests/test_cad_agent_v3.py deleted file mode 100644 index e5138dc5..00000000 --- a/backend/tests/test_cad_agent_v3.py +++ /dev/null @@ -1,1117 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -from hashlib import sha256 -import json -from pathlib import Path -import sqlite3 -import sys -import tempfile -import unittest -from unittest.mock import AsyncMock, patch - - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "backend")) - -from app.cad_agent.adapters.artifact_store import FileArtifactStore -from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher -from app.cad_agent.adapters.review_gateway import RenderedReviewGateway -from app.cad_agent.adapters.runtime import ProfileCadRuntime, RuntimeAdapterError -from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository -from app.cad_agent.adapters.verifier import RegistryVerifierExecutor -from app.cad_agent.application.capabilities import cached_model_capability, conformance_hash, conformance_tools, verify_model_capability -from app.cad_agent.application.action_handlers import ActionCommandHandler -from app.cad_agent.application.llm_contracts import ( - AcceptanceClaimInput, - CandidateReview, - CompiledRequirementsSpec, - MarkdownDocument, - NextAction, - StatelessCandidateReview, - StatelessGeometryConclusion, - canonical_validate, - canonical_validate_schema, - compiled_requirements_schema, - sanitize_compiled_requirements_arguments, - stateless_final_review_schema, - stateless_next_action_schema, - stateless_rollback_checkpoint_schema, -) -from app.cad_agent.application.outbox import OutboxDispatcher -from app.cad_agent.application.requirements import RequirementsCommandHandler -from app.cad_agent.application.results import Accepted, Rejected, Waiting -from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator -from app.cad_agent.domain.errors import ErrorCode, WorkflowError -from app.cad_agent.domain.operation_contract import fragment_schema, validate_fragment -from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState, legal_transitions, retry_resume_event, transition -from app.cad_agent.domain.verifier_registry import default_registry -from app.models.contracts import ChatMessage -from app.services.agent_service import AgentService -from app.services.library import CdslLibrary -from app.services.storage import WorkspaceStore -from app.settings import ProviderConfig, ProviderModel, Settings - - -def settings(root: Path) -> Settings: - author = ProviderConfig("author", "Author", "https://author.invalid/v1", "author-key", (ProviderModel("author-model"),)) - reviewer = ProviderConfig("reviewer", "Reviewer", "https://reviewer.invalid/v1", "reviewer-key", (ProviderModel("reviewer-model", vision=True),)) - return Settings( - task_root=root / "tasks", - conversation_root=root / "conversations", - library_root=ROOT / "backend" / "cdsl_library", - engine_root=ROOT / "backend" / "engine" / "cdsl_engine", - llm_base_url=author.base_url, - llm_api_key=author.api_key, - llm_model="author-model", - llm_timeout_s=1, - default_provider_id="author", - providers=(author, reviewer), - review_provider_id="reviewer", - review_model_id="reviewer-model", - ) - - -def requirements_document() -> MarkdownDocument: - return MarkdownDocument(markdown="""# Design Understanding - -Simple functional flange. - -# Explicit User Requirements - -- Create a simple flange. - -# Engineering Defaults and Assumptions - -- Use a circular body, central through bore, and four equally spaced mounting holes. - -# Dimensions and Coordinate Convention - -- Units are mm. The body is diameter 100 and thickness 10; bore diameter 30; four holes diameter 10 on radius 35. - -# Open Uncertainties - -- None. -""") - - -def completion_target() -> MarkdownDocument: - return MarkdownDocument(markdown="""# Completion Target - -- [ ] One connected cylindrical flange body, 100 mm outer diameter and 10 mm thickness. -- [ ] Centered 30 mm through bore. -- [ ] Four 10 mm mounting holes on a circular pattern of 35 mm pitch radius. -""") - - -def compiled_flange() -> CompiledRequirementsSpec: - return CompiledRequirementsSpec.model_validate({"requirements": [ - {"assumptions": [], "acceptance_claims": [{"claim_kind": "single_connected_body", "expected": {}}, {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "tolerance_mm": 0.1}}, {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10, "tolerance_mm": 0.1}}]}, - {"assumptions": [], "acceptance_claims": [ - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}, - {"claim_kind": "concentric_bore_to_outer_cylinder", "expected": {"bore_diameter_mm": 30, "outer_diameter_mm": 100, "tolerance_mm": 0.01}}, - ]}, - {"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"count": 4, "diameter_mm": 10, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]}, - ]}) - - -def modeling_plan() -> MarkdownDocument: - return MarkdownDocument(markdown="# Modeling Plan\n\n1. Create the circular flange body.\n2. Cut the centered bore.\n3. Add the circular mounting-hole pattern.\n") - - -def walk_keys(value: object) -> set[str]: - keys: set[str] = set() - if isinstance(value, dict): - keys.update(str(key) for key in value) - for item in value.values(): - keys.update(walk_keys(item)) - elif isinstance(value, list): - for item in value: - keys.update(walk_keys(item)) - return keys - - -class CadV3ProtocolTests(unittest.TestCase): - def test_state_machine_has_no_requirements_review_phase(self) -> None: - self.assertNotIn("REVIEWING_REQUIREMENTS", {phase.value for phase in TaskPhase}) - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0) - document = transition(state, "requirements_document_written", requirements_document_path="requirements.md") - target = transition(document, "completion_target_written", completion_target_path="completion-target.md") - compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json") - approved = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md") - self.assertEqual(approved.phase, TaskPhase.AWAITING_ACTION) - self.assertNotIn("requirements_finalized", {event for _phase, event in legal_transitions()}) - - def test_waiting_retry_resumes_exact_source_phase(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0) - waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING) - self.assertEqual(retry_resume_event(waiting), "resume_drafting_requirements_document") - resumed = transition(waiting, retry_resume_event(waiting) or "") - self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT) - self.assertIsNone(resumed.retry_from_phase) - - def test_llm_schemas_exclude_server_owned_runtime_ids(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - schemas = [ - MarkdownDocument.model_json_schema(), - compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 2), - stateless_next_action_schema(list(runtime.supported_atomic_ids())), - StatelessCandidateReview.model_json_schema(), - StatelessGeometryConclusion.model_json_schema(), - stateless_final_review_schema(2), - ] - forbidden = { - "task_id", "working_head", "requirement_id", "requirement_ids", "claim_id", - "candidate_id", "action_id", "evidence_id", "evidence_refs", "source_id", "source_ids", - "draft_id", "attachment_id", "record_ids", - } - for schema in schemas: - self.assertFalse(walk_keys(schema) & forbidden, walk_keys(schema) & forbidden) - - def test_compiled_requirements_ignores_non_executable_extra_fields(self) -> None: - schema = compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1) - raw = json.dumps({ - "assumptions": ["top-level notes from the compiler are not executable"], - "requirements": [{ - "statement": "model-added copy of the checklist text", - "assumptions": [], - "acceptance_claims": [{ - "claim_kind": "single_connected_body", - "expected": {}, - "evidence": "not part of the compiler contract", - }], - }], - }) - sanitized = sanitize_compiled_requirements_arguments(raw) - self.assertIsInstance(sanitized, str) - self.assertIsNone(canonical_validate_schema(sanitized, schema)) - parsed = canonical_validate(sanitized, CompiledRequirementsSpec) - self.assertIsInstance(parsed, CompiledRequirementsSpec) - self.assertEqual(parsed.requirements[0].acceptance_claims[0].claim_kind, "single_connected_body") - - def test_dynamic_tokens_are_enum_constrained(self) -> None: - rollback = stateless_rollback_checkpoint_schema(["checkpoint_one"]) - self.assertEqual(rollback["properties"]["checkpoint_token"], {"enum": ["checkpoint_one"]}) - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - schema = fragment_schema(runtime.operation_contract("hole_blind"), selector_tokens=["selector_one"], reference_tokens=[]) - selector = schema["properties"]["feature"]["properties"]["selector_tokens"]["items"] - self.assertEqual(selector, {"enum": ["selector_one"]}) - - def test_counterbore_can_reuse_a_matching_pilot_inside_an_annular_host_face(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - host = { - "kind": "face", - "geometry": { - "surface_type": "plane", "center_mm": [0, 0, 34], "normal": [0, 0, 1], - "bbox_mm": [-48, -48, 34, 48, 48, 34], - "boundary_loops_mm": [ - [[-48, -48, 34], [48, -48, 34], [48, 48, 34], [-48, 48, 34]], - [[-16, -16, 34], [16, -16, 34], [16, 16, 34], [-16, 16, 34]], - ], - }, - } - pilot = { - "kind": "face", - "geometry": { - "surface_type": "cylinder", "cylinder_role": "inner", "through": True, - "radius_mm": 16, "axis_origin_mm": [0, 0, 0], "axis_direction": [0, 0, 1], - "bbox_mm": [-16, -16, 0, 16, 16, 34], - }, - } - fragment = { - "feature": { - "atomic_id": "hole_counterbore", "selector_tokens": ["host"], - "params": {"diameter_mm": 32, "depth_mm": 34, "counterbore_diameter_mm": 62, "counterbore_depth_mm": 12, "positions": [{"mm": [0, 0, 34]}]}, - }, - } - runtime._preflight_hole_positions_on_host_plane(fragment, {"host": host, "pilot": pilot}, None, False) - fragment["feature"]["params"]["diameter_mm"] = 30 - with self.assertRaises(RuntimeAdapterError): - runtime._preflight_hole_positions_on_host_plane(fragment, {"host": host, "pilot": pilot}, None, False) - - def test_counterbore_operation_verifier_measures_the_new_recess_not_the_existing_pilot(self) -> None: - class CounterboreVerifier: - def __init__(self) -> None: - self.operation_claims: list[dict[str, object]] = [] - - def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]: - if claims[0]["claim_id"] == "operation_parent_bore_count": - self.assertEqual(claims[0]["expected"]["diameter_mm"], 62.0) - return [{"evidence": {"actual_count": 0}}] - self.operation_claims = claims - return [{"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": True, "status": "pass", "evidence": {}} for claim in claims] - - def assertEqual(self, actual: object, expected: object) -> None: - if actual != expected: - raise AssertionError(f"{actual!r} != {expected!r}") - - verifier = CounterboreVerifier() - handler = ActionCommandHandler(None, None, None, verifier) - action = PendingAction( - action_id="counterbore", working_head="cad_test:rev_001:v1", intent="Counterbore.", requirement_ids=(), - atomic_id="hole_counterbore", expected_change="Cut a counterbore.", contract_hash="contract", idempotency_key="key", - ) - results = handler._operation_candidate_results( - action, - {"candidate_verifiers": ["cylindrical_bore"]}, - {"features": [{"atomic_id": "hole_counterbore", "params": {"diameter_mm": 32, "counterbore_diameter_mm": 62, "positions": [{"mm": [0, 0, 34]}]}}]}, - {"health": {}, "topology": {}, "report": {}}, - parent_facts={"health": {}, "topology": {}, "report": {}}, - require_through=False, - ) - self.assertEqual(verifier.operation_claims[0]["expected"], {"diameter_mm": 62.0, "count": 1, "tolerance_mm": 0.01}) - self.assertEqual(results[0]["status"], "pass") - - def test_extrude_cut_rejects_a_slot_profile_floating_above_the_base(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - base_face = { - "kind": "face", - "geometry": { - "surface_type": "plane", "center_mm": [0, 0, 16], "normal": [0, 0, 1], - "boundary_loops_mm": [[[-90, -50, 16], [90, -50, 16], [90, 50, 16], [-90, 50, 16]]], - }, - } - boss_face = { - "kind": "face", - "geometry": { - "surface_type": "plane", "center_mm": [0, 0, 34], "normal": [0, 0, 1], - "boundary_loops_mm": [[[48, 0, 34], [0, 48, 34], [-48, 0, 34], [0, -48, 34]]], - }, - } - fragment = { - "sketch": { - "workplane": {"origin_mm": [0, 0, 34], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, - "profile": {"type": "analytic_contours", "contours": [{"closed": True, "role": "outer", "segments": [ - {"type": "line", "start": [68, 40], "end": [85, 40]}, - {"type": "line", "start": [85, 40], "end": [85, 49]}, - {"type": "line", "start": [85, 49], "end": [68, 49]}, - {"type": "line", "start": [68, 49], "end": [68, 40]}, - ]}]}, - }, - "feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 2}}, - } - with self.assertRaisesRegex(RuntimeAdapterError, "profile does not contact material"): - runtime._preflight_extrude_cut_contacts_material(fragment, {"base": base_face, "boss": boss_face}) - fragment["sketch"]["workplane"]["origin_mm"][2] = 16 - runtime._preflight_extrude_cut_contacts_material(fragment, {"base": base_face, "boss": boss_face}) - - def test_surface_attached_cut_direction_is_normalized_into_material(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - base = { - "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "cut_direction", - "geometry": {"sketches": [{ - "id": "sketch_001", - "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, - "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}, - }]}, - "features": [{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"}], - } - fragment = { - "sketch": { - "workplane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, - "profile": {"type": "circle", "center": [6, 0], "radius_mm": 1}, - }, - "feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 2}}, - } - top_face = { - "kind": "face", - "geometry": { - "surface_type": "plane", "center_mm": [0, 0, 5], "normal": [0, 0, 1], - "boundary_loops_mm": [[[-10, -10, 5], [10, -10, 5], [10, 10, 5], [-10, 10, 5]]], - }, - } - document, audit = runtime.materialize_fragment( - base, - fragment, - runtime.operation_contract("extrude_cut_blind"), - {"top": top_face}, - runtime.reference_tokens(base), - ) - self.assertTrue(document["features"][-1]["params"]["reverse"]) - direction = next(item for item in audit["server_normalizations"] if item["path"] == "feature.params.reverse") - self.assertFalse(direction["submitted"]) - self.assertTrue(direction["materialized"]) - - def test_sketch_workplane_candidates_prefer_the_broad_base_support(self) -> None: - candidates = WorkflowCoordinator._sketch_workplane_candidates({"records": [ - { - "kind": "face", - "geometry": { - "surface_type": "plane", "center_mm": [0, 0, 34], "normal": [0, 0, 1], - "bbox_mm": [-48, -48, 34, 48, 48, 34], - }, - }, - { - "kind": "face", - "geometry": { - "surface_type": "plane", "center_mm": [0, 0, 16], "normal": [0, 0, 1], - "bbox_mm": [-90, -50, 16, 90, 50, 16], - }, - }, - { - "kind": "face", - "geometry": { - "surface_type": "plane", "center_mm": [90, 0, 8], "normal": [1, 0, 0], - "bbox_mm": [90, -50, 0, 90, 50, 16], - }, - }, - ]}) - self.assertEqual(candidates[0]["point_mm"], [0.0, 0.0, 16.0]) - self.assertEqual(candidates[0]["footprint_bbox_area_mm2"], 18000.0) - self.assertEqual(candidates[1]["point_mm"], [0.0, 0.0, 34.0]) - - def test_replan_budget_spans_replacement_node_ids_at_one_checkpoint(self) -> None: - events = [ - { - "event": "feature_node_failed", "node_id": node_id, - "atomic_id": "extrude_cut_blind", "checkpoint_revision": "rev_005", "terminal": True, - } - for node_id in ("corner_slots_v1", "corner_slots_v2", "corner_slots_v3") - ] - - class Repository: - @staticmethod - def ledger_events(_task_id: str) -> list[dict[str, object]]: - return events - - workflow = object.__new__(WorkflowCoordinator) - workflow.repository = Repository() - state = TaskState("cad_123456abcdef", TaskPhase.REPLANNING_FEATURE_SUBGRAPH, 10, active_revision="rev_005") - exhausted = workflow._feature_replan_exhausted(state.task_id, state) - self.assertIsNotNone(exhausted) - self.assertEqual(exhausted["terminal_failure_count"], 3) - self.assertEqual(exhausted["node_ids"], ["corner_slots_v1", "corner_slots_v2", "corner_slots_v3"]) - - def test_root_extrusion_schema_fixes_world_xy_datum_without_deciding_z(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - contract = runtime.operation_contract("extrude_add_blind") - fragment = { - "sketch": {"workplane": {"origin_mm": [0, -6, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 60}}, - "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 12}}, - } - self.assertTrue(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True)) - fragment["sketch"]["workplane"]["origin_mm"] = [0, 0, -6] - self.assertEqual(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True), []) - - def test_runtime_keeps_executable_feature_prefix_when_later_feature_is_invalid(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - cdsl = { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.1.0", - "kind": "part", - "part_id": "partial_rebuild", - "geometry": {"sketches": [{ - "id": "sketch_001", - "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, - "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}, - }]}, - "features": [ - {"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"}, - {"id": "feature_002", "atomic_id": "not_an_engine_operation", "params": {}, "depends_on": ["feature_001"]}, - ], - } - rebuilt, failures = runtime.rebuild_best_effort(cdsl, str(Path(temporary) / "candidate"), "partial_rebuild", "candidate") - self.assertEqual(rebuilt["executed_feature_ids"], ["feature_001"]) - self.assertEqual(len(failures), 1) - self.assertEqual(failures[0]["feature_id"], "feature_002") - - def test_action_submission_keeps_partial_feature_batch_for_review(self) -> None: - class PassingVerifier: - def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]: - return [ - { - "claim_id": str(claim.get("claim_id") or ""), - "claim_kind": str(claim.get("claim_kind") or ""), - "deterministic": True, - "status": "pass", - "evidence": {}, - } - for claim in claims - ] - - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, PassingVerifier()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a flange.") - artifacts.initialize_task(task_id, "Create a flange.") - requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile") - requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan") - state = repository.get_state(task_id) - proposal = NextAction( - working_head=state.working_head, - intent="Create a two-feature batch.", - requirement_ids=["req_001"], - atomic_id="extrude_add_blind", - expected_change="Keep the executable part of the batch.", - ) - self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted) - fragment = { - "sketch": { - "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, - "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}, - }, - "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}}, - } - cdsl = { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.1.0", - "kind": "part", - "part_id": "partial_batch", - "geometry": {"sketches": []}, - "features": [ - {"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": []}, - {"id": "feature_002", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": ["feature_001"]}, - ], - } - audit = { - "schema_version": "cad.v3.fragment-audit.v1", - "atomic_id": "extrude_add_blind", - "fragment_hash": "hash", - "contract_hash": runtime.operation_contract("extrude_add_blind")["contract_hash"], - "assigned_feature_ids": ["feature_001", "feature_002"], - "assigned_sketch_ids": [], - "selector_snapshot_id": "", - "selector_tokens": [], - "reference_snapshot_id": "", - "reference_tokens": [], - } - rebuilt = { - "executed_feature_ids": ["feature_001"], - "health": {"solid_count": 1}, - "topology": {"records": []}, - "report": {}, - "render_manifest": {}, - "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json"}, - } - operation_failures = [{"feature_index": 1, "feature_id": "feature_002", "message": "failed after feature_001"}] - with patch.object(runtime, "materialize_fragment", return_value=(cdsl, audit)), patch.object(runtime, "rebuild_best_effort", return_value=(rebuilt, operation_failures)): - result = actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment") - self.assertIsInstance(result, Accepted) - reviewing = repository.get_state(task_id) - self.assertEqual(reviewing.phase, TaskPhase.CANDIDATE_REVIEW) - candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {} - self.assertEqual(candidate["executed_feature_ids"], ["feature_001"]) - self.assertEqual(candidate["operation_failures"], operation_failures) - - def test_best_effort_transition_completes_from_an_executable_checkpoint(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.AWAITING_ACTION, 7, active_revision="rev_001", repair_required=True) - completed = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False) - self.assertEqual(completed.phase, TaskPhase.COMPLETED) - self.assertFalse(completed.repair_required) - - def test_review_rejection_publishes_the_executable_checkpoint_for_repair(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a simple flange.") - artifacts.initialize_task(task_id, "Create a simple flange.") - requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile") - requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan") - state = repository.get_state(task_id) - proposal = NextAction( - working_head=state.working_head, - intent="Create the base body.", - requirement_ids=["req_001"], - atomic_id="extrude_add_blind", - expected_change="Create the circular body.", - ) - self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted) - fragment = { - "sketch": { - "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, - "profile": {"type": "circle", "center": [0, 0], "radius_mm": 50}, - }, - "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 10}}, - } - self.assertIsInstance(actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment"), Accepted) - reviewing = repository.get_state(task_id) - candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {} - review = CandidateReview( - candidate_id=reviewing.candidate_id, - working_head=reviewing.pending_action.working_head, - verdict="reject", - claim_coverage=[ - {"claim_id": str(item["claim_id"]), "status": str(item["status"]), "evidence_refs": []} - for item in candidate["claim_results"] - ], - evidence=["Base body is executable."], - issues=["The bore and bolt holes remain to be added."], - ) - result = actions.record_candidate_review(task_id, review, invocation_id="review") - self.assertIsInstance(result, Accepted) - self.assertEqual(result.payload["status"], "accepted_with_issues") - published = repository.get_state(task_id) - self.assertEqual(published.phase, TaskPhase.AWAITING_ACTION) - self.assertEqual(published.active_revision, "rev_001") - self.assertTrue(published.repair_required) - completed = actions.finalize_best_effort(task_id, reason=ErrorCode.NO_PROGRESS_LIMIT, invocation_id="best_effort") - self.assertIsInstance(completed, Accepted) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.COMPLETED) - - def test_root_checkpoint_is_not_offered_as_a_rollback_target(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = settings(root) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(configured) - initial = repository.create_task("cad_123456abcdef", "Create a flange.") - document = transition(initial, "requirements_document_written", requirements_document_path="requirements.md") - target = transition(document, "completion_target_written", completion_target_path="completion-target.md") - compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json") - awaiting = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md", repair_required=True) - self.assertTrue(repository.compare_and_swap(document)) - self.assertTrue(repository.compare_and_swap(target)) - self.assertTrue(repository.compare_and_swap(compiled)) - self.assertTrue(repository.compare_and_swap(awaiting, events=[{ - "event": "geometry_conclusion", - "decision": "rollback", - "working_head": awaiting.working_head, - }])) - actions = ActionCommandHandler(repository, artifacts, runtime, default_registry()) - self.assertFalse(actions.rollback_available(awaiting.task_id)) - - def test_geometry_conclusion_is_stateless_for_the_author(self) -> None: - schema = StatelessGeometryConclusion.model_json_schema() - self.assertFalse({"working_head", "evidence_refs"} & walk_keys(schema)) - - def test_outer_cylinder_span_merges_oppositely_oriented_two_sided_faces(self) -> None: - def outer_face(record_id: str, direction: list[float], bbox: list[float]) -> dict[str, object]: - return { - "record_id": record_id, - "geometry": { - "surface_type": "cylinder", - "cylinder_role": "outer", - "radius_mm": 60.0, - "axis_origin_mm": [0.0, 0.0, 0.0], - "axis_direction": direction, - "bbox_mm": bbox, - }, - } - - facts = {"topology": {"records": [ - outer_face("upper", [0.0, 0.0, -1.0], [-60.0, -60.0, 0.0, 60.0, 60.0, 6.0]), - outer_face("lower", [0.0, 0.0, 1.0], [-60.0, -60.0, -6.0, 60.0, 60.0, 0.0]), - ]}} - result = default_registry().evaluate( - "outer_cylindrical_surface", - {"diameter_mm": 120.0, "count": 1, "axial_span_mm": 12.0}, - facts, - ) - self.assertEqual(result["status"], "pass") - self.assertEqual(result["evidence"]["axial_spans_mm"], [12.0]) - self.assertEqual(result["evidence"]["tolerance_mm"], 0.1) - - def test_compiler_persists_default_tolerance_for_axial_outer_cylinder(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a flange.") - artifacts.initialize_task(task_id, "Create a flange.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - compiled = CompiledRequirementsSpec.model_validate({"requirements": [ - {"assumptions": [], "acceptance_claims": [{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "axial_span_mm": 10, "count": 1}}]}, - {"assumptions": [], "acceptance_claims": [ - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}, - {"claim_kind": "concentric_bore_to_outer_cylinder", "expected": {"bore_diameter_mm": 30, "outer_diameter_mm": 100, "tolerance_mm": 0.01}}, - ]}, - {"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 10, "count": 4, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]}, - ]}) - self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted) - state = repository.get_state(task_id) - contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} - first_claim = contract["requirements"][0]["acceptance_claims"][0] - self.assertEqual(first_claim["expected"]["tolerance_mm"], 0.1) - - def test_record_bound_compiler_claims_are_visualized_before_contract_freeze(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a simple flange.") - artifacts.initialize_task(task_id, "Create a simple flange.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - compiled = CompiledRequirementsSpec.model_validate({"requirements": [ - {"assumptions": [], "acceptance_claims": [{"claim_kind": "coaxial", "expected": {"record_ids": ["outer", "bore"], "tolerance": 0.01}}]}, - {"assumptions": [], "acceptance_claims": [ - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}, - {"claim_kind": "concentric_bore_to_outer_cylinder", "expected": {"bore_diameter_mm": 30, "outer_diameter_mm": 100, "tolerance_mm": 0.01}}, - ]}, - {"assumptions": [], "acceptance_claims": [{"claim_kind": "coplanar", "expected": {"record_ids": ["top_face", "bottom_face"], "tolerance_mm": 0.1}}]}, - ]}) - self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted) - state = repository.get_state(task_id) - contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} - claims = [claim for requirement in contract["requirements"] for claim in requirement["acceptance_claims"]] - self.assertEqual([claim["claim_kind"] for claim in claims], ["visual", "through_cylindrical_bore", "concentric_bore_to_outer_cylinder", "visual"]) - self.assertEqual([claim["verification_mode"] for claim in claims], ["visual", "deterministic", "deterministic", "visual"]) - self.assertTrue(any("coaxial verifier" in warning for warning in contract["verification_warnings"])) - self.assertTrue(any("coplanar verifier" in warning for warning in contract["verification_warnings"])) - - def test_unbacked_coaxial_bore_group_is_not_frozen_as_a_deterministic_claim(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - output = CompiledRequirementsSpec.model_validate({"requirements": [ - {"assumptions": [], "acceptance_claims": [ - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}}, - ]}, - {"assumptions": [], "acceptance_claims": [ - {"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 40, "count": 2, "tolerance_mm": 0.01}}, - ]}, - ]}) - normalized, warnings = handler._normalize_compiled_spec(output, ["A through bore.", "The bore is concentric with the outer profile."]) - self.assertEqual(normalized.requirements[1].acceptance_claims[0].claim_kind, "visual") - self.assertTrue(any("no matching multi-bore target" in warning for warning in warnings)) - - def test_obround_slot_is_not_compiled_as_a_corner_bore_pattern(self) -> None: - output = CompiledRequirementsSpec.model_validate({"requirements": [{ - "assumptions": [], - "acceptance_claims": [{ - "claim_kind": "rectangular_corner_through_bore_pattern", - "expected": {"diameter_mm": 9, "count": 4, "edge_offset_mm": 26, "tolerance_mm": 0.1}, - }], - }]}) - normalized, warnings = RequirementsCommandHandler(None, None, default_registry())._normalize_compiled_spec( - output, - ["Four 26 x 9 mm oblong adjustment slots are present at the four corners."], - ) - claim = normalized.requirements[0].acceptance_claims[0] - self.assertEqual(claim.claim_kind, "visual") - self.assertEqual(claim.expected["description"], "Four 26 x 9 mm oblong adjustment slots are present at the four corners.") - self.assertTrue(any("describes an obround slot" in warning for warning in warnings)) - - def test_centered_bore_checklist_item_requires_concentric_claim_coverage(self) -> None: - output = CompiledRequirementsSpec.model_validate({"requirements": [{ - "assumptions": [], - "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}}], - }]}) - errors = RequirementsCommandHandler._relationship_claim_errors( - output, - ["A centered 40 mm through bore is present."], - ) - self.assertEqual(errors[0]["path"], "/requirements/0/acceptance_claims") - output.requirements[0].acceptance_claims.append(AcceptanceClaimInput.model_validate({ - "claim_kind": "concentric_bore_to_outer_cylinder", - "expected": {"bore_diameter_mm": 40, "outer_diameter_mm": 120, "tolerance_mm": 0.01}, - })) - self.assertEqual( - RequirementsCommandHandler._relationship_claim_errors(output, ["A centered 40 mm through bore is present."]), - [], - ) - - def test_compiler_derives_concentric_claim_from_frozen_outer_cylinder_and_centered_bore(self) -> None: - output = CompiledRequirementsSpec.model_validate({"requirements": [ - {"assumptions": [], "acceptance_claims": [{ - "claim_kind": "outer_cylindrical_surface", - "expected": {"diameter_mm": 120, "axial_span_mm": 12, "count": 1, "tolerance_mm": 0.01}, - }]}, - {"assumptions": [], "acceptance_claims": [{ - "claim_kind": "through_cylindrical_bore", - "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}, - }]}, - ]}) - normalized, _ = RequirementsCommandHandler(None, None, default_registry())._normalize_compiled_spec( - output, - ["A 120 mm cylindrical outer flange is present.", "A centered 40 mm through bore is present."], - ) - derived = normalized.requirements[1].acceptance_claims[-1] - self.assertEqual(derived.claim_kind, "concentric_bore_to_outer_cylinder") - self.assertEqual(derived.expected, {"bore_diameter_mm": 40.0, "outer_diameter_mm": 120.0, "tolerance_mm": 0.01}) - self.assertEqual( - RequirementsCommandHandler._relationship_claim_errors( - normalized, - ["A 120 mm cylindrical outer flange is present.", "A centered 40 mm through bore is present."], - ), - [], - ) - - def test_concentric_bore_to_outer_cylinder_verifier_measures_axis_offset(self) -> None: - outer = { - "record_id": "outer", "geometry": { - "surface_type": "cylinder", "cylinder_role": "outer", "radius_mm": 60.0, - "axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, 0.0, 1.0], - "bbox_mm": [-60.0, -60.0, 0.0, 60.0, 60.0, 12.0], - }, - } - bore = { - "record_id": "bore", "geometry": { - "surface_type": "cylinder", "cylinder_role": "inner", "radius_mm": 20.0, - "axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, 0.0, 1.0], - "bbox_mm": [-20.0, -20.0, 0.0, 20.0, 20.0, 12.0], "through": True, - }, - } - expected = {"bore_diameter_mm": 40.0, "outer_diameter_mm": 120.0, "tolerance_mm": 0.01} - registry = default_registry() - result = registry.evaluate("concentric_bore_to_outer_cylinder", expected, {"topology": {"records": [outer, bore]}}) - self.assertEqual(result["status"], "pass") - bore["geometry"]["axis_origin_mm"] = [0.1, 0.0, 0.0] - result = registry.evaluate("concentric_bore_to_outer_cylinder", expected, {"topology": {"records": [outer, bore]}}) - self.assertEqual(result["status"], "fail") - self.assertAlmostEqual(result["evidence"]["axis_distance_mm"], 0.1) - - def test_local_cylindrical_span_does_not_become_global_bbox_requirement(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a stepped hub adapter.") - artifacts.initialize_task(task_id, "Create a stepped hub adapter.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - handler.submit_completion_target(task_id, MarkdownDocument(markdown="""# Completion Target - -- [ ] A centered solid cylindrical flange body is present with 120 mm outer diameter and 12 mm thickness. -"""), invocation_id="completion_target") - compiled = CompiledRequirementsSpec.model_validate({"requirements": [{ - "assumptions": [], - "acceptance_claims": [ - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 120, "count": 1, "tolerance_mm": 0.1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.1}}, - ], - }]}) - self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted) - state = repository.get_state(task_id) - contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} - claims = contract["requirements"][0]["acceptance_claims"] - self.assertEqual([claim["claim_kind"] for claim in claims], ["outer_cylindrical_surface"]) - self.assertEqual(claims[0]["expected"]["axial_span_mm"], 12) - self.assertTrue(any("Global bbox Z verifier" in warning for warning in contract["verification_warnings"])) - - def test_requirements_markdown_is_not_rejected_for_missing_headings(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a flange.") - artifacts.initialize_task(task_id, "Create a flange.") - result = handler.submit_requirements_document(task_id, MarkdownDocument(markdown="A simple circular flange with a bore."), invocation_id="plain_markdown") - self.assertIsInstance(result, Accepted) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.DRAFTING_COMPLETION_TARGET) - - def test_server_bound_action_accepts_more_than_five_checklist_targets(self) -> None: - action = NextAction( - working_head="cad_123456abcdef:root:v4", - intent="Create the flange body.", - requirement_ids=[f"req_{position:03d}" for position in range(1, 8)], - atomic_id="extrude_add_blind", - expected_change="Add the first solid body.", - ) - self.assertEqual(len(action.requirement_ids), 7) - - def test_markdown_documents_freeze_before_compiled_flange_contract(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a simple flange.") - artifacts.initialize_task(task_id, "Create a simple flange.") - self.assertIsInstance(handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document"), Accepted) - self.assertIsInstance(handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target"), Accepted) - self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile"), Accepted) - self.assertIsInstance(handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan"), Accepted) - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.AWAITING_ACTION) - contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} - self.assertEqual(len(contract["requirements"]), 3) - claim_kinds = {claim["claim_kind"] for item in contract["requirements"] for claim in item["acceptance_claims"]} - self.assertTrue({"single_connected_body", "through_cylindrical_bore", "circular_hole_pattern"}.issubset(claim_kinds)) - self.assertTrue((artifacts.task_dir(task_id) / "requirements.md").is_file()) - self.assertTrue((artifacts.task_dir(task_id) / "completion-target.md").is_file()) - self.assertTrue((artifacts.task_dir(task_id) / "modeling-plan.md").is_file()) - - def test_invalid_verifier_contract_is_rejected_without_state_change(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create one solid.") - artifacts.initialize_task(task_id, "Create one solid.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - invalid = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 0}}]}] * 3}) - before = repository.get_state(task_id) - result = handler.submit_compiled_spec(task_id, invalid, invocation_id="invalid_spec") - self.assertIsInstance(result, Rejected) - self.assertEqual(result.error.code, ErrorCode.REQUIREMENTS_SPEC_INVALID) - self.assertEqual(repository.get_state(task_id), before) - - def test_feature_plan_rejection_has_an_independent_retry_budget(self) -> None: - """A failed replan must not consume a prior requirements-format retry.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=8, format_error_limit=2), - repository, - artifacts, - runtime, - object(), # The direct retry-budget test does not call a model. - object(), - requirements, - actions, - ) - initial = repository.create_task("cad_123456abcdef", "Create a plate.") - documented = transition(initial, "requirements_document_written", requirements_document_path="requirements.md") - targeted = transition(documented, "completion_target_written", completion_target_path="completion-target.md") - compiling = transition(targeted, "requirements_compiled", requirements_contract_path="requirements-contract.json") - scheduled = transition( - compiling, - "feature_plan_written", - feature_plan_path="plans/feature-plan-active.json", - feature_plan_hash="a" * 64, - ) - replanning = transition(scheduled, "feature_replan", error=ErrorCode.CANDIDATE_REVIEW_REJECTED) - self.assertTrue(repository.compare_and_swap(documented)) - self.assertTrue(repository.compare_and_swap(targeted)) - self.assertTrue(repository.compare_and_swap(compiling)) - self.assertTrue(repository.compare_and_swap(scheduled)) - self.assertTrue(repository.compare_and_swap(replanning)) - counters = {"requirements_spec": 1} - feedback: list[dict[str, object]] = [] - terminal = workflow._requirements_rejection( - replanning.task_id, - replanning, - WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan violates immutable-node rules."), - counters, - feedback, - tool="write_feature_plan", - ) - self.assertIsNone(terminal) - self.assertEqual(repository.get_state(replanning.task_id).phase, TaskPhase.REPLANNING_FEATURE_SUBGRAPH) - self.assertEqual(counters, {"requirements_spec": 1, "write_feature_plan": 1}) - self.assertEqual(len(feedback), 1) - - def test_completion_result_reports_frozen_checklist(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a coherent flange.") - artifacts.initialize_task(task_id, "Create a coherent flange.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile") - handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan") - state = repository.get_state(task_id) - path = handler.write_completion_result( - task_id, state, - claim_results=[{"claim_id": f"claim_{position:03d}", "status": "pass", "evidence": {"measured": True}} for position in range(1, 6)], - review={"visual_claims": []}, - ) - self.assertEqual(path, "completion-result.md") - result = (artifacts.task_dir(task_id) / path).read_text(encoding="utf-8") - target = (artifacts.task_dir(task_id) / "completion-target.md").read_text(encoding="utf-8") - requirements = (artifacts.task_dir(task_id) / "requirements.md").read_text(encoding="utf-8") - self.assertIn("Engineering Defaults", requirements) - self.assertIn("Centered 30 mm through bore", target) - self.assertIn("Centered 30 mm through bore.: pass", result) - - def test_sqlite_schema_contains_only_current_requirement_paths(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - database = Path(temporary) / "state.sqlite3" - SqliteTaskRepository(database) - with sqlite3.connect(database) as connection: - columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)")} - self.assertIn("requirements_spec_path", columns) - self.assertIn("requirements_document_path", columns) - self.assertIn("completion_target_path", columns) - self.assertIn("modeling_plan_path", columns) - self.assertIn("clarification_path", columns) - self.assertNotIn("requirements_draft_path", columns) - self.assertNotIn("requirements_review_path", columns) - - def test_role_specific_capability_tools_have_no_review_loop(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - author = conformance_tools(runtime, role="author") - reviewer = conformance_tools(runtime, role="reviewer") - author_names = {item["function"]["name"] for item in author} - reviewer_names = {item["function"]["name"] for item in reviewer} - self.assertTrue({"write_requirements_document", "write_completion_target", "compile_requirements_spec", "write_modeling_plan"}.issubset(author_names)) - self.assertNotIn("review_requirements", author_names | reviewer_names) - self.assertNotIn("get_cdsl_operation_contract", author_names) - self.assertEqual(reviewer_names, {"observe_images", "review_candidate", "review_final"}) - - def test_capability_cache_is_role_scoped(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - runtime = ProfileCadRuntime(settings(root)) - class Models: - def __init__(self) -> None: - self.calls = 0 - async def conformance(self, **_kwargs: object) -> dict[str, object]: - self.calls += 1 - return {"supported": True, "failures": [], "probe_unavailable": False} - models = Models() - first = asyncio.run(verify_model_capability(repository, runtime, models, provider_id="p", model_id="m", role="author")) - second = asyncio.run(verify_model_capability(repository, runtime, models, provider_id="p", model_id="m", role="author")) - reviewer = asyncio.run(verify_model_capability(repository, runtime, models, provider_id="p", model_id="m", role="reviewer")) - self.assertFalse(first.get("cached", False)) - self.assertTrue(second["cached"]) - self.assertNotEqual(first["schema_hash"], reviewer["schema_hash"]) - self.assertEqual(models.calls, 2) - self.assertIsNotNone(cached_model_capability(repository, runtime, provider_id="p", model_id="m", role="author")) - - def test_unsupported_capability_is_cached_but_transport_failure_is_not(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - runtime = ProfileCadRuntime(settings(root)) - schema_hash = conformance_hash(conformance_tools(runtime, role="reviewer"), role="reviewer") - class Unsupported: - async def conformance(self, **_kwargs: object) -> dict[str, object]: - return {"supported": False, "failures": [{"message": "schema"}], "probe_unavailable": False} - asyncio.run(verify_model_capability(repository, runtime, Unsupported(), provider_id="p", model_id="m", role="reviewer")) - self.assertIsNotNone(repository.model_capability("p", "m", schema_hash)) - class Unavailable: - async def conformance(self, **_kwargs: object) -> dict[str, object]: - return {"supported": False, "failures": [{"message": "network"}], "probe_unavailable": True} - asyncio.run(verify_model_capability(repository, runtime, Unavailable(), provider_id="p2", model_id="m", role="reviewer")) - self.assertIsNone(repository.model_capability("p2", "m", schema_hash)) - - def test_task_started_is_emitted_before_capability_work(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - message = ChatMessage.model_validate({"id": "user_1", "role": "user", "parts": [{"type": "text", "text": "Create a plate."}]}) - async def first_chunk() -> bytes: - stream = service.stream([message], None, None) - chunk = await anext(stream) - await stream.aclose() - return chunk - with patch("app.services.agent_service.verify_model_capability", AsyncMock()) as capability: - chunk = asyncio.run(first_chunk()).decode("utf-8") - self.assertIn("task_started", chunk) - capability.assert_not_awaited() - self.assertEqual(len(service.v3.repository.running_task_ids()), 1) - - def test_cached_capabilities_skip_normal_request_probe(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - task_id = "cad_123456abcdef" - service.v3.workflow.create_task(task_id, "Create a plate.") - queue: asyncio.Queue = asyncio.Queue() - with patch("app.services.agent_service.cached_model_capability", return_value={"supported": True}), patch( - "app.services.agent_service.verify_model_capability", AsyncMock() - ) as verify: - result = asyncio.run(service._ensure_task_capabilities( - task_id, ModelIdentity("author", "author-model"), ModelIdentity("reviewer", "reviewer-model"), queue, - )) - self.assertIsNone(result) - verify.assert_not_awaited() - self.assertTrue(queue.empty()) - - def test_missing_cache_is_visible_and_auto_resumes_same_task(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - task_id = "cad_123456abcdef" - service.v3.workflow.create_task(task_id, "Create a plate.") - queue: asyncio.Queue = asyncio.Queue() - with patch("app.services.agent_service.cached_model_capability", return_value=None), patch( - "app.services.agent_service.verify_model_capability", AsyncMock(return_value={"supported": True, "probe_unavailable": False}) - ) as verify: - result = asyncio.run(service._ensure_task_capabilities( - task_id, ModelIdentity("author", "author-model"), ModelIdentity("reviewer", "reviewer-model"), queue, - )) - self.assertIsNone(result) - self.assertEqual(verify.await_count, 2) - self.assertEqual(service.v3.repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT) - events = [queue.get_nowait(), queue.get_nowait()] - self.assertEqual([item[1]["status"] for item in events], ["waiting", "success"]) - - def test_image_bytes_are_frozen_and_sent_to_vision(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - artifacts = FileArtifactStore(root / "tasks") - task_id = "cad_123456abcdef" - source = root / "reference.png" - png = b"\x89PNG\r\n\x1a\nreference-bytes" - source.write_bytes(png) - artifacts.initialize_task(task_id, "Match the image.", image_inputs=[{ - "path": str(source), "mime": "image/png", "sha256": sha256(png).hexdigest(), - }]) - frozen = Path(artifacts.source_image_paths(task_id)[0]) - class Models: - def __init__(self) -> None: - self.messages: list[list[dict[str, object]]] = [] - async def call_tool(self, *, messages: list[dict[str, object]], **_kwargs: object) -> dict[str, object]: - self.messages.append(messages) - return {"tool_calls": [], "usage": {}} - models = Models() - gateway = RenderedReviewGateway(models) - tool = {"type": "function", "function": {"name": "observe_images", "parameters": {"type": "object"}}} - asyncio.run(gateway.review(kind="image_observation", payload={"reference_image_paths": [str(frozen)]}, tool=tool, provider_id="p", model_id="m")) - image_part = models.messages[0][1]["content"][1] - encoded = image_part["image_url"]["url"].split(",", 1)[1] - self.assertEqual(base64.b64decode(encoded), png) - - def test_final_review_schema_has_ordered_visual_decision_count(self) -> None: - visual = stateless_final_review_schema(2)["properties"]["visual_claims"] - self.assertEqual((visual["minItems"], visual["maxItems"]), (2, 2)) - - def test_sqlite_cas_and_outbox_are_atomic(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") - initial = repository.create_task("cad_123456abcdef", "Create a plate.") - changed = transition(initial, "image_observed") - self.assertTrue(repository.compare_and_swap(changed, events=[{"event": "image_observation_ready"}])) - self.assertFalse(repository.compare_and_swap(changed, events=[{"event": "duplicate"}])) - self.assertEqual(len(repository.pending_outbox()), 1) - delivered = asyncio.run(OutboxDispatcher(repository, IdempotentInProcessPublisher()).dispatch_pending()) - self.assertEqual(len(delivered), 1) - self.assertEqual(repository.pending_outbox(), []) - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index 250d9118..e9f4bb51 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -916,7 +916,6 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(resolved.record.record_id, "sweep:end") self.assertEqual(resolved.record.output_roles, ("sweep.end",)) self.assertEqual(evidence["output_role_status"], "unique_result_snapshot") - registry.replace_body_topology("later", "body:later", [ TopologyRecord("later:end", "face", "later", "body:later", {"center_mm": [0, 0, 10]}, object()), ]) @@ -926,6 +925,103 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(stale.status, "not_found") self.assertEqual(stale.diagnostic.code, "selector_output_role_not_found") + def test_provenance_selector_uses_exact_lineage_not_geometry_successors(self) -> None: + registry = TopologyRegistry() + source = object() + exact_result = object() + geometrically_similar = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:face", "face", "base", "body:base", {"center_mm": [0, 0, 0]}, source), + ]) + registry.replace_body_topology("later", "body:later", [ + TopologyRecord("later:exact", "face", "later", "body:later", {"center_mm": [10, 0, 0]}, exact_result), + TopologyRecord("later:similar", "face", "later", "body:later", {"center_mm": [0, 0, 0]}, geometrically_similar), + ], topology_delta=TopologyDelta("transform", ( + TopologyDeltaRelation("modified", "face", source, (exact_result,)), + ))) + selector = { + "kind": "face", "owner_feature_id": "base", "stable_id": "base:face", + "source": "runtime_snapshot", "confidence": 1.0, + "geometry": {"center_mm": [0, 0, 0]}, + "selector_intent": { + "version": "1.0", "kind": "face", "query_family": "SWEPT_FACE", + "source_query": {"ast": {}, "featurescript_version": "1511"}, + "derivation_policy": {"allowed": ["continuation"], "multiplicity": "one"}, + "evidence": "kernel_history", + }, + } + resolution = registry.resolve(selector, active_body_id="body:later") + self.assertEqual(resolution.status, "resolved") + self.assertEqual(resolution.record.record_id, "later:exact") + self.assertEqual(registry.lineage()[0].derivation, "continuation") + + def test_provenance_selector_rejects_non_unique_fragment(self) -> None: + registry = TopologyRegistry() + source = object() + first = object() + second = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:edge", "edge", "base", "body:base", {"center_mm": [0, 0, 0]}, source), + ]) + registry.replace_body_topology("fillet", "body:fillet", [ + TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {"center_mm": [0, 0, 0]}, first), + TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {"center_mm": [1, 0, 0]}, second), + ], topology_delta=TopologyDelta("fillet", ( + TopologyDeltaRelation("modified", "edge", source, (first, second)), + ))) + resolution = registry.resolve({ + "kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + "version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE", + "source_query": {"ast": {}, "featurescript_version": "1511"}, + "derivation_policy": {"allowed": ["fragment"], "multiplicity": "one"}, + "evidence": "kernel_history", + }, + }, active_body_id="body:fillet") + self.assertEqual(resolution.status, "ambiguous") + self.assertEqual(resolution.diagnostic.code, "selector_relation_non_unique") + + def test_provenance_selector_returns_all_proven_fragments(self) -> None: + registry = TopologyRegistry() + source = object() + first = object() + second = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:edge", "edge", "base", "body:base", {}, source), + ]) + registry.replace_body_topology("fillet", "body:fillet", [ + TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {}, first), + TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {}, second), + ], topology_delta=TopologyDelta("fillet", ( + TopologyDeltaRelation("modified", "edge", source, (first, second)), + ))) + resolution = registry.resolve({ + "kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + "version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE", + "source_query": {"ast": {}, "featurescript_version": "1511"}, + "derivation_policy": {"allowed": ["fragment"], "multiplicity": "all_fragments"}, + "evidence": "kernel_history", + }, + }, active_body_id="body:fillet") + self.assertEqual(resolution.status, "resolved") + self.assertIsNone(resolution.record) + self.assertEqual([record.record_id for record in resolution.records], ["fillet:first", "fillet:second"]) + + def test_boolean_section_edges_are_recorded_as_intersection_lineage(self) -> None: + registry = TopologyRegistry() + section_edge = object() + registry.replace_body_topology("boolean", "body:boolean", [ + TopologyRecord("boolean:section", "edge", "boolean", "body:boolean", {}, section_edge), + ], topology_delta=TopologyDelta("intersect", section_values=(section_edge,))) + delta = registry.topology_deltas()[0] + lineage = delta["lineage"][0] + self.assertEqual(lineage["derivation"], "intersection") + self.assertEqual(lineage["result_record_ids"], ["boolean:section"]) + self.assertTrue(delta["relations"][0]["section_edge"]) + def test_shell_offset_role_source_selects_one_exact_builder_relation(self) -> None: registry = TopologyRegistry() extrude_start = object() diff --git a/backend/tests/test_feature_plan.py b/backend/tests/test_feature_plan.py deleted file mode 100644 index f9d895a0..00000000 --- a/backend/tests/test_feature_plan.py +++ /dev/null @@ -1,357 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import sys -import tempfile -import unittest - - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "backend")) - -from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan -from app.cad_agent.adapters.artifact_store import FileArtifactStore -from app.cad_agent.adapters.runtime import ProfileCadRuntime -from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository -from app.cad_agent.adapters.verifier import RegistryVerifierExecutor -from app.cad_agent.application.action_handlers import ActionCommandHandler -from app.cad_agent.application.llm_contracts import CompiledRequirementsSpec, MarkdownDocument -from app.cad_agent.application.requirements import RequirementsCommandHandler -from app.cad_agent.application.results import Accepted, Rejected -from app.cad_agent.domain.state import TaskPhase, transition -from app.cad_agent.domain.verifier_registry import default_registry -from app.settings import ProviderConfig, ProviderModel, Settings - - -def runtime_settings(root: Path) -> Settings: - provider = ProviderConfig("test", "Test", "https://test.invalid/v1", "key", (ProviderModel("test-model"),)) - return Settings( - task_root=root / "tasks", conversation_root=root / "conversations", - library_root=ROOT / "backend" / "cdsl_library", engine_root=ROOT / "backend" / "engine" / "cdsl_engine", - llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1, - default_provider_id="test", providers=(provider,), - ) - - -def contract() -> dict[str, object]: - return { - "requirements": [ - { - "requirement_id": "req_001", - "acceptance_claims": [ - {"claim_id": "claim_base", "verification_mode": "deterministic"}, - {"claim_id": "claim_visual", "verification_mode": "visual"}, - ], - }, - { - "requirement_id": "req_002", - "acceptance_claims": [{"claim_id": "claim_bore", "verification_mode": "deterministic"}], - }, - { - "requirement_id": "req_003", - "acceptance_claims": [{"claim_id": "claim_pattern", "verification_mode": "deterministic"}], - }, - ], - } - - -def initial_plan() -> FeaturePlan: - return FeaturePlan.model_validate({ - "schema_version": "cad.v3.2.feature-plan.v1", - "parent_plan_hash": "", - "replaces_node_ids": [], - "nodes": [ - {"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_base"], "expected_change": "One base solid."}, - {"node_id": "bore", "priority": 20, "intent": "Cut the bore.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_bore"], "expected_change": "One through bore."}, - {"node_id": "pattern", "priority": 30, "intent": "Add the pattern.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_pattern"], "expected_change": "Mounting holes."}, - ], - "final_claim_ids": ["claim_visual"], - }) - - -class FeaturePlanTests(unittest.TestCase): - def test_plan_requires_exact_claim_ownership(self) -> None: - plan = initial_plan() - self.assertEqual(validate_feature_plan(plan, contract(), {"extrude_add_blind", "hole_blind"}), []) - broken = plan.model_copy(deep=True) - broken.nodes[1].claim_ids = ["claim_base"] - messages = [item["message"] for item in validate_feature_plan(broken, contract(), {"extrude_add_blind", "hole_blind"})] - self.assertTrue(any("already owned" in message for message in messages)) - self.assertTrue(any("has no owner" in message for message in messages)) - - def test_visual_claim_repeated_on_a_node_is_removed_before_persisting_the_plan(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=lambda: ("extrude_add_blind", "hole_blind")) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a part.") - artifacts.initialize_task(task_id, "Create a part.") - self.assertIsInstance(handler.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a part."), invocation_id="requirements"), Accepted) - self.assertIsInstance(handler.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] A base and a visual edge treatment."), invocation_id="target"), Accepted) - compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "visual", "expected": {"description": "A visible edge treatment."}}, - ]}]}) - self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted) - submitted = FeaturePlan.model_validate({ - "schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [], - "nodes": [{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One base solid."}], - "final_claim_ids": ["claim_002"], - }) - accepted = handler.submit_feature_plan(task_id, submitted, invocation_id="plan") - self.assertIsInstance(accepted, Accepted) - state = repository.get_state(task_id) - persisted = artifacts.read_json(task_id, state.feature_plan_path) - self.assertEqual(persisted["nodes"][0]["claim_ids"], ["claim_001"]) - self.assertEqual(persisted["final_claim_ids"], ["claim_002"]) - - def test_unowned_global_health_claim_is_bound_to_the_unique_root_add_feature(self) -> None: - plan = FeaturePlan.model_validate({ - "schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [], - "nodes": [ - {"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_bore"], "expected_change": "One base solid."}, - {"node_id": "finish", "priority": 20, "intent": "Finish the part.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_pattern"], "expected_change": "One bore."}, - ], - "final_claim_ids": ["claim_visual"], - }) - health_contract = contract() - health_contract["requirements"][0]["acceptance_claims"][0]["claim_kind"] = "single_connected_body" - normalized = RequirementsCommandHandler._assign_unowned_global_health_claims(plan, health_contract) - self.assertEqual(normalized.nodes[0].claim_ids, ["claim_bore", "claim_base"]) - self.assertEqual(validate_feature_plan(normalized, health_contract, {"extrude_add_blind", "hole_blind"}), []) - - def test_scheduler_uses_ready_nodes_and_fixed_priority(self) -> None: - plan = initial_plan() - scheduler = FeatureScheduler(plan, []) - self.assertEqual(scheduler.next_ready().node_id, "base") - base = plan.nodes[0] - events = [{ - "event": "feature_node_verified", "node_id": "base", "node_hash": node_hash(base), - "feature_id": "feature_001", "revision_id": "rev_001", - }] - scheduler = FeatureScheduler(plan, events) - self.assertEqual(scheduler.statuses()["base"], "done") - # Both bore and pattern are ready; priority decides deterministically. - self.assertEqual(scheduler.next_ready().node_id, "bore") - self.assertEqual(scheduler.feature_ids(), {"base": "feature_001"}) - - def test_revision_cannot_change_done_node_and_replaces_failed_subgraph(self) -> None: - previous = initial_plan() - base = previous.nodes[0] - bore = previous.nodes[1] - events = [ - {"event": "feature_node_verified", "node_id": "base", "node_hash": node_hash(base), "feature_id": "feature_001", "revision_id": "rev_001"}, - {"event": "feature_node_failed", "node_id": "bore", "node_hash": node_hash(bore), "failure_class": "engine_build", "attempt": 2, "terminal": True}, - ] - completed = FeatureScheduler(previous, events).completed_node_hashes() - revision = FeaturePlan.model_validate({ - "schema_version": "cad.v3.2.feature-plan.v1", - "parent_plan_hash": plan_hash(previous), - "replaces_node_ids": ["bore"], - "nodes": [ - base.model_dump(mode="json"), - {"node_id": "bore_revised", "priority": 20, "intent": "Cut the bore with revised operation.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_bore"], "expected_change": "One through bore."}, - previous.nodes[2].model_dump(mode="json"), - ], - "final_claim_ids": ["claim_visual"], - }) - self.assertEqual( - validate_feature_plan(revision, contract(), {"extrude_add_blind", "hole_blind"}, previous_plan=previous, completed_node_hashes=completed, required_replacements={"bore"}), - [], - ) - changed = revision.model_copy(deep=True) - changed.nodes[0].intent = "Changed completed node." - self.assertTrue(any("completed node 'base' was modified" in item["message"] for item in validate_feature_plan(changed, contract(), {"extrude_add_blind", "hole_blind"}, previous_plan=previous, completed_node_hashes=completed, required_replacements={"bore"}))) - - def test_materialized_feature_uses_direct_dag_dependencies_not_previous_history_item(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(runtime_settings(Path(temporary))) - base = { - "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "dag_test", - "geometry": {"sketches": [{ - "id": "sketch_001", "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, - "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}, - }]}, - "features": [ - {"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"}, - {"id": "feature_002", "atomic_id": "reference_plane", "params": {"plane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}}, "depends_on": ["feature_001"]}, - ], - } - contract = runtime.operation_contract("reference_axis") - document, audit = runtime.materialize_fragment( - base, - {"feature": {"atomic_id": "reference_axis", "params": {"axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}}}}, - contract, - {}, - runtime.reference_tokens(base), - depends_on_feature_ids=("feature_001",), - ) - self.assertEqual(document["features"][-1]["depends_on"], ["feature_001"]) - self.assertEqual(audit["depends_on_feature_ids"], ["feature_001"]) - - def test_required_through_cut_gets_a_server_recorded_exit_allowance(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(runtime_settings(Path(temporary))) - base = { - "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "through_cut", - "geometry": {"sketches": [{ - "id": "sketch_001", - "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, - "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}, - }]}, - "features": [{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"}], - } - document, audit = runtime.materialize_fragment( - base, - { - "sketch": { - "workplane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, - "profile": {"type": "circle", "center": [0, 0], "radius_mm": 2}, - }, - "feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 5, "reverse": True}}, - }, - runtime.operation_contract("extrude_cut_blind"), - {"body": {"kind": "body", "geometry": {"bbox_mm": [-10, -10, 0, 10, 10, 5]}}}, - runtime.reference_tokens(base), - require_through=True, - depends_on_feature_ids=("feature_001",), - ) - self.assertAlmostEqual(document["features"][-1]["params"]["distance_mm"], 5.01) - self.assertEqual(audit["server_normalizations"][0]["submitted_mm"], 5.0) - self.assertEqual(audit["server_normalizations"][0]["reason"], "required through-cut exit allowance") - - def test_verified_node_publishes_without_a_render_bundle_or_candidate_review(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - registry = default_registry() - runtime = ProfileCadRuntime(runtime_settings(root)) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids) - actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(registry)) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a disk.") - artifacts.initialize_task(task_id, "Create a disk.") - self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted) - self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One connected disk with 20 mm diameter and 5 mm thickness."), invocation_id="target"), Accepted) - compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "axial_span_mm": 5, "tolerance_mm": 0.1}}, - ]}]}) - self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted) - plan = FeaturePlan.model_validate({ - "schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [], - "nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One disk solid."}], - "final_claim_ids": [], - }) - self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.SCHEDULING_FEATURE) - self.assertIsInstance(actions.schedule_next_feature(task_id), Accepted) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FEATURE_PENDING) - result = actions.submit_feature_fragment(task_id, { - "sketch": {"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}}, - "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}}, - }, invocation_id="fragment") - self.assertIsInstance(result, Accepted) - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.SCHEDULING_FEATURE) - self.assertEqual(state.active_revision, "rev_001") - self.assertFalse((artifacts.task_dir(task_id) / "revisions" / "rev_001" / "renders").exists()) - events = repository.ledger_events(task_id) - self.assertTrue(any(event.get("event") == "feature_node_verified" for event in events)) - self.assertFalse(any(event.get("event") == "candidate_built" for event in events)) - final_gate = actions.schedule_next_feature(task_id) - self.assertIsInstance(final_gate, Accepted) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FINAL_VALIDATION) - self.assertTrue(any(event.get("event") == "feature_plan_complete" for event in repository.ledger_events(task_id))) - - def test_feature_plan_schema_binds_plan_revision_lineage_to_state(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - runtime = ProfileCadRuntime(runtime_settings(root)) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - requirements = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=runtime.supported_atomic_ids) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a disk.") - artifacts.initialize_task(task_id, "Create a disk.") - self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted) - self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One disk."), invocation_id="target"), Accepted) - compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - ]}]}) - self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted) - plan = FeaturePlan.model_validate({ - "schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [], - "nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001"], "expected_change": "One disk solid."}], - "final_claim_ids": [], - }) - self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted) - scheduled = transition(repository.get_state(task_id), "feature_scheduled") - self.assertTrue(repository.compare_and_swap(scheduled)) - replanning = transition(repository.get_state(task_id), "feature_replan") - self.assertTrue(repository.compare_and_swap(replanning)) - schema = requirements.feature_plan_schema(task_id) - properties = schema["properties"] - self.assertEqual(properties["parent_plan_hash"]["enum"], [plan_hash(plan)]) - self.assertEqual(properties["replaces_node_ids"]["minItems"], 0) - self.assertEqual(properties["replaces_node_ids"]["maxItems"], 0) - node_schema = schema["$defs"]["FeatureNode"]["properties"] - self.assertEqual(node_schema["claim_ids"]["items"], {"enum": ["claim_001"]}) - self.assertEqual(properties["final_claim_ids"]["items"], {"enum": []}) - - def test_pending_assigned_claim_rejects_the_node_checkpoint(self) -> None: - class PendingAssignedClaimVerifier: - def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]: - return [ - { - "claim_id": str(claim.get("claim_id") or ""), - "claim_kind": str(claim.get("claim_kind") or ""), - "deterministic": True, - "status": "pending" if claim.get("claim_id") == "claim_002" else "pass", - "evidence": {"reason": "target geometry has not been introduced"}, - } - for claim in claims - ] - - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - runtime = ProfileCadRuntime(runtime_settings(root)) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - requirements = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=runtime.supported_atomic_ids) - actions = ActionCommandHandler(repository, artifacts, runtime, PendingAssignedClaimVerifier()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a disk.") - artifacts.initialize_task(task_id, "Create a disk.") - self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted) - self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One disk."), invocation_id="target"), Accepted) - compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "tolerance_mm": 0.1}}, - ]}]}) - self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted) - plan = FeaturePlan.model_validate({ - "schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [], - "nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One disk solid."}], - "final_claim_ids": [], - }) - self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted) - self.assertIsInstance(actions.schedule_next_feature(task_id), Accepted) - result = actions.submit_feature_fragment(task_id, { - "sketch": {"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}}, - "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}}, - }, invocation_id="fragment") - self.assertIsInstance(result, Rejected) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FEATURE_PENDING) - events = repository.ledger_events(task_id) - failure = next(event for event in reversed(events) if event.get("event") == "feature_node_failed") - self.assertEqual(failure["failure_class"], "node_validation") - self.assertTrue(any(item.get("claim_id") == "claim_002" and item.get("status") == "pending" for item in failure["blockers"])) - self.assertFalse(any(event.get("event") == "feature_node_verified" for event in events)) - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/test_live_guidance_comparison.py b/backend/tests/test_live_guidance_comparison.py deleted file mode 100644 index eb1224e0..00000000 --- a/backend/tests/test_live_guidance_comparison.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import sys -import unittest - - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "backend")) - -from app.cad_agent.evals.live import _fixture, compare_guidance_reports # noqa: E402 - - -def _result(*, scenario: str, repetition: int, author_calls: int, context_chars: int, schema_rejections: int, failure_layer: str = "") -> dict: - return { - "scenario": scenario, - "repetition": repetition, - "outcome": "passed", - "revision_ids": ["revision_001"], - "projection": {"phase": "COMPLETED"}, - "checks": {"deterministic_claims_pass": True}, - "schema_rejection_count": schema_rejections, - "failure_attribution": {"layer": failure_layer} if failure_layer else None, - "scenario_budget": {"max_author_turns": 8, "max_reviewer_turns": 2, "max_total_calls": 10, "max_total_tokens": 1000}, - "usage": {"records": [{"context_chars": context_chars} for _ in range(author_calls)]}, - } - - -def _report(results: list[dict]) -> dict: - return { - "author": {"provider": "author", "model": "model"}, - "reviewer": {"provider": "reviewer", "model": "review"}, - "runtime_profile_sha256": "a" * 64, - "operation_contracts": [{"atomic_id": "extrude_add_blind", "contract_hash": "b" * 64}], - "author_guidance": {"enabled": False, "max_chars": 3600}, - "results": results, - } - - -class LiveGuidanceComparisonTests(unittest.TestCase): - def test_fixture_accepts_multiple_stable_scenarios_in_fixture_order(self) -> None: - selected = _fixture("comprehensive", ["l_bracket", "circular_flange_pcd"]) - self.assertEqual([item["id"] for item in selected], ["circular_flange_pcd", "l_bracket"]) - - def test_comparison_enforces_matched_budget_and_quality_gates(self) -> None: - control = _report([ - _result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=2, failure_layer="cdsl_expression"), - _result(scenario="part_a", repetition=2, author_calls=10, context_chars=1000, schema_rejections=1), - ]) - treatment = _report([ - _result(scenario="part_a", repetition=1, author_calls=11, context_chars=1300, schema_rejections=0), - _result(scenario="part_a", repetition=2, author_calls=11, context_chars=1300, schema_rejections=0), - ]) - treatment["author_guidance"]["enabled"] = True - comparison = compare_guidance_reports(control, treatment) - self.assertEqual(comparison["status"], "passed") - self.assertTrue(comparison["gates"]["median_author_calls_within_ten_percent"]) - self.assertTrue(comparison["gates"]["model_or_cdsl_failure_improved"]) - - def test_comparison_excludes_explicit_unsupported_runtime_capability(self) -> None: - control = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=1)]) - treatment = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1200, schema_rejections=0)]) - treatment["author_guidance"]["enabled"] = True - treatment["results"][0]["ledger"] = [{"operation_failures": [{"message": "unsupported_draft"}]}] - comparison = compare_guidance_reports(control, treatment) - self.assertEqual(comparison["treatment"]["eligible_runs"], 0) - self.assertEqual(comparison["excluded_capability_gaps"]["treatment"], [{"scenario": "part_a", "repetition": 1}]) - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/test_review_renderer.py b/backend/tests/test_render_bundle.py similarity index 93% rename from backend/tests/test_review_renderer.py rename to backend/tests/test_render_bundle.py index 02c6394c..9d1313ad 100644 --- a/backend/tests/test_review_renderer.py +++ b/backend/tests/test_render_bundle.py @@ -9,7 +9,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) -from app.services.review_renderer import render_section # noqa: E402 +from app.services.render_bundle import render_section # noqa: E402 from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 @@ -32,7 +32,7 @@ def settings(root: Path) -> Settings: ) -class ReviewRendererTests(unittest.TestCase): +class RenderBundleTests(unittest.TestCase): def test_section_uses_build123d_part_operation_and_emits_a_png(self) -> None: import build123d as b3d diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index b3a8e9c5..22a2847e 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -58,24 +58,3 @@ class SettingsModelSelectionTests(unittest.TestCase): self.assertEqual(resolved_provider.id, "alternate") self.assertEqual(resolved_model.id, "alternate-first") - - def test_reviewer_cannot_be_the_same_author_model(self) -> None: - provider = ProviderConfig("openai", "OpenAI", "https://example.invalid/v1", "key", (ProviderModel("gpt-5.5", vision=True),)) - settings = Settings( - task_root=ROOT / "tmp-tasks", - conversation_root=ROOT / "tmp-conversations", - library_root=ROOT / "backend" / "cdsl_library", - engine_root=ROOT / "backend" / "engine" / "cdsl_engine", - llm_base_url=provider.base_url, - llm_api_key=provider.api_key, - llm_model="gpt-5.5", - llm_timeout_s=1, - default_provider_id="openai", - providers=(provider,), - review_provider_id="openai", - review_model_id="gpt-5.5", - ) - - author_provider, author_model = settings.resolve_model(None, None) - with self.assertRaisesRegex(ValueError, "must differ"): - settings.resolve_independent_review_model(author_provider, author_model) diff --git a/backend/tests/test_single_stage.py b/backend/tests/test_single_stage.py new file mode 100644 index 00000000..d0c99867 --- /dev/null +++ b/backend/tests/test_single_stage.py @@ -0,0 +1,21 @@ +from app.cad_agent.application.single_stage import SingleStageExecutor + + +class Artifacts: + def __init__(self): self.writes = [] + def write_json_once(self, *_args): self.writes.append(_args); return "x" + def start_staging_revision(self, *_args): + return type("Stage", (), {"stage_id": "stage", "output_dir": "/tmp"})() + def write_stage_json(self, *_args): return "x" + def publish_staging_revision(self, *_args): return {"model.step": "x.step"} + + +class Runtime: + def compile_authoring(self, value): return ({"features": [{"id": "feature_001"}]}, {"feature_ids": {"base": "feature_001"}}) + def rebuild_best_effort(self, *_args): return ({"executed_feature_ids": ["feature_001"]}, []) + + +def test_single_stage_publishes_compiled_document(): + result = SingleStageExecutor(None, Artifacts(), Runtime()).execute("cad_abcdefghijkl", {"bodies": []}) + assert result["status"] == "completed" + assert result["executed_feature_ids"] == ["feature_001"] diff --git a/backend/tests/test_single_stage_evals.py b/backend/tests/test_single_stage_evals.py new file mode 100644 index 00000000..a8b9d31f --- /dev/null +++ b/backend/tests/test_single_stage_evals.py @@ -0,0 +1,27 @@ +from app.cad_agent.evals.single_stage import summarize + + +def test_single_stage_summary_reports_protocol_quality_and_cost() -> None: + report = summarize([ + { + "attempts": [{"schema_valid": True, "executable": False}], + "published_revision": "rev_1", + "requirement_targets": [{"status": "pass"}, {"status": "pending"}], + "usage": {"records": [{"prompt_tokens": 10, "completion_tokens": 2}]}, + "duration_ms": 15, + }, + { + "attempts": [{"schema_valid": False, "executable": False}], + "requirement_targets": [{"status": "fail"}], + "usage": {"records": [{"prompt_tokens": 3, "completion_tokens": 1}]}, + "duration_ms": 5, + }, + ]) + assert report["first_pass_schema_rate"] == 0.5 + assert report["first_pass_executable_rate"] == 0.0 + assert report["final_executable_rate"] == 0.5 + assert report["requirement_targets"] == {"pass": 1, "fail": 1, "pending": 1, "not_applicable": 0} + assert report["calls"] == 2 + assert report["prompt_tokens"] == 13 + assert report["completion_tokens"] == 3 + assert report["duration_ms"] == 20 diff --git a/backend/tests/test_single_stage_workflow.py b/backend/tests/test_single_stage_workflow.py new file mode 100644 index 00000000..fee6faa3 --- /dev/null +++ b/backend/tests/test_single_stage_workflow.py @@ -0,0 +1,180 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from app.cad_agent.adapters.artifact_store import FileArtifactStore +from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository +from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator +from app.cad_agent.application.authoring_compiler import AuthoringCompileError +from app.cad_agent.domain.errors import ErrorCode +from app.cad_agent.domain.state import TaskPhase, transition + + +class Runtime: + def supported_atomic_ids(self): return ("extrude_add_blind",) + def operation_contract(self, _operation): + return {"atomic_id": "extrude_add_blind", "fragment_shape": {"sketch": "required", "selector_tokens": "forbidden"}, "selector_policy": {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0}, "reference_policy": {"mode": "none"}, "author_params_schema": {"type": "object", "properties": {"distance_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["distance_mm"], "additionalProperties": False}} + + +class Executor: + def __init__(self, artifacts): self.artifacts = artifacts + def compile(self, _task, _authoring, *, repair_count): + runtime, audit = {"features": []}, {} + runtime_path, audit_path = "documents/runtime.json", "documents/audit.json" + self.artifacts.write_json_once(_task, runtime_path, runtime) + self.artifacts.write_json_once(_task, audit_path, audit) + return {"runtime": runtime, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": "x"} + def build(self, _task, _authoring, _runtime, _audit, *, repair_count, digest=""): + return {"status": "completed", "revision_id": "rev_1", "paths": {"model.step": "revisions/rev_1/model.step"}, "executed_feature_ids": ["feature_001"], "diagnostics": []} + + +class Models: + def __init__(self): self.calls = 0 + async def call_tool(self, **kwargs): + self.calls += 1 + if kwargs["required_tool_name"] == "analyze_requirements": + payload = {"explicit_requirements": ["plate"], "assumptions": [], "acceptance_targets": [], "manual_targets": []} + else: + payload = {"bodies": [{"name": "main", "features": [{"name": "base", "operation": "extrude_add_blind", "params": {"distance_mm": 8}, "sketch": {"workplane": {"origin_mm": [0,0,0], "x_dir": [1,0,0], "normal": [0,0,1]}, "profile": {"type": "circle", "diameter_mm": 20}}}]}]} + import json + return {"tool_calls": [{"function": {"name": kwargs["required_tool_name"], "arguments": json.dumps(payload)}}], "usage": {}} + + +class InvalidAuthorModels(Models): + def __init__(self): + super().__init__() + self.author_calls = 0 + + async def call_tool(self, **kwargs): + if kwargs["required_tool_name"] == "analyze_requirements": + return await super().call_tool(**kwargs) + self.calls += 1 + self.author_calls += 1 + import json + return { + "tool_calls": [{"function": { + "name": "write_authoring_cdsl", + "arguments": json.dumps({"feature_id": "feature_001", "bodies": []}), + }}], + "usage": {}, + } + + +def test_workflow_uses_two_author_calls_and_publishes(): + with TemporaryDirectory() as temporary: + repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") + artifacts = FileArtifactStore(Path(temporary) / "artifacts") + models = Models() + workflow = WorkflowCoordinator(WorkflowConfig(), repository, artifacts, Runtime(), models, Executor(artifacts)) + workflow.create_task("cad_abcdefghijkl", "make a plate") + events = asyncio.run(_collect(workflow)) + assert models.calls == 2 + assert events[-1][0] == "task_terminal" + assert repository.get_state("cad_abcdefghijkl").phase.value == "COMPLETED" + + +def test_service_retry_returns_to_the_persisted_stage_without_new_authoring_call(): + with TemporaryDirectory() as temporary: + repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") + artifacts = FileArtifactStore(Path(temporary) / "artifacts") + workflow = WorkflowCoordinator(WorkflowConfig(), repository, artifacts, Runtime(), Models(), Executor(artifacts)) + workflow.create_task("cad_abcdefghijkl", "make a plate") + created = repository.get_state("cad_abcdefghijkl") + assert created is not None + authoring = transition(created, "analysis_written", requirements_path="documents/requirements-analysis.json") + assert repository.compare_and_swap(authoring) + failed = transition(authoring, "failed", error=ErrorCode.STORAGE_FAILURE) + assert failed.phase == TaskPhase.FAILED + assert failed.retry_from_phase == TaskPhase.AUTHORING_CDSL + assert repository.compare_and_swap(failed) + + assert workflow.resume("cad_abcdefghijkl") + resumed = repository.get_state("cad_abcdefghijkl") + assert resumed is not None + assert resumed.phase == TaskPhase.AUTHORING_CDSL + assert resumed.retry_from_phase is None + + +def test_authoring_schema_failures_use_at_most_two_repairs(): + with TemporaryDirectory() as temporary: + repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") + artifacts = FileArtifactStore(Path(temporary) / "artifacts") + models = InvalidAuthorModels() + workflow = WorkflowCoordinator(WorkflowConfig(), repository, artifacts, Runtime(), models, Executor(artifacts)) + workflow.create_task("cad_abcdefghijkl", "make a plate") + + asyncio.run(_collect(workflow)) + + state = repository.get_state("cad_abcdefghijkl") + assert state is not None + assert state.phase == TaskPhase.FAILED + assert state.repair_count == 2 + assert models.author_calls == 3 + + +def test_author_operation_context_exposes_server_injected_selector_contract(): + context = WorkflowCoordinator._author_operation_contract({ + "fragment_shape": {"sketch": "forbidden", "selector_tokens": "required"}, + "selector_policy": { + "slot": "params.host_face", "token_kind": "face", "min_items": 1, "max_items": 1, + }, + "author_params_schema": {"type": "object", "properties": {}, "additionalProperties": False}, + }) + + assert context["sketch"] == "forbidden" + assert context["selector"] == { + "required": True, + "kind": "face", + "min_items": 1, + "max_items": 1, + "destination": "params.host_face", + "source_syntax": ".", + } + assert context["authoring_sketch_template"] is None + + +def test_schema_repair_hint_contains_the_exact_authoring_sketch_form(): + assert "diameter_mm" in WorkflowCoordinator._repair_hint( + "AUTHOR_SCHEMA_INVALID", "bodies.0.features.0.sketch.profile", + ) + + +def test_repair_can_change_unexecuted_features_after_compile_failure(): + previous = {"bodies": [{"name": "main", "features": [ + {"name": "base", "operation": "box_add", "params": {}}, + {"name": "bad_hole", "operation": "hole_wizard", "params": {}}, + ]}]} + replacement = {"bodies": [{"name": "main", "features": [ + {"name": "base", "operation": "box_add", "params": {}}, + {"name": "bad_hole", "operation": "hole_wizard", "params": {"diameter_mm": 8}}, + ]}]} + WorkflowCoordinator._validate_repair_document( + previous, + replacement, + {"diagnostics": [{"code": "SELECTOR_NOT_FOUND", "path": "features.bad_hole.selectors"}]}, + {"feature_ids": {"base": "feature_001", "bad_hole": "feature_002"}}, + ) + + +def test_repair_cannot_change_executed_feature_without_targeted_diagnostic(): + previous = {"bodies": [{"name": "main", "features": [ + {"name": "base", "operation": "box_add", "params": {"height_mm": 8}}, + ]}]} + replacement = {"bodies": [{"name": "main", "features": [ + {"name": "base", "operation": "box_add", "params": {"height_mm": 9}}, + ]}]} + try: + WorkflowCoordinator._validate_repair_document( + previous, + replacement, + {"diagnostics": [], "executed_feature_ids": ["feature_001"]}, + {"feature_ids": {"base": "feature_001"}}, + ) + except AuthoringCompileError as error: + assert error.code == "AUTHOR_SCHEMA_INVALID" + else: + raise AssertionError("an executed feature was changed without a diagnostic target") + + +async def _collect(workflow): + return [item async for item in workflow.run(task_id="cad_abcdefghijkl", author=ModelIdentity("p", "m"))] diff --git a/cadfs_to_cdsl/CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md b/cadfs_to_cdsl/CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md new file mode 100644 index 00000000..2a9c00ca --- /dev/null +++ b/cadfs_to_cdsl/CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md @@ -0,0 +1,139 @@ +# CADFS 全量能力快照对比 + +## 范围 + +本文档对比第一次归档的 CADFS 全量运行与最新归档的全量运行。两份快照均包含 +9,347 个源样本。 + +| 快照 | 报告生成时间 | 证据目录 | +| --- | --- | --- | +| 第一次全量运行 | 2026-09-02 20:40:56 | `cadfs_to_cdsl/output-history/20260907-185128/` | +| 最新全量运行 | 2026-09-08 21:59:59 | `cadfs_to_cdsl/output-history/20260908-215959/` | + +一个 executor 已注册,仅表示运行时能识别该原子操作;它本身不表示全部 CADFS +参数变体、拓扑 selector、body 生命周期或源模型都能正确重建。下文的全量比较与 +能力缺口表才是实际覆盖范围的证据。 + +## 已注册的原子操作 + +| 原子操作组 | 第一次全量运行 | 最新全量运行 | 变化 | +| --- | --- | --- | --- | +| 基础实体 | `sphere_add` | `box_add`, `cylinder_add`, `sphere_add` | 新增方体与圆柱体 | +| 盲拉伸/加料拉伸 | `extrude_add_blind`, `extrude_add_two_sided` | 保留原有操作,新增 `extrude_add_blind_with_hole` | 新增 selector 绑定的端盖孔拉伸 | +| 切除拉伸 | `extrude_cut_blind` | 保留原有操作,新增 `extrude_cut_through`、`extrude_cut_two_sided` | 新增贯穿与双向切除 | +| 曲面拉伸 | 未注册 | `extrude_surface` | 新增仅生成曲面的拉伸路径 | +| 回转 | `revolve_add`, `revolve_cut` | 保留原有操作,新增 `revolve_surface` | 新增曲面回转路径 | +| 放样 | 未注册 | `loft_add`, `loft_add_with_cap_face` | 新增实体放样与 `CAP_FACE` 驱动放样 | +| 扫掠 | 未注册 | `sweep_add` | 新增实体扫掠路径 | +| 抽壳 | 未注册 | `shell` | 新增抽壳路径 | +| 多 body 布尔 | 未注册 | `boolean_bodies` | 新增 body 的 union/subtract/intersect | +| 孔 | `hole_blind`, `hole_counterbore`, `hole_countersink`, `hole_wizard` | 相同 | 无新原子操作 | +| 修饰特征 | `fillet`, `chamfer` | 相同 | 无新原子操作;覆盖和 selector 处理有改进 | +| 阵列 | `pattern_linear`, `pattern_mirror` | 保留原有操作,新增 `pattern_circular` | 新增环形阵列;replay/body 处理也有改进 | +| 基准几何 | `reference_plane`, `reference_axis` | 相同 | 无新原子操作;CADFS 基准面变体覆盖增加 | +| 螺纹 | 未注册 | `thread_add`, `thread_cut` | 新增加料/切除螺纹 | +| 钣金折弯 | 未注册 | `bend_add` | 已注册;这两份 CADFS 全量快照未单独证明其 CADFS 映射与几何接受率 | + +## 全量能力覆盖 + +计数是被记录为能力缺口的样本数。减少表示更多样本进入支持的转换/运行时路径, +但不表示每个新增可执行模型都已通过几何验收。 + +| 类别 | 能力 | 第一次缺口 | 最新缺口 | 变化 | 当前解释 | +| --- | --- | ---: | ---: | ---: | --- | +| 新覆盖路径 | `revolve_surface` | 367 | 0 | -367 | 已观察到的样本均进入支持的运行时路径 | +| 新覆盖路径 | `extrude_cut_through_all` | 301 | 0 | -301 | 不再因贯穿切除产生此能力缺口 | +| 新覆盖路径 | `extrude_cut_two_sided` | 260 | 0 | -260 | 不再因双向切除产生此能力缺口 | +| 新覆盖路径 | `extrude_add_through_all` | 3 | 0 | -3 | 不再因贯穿加料产生此能力缺口 | +| 新覆盖路径 | `extrude_extent:up_to_next` | 48 | 0 | -48 | 不再因 up-to-next 产生此能力缺口 | +| 新覆盖路径 | `reference_plane:line_angle` | 121 | 0 | -121 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:plane_point` | 46 | 0 | -46 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:three_point` | 34 | 0 | -34 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:mid_plane` | 24 | 0 | -24 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:line_point` | 22 | 0 | -22 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:curve_point` | 12 | 0 | -12 | 不再因该基准面变体产生此能力缺口 | +| 主要覆盖提升 | `extrude` | 3,861 | 1,197 | -2,664 | 最大降幅;派生 profile 与 selector 变体仍待完成 | +| 主要覆盖提升 | `shell` | 696 | 57 | -639 | 主路径已存在;面选择器变体仍待完成 | +| 主要覆盖提升 | `fillet` | 1,914 | 1,417 | -497 | 覆盖增加;OCC 可行性和后继选择仍阻塞大量模型 | +| 主要覆盖提升 | `revolve` | 741 | 344 | -397 | 实体回转覆盖增加 | +| 主要覆盖提升 | `loft` | 308 | 68 | -240 | 主放样路径和部分 cap-face 形式已覆盖 | +| 主要覆盖提升 | `chamfer` | 589 | 381 | -208 | 覆盖增加;selector 与内核失败仍存在 | +| 主要覆盖提升 | `hole` | 623 | 386 | -237 | 更多孔变体进入运行时 | +| 主要覆盖提升 | `sweep` | 326 | 133 | -193 | 实体扫掠覆盖增加;路径变体仍待完成 | +| 主要覆盖提升 | `booleanBodies` | 187 | 92 | -95 | 多 body 路径覆盖增加 | +| 主要覆盖提升 | `cPlane` | 147 | 84 | -63 | 更多基准面路径成功 lower | +| 主要覆盖提升 | `mirror` | 262 | 158 | -104 | 阵列/body replay 覆盖增加 | +| 主要覆盖提升 | `circularPattern` | 228 | 74 | -154 | 阵列/body replay 覆盖增加 | +| 主要覆盖提升 | `transform` | 1 | 230 | +229 | 更多 transform 语义被识别并诊断;不代表完整支持 | +| 派生拓扑 | `extrude_profile_topology:cap_edge` | 133 | 91 | -42 | 部分 `CAP_EDGE` 覆盖 | +| 派生拓扑 | `extrude_profile_topology:cap_face` | 200 | 183 | -17 | 部分 `CAP_FACE` 覆盖 | +| 派生拓扑 | `extrude_profile_topology:intersect` | 382 | 362 | -20 | 部分 `INTERSECT` 覆盖 | +| 派生拓扑 | `extrude_profile_topology:offset_face` | 18 | 9 | -9 | 部分 `OFFSET_FACE` 覆盖 | +| 派生拓扑 | `extrude_profile_topology:swept_face` | 99 | 94 | -5 | 部分 `SWEPT_FACE` 覆盖 | + +## 最新快照中新分类的未完成边界 + +这些条目在最新全量报告中被新分类或单独拆出。它们表示已知未完成语义,不能视为 +已完成能力。 + +| 能力缺口 | 最新受影响样本 | 含义 | +| --- | ---: | --- | +| `shell_face_selector` | 221 | 这些 source history 的 shell remove-face selector 尚不能解析 | +| `delete_bodies` | 121 | body 生命周期中的删除语义不完整 | +| `sweep_path` | 68 | 扫掠路径变体尚不能表达或执行 | +| `shell_outward` | 19 | 向外抽壳方向尚未完成 | +| `extrude_draft_extent` | 15 | 带拔模拉伸的终止语义尚未完成 | +| `sweep_remove` | 6 | 扫掠切除语义尚未完成 | +| `circular_pattern_remove_source` | 1 | 环形阵列移除源 body 的生命周期尚未完成 | +| `extrude_cap_edge_profile` | 1 | `CAP_EDGE` 派生拉伸 profile 尚未完成 | + +## 端到端结果 + +结果表区分可执行工件和几何验收。`RP` 是工程验收口径;`strict` 是更严格的诊断, +不能隐藏已 RP 通过的工件。 + +| 指标 | 第一次全量运行 | 最新全量运行 | 变化 | +| --- | ---: | ---: | ---: | +| 候选 CDSL 文件 | 8,108 | 8,633 | +525 | +| 完整转换 | 2,427 | 5,367 | +2,940 | +| 无可执行特征的 deferred | 2,602 | 693 | -1,909 | +| 重建 STEP 文件 | 1,812 | 6,134 | +4,322 | +| 比较报告 | 1,719 | 6,090 | +4,371 | +| RP 通过模型 | 1,159 (12.40%) | 2,031 (21.73%) | +872 | +| Strict 通过模型 | 578 (6.18%) | 905 (9.68%) | +327 | +| 比较超时或 worker 失败 | 56 | 44 | -12 | +| 已重建但几何拒绝 | 568 | 4,059 | +3,491 | +| 重建失败 | 617 | 2,144 | +1,527 | +| 解析/lowering 失败 | 3 | 21 | +18 | + +当更多 partial 或原先不支持的 history 能进入 engine 后,被拒绝的 STEP 明显增加。 +这些 STEP 工件和诊断必须保留;它们是 converter/runtime 的证据,不是验收通过的重建。 + +## 当前失败优先级 + +最新全量运行记录了以下主要重建失败类别。 + +| 失败类别 | 受影响重建 | 优先工作 | +| --- | ---: | --- | +| Selector 找不到 | 982 | 保留拓扑 provenance,实现精确的后继/output-role 绑定 | +| Fillet 内核失败 | 347 | 改善通用可行性处理,并保留有界 OCC 诊断 | +| Chamfer 内核失败 | 174 | 改善通用可行性处理,并保留有界 OCC 诊断 | +| BRep API 失败 | 139 | 调查操作特定的内核前置条件与拓扑输入 | +| Compound 拉伸不支持 | 83 | 完成显式多 body/Compound 的拉伸语义 | +| Selector 歧义 | 82 | 改善 selector provenance,确定性拒绝不唯一候选 | +| 终止目标不可达或空 | 71 | 完成以目标为基础的 extent/trim 语义 | +| Boolean 操作失败 | 98 | 完成通用多 body boolean 输入和结果生命周期 | +| Shell 操作失败 | 46 | 完成 remove-face 及向内/向外抽壳语义 | + +## 状态 + +最新快照显示转换和可执行 STEP 覆盖已有明显扩展,但尚未完成全量重建。只有当一项 +能力同时具备 FeatureScript lowering、CDSL contract、runtime/adapter 行为、selector/body +生命周期、单元覆盖、多个真实语料回归与 RP 比较证据时,才能标记为完成。 + +## 证据 + +- `cadfs_to_cdsl/output-history/20260907-185128/summary.json` +- `cadfs_to_cdsl/output-history/20260907-185128/full_run_report.md` +- `cadfs_to_cdsl/output-history/20260908-215959/summary.json` +- `cadfs_to_cdsl/output-history/20260908-215959/full_run_report.md` diff --git a/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md b/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md index 2a63c5a8..23edad0c 100644 --- a/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md +++ b/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md @@ -35,6 +35,11 @@ feature history 生成工程相似的 STEP。 - 若已有实现反复依赖样本化补丁、不能表达已出现的通用语义或受内核 API 结构性限制, 必须评估替代方案,不得沿错误方向继续累积补丁。替换需要可复现根因、成熟实现或最小 原型的对照、contract/迁移影响评估和回归计划;单个样本或偶发内核失败不足以触发重写。 +- 每项 FeatureScript operation、query 或枚举语义的 lowering,必须对照源文件声明的 + FeatureScript/standard library 版本对应的官方 API/query contract,并在能力矩阵记录 + 文档或标准库来源、已采用的语义与未覆盖边界。当前官网或其它版本的文档不得替代源版本 + 的默认值和行为。文档只用于确定源语义,不能替代 CDSL provenance、OCC builder history + 或 selector 的唯一性证据。 - 每项能力必须同时具备:FeatureScript lowering、CDSL schema/semantic validation、 runtime/adapter 实现、selector/body 语义、单元测试、多个语料回归和比较工件;缺少 任一层只能标记为“部分完成”。 @@ -495,8 +500,9 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 和 body provenance。仅有 runtime CDSL contract 而未由 lowering 产生的能力必须标为 部分完成。 3. runtime/adapter 按通用算法执行,记录结果 body 和 topology delta,不依赖样本信息。 -4. 原子语义矩阵包含正向、边界和拒绝测试;至少多个真实语料样本覆盖不同几何和 - 生命周期组合。 +4. 能力矩阵记录对应 FeatureScript API/query 的源版本文档或标准库依据、采用的语义和 + 未覆盖边界;原子语义矩阵包含正向、边界和拒绝测试,且至少多个真实语料样本覆盖 + 不同几何和生命周期组合。 5. 受影响核心集、扩展集和全量 shard 有可复现结果,工程相似通过率、失败数和剩余 exception 均更新到本地台账。 6. 代码审查确认没有 sample-specific 分支、gold STEP 参数回填、隐式默认尺寸或为 diff --git a/cadfs_to_cdsl/featurescript_parser.py b/cadfs_to_cdsl/featurescript_parser.py index c2ca377d..a2c1ecdc 100644 --- a/cadfs_to_cdsl/featurescript_parser.py +++ b/cadfs_to_cdsl/featurescript_parser.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from typing import Any from .featurescript_lexer import Token, lex from .ir import Call, FeatureIR, ModelIR, SketchIR @@ -130,7 +131,18 @@ def _arg_map(call: Call) -> dict[str, Any]: def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR: - parser = Parser(source); calls = parser.statements(); model = ModelIR(sample_id, raw_source=source) + parser = Parser(source); calls = parser.statements() + version = re.search(r"\bFeatureScript\s+(\d+(?:\.\d+)*)\s*;", source) + standard_library = re.search( + r"\bimport\s*\(\s*path\s*:\s*[\"']([^\"']*onshape/std/[^\"']*)[\"']", + source, + ) + model = ModelIR( + sample_id, + raw_source=source, + featurescript_version=version.group(1) if version else None, + standard_library=standard_library.group(1) if standard_library else None, + ) for call in calls: if call.name == "newSketch": definition = _arg_map(call) diff --git a/cadfs_to_cdsl/ir.py b/cadfs_to_cdsl/ir.py index d4abb10c..40568e76 100644 --- a/cadfs_to_cdsl/ir.py +++ b/cadfs_to_cdsl/ir.py @@ -37,3 +37,7 @@ class ModelIR: sketches: list[SketchIR] = field(default_factory=list) steps: list[Any] = field(default_factory=list) raw_source: str = "" + # Source metadata is retained independently from the lowered CDSL so a + # selector can be audited against the FeatureScript API contract it used. + featurescript_version: str | None = None + standard_library: str | None = None diff --git a/cadfs_to_cdsl/lowering.py b/cadfs_to_cdsl/lowering.py index 45ba9b16..dacf8fac 100644 --- a/cadfs_to_cdsl/lowering.py +++ b/cadfs_to_cdsl/lowering.py @@ -49,6 +49,55 @@ def plain(value: Any) -> Any: return value +def _selector_intent( + value: Any, + *, + query_family: str, + kind: str, + evidence: str, + allowed: tuple[str, ...] = ("continuation",), + multiplicity: str = "one", + output_role: str | None = None, + disambiguation: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Keep the source query as semantics, separate from runtime topology IDs.""" + query = parse_query(value) + intent: dict[str, Any] = { + "version": "1.0", + "kind": kind, + "query_family": query_family, + # This placeholder is populated from the enclosing ModelIR once the + # FeatureScript source header has been parsed. + "source_query": {"ast": query.ast, "featurescript_version": "0"}, + "derivation_policy": {"allowed": list(allowed), "multiplicity": multiplicity}, + "evidence": evidence, + } + if query.source_sketch and query.source_entity: + intent["source_entity"] = {"sketch_id": query.source_sketch, "entity_id": query.source_entity} + if output_role is not None: + intent["output_role"] = output_role + if disambiguation is not None: + intent["disambiguation"] = disambiguation + return intent + + +def _finalize_selector_intents(cdsl: dict[str, Any], model: ModelIR) -> None: + """Bind source-version metadata after all FeatureScript selectors lower.""" + source_version = model.featurescript_version or "0" + for feature in cdsl.get("features") or []: + for selector in feature.get("selectors") or []: + intent = selector.get("selector_intent") if isinstance(selector, dict) else None + if not isinstance(intent, dict): + continue + selector["selector_intent_version"] = "1.0" + source_query = intent.get("source_query") + if not isinstance(source_query, dict): + continue + source_query["featurescript_version"] = source_version + if model.standard_library: + source_query["standard_library"] = model.standard_library + + def _bool(value: Any) -> bool: return value is True or (isinstance(value, str) and value.lower() == "true") @@ -573,6 +622,17 @@ def _shell_offset_face_output_role_selector( }, "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _selector_intent( + value, + query_family="OFFSET_FACE", + kind="face", + evidence="operation_role", + allowed=("boundary", "replacement"), + output_role="shell.offset_face", + disambiguation={"type": "true_dependency", "sources": [ + {"owner_feature_id": source["owner_feature_id"], "output_role": source["output_role"]}, + ]}, + ), } @@ -1792,6 +1852,14 @@ def _cap_face_output_role_selector( "output_role": f"{role_prefix}.{'start' if query.is_start else 'end'}", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _selector_intent( + value, + query_family="CAP_FACE", + kind="face", + evidence="operation_role", + allowed=("boundary", "continuation"), + output_role=f"{role_prefix}.{'start' if query.is_start else 'end'}", + ), } @@ -4800,6 +4868,8 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: diagnostics.append({"code": "feature_deferred", "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False if not features: return LoweringResult(None, "deferred_no_executable_feature", diagnostics, history) cdsl = {"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": model.sample_id, - "meta": {"unit": "mm", "source": "CADFS", "provenance": provenance, "capability_gaps": sorted({d.get("operation") for d in diagnostics if d.get("operation")})}, + "meta": {"unit": "mm", "source": "CADFS", "provenance": provenance, "capability_gaps": sorted({d.get("operation") for d in diagnostics if d.get("operation")}), + "source_featurescript": {"version": model.featurescript_version or "0", **({"standard_library": model.standard_library} if model.standard_library else {})}}, "geometry": {"sketches": sketches}, "features": features} + _finalize_selector_intents(cdsl, model) return LoweringResult(cdsl, "converted_complete" if complete else "converted_partial", diagnostics, history) diff --git a/cadfs_to_cdsl/query_parser.py b/cadfs_to_cdsl/query_parser.py index c0f9c13c..c7b4fa72 100644 --- a/cadfs_to_cdsl/query_parser.py +++ b/cadfs_to_cdsl/query_parser.py @@ -15,6 +15,18 @@ class QueryInfo: source_entity: str | None = None is_start: bool | None = None calls: list[str] = field(default_factory=list) + # The AST is intentionally lossless for the parser's value model. Query + # aliases may be resolved for lowering, but their nested query semantics + # must remain auditable and cannot be collapsed into a geometry hint. + ast: dict[str, Any] | list[Any] | str | float | bool | None = None + query_combinators: list[str] = field(default_factory=list) + filters: list[str] = field(default_factory=list) + body_scope: list[str] = field(default_factory=list) + disambiguation: list[str] = field(default_factory=list) + + def __post_init__(self) -> None: + if self.ast is None: + self.ast = {} def as_dict(self) -> dict[str, Any]: return asdict(self) @@ -29,10 +41,31 @@ def walk_calls(value: Any): for item in value.values(): yield from walk_calls(item) +def query_ast(value: Any) -> dict[str, Any] | list[Any] | str | float | bool | None: + """Serialize parsed FeatureScript query syntax without evaluating it.""" + if isinstance(value, Call): + return {"call": value.name, "args": [query_ast(arg) for arg in value.args], "line": value.line} + if isinstance(value, list): + return [query_ast(item) for item in value] + if isinstance(value, dict): + return {str(key): query_ast(item) for key, item in value.items()} + if value is None or isinstance(value, (str, float, bool, int)): + return value + return str(value) + + def parse_query(value: Any) -> QueryInfo: - info = QueryInfo() + info = QueryInfo(ast=query_ast(value)) for call in walk_calls(value): info.calls.append(call.name) + if call.name in {"qUnion", "qIntersection", "qSubtraction", "qAdjacent"}: + info.query_combinators.append(call.name) + if call.name in {"qBodyType", "qOwnerBody"}: + info.body_scope.append(call.name) + if call.name in {"TDD", "trueDependencyDisambiguation"}: + info.disambiguation.append(call.name) + if call.name in {"qBodyType", "qOwnerBody", "qAdjacent"}: + info.filters.append(call.name) if call.name in {"makeQuery", "qCreatedBy"} and call.args: owner = symbolic_string(call.args[0]) if "F" in owner: diff --git a/cadfs_to_cdsl/selector_binding.py b/cadfs_to_cdsl/selector_binding.py index 1f7b2145..938f09bc 100644 --- a/cadfs_to_cdsl/selector_binding.py +++ b/cadfs_to_cdsl/selector_binding.py @@ -152,6 +152,18 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list for placeholder in targets: records = prefix_records(placeholder.get("binding_feature_id"), placeholder.get("owner_feature_id")) output_role = str(placeholder.get("output_role") or "").strip() + intent = placeholder.get("selector_intent") + if ( + isinstance(intent, dict) + and intent.get("query_family") != "GEOMETRIC" + and not output_role + ): + # Prefix rebuilding is retained as a diagnostic adapter. + # It must not turn an unproven FeatureScript provenance + # query into a geometry-scored stable selector. + raise ValueError( + f"{feature['id']}: selector_kernel_history_missing after prefix rebuild" + ) if output_role: # Builder output roles are not geometry placeholders. They # remain in the bound CDSL so runtime can resolve the diff --git a/cadfs_to_cdsl/tests/test_lowering.py b/cadfs_to_cdsl/tests/test_lowering.py index 3c9b9950..69075369 100644 --- a/cadfs_to_cdsl/tests/test_lowering.py +++ b/cadfs_to_cdsl/tests/test_lowering.py @@ -1041,10 +1041,15 @@ class LoweringTests(unittest.TestCase): cap_extrude = features["f_F5"] self.assertEqual(cap_extrude["atomic_id"], "extrude_from_face") self.assertNotIn("sketch_id", cap_extrude) - self.assertEqual(cap_extrude["selectors"], [{ + selector = cap_extrude["selectors"][0] + self.assertEqual({key: selector[key] for key in ( + "kind", "owner_feature_id", "output_role", "source", "confidence", + )}, { "kind": "face", "owner_feature_id": "f_F3", "output_role": "extrude.start", "source": "runtime_snapshot", "confidence": 1.0, - }]) + }) + self.assertEqual(selector["selector_intent_version"], "1.0") + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") from engine.cdsl_engine.runtime import rebuild_cdsl with tempfile.TemporaryDirectory() as directory: @@ -1691,6 +1696,21 @@ class LoweringTests(unittest.TestCase): self.assertEqual(result.diagnostics[0]["code"], "unsupported_engine_capability") self.assertIn("extrude_profile_topology:cap_face", result.diagnostics[0]["capability"]) + def test_cap_face_selector_retains_versioned_provenance_intent(self): + source = SOURCE.replace( + '\n});\n', + '\n extrude(context, id + "F2", {"entities":makeQuery(id + "F1.opExtrude", "CAP_FACE", FACE, {"isStart":false}), "depth":10 * mm});\n});\n', + ) + result = lower_model(parse_featurescript(source, "cap-intent"), {}) + self.assertEqual(result.status, "converted_complete") + selector = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")["selectors"][0] + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") + self.assertEqual(selector["selector_intent_version"], "1.0") + self.assertEqual(selector["selector_intent"]["source_query"]["featurescript_version"], "1511") + self.assertEqual(selector["selector_intent"]["evidence"], "operation_role") + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + def test_conversion_writes_status_and_sidecars(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE) diff --git a/cadfs_to_cdsl/tests/test_parser.py b/cadfs_to_cdsl/tests/test_parser.py index 0b388338..4d9a9952 100644 --- a/cadfs_to_cdsl/tests/test_parser.py +++ b/cadfs_to_cdsl/tests/test_parser.py @@ -52,6 +52,16 @@ class ParserTests(unittest.TestCase): value = parse_query(query) self.assertEqual((value.owner_feature, value.source_sketch, value.source_entity), ("F1", "F0", "E0")) self.assertFalse(value.is_start) + self.assertEqual(value.ast["call"], "makeQuery") + self.assertEqual(value.ast["args"][0]["call"], "__binary__") + + def test_source_version_and_standard_library_are_retained(self): + source = '''FeatureScript 1511; + import(path : "onshape/std/geometry.fs", version : "1511.0"); + export const f = defineFeature(function(context, id, definition) {});''' + model = parse_featurescript(source, "versioned") + self.assertEqual(model.featurescript_version, "1511") + self.assertEqual(model.standard_library, "onshape/std/geometry.fs") def test_block_scoped_query_aliases_do_not_use_the_last_assignment(self): source = r''' diff --git a/cadfs_to_cdsl/tests/test_selector_binding.py b/cadfs_to_cdsl/tests/test_selector_binding.py index 32dc3ec4..ef0130c7 100644 --- a/cadfs_to_cdsl/tests/test_selector_binding.py +++ b/cadfs_to_cdsl/tests/test_selector_binding.py @@ -123,10 +123,14 @@ class SelectorBindingTests(unittest.TestCase): bound, evidence = bind_candidate_selectors(candidate.cdsl) selector = next(item for item in bound["features"] if item["id"] == "f_F3")["selectors"][0] - self.assertEqual(selector, { + self.assertEqual({key: selector[key] for key in ( + "kind", "owner_feature_id", "output_role", "source", "confidence", + )}, { "kind": "face", "owner_feature_id": "f_F1", "output_role": "extrude.end", "source": "runtime_snapshot", "confidence": 1.0, }) + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") + self.assertEqual(selector["selector_intent"]["evidence"], "operation_role") self.assertNotIn("stable_id", selector) self.assertNotIn("snapshot_id", selector) self.assertNotIn("geometry", selector) @@ -155,11 +159,15 @@ class SelectorBindingTests(unittest.TestCase): self.assertEqual(first["snapshot_id"], "body:f_F2:face:1") self.assertEqual(first["source"], "runtime_snapshot") self.assertEqual(first["confidence"], 1.0) - self.assertEqual(offset, { + self.assertEqual({key: offset[key] for key in ( + "kind", "owner_feature_id", "output_role", "output_role_source", "source", "confidence", + )}, { "kind": "face", "owner_feature_id": "f_F2", "output_role": "shell.offset_face", "output_role_source": {"owner_feature_id": "f_F1", "output_role": "extrude.start"}, "source": "runtime_snapshot", "confidence": 1.0, }) + self.assertEqual(offset["selector_intent"]["query_family"], "OFFSET_FACE") + self.assertEqual(offset["selector_intent"]["disambiguation"]["type"], "true_dependency") binding = next(item for item in evidence if item["feature_id"] == "f_F3") self.assertEqual(binding["resolved"][0]["snapshot_id"], "body:f_F2:face:1") self.assertEqual(binding["resolved"][1]["record_id"], "body:f_F2:face:5") diff --git a/frontend/src/components/agent-studio.tsx b/frontend/src/components/agent-studio.tsx index 22a6d798..40049575 100644 --- a/frontend/src/components/agent-studio.tsx +++ b/frontend/src/components/agent-studio.tsx @@ -21,7 +21,7 @@ import type { } from "@/lib/cad-types"; import { AgentThread } from "./agent-thread"; import { CadViewerPreview } from "./cad-viewer-preview"; -import { MarkdownDocument } from "./rich-content"; +import { JsonTree, MarkdownDocument } from "./rich-content"; import type { AssistantRuntime } from "@assistant-ui/react"; type LoadState = "loading" | "ready" | "error"; @@ -557,27 +557,10 @@ function StudioShell({ {!config?.configured ?
未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。
: null} - {config?.autonomous_generation && !config.review_configured ?
最终视觉复核未配置,任务在最终发布前会停止:{config.review_error || "请配置独立视觉模型。"}
: null}
@@ -587,27 +570,18 @@ function StudioShell({ ); } -function TaskDocuments({ task, onSelectRevision }: { task: TaskRecord | null; onSelectRevision: (revisionId: string) => void }) { - const documents = [ - ["需求文档", task?.requirements_markdown], - ["完成目标", task?.completion_target_markdown], +function TaskDocuments({ task }: { task: TaskRecord | null }) { + const structured = [ + ["需求分析", task?.requirements_analysis], + ["Authoring CDSL", task?.authoring_cdsl], + ["编译审计", task?.compile_audit], + ["构建诊断", task?.diagnostics], ] as const; - if (!documents.some(([, markdown]) => markdown) && !task?.feature_nodes?.length) return null; + if (!structured.some(([, value]) => value) && !task?.completion_result_markdown) return null; return
- {task?.checklist_progress?.length ?
- {task.checklist_progress.map((item) =>
{item.status === "pass" ? "完成" : item.status === "fail" ? "未通过" : "待验证"}{item.statement}
)} -
: null} - {task?.feature_nodes?.length ?
- 特征 DAG -
- {task.feature_nodes.slice().sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)).map((node) =>
- {node.status === "done" ? "完成" : node.status === "running" ? "执行中" : node.status === "failed" ? "失败" : node.status === "blocked" ? "阻塞" : "待执行"} -
{node.intent || node.node_id}{node.atomic_id} · 优先级 {node.priority}{node.depends_on?.length ? ` · 依赖 ${node.depends_on.join(", ")}` : ""}{node.attempt ? ` · 尝试 ${node.attempt}` : ""}{node.error ? {node.error} : null}
- {node.status === "done" && node.revision_id ? : null} -
)} -
-
: null} - {documents.map(([title, markdown]) => markdown ?
{title}{markdown}
: null)} + {task?.repair_count !== undefined ?
修复
{task.repair_count} / {task.repair_budget ?? 2}
: null} + {structured.map(([title, value]) => value ?
{title}
: null)} + {task?.completion_result_markdown ?
完成报告{task.completion_result_markdown}
: null}
; } diff --git a/frontend/src/components/cad-message-parts.tsx b/frontend/src/components/cad-message-parts.tsx index 1735a15b..f4a9afff 100644 --- a/frontend/src/components/cad-message-parts.tsx +++ b/frontend/src/components/cad-message-parts.tsx @@ -1,9 +1,9 @@ "use client"; -import { AlertTriangle, Box, Check, Download, Eye, FileCheck, Loader2, RotateCcw, Search, Wrench } from "lucide-react"; +import { AlertTriangle, Box, Check, Download, FileCheck, Loader2, RotateCcw, Search } from "lucide-react"; import { encodeArtifactUrl } from "@/lib/cad-artifacts"; import type { CadError, CadProgress, CadResult } from "@/lib/cad-types"; -import { JsonTree, MarkdownDocument } from "./rich-content"; +import { MarkdownDocument } from "./rich-content"; export function TextPart({ text }: { text: string }) { if (!text.trim()) return null; @@ -15,26 +15,20 @@ export function CadProgressPart({ data }: { data: CadProgress }) { const isRunning = status === "running"; const isError = status === "error"; const isWaiting = status === "waiting"; - const statusLabel = isRunning ? "进行中" : isWaiting ? data.lifecycle === "waiting_retry" ? "等待重试" : "等待确认" : isError ? "失败" : status === "success" ? "完成" : data.status; - const Icon = data.step === "tool_call" ? Wrench : data.step.includes("review") || data.step === "final_review" ? Eye : data.step === "rollback" ? RotateCcw : data.step.includes("requirements") || data.step.includes("checklist") ? FileCheck : data.step.includes("diagnostic") ? Search : isError || isWaiting ? AlertTriangle : Check; - const evidence = data.evidence || (Array.isArray(data.review?.evidence) ? data.review.evidence.map(String) : []); + const statusLabel = isRunning ? "进行中" : isWaiting ? "等待确认" : isError ? "失败" : status === "success" ? "完成" : data.status; + const Icon = data.step === "repair_started" ? RotateCcw : data.step === "build_result" ? Box : data.step === "cdsl_compiled" ? Search : data.step === "requirements_ready" || data.step === "authoring_cdsl_ready" ? FileCheck : isError || isWaiting ? AlertTriangle : Check; const documentMarkdown = data.markdown || ""; return (
{isRunning ?
{data.message ?
{data.message}
: null} {data.questions?.length ?
    {data.questions.map((question, index) =>
  • {question}
  • )}
: null} {data.issues?.length ?
    {data.issues.map((issue, index) =>
  • {issue}
  • )}
: null} - {data.verificationWarnings?.length ?
验证风险 ({data.verificationWarnings.length})
    {data.verificationWarnings.map((warning, index) =>
  • {warning}
  • )}
: null} {documentMarkdown ?
文档内容{documentMarkdown}
: null} - {data.arguments ?
调用参数
: null} - {data.result !== undefined ?
执行结果
: null} - {evidence.length ?
证据 ({evidence.length})
    {evidence.map((item, index) =>
  • {item}
  • )}
: null}
); } @@ -59,7 +53,6 @@ export function CadResultPart({ data }: { data: CadResult }) { {data.referenceIds.length} 个参考
{data.referenceIds.length ?
参考{data.referenceIds.join(";")}
: null} - {data.verificationWarnings?.length ?
验证风险{data.verificationWarnings.join(";")}
: null} {downloads.length ?
{downloads.map(([label, path]) => ( @@ -84,7 +77,6 @@ export function CadErrorPart({ data }: { data: CadError }) {
{data.message}
- {data.tool ? {data.tool} : null} {data.fieldErrors?.length ?
字段错误 ({data.fieldErrors.length})
    {data.fieldErrors.map((error, index) =>
  • {error.path || "/"}{error.message ? `: ${error.message}` : ""}
  • )}
: null}
); diff --git a/frontend/src/lib/cad-artifacts.ts b/frontend/src/lib/cad-artifacts.ts index 0c3c2256..3ec3a491 100644 --- a/frontend/src/lib/cad-artifacts.ts +++ b/frontend/src/lib/cad-artifacts.ts @@ -30,8 +30,6 @@ function resultForRevision(task: TaskRecord, revisionId: string, checkpoint: boo engine: current.engine || "cdsl_only", checkpoint, lifecycle: task.lifecycle || "completed", - verificationStatus: task.verification_status, - verificationWarnings: task.verification_warnings || [], }; } @@ -44,10 +42,7 @@ export function activeCheckpointPreview(task: TaskRecord | null): CadResult | nu export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null { if (!task) return null; - // v3.2 deliberately exposes its last verified checkpoint on a failed DAG: - // failure means requirements were not completed, not that earlier geometry - // should disappear. Legacy task projections retain the former policy. - if (task.lifecycle === "failed" && !task.published_revision && task.schema_version !== "3.2") return null; + if (task.lifecycle === "failed" && !task.published_revision) return null; const current = task.revisions.find((revision) => revision.revision_id === (task.published_revision || task.current_revision)) ?? [...task.revisions].reverse().find((revision) => revision.status === "success" && revision.visibility !== "checkpoint"); diff --git a/frontend/src/lib/cad-messages.ts b/frontend/src/lib/cad-messages.ts index a19be29e..b10cd1a0 100644 --- a/frontend/src/lib/cad-messages.ts +++ b/frontend/src/lib/cad-messages.ts @@ -62,7 +62,7 @@ export function restoreTaskProjection(messages: CadUIMessage[], task: TaskRecord )); if (alreadyVisible) return messages; const status = task.lifecycle === "failed" ? "error" - : task.lifecycle === "waiting_for_user" || task.lifecycle === "waiting_retry" ? "waiting" + : task.lifecycle === "waiting_for_user" ? "waiting" : task.lifecycle === "completed" ? "success" : "running"; const progress: CadProgress = { step, @@ -73,10 +73,7 @@ export function restoreTaskProjection(messages: CadUIMessage[], task: TaskRecord message: task.message || (terminal ? "CAD 任务已停止。" : "CAD 任务正在运行。"), questions: task.questions || [], issues: task.issues || [], - blockerType: task.blocker_type, userActionRequired: Boolean(task.user_action_required), - verificationStatus: task.verification_status, - verificationWarnings: task.verification_warnings || [], }; return [...messages, { id: `projection_${task.task_id}_${task.state_version || 0}`, diff --git a/frontend/src/lib/cad-stream.test.ts b/frontend/src/lib/cad-stream.test.ts index c1618a1e..01e2dd75 100644 --- a/frontend/src/lib/cad-stream.test.ts +++ b/frontend/src/lib/cad-stream.test.ts @@ -9,10 +9,18 @@ import type { CadUIMessage } from "./cad-types"; test("maps backend cad_result SSE into an AI SDK data part", () => { const chunk = backendEventToUiChunk({ event: "cad_result", - data: { taskId: "cad_abc", revisionId: "rev_001" }, + data: { + taskId: "cad_abc", revisionId: "rev_001", cdslPath: "model.cdsl.json", + stepPath: "model.step", glbPath: "model.glb", reportPath: "rebuild-report.json", + summary: "plate", referenceIds: [], engine: "cdsl_only", + }, }, "text_1"); assert.equal(chunk?.type, "data-cad-result"); - assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" }); + assert.deepEqual("data" in chunk! ? chunk.data : null, { + taskId: "cad_abc", revisionId: "rev_001", cdslPath: "model.cdsl.json", + stepPath: "model.step", glbPath: "model.glb", reportPath: "rebuild-report.json", + summary: "plate", referenceIds: [], engine: "cdsl_only", + }); }); test("keeps progressive revisions as separate data parts", () => { @@ -21,23 +29,13 @@ test("keeps progressive revisions as separate data parts", () => { assert.notEqual(first?.id, second?.id); }); -test("maps final repair review into a blocking progress state", () => { +test("maps a single-stage build repair into a blocking progress state", () => { const chunk = backendEventToUiChunk({ - event: "final_review", data: { taskId: "cad_abc", result: { status: "repair", evidence: ["missing round"] } }, + event: "build_result", data: { taskId: "cad_abc", status: "repair_required", message: "host face is ambiguous" }, }, "text_1"); assert.equal(chunk?.type, "data-cad-progress"); assert.deepEqual("data" in chunk! ? chunk.data : null, { - step: "final_review", label: "最终独立复核", status: "error", message: "", taskId: "cad_abc", result: { status: "repair", evidence: ["missing round"] }, - }); -}); - -test("maps a rejected independent candidate review into a blocking progress state", () => { - const chunk = backendEventToUiChunk({ - event: "candidate_review", data: { taskId: "cad_abc", result: { status: "rejected", evidence: ["base is disconnected"] } }, - }, "text_1"); - assert.equal(chunk?.type, "data-cad-progress"); - assert.deepEqual("data" in chunk! ? chunk.data : null, { - step: "candidate_review", label: "候选独立复核", status: "error", message: "", taskId: "cad_abc", result: { status: "rejected", evidence: ["base is disconnected"] }, + step: "build_result", label: "CAD 构建", status: "error", message: "host face is ambiguous", taskId: "cad_abc", }); }); @@ -46,7 +44,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => { event: "cad_error", data: { stage: "generation", - tool: "compile_requirements_spec", + tool: "write_authoring_cdsl", message: "Author repeatedly failed the schema.", fieldErrors: [{ path: "/patches", message: "Field required" }], }, @@ -54,7 +52,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => { assert.equal(chunk?.type, "data-cad-error"); assert.deepEqual("data" in chunk! ? chunk.data : null, { stage: "generation", - tool: "compile_requirements_spec", + tool: "write_authoring_cdsl", message: "Author repeatedly failed the schema.", fieldErrors: [{ path: "/patches", message: "Field required" }], }); @@ -124,17 +122,17 @@ test("keeps explicit runtime issues visible when generation stops", () => { }); }); -test("gives repeated tool events unique ordered parts", () => { - const first = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", tool: "inspect_model", status: "running" } }, "text_1", 4); - const second = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", tool: "inspect_model", status: "success" } }, "text_1", 5); +test("gives repeated build events unique ordered parts", () => { + const first = backendEventToUiChunk({ event: "build_result", data: { taskId: "cad_abc", status: "repair_required" } }, "text_1", 4); + const second = backendEventToUiChunk({ event: "build_result", data: { taskId: "cad_abc", status: "completed" } }, "text_1", 5); assert.notEqual(first?.id, second?.id); assert.equal("data" in first! ? (first.data as { sequence?: number }).sequence : null, 4); assert.equal("data" in second! ? (second.data as { sequence?: number }).sequence : null, 5); }); -test("reuses an invocation id so tool completion updates its running card", () => { - const running = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", eventId: "call_1", invocationId: "call_1", tool: "submit_cdsl_fragment", status: "running" } }, "text_1", 4); - const complete = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", eventId: "call_1", invocationId: "call_1", tool: "submit_cdsl_fragment", status: "success" } }, "text_1", 5); +test("reuses an event id for build card updates", () => { + const running = backendEventToUiChunk({ event: "build_result", data: { taskId: "cad_abc", eventId: "build_1", status: "repair_required" } }, "text_1", 4); + const complete = backendEventToUiChunk({ event: "build_result", data: { taskId: "cad_abc", eventId: "build_1", status: "completed" } }, "text_1", 5); assert.equal(running?.id, complete?.id); }); @@ -180,21 +178,6 @@ test("does not restore a private checkpoint after a failed run", () => { assert.equal(result, null); }); -test("restores the last verified v3.2 DAG checkpoint after a failed run", () => { - const result = latestSuccessfulResult({ - schema_version: "3.2", - task_id: "cad_abc", - current_revision: "rev_002", - active_revision: "rev_002", - lifecycle: "failed", - revisions: [ - { revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" }, - ], - }); - assert.equal(result?.revisionId, "rev_002"); - assert.equal(result?.checkpoint, true); -}); - test("restores an active checkpoint only while the task is running", () => { const result = activeCheckpointPreview({ task_id: "cad_abc", current_revision: "rev_002", active_revision: "rev_002", published_revision: "rev_001", lifecycle: "running", diff --git a/frontend/src/lib/cad-stream.ts b/frontend/src/lib/cad-stream.ts index ef330a5d..faa72884 100644 --- a/frontend/src/lib/cad-stream.ts +++ b/frontend/src/lib/cad-stream.ts @@ -20,15 +20,11 @@ export function backendEventToUiChunk( data: { ...item.data, sequence }, }; } - if (["image_observation", "requirements_document_ready", "completion_target_ready", "requirements_compiled", "modeling_plan_ready", "completion_result_ready", "action_selection", "tool_call", "candidate_result", "candidate_review", "final_review", "task_terminal"].includes(item.event)) { - const review = item.data.review && typeof item.data.review === "object" - ? item.data.review as Record - : null; + if (["requirements_ready", "authoring_cdsl_ready", "cdsl_compiled", "build_result", "repair_started", "task_terminal"].includes(item.event)) { const lifecycle = String(item.data.lifecycle || ""); const status = item.event === "task_terminal" - ? (lifecycle === "failed" ? "error" : lifecycle === "waiting_for_user" || lifecycle === "waiting_retry" ? "waiting" : "success") - : String((item.data.result as Record | undefined)?.status || "") === "rejected" - || String((item.data.result as Record | undefined)?.status || "") === "repair" + ? (lifecycle === "failed" ? "error" : lifecycle === "waiting_for_user" ? "waiting" : "success") + : String(item.data.status || "") === "repair_required" ? "error" : String(item.data.status || "running"); const taskId = String(item.data.taskId || "task"); @@ -37,37 +33,25 @@ export function backendEventToUiChunk( ? { eventId, sequence, - ...(review ? { review } : {}), } : {}; return { type: "data-cad-progress", id: `event_${eventId}`, data: { step: item.event, label: ({ - image_observation: "参考图片观察", requirements_document_ready: "需求文档已冻结", completion_target_ready: "完成目标已冻结", requirements_compiled: "需求合同已编译", modeling_plan_ready: "建模计划已冻结", completion_result_ready: "完成结果已就绪", action_selection: "动作选择", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", final_review: "最终独立复核", task_terminal: "生成任务", + requirements_ready: "需求分析", authoring_cdsl_ready: "完整 CDSL", cdsl_compiled: "CDSL 编译", build_result: "CAD 构建", repair_started: "CDSL 修复", task_terminal: "生成任务", } as Record)[item.event], status, ...metadata, message: String( item.data.message || item.data.reason - || (Array.isArray(item.data.questions) ? item.data.questions.map(String).filter(Boolean).join(";") : "") - || (review?.evidence instanceof Array ? review.evidence.join(";") : "") - || (review?.issues instanceof Array ? review.issues.map((issue) => typeof issue === "object" && issue ? String((issue as Record).message || "") : String(issue)).filter(Boolean).join(";") : ""), + || (Array.isArray(item.data.questions) ? item.data.questions.map(String).filter(Boolean).join(";") : ""), ), ...(item.data.taskId ? { taskId } : {}), - ...(item.data.nodeId ? { nodeId: String(item.data.nodeId) } : {}), ...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}), ...(Array.isArray(item.data.questions) ? { questions: item.data.questions.map(String).filter(Boolean) } : {}), ...(Array.isArray(item.data.issues) ? { issues: item.data.issues.map(String).filter(Boolean) } : {}), - ...(item.data.blockerType ? { blockerType: String(item.data.blockerType) } : {}), ...(typeof item.data.userActionRequired === "boolean" ? { userActionRequired: item.data.userActionRequired } : {}), - ...(item.data.verificationStatus ? { verificationStatus: String(item.data.verificationStatus) } : {}), - ...(Array.isArray(item.data.verificationWarnings) ? { verificationWarnings: item.data.verificationWarnings.map(String).filter(Boolean) } : {}), ...(item.data.clarificationPath ? { clarificationPath: String(item.data.clarificationPath) } : {}), ...(item.data.timestamp ? { timestamp: String(item.data.timestamp) } : {}), ...(item.data.markdown ? { markdown: String(item.data.markdown) } : {}), - ...(item.data.tool ? { tool: String(item.data.tool) } : {}), - ...(item.data.invocationId ? { invocationId: String(item.data.invocationId) } : {}), - ...(item.data.arguments && typeof item.data.arguments === "object" ? { arguments: item.data.arguments as Record } : {}), - ...(item.data.result !== undefined ? { result: item.data.result } : {}), - ...(Array.isArray(item.data.evidence) ? { evidence: item.data.evidence.map(String) } : {}), }, }; } diff --git a/frontend/src/lib/cad-types.ts b/frontend/src/lib/cad-types.ts index 7dc98db1..7f0be71b 100644 --- a/frontend/src/lib/cad-types.ts +++ b/frontend/src/lib/cad-types.ts @@ -10,25 +10,12 @@ export type CadProgress = { message?: string; markdown?: string; taskId?: string; - nodeId?: string; - tool?: string; - invocationId?: string; - arguments?: Record; - result?: unknown; - evidence?: string[]; questions?: string[]; issues?: string[]; - blockerType?: string; userActionRequired?: boolean; - verificationStatus?: "verified" | "completed_with_risks" | string; - verificationWarnings?: string[]; - review?: Record; clarificationPath?: string; - lifecycle?: "running" | "completed" | "failed" | "waiting_retry" | "waiting_for_user" | string; - attempt?: number; - maxAttempts?: number; + lifecycle?: "running" | "completed" | "failed" | "waiting_for_user" | string; path?: string; - contractHash?: string; }; export type CadResult = { @@ -43,8 +30,6 @@ export type CadResult = { engine: string; checkpoint?: boolean; lifecycle?: "running" | "completed" | "failed" | string; - verificationStatus?: "verified" | "completed_with_risks" | string; - verificationWarnings?: string[]; }; export type CadError = { @@ -97,12 +82,6 @@ export type TaskRevision = { engine?: string; error?: string; visibility?: "checkpoint" | "final" | "superseded" | string; - parent_revision_id?: string; - branch_id?: string; - step_review_path?: string; - candidate_review_path?: string; - render_manifest_path?: string; - visual_review_path?: string; }; export type TaskRecord = { @@ -111,71 +90,34 @@ export type TaskRecord = { current_revision: string; active_revision?: string; published_revision?: string; - lifecycle?: "running" | "completed" | "failed" | "cancelled" | "waiting_retry" | "waiting_for_user" | string; + lifecycle?: "running" | "completed" | "failed" | "cancelled" | "waiting_for_user" | string; phase?: string; state_version?: number; - active_candidate_id?: string; - requirements_spec?: Record | null; - requirements_spec_path?: string; + repair_count?: number; + repair_budget?: number; + requirements_path?: string; + authoring_path?: string; + runtime_cdsl_path?: string; + compile_audit_path?: string; + diagnostics_path?: string; + completion_path?: string; clarification_path?: string; - requirements_contract?: Record | null; - requirements_contract_path?: string; - requirements_markdown?: string | null; - requirements_document_path?: string; - completion_target_markdown?: string | null; - completion_target_path?: string; - feature_plan?: { + requirements_analysis?: Record | null; + authoring_cdsl?: Record | null; + runtime_cdsl?: Record | null; + compile_audit?: Record | null; + diagnostics?: Record | null; + claim_report?: { schema_version?: string; - parent_plan_hash?: string; - replaces_node_ids?: string[]; - nodes?: Array>; - final_claim_ids?: string[]; + claims?: Array<{ target?: string; status?: "pass" | "fail" | "pending" | "not_applicable" | string; verification?: string }>; } | null; - feature_plan_path?: string; - feature_plan_hash?: string; - current_feature_node_id?: string; - pending_feature?: { action_id: string; node_id: string; plan_hash: string; atomic_id: string; claim_ids: string[]; depends_on_node_ids: string[] } | null; - feature_nodes?: Array<{ - node_id: string; - intent?: string; - atomic_id?: string; - priority?: number; - depends_on?: string[]; - claim_ids?: string[]; - status?: "pending" | "ready" | "running" | "done" | "failed" | "blocked" | "invalidated" | string; - attempt?: number; - failure_class?: string; - error?: string; - revision_id?: string; - feature_id?: string; - evidence?: Array>; - }>; completion_result_markdown?: string | null; - completion_result_path?: string; - claim_summary?: Array<{ - requirement_id: string; - claim_id: string; - claim_kind: string; - deterministic: boolean; - status: "pass" | "pending" | "fail" | "unavailable" | string; - evidence?: Record; - }>; - checklist_progress?: Array<{ - requirement_id: string; - statement: string; - status: "pass" | "pending" | "fail" | string; - }>; - pending_action?: { action_id: string; working_head: string; intent: string; requirement_ids: string[]; atomic_id: string; expected_change: string; contract_hash: string } | null; - action_ledger_summary?: Array>; - usage?: { calls: number; prompt_tokens: number; completion_tokens: number; context_chars: number }; + usage?: { calls: number; records: Array> }; preview_revision?: string; message?: string; questions?: string[]; issues?: string[]; - blocker_type?: string; user_action_required?: boolean; - verification_status?: "verified" | "completed_with_risks" | string; - verification_warnings?: string[]; revisions: TaskRevision[]; }; @@ -191,6 +133,4 @@ export type BackendConfig = { configured: boolean; library_samples: number; autonomous_generation?: boolean; - review_configured?: boolean; - review_error?: string; };