abelien-harness 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/package.json +1 -1
  2. package/skills/abelien-algorithm/contract/host-tools.generated.json +7 -7
  3. package/skills/abelien-algorithm/scripts/research.mjs +1 -1
  4. package/skills/abelien-appearance/contract/host-tools.generated.json +15 -3
  5. package/skills/abelien-appearance/references/actions/appearance.points.generated.md +2 -2
  6. package/skills/abelien-appearance/references/points.md +3 -3
  7. package/skills/abelien-appearance/schemas/points-reading-reply.v1.json +100 -0
  8. package/skills/abelien-appearance/schemas/points-reading.v1.json +7 -3
  9. package/skills/abelien-appearance/scripts/points.mjs +102 -8
  10. package/skills/abelien-appearance/scripts/schemas.mjs +1 -1
  11. package/skills/abelien-clarify/SKILL.md +23 -7
  12. package/skills/abelien-clarify/references/examples.md +13 -2
  13. package/skills/abelien-clarify/references/runtime-contract.md +5 -3
  14. package/skills/abelien-clarify/schemas/skill-reply.v1.json +57 -0
  15. package/skills/abelien-clarify/schemas/skill-result.v1.json +264 -2
  16. package/skills/abelien-clarify/scripts/check_result.py +94 -6
  17. package/skills/abelien-clarify/scripts/dimensions.mjs +141 -0
  18. package/skills/abelien-clarify/scripts/test_check_result.py +66 -27
  19. package/skills/abelien-clarify/scripts/validate-result.mjs +32 -9
  20. package/skills/abelien-hardware/SKILL.md +2 -0
  21. package/skills/abelien-hardware/assets/AGENTS.md +10 -0
  22. package/skills/abelien-hardware/assets/category-default-dimensions.v1.json +169 -0
  23. package/skills/abelien-hardware/domain.json +1 -1
  24. package/skills/abelien-hardware/references/category-default-dimensions.md +55 -0
  25. package/skills/abelien-hardware/schemas/category-default-dimensions.v1.json +70 -0
  26. package/skills/abelien-hardware/scripts/oss-wiki-search.mjs +1 -1
  27. package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +1 -1
  28. package/skills/abelien-harness/contracts/README.md +1 -1
  29. package/skills/abelien-harness/contracts/index.d.mts +4 -2
  30. package/skills/abelien-harness/contracts/index.mjs +19 -14
  31. package/skills/abelien-harness/contracts/schemas/cross-domain.v1.json +1 -1
  32. package/skills/abelien-harness/runtime/README.md +1 -1
  33. package/skills/abelien-harness/runtime/engine.mjs +2 -2
  34. package/skills/abelien-harness/runtime/index.mjs +1 -1
  35. package/skills/abelien-harness/runtime/task-view/README.md +3 -1
  36. package/skills/abelien-harness/runtime/task-view/index.mjs +117 -0
  37. package/skills/abelien-harness/scripts/ab.mjs +1 -1
  38. package/skills/abelien-harness/scripts/calc/index.mjs +1 -0
  39. package/skills/abelien-harness/scripts/calc/sources.mjs +90 -0
  40. package/skills/abelien-reference/actions/reference.page.json +1 -1
  41. package/skills/abelien-reference/contract/host-tools.generated.json +222 -34
  42. package/skills/abelien-reference/fixtures/restore.final.json +13 -2
  43. package/skills/abelien-reference/references/actions/reference.finalize.generated.md +2 -2
  44. package/skills/abelien-reference/references/actions/reference.page.generated.md +6 -3
  45. package/skills/abelien-reference/references/actions.generated.md +1 -1
  46. package/skills/abelien-reference/references/research-contract.md +2 -2
  47. package/skills/abelien-reference/schemas/host-tools.v1.json +5 -3
  48. package/skills/abelien-reference/schemas/reference-evidence.v1.json +3 -2
  49. package/skills/abelien-reference/schemas/reference-judgement.v1.json +6 -6
  50. package/skills/abelien-reference/scripts/finalize.mjs +49 -43
  51. package/skills/abelien-reference/scripts/research.mjs +1 -1
  52. package/skills/abelien-structure/SKILL.md +1 -1
  53. package/skills/abelien-structure/actions/structure.cad-check.json +1 -1
  54. package/skills/abelien-structure/actions/structure.layout-check.json +6 -1
  55. package/skills/abelien-structure/contract/host-tools.generated.json +7008 -2234
  56. package/skills/abelien-structure/flows/user-journey-cad.json +7 -2
  57. package/skills/abelien-structure/references/actions/structure.assets-request.generated.md +15 -0
  58. package/skills/abelien-structure/references/actions/structure.cad-assemble.generated.md +49 -6
  59. package/skills/abelien-structure/references/actions/structure.cad-check.generated.md +45 -1
  60. package/skills/abelien-structure/references/actions/structure.layout-check.generated.md +44 -1
  61. package/skills/abelien-structure/references/actions/structure.layout-request.generated.md +82 -9
  62. package/skills/abelien-structure/references/actions/structure.prelude.generated.md +17 -0
  63. package/skills/abelien-structure/references/cad-functions.generated.md +76 -23
  64. package/skills/abelien-structure/references/cad-modules.md +25 -4
  65. package/skills/abelien-structure/references/skeleton.md +7 -4
  66. package/skills/abelien-structure/references/structure-reasonableness.md +4 -4
  67. package/skills/abelien-structure/schemas/cad-assemble-input.v1.json +27 -2
  68. package/skills/abelien-structure/schemas/cad-build.v1.json +27 -0
  69. package/skills/abelien-structure/schemas/cad-modules.v1.json +5 -6
  70. package/skills/abelien-structure/schemas/cad-source.v1.json +6 -1
  71. package/skills/abelien-structure/schemas/layout-check-result.v1.json +2 -1
  72. package/skills/abelien-structure/schemas/layout-request-input.v1.json +103 -2
  73. package/skills/abelien-structure/schemas/layout-request.v1.json +30 -2
  74. package/skills/abelien-structure/schemas/prelude.v1.json +9 -0
  75. package/skills/abelien-structure/schemas/skeleton.v1.json +2 -1
  76. package/skills/abelien-structure/scripts/cad.mjs +359 -37
  77. package/skills/abelien-structure/scripts/layout.mjs +191 -27
  78. package/skills/abelien-structure/scripts/source-limits.mjs +5 -0
  79. package/skills/abelien-structure-worker/domain.json +3 -0
  80. package/skills/abelien-structure-worker/worker/abelien_cad.py +199 -34
  81. package/skills/abelien-structure-worker/worker/cad.py +92 -35
  82. package/skills/abelien-structure-worker/worker/jobs.mjs +5 -2
  83. package/skills/abelien-structure-worker/worker/main.mjs +5 -3
  84. package/skills/abelien-structure-worker/worker/source-contract.mjs +26 -19
  85. package/skills/abelien-workspace/actions/calc.evaluate.json +1 -1
  86. package/skills/abelien-workspace/assets/creation-start-flow.json +4 -1
  87. package/skills/abelien-workspace/assets/journey-stage-plan.json +61 -7
  88. package/skills/abelien-workspace/contract/host-tools.generated.json +1173 -45
  89. package/skills/abelien-workspace/flows/creation.create.json +73 -17
  90. package/skills/abelien-workspace/flows/creation.modify.json +73 -17
  91. package/skills/abelien-workspace/flows/creation.remix.json +4 -2
  92. package/skills/abelien-workspace/flows/creation.restore.json +73 -17
  93. package/skills/abelien-workspace/references/actions/calc.evaluate.generated.md +1 -1
  94. package/skills/abelien-workspace/references/actions/workspace.hardware-reselect-input.generated.md +49 -0
  95. package/skills/abelien-workspace/references/actions/workspace.intake-complete.generated.md +49 -0
  96. package/skills/abelien-workspace/references/actions/workspace.intake-route.generated.md +50 -1
  97. package/skills/abelien-workspace/references/actions/workspace.intake-wait.generated.md +49 -0
  98. package/skills/abelien-workspace/references/actions.generated.md +1 -1
  99. package/skills/abelien-workspace/references/creation-intake.md +1 -1
  100. package/skills/abelien-workspace/references/journey-stage-plan.md +11 -10
  101. package/skills/abelien-workspace/references/product-flow.md +4 -4
  102. package/skills/abelien-workspace/schemas/skeleton-model-reply.v1.json +3 -3
  103. package/skills/abelien-workspace/schemas/skeleton-reply.v1.json +37 -20
  104. package/skills/abelien-workspace/scripts/calc.mjs +10 -57
  105. package/skills/abelien-workspace/scripts/creation-intake.mjs +22 -8
  106. package/skills/abelien-workspace/scripts/creation-tasks.mjs +81 -23
  107. package/skills/abelien-workspace/scripts/flow-compile.mjs +6 -1
  108. package/skills/abelien-workspace/scripts/plans.mjs +6 -3
@@ -0,0 +1,57 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://git.abelien.cn/abelien/abelienharness/skills/abelien-clarify/schemas/skill-reply.v1.json",
4
+ "title": "clarify SkillResult reply",
5
+ "description": "clarify 模型回复契约(CLR-1):与 skill-result.v1.json 同形,但 limits 只写 dimensions,不写 envelope;定稿代码核对引文出现在用户原话里,用共用求值核心换算出 envelope,得到 skill-result.v1.json 的完整快照。",
6
+ "type": "object",
7
+ "required": [],
8
+ "properties": {
9
+ "result": {
10
+ "allOf": [
11
+ {
12
+ "$ref": "skill-result.v1.json#/$defs/ReplyRequirements"
13
+ },
14
+ {
15
+ "required": [
16
+ "intent"
17
+ ]
18
+ }
19
+ ]
20
+ },
21
+ "questions": {
22
+ "type": "array",
23
+ "items": {
24
+ "$ref": "skill-result.v1.json#/$defs/Question"
25
+ },
26
+ "maxItems": 3
27
+ },
28
+ "note": {
29
+ "type": "string",
30
+ "minLength": 1,
31
+ "pattern": "\\S"
32
+ }
33
+ },
34
+ "additionalProperties": false,
35
+ "anyOf": [
36
+ {
37
+ "required": [
38
+ "result"
39
+ ]
40
+ },
41
+ {
42
+ "required": [
43
+ "questions"
44
+ ],
45
+ "properties": {
46
+ "questions": {
47
+ "minItems": 1
48
+ }
49
+ }
50
+ },
51
+ {
52
+ "required": [
53
+ "note"
54
+ ]
55
+ }
56
+ ]
57
+ }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://git.abelien.cn/abelien/abelienharness/skills/abelien-clarify/schemas/skill-result.v1.json",
4
4
  "title": "clarify SkillResult",
5
- "description": "现行 clarify 完整快照契约;来源、稳定ID、系统假设 note 与 Envelope 算术由同包只读校验器复核。",
5
+ "description": "clarify 完整快照契约(定稿后的形状):limits 的 envelope 只能由代码按 dimensions 的引文换算得出(CLR-1),两者同在同缺;模型回复的形状见 skill-reply.v1.json。来源、稳定ID、系统假设 note 与 Envelope 算术由同包只读校验器复核。",
6
6
  "type": "object",
7
7
  "required": [],
8
8
  "properties": {
@@ -149,6 +149,9 @@
149
149
  "preference"
150
150
  ]
151
151
  },
152
+ "dimensions": {
153
+ "$ref": "#/$defs/Dimensions"
154
+ },
152
155
  "envelope": {
153
156
  "$ref": "../../abelien-harness/contracts/schemas/cross-domain.v1.json#/$defs/Envelope"
154
157
  },
@@ -156,7 +159,16 @@
156
159
  "$ref": "#/$defs/Network"
157
160
  }
158
161
  },
159
- "additionalProperties": false
162
+ "additionalProperties": false,
163
+ "description": "需求限制。envelope 是代码从 dimensions 换算出的毫米包络(abelien-clarify/scripts/dimensions.mjs),不是模型写的数;有 dimensions 就有 envelope,反之亦然,校验时按 dimensions 重新换算并要求与 envelope 完全一致。",
164
+ "dependentRequired": {
165
+ "dimensions": [
166
+ "envelope"
167
+ ],
168
+ "envelope": [
169
+ "dimensions"
170
+ ]
171
+ }
160
172
  }
161
173
  }
162
174
  },
@@ -258,6 +270,256 @@
258
270
  "offline",
259
271
  "no_cloud"
260
272
  ]
273
+ },
274
+ "Dimensions": {
275
+ "description": "尺寸限制的读法(CLR-1):模型只给形状和每个边界的来源——用户原话的逐字引文,或系统估算的选择——以及它对应的轴和上下限;毫米数由代码用共用求值核心换算(半径乘 2 也由代码算)。盒体轴 width、depth、height 依次对应 envelope.max_mm 的三个分量;圆柱轴 diameter 或 radius、height;wall 是壁厚。",
276
+ "type": "object",
277
+ "required": [
278
+ "shape",
279
+ "bounds"
280
+ ],
281
+ "properties": {
282
+ "shape": {
283
+ "enum": [
284
+ "box",
285
+ "cylinder"
286
+ ]
287
+ },
288
+ "bounds": {
289
+ "type": "array",
290
+ "items": {
291
+ "$ref": "#/$defs/DimensionBound"
292
+ },
293
+ "minItems": 1,
294
+ "maxItems": 12
295
+ }
296
+ },
297
+ "additionalProperties": false
298
+ },
299
+ "DimensionBound": {
300
+ "description": "一个尺寸边界。axes:这个数管哪些轴(\"10 厘米见方\"写 width、depth、height 三个);wall 只能单独出现。bound:max 上限、min 下限、exact 精确(上下限都取它);wall 用 min(保证的最小壁厚)或 estimate(估算壁厚,只作风险提示)。quote 与 choice 二选一:source=user 的限制只能用 quote。",
301
+ "type": "object",
302
+ "required": [
303
+ "axes",
304
+ "bound"
305
+ ],
306
+ "properties": {
307
+ "axes": {
308
+ "type": "array",
309
+ "items": {
310
+ "enum": [
311
+ "width",
312
+ "depth",
313
+ "height",
314
+ "diameter",
315
+ "radius",
316
+ "wall"
317
+ ]
318
+ },
319
+ "minItems": 1,
320
+ "maxItems": 3,
321
+ "uniqueItems": true
322
+ },
323
+ "bound": {
324
+ "enum": [
325
+ "max",
326
+ "min",
327
+ "exact",
328
+ "estimate"
329
+ ]
330
+ },
331
+ "quote": {
332
+ "$ref": "#/$defs/DimensionQuote"
333
+ },
334
+ "choice": {
335
+ "$ref": "#/$defs/DimensionChoice"
336
+ }
337
+ },
338
+ "additionalProperties": false,
339
+ "oneOf": [
340
+ {
341
+ "required": [
342
+ "quote"
343
+ ]
344
+ },
345
+ {
346
+ "required": [
347
+ "choice"
348
+ ]
349
+ }
350
+ ]
351
+ },
352
+ "DimensionQuote": {
353
+ "description": "用户原话的逐字引文(本轮用户消息或卡片答复里的原文,做 NFKC 规范化后比对),由代码从中取量。引文里不止一个数时用 index(从 0 起,数所有的数)指出取第几个。unit 只在取到的数自己没写单位时填(例如\"长 100、宽 80、高 60 毫米\"里的 100),写的是原话里共用的那个单位;数自己写了单位时不许填。",
354
+ "type": "object",
355
+ "required": [
356
+ "text"
357
+ ],
358
+ "properties": {
359
+ "text": {
360
+ "type": "string",
361
+ "minLength": 1,
362
+ "maxLength": 200,
363
+ "pattern": "\\S"
364
+ },
365
+ "index": {
366
+ "type": "integer",
367
+ "minimum": 0,
368
+ "maximum": 63
369
+ },
370
+ "unit": {
371
+ "enum": [
372
+ "um",
373
+ "mm",
374
+ "cm",
375
+ "m",
376
+ "in",
377
+ "mil"
378
+ ]
379
+ }
380
+ },
381
+ "additionalProperties": false
382
+ },
383
+ "DimensionChoice": {
384
+ "description": "系统估算:模型的选择,写数值、单位(原样,代码换算成毫米)和依据。只用于 source=system 的限制,换算结果不带硬筛选权限。",
385
+ "type": "object",
386
+ "required": [
387
+ "value",
388
+ "unit",
389
+ "basis"
390
+ ],
391
+ "properties": {
392
+ "value": {
393
+ "type": "number",
394
+ "minimum": 0
395
+ },
396
+ "unit": {
397
+ "enum": [
398
+ "um",
399
+ "mm",
400
+ "cm",
401
+ "m",
402
+ "in",
403
+ "mil"
404
+ ]
405
+ },
406
+ "basis": {
407
+ "type": "string",
408
+ "minLength": 1,
409
+ "maxLength": 300,
410
+ "pattern": "\\S"
411
+ }
412
+ },
413
+ "additionalProperties": false
414
+ },
415
+ "ReplyRequirements": {
416
+ "type": "object",
417
+ "required": [
418
+ "reaction",
419
+ "limits"
420
+ ],
421
+ "properties": {
422
+ "context": {
423
+ "type": "object",
424
+ "required": [
425
+ "text",
426
+ "source"
427
+ ],
428
+ "properties": {
429
+ "text": {
430
+ "type": "string",
431
+ "minLength": 1,
432
+ "pattern": "\\S"
433
+ },
434
+ "source": {
435
+ "enum": [
436
+ "user",
437
+ "system"
438
+ ]
439
+ }
440
+ },
441
+ "additionalProperties": false
442
+ },
443
+ "intent": {
444
+ "$ref": "#/$defs/Intent"
445
+ },
446
+ "reaction": {
447
+ "type": "array",
448
+ "items": {
449
+ "type": "object",
450
+ "required": [
451
+ "id",
452
+ "text",
453
+ "source"
454
+ ],
455
+ "properties": {
456
+ "id": {
457
+ "type": "string",
458
+ "minLength": 1,
459
+ "pattern": "\\S"
460
+ },
461
+ "text": {
462
+ "type": "string",
463
+ "minLength": 1,
464
+ "pattern": "\\S"
465
+ },
466
+ "source": {
467
+ "enum": [
468
+ "user",
469
+ "system"
470
+ ]
471
+ }
472
+ },
473
+ "additionalProperties": false
474
+ }
475
+ },
476
+ "limits": {
477
+ "type": "array",
478
+ "items": {
479
+ "type": "object",
480
+ "required": [
481
+ "id",
482
+ "text",
483
+ "source",
484
+ "strength"
485
+ ],
486
+ "properties": {
487
+ "id": {
488
+ "type": "string",
489
+ "minLength": 1,
490
+ "pattern": "\\S"
491
+ },
492
+ "text": {
493
+ "type": "string",
494
+ "minLength": 1,
495
+ "pattern": "\\S"
496
+ },
497
+ "source": {
498
+ "enum": [
499
+ "user",
500
+ "system"
501
+ ]
502
+ },
503
+ "strength": {
504
+ "enum": [
505
+ "hard",
506
+ "preference"
507
+ ]
508
+ },
509
+ "dimensions": {
510
+ "$ref": "#/$defs/Dimensions"
511
+ },
512
+ "network": {
513
+ "$ref": "#/$defs/Network"
514
+ }
515
+ },
516
+ "additionalProperties": false,
517
+ "description": "模型回复里的限制:尺寸只写 dimensions(引文或系统估算 + 轴 + 上下限),不写 envelope 或任何换算后的毫米数。"
518
+ }
519
+ }
520
+ },
521
+ "additionalProperties": false,
522
+ "description": "模型回复里的需求:与 Requirements 同形,只是 limits 不带 envelope(毫米包络由代码从 dimensions 换算)。"
261
523
  }
262
524
  }
263
525
  }
@@ -1,5 +1,9 @@
1
1
  #!/usr/bin/env python3
2
- """Read-only validation and ID-based diff of a clarify SkillResult (Python 3.9+)."""
2
+ """Read-only validation and ID-based diff of a clarify SkillResult reply (Python 3.9+).
3
+
4
+ Sizes are limits[].dimensions (quotes or system choices, axes, bounds); the millimetre envelope is computed by the
5
+ runtime from them, so a reply never carries one. This script checks structure only: it does not look the quotes up in
6
+ the user's message or convert units."""
3
7
 
4
8
  import argparse
5
9
  import json
@@ -18,6 +22,10 @@ INTENT_KINDS = ("restore", "create", "modify")
18
22
  DEPTHS = ("appearance", "assembly", "functional")
19
23
  MAX_FEATURES = 20
20
24
  MAX_FEATURE_CHARS = 500
25
+ LENGTH_UNITS = ("um", "mm", "cm", "m", "in", "mil")
26
+ SHAPE_AXES = {"box": ("width", "depth", "height", "wall"), "cylinder": ("diameter", "radius", "height", "wall")}
27
+ BOUNDS = ("max", "min", "exact", "estimate")
28
+ MAX_BOUNDS = 12
21
29
 
22
30
 
23
31
  def nonempty_string(value: Any) -> bool:
@@ -96,6 +104,76 @@ def check_envelope(value: Any, path: str, errors: list) -> None:
96
104
  issue(errors, f"{path}.wall_mm.estimate", "估算壁厚必须有限且不小于保证下界")
97
105
 
98
106
 
107
+ def check_dimensions(value: Any, path: str, user_limit: bool, errors: list) -> None:
108
+ """Structure of limits[].dimensions (CLR-1). Quote lookup and unit conversion are code's job in the runtime."""
109
+ if not object_fields(value, ("shape", "bounds"), (), path, errors):
110
+ return
111
+ shape = value.get("shape")
112
+ if shape not in SHAPE_AXES:
113
+ issue(errors, f"{path}.shape", "shape 必须是 box 或 cylinder")
114
+ return
115
+ bounds = value.get("bounds")
116
+ if not isinstance(bounds, list) or not 1 <= len(bounds) <= MAX_BOUNDS:
117
+ issue(errors, f"{path}.bounds", f"必须是 1–{MAX_BOUNDS} 项的数组")
118
+ return
119
+ slots = set()
120
+ for index, bound in enumerate(bounds):
121
+ location = f"{path}.bounds[{index}]"
122
+ if not object_fields(bound, ("axes", "bound"), ("quote", "choice"), location, errors):
123
+ continue
124
+ axes = bound.get("axes")
125
+ if (not isinstance(axes, list) or not 1 <= len(axes) <= 3 or len(set(map(str, axes))) != len(axes)
126
+ or any(axis not in SHAPE_AXES[shape] for axis in axes)):
127
+ issue(errors, f"{location}.axes", f"必须是 1–3 个不重复的 {shape} 轴:{', '.join(SHAPE_AXES[shape])}")
128
+ continue
129
+ if ("wall" in axes or "radius" in axes) and len(axes) > 1:
130
+ issue(errors, f"{location}.axes", "wall、radius 只能单独出现")
131
+ kind = bound.get("bound")
132
+ allowed = ("min", "estimate") if "wall" in axes else ("max", "min", "exact")
133
+ if kind not in allowed:
134
+ issue(errors, f"{location}.bound", f"必须是 {' / '.join(allowed)}")
135
+ for axis in axes:
136
+ for side in (("max", "min") if kind == "exact" else (kind,)):
137
+ slot = ("diameter" if axis == "radius" else axis, side)
138
+ if slot in slots:
139
+ issue(errors, f"{location}.axes", f"{slot[0]} 的 {slot[1]} 给了两次")
140
+ slots.add(slot)
141
+ if ("quote" in bound) == ("choice" in bound):
142
+ issue(errors, location, "quote 与 choice 必须二选一")
143
+ continue
144
+ if "quote" in bound:
145
+ quote = bound["quote"]
146
+ if object_fields(quote, ("text",), ("index", "unit"), f"{location}.quote", errors):
147
+ if not nonempty_string(quote.get("text")) or len(quote["text"]) > 200:
148
+ issue(errors, f"{location}.quote.text", "必须是 1–200 字的逐字引文")
149
+ if "index" in quote and not (type(quote["index"]) is int and 0 <= quote["index"] <= 63):
150
+ issue(errors, f"{location}.quote.index", "必须是 0–63 的整数")
151
+ if "unit" in quote and quote["unit"] not in LENGTH_UNITS:
152
+ issue(errors, f"{location}.quote.unit", f"必须是长度单位:{', '.join(LENGTH_UNITS)}")
153
+ else:
154
+ choice = bound["choice"]
155
+ if user_limit:
156
+ issue(errors, f"{location}.choice", "source=user 的限制只能引用用户原话;系统估算写在 source=system 的限制里")
157
+ if object_fields(choice, ("value", "unit", "basis"), (), f"{location}.choice", errors):
158
+ if not finite_number(choice.get("value"), inclusive=True):
159
+ issue(errors, f"{location}.choice.value", "必须是有限非负数")
160
+ if choice.get("unit") not in LENGTH_UNITS:
161
+ issue(errors, f"{location}.choice.unit", f"必须是长度单位:{', '.join(LENGTH_UNITS)}")
162
+ if not nonempty_string(choice.get("basis")) or len(choice["basis"]) > 300:
163
+ issue(errors, f"{location}.choice.basis", "必须写 1–300 字的依据")
164
+ check_dimension_cover(shape, slots, path, errors)
165
+
166
+
167
+ def check_dimension_cover(shape: str, slots: set, path: str, errors: list) -> None:
168
+ axes = ("width", "depth", "height") if shape == "box" else ("diameter", "height")
169
+ missing = [axis for axis in axes if (axis, "max") not in slots]
170
+ if missing:
171
+ issue(errors, f"{path}.bounds", f"{shape} 需要 {'、'.join(axes)} 的上限,缺 {'、'.join(missing)};凑不齐就不写 dimensions")
172
+ mins = [axis for axis in axes if (axis, "min") in slots]
173
+ if shape == "box" and mins and len(mins) != 3:
174
+ issue(errors, f"{path}.bounds", "盒体下限要三个轴一起给")
175
+
176
+
99
177
  def check_features(value: Any, path: str, errors: list) -> None:
100
178
  if not isinstance(value, list) or len(value) > MAX_FEATURES:
101
179
  issue(errors, path, f"必须是数组,至多 {MAX_FEATURES} 项")
@@ -122,7 +200,8 @@ def check_intent(value: Any, path: str, errors: list) -> None:
122
200
  issue(errors, f"{path}.keep", "create 没有来源产品,keep 必须为空")
123
201
 
124
202
 
125
- def check_requirements(value: Any, path: str, errors: list, require_intent: bool = False) -> None:
203
+ def check_requirements(value: Any, path: str, errors: list, require_intent: bool = False, reply: bool = False) -> None:
204
+ """reply=True: a model reply, whose limits carry dimensions only; otherwise stored requirements (envelope comes with dimensions)."""
126
205
  if not object_fields(value, ("reaction", "limits"), ("context", "intent"), path, errors):
127
206
  return
128
207
  if "intent" in value:
@@ -145,7 +224,11 @@ def check_requirements(value: Any, path: str, errors: list, require_intent: bool
145
224
  fields = ("id", "text", "source") + (("strength",) if group == "limits" else ())
146
225
  for index, entry in enumerate(entries):
147
226
  location = f"{path}.{group}[{index}]"
148
- if not object_fields(entry, fields, ("envelope", "network") if group == "limits" else (), location, errors):
227
+ if group == "limits" and reply and "envelope" in entry:
228
+ issue(errors, f"{location}.envelope", "envelope 由代码按 dimensions 换算,回复只写 dimensions(引文、轴和上下限),不写毫米数")
229
+ continue
230
+ optional = (("dimensions", "network") if reply else ("dimensions", "envelope", "network")) if group == "limits" else ()
231
+ if not object_fields(entry, fields, optional, location, errors):
149
232
  continue
150
233
  text_field(entry, "id", location, errors)
151
234
  text_field(entry, "text", location, errors)
@@ -156,6 +239,10 @@ def check_requirements(value: Any, path: str, errors: list, require_intent: bool
156
239
  seen_ids.add(entry_id)
157
240
  if entry.get("source") not in SOURCES:
158
241
  issue(errors, f"{location}.source", "必须是 user 或 system")
242
+ if group == "limits" and "dimensions" in entry:
243
+ check_dimensions(entry["dimensions"], f"{location}.dimensions", entry.get("source") == "user", errors)
244
+ if group == "limits" and not reply and ("envelope" in entry) != ("dimensions" in entry):
245
+ issue(errors, location, "envelope 与 dimensions 同在同缺:envelope 由代码按 dimensions 换算")
159
246
  if group == "limits" and "envelope" in entry:
160
247
  check_envelope(entry["envelope"], f"{location}.envelope", errors)
161
248
  if group == "limits" and "network" in entry and entry["network"] not in NETWORKS:
@@ -189,7 +276,8 @@ def check_questions(value: Any, errors: list) -> None:
189
276
 
190
277
 
191
278
  def requirement_items(requirements: dict) -> dict:
192
- """Keys are tuples: an opaque ID can contain any character without collision."""
279
+ """Keys are tuples: an opaque ID can contain any character without collision.
280
+ A stored envelope is derived from dimensions, so it is left out of the comparison."""
193
281
  items = {}
194
282
  if "context" in requirements:
195
283
  items[("context", None)] = requirements["context"]
@@ -197,7 +285,7 @@ def requirement_items(requirements: dict) -> dict:
197
285
  items[("intent", None)] = requirements["intent"]
198
286
  for group in ("reaction", "limits"):
199
287
  for entry in requirements[group]:
200
- items[(group, entry["id"])] = entry
288
+ items[(group, entry["id"])] = {key: item for key, item in entry.items() if key != "envelope"}
201
289
  return items
202
290
 
203
291
 
@@ -222,7 +310,7 @@ def validate(proposal: Any, current: Any = None) -> dict:
222
310
  if not object_fields(proposal, (), ("result", "questions", "note"), "$", errors):
223
311
  return report
224
312
  if "result" in proposal:
225
- check_requirements(proposal["result"], "$.result", errors, require_intent=True)
313
+ check_requirements(proposal["result"], "$.result", errors, require_intent=True, reply=True)
226
314
  if "questions" in proposal:
227
315
  check_questions(proposal["questions"], errors)
228
316
  if "note" in proposal:
@@ -0,0 +1,141 @@
1
+ // 需求尺寸的代码换算(模型算术 CLR-1):模型只给用户原话的数值引文(或系统估算的选择)和轴向映射,
2
+ // 毫米包络由这里用共用求值核心换算(英寸、厘米换毫米,半径乘 2),并核对引文确实出现在用户本轮的原话里,
3
+ // 做法同 web clarify.ts 的 withdraw。换算出的 envelope 与 abelien-hardware dimensions.mjs 的 validateEnvelope 同形,
4
+ // 下游硬件选型、骨架照旧只读 envelope。模型回复里不再有任何换算后的毫米数,也没有兼容别名。
5
+ import { canonical } from "../../abelien-harness/contracts/index.mjs";
6
+ import { evaluateFormula, parseQuantities } from "../../abelien-harness/scripts/calc/index.mjs";
7
+ import { validateEnvelope } from "../../abelien-hardware/scripts/dimensions.mjs";
8
+
9
+ /** 尺寸换算的拒收码;定稿把它们包进 TASK_REPLY_INVALID,details.issues[].code 是这里的码。 */
10
+ export const CLARIFY_DIMENSION_CODES = Object.freeze([
11
+ "CLARIFY_ENVELOPE_FROM_MODEL", // 模型回复里写了 envelope(毫米数只由代码换算)
12
+ "CLARIFY_QUOTE_NOT_IN_MESSAGE", // 引文没有逐字出现在本轮用户消息或卡片答复里(沿用的旧边界除外)
13
+ "CLARIFY_QUOTE_NO_LENGTH", // 引文里取不到唯一一个长度量(没有数、不止一个数却没给 index、index 越界、单位不是长度)
14
+ "CLARIFY_QUOTE_UNIT", // 数自己写了单位却又填 unit,或裸数没填 unit,或链式尺寸里单位有歧义
15
+ "CLARIFY_CHOICE_ON_USER_LIMIT", // source=user 的限制用了系统估算的 choice
16
+ "CLARIFY_DIMENSIONS_MAPPING", // 轴与形状不符、wall 和别的轴混写、上下限不适用于该轴、同一边界给了两次、缺必需的上限
17
+ "CLARIFY_ENVELOPE_INVALID", // 换算出的包络不成立(下限大于上限、保证壁厚吃掉腔体等)
18
+ "CLARIFY_ENVELOPE_MISMATCH", // 存下的 envelope 与按 dimensions 重新换算的结果不一致,或两者只有其一
19
+ ]);
20
+ const LENGTH_UNITS = new Set(["um", "mm", "cm", "m", "in", "mil"]);
21
+ const AXES = { box: ["width", "depth", "height"], cylinder: ["diameter", "height"] };
22
+ const SHAPE_AXES = { box: new Set(["width", "depth", "height", "wall"]), cylinder: new Set(["diameter", "radius", "height", "wall"]) };
23
+
24
+ function fail(code, path, message) {
25
+ throw Object.assign(new Error(`${code} at ${path}: ${message}`), { code: "TASK_REPLY_INVALID", details: { issues: [{ code, path, message }] } });
26
+ }
27
+ const normalize = text => text.normalize("NFKC");
28
+
29
+ /** 引文 → 带单位的操作数(值仍是原文十进制,换算交给求值核心)。 */
30
+ function quoteOperand(quote, path) {
31
+ const found = parseQuantities(quote.text);
32
+ if (!found.length) fail("CLARIFY_QUOTE_NO_LENGTH", `${path}/quote`, `the quote has no number: ${JSON.stringify(quote.text)}`);
33
+ if (quote.index === undefined && found.length !== 1)
34
+ fail("CLARIFY_QUOTE_NO_LENGTH", `${path}/quote`, `the quote has ${found.length} numbers (${found.map(item => item.text).join(", ")}); give index to pick one`);
35
+ const picked = found[quote.index ?? 0];
36
+ if (!picked) fail("CLARIFY_QUOTE_NO_LENGTH", `${path}/quote/index`, `index ${quote.index} is outside the quote's ${found.length} numbers`);
37
+ if (picked.ambiguous) fail("CLARIFY_QUOTE_UNIT", `${path}/quote`, `${picked.text} has no unit of its own and its chain mixes units`);
38
+ const bare = picked.unit === null && picked.unit_text === null && !picked.inherited;
39
+ if (bare) {
40
+ if (quote.unit === undefined) fail("CLARIFY_QUOTE_UNIT", `${path}/quote/unit`, `${picked.text} has no unit; give the length unit the user stated for it`);
41
+ return { value: Number(picked.value), unit: quote.unit, source: { choice: `the user wrote ${picked.text} with the shared unit ${quote.unit}` } };
42
+ }
43
+ if (quote.unit !== undefined) fail("CLARIFY_QUOTE_UNIT", `${path}/quote/unit`, `${picked.text} states its own unit; leave unit out`);
44
+ if (!LENGTH_UNITS.has(picked.unit)) fail("CLARIFY_QUOTE_NO_LENGTH", `${path}/quote`, `${picked.text} is not a length (${picked.unit ?? picked.unit_text})`);
45
+ return { source: { quote: { text: quote.text, source_id: "user-message", ...(quote.index !== undefined ? { index: quote.index } : {}) } } };
46
+ }
47
+
48
+ /** 一个边界的毫米数,全部经求值核心(引文取量、单位换算、半径乘 2)。 */
49
+ function boundMillimetres(bound, radius, path) {
50
+ const operand = bound.quote ? quoteOperand(bound.quote, path) : { value: bound.choice.value, unit: bound.choice.unit, source: { choice: bound.choice.basis } };
51
+ if (bound.choice && !LENGTH_UNITS.has(bound.choice.unit)) fail("CLARIFY_QUOTE_NO_LENGTH", `${path}/choice/unit`, "the unit must be a length");
52
+ try {
53
+ return evaluateFormula({ expression: radius ? "2 * q" : "q", operands: { q: operand }, unit: "mm" }, { resolveQuote: () => true }).value;
54
+ } catch (error) {
55
+ fail("CLARIFY_QUOTE_NO_LENGTH", path, `${error.code ?? "CALC"}: ${error.message}`);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * dimensions → envelope。onQuote(quote, path, bound) 核对引文出处(定稿:本轮原话;复核存档:不再核对);
61
+ * userLimit 为 true 时只收引文。返回与 validateEnvelope 同形的包络。
62
+ */
63
+ export function deriveEnvelope(dimensions, { userLimit, onQuote = () => {}, path = "/dimensions" } = {}) {
64
+ const shape = dimensions?.shape;
65
+ if (!Object.hasOwn(AXES, shape)) fail("CLARIFY_DIMENSIONS_MAPPING", `${path}/shape`, "shape must be box or cylinder");
66
+ const slots = new Map();
67
+ for (const [index, bound] of (dimensions.bounds ?? []).entries()) {
68
+ const at = `${path}/bounds/${index}`;
69
+ if (Boolean(bound.quote) === Boolean(bound.choice)) fail("CLARIFY_DIMENSIONS_MAPPING", at, "give exactly one of quote or choice");
70
+ if (bound.choice && userLimit) fail("CLARIFY_CHOICE_ON_USER_LIMIT", `${at}/choice`, "a source=user limit takes the user's words only; a system estimate belongs to a source=system limit");
71
+ const axes = bound.axes ?? [];
72
+ for (const axis of axes) if (!SHAPE_AXES[shape].has(axis)) fail("CLARIFY_DIMENSIONS_MAPPING", `${at}/axes`, `${axis} is not an axis of a ${shape}`);
73
+ if (axes.includes("wall") && axes.length > 1) fail("CLARIFY_DIMENSIONS_MAPPING", `${at}/axes`, "wall stands alone");
74
+ if (axes.includes("wall") ? !["min", "estimate"].includes(bound.bound) : !["max", "min", "exact"].includes(bound.bound))
75
+ fail("CLARIFY_DIMENSIONS_MAPPING", `${at}/bound`, axes.includes("wall") ? "a wall takes min or estimate" : "a size takes max, min or exact");
76
+ if (bound.quote) onQuote(bound.quote, `${at}/quote`, bound);
77
+ const radius = axes.includes("radius");
78
+ if (radius && axes.length > 1) fail("CLARIFY_DIMENSIONS_MAPPING", `${at}/axes`, "radius stands alone");
79
+ const mm = boundMillimetres(bound, radius, at);
80
+ for (const axis of axes) for (const kind of bound.bound === "exact" ? ["max", "min"] : [bound.bound]) {
81
+ const key = `${radius ? "diameter" : axis}:${kind}`;
82
+ if (slots.has(key)) fail("CLARIFY_DIMENSIONS_MAPPING", `${at}/axes`, `${key} is given twice`);
83
+ slots.set(key, mm);
84
+ }
85
+ }
86
+ const get = (axis, kind) => slots.get(`${axis}:${kind}`);
87
+ const missing = AXES[shape].filter(axis => get(axis, "max") === undefined);
88
+ if (missing.length) fail("CLARIFY_DIMENSIONS_MAPPING", `${path}/bounds`, `a ${shape} needs an upper bound on ${AXES[shape].join(", ")}; missing ${missing.join(", ")}`);
89
+ const mins = AXES[shape].filter(axis => get(axis, "min") !== undefined);
90
+ let envelope;
91
+ if (shape === "box") {
92
+ if (mins.length && mins.length !== 3) fail("CLARIFY_DIMENSIONS_MAPPING", `${path}/bounds`, "a box lower bound needs width, depth and height together");
93
+ envelope = { shape, max_mm: AXES.box.map(axis => get(axis, "max")), ...(mins.length ? { min_mm: AXES.box.map(axis => get(axis, "min")) } : {}) };
94
+ } else {
95
+ envelope = { shape, max_diameter_mm: get("diameter", "max"), max_height_mm: get("height", "max"),
96
+ ...(get("diameter", "min") !== undefined ? { min_diameter_mm: get("diameter", "min") } : {}),
97
+ ...(get("height", "min") !== undefined ? { min_height_mm: get("height", "min") } : {}) };
98
+ }
99
+ const wallMin = get("wall", "min"), wallEstimate = get("wall", "estimate");
100
+ // 没写保证壁厚时按 0(契约原有口径),估算只作风险提示。
101
+ if (wallMin !== undefined || wallEstimate !== undefined) envelope.wall_mm = { minimum: wallMin ?? 0, ...(wallEstimate !== undefined ? { estimate: wallEstimate } : {}) };
102
+ try { validateEnvelope(envelope); }
103
+ catch (error) { fail("CLARIFY_ENVELOPE_INVALID", path, `${error.message}; derived ${canonical(envelope)}`); }
104
+ return envelope;
105
+ }
106
+
107
+ const boundKey = bound => canonical(bound);
108
+
109
+ /**
110
+ * 定稿:把模型回复里的需求补成完整快照。每个带 dimensions 的限制由代码换算出 envelope;
111
+ * 新写或改动的引文必须逐字出现在 userTexts(本轮用户消息与卡片答复)里,与当前需求同 ID 同形状里原样沿用的边界不再核对。
112
+ */
113
+ export function completeRequirements(result, { current = null, userTexts = [] } = {}) {
114
+ const words = userTexts.filter(text => typeof text === "string").map(normalize);
115
+ const priorLimits = new Map((current?.limits ?? []).map(item => [item.id, item]));
116
+ const completed = structuredClone(result);
117
+ for (const [index, limit] of completed.limits.entries()) {
118
+ const path = `/result/limits/${index}`;
119
+ if (Object.hasOwn(limit, "envelope")) fail("CLARIFY_ENVELOPE_FROM_MODEL", `${path}/envelope`, "write dimensions (quotes, axes, bounds); the millimetre envelope is computed by code");
120
+ if (limit.dimensions === undefined) continue;
121
+ const prior = priorLimits.get(limit.id)?.dimensions;
122
+ const carried = new Set(prior?.shape === limit.dimensions.shape ? prior.bounds.map(boundKey) : []);
123
+ limit.envelope = deriveEnvelope(limit.dimensions, { userLimit: limit.source === "user", path: `${path}/dimensions`,
124
+ onQuote: (quote, at, bound) => {
125
+ if (carried.has(boundKey(bound))) return;
126
+ if (!words.some(text => text.includes(normalize(quote.text))))
127
+ fail("CLARIFY_QUOTE_NOT_IN_MESSAGE", at, `${JSON.stringify(quote.text)} is not word for word in this turn's user message or card answer`);
128
+ } });
129
+ }
130
+ return completed;
131
+ }
132
+
133
+ /** 复核存档:envelope 与 dimensions 同在同缺,且 envelope 等于按 dimensions 重新换算的结果(不再核对引文出处)。 */
134
+ export function checkStoredEnvelope(limit, path) {
135
+ const has = [Object.hasOwn(limit, "envelope"), Object.hasOwn(limit, "dimensions")];
136
+ if (has[0] !== has[1]) fail("CLARIFY_ENVELOPE_MISMATCH", path, "envelope and dimensions come together: the envelope is derived from dimensions");
137
+ if (!has[0]) return;
138
+ const derived = deriveEnvelope(limit.dimensions, { userLimit: limit.source === "user", path: `${path}/dimensions` });
139
+ if (canonical(derived) !== canonical(limit.envelope))
140
+ fail("CLARIFY_ENVELOPE_MISMATCH", `${path}/envelope`, `the stored envelope ${canonical(limit.envelope)} is not the one its dimensions give (${canonical(derived)})`);
141
+ }