From 921ba5eab33e4c13e87e7a097dbeeb3b0453488e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Fri, 4 Sep 2026 11:16:23 +0800 Subject: [PATCH] change --- .../00-author-contract.md | 1 + .../01-brief-and-assumptions.md | 1 + .../02-parameters-and-derived-dimensions.md | 1 + .../03-coordinate-system-and-datums.md | 1 + .../04-construction-and-feature-order.md | 1 + .../05-profiles-workplanes-and-cuts.md | 1 + ...-hosted-features-selectors-and-topology.md | 1 + .../07-patterns-symmetry-and-repetition.md | 1 + .../08-finishing-and-boolean-risk.md | 1 + ...9-evidence-visual-review-and-validation.md | 1 + .../10-repair-and-best-effort.md | 1 + .../skills/cdsl-author-guidance/README.md | 63 ++ .../skills/cdsl-author-guidance/manifest.json | 56 + .../cdsl-author-guidance/op-extrude-add.md | 1 + .../cdsl-author-guidance/op-extrude-cut.md | 1 + .../skills/cdsl-author-guidance/op-finish.md | 1 + .../skills/cdsl-author-guidance/op-hole.md | 1 + .../skills/cdsl-author-guidance/op-pattern.md | 1 + .../cdsl-author-guidance/op-reference.md | 1 + .../skills/cdsl-author-guidance/op-revolve.md | 1 + .../skills/cdsl-author-guidance/op-sphere.md | 1 + .../app/cad_agent/adapters/author_guidance.py | 191 ++++ .../app/cad_agent/adapters/structured_llm.py | 81 +- backend/app/cad_agent/application/workflow.py | 52 +- backend/app/cad_agent/composition.py | 8 +- backend/app/cad_agent/evals/live.py | 209 +++- backend/app/cad_agent/ports.py | 49 +- backend/app/settings.py | 4 + backend/tests/test_author_guidance.py | 158 +++ .../tests/test_live_guidance_comparison.py | 71 ++ backend/tests/test_structured_llm.py | 123 +++ cadfs_to_cdsl/cli.py | 30 +- cadfs_to_cdsl/describe.py | 976 ++++++++++++++++++ cadfs_to_cdsl/tests/test_describe.py | 147 +++ .../cadfs_0005_模型描述.xlsx | Bin 0 -> 20236 bytes .../cadfs_0005_模型描述.xlsx.inspect.ndjson | 457 ++++++++ 36 files changed, 2665 insertions(+), 29 deletions(-) create mode 100644 backend/agent/skills/cdsl-author-guidance/00-author-contract.md create mode 100644 backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md create mode 100644 backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md create mode 100644 backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md create mode 100644 backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md create mode 100644 backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md create mode 100644 backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md create mode 100644 backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md create mode 100644 backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md create mode 100644 backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md create mode 100644 backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md create mode 100644 backend/agent/skills/cdsl-author-guidance/README.md create mode 100644 backend/agent/skills/cdsl-author-guidance/manifest.json create mode 100644 backend/agent/skills/cdsl-author-guidance/op-extrude-add.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-finish.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-hole.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-pattern.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-reference.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-revolve.md create mode 100644 backend/agent/skills/cdsl-author-guidance/op-sphere.md create mode 100644 backend/app/cad_agent/adapters/author_guidance.py create mode 100644 backend/tests/test_author_guidance.py create mode 100644 backend/tests/test_live_guidance_comparison.py create mode 100644 backend/tests/test_structured_llm.py create mode 100644 cadfs_to_cdsl/describe.py create mode 100644 cadfs_to_cdsl/tests/test_describe.py create mode 100644 outputs/cadfs_descriptions_0005/cadfs_0005_模型描述.xlsx create mode 100644 outputs/cadfs_descriptions_0005/cadfs_0005_模型描述.xlsx.inspect.ndjson diff --git a/backend/agent/skills/cdsl-author-guidance/00-author-contract.md b/backend/agent/skills/cdsl-author-guidance/00-author-contract.md new file mode 100644 index 00000000..92470134 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/00-author-contract.md @@ -0,0 +1 @@ +只使用当前工具 schema、`operation_contract` 和服务端提供的 topology/reference token;它们高于本指引、示例和任何经验。每次只完成被调度的一个原子操作,不编造 atom、字段、selector 或能力。参数须为有限 mm/deg 数值。已有可执行 checkpoint 是应保留的最佳结果;能力缺口、未验证项和视觉疑点要如实交给服务端证据流程,不能用虚构几何掩盖。 diff --git a/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md b/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md new file mode 100644 index 00000000..1c850d90 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md @@ -0,0 +1 @@ +先区分显式事实、图像观察、工程默认值和未知项。提取单位、外形、功能面、孔/槽、配合关系、关键尺寸及可验证目标。默认值只能补足常见零件的非关键构造,不能把未说明尺寸伪装成用户要求或确定性验收值。只有安全、配合、合规或可建模性确实取决于一个缺失事实时,才提出一个聚焦澄清;其余不确定性记录为假设或风险。 diff --git a/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md b/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md new file mode 100644 index 00000000..035276fe --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md @@ -0,0 +1 @@ +把尺寸当作模型契约:先识别主控的长度、宽度、厚度、直径、中心距、节距、数量、半径和角度,再从它们推导重复位置、对称偏移和余量。所有尺寸明确使用 mm,角度使用 deg;长度、直径、深度、节距和圆角半径必须为合理正有限值。阵列优先由中心线、数量、节距、半径或角度推导,避免难以追溯的点坐标常数。提交前以包围盒、比例、壁厚/材料余量和目标特征数量做常识检查。 diff --git a/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md b/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md new file mode 100644 index 00000000..0e85183c --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md @@ -0,0 +1 @@ +世界坐标默认是右手 mm。根 `XY` 与 `+Z` 挤出只用于当前 contract 允许的根挤出。原点按功能基准选取:对称件取中心,板件取占地中心,轴对称件取轴线,存在安装或配合接口时取安装面、配合轴或明确接口基准。`workplane.origin_mm` 是局部 `(0,0)` 的世界点,`x_dir` 是局部 `+X`,`normal` 指向正挤出;草图局部坐标不是世界坐标。孔 `positions[].mm` 是宿主面上的绝对世界点,不是面内偏移。非根特征依据当前 contract、测量宿主和 token 建立 frame,不能猜测最后生成面或全局平面。`reference_plane`/`reference_axis` 是唯一支持的命名定位上下文;先创建基准,再创建依赖它的旋转、镜像、阵列和草图。位置必须由基准、中心线、偏移、节距或半径导出;定位失败时改 frame、偏移或方向,不修改已发布工件。 diff --git a/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md b/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md new file mode 100644 index 00000000..28e2331d --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md @@ -0,0 +1 @@ +优先把零件身份和主控尺寸写进稳定根特征:根体、主要增材体、主要切除、孔/槽、重复特征、最后的圆角/倒角。每个节点只承担一个原子意图,依赖边只表示直接几何前提。默认形成连通单体;确需多体时必须由目标和 contract 支持。避免把视觉装饰、细小倒角或易碎布尔放在主形体之前。重规划时保留已完成节点和可执行检查点,只替换最小必要子图。 diff --git a/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md b/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md new file mode 100644 index 00000000..289c3ecf --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md @@ -0,0 +1 @@ +轮廓必须闭合、不自交、无零长或重叠边,并清楚区分外环和内环。先验证 workplane 的原点、`x_dir`、`normal` 与局部轮廓方向;翻转方向使用 contract 允许的字段,不凭视觉猜测。切除从实际材料面进入,深度覆盖目标材料并满足当前预检;避免刚好停在共面边界。对薄壁、近相切、重叠工具和零厚度结果保持余量。切除失败先检查宿主、方向、深度和轮廓,再考虑更换建模顺序。 diff --git a/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md b/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md new file mode 100644 index 00000000..56109b58 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md @@ -0,0 +1 @@ +宿主特征只能使用当前 revision 的测量 topology 和服务端给出的不透明 selector token;不得按边/面列表下标、历史名称或“最后一个面”猜选。选择前核对 token 的 kind、中心、法向、包围盒和 surface_type 是否覆盖预期材料区域。布尔、孔、阵列、圆角后拓扑可能变化,旧 token 和 reference 不可假定仍有效;依赖新拓扑时重新观察。reference token 只按当前 contract 放入允许槽位。选择不确定时请求 topology,而不是提交模糊 selector。 diff --git a/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md b/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md new file mode 100644 index 00000000..6a1fa24b --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md @@ -0,0 +1 @@ +对称和重复优先通过 `pattern_linear`、`pattern_mirror` 及其 contract 参数表达。先完成一个正确的源特征,再用中心面、中心线、方向、数量、节距、半径或角度定义重复关系;不要用零散手填坐标代替可追溯模式。镜像平面和阵列方向应来自已建立的 datum 或当前测量 token。阵列前确认源特征、间距和数量不会重叠、越界或使材料变成零厚度。 diff --git a/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md b/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md new file mode 100644 index 00000000..3ab5a436 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md @@ -0,0 +1 @@ +圆角和倒角仅在主形体、切除和孔稳定后执行,并只选择唯一、当前有效的边 token;禁止“所有边”式回退。半径/距离必须小于邻近材料可容纳范围,避免相邻圆角相交。布尔操作避开共面终止、近相切和重复工具重叠;若风险高,优先以更稳定的主轮廓、顺序或足够余量表达。失败时不要重复原片段,先诊断受影响的面、边、深度和拓扑。 diff --git a/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md b/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md new file mode 100644 index 00000000..8183f587 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md @@ -0,0 +1 @@ +确定性几何事实与视觉审查职责不同:包围盒、实体数、孔深或贯穿状态只能证明已测量的 claim,不能证明整体设计语义。使用当前 contract、预检结果、claim evidence、render manifest 和 recent failures 作决定。视觉不符时给出具体的形状、位置、方向或比例差异作为修复依据,不能把它伪装成确定性通过。仅在几何改变后重新审查;STEP/checkpoint 是主工件,GLB 和渲染是派生审查证据,不能替代 CAD 几何。 diff --git a/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md b/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md new file mode 100644 index 00000000..145d9f5f --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md @@ -0,0 +1 @@ +修复先读错误和证据,定位最小责任点,再改最小的 CDSL/计划部分并重新执行依赖检查。常见原因包括开环/自交轮廓、零或负尺寸、切除方向或深度错误、错误 host frame、布尔后的旧 selector、过大圆角和直径/半径混淆。不要原样重试已失败片段。运行时不支持的能力应作为风险或缺口保留并继续发布最佳可执行模型,不能发明新 atom 或删除有效 checkpoint。 diff --git a/backend/agent/skills/cdsl-author-guidance/README.md b/backend/agent/skills/cdsl-author-guidance/README.md new file mode 100644 index 00000000..96da5a74 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/README.md @@ -0,0 +1,63 @@ +# CDSL Author Guidance Corpus + +This corpus is a Chinese-first, non-authoritative author aid. The runtime +operation contract, fragment schema, topology/reference tokens, preflight and +verifier evidence always win over these Markdown files. The manifest maps +only workflow phase, scheduled atomic operation and repair state; it never +classifies the user's part request. + +## Source Migration + +| Source reference | CDSL target sections | Intentionally excluded | +| --- | --- | --- | +| `cad-brief.md` | `01`, `02`, `09` | Python/file workflow | +| `parameters.md` | `02`, `07` | sidecars, animation, viewer control | +| `positioning.md` | `03`, `06`, `op-reference` | assemblies, joints, `Location`, imported STEP placement | +| `build123d-modeling.md` | `03` through `08`, operation appendices | build123d APIs, labels, colors and assembly source | +| `build123d-modeling.zh-CN.md` | all Chinese terminology and rule review | a duplicate competing rule set | +| `inspection-and-validation.md` | `09`, `10` | CLI paths and selector syntax | +| `snapshot-review.md` | `09` | renderer commands | +| `repair-loop.md` | `10`, `05`, `06`, `08` | build123d-only remediation syntax | +| `step-generation.md` | `00`, `09` | Python generator commands | +| `supported-exports.md` | `09` | mesh tolerance and exporter-specific flags | + +## Selection Contract + +- Requirements authoring selects `00` to `03`. +- Feature planning selects `00`, `02`, `03`, `04`, `07`, and `08`. +- A scheduled feature selects `00`, `03` to `06`, `08`, and its current + operation appendix. +- Repair selects `00`, `03`, `06`, `09`, `10`, and its operation appendix. +- Final validation selects `00`, `09`, and `10`. + +At a bounded prompt budget, contract, coordinate/datum, and the scheduled +operation appendix are mandatory. Other sections are included in stable +priority order. A malformed corpus or an unsupported operation registry +falls back to the original short author prompt and records fallback metadata +with the author usage record. + +## Evaluation Commands + +Run the six matched control scenarios three times each, first without and +then with guidance: + +```bash +PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance off \ + --scenario rectangular_mounting_plate --scenario circular_flange_pcd \ + --scenario obround_slot_plate --scenario rounded_rectangular_pocket \ + --scenario double_hole_linkage_arm --scenario l_bracket + +PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance on \ + --scenario rectangular_mounting_plate --scenario circular_flange_pcd \ + --scenario obround_slot_plate --scenario rounded_rectangular_pocket \ + --scenario double_hole_linkage_arm --scenario l_bracket + +PYTHONPATH=backend python -m app.cad_agent.evals.live --compare-guidance-reports CONTROL/report.json TREATMENT/report.json +``` + +The comparator excludes declared validation gaps and engine-declared +`unsupported_*` capability gaps from prompt quality metrics, checks paired +model/runtime/contract/budget equivalence, and +requires the treatment's checkpoint/completion rates not to regress, median +author calls to stay within 10 percent, and either schema/decision or CDSL +expression failures to improve. diff --git a/backend/agent/skills/cdsl-author-guidance/manifest.json b/backend/agent/skills/cdsl-author-guidance/manifest.json new file mode 100644 index 00000000..74d07eff --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/manifest.json @@ -0,0 +1,56 @@ +{ + "schema_version": "cdsl.author-guidance.manifest.v1", + "version": "2026-09-03.1", + "sections": [ + {"id": "00-author-contract", "file": "00-author-contract.md", "title": "00 Author Contract", "priority": 100, "mandatory": true}, + {"id": "01-brief-and-assumptions", "file": "01-brief-and-assumptions.md", "title": "01 Brief And Assumptions", "priority": 70, "mandatory": false}, + {"id": "02-parameters-and-derived-dimensions", "file": "02-parameters-and-derived-dimensions.md", "title": "02 Parameters And Derived Dimensions", "priority": 80, "mandatory": false}, + {"id": "03-coordinate-system-and-datums", "file": "03-coordinate-system-and-datums.md", "title": "03 Coordinate System And Datums", "priority": 100, "mandatory": true}, + {"id": "04-construction-and-feature-order", "file": "04-construction-and-feature-order.md", "title": "04 Construction And Feature Order", "priority": 70, "mandatory": false}, + {"id": "05-profiles-workplanes-and-cuts", "file": "05-profiles-workplanes-and-cuts.md", "title": "05 Profiles Workplanes And Cuts", "priority": 90, "mandatory": false}, + {"id": "06-hosted-features-selectors-and-topology", "file": "06-hosted-features-selectors-and-topology.md", "title": "06 Hosted Features Selectors And Topology", "priority": 90, "mandatory": false}, + {"id": "07-patterns-symmetry-and-repetition", "file": "07-patterns-symmetry-and-repetition.md", "title": "07 Patterns Symmetry And Repetition", "priority": 60, "mandatory": false}, + {"id": "08-finishing-and-boolean-risk", "file": "08-finishing-and-boolean-risk.md", "title": "08 Finishing And Boolean Risk", "priority": 60, "mandatory": false}, + {"id": "09-evidence-visual-review-and-validation", "file": "09-evidence-visual-review-and-validation.md", "title": "09 Evidence Visual Review And Validation", "priority": 80, "mandatory": false}, + {"id": "10-repair-and-best-effort", "file": "10-repair-and-best-effort.md", "title": "10 Repair And Best Effort", "priority": 80, "mandatory": false}, + {"id": "op-extrude-add", "file": "op-extrude-add.md", "title": "Operation Appendix Extrude Add", "priority": 100, "mandatory": true}, + {"id": "op-extrude-cut", "file": "op-extrude-cut.md", "title": "Operation Appendix Extrude Cut", "priority": 100, "mandatory": true}, + {"id": "op-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} + ], + "phase_sections": { + "DEFAULT": ["00-author-contract", "03-coordinate-system-and-datums", "09-evidence-visual-review-and-validation"], + "DRAFTING_REQUIREMENTS_DOCUMENT": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"], + "DRAFTING_COMPLETION_TARGET": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"], + "COMPILING_REQUIREMENTS": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"], + "COMPILING_FEATURE_PLAN": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"], + "REPLANNING_FEATURE_SUBGRAPH": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"], + "FEATURE_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "05-profiles-workplanes-and-cuts", "06-hosted-features-selectors-and-topology", "08-finishing-and-boolean-risk"], + "AWAITING_ACTION": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"], + "ACTION_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"] + }, + "repair_sections": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"], + "final_sections": ["00-author-contract", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"], + "operation_sections": { + "extrude_add_blind": ["op-extrude-add"], + "extrude_add_two_sided": ["op-extrude-add"], + "extrude_cut_blind": ["op-extrude-cut"], + "revolve_add": ["op-revolve"], + "revolve_cut": ["op-revolve"], + "hole_blind": ["op-hole"], + "hole_counterbore": ["op-hole"], + "hole_countersink": ["op-hole"], + "hole_wizard": ["op-hole"], + "reference_plane": ["op-reference"], + "reference_axis": ["op-reference"], + "pattern_linear": ["op-pattern"], + "pattern_mirror": ["op-pattern"], + "fillet": ["op-finish"], + "chamfer": ["op-finish"], + "sphere_add": ["op-sphere"] + } +} diff --git a/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md b/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md new file mode 100644 index 00000000..754cacb0 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md @@ -0,0 +1 @@ +`extrude_add_blind` 和 `extrude_add_two_sided` 必须使用闭合草图和 contract 允许的正距离。根挤出遵守根 `XY` datum;后续增材先确认草图 frame 与已有实体的连接。双向挤出分别核对两个方向的长度与材料范围;`reverse` 只用于当前 frame 的方向修正,不能代替错误的 workplane。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md b/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md new file mode 100644 index 00000000..960bca12 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md @@ -0,0 +1 @@ +`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-finish.md b/backend/agent/skills/cdsl-author-guidance/op-finish.md new file mode 100644 index 00000000..728dc7c1 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-finish.md @@ -0,0 +1 @@ +`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。先完成影响这些边的布尔、孔和阵列,再按 contract 使用正半径或距离。局部材料不足、相邻过渡相交或 token 已失效时,不用全局边选择兜底;保留主体并报告该收尾特征的风险。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-hole.md b/backend/agent/skills/cdsl-author-guidance/op-hole.md new file mode 100644 index 00000000..455e5be8 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-hole.md @@ -0,0 +1 @@ +孔 atom 需要当前宿主面的有效 selector token。`positions[].mm` 使用该宿主面上的绝对世界坐标,先核对点在面区域内与法向方向。直径、深度、沉孔/沉头参数以 contract 为准,深度覆盖预期材料;多孔共享一个原子操作时保持同一规格和同一宿主。不要把点写成面局部偏移或裸数组。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-pattern.md b/backend/agent/skills/cdsl-author-guidance/op-pattern.md new file mode 100644 index 00000000..8bf48548 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-pattern.md @@ -0,0 +1 @@ +`pattern_linear` 只复制当前 contract 允许且存在的源 feature reference;方向是明确世界/基准方向,数量和 spacing 为合理值。`pattern_mirror` 使用存在的镜像 plane reference,先确认源与平面关系以及复制后不会重叠或意外合并。pattern 不代替新的宿主选择;下游特征若依赖新面,重新读取 topology。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-reference.md b/backend/agent/skills/cdsl-author-guidance/op-reference.md new file mode 100644 index 00000000..20e1e31f --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-reference.md @@ -0,0 +1 @@ +`reference_plane` 用有限非零 `normal` 和与其不平行的 `x_dir` 定义局部 frame;`origin_mm` 是世界点。`reference_axis` 用有限非零 `direction` 和世界原点定义。它们只建立可追溯 datum,不直接制造实体;先于依赖它的旋转、镜像、阵列或定位特征,并依照 contract 的 reference token 规则引用。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-revolve.md b/backend/agent/skills/cdsl-author-guidance/op-revolve.md new file mode 100644 index 00000000..161997dc --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-revolve.md @@ -0,0 +1 @@ +`revolve_add` 和 `revolve_cut` 的轴必须由明确 datum 或 contract 中的世界坐标轴表达,并按预检要求位于正确的草图关系中。核对 axis origin、direction、角度和 `reverse`;完整回转避免轮廓跨轴造成自交,局部回转避免与现有材料近相切。切除回转仍必须覆盖目标材料。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-sphere.md b/backend/agent/skills/cdsl-author-guidance/op-sphere.md new file mode 100644 index 00000000..99c82b4b --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-sphere.md @@ -0,0 +1 @@ +`sphere_add` 用明确的世界中心和正有限半径定义。确认它与目标实体的连接意图:需要单体时应有足够相交,独立体仅在需求允许多体时使用。球体位置从 datum 或主尺寸导出,不把视图坐标误当世界坐标。 diff --git a/backend/app/cad_agent/adapters/author_guidance.py b/backend/app/cad_agent/adapters/author_guidance.py new file mode 100644 index 00000000..4cae1ef5 --- /dev/null +++ b/backend/app/cad_agent/adapters/author_guidance.py @@ -0,0 +1,191 @@ +"""Bounded, file-backed author guidance for the CDSL workflow. + +The corpus is deliberately non-authoritative: contracts, schemas, topology +tokens, and server preflight always remain the executable source of truth. +Loading errors return an empty selection so authoring continues with the +pre-guidance prompt instead of turning documentation into an availability +dependency. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from app.cad_agent.domain.state import TaskPhase +from app.cad_agent.ports import AuthorGuidanceSelection + + +_MIN_CHARS = 1_200 +_MAX_CHARS = 6_000 + + +@dataclass(frozen=True, slots=True) +class _Section: + section_id: str + title: str + priority: int + mandatory: bool + content: str + + @property + def block(self) -> str: + return f"## {self.title}\n{self.content.strip()}" + + +@dataclass(frozen=True, slots=True) +class _Corpus: + version: str + sections: dict[str, _Section] + phase_sections: dict[str, tuple[str, ...]] + repair_sections: tuple[str, ...] + final_sections: tuple[str, ...] + operation_sections: dict[str, tuple[str, ...]] + + +class FileAuthorGuidance: + """Read and select the checked-in corpus deterministically. + + Selection depends exclusively on workflow state and the runtime operation + registry. It intentionally receives neither the user's request nor image + observations, so it cannot become an implicit part-family classifier. + """ + + def __init__(self, root: Path, *, enabled: bool = True, max_chars: int = 3_600) -> None: + self.root = root + self.enabled = enabled + self.max_chars = min(_MAX_CHARS, max(_MIN_CHARS, max_chars)) + self._corpus: _Corpus | None = None + self._load_error = "" + + def select( + self, + *, + phase: TaskPhase, + atomic_id: str, + repair_required: bool, + supported_atomic_ids: tuple[str, ...], + ) -> AuthorGuidanceSelection: + if not self.enabled: + return AuthorGuidanceSelection(fallback_reason="guidance_disabled") + corpus = self._load() + if corpus is None: + return AuthorGuidanceSelection(fallback_reason=self._load_error or "guidance_unavailable") + supported = set(supported_atomic_ids) + if set(corpus.operation_sections) != supported: + return AuthorGuidanceSelection(fallback_reason="guidance_operation_coverage_mismatch") + if atomic_id and atomic_id not in supported: + return AuthorGuidanceSelection(fallback_reason="guidance_unknown_atomic_id") + + if repair_required: + requested = list(corpus.repair_sections) + elif phase == TaskPhase.FINAL_VALIDATION: + requested = list(corpus.final_sections) + else: + requested = list(corpus.phase_sections.get(phase.value, corpus.phase_sections.get("DEFAULT", ()))) + if atomic_id: + requested.extend(corpus.operation_sections[atomic_id]) + requested = list(dict.fromkeys(requested)) + if not requested: + return AuthorGuidanceSelection(fallback_reason="guidance_no_matching_sections") + + mandatory = [section_id for section_id in requested if corpus.sections[section_id].mandatory] + optional = [section_id for section_id in requested if not corpus.sections[section_id].mandatory] + optional.sort(key=lambda section_id: (-corpus.sections[section_id].priority, requested.index(section_id))) + selected: list[str] = [] + text = "" + for section_id in [*mandatory, *optional]: + block = corpus.sections[section_id].block + candidate = block if not text else f"{text}\n\n{block}" + if len(candidate) <= self.max_chars: + text = candidate + selected.append(section_id) + elif section_id in mandatory: + # Do not silently drop contract, datum, or operation guidance. + return AuthorGuidanceSelection(fallback_reason="guidance_required_sections_exceed_budget") + return AuthorGuidanceSelection( + version=corpus.version, + section_ids=tuple(selected), + content=text, + enabled=True, + ) + + def _load(self) -> _Corpus | None: + if self._corpus is not None: + return self._corpus + if self._load_error: + return None + try: + manifest_path = self.root / "manifest.json" + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("manifest is not an object") + version = raw.get("version") + if raw.get("schema_version") != "cdsl.author-guidance.manifest.v1" or not isinstance(version, str) or not version: + raise ValueError("manifest version is invalid") + raw_sections = raw.get("sections") + if not isinstance(raw_sections, list) or not raw_sections: + raise ValueError("manifest sections are invalid") + sections: dict[str, _Section] = {} + root = self.root.resolve() + for item in raw_sections: + if not isinstance(item, dict): + raise ValueError("section declaration is invalid") + section_id = item.get("id") + filename = item.get("file") + title = item.get("title") + priority = item.get("priority") + mandatory = item.get("mandatory", False) + if ( + not isinstance(section_id, str) or not section_id + or not isinstance(filename, str) or not filename + or not isinstance(title, str) or not title + or not isinstance(priority, int) or isinstance(priority, bool) + or not isinstance(mandatory, bool) + or section_id in sections + ): + raise ValueError("section metadata is invalid") + path = (self.root / filename).resolve() + if root not in path.parents or not path.is_file(): + raise ValueError("section file is unavailable") + content = path.read_text(encoding="utf-8").strip() + if not content: + raise ValueError("section content is empty") + sections[section_id] = _Section(section_id, title, priority, mandatory, content) + + def identifiers(value: Any, field: str) -> tuple[str, ...]: + if not isinstance(value, list) or not value or not all(isinstance(item, str) and item in sections for item in value): + raise ValueError(f"{field} is invalid") + return tuple(dict.fromkeys(value)) + + raw_phases = raw.get("phase_sections") + if not isinstance(raw_phases, dict) or "DEFAULT" not in raw_phases: + raise ValueError("phase sections are invalid") + phase_sections = { + phase: identifiers(section_ids, f"phase {phase}") + for phase, section_ids in raw_phases.items() + if isinstance(phase, str) + } + if len(phase_sections) != len(raw_phases): + raise ValueError("phase name is invalid") + operation_sections = { + atomic_id: identifiers(section_ids, f"operation {atomic_id}") + for atomic_id, section_ids in (raw.get("operation_sections") or {}).items() + if isinstance(atomic_id, str) + } + if not operation_sections or len(operation_sections) != len(raw.get("operation_sections") or {}): + raise ValueError("operation sections are invalid") + self._corpus = _Corpus( + version=version, + sections=sections, + phase_sections=phase_sections, + repair_sections=identifiers(raw.get("repair_sections"), "repair sections"), + final_sections=identifiers(raw.get("final_sections"), "final sections"), + operation_sections=operation_sections, + ) + return self._corpus + except (OSError, ValueError, TypeError, json.JSONDecodeError) as error: + self._load_error = f"guidance_load_failed:{type(error).__name__}" + return None diff --git a/backend/app/cad_agent/adapters/structured_llm.py b/backend/app/cad_agent/adapters/structured_llm.py index 9d1f1054..53532ef8 100644 --- a/backend/app/cad_agent/adapters/structured_llm.py +++ b/backend/app/cad_agent/adapters/structured_llm.py @@ -46,13 +46,67 @@ class StructuredModelGateway: async def call_tool(self, *, messages: list[dict[str, Any]], tool: dict[str, Any], provider_id: str, model_id: str, required_tool_name: str) -> dict[str, Any]: provider, model = self._provider_model(provider_id, model_id) - payload = self._payload(provider, model.id, messages, [tool], required_tool_name) - response = await self._request(provider, payload) + response, compatibility_mode = await self._request_tool_call( + provider, model.id, messages, [tool], required_tool_name, + ) try: - return self._normalized_response(provider, response) + normalized = self._normalized_response(provider, response) + normalized["usage"]["structured_compatibility_mode"] = compatibility_mode + return normalized except (KeyError, TypeError, ValueError) as error: raise StructuredModelError("Provider response cannot be normalized as a structured tool-call result") from error + async def _request_tool_call( + self, + provider: ProviderConfig, + model_id: str, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + required_tool_name: str, + ) -> tuple[dict[str, Any], str]: + """Keep forced tool choice unless a provider explicitly rejects thinking mode. + + Some OpenAI-compatible gateways reject an otherwise valid forced + ``tool_choice`` whenever reasoning is enabled or implicit. This is a + request compatibility issue rather than an author error. First retry + without the client-requested reasoning option; only if that exact + rejection remains do we allow automatic choice among the single tool + already exposed to the model. The workflow's one-call/schema checks + still reject free-form or wrong-tool responses. + """ + try: + return await self._request( + provider, + self._payload(provider, model_id, messages, tools, required_tool_name), + ), "forced" + except StructuredModelError as first_error: + if not self._thinking_tool_choice_rejection(first_error): + raise + try: + return await self._request( + provider, + self._payload( + provider, model_id, messages, tools, required_tool_name, + include_reasoning=False, + ), + ), "reasoning_disabled" + except StructuredModelError as second_error: + if not self._thinking_tool_choice_rejection(second_error): + raise + return await self._request( + provider, + self._payload( + provider, model_id, messages, tools, required_tool_name, + include_reasoning=False, + force_tool_name=False, + ), + ), "single_tool_auto" + + @staticmethod + def _thinking_tool_choice_rejection(error: StructuredModelError) -> bool: + message = str(error).casefold() + return "thinking" in message and ("tool_choice" in message or "tool choice" in message) + async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]: failures: list[dict[str, str]] = [] usage = {"prompt_tokens": 0, "completion_tokens": 0} @@ -190,20 +244,31 @@ class StructuredModelGateway: }) return values - def _payload(self, provider: ProviderConfig, model_id: str, messages: list[dict[str, Any]], tools: list[dict[str, Any]], required_tool_name: str) -> dict[str, Any]: + def _payload( + self, + provider: ProviderConfig, + model_id: str, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + required_tool_name: str, + *, + include_reasoning: bool = True, + force_tool_name: bool = True, + ) -> dict[str, Any]: if len(tools) != 1 or not required_tool_name: raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.") + request_options = provider.request_options if include_reasoning else {} if provider.api_style == "responses": payload: dict[str, Any] = { "model": model_id, "input": self._responses_input(messages), "tools": self._responses_tools(tools), - "tool_choice": {"type": "function", "name": required_tool_name}, - **provider.request_options, + "tool_choice": {"type": "function", "name": required_tool_name} if force_tool_name else "auto", + **request_options, } return payload return { "model": model_id, "messages": messages, "tools": tools, - "tool_choice": {"type": "function", "function": {"name": required_tool_name}}, - "temperature": 0, **provider.request_options, + "tool_choice": {"type": "function", "function": {"name": required_tool_name}} if force_tool_name else "auto", + "temperature": 0, **request_options, } @staticmethod diff --git a/backend/app/cad_agent/application/workflow.py b/backend/app/cad_agent/application/workflow.py index f92e33d0..73e9839b 100644 --- a/backend/app/cad_agent/application/workflow.py +++ b/backend/app/cad_agent/application/workflow.py @@ -33,7 +33,17 @@ from app.cad_agent.domain.feature_plan import FeaturePlan, plan_hash from app.cad_agent.domain.errors import ErrorCode, WorkflowError from app.cad_agent.domain.operation_contract import fragment_schema from app.cad_agent.domain.state import TaskPhase, TaskState, retry_resume_event, transition -from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, ModelGateway, ReviewGateway, TaskRepository +from app.cad_agent.ports import ( + AdapterUnavailable, + ArtifactStore, + AuthorGuidance, + AuthorGuidanceSelection, + CadRuntime, + ModelGateway, + NullAuthorGuidance, + ReviewGateway, + TaskRepository, +) T = TypeVar("T", bound=BaseModel) @@ -116,6 +126,7 @@ class WorkflowCoordinator: review_gateway: ReviewGateway, requirements: RequirementsCommandHandler, actions: ActionCommandHandler, + author_guidance: AuthorGuidance | None = None, ) -> None: self.config = config self.repository = repository @@ -125,6 +136,7 @@ class WorkflowCoordinator: self.review_gateway = review_gateway self.requirements = requirements self.actions = actions + self.author_guidance = author_guidance or NullAuthorGuidance() def create_task( self, @@ -1078,14 +1090,14 @@ class WorkflowCoordinator: "message": message[:1000], } - async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, int]] | WorkflowError: + async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, Any]] | WorkflowError: if len(tools) != 1: raise RuntimeError("Workflow state must expose exactly one author tool.") tool = tools[0] name = str((tool.get("function") or {}).get("name") or "") if not name: raise RuntimeError("Workflow exposed an unnamed author tool.") - messages = self._author_context(task_id, feedback) + messages, guidance = self._author_context(task_id, feedback) try: response = await self.model_gateway.call_tool( messages=messages, tool=tool, provider_id=author.provider_id, @@ -1103,10 +1115,26 @@ class WorkflowCoordinator: "model_id": author.model_id, "retry_reason": "provider_unavailable", "cache_hit": False, + **guidance.usage_metadata(), }) return WorkflowError(ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error)[:1000], retryable=True) call = validate_one_tool_call(response["tool_calls"], name) if isinstance(call, WorkflowError): + # A provider response is still a billable author attempt even when + # it violates the one-tool-call protocol. Keep guidance audit + # metadata on that record so A/B reports do not silently omit the + # failures this corpus is intended to reduce. + self.repository.record_usage(task_id, { + **response["usage"], + "context_chars": len(json.dumps(messages, ensure_ascii=False)), + "tool": name, + "provider_id": author.provider_id, + "model_id": author.model_id, + "raw_arguments_hash": "", + "retry_reason": "invalid_tool_call", + "cache_hit": False, + **guidance.usage_metadata(), + }) self._record_rejected_tool_calls( task_id, actor="author", @@ -1132,6 +1160,7 @@ class WorkflowCoordinator: "raw_arguments_hash": raw_arguments_hash(raw), "retry_reason": "", "cache_hit": False, + **guidance.usage_metadata(), } self.repository.record_usage(task_id, usage) return name, raw, usage @@ -1400,10 +1429,10 @@ class WorkflowCoordinator: return [] return [self._tool("record_geometry_conclusion", StatelessGeometryConclusion)] - def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> list[dict[str, Any]]: + def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], AuthorGuidanceSelection]: state = self.repository.get_state(task_id) if state is None: - return [] + return [], AuthorGuidanceSelection(fallback_reason="task_state_unavailable") if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT: content = { "protocol": "cad.v3.2.feature-dag", @@ -1557,8 +1586,17 @@ class WorkflowCoordinator: "feature_plan_hash": state.feature_plan_hash, "feature_node_statuses": self._feature_node_statuses(task_id, None), }) - messages: list[dict[str, Any]] = [{"role": "system", "content": "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}] - return [*messages, *feedback[-2:]] + guidance = self.author_guidance.select( + phase=state.phase, + atomic_id=state.pending_feature.atomic_id if state.pending_feature is not None else "", + repair_required=state.repair_required, + supported_atomic_ids=self.runtime.supported_atomic_ids(), + ) + system = "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON." + if guidance.content: + system += "\n\nThe following is non-authoritative CDSL author guidance. The current tool schema, operation contract, and server facts take precedence.\n\n" + guidance.content + messages: list[dict[str, Any]] = [{"role": "system", "content": system}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}] + return [*messages, *feedback[-2:]], guidance def _user_clarifications(self, task_id: str) -> list[dict[str, str]]: clarifications: list[dict[str, str]] = [] diff --git a/backend/app/cad_agent/composition.py b/backend/app/cad_agent/composition.py index d8f99944..13287c9c 100644 --- a/backend/app/cad_agent/composition.py +++ b/backend/app/cad_agent/composition.py @@ -6,6 +6,7 @@ from dataclasses import dataclass import shutil from app.cad_agent.adapters.artifact_store import FileArtifactStore +from app.cad_agent.adapters.author_guidance import FileAuthorGuidance from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher from app.cad_agent.adapters.runtime import ProfileCadRuntime from app.cad_agent.adapters.review_gateway import RenderedReviewGateway @@ -17,7 +18,7 @@ from app.cad_agent.application.outbox import OutboxDispatcher from app.cad_agent.application.requirements import RequirementsCommandHandler from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator from app.cad_agent.domain.verifier_registry import default_registry -from app.settings import Settings +from app.settings import BACKEND_ROOT, Settings from app.services.storage import WorkspaceStore @@ -65,5 +66,10 @@ def compose_v3(settings: Settings) -> V3Services: RenderedReviewGateway(models), requirements, actions, + FileAuthorGuidance( + BACKEND_ROOT / "agent" / "skills" / "cdsl-author-guidance", + enabled=settings.agent_author_guidance_enabled, + max_chars=settings.agent_author_guidance_max_chars, + ), ) return V3Services(repository, artifacts, workflow, models, outbox) diff --git a/backend/app/cad_agent/evals/live.py b/backend/app/cad_agent/evals/live.py index 7b6656ee..593767b1 100644 --- a/backend/app/cad_agent/evals/live.py +++ b/backend/app/cad_agent/evals/live.py @@ -15,6 +15,7 @@ from hashlib import sha256 import json from pathlib import Path import secrets +from statistics import median import subprocess import sys from typing import Any @@ -100,12 +101,15 @@ def _arguments() -> argparse.Namespace: parser.add_argument("--author-model") parser.add_argument("--review-provider") parser.add_argument("--review-model") - parser.add_argument("--scenario", help="Run one fixture scenario by its stable ID for targeted regression validation.") + parser.add_argument("--scenario", action="append", dest="scenarios", help="Run a fixture scenario by stable ID. Repeat this option to select a comparison set.") + parser.add_argument("--repetitions", type=int, help="Run every selected scenario this many times.") + parser.add_argument("--author-guidance", choices=("on", "off"), help="Override CDSL author guidance for this run.") + parser.add_argument("--compare-guidance-reports", nargs=2, type=Path, metavar=("CONTROL", "TREATMENT"), help="Compare matched --author-guidance off/on report.json files without invoking providers.") parser.add_argument("--baseline-report", type=Path, help="Measured pre-v3 token baseline JSON for a release run.") return parser.parse_args() -def _fixture(suite: str, scenario_id: str | None = None) -> list[dict[str, Any]]: +def _fixture(suite: str, scenario_ids: list[str] | None = None) -> list[dict[str, Any]]: fixture_name = "comprehensive.json" if suite == "comprehensive" else "release.json" value = json.loads((Path(__file__).parent / "fixtures" / fixture_name).read_text(encoding="utf-8")) if fixture_name == "comprehensive.json": @@ -122,11 +126,14 @@ def _fixture(suite: str, scenario_id: str | None = None) -> list[dict[str, Any]] raise ValueError("Comprehensive fixture is not synchronized with its source document.") values = [item for item in value.get("scenarios") or () if isinstance(item, dict)] values = values[:2] if suite == "smoke" else values - if scenario_id is None: + if not scenario_ids: return values - selected = [item for item in values if str(item.get("id") or "") == scenario_id] - if not selected: - raise ValueError(f"Unknown scenario {scenario_id!r} for suite {suite!r}") + requested = list(dict.fromkeys(scenario_ids)) + available = {str(item.get("id") or "") for item in values} + unknown = [scenario_id for scenario_id in requested if scenario_id not in available] + if unknown: + raise ValueError(f"Unknown scenario {unknown[0]!r} for suite {suite!r}") + selected = [item for item in values if str(item.get("id") or "") in set(requested)] return selected @@ -567,12 +574,180 @@ def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]: return {"schema_version": "cad.live-eval-artifact-manifest.v1", "artifact_root": str(artifact_root), "files": files} +def _guidance_metadata(usage: dict[str, Any]) -> dict[str, Any]: + """Summarize author-only guidance audit metadata without retaining prompts.""" + records = [ + item for item in usage.get("records") or () + if isinstance(item, dict) and item.get("role") != "reviewer" + ] + sections = sorted({ + section_id + for item in records + for section_id in item.get("guidance_section_ids") or () + if isinstance(section_id, str) + }) + versions = sorted({ + str(item.get("guidance_version") or "") + for item in records + if str(item.get("guidance_version") or "") + }) + return { + "enabled": bool(records) and any(item.get("guidance_enabled") is True for item in records), + "versions": versions, + "section_ids": sections, + "chars_total": sum(int(item.get("guidance_chars") or 0) for item in records), + "fallback_reasons": sorted({ + str(item.get("guidance_fallback_reason") or "") + for item in records + if str(item.get("guidance_fallback_reason") or "") + }), + } + + +def _has_unsupported_capability(row: dict[str, Any]) -> bool: + """Recognize engine-declared unsupported capability without hiding model errors.""" + for event in row.get("ledger") or (): + if not isinstance(event, dict): + continue + for failure in event.get("operation_failures") or (): + if isinstance(failure, dict) and "unsupported_" in str(failure.get("message") or ""): + return True + if "unsupported_" in str(event.get("message") or ""): + return True + return False + + +def _guidance_metric_rows(report: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + results = [item for item in report.get("results") or () if isinstance(item, dict)] + capability_gaps = [ + item for item in results + if item.get("outcome") == "validation_capability_gap" or _has_unsupported_capability(item) + ] + return [item for item in results if item not in capability_gaps], capability_gaps + + +def _guidance_metrics(rows: list[dict[str, Any]]) -> dict[str, Any]: + if not rows: + return {"eligible_runs": 0} + author_calls = [ + sum(1 for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer") + for row in rows + ] + context_chars = [ + sum(int(item.get("context_chars") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer") + for row in rows + ] + prompt_tokens = [ + sum(int(item.get("prompt_tokens") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer") + for row in rows + ] + failure_layers: dict[str, int] = {} + for row in rows: + layer = str((row.get("failure_attribution") or {}).get("layer") or "passed") + failure_layers[layer] = failure_layers.get(layer, 0) + 1 + def rate(predicate: Any) -> float: + return sum(1 for row in rows if predicate(row)) / len(rows) + return { + "eligible_runs": len(rows), + "executable_checkpoint_rate": rate(lambda row: bool(row.get("revision_ids"))), + "completion_rate": rate(lambda row: str((row.get("projection") or {}).get("phase") or "") == "COMPLETED"), + "deterministic_claim_success_rate": rate(lambda row: bool((row.get("checks") or {}).get("deterministic_claims_pass"))), + "schema_or_decision_rejections": sum(int(row.get("schema_rejection_count") or 0) for row in rows), + "cdsl_expression_failures": sum(1 for row in rows if str((row.get("failure_attribution") or {}).get("layer") or "") == "cdsl_expression"), + "median_author_calls": median(author_calls), + "median_author_context_chars": median(context_chars), + "total_author_prompt_tokens": sum(prompt_tokens), + "median_author_prompt_tokens": median(prompt_tokens), + "failure_layers": dict(sorted(failure_layers.items())), + } + + +def compare_guidance_reports(control: dict[str, Any], treatment: dict[str, Any]) -> dict[str, Any]: + """Compare paired guidance-off/on runs without treating engine gaps as prompt results.""" + control_rows, control_gaps = _guidance_metric_rows(control) + treatment_rows, treatment_gaps = _guidance_metric_rows(treatment) + control_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in control_rows} + treatment_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in treatment_rows} + paired = sorted(set(control_by_key).intersection(treatment_by_key)) + control_only = sorted(set(control_by_key).difference(treatment_by_key)) + treatment_only = sorted(set(treatment_by_key).difference(control_by_key)) + control_pairs = [control_by_key[key] for key in paired] + treatment_pairs = [treatment_by_key[key] for key in paired] + control_metrics = _guidance_metrics(control_pairs) + treatment_metrics = _guidance_metrics(treatment_pairs) + same_runtime = ( + control.get("author") == treatment.get("author") + and control.get("reviewer") == treatment.get("reviewer") + and control.get("runtime_profile_sha256") == treatment.get("runtime_profile_sha256") + and control.get("operation_contracts") == treatment.get("operation_contracts") + ) + control_guidance = bool((control.get("author_guidance") or {}).get("enabled")) + treatment_guidance = bool((treatment.get("author_guidance") or {}).get("enabled")) + same_budgets = all( + control_by_key[key].get("scenario_budget") == treatment_by_key[key].get("scenario_budget") + for key in paired + ) + calls_control = control_metrics.get("median_author_calls") + calls_treatment = treatment_metrics.get("median_author_calls") + calls_within_limit = ( + isinstance(calls_control, (int, float)) + and isinstance(calls_treatment, (int, float)) + and calls_treatment <= calls_control * 1.10 + ) + improved = ( + treatment_metrics.get("schema_or_decision_rejections", 0) < control_metrics.get("schema_or_decision_rejections", 0) + or treatment_metrics.get("cdsl_expression_failures", 0) < control_metrics.get("cdsl_expression_failures", 0) + ) + gates = { + "complete_pairing": bool(paired) and not control_only and not treatment_only, + "control_off_treatment_on": not control_guidance and treatment_guidance, + "same_author_reviewer_runtime_and_contracts": same_runtime, + "same_per_scenario_budgets": same_budgets, + "checkpoint_rate_not_lower": treatment_metrics.get("executable_checkpoint_rate", -1) >= control_metrics.get("executable_checkpoint_rate", 0), + "completion_rate_not_lower": treatment_metrics.get("completion_rate", -1) >= control_metrics.get("completion_rate", 0), + "median_author_calls_within_ten_percent": calls_within_limit, + "model_or_cdsl_failure_improved": improved, + } + return { + "schema_version": "cad.author-guidance-comparison.v1", + "status": "passed" if all(gates.values()) else "failed", + "gates": gates, + "paired_runs": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in paired], + "unpaired_runs": { + "control_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in control_only], + "treatment_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in treatment_only], + }, + "control": control_metrics, + "treatment": treatment_metrics, + "excluded_capability_gaps": { + "control": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in control_gaps], + "treatment": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in treatment_gaps], + }, + } + + +def compare_guidance_report_paths(control_path: Path, treatment_path: Path) -> dict[str, Any]: + try: + control = json.loads(control_path.read_text(encoding="utf-8")) + treatment = json.loads(treatment_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + return {"status": "failed", "error": f"GUIDANCE_COMPARISON_INPUT_INVALID: {type(error).__name__}"} + if not isinstance(control, dict) or not isinstance(treatment, dict): + return {"status": "failed", "error": "GUIDANCE_COMPARISON_INPUT_INVALID: report must be an object"} + return compare_guidance_reports(control, treatment) + + async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]: try: - scenarios = _fixture(arguments.suite, arguments.scenario) + scenarios = _fixture(arguments.suite, arguments.scenarios) except ValueError as error: return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)} + repetitions = arguments.repetitions if arguments.repetitions is not None else 3 if arguments.suite == "release" else 1 + if repetitions < 1: + return {"status": "LIVE_EVAL_BLOCKED", "error": "--repetitions must be at least 1"} settings = get_settings() + if arguments.author_guidance is not None: + settings = replace(settings, agent_author_guidance_enabled=arguments.author_guidance == "on") try: author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model) if arguments.review_provider or arguments.review_model: @@ -628,7 +803,7 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An } results: list[dict[str, Any]] = [] for scenario in scenarios: - for repetition in range(1, 4 if arguments.suite == "release" else 2): + for repetition in range(1, repetitions + 1): services.workflow.config = replace( services.workflow.config, # State transitions include local candidate recovery, so the @@ -716,6 +891,13 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An "terminal": terminal, "projection": projection, "usage": usage, "checks": checks, "acceptance_coverage": acceptance, "failure_attribution": failure_attribution, + "guidance": _guidance_metadata(usage), + "scenario_budget": { + "max_author_turns": int(scenario["max_author_turns"]), + "max_reviewer_turns": int(scenario["max_reviewer_turns"]), + "max_total_calls": int(scenario["max_total_calls"]), + "max_total_tokens": int(scenario["max_total_tokens"]), + }, "rejection_codes": _rejection_codes(events), "schema_rejection_count": len(_rejection_codes(events)), "retry_count": sum(1 for event in events if (event.get("payload") or {}).get("status") == "error"), @@ -745,6 +927,10 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An "author": {"provider": author_provider.id, "model": author_model.id}, "author_request_identity": current_author_identity, "reviewer": {"provider": review_provider.id, "model": review_model.id}, + "author_guidance": { + "enabled": settings.agent_author_guidance_enabled, + "max_chars": settings.agent_author_guidance_max_chars, + }, "author_capability": author_capability, "reviewer_capability": reviewer_capability, "structured_output_mode": { @@ -759,6 +945,7 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An "verifier_schema_hash": verifier_schema_hash, "token_comparison": token_comparison, "baseline_report": str(baseline_path) if baseline_path else "", + "repetitions": repetitions, "results": results, } @@ -768,7 +955,11 @@ def main() -> int: report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") report_root.mkdir(parents=True, exist_ok=True) try: - result = asyncio.run(_run(arguments, report_root)) + result = ( + compare_guidance_report_paths(*arguments.compare_guidance_reports) + if arguments.compare_guidance_reports + else asyncio.run(_run(arguments, report_root)) + ) except KeyboardInterrupt: # Let an explicit operator interruption retain its normal CLI # semantics. An external kill cannot be reported reliably either. diff --git a/backend/app/cad_agent/ports.py b/backend/app/cad_agent/ports.py index 5c8809bc..dc88cff5 100644 --- a/backend/app/cad_agent/ports.py +++ b/backend/app/cad_agent/ports.py @@ -5,13 +5,60 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any, Protocol -from .domain.state import TaskState +from .domain.state import TaskPhase, TaskState class AdapterUnavailable(RuntimeError): """A bounded external-service outage; handlers must preserve checkpoints.""" +@dataclass(frozen=True, slots=True) +class AuthorGuidanceSelection: + """Non-authoritative author context selected from the local guidance corpus.""" + + version: str = "" + section_ids: tuple[str, ...] = () + content: str = "" + enabled: bool = False + fallback_reason: str = "" + + def usage_metadata(self) -> dict[str, object]: + return { + "guidance_version": self.version, + "guidance_section_ids": list(self.section_ids), + "guidance_chars": len(self.content), + "guidance_enabled": self.enabled, + "guidance_fallback_reason": self.fallback_reason, + } + + +class AuthorGuidance(Protocol): + """Select bounded local author guidance without interpreting user intent.""" + + def select( + self, + *, + phase: TaskPhase, + atomic_id: str, + repair_required: bool, + supported_atomic_ids: tuple[str, ...], + ) -> AuthorGuidanceSelection: ... + + +class NullAuthorGuidance: + """Compatibility default that retains the pre-guidance author prompt.""" + + def select( + self, + *, + phase: TaskPhase, + atomic_id: str, + repair_required: bool, + supported_atomic_ids: tuple[str, ...], + ) -> AuthorGuidanceSelection: + return AuthorGuidanceSelection(fallback_reason="guidance_not_configured") + + @dataclass(frozen=True, slots=True) class InvocationRecord: invocation_id: str diff --git a/backend/app/settings.py b/backend/app/settings.py index 8eafe639..49a6c07d 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -71,6 +71,8 @@ class Settings: agent_consecutive_no_progress_limit: int = 6 agent_format_error_repeat_limit: int = 3 agent_context_char_limit: int = 14000 + agent_author_guidance_enabled: bool = True + agent_author_guidance_max_chars: int = 3600 agent_render_cache: bool = True autonomous_generation: bool = True resume_running_tasks_on_startup: bool = True @@ -211,6 +213,8 @@ def get_settings() -> Settings: agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))), agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))), agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))), + agent_author_guidance_enabled=_env_flag("CDSL_AGENT_AUTHOR_GUIDANCE_ENABLED", True), + agent_author_guidance_max_chars=min(6000, max(1200, int(os.getenv("CDSL_AGENT_AUTHOR_GUIDANCE_MAX_CHARS", "3600")))), agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True), autonomous_generation=True, # Production instances recover durable runs by default. Test workers diff --git a/backend/tests/test_author_guidance.py b/backend/tests/test_author_guidance.py new file mode 100644 index 00000000..3ba6d541 --- /dev/null +++ b/backend/tests/test_author_guidance.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.cad_agent.adapters.author_guidance import FileAuthorGuidance # noqa: E402 +from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator # noqa: E402 +from app.cad_agent.domain.errors import ErrorCode, WorkflowError # noqa: E402 +from app.cad_agent.domain.state import TaskPhase, TaskState # noqa: E402 + + +GUIDANCE_ROOT = ROOT / "backend" / "agent" / "skills" / "cdsl-author-guidance" +PROFILE = ROOT / "backend" / "engine" / "cdsl_engine" / "profile_schema.json" + + +def atomic_ids() -> tuple[str, ...]: + return tuple(json.loads(PROFILE.read_text(encoding="utf-8"))["operation_contracts"]) + + +class _Repository: + def __init__(self, state: TaskState) -> None: + self.state = state + self.usage_records: list[dict] = [] + + def get_state(self, _task_id: str) -> TaskState: + return self.state + + def ledger_events(self, _task_id: str) -> list[dict]: + return [] + + def record_usage(self, _task_id: str, payload: dict) -> None: + self.usage_records.append(payload) + + def record_tool_audit(self, _task_id: str, _payload: dict) -> None: + pass + + +class _Artifacts: + def read_source_requirements(self, _task_id: str) -> str: + return "Create a symmetric mounting plate." + + def read_json(self, *_args: object) -> None: + return None + + +class _Runtime: + def supported_atomic_ids(self) -> tuple[str, ...]: + return atomic_ids() + + +class AuthorGuidanceTests(unittest.TestCase): + def test_manifest_covers_every_runtime_atomic_and_keeps_coordinate_core_at_minimum_budget(self) -> None: + guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=1_200) + covered: set[str] = set() + for atomic_id in atomic_ids(): + selection = guidance.select( + phase=TaskPhase.FEATURE_PENDING, + atomic_id=atomic_id, + repair_required=False, + supported_atomic_ids=atomic_ids(), + ) + self.assertTrue(selection.enabled, selection.fallback_reason) + self.assertIn("00-author-contract", selection.section_ids) + self.assertIn("03-coordinate-system-and-datums", selection.section_ids) + self.assertLessEqual(len(selection.content), 1_200) + self.assertIn("世界坐标", selection.content) + covered.update(section_id for section_id in selection.section_ids if section_id.startswith("op-")) + self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere"}) + + def test_phase_repair_and_budget_selection_are_stable(self) -> None: + guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600) + planning = guidance.select( + phase=TaskPhase.COMPILING_FEATURE_PLAN, + atomic_id="", + repair_required=False, + supported_atomic_ids=atomic_ids(), + ) + repair = guidance.select( + phase=TaskPhase.AWAITING_ACTION, + atomic_id="fillet", + repair_required=True, + supported_atomic_ids=atomic_ids(), + ) + self.assertEqual(planning.section_ids[:2], ("00-author-contract", "03-coordinate-system-and-datums")) + self.assertIn("02-parameters-and-derived-dimensions", planning.section_ids) + self.assertEqual(repair.section_ids[:3], ("00-author-contract", "03-coordinate-system-and-datums", "op-finish")) + self.assertIn("10-repair-and-best-effort", repair.section_ids) + + def test_disabled_missing_and_invalid_corpus_fall_back_without_authoring_failure(self) -> None: + common = { + "phase": TaskPhase.FEATURE_PENDING, + "atomic_id": "extrude_add_blind", + "repair_required": False, + "supported_atomic_ids": atomic_ids(), + } + self.assertEqual(FileAuthorGuidance(GUIDANCE_ROOT, enabled=False).select(**common).fallback_reason, "guidance_disabled") + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:FileNotFoundError") + (root / "manifest.json").write_text("{}", encoding="utf-8") + self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:ValueError") + + def test_author_context_receives_guidance_but_keeps_the_existing_tool_instruction(self) -> None: + state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1) + workflow = WorkflowCoordinator( + WorkflowConfig(max_turns=8, format_error_limit=2), + _Repository(state), + _Artifacts(), + _Runtime(), + object(), + object(), + object(), + object(), + FileAuthorGuidance(GUIDANCE_ROOT), + ) + messages, selection = workflow._author_context(state.task_id, []) + system = str(messages[0]["content"]) + self.assertTrue(selection.enabled) + self.assertIn("Use exactly one offered structured tool call", system) + self.assertIn("Coordinate System And Datums", system) + self.assertIn("世界坐标", system) + + def test_invalid_author_tool_call_retains_guidance_usage_metadata(self) -> None: + state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1) + repository = _Repository(state) + class _Models: + async def call_tool(self, **_kwargs: object) -> dict: + return {"tool_calls": [], "usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4}} + workflow = WorkflowCoordinator( + WorkflowConfig(max_turns=8, format_error_limit=2), + repository, + _Artifacts(), + _Runtime(), + _Models(), + object(), + object(), + object(), + FileAuthorGuidance(GUIDANCE_ROOT), + ) + tool = {"type": "function", "function": {"name": "write_requirements_document", "parameters": {"type": "object"}}} + result = asyncio.run(workflow._author_turn(state.task_id, ModelIdentity("provider", "model"), [tool], [])) + self.assertIsInstance(result, WorkflowError) + self.assertEqual(result.code, ErrorCode.AUTHOR_FORMAT_INVALID) + self.assertEqual(repository.usage_records[0]["guidance_enabled"], True) + self.assertIn("03-coordinate-system-and-datums", repository.usage_records[0]["guidance_section_ids"]) + self.assertEqual(repository.usage_records[0]["retry_reason"], "invalid_tool_call") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_live_guidance_comparison.py b/backend/tests/test_live_guidance_comparison.py new file mode 100644 index 00000000..eb1224e0 --- /dev/null +++ b/backend/tests/test_live_guidance_comparison.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from pathlib import Path +import sys +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.cad_agent.evals.live import _fixture, compare_guidance_reports # noqa: E402 + + +def _result(*, scenario: str, repetition: int, author_calls: int, context_chars: int, schema_rejections: int, failure_layer: str = "") -> dict: + return { + "scenario": scenario, + "repetition": repetition, + "outcome": "passed", + "revision_ids": ["revision_001"], + "projection": {"phase": "COMPLETED"}, + "checks": {"deterministic_claims_pass": True}, + "schema_rejection_count": schema_rejections, + "failure_attribution": {"layer": failure_layer} if failure_layer else None, + "scenario_budget": {"max_author_turns": 8, "max_reviewer_turns": 2, "max_total_calls": 10, "max_total_tokens": 1000}, + "usage": {"records": [{"context_chars": context_chars} for _ in range(author_calls)]}, + } + + +def _report(results: list[dict]) -> dict: + return { + "author": {"provider": "author", "model": "model"}, + "reviewer": {"provider": "reviewer", "model": "review"}, + "runtime_profile_sha256": "a" * 64, + "operation_contracts": [{"atomic_id": "extrude_add_blind", "contract_hash": "b" * 64}], + "author_guidance": {"enabled": False, "max_chars": 3600}, + "results": results, + } + + +class LiveGuidanceComparisonTests(unittest.TestCase): + def test_fixture_accepts_multiple_stable_scenarios_in_fixture_order(self) -> None: + selected = _fixture("comprehensive", ["l_bracket", "circular_flange_pcd"]) + self.assertEqual([item["id"] for item in selected], ["circular_flange_pcd", "l_bracket"]) + + def test_comparison_enforces_matched_budget_and_quality_gates(self) -> None: + control = _report([ + _result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=2, failure_layer="cdsl_expression"), + _result(scenario="part_a", repetition=2, author_calls=10, context_chars=1000, schema_rejections=1), + ]) + treatment = _report([ + _result(scenario="part_a", repetition=1, author_calls=11, context_chars=1300, schema_rejections=0), + _result(scenario="part_a", repetition=2, author_calls=11, context_chars=1300, schema_rejections=0), + ]) + treatment["author_guidance"]["enabled"] = True + comparison = compare_guidance_reports(control, treatment) + self.assertEqual(comparison["status"], "passed") + self.assertTrue(comparison["gates"]["median_author_calls_within_ten_percent"]) + self.assertTrue(comparison["gates"]["model_or_cdsl_failure_improved"]) + + def test_comparison_excludes_explicit_unsupported_runtime_capability(self) -> None: + control = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=1)]) + treatment = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1200, schema_rejections=0)]) + treatment["author_guidance"]["enabled"] = True + treatment["results"][0]["ledger"] = [{"operation_failures": [{"message": "unsupported_draft"}]}] + comparison = compare_guidance_reports(control, treatment) + self.assertEqual(comparison["treatment"]["eligible_runs"], 0) + self.assertEqual(comparison["excluded_capability_gaps"]["treatment"], [{"scenario": "part_a", "repetition": 1}]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_structured_llm.py b/backend/tests/test_structured_llm.py new file mode 100644 index 00000000..ccb50777 --- /dev/null +++ b/backend/tests/test_structured_llm.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +import sys +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.cad_agent.adapters.structured_llm import StructuredModelError, StructuredModelGateway # noqa: E402 +from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 + + +def _settings(*, api_style: str = "responses", reasoning_effort: str = "medium") -> Settings: + provider = ProviderConfig( + "provider", "Provider", "https://example.invalid/v1", "key", + (ProviderModel("model"),), reasoning_effort=reasoning_effort, api_style=api_style, + ) + return Settings( + task_root=ROOT / "tmp-tasks", + conversation_root=ROOT / "tmp-conversations", + library_root=ROOT / "backend" / "cdsl_library", + engine_root=ROOT / "backend" / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="model", + llm_timeout_s=1, + default_provider_id="provider", + providers=(provider,), + ) + + +def _tool() -> dict: + return { + "type": "function", + "function": { + "name": "write_document", + "description": "Write one document.", + "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + } + + +def _response(api_style: str) -> dict: + if api_style == "responses": + return { + "output": [{"type": "function_call", "name": "write_document", "arguments": "{}"}], + "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, + } + return { + "choices": [{"message": {"tool_calls": [{"function": {"name": "write_document", "arguments": "{}"}}]}}], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + } + + +class StructuredModelGatewayCompatibilityTests(unittest.TestCase): + def test_thinking_tool_choice_retries_without_reasoning_before_relaxing_tool_choice(self) -> None: + gateway = StructuredModelGateway(_settings()) + payloads: list[dict] = [] + + async def request(_provider: object, payload: dict) -> dict: + payloads.append(payload) + if len(payloads) == 1: + raise StructuredModelError("Provider rejected structured request (400): Thinking mode does not support this tool_choice") + return _response("responses") + + gateway._request = request # type: ignore[method-assign] + result = asyncio.run(gateway.call_tool( + messages=[{"role": "system", "content": "Call the tool."}], + tool=_tool(), provider_id="provider", model_id="model", required_tool_name="write_document", + )) + + self.assertEqual(len(payloads), 2) + self.assertEqual(payloads[0]["tool_choice"], {"type": "function", "name": "write_document"}) + self.assertEqual(payloads[0]["reasoning"], {"effort": "medium"}) + self.assertEqual(payloads[1]["tool_choice"], {"type": "function", "name": "write_document"}) + self.assertNotIn("reasoning", payloads[1]) + self.assertEqual(result["usage"]["structured_compatibility_mode"], "reasoning_disabled") + + def test_persistent_thinking_rejection_uses_auto_with_the_same_single_tool(self) -> None: + gateway = StructuredModelGateway(_settings(api_style="chat_completions", reasoning_effort="")) + payloads: list[dict] = [] + + async def request(_provider: object, payload: dict) -> dict: + payloads.append(payload) + if len(payloads) < 3: + raise StructuredModelError("Thinking mode does not support this tool_choice") + return _response("chat_completions") + + gateway._request = request # type: ignore[method-assign] + result = asyncio.run(gateway.call_tool( + messages=[{"role": "system", "content": "Call the tool."}], + tool=_tool(), provider_id="provider", model_id="model", required_tool_name="write_document", + )) + + self.assertEqual(len(payloads), 3) + self.assertEqual(payloads[0]["tool_choice"]["function"]["name"], "write_document") + self.assertEqual(payloads[1]["tool_choice"]["function"]["name"], "write_document") + self.assertEqual(payloads[2]["tool_choice"], "auto") + self.assertEqual(len(payloads[2]["tools"]), 1) + self.assertEqual(result["usage"]["structured_compatibility_mode"], "single_tool_auto") + + def test_unrelated_provider_rejection_is_not_retried(self) -> None: + gateway = StructuredModelGateway(_settings()) + payloads: list[dict] = [] + + async def request(_provider: object, payload: dict) -> dict: + payloads.append(payload) + raise StructuredModelError("Provider rejected structured request (400): invalid model") + + gateway._request = request # type: ignore[method-assign] + with self.assertRaisesRegex(StructuredModelError, "invalid model"): + asyncio.run(gateway.call_tool( + messages=[{"role": "system", "content": "Call the tool."}], + tool=_tool(), provider_id="provider", model_id="model", required_tool_name="write_document", + )) + self.assertEqual(len(payloads), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/cadfs_to_cdsl/cli.py b/cadfs_to_cdsl/cli.py index 0374c8c0..9bcc1777 100644 --- a/cadfs_to_cdsl/cli.py +++ b/cadfs_to_cdsl/cli.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse, json from pathlib import Path +from .describe import describe_samples from .pipeline import load_samples, run_stage, scan, select_samples from .reports import generate_markdown_report, generate_reports, read_json @@ -12,11 +13,20 @@ DEFAULT_OUTPUT = Path("cadfs_to_cdsl/output") def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Convert CADFS FeatureScript to CDSL and validate against STEP") commands = parser.add_subparsers(dest="command", required=True) - for name in ("scan", "convert", "rebuild", "compare", "report", "pipeline"): + for name in ("scan", "convert", "rebuild", "compare", "report", "pipeline", "describe"): command = commands.add_parser(name) command.add_argument("--input", type=Path, default=DEFAULT_INPUT) command.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - if name != "scan": + if name == "describe": + command.add_argument("--shard", help="input shard directory to describe, for example 0005") + command.add_argument("--sample-id", action="append") + command.add_argument("--offset", type=int, default=0) + command.add_argument("--limit", type=int) + command.add_argument("--seed", type=int) + command.add_argument("--workers", type=int, default=1) + command.add_argument("--mode", choices=("local", "hybrid", "vision"), default="hybrid") + command.add_argument("--force", action="store_true") + elif name != "scan": command.add_argument("--sample-id", action="append") command.add_argument("--offset", type=int, default=0) command.add_argument("--limit", type=int) @@ -31,9 +41,23 @@ def _parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: - args = _parser().parse_args(argv); args.output.mkdir(parents=True, exist_ok=True) + args = _parser().parse_args(argv) + if args.command != "describe": + args.output.mkdir(parents=True, exist_ok=True) if args.command == "scan": records = scan(args.input, args.output); result = {"sample_count": len(records), "output": str(args.output / "dataset_index.json")} + elif args.command == "describe": + records, result = describe_samples( + args.input, + shard=args.shard, + sample_ids=args.sample_id, + mode=args.mode, + offset=args.offset, + limit=args.limit, + seed=args.seed, + force=args.force, + workers=args.workers, + ) elif args.command == "report": manifest = args.output / "manifest.jsonl"; records = [json.loads(line) for line in manifest.read_text().splitlines() if line.strip()] result = generate_reports(args.output, records) diff --git a/cadfs_to_cdsl/describe.py b/cadfs_to_cdsl/describe.py new file mode 100644 index 00000000..0ded9d08 --- /dev/null +++ b/cadfs_to_cdsl/describe.py @@ -0,0 +1,976 @@ +from __future__ import annotations + +import asyncio +import base64 +from collections import Counter +from concurrent.futures import ThreadPoolExecutor +import json +import math +from pathlib import Path +import random +import re +import struct +import threading +from typing import Any, Protocol + +from .dataset import Sample, scan_dataset +from .featurescript_parser import parse_featurescript +from .lowering import _number, _point, plain + + +DESCRIPTION_SCHEMA_VERSION = "cadfs_to_cdsl.description.v1" +MANIFEST_NAME = "description_manifest.jsonl" + +ENTITY_LABELS = { + "skLineSegment": "线段", + "skCircle": "圆", + "skArc": "圆弧", + "skEllipse": "椭圆", + "skFitSpline": "样条", + "skPoint": "点", +} + +OPERATION_LABELS = { + "extrude": "拉伸", + "revolve": "旋转", + "fillet": "圆角", + "chamfer": "倒角", + "hole": "孔", + "linearPattern": "线性阵列", + "circularPattern": "圆周阵列", + "mirror": "镜像", + "shell": "抽壳", + "loft": "放样", + "sweep": "扫掠", + "booleanBodies": "布尔", + "cPlane": "参考平面", + "referenceAxis": "参考轴", +} + +VISION_DESCRIPTION_TOOL = { + "type": "function", + "function": { + "name": "describe_cad_model", + "description": "Return a conservative semantic and geometric description for one CAD model.", + "parameters": { + "type": "object", + "additionalProperties": False, + "required": [ + "category", + "category_confidence", + "candidate_names", + "summary_zh", + "possible_functions", + "applications", + "structural_features", + "geometric_features", + "keywords_zh", + "keywords_en", + "uncertainties", + ], + "properties": { + "category": {"type": "string", "minLength": 1, "maxLength": 160}, + "category_confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "candidate_names": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 120}, "maxItems": 8}, + "summary_zh": {"type": "string", "minLength": 1, "maxLength": 1200}, + "possible_functions": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 8}, + "applications": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 8}, + "structural_features": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 16}, + "geometric_features": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 16}, + "keywords_zh": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 80}, "maxItems": 32}, + "keywords_en": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 80}, "maxItems": 32}, + "uncertainties": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 12}, + }, + }, + }, +} + + +class VisionDescriptionClient(Protocol): + def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, Any]) -> dict[str, Any]: + ... + + +class ConfiguredVisionDescriptionClient: + def __init__(self) -> None: + from app.cad_agent.adapters.structured_llm import StructuredModelGateway + from app.settings import get_settings + + self.settings = get_settings() + provider, model = self.settings.resolve_review_model() + if not model.vision: + raise ValueError("configured review model is not vision-capable") + self.provider_id = provider.id + self.model_id = model.id + self.gateway = StructuredModelGateway(self.settings) + + def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, Any]) -> dict[str, Any]: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + if loop and loop.is_running(): + raise RuntimeError("vision description cannot run inside an active event loop") + return asyncio.run(self._describe(sample_id=sample_id, image_path=image_path, local_facts=local_facts)) + + async def _describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, Any]) -> dict[str, Any]: + payload = { + "sample_id": sample_id, + "local_category": local_facts.get("category"), + "local_candidate_names": local_facts.get("candidate_names"), + "local_geometric_features": local_facts.get("geometric_features"), + "local_operations": local_facts.get("operations"), + "local_dimensions": local_facts.get("dimensions"), + "annotation_excerpt": str(local_facts.get("annotation_excerpt") or "")[:4000], + "instruction": ( + "Use the image and deterministic CAD facts to identify likely model class and retrieval terms. " + "Keep product identity and use cases as candidates when the evidence is not definitive. " + "Do not invent exact dimensions beyond the supplied facts." + ), + } + content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}] + if image_path.is_file(): + content.append(self._image_part(image_path)) + response = await self.gateway.call_tool( + messages=[ + { + "role": "system", + "content": ( + "You describe CAD parts for vector search. Return only the required tool call. " + "Separate visible geometry from inferred semantics, and mark uncertain real-world identity conservatively." + ), + }, + {"role": "user", "content": content}, + ], + tool=VISION_DESCRIPTION_TOOL, + provider_id=self.provider_id, + model_id=self.model_id, + required_tool_name="describe_cad_model", + ) + calls = response.get("tool_calls") or [] + if len(calls) != 1: + raise ValueError("vision provider did not return exactly one tool call") + function = calls[0].get("function") if isinstance(calls[0], dict) else None + if not isinstance(function, dict) or function.get("name") != "describe_cad_model": + raise ValueError("vision provider returned an unexpected tool call") + arguments = json.loads(str(function.get("arguments") or "{}")) + if not isinstance(arguments, dict): + raise ValueError("vision provider arguments are not an object") + arguments["usage"] = response.get("usage") or {} + return arguments + + @staticmethod + def _image_part(path: Path) -> dict[str, Any]: + media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png" + data = base64.b64encode(path.read_bytes()).decode("ascii") + return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{data}"}} + + +class VisionCallState: + def __init__(self, client: VisionDescriptionClient, *, failure_limit: int = 3) -> None: + self.client = client + self.failure_limit = failure_limit + self._lock = threading.Lock() + self._consecutive_failures = 0 + self._disabled_reason = "" + + def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + with self._lock: + disabled = self._disabled_reason + if disabled: + return None, {"code": "vision_disabled", "message": disabled} + try: + result = self.client.describe(sample_id=sample_id, image_path=image_path, local_facts=local_facts) + except Exception as exc: + with self._lock: + self._consecutive_failures += 1 + if self._consecutive_failures >= self.failure_limit: + self._disabled_reason = f"vision disabled after {self._consecutive_failures} consecutive failures" + return None, {"code": "vision_failed", "message": str(exc), "type": type(exc).__name__} + with self._lock: + self._consecutive_failures = 0 + return result, None + + +def sample_shard(sample: Sample) -> str: + for modality in ("image", "featurescript", "annotation", "step", "stl"): + path = sample.files.get(modality) + if path: + return Path(path).parent.name + return sample.sample_id[:4] + + +def select_description_samples( + input_root: Path, + *, + shard: str | None = None, + sample_ids: list[str] | None = None, + offset: int = 0, + limit: int | None = None, + seed: int | None = None, +) -> list[Sample]: + samples = scan_dataset(input_root, include_hashes=False) + if shard: + samples = [sample for sample in samples if sample_shard(sample) == shard] + if sample_ids: + wanted = set(sample_ids) + samples = [sample for sample in samples if sample.sample_id in wanted] + missing = wanted - {sample.sample_id for sample in samples} + if missing: + raise ValueError("unknown sample ids: " + ", ".join(sorted(missing))) + if seed is not None and limit is not None: + samples = random.Random(seed).sample(samples, min(limit, len(samples))) + return sorted(samples, key=lambda sample: sample.sample_id) + return samples[offset:None if limit is None else offset + limit] + + +def describe_samples( + input_root: Path, + *, + shard: str | None = None, + sample_ids: list[str] | None = None, + mode: str = "hybrid", + offset: int = 0, + limit: int | None = None, + seed: int | None = None, + force: bool = False, + workers: int = 1, + vision_client: VisionDescriptionClient | None = None, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + if mode not in {"local", "hybrid", "vision"}: + raise ValueError("--mode must be local, hybrid, or vision") + if not shard and not sample_ids and limit is None: + raise ValueError("describe requires --shard, --sample-id, or --limit to avoid accidental full-corpus generation") + if not 1 <= workers <= 8: + raise ValueError("--workers must be between 1 and 8") + + samples = select_description_samples(input_root, shard=shard, sample_ids=sample_ids, offset=offset, limit=limit, seed=seed) + existing_manifest = _read_manifest(input_root / "description_txt" / MANIFEST_NAME) + shared_diagnostics: list[dict[str, Any]] = [] + vision_state = VisionCallState(vision_client) if vision_client is not None else None + if mode != "local" and vision_client is None: + try: + vision_client = ConfiguredVisionDescriptionClient() + vision_state = VisionCallState(vision_client) + except Exception as exc: + shared_diagnostics.append({"code": "vision_unavailable", "message": str(exc), "type": type(exc).__name__}) + + def process(sample: Sample) -> dict[str, Any]: + return describe_one( + sample, + input_root, + mode=mode, + force=force, + vision_state=vision_state, + existing_record=existing_manifest.get(sample.sample_id), + shared_diagnostics=shared_diagnostics, + ) + + if workers == 1: + records = [process(sample) for sample in samples] + else: + with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="cadfs-describe") as executor: + records = list(executor.map(process, samples)) + manifest_rows = _write_manifest(input_root, records) + return records, _summary(input_root, records, manifest_rows) + + +def describe_one( + sample: Sample, + input_root: Path, + *, + mode: str, + force: bool = False, + vision_state: VisionCallState | None = None, + existing_record: dict[str, Any] | None = None, + shared_diagnostics: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + shard = sample_shard(sample) + txt_path = input_root / "description_txt" / shard / f"{sample.sample_id}.txt" + if txt_path.exists() and not force: + return existing_record or _skipped_record(sample, input_root, txt_path, shard) + + diagnostics = list(shared_diagnostics or []) + try: + local_facts = _local_facts(sample) + diagnostics.extend(local_facts.pop("diagnostics", [])) + vision_result = None + if mode != "local": + if vision_state is None: + diagnostics.append({"code": "vision_unavailable", "message": "no configured vision client"}) + else: + vision_result, vision_diagnostic = vision_state.describe( + sample_id=sample.sample_id, + image_path=Path(sample.files.get("image", "")), + local_facts=local_facts, + ) + if vision_diagnostic is not None: + diagnostics.append(vision_diagnostic) + record = _compose_record(sample, input_root, shard, txt_path, local_facts, vision_result, diagnostics, mode) + _atomic_write_text(txt_path, render_description_text(record)) + return record + except Exception as exc: + diagnostics.append({"code": "description_failed", "message": str(exc), "type": type(exc).__name__}) + record = _failed_record(sample, input_root, txt_path, shard, diagnostics) + _atomic_write_text(txt_path, render_description_text(record)) + return record + + +def render_description_text(record: dict[str, Any]) -> str: + confidence = _format_float(float(record.get("category_confidence") or 0.0)) + keywords = _unique([*(record.get("keywords_zh") or []), *(record.get("keywords_en") or [])], limit=48) + evidence = [ + "确定事实来自 FeatureScript、原始 CAD 操作说明、STL/STEP 文件存在性和可解析的几何范围。", + *_as_text_list(record.get("uncertainties")), + ] + diagnostics = record.get("diagnostics") or [] + if diagnostics: + codes = _unique([str(item.get("code") or "diagnostic") for item in diagnostics if isinstance(item, dict)], limit=8) + if codes: + evidence.append("诊断:" + ",".join(codes)) + lines = [ + f"样本ID:{record.get('sample_id', '')}", + f"分类:{record.get('category', '通用机械 CAD 零件(候选)')}", + "候选名称:" + _join_or_unknown(record.get("candidate_names")), + "模型概述:" + str(record.get("summary_zh") or "该模型为缺少语义上下文的 CAD 几何样本,描述以可见结构和建模特征为主。"), + "可能作用:" + _join_or_unknown(record.get("possible_functions")), + "典型应用:" + _join_or_unknown(record.get("applications")), + "结构特征:" + _join_or_unknown(record.get("structural_features")), + "建模与几何特征:" + _join_or_unknown(record.get("geometric_features")), + "检索关键词:" + _join_or_unknown(keywords), + "证据与不确定性:" + ";".join(evidence), + f"置信度:{confidence}(几何事实置信度较高;真实零件类别、用途和装配位置为候选判断)", + ] + return "\n".join(lines) + "\n" + + +def _local_facts(sample: Sample) -> dict[str, Any]: + feature_path = Path(sample.files["featurescript"]) + annotation_path = Path(sample.files.get("annotation", "")) + stl_path = Path(sample.files.get("stl", "")) + image_path = Path(sample.files.get("image", "")) + source = feature_path.read_text(encoding="utf-8") + annotation = annotation_path.read_text(encoding="utf-8") if annotation_path.is_file() else "" + model = parse_featurescript(source, sample.sample_id) + diagnostics: list[dict[str, Any]] = [] + sketch_info, sketch_features, sketch_dimensions, entity_counts = _describe_sketches(model) + operations, operation_dimensions = _describe_operations(model) + stl_info = _stl_bbox(stl_path) if stl_path.is_file() else None + image_info = _image_metadata(image_path) if image_path.is_file() else None + shape_cues = _shape_cues(annotation, entity_counts, operations) + geometric_features = _unique( + [ + f"包含 {len(model.sketches)} 个草图和 {len(model.features)} 个建模/修饰特征", + *sketch_features, + *shape_cues, + *_stl_features(stl_info), + ], + limit=32, + ) + dimensions = _unique([*sketch_dimensions, *operation_dimensions, *_stl_dimensions(stl_info)], limit=32) + classification = _classify_local(annotation, operations, geometric_features, stl_info) + keywords_zh, keywords_en = _keywords(classification, operations, geometric_features) + uncertainties = [ + "CADFS 样本未提供真实装配上下文,类别、用途和安装位置只能作为候选语义。", + "确定描述优先依据几何、草图和特征操作;视觉判断仅作为补充证据。", + ] + summary = _local_summary(classification, operations, geometric_features, dimensions) + if not source.strip(): + diagnostics.append({"code": "empty_featurescript", "message": "FeatureScript file is empty"}) + return { + "category": classification["category"], + "category_confidence": classification["confidence"], + "candidate_names": classification["candidate_names"], + "summary_zh": summary, + "possible_functions": classification["possible_functions"], + "applications": classification["applications"], + "structural_features": _unique([*classification["structural_features"], *sketch_info], limit=24), + "geometric_features": geometric_features, + "operations": operations, + "dimensions": dimensions, + "keywords_zh": keywords_zh, + "keywords_en": keywords_en, + "uncertainties": uncertainties, + "annotation_excerpt": annotation[:4000], + "image_metadata": image_info, + "diagnostics": diagnostics, + } + + +def _describe_sketches(model: Any) -> tuple[list[str], list[str], list[str], Counter[str]]: + sketch_info: list[str] = [] + features: list[str] = [] + dimensions: list[str] = [] + entity_counts: Counter[str] = Counter() + for sketch in model.sketches: + counts = Counter(entity.operation for entity in sketch.entities) + entity_counts.update(counts) + labels = [f"{ENTITY_LABELS.get(name, name)} {count} 个" for name, count in sorted(counts.items())] + plane = _plane_name(sketch.workplane) + if labels: + sketch_info.append(f"{sketch.feature_id} 位于 {plane} 平面,包含" + "、".join(labels)) + else: + sketch_info.append(f"{sketch.feature_id} 位于 {plane} 平面,没有可解析草图实体") + if counts: + features.append("草图包含" + "、".join(labels)) + bbox = _sketch_bbox(sketch) + if bbox: + width = bbox[2] - bbox[0] + height = bbox[3] - bbox[1] + dimensions.append(f"{sketch.feature_id} 二维范围约 {_format_float(width)} × {_format_float(height)} mm") + return sketch_info, _unique(features, limit=12), dimensions, entity_counts + + +def _describe_operations(model: Any) -> tuple[list[str], list[str]]: + operations: list[str] = [] + dimensions: list[str] = [] + for feature in model.features: + params = feature.params + operation = feature.operation + if operation == "extrude": + operation_type = _enum_tail(params.get("operationType") or "NEW").upper() + action = "extrude_cut" if operation_type in {"REMOVE", "CUT"} else "extrude_add" + depth = _safe_number(params.get("depth")) + label = f"{action}" + if depth is not None: + label += f" depth_mm={_format_float(depth)}" + dimensions.append(f"{feature.feature_id} 拉伸深度 {_format_mm(depth)}") + if _truthy(params.get("hasSecondDirection")): + label += " two_sided=true" + operations.append(label) + elif operation == "revolve": + operation_type = _enum_tail(params.get("operationType") or params.get("surfaceOperationType") or "NEW").upper() + action = "revolve_cut" if operation_type in {"REMOVE", "CUT"} else "revolve_add" + angle = 360.0 if "FULL" in _enum_tail(params.get("revolveType") or "FULL").upper() else _safe_number(params.get("angle")) + operations.append(f"{action} angle_deg={_format_float(angle or 360.0)}") + elif operation == "hole": + diameter = _safe_number(params.get("holeDiameter")) + style = _enum_tail(params.get("style") or "simple").lower() + label = f"hole {style}" + if diameter is not None: + label += f" diameter_mm={_format_float(diameter)}" + dimensions.append(f"{feature.feature_id} 孔径 {_format_mm(diameter)}") + operations.append(label) + elif operation == "fillet": + radius = _safe_number(params.get("radius")) + operations.append("fillet" + (f" radius_mm={_format_float(radius)}" if radius is not None else "")) + if radius is not None: + dimensions.append(f"{feature.feature_id} 圆角半径 {_format_mm(radius)}") + elif operation == "chamfer": + width = _safe_number(params.get("width") or params.get("width1")) + operations.append("chamfer" + (f" distance_mm={_format_float(width)}" if width is not None else "")) + if width is not None: + dimensions.append(f"{feature.feature_id} 倒角距离 {_format_mm(width)}") + else: + operations.append(OPERATION_LABELS.get(operation, operation)) + return _unique(operations, limit=32), _unique(dimensions, limit=32) + + +def _shape_cues(annotation: str, entity_counts: Counter[str], operations: list[str]) -> list[str]: + text = annotation.lower() + joined_ops = " ".join(operations).lower() + cues: list[str] = [] + if "triangle" in text or "triangular" in text: + cues.append("具有三角形轮廓或三角截面") + if "rectangle" in text or entity_counts.get("skLineSegment", 0) >= 4: + cues.append("包含矩形/多边形直线轮廓") + if "circle" in text or entity_counts.get("skCircle", 0): + cues.append("包含圆形轮廓或圆孔候选特征") + if "arc" in text or entity_counts.get("skArc", 0): + cues.append("包含圆弧边界") + if "slot" in text or "notch" in text or "cutout" in text or "开口" in text: + cues.append("包含槽、缺口或开口候选结构") + if "hole" in text or "hole" in joined_ops: + cues.append("包含孔加工候选结构") + if "fillet" in joined_ops: + cues.append("包含圆角过渡") + if "chamfer" in joined_ops: + cues.append("包含倒角边") + if "pattern" in joined_ops: + cues.append("包含阵列重复特征") + if "mirror" in joined_ops: + cues.append("包含镜像对称特征") + if any(item.startswith("revolve") for item in operations): + cues.append("绕轴旋转形成回转体") + if any(item.startswith("extrude") for item in operations): + cues.append("由二维草图拉伸形成实体") + return _unique(cues, limit=16) + + +def _classify_local(annotation: str, operations: list[str], geometric_features: list[str], stl_info: dict[str, Any] | None) -> dict[str, Any]: + text = " ".join([annotation.lower(), " ".join(operations).lower(), " ".join(geometric_features)]) + slender = _is_slender(stl_info) + has_extrude = any(item.startswith("extrude") for item in operations) + has_revolve = any(item.startswith("revolve") for item in operations) + has_hole = "hole" in text or "孔" in text + has_triangle = "triangle" in text or "triangular" in text or "三角" in text + has_rectangle = "rectangle" in text or "矩形" in text + has_cutout = any(word in text for word in ("slot", "notch", "cutout", "开口", "缺口")) + + if has_revolve: + return { + "category": "回转轴套/法兰类零件(候选)" if has_hole else "回转体机械零件(候选)", + "confidence": 0.5, + "candidate_names": ["回转体", "轴套", "法兰盘", "轮毂状零件", "revolved part", "flange", "bushing"], + "possible_functions": ["可能用于同轴定位、连接、支承、隔套或旋转类结构的几何占位。"], + "applications": ["机械传动、夹具、管路连接、轴承座周边或需要轴线对称零件的装配场景。"], + "structural_features": ["回转外形", "轴向轮廓", "圆柱/圆盘候选结构"], + } + if has_triangle and has_extrude: + return { + "category": "三角棱柱/楔形梁类零件(候选)", + "confidence": 0.56, + "candidate_names": ["长条三角棱柱", "楔形梁", "三角截面导轨", "triangular prism", "wedge beam"], + "possible_functions": ["可能作为楔块、导向条、支撑肋、定位块或三角截面结构件使用。"], + "applications": ["夹具定位、机械支撑、导向结构、教育/仿真几何库或需要楔形截面的 CAD 检索场景。"], + "structural_features": ["三角截面", "长条拉伸体", "棱柱体", "斜面侧壁"], + } + if has_hole and has_rectangle: + return { + "category": "带孔板/安装板类零件(候选)", + "confidence": 0.52, + "candidate_names": ["安装板", "连接板", "带孔支架板", "mounting plate", "bracket plate"], + "possible_functions": ["可能用于螺钉安装、定位连接、固定支撑或作为装配转接板。"], + "applications": ["设备框架、夹具底板、连接支架、外壳内部固定件。"], + "structural_features": ["板状主体", "孔特征", "平面安装面"], + } + if has_cutout and has_extrude: + return { + "category": "开槽板/叉形支架类零件(候选)", + "confidence": 0.48, + "candidate_names": ["开槽板", "叉形支架", "U 形支架", "slotted plate", "fork bracket"], + "possible_functions": ["可能用于避让、卡接、导向、夹持或作为插槽式连接件。"], + "applications": ["支架、夹具、连接耳、导向槽结构或板件开口检索场景。"], + "structural_features": ["开口/缺口", "板状拉伸体", "平直侧壁"], + } + if has_extrude and slender: + return { + "category": "拉伸型梁/导轨类零件(候选)", + "confidence": 0.46, + "candidate_names": ["拉伸梁", "导轨条", "长条棱柱", "extruded beam", "rail"], + "possible_functions": ["可能用于导向、支撑、隔距、边框或长条结构件。"], + "applications": ["框架、滑轨、夹具、机械结构支撑或型材检索场景。"], + "structural_features": ["长条外形", "恒定截面候选", "拉伸成型"], + } + if has_extrude: + return { + "category": "拉伸棱柱/板块类零件(候选)", + "confidence": 0.42, + "candidate_names": ["拉伸实体", "板块", "棱柱体", "extruded solid", "prismatic part"], + "possible_functions": ["可能作为基础块、板件、支撑件或后续加工毛坯。"], + "applications": ["通用机械零件、夹具、支架、CAD 几何检索和相似形状匹配。"], + "structural_features": ["二维轮廓拉伸", "平面端面", "直壁结构"], + } + return { + "category": "通用机械 CAD 零件(候选)", + "confidence": 0.34, + "candidate_names": ["机械零件", "CAD 几何样本", "mechanical part", "CAD model"], + "possible_functions": ["可能作为机械结构、连接、支撑或几何检索样本,实际用途需结合装配上下文确认。"], + "applications": ["CAD 数据集检索、几何相似度匹配、零件分类训练和工程知识库索引。"], + "structural_features": ["可解析 CAD 特征组合"], + } + + +def _keywords(classification: dict[str, Any], operations: list[str], geometric_features: list[str]) -> tuple[list[str], list[str]]: + text = " ".join([classification["category"], " ".join(classification["candidate_names"]), " ".join(operations), " ".join(geometric_features)]).lower() + zh = ["CAD模型", "机械零件", "几何检索", "相似特征", *classification["candidate_names"][:4]] + en = ["cad model", "mechanical part", "geometry retrieval", "similar features"] + mappings = [ + ("三角", "三角棱柱", "triangular prism"), + ("wedge", "楔形", "wedge"), + ("extrude", "拉伸", "extruded"), + ("revolve", "回转", "revolved"), + ("hole", "孔", "hole"), + ("fillet", "圆角", "fillet"), + ("chamfer", "倒角", "chamfer"), + ("slot", "槽", "slot"), + ("flange", "法兰", "flange"), + ("bushing", "轴套", "bushing"), + ("plate", "板件", "plate"), + ("bracket", "支架", "bracket"), + ("rail", "导轨", "rail"), + ] + for needle, zh_value, en_value in mappings: + if needle in text or zh_value in text: + zh.append(zh_value) + en.append(en_value) + return _unique(zh, limit=32), _unique(en, limit=32) + + +def _compose_record( + sample: Sample, + input_root: Path, + shard: str, + txt_path: Path, + local_facts: dict[str, Any], + vision_result: dict[str, Any] | None, + diagnostics: list[dict[str, Any]], + mode: str, +) -> dict[str, Any]: + vision = _normalize_vision(vision_result) + status = "described_local" + if mode != "local": + status = "described_hybrid" if vision else "local_fallback" + category = vision.get("category") or local_facts["category"] + category_confidence = _clamp_float(vision.get("category_confidence"), local_facts["category_confidence"]) + candidate_names = _unique([*vision.get("candidate_names", []), *local_facts["candidate_names"]], limit=12) + structural_features = _unique([*local_facts["structural_features"], *vision.get("structural_features", [])], limit=32) + geometric_features = _unique([*local_facts["geometric_features"], *vision.get("geometric_features", [])], limit=40) + uncertainties = _unique([*local_facts["uncertainties"], *vision.get("uncertainties", [])], limit=20) + return { + "schema_version": DESCRIPTION_SCHEMA_VERSION, + "sample_id": sample.sample_id, + "shard": shard, + "status": status, + "txt_path": str(txt_path), + "category": category, + "category_confidence": category_confidence, + "candidate_names": candidate_names, + "summary_zh": vision.get("summary_zh") or local_facts["summary_zh"], + "possible_functions": _unique([*vision.get("possible_functions", []), *local_facts["possible_functions"]], limit=12), + "applications": _unique([*vision.get("applications", []), *local_facts["applications"]], limit=12), + "structural_features": structural_features, + "geometric_features": geometric_features, + "operations": local_facts["operations"], + "dimensions": local_facts["dimensions"], + "keywords_zh": _unique([*vision.get("keywords_zh", []), *local_facts["keywords_zh"]], limit=40), + "keywords_en": _unique([*vision.get("keywords_en", []), *local_facts["keywords_en"]], limit=40), + "uncertainties": uncertainties, + "source_files": _source_files(sample, input_root), + "diagnostics": diagnostics, + } + + +def _normalize_vision(value: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + result: dict[str, Any] = {} + for key in ( + "candidate_names", + "possible_functions", + "applications", + "structural_features", + "geometric_features", + "keywords_zh", + "keywords_en", + "uncertainties", + ): + result[key] = _as_text_list(value.get(key)) + for key in ("category", "summary_zh"): + if isinstance(value.get(key), str) and value[key].strip(): + result[key] = value[key].strip() + result["category_confidence"] = value.get("category_confidence") + return result + + +def _local_summary(classification: dict[str, Any], operations: list[str], geometric_features: list[str], dimensions: list[str]) -> str: + operation_text = "、".join(operations[:4]) if operations else "可解析 CAD 特征" + feature_text = ";".join(geometric_features[:4]) if geometric_features else "几何结构待进一步识别" + dimension_text = ";".join(dimensions[:3]) if dimensions else "未提取到稳定尺寸摘要" + return ( + f"该模型可保守识别为{classification['category']},主要由 {operation_text} 构成。" + f"确定结构包括:{feature_text}。尺寸线索:{dimension_text}。" + "真实产品身份和装配用途缺少上下文,因此以候选名称和相似几何特征用于检索。" + ) + + +def _failed_record(sample: Sample, input_root: Path, txt_path: Path, shard: str, diagnostics: list[dict[str, Any]]) -> dict[str, Any]: + return { + "schema_version": DESCRIPTION_SCHEMA_VERSION, + "sample_id": sample.sample_id, + "shard": shard, + "status": "failed", + "txt_path": str(txt_path), + "category": "通用机械 CAD 零件(描述失败)", + "category_confidence": 0.0, + "candidate_names": ["CAD 模型"], + "summary_zh": "该样本描述生成失败,仅保留源文件索引和诊断信息。", + "possible_functions": ["无法可靠判断。"], + "applications": ["需重新运行描述生成或人工复核后再进入向量库。"], + "structural_features": [], + "geometric_features": [], + "operations": [], + "dimensions": [], + "keywords_zh": ["CAD模型", "描述失败"], + "keywords_en": ["cad model", "description failed"], + "uncertainties": ["描述生成过程失败,不能据此判断模型类别或用途。"], + "source_files": _source_files(sample, input_root), + "diagnostics": diagnostics, + } + + +def _skipped_record(sample: Sample, input_root: Path, txt_path: Path, shard: str) -> dict[str, Any]: + return { + "schema_version": DESCRIPTION_SCHEMA_VERSION, + "sample_id": sample.sample_id, + "shard": shard, + "status": "skipped_existing", + "txt_path": str(txt_path), + "category": "", + "category_confidence": 0.0, + "candidate_names": [], + "geometric_features": [], + "operations": [], + "dimensions": [], + "keywords_zh": [], + "keywords_en": [], + "uncertainties": ["已有描述文件,未使用 --force,因此本次未覆盖。"], + "source_files": _source_files(sample, input_root), + "diagnostics": [{"code": "existing_output_skipped"}], + } + + +def _source_files(sample: Sample, input_root: Path) -> dict[str, str]: + return {key: str(Path(value)) for key, value in sorted(sample.files.items()) if Path(value).is_file() or input_root} + + +def _read_manifest(path: Path) -> dict[str, dict[str, Any]]: + if not path.exists(): + return {} + records: dict[str, dict[str, Any]] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + sample_id = str(value.get("sample_id") or "") + if sample_id: + records[sample_id] = value + return records + + +def _write_manifest(input_root: Path, records: list[dict[str, Any]]) -> int: + path = input_root / "description_txt" / MANIFEST_NAME + existing = _read_manifest(path) + for record in records: + existing[str(record["sample_id"])] = record + ordered = [existing[key] for key in sorted(existing)] + text = "".join(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n" for record in ordered) + _atomic_write_text(path, text) + return len(ordered) + + +def _summary(input_root: Path, records: list[dict[str, Any]], manifest_rows: int) -> dict[str, Any]: + counts = Counter(str(record.get("status") or "unknown") for record in records) + txt_count = sum(1 for record in records if Path(str(record.get("txt_path") or "")).is_file()) + return { + "sample_count": len(records), + "statuses": dict(sorted(counts.items())), + "txt_count": txt_count, + "manifest": str(input_root / "description_txt" / MANIFEST_NAME), + "manifest_rows": manifest_rows, + } + + +def _atomic_write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(text, encoding="utf-8") + tmp.replace(path) + + +def _safe_number(value: Any) -> float | None: + try: + result = _number(value, True) + except Exception: + return None + return result if math.isfinite(result) else None + + +def _safe_point(value: Any) -> list[float] | None: + try: + point = _point(value) + except Exception: + return None + return point if len(point) >= 2 and all(math.isfinite(item) for item in point[:2]) else None + + +def _sketch_bbox(sketch: Any) -> list[float] | None: + xs: list[float] = [] + ys: list[float] = [] + for entity in sketch.entities: + params = entity.params + if entity.operation == "skLineSegment": + for key in ("start", "end"): + point = _safe_point(params.get(key)) + if point: + xs.append(point[0]) + ys.append(point[1]) + elif entity.operation == "skCircle": + center = _safe_point(params.get("center")) + radius = _safe_number(params.get("radius")) + if center and radius is not None: + xs.extend([center[0] - radius, center[0] + radius]) + ys.extend([center[1] - radius, center[1] + radius]) + elif entity.operation == "skArc": + for key in ("start", "mid", "end"): + point = _safe_point(params.get(key)) + if point: + xs.append(point[0]) + ys.append(point[1]) + elif entity.operation == "skPoint": + point = _safe_point(params.get("position")) + if point: + xs.append(point[0]) + ys.append(point[1]) + if not xs or not ys: + return None + return [min(xs), min(ys), max(xs), max(ys)] + + +def _stl_bbox(path: Path) -> dict[str, Any] | None: + try: + data = path.read_bytes() + except OSError: + return None + vertices = _binary_stl_vertices(data) or _ascii_stl_vertices(data) + if not vertices: + return None + mins = [min(vertex[i] for vertex in vertices) for i in range(3)] + maxs = [max(vertex[i] for vertex in vertices) for i in range(3)] + size = [maxs[i] - mins[i] for i in range(3)] + return {"min_mm": mins, "max_mm": maxs, "size_mm": size, "vertex_count": len(vertices)} + + +def _binary_stl_vertices(data: bytes) -> list[tuple[float, float, float]]: + if len(data) < 84: + return [] + triangle_count = struct.unpack(" len(data): + return [] + vertices: list[tuple[float, float, float]] = [] + offset = 84 + for _ in range(triangle_count): + offset += 12 + for _ in range(3): + vertices.append(struct.unpack(" list[tuple[float, float, float]]: + try: + text = data[:5_000_000].decode("utf-8", errors="ignore") + except Exception: + return [] + pattern = re.compile(r"\bvertex\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)") + vertices = [] + for match in pattern.finditer(text): + try: + vertices.append((float(match.group(1)), float(match.group(2)), float(match.group(3)))) + except ValueError: + continue + return vertices + + +def _image_metadata(path: Path) -> dict[str, Any]: + try: + from PIL import Image + + with Image.open(path) as image: + return {"width": image.width, "height": image.height, "format": str(image.format or "").lower()} + except Exception as exc: + return {"error": f"image metadata unavailable: {type(exc).__name__}"} + + +def _stl_features(stl_info: dict[str, Any] | None) -> list[str]: + if not stl_info: + return [] + size = stl_info.get("size_mm") or [] + if len(size) != 3: + return [] + features = ["STL 网格提供三维包围盒"] + if _is_slender(stl_info): + features.append("整体呈长条比例") + if _is_plate_like(stl_info): + features.append("整体呈薄板比例") + return features + + +def _stl_dimensions(stl_info: dict[str, Any] | None) -> list[str]: + if not stl_info: + return [] + size = stl_info.get("size_mm") or [] + if len(size) != 3: + return [] + return [f"STL 三维包围盒约 {_format_float(size[0])} × {_format_float(size[1])} × {_format_float(size[2])} mm"] + + +def _is_slender(stl_info: dict[str, Any] | None) -> bool: + if not stl_info: + return False + values = [abs(float(item)) for item in stl_info.get("size_mm") or [] if abs(float(item)) > 1e-9] + return len(values) >= 2 and max(values) / max(min(values), 1e-9) >= 3.0 + + +def _is_plate_like(stl_info: dict[str, Any] | None) -> bool: + if not stl_info: + return False + values = sorted(abs(float(item)) for item in stl_info.get("size_mm") or [] if abs(float(item)) > 1e-9) + return len(values) == 3 and values[0] * 4 <= values[1] + + +def _plane_name(value: Any) -> str: + text = json.dumps(plain(value), ensure_ascii=False) + for name in ("Top", "Front", "Right"): + if f"{name}.planeOp" in text: + return name + return "未知" + + +def _enum_tail(value: Any) -> str: + return str(value or "").split(".")[-1] + + +def _truthy(value: Any) -> bool: + return value is True or (isinstance(value, str) and value.lower() == "true") + + +def _format_float(value: float) -> str: + rounded = round(float(value), 4) + if rounded == int(rounded): + return str(int(rounded)) + return f"{rounded:.4f}".rstrip("0").rstrip(".") + + +def _format_mm(value: float) -> str: + return f"{_format_float(value)} mm" + + +def _clamp_float(value: Any, fallback: float) -> float: + try: + number = float(value) + except (TypeError, ValueError): + number = float(fallback) + if not math.isfinite(number): + number = float(fallback) + return max(0.0, min(1.0, number)) + + +def _as_text_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + +def _unique(values: list[Any], *, limit: int | None = None) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values: + text = str(value).strip() + if not text or text in seen: + continue + seen.add(text) + result.append(text) + if limit is not None and len(result) >= limit: + break + return result + + +def _join_or_unknown(values: Any) -> str: + items = _as_text_list(values) if isinstance(values, list) else _unique(list(values or [])) if isinstance(values, tuple) else [] + return "、".join(items) if items else "无法可靠判断" diff --git a/cadfs_to_cdsl/tests/test_describe.py b/cadfs_to_cdsl/tests/test_describe.py new file mode 100644 index 00000000..e229d29d --- /dev/null +++ b/cadfs_to_cdsl/tests/test_describe.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from cadfs_to_cdsl.describe import describe_samples, select_description_samples + + +SAMPLE_FS = r''' +FeatureScript 1511; +import(path : "onshape/std/geometry.fs", version : "1511.0"); +const mm = millimeter; +const FACE = EntityType.FACE; +function v(x, y){return vector(x, y);} +function sQuery(a, b, c) {return sketchEntityQuery(a, b, c);} +export const myFeature = defineFeature(function(context is Context, id is Id, definition is map) + precondition{} + { + { + var Q0; + Q0=qCreatedBy(makeId("Top.planeOp"),FACE); + var sketch = newSketch(context, id + "F0", { "sketchPlane" : qUnion([Q0])}); + skLineSegment(sketch, "E0", {"start": v(-269.41, -156.6) * mm, "end": v(-0.92, 311.62) * mm}); + skLineSegment(sketch, "E1", {"start": v(-0.92, 311.62) * mm, "end": v(270.33, -155.02) * mm}); + skLineSegment(sketch, "E2", {"start": v(270.33, -155.02) * mm, "end": v(-269.41, -156.6) * mm}); + skSolve(sketch); + } + { + var Q0; + Q0 = qSketchRegion(id + "F0", true); + extrude(context, id + "F1", {"entities" : qUnion([Q0]), "depth" : 1828.8 * mm}); + } + }); +''' + + +SAMPLE_ANNOTATION = """Step 1 - Sketch +Draw a closed triangle. + +Step 2 - Extrude NEW +Extrude the triangular area upward a distance of 1828.8 mm. +""" + + +class FailingVision: + def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, object]) -> dict[str, object]: + raise RuntimeError("vision offline") + + +class StaticVision: + def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, object]) -> dict[str, object]: + return { + "category": "视觉增强三角楔块(候选)", + "category_confidence": 0.7, + "candidate_names": ["视觉楔块"], + "summary_zh": "视觉模型认为该模型是三角楔块候选件。", + "possible_functions": ["可能用于定位。"], + "applications": ["夹具。"], + "structural_features": ["斜面"], + "geometric_features": ["三角截面"], + "keywords_zh": ["视觉识别"], + "keywords_en": ["vision wedge"], + "uncertainties": ["视觉语义仍需人工确认。"], + } + + +class DescribeTests(unittest.TestCase): + def test_selects_only_requested_shard(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_sample(root, "0005", "00050089") + self._write_sample(root, "0006", "00060001") + + selected = select_description_samples(root, shard="0005") + + self.assertEqual([sample.sample_id for sample in selected], ["00050089"]) + + def test_hybrid_falls_back_and_writes_txt_manifest(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_sample(root, "0005", "00050089") + + records, summary = describe_samples(root, shard="0005", mode="hybrid", vision_client=FailingVision()) + + self.assertEqual(summary["sample_count"], 1) + self.assertEqual(summary["statuses"], {"local_fallback": 1}) + txt_path = root / "description_txt/0005/00050089.txt" + self.assertTrue(txt_path.is_file()) + text = txt_path.read_text(encoding="utf-8") + self.assertIn("样本ID:00050089", text) + self.assertIn("三角棱柱", text) + self.assertIn("证据与不确定性", text) + manifest = root / "description_txt/description_manifest.jsonl" + rows = [json.loads(line) for line in manifest.read_text(encoding="utf-8").splitlines()] + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["sample_id"], "00050089") + self.assertEqual(rows[0]["status"], "local_fallback") + self.assertIn("vision_failed", [item["code"] for item in records[0]["diagnostics"]]) + + def test_hybrid_uses_vision_when_available(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_sample(root, "0005", "00050089") + + records, _summary = describe_samples(root, shard="0005", mode="hybrid", vision_client=StaticVision()) + + self.assertEqual(records[0]["status"], "described_hybrid") + self.assertEqual(records[0]["category"], "视觉增强三角楔块(候选)") + self.assertIn("视觉楔块", records[0]["candidate_names"]) + + def test_existing_txt_is_skipped_without_force(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_sample(root, "0005", "00050089") + first, _ = describe_samples(root, shard="0005", mode="local") + txt_path = root / "description_txt/0005/00050089.txt" + before = txt_path.read_text(encoding="utf-8") + + second, _ = describe_samples(root, shard="0005", mode="local") + + self.assertEqual(second[0]["status"], first[0]["status"]) + self.assertEqual(txt_path.read_text(encoding="utf-8"), before) + + @staticmethod + def _write_sample(root: Path, shard: str, sample_id: str) -> None: + for directory, suffix, content in ( + ("featurescript_rp", ".txt", SAMPLE_FS), + ("text_annotations", ".txt", SAMPLE_ANNOTATION), + ("step_abc", ".step", "ISO-10303-21;\nEND-ISO-10303-21;\n"), + ("stl_abc", ".stl", "solid sample\nendsolid sample\n"), + ): + path = root / directory / shard / f"{sample_id}{suffix}" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + image = root / "multiview_images_abc" / shard / f"{sample_id}.png" + image.parent.mkdir(parents=True, exist_ok=True) + image.write_bytes( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00" + b"\x90wS\xde\x00\x00\x00\x00IEND\xaeB`\x82" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/outputs/cadfs_descriptions_0005/cadfs_0005_模型描述.xlsx b/outputs/cadfs_descriptions_0005/cadfs_0005_模型描述.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5ccad3bd6119c2d8923b593477b68ce3f0c2f450 GIT binary patch literal 20236 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