wip: preserve local engine and agent workflow changes before ganjihong refactor integration
This commit is contained in:
+1
-14
@@ -2,7 +2,7 @@
|
||||
# CDSL_DEFAULT_PROVIDER=deepseek
|
||||
# CDSL_DEFAULT_MODEL=deepseek-v4-flash
|
||||
CDSL_DEFAULT_PROVIDER=openai
|
||||
CDSL_DEFAULT_MODEL=gpt-5.4-mini
|
||||
CDSL_DEFAULT_MODEL=gpt-5.5
|
||||
|
||||
# DeepSeek. Fill in your own API key below.
|
||||
CDSL_LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
@@ -11,9 +11,6 @@ CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp
|
||||
CDSL_LLM_TIMEOUT_S=90
|
||||
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
|
||||
|
||||
# Final autonomous-task publication uses this independent vision reviewer.
|
||||
CDSL_REVIEW_PROVIDER=openai
|
||||
CDSL_REVIEW_MODEL=gpt-5.5
|
||||
|
||||
# Optional OpenAI provider. Comma-separate enabled models; list vision models
|
||||
# separately so image attachments can be routed safely.
|
||||
@@ -30,13 +27,3 @@ CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1
|
||||
CDSL_KIMI_API_KEY=
|
||||
CDSL_KIMI_MODELS=moonshot-v1-8k
|
||||
CDSL_KIMI_VISION_MODELS=
|
||||
|
||||
# Autonomous CDSL agent limits. These protect an active modelling head, not
|
||||
# the total feature count of a CAD task.
|
||||
CDSL_AGENT_TOOL_CALLS_PER_CYCLE=12
|
||||
CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD=3
|
||||
CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT=6
|
||||
CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT=3
|
||||
CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT=6
|
||||
CDSL_AGENT_CONTEXT_CHAR_LIMIT=14000
|
||||
CDSL_AGENT_RENDER_CACHE=true
|
||||
|
||||
+20
-37
@@ -20,53 +20,36 @@ CDSL_OPENAI_REASONING_EFFORT=medium
|
||||
```
|
||||
|
||||
Use `low`, `medium`, or `high` according to the latency/cost versus quality
|
||||
tradeoff. The setting is sent as Chat Completions' `reasoning_effort` field to
|
||||
authoring, streaming, and visual-review requests. Leave it empty to use the
|
||||
tradeoff. The setting is sent as the provider's `reasoning_effort` field to
|
||||
requirements analysis and Authoring CDSL generation. Leave it empty to use the
|
||||
provider/model default. The selected OpenAI-compatible endpoint must support
|
||||
the requested value.
|
||||
|
||||
## Autonomous CDSL Agent Configuration
|
||||
|
||||
The autonomous agent writes one frozen free-form `requirements.md`, then
|
||||
observes, measures, renders and appends one CDSL feature at a time. Its author
|
||||
uses normal function calls; no provider strict JSON Schema capability or
|
||||
complete modelling DAG is required. Candidate fragments are rebuilt in a
|
||||
staging directory through `cdsl_only` before a checkpoint can be committed.
|
||||
Each task has one bounded workflow:
|
||||
|
||||
Final publication requires a separately configured vision-capable review model
|
||||
and the Python OpenCascade/Pillow technical renderer. The agent may build and
|
||||
inspect intermediate checkpoints without image review; a final run fails
|
||||
closed if its independent review configuration is unavailable.
|
||||
```text
|
||||
request analysis -> complete cad.author.v1 -> server compilation -> runtime build
|
||||
-> at most two complete repairs -> final or best-effort publication
|
||||
```
|
||||
|
||||
The model outputs only local body/feature names and declarative selectors.
|
||||
The server validates strict schemas, allocates Runtime CDSL identities,
|
||||
compiles references, executes dependencies, and preserves the last executable
|
||||
prefix. STEP is the primary artifact; GLB and the CPU-only OpenCascade/Pillow
|
||||
render bundle are generated from the same published revision.
|
||||
|
||||
```dotenv
|
||||
# Must name one configured provider and one model listed in that provider's
|
||||
# CDSL_<PROVIDER>_VISION_MODELS setting. It is intentionally not inferred
|
||||
# from the authoring model.
|
||||
CDSL_REVIEW_PROVIDER=deepseek
|
||||
CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp
|
||||
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
|
||||
|
||||
# Install Python rendering dependencies. The renderer reads the revision STEP
|
||||
# file and creates canonical images without a browser or GPU driver.
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Limits apply to the current checkpoint head, never to total task complexity.
|
||||
CDSL_AGENT_TOOL_CALLS_PER_CYCLE=12
|
||||
CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD=3
|
||||
CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT=6
|
||||
CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT=6
|
||||
CDSL_AGENT_CONTEXT_CHAR_LIMIT=24000
|
||||
CDSL_AGENT_RENDER_CACHE=true
|
||||
```
|
||||
|
||||
The author chooses each coherent 1-6 feature batch. Every rebuilt batch is
|
||||
rendered and independently reviewed before it can become a checkpoint; only
|
||||
an accepted reviewer verdict advances the working model. Every checkpoint is rebuilt from its fully materialized CDSL through the
|
||||
`cdsl_only` runtime. Checkpoint GLB files are preview-only; STEP, CDSL, and
|
||||
reports are available only after the task reaches `COMPLETED`.
|
||||
|
||||
The backend assigns feature and sketch IDs, appends causal dependencies and
|
||||
expands only opaque current-snapshot selector tokens. It does not compile
|
||||
geometry templates or correct workplanes, profiles, sizes, directions or
|
||||
boolean semantics authored by the model. Failed candidates remain auditable
|
||||
but never become revisions.
|
||||
The initial generation plus two repairs are the only model calls allowed after
|
||||
requirements analysis. A repair returns a complete replacement Authoring CDSL
|
||||
and may only alter diagnosed features. Runtime selector ambiguity, missing
|
||||
selectors, and unavailable dependencies produce stable diagnostics rather than
|
||||
topology guesses. Requirement compliance is reported independently as
|
||||
`pass`, `fail`, `pending`, or `not_applicable`; a partial executable model is
|
||||
still published after the repair budget is exhausted.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Authoring CDSL Modeling Guide
|
||||
|
||||
Write one complete `cad.author.v1` document. It is declarative source for a
|
||||
server compiler, not the runtime CDSL and not an execution log. Return only
|
||||
the schema-valid object requested by the tool.
|
||||
|
||||
## Contract Boundary
|
||||
|
||||
Use lower-case local `name` values for bodies and features. They are symbols
|
||||
within this document only. Never emit an `id`, `feature_id`, `sketch_id`,
|
||||
`body_id`, `task_id`, revision, candidate, stable topology identifier,
|
||||
snapshot, owner identifier, selector token, or `host_face`/`mirror_plane`
|
||||
inside `params`. The server allocates identities and injects selector values
|
||||
into the destination stated by the operation contract.
|
||||
|
||||
Use millimetres and a right-handed coordinate system unless the request says
|
||||
otherwise. Put every reasonable but unstated design choice in `assumptions`.
|
||||
Do not turn an assumed dimension into a deterministic acceptance target.
|
||||
|
||||
## Modeling Brief
|
||||
|
||||
Before authoring, derive this internal brief from the requirements:
|
||||
|
||||
- part or multi-body intent; explicit dimensions and units;
|
||||
- functional datums, origin, base plane, and positive directions;
|
||||
- primary volumes, holes, pockets, bosses, ribs, patterns, and finishing;
|
||||
- explicitly verifiable targets versus manual targets;
|
||||
- assumptions that do not affect fit, safety, or compliance.
|
||||
|
||||
Dimensioned request facts take precedence over proportions inferred from an
|
||||
image. Ask for clarification only when a missing interface, scale, safety, or
|
||||
compliance value makes construction impossible. Otherwise choose a practical
|
||||
engineering default and record it as an assumption.
|
||||
|
||||
## Construction Order
|
||||
|
||||
Choose the simplest supported construction whose parameters directly express
|
||||
the requested dimensions. Use a stable order:
|
||||
|
||||
1. establish the body and functional coordinate frame;
|
||||
2. create primary additive volume(s);
|
||||
3. create any selector-hosted feature before its named source output is
|
||||
changed by a fuse, cut, shell, pattern, or finishing operation;
|
||||
4. add remaining bosses, ribs, and other major additive geometry;
|
||||
5. make remaining pockets, bores, and through features;
|
||||
6. apply patterns, then fillets and chamfers last.
|
||||
|
||||
Every feature must list each feature it actually uses in `depends_on`.
|
||||
Prefer one complete profile-driven feature for a planar silhouette. Use
|
||||
primitives when their axis, radius, and height directly express the part.
|
||||
For through cuts, choose the operation's through extent and make the tool
|
||||
cross the material; never rely on coincident faces or a guessed nearby face.
|
||||
Delay dress-up operations because they can alter downstream topology.
|
||||
|
||||
The selector-source rule is an intentional exception to a generic “all adds,
|
||||
then all cuts” sequence. For example, a bolt circle hosted on an original
|
||||
flange cap must be placed immediately after the base when later additive
|
||||
fusions replace that exact cap. A required final through-cut can still follow
|
||||
all additive features and the earlier bolt operation.
|
||||
|
||||
Use only the supplied operation list. Follow each operation's parameter schema
|
||||
exactly, including required sketch state. Do not invent unsupported operation
|
||||
parameters, implicit booleans, or a substitute operation after a capability
|
||||
error.
|
||||
|
||||
## Sketches And Coordinates
|
||||
|
||||
Keep a sketch to exactly `workplane` and `profile`. The workplane declares its
|
||||
origin, `x_dir`, and normal. Profile coordinates are local to that workplane.
|
||||
For primitive axes, `origin_mm` is the start-cap center and `direction` is the
|
||||
positive build direction. For a selector-hosted hole, position coordinates are
|
||||
world coordinates unless the operation contract explicitly says otherwise.
|
||||
|
||||
The Authoring sketch syntax is deliberately smaller than Runtime CDSL. For
|
||||
every sketch operation, emit exactly this shape. `profile` is singular,
|
||||
circles use the requested `diameter_mm`, and the local center is `center_mm`:
|
||||
|
||||
```json
|
||||
{
|
||||
"workplane": {
|
||||
"origin_mm": [0, 0, 12],
|
||||
"x_dir": [1, 0, 0],
|
||||
"normal": [0, 0, 1]
|
||||
},
|
||||
"profile": {
|
||||
"type": "circle",
|
||||
"diameter_mm": 56,
|
||||
"center_mm": [0, 0]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Do not write `profiles`, `plane`, `support`, `radius_mm`, `center`, or any
|
||||
other key inside an Authoring sketch. The compiler derives Runtime radius and
|
||||
sketch identity. Use a primitive such as `cylinder_add` when its axis, radius,
|
||||
and height directly express the requested geometry and no sketch is needed.
|
||||
|
||||
Name features for their manufacturing role, for example `base_plate`,
|
||||
`front_hub_boss`, `center_bore`, and `bolt_holes`. Names make dependencies and
|
||||
repair diagnostics readable; they are not server identities.
|
||||
|
||||
## Selectors
|
||||
|
||||
The operation metadata states whether `selectors` are required, their kind,
|
||||
cardinality, and server-side destination. Put only declarative selectors in a
|
||||
feature's `selectors` array. Never write the destination field itself inside
|
||||
`params`.
|
||||
|
||||
For an output face, use an exact local role embedded in `source` and state a
|
||||
unique match:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "face",
|
||||
"source": "front_hub_boss.top_planar_face",
|
||||
"match": "unique"
|
||||
}
|
||||
```
|
||||
|
||||
Do not add `role`, `query`, `host_face`, a face index, a coordinate selector,
|
||||
or a Runtime selector token. The `source` value is the complete local intent.
|
||||
The compiler adds its source feature as an auditable graph dependency; include
|
||||
other true construction dependencies in `depends_on` yourself.
|
||||
|
||||
`top_planar_face` and `end_face` mean the positive-direction cap of a supported
|
||||
extrude, sweep, loft, or cylinder. `bottom_planar_face` and `start_face` mean
|
||||
the opposite cap. Select the most recent feature whose output is known to be
|
||||
the required host; do not select a similar face by location, face index, or
|
||||
proximity.
|
||||
|
||||
Translate a request's descriptive `max_z`/`min_z` wording into these output
|
||||
roles before writing CDSL. Never emit `base.max_z_face` or `base.min_z_face`.
|
||||
For a cylinder built in `+Z`, the top cap is `top_planar_face` and the bottom
|
||||
cap is `bottom_planar_face`; reverse-direction features swap their world-Z
|
||||
position but retain their own start/end roles.
|
||||
|
||||
`hole_wizard` requires exactly one `face` selector. It uses that selector as
|
||||
its host face, so a central bore on a hub should select the hub cap, while a
|
||||
bolt circle on an exposed flange should select the flange cap. A selector must
|
||||
name a host that contains every requested hole position. If that cannot be
|
||||
made unique, redesign the feature sequence or omit the unsupported feature;
|
||||
never guess a face.
|
||||
|
||||
Before creating a hosted hole, calculate each position against the actual host
|
||||
face. A local boss can be the global highest face while being too small to host
|
||||
a larger bolt circle. In that case use the exposed flange cap at the bolt
|
||||
radius, with its own plane height, rather than the global maximum-Z cap. Place
|
||||
the hosted holes while that source cap's exact provenance is still active:
|
||||
before a later fusion or cut would split, remove, or replace it. A final
|
||||
through-cut may still follow all additive features, so a bolt circle can be
|
||||
hosted before an unrelated final boss and before that final cut.
|
||||
|
||||
## Acceptance And Repair
|
||||
|
||||
Describe only user-requested, measurable acceptance targets in
|
||||
`acceptance_targets`; leave inferred dimensions in `assumptions`. On repair,
|
||||
return a complete replacement document. Preserve feature names and every
|
||||
feature listed in `executed_feature_ids`, except a feature explicitly named by
|
||||
the diagnostic. Features that were never executed may be changed freely to
|
||||
repair an invalid selector, parameter, dependency, or geometry construction.
|
||||
|
||||
Read structured diagnostics literally. Fix their named cause with the smallest
|
||||
document change, then return the entire document. Do not add internal IDs,
|
||||
weaken a requested value, silently delete a failed feature, or replace a
|
||||
failed selector with an arbitrary topology element.
|
||||
@@ -1 +0,0 @@
|
||||
以当前 schema、`operation_contract` 与 topology token 为准;每次只执行一个原子操作,不编造字段或选择器。数值须为有限 mm/deg。保留最后可执行 checkpoint,并如实报告未满足项。
|
||||
@@ -1 +0,0 @@
|
||||
先区分显式事实、图像观察、工程默认值和未知项。提取单位、外形、功能面、孔/槽、配合关系、关键尺寸及可验证目标。默认值只能补足常见零件的非关键构造,不能把未说明尺寸伪装成用户要求或确定性验收值。只有安全、配合、合规或可建模性确实取决于一个缺失事实时,才提出一个聚焦澄清;其余不确定性记录为假设或风险。
|
||||
@@ -1 +0,0 @@
|
||||
把尺寸当作模型契约:先识别主控的长度、宽度、厚度、直径、中心距、节距、数量、半径和角度,再从它们推导重复位置、对称偏移和余量。所有尺寸明确使用 mm,角度使用 deg;长度、直径、深度、节距和圆角半径必须为合理正有限值。阵列优先由中心线、数量、节距、半径或角度推导,避免难以追溯的点坐标常数。提交前以包围盒、比例、壁厚/材料余量和目标特征数量做常识检查。
|
||||
@@ -1 +0,0 @@
|
||||
世界坐标为右手 mm;根特征只在 contract 允许时用 `XY`/`+Z`。`workplane.origin_mm`、`x_dir`、`normal` 定义局部 frame;孔位是世界坐标。后续特征仅用已验证 datum/token,不能猜测最后生成面。
|
||||
@@ -1 +0,0 @@
|
||||
优先把零件身份和主控尺寸写进稳定根特征:根体、主要增材体、主要切除、孔/槽、重复特征、最后的圆角/倒角。每个节点只承担一个原子意图,依赖边只表示直接几何前提。默认形成连通单体;确需多体时必须由目标和 contract 支持。避免把视觉装饰、细小倒角或易碎布尔放在主形体之前。重规划时保留已完成节点和可执行检查点,只替换最小必要子图。
|
||||
@@ -1 +0,0 @@
|
||||
轮廓必须闭合、不自交、无零长或重叠边,并清楚区分外环和内环。先验证 workplane 的原点、`x_dir`、`normal` 与局部轮廓方向;翻转方向使用 contract 允许的字段,不凭视觉猜测。切除从实际材料面进入,深度覆盖目标材料并满足当前预检;避免刚好停在共面边界。对薄壁、近相切、重叠工具和零厚度结果保持余量。切除失败先检查宿主、方向、深度和轮廓,再考虑更换建模顺序。
|
||||
@@ -1 +0,0 @@
|
||||
宿主特征只能使用当前 revision 的测量 topology 和服务端给出的不透明 selector token;不得按边/面列表下标、历史名称或“最后一个面”猜选。选择前核对 token 的 kind、中心、法向、包围盒和 surface_type 是否覆盖预期材料区域。布尔、孔、阵列、圆角后拓扑可能变化,旧 token 和 reference 不可假定仍有效;依赖新拓扑时重新观察。reference token 只按当前 contract 放入允许槽位。选择不确定时请求 topology,而不是提交模糊 selector。
|
||||
@@ -1 +0,0 @@
|
||||
对称和重复优先通过 `pattern_linear`、`pattern_mirror` 及其 contract 参数表达。先完成一个正确的源特征,再用中心面、中心线、方向、数量、节距、半径或角度定义重复关系;不要用零散手填坐标代替可追溯模式。镜像平面和阵列方向应来自已建立的 datum 或当前测量 token。阵列前确认源特征、间距和数量不会重叠、越界或使材料变成零厚度。
|
||||
@@ -1 +0,0 @@
|
||||
圆角和倒角仅在主形体、切除和孔稳定后执行,并只选择唯一、当前有效的边 token;禁止“所有边”式回退。半径/距离必须小于邻近材料可容纳范围,避免相邻圆角相交。布尔操作避开共面终止、近相切和重复工具重叠;若风险高,优先以更稳定的主轮廓、顺序或足够余量表达。失败时不要重复原片段,先诊断受影响的面、边、深度和拓扑。
|
||||
@@ -1 +0,0 @@
|
||||
确定性几何事实与视觉审查职责不同:包围盒、实体数、孔深或贯穿状态只能证明已测量的 claim,不能证明整体设计语义。使用当前 contract、预检结果、claim evidence、render manifest 和 recent failures 作决定。视觉不符时给出具体的形状、位置、方向或比例差异作为修复依据,不能把它伪装成确定性通过。仅在几何改变后重新审查;STEP/checkpoint 是主工件,GLB 和渲染是派生审查证据,不能替代 CAD 几何。
|
||||
@@ -1 +0,0 @@
|
||||
修复先读错误和证据,定位最小责任点,再改最小的 CDSL/计划部分并重新执行依赖检查。常见原因包括开环/自交轮廓、零或负尺寸、切除方向或深度错误、错误 host frame、布尔后的旧 selector、过大圆角和直径/半径混淆。不要原样重试已失败片段。运行时不支持的能力应作为风险或缺口保留并继续发布最佳可执行模型,不能发明新 atom 或删除有效 checkpoint。
|
||||
@@ -1,63 +0,0 @@
|
||||
# CDSL Author Guidance Corpus
|
||||
|
||||
This corpus is a Chinese-first, non-authoritative author aid. The runtime
|
||||
operation contract, fragment schema, topology/reference tokens, preflight and
|
||||
verifier evidence always win over these Markdown files. The manifest maps
|
||||
only workflow phase, scheduled atomic operation and repair state; it never
|
||||
classifies the user's part request.
|
||||
|
||||
## Source Migration
|
||||
|
||||
| Source reference | CDSL target sections | Intentionally excluded |
|
||||
| --- | --- | --- |
|
||||
| `cad-brief.md` | `01`, `02`, `09` | Python/file workflow |
|
||||
| `parameters.md` | `02`, `07` | sidecars, animation, viewer control |
|
||||
| `positioning.md` | `03`, `06`, `op-reference` | assemblies, joints, `Location`, imported STEP placement |
|
||||
| `build123d-modeling.md` | `03` through `08`, operation appendices | build123d APIs, labels, colors and assembly source |
|
||||
| `build123d-modeling.zh-CN.md` | all Chinese terminology and rule review | a duplicate competing rule set |
|
||||
| `inspection-and-validation.md` | `09`, `10` | CLI paths and selector syntax |
|
||||
| `snapshot-review.md` | `09` | renderer commands |
|
||||
| `repair-loop.md` | `10`, `05`, `06`, `08` | build123d-only remediation syntax |
|
||||
| `step-generation.md` | `00`, `09` | Python generator commands |
|
||||
| `supported-exports.md` | `09` | mesh tolerance and exporter-specific flags |
|
||||
|
||||
## Selection Contract
|
||||
|
||||
- Requirements authoring selects `00` to `03`.
|
||||
- Feature planning selects `00`, `02`, `03`, `04`, `07`, and `08`.
|
||||
- A scheduled feature selects `00`, `03` to `06`, `08`, and its current
|
||||
operation appendix.
|
||||
- Repair selects `00`, `03`, `06`, `09`, `10`, and its operation appendix.
|
||||
- Final validation selects `00`, `09`, and `10`.
|
||||
|
||||
At a bounded prompt budget, contract, coordinate/datum, and the scheduled
|
||||
operation appendix are mandatory. Other sections are included in stable
|
||||
priority order. A malformed corpus or an unsupported operation registry
|
||||
falls back to the original short author prompt and records fallback metadata
|
||||
with the author usage record.
|
||||
|
||||
## Evaluation Commands
|
||||
|
||||
Run the six matched control scenarios three times each, first without and
|
||||
then with guidance:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance off \
|
||||
--scenario rectangular_mounting_plate --scenario circular_flange_pcd \
|
||||
--scenario obround_slot_plate --scenario rounded_rectangular_pocket \
|
||||
--scenario double_hole_linkage_arm --scenario l_bracket
|
||||
|
||||
PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance on \
|
||||
--scenario rectangular_mounting_plate --scenario circular_flange_pcd \
|
||||
--scenario obround_slot_plate --scenario rounded_rectangular_pocket \
|
||||
--scenario double_hole_linkage_arm --scenario l_bracket
|
||||
|
||||
PYTHONPATH=backend python -m app.cad_agent.evals.live --compare-guidance-reports CONTROL/report.json TREATMENT/report.json
|
||||
```
|
||||
|
||||
The comparator excludes declared validation gaps and engine-declared
|
||||
`unsupported_*` capability gaps from prompt quality metrics, checks paired
|
||||
model/runtime/contract/budget equivalence, and
|
||||
requires the treatment's checkpoint/completion rates not to regress, median
|
||||
author calls to stay within 10 percent, and either schema/decision or CDSL
|
||||
expression failures to improve.
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"schema_version": "cdsl.author-guidance.manifest.v1",
|
||||
"version": "2026-09-09.1",
|
||||
"sections": [
|
||||
{"id": "00-author-contract", "file": "00-author-contract.md", "title": "00 Author Contract", "priority": 100, "mandatory": true},
|
||||
{"id": "01-brief-and-assumptions", "file": "01-brief-and-assumptions.md", "title": "01 Brief And Assumptions", "priority": 70, "mandatory": false},
|
||||
{"id": "02-parameters-and-derived-dimensions", "file": "02-parameters-and-derived-dimensions.md", "title": "02 Parameters And Derived Dimensions", "priority": 80, "mandatory": false},
|
||||
{"id": "03-coordinate-system-and-datums", "file": "03-coordinate-system-and-datums.md", "title": "03 Coordinate System And Datums", "priority": 100, "mandatory": true},
|
||||
{"id": "04-construction-and-feature-order", "file": "04-construction-and-feature-order.md", "title": "04 Construction And Feature Order", "priority": 70, "mandatory": false},
|
||||
{"id": "05-profiles-workplanes-and-cuts", "file": "05-profiles-workplanes-and-cuts.md", "title": "05 Profiles Workplanes And Cuts", "priority": 90, "mandatory": false},
|
||||
{"id": "06-hosted-features-selectors-and-topology", "file": "06-hosted-features-selectors-and-topology.md", "title": "06 Hosted Features Selectors And Topology", "priority": 90, "mandatory": false},
|
||||
{"id": "07-patterns-symmetry-and-repetition", "file": "07-patterns-symmetry-and-repetition.md", "title": "07 Patterns Symmetry And Repetition", "priority": 60, "mandatory": false},
|
||||
{"id": "08-finishing-and-boolean-risk", "file": "08-finishing-and-boolean-risk.md", "title": "08 Finishing And Boolean Risk", "priority": 60, "mandatory": false},
|
||||
{"id": "09-evidence-visual-review-and-validation", "file": "09-evidence-visual-review-and-validation.md", "title": "09 Evidence Visual Review And Validation", "priority": 80, "mandatory": false},
|
||||
{"id": "10-repair-and-best-effort", "file": "10-repair-and-best-effort.md", "title": "10 Repair And Best Effort", "priority": 80, "mandatory": false},
|
||||
{"id": "op-extrude-add", "file": "op-extrude-add.md", "title": "Operation Appendix Extrude Add", "priority": 100, "mandatory": true},
|
||||
{"id": "op-extrude-cut", "file": "op-extrude-cut.md", "title": "Operation Appendix Extrude Cut", "priority": 100, "mandatory": true},
|
||||
{"id": "op-loft", "file": "op-loft.md", "title": "Operation Appendix Loft", "priority": 100, "mandatory": true},
|
||||
{"id": "op-revolve", "file": "op-revolve.md", "title": "Operation Appendix Revolve", "priority": 100, "mandatory": true},
|
||||
{"id": "op-hole", "file": "op-hole.md", "title": "Operation Appendix Hole", "priority": 100, "mandatory": true},
|
||||
{"id": "op-reference", "file": "op-reference.md", "title": "Operation Appendix Reference", "priority": 100, "mandatory": true},
|
||||
{"id": "op-pattern", "file": "op-pattern.md", "title": "Operation Appendix Pattern", "priority": 100, "mandatory": true},
|
||||
{"id": "op-finish", "file": "op-finish.md", "title": "Operation Appendix Finish", "priority": 100, "mandatory": true},
|
||||
{"id": "op-sphere", "file": "op-sphere.md", "title": "Operation Appendix Sphere", "priority": 100, "mandatory": true},
|
||||
{"id": "op-primitives", "file": "op-primitives.md", "title": "Operation Appendix Primitives", "priority": 100, "mandatory": true},
|
||||
{"id": "op-thread", "file": "op-thread.md", "title": "Operation Appendix Thread", "priority": 100, "mandatory": true},
|
||||
{"id": "op-bend", "file": "op-bend.md", "title": "Operation Appendix Bend", "priority": 100, "mandatory": true},
|
||||
{"id": "op-gear", "file": "op-gear.md", "title": "Operation Appendix Gear", "priority": 100, "mandatory": true}
|
||||
],
|
||||
"phase_sections": {
|
||||
"DEFAULT": ["00-author-contract", "03-coordinate-system-and-datums", "09-evidence-visual-review-and-validation"],
|
||||
"DRAFTING_REQUIREMENTS_DOCUMENT": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"],
|
||||
"DRAFTING_COMPLETION_TARGET": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"],
|
||||
"COMPILING_REQUIREMENTS": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"],
|
||||
"COMPILING_FEATURE_PLAN": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"],
|
||||
"REPLANNING_FEATURE_SUBGRAPH": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"],
|
||||
"FEATURE_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "05-profiles-workplanes-and-cuts", "06-hosted-features-selectors-and-topology", "08-finishing-and-boolean-risk"],
|
||||
"AWAITING_ACTION": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
|
||||
"ACTION_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"]
|
||||
},
|
||||
"repair_sections": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
|
||||
"final_sections": ["00-author-contract", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
|
||||
"operation_sections": {
|
||||
"extrude_add_blind": ["op-extrude-add"],
|
||||
"extrude_add_blind_with_hole": ["op-extrude-add"],
|
||||
"extrude_add_two_sided": ["op-extrude-add"],
|
||||
"extrude_from_face": ["op-extrude-add"],
|
||||
"extrude_surface": ["op-extrude-add"],
|
||||
"extrude_cut_blind": ["op-extrude-cut"],
|
||||
"extrude_cut_two_sided": ["op-extrude-cut"],
|
||||
"extrude_cut_through": ["op-extrude-cut"],
|
||||
"loft_add": ["op-loft"],
|
||||
"loft_add_with_cap_face": ["op-loft"],
|
||||
"revolve_add": ["op-revolve"],
|
||||
"revolve_cut": ["op-revolve"],
|
||||
"revolve_surface": ["op-revolve"],
|
||||
"sweep_add": ["op-loft"],
|
||||
"hole_blind": ["op-hole"],
|
||||
"hole_counterbore": ["op-hole"],
|
||||
"hole_countersink": ["op-hole"],
|
||||
"hole_wizard": ["op-hole"],
|
||||
"reference_plane": ["op-reference"],
|
||||
"reference_axis": ["op-reference"],
|
||||
"pattern_linear": ["op-pattern"],
|
||||
"pattern_mirror": ["op-pattern"],
|
||||
"pattern_circular": ["op-pattern"],
|
||||
"fillet": ["op-finish"],
|
||||
"chamfer": ["op-finish"],
|
||||
"shell": ["op-finish"],
|
||||
"boolean_bodies": ["op-finish"],
|
||||
"transform_bodies": ["op-finish"],
|
||||
"delete_bodies": ["op-finish"],
|
||||
"sphere_add": ["op-sphere"],
|
||||
"box_add": ["op-primitives"],
|
||||
"cylinder_add": ["op-primitives"],
|
||||
"thread_add": ["op-thread"],
|
||||
"thread_cut": ["op-thread"],
|
||||
"bend_add": ["op-bend"],
|
||||
"gear_add": ["op-gear"],
|
||||
"rack_add": ["op-gear"]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
`bend_add` 以中面折线链定义等厚钣金折弯:正的 `thickness_mm`、`width_mm` 和至少一翼的 `chain`;非末翼必须给 `bend_angle_deg`(内角,0 < angle < 180,90 为直角折弯),可选 `inner_radius_mm`(>= 0,外半径恒为内半径加板厚)与 `side`(+1 / -1,折弯方向)。`frame` 可选,缺省为世界 XY 平面(首翼沿 +X,厚度沿法向)。相邻折弯圆角会消耗直段长度(约 `inner_radius + thickness/2` 的切线距离),每翼剩余直段必须为正,否则规格非法。仅用于意图明确的钣金折弯;单翼链退化为平板,普通平板轮廓应保留草图历史表达而非用 bend_add 替代。
|
||||
@@ -1 +0,0 @@
|
||||
`extrude_add_blind` 和 `extrude_add_two_sided` 必须使用闭合草图和 contract 允许的正距离。根挤出遵守根 `XY` datum;后续增材先确认草图 frame 与已有实体的连接。双向挤出分别核对两个方向的长度与材料范围;`reverse` 只用于当前 frame 的方向修正,不能代替错误的 workplane。
|
||||
@@ -1 +0,0 @@
|
||||
`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。`extrude_cut_two_sided` 必须分别提供正向和反向的距离与终止条件,不能以单侧深度近似双向切除。`extrude_cut_through` 只接受明确的 `end_condition`,由现有主体跨度决定穿透距离,不能伪造盲向深度。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。
|
||||
@@ -1 +0,0 @@
|
||||
`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。`shell` 必须保留显式目标 body 与待移除 face token。`boolean_bodies`、`transform_bodies`、`delete_bodies` 只操作 contract 指定且仍独立存在的 body;不以当前 body 或隐式 union 兜底。失败时保留主体并报告风险。
|
||||
@@ -1 +0,0 @@
|
||||
`gear_add` 与 `rack_add` 是渐开线齿轮/齿条的原生图元:正的 `module_mm`、`teeth_count`(齿轮 8–200,齿条 1–2000)与明确的 `axis`。齿轮 `axis.origin_mm` 是 `z=0` 端面圆心、`axis.direction` 为齿轴;齿条 `axis.direction` 是齿的伸出方向、`axis.origin_mm` 是长度起点端面与厚度起始面及齿谷平面的交点,有效长度精确等于 `teeth_count * pi * module`。`helix_angle_rad > 0` 生成斜齿(螺旋沿轴推进);加 `herringbone: true` 得到人字齿(双螺旋在齿宽中点对称反转,无退刀槽)。齿数低于 17 时存在根切风险,引擎照常生成并附诊断说明,不静默修正;如需无根切请提高齿数或改用变位设计(当前不支持变位)。齿轮孔、键槽等后续特征用 hole/extrude_cut 沿同一 axis 叠加。
|
||||
@@ -1 +0,0 @@
|
||||
孔 atom 需要当前宿主面的有效 selector token。`positions[].mm` 使用该宿主面上的绝对世界坐标,先核对点在面区域内与法向方向。直径、深度、沉孔/沉头参数以 contract 为准,深度覆盖预期材料;多孔共享一个原子操作时保持同一规格和同一宿主。不要把点写成面局部偏移或裸数组。
|
||||
@@ -1 +0,0 @@
|
||||
`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。`loft_add_with_cap_face` 只能使用 contract 许可的 cap-face token。`sweep_add` 必须保留闭合截面与显式、非退化路径,不以放样或挤出替代。不要把 selector 选中的实体面当作放样截面,除非 contract 明确支持。
|
||||
@@ -1 +0,0 @@
|
||||
`pattern_linear` 只复制当前 contract 允许且存在的源 feature reference;方向是明确世界/基准方向,数量和 spacing 为合理值。`pattern_mirror` 使用存在的镜像 plane reference,先确认源与平面关系以及复制后不会重叠或意外合并。pattern 不代替新的宿主选择;下游特征若依赖新面,重新读取 topology。
|
||||
@@ -1 +0,0 @@
|
||||
`box_add` 和 `cylinder_add` 是世界坐标原生图元。按 operation contract 提供正尺寸以及明确的 `center_mm` 或 axis。仅在目标确为长方体或圆柱体时使用;由轮廓驱动的几何保留草图、放样等历史表达。
|
||||
@@ -1 +0,0 @@
|
||||
`reference_plane` 用有限非零 `normal` 和与其不平行的 `x_dir` 定义局部 frame;`origin_mm` 是世界点。`reference_axis` 用有限非零 `direction` 和世界原点定义。它们只建立可追溯 datum,不直接制造实体;先于依赖它的旋转、镜像、阵列或定位特征,并依照 contract 的 reference token 规则引用。
|
||||
@@ -1 +0,0 @@
|
||||
`revolve_add` 和 `revolve_cut` 的轴必须由明确 datum 或 contract 中的世界坐标轴表达,并按预检要求位于正确的草图关系中。核对 axis origin、direction、角度和 `reverse`;完整回转避免轮廓跨轴造成自交,局部回转避免与现有材料近相切。切除回转仍必须覆盖目标材料。
|
||||
@@ -1 +0,0 @@
|
||||
`sphere_add` 用明确的世界中心和正有限半径定义。确认它与目标实体的连接意图:需要单体时应有足够相交,独立体仅在需求允许多体时使用。球体位置从 datum 或主尺寸导出,不把视图坐标误当世界坐标。
|
||||
@@ -1 +0,0 @@
|
||||
`thread_add` 和 `thread_cut` 需要明确的 axis、正的大小径、螺距和长度,且各参数必须物理一致。螺纹切除必须有已有宿主实体;需求为实体螺纹时,不能以光滑孔替代。
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Autonomous CAD protocol v3.
|
||||
"""Single-stage Authoring CDSL protocol.
|
||||
|
||||
The package intentionally separates policy from I/O. Delivery code composes
|
||||
these modules with adapters; it must not bypass the command handlers.
|
||||
"""
|
||||
|
||||
PROTOCOL_VERSION = "3.0"
|
||||
PROTOCOL_VERSION = "cad.single-stage.v1"
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Infrastructure adapters for the v3 ports."""
|
||||
"""Infrastructure adapters for the single-stage ports."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Immutable v3 file artifacts with staging manifests and atomic publication."""
|
||||
"""Immutable single-stage artifacts with staging manifests and atomic publication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,10 +8,9 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.ports import CandidateStage
|
||||
from app.cad_agent.ports import StagingRevision
|
||||
|
||||
|
||||
_SAFE_RELATIVE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$")
|
||||
@@ -27,7 +26,7 @@ class FileArtifactStore:
|
||||
|
||||
def task_dir(self, task_id: str) -> Path:
|
||||
if not re.fullmatch(r"cad_[a-z0-9]{12}", task_id):
|
||||
raise ValueError("Invalid v3 task id")
|
||||
raise ValueError("Invalid CAD task id")
|
||||
return self.root / task_id
|
||||
|
||||
def artifact_path(self, task_id: str, relative_path: str) -> Path:
|
||||
@@ -43,7 +42,7 @@ class FileArtifactStore:
|
||||
) -> None:
|
||||
root = self.task_dir(task_id)
|
||||
(root / "documents").mkdir(parents=True, exist_ok=True)
|
||||
(root / "actions").mkdir(exist_ok=True)
|
||||
(root / "events").mkdir(exist_ok=True)
|
||||
(root / "revisions").mkdir(exist_ok=True)
|
||||
(root / ".staging").mkdir(exist_ok=True)
|
||||
source = root / "source-requirements.md"
|
||||
@@ -67,14 +66,14 @@ class FileArtifactStore:
|
||||
if images:
|
||||
self.write_json_once(task_id, "documents/source-images.json", {"schema_version": "cad.source-images.v1", "images": images})
|
||||
|
||||
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
|
||||
def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
|
||||
"""Mirror committed SQLite events into an append-only JSONL audit log.
|
||||
|
||||
SQLite remains authoritative. Replaying this method after an
|
||||
interruption appends only missing committed sequences and rejects a
|
||||
divergent line instead of rewriting audit history.
|
||||
"""
|
||||
path = self._path(task_id, "actions/action-ledger.jsonl")
|
||||
path = self._path(task_id, "events/event-ledger.jsonl")
|
||||
existing: dict[int, dict[str, Any]] = {}
|
||||
if path.is_file():
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
@@ -83,18 +82,18 @@ class FileArtifactStore:
|
||||
value = json.loads(raw)
|
||||
sequence = value.get("sequence") if isinstance(value, dict) else None
|
||||
if not isinstance(sequence, int) or sequence < 1:
|
||||
raise ValueError("Action ledger contains an invalid sequence")
|
||||
raise ValueError("Event ledger contains an invalid sequence")
|
||||
existing[sequence] = value
|
||||
missing: list[str] = []
|
||||
for event in events:
|
||||
sequence = event.get("sequence")
|
||||
if not isinstance(sequence, int) or sequence < 1:
|
||||
raise ValueError("Action ledger event has an invalid sequence")
|
||||
entry = {"schema_version": "cad.action-ledger.v1", **event}
|
||||
raise ValueError("Event ledger event has an invalid sequence")
|
||||
entry = {"schema_version": "cad.event-ledger.v1", **event}
|
||||
previous = existing.get(sequence)
|
||||
if previous is not None:
|
||||
if previous != entry:
|
||||
raise ValueError("Action ledger diverges from committed SQLite event")
|
||||
raise ValueError("Event ledger diverges from committed SQLite event")
|
||||
continue
|
||||
missing.append(json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
if missing:
|
||||
@@ -103,7 +102,7 @@ class FileArtifactStore:
|
||||
handle.writelines(missing)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
return "actions/action-ledger.jsonl"
|
||||
return "events/event-ledger.jsonl"
|
||||
|
||||
def write_source_index(
|
||||
self,
|
||||
@@ -136,9 +135,8 @@ class FileArtifactStore:
|
||||
"""Return immutable source paragraphs and attachment blocks.
|
||||
|
||||
Source identifiers are assigned only after this method returns, so
|
||||
callers cannot choose IDs. Attachment metadata is deliberately a
|
||||
closed, server-provided projection: it lets a reviewer trace the
|
||||
source without treating upload metadata as arbitrary LLM input.
|
||||
callers cannot choose IDs. Attachment metadata is a closed,
|
||||
server-provided projection rather than arbitrary model input.
|
||||
"""
|
||||
supplied = source_blocks
|
||||
if supplied is None:
|
||||
@@ -195,19 +193,6 @@ class FileArtifactStore:
|
||||
if isinstance(item, dict) and item.get("path") and self._path(task_id, str(item["path"])).is_file()
|
||||
]
|
||||
|
||||
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
|
||||
return self.read_json(task_id, artifact_path or "documents/requirements-spec.json")
|
||||
|
||||
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
|
||||
# State points to the immutable artifact used as program input. The
|
||||
# fixed name is a read-only convenience view for terminal tasks.
|
||||
return self.read_json(task_id, artifact_path or "requirements-contract.json")
|
||||
|
||||
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str:
|
||||
if not invocation_id:
|
||||
return self.write_json_once(task_id, "requirements-contract.json", payload)
|
||||
return self._write_invocation_json(task_id, "requirements-contract", payload, invocation_id)
|
||||
|
||||
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None:
|
||||
path = self._path(task_id, relative_path)
|
||||
if not path.is_file():
|
||||
@@ -225,36 +210,20 @@ class FileArtifactStore:
|
||||
self._write_once(path, text)
|
||||
return relative_path
|
||||
|
||||
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
|
||||
return self.read_json(task_id, f"revisions/{revision_id}/model.cdsl.json") if revision_id else None
|
||||
|
||||
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
|
||||
return self.read_json(task_id, f"revisions/{revision_id}/model.topology.json") if revision_id else None
|
||||
|
||||
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage:
|
||||
def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision:
|
||||
root = self.task_dir(task_id) / ".staging"
|
||||
stable_id = "stage_" + sha256(idempotency_key.encode("utf-8")).hexdigest()[:20]
|
||||
directory = root / stable_id
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
self._write_json_once(directory / "input.json", payload)
|
||||
return CandidateStage(stable_id, str(directory.resolve()))
|
||||
|
||||
def stage_output_dir(self, task_id: str, stage_id: str) -> str:
|
||||
return str(self._stage_path(task_id, stage_id, ""))
|
||||
return StagingRevision(stable_id, str(directory.resolve()))
|
||||
|
||||
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str:
|
||||
path = self._stage_path(task_id, stage_id, relative_path)
|
||||
self._write_json_once(path, payload)
|
||||
return relative_path
|
||||
|
||||
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None:
|
||||
path = self._stage_path(task_id, stage_id, relative_path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
|
||||
def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
|
||||
source = self._stage_path(task_id, stage_id, "")
|
||||
target = self._path(task_id, f"revisions/{revision_id}")
|
||||
if target.exists():
|
||||
@@ -262,9 +231,8 @@ class FileArtifactStore:
|
||||
if manifest is None:
|
||||
raise RuntimeError("Published revision has no valid manifest")
|
||||
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
|
||||
# Render/rebuild reports contain paths for the reviewer. Rebase those
|
||||
# paths while artifacts are still mutable staging output, so they point
|
||||
# at the revision after the atomic directory rename.
|
||||
# Rebase report paths while artifacts are still mutable staging output,
|
||||
# so they point at the revision after the atomic directory rename.
|
||||
self._rebase_staged_paths(source, target)
|
||||
manifest = self._create_manifest(source)
|
||||
self._write_json_once(source / "manifest.json", manifest)
|
||||
@@ -272,39 +240,6 @@ class FileArtifactStore:
|
||||
os.replace(source, target)
|
||||
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
|
||||
|
||||
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None:
|
||||
"""Find a manifest-verified candidate already renamed before its DB CAS.
|
||||
|
||||
Publishing artifacts and advancing SQLite cannot share a transaction.
|
||||
The stage id persisted inside ``candidate.json`` makes a post-rename
|
||||
recovery deterministic and prevents another build or revision.
|
||||
"""
|
||||
revisions = self.task_dir(task_id) / "revisions"
|
||||
if not revisions.is_dir():
|
||||
return None
|
||||
for revision in sorted(revisions.iterdir()):
|
||||
if not revision.is_dir() or self._manifest(revision) is None:
|
||||
continue
|
||||
candidate_path = revision / "candidate.json"
|
||||
if not candidate_path.is_file():
|
||||
continue
|
||||
try:
|
||||
candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(candidate, dict) and candidate.get("stage_id") == stage_id:
|
||||
return revision.name, candidate
|
||||
return None
|
||||
|
||||
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None:
|
||||
root = self.task_dir(task_id) / ".staging"
|
||||
if not root.is_dir():
|
||||
return
|
||||
for directory in root.iterdir():
|
||||
if not directory.is_dir() or directory.name in referenced_stage_ids:
|
||||
continue
|
||||
shutil.rmtree(directory)
|
||||
|
||||
def _path(self, task_id: str, relative_path: str) -> Path:
|
||||
if relative_path and (not _SAFE_RELATIVE.fullmatch(relative_path) or ".." in Path(relative_path).parts):
|
||||
raise ValueError("Invalid artifact relative path")
|
||||
@@ -316,7 +251,7 @@ class FileArtifactStore:
|
||||
|
||||
def _stage_path(self, task_id: str, stage_id: str, relative_path: str) -> Path:
|
||||
if not re.fullmatch(r"stage_[a-f0-9]{20}", stage_id):
|
||||
raise ValueError("Invalid candidate stage id")
|
||||
raise ValueError("Invalid staging revision id")
|
||||
root = self.task_dir(task_id).resolve()
|
||||
stage = (root / ".staging" / stage_id).resolve()
|
||||
if root not in stage.parents:
|
||||
@@ -351,13 +286,6 @@ class FileArtifactStore:
|
||||
temporary.write_bytes(data)
|
||||
os.replace(temporary, path)
|
||||
|
||||
def _write_invocation_json(self, task_id: str, stem: str, payload: dict[str, Any], invocation_id: str) -> str:
|
||||
digest = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12]
|
||||
safe_invocation = re.sub(r"[^A-Za-z0-9_-]", "", invocation_id)[:32]
|
||||
if not safe_invocation:
|
||||
raise ValueError("Artifact invocation ID is invalid")
|
||||
return self.write_json_once(task_id, f"documents/{stem}-{digest}-{safe_invocation}.json", payload)
|
||||
|
||||
@classmethod
|
||||
def _write_json_once(cls, path: Path, payload: dict[str, Any]) -> None:
|
||||
cls._write_once(path, json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n")
|
||||
@@ -371,8 +299,8 @@ class FileArtifactStore:
|
||||
relative = path.relative_to(directory).as_posix()
|
||||
files[relative] = sha256(path.read_bytes()).hexdigest()
|
||||
if not files:
|
||||
raise RuntimeError("Candidate staging directory has no artifacts")
|
||||
return {"schema_version": "cad.v3.artifact-manifest.v1", "files": files}
|
||||
raise RuntimeError("Staging revision has no artifacts")
|
||||
return {"schema_version": "cad.single-stage.artifact-manifest.v1", "files": files}
|
||||
|
||||
@staticmethod
|
||||
def _manifest(directory: Path) -> dict[str, Any] | None:
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
"""Bounded, file-backed author guidance for the CDSL workflow.
|
||||
|
||||
The corpus is deliberately non-authoritative: contracts, schemas, topology
|
||||
tokens, and server preflight always remain the executable source of truth.
|
||||
Loading errors return an empty selection so authoring continues with the
|
||||
pre-guidance prompt instead of turning documentation into an availability
|
||||
dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.state import TaskPhase
|
||||
from app.cad_agent.ports import AuthorGuidanceSelection
|
||||
|
||||
|
||||
_MIN_CHARS = 1_200
|
||||
_MAX_CHARS = 6_000
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Section:
|
||||
section_id: str
|
||||
title: str
|
||||
priority: int
|
||||
mandatory: bool
|
||||
content: str
|
||||
|
||||
@property
|
||||
def block(self) -> str:
|
||||
return f"## {self.title}\n{self.content.strip()}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Corpus:
|
||||
version: str
|
||||
sections: dict[str, _Section]
|
||||
phase_sections: dict[str, tuple[str, ...]]
|
||||
repair_sections: tuple[str, ...]
|
||||
final_sections: tuple[str, ...]
|
||||
operation_sections: dict[str, tuple[str, ...]]
|
||||
|
||||
|
||||
class FileAuthorGuidance:
|
||||
"""Read and select the checked-in corpus deterministically.
|
||||
|
||||
Selection depends exclusively on workflow state and the runtime operation
|
||||
registry. It intentionally receives neither the user's request nor image
|
||||
observations, so it cannot become an implicit part-family classifier.
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path, *, enabled: bool = True, max_chars: int = 3_600) -> None:
|
||||
self.root = root
|
||||
self.enabled = enabled
|
||||
self.max_chars = min(_MAX_CHARS, max(_MIN_CHARS, max_chars))
|
||||
self._corpus: _Corpus | None = None
|
||||
self._load_error = ""
|
||||
|
||||
def select(
|
||||
self,
|
||||
*,
|
||||
phase: TaskPhase,
|
||||
atomic_id: str,
|
||||
repair_required: bool,
|
||||
supported_atomic_ids: tuple[str, ...],
|
||||
) -> AuthorGuidanceSelection:
|
||||
if not self.enabled:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_disabled")
|
||||
corpus = self._load()
|
||||
if corpus is None:
|
||||
return AuthorGuidanceSelection(fallback_reason=self._load_error or "guidance_unavailable")
|
||||
supported = set(supported_atomic_ids)
|
||||
if set(corpus.operation_sections) != supported:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_operation_coverage_mismatch")
|
||||
if atomic_id and atomic_id not in supported:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_unknown_atomic_id")
|
||||
|
||||
if repair_required:
|
||||
requested = list(corpus.repair_sections)
|
||||
elif phase == TaskPhase.FINAL_VALIDATION:
|
||||
requested = list(corpus.final_sections)
|
||||
else:
|
||||
requested = list(corpus.phase_sections.get(phase.value, corpus.phase_sections.get("DEFAULT", ())))
|
||||
if atomic_id:
|
||||
requested.extend(corpus.operation_sections[atomic_id])
|
||||
requested = list(dict.fromkeys(requested))
|
||||
if not requested:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_no_matching_sections")
|
||||
|
||||
mandatory = [section_id for section_id in requested if corpus.sections[section_id].mandatory]
|
||||
optional = [section_id for section_id in requested if not corpus.sections[section_id].mandatory]
|
||||
optional.sort(key=lambda section_id: (-corpus.sections[section_id].priority, requested.index(section_id)))
|
||||
selected: list[str] = []
|
||||
text = ""
|
||||
for section_id in [*mandatory, *optional]:
|
||||
block = corpus.sections[section_id].block
|
||||
candidate = block if not text else f"{text}\n\n{block}"
|
||||
if len(candidate) <= self.max_chars:
|
||||
text = candidate
|
||||
selected.append(section_id)
|
||||
elif section_id in mandatory:
|
||||
# Do not silently drop contract, datum, or operation guidance.
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_required_sections_exceed_budget")
|
||||
return AuthorGuidanceSelection(
|
||||
version=corpus.version,
|
||||
section_ids=tuple(selected),
|
||||
content=text,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
def _load(self) -> _Corpus | None:
|
||||
if self._corpus is not None:
|
||||
return self._corpus
|
||||
if self._load_error:
|
||||
return None
|
||||
try:
|
||||
manifest_path = self.root / "manifest.json"
|
||||
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("manifest is not an object")
|
||||
version = raw.get("version")
|
||||
if raw.get("schema_version") != "cdsl.author-guidance.manifest.v1" or not isinstance(version, str) or not version:
|
||||
raise ValueError("manifest version is invalid")
|
||||
raw_sections = raw.get("sections")
|
||||
if not isinstance(raw_sections, list) or not raw_sections:
|
||||
raise ValueError("manifest sections are invalid")
|
||||
sections: dict[str, _Section] = {}
|
||||
root = self.root.resolve()
|
||||
for item in raw_sections:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("section declaration is invalid")
|
||||
section_id = item.get("id")
|
||||
filename = item.get("file")
|
||||
title = item.get("title")
|
||||
priority = item.get("priority")
|
||||
mandatory = item.get("mandatory", False)
|
||||
if (
|
||||
not isinstance(section_id, str) or not section_id
|
||||
or not isinstance(filename, str) or not filename
|
||||
or not isinstance(title, str) or not title
|
||||
or not isinstance(priority, int) or isinstance(priority, bool)
|
||||
or not isinstance(mandatory, bool)
|
||||
or section_id in sections
|
||||
):
|
||||
raise ValueError("section metadata is invalid")
|
||||
path = (self.root / filename).resolve()
|
||||
if root not in path.parents or not path.is_file():
|
||||
raise ValueError("section file is unavailable")
|
||||
content = path.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
raise ValueError("section content is empty")
|
||||
sections[section_id] = _Section(section_id, title, priority, mandatory, content)
|
||||
|
||||
def identifiers(value: Any, field: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item in sections for item in value):
|
||||
raise ValueError(f"{field} is invalid")
|
||||
return tuple(dict.fromkeys(value))
|
||||
|
||||
raw_phases = raw.get("phase_sections")
|
||||
if not isinstance(raw_phases, dict) or "DEFAULT" not in raw_phases:
|
||||
raise ValueError("phase sections are invalid")
|
||||
phase_sections = {
|
||||
phase: identifiers(section_ids, f"phase {phase}")
|
||||
for phase, section_ids in raw_phases.items()
|
||||
if isinstance(phase, str)
|
||||
}
|
||||
if len(phase_sections) != len(raw_phases):
|
||||
raise ValueError("phase name is invalid")
|
||||
operation_sections = {
|
||||
atomic_id: identifiers(section_ids, f"operation {atomic_id}")
|
||||
for atomic_id, section_ids in (raw.get("operation_sections") or {}).items()
|
||||
if isinstance(atomic_id, str)
|
||||
}
|
||||
if not operation_sections or len(operation_sections) != len(raw.get("operation_sections") or {}):
|
||||
raise ValueError("operation sections are invalid")
|
||||
self._corpus = _Corpus(
|
||||
version=version,
|
||||
sections=sections,
|
||||
phase_sections=phase_sections,
|
||||
repair_sections=identifiers(raw.get("repair_sections"), "repair sections"),
|
||||
final_sections=identifiers(raw.get("final_sections"), "final sections"),
|
||||
operation_sections=operation_sections,
|
||||
)
|
||||
return self._corpus
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as error:
|
||||
self._load_error = f"guidance_load_failed:{type(error).__name__}"
|
||||
return None
|
||||
@@ -1,4 +1,4 @@
|
||||
"""In-process idempotent event delivery for the v3 delivery boundary."""
|
||||
"""In-process idempotent event delivery for the single-stage protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Independent rendered-review adapter for protocol v3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
|
||||
from app.cad_agent.ports import AdapterUnavailable
|
||||
|
||||
|
||||
class RenderedReviewGateway:
|
||||
def __init__(self, models: StructuredModelGateway) -> None:
|
||||
self.models = models
|
||||
|
||||
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]:
|
||||
name = str((tool.get("function") or {}).get("name") or "")
|
||||
if not name:
|
||||
raise RuntimeError("Review tool is missing a name")
|
||||
public_payload = {key: value for key, value in payload.items() if key != "reference_image_paths"}
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(public_payload, ensure_ascii=False)}]
|
||||
if kind in {"image_observation", "final"}:
|
||||
for raw_path in payload.get("reference_image_paths") or ():
|
||||
path = Path(str(raw_path))
|
||||
if path.is_file():
|
||||
content.append(self._image_part(path))
|
||||
if kind in {"candidate", "final"}:
|
||||
manifest = payload.get("render_manifest") if isinstance(payload.get("render_manifest"), dict) else {}
|
||||
for path in self._evidence_paths(manifest):
|
||||
content.append(self._image_part(path))
|
||||
return await self.models.call_tool(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are an independent CAD reviewer. Inspect supplied deterministic facts and rendered images. Return only the specified structured tool call."},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
tool=tool,
|
||||
provider_id=provider_id,
|
||||
model_id=model_id,
|
||||
required_tool_name=name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _evidence_paths(manifest: dict[str, Any]) -> list[Path]:
|
||||
selected: list[Path] = []
|
||||
contact = Path(str(manifest.get("contact_sheet_path") or ""))
|
||||
if contact.is_file():
|
||||
selected.append(contact)
|
||||
wanted = {"top", "front", "right", "isometric"}
|
||||
for item in manifest.get("views") or ():
|
||||
if not isinstance(item, dict) or str(item.get("id") or "") not in wanted:
|
||||
continue
|
||||
path = Path(str(item.get("path") or ""))
|
||||
if path.is_file():
|
||||
selected.append(path)
|
||||
if not selected:
|
||||
raise AdapterUnavailable("RENDER_SERVICE_UNAVAILABLE: review render evidence is unavailable")
|
||||
return selected[:5]
|
||||
|
||||
@staticmethod
|
||||
def _image_part(path: Path) -> dict[str, Any]:
|
||||
media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
|
||||
return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
"""SQLite repository: the sole mutable state authority for protocol v3."""
|
||||
"""SQLite authority for ``cad.single-stage.v1`` tasks.
|
||||
|
||||
There is no reader or migration for the deleted feature-DAG protocol. Opening
|
||||
a legacy task database drops its task data before creating this schema.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
@@ -10,11 +13,13 @@ from threading import RLock
|
||||
from typing import Any, Iterator
|
||||
|
||||
from app.cad_agent.domain.errors import ErrorCode
|
||||
from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState
|
||||
from app.cad_agent.ports import InvocationRecord
|
||||
|
||||
|
||||
class SqliteTaskRepository:
|
||||
PROTOCOL_VERSION = "cad.single-stage.v1"
|
||||
|
||||
def __init__(self, database_path: Path) -> None:
|
||||
self.database_path = database_path
|
||||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -35,271 +40,151 @@ class SqliteTaskRepository:
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
# Protocol 3.2 intentionally has no migration path from the
|
||||
# structured-only / review-loop task model. Deployment starts with
|
||||
# an empty task database, as those tasks do not have immutable
|
||||
# Markdown source artifacts to compile from.
|
||||
existing = connection.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").fetchone()
|
||||
if existing is not None and "'3.2'" not in str(existing[0] or ""):
|
||||
existing = connection.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'").fetchone()
|
||||
if existing is not None and self.PROTOCOL_VERSION not in str(existing[0] or ""):
|
||||
self.protocol_reset = True
|
||||
connection.executescript("""
|
||||
DROP TABLE IF EXISTS outbox;
|
||||
DROP TABLE IF EXISTS tool_audits;
|
||||
DROP TABLE IF EXISTS usage_records;
|
||||
DROP TABLE IF EXISTS invocations;
|
||||
DROP TABLE IF EXISTS ledger;
|
||||
DROP TABLE IF EXISTS model_capabilities;
|
||||
DROP TABLE IF EXISTS tasks;
|
||||
""")
|
||||
connection.executescript(
|
||||
"""
|
||||
# This database is dedicated to CAD task state. An old
|
||||
# protocol has no safe reader or migration, so remove every
|
||||
# persisted task table and let SQLite remove their indexes.
|
||||
tables = connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
for row in tables:
|
||||
name = str(row[0]).replace('"', '""')
|
||||
connection.execute(f'DROP TABLE IF EXISTS "{name}"')
|
||||
connection.executescript(f"""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.2'),
|
||||
request TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
state_version INTEGER NOT NULL,
|
||||
active_revision TEXT NOT NULL DEFAULT '',
|
||||
pending_action_json TEXT,
|
||||
candidate_id TEXT NOT NULL DEFAULT '',
|
||||
candidate_stage_id TEXT NOT NULL DEFAULT '',
|
||||
repair_required INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
retry_from_phase TEXT NOT NULL DEFAULT '',
|
||||
requirements_spec_path TEXT NOT NULL DEFAULT '',
|
||||
requirements_document_path TEXT NOT NULL DEFAULT '',
|
||||
completion_target_path TEXT NOT NULL DEFAULT '',
|
||||
modeling_plan_path TEXT NOT NULL DEFAULT '',
|
||||
feature_plan_path TEXT NOT NULL DEFAULT '',
|
||||
feature_plan_hash TEXT NOT NULL DEFAULT '',
|
||||
feature_stage_id TEXT NOT NULL DEFAULT '',
|
||||
protocol_version TEXT NOT NULL CHECK(protocol_version = '{self.PROTOCOL_VERSION}'),
|
||||
request TEXT NOT NULL, phase TEXT NOT NULL, state_version INTEGER NOT NULL,
|
||||
active_revision TEXT NOT NULL DEFAULT '', repair_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT, retry_from_phase TEXT NOT NULL DEFAULT '',
|
||||
requirements_path TEXT NOT NULL DEFAULT '', authoring_path TEXT NOT NULL DEFAULT '',
|
||||
runtime_cdsl_path TEXT NOT NULL DEFAULT '', compile_audit_path TEXT NOT NULL DEFAULT '',
|
||||
diagnostics_path TEXT NOT NULL DEFAULT '', completion_path TEXT NOT NULL DEFAULT '',
|
||||
clarification_path TEXT NOT NULL DEFAULT '',
|
||||
requirements_contract_path TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invocations (
|
||||
invocation_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
idempotency_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
result_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(task_id, idempotency_key)
|
||||
invocation_id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
idempotency_key TEXT NOT NULL, status TEXT NOT NULL, result_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(task_id,idempotency_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ledger (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL,
|
||||
published_at TEXT
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL, published_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS usage_records (
|
||||
usage_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
usage_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
usage_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
usage_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tool_audits (
|
||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
audit_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
audit_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS model_capabilities (
|
||||
provider_id TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
schema_hash TEXT NOT NULL,
|
||||
supported INTEGER NOT NULL,
|
||||
report_json TEXT NOT NULL,
|
||||
checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(provider_id, model_id, schema_hash)
|
||||
provider_id TEXT NOT NULL, model_id TEXT NOT NULL, schema_hash TEXT NOT NULL,
|
||||
supported INTEGER NOT NULL, report_json TEXT NOT NULL, checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(provider_id,model_id,schema_hash)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
def create_task(self, task_id: str, request: str) -> TaskState:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.2', ?, ?, 0)",
|
||||
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value),
|
||||
"INSERT OR IGNORE INTO tasks(task_id,protocol_version,request,phase,state_version) VALUES (?,?,?,?,0)",
|
||||
(task_id, self.PROTOCOL_VERSION, request, TaskPhase.ANALYZING_REQUEST.value),
|
||||
)
|
||||
state = self.get_state(task_id)
|
||||
if state is None:
|
||||
raise RuntimeError("SQLite task insert was not visible")
|
||||
raise RuntimeError("Task was not persisted")
|
||||
return state
|
||||
|
||||
def get_state(self, task_id: str) -> TaskState | None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
||||
return self._state(row) if row is not None else None
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
return self._state(row) if row else None
|
||||
|
||||
def get_task_projection(self, task_id: str) -> dict[str, Any] | None:
|
||||
state = self.get_state(task_id)
|
||||
if state is None:
|
||||
return None
|
||||
events = self.ledger_events(task_id)
|
||||
revisions = [
|
||||
{
|
||||
"revision_id": item["revision_id"], "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED and item["revision_id"] == state.active_revision else "checkpoint",
|
||||
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": "" if item.get("preview_unavailable") else f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""),
|
||||
}
|
||||
for item in events
|
||||
if item.get("event") in {"accepted", "feature_node_verified"} and isinstance(item.get("revision_id"), str)
|
||||
]
|
||||
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_compiled"), {})
|
||||
verification_warnings = [
|
||||
str(item) for item in frozen.get("verification_warnings") or () if str(item)
|
||||
] if isinstance(frozen, dict) else []
|
||||
status_event = next((
|
||||
item for item in reversed(events)
|
||||
if item.get("event") in {
|
||||
"requirements_waiting_for_user", "waiting_retry", "call_budget_exhausted",
|
||||
"no_progress_limit",
|
||||
"candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure",
|
||||
"failed_author_format", "runtime_contract_invalid",
|
||||
"completed_best_effort",
|
||||
}
|
||||
), {}) if state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {}
|
||||
questions = [str(item) for item in status_event.get("questions") or () if str(item)] if isinstance(status_event, dict) else []
|
||||
issues = [str(item) for item in status_event.get("issues") or () if str(item)] if isinstance(status_event, dict) else []
|
||||
# A later repair can end at authoring or compilation after an earlier
|
||||
# build already published an executable prefix. Its terminal event
|
||||
# intentionally contains diagnostics rather than a duplicate path
|
||||
# map, so keep the most recent event that actually delivered a
|
||||
# revision. Otherwise the completed projection loses the existing
|
||||
# STEP/GLB links exactly when best-effort publication matters most.
|
||||
result: dict[str, Any] = {}
|
||||
paths: dict[str, Any] = {}
|
||||
for item in reversed(events):
|
||||
if item.get("event") not in {
|
||||
"build_completed", "published_best_effort", "build_failed",
|
||||
}:
|
||||
continue
|
||||
candidate_paths = item.get("paths")
|
||||
if isinstance(candidate_paths, dict) and candidate_paths:
|
||||
result, paths = item, candidate_paths
|
||||
break
|
||||
return {
|
||||
"schema_version": "3.2",
|
||||
"task_id": state.task_id,
|
||||
"phase": state.phase.value,
|
||||
"lifecycle": self._lifecycle(state.phase),
|
||||
"state_version": state.version,
|
||||
"active_revision": state.active_revision,
|
||||
"current_revision": state.active_revision,
|
||||
"schema_version": self.PROTOCOL_VERSION, "task_id": task_id, "phase": state.phase.value,
|
||||
"lifecycle": self._lifecycle(state.phase), "state_version": state.version,
|
||||
"active_revision": state.active_revision, "current_revision": state.active_revision,
|
||||
"published_revision": state.active_revision if state.phase == TaskPhase.COMPLETED else "",
|
||||
"pending_action": self._pending_payload(state.pending_action),
|
||||
"active_candidate_id": state.candidate_id,
|
||||
"repair_required": state.repair_required,
|
||||
"repair_count": state.repair_count, "repair_budget": 2,
|
||||
"last_error": state.last_error.value if state.last_error else "",
|
||||
"retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "",
|
||||
"requirements_spec_path": state.requirements_spec_path,
|
||||
"requirements_document_path": state.requirements_document_path,
|
||||
"completion_target_path": state.completion_target_path,
|
||||
"modeling_plan_path": state.modeling_plan_path,
|
||||
"feature_plan_path": state.feature_plan_path,
|
||||
"feature_plan_hash": state.feature_plan_hash,
|
||||
"current_feature_node_id": state.pending_feature.node_id if state.pending_feature else "",
|
||||
"pending_feature": self._pending_payload(state.pending_feature),
|
||||
"feature_nodes": self._feature_node_projection(events),
|
||||
"clarification_path": state.clarification_path,
|
||||
"requirements_contract_path": state.requirements_contract_path,
|
||||
"verification_status": (
|
||||
"completed_with_risks" if state.phase == TaskPhase.COMPLETED and (verification_warnings or state.last_error == ErrorCode.BEST_EFFORT_COMPLETED)
|
||||
else "verified" if state.phase == TaskPhase.COMPLETED
|
||||
else "pending"
|
||||
),
|
||||
"verification_warnings": verification_warnings,
|
||||
"message": str(status_event.get("message") or "") if isinstance(status_event, dict) else "",
|
||||
"questions": questions,
|
||||
"issues": issues,
|
||||
"blocker_type": (
|
||||
"requirements_ambiguity" if state.phase == TaskPhase.WAITING_FOR_USER
|
||||
else str(status_event.get("event") or "") if isinstance(status_event, dict) else ""
|
||||
),
|
||||
"user_action_required": state.phase == TaskPhase.WAITING_FOR_USER and bool(questions),
|
||||
"action_ledger_summary": events[-12:],
|
||||
"revisions": revisions,
|
||||
"requirements_path": state.requirements_path, "authoring_path": state.authoring_path,
|
||||
"runtime_cdsl_path": state.runtime_cdsl_path, "compile_audit_path": state.compile_audit_path,
|
||||
"diagnostics_path": state.diagnostics_path, "completion_path": state.completion_path,
|
||||
"message": str(result.get("message") or ""),
|
||||
"revisions": ([{
|
||||
"revision_id": state.active_revision, "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED else "checkpoint",
|
||||
"cdsl_path": paths.get("model.cdsl.json", ""), "step_path": paths.get("model.step", ""),
|
||||
"glb_path": paths.get("model.glb", ""), "report_path": paths.get("rebuild-report.json", ""),
|
||||
}] if state.active_revision else []),
|
||||
"action_ledger_summary": events[-16:],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _feature_node_projection(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return ledger-backed execution evidence; API adds immutable plan fields."""
|
||||
nodes: dict[str, dict[str, Any]] = {}
|
||||
for event in events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if not node_id:
|
||||
continue
|
||||
item = nodes.setdefault(node_id, {"node_id": node_id, "status": "pending", "attempt": 0})
|
||||
if event.get("event") == "feature_node_scheduled":
|
||||
item.update({"status": "running", "atomic_id": str(event.get("atomic_id") or ""), "priority": event.get("priority"), "claim_ids": event.get("claim_ids") or [], "depends_on": event.get("depends_on") or []})
|
||||
elif event.get("event") == "feature_node_failed":
|
||||
item.update({"status": "failed" if event.get("terminal") else "pending", "attempt": int(event.get("attempt") or 0), "failure_class": str(event.get("failure_class") or ""), "error": str(event.get("message") or "")})
|
||||
elif event.get("event") == "feature_node_verified":
|
||||
item.update({"status": "done", "revision_id": str(event.get("revision_id") or ""), "feature_id": str(event.get("feature_id") or ""), "evidence": event.get("claim_results") or []})
|
||||
elif event.get("event") == "feature_node_invalidated" and item.get("status") != "done":
|
||||
item["status"] = "invalidated"
|
||||
return list(nodes.values())
|
||||
|
||||
def ledger_events(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute("SELECT sequence, event_json, created_at FROM ledger WHERE task_id = ? ORDER BY sequence", (task_id,)).fetchall()
|
||||
return [{"sequence": row["sequence"], "at": row["created_at"], **json.loads(row["event_json"])} for row in rows]
|
||||
rows = connection.execute("SELECT sequence,event_json,created_at FROM ledger WHERE task_id=? ORDER BY sequence", (task_id,)).fetchall()
|
||||
return [{"sequence": int(row["sequence"]), "at": str(row["created_at"]), **json.loads(row["event_json"])} for row in rows]
|
||||
|
||||
def invocation_records(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT invocation_id, idempotency_key, status, result_json, created_at, updated_at FROM invocations WHERE task_id = ? ORDER BY created_at, invocation_id",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"invocation_id": str(row["invocation_id"]), "idempotency_key": str(row["idempotency_key"]),
|
||||
"status": str(row["status"]), "result": json.loads(row["result_json"]) if row["result_json"] else None,
|
||||
"created_at": str(row["created_at"]), "updated_at": str(row["updated_at"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def compare_and_swap(
|
||||
self,
|
||||
state: TaskState,
|
||||
*,
|
||||
events: list[dict[str, Any]] = (),
|
||||
invocation_id: str | None = None,
|
||||
invocation_result: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
def compare_and_swap(self, state: TaskState, *, events: list[dict[str, Any]] = (), invocation_id: str | None = None, invocation_result: dict[str, Any] | None = None) -> bool:
|
||||
if (invocation_id is None) != (invocation_result is None):
|
||||
raise ValueError("Invocation completion requires both invocation_id and invocation_result")
|
||||
previous_version = state.version - 1
|
||||
if previous_version < 0:
|
||||
raise ValueError("State version must advance exactly once")
|
||||
raise ValueError("Invocation completion requires both values")
|
||||
previous = state.version - 1
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
cursor = connection.execute(
|
||||
"""UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?,
|
||||
candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_spec_path = ?,
|
||||
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, feature_plan_path = ?, feature_plan_hash = ?, feature_stage_id = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE task_id = ? AND state_version = ?""",
|
||||
(
|
||||
state.phase.value, state.version, state.active_revision,
|
||||
json.dumps(self._pending_payload(state.pending_action), ensure_ascii=True) if state.pending_action else None,
|
||||
state.candidate_id, state.candidate_stage_id,
|
||||
int(state.repair_required), state.last_error.value if state.last_error else None,
|
||||
state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_spec_path,
|
||||
state.requirements_document_path, state.completion_target_path, state.modeling_plan_path,
|
||||
state.feature_plan_path, state.feature_plan_hash, state.feature_stage_id,
|
||||
state.clarification_path, state.requirements_contract_path,
|
||||
state.task_id, previous_version,
|
||||
),
|
||||
)
|
||||
cursor = connection.execute("""
|
||||
UPDATE tasks SET phase=?,state_version=?,active_revision=?,repair_count=?,last_error=?,retry_from_phase=?,
|
||||
requirements_path=?,authoring_path=?,runtime_cdsl_path=?,compile_audit_path=?,diagnostics_path=?,completion_path=?,clarification_path=?,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE task_id=? AND state_version=?
|
||||
""", (
|
||||
state.phase.value, state.version, state.active_revision, state.repair_count,
|
||||
state.last_error.value if state.last_error else None, state.retry_from_phase.value if state.retry_from_phase else "",
|
||||
state.requirements_path, state.authoring_path, state.runtime_cdsl_path, state.compile_audit_path,
|
||||
state.diagnostics_path, state.completion_path, state.clarification_path, state.task_id, previous,
|
||||
))
|
||||
if cursor.rowcount != 1:
|
||||
connection.execute("ROLLBACK")
|
||||
return False
|
||||
for event in events:
|
||||
encoded = json.dumps(event, ensure_ascii=True, sort_keys=True)
|
||||
connection.execute("INSERT INTO ledger(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded))
|
||||
connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded))
|
||||
if invocation_id is not None and invocation_result is not None:
|
||||
finished = connection.execute(
|
||||
"""UPDATE invocations
|
||||
SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE invocation_id = ? AND task_id = ? AND status = 'processing'""",
|
||||
(json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id),
|
||||
)
|
||||
if finished.rowcount != 1:
|
||||
raise ValueError("Invocation is not an active record for this state transition")
|
||||
for item in events:
|
||||
encoded = json.dumps(item, ensure_ascii=True, sort_keys=True)
|
||||
connection.execute("INSERT INTO ledger(task_id,event_json) VALUES (?,?)", (state.task_id, encoded))
|
||||
connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (state.task_id, encoded))
|
||||
if invocation_id is not None:
|
||||
updated = connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND task_id=? AND status='processing'", (json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id))
|
||||
if updated.rowcount != 1:
|
||||
raise ValueError("Invocation is not active")
|
||||
connection.execute("COMMIT")
|
||||
return True
|
||||
except Exception:
|
||||
@@ -310,144 +195,92 @@ class SqliteTaskRepository:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
existing = connection.execute("SELECT * FROM invocations WHERE task_id = ? AND idempotency_key = ?", (task_id, idempotency_key)).fetchone()
|
||||
if existing is not None:
|
||||
row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND idempotency_key=?", (task_id, idempotency_key)).fetchone()
|
||||
if row is None:
|
||||
connection.execute("INSERT INTO invocations(invocation_id,task_id,idempotency_key,status) VALUES (?,?,?,'processing')", (invocation_id, task_id, idempotency_key))
|
||||
connection.execute("COMMIT")
|
||||
return self._invocation(existing)
|
||||
connection.execute("INSERT INTO invocations(invocation_id, task_id, idempotency_key, status) VALUES (?, ?, ?, 'processing')", (invocation_id, task_id, idempotency_key))
|
||||
return InvocationRecord(invocation_id, idempotency_key, "processing")
|
||||
connection.execute("COMMIT")
|
||||
return InvocationRecord(invocation_id, idempotency_key, "processing")
|
||||
return self._invocation(row)
|
||||
except Exception:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def get_invocation(self, task_id: str, invocation_id: str) -> InvocationRecord | None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM invocations WHERE task_id = ? AND invocation_id = ?",
|
||||
(task_id, invocation_id),
|
||||
).fetchone()
|
||||
return self._invocation(row) if row is not None else None
|
||||
row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND invocation_id=?", (task_id, invocation_id)).fetchone()
|
||||
return self._invocation(row) if row else None
|
||||
|
||||
def finish_invocation(self, invocation_id: str, result: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute("SELECT status, result_json FROM invocations WHERE invocation_id = ?", (invocation_id,)).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("Unknown invocation")
|
||||
if str(row["status"]) == "finished":
|
||||
# A winning concurrent command has already recorded the only
|
||||
# durable result for this idempotency key. Never overwrite it.
|
||||
return
|
||||
cursor = connection.execute(
|
||||
"UPDATE invocations SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP WHERE invocation_id = ? AND status = 'processing'",
|
||||
(json.dumps(result, ensure_ascii=True), invocation_id),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise ValueError("Invocation could not be completed")
|
||||
connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND status='processing'", (json.dumps(result, ensure_ascii=True), invocation_id))
|
||||
|
||||
def append_outbox(self, task_id: str, event: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True)))
|
||||
connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
def pending_outbox(self, limit: int = 100, *, task_id: str | None = None) -> list[dict[str, Any]]:
|
||||
query = "SELECT event_id,task_id,event_json FROM outbox WHERE published_at IS NULL"
|
||||
args: tuple[Any, ...] = ()
|
||||
if task_id is not None:
|
||||
query += " AND task_id=?"
|
||||
args += (task_id,)
|
||||
query += " ORDER BY event_id LIMIT ?"
|
||||
args += (limit,)
|
||||
with self._lock, self._connection() as connection:
|
||||
if task_id is None:
|
||||
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL ORDER BY event_id LIMIT ?", (limit,)).fetchall()
|
||||
else:
|
||||
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL AND task_id = ? ORDER BY event_id LIMIT ?", (task_id, limit)).fetchall()
|
||||
return [{"event_id": row["event_id"], "task_id": row["task_id"], **json.loads(row["event_json"])} for row in rows]
|
||||
rows = connection.execute(query, args).fetchall()
|
||||
return [{"event_id": int(row["event_id"]), "task_id": str(row["task_id"]), **json.loads(row["event_json"])} for row in rows]
|
||||
|
||||
def mark_outbox_published(self, event_id: int) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("UPDATE outbox SET published_at = CURRENT_TIMESTAMP WHERE event_id = ? AND published_at IS NULL", (event_id,))
|
||||
connection.execute("UPDATE outbox SET published_at=CURRENT_TIMESTAMP WHERE event_id=? AND published_at IS NULL", (event_id,))
|
||||
|
||||
def record_usage(self, task_id: str, payload: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("INSERT INTO usage_records(task_id, usage_json) VALUES (?, ?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
|
||||
connection.execute("INSERT INTO usage_records(task_id,usage_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
def record_tool_audit(self, task_id: str, payload: dict[str, Any]) -> None:
|
||||
"""Persist a diagnostic structured-output audit separately from usage."""
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO tool_audits(task_id, audit_json) VALUES (?, ?)",
|
||||
(task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)),
|
||||
)
|
||||
connection.execute("INSERT INTO tool_audits(task_id,audit_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
def tool_audits(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT audit_id, audit_json, created_at FROM tool_audits WHERE task_id = ? ORDER BY audit_id",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])}
|
||||
for row in rows
|
||||
]
|
||||
rows = connection.execute("SELECT audit_id,audit_json,created_at FROM tool_audits WHERE task_id=? ORDER BY audit_id", (task_id,)).fetchall()
|
||||
return [{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])} for row in rows]
|
||||
|
||||
def usage_summary(self, task_id: str) -> dict[str, Any]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id = ? ORDER BY usage_id", (task_id,)).fetchall()
|
||||
values = [json.loads(row["usage_json"]) for row in rows]
|
||||
return {
|
||||
"calls": len(values),
|
||||
"prompt_tokens": sum(int(item.get("prompt_tokens") or 0) for item in values if isinstance(item, dict)),
|
||||
"completion_tokens": sum(int(item.get("completion_tokens") or 0) for item in values if isinstance(item, dict)),
|
||||
"context_chars": sum(int(item.get("context_chars") or 0) for item in values if isinstance(item, dict)),
|
||||
"records": values,
|
||||
}
|
||||
rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id=? ORDER BY usage_id", (task_id,)).fetchall()
|
||||
return {"calls": len(rows), "records": [json.loads(row["usage_json"]) for row in rows]}
|
||||
|
||||
def running_task_ids(self) -> list[str]:
|
||||
phases = tuple(phase.value for phase in TaskPhase if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER, TaskPhase.WAITING_RETRY})
|
||||
placeholders = ", ".join("?" for _ in phases)
|
||||
terminal = tuple(item.value for item in (TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER))
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute(f"SELECT task_id FROM tasks WHERE phase IN ({placeholders}) ORDER BY created_at", phases).fetchall()
|
||||
rows = connection.execute("SELECT task_id FROM tasks WHERE phase NOT IN (?,?,?,?) ORDER BY created_at", terminal).fetchall()
|
||||
return [str(row["task_id"]) for row in rows]
|
||||
|
||||
def model_capability(self, provider_id: str, model_id: str, schema_hash: str) -> dict[str, Any] | None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute("SELECT supported, report_json, checked_at FROM model_capabilities WHERE provider_id = ? AND model_id = ? AND schema_hash = ?", (provider_id, model_id, schema_hash)).fetchone()
|
||||
return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": row["checked_at"]} if row else None
|
||||
row = connection.execute("SELECT supported,report_json,checked_at FROM model_capabilities WHERE provider_id=? AND model_id=? AND schema_hash=?", (provider_id, model_id, schema_hash)).fetchone()
|
||||
return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": str(row["checked_at"])} if row else None
|
||||
|
||||
def record_model_capability(self, provider_id: str, model_id: str, schema_hash: str, report: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("""INSERT INTO model_capabilities(provider_id, model_id, schema_hash, supported, report_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider_id, model_id, schema_hash) DO UPDATE SET supported = excluded.supported, report_json = excluded.report_json, checked_at = CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True)))
|
||||
connection.execute("""INSERT INTO model_capabilities(provider_id,model_id,schema_hash,supported,report_json) VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(provider_id,model_id,schema_hash) DO UPDATE SET supported=excluded.supported,report_json=excluded.report_json,checked_at=CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
@staticmethod
|
||||
def _state(row: sqlite3.Row) -> TaskState:
|
||||
raw_pending = json.loads(row["pending_action_json"]) if row["pending_action_json"] else None
|
||||
pending = PendingAction(
|
||||
action_id=raw_pending["action_id"], working_head=raw_pending["working_head"], intent=raw_pending["intent"],
|
||||
requirement_ids=tuple(raw_pending["requirement_ids"]), atomic_id=raw_pending["atomic_id"],
|
||||
expected_change=raw_pending["expected_change"], contract_hash=raw_pending["contract_hash"], idempotency_key=raw_pending["idempotency_key"],
|
||||
node_id=str(raw_pending.get("node_id") or ""), plan_hash=str(raw_pending.get("plan_hash") or ""),
|
||||
claim_ids=tuple(raw_pending.get("claim_ids") or ()), depends_on_node_ids=tuple(raw_pending.get("depends_on_node_ids") or ()),
|
||||
) if isinstance(raw_pending, dict) else None
|
||||
return TaskState(
|
||||
task_id=str(row["task_id"]), phase=TaskPhase(str(row["phase"])), version=int(row["state_version"]),
|
||||
active_revision=str(row["active_revision"] or ""), pending_action=pending,
|
||||
candidate_id=str(row["candidate_id"] or ""), candidate_stage_id=str(row["candidate_stage_id"] or ""),
|
||||
repair_required=bool(row["repair_required"]),
|
||||
active_revision=str(row["active_revision"]), repair_count=int(row["repair_count"]),
|
||||
last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None,
|
||||
retry_from_phase=TaskPhase(str(row["retry_from_phase"])) if row["retry_from_phase"] else None,
|
||||
requirements_spec_path=str(row["requirements_spec_path"] or ""),
|
||||
requirements_document_path=str(row["requirements_document_path"] or ""),
|
||||
completion_target_path=str(row["completion_target_path"] or ""),
|
||||
modeling_plan_path=str(row["modeling_plan_path"] or ""),
|
||||
feature_plan_path=str(row["feature_plan_path"] or ""),
|
||||
feature_plan_hash=str(row["feature_plan_hash"] or ""),
|
||||
feature_stage_id=str(row["feature_stage_id"] or ""),
|
||||
clarification_path=str(row["clarification_path"] or ""),
|
||||
requirements_contract_path=str(row["requirements_contract_path"] or ""),
|
||||
requirements_path=str(row["requirements_path"]), authoring_path=str(row["authoring_path"]),
|
||||
runtime_cdsl_path=str(row["runtime_cdsl_path"]), compile_audit_path=str(row["compile_audit_path"]),
|
||||
diagnostics_path=str(row["diagnostics_path"]), completion_path=str(row["completion_path"]), clarification_path=str(row["clarification_path"]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _pending_payload(pending: PendingAction | None) -> dict[str, Any] | None:
|
||||
if pending is None:
|
||||
return None
|
||||
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key, "node_id": pending.node_id, "plan_hash": pending.plan_hash, "claim_ids": list(pending.claim_ids), "depends_on_node_ids": list(pending.depends_on_node_ids)}
|
||||
|
||||
@staticmethod
|
||||
def _invocation(row: sqlite3.Row) -> InvocationRecord:
|
||||
return InvocationRecord(str(row["invocation_id"]), str(row["idempotency_key"]), str(row["status"]), json.loads(row["result_json"]) if row["result_json"] else None)
|
||||
@@ -460,6 +293,6 @@ class SqliteTaskRepository:
|
||||
return "failed"
|
||||
if phase == TaskPhase.CANCELLED:
|
||||
return "cancelled"
|
||||
if phase in {TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}:
|
||||
return phase.value.lower()
|
||||
if phase == TaskPhase.WAITING_FOR_USER:
|
||||
return "waiting_for_user"
|
||||
return "running"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The sole OpenAI-compatible structured-output adapter for protocol v3.
|
||||
"""The sole OpenAI-compatible structured-output adapter for Authoring CDSL.
|
||||
|
||||
Raw tool arguments are intentionally preserved. Callers must run their
|
||||
Pydantic/JSON-Schema canonical validator before causing any state transition.
|
||||
@@ -170,39 +170,12 @@ class StructuredModelGateway:
|
||||
|
||||
@staticmethod
|
||||
def _conformance_messages(tool: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Give capability probes the same selector fact production exposes.
|
||||
|
||||
A required opaque selector is not inferable from a generic request for
|
||||
a minimal example. Production fragment turns expose a topology token
|
||||
before the fragment tool, so the probe must do the same while still
|
||||
letting the provider generate every other schema field itself.
|
||||
"""
|
||||
"""Probe the two standalone structured documents used by this protocol."""
|
||||
function = tool.get("function") if isinstance(tool.get("function"), dict) else {}
|
||||
name = str(function.get("name") or "")
|
||||
parameters = function.get("parameters") if isinstance(function.get("parameters"), dict) else {}
|
||||
feature = parameters.get("properties", {}).get("feature") if isinstance(parameters.get("properties"), dict) else None
|
||||
feature_properties = feature.get("properties") if isinstance(feature, dict) and isinstance(feature.get("properties"), dict) else {}
|
||||
feature_required = feature.get("required") if isinstance(feature, dict) and isinstance(feature.get("required"), list) else []
|
||||
selector_required = "selector_tokens" in feature_required and isinstance(feature_properties.get("selector_tokens"), dict)
|
||||
instruction = "Return exactly the required function call with a minimal valid example. The active JSON Schema is authoritative."
|
||||
if selector_required:
|
||||
atomic_id = feature_properties.get("atomic_id", {}).get("const") if isinstance(feature_properties.get("atomic_id"), dict) else "..."
|
||||
instruction += (
|
||||
" The current topology exposes opaque selector token `sel_conformance`. "
|
||||
f"Use root shape {{\"feature\":{{\"atomic_id\":{json.dumps(atomic_id)},"
|
||||
"\"selector_tokens\":[\"sel_conformance\"],\"params\":{...}}}}. "
|
||||
"Because feature.selector_tokens is required, it is author input and must not be moved into params."
|
||||
)
|
||||
if name == "review_candidate":
|
||||
instruction += (
|
||||
" This is a candidate-review probe: include every required top-level and nested field; "
|
||||
"in particular provide a verdict and claim_coverage. Use accept and pass only where the schema permits them."
|
||||
)
|
||||
elif name == "review_final":
|
||||
instruction += (
|
||||
" This is a final-review probe: include every required top-level and nested field; "
|
||||
"in particular provide a verdict and claim_coverage. Use pass only where the schema permits it."
|
||||
)
|
||||
if name == "write_authoring_cdsl":
|
||||
instruction += " Return cad.author.v1 with document-local names only; do not include runtime IDs, stable selectors, snapshots, or selector tokens."
|
||||
return [{"role": "system", "content": instruction}]
|
||||
|
||||
def _provider_model(self, provider_id: str, model_id: str) -> tuple[ProviderConfig, ProviderModel]:
|
||||
@@ -256,7 +229,7 @@ class StructuredModelGateway:
|
||||
force_tool_name: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if len(tools) != 1 or not required_tool_name:
|
||||
raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.")
|
||||
raise StructuredModelError("The Authoring protocol requires exactly one named tool per provider call.")
|
||||
request_options = provider.request_options if include_reasoning else {}
|
||||
if provider.api_style == "responses":
|
||||
payload: dict[str, Any] = {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Adapter exposing the pure verifier registry through the application port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.verifier_registry import VerifierRegistry
|
||||
|
||||
|
||||
class RegistryVerifierExecutor:
|
||||
def __init__(self, registry: VerifierRegistry) -> None:
|
||||
self.registry = registry
|
||||
|
||||
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
for claim in claims:
|
||||
result = self.registry.evaluate(str(claim["claim_kind"]), claim["expected"], facts)
|
||||
results.append({"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": self.registry.definition(str(claim["claim_kind"])).deterministic, **result})
|
||||
return results
|
||||
@@ -1 +1 @@
|
||||
"""Application command handlers and context assembly for protocol v3."""
|
||||
"""Application workflow and compiler for the Authoring CDSL protocol."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
||||
"""Compile model-facing Authoring CDSL into server-owned runtime CDSL."""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from .authoring_contract import AuthoringDocument, validate_finite, validation_error_code
|
||||
|
||||
|
||||
class AuthoringCompileError(ValueError):
|
||||
def __init__(self, code: str, message: str, *, path: str = "") -> None:
|
||||
self.code, self.path = code, path
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AuthoringCompiler:
|
||||
def __init__(self, operation_contract: Callable[[str], dict[str, Any]], *, version: str = "1") -> None:
|
||||
self.operation_contract = operation_contract
|
||||
self.version = version
|
||||
|
||||
def compile(self, raw: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
try:
|
||||
validate_finite(raw)
|
||||
doc = AuthoringDocument.model_validate(raw)
|
||||
except Exception as error:
|
||||
raise AuthoringCompileError(validation_error_code(error), str(error)) from error
|
||||
source_features = [feature for body in doc.bodies for feature in body.features]
|
||||
dependencies, implicit_selector_dependencies = self._effective_dependencies(doc)
|
||||
ordered = self._topological(doc, dependencies)
|
||||
# Identity follows the source document, while execution follows the
|
||||
# dependency graph. A dependency reordering must never renumber IDs.
|
||||
feature_ids = {feature.name: f"feature_{index:03d}" for index, feature in enumerate(source_features, 1)}
|
||||
body_ids = {body.name: f"body_{index:03d}" for index, body in enumerate(doc.bodies, 1)}
|
||||
features: list[dict[str, Any]] = []
|
||||
sketches: list[dict[str, Any]] = []
|
||||
sketch_index = 0
|
||||
source_positions = {feature.name: index for index, feature in enumerate(source_features, 1)}
|
||||
sketch_ids_by_feature = {
|
||||
feature.name: f"sketch_{source_positions[feature.name]:03d}"
|
||||
for feature in source_features
|
||||
if feature.sketch is not None
|
||||
}
|
||||
for feature in ordered:
|
||||
try:
|
||||
contract = self.operation_contract(feature.operation)
|
||||
except Exception as error:
|
||||
raise AuthoringCompileError("OPERATION_UNSUPPORTED", str(error), path=f"features.{feature.name}.operation") from error
|
||||
params = self._references(
|
||||
deepcopy(feature.params), contract, feature_ids, feature.name,
|
||||
sketch_ids_by_feature=sketch_ids_by_feature,
|
||||
)
|
||||
self._validate_params(contract, params, feature.name)
|
||||
selectors = [self._selector(item, feature_ids, source_features) for item in feature.selectors]
|
||||
selector_policy = contract.get("selector_policy") or {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0}
|
||||
fragment_shape = contract.get("fragment_shape") or {"sketch": "forbidden", "selector_tokens": "forbidden"}
|
||||
required_selectors = fragment_shape["selector_tokens"] == "required"
|
||||
runtime_feature_selectors: list[dict[str, Any]] = []
|
||||
if required_selectors and not selector_policy["min_items"] <= len(selectors) <= selector_policy["max_items"]:
|
||||
raise AuthoringCompileError("SELECTOR_NOT_FOUND", f"operation {feature.operation} requires {selector_policy['min_items']}..{selector_policy['max_items']} selectors", path=f"features.{feature.name}.selectors")
|
||||
if not required_selectors and selectors:
|
||||
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} does not accept selectors", path=f"features.{feature.name}.selectors")
|
||||
if required_selectors and any(item["kind"] != selector_policy["token_kind"] for item in selectors):
|
||||
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} requires {selector_policy['token_kind']} selectors", path=f"features.{feature.name}.selectors")
|
||||
if required_selectors:
|
||||
slot = str(selector_policy["slot"])
|
||||
if slot == "feature.selectors":
|
||||
runtime_feature_selectors = selectors
|
||||
elif slot.startswith("params."):
|
||||
parameter = slot.removeprefix("params.")
|
||||
if "." in parameter:
|
||||
raise AuthoringCompileError(
|
||||
"OPERATION_UNSUPPORTED",
|
||||
f"runtime has no safe selector binding for {slot}",
|
||||
path=f"features.{feature.name}.selectors",
|
||||
)
|
||||
if parameter in params:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_FORBIDDEN_FIELD",
|
||||
f"{parameter} is server-injected from selectors",
|
||||
path=f"features.{feature.name}.params.{parameter}",
|
||||
)
|
||||
params[parameter] = selectors[0] if len(selectors) == 1 else selectors
|
||||
else:
|
||||
raise AuthoringCompileError(
|
||||
"OPERATION_UNSUPPORTED",
|
||||
f"runtime has no safe selector binding for {slot}",
|
||||
path=f"features.{feature.name}.selectors",
|
||||
)
|
||||
output = {
|
||||
"id": feature_ids[feature.name], "atomic_id": feature.operation,
|
||||
"depends_on": [feature_ids[name] for name in dependencies[feature.name]],
|
||||
"params": params,
|
||||
**({"selectors": runtime_feature_selectors} if runtime_feature_selectors else {}),
|
||||
}
|
||||
needs_sketch = fragment_shape["sketch"] == "required"
|
||||
if needs_sketch and feature.sketch is None:
|
||||
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} requires sketch", path=f"features.{feature.name}.sketch")
|
||||
if not needs_sketch and feature.sketch is not None:
|
||||
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} does not accept sketch", path=f"features.{feature.name}.sketch")
|
||||
if feature.sketch is not None:
|
||||
sketch_index += 1
|
||||
sketch_id = f"sketch_{source_positions[feature.name]:03d}"
|
||||
sketches.append({"id": sketch_id, **self._runtime_sketch(feature.sketch.model_dump(mode="json"))})
|
||||
output["sketch_id"] = sketch_id
|
||||
features.append(output)
|
||||
runtime = {
|
||||
"schema": "cad.runtime.v1", "schema_version": "1.0.0", "kind": "part",
|
||||
"part_id": "compiled", "meta": {"unit": "mm"},
|
||||
"bodies": [
|
||||
{"id": body_ids[body.name], "name": body.name}
|
||||
for body in doc.bodies
|
||||
],
|
||||
"geometry": {"sketches": sketches}, "features": features,
|
||||
}
|
||||
digest = sha256(self._canonical_json(raw)).hexdigest()
|
||||
return runtime, {
|
||||
"schema_version": "cad.author.compile-audit.v1", "compiler_version": self.version,
|
||||
"source_sha256": digest, "body_ids": body_ids, "feature_ids": feature_ids,
|
||||
"sketch_ids": [item["id"] for item in sketches],
|
||||
"implicit_selector_dependencies": implicit_selector_dependencies,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _effective_dependencies(doc: AuthoringDocument) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
|
||||
"""Make every declared selector source an auditable graph dependency.
|
||||
|
||||
A selector is already an explicit local source reference. Requiring the
|
||||
author to repeat that same edge in a second field only creates a
|
||||
formatting failure; it does not add CAD intent. The compiler therefore
|
||||
adds the direct source edge deterministically and records it in the
|
||||
audit. It never selects a substitute topology element.
|
||||
"""
|
||||
by_name = {item.name: item for body in doc.bodies for item in body.features}
|
||||
dependencies: dict[str, list[str]] = {}
|
||||
implicit: dict[str, list[str]] = {}
|
||||
for feature in by_name.values():
|
||||
values = list(feature.depends_on)
|
||||
additions: list[str] = []
|
||||
for selector in feature.selectors:
|
||||
source = selector.source.split(".", 1)[0]
|
||||
if source not in by_name:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_REFERENCE_INVALID",
|
||||
f"unknown selector source: {selector.source}",
|
||||
path=f"features.{feature.name}.selectors",
|
||||
)
|
||||
if source not in values:
|
||||
values.append(source)
|
||||
additions.append(source)
|
||||
dependencies[feature.name] = values
|
||||
if additions:
|
||||
implicit[feature.name] = additions
|
||||
return dependencies, implicit
|
||||
|
||||
@staticmethod
|
||||
def _topological(doc: AuthoringDocument, dependencies: dict[str, list[str]]) -> list[Any]:
|
||||
by_name = {item.name: item for body in doc.bodies for item in body.features}
|
||||
result: list[Any] = []
|
||||
visiting: set[str] = set()
|
||||
done: set[str] = set()
|
||||
def visit(name: str) -> None:
|
||||
if name in visiting:
|
||||
raise AuthoringCompileError("AUTHOR_CYCLE", f"cyclic feature dependency: {name}")
|
||||
if name in done:
|
||||
return
|
||||
visiting.add(name)
|
||||
for dependency in dependencies[name]:
|
||||
visit(dependency)
|
||||
visiting.remove(name); done.add(name); result.append(by_name[name])
|
||||
for body in doc.bodies:
|
||||
for feature in body.features:
|
||||
visit(feature.name)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _selector(selector: Any, feature_ids: dict[str, str], source_features: list[Any]) -> dict[str, Any]:
|
||||
source = selector.source.split(".", 1)[0]
|
||||
if source not in feature_ids:
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"unknown selector source: {selector.source}")
|
||||
source_feature = next(item for item in source_features if item.name == source)
|
||||
role = selector.source.split(".", 1)[1]
|
||||
role = AuthoringCompiler._runtime_role(role, source_feature.operation)
|
||||
return {"kind": selector.kind, "output_role": role, "owner_feature_id": feature_ids[source], "source": "runtime_snapshot", "confidence": 1.0, "match_mode": selector.match}
|
||||
|
||||
@staticmethod
|
||||
def _runtime_sketch(sketch: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Lower the small Authoring sketch language to the generic runtime form."""
|
||||
profile = sketch["profile"]
|
||||
if profile["type"] == "circle":
|
||||
profile = {
|
||||
"type": "circle",
|
||||
"center": profile["center_mm"],
|
||||
"radius_mm": float(profile["diameter_mm"]) / 2.0,
|
||||
}
|
||||
return {"workplane": sketch["workplane"], "profile": profile}
|
||||
|
||||
@staticmethod
|
||||
def _runtime_role(role: str, operation: str) -> str:
|
||||
if role in {"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end", "shell.offset_face", "shell.closing_descendant", "shell.body_face"}:
|
||||
return role
|
||||
family = (
|
||||
"extrude" if operation.startswith("extrude") else
|
||||
"sweep" if operation.startswith("sweep") else
|
||||
"loft" if operation.startswith("loft") else
|
||||
"cylinder" if operation == "cylinder_add" else ""
|
||||
)
|
||||
if role in {"top_planar_face", "end_face"} and family:
|
||||
return family + ".end"
|
||||
if role in {"bottom_planar_face", "start_face"} and family:
|
||||
return family + ".start"
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"selector output role {role!r} is not available from {operation}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_params(contract: dict[str, Any], params: dict[str, Any], feature_name: str) -> None:
|
||||
schema = contract.get("author_params_schema") or {"type": "object"}
|
||||
errors = list(Draft202012Validator(schema).iter_errors(params))
|
||||
if errors:
|
||||
first = errors[0]
|
||||
path = ".".join(str(item) for item in first.absolute_path)
|
||||
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", first.message, path=f"features.{feature_name}.params.{path}".rstrip("."))
|
||||
|
||||
@staticmethod
|
||||
def _references(
|
||||
params: dict[str, Any],
|
||||
contract: dict[str, Any],
|
||||
feature_ids: dict[str, str],
|
||||
feature_name: str,
|
||||
*,
|
||||
sketch_ids_by_feature: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
params = AuthoringCompiler._rewrite_named_references(
|
||||
params, feature_ids, sketch_ids_by_feature, feature_name,
|
||||
)
|
||||
policy = contract.get("reference_policy") or {"mode": "none"}
|
||||
if policy["mode"] != "snapshot_bound" or policy.get("slot") == "feature.selectors":
|
||||
return params
|
||||
key = str(policy["slot"]).removeprefix("params.")
|
||||
value = params.get(key)
|
||||
if isinstance(value, list):
|
||||
names = value
|
||||
elif value is None and policy["min_items"] == 0:
|
||||
names = []
|
||||
elif isinstance(value, str):
|
||||
names = [value]
|
||||
else:
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} must contain local feature names", path=f"features.{feature_name}.params.{key}")
|
||||
runtime_ids = set(feature_ids.values())
|
||||
if not all(isinstance(item, str) and item in runtime_ids for item in names):
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} contains an unknown local feature name", path=f"features.{feature_name}.params.{key}")
|
||||
if not policy["min_items"] <= len(names) <= policy["max_items"]:
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} has invalid item count", path=f"features.{feature_name}.params.{key}")
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_named_references(
|
||||
value: Any,
|
||||
feature_ids: dict[str, str],
|
||||
sketch_ids_by_feature: dict[str, str],
|
||||
consuming_feature: str,
|
||||
key: str = "",
|
||||
) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
item_key: AuthoringCompiler._rewrite_named_references(
|
||||
item_value, feature_ids, sketch_ids_by_feature, consuming_feature, item_key,
|
||||
)
|
||||
for item_key, item_value in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
AuthoringCompiler._rewrite_named_references(
|
||||
item, feature_ids, sketch_ids_by_feature, consuming_feature, key,
|
||||
)
|
||||
for item in value
|
||||
]
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if key == "profile_sketch_ids":
|
||||
if value not in sketch_ids_by_feature:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_REFERENCE_INVALID", f"unknown local sketch source: {value}",
|
||||
path=f"features.{consuming_feature}.params.{key}",
|
||||
)
|
||||
return sketch_ids_by_feature[value]
|
||||
if key.endswith("_feature_id") or key.endswith("_feature_ids"):
|
||||
if value not in feature_ids:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_REFERENCE_INVALID", f"unknown local feature reference: {value}",
|
||||
path=f"features.{consuming_feature}.params.{key}",
|
||||
)
|
||||
return feature_ids[value]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Strict model-facing Authoring CDSL contract.
|
||||
|
||||
This is intentionally separate from the runtime CDSL: model output contains
|
||||
only document-local names and declarative references. Runtime identities are
|
||||
allocated by :mod:`authoring_compiler`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
_NAME = r"^[a-z][a-z0-9_]{0,63}$"
|
||||
_FORBIDDEN = {
|
||||
"id",
|
||||
"task_id", "revision_id", "candidate_id", "action_id", "requirement_id",
|
||||
"claim_id", "evidence_id", "feature_id", "sketch_id", "body_id",
|
||||
"stable_id", "snapshot_id", "owner_feature_id", "working_head",
|
||||
"selector_token", "selector_tokens", "selector token", "selector-token", "selectorToken",
|
||||
"host_face", "mirror_plane",
|
||||
}
|
||||
|
||||
|
||||
def validation_error_code(error: Exception) -> str:
|
||||
"""Map strict model validation failures to a stable public diagnostic."""
|
||||
return "AUTHOR_FORBIDDEN_FIELD" if "AUTHOR_FORBIDDEN_FIELD:" in str(error) else "AUTHOR_SCHEMA_INVALID"
|
||||
|
||||
|
||||
class AuthorModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def reject_internal_fields(cls, value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
found = sorted(
|
||||
key for key in value
|
||||
if isinstance(key, str)
|
||||
and (key in _FORBIDDEN or key.lower().replace("-", "_").replace(" ", "_") in _FORBIDDEN)
|
||||
)
|
||||
if found:
|
||||
raise ValueError(f"AUTHOR_FORBIDDEN_FIELD: {found[0]}")
|
||||
for nested in value.values():
|
||||
cls.reject_internal_fields(nested)
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
cls.reject_internal_fields(item)
|
||||
return value
|
||||
|
||||
|
||||
class AuthorWorkplane(AuthorModel):
|
||||
"""A fully explicit local sketch frame in world millimetres."""
|
||||
|
||||
origin_mm: list[float] = Field(min_length=3, max_length=3)
|
||||
x_dir: list[float] = Field(min_length=3, max_length=3)
|
||||
normal: list[float] = Field(min_length=3, max_length=3)
|
||||
|
||||
|
||||
class AuthorCircleProfile(AuthorModel):
|
||||
"""A declarative circle expressed with the user-facing diameter."""
|
||||
|
||||
type: Literal["circle"]
|
||||
diameter_mm: float = Field(gt=0)
|
||||
center_mm: list[float] = Field(default_factory=lambda: [0.0, 0.0], min_length=2, max_length=2)
|
||||
|
||||
|
||||
class AuthorPolygonProfile(AuthorModel):
|
||||
"""A closed polygon in the local sketch workplane."""
|
||||
|
||||
type: Literal["polygon"]
|
||||
vertices: list[list[float]] = Field(min_length=3)
|
||||
|
||||
@field_validator("vertices")
|
||||
@classmethod
|
||||
def require_planar_points(cls, value: list[list[float]]) -> list[list[float]]:
|
||||
if any(len(point) != 2 for point in value):
|
||||
raise ValueError("polygon vertices must have exactly two coordinates")
|
||||
return value
|
||||
|
||||
|
||||
class AuthorSketch(AuthorModel):
|
||||
"""The only authoring sketch form currently accepted by the compiler."""
|
||||
|
||||
workplane: AuthorWorkplane
|
||||
profile: AuthorCircleProfile | AuthorPolygonProfile
|
||||
|
||||
|
||||
class SelectorIntent(AuthorModel):
|
||||
"""A local feature-output reference, never a Runtime selector token."""
|
||||
|
||||
kind: Literal["face", "edge", "axis", "plane", "vertex", "body"]
|
||||
source: str = Field(
|
||||
min_length=3,
|
||||
max_length=160,
|
||||
description="A local feature output in the form <feature_name>.<output_role>.",
|
||||
)
|
||||
match: Literal["unique", "all"] = "unique"
|
||||
|
||||
@field_validator("source")
|
||||
@classmethod
|
||||
def require_feature_output_reference(cls, value: str) -> str:
|
||||
feature, separator, role = value.partition(".")
|
||||
if not separator or not re.fullmatch(_NAME, feature) or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,80}", role):
|
||||
raise ValueError("selector source must be <feature_name>.<output_role>")
|
||||
return value
|
||||
|
||||
|
||||
class AuthorFeature(AuthorModel):
|
||||
name: str = Field(pattern=_NAME)
|
||||
operation: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$")
|
||||
params: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Only parameters from this feature operation's supplied params_schema.",
|
||||
)
|
||||
depends_on: list[str] = Field(default_factory=list, max_length=32)
|
||||
selectors: list[SelectorIntent] = Field(default_factory=list, max_length=32)
|
||||
sketch: AuthorSketch | None = Field(
|
||||
default=None,
|
||||
description="For sketch operations: exactly {workplane, profile}. Circle profiles use diameter_mm and center_mm.",
|
||||
)
|
||||
|
||||
|
||||
class AuthorBody(AuthorModel):
|
||||
name: str = Field(pattern=_NAME)
|
||||
features: list[AuthorFeature] = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class AuthoringDocument(AuthorModel):
|
||||
schema_version: str = Field(default="cad.author.v1", pattern=r"^cad\.author\.v1$")
|
||||
units: str = Field(default="mm", pattern=r"^mm$")
|
||||
coordinate_system: str = Field(default="right_handed", pattern=r"^[a-z][a-z0-9_-]{0,40}$")
|
||||
assumptions: list[str] = Field(default_factory=list, max_length=64)
|
||||
bodies: list[AuthorBody] = Field(min_length=1, max_length=32)
|
||||
acceptance_targets: list[dict[str, Any]] = Field(default_factory=list, max_length=128)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_symbols(self) -> "AuthoringDocument":
|
||||
validate_finite(self.model_dump(mode="python"))
|
||||
bodies = [b.name for b in self.bodies]
|
||||
if len(bodies) != len(set(bodies)):
|
||||
raise ValueError("duplicate body name")
|
||||
names: set[str] = set()
|
||||
for body in self.bodies:
|
||||
for feature in body.features:
|
||||
if feature.name in names:
|
||||
raise ValueError(f"duplicate feature name: {feature.name}")
|
||||
names.add(feature.name)
|
||||
for body in self.bodies:
|
||||
for feature in body.features:
|
||||
if len(feature.depends_on) != len(set(feature.depends_on)):
|
||||
raise ValueError(f"duplicate dependency: {feature.name}")
|
||||
if any(dep not in names for dep in feature.depends_on):
|
||||
missing = next(dep for dep in feature.depends_on if dep not in names)
|
||||
raise ValueError(f"unknown feature reference: {missing}")
|
||||
return self
|
||||
|
||||
|
||||
def validate_finite(value: Any, path: str = "$") -> None:
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
raise ValueError(f"non-finite number at {path}")
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
validate_finite(item, f"{path}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
validate_finite(item, f"{path}[{index}]")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Prompt material for the model-facing Authoring CDSL contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_GUIDANCE = Path(__file__).resolve().parents[4] / "agent" / "skills" / "cad-authoring" / "SKILL.md"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_authoring_guidance() -> str:
|
||||
try:
|
||||
return _GUIDANCE.read_text(encoding="utf-8")[:12_000]
|
||||
except OSError:
|
||||
return "Use cad.author.v1 only. Never create runtime IDs or selector tokens."
|
||||
@@ -6,62 +6,25 @@ from hashlib import sha256
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.cad_agent.application.llm_contracts import (
|
||||
EmptyCommand,
|
||||
ImageObservation,
|
||||
MarkdownDocument,
|
||||
StatelessCandidateReview,
|
||||
StatelessGeometryConclusion,
|
||||
StatelessRollbackCheckpoint,
|
||||
StatelessTopologyRequest,
|
||||
compiled_requirements_schema,
|
||||
stateless_final_review_schema,
|
||||
stateless_next_action_schema,
|
||||
)
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan
|
||||
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.cad_agent.application.authoring_contract import AuthoringDocument
|
||||
from app.cad_agent.application.workflow import RequirementsAnalysis
|
||||
from app.cad_agent.ports import CadRuntime, ModelGateway
|
||||
|
||||
|
||||
CapabilityRole = Literal["author", "reviewer"]
|
||||
CapabilityRole = Literal["author"]
|
||||
|
||||
|
||||
def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict[str, Any]]:
|
||||
if role == "reviewer":
|
||||
return [
|
||||
_tool("observe_images", ImageObservation.model_json_schema()),
|
||||
# Compatibility conformance probe; regular DAG execution never
|
||||
# calls a per-node reviewer.
|
||||
_tool("review_candidate", StatelessCandidateReview.model_json_schema()),
|
||||
_tool("review_final", stateless_final_review_schema(1)),
|
||||
]
|
||||
atomic_ids = list(runtime.supported_atomic_ids())
|
||||
if not atomic_ids:
|
||||
if not runtime.supported_atomic_ids():
|
||||
raise RuntimeError("Runtime has no operations for conformance")
|
||||
tools = [
|
||||
_tool("write_requirements_document", MarkdownDocument.model_json_schema()),
|
||||
_tool("write_completion_target", MarkdownDocument.model_json_schema()),
|
||||
_tool("compile_requirements_spec", compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)),
|
||||
_tool("write_feature_plan", FeaturePlan.model_json_schema()),
|
||||
# Compatibility probe only; production v3.2 workflow never exposes it.
|
||||
_tool("write_modeling_plan", MarkdownDocument.model_json_schema()),
|
||||
_tool("inspect_topology", StatelessTopologyRequest.model_json_schema()),
|
||||
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
|
||||
_tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()),
|
||||
_tool("complete_task", EmptyCommand.model_json_schema()),
|
||||
return [
|
||||
_tool("analyze_requirements", RequirementsAnalysis.model_json_schema()),
|
||||
_tool("write_authoring_cdsl", AuthoringDocument.model_json_schema()),
|
||||
]
|
||||
for atomic_id in atomic_ids:
|
||||
contract = runtime.operation_contract(atomic_id)
|
||||
tools.append(_tool(
|
||||
f"conformance_{atomic_id}",
|
||||
fragment_schema(contract, selector_tokens=["sel_conformance"], reference_tokens=["ref_conformance"]),
|
||||
))
|
||||
return tools
|
||||
|
||||
|
||||
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
|
||||
payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools}
|
||||
payload = {"protocol": "cad.single-stage.v1", "role": role, "tools": tools}
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
"""Canonical schemas for every v3 LLM state-changing command.
|
||||
|
||||
Providers may parse structured output first, but this module validates the raw
|
||||
tool arguments a second time before a command reaches a handler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from typing import Annotated, Any, Literal, TypeVar
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, ValidationError, model_validator
|
||||
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
|
||||
|
||||
class StrictDto(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
ShortText = Annotated[str, Field(min_length=1, max_length=360)]
|
||||
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")]
|
||||
|
||||
|
||||
class AcceptanceClaimInput(StrictDto):
|
||||
claim_kind: Identifier
|
||||
expected: dict[str, JsonValue] = Field(min_length=0, max_length=24)
|
||||
|
||||
|
||||
class SpecRequirementInput(StrictDto):
|
||||
statement: Annotated[str, Field(min_length=1, max_length=1000)]
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class MarkdownDocument(StrictDto):
|
||||
"""A frozen human-readable design artifact, never an executable payload."""
|
||||
markdown: Annotated[str, Field(min_length=1, max_length=16_000)]
|
||||
|
||||
|
||||
class CompiledRequirementInput(StrictDto):
|
||||
"""One verifier bundle for one server-parsed checklist item.
|
||||
|
||||
The checklist text, ordering, source bindings, and all identifiers are
|
||||
intentionally absent: the service owns them after Markdown is frozen.
|
||||
"""
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class CompiledRequirementsSpec(StrictDto):
|
||||
requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
# Kept only so an interrupted process with an already imported old tool schema
|
||||
# fails at the workflow boundary instead of failing module import. New v3.1
|
||||
# tasks never expose or accept this aggregate specification.
|
||||
class RequirementsSpec(StrictDto):
|
||||
outcome: Literal["ready"]
|
||||
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=32)
|
||||
requirements: list[SpecRequirementInput] = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class RequirementsClarification(StrictDto):
|
||||
outcome: Literal["clarification"]
|
||||
source_quotes: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field(min_length=2, max_length=4)
|
||||
question: Annotated[str, Field(min_length=1, max_length=500)]
|
||||
|
||||
|
||||
class RequirementsAuthorOutput(RootModel[Annotated[RequirementsSpec | RequirementsClarification, Field(discriminator="outcome")]]):
|
||||
pass
|
||||
|
||||
|
||||
class EmptyCommand(StrictDto):
|
||||
pass
|
||||
|
||||
|
||||
class NextAction(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
intent: ShortText
|
||||
# The server binds this list from every frozen checklist target. It is not
|
||||
# author input, so a five-item UI-era limit must not reject a valid task.
|
||||
requirement_ids: list[Identifier] = Field(min_length=1, max_length=64)
|
||||
atomic_id: Identifier
|
||||
expected_change: ShortText
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _requirement_ids_are_unique(self) -> "NextAction":
|
||||
if len(self.requirement_ids) != len(set(self.requirement_ids)):
|
||||
raise ValueError("requirement_ids must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
class StatelessNextAction(StrictDto):
|
||||
intent: ShortText
|
||||
operation: Identifier
|
||||
expected_change: ShortText
|
||||
|
||||
|
||||
class TopologyRequest(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
||||
limit: int = Field(default=16, ge=1, le=64)
|
||||
|
||||
|
||||
class GeometryConclusion(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
evidence_refs: list[Identifier] = Field(min_length=1, max_length=16)
|
||||
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
decision: Literal["return_to_action_selection", "rollback"]
|
||||
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _evidence_refs_are_unique(self) -> "GeometryConclusion":
|
||||
if len(self.evidence_refs) != len(set(self.evidence_refs)):
|
||||
raise ValueError("evidence_refs must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
class RollbackCheckpoint(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
checkpoint_token: Identifier
|
||||
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
|
||||
|
||||
class StatelessTopologyRequest(StrictDto):
|
||||
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
||||
limit: int = Field(default=16, ge=1, le=64)
|
||||
|
||||
|
||||
class StatelessGeometryConclusion(StrictDto):
|
||||
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
decision: Literal["return_to_action_selection", "rollback"]
|
||||
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
|
||||
|
||||
|
||||
class StatelessRollbackCheckpoint(StrictDto):
|
||||
checkpoint_token: Identifier
|
||||
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
|
||||
|
||||
class ClaimCoverage(StrictDto):
|
||||
claim_id: Identifier
|
||||
status: Literal["pass", "pending", "fail", "not_applicable"]
|
||||
evidence_refs: list[Identifier] = Field(default_factory=list, max_length=16)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _evidence_refs_are_unique(self) -> "ClaimCoverage":
|
||||
if len(self.evidence_refs) != len(set(self.evidence_refs)):
|
||||
raise ValueError("evidence_refs must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
class CandidateReview(StrictDto):
|
||||
candidate_id: Identifier = Field(description="Server-issued candidate ID from the review facts.")
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the review facts.")]
|
||||
verdict: Literal["accept", "reject"] = Field(description="Required independent decision. Set accept only when the supplied candidate evidence supports every covered claim; otherwise set reject.")
|
||||
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied candidate facts.")
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class StatelessCandidateReview(StrictDto):
|
||||
verdict: Literal["accept", "reject"]
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class VisualClaimDecision(StrictDto):
|
||||
status: Literal["pass", "fail"]
|
||||
evidence: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
|
||||
|
||||
class StatelessFinalReview(StrictDto):
|
||||
verdict: Literal["pass", "repair"]
|
||||
visual_claims: list[VisualClaimDecision] = Field(default_factory=list, max_length=128)
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class ImageMeasurement(StrictDto):
|
||||
name: Annotated[str, Field(min_length=1, max_length=160)]
|
||||
value: float | None = None
|
||||
unit: Literal["mm", "degree", "count", "unknown"] = "unknown"
|
||||
evidence: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
confidence: float = Field(ge=0, le=1)
|
||||
|
||||
|
||||
class ImageObservation(StrictDto):
|
||||
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||
visible_features: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||
measurements: list[ImageMeasurement] = Field(default_factory=list, max_length=128)
|
||||
view_directions: list[Annotated[str, Field(min_length=1, max_length=120)]] = Field(default_factory=list, max_length=16)
|
||||
uncertainties: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||
|
||||
|
||||
class FinalReview(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")]
|
||||
verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.")
|
||||
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied final-review facts.")
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]:
|
||||
schema = RequirementsAuthorOutput.model_json_schema()
|
||||
requirement = schema.get("$defs", {}).get("SpecRequirementInput")
|
||||
if isinstance(requirement, dict):
|
||||
claims = requirement.get("properties", {}).get("acceptance_claims")
|
||||
if isinstance(claims, dict):
|
||||
claims["items"] = deepcopy(claim_one_of)
|
||||
return schema
|
||||
|
||||
|
||||
def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]:
|
||||
schema = CompiledRequirementsSpec.model_json_schema()
|
||||
definitions = schema.get("$defs", {})
|
||||
requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None
|
||||
if isinstance(requirement, dict):
|
||||
claims = requirement.get("properties", {}).get("acceptance_claims")
|
||||
if isinstance(claims, dict):
|
||||
claims["items"] = deepcopy(claim_one_of)
|
||||
requirements = schema.get("properties", {}).get("requirements")
|
||||
if isinstance(requirements, dict):
|
||||
requirements["minItems"] = target_count
|
||||
requirements["maxItems"] = target_count
|
||||
return schema
|
||||
|
||||
|
||||
def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError:
|
||||
"""Drop harmless compiler chatter before strict requirements validation.
|
||||
|
||||
``compile_requirements_spec`` is a compiler stage: the service only needs
|
||||
the ordered verifier bundles for the frozen checklist items. Real models
|
||||
sometimes add explanatory fields such as a top-level ``assumptions`` or
|
||||
per-item ``statement`` even when the dynamic tool schema forbids them. Those
|
||||
fields are not executable and are not part of the frozen contract, so they
|
||||
should not abort a task before modeling starts.
|
||||
|
||||
The verifier ``expected`` payload is intentionally not sanitized here. It
|
||||
remains governed by the registry's strict per-claim schema because those
|
||||
values drive deterministic validation.
|
||||
"""
|
||||
value = canonical_json_object(raw_arguments_json)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
requirements = value.get("requirements")
|
||||
sanitized: dict[str, Any] = {}
|
||||
if isinstance(requirements, list):
|
||||
sanitized_requirements: list[Any] = []
|
||||
for requirement in requirements:
|
||||
if not isinstance(requirement, dict):
|
||||
sanitized_requirements.append(requirement)
|
||||
continue
|
||||
item: dict[str, Any] = {}
|
||||
if "assumptions" in requirement:
|
||||
item["assumptions"] = requirement["assumptions"]
|
||||
if "acceptance_claims" in requirement:
|
||||
claims = requirement["acceptance_claims"]
|
||||
if isinstance(claims, list):
|
||||
item["acceptance_claims"] = [
|
||||
{key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim}
|
||||
if isinstance(claim, dict) else claim
|
||||
for claim in claims
|
||||
]
|
||||
else:
|
||||
item["acceptance_claims"] = claims
|
||||
sanitized_requirements.append(item)
|
||||
sanitized["requirements"] = sanitized_requirements
|
||||
else:
|
||||
sanitized["requirements"] = requirements
|
||||
return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]:
|
||||
schema = StatelessNextAction.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["operation"] = {"enum": atomic_ids}
|
||||
return schema
|
||||
|
||||
|
||||
def stateless_final_review_schema(visual_claim_count: int) -> dict[str, Any]:
|
||||
schema = StatelessFinalReview.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
visual = properties.get("visual_claims") if isinstance(properties, dict) else None
|
||||
if isinstance(visual, dict):
|
||||
visual["minItems"] = visual_claim_count
|
||||
visual["maxItems"] = visual_claim_count
|
||||
return schema
|
||||
|
||||
|
||||
def stateless_rollback_checkpoint_schema(checkpoint_tokens: list[str]) -> dict[str, Any]:
|
||||
schema = StatelessRollbackCheckpoint.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
|
||||
return schema
|
||||
|
||||
|
||||
def topology_request_schema(working_head: str) -> dict[str, Any]:
|
||||
schema = TopologyRequest.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["working_head"] = {"const": working_head}
|
||||
return schema
|
||||
|
||||
|
||||
def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]:
|
||||
"""Bind a rollback request to immutable checkpoints in the active lineage."""
|
||||
schema = RollbackCheckpoint.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["working_head"] = {"const": working_head}
|
||||
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
|
||||
return schema
|
||||
|
||||
|
||||
def _bind_claim_coverage_ids(schema: dict[str, Any], claim_ids: list[str]) -> None:
|
||||
definitions = schema.get("$defs", {})
|
||||
coverage = definitions.get("ClaimCoverage") if isinstance(definitions, dict) else None
|
||||
if not isinstance(coverage, dict):
|
||||
return
|
||||
properties = coverage.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["claim_id"] = {"enum": claim_ids}
|
||||
|
||||
|
||||
Dto = TypeVar("Dto", bound=StrictDto)
|
||||
|
||||
|
||||
def raw_arguments_hash(raw_arguments_json: str) -> str:
|
||||
return hashlib.sha256(raw_arguments_json.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def json_depth(value: Any, current: int = 0) -> int:
|
||||
if isinstance(value, dict):
|
||||
return max([current, *(json_depth(item, current + 1) for item in value.values())])
|
||||
if isinstance(value, list):
|
||||
return max([current, *(json_depth(item, current + 1) for item in value)])
|
||||
return current
|
||||
|
||||
|
||||
def canonical_json_object(raw_arguments_json: str, *, max_bytes: int = 48_000, max_depth: int = 16) -> dict[str, Any] | WorkflowError:
|
||||
"""Bound and parse raw arguments before any schema-specific validation."""
|
||||
if len(raw_arguments_json.encode("utf-8")) > max_bytes:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the byte limit.")
|
||||
try:
|
||||
value = json.loads(raw_arguments_json)
|
||||
except json.JSONDecodeError as error:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments are not valid JSON.", field_errors=({"path": "/", "message": error.msg},))
|
||||
if not isinstance(value, dict):
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must be a JSON object.")
|
||||
if json_depth(value) > max_depth:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the nesting-depth limit.")
|
||||
if _contains_non_finite_number(value):
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must not contain NaN or infinite numbers.")
|
||||
return value
|
||||
|
||||
|
||||
def _contains_non_finite_number(value: Any) -> bool:
|
||||
if isinstance(value, float):
|
||||
return not math.isfinite(value)
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_non_finite_number(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_non_finite_number(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def canonical_validate_schema(raw_arguments_json: str, schema: dict[str, Any]) -> WorkflowError | None:
|
||||
"""Revalidate raw arguments against the current dynamic JSON Schema."""
|
||||
value = canonical_json_object(raw_arguments_json)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
errors = [
|
||||
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
|
||||
for error in sorted(Draft202012Validator(schema).iter_errors(value), key=lambda item: (list(item.absolute_path), item.message))
|
||||
]
|
||||
if errors:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the active dynamic schema.", field_errors=tuple(errors))
|
||||
return None
|
||||
|
||||
|
||||
def canonical_validate(raw_arguments_json: str, model: type[Dto], *, max_bytes: int = 48_000, max_depth: int = 16) -> Dto | WorkflowError:
|
||||
"""Parse raw tool arguments once and return field-level DTO failures safely."""
|
||||
value = canonical_json_object(raw_arguments_json, max_bytes=max_bytes, max_depth=max_depth)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
try:
|
||||
return model.model_validate(value)
|
||||
except ValidationError as error:
|
||||
fields = tuple({"path": "/" + "/".join(str(part) for part in issue["loc"]), "message": issue["msg"]} for issue in error.errors())
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the canonical schema.", field_errors=fields)
|
||||
|
||||
|
||||
def validate_one_tool_call(tool_calls: list[dict[str, Any]], allowed_name: str) -> tuple[str, str] | WorkflowError:
|
||||
"""Require exactly one known tool call; no provider parser is trusted."""
|
||||
if len(tool_calls) != 1:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Exactly one tool call is required.")
|
||||
function = tool_calls[0].get("function") if isinstance(tool_calls[0], dict) else None
|
||||
name = str(function.get("name") or "") if isinstance(function, dict) else ""
|
||||
raw = str(function.get("arguments") or "") if isinstance(function, dict) else ""
|
||||
if name != allowed_name:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "The returned tool is not allowed in this workflow state.", details={"expected_tool": allowed_name, "actual_tool": name})
|
||||
return name, raw
|
||||
@@ -1,670 +0,0 @@
|
||||
"""Immutable Markdown-first requirements artifacts and compiled contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
|
||||
from app.cad_agent.application.results import Accepted, Rejected
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
|
||||
from app.cad_agent.domain.verifier_registry import VerifierRegistry
|
||||
from app.cad_agent.ports import ArtifactStore, TaskRepository
|
||||
|
||||
|
||||
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
|
||||
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
|
||||
_CENTERED_BORE_MARKERS = ("centered", "concentric", "coaxial", "中心", "同心", "同轴")
|
||||
_BORE_MARKERS = ("bore", "hole", "孔")
|
||||
_OBROUND_SLOT_MARKERS = ("oblong", "slot", "slotted", "腰形", "长圆", "调节槽")
|
||||
|
||||
|
||||
class RequirementsCommandHandler:
|
||||
"""Persist frozen documents and compile their checklist into a contract.
|
||||
|
||||
The model never names targets or internal objects during compilation. The
|
||||
service derives those values strictly from the immutable checklist.
|
||||
"""
|
||||
|
||||
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry, *, atomic_ids: Callable[[], tuple[str, ...]] | None = None) -> None:
|
||||
self.repository = repository
|
||||
self.artifacts = artifacts
|
||||
self.registry = registry
|
||||
self.atomic_ids = atomic_ids or (lambda: ())
|
||||
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
|
||||
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
|
||||
|
||||
def register_evaluation_contract_oracle(self, task_id: str, required_claims: list[dict[str, Any]], *, validation_capability_gaps: list[dict[str, Any]] | None = None) -> None:
|
||||
self._evaluation_contract_oracles[task_id] = deepcopy(required_claims)
|
||||
self._evaluation_capability_gaps[task_id] = [
|
||||
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
|
||||
for item in validation_capability_gaps or () if isinstance(item, dict)
|
||||
]
|
||||
|
||||
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
|
||||
claims = self._evaluation_contract_oracles.get(task_id)
|
||||
return None if claims is None else {
|
||||
"evaluation_only": True,
|
||||
"required_claims": deepcopy(claims),
|
||||
"known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def document_schema() -> dict[str, Any]:
|
||||
return MarkdownDocument.model_json_schema()
|
||||
|
||||
def compiler_schema(self, task_id: str) -> dict[str, Any]:
|
||||
return compiled_requirements_schema(
|
||||
self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS),
|
||||
len(self._checklist_items(task_id)),
|
||||
)
|
||||
|
||||
def feature_plan_schema(self, task_id: str) -> dict[str, Any]:
|
||||
"""Return the plan tool schema bound to the persisted planning state.
|
||||
|
||||
A model is allowed to choose node content, but it must not guess the
|
||||
immutable lineage identifiers of a plan revision. Binding those values
|
||||
as enums prevents a requirements-contract hash (or a stale plan hash)
|
||||
from being mistaken for ``parent_plan_hash``.
|
||||
"""
|
||||
schema = FeaturePlan.model_json_schema()
|
||||
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
||||
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), dict) else {}
|
||||
node = definitions.get("FeatureNode") if isinstance(definitions, dict) else None
|
||||
node_properties = node.get("properties") if isinstance(node, dict) else None
|
||||
atomic = node_properties.get("atomic_id") if isinstance(node_properties, dict) else None
|
||||
if isinstance(atomic, dict):
|
||||
atomic["enum"] = list(self.atomic_ids())
|
||||
state = self.repository.get_state(task_id)
|
||||
previous: FeaturePlan | None = None
|
||||
if state is not None and state.feature_plan_path:
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
previous = FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
previous = None
|
||||
parent_hash = plan_hash(previous) if previous is not None else ""
|
||||
replacements = sorted(self._required_replacements(previous, task_id)) if previous is not None else []
|
||||
parent = properties.get("parent_plan_hash") if isinstance(properties, dict) else None
|
||||
if isinstance(parent, dict):
|
||||
parent["enum"] = [parent_hash]
|
||||
replaced = properties.get("replaces_node_ids") if isinstance(properties, dict) else None
|
||||
if isinstance(replaced, dict):
|
||||
replaced.update({
|
||||
"type": "array",
|
||||
"uniqueItems": True,
|
||||
"minItems": len(replacements),
|
||||
"maxItems": len(replacements),
|
||||
"items": {"enum": replacements},
|
||||
})
|
||||
contract = self.artifacts.read_requirements_contract(
|
||||
task_id,
|
||||
state.requirements_contract_path if state is not None else "",
|
||||
) or {}
|
||||
deterministic_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") == "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
visual_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
node_claim_ids = node_properties.get("claim_ids") if isinstance(node_properties, dict) else None
|
||||
if isinstance(node_claim_ids, dict):
|
||||
# Authors occasionally repeat a visual claim on the node that
|
||||
# creates the feature as well as in final_claim_ids. Accept that
|
||||
# harmless reference at the tool boundary; submit_feature_plan()
|
||||
# removes it before the immutable DAG is validated and written.
|
||||
node_claim_ids["items"] = {"enum": [*deterministic_claim_ids, *visual_claim_ids]}
|
||||
final_claim_ids = properties.get("final_claim_ids") if isinstance(properties, dict) else None
|
||||
if isinstance(final_claim_ids, dict):
|
||||
final_claim_ids["items"] = {"enum": visual_claim_ids}
|
||||
final_claim_ids["maxItems"] = len(visual_claim_ids)
|
||||
return schema
|
||||
|
||||
def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document)
|
||||
|
||||
def submit_completion_target(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target)
|
||||
|
||||
def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
# Compatibility shim for callers compiled against v3.1. The v3.2
|
||||
# coordinator never offers this method to an LLM.
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.COMPILING_FEATURE_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
|
||||
|
||||
def submit_feature_plan(self, task_id: str, plan: FeaturePlan, *, invocation_id: str) -> Accepted | Rejected:
|
||||
"""Freeze a validated initial plan or full subgraph plan revision."""
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase not in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature plan is not expected in the current workflow phase."))
|
||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
||||
if not isinstance(contract, dict):
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements contract is unavailable for feature planning."))
|
||||
plan = self._normalize_feature_plan_visual_references(plan, contract)
|
||||
plan = self._assign_unowned_global_health_claims(plan, contract)
|
||||
previous: FeaturePlan | None = None
|
||||
completed: dict[str, str] = {}
|
||||
if state.feature_plan_path:
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
previous = FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan artifact is invalid.", retryable=True))
|
||||
completed = FeatureScheduler(previous, self.repository.ledger_events(task_id)).completed_node_hashes()
|
||||
required_replacements = self._required_replacements(previous, task_id) if previous is not None else set()
|
||||
errors = validate_feature_plan(plan, contract, self.atomic_ids(), previous_plan=previous, completed_node_hashes=completed, required_replacements=required_replacements)
|
||||
if errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan does not satisfy the frozen contract.", field_errors=tuple(errors)))
|
||||
digest = plan_hash(plan)
|
||||
path = f"plans/feature-plan-{digest}.json"
|
||||
event = "feature_plan_written" if previous is None else "feature_plan_revised"
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, plan.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
try:
|
||||
written = self.artifacts.write_json_once(task_id, path, plan.model_dump(mode="json"))
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
next_state = transition(state, event, feature_plan_path=written, feature_plan_hash=digest)
|
||||
result = Accepted({"phase": next_state.phase.value, "path": written, "plan_hash": digest, "node_count": len(plan.nodes)})
|
||||
events: list[dict[str, Any]] = [{
|
||||
"event": event,
|
||||
"invocation_id": invocation_id,
|
||||
"plan_path": written,
|
||||
"plan_hash": digest,
|
||||
"parent_plan_hash": plan.parent_plan_hash,
|
||||
"replaces_node_ids": plan.replaces_node_ids,
|
||||
}]
|
||||
if previous is not None:
|
||||
old_nodes = {node.node_id: node for node in previous.nodes}
|
||||
old_hash = plan_hash(previous)
|
||||
events.extend({
|
||||
"event": "feature_node_invalidated",
|
||||
"node_id": node_id,
|
||||
"node_hash": node_hash(old_nodes[node_id]),
|
||||
"plan_hash": old_hash,
|
||||
"replacement_plan_hash": digest,
|
||||
} for node_id in plan.replaces_node_ids)
|
||||
if not self._commit(next_state, events, invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _normalize_feature_plan_visual_references(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
||||
"""Drop non-owning visual references from feature nodes.
|
||||
|
||||
A node's ``claim_ids`` drive synchronous deterministic acceptance.
|
||||
Visual claims are owned solely by ``final_claim_ids`` and have no
|
||||
node-local verifier. Retaining a repeated visual ID therefore adds
|
||||
no behavior and turns an otherwise valid plan into a schema retry.
|
||||
The contract validation below still requires every visual claim to be
|
||||
present exactly once in ``final_claim_ids``.
|
||||
"""
|
||||
visual_claim_ids = {
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
}
|
||||
if not visual_claim_ids or not any(
|
||||
claim_id in visual_claim_ids
|
||||
for node in plan.nodes
|
||||
for claim_id in node.claim_ids
|
||||
):
|
||||
return plan
|
||||
normalized = plan.model_copy(deep=True)
|
||||
for node in normalized.nodes:
|
||||
node.claim_ids = [claim_id for claim_id in node.claim_ids if claim_id not in visual_claim_ids]
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _assign_unowned_global_health_claims(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
||||
"""Bind global solid-health claims to the unique root body feature.
|
||||
|
||||
``single_connected_body`` and ``solid_count_equals`` are checked as
|
||||
global health on every feature checkpoint. When a plan has exactly
|
||||
one root additive feature, their node owner is consequently
|
||||
determined without choosing any geometry strategy. This prevents an
|
||||
otherwise complete plan from failing merely because an author omitted
|
||||
the redundant ownership annotation.
|
||||
"""
|
||||
claims = {
|
||||
str(claim.get("claim_id") or ""): str(claim.get("claim_kind") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str)
|
||||
}
|
||||
assigned = {claim_id for node in plan.nodes for claim_id in node.claim_ids}
|
||||
unowned = [
|
||||
claim_id for claim_id, claim_kind in claims.items()
|
||||
if claim_id not in assigned and claim_kind in {"single_connected_body", "solid_count_equals"}
|
||||
]
|
||||
roots = [
|
||||
node for node in plan.nodes
|
||||
if not node.depends_on and node.atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add", "sphere_add"}
|
||||
]
|
||||
if not unowned or len(roots) != 1:
|
||||
return plan
|
||||
normalized = plan.model_copy(deep=True)
|
||||
root_id = roots[0].node_id
|
||||
for node in normalized.nodes:
|
||||
if node.node_id == root_id:
|
||||
node.claim_ids = [*node.claim_ids, *unowned]
|
||||
break
|
||||
return normalized
|
||||
|
||||
def _required_replacements(self, plan: FeaturePlan, task_id: str) -> set[str]:
|
||||
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
||||
statuses = scheduler.statuses()
|
||||
failed = {node_id for node_id, status in statuses.items() if status == "failed"}
|
||||
if not failed:
|
||||
return set()
|
||||
children: dict[str, set[str]] = {node.node_id: set() for node in plan.nodes}
|
||||
for node in plan.nodes:
|
||||
for dependency in node.depends_on:
|
||||
children.setdefault(dependency, set()).add(node.node_id)
|
||||
result = set(failed)
|
||||
pending = list(failed)
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
for child in children.get(current, set()):
|
||||
if statuses.get(child) != "done" and child not in result:
|
||||
result.add(child)
|
||||
pending.append(child)
|
||||
return result
|
||||
|
||||
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected:
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != TaskPhase.COMPILING_REQUIREMENTS:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase."))
|
||||
targets = self._checklist_items(task_id)
|
||||
if len(output.requirements) != len(targets):
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},)))
|
||||
normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets)
|
||||
field_errors = [
|
||||
*self._claim_errors(normalized_output),
|
||||
*self._relationship_claim_errors(normalized_output, targets),
|
||||
]
|
||||
if field_errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation contains an unreadable or non-executable acceptance target.", field_errors=tuple(field_errors)))
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_compilation", state.working_head, normalized_output.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
source_ids = list(self.artifacts.read_source_index(task_id))
|
||||
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
|
||||
warnings = [
|
||||
*[str(value) for value in observation.get("uncertainties") or () if str(value)],
|
||||
*compiler_warnings,
|
||||
]
|
||||
requirements: list[dict[str, Any]] = []
|
||||
claim_position = 1
|
||||
for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1):
|
||||
claims: list[dict[str, Any]] = []
|
||||
for claim in compiled.acceptance_claims:
|
||||
definition = self.registry.definition(claim.claim_kind)
|
||||
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"})
|
||||
claim_position += 1
|
||||
requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims})
|
||||
spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]}
|
||||
contract = {"schema_version": "cad.requirements-contract.v3.2", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
|
||||
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
try:
|
||||
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
|
||||
contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id)
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path)
|
||||
result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)})
|
||||
if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
def write_completion_result(self, task_id: str, state: TaskState, *, claim_results: list[dict[str, Any]], review: dict[str, Any]) -> str:
|
||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
|
||||
by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)}
|
||||
visual = iter(review.get("visual_claims") or ())
|
||||
rows = ["# Completion Result", "", "## Checklist", ""]
|
||||
for requirement in contract.get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
statuses: list[str] = []
|
||||
evidence: list[str] = []
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if not isinstance(claim, dict):
|
||||
continue
|
||||
result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {})
|
||||
statuses.append(str(result.get("status") or "unknown"))
|
||||
value = result.get("evidence")
|
||||
if value:
|
||||
evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True))
|
||||
rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}")
|
||||
rows.extend(f" - Evidence: {value}" for value in evidence)
|
||||
return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n")
|
||||
|
||||
def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, str]]]) -> Accepted | Rejected:
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != phase:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase."))
|
||||
errors = validator(document.markdown)
|
||||
if errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors)))
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
try:
|
||||
written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n")
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written}
|
||||
next_state = transition(state, event, **kwargs)
|
||||
result = Accepted({"phase": next_state.phase.value, "path": written})
|
||||
if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
def _checklist_items(self, task_id: str) -> list[str]:
|
||||
path = self.artifacts.task_dir(task_id) / "completion-target.md"
|
||||
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))]
|
||||
|
||||
def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]:
|
||||
errors: list[dict[str, str]] = []
|
||||
for requirement_index, requirement in enumerate(output.requirements):
|
||||
for claim_index, claim in enumerate(requirement.acceptance_claims):
|
||||
try:
|
||||
messages = self.registry.validate_expected(claim.claim_kind, claim.expected)
|
||||
except ValueError:
|
||||
messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
|
||||
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def _relationship_claim_errors(output: CompiledRequirementsSpec, targets: list[str]) -> list[dict[str, str]]:
|
||||
"""Require explicit coverage for an unambiguous centered-bore target.
|
||||
|
||||
The compiler remains free to choose claims for open-ended CAD prose.
|
||||
A checklist item that explicitly says a bore is centered/concentric is
|
||||
different: dropping that relationship leaves a measurable user fact
|
||||
with no acceptance owner. The service only checks its presence here;
|
||||
the registry validates its numeric parameters independently.
|
||||
"""
|
||||
errors: list[dict[str, str]] = []
|
||||
for index, (target, requirement) in enumerate(zip(targets, output.requirements, strict=True)):
|
||||
lowered = target.casefold()
|
||||
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
||||
continue
|
||||
if not any(marker in lowered for marker in _BORE_MARKERS):
|
||||
continue
|
||||
if any(
|
||||
claim.claim_kind == "concentric_bore_to_outer_cylinder"
|
||||
for claim in requirement.acceptance_claims
|
||||
):
|
||||
continue
|
||||
errors.append({
|
||||
"path": f"/requirements/{index}/acceptance_claims",
|
||||
"message": "A centered or concentric bore requires concentric_bore_to_outer_cylinder coverage.",
|
||||
})
|
||||
return errors
|
||||
|
||||
def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]:
|
||||
normalized = output.model_copy(deep=True)
|
||||
warnings: list[str] = []
|
||||
for target, requirement in zip(targets, normalized.requirements, strict=True):
|
||||
normalized_claims = []
|
||||
for claim in requirement.acceptance_claims:
|
||||
if self._is_slot_misclassified_as_corner_bore_pattern(claim, target):
|
||||
warnings.append(
|
||||
f"rectangular_corner_through_bore_pattern for checklist item '{target}' describes an obround slot, not four circular bores, so it was compiled as visual review."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if self._is_unbacked_coaxial_bore_group(normalized, claim):
|
||||
warnings.append(
|
||||
f"coaxial_through_bore_group for checklist item '{target}' has no matching multi-bore target, so it was compiled as visual review. "
|
||||
"Use concentric_bore_to_outer_cylinder for one central bore and one outer cylinder."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if claim.claim_kind in _RECORD_BOUND_CLAIMS:
|
||||
warnings.append(
|
||||
f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target):
|
||||
self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim)
|
||||
warnings.append(
|
||||
f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope."
|
||||
)
|
||||
continue
|
||||
claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected)
|
||||
normalized_claims.append(claim)
|
||||
requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "visual",
|
||||
"expected": {"description": target[:360]},
|
||||
})]
|
||||
self._derive_centered_bore_claims(normalized, targets)
|
||||
return normalized, list(dict.fromkeys(warnings))
|
||||
|
||||
@staticmethod
|
||||
def _is_slot_misclassified_as_corner_bore_pattern(claim: Any, target: str) -> bool:
|
||||
"""Keep a circular-hole verifier from accepting or rejecting a slot.
|
||||
|
||||
``rectangular_corner_through_bore_pattern`` measures four complete
|
||||
cylindrical bores at equal edge offsets. An obround slot has two arc
|
||||
ends and straight flanks; treating its stated length as an edge offset
|
||||
produces an unsatisfiable contract even when the CAD is correct.
|
||||
"""
|
||||
return (
|
||||
getattr(claim, "claim_kind", "") == "rectangular_corner_through_bore_pattern"
|
||||
and any(marker in target.casefold() for marker in _OBROUND_SLOT_MARKERS)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _derive_centered_bore_claims(output: CompiledRequirementsSpec, targets: list[str]) -> None:
|
||||
"""Attach a measurable concentricity claim when its inputs are frozen.
|
||||
|
||||
The requirements compiler receives a Markdown checklist, not runtime
|
||||
geometry IDs. Once it has already compiled an external cylindrical
|
||||
diameter and an explicitly centred bore diameter, their relationship
|
||||
is a service-owned mechanical consequence. Requiring an author to
|
||||
remember the internal verifier name makes a complete user request
|
||||
fail for a bookkeeping omission rather than a CAD decision.
|
||||
"""
|
||||
outer_diameters: list[float] = []
|
||||
for requirement in output.requirements:
|
||||
for claim in requirement.acceptance_claims:
|
||||
expected = claim.expected
|
||||
diameter = expected.get("diameter_mm") if isinstance(expected, dict) else None
|
||||
if claim.claim_kind == "outer_cylindrical_surface" and isinstance(diameter, (int, float)) and float(diameter) > 0:
|
||||
outer_diameters.append(float(diameter))
|
||||
if not outer_diameters:
|
||||
return
|
||||
outer_diameter = max(outer_diameters)
|
||||
bore_claim_kinds = frozenset({"through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth"})
|
||||
for target, requirement in zip(targets, output.requirements, strict=True):
|
||||
lowered = target.casefold()
|
||||
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
||||
continue
|
||||
if not any(marker in lowered for marker in _BORE_MARKERS):
|
||||
continue
|
||||
if any(claim.claim_kind == "concentric_bore_to_outer_cylinder" for claim in requirement.acceptance_claims):
|
||||
continue
|
||||
bore_diameter = next((
|
||||
float(claim.expected["diameter_mm"])
|
||||
for claim in requirement.acceptance_claims
|
||||
if claim.claim_kind in bore_claim_kinds
|
||||
and isinstance(claim.expected, dict)
|
||||
and isinstance(claim.expected.get("diameter_mm"), (int, float))
|
||||
and float(claim.expected["diameter_mm"]) > 0
|
||||
), None)
|
||||
if bore_diameter is None:
|
||||
continue
|
||||
requirement.acceptance_claims.append(AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "concentric_bore_to_outer_cylinder",
|
||||
"expected": {
|
||||
"bore_diameter_mm": bore_diameter,
|
||||
"outer_diameter_mm": outer_diameter,
|
||||
"tolerance_mm": 0.01,
|
||||
},
|
||||
}))
|
||||
|
||||
@staticmethod
|
||||
def _is_unbacked_coaxial_bore_group(output: CompiledRequirementsSpec, claim: Any) -> bool:
|
||||
"""Reject a bore-group verifier when the contract has no such group.
|
||||
|
||||
``coaxial_through_bore_group`` measures multiple inner bores of one
|
||||
diameter. It cannot prove a lone central bore is concentric with an
|
||||
external cylindrical wall. This is a mechanical consistency check:
|
||||
some through-bore claim must request at least the group count.
|
||||
"""
|
||||
if getattr(claim, "claim_kind", "") != "coaxial_through_bore_group":
|
||||
return False
|
||||
expected = getattr(claim, "expected", {})
|
||||
if not isinstance(expected, dict):
|
||||
return True
|
||||
diameter = expected.get("diameter_mm")
|
||||
count = expected.get("count")
|
||||
if not isinstance(diameter, (int, float)) or not isinstance(count, int):
|
||||
return True
|
||||
for requirement in output.requirements:
|
||||
for candidate in requirement.acceptance_claims:
|
||||
candidate_expected = getattr(candidate, "expected", {})
|
||||
if (
|
||||
getattr(candidate, "claim_kind", "") == "through_cylindrical_bore"
|
||||
and isinstance(candidate_expected, dict)
|
||||
and isinstance(candidate_expected.get("diameter_mm"), (int, float))
|
||||
and isinstance(candidate_expected.get("count"), int)
|
||||
and abs(float(candidate_expected["diameter_mm"]) - float(diameter)) <= 1e-9
|
||||
and int(candidate_expected["count"]) >= count
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool:
|
||||
if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z":
|
||||
return False
|
||||
if RequirementsCommandHandler._target_describes_finished_envelope(target):
|
||||
return False
|
||||
return any(
|
||||
getattr(item, "claim_kind", "") == "outer_cylindrical_surface"
|
||||
for item in claims
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _target_describes_finished_envelope(target: str) -> bool:
|
||||
lowered = target.lower()
|
||||
return any(token in lowered for token in (
|
||||
"overall",
|
||||
"total",
|
||||
"finished part",
|
||||
"entire part",
|
||||
"whole part",
|
||||
"bounding box",
|
||||
"envelope",
|
||||
"总",
|
||||
"整体",
|
||||
"成品",
|
||||
"全高",
|
||||
"包围盒",
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None:
|
||||
value = bbox_claim.expected.get("value")
|
||||
if not isinstance(value, (int, float)):
|
||||
return
|
||||
for item in claims:
|
||||
if getattr(item, "claim_kind", "") != "outer_cylindrical_surface":
|
||||
continue
|
||||
expected = getattr(item, "expected", None)
|
||||
if not isinstance(expected, dict) or "axial_span_mm" in expected:
|
||||
continue
|
||||
expected["axial_span_mm"] = value
|
||||
if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)):
|
||||
expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"]
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _validate_requirements_document(markdown: str) -> list[dict[str, str]]:
|
||||
# Markdown is a human-facing semantic artifact. Its content is frozen
|
||||
# verbatim and is not executable, so headings are guidance for the
|
||||
# author rather than a server-enforced protocol.
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _validate_completion_target(markdown: str) -> list[dict[str, str]]:
|
||||
values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))]
|
||||
errors: list[dict[str, str]] = []
|
||||
if not values:
|
||||
errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."})
|
||||
if len(values) != len(set(values)):
|
||||
errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."})
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]:
|
||||
return []
|
||||
|
||||
def _replay(self, task_id: str, invocation_id: str) -> Accepted | None:
|
||||
invocation = self.repository.get_invocation(task_id, invocation_id)
|
||||
return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None
|
||||
|
||||
@staticmethod
|
||||
def _restore(payload: dict[str, Any]) -> Accepted:
|
||||
return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload)
|
||||
|
||||
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool:
|
||||
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload})
|
||||
|
||||
@staticmethod
|
||||
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _stale() -> WorkflowError:
|
||||
return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.")
|
||||
|
||||
def _park_for_storage_retry(self, state: TaskState, message: str) -> Rejected:
|
||||
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
|
||||
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.STORAGE_FAILURE.value, "message": message[:1000]}])
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True))
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Typed results returned by all command handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.errors import WorkflowError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Accepted:
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rejected:
|
||||
error: WorkflowError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Waiting:
|
||||
error: WorkflowError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FailedInternal:
|
||||
correlation_id: str
|
||||
message: str
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Deterministic execution service for the single-stage Authoring protocol.
|
||||
|
||||
The service is deliberately synchronous and side-effect bounded so the HTTP
|
||||
workflow can call it after one author response (and at most two repairs).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.ports import AdapterUnavailable
|
||||
|
||||
class SingleStageExecutor:
|
||||
MAX_REPAIRS = 2
|
||||
|
||||
def __init__(self, repository: Any, artifacts: Any, runtime: Any) -> None:
|
||||
self.repository, self.artifacts, self.runtime = repository, artifacts, runtime
|
||||
|
||||
def compile(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
|
||||
"""Compile once and persist immutable compiler inputs and output."""
|
||||
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
|
||||
raise ValueError("repair budget exhausted")
|
||||
runtime_cdsl, audit = self.runtime.compile_authoring(authoring)
|
||||
digest = self._digest(authoring)
|
||||
attempt = repair_count + 1
|
||||
runtime_path = f"documents/runtime-cdsl-attempt-{attempt:02d}-{digest[:8]}.json"
|
||||
audit_path = f"documents/compile-audit-attempt-{attempt:02d}-{digest[:8]}.json"
|
||||
self.artifacts.write_json_once(task_id, runtime_path, runtime_cdsl)
|
||||
self.artifacts.write_json_once(task_id, audit_path, audit)
|
||||
return {"runtime": runtime_cdsl, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": digest}
|
||||
|
||||
def build(self, task_id: str, authoring: dict[str, Any], runtime_cdsl: dict[str, Any], audit: dict[str, Any], *, repair_count: int, digest: str = "") -> dict[str, Any]:
|
||||
"""Build one compiled CDSL document and publish any executable prefix."""
|
||||
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
|
||||
raise ValueError("repair budget exhausted")
|
||||
digest = digest or self._digest(authoring)
|
||||
stage = self.artifacts.start_staging_revision(task_id, f"single_{repair_count}_{digest}", {
|
||||
"schema_version": "cad.single-stage.v1", "authoring": authoring,
|
||||
"runtime": runtime_cdsl, "compile_audit": audit,
|
||||
})
|
||||
try:
|
||||
rebuilt, failures = self.runtime.rebuild_best_effort(runtime_cdsl, stage.output_dir, task_id, stage.stage_id)
|
||||
except (OSError, AdapterUnavailable):
|
||||
raise
|
||||
except Exception as error:
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
|
||||
"schema_version": "cad.build-diagnostics.v1",
|
||||
"diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}],
|
||||
})
|
||||
return {"status": "failed", "repair_count": repair_count, "diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}], "compile_audit": audit}
|
||||
diagnostics = self._decorate_diagnostics(failures, audit)
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
|
||||
"schema_version": "cad.build-diagnostics.v1",
|
||||
"diagnostics": diagnostics,
|
||||
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
|
||||
})
|
||||
if rebuilt:
|
||||
revision = f"rev_{digest}"
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "staging-manifest.json", {
|
||||
"schema_version": "cad.single-stage.staging-manifest.v1",
|
||||
"stage_id": stage.stage_id,
|
||||
"revision_id": revision,
|
||||
"repair_count": repair_count,
|
||||
"source_sha256": audit.get("source_sha256", ""),
|
||||
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
|
||||
})
|
||||
paths = self.artifacts.publish_staging_revision(task_id, stage.stage_id, revision)
|
||||
else:
|
||||
paths = {}
|
||||
return {"status": "published_best_effort" if diagnostics else "completed", "repair_count": repair_count, "paths": paths, "revision_id": revision if rebuilt else "", "executed_feature_ids": rebuilt.get("executed_feature_ids", []), "diagnostics": diagnostics, "compile_audit": audit}
|
||||
|
||||
def execute(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
|
||||
"""Compatibility convenience for direct callers and focused tests."""
|
||||
compiled = self.compile(task_id, authoring, repair_count=repair_count)
|
||||
return {
|
||||
**self.build(task_id, authoring, compiled["runtime"], compiled["compile_audit"], repair_count=repair_count, digest=compiled["digest"]),
|
||||
"runtime_path": compiled["runtime_path"], "audit_path": compiled["audit_path"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _digest(authoring: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(authoring, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
return sha256(encoded).hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def _decorate_diagnostics(failures: list[dict[str, Any]], audit: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
names = {
|
||||
str(feature_id): str(name)
|
||||
for name, feature_id in (audit.get("feature_ids") or {}).items()
|
||||
if isinstance(name, str) and isinstance(feature_id, str)
|
||||
}
|
||||
result: list[dict[str, Any]] = []
|
||||
for failure in failures:
|
||||
if not isinstance(failure, dict):
|
||||
continue
|
||||
feature_id = str(failure.get("feature_id") or "")
|
||||
result.append({**failure, "feature_name": names.get(feature_id, "")})
|
||||
return result
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
"""Protocol v3 composition root. This is the only layer joining adapters."""
|
||||
"""Single-stage Authoring CDSL composition root."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,70 +6,58 @@ from dataclasses import dataclass
|
||||
import shutil
|
||||
|
||||
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
||||
from app.cad_agent.adapters.author_guidance import FileAuthorGuidance
|
||||
from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
|
||||
from app.cad_agent.adapters.runtime import ProfileCadRuntime
|
||||
from app.cad_agent.adapters.review_gateway import RenderedReviewGateway
|
||||
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
|
||||
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
|
||||
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
|
||||
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
||||
from app.cad_agent.application.outbox import OutboxDispatcher
|
||||
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.settings import BACKEND_ROOT, Settings
|
||||
from app.cad_agent.application.workflow import WorkflowConfig, WorkflowCoordinator
|
||||
from app.cad_agent.application.single_stage import SingleStageExecutor
|
||||
from app.settings import Settings
|
||||
from app.services.storage import WorkspaceStore
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class V3Services:
|
||||
class CadServices:
|
||||
repository: SqliteTaskRepository
|
||||
artifacts: FileArtifactStore
|
||||
workflow: WorkflowCoordinator
|
||||
models: StructuredModelGateway
|
||||
outbox: OutboxDispatcher
|
||||
single_stage: SingleStageExecutor
|
||||
|
||||
|
||||
def compose_v3(settings: Settings) -> V3Services:
|
||||
repository = SqliteTaskRepository(settings.task_root.parent / "autonomous-cad-v3.sqlite3")
|
||||
if repository.protocol_reset and settings.task_root.exists():
|
||||
# Protocol 3.1 has no valid interpretation for structured-only task
|
||||
# artifacts, so clear that task root together with its old database.
|
||||
def compose_cad_services(settings: Settings) -> CadServices:
|
||||
database_root = settings.task_root.parent
|
||||
legacy_database = database_root / "autonomous-cad-v3.sqlite3"
|
||||
removed_legacy_database = False
|
||||
if legacy_database.exists():
|
||||
# The removed coordinator persisted incompatible task/action state in
|
||||
# its own database. Delete it as part of the deliberate destructive
|
||||
# migration, including SQLite sidecars if a worker stopped mid-write.
|
||||
for candidate in (legacy_database, *(database_root / f"{legacy_database.name}{suffix}" for suffix in ("-wal", "-shm"))):
|
||||
if candidate.exists():
|
||||
candidate.unlink()
|
||||
removed_legacy_database = True
|
||||
repository = SqliteTaskRepository(database_root / "autonomous-cad-single-stage.sqlite3")
|
||||
protocol_reset = repository.protocol_reset or removed_legacy_database
|
||||
if protocol_reset and settings.task_root.exists():
|
||||
# Old task artifacts have no valid interpretation under the Authoring
|
||||
# protocol, so clear them together with the task database.
|
||||
shutil.rmtree(settings.task_root)
|
||||
if repository.protocol_reset:
|
||||
if protocol_reset:
|
||||
WorkspaceStore(settings).clear_current_task_references()
|
||||
artifacts = FileArtifactStore(settings.task_root)
|
||||
runtime = ProfileCadRuntime(settings)
|
||||
registry = default_registry()
|
||||
verifier = RegistryVerifierExecutor(registry)
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids)
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, verifier)
|
||||
fallbacks = tuple(
|
||||
ModelIdentity(provider.id, model.id)
|
||||
for provider in settings.providers
|
||||
if provider.configured
|
||||
for model in provider.models[:1]
|
||||
)
|
||||
models = StructuredModelGateway(settings)
|
||||
outbox = OutboxDispatcher(repository, IdempotentInProcessPublisher())
|
||||
single_stage = SingleStageExecutor(repository, artifacts, runtime)
|
||||
workflow = WorkflowCoordinator(
|
||||
WorkflowConfig(
|
||||
max_turns=max(8, settings.agent_tool_calls_per_cycle * 8),
|
||||
format_error_limit=settings.agent_format_error_repeat_limit,
|
||||
author_fallbacks=fallbacks,
|
||||
),
|
||||
WorkflowConfig(),
|
||||
repository,
|
||||
artifacts,
|
||||
runtime,
|
||||
models,
|
||||
RenderedReviewGateway(models),
|
||||
requirements,
|
||||
actions,
|
||||
FileAuthorGuidance(
|
||||
BACKEND_ROOT / "agent" / "skills" / "cdsl-author-guidance",
|
||||
enabled=settings.agent_author_guidance_enabled,
|
||||
max_chars=settings.agent_author_guidance_max_chars,
|
||||
),
|
||||
single_stage,
|
||||
)
|
||||
return V3Services(repository, artifacts, workflow, models, outbox)
|
||||
return CadServices(repository, artifacts, workflow, models, outbox, single_stage)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Pure domain objects and policies for protocol v3."""
|
||||
"""Pure domain objects and policies for the Authoring CDSL protocol."""
|
||||
|
||||
from .errors import ErrorCode, WorkflowError
|
||||
from .state import TaskPhase, TaskState, transition
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"""Canonical partial matching for acceptance-claim expectations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def contains_expected(actual: Any, required: Any) -> bool:
|
||||
"""Allow an actual claim to add provider-chosen optional fields only."""
|
||||
if isinstance(required, dict):
|
||||
return isinstance(actual, dict) and all(
|
||||
key in actual and contains_expected(actual[key], value)
|
||||
for key, value in required.items()
|
||||
)
|
||||
if isinstance(required, list):
|
||||
return isinstance(actual, list) and len(actual) == len(required) and all(
|
||||
contains_expected(actual_item, required_item)
|
||||
for actual_item, required_item in zip(actual, required, strict=True)
|
||||
)
|
||||
return actual == required
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Typed, serializable failures used across the v3 workflow."""
|
||||
"""Typed, serializable failures used across the single-stage workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,29 +9,25 @@ from typing import Any
|
||||
|
||||
class ErrorCode(StrEnum):
|
||||
CANCELLED = "CANCELLED"
|
||||
AUTHOR_FORMAT_INVALID = "AUTHOR_FORMAT_INVALID"
|
||||
AUTHOR_DECISION_REJECTED = "AUTHOR_DECISION_REJECTED"
|
||||
STALE_WORKING_HEAD = "STALE_WORKING_HEAD"
|
||||
FAILED_AUTHOR_FORMAT = "FAILED_AUTHOR_FORMAT"
|
||||
RUNTIME_PRECONDITION_FAILED = "RUNTIME_PRECONDITION_FAILED"
|
||||
RUNTIME_CONTRACT_INVALID = "RUNTIME_CONTRACT_INVALID"
|
||||
CANDIDATE_BUILD_FAILED = "CANDIDATE_BUILD_FAILED"
|
||||
CANDIDATE_REVIEW_REJECTED = "CANDIDATE_REVIEW_REJECTED"
|
||||
VERIFIER_UNAVAILABLE = "VERIFIER_UNAVAILABLE"
|
||||
CLAIM_VERIFICATION_FAILED = "CLAIM_VERIFICATION_FAILED"
|
||||
MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED"
|
||||
MODEL_PROTOCOL_CHECK_PENDING = "MODEL_PROTOCOL_CHECK_PENDING"
|
||||
AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE"
|
||||
REVIEW_SERVICE_UNAVAILABLE = "REVIEW_SERVICE_UNAVAILABLE"
|
||||
RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE"
|
||||
STORAGE_FAILURE = "STORAGE_FAILURE"
|
||||
CALL_BUDGET_EXHAUSTED = "CALL_BUDGET_EXHAUSTED"
|
||||
REQUIREMENTS_SPEC_INVALID = "REQUIREMENTS_SPEC_INVALID"
|
||||
NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT"
|
||||
RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE"
|
||||
BEST_EFFORT_COMPLETED = "BEST_EFFORT_COMPLETED"
|
||||
FAILED_INTERNAL = "FAILED_INTERNAL"
|
||||
WAITING_FOR_USER = "WAITING_FOR_USER"
|
||||
AUTHOR_FORBIDDEN_FIELD = "AUTHOR_FORBIDDEN_FIELD"
|
||||
AUTHOR_SCHEMA_INVALID = "AUTHOR_SCHEMA_INVALID"
|
||||
AUTHOR_REFERENCE_INVALID = "AUTHOR_REFERENCE_INVALID"
|
||||
AUTHOR_CYCLE = "AUTHOR_CYCLE"
|
||||
OPERATION_UNSUPPORTED = "OPERATION_UNSUPPORTED"
|
||||
SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND"
|
||||
SELECTOR_AMBIGUOUS = "SELECTOR_AMBIGUOUS"
|
||||
SELECTOR_DEPENDENCY_UNAVAILABLE = "SELECTOR_DEPENDENCY_UNAVAILABLE"
|
||||
SELECTOR_KIND_MISMATCH = "SELECTOR_KIND_MISMATCH"
|
||||
ENGINE_EXECUTION_FAILED = "ENGINE_EXECUTION_FAILED"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"""Immutable feature-DAG planning contracts and deterministic scheduling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class _StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
class FeatureNode(_StrictModel):
|
||||
node_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
|
||||
priority: int = Field(ge=0, le=100_000)
|
||||
intent: str = Field(min_length=1, max_length=360)
|
||||
atomic_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
|
||||
depends_on: list[str] = Field(default_factory=list, max_length=64)
|
||||
claim_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
expected_change: str = Field(min_length=1, max_length=360)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _unique_references(self) -> "FeatureNode":
|
||||
if len(self.depends_on) != len(set(self.depends_on)):
|
||||
raise ValueError("depends_on must not contain duplicates")
|
||||
if len(self.claim_ids) != len(set(self.claim_ids)):
|
||||
raise ValueError("claim_ids must not contain duplicates")
|
||||
if self.node_id in self.depends_on:
|
||||
raise ValueError("a feature node cannot depend on itself")
|
||||
return self
|
||||
|
||||
|
||||
class FeaturePlan(_StrictModel):
|
||||
schema_version: str = Field(pattern=r"^cad\.v3\.2\.feature-plan\.v1$")
|
||||
parent_plan_hash: str = Field(default="", pattern=r"^(|[a-f0-9]{64})$")
|
||||
replaces_node_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
nodes: list[FeatureNode] = Field(min_length=1, max_length=256)
|
||||
final_claim_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _unique_plan_fields(self) -> "FeaturePlan":
|
||||
node_ids = [node.node_id for node in self.nodes]
|
||||
priorities = [node.priority for node in self.nodes]
|
||||
if len(node_ids) != len(set(node_ids)):
|
||||
raise ValueError("node_id values must be unique")
|
||||
if len(priorities) != len(set(priorities)):
|
||||
raise ValueError("priority values must be unique")
|
||||
if len(self.replaces_node_ids) != len(set(self.replaces_node_ids)):
|
||||
raise ValueError("replaces_node_ids must not contain duplicates")
|
||||
if len(self.final_claim_ids) != len(set(self.final_claim_ids)):
|
||||
raise ValueError("final_claim_ids must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
def plan_hash(plan: FeaturePlan | dict[str, Any]) -> str:
|
||||
payload = plan.model_dump(mode="json") if isinstance(plan, FeaturePlan) else plan
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def node_hash(node: FeatureNode | dict[str, Any]) -> str:
|
||||
payload = node.model_dump(mode="json") if isinstance(node, FeatureNode) else node
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def contract_claims(contract: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for requirement in contract.get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str):
|
||||
result[claim["claim_id"]] = claim
|
||||
return result
|
||||
|
||||
|
||||
def validate_feature_plan(
|
||||
plan: FeaturePlan,
|
||||
contract: dict[str, Any],
|
||||
atomic_ids: set[str] | frozenset[str] | tuple[str, ...],
|
||||
*,
|
||||
previous_plan: FeaturePlan | None = None,
|
||||
completed_node_hashes: dict[str, str] | None = None,
|
||||
required_replacements: set[str] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Return stable, tool-facing validation errors for a frozen plan."""
|
||||
errors: list[dict[str, str]] = []
|
||||
nodes = {node.node_id: node for node in plan.nodes}
|
||||
known_atoms = set(atomic_ids)
|
||||
claims = contract_claims(contract)
|
||||
assigned: dict[str, str] = {}
|
||||
|
||||
for index, node in enumerate(plan.nodes):
|
||||
prefix = f"/nodes/{index}"
|
||||
if node.atomic_id not in known_atoms:
|
||||
errors.append({"path": f"{prefix}/atomic_id", "message": "atomic_id is not supported by the runtime"})
|
||||
for dependency in node.depends_on:
|
||||
if dependency not in nodes:
|
||||
errors.append({"path": f"{prefix}/depends_on", "message": f"unknown dependency '{dependency}'"})
|
||||
for claim_id in node.claim_ids:
|
||||
claim = claims.get(claim_id)
|
||||
if claim is None:
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
|
||||
continue
|
||||
if claim.get("verification_mode") != "deterministic":
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"visual claim '{claim_id}' belongs in final_claim_ids"})
|
||||
owner = assigned.setdefault(claim_id, node.node_id)
|
||||
if owner != node.node_id:
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"claim '{claim_id}' is already owned by '{owner}'"})
|
||||
|
||||
final = set(plan.final_claim_ids)
|
||||
for claim_id in plan.final_claim_ids:
|
||||
claim = claims.get(claim_id)
|
||||
if claim is None:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
|
||||
elif claim.get("verification_mode") == "deterministic":
|
||||
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' must belong to one node"})
|
||||
|
||||
for claim_id, claim in claims.items():
|
||||
deterministic = claim.get("verification_mode") == "deterministic"
|
||||
if deterministic and claim_id not in assigned:
|
||||
errors.append({"path": "/nodes", "message": f"deterministic claim '{claim_id}' has no owner"})
|
||||
if not deterministic and claim_id not in final:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"visual claim '{claim_id}' has no final-review owner"})
|
||||
if deterministic and claim_id in final:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' has two owners"})
|
||||
|
||||
errors.extend(_cycle_errors(nodes))
|
||||
if previous_plan is None:
|
||||
if plan.parent_plan_hash:
|
||||
errors.append({"path": "/parent_plan_hash", "message": "initial plan cannot have a parent_plan_hash"})
|
||||
if plan.replaces_node_ids:
|
||||
errors.append({"path": "/replaces_node_ids", "message": "initial plan cannot replace nodes"})
|
||||
else:
|
||||
errors.extend(_revision_errors(plan, previous_plan, completed_node_hashes or {}, required_replacements or set()))
|
||||
return errors
|
||||
|
||||
|
||||
def _cycle_errors(nodes: dict[str, FeatureNode]) -> list[dict[str, str]]:
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
errors: list[dict[str, str]] = []
|
||||
|
||||
def walk(current: str) -> None:
|
||||
if current in visiting:
|
||||
errors.append({"path": "/nodes", "message": "feature dependencies contain a cycle"})
|
||||
return
|
||||
if current in visited:
|
||||
return
|
||||
visiting.add(current)
|
||||
for dependency in nodes[current].depends_on:
|
||||
if dependency in nodes:
|
||||
walk(dependency)
|
||||
visiting.remove(current)
|
||||
visited.add(current)
|
||||
|
||||
for node_id in nodes:
|
||||
walk(node_id)
|
||||
return errors[:1]
|
||||
|
||||
|
||||
def _revision_errors(plan: FeaturePlan, previous: FeaturePlan, completed: dict[str, str], required_replacements: set[str]) -> list[dict[str, str]]:
|
||||
errors: list[dict[str, str]] = []
|
||||
old_nodes = {node.node_id: node for node in previous.nodes}
|
||||
next_nodes = {node.node_id: node for node in plan.nodes}
|
||||
if plan.parent_plan_hash != plan_hash(previous):
|
||||
errors.append({"path": "/parent_plan_hash", "message": "parent_plan_hash does not match the active plan"})
|
||||
replaced = set(plan.replaces_node_ids)
|
||||
if required_replacements and replaced != required_replacements:
|
||||
errors.append({"path": "/replaces_node_ids", "message": "failed node and its unresolved downstream subgraph must be replaced together"})
|
||||
for node_id, frozen_hash in completed.items():
|
||||
node = next_nodes.get(node_id)
|
||||
if node is None:
|
||||
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was removed"})
|
||||
elif node_hash(node) != frozen_hash:
|
||||
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was modified"})
|
||||
if node_id in replaced:
|
||||
errors.append({"path": "/replaces_node_ids", "message": f"completed node '{node_id}' cannot be replaced"})
|
||||
for node_id, old_node in old_nodes.items():
|
||||
if node_id in replaced:
|
||||
continue
|
||||
current = next_nodes.get(node_id)
|
||||
if current is None:
|
||||
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was removed outside the replacement subgraph"})
|
||||
elif node_hash(current) != node_hash(old_node):
|
||||
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was modified outside the replacement subgraph"})
|
||||
for node_id in replaced:
|
||||
if node_id not in old_nodes:
|
||||
errors.append({"path": "/replaces_node_ids", "message": f"unknown replaced node '{node_id}'"})
|
||||
if node_id in next_nodes:
|
||||
errors.append({"path": "/nodes", "message": f"replacement must use a new node_id, found '{node_id}'"})
|
||||
return errors
|
||||
|
||||
|
||||
class FeatureScheduler:
|
||||
"""Derive a plan's runnable node from immutable ledger evidence."""
|
||||
|
||||
def __init__(self, plan: FeaturePlan, events: list[dict[str, Any]]) -> None:
|
||||
self.plan = plan
|
||||
self.events = events
|
||||
self._nodes = {node.node_id: node for node in plan.nodes}
|
||||
|
||||
def statuses(self) -> dict[str, str]:
|
||||
states: dict[str, str] = {node_id: "pending" for node_id in self._nodes}
|
||||
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
|
||||
for event in self.events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if node_id not in states or event.get("node_hash") != expected_hashes[node_id]:
|
||||
continue
|
||||
if event.get("event") == "feature_node_verified":
|
||||
states[node_id] = "done"
|
||||
elif event.get("event") == "feature_node_invalidated" and states[node_id] != "done":
|
||||
states[node_id] = "invalidated"
|
||||
elif event.get("event") == "feature_node_failed" and states[node_id] != "done":
|
||||
states[node_id] = "failed" if bool(event.get("terminal")) else "pending"
|
||||
elif event.get("event") == "feature_node_scheduled" and states[node_id] == "pending":
|
||||
states[node_id] = "running"
|
||||
for node in self.plan.nodes:
|
||||
if states[node.node_id] in {"done", "failed", "invalidated"}:
|
||||
continue
|
||||
dependency_states = [states.get(dependency, "blocked") for dependency in node.depends_on]
|
||||
if any(value in {"failed", "invalidated", "blocked"} for value in dependency_states):
|
||||
states[node.node_id] = "blocked"
|
||||
elif all(value == "done" for value in dependency_states):
|
||||
states[node.node_id] = "ready" if states[node.node_id] != "running" else "running"
|
||||
return states
|
||||
|
||||
def next_ready(self) -> FeatureNode | None:
|
||||
statuses = self.statuses()
|
||||
ready = [node for node in self.plan.nodes if statuses[node.node_id] == "ready"]
|
||||
return min(ready, key=lambda node: node.priority) if ready else None
|
||||
|
||||
def all_done(self) -> bool:
|
||||
return all(value == "done" for value in self.statuses().values())
|
||||
|
||||
def feature_ids(self) -> dict[str, str]:
|
||||
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
|
||||
result: dict[str, str] = {}
|
||||
for event in self.events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
feature_id = str(event.get("feature_id") or "")
|
||||
if event.get("event") == "feature_node_verified" and node_id in expected_hashes and event.get("node_hash") == expected_hashes[node_id] and feature_id:
|
||||
result[node_id] = feature_id
|
||||
return result
|
||||
|
||||
def completed_node_hashes(self) -> dict[str, str]:
|
||||
statuses = self.statuses()
|
||||
return {
|
||||
node_id: node_hash(self._nodes[node_id])
|
||||
for node_id, status in statuses.items()
|
||||
if status == "done"
|
||||
}
|
||||
|
||||
def failure_count(self, node_id: str, failure_class: str) -> int:
|
||||
expected = node_hash(self._nodes[node_id])
|
||||
return sum(
|
||||
1
|
||||
for event in self.events
|
||||
if event.get("event") == "feature_node_failed"
|
||||
and event.get("node_id") == node_id
|
||||
and event.get("node_hash") == expected
|
||||
and event.get("failure_class") == failure_class
|
||||
)
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Versioned runtime operation contracts and dynamic fragment schemas."""
|
||||
"""Versioned runtime operation contracts for the Authoring compiler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
@@ -15,25 +14,6 @@ class OperationContractError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
SEMANTIC_PREFLIGHT_NAMES = frozenset({
|
||||
"sketch_workplane",
|
||||
"profile_non_self_intersecting",
|
||||
"host_face_exists",
|
||||
"hole_positions_on_host_plane",
|
||||
"cut_exit_distance",
|
||||
"requires_active_solid",
|
||||
"revolve_axis_on_sketch",
|
||||
"reference_plane_nonzero_normal",
|
||||
"reference_axis_nonzero_direction",
|
||||
"selected_edges_exist",
|
||||
"source_features_exist",
|
||||
"mirror_plane_exists",
|
||||
"loft_profiles_exist",
|
||||
"loft_profiles_closed",
|
||||
"loft_profiles_single_region",
|
||||
})
|
||||
|
||||
|
||||
def canonical_hash(value: dict[str, Any]) -> str:
|
||||
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -58,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}
|
||||
|
||||
@@ -1,56 +1,24 @@
|
||||
"""Finite workflow state machine. This module has no persistence imports."""
|
||||
"""Finite state for the single-stage Authoring CDSL protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import StrEnum
|
||||
|
||||
from .errors import ErrorCode, WorkflowError
|
||||
from .errors import ErrorCode
|
||||
|
||||
|
||||
class TaskPhase(StrEnum):
|
||||
DRAFTING_REQUIREMENTS_DOCUMENT = "DRAFTING_REQUIREMENTS_DOCUMENT"
|
||||
DRAFTING_COMPLETION_TARGET = "DRAFTING_COMPLETION_TARGET"
|
||||
COMPILING_REQUIREMENTS = "COMPILING_REQUIREMENTS"
|
||||
COMPILING_FEATURE_PLAN = "COMPILING_FEATURE_PLAN"
|
||||
SCHEDULING_FEATURE = "SCHEDULING_FEATURE"
|
||||
FEATURE_PENDING = "FEATURE_PENDING"
|
||||
FEATURE_BUILDING = "FEATURE_BUILDING"
|
||||
REPLANNING_FEATURE_SUBGRAPH = "REPLANNING_FEATURE_SUBGRAPH"
|
||||
# Legacy v3.1 phases remain readable only so an interrupted process can
|
||||
# fail cleanly during the protocol reset. New v3.2 tasks never enter them.
|
||||
DRAFTING_MODELING_PLAN = "DRAFTING_MODELING_PLAN"
|
||||
AWAITING_ACTION = "AWAITING_ACTION"
|
||||
ACTION_PENDING = "ACTION_PENDING"
|
||||
CANDIDATE_BUILDING = "CANDIDATE_BUILDING"
|
||||
CANDIDATE_REVIEW = "CANDIDATE_REVIEW"
|
||||
FINAL_VALIDATION = "FINAL_VALIDATION"
|
||||
WAITING_RETRY = "WAITING_RETRY"
|
||||
ANALYZING_REQUEST = "ANALYZING_REQUEST"
|
||||
AUTHORING_CDSL = "AUTHORING_CDSL"
|
||||
COMPILING_CDSL = "COMPILING_CDSL"
|
||||
BUILDING = "BUILDING"
|
||||
REPAIRING = "REPAIRING"
|
||||
PUBLISHING_BEST_EFFORT = "PUBLISHING_BEST_EFFORT"
|
||||
WAITING_FOR_USER = "WAITING_FOR_USER"
|
||||
CANCELLED = "CANCELLED"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PendingAction:
|
||||
action_id: str
|
||||
working_head: str
|
||||
intent: str
|
||||
requirement_ids: tuple[str, ...]
|
||||
atomic_id: str
|
||||
expected_change: str
|
||||
contract_hash: str
|
||||
idempotency_key: str
|
||||
node_id: str = ""
|
||||
plan_hash: str = ""
|
||||
claim_ids: tuple[str, ...] = ()
|
||||
depends_on_node_ids: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# The stored JSON key remains ``pending_action_json`` only in old artifacts.
|
||||
# v3.2 code uses this alias to make the ownership boundary explicit.
|
||||
PendingFeature = PendingAction
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -59,167 +27,84 @@ class TaskState:
|
||||
phase: TaskPhase
|
||||
version: int
|
||||
active_revision: str = ""
|
||||
pending_action: PendingAction | None = None
|
||||
candidate_id: str = ""
|
||||
candidate_stage_id: str = ""
|
||||
repair_required: bool = False
|
||||
repair_count: int = 0
|
||||
last_error: ErrorCode | None = None
|
||||
retry_from_phase: TaskPhase | None = None
|
||||
requirements_spec_path: str = ""
|
||||
requirements_document_path: str = ""
|
||||
completion_target_path: str = ""
|
||||
modeling_plan_path: str = ""
|
||||
feature_plan_path: str = ""
|
||||
feature_plan_hash: str = ""
|
||||
feature_stage_id: str = ""
|
||||
requirements_path: str = ""
|
||||
authoring_path: str = ""
|
||||
runtime_cdsl_path: str = ""
|
||||
compile_audit_path: str = ""
|
||||
diagnostics_path: str = ""
|
||||
completion_path: str = ""
|
||||
clarification_path: str = ""
|
||||
requirements_contract_path: str = ""
|
||||
|
||||
@property
|
||||
def pending_feature(self) -> PendingFeature | None:
|
||||
return self.pending_action
|
||||
|
||||
@property
|
||||
def working_head(self) -> str:
|
||||
return f"{self.task_id}:{self.active_revision or 'root'}:v{self.version}"
|
||||
|
||||
|
||||
# Legal state transitions. Events are intentionally terse persistence-neutral
|
||||
# names used by command handlers and architecture tests.
|
||||
_TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
|
||||
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
|
||||
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "requirements_document_written"): TaskPhase.DRAFTING_COMPLETION_TARGET,
|
||||
(TaskPhase.DRAFTING_COMPLETION_TARGET, "completion_target_written"): TaskPhase.COMPILING_REQUIREMENTS,
|
||||
(TaskPhase.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.COMPILING_FEATURE_PLAN,
|
||||
(TaskPhase.COMPILING_FEATURE_PLAN, "feature_plan_written"): TaskPhase.SCHEDULING_FEATURE,
|
||||
# Retained for direct v3.1 handler callers only. The v3.2 workflow never
|
||||
# exposes this event or accepts a Markdown plan from a model.
|
||||
(TaskPhase.COMPILING_FEATURE_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.REPLANNING_FEATURE_SUBGRAPH, "feature_plan_revised"): TaskPhase.SCHEDULING_FEATURE,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "feature_scheduled"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "final_requested"): TaskPhase.FINAL_VALIDATION,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_started"): TaskPhase.FEATURE_BUILDING,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_verified"): TaskPhase.SCHEDULING_FEATURE,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.DRAFTING_MODELING_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
|
||||
# User clarifications are durable task evidence. Resume on the same task
|
||||
# so its frozen request remains authoritative
|
||||
# instead of turning a clarification into a new CAD request.
|
||||
(TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
|
||||
(TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING,
|
||||
(TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.AWAITING_ACTION, "rollback"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.ACTION_PENDING, "candidate_started"): TaskPhase.CANDIDATE_BUILDING,
|
||||
# Runtime semantic preflight happens before candidate staging. It must
|
||||
# leave the checkpoint untouched and return control to the author for a
|
||||
# fresh observation/action decision, never masquerade as a format error.
|
||||
(TaskPhase.ACTION_PENDING, "runtime_precondition_rejected"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.ACTION_PENDING, "diagnosis_recorded"): TaskPhase.ACTION_PENDING,
|
||||
(TaskPhase.ACTION_PENDING, "diagnosis_return_to_action_selection"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.CANDIDATE_BUILDING, "candidate_built"): TaskPhase.CANDIDATE_REVIEW,
|
||||
(TaskPhase.CANDIDATE_BUILDING, "candidate_rejected"): TaskPhase.ACTION_PENDING,
|
||||
(TaskPhase.CANDIDATE_REVIEW, "candidate_accepted"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.CANDIDATE_REVIEW, "candidate_rejected"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.AWAITING_ACTION, "final_requested"): TaskPhase.FINAL_VALIDATION,
|
||||
(TaskPhase.FINAL_VALIDATION, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED,
|
||||
(TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.ANALYZING_REQUEST, "analysis_written"): TaskPhase.AUTHORING_CDSL,
|
||||
(TaskPhase.ANALYZING_REQUEST, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
|
||||
(TaskPhase.WAITING_FOR_USER, "clarification_received"): TaskPhase.ANALYZING_REQUEST,
|
||||
(TaskPhase.AUTHORING_CDSL, "authoring_written"): TaskPhase.COMPILING_CDSL,
|
||||
(TaskPhase.AUTHORING_CDSL, "repair_required"): TaskPhase.REPAIRING,
|
||||
(TaskPhase.COMPILING_CDSL, "compiled"): TaskPhase.BUILDING,
|
||||
(TaskPhase.COMPILING_CDSL, "repair_required"): TaskPhase.REPAIRING,
|
||||
(TaskPhase.BUILDING, "build_completed"): TaskPhase.PUBLISHING_BEST_EFFORT,
|
||||
(TaskPhase.BUILDING, "repair_required"): TaskPhase.REPAIRING,
|
||||
(TaskPhase.REPAIRING, "repair_started"): TaskPhase.AUTHORING_CDSL,
|
||||
(TaskPhase.PUBLISHING_BEST_EFFORT, "published"): TaskPhase.COMPLETED,
|
||||
(TaskPhase.PUBLISHING_BEST_EFFORT, "failed"): TaskPhase.FAILED,
|
||||
}
|
||||
_TRANSITIONS.update({
|
||||
(phase, "best_effort_completed"): TaskPhase.COMPLETED
|
||||
for phase in TaskPhase
|
||||
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
|
||||
})
|
||||
_TRANSITIONS.update({
|
||||
(phase, "failed"): TaskPhase.FAILED
|
||||
for phase in TaskPhase
|
||||
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
|
||||
})
|
||||
_TRANSITIONS.update({
|
||||
(phase, "cancelled"): TaskPhase.CANCELLED
|
||||
for phase in TaskPhase
|
||||
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
|
||||
})
|
||||
_RETRY_RESUMABLE_PHASES = frozenset({
|
||||
TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
|
||||
TaskPhase.DRAFTING_COMPLETION_TARGET,
|
||||
TaskPhase.COMPILING_REQUIREMENTS,
|
||||
TaskPhase.COMPILING_FEATURE_PLAN,
|
||||
TaskPhase.SCHEDULING_FEATURE,
|
||||
TaskPhase.FEATURE_PENDING,
|
||||
TaskPhase.FEATURE_BUILDING,
|
||||
TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
TaskPhase.DRAFTING_MODELING_PLAN,
|
||||
TaskPhase.AWAITING_ACTION,
|
||||
TaskPhase.ACTION_PENDING,
|
||||
TaskPhase.CANDIDATE_BUILDING,
|
||||
TaskPhase.CANDIDATE_REVIEW,
|
||||
TaskPhase.FINAL_VALIDATION,
|
||||
})
|
||||
_TRANSITIONS.update({
|
||||
(TaskPhase.WAITING_RETRY, f"resume_{phase.value.lower()}"): phase
|
||||
for phase in _RETRY_RESUMABLE_PHASES
|
||||
})
|
||||
_TRANSITIONS.update({
|
||||
(phase, "waiting_retry"): TaskPhase.WAITING_RETRY
|
||||
for phase in TaskPhase
|
||||
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY}
|
||||
})
|
||||
for _phase in TaskPhase:
|
||||
if _phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
_TRANSITIONS[(_phase, "failed")] = TaskPhase.FAILED
|
||||
_TRANSITIONS[(_phase, "cancelled")] = TaskPhase.CANCELLED
|
||||
if _phase in {
|
||||
TaskPhase.AUTHORING_CDSL, TaskPhase.COMPILING_CDSL,
|
||||
TaskPhase.BUILDING, TaskPhase.REPAIRING,
|
||||
}:
|
||||
_TRANSITIONS[(_phase, "publish_best_effort")] = TaskPhase.PUBLISHING_BEST_EFFORT
|
||||
|
||||
|
||||
def legal_transitions() -> dict[tuple[TaskPhase, str], TaskPhase]:
|
||||
"""Return a copy of the protocol transition table for architecture tests."""
|
||||
return dict(_TRANSITIONS)
|
||||
|
||||
|
||||
def retry_resume_event(state: TaskState) -> str | None:
|
||||
"""Return the only lossless resume event for a parked service failure."""
|
||||
if state.phase != TaskPhase.WAITING_RETRY or state.retry_from_phase not in _RETRY_RESUMABLE_PHASES:
|
||||
return None
|
||||
return f"resume_{state.retry_from_phase.value.lower()}"
|
||||
|
||||
|
||||
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, feature_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, requirements_document_path: str | None = None, completion_target_path: str | None = None, modeling_plan_path: str | None = None, feature_plan_path: str | None = None, feature_plan_hash: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
|
||||
"""Apply one legal transition and advance optimistic-concurrency version."""
|
||||
target = _TRANSITIONS.get((state.phase, event))
|
||||
def transition(
|
||||
state: TaskState,
|
||||
event: str,
|
||||
*,
|
||||
active_revision: str | None = None,
|
||||
repair_count: int | None = None,
|
||||
error: ErrorCode | None = None,
|
||||
requirements_path: str | None = None,
|
||||
authoring_path: str | None = None,
|
||||
runtime_cdsl_path: str | None = None,
|
||||
compile_audit_path: str | None = None,
|
||||
diagnostics_path: str | None = None,
|
||||
completion_path: str | None = None,
|
||||
clarification_path: str | None = None,
|
||||
) -> TaskState:
|
||||
target = state.retry_from_phase if state.phase == TaskPhase.FAILED and event == "resume" else _TRANSITIONS.get((state.phase, event))
|
||||
if target is None:
|
||||
raise ValueError(f"Illegal v3 transition: {state.phase.value} --{event}--> ?")
|
||||
if state.phase == TaskPhase.WAITING_RETRY and event.startswith("resume_") and state.retry_from_phase != target:
|
||||
raise ValueError("WAITING_RETRY resume event does not match its persisted source phase")
|
||||
next_pending = state.pending_action if pending_action is ... else pending_action
|
||||
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
next_pending = None
|
||||
raise ValueError(f"Illegal single-stage transition: {state.phase.value} --{event}--> ?")
|
||||
next_repairs = state.repair_count if repair_count is None else repair_count
|
||||
if not 0 <= next_repairs <= 2:
|
||||
raise ValueError("Single-stage repair count must be within [0, 2]")
|
||||
return replace(
|
||||
state,
|
||||
phase=target,
|
||||
version=state.version + 1,
|
||||
active_revision=state.active_revision if active_revision is None else active_revision,
|
||||
pending_action=next_pending,
|
||||
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
|
||||
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
|
||||
repair_required=state.repair_required if repair_required is None else repair_required,
|
||||
repair_count=next_repairs,
|
||||
last_error=error,
|
||||
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None,
|
||||
requirements_spec_path=state.requirements_spec_path if requirements_spec_path is None else requirements_spec_path,
|
||||
requirements_document_path=state.requirements_document_path if requirements_document_path is None else requirements_document_path,
|
||||
completion_target_path=state.completion_target_path if completion_target_path is None else completion_target_path,
|
||||
modeling_plan_path=state.modeling_plan_path if modeling_plan_path is None else modeling_plan_path,
|
||||
feature_plan_path=state.feature_plan_path if feature_plan_path is None else feature_plan_path,
|
||||
feature_plan_hash=state.feature_plan_hash if feature_plan_hash is None else feature_plan_hash,
|
||||
feature_stage_id="" if target in {TaskPhase.SCHEDULING_FEATURE, TaskPhase.FEATURE_PENDING, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.feature_stage_id if feature_stage_id is None else feature_stage_id,
|
||||
retry_from_phase=state.phase if target == TaskPhase.FAILED and error in {
|
||||
ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, ErrorCode.STORAGE_FAILURE,
|
||||
ErrorCode.RUNTIME_EXECUTION_FAILURE,
|
||||
} else None,
|
||||
requirements_path=state.requirements_path if requirements_path is None else requirements_path,
|
||||
authoring_path=state.authoring_path if authoring_path is None else authoring_path,
|
||||
runtime_cdsl_path=state.runtime_cdsl_path if runtime_cdsl_path is None else runtime_cdsl_path,
|
||||
compile_audit_path=state.compile_audit_path if compile_audit_path is None else compile_audit_path,
|
||||
diagnostics_path=state.diagnostics_path if diagnostics_path is None else diagnostics_path,
|
||||
completion_path=state.completion_path if completion_path is None else completion_path,
|
||||
clarification_path=state.clarification_path if clarification_path is None else clarification_path,
|
||||
requirements_contract_path=state.requirements_contract_path if requirements_contract_path is None else requirements_contract_path,
|
||||
)
|
||||
|
||||
|
||||
def reject_stale_head(state: TaskState, supplied_head: str) -> WorkflowError | None:
|
||||
if supplied_head != state.working_head:
|
||||
return WorkflowError(
|
||||
ErrorCode.STALE_WORKING_HEAD,
|
||||
"The command was bound to an obsolete working head.",
|
||||
details={"expected_working_head": state.working_head, "supplied_working_head": supplied_head},
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Token Baseline Format
|
||||
|
||||
`python -m app.cad_agent.evals.live --suite release --require-live` requires
|
||||
`--baseline-report` to point at a measured protocol-2 report. It is not a
|
||||
fixture or an estimate. Record three independent runs of every release
|
||||
scenario with the same author request configuration and runtime profile.
|
||||
|
||||
| Field | Requirement |
|
||||
| --- | --- |
|
||||
| `schema_version` | `cad.token-baseline.v1` |
|
||||
| `protocol_version` | `2.0` |
|
||||
| `author` | Exact `provider`, `model`, `api_style`, `reasoning_effort`, and `sampling` object from the v3 report |
|
||||
| `runtime_profile_sha256` | Exact v3 `runtime_profile_sha256` |
|
||||
| `measurement` | `prompt_tokens` when provider usage exists; otherwise `context_chars` |
|
||||
| `scenarios[]` | One entry for every versioned release scenario, including its request SHA-256 |
|
||||
| `repetitions[]` | Entries `1`, `2`, and `3`, with non-negative `author_metric` and `plan_review_metric`, plus completion, final-review, and deterministic-claim booleans |
|
||||
|
||||
The comparison uses `author_metric + plan_review_metric` for the v2 median and
|
||||
the v3 author metric only. It fails closed when provenance differs, any
|
||||
repetition is absent, completion/review/claim rates drop, plan-review calls
|
||||
remain, or the median reduction is below 30%.
|
||||
@@ -1 +1 @@
|
||||
"""Live, non-mocked protocol v3 release evaluations."""
|
||||
"""Local evaluation helpers for the single-stage Authoring protocol."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Create a resumable live-evaluation task without starting its workflow."""
|
||||
"""Create an isolated single-stage task without starting its workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,12 +9,12 @@ from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.cad_agent.composition import compose_cad_services
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Create one isolated live CAD task for resume_one_step.")
|
||||
parser = argparse.ArgumentParser(description="Create one isolated Authoring CDSL evaluation task.")
|
||||
parser.add_argument("--report-root", required=True, type=Path)
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--task-id")
|
||||
@@ -25,7 +25,7 @@ def main() -> int:
|
||||
arguments = _arguments()
|
||||
root = arguments.report_root.resolve()
|
||||
settings = get_settings()
|
||||
services = compose_v3(replace(
|
||||
services = compose_cad_services(replace(
|
||||
settings,
|
||||
task_root=root / "artifacts",
|
||||
conversation_root=root / "conversations",
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
{
|
||||
"schema_version": "cad.comprehensive-prompt-fixtures.v1",
|
||||
"source_document": "docs/cad-agent-v3-comprehensive-prompt-test-target.md",
|
||||
"source_document_sha256": "79fbfeba41b2bf1fa37d2e90df1c4f5f06460f725f8898ae4238676bfea82d8b",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "rectangular_mounting_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个CNC矩形安装板,长100毫米,宽60毫米,厚8毫米,四角圆角R6,四角各有一个直径8毫米的贯穿孔,孔中心距左右边10毫米、上下边10毫米,中心有一个直径30毫米的贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 8, "count": 4, "edge_offset_mm": 10}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "corner_radius", "description": "R6 outer corner radii"}
|
||||
],
|
||||
"max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000
|
||||
},
|
||||
{
|
||||
"id": "circular_flange_pcd",
|
||||
"units": "mm",
|
||||
"request": "生成一个圆形法兰盘,外径120毫米,厚度12毫米,中心贯穿孔直径40毫米,在直径90毫米的分度圆上均布6个直径8毫米的贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 6}},
|
||||
{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 8, "count": 6, "pitch_radius_mm": 45, "concentric_bore_diameter_mm": 40}}
|
||||
],
|
||||
"required_atomic_ids": [],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind", "pattern_linear"],
|
||||
"required_any_atomic_id_groups": [["extrude_add_blind", "revolve_add"]],
|
||||
"validation_capability_gaps": [],
|
||||
"max_author_turns": 52, "max_reviewer_turns": 20, "max_total_calls": 72, "max_wall_seconds": 1500, "max_total_tokens": 320000
|
||||
},
|
||||
{
|
||||
"id": "square_flange",
|
||||
"units": "mm",
|
||||
"request": "生成一个方形安装法兰,长100毫米,宽100毫米,厚度12毫米,四角圆角R8,中心贯穿孔直径45毫米,四角各有一个直径10毫米的安装孔,孔中心距相邻两边各15毫米。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 45, "count": 1}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 10, "count": 4}},
|
||||
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 10, "count": 4, "edge_offset_mm": 15}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "corner_radius", "description": "R8 outer corner radii"}
|
||||
],
|
||||
"max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000
|
||||
},
|
||||
{
|
||||
"id": "counterbored_mounting_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个矩形安装板,长120毫米,宽80毫米,厚度15毫米,四角各有一个直径9毫米的贯穿孔,每个孔顶部带直径16毫米、深5毫米的圆柱沉孔,孔中心距相邻边各12毫米。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth", "rectangular_corner_through_bore_pattern"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 15}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}},
|
||||
{"claim_kind": "cylindrical_bore", "expected": {"diameter_mm": 16, "count": 4}},
|
||||
{"claim_kind": "cylindrical_bore_depth", "expected": {"diameter_mm": 16, "count": 4, "depth_mm": 5}},
|
||||
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 9, "count": 4, "edge_offset_mm": 12}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_counterbore", "hole_wizard"],
|
||||
"validation_capability_gaps": [],
|
||||
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||
},
|
||||
{
|
||||
"id": "countersunk_cover_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个盖板,长100毫米,宽70毫米,厚度8毫米,四角圆角R5,四角各有一个直径6.5毫米的贯穿孔,孔顶部带90度沉头,沉头最大直径12毫米。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "conical_bore"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 70}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 6.5, "count": 4}},
|
||||
{"claim_kind": "conical_bore", "expected": {"small_diameter_mm": 6.5, "large_diameter_mm": 12, "included_angle_deg": 90, "count": 4}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_countersink", "hole_wizard"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "corner_radius", "description": "R5 outer corner radii"}
|
||||
],
|
||||
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||
},
|
||||
{
|
||||
"id": "obround_slot_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个连接板,长140毫米,宽50毫米,厚度10毫米,两端各有一个长圆形贯穿槽,槽总长30毫米、宽12毫米,槽中心距板端20毫米,槽的长轴沿板长度方向。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "slot_dimensions", "description": "two through obround slots with 30 x 12 dimensions"},
|
||||
{"id": "slot_position_orientation", "description": "20 mm end offset and lengthwise major-axis orientation"}
|
||||
],
|
||||
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||
},
|
||||
{
|
||||
"id": "t_slot_test_block",
|
||||
"units": "mm",
|
||||
"request": "生成一个T形槽试块,长100毫米,宽50毫米,高25毫米,在顶面中心沿长度方向加工一条T形槽,槽口宽10毫米、深8毫米,槽底宽20毫米、总深15毫米,槽贯穿试块两端。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "t_slot_cross_section", "description": "10 x 8 slot mouth, 20 mm lower width and 15 mm total depth"},
|
||||
{"id": "t_slot_through_orientation", "description": "top-centred T-slot through both lengthwise ends"}
|
||||
],
|
||||
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||
},
|
||||
{
|
||||
"id": "rounded_rectangular_pocket",
|
||||
"units": "mm",
|
||||
"request": "生成一个矩形底板,长120毫米,宽80毫米,厚20毫米,在顶面中心加工一个长80毫米、宽45毫米、深12毫米的矩形口袋,口袋四角圆角R6,底部保留8毫米厚度。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 20}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "pocket_dimensions", "description": "centred 80 x 45 x 12 pocket with 8 mm remaining floor"},
|
||||
{"id": "pocket_corner_radius", "description": "R6 internal pocket corners"}
|
||||
],
|
||||
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||
},
|
||||
{
|
||||
"id": "two_level_pocket_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个CNC加工板,长140毫米,宽100毫米,厚25毫米。顶面中心先加工一个长100毫米、宽70毫米、深8毫米的矩形口袋,再在第一级口袋中心加工一个长60毫米、宽35毫米、额外深7毫米的第二级口袋,所有内角圆角R5。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "pocket_levels", "description": "100 x 70 x 8 first pocket and centred 60 x 35 x 7 additional-depth second pocket"},
|
||||
{"id": "pocket_corner_radii", "description": "R5 on every internal corner"}
|
||||
],
|
||||
"max_author_turns": 80, "max_reviewer_turns": 30, "max_total_calls": 110, "max_wall_seconds": 2400, "max_total_tokens": 320000
|
||||
},
|
||||
{
|
||||
"id": "cross_drilled_valve_block",
|
||||
"units": "mm",
|
||||
"request": "生成一个阀块试件,长80毫米,宽60毫米,高50毫米。沿长度方向加工一个直径20毫米的贯穿孔,沿宽度方向加工一个直径12毫米的贯穿孔,两个孔的轴线在零件中心相交。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "orthogonal_intersecting_through_bores"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 50}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 1}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 1}},
|
||||
{"claim_kind": "orthogonal_intersecting_through_bores", "expected": {"first_diameter_mm": 20, "second_diameter_mm": 12, "first_axis": "x", "second_axis": "y"}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [],
|
||||
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||
},
|
||||
{
|
||||
"id": "double_hole_linkage_arm",
|
||||
"units": "mm",
|
||||
"request": "生成一个机械臂双孔连杆,两个销孔中心距120毫米,连杆厚度12毫米,两端外圆直径40毫米,两个销孔直径16毫米,中间杆身最小宽度24毫米,轮廓平滑相切,中部设置三个直径14毫米的减重贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 12}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 14, "count": 3}},
|
||||
{"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 16, "adjacent_distances_mm": [120], "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "linkage_outline", "description": "40 mm end circles, 24 mm minimum web width and smooth tangency"},
|
||||
{"id": "lightening_holes", "description": "three 14 mm central through holes"}
|
||||
],
|
||||
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||
},
|
||||
{
|
||||
"id": "three_hole_linkage",
|
||||
"units": "mm",
|
||||
"request": "生成一个三孔机械连杆,三个孔的中心位于同一直线上,相邻孔中心距分别为60毫米和80毫米,三个孔直径均为12毫米,连杆厚度10毫米,每个孔周围外圆直径32毫米,各段外轮廓平滑连接。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 10}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 3}},
|
||||
{"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 12, "adjacent_distances_mm": [60, 80], "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "linkage_outer_circles", "description": "32 mm outer circles with smoothly connected segments"}
|
||||
],
|
||||
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||
},
|
||||
{
|
||||
"id": "l_bracket",
|
||||
"units": "mm",
|
||||
"request": "生成一个整体式L形角码,水平底板长80毫米、宽50毫米、厚8毫米,竖直板高60毫米、宽50毫米、厚8毫米,两板成90度。水平板上有两个直径8毫米贯穿孔,竖直板上有两个直径8毫米贯穿孔,孔左右对称。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "l_bracket_dimensions", "description": "base and upright dimensions, thicknesses and 90 degree relationship"},
|
||||
{"id": "l_bracket_hole_symmetry", "description": "two symmetric 8 mm through holes on each plate"}
|
||||
],
|
||||
"max_author_turns": 88, "max_reviewer_turns": 32, "max_total_calls": 120, "max_wall_seconds": 2700, "max_total_tokens": 360000
|
||||
},
|
||||
{
|
||||
"id": "ribbed_l_bracket",
|
||||
"units": "mm",
|
||||
"request": "生成一个整体式L形机械支架,底板长100毫米、宽60毫米、厚10毫米,竖板高80毫米、宽60毫米、厚10毫米,两板成90度。底板和竖板之间设置两个厚度8毫米的三角加强筋。底板有四个直径9毫米贯穿孔,竖板中心有一个直径30毫米贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "bracket_dimensions", "description": "100 x 60 x 10 base and 80 x 60 x 10 upright at 90 degrees"},
|
||||
{"id": "triangular_ribs", "description": "two triangular 8 mm thick ribs"},
|
||||
{"id": "bracket_hole_placement", "description": "four base 9 mm holes and centred upright 30 mm hole"}
|
||||
],
|
||||
"max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000
|
||||
},
|
||||
{
|
||||
"id": "u_bearing_support",
|
||||
"units": "mm",
|
||||
"request": "生成一个U形轴承支座,底板长100毫米、宽60毫米、厚12毫米,两侧竖耳厚12毫米、高55毫米,两个竖耳内侧间距40毫米。两个竖耳上各有一个直径20毫米的同轴贯穿孔,孔轴线距底板上表面35毫米。底板四角各有一个直径8毫米安装孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 2}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
|
||||
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 20, "count": 2}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "u_support_dimensions", "description": "base and ear dimensions, ear spacing and 35 mm axis height"},
|
||||
{"id": "base_hole_positions", "description": "four 8 mm base mounting-hole positions"}
|
||||
],
|
||||
"max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000
|
||||
},
|
||||
{
|
||||
"id": "double_lug_mount",
|
||||
"units": "mm",
|
||||
"request": "生成一个双耳连接座,底座长90毫米、宽60毫米、厚12毫米,底座上有两个平行耳板,每个耳板厚10毫米、高50毫米,两耳板内侧间距30毫米。两个耳板上各有一个直径16毫米的同轴贯穿销孔,孔中心距底座上表面32毫米。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}},
|
||||
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 16, "count": 2}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "lug_dimensions", "description": "base, lug thickness/height, 30 mm inside spacing and 32 mm bore-axis height"}
|
||||
],
|
||||
"max_author_turns": 92, "max_reviewer_turns": 34, "max_total_calls": 126, "max_wall_seconds": 2700, "max_total_tokens": 380000
|
||||
},
|
||||
{
|
||||
"id": "stepped_shaft",
|
||||
"units": "mm",
|
||||
"request": "生成一根阶梯轴,总长120毫米。第一段直径30毫米、长40毫米;第二段直径24毫米、长50毫米;第三段直径18毫米、长30毫米。所有轴肩过渡圆角R2,两端倒角1毫米乘45度。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 120}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 24, "count": 1}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 18, "count": 1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["revolve_add"],
|
||||
"required_any_atomic_ids": ["fillet", "chamfer"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "shaft_segment_lengths", "description": "the 30 mm, 24 mm and 18 mm shaft sections have axial lengths 40 mm, 50 mm and 30 mm"},
|
||||
{"id": "shaft_finish_features", "description": "R2 shoulders and 1 x 45 degree end chamfers"}
|
||||
],
|
||||
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||
},
|
||||
{
|
||||
"id": "keyed_stepped_shaft",
|
||||
"units": "mm",
|
||||
"request": "生成一根阶梯传动轴,总长140毫米,中间轴段直径30毫米、长70毫米,两端轴段直径20毫米、各长35毫米。中间轴段沿轴向加工一条平键槽,键槽宽8毫米、深3.3毫米、长50毫米,所有轴肩圆角R2。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 140}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "count": 2}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["revolve_add"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind", "fillet"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "shaft_segment_lengths", "description": "the 20 mm end sections are 35 mm each and the 30 mm middle section is 70 mm"},
|
||||
{"id": "keyway", "description": "8 x 3.3 x 50 axial keyway on the middle shaft section"},
|
||||
{"id": "shoulder_fillet", "description": "R2 on all shaft shoulders"}
|
||||
],
|
||||
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||
},
|
||||
{
|
||||
"id": "flanged_sleeve",
|
||||
"units": "mm",
|
||||
"request": "生成一个机械套筒,外径50毫米,内孔直径30毫米,总长60毫米。套筒一端带外径70毫米、厚度10毫米的法兰,法兰上在直径56毫米分度圆上均布4个直径7毫米贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern", "outer_cylindrical_surface"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 60}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 70}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 50, "count": 1, "axial_span_mm": 50}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 70, "count": 1, "axial_span_mm": 10}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 7, "count": 4}},
|
||||
{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 7, "count": 4, "pitch_radius_mm": 28, "concentric_bore_diameter_mm": 30}}
|
||||
],
|
||||
"required_atomic_ids": ["revolve_add"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "flange_at_one_end", "description": "the 70 mm flange occurs at exactly one end of the coaxial sleeve, not at an interior position"}
|
||||
],
|
||||
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||
},
|
||||
{
|
||||
"id": "chamfered_bushing",
|
||||
"units": "mm",
|
||||
"request": "生成一个圆柱轴套,外径40毫米,内孔直径25毫米,长度50毫米,两端外边缘倒角1.5毫米乘45度,两端内孔边缘倒角1毫米乘45度。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 40}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 50}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 25, "count": 1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["revolve_add"],
|
||||
"required_any_atomic_ids": ["chamfer"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "inside_outside_chamfers", "description": "both 1.5 x 45 degree external and 1 x 45 degree internal chamfers"}
|
||||
],
|
||||
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
{
|
||||
"schema_version": "cad.live-eval-fixtures.v1",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "rectangular_plate",
|
||||
"units": "mm",
|
||||
"request": "Create one connected rectangular plate, 80 mm long, 50 mm wide, and 8 mm thick. Use millimetres. Verify the single solid and all three bounding-box dimensions.",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8, "tolerance_mm": 0.01}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"max_author_turns": 32,
|
||||
"max_reviewer_turns": 12,
|
||||
"max_total_calls": 44,
|
||||
"max_wall_seconds": 900,
|
||||
"max_total_tokens": 120000
|
||||
},
|
||||
{
|
||||
"id": "simple_flange",
|
||||
"units": "mm",
|
||||
"request": "Create one connected round flange in millimetres: outer diameter 120 mm, thickness 12 mm, and a centered 40 mm through bore. Verify the single solid, thickness, and through bore topology.",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"max_author_turns": 40,
|
||||
"max_reviewer_turns": 16,
|
||||
"max_total_calls": 56,
|
||||
"max_wall_seconds": 1200,
|
||||
"max_total_tokens": 160000
|
||||
},
|
||||
{
|
||||
"id": "ribbed_mounting_plate",
|
||||
"units": "mm",
|
||||
"request": "Create one connected mounting plate in millimetres with a rectangular base, a vertical reinforcing rib, and four equally spaced mounting through holes. State reasonable dimensions as assumptions, then verify a single connected solid and the four-hole pattern.",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "circular_hole_pattern", "through_cylindrical_bore"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind", "hole_blind"],
|
||||
"max_author_turns": 56,
|
||||
"max_reviewer_turns": 20,
|
||||
"max_total_calls": 76,
|
||||
"max_wall_seconds": 1500,
|
||||
"max_total_tokens": 200000
|
||||
},
|
||||
{
|
||||
"id": "selector_finish",
|
||||
"units": "mm",
|
||||
"request": "Create one connected rectangular plate with an added rounded or chamfered edge selected from the current topology. Use millimetres, state assumptions, and verify the final single solid.",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["fillet", "chamfer"],
|
||||
"max_author_turns": 40,
|
||||
"max_reviewer_turns": 16,
|
||||
"max_total_calls": 56,
|
||||
"max_wall_seconds": 1200,
|
||||
"max_total_tokens": 160000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,990 +0,0 @@
|
||||
"""Run the real provider, engine, reviewer and v3 state machine end to end.
|
||||
|
||||
``--require-live`` deliberately fails closed: missing credentials, blocked
|
||||
network, a skipped model capability, a timeout, or any scenario failure emits
|
||||
``LIVE_EVAL_BLOCKED``/failure details and exits non-zero.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from statistics import median
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.capabilities import verify_model_capability
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.cad_agent.domain.claim_matching import contains_expected
|
||||
from app.cad_agent.domain.operation_contract import canonical_hash
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.cad_agent.evals.token_baseline import (
|
||||
TokenBaselineError,
|
||||
author_request_identity,
|
||||
compare_token_baseline,
|
||||
load_token_baseline,
|
||||
profile_sha256,
|
||||
validate_token_baseline_provenance,
|
||||
)
|
||||
from app.settings import BACKEND_ROOT, get_settings
|
||||
|
||||
|
||||
_LEGACY_CANDIDATE_EVIDENCE_FILES = frozenset({
|
||||
"candidate.json",
|
||||
"candidate-review.json",
|
||||
"model.cdsl.json",
|
||||
"model.step",
|
||||
"model.glb",
|
||||
"model.topology.json",
|
||||
"rebuild-report.json",
|
||||
"renders/render-manifest.json",
|
||||
"renders/contact-sheet.jpg",
|
||||
})
|
||||
|
||||
# A v3.2 Feature DAG publishes one atomic checkpoint after local verification.
|
||||
# It deliberately has no candidate review or technical render bundle; the
|
||||
# final review owns that later evidence. GLB is optional because a preview
|
||||
# conversion outage must not invalidate an otherwise sound STEP checkpoint.
|
||||
_FEATURE_NODE_EVIDENCE_FILES = frozenset({
|
||||
"input.json",
|
||||
"model.cdsl.json",
|
||||
"model.step",
|
||||
"model.topology.json",
|
||||
"node-verification.json",
|
||||
"rebuild-report.json",
|
||||
})
|
||||
|
||||
_FAILURE_LAYERS = frozenset({
|
||||
"model_format_or_decision",
|
||||
"v3_contract_or_verifier",
|
||||
"cdsl_expression",
|
||||
"engine_execution",
|
||||
"independent_visual_review",
|
||||
"configuration_or_network",
|
||||
})
|
||||
|
||||
_ERROR_FAILURE_LAYERS = {
|
||||
"AUTHOR_FORMAT_INVALID": "model_format_or_decision",
|
||||
"AUTHOR_DECISION_REJECTED": "model_format_or_decision",
|
||||
"STALE_WORKING_HEAD": "model_format_or_decision",
|
||||
"FAILED_AUTHOR_FORMAT": "model_format_or_decision",
|
||||
"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED": "model_format_or_decision",
|
||||
"RUNTIME_PRECONDITION_FAILED": "v3_contract_or_verifier",
|
||||
"RUNTIME_CONTRACT_INVALID": "v3_contract_or_verifier",
|
||||
"VERIFIER_UNAVAILABLE": "v3_contract_or_verifier",
|
||||
"CLAIM_VERIFICATION_FAILED": "cdsl_expression",
|
||||
"CANDIDATE_BUILD_FAILED": "engine_execution",
|
||||
"CANDIDATE_REVIEW_REJECTED": "independent_visual_review",
|
||||
"AUTHOR_TRANSPORT_UNAVAILABLE": "configuration_or_network",
|
||||
"REVIEW_SERVICE_UNAVAILABLE": "configuration_or_network",
|
||||
"RENDER_SERVICE_UNAVAILABLE": "configuration_or_network",
|
||||
"STORAGE_FAILURE": "configuration_or_network",
|
||||
"LIVE_EVAL_TIMEOUT": "configuration_or_network",
|
||||
"FAILED_INTERNAL": "engine_execution",
|
||||
}
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run live protocol-v3 CAD evaluations.")
|
||||
parser.add_argument("--suite", choices=("smoke", "release", "comprehensive"), default="smoke")
|
||||
parser.add_argument("--require-live", action="store_true")
|
||||
parser.add_argument("--allow-skip", action="store_true")
|
||||
parser.add_argument("--author-provider")
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
parser.add_argument("--scenario", action="append", dest="scenarios", help="Run a fixture scenario by stable ID. Repeat this option to select a comparison set.")
|
||||
parser.add_argument("--repetitions", type=int, help="Run every selected scenario this many times.")
|
||||
parser.add_argument("--author-guidance", choices=("on", "off"), help="Override CDSL author guidance for this run.")
|
||||
parser.add_argument("--compare-guidance-reports", nargs=2, type=Path, metavar=("CONTROL", "TREATMENT"), help="Compare matched --author-guidance off/on report.json files without invoking providers.")
|
||||
parser.add_argument("--baseline-report", type=Path, help="Measured pre-v3 token baseline JSON for a release run.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _fixture(suite: str, scenario_ids: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
fixture_name = "comprehensive.json" if suite == "comprehensive" else "release.json"
|
||||
value = json.loads((Path(__file__).parent / "fixtures" / fixture_name).read_text(encoding="utf-8"))
|
||||
if fixture_name == "comprehensive.json":
|
||||
source_document = str(value.get("source_document") or "")
|
||||
expected_digest = str(value.get("source_document_sha256") or "")
|
||||
source_path = (BACKEND_ROOT.parent / source_document).resolve()
|
||||
workspace_root = BACKEND_ROOT.parent.resolve()
|
||||
if (
|
||||
not source_document
|
||||
or workspace_root not in source_path.parents
|
||||
or not source_path.is_file()
|
||||
or sha256(source_path.read_bytes()).hexdigest() != expected_digest
|
||||
):
|
||||
raise ValueError("Comprehensive fixture is not synchronized with its source document.")
|
||||
values = [item for item in value.get("scenarios") or () if isinstance(item, dict)]
|
||||
values = values[:2] if suite == "smoke" else values
|
||||
if not scenario_ids:
|
||||
return values
|
||||
requested = list(dict.fromkeys(scenario_ids))
|
||||
available = {str(item.get("id") or "") for item in values}
|
||||
unknown = [scenario_id for scenario_id in requested if scenario_id not in available]
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown scenario {unknown[0]!r} for suite {suite!r}")
|
||||
selected = [item for item in values if str(item.get("id") or "") in set(requested)]
|
||||
return selected
|
||||
|
||||
|
||||
def _rejection_codes(events: list[dict[str, Any]]) -> list[str]:
|
||||
codes: list[str] = []
|
||||
for event in events:
|
||||
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
|
||||
code = result.get("code") if isinstance(result, dict) else None
|
||||
if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "STALE_WORKING_HEAD"}:
|
||||
codes.append(str(code))
|
||||
return codes
|
||||
|
||||
|
||||
def _acceptance_coverage(
|
||||
scenario: dict[str, Any],
|
||||
requirements_contract: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether a fixture's acceptance contract is representable today.
|
||||
|
||||
Comprehensive prompts deliberately include manufacturing relationships that
|
||||
may not yet have a deterministic verifier. A healthy-looking solid is
|
||||
not evidence for those relationships, so they remain explicit capability
|
||||
gaps instead of silently passing a scenario.
|
||||
"""
|
||||
actual = [
|
||||
{
|
||||
"claim_kind": str(claim.get("claim_kind") or ""),
|
||||
"expected": claim.get("expected") if isinstance(claim.get("expected"), dict) else {},
|
||||
}
|
||||
for requirement in (requirements_contract or {}).get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
]
|
||||
required = {str(value) for value in scenario.get("required_claim_kinds") or ()}
|
||||
expected_claims = [
|
||||
{
|
||||
"claim_kind": str(item.get("claim_kind") or ""),
|
||||
"expected": item.get("expected") if isinstance(item.get("expected"), dict) else {},
|
||||
}
|
||||
for item in scenario.get("required_claims") or ()
|
||||
if isinstance(item, dict) and isinstance(item.get("claim_kind"), str)
|
||||
]
|
||||
expected_kinds = {claim["claim_kind"] for claim in expected_claims}
|
||||
expected_claims.extend(
|
||||
{"claim_kind": claim_kind, "expected": {}}
|
||||
for claim_kind in sorted(required - expected_kinds)
|
||||
)
|
||||
gaps = [
|
||||
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
|
||||
for item in scenario.get("validation_capability_gaps") or ()
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
missing = [
|
||||
claim for claim in expected_claims
|
||||
if not any(
|
||||
actual_claim["claim_kind"] == claim["claim_kind"]
|
||||
and _contains_business_expected(actual_claim["expected"], claim["expected"])
|
||||
for actual_claim in actual
|
||||
)
|
||||
]
|
||||
return {
|
||||
"required_claim_kinds": sorted(required),
|
||||
"covered_claim_kinds": sorted(required.intersection({claim["claim_kind"] for claim in actual})),
|
||||
"required_claims": expected_claims,
|
||||
"missing_claims": missing,
|
||||
"validation_capability_gaps": gaps,
|
||||
"complete": not missing and not gaps,
|
||||
}
|
||||
|
||||
|
||||
def _contains_business_expected(actual: Any, required: Any) -> bool:
|
||||
"""Match fixture business values without coupling to verifier tolerances.
|
||||
|
||||
Tolerances are executable verifier parameters chosen within the schema's
|
||||
safe range. They are not a separate user requirement and should not make
|
||||
a valid generated contract fail release evaluation merely because the
|
||||
author used the registry default instead of the fixture's tighter value.
|
||||
"""
|
||||
if isinstance(actual, dict) and isinstance(required, dict):
|
||||
return all(
|
||||
key in actual
|
||||
and _contains_business_expected(actual[key], value)
|
||||
for key, value in required.items()
|
||||
if key not in {"tolerance_mm", "tolerance"}
|
||||
)
|
||||
return contains_expected(actual, required)
|
||||
|
||||
|
||||
def _contains_expected(actual: Any, required: Any) -> bool:
|
||||
"""Match canonical claim values in release evaluation."""
|
||||
return contains_expected(actual, required)
|
||||
|
||||
|
||||
def _capability_block_reason(*reports: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Classify a failed conformance probe without confusing outages for gaps.
|
||||
|
||||
A provider transport failure means the probe did not establish either
|
||||
support or non-support. Only a complete response that violates one of the
|
||||
exposed tool contracts is evidence of a model structured-output limit.
|
||||
"""
|
||||
messages = [
|
||||
str(failure.get("message") or "").casefold()
|
||||
for report in reports
|
||||
for failure in report.get("failures") or ()
|
||||
if isinstance(failure, dict)
|
||||
]
|
||||
unavailable_markers = (
|
||||
"transport unavailable",
|
||||
"connection",
|
||||
"network",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"temporarily unavailable",
|
||||
)
|
||||
if any(any(marker in message for marker in unavailable_markers) for message in messages):
|
||||
return "MODEL_CAPABILITY_PROBE_UNAVAILABLE", "configuration_or_network"
|
||||
return "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED", "model_format_or_decision"
|
||||
|
||||
|
||||
def _artifact_evidence_complete(
|
||||
artifact_root: Path,
|
||||
revisions: list[str],
|
||||
active_revision: str,
|
||||
artifact_manifest: dict[str, Any] | None,
|
||||
ledger: list[dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Require every published CAD decision to retain its reviewable evidence.
|
||||
|
||||
A non-empty report manifest is not enough: a task could otherwise report
|
||||
only a rendered contract view while silently losing the STEP, topology, or
|
||||
node verification used to accept a revision. Checkpoint manifests protect
|
||||
immutable build inputs and outputs; the report manifest additionally
|
||||
protects the frozen contract and final independent review written later.
|
||||
"""
|
||||
if not revisions or not active_revision:
|
||||
return False
|
||||
report_files = {
|
||||
str(item.get("path") or "")
|
||||
for item in (artifact_manifest or {}).get("files") or ()
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
required_report_files = {
|
||||
"requirements-contract.json",
|
||||
f"reviews/final/{active_revision}/final-review.json",
|
||||
}
|
||||
if artifact_manifest is not None and not required_report_files.issubset(report_files):
|
||||
return False
|
||||
if not all((artifact_root / path).is_file() for path in required_report_files):
|
||||
return False
|
||||
feature_revisions = {
|
||||
str(item.get("revision_id") or "")
|
||||
for item in ledger
|
||||
if isinstance(item, dict) and item.get("event") == "feature_node_verified"
|
||||
}
|
||||
for revision_id in sorted(set(revisions)):
|
||||
revision_root = artifact_root / "revisions" / revision_id
|
||||
evidence_files = (
|
||||
_FEATURE_NODE_EVIDENCE_FILES
|
||||
if revision_id in feature_revisions
|
||||
else _LEGACY_CANDIDATE_EVIDENCE_FILES
|
||||
)
|
||||
required_paths = {revision_root / relative for relative in evidence_files}
|
||||
manifest_path = revision_root / "manifest.json"
|
||||
if not manifest_path.is_file() or not all(path.is_file() for path in required_paths):
|
||||
return False
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
declared = manifest.get("files") if isinstance(manifest, dict) else None
|
||||
if not isinstance(declared, dict) or not evidence_files.issubset(declared):
|
||||
return False
|
||||
for relative, digest in declared.items():
|
||||
path = revision_root / str(relative)
|
||||
if not isinstance(digest, str) or len(digest) != 64 or not path.is_file():
|
||||
return False
|
||||
if sha256(path.read_bytes()).hexdigest() != digest:
|
||||
return False
|
||||
if artifact_manifest is not None:
|
||||
expected_report_paths = {f"revisions/{revision_id}/{relative}" for relative in evidence_files}
|
||||
if not expected_report_paths.issubset(report_files):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _failure_attribution(
|
||||
*,
|
||||
checks: dict[str, bool],
|
||||
outcome: str,
|
||||
events: list[dict[str, Any]],
|
||||
projection: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return one evidence-backed failure layer required by the test target."""
|
||||
if outcome == "passed":
|
||||
return None
|
||||
codes: list[str] = []
|
||||
for event in events:
|
||||
payload = event.get("payload") if isinstance(event, dict) and isinstance(event.get("payload"), dict) else {}
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
|
||||
code = result.get("code") if isinstance(result, dict) else ""
|
||||
if isinstance(code, str) and code:
|
||||
codes.append(code)
|
||||
last_error = projection.get("last_error") if isinstance(projection, dict) else ""
|
||||
if isinstance(last_error, str) and last_error:
|
||||
codes.append(last_error)
|
||||
for code in reversed(codes):
|
||||
layer = _ERROR_FAILURE_LAYERS.get(code)
|
||||
if layer:
|
||||
return {
|
||||
"layer": layer,
|
||||
"reason_code": code,
|
||||
"evidence_codes": list(dict.fromkeys(codes)),
|
||||
"failed_checks": [name for name, passed in checks.items() if not passed],
|
||||
}
|
||||
failed_checks = [name for name, passed in checks.items() if not passed]
|
||||
if outcome == "validation_capability_gap" or any(name in {"required_claims", "acceptance_contract_coverage"} for name in failed_checks):
|
||||
layer, reason = "v3_contract_or_verifier", "VALIDATION_CAPABILITY_GAP"
|
||||
elif any(name in {"deterministic_claims_pass", "required_operations", "required_operation_alternative"} for name in failed_checks):
|
||||
layer, reason = "cdsl_expression", "CAD_ACCEPTANCE_GATE_FAILED"
|
||||
elif any(name in {"token_budget", "call_budget", "author_turn_budget", "reviewer_turn_budget"} for name in failed_checks):
|
||||
layer, reason = "configuration_or_network", "EVALUATION_BUDGET_EXCEEDED"
|
||||
elif "raw_argument_audit" in failed_checks:
|
||||
layer, reason = "v3_contract_or_verifier", "RAW_ARGUMENT_AUDIT_FAILED"
|
||||
elif any(name in {"immutable_artifacts", "artifact_manifest", "required_artifact_evidence", "action_ledger"} for name in failed_checks):
|
||||
layer, reason = "configuration_or_network", "ARTIFACT_EVIDENCE_INCOMPLETE"
|
||||
else:
|
||||
layer, reason = "model_format_or_decision", "WORKFLOW_TERMINAL_STATE_MISMATCH"
|
||||
return {
|
||||
"layer": layer,
|
||||
"reason_code": reason,
|
||||
"evidence_codes": list(dict.fromkeys(codes)),
|
||||
"failed_checks": failed_checks,
|
||||
}
|
||||
|
||||
|
||||
def _run_checks(
|
||||
scenario: dict[str, Any],
|
||||
projection: dict[str, Any],
|
||||
usage: dict[str, Any],
|
||||
requirements_contract: dict[str, Any] | None,
|
||||
events: list[dict[str, Any]],
|
||||
artifact_root: Path,
|
||||
ledger: list[dict[str, Any]] | None = None,
|
||||
tool_audits: list[dict[str, Any]] | None = None,
|
||||
artifact_manifest: dict[str, Any] | None = None,
|
||||
) -> dict[str, bool]:
|
||||
records = usage.get("records") if isinstance(usage.get("records"), list) else []
|
||||
author_calls = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"]
|
||||
reviewer_calls = [item for item in records if isinstance(item, dict) and item.get("role") == "reviewer"]
|
||||
total_tokens = int(usage.get("prompt_tokens") or 0) + int(usage.get("completion_tokens") or 0)
|
||||
audit_ledger = ledger if ledger is not None else [item for item in projection.get("action_ledger_summary") or () if isinstance(item, dict)]
|
||||
published = [
|
||||
item for item in audit_ledger
|
||||
if isinstance(item, dict) and item.get("event") in {"accepted", "feature_node_verified"}
|
||||
]
|
||||
revisions = [str(item.get("revision_id") or "") for item in published]
|
||||
scheduled_atomic_ids = {
|
||||
str(item.get("node_id") or ""): str(item.get("atomic_id") or "")
|
||||
for item in audit_ledger
|
||||
if isinstance(item, dict) and item.get("event") == "feature_node_scheduled"
|
||||
}
|
||||
operation_ids = {
|
||||
str(
|
||||
item.get("actual_atomic_id")
|
||||
or item.get("atomic_id")
|
||||
or scheduled_atomic_ids.get(str(item.get("node_id") or ""), "")
|
||||
)
|
||||
for item in published
|
||||
}
|
||||
required_operation_groups = [
|
||||
{str(atomic_id) for atomic_id in group if isinstance(atomic_id, str) and atomic_id}
|
||||
for group in scenario.get("required_any_atomic_id_groups") or ()
|
||||
if isinstance(group, list)
|
||||
]
|
||||
active_revision = str(projection.get("active_revision") or "")
|
||||
manifest = artifact_root / "revisions" / active_revision / "manifest.json"
|
||||
jsonl = artifact_root / "actions" / "action-ledger.jsonl"
|
||||
final_claims = next((item.get("claim_results") for item in reversed(audit_ledger) if isinstance(item, dict) and item.get("event") == "completed" and isinstance(item.get("claim_results"), list)), [])
|
||||
audit_records = tool_audits if isinstance(tool_audits, list) else []
|
||||
audit_valid = bool(audit_records) and all(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get("raw_arguments_hash"), str)
|
||||
and len(item["raw_arguments_hash"]) == 64
|
||||
and item.get("canonical_schema_valid") is True
|
||||
and isinstance(item.get("state_binding"), dict)
|
||||
and bool(item["state_binding"].get("phase"))
|
||||
and item["state_binding"].get("binding_valid") is True
|
||||
and item.get("single_allowed_call") is True
|
||||
for item in audit_records
|
||||
)
|
||||
acceptance = _acceptance_coverage(scenario, requirements_contract)
|
||||
artifact_evidence = _artifact_evidence_complete(
|
||||
artifact_root,
|
||||
revisions,
|
||||
active_revision,
|
||||
artifact_manifest,
|
||||
audit_ledger,
|
||||
)
|
||||
return {
|
||||
"expected_terminal_phase": projection.get("phase") == scenario.get("expected_phase", "COMPLETED"),
|
||||
"token_budget": total_tokens <= int(scenario["max_total_tokens"]),
|
||||
"call_budget": len(records) <= int(scenario["max_total_calls"]),
|
||||
"author_turn_budget": len(author_calls) <= int(scenario["max_author_turns"]),
|
||||
"reviewer_turn_budget": len(reviewer_calls) <= int(scenario["max_reviewer_turns"]),
|
||||
"required_claims": not acceptance["missing_claims"],
|
||||
"acceptance_contract_coverage": acceptance["complete"],
|
||||
"required_operations": {str(value) for value in scenario.get("required_atomic_ids") or ()}.issubset(operation_ids),
|
||||
"required_operation_alternative": (
|
||||
(not scenario.get("required_any_atomic_ids") or bool({str(value) for value in scenario["required_any_atomic_ids"]}.intersection(operation_ids)))
|
||||
and all(group.intersection(operation_ids) for group in required_operation_groups)
|
||||
),
|
||||
"immutable_artifacts": bool(active_revision) and manifest.is_file(),
|
||||
"required_artifact_evidence": artifact_evidence,
|
||||
"artifact_manifest": (
|
||||
artifact_manifest is None
|
||||
or (
|
||||
isinstance(artifact_manifest.get("files"), list)
|
||||
and bool(artifact_manifest["files"])
|
||||
and all(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get("path"), str)
|
||||
and isinstance(item.get("sha256"), str)
|
||||
and len(item["sha256"]) == 64
|
||||
for item in artifact_manifest["files"]
|
||||
)
|
||||
)
|
||||
),
|
||||
"action_ledger": jsonl.is_file(),
|
||||
"unique_revisions": len(revisions) == len(set(revisions)),
|
||||
"deterministic_claims_pass": bool(final_claims) and all(
|
||||
not isinstance(item, dict) or not item.get("deterministic") or item.get("status") == "pass"
|
||||
for item in final_claims
|
||||
),
|
||||
"raw_argument_audit": audit_valid if tool_audits is not None else bool(records) and all(isinstance(item, dict) and isinstance(item.get("raw_arguments_hash"), str) and len(item["raw_arguments_hash"]) == 64 for item in records),
|
||||
"no_schema_or_decision_rejections": not _rejection_codes(events),
|
||||
}
|
||||
|
||||
|
||||
def _git_revision() -> str:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"], cwd=BACKEND_ROOT.parent, capture_output=True,
|
||||
text=True, check=True, timeout=5,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _event_audit(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Persist event metadata without prompts, tool arguments, or tool facts."""
|
||||
audit: list[dict[str, Any]] = []
|
||||
for item in events:
|
||||
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
|
||||
audit.append({
|
||||
"name": str(item.get("name") or ""), "tool": str(payload.get("tool") or ""),
|
||||
"status": str(payload.get("status") or ""), "code": str(result.get("code") or payload.get("code") or ""),
|
||||
"raw_arguments_hash": str(usage.get("raw_arguments_hash") or ""),
|
||||
"field_errors": result.get("field_errors") if isinstance(result.get("field_errors"), list) else [],
|
||||
})
|
||||
return audit
|
||||
|
||||
|
||||
def _report_tool_audits(audits: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return report-safe audit metadata without argument values or prompts."""
|
||||
return [
|
||||
{
|
||||
"audit_id": item.get("audit_id"),
|
||||
"at": item.get("at"),
|
||||
"actor": item.get("actor"),
|
||||
"tool": item.get("tool"),
|
||||
"raw_arguments_hash": item.get("raw_arguments_hash"),
|
||||
"canonical_schema_valid": item.get("canonical_schema_valid"),
|
||||
"field_errors": item.get("field_errors") if isinstance(item.get("field_errors"), list) else [],
|
||||
"state_binding": item.get("state_binding") if isinstance(item.get("state_binding"), dict) else {},
|
||||
"returned_tool": item.get("returned_tool"),
|
||||
"single_allowed_call": item.get("single_allowed_call"),
|
||||
}
|
||||
for item in audits
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
|
||||
|
||||
def _ledger_identifiers(ledger: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||
"""Expose audit identifiers explicitly without copying provider payloads."""
|
||||
candidate_ids = sorted({
|
||||
str(item.get("candidate_id"))
|
||||
for item in ledger
|
||||
if isinstance(item.get("candidate_id"), str) and item.get("candidate_id")
|
||||
})
|
||||
revision_ids = sorted({
|
||||
str(item.get("revision_id"))
|
||||
for item in ledger
|
||||
if isinstance(item.get("revision_id"), str) and item.get("revision_id")
|
||||
})
|
||||
return {"candidate_ids": candidate_ids, "revision_ids": revision_ids}
|
||||
|
||||
|
||||
def _final_claim_results(ledger: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
for item in reversed(ledger):
|
||||
results = item.get("claim_results")
|
||||
if item.get("event") == "completed" and isinstance(results, list):
|
||||
return [value for value in results if isinstance(value, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _redacted_correlation_ids(task_id: str, invocations: list[dict[str, Any]]) -> list[str]:
|
||||
"""Keep run linkage without publishing task or provider correlation values."""
|
||||
return [
|
||||
canonical_hash({"task_id": task_id, "invocation_id": str(item.get("invocation_id") or "")})[:20]
|
||||
for item in invocations
|
||||
if isinstance(item, dict) and item.get("invocation_id")
|
||||
]
|
||||
|
||||
|
||||
def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]:
|
||||
"""Hash reviewable CAD evidence without copying source or prompt content."""
|
||||
allowed_exact = {
|
||||
"requirements-contract.json",
|
||||
"requirements.md",
|
||||
"completion-target.md",
|
||||
"completion-result.md",
|
||||
}
|
||||
allowed_prefixes = ("actions/", "revisions/", "reviews/", "documents/requirements-")
|
||||
files: list[dict[str, str]] = []
|
||||
if artifact_root.is_dir():
|
||||
for path in sorted(artifact_root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(artifact_root).as_posix()
|
||||
if relative not in allowed_exact and not relative.startswith(allowed_prefixes):
|
||||
continue
|
||||
files.append({"path": relative, "sha256": sha256(path.read_bytes()).hexdigest()})
|
||||
return {"schema_version": "cad.live-eval-artifact-manifest.v1", "artifact_root": str(artifact_root), "files": files}
|
||||
|
||||
|
||||
def _guidance_metadata(usage: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Summarize author-only guidance audit metadata without retaining prompts."""
|
||||
records = [
|
||||
item for item in usage.get("records") or ()
|
||||
if isinstance(item, dict) and item.get("role") != "reviewer"
|
||||
]
|
||||
sections = sorted({
|
||||
section_id
|
||||
for item in records
|
||||
for section_id in item.get("guidance_section_ids") or ()
|
||||
if isinstance(section_id, str)
|
||||
})
|
||||
versions = sorted({
|
||||
str(item.get("guidance_version") or "")
|
||||
for item in records
|
||||
if str(item.get("guidance_version") or "")
|
||||
})
|
||||
return {
|
||||
"enabled": bool(records) and any(item.get("guidance_enabled") is True for item in records),
|
||||
"versions": versions,
|
||||
"section_ids": sections,
|
||||
"chars_total": sum(int(item.get("guidance_chars") or 0) for item in records),
|
||||
"fallback_reasons": sorted({
|
||||
str(item.get("guidance_fallback_reason") or "")
|
||||
for item in records
|
||||
if str(item.get("guidance_fallback_reason") or "")
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
def _has_unsupported_capability(row: dict[str, Any]) -> bool:
|
||||
"""Recognize engine-declared unsupported capability without hiding model errors."""
|
||||
for event in row.get("ledger") or ():
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
for failure in event.get("operation_failures") or ():
|
||||
if isinstance(failure, dict) and "unsupported_" in str(failure.get("message") or ""):
|
||||
return True
|
||||
if "unsupported_" in str(event.get("message") or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _guidance_metric_rows(report: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
results = [item for item in report.get("results") or () if isinstance(item, dict)]
|
||||
capability_gaps = [
|
||||
item for item in results
|
||||
if item.get("outcome") == "validation_capability_gap" or _has_unsupported_capability(item)
|
||||
]
|
||||
return [item for item in results if item not in capability_gaps], capability_gaps
|
||||
|
||||
|
||||
def _guidance_metrics(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
if not rows:
|
||||
return {"eligible_runs": 0}
|
||||
author_calls = [
|
||||
sum(1 for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
|
||||
for row in rows
|
||||
]
|
||||
context_chars = [
|
||||
sum(int(item.get("context_chars") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
|
||||
for row in rows
|
||||
]
|
||||
prompt_tokens = [
|
||||
sum(int(item.get("prompt_tokens") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
|
||||
for row in rows
|
||||
]
|
||||
failure_layers: dict[str, int] = {}
|
||||
for row in rows:
|
||||
layer = str((row.get("failure_attribution") or {}).get("layer") or "passed")
|
||||
failure_layers[layer] = failure_layers.get(layer, 0) + 1
|
||||
def rate(predicate: Any) -> float:
|
||||
return sum(1 for row in rows if predicate(row)) / len(rows)
|
||||
return {
|
||||
"eligible_runs": len(rows),
|
||||
"executable_checkpoint_rate": rate(lambda row: bool(row.get("revision_ids"))),
|
||||
"completion_rate": rate(lambda row: str((row.get("projection") or {}).get("phase") or "") == "COMPLETED"),
|
||||
"deterministic_claim_success_rate": rate(lambda row: bool((row.get("checks") or {}).get("deterministic_claims_pass"))),
|
||||
"schema_or_decision_rejections": sum(int(row.get("schema_rejection_count") or 0) for row in rows),
|
||||
"cdsl_expression_failures": sum(1 for row in rows if str((row.get("failure_attribution") or {}).get("layer") or "") == "cdsl_expression"),
|
||||
"median_author_calls": median(author_calls),
|
||||
"median_author_context_chars": median(context_chars),
|
||||
"total_author_prompt_tokens": sum(prompt_tokens),
|
||||
"median_author_prompt_tokens": median(prompt_tokens),
|
||||
"failure_layers": dict(sorted(failure_layers.items())),
|
||||
}
|
||||
|
||||
|
||||
def compare_guidance_reports(control: dict[str, Any], treatment: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Compare paired guidance-off/on runs without treating engine gaps as prompt results."""
|
||||
control_rows, control_gaps = _guidance_metric_rows(control)
|
||||
treatment_rows, treatment_gaps = _guidance_metric_rows(treatment)
|
||||
control_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in control_rows}
|
||||
treatment_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in treatment_rows}
|
||||
paired = sorted(set(control_by_key).intersection(treatment_by_key))
|
||||
control_only = sorted(set(control_by_key).difference(treatment_by_key))
|
||||
treatment_only = sorted(set(treatment_by_key).difference(control_by_key))
|
||||
control_pairs = [control_by_key[key] for key in paired]
|
||||
treatment_pairs = [treatment_by_key[key] for key in paired]
|
||||
control_metrics = _guidance_metrics(control_pairs)
|
||||
treatment_metrics = _guidance_metrics(treatment_pairs)
|
||||
same_runtime = (
|
||||
control.get("author") == treatment.get("author")
|
||||
and control.get("reviewer") == treatment.get("reviewer")
|
||||
and control.get("runtime_profile_sha256") == treatment.get("runtime_profile_sha256")
|
||||
and control.get("operation_contracts") == treatment.get("operation_contracts")
|
||||
)
|
||||
control_guidance = bool((control.get("author_guidance") or {}).get("enabled"))
|
||||
treatment_guidance = bool((treatment.get("author_guidance") or {}).get("enabled"))
|
||||
same_budgets = all(
|
||||
control_by_key[key].get("scenario_budget") == treatment_by_key[key].get("scenario_budget")
|
||||
for key in paired
|
||||
)
|
||||
calls_control = control_metrics.get("median_author_calls")
|
||||
calls_treatment = treatment_metrics.get("median_author_calls")
|
||||
calls_within_limit = (
|
||||
isinstance(calls_control, (int, float))
|
||||
and isinstance(calls_treatment, (int, float))
|
||||
and calls_treatment <= calls_control * 1.10
|
||||
)
|
||||
improved = (
|
||||
treatment_metrics.get("schema_or_decision_rejections", 0) < control_metrics.get("schema_or_decision_rejections", 0)
|
||||
or treatment_metrics.get("cdsl_expression_failures", 0) < control_metrics.get("cdsl_expression_failures", 0)
|
||||
)
|
||||
gates = {
|
||||
"complete_pairing": bool(paired) and not control_only and not treatment_only,
|
||||
"control_off_treatment_on": not control_guidance and treatment_guidance,
|
||||
"same_author_reviewer_runtime_and_contracts": same_runtime,
|
||||
"same_per_scenario_budgets": same_budgets,
|
||||
"checkpoint_rate_not_lower": treatment_metrics.get("executable_checkpoint_rate", -1) >= control_metrics.get("executable_checkpoint_rate", 0),
|
||||
"completion_rate_not_lower": treatment_metrics.get("completion_rate", -1) >= control_metrics.get("completion_rate", 0),
|
||||
"median_author_calls_within_ten_percent": calls_within_limit,
|
||||
"model_or_cdsl_failure_improved": improved,
|
||||
}
|
||||
return {
|
||||
"schema_version": "cad.author-guidance-comparison.v1",
|
||||
"status": "passed" if all(gates.values()) else "failed",
|
||||
"gates": gates,
|
||||
"paired_runs": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in paired],
|
||||
"unpaired_runs": {
|
||||
"control_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in control_only],
|
||||
"treatment_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in treatment_only],
|
||||
},
|
||||
"control": control_metrics,
|
||||
"treatment": treatment_metrics,
|
||||
"excluded_capability_gaps": {
|
||||
"control": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in control_gaps],
|
||||
"treatment": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in treatment_gaps],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def compare_guidance_report_paths(control_path: Path, treatment_path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
control = json.loads(control_path.read_text(encoding="utf-8"))
|
||||
treatment = json.loads(treatment_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
return {"status": "failed", "error": f"GUIDANCE_COMPARISON_INPUT_INVALID: {type(error).__name__}"}
|
||||
if not isinstance(control, dict) or not isinstance(treatment, dict):
|
||||
return {"status": "failed", "error": "GUIDANCE_COMPARISON_INPUT_INVALID: report must be an object"}
|
||||
return compare_guidance_reports(control, treatment)
|
||||
|
||||
|
||||
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
|
||||
try:
|
||||
scenarios = _fixture(arguments.suite, arguments.scenarios)
|
||||
except ValueError as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
|
||||
repetitions = arguments.repetitions if arguments.repetitions is not None else 3 if arguments.suite == "release" else 1
|
||||
if repetitions < 1:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": "--repetitions must be at least 1"}
|
||||
settings = get_settings()
|
||||
if arguments.author_guidance is not None:
|
||||
settings = replace(settings, agent_author_guidance_enabled=arguments.author_guidance == "on")
|
||||
try:
|
||||
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
|
||||
if arguments.review_provider or arguments.review_model:
|
||||
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
|
||||
if review_provider.id == author_provider.id and review_model.id == author_model.id:
|
||||
raise ValueError("Reviewer must differ from author")
|
||||
else:
|
||||
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
|
||||
except ValueError as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
|
||||
baseline_path = arguments.baseline_report.resolve() if arguments.baseline_report else None
|
||||
if arguments.suite == "release" and baseline_path is None:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": "TOKEN_BASELINE_REQUIRED: release requires a provenance-checked pre-v3 token baseline report."}
|
||||
try:
|
||||
baseline = load_token_baseline(baseline_path) if baseline_path else None
|
||||
current_author_identity = author_request_identity(author_provider, author_model)
|
||||
runtime_profile_hash = profile_sha256(settings.engine_root / "profile_schema.json")
|
||||
except TokenBaselineError as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {error}"}
|
||||
if baseline is not None:
|
||||
baseline_errors = validate_token_baseline_provenance(
|
||||
baseline,
|
||||
scenarios=scenarios,
|
||||
author_identity=current_author_identity,
|
||||
runtime_profile_hash=runtime_profile_hash,
|
||||
)
|
||||
if baseline_errors:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {baseline_errors[0]}", "baseline_errors": baseline_errors}
|
||||
isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations")
|
||||
services = compose_v3(isolated)
|
||||
contracts = [
|
||||
{
|
||||
"atomic_id": atomic_id, "contract_hash": services.workflow.runtime.operation_contract(atomic_id)["contract_hash"],
|
||||
"contract_version": services.workflow.runtime.operation_contract(atomic_id)["contract_version"],
|
||||
"registry_revision": services.workflow.runtime.operation_contract(atomic_id)["registry_revision"],
|
||||
}
|
||||
for atomic_id in services.workflow.runtime.supported_atomic_ids()
|
||||
]
|
||||
verifier_schema_hash = canonical_hash(default_registry().expected_one_of_schema())
|
||||
try:
|
||||
author_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=author_provider.id, model_id=author_model.id, role="author", force=True)
|
||||
reviewer_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=review_provider.id, model_id=review_model.id, role="reviewer", force=True)
|
||||
except Exception as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)[:1000]}
|
||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||
error, failure_layer = _capability_block_reason(author_capability, reviewer_capability)
|
||||
return {
|
||||
"status": "LIVE_EVAL_BLOCKED",
|
||||
"error": error,
|
||||
"failure_layer": failure_layer,
|
||||
"author_capability": author_capability,
|
||||
"reviewer_capability": reviewer_capability,
|
||||
}
|
||||
results: list[dict[str, Any]] = []
|
||||
for scenario in scenarios:
|
||||
for repetition in range(1, repetitions + 1):
|
||||
services.workflow.config = replace(
|
||||
services.workflow.config,
|
||||
# State transitions include local candidate recovery, so the
|
||||
# loop guard is deliberately independent from the externally
|
||||
# measured model-call budgets below.
|
||||
max_turns=max(8, int(scenario["max_total_calls"]) * 3),
|
||||
max_author_turns=int(scenario["max_author_turns"]),
|
||||
max_reviewer_turns=int(scenario["max_reviewer_turns"]),
|
||||
max_model_calls=int(scenario["max_total_calls"]),
|
||||
)
|
||||
task_id = f"cad_{secrets.token_hex(6)}"
|
||||
oracle_claims = [item for item in scenario.get("required_claims") or () if isinstance(item, dict)]
|
||||
if oracle_claims:
|
||||
services.workflow.requirements.register_evaluation_contract_oracle(
|
||||
task_id,
|
||||
oracle_claims,
|
||||
validation_capability_gaps=[item for item in scenario.get("validation_capability_gaps") or () if isinstance(item, dict)],
|
||||
)
|
||||
services.workflow.create_task(task_id, str(scenario["request"]))
|
||||
events: list[dict[str, Any]] = []
|
||||
started = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with asyncio.timeout(int(scenario["max_wall_seconds"])):
|
||||
async for name, payload in services.workflow.run(
|
||||
task_id=task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
):
|
||||
events.append({"name": name, "payload": payload})
|
||||
except TimeoutError:
|
||||
events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT"}})
|
||||
projection = services.repository.get_task_projection(task_id) or {}
|
||||
usage = services.repository.usage_summary(task_id)
|
||||
ledger = services.repository.ledger_events(task_id)
|
||||
invocations = services.repository.invocation_records(task_id)
|
||||
tool_audits = services.repository.tool_audits(task_id)
|
||||
terminal = next((item["payload"] for item in reversed(events) if item["name"] == "task_terminal"), {})
|
||||
artifact_root = (isolated.task_root / task_id).resolve()
|
||||
artifact_manifest = _safe_artifact_manifest(artifact_root)
|
||||
finished = datetime.now(timezone.utc)
|
||||
checks = _run_checks(
|
||||
scenario,
|
||||
projection,
|
||||
usage,
|
||||
services.artifacts.read_requirements_contract(
|
||||
task_id,
|
||||
services.repository.get_state(task_id).requirements_contract_path
|
||||
if services.repository.get_state(task_id) is not None
|
||||
else "",
|
||||
),
|
||||
events,
|
||||
artifact_root,
|
||||
ledger,
|
||||
tool_audits,
|
||||
artifact_manifest,
|
||||
)
|
||||
success = all(checks.values())
|
||||
acceptance = _acceptance_coverage(
|
||||
scenario,
|
||||
services.artifacts.read_requirements_contract(
|
||||
task_id,
|
||||
services.repository.get_state(task_id).requirements_contract_path
|
||||
if services.repository.get_state(task_id) is not None
|
||||
else "",
|
||||
),
|
||||
)
|
||||
outcome = (
|
||||
"passed"
|
||||
if success
|
||||
else "validation_capability_gap"
|
||||
if not acceptance["complete"]
|
||||
and all(value for key, value in checks.items() if key != "acceptance_contract_coverage")
|
||||
else "failed"
|
||||
)
|
||||
failure_attribution = _failure_attribution(
|
||||
checks=checks,
|
||||
outcome=outcome,
|
||||
events=events,
|
||||
projection=projection,
|
||||
)
|
||||
results.append({
|
||||
"scenario": scenario["id"], "repetition": repetition, "success": success, "outcome": outcome,
|
||||
"started_at": started.isoformat(), "finished_at": finished.isoformat(),
|
||||
"duration_ms": round((finished - started).total_seconds() * 1000),
|
||||
"terminal": terminal, "projection": projection, "usage": usage, "checks": checks,
|
||||
"acceptance_coverage": acceptance,
|
||||
"failure_attribution": failure_attribution,
|
||||
"guidance": _guidance_metadata(usage),
|
||||
"scenario_budget": {
|
||||
"max_author_turns": int(scenario["max_author_turns"]),
|
||||
"max_reviewer_turns": int(scenario["max_reviewer_turns"]),
|
||||
"max_total_calls": int(scenario["max_total_calls"]),
|
||||
"max_total_tokens": int(scenario["max_total_tokens"]),
|
||||
},
|
||||
"rejection_codes": _rejection_codes(events),
|
||||
"schema_rejection_count": len(_rejection_codes(events)),
|
||||
"retry_count": sum(1 for event in events if (event.get("payload") or {}).get("status") == "error"),
|
||||
"rollback_count": sum(1 for item in ledger if item.get("event") == "rollback"),
|
||||
**_ledger_identifiers(ledger),
|
||||
"final_claim_results": _final_claim_results(ledger),
|
||||
"redacted_correlation_ids": _redacted_correlation_ids(task_id, invocations),
|
||||
"tool_audits": _report_tool_audits(tool_audits),
|
||||
"artifact_root": str(artifact_root), "action_ledger_path": str(artifact_root / "actions" / "action-ledger.jsonl"),
|
||||
"artifact_manifest": artifact_manifest,
|
||||
"ledger": ledger, "invocations": invocations, "event_audit": _event_audit(events),
|
||||
})
|
||||
token_comparison = (
|
||||
compare_token_baseline(
|
||||
baseline,
|
||||
scenarios=scenarios,
|
||||
v3_results=results,
|
||||
author_identity=current_author_identity,
|
||||
runtime_profile_hash=runtime_profile_hash,
|
||||
)
|
||||
if baseline is not None
|
||||
else {"schema_version": "cad.token-comparison.v1", "status": "not_required"}
|
||||
)
|
||||
token_gate = all(token_comparison.get("checks", {}).values()) if baseline is not None else True
|
||||
return {
|
||||
"status": "passed" if results and all(item["success"] for item in results) and token_gate else "failed",
|
||||
"author": {"provider": author_provider.id, "model": author_model.id},
|
||||
"author_request_identity": current_author_identity,
|
||||
"reviewer": {"provider": review_provider.id, "model": review_model.id},
|
||||
"author_guidance": {
|
||||
"enabled": settings.agent_author_guidance_enabled,
|
||||
"max_chars": settings.agent_author_guidance_max_chars,
|
||||
},
|
||||
"author_capability": author_capability,
|
||||
"reviewer_capability": reviewer_capability,
|
||||
"structured_output_mode": {
|
||||
"author": str(author_capability.get("mode") or ""),
|
||||
"reviewer": str(reviewer_capability.get("mode") or ""),
|
||||
},
|
||||
"git_revision": _git_revision(),
|
||||
"protocol_version": "3.2",
|
||||
"runtime_profile_sha256": runtime_profile_hash,
|
||||
"operation_contracts": contracts,
|
||||
"verifier_registry_version": "cad.verifier-registry.v1",
|
||||
"verifier_schema_hash": verifier_schema_hash,
|
||||
"token_comparison": token_comparison,
|
||||
"baseline_report": str(baseline_path) if baseline_path else "",
|
||||
"repetitions": repetitions,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
report_root.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
result = (
|
||||
compare_guidance_report_paths(*arguments.compare_guidance_reports)
|
||||
if arguments.compare_guidance_reports
|
||||
else asyncio.run(_run(arguments, report_root))
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
# Let an explicit operator interruption retain its normal CLI
|
||||
# semantics. An external kill cannot be reported reliably either.
|
||||
raise
|
||||
except BaseException as error:
|
||||
# A live evaluation may fail before it creates a task (for example
|
||||
# during conformance). Its report is still the release gate's audit
|
||||
# artifact, so an unexpected evaluator failure must not disappear.
|
||||
result = {
|
||||
"status": "LIVE_EVAL_BLOCKED",
|
||||
"error": f"UNEXPECTED_LIVE_EVAL_ERROR: {type(error).__name__}: {str(error)[:900]}",
|
||||
"failure_layer": "configuration_or_network",
|
||||
}
|
||||
if result["status"] == "LIVE_EVAL_BLOCKED" and arguments.allow_skip and not arguments.require_live:
|
||||
result["skip_reason"] = result.get("error", "live provider access is unavailable")
|
||||
result["status"] = "skipped"
|
||||
result.update({"suite": arguments.suite, "require_live": arguments.require_live, "report_root": str(report_root.resolve())})
|
||||
(report_root / "report.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({"status": result["status"], "report": str((report_root / "report.json").resolve())}, ensure_ascii=False))
|
||||
if result["status"] == "passed":
|
||||
return 0
|
||||
if result["status"] == "skipped":
|
||||
return 0
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Advance one persisted event for a task in an isolated live-evaluation root.
|
||||
|
||||
Long real-provider evaluations can outlive a command host's execution window.
|
||||
This utility takes exactly one event from ``WorkflowCoordinator.run`` and
|
||||
closes the async generator after that event has been durably handled. Repeated
|
||||
invocations therefore resume the same task without re-running already
|
||||
persisted nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Advance one event for an isolated live CAD evaluation task.")
|
||||
parser.add_argument("--report-root", required=True, type=Path)
|
||||
parser.add_argument("--task-id", required=True)
|
||||
parser.add_argument("--author-provider")
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def _advance(arguments: argparse.Namespace) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
|
||||
if arguments.review_provider or arguments.review_model:
|
||||
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
|
||||
else:
|
||||
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
|
||||
report_root = arguments.report_root.resolve()
|
||||
services = compose_v3(replace(
|
||||
settings,
|
||||
task_root=report_root / "artifacts",
|
||||
conversation_root=report_root / "conversations",
|
||||
))
|
||||
before = services.repository.get_state(arguments.task_id)
|
||||
if before is None:
|
||||
raise ValueError(f"Unknown task {arguments.task_id!r} in {report_root}")
|
||||
runner = services.workflow.run(
|
||||
task_id=arguments.task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
)
|
||||
try:
|
||||
name, payload = await anext(runner)
|
||||
except StopAsyncIteration:
|
||||
name, payload = "workflow_exhausted", {}
|
||||
finally:
|
||||
await runner.aclose()
|
||||
after = services.repository.get_state(arguments.task_id)
|
||||
return {
|
||||
"task_id": arguments.task_id,
|
||||
"event": {"name": name, "payload": payload},
|
||||
"before": {"phase": before.phase.value, "version": before.version},
|
||||
"after": {
|
||||
"phase": after.phase.value if after is not None else "",
|
||||
"version": after.version if after is not None else -1,
|
||||
"active_revision": after.active_revision if after is not None else "",
|
||||
"last_error": after.last_error.value if after is not None and after.last_error else "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
try:
|
||||
result = asyncio.run(_advance(arguments))
|
||||
except BaseException as error:
|
||||
result = {"error": f"{type(error).__name__}: {str(error)[:1000]}"}
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 2
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Aggregate local results for the `cad.single-stage.v1` evaluation suite.
|
||||
|
||||
The evaluator accepts already-recorded task projections and never calls a
|
||||
model. This keeps rate reporting reproducible and separates it from live
|
||||
provider experiments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
def summarize(records: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Return first-pass, final, compliance, and cost counters.
|
||||
|
||||
A record may contain ``attempts`` with ``schema_valid`` and
|
||||
``executable`` booleans, final lifecycle/revision information, requirement
|
||||
target states, usage records, and an elapsed duration in milliseconds.
|
||||
Missing fields are reported as zero rather than guessed.
|
||||
"""
|
||||
values = [item for item in records if isinstance(item, dict)]
|
||||
first_schema = first_executable = final_executable = 0
|
||||
targets: dict[str, int] = {key: 0 for key in ("pass", "fail", "pending", "not_applicable")}
|
||||
calls = prompt_tokens = completion_tokens = duration_ms = 0
|
||||
for record in values:
|
||||
attempts = record.get("attempts") if isinstance(record.get("attempts"), list) else []
|
||||
first = attempts[0] if attempts and isinstance(attempts[0], dict) else {}
|
||||
first_schema += int(bool(first.get("schema_valid")))
|
||||
first_executable += int(bool(first.get("executable")))
|
||||
final_executable += int(bool(record.get("published_revision") or record.get("active_revision")))
|
||||
for target in record.get("requirement_targets") or ():
|
||||
state = str(target.get("status") or "pending") if isinstance(target, dict) else "pending"
|
||||
targets[state if state in targets else "pending"] += 1
|
||||
usage = record.get("usage") if isinstance(record.get("usage"), dict) else {}
|
||||
usage_records = usage.get("records") if isinstance(usage.get("records"), list) else []
|
||||
calls += len(usage_records)
|
||||
for item in usage_records:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
prompt_tokens += int(item.get("prompt_tokens") or 0)
|
||||
completion_tokens += int(item.get("completion_tokens") or 0)
|
||||
duration_ms += int(record.get("duration_ms") or 0)
|
||||
total = len(values)
|
||||
rate = lambda count: count / total if total else 0.0
|
||||
return {
|
||||
"schema_version": "cad.single-stage.eval-summary.v1",
|
||||
"tasks": total,
|
||||
"first_pass_schema_rate": rate(first_schema),
|
||||
"first_pass_executable_rate": rate(first_executable),
|
||||
"final_executable_rate": rate(final_executable),
|
||||
"requirement_targets": targets,
|
||||
"calls": calls,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
"""Provenance-checked token baseline comparison for the v3 release gate.
|
||||
|
||||
The target intentionally does not accept estimated tokens or a hand-written
|
||||
percentage. A v2 run must record the same fixed requests and author request
|
||||
configuration before it can be compared with a v3 release report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
from typing import Any
|
||||
|
||||
from app.settings import ProviderConfig, ProviderModel
|
||||
|
||||
|
||||
BASELINE_SCHEMA_VERSION = "cad.token-baseline.v1"
|
||||
BASELINE_PROTOCOL_VERSION = "2.0"
|
||||
MINIMUM_REPETITIONS = 3
|
||||
TOKEN_REDUCTION_TARGET = 0.30
|
||||
MEASUREMENTS = {"prompt_tokens", "context_chars"}
|
||||
|
||||
|
||||
class TokenBaselineError(ValueError):
|
||||
"""A baseline cannot prove the release token target."""
|
||||
|
||||
|
||||
def request_sha256(request: str) -> str:
|
||||
return sha256(request.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def profile_sha256(profile_path: Path) -> str:
|
||||
try:
|
||||
return sha256(profile_path.read_bytes()).hexdigest()
|
||||
except OSError as error:
|
||||
raise TokenBaselineError("Runtime profile is unavailable for token-baseline provenance.") from error
|
||||
|
||||
|
||||
def author_request_identity(provider: ProviderConfig, model: ProviderModel) -> dict[str, Any]:
|
||||
"""Capture every author setting that can affect prompt-token comparison."""
|
||||
return {
|
||||
"provider": provider.id,
|
||||
"model": model.id,
|
||||
"api_style": provider.api_style,
|
||||
"reasoning_effort": provider.reasoning_effort,
|
||||
# Chat Completions is explicitly deterministic in StructuredModelGateway.
|
||||
# Responses models use their configured/provider-default sampling mode.
|
||||
"sampling": {"temperature": 0 if provider.api_style == "chat_completions" else None},
|
||||
}
|
||||
|
||||
|
||||
def load_token_baseline(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise TokenBaselineError("Token baseline report is missing or is not valid JSON.") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise TokenBaselineError("Token baseline report must be a JSON object.")
|
||||
if payload.get("schema_version") != BASELINE_SCHEMA_VERSION:
|
||||
raise TokenBaselineError(f"Token baseline must use {BASELINE_SCHEMA_VERSION}.")
|
||||
if payload.get("protocol_version") != BASELINE_PROTOCOL_VERSION:
|
||||
raise TokenBaselineError("Token baseline must be a measured pre-v3 (2.0) report.")
|
||||
if payload.get("measurement") not in MEASUREMENTS:
|
||||
raise TokenBaselineError("Token baseline measurement must be prompt_tokens or context_chars.")
|
||||
return payload
|
||||
|
||||
|
||||
def validate_token_baseline_provenance(
|
||||
baseline: dict[str, Any],
|
||||
*,
|
||||
scenarios: list[dict[str, Any]],
|
||||
author_identity: dict[str, Any],
|
||||
runtime_profile_hash: str,
|
||||
) -> list[str]:
|
||||
"""Reject an incomparable baseline before any billable v3 API call."""
|
||||
errors: list[str] = []
|
||||
if baseline.get("schema_version") != BASELINE_SCHEMA_VERSION:
|
||||
errors.append(f"schema_version must equal {BASELINE_SCHEMA_VERSION}")
|
||||
if baseline.get("protocol_version") != BASELINE_PROTOCOL_VERSION:
|
||||
errors.append("protocol_version must equal 2.0")
|
||||
if baseline.get("author") != author_identity:
|
||||
errors.append("author provider/model/API mode/reasoning/sampling does not match the v3 release")
|
||||
if baseline.get("runtime_profile_sha256") != runtime_profile_hash:
|
||||
errors.append("runtime profile hash does not match the v3 release")
|
||||
measurement = str(baseline.get("measurement") or "")
|
||||
if measurement not in MEASUREMENTS:
|
||||
errors.append("measurement must be prompt_tokens or context_chars")
|
||||
baseline_scenarios = baseline.get("scenarios")
|
||||
if not isinstance(baseline_scenarios, list):
|
||||
return [*errors, "scenarios must be an array"]
|
||||
by_scenario = {
|
||||
str(item.get("scenario") or ""): item
|
||||
for item in baseline_scenarios
|
||||
if isinstance(item, dict) and item.get("scenario")
|
||||
}
|
||||
for scenario in scenarios:
|
||||
scenario_id = str(scenario.get("id") or "")
|
||||
expected_request_hash = request_sha256(str(scenario.get("request") or ""))
|
||||
baseline_scenario = by_scenario.get(scenario_id)
|
||||
if not isinstance(baseline_scenario, dict):
|
||||
errors.append(f"missing baseline scenario {scenario_id}")
|
||||
continue
|
||||
if baseline_scenario.get("request_sha256") != expected_request_hash:
|
||||
errors.append(f"baseline request hash differs for {scenario_id}")
|
||||
repetitions = baseline_scenario.get("repetitions")
|
||||
if not isinstance(repetitions, list):
|
||||
errors.append(f"baseline {scenario_id} repetitions must be an array")
|
||||
continue
|
||||
by_repetition = {
|
||||
int(item.get("repetition")): item
|
||||
for item in repetitions
|
||||
if isinstance(item, dict) and isinstance(item.get("repetition"), int)
|
||||
}
|
||||
expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1))
|
||||
if not expected_repetitions.issubset(by_repetition):
|
||||
errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
|
||||
continue
|
||||
for repetition in expected_repetitions:
|
||||
run = by_repetition[repetition]
|
||||
if _non_negative_int(run.get("author_metric")) is None or _non_negative_int(run.get("plan_review_metric")) is None:
|
||||
errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics")
|
||||
for field in ("completed", "final_review_passed", "deterministic_claims_pass"):
|
||||
if not isinstance(run.get(field), bool):
|
||||
errors.append(f"baseline {scenario_id} repetition {repetition} has non-boolean {field}")
|
||||
return errors
|
||||
|
||||
|
||||
def compare_token_baseline(
|
||||
baseline: dict[str, Any],
|
||||
*,
|
||||
scenarios: list[dict[str, Any]],
|
||||
v3_results: list[dict[str, Any]],
|
||||
author_identity: dict[str, Any],
|
||||
runtime_profile_hash: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a complete, fail-closed comparison for the release report."""
|
||||
errors = validate_token_baseline_provenance(
|
||||
baseline, scenarios=scenarios, author_identity=author_identity,
|
||||
runtime_profile_hash=runtime_profile_hash,
|
||||
)
|
||||
measurement = str(baseline.get("measurement") or "")
|
||||
|
||||
baseline_scenarios = baseline.get("scenarios")
|
||||
if not isinstance(baseline_scenarios, list):
|
||||
errors.append("scenarios must be an array")
|
||||
baseline_scenarios = []
|
||||
by_scenario = {
|
||||
str(item.get("scenario") or ""): item
|
||||
for item in baseline_scenarios
|
||||
if isinstance(item, dict) and item.get("scenario")
|
||||
}
|
||||
current_by_scenario: dict[str, list[dict[str, Any]]] = {}
|
||||
for result in v3_results:
|
||||
if isinstance(result, dict) and isinstance(result.get("scenario"), str):
|
||||
current_by_scenario.setdefault(result["scenario"], []).append(result)
|
||||
|
||||
baseline_tokens: list[int] = []
|
||||
current_tokens: list[int] = []
|
||||
baseline_completed: list[bool] = []
|
||||
current_completed: list[bool] = []
|
||||
baseline_final_review: list[bool] = []
|
||||
current_final_review: list[bool] = []
|
||||
baseline_deterministic: list[bool] = []
|
||||
current_deterministic: list[bool] = []
|
||||
per_scenario: list[dict[str, Any]] = []
|
||||
|
||||
for scenario in scenarios:
|
||||
scenario_id = str(scenario.get("id") or "")
|
||||
expected_request_hash = request_sha256(str(scenario.get("request") or ""))
|
||||
baseline_scenario = by_scenario.get(scenario_id)
|
||||
if not isinstance(baseline_scenario, dict):
|
||||
errors.append(f"missing baseline scenario {scenario_id}")
|
||||
continue
|
||||
if baseline_scenario.get("request_sha256") != expected_request_hash:
|
||||
errors.append(f"baseline request hash differs for {scenario_id}")
|
||||
continue
|
||||
repetitions = baseline_scenario.get("repetitions")
|
||||
if not isinstance(repetitions, list) or len(repetitions) < MINIMUM_REPETITIONS:
|
||||
errors.append(f"baseline {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions")
|
||||
continue
|
||||
current = current_by_scenario.get(scenario_id, [])
|
||||
if len(current) < MINIMUM_REPETITIONS:
|
||||
errors.append(f"v3 {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions")
|
||||
continue
|
||||
baseline_by_repetition = {
|
||||
int(item.get("repetition")) : item
|
||||
for item in repetitions
|
||||
if isinstance(item, dict) and isinstance(item.get("repetition"), int)
|
||||
}
|
||||
current_by_repetition = {
|
||||
int(item.get("repetition")): item
|
||||
for item in current
|
||||
if isinstance(item.get("repetition"), int)
|
||||
}
|
||||
expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1))
|
||||
if not expected_repetitions.issubset(baseline_by_repetition):
|
||||
errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
|
||||
continue
|
||||
if not expected_repetitions.issubset(current_by_repetition):
|
||||
errors.append(f"v3 {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
|
||||
continue
|
||||
|
||||
scenario_baseline_tokens: list[int] = []
|
||||
scenario_current_tokens: list[int] = []
|
||||
for repetition in sorted(expected_repetitions):
|
||||
baseline_run = baseline_by_repetition[repetition]
|
||||
author_tokens = _non_negative_int(baseline_run.get("author_metric"))
|
||||
plan_review_tokens = _non_negative_int(baseline_run.get("plan_review_metric"))
|
||||
if author_tokens is None or plan_review_tokens is None:
|
||||
errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics")
|
||||
continue
|
||||
current_run = current_by_repetition[repetition]
|
||||
usage = current_run.get("usage") if isinstance(current_run.get("usage"), dict) else {}
|
||||
v3_author_tokens = _v3_author_metric(usage, measurement)
|
||||
if v3_author_tokens is None:
|
||||
errors.append(f"v3 {scenario_id} repetition {repetition} has invalid author {measurement} usage")
|
||||
continue
|
||||
scenario_baseline_tokens.append(author_tokens + plan_review_tokens)
|
||||
scenario_current_tokens.append(v3_author_tokens)
|
||||
baseline_completed.append(bool(baseline_run.get("completed")))
|
||||
current_completed.append(str((current_run.get("projection") or {}).get("phase") or "") == "COMPLETED")
|
||||
baseline_final_review.append(bool(baseline_run.get("final_review_passed")))
|
||||
current_final_review.append(str((current_run.get("terminal") or {}).get("lifecycle") or "") == "completed")
|
||||
baseline_deterministic.append(bool(baseline_run.get("deterministic_claims_pass")))
|
||||
checks = current_run.get("checks") if isinstance(current_run.get("checks"), dict) else {}
|
||||
current_deterministic.append(bool(checks.get("deterministic_claims_pass")))
|
||||
if len(scenario_baseline_tokens) == MINIMUM_REPETITIONS and len(scenario_current_tokens) == MINIMUM_REPETITIONS:
|
||||
baseline_tokens.extend(scenario_baseline_tokens)
|
||||
current_tokens.extend(scenario_current_tokens)
|
||||
per_scenario.append({
|
||||
"scenario": scenario_id,
|
||||
"baseline_median_metric": median(scenario_baseline_tokens),
|
||||
"v3_median_author_metric": median(scenario_current_tokens),
|
||||
})
|
||||
|
||||
baseline_median = median(baseline_tokens) if baseline_tokens else None
|
||||
current_median = median(current_tokens) if current_tokens else None
|
||||
reduction = (1 - current_median / baseline_median) if baseline_median and current_median is not None else None
|
||||
baseline_valid = not errors
|
||||
checks = {
|
||||
"baseline_valid": baseline_valid,
|
||||
"median_metric_reduction": baseline_valid and reduction is not None and reduction >= TOKEN_REDUCTION_TARGET,
|
||||
"completion_rate_not_lower": baseline_valid and _rate(current_completed) >= _rate(baseline_completed) if baseline_completed and current_completed else False,
|
||||
"final_review_rate_not_lower": baseline_valid and _rate(current_final_review) >= _rate(baseline_final_review) if baseline_final_review and current_final_review else False,
|
||||
"deterministic_claim_rate_not_lower": baseline_valid and _rate(current_deterministic) >= _rate(baseline_deterministic) if baseline_deterministic and current_deterministic else False,
|
||||
"plan_review_calls_removed": baseline_valid and all(
|
||||
not str(record.get("tool") or "").startswith("modeling_plan")
|
||||
for result in v3_results if isinstance(result, dict)
|
||||
for record in ((result.get("usage") or {}).get("records") or [])
|
||||
if isinstance(record, dict)
|
||||
),
|
||||
}
|
||||
return {
|
||||
"schema_version": "cad.token-comparison.v1",
|
||||
"baseline_protocol_version": baseline.get("protocol_version"),
|
||||
"measurement": measurement,
|
||||
"baseline_median_metric": baseline_median,
|
||||
"v3_median_author_metric": current_median,
|
||||
"median_metric_reduction": reduction,
|
||||
"target_median_metric_reduction": TOKEN_REDUCTION_TARGET,
|
||||
"baseline_completion_rate": _rate(baseline_completed),
|
||||
"v3_completion_rate": _rate(current_completed),
|
||||
"baseline_final_review_rate": _rate(baseline_final_review),
|
||||
"v3_final_review_rate": _rate(current_final_review),
|
||||
"baseline_deterministic_claim_rate": _rate(baseline_deterministic),
|
||||
"v3_deterministic_claim_rate": _rate(current_deterministic),
|
||||
"per_scenario": per_scenario,
|
||||
"errors": errors,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def _non_negative_int(value: Any) -> int | None:
|
||||
return value if isinstance(value, int) and value >= 0 else None
|
||||
|
||||
|
||||
def _v3_author_metric(usage: dict[str, Any], measurement: str) -> int | None:
|
||||
records = usage.get("records")
|
||||
if not isinstance(records, list):
|
||||
return None
|
||||
author_records = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"]
|
||||
if not author_records:
|
||||
return None
|
||||
if measurement == "prompt_tokens" and not all(item.get("usage_available") is True for item in author_records):
|
||||
return None
|
||||
field = "prompt_tokens" if measurement == "prompt_tokens" else "context_chars"
|
||||
values = [
|
||||
_non_negative_int(item.get(field))
|
||||
for item in author_records
|
||||
]
|
||||
return sum(value for value in values if value is not None) if all(value is not None for value in values) else None
|
||||
|
||||
|
||||
def _rate(values: list[bool]) -> float | None:
|
||||
return sum(values) / len(values) if values else None
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Run real-provider CAD tasks with a usable-model success criterion.
|
||||
|
||||
This evaluator is intentionally narrower than the release gate in ``live.py``.
|
||||
It answers one operational question: can the current workflow reliably finish
|
||||
ordinary prompts with a downloadable/previewable checkpoint, even if some
|
||||
acceptance claims remain best-effort warnings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.settings import BACKEND_ROOT, get_settings
|
||||
|
||||
|
||||
DEFAULT_PROMPTS = (
|
||||
"生成一个 80 mm x 50 mm x 8 mm 的简单矩形板,使用毫米,输出一个单一实体。",
|
||||
"生成一个简单法兰,外径 100 mm,厚度 10 mm,中间有 30 mm 通孔,使用毫米。",
|
||||
"生成一个圆柱垫块,直径 60 mm,高度 20 mm,中间有 20 mm 通孔,使用毫米。",
|
||||
)
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run real CAD generation prompts and require usable model artifacts.")
|
||||
parser.add_argument("--author-provider")
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
parser.add_argument("--prompt", action="append", help="Prompt to run. Repeat for multiple prompts. Defaults to three simple CAD prompts.")
|
||||
parser.add_argument("--max-wall-seconds", type=int, default=1200)
|
||||
parser.add_argument("--max-model-calls", type=int, default=60)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _result_code(events: list[dict[str, Any]], projection: dict[str, Any]) -> str:
|
||||
terminal = next((item["payload"] for item in reversed(events) if item.get("name") == "task_terminal"), {})
|
||||
for source in (terminal, projection):
|
||||
code = source.get("code") or source.get("last_error") if isinstance(source, dict) else ""
|
||||
if isinstance(code, str) and code:
|
||||
return code
|
||||
return ""
|
||||
|
||||
|
||||
def _artifact_ok(artifact_root: Path, revision_id: str) -> bool:
|
||||
if not revision_id:
|
||||
return False
|
||||
revision_root = artifact_root / "revisions" / revision_id
|
||||
return all((revision_root / name).is_file() for name in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json"))
|
||||
|
||||
|
||||
def _classify_failure(code: str, projection: dict[str, Any]) -> str:
|
||||
if str(projection.get("lifecycle") or "") == "waiting_retry":
|
||||
return "configuration_or_service"
|
||||
if code in {"FAILED_INTERNAL", "RUNTIME_CONTRACT_INVALID", "REQUIREMENTS_SPEC_INVALID", "STORAGE_FAILURE", "RENDER_SERVICE_UNAVAILABLE"}:
|
||||
return "code_or_flow"
|
||||
if code in {"AUTHOR_TRANSPORT_UNAVAILABLE", "REVIEW_SERVICE_UNAVAILABLE", "MODEL_PROTOCOL_CHECK_PENDING"}:
|
||||
return "configuration_or_service"
|
||||
if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "CANDIDATE_BUILD_FAILED", "CLAIM_VERIFICATION_FAILED", "CANDIDATE_REVIEW_REJECTED", "NO_PROGRESS_LIMIT", "BEST_EFFORT_COMPLETED"}:
|
||||
return "model_output_or_best_effort"
|
||||
return "unknown"
|
||||
|
||||
|
||||
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
try:
|
||||
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
|
||||
if arguments.review_provider or arguments.review_model:
|
||||
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
|
||||
else:
|
||||
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
|
||||
except ValueError as error:
|
||||
return {"status": "blocked", "error": str(error), "results": []}
|
||||
isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations")
|
||||
services = compose_v3(isolated)
|
||||
services.workflow.config = replace(
|
||||
services.workflow.config,
|
||||
max_turns=max(8, arguments.max_model_calls * 3),
|
||||
max_model_calls=arguments.max_model_calls,
|
||||
)
|
||||
prompts = tuple(arguments.prompt or DEFAULT_PROMPTS)
|
||||
results: list[dict[str, Any]] = []
|
||||
for index, prompt in enumerate(prompts, start=1):
|
||||
task_id = f"cad_{secrets.token_hex(6)}"
|
||||
services.workflow.create_task(task_id, prompt)
|
||||
events: list[dict[str, Any]] = []
|
||||
started = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with asyncio.timeout(arguments.max_wall_seconds):
|
||||
async for name, payload in services.workflow.run(
|
||||
task_id=task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
):
|
||||
events.append({"name": name, "payload": payload})
|
||||
except TimeoutError:
|
||||
events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT", "message": "Task timed out."}})
|
||||
projection = services.repository.get_task_projection(task_id) or {}
|
||||
artifact_root = (isolated.task_root / task_id).resolve()
|
||||
revision_id = str(projection.get("active_revision") or projection.get("current_revision") or "")
|
||||
code = _result_code(events, projection)
|
||||
usable = str(projection.get("lifecycle") or "") == "completed" and _artifact_ok(artifact_root, revision_id)
|
||||
results.append({
|
||||
"index": index,
|
||||
"task_id": task_id,
|
||||
"prompt": prompt,
|
||||
"success": usable,
|
||||
"failure_layer": "" if usable else _classify_failure(code, projection),
|
||||
"code": code,
|
||||
"phase": projection.get("phase"),
|
||||
"lifecycle": projection.get("lifecycle"),
|
||||
"active_revision": revision_id,
|
||||
"verification_status": projection.get("verification_status"),
|
||||
"artifact_root": str(artifact_root),
|
||||
"duration_ms": round((datetime.now(timezone.utc) - started).total_seconds() * 1000),
|
||||
"usage": services.repository.usage_summary(task_id),
|
||||
"event_audit": [
|
||||
{
|
||||
"name": item.get("name"),
|
||||
"status": (item.get("payload") or {}).get("status"),
|
||||
"lifecycle": (item.get("payload") or {}).get("lifecycle"),
|
||||
"code": ((item.get("payload") or {}).get("result") or {}).get("code") if isinstance((item.get("payload") or {}).get("result"), dict) else (item.get("payload") or {}).get("code"),
|
||||
"tool": (item.get("payload") or {}).get("tool"),
|
||||
}
|
||||
for item in events
|
||||
],
|
||||
})
|
||||
return {
|
||||
"status": "passed" if results and all(item["success"] for item in results) else "failed",
|
||||
"schema_version": "cad.usable-smoke.v1",
|
||||
"author": {"provider": author_provider.id, "model": author_model.id},
|
||||
"reviewer": {"provider": review_provider.id, "model": review_model.id},
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
report_root.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
result = asyncio.run(_run(arguments, report_root))
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except BaseException as error:
|
||||
result = {"status": "blocked", "error": f"UNEXPECTED_USABLE_SMOKE_ERROR: {type(error).__name__}: {str(error)[:1000]}", "results": []}
|
||||
result.update({"report_root": str(report_root.resolve())})
|
||||
report_path = report_root / "usable-smoke-report.json"
|
||||
report_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({"status": result["status"], "report": str(report_path.resolve())}, ensure_ascii=False))
|
||||
return 0 if result["status"] == "passed" else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -5,60 +5,13 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .domain.state import TaskPhase, TaskState
|
||||
from .domain.state import TaskState
|
||||
|
||||
|
||||
class AdapterUnavailable(RuntimeError):
|
||||
"""A bounded external-service outage; handlers must preserve checkpoints."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorGuidanceSelection:
|
||||
"""Non-authoritative author context selected from the local guidance corpus."""
|
||||
|
||||
version: str = ""
|
||||
section_ids: tuple[str, ...] = ()
|
||||
content: str = ""
|
||||
enabled: bool = False
|
||||
fallback_reason: str = ""
|
||||
|
||||
def usage_metadata(self) -> dict[str, object]:
|
||||
return {
|
||||
"guidance_version": self.version,
|
||||
"guidance_section_ids": list(self.section_ids),
|
||||
"guidance_chars": len(self.content),
|
||||
"guidance_enabled": self.enabled,
|
||||
"guidance_fallback_reason": self.fallback_reason,
|
||||
}
|
||||
|
||||
|
||||
class AuthorGuidance(Protocol):
|
||||
"""Select bounded local author guidance without interpreting user intent."""
|
||||
|
||||
def select(
|
||||
self,
|
||||
*,
|
||||
phase: TaskPhase,
|
||||
atomic_id: str,
|
||||
repair_required: bool,
|
||||
supported_atomic_ids: tuple[str, ...],
|
||||
) -> AuthorGuidanceSelection: ...
|
||||
|
||||
|
||||
class NullAuthorGuidance:
|
||||
"""Compatibility default that retains the pre-guidance author prompt."""
|
||||
|
||||
def select(
|
||||
self,
|
||||
*,
|
||||
phase: TaskPhase,
|
||||
atomic_id: str,
|
||||
repair_required: bool,
|
||||
supported_atomic_ids: tuple[str, ...],
|
||||
) -> AuthorGuidanceSelection:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_not_configured")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InvocationRecord:
|
||||
invocation_id: str
|
||||
@@ -68,7 +21,7 @@ class InvocationRecord:
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CandidateStage:
|
||||
class StagingRevision:
|
||||
stage_id: str
|
||||
output_dir: str
|
||||
|
||||
@@ -106,38 +59,21 @@ class TaskRepository(Protocol):
|
||||
|
||||
class ArtifactStore(Protocol):
|
||||
def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> None: ...
|
||||
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ...
|
||||
def write_source_index(self, task_id: str, request: str) -> dict[str, str]: ...
|
||||
def read_source_index(self, task_id: str) -> dict[str, str]: ...
|
||||
def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ...
|
||||
def read_source_requirements(self, task_id: str) -> str: ...
|
||||
def source_image_paths(self, task_id: str) -> list[str]: ...
|
||||
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
|
||||
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
|
||||
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: ...
|
||||
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: ...
|
||||
def write_json_once(self, task_id: str, relative_path: str, payload: dict[str, Any]) -> str: ...
|
||||
def write_text_once(self, task_id: str, relative_path: str, text: str) -> str: ...
|
||||
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ...
|
||||
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ...
|
||||
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage: ...
|
||||
def stage_output_dir(self, task_id: str, stage_id: str) -> str: ...
|
||||
def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision: ...
|
||||
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str: ...
|
||||
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None: ...
|
||||
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ...
|
||||
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None: ...
|
||||
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None: ...
|
||||
def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ...
|
||||
|
||||
|
||||
class CadRuntime(Protocol):
|
||||
def supported_atomic_ids(self) -> tuple[str, ...]: ...
|
||||
def operation_contract(self, atomic_id: str) -> dict[str, Any]: ...
|
||||
def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]: ...
|
||||
def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]: ...
|
||||
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False, depends_on_feature_ids: tuple[str, ...] | list[str] = ()) -> tuple[dict[str, Any], dict[str, Any]]: ...
|
||||
def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
|
||||
def create_preview(self, output_dir: str) -> dict[str, Any]: ...
|
||||
def render_review_bundle(self, output_dir: str) -> dict[str, Any]: ...
|
||||
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
|
||||
def compile_authoring(self, document: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: ...
|
||||
def rebuild_best_effort(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]: ...
|
||||
|
||||
|
||||
@@ -146,13 +82,5 @@ class ModelGateway(Protocol):
|
||||
async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class ReviewGateway(Protocol):
|
||||
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class VerifierExecutor(Protocol):
|
||||
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]: ...
|
||||
|
||||
|
||||
class EventPublisher(Protocol):
|
||||
async def publish(self, event: dict[str, Any]) -> None: ...
|
||||
|
||||
+22
-123
@@ -7,13 +7,11 @@ from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.models.contracts import ChatRequest, ConversationPatch
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler
|
||||
from app.services.agent_service import AgentService
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id
|
||||
from app.services.attachments import attachment_record, classify_upload, extract_document_text
|
||||
from app.services.image_processing import image_metadata
|
||||
from app.services.review_renderer import renderer_status
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
@@ -57,12 +55,6 @@ async def config() -> dict[str, Any]:
|
||||
for model in provider.models
|
||||
],
|
||||
})
|
||||
try:
|
||||
settings.resolve_review_model()
|
||||
renderer_ready, renderer_detail = renderer_status()
|
||||
review_error = "" if renderer_ready else renderer_detail
|
||||
except ValueError as error:
|
||||
review_error = str(error)
|
||||
return {
|
||||
"default_provider": settings.default_provider_id,
|
||||
"default_model": settings.llm_model,
|
||||
@@ -71,8 +63,6 @@ async def config() -> dict[str, Any]:
|
||||
"configured": settings.llm_configured,
|
||||
"library_samples": library.count(),
|
||||
"autonomous_generation": settings.autonomous_generation,
|
||||
"review_configured": not review_error,
|
||||
"review_error": review_error,
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +113,7 @@ async def upload_conversation_attachment(
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
active_task_id = str(current.get("current_task_id") or "")
|
||||
active_task = agent.v3.repository.get_task_projection(active_task_id) if active_task_id else None
|
||||
active_task = agent.cad.repository.get_task_projection(active_task_id) if active_task_id else None
|
||||
if str((active_task or {}).get("lifecycle") or "") == "running":
|
||||
raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state")
|
||||
kind = classify_upload(filename, file.content_type or "", len(data))
|
||||
@@ -145,101 +135,25 @@ async def upload_conversation_attachment(
|
||||
async def read_task(task_id: str) -> JSONResponse:
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
task = agent.v3.repository.get_task_projection(safe_id)
|
||||
task = agent.cad.repository.get_task_projection(safe_id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
|
||||
state = agent.v3.repository.get_state(safe_id)
|
||||
task["requirements_spec"] = agent.v3.artifacts.read_requirements_spec(safe_id, state.requirements_spec_path) if state is not None else None
|
||||
task["requirements_contract"] = agent.v3.artifacts.read_requirements_contract(safe_id, state.requirements_contract_path) if state is not None else None
|
||||
task["claim_summary"] = _claim_summary(task["requirements_contract"], task.get("action_ledger_summary"))
|
||||
task["checklist_progress"] = _checklist_progress(task["requirements_contract"], task["claim_summary"])
|
||||
requirements_path = agent.v3.artifacts.artifact_path(safe_id, state.requirements_document_path) if state is not None and state.requirements_document_path else None
|
||||
task["requirements_markdown"] = requirements_path.read_text(encoding="utf-8") if requirements_path and requirements_path.is_file() else None
|
||||
target_path = agent.v3.artifacts.artifact_path(safe_id, state.completion_target_path) if state is not None and state.completion_target_path else None
|
||||
plan_path = agent.v3.artifacts.artifact_path(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
|
||||
result_path = agent.v3.artifacts.task_dir(safe_id) / "completion-result.md"
|
||||
task["completion_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path and target_path.is_file() else None
|
||||
task["completion_target_path"] = state.completion_target_path if target_path and target_path.is_file() else ""
|
||||
task["feature_plan"] = agent.v3.artifacts.read_json(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
|
||||
task["feature_plan_path"] = state.feature_plan_path if plan_path and plan_path.is_file() else ""
|
||||
task["feature_plan_hash"] = state.feature_plan_hash if state is not None else ""
|
||||
if isinstance(task["feature_plan"], dict):
|
||||
plan = FeaturePlan.model_validate(task["feature_plan"])
|
||||
statuses = FeatureScheduler(plan, agent.v3.repository.ledger_events(safe_id)).statuses()
|
||||
node_evidence = {str(item.get("node_id") or ""): item for item in task.get("feature_nodes") or () if isinstance(item, dict)}
|
||||
task["feature_nodes"] = [
|
||||
{
|
||||
"node_id": str(node.get("node_id") or ""), "intent": str(node.get("intent") or ""),
|
||||
"atomic_id": str(node.get("atomic_id") or ""), "priority": node.get("priority"),
|
||||
"depends_on": node.get("depends_on") or [], "claim_ids": node.get("claim_ids") or [],
|
||||
"status": statuses.get(str(node.get("node_id") or ""), "pending"),
|
||||
**node_evidence.get(str(node.get("node_id") or ""), {}),
|
||||
}
|
||||
for node in task["feature_plan"].get("nodes") or () if isinstance(node, dict)
|
||||
]
|
||||
task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path.is_file() else None
|
||||
task["completion_result_path"] = "completion-result.md" if result_path.is_file() else ""
|
||||
task["usage"] = agent.v3.repository.usage_summary(safe_id)
|
||||
task["preview_revision"] = str(task.get("active_revision") or "")
|
||||
state = agent.cad.repository.get_state(safe_id)
|
||||
task["requirements_analysis"] = agent.cad.artifacts.read_json(safe_id, state.requirements_path) if state and state.requirements_path else None
|
||||
task["authoring_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.authoring_path) if state and state.authoring_path else None
|
||||
task["runtime_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.runtime_cdsl_path) if state and state.runtime_cdsl_path else None
|
||||
task["compile_audit"] = agent.cad.artifacts.read_json(safe_id, state.compile_audit_path) if state and state.compile_audit_path else None
|
||||
task["diagnostics"] = agent.cad.artifacts.read_json(safe_id, state.diagnostics_path) if state and state.diagnostics_path else None
|
||||
task["claim_report"] = agent.cad.artifacts.read_json(safe_id, "documents/claim-report.json")
|
||||
result_path = agent.cad.artifacts.artifact_path(safe_id, state.completion_path) if state and state.completion_path else None
|
||||
task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path and result_path.is_file() else None
|
||||
task["usage"] = agent.cad.repository.usage_summary(safe_id)
|
||||
return JSONResponse(task)
|
||||
|
||||
|
||||
def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[str, Any]]:
|
||||
"""Project frozen claims with the newest committed verification evidence.
|
||||
|
||||
This is API-only data derived from SQLite-backed ledger entries and the
|
||||
state-referenced frozen contract. It never parses Markdown or exposes a
|
||||
staged candidate as a task checkpoint.
|
||||
"""
|
||||
latest: dict[str, dict[str, Any]] = {}
|
||||
for event in reversed(ledger if isinstance(ledger, list) else []):
|
||||
if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed", "feature_node_verified", "final_visual_reviewed"}:
|
||||
continue
|
||||
values = event.get("claim_results")
|
||||
if not isinstance(values, list):
|
||||
values = event.get("coverage")
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
for value in values:
|
||||
if isinstance(value, dict) and isinstance(value.get("claim_id"), str) and value["claim_id"] not in latest:
|
||||
latest[value["claim_id"]] = value
|
||||
result: list[dict[str, Any]] = []
|
||||
for requirement in (contract or {}).get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if not isinstance(claim, dict) or not isinstance(claim.get("claim_id"), str):
|
||||
continue
|
||||
evidence = latest.get(claim["claim_id"], {})
|
||||
result.append({
|
||||
"requirement_id": str(requirement.get("requirement_id") or ""),
|
||||
"claim_id": claim["claim_id"],
|
||||
"claim_kind": str(claim.get("claim_kind") or ""),
|
||||
"deterministic": claim.get("verification_mode") == "deterministic",
|
||||
"status": str(evidence.get("status") or "pending"),
|
||||
"evidence": evidence.get("evidence") if isinstance(evidence.get("evidence"), dict) else {},
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _checklist_progress(contract: dict[str, Any] | None, claims: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Expose server-evaluated checklist progress without parsing Markdown."""
|
||||
by_claim = {str(item.get("claim_id") or ""): str(item.get("status") or "pending") for item in claims if isinstance(item, dict)}
|
||||
progress: list[dict[str, Any]] = []
|
||||
for requirement in (contract or {}).get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
statuses = [by_claim.get(str(claim.get("claim_id") or ""), "pending") for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)]
|
||||
progress.append({
|
||||
"requirement_id": str(requirement.get("requirement_id") or ""),
|
||||
"statement": str(requirement.get("statement") or ""),
|
||||
"status": "pass" if statuses and all(status == "pass" for status in statuses) else "fail" if "fail" in statuses or "unavailable" in statuses else "pending",
|
||||
})
|
||||
return progress
|
||||
|
||||
|
||||
@app.delete("/v1/tasks/{task_id}")
|
||||
async def cancel_task(task_id: str) -> JSONResponse:
|
||||
try:
|
||||
@@ -281,19 +195,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
path = agent.v3.artifacts.artifact_path(safe_id, artifact_path)
|
||||
path = agent.cad.artifacts.artifact_path(safe_id, artifact_path)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
task = agent.v3.repository.get_task_projection(safe_id) or {}
|
||||
task = agent.cad.repository.get_task_projection(safe_id) or {}
|
||||
parts = artifact_path.split("/")
|
||||
revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else ""
|
||||
published_revision = str(task.get("active_revision") or "") if str(task.get("lifecycle") or "") == "completed" else ""
|
||||
published_revision = str(task.get("published_revision") or "")
|
||||
active_revision = str(task.get("active_revision") or task.get("current_revision") or "")
|
||||
if not revision_id:
|
||||
# The task directory also contains candidate staging, agent audit and
|
||||
# frozen-input files. None of those are a public artifact surface.
|
||||
if artifact_path == "completion-result.md" and str(task.get("completion_path") or "") == artifact_path:
|
||||
return FileResponse(path, filename=path.name)
|
||||
raise HTTPException(status_code=403, detail="This task artifact is not public")
|
||||
|
||||
if revision_id == published_revision:
|
||||
@@ -302,30 +216,15 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
f"revisions/{revision_id}/model.step",
|
||||
f"revisions/{revision_id}/model.glb",
|
||||
f"revisions/{revision_id}/rebuild-report.json",
|
||||
f"revisions/{revision_id}/build-diagnostics.json",
|
||||
f"revisions/{revision_id}/renders/render-manifest.json",
|
||||
}
|
||||
if artifact_path not in published_paths:
|
||||
raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable")
|
||||
return FileResponse(path, filename=path.name)
|
||||
|
||||
v32_checkpoint_paths = {
|
||||
f"revisions/{revision_id}/model.cdsl.json",
|
||||
f"revisions/{revision_id}/model.step",
|
||||
f"revisions/{revision_id}/model.glb",
|
||||
f"revisions/{revision_id}/rebuild-report.json",
|
||||
}
|
||||
verified_revisions = {
|
||||
str(item.get("revision_id") or "")
|
||||
for item in task.get("revisions") or ()
|
||||
if isinstance(item, dict) and item.get("status") == "success"
|
||||
}
|
||||
# Each v3.2 node revision is immutable and manifest-published. Make those
|
||||
# checkpoints inspectable from the DAG while keeping every staging input,
|
||||
# rejected candidate and arbitrary task artifact private.
|
||||
if task.get("schema_version") == "3.2" and revision_id in verified_revisions and artifact_path in v32_checkpoint_paths:
|
||||
return FileResponse(path, media_type="model/gltf-binary" if path.suffix == ".glb" else None, headers={"Content-Disposition": "inline" if path.suffix == ".glb" else f"attachment; filename={path.name}"})
|
||||
|
||||
# A legacy running task may render its active checkpoint in the browser,
|
||||
# but cannot expose any other checkpoint artifact or failed-task preview.
|
||||
# A running task can render its active executable prefix, but its staging
|
||||
# inputs and diagnostics remain private until publication.
|
||||
if (
|
||||
str(task.get("lifecycle") or "") != "running"
|
||||
or revision_id != active_revision
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""HTTP/SSE delivery adapter for the protocol v3 workflow."""
|
||||
|
||||
"""HTTP/SSE delivery adapter for the single-stage CAD protocol."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -10,8 +9,7 @@ import secrets
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.application.capabilities import cached_model_capability, verify_model_capability
|
||||
from app.cad_agent.composition import V3Services, compose_v3
|
||||
from app.cad_agent.composition import CadServices, compose_cad_services
|
||||
from app.cad_agent.domain.errors import ErrorCode
|
||||
from app.cad_agent.domain.state import TaskPhase, transition
|
||||
from app.models.contracts import ChatMessage
|
||||
@@ -25,480 +23,174 @@ def text_from_message(message: ChatMessage) -> str:
|
||||
return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip()
|
||||
|
||||
|
||||
def _response_language(text: str) -> str:
|
||||
cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff")
|
||||
latin = sum(1 for char in text if char.isascii() and char.isalpha())
|
||||
return "Chinese" if cjk >= 2 and cjk >= latin * 0.15 else "English"
|
||||
|
||||
|
||||
_EVENT_LABELS = {
|
||||
"image_observation": "参考图片观察",
|
||||
"requirements_document_ready": "需求文档已冻结",
|
||||
"completion_target_ready": "完成目标已冻结",
|
||||
"requirements_compiled": "需求合同已编译",
|
||||
"modeling_plan_ready": "建模计划已冻结",
|
||||
"completion_result_ready": "完成结果已就绪",
|
||||
"model_protocol_check": "模型协议检查",
|
||||
"action_selection": "动作选择",
|
||||
"tool_call": "建模工具",
|
||||
"candidate_result": "候选构建",
|
||||
"candidate_review": "候选独立复核",
|
||||
"final_review": "最终独立复核",
|
||||
"requirements_ready": "需求分析",
|
||||
"authoring_cdsl_ready": "完整 CDSL",
|
||||
"cdsl_compiled": "CDSL 编译",
|
||||
"build_result": "CAD 构建",
|
||||
"repair_started": "CDSL 修复",
|
||||
"task_terminal": "生成任务",
|
||||
"state_changed": "任务状态",
|
||||
}
|
||||
|
||||
|
||||
def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
def _progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
lifecycle = str(payload.get("lifecycle") or "")
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||
status = "error" if lifecycle == "failed" or str(payload.get("status") or "") == "error" or str(result.get("status") or "") in {"rejected", "repair"} else "waiting" if lifecycle in {"waiting_for_user", "waiting_retry"} else "success" if lifecycle == "completed" else str(payload.get("status") or "running")
|
||||
status = "error" if lifecycle == "failed" or payload.get("status") in {"failed", "repair_required"} else "waiting" if lifecycle == "waiting_for_user" else "success" if lifecycle == "completed" else "running"
|
||||
return {**payload, "step": name, "label": _EVENT_LABELS.get(name, name), "status": status}
|
||||
|
||||
|
||||
def _upsert_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None:
|
||||
part_id = str(part.get("id") or "")
|
||||
if part_id:
|
||||
for index, existing in enumerate(parts):
|
||||
if str(existing.get("id") or "") == part_id:
|
||||
parts[index] = part
|
||||
return
|
||||
parts.append(part)
|
||||
|
||||
|
||||
class AgentService:
|
||||
"""Delivery boundary: no CAD state transitions or provider calls live here."""
|
||||
|
||||
def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None:
|
||||
self.settings = settings
|
||||
self.store = store # Conversation/attachment store, not v3 CAD state.
|
||||
self.library = library
|
||||
self.v3: V3Services = compose_v3(settings)
|
||||
self.settings, self.store, self.library = settings, store, library
|
||||
self.cad: CadServices = compose_cad_services(settings)
|
||||
self._autonomous_runs: dict[str, asyncio.Task[None]] = {}
|
||||
|
||||
async def resume_running_tasks(self) -> None:
|
||||
task_ids = self.v3.repository.running_task_ids()
|
||||
try:
|
||||
author_provider, author_model = self.settings.resolve_model(None, None)
|
||||
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||
except ValueError as error:
|
||||
await self._park_startup_tasks(
|
||||
task_ids,
|
||||
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
|
||||
str(error),
|
||||
retryable=False,
|
||||
)
|
||||
return
|
||||
try:
|
||||
author_capability, reviewer_capability = await asyncio.gather(
|
||||
verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=author_provider.id,
|
||||
model_id=author_model.id,
|
||||
role="author",
|
||||
),
|
||||
verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=review_provider.id,
|
||||
model_id=review_model.id,
|
||||
role="reviewer",
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
# Startup recovery must never bypass a production capability gate.
|
||||
# Connectivity failures are recoverable, but they may not leave a
|
||||
# task falsely marked running without a worker.
|
||||
await self._park_startup_tasks(
|
||||
task_ids,
|
||||
ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE,
|
||||
str(error),
|
||||
retryable=True,
|
||||
)
|
||||
return
|
||||
if author_capability.get("probe_unavailable") or reviewer_capability.get("probe_unavailable"):
|
||||
await self._park_startup_tasks(
|
||||
task_ids,
|
||||
ErrorCode.MODEL_PROTOCOL_CHECK_PENDING,
|
||||
"Model protocol check is temporarily unavailable.",
|
||||
retryable=True,
|
||||
)
|
||||
return
|
||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||
await self._park_startup_tasks(
|
||||
task_ids,
|
||||
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
|
||||
"Selected author or reviewer did not pass the v3 conformance suite.",
|
||||
retryable=False,
|
||||
)
|
||||
return
|
||||
if not self.settings.resume_running_tasks_on_startup:
|
||||
return
|
||||
for task_id in task_ids:
|
||||
if task_id in self._autonomous_runs:
|
||||
continue
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(
|
||||
self._consume_discarding(task_id, ModelIdentity(author_provider.id, author_model.id), ModelIdentity(review_provider.id, review_model.id)),
|
||||
name=f"resume-autonomous-cad-v3-{task_id}",
|
||||
)
|
||||
|
||||
async def _park_startup_tasks(
|
||||
self,
|
||||
task_ids: list[str],
|
||||
error: ErrorCode,
|
||||
message: str,
|
||||
*,
|
||||
retryable: bool,
|
||||
) -> None:
|
||||
"""Make a failed startup gate durable instead of leaving phantom runs."""
|
||||
event_name = "waiting_retry" if retryable else "failed_model_capability"
|
||||
for task_id in task_ids:
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
if state is None or task_id in self._autonomous_runs:
|
||||
continue
|
||||
try:
|
||||
next_state = transition(
|
||||
state,
|
||||
"waiting_retry" if retryable else "failed",
|
||||
error=error,
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
if self.v3.repository.compare_and_swap(next_state, events=[{
|
||||
"event": event_name,
|
||||
"code": error.value,
|
||||
"message": message[:1000],
|
||||
"startup_recovery": True,
|
||||
}]):
|
||||
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||
|
||||
async def _consume_discarding(self, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> None:
|
||||
try:
|
||||
async for _name, _payload in self.v3.workflow.run(task_id=task_id, author=author, reviewer=reviewer):
|
||||
# A resumed task has no active SSE client, but its durable
|
||||
# state events must still leave the transactional outbox.
|
||||
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||
provider, model = self.settings.resolve_model(None, None)
|
||||
except ValueError:
|
||||
return
|
||||
author = ModelIdentity(provider.id, model.id)
|
||||
for task_id in self.cad.repository.running_task_ids():
|
||||
if task_id not in self._autonomous_runs:
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, author), name=f"resume-cad-single-stage-{task_id}")
|
||||
|
||||
async def _consume_discarding(self, task_id: str, author: ModelIdentity) -> None:
|
||||
try:
|
||||
async for _name, _payload in self.cad.workflow.run(task_id=task_id, author=author):
|
||||
await self.cad.outbox.dispatch_pending(task_id=task_id)
|
||||
finally:
|
||||
# Flush a final transition emitted immediately before the worker
|
||||
# exits, such as WAITING_RETRY or FAILED_INTERNAL.
|
||||
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||
await self.cad.outbox.dispatch_pending(task_id=task_id)
|
||||
self._autonomous_runs.pop(task_id, None)
|
||||
|
||||
async def cancel(self, task_id: str) -> dict[str, Any] | None:
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
state = self.cad.repository.get_state(task_id)
|
||||
if state is None:
|
||||
return None
|
||||
if state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
cancelled = transition(state, "cancelled", error=ErrorCode.CANCELLED)
|
||||
if self.v3.repository.compare_and_swap(cancelled, events=[{
|
||||
"event": "task_cancelled",
|
||||
"from_phase": state.phase.value,
|
||||
"active_revision": state.active_revision,
|
||||
"checkpoint_preserved": bool(state.active_revision),
|
||||
}]):
|
||||
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||
# Persist the terminal transition before interrupting the coroutine.
|
||||
# A concurrent worker will fail its optimistic CAS instead of reviving
|
||||
# the task after the caller has requested cancellation.
|
||||
self.cad.repository.compare_and_swap(cancelled, events=[{"event": "task_cancelled", "active_revision": state.active_revision}])
|
||||
await self.cad.outbox.dispatch_pending(task_id=task_id)
|
||||
running = self._autonomous_runs.pop(task_id, None)
|
||||
if running and not running.done():
|
||||
running.cancel()
|
||||
return self.v3.repository.get_task_projection(task_id)
|
||||
return self.cad.repository.get_task_projection(task_id)
|
||||
|
||||
async def resume_retry(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""Explicitly restart one durably parked infrastructure retry.
|
||||
|
||||
``WAITING_FOR_USER`` is deliberately excluded: it requires new user
|
||||
input, while this endpoint is only the controlled recovery route for
|
||||
bounded provider/render/storage failures.
|
||||
"""
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
state = self.cad.repository.get_state(task_id)
|
||||
if state is None:
|
||||
return None
|
||||
if state.phase.value != "WAITING_RETRY":
|
||||
raise ValueError("Only a WAITING_RETRY CAD task can be resumed through this endpoint")
|
||||
running = self._autonomous_runs.get(task_id)
|
||||
if running is not None and not running.done():
|
||||
if state.phase != TaskPhase.FAILED or state.retry_from_phase is None:
|
||||
raise ValueError("Only a retryable failed CAD task can be resumed")
|
||||
if task_id in self._autonomous_runs and not self._autonomous_runs[task_id].done():
|
||||
raise ValueError("The CAD task is already running")
|
||||
author_provider, author_model = self.settings.resolve_model(None, None)
|
||||
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||
author_capability = await verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=author_provider.id,
|
||||
model_id=author_model.id,
|
||||
role="author",
|
||||
)
|
||||
reviewer_capability = await verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=review_provider.id,
|
||||
model_id=review_model.id,
|
||||
role="reviewer",
|
||||
)
|
||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||
raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite")
|
||||
if not self.v3.workflow.resume(task_id):
|
||||
raise ValueError("The CAD task no longer has a recoverable retry checkpoint")
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(
|
||||
self._consume_discarding(
|
||||
task_id,
|
||||
ModelIdentity(author_provider.id, author_model.id),
|
||||
ModelIdentity(review_provider.id, review_model.id),
|
||||
),
|
||||
name=f"resume-autonomous-cad-v3-{task_id}",
|
||||
)
|
||||
return self.v3.repository.get_task_projection(task_id)
|
||||
provider, model = self.settings.resolve_model(None, None)
|
||||
if not self.cad.workflow.resume(task_id):
|
||||
raise ValueError("The CAD task no longer has a retry checkpoint")
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, ModelIdentity(provider.id, model.id)), name=f"resume-cad-single-stage-{task_id}")
|
||||
return self.cad.repository.get_task_projection(task_id)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
conversation_id: str | None,
|
||||
selected_task_id: str | None,
|
||||
provider_id: str | None = None,
|
||||
model_id: str | None = None,
|
||||
viewer_context: list[dict[str, Any]] | None = None,
|
||||
) -> AsyncIterator[bytes]:
|
||||
async def stream(self, messages: list[ChatMessage], conversation_id: str | None, selected_task_id: str | None, provider_id: str | None = None, model_id: str | None = None, viewer_context: list[dict[str, Any]] | None = None) -> AsyncIterator[bytes]:
|
||||
del viewer_context
|
||||
latest_user = next((message for message in reversed(messages) if message.role == "user"), None)
|
||||
if latest_user is None or not text_from_message(latest_user):
|
||||
latest = next((item for item in reversed(messages) if item.role == "user"), None)
|
||||
if latest is None or not text_from_message(latest):
|
||||
yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."})
|
||||
yield event("done", {})
|
||||
return
|
||||
request = text_from_message(latest_user)
|
||||
request = text_from_message(latest)
|
||||
conversation = self.store.ensure_conversation(conversation_id)
|
||||
selected = str(selected_task_id or conversation.get("current_task_id") or "")
|
||||
current = self.v3.repository.get_task_projection(selected) if selected else None
|
||||
resumed_task_id = ""
|
||||
if current and str(current.get("lifecycle") or "") == "waiting_for_user":
|
||||
state = self.v3.repository.get_state(selected)
|
||||
if state is not None:
|
||||
terminal = self.v3.workflow.waiting_for_user_terminal(selected, state)
|
||||
fields = [
|
||||
{"path": "/requirements/clarification", "message": str(question)}
|
||||
for question in terminal.get("questions") or ()
|
||||
if str(question).strip()
|
||||
]
|
||||
fields.extend(
|
||||
{"path": "/requirements", "message": str(issue)}
|
||||
for issue in terminal.get("issues") or ()
|
||||
if str(issue).strip()
|
||||
)
|
||||
if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id):
|
||||
yield event("cad_error", {
|
||||
"stage": "request",
|
||||
"message": "This parked CAD task cannot apply the supplied clarification. " + str(terminal["message"]),
|
||||
"fieldErrors": fields,
|
||||
"taskId": selected,
|
||||
"blockerType": terminal.get("blockerType"),
|
||||
"userActionRequired": terminal.get("userActionRequired"),
|
||||
})
|
||||
yield event("done", {})
|
||||
return
|
||||
resumed_task_id = selected
|
||||
if current and str(current.get("lifecycle") or "") == "running":
|
||||
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
|
||||
current = self.cad.repository.get_task_projection(selected) if selected else None
|
||||
resumed = False
|
||||
if current and current.get("lifecycle") == "waiting_for_user":
|
||||
resumed = self.cad.workflow.resume_with_user_clarification(selected, request, message_id=latest.id)
|
||||
if not resumed:
|
||||
yield event("cad_error", {"stage": "request", "message": "The CAD task cannot apply this clarification."})
|
||||
yield event("done", {})
|
||||
return
|
||||
if current and current.get("lifecycle") == "running":
|
||||
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成后才能继续。"})
|
||||
yield event("done", {})
|
||||
return
|
||||
# A terminal task is immutable; follow-up text creates a new task.
|
||||
task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}"
|
||||
if not resumed_task_id:
|
||||
task_id = selected if resumed else f"cad_{secrets.token_hex(6)}"
|
||||
if not resumed:
|
||||
try:
|
||||
source_blocks, image_inputs = self._task_inputs(conversation, request)
|
||||
self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
|
||||
self.cad.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
|
||||
except ValueError as error:
|
||||
yield event("cad_error", {"stage": "request", "message": str(error)})
|
||||
yield event("done", {})
|
||||
return
|
||||
conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id)
|
||||
yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."})
|
||||
|
||||
self.store.append_conversation_message(conversation["conversation_id"], latest.model_dump(), task_id)
|
||||
yield event("progress", _progress("task_started", {"taskId": task_id, "message": "CAD task started."}))
|
||||
try:
|
||||
author_provider, author_model = self.settings.resolve_model(provider_id, model_id)
|
||||
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||
provider, model = self.settings.resolve_model(provider_id, model_id)
|
||||
except ValueError as error:
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
if state is not None:
|
||||
state = self.cad.repository.get_state(task_id)
|
||||
if state:
|
||||
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
|
||||
self.v3.repository.compare_and_swap(failed, events=[{
|
||||
"event": "model_configuration_invalid",
|
||||
"message": str(error)[:1000],
|
||||
"issues": [str(error)[:1000]],
|
||||
}])
|
||||
terminal = {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": str(error), "userActionRequired": False}
|
||||
yield event("task_terminal", terminal)
|
||||
self.cad.repository.compare_and_swap(failed, events=[{"event": "model_configuration_invalid", "message": str(error)}])
|
||||
yield event("cad_error", {"stage": "configuration", "message": str(error)})
|
||||
yield event("done", {})
|
||||
return
|
||||
|
||||
author = ModelIdentity(provider.id, model.id)
|
||||
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
|
||||
parts: list[dict[str, Any]] = []
|
||||
sequence = 0
|
||||
|
||||
async def consume() -> None:
|
||||
nonlocal sequence
|
||||
|
||||
async def dispatch_state_events() -> None:
|
||||
"""Publish state notifications only through the outbox.
|
||||
|
||||
The dispatcher preserves at-least-once semantics. The SSE
|
||||
event ID derives from the durable outbox row, so reconnecting
|
||||
clients can safely de-duplicate a delivery replay.
|
||||
"""
|
||||
nonlocal sequence
|
||||
for outbox_event in await self.v3.outbox.dispatch_pending(task_id=task_id):
|
||||
sequence += 1
|
||||
event_name = str(outbox_event.get("event") or "state_changed")
|
||||
payload = {
|
||||
"taskId": task_id,
|
||||
"eventId": f"outbox_{outbox_event['event_id']}",
|
||||
"sequence": sequence,
|
||||
"timestamp": now_iso(),
|
||||
"outboxEvent": event_name,
|
||||
"message": event_name.replace("_", " "),
|
||||
}
|
||||
await queue.put(("progress", _visible_progress("state_changed", payload)))
|
||||
|
||||
try:
|
||||
capability_terminal = await self._ensure_task_capabilities(
|
||||
task_id,
|
||||
ModelIdentity(author_provider.id, author_model.id),
|
||||
ModelIdentity(review_provider.id, review_model.id),
|
||||
queue,
|
||||
)
|
||||
if capability_terminal is not None:
|
||||
sequence += 1
|
||||
capability_terminal = {**capability_terminal, "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso()}
|
||||
_upsert_part(parts, {"type": "data-cad-progress", "id": capability_terminal["eventId"], "data": _visible_progress("task_terminal", capability_terminal)})
|
||||
await queue.put(("task_terminal", capability_terminal))
|
||||
return
|
||||
async for name, payload in self.v3.workflow.run(
|
||||
task_id=task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
):
|
||||
sequence += 1
|
||||
decorated = {**payload, "taskId": task_id, "eventId": str(payload.get("eventId") or f"{task_id}_{sequence}_{name}"), "sequence": sequence, "timestamp": now_iso()}
|
||||
_upsert_part(parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)})
|
||||
if name == "task_terminal" and str(decorated.get("lifecycle") or "") == "failed":
|
||||
_upsert_part(parts, {"type": "data-cad-error", "id": f"{decorated['eventId']}_error", "data": {
|
||||
"stage": "generation",
|
||||
"message": str(decorated.get("message") or "CAD autonomous generation failed."),
|
||||
"tool": str(decorated.get("tool") or ""),
|
||||
"fieldErrors": decorated.get("field_errors") if isinstance(decorated.get("field_errors"), list) else [],
|
||||
}})
|
||||
async for name, payload in self.cad.workflow.run(task_id=task_id, author=author):
|
||||
decorated = {**payload, "taskId": task_id, "eventId": f"{task_id}_{secrets.token_hex(4)}", "timestamp": now_iso()}
|
||||
parts.append({"type": "data-cad-progress", "id": decorated["eventId"], "data": _progress(name, decorated)})
|
||||
await queue.put((name, decorated))
|
||||
await dispatch_state_events()
|
||||
except Exception as error:
|
||||
sequence += 1
|
||||
terminal = {"taskId": task_id, "lifecycle": "failed", "code": "FAILED_INTERNAL", "message": str(error)[:1000], "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso(), "userActionRequired": False}
|
||||
_upsert_part(parts, {"type": "data-cad-progress", "id": terminal["eventId"], "data": _visible_progress("task_terminal", terminal)})
|
||||
_upsert_part(parts, {"type": "data-cad-error", "id": f"{terminal['eventId']}_error", "data": {"stage": "generation", "message": terminal["message"], "fieldErrors": []}})
|
||||
await queue.put(("task_terminal", terminal))
|
||||
await self.cad.outbox.dispatch_pending(task_id=task_id)
|
||||
if name == "task_terminal" and decorated.get("lifecycle") == "completed":
|
||||
projection = self.cad.repository.get_task_projection(task_id) or {}
|
||||
result = self._result_payload(task_id, projection)
|
||||
if result is not None:
|
||||
await queue.put(("cad_result", result))
|
||||
finally:
|
||||
self.store.append_conversation_message(conversation["conversation_id"], {"id": f"assistant_{secrets.token_hex(8)}", "role": "assistant", "parts": parts}, task_id)
|
||||
self._autonomous_runs.pop(task_id, None)
|
||||
await queue.put(None)
|
||||
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cad-v3-{task_id}")
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"cad-single-stage-{task_id}")
|
||||
while True:
|
||||
try:
|
||||
item = await asyncio.wait_for(queue.get(), timeout=15)
|
||||
except asyncio.TimeoutError:
|
||||
yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()})
|
||||
continue
|
||||
item = await queue.get()
|
||||
if item is None:
|
||||
break
|
||||
name, payload = item
|
||||
if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed":
|
||||
yield event("cad_error", {
|
||||
"stage": "generation",
|
||||
"message": str(payload.get("message") or "CAD autonomous generation failed."),
|
||||
"tool": str(payload.get("tool") or ""),
|
||||
"fieldErrors": payload.get("field_errors") if isinstance(payload.get("field_errors"), list) else [],
|
||||
})
|
||||
if name == "task_terminal" and payload.get("lifecycle") == "failed":
|
||||
yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD generation failed."), "code": payload.get("code")})
|
||||
yield event(name, payload)
|
||||
yield event("done", {})
|
||||
|
||||
async def _ensure_task_capabilities(
|
||||
self,
|
||||
task_id: str,
|
||||
author: ModelIdentity,
|
||||
reviewer: ModelIdentity,
|
||||
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None],
|
||||
) -> dict[str, Any] | None:
|
||||
roles = (("author", author), ("reviewer", reviewer))
|
||||
cached = {
|
||||
role: cached_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
provider_id=model.provider_id,
|
||||
model_id=model.model_id,
|
||||
role=role,
|
||||
)
|
||||
for role, model in roles
|
||||
}
|
||||
unsupported = [role for role, result in cached.items() if result is not None and not result.get("supported")]
|
||||
if unsupported:
|
||||
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
|
||||
missing = [(role, model) for role, model in roles if cached[role] is None]
|
||||
if not missing:
|
||||
@staticmethod
|
||||
def _result_payload(task_id: str, projection: dict[str, Any]) -> dict[str, Any] | None:
|
||||
revision_id = str(projection.get("published_revision") or projection.get("active_revision") or "")
|
||||
revisions = projection.get("revisions") if isinstance(projection.get("revisions"), list) else []
|
||||
revision = next((item for item in revisions if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
|
||||
if not isinstance(revision, dict):
|
||||
return None
|
||||
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
if state is None:
|
||||
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "Task state is unavailable.", "userActionRequired": False}
|
||||
waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING)
|
||||
if not self.v3.repository.compare_and_swap(waiting, events=[{
|
||||
"event": "model_protocol_check_pending",
|
||||
"code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value,
|
||||
"message": "模型协议检查中,完成后将自动继续。",
|
||||
}]):
|
||||
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STALE_WORKING_HEAD.value, "message": "Task state changed before the model protocol check started.", "userActionRequired": False}
|
||||
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "waiting", "lifecycle": "waiting_retry", "message": "模型协议检查中,完成后将自动继续。"}))
|
||||
try:
|
||||
results = await asyncio.gather(*(
|
||||
verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=model.provider_id,
|
||||
model_id=model.model_id,
|
||||
role=role,
|
||||
)
|
||||
for role, model in missing
|
||||
))
|
||||
except Exception as error:
|
||||
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用:{str(error)[:500]}", "userActionRequired": False}
|
||||
unavailable = [role for (role, _model), result in zip(missing, results, strict=True) if result.get("probe_unavailable")]
|
||||
if unavailable:
|
||||
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用({', '.join(unavailable)}),可稍后重试。", "userActionRequired": False}
|
||||
unsupported = [role for (role, _model), result in zip(missing, results, strict=True) if not result.get("supported")]
|
||||
if unsupported:
|
||||
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
|
||||
if not self.v3.workflow.resume(task_id):
|
||||
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Model protocol check completed but the task could not resume.", "userActionRequired": False}
|
||||
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "success", "lifecycle": "running", "message": "模型协议检查完成,继续生成。"}))
|
||||
return None
|
||||
|
||||
def _fail_capability(self, task_id: str, message: str) -> dict[str, Any]:
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
if state is not None and state.phase not in {TaskPhase.FAILED, TaskPhase.COMPLETED, TaskPhase.CANCELLED}:
|
||||
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
|
||||
self.v3.repository.compare_and_swap(failed, events=[{
|
||||
"event": "model_protocol_unsupported",
|
||||
"message": message,
|
||||
"issues": [message],
|
||||
}])
|
||||
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": message, "userActionRequired": False}
|
||||
required = ("cdsl_path", "step_path", "glb_path", "report_path")
|
||||
if not all(isinstance(revision.get(path), str) and revision[path] for path in required):
|
||||
return None
|
||||
return {
|
||||
"taskId": task_id,
|
||||
"revisionId": revision_id,
|
||||
"cdslPath": revision["cdsl_path"],
|
||||
"stepPath": revision["step_path"],
|
||||
"glbPath": revision["glb_path"],
|
||||
"reportPath": revision["report_path"],
|
||||
"summary": str(revision.get("summary") or "CDSL CAD model"),
|
||||
"referenceIds": list(revision.get("reference_ids") or []),
|
||||
"engine": str(revision.get("engine") or "cdsl_only"),
|
||||
"lifecycle": str(projection.get("lifecycle") or "completed"),
|
||||
}
|
||||
|
||||
def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
||||
"""Freeze message paragraphs and attachment blocks before task creation."""
|
||||
blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()]
|
||||
image_inputs: list[dict[str, str]] = []
|
||||
conversation_id = str(conversation.get("conversation_id") or "")
|
||||
@@ -509,51 +201,24 @@ class AgentService:
|
||||
continue
|
||||
if str(attachment.get("conversation_id") or "") != conversation_id:
|
||||
raise ValueError("Attachment does not belong to this conversation")
|
||||
attachment_id = str(attachment.get("id") or "")
|
||||
relative_path = str(attachment.get("path") or "")
|
||||
expected_digest = str(attachment.get("sha256") or "")
|
||||
if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", expected_digest):
|
||||
attachment_id, relative_path, digest = str(attachment.get("id") or ""), str(attachment.get("path") or ""), str(attachment.get("sha256") or "")
|
||||
if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", digest):
|
||||
raise ValueError("Attachment metadata is incomplete")
|
||||
binary_path = self.store.conversation_attachment_path(conversation_id, relative_path)
|
||||
if not binary_path.is_file():
|
||||
raise ValueError(f"Attachment is missing: {attachment.get('name') or attachment_id}")
|
||||
if sha256(binary_path.read_bytes()).hexdigest() != expected_digest:
|
||||
raise ValueError(f"Attachment checksum mismatch: {attachment.get('name') or attachment_id}")
|
||||
binary = self.store.conversation_attachment_path(conversation_id, relative_path)
|
||||
if not binary.is_file() or sha256(binary.read_bytes()).hexdigest() != digest:
|
||||
raise ValueError(f"Attachment is unavailable: {attachment.get('name') or attachment_id}")
|
||||
kind = str(attachment.get("kind") or "")
|
||||
if kind == "document":
|
||||
extracted_path = str(attachment.get("extracted_path") or "")
|
||||
if not extracted_path:
|
||||
raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}")
|
||||
text_path = self.store.conversation_attachment_path(conversation_id, extracted_path)
|
||||
text_path = self.store.conversation_attachment_path(conversation_id, str(attachment.get("extracted_path") or ""))
|
||||
if not text_path.is_file():
|
||||
raise ValueError(f"Attachment text is missing: {attachment.get('name') or attachment_id}")
|
||||
raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}")
|
||||
text = text_path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
elif kind == "image":
|
||||
# The binary is integrity-checked above. Do not claim that a
|
||||
# text-only author has interpreted visual content; the source
|
||||
# block is still a stable, reviewable attachment reference.
|
||||
text = (
|
||||
f"Visual attachment {attachment.get('name') or attachment_id} "
|
||||
f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). "
|
||||
"It is a visual reference and requires explicit visual verification."
|
||||
)
|
||||
image_inputs.append({
|
||||
"path": str(binary_path),
|
||||
"mime": str(attachment.get("mime") or "image/*"),
|
||||
"sha256": expected_digest,
|
||||
})
|
||||
text = f"Visual attachment {attachment.get('name') or attachment_id} (SHA-256 {digest})."
|
||||
image_inputs.append({"path": str(binary), "mime": str(attachment.get("mime") or "image/*"), "sha256": digest})
|
||||
else:
|
||||
raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}")
|
||||
if not text:
|
||||
raise ValueError(f"Attachment source is empty: {attachment.get('name') or attachment_id}")
|
||||
blocks.append({
|
||||
"text": text,
|
||||
"attachment": {
|
||||
"attachment_id": attachment_id,
|
||||
"name": str(attachment.get("name") or attachment_id),
|
||||
"kind": kind,
|
||||
"mime": str(attachment.get("mime") or "application/octet-stream"),
|
||||
"sha256": expected_digest,
|
||||
},
|
||||
})
|
||||
blocks.append({"text": text, "attachment": {"attachment_id": attachment_id, "name": str(attachment.get("name") or attachment_id), "kind": kind, "mime": str(attachment.get("mime") or "application/octet-stream"), "sha256": digest}})
|
||||
return blocks, image_inputs
|
||||
|
||||
@@ -47,7 +47,7 @@ def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]:
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
|
||||
if not isinstance(schema, dict) or not isinstance(schema.get("operation_contracts"), dict):
|
||||
raise RuntimeError("The local engine schema has no v3 operation contract registry")
|
||||
raise RuntimeError("The local engine schema has no operation contract registry")
|
||||
return schema
|
||||
|
||||
|
||||
@@ -108,10 +108,11 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
|
||||
|
||||
|
||||
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
def validate_cdsl_shape(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
"""Validate the static Runtime CDSL contract without resolving topology."""
|
||||
if not isinstance(cdsl, dict):
|
||||
raise ValueError("CDSL must be a JSON object")
|
||||
if cdsl.get("schema") != "cad.cdsl.llm.v1":
|
||||
if cdsl.get("schema") not in {"cad.cdsl.llm.v1", "cad.runtime.v1"}:
|
||||
raise ValueError("Unsupported CDSL schema")
|
||||
_validate_cdsl_json_schema(cdsl, engine)
|
||||
part_id = str(cdsl.get("part_id") or "")
|
||||
@@ -125,6 +126,14 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
sketches = cdsl.get("geometry", {}).get("sketches")
|
||||
if not isinstance(features, list) or not features or not isinstance(sketches, list):
|
||||
raise ValueError("CDSL requires a feature list and a geometry.sketches array")
|
||||
if cdsl.get("schema") == "cad.runtime.v1":
|
||||
bodies = cdsl.get("bodies")
|
||||
if not isinstance(bodies, list) or not bodies:
|
||||
raise ValueError("Runtime CDSL requires server-assigned bodies")
|
||||
body_ids = [str(body.get("id") or "") for body in bodies if isinstance(body, dict)]
|
||||
body_names = [str(body.get("name") or "") for body in bodies if isinstance(body, dict)]
|
||||
if len(body_ids) != len(bodies) or len(body_ids) != len(set(body_ids)) or len(body_names) != len(set(body_names)):
|
||||
raise ValueError("Runtime CDSL bodies must have unique server IDs and local names")
|
||||
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
|
||||
semantic_contract = _engine_schema(engine)
|
||||
try:
|
||||
@@ -185,6 +194,11 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
raise ValueError("Polygon profiles require vertices")
|
||||
elif profile_type not in engine.SHAPE_GENERATORS:
|
||||
raise ValueError(f"Unsupported CDSL profile: {profile_type}")
|
||||
|
||||
|
||||
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
"""Validate static CDSL and the current executable topology semantics."""
|
||||
validate_cdsl_shape(cdsl, engine)
|
||||
try:
|
||||
analysis = engine.analyze_cdsl(copy.deepcopy(cdsl))
|
||||
except Exception as error:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Deterministic, CPU-only CAD technical renders for visual review.
|
||||
"""Deterministic, CPU-only CAD technical render bundles.
|
||||
|
||||
OpenCascade computes exact visible/hidden edges from the revision STEP file.
|
||||
Pillow rasterizes the resulting technical drawings. Neither stage needs a web
|
||||
browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence
|
||||
browser, OpenGL, a desktop session, nor a GPU, which keeps published artifacts
|
||||
consistent on macOS, Linux, and Windows workers.
|
||||
"""
|
||||
|
||||
@@ -26,7 +26,7 @@ VISIBLE_EDGE_RGB = (34, 54, 69)
|
||||
HIDDEN_EDGE_RGB = (142, 157, 170)
|
||||
|
||||
|
||||
class ReviewRenderError(RuntimeError):
|
||||
class RenderBundleError(RuntimeError):
|
||||
"""The fixed-view renderer was unavailable or produced incomplete evidence."""
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ def renderer_status() -> tuple[bool, str]:
|
||||
"""Verify that the pure-Python/OCC renderer dependencies are importable."""
|
||||
try:
|
||||
_render_modules()
|
||||
except ReviewRenderError as error:
|
||||
except RenderBundleError as error:
|
||||
return False, str(error)
|
||||
return True, ""
|
||||
|
||||
@@ -45,7 +45,7 @@ def _render_modules() -> tuple[Any, Any, Any]:
|
||||
pillow_draw = importlib.import_module("PIL.ImageDraw")
|
||||
import_step = importlib.import_module("build123d").import_step
|
||||
except (ImportError, AttributeError) as error:
|
||||
raise ReviewRenderError(
|
||||
raise RenderBundleError(
|
||||
"Python technical renderer is unavailable; install backend requirements (build123d and Pillow)"
|
||||
) from error
|
||||
return pillow_image, pillow_draw, import_step
|
||||
@@ -73,7 +73,7 @@ def _shape_bounds(shape: Any) -> list[float]:
|
||||
box = shape.bounding_box()
|
||||
bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)]
|
||||
if not all(math.isfinite(value) for value in bounds):
|
||||
raise ReviewRenderError("STEP review source has invalid bounds")
|
||||
raise RenderBundleError("STEP render source has invalid bounds")
|
||||
return bounds
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]:
|
||||
def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]:
|
||||
points = [point for edge in edges for point in _edge_points(edge, 0.5)]
|
||||
if not points:
|
||||
raise ReviewRenderError("Hidden-line projection produced no drawable edges")
|
||||
raise RenderBundleError("Hidden-line projection produced no drawable edges")
|
||||
xs, ys = zip(*points)
|
||||
return min(xs), max(xs), min(ys), max(ys)
|
||||
|
||||
@@ -242,7 +242,7 @@ def _render_view(
|
||||
camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"]
|
||||
)
|
||||
except Exception as error:
|
||||
raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
|
||||
raise RenderBundleError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
|
||||
visible_edges, hidden_edges = list(visible), list(hidden)
|
||||
frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent)
|
||||
rendered = _rasterize(
|
||||
@@ -254,12 +254,12 @@ def _render_view(
|
||||
intentional_crop=target_extent > 0,
|
||||
)
|
||||
if not rendered["diagnostics"]["valid"]:
|
||||
raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
|
||||
raise RenderBundleError(f"Render bundle quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
|
||||
return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered}
|
||||
|
||||
|
||||
def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str:
|
||||
"""Create compact whole-model evidence for routine reviewer calls."""
|
||||
"""Create compact whole-model images for published CAD artifacts."""
|
||||
pillow_image, pillow_draw, _ = _render_modules()
|
||||
canonical = [item for item in views if item["id"] in CANONICAL_VIEWS]
|
||||
if not canonical:
|
||||
@@ -283,27 +283,27 @@ def render_checkpoint(
|
||||
*,
|
||||
step_path: Path,
|
||||
output_dir: Path,
|
||||
review_targets: list[dict[str, Any]] | None = None,
|
||||
detail_targets: list[dict[str, Any]] | None = None,
|
||||
include_canonical: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Render STEP geometry into stable canonical and bounded node-detail views."""
|
||||
del settings
|
||||
ready, detail = renderer_status()
|
||||
if not ready:
|
||||
raise ReviewRenderError(detail)
|
||||
raise RenderBundleError(detail)
|
||||
if not step_path.is_file():
|
||||
raise ReviewRenderError(f"STEP review source is missing: {step_path.name}")
|
||||
raise RenderBundleError(f"STEP render source is missing: {step_path.name}")
|
||||
_, _, import_step = _render_modules()
|
||||
try:
|
||||
shape = import_step(str(step_path))
|
||||
except Exception as error:
|
||||
raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error
|
||||
raise RenderBundleError(f"Unable to read STEP render source: {error}") from error
|
||||
bounds = _shape_bounds(shape)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
jobs: list[tuple[str, dict[str, Any] | None]] = []
|
||||
if include_canonical:
|
||||
jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS)
|
||||
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3]))
|
||||
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((detail_targets or [])[:3]))
|
||||
views = [
|
||||
_render_view(
|
||||
shape=shape,
|
||||
@@ -317,14 +317,14 @@ def render_checkpoint(
|
||||
]
|
||||
canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")}
|
||||
if include_canonical and canonical != set(CANONICAL_VIEWS):
|
||||
raise ReviewRenderError("Python review renderer did not produce every canonical view")
|
||||
raise RenderBundleError("Python render bundle generator did not produce every canonical view")
|
||||
contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else ""
|
||||
manifest = {
|
||||
"schema_version": "cad.render-manifest.v2",
|
||||
"renderer": "python-occ-hlr-pillow",
|
||||
"source": {"type": "step", "path": str(step_path), "bounds_mm": bounds},
|
||||
"high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"},
|
||||
"review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
|
||||
"render_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
|
||||
"contact_sheet_path": contact_sheet_path,
|
||||
"views": views,
|
||||
}
|
||||
@@ -343,22 +343,22 @@ def render_section(
|
||||
"""Create an actual OpenCascade section drawing, not a clipped viewport.
|
||||
|
||||
It intentionally uses the same deterministic Pillow raster path as the
|
||||
seven canonical review views. The output contains compact contour evidence
|
||||
suitable for a multimodal author without sending a STEP file or full B-rep.
|
||||
seven canonical render views. The output contains compact contour evidence
|
||||
suitable for inspection without sending a STEP file or full B-rep.
|
||||
"""
|
||||
del settings
|
||||
ready, detail = renderer_status()
|
||||
if not ready:
|
||||
raise ReviewRenderError(detail)
|
||||
raise RenderBundleError(detail)
|
||||
if not step_path.is_file():
|
||||
raise ReviewRenderError(f"STEP section source is missing: {step_path.name}")
|
||||
raise RenderBundleError(f"STEP section source is missing: {step_path.name}")
|
||||
origin = _number_list(origin_mm, size=3)
|
||||
direction = _number_list(normal, size=3)
|
||||
if origin is None or direction is None:
|
||||
raise ReviewRenderError("Section origin_mm and normal must each contain three finite numbers")
|
||||
raise RenderBundleError("Section origin_mm and normal must each contain three finite numbers")
|
||||
length = math.sqrt(sum(value * value for value in direction))
|
||||
if length <= 1e-9:
|
||||
raise ReviewRenderError("Section normal must not be zero")
|
||||
raise RenderBundleError("Section normal must not be zero")
|
||||
normal_unit = [value / length for value in direction]
|
||||
_, _, import_step = _render_modules()
|
||||
try:
|
||||
@@ -372,9 +372,9 @@ def render_section(
|
||||
section = b3d.section(shape, section_by=plane)
|
||||
edges = list(section.edges())
|
||||
except Exception as error:
|
||||
raise ReviewRenderError(f"OpenCascade section operation failed: {error}") from error
|
||||
raise RenderBundleError(f"OpenCascade section operation failed: {error}") from error
|
||||
if not edges:
|
||||
raise ReviewRenderError("Section plane does not intersect the model")
|
||||
raise RenderBundleError("Section plane does not intersect the model")
|
||||
|
||||
# Choose a deterministic right-handed in-plane frame. Projecting exact
|
||||
# OCC section edges into this frame preserves holes and internal contours.
|
||||
@@ -406,7 +406,7 @@ def render_section(
|
||||
if len(line) >= 2:
|
||||
projected.append(line)
|
||||
if not projected:
|
||||
raise ReviewRenderError("Section operation produced no drawable contours")
|
||||
raise RenderBundleError("Section operation produced no drawable contours")
|
||||
xs = [point[0] for line in projected for point in line]
|
||||
ys = [point[1] for line in projected for point in line]
|
||||
minimum_x, maximum_x, minimum_y, maximum_y = min(xs), max(xs), min(ys), max(ys)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Conversation and attachment storage for the v3 delivery boundary.
|
||||
"""Conversation and attachment storage for the CAD delivery boundary.
|
||||
|
||||
CAD task state deliberately does not live here. Protocol v3 owns mutable task
|
||||
CAD task state deliberately does not live here. The Authoring protocol owns mutable task
|
||||
state in ``SqliteTaskRepository`` and immutable task artifacts in
|
||||
``FileArtifactStore``.
|
||||
"""
|
||||
|
||||
@@ -65,15 +65,6 @@ class Settings:
|
||||
llm_timeout_s: float
|
||||
default_provider_id: str
|
||||
providers: tuple[ProviderConfig, ...]
|
||||
review_provider_id: str = ""
|
||||
review_model_id: str = ""
|
||||
agent_tool_calls_per_cycle: int = 12
|
||||
agent_consecutive_no_progress_limit: int = 6
|
||||
agent_format_error_repeat_limit: int = 3
|
||||
agent_context_char_limit: int = 14000
|
||||
agent_author_guidance_enabled: bool = True
|
||||
agent_author_guidance_max_chars: int = 3600
|
||||
agent_render_cache: bool = True
|
||||
autonomous_generation: bool = True
|
||||
resume_running_tasks_on_startup: bool = True
|
||||
|
||||
@@ -102,29 +93,6 @@ class Settings:
|
||||
raise ValueError("The selected model is not enabled for this provider")
|
||||
return provider, model
|
||||
|
||||
def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]:
|
||||
"""Return the independently configured visual reviewer, never an author fallback."""
|
||||
provider_id = self.review_provider_id
|
||||
if not provider_id:
|
||||
raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider")
|
||||
provider = self.provider_for(provider_id)
|
||||
if provider is None:
|
||||
raise ValueError("The configured visual review provider is unavailable")
|
||||
model_id = self.review_model_id or ""
|
||||
if not model_id:
|
||||
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model")
|
||||
model = provider.model(model_id)
|
||||
if model is None or not model.vision:
|
||||
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model")
|
||||
return provider, model
|
||||
|
||||
def resolve_independent_review_model(self, author_provider: ProviderConfig, author_model: ProviderModel) -> tuple[ProviderConfig, ProviderModel]:
|
||||
"""Require the candidate judge to be a separately configured model."""
|
||||
provider, model = self.resolve_review_model()
|
||||
if provider.id == author_provider.id and model.id == author_model.id:
|
||||
raise ValueError("CDSL_REVIEW_PROVIDER/CDSL_REVIEW_MODEL must differ from the autonomous author model")
|
||||
return provider, model
|
||||
|
||||
|
||||
def _reasoning_effort(value: str) -> str:
|
||||
effort = value.strip().lower()
|
||||
@@ -207,15 +175,6 @@ def get_settings() -> Settings:
|
||||
llm_timeout_s=llm_timeout_s,
|
||||
default_provider_id=default_provider_id,
|
||||
providers=providers,
|
||||
review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(),
|
||||
review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(),
|
||||
agent_tool_calls_per_cycle=max(1, int(os.getenv("CDSL_AGENT_TOOL_CALLS_PER_CYCLE", "12"))),
|
||||
agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))),
|
||||
agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))),
|
||||
agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))),
|
||||
agent_author_guidance_enabled=_env_flag("CDSL_AGENT_AUTHOR_GUIDANCE_ENABLED", True),
|
||||
agent_author_guidance_max_chars=min(6000, max(1200, int(os.getenv("CDSL_AGENT_AUTHOR_GUIDANCE_MAX_CHARS", "3600")))),
|
||||
agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True),
|
||||
autonomous_generation=True,
|
||||
# Production instances recover durable runs by default. Test workers
|
||||
# can disable this before startup to guarantee they touch only tasks
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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": "<local_feature_name>.<output_role>",
|
||||
}
|
||||
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"))]
|
||||
Reference in New Issue
Block a user