abelien-harness 0.4.0 → 0.6.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 (104) hide show
  1. package/.env.example +4 -0
  2. package/README.md +11 -18
  3. package/package.json +2 -3
  4. package/skills/abelien-appearance/SKILL.md +1 -0
  5. package/skills/abelien-clarify/SKILL.md +12 -2
  6. package/skills/abelien-clarify/references/examples.md +22 -0
  7. package/skills/abelien-clarify/scripts/check_result.py +38 -3
  8. package/skills/abelien-clarify/scripts/test_check_result.py +32 -1
  9. package/skills/abelien-guide/SKILL.md +1 -1
  10. package/skills/abelien-hardware/SKILL.md +10 -5
  11. package/skills/abelien-hardware/domain.json +3 -3
  12. package/skills/abelien-hardware/references/search-index-format.md +13 -1
  13. package/skills/abelien-hardware/references/search-index-release.md +4 -2
  14. package/skills/abelien-hardware/references/web-evidence-contract.md +3 -3
  15. package/skills/abelien-hardware/scripts/dimensions.mjs +23 -8
  16. package/skills/abelien-hardware/scripts/electrical.mjs +168 -0
  17. package/skills/abelien-hardware/scripts/finalize-selection.mjs +4 -2
  18. package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +27 -6
  19. package/skills/abelien-hardware/scripts/public-page.mjs +11 -121
  20. package/skills/abelien-hardware/scripts/response-redaction.mjs +1 -130
  21. package/skills/abelien-hardware/scripts/search-index-release.mjs +30 -17
  22. package/skills/abelien-hardware/scripts/selection-request.mjs +6 -5
  23. package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +13 -8
  24. package/skills/abelien-hardware/scripts/volc-web-search.mjs +10 -27
  25. package/skills/abelien-hardware/scripts/web-common.mjs +8 -11
  26. package/skills/abelien-hardware/scripts/web-integrity.mjs +1 -1
  27. package/skills/abelien-hardware/scripts/web-provider-chain.mjs +10 -3
  28. package/skills/abelien-hardware/scripts/web-receipt.mjs +4 -2
  29. package/skills/abelien-hardware/scripts/web-search-plan.mjs +21 -2
  30. package/skills/abelien-hardware/scripts/web-search.mjs +5 -2
  31. package/skills/abelien-hardware-selection/SKILL.md +1 -1
  32. package/skills/abelien-hardware-selection/domain.json +2 -2
  33. package/skills/abelien-hardware-selection/references/selection-service-contract.md +30 -3
  34. package/skills/abelien-hardware-selection/scripts/select.mjs +35 -8
  35. package/skills/abelien-harness/scripts/ab.mjs +24 -2
  36. package/skills/abelien-harness/scripts/web/common.mjs +20 -0
  37. package/skills/abelien-harness/scripts/web/public-read.mjs +168 -0
  38. package/skills/abelien-harness/scripts/web/redact.mjs +136 -0
  39. package/skills/abelien-harness/scripts/web/volc.mjs +49 -0
  40. package/skills/abelien-reference/SKILL.md +51 -0
  41. package/skills/abelien-reference/domain.json +92 -0
  42. package/skills/abelien-reference/fixtures/create.final.json +102 -0
  43. package/skills/abelien-reference/fixtures/restore.collected.json +189 -0
  44. package/skills/abelien-reference/fixtures/restore.final.json +267 -0
  45. package/skills/abelien-reference/references/research-contract.md +74 -0
  46. package/skills/abelien-reference/schemas/reference-evidence.v1.json +140 -0
  47. package/skills/abelien-reference/scripts/evidence.mjs +60 -0
  48. package/skills/abelien-reference/scripts/finalize.mjs +116 -0
  49. package/skills/abelien-reference/scripts/measure.mjs +62 -0
  50. package/skills/abelien-reference/scripts/png.mjs +86 -0
  51. package/skills/abelien-reference/scripts/research.mjs +179 -0
  52. package/skills/abelien-structure/SKILL.md +2 -2
  53. package/skills/abelien-structure/domain.json +64 -0
  54. package/skills/abelien-structure/references/build123d-execution.md +1 -1
  55. package/skills/abelien-structure/references/handoff.md +1 -1
  56. package/skills/abelien-structure/worker/abelien_cad.py +585 -0
  57. package/skills/abelien-structure/worker/assembly-validation.mjs +106 -0
  58. package/skills/abelien-structure/worker/cad.py +1623 -0
  59. package/skills/abelien-structure/worker/cli-adapter.mjs +207 -0
  60. package/skills/abelien-structure/worker/component-fit-glb.mjs +173 -0
  61. package/skills/abelien-structure/worker/component-fit.d.mts +82 -0
  62. package/skills/abelien-structure/worker/component-fit.mjs +167 -0
  63. package/skills/abelien-structure/worker/component-target-image.d.mts +23 -0
  64. package/skills/abelien-structure/worker/component-target-image.mjs +107 -0
  65. package/skills/abelien-structure/worker/component-target-render.py +58 -0
  66. package/skills/abelien-structure/worker/config.mjs +6 -0
  67. package/skills/abelien-structure/worker/engineering-fit.d.mts +48 -0
  68. package/skills/abelien-structure/worker/engineering-fit.mjs +118 -0
  69. package/skills/abelien-structure/worker/engineering-policy.json +16 -0
  70. package/skills/abelien-structure/worker/engineering-repair.py +147 -0
  71. package/skills/abelien-structure/worker/engineering.py +324 -0
  72. package/skills/abelien-structure/worker/identity.mjs +98 -0
  73. package/skills/abelien-structure/worker/inspection-geometry.mjs +71 -0
  74. package/skills/abelien-structure/worker/jobs.mjs +451 -0
  75. package/skills/abelien-structure/worker/library-runtime.mjs +15 -0
  76. package/skills/abelien-structure/worker/main.mjs +102 -0
  77. package/skills/abelien-structure/worker/manifest-digest.mjs +11 -0
  78. package/skills/abelien-structure/worker/materials.mjs +52 -0
  79. package/skills/abelien-structure/worker/native-launcher.py +13 -0
  80. package/skills/abelien-structure/worker/native.mjs +157 -0
  81. package/skills/abelien-structure/worker/representation.mjs +47 -0
  82. package/skills/abelien-structure/worker/sandbox.mjs +41 -0
  83. package/skills/abelien-structure/worker/source-contract.mjs +143 -0
  84. package/skills/abelien-structure/worker/source_call_stats.py +59 -0
  85. package/skills/abelien-structure/worker/supervisor.mjs +38 -0
  86. package/skills/abelien-workspace/SKILL.md +11 -0
  87. package/skills/abelien-workspace/assets/journey-stage-plan.json +155 -0
  88. package/skills/abelien-workspace/domain.json +25 -2
  89. package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
  90. package/skills/abelien-workspace/references/harness-binding.md +2 -2
  91. package/skills/abelien-workspace/references/user-journey.md +10 -0
  92. package/skills/abelien-workspace/scripts/journey-next.mjs +38 -0
  93. package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +229 -0
  94. package/skills/abelien-workspace/workflows/definitions/399ef372790b7330d0ef88ce5096e7f69943d4793de97b2ed6c25165a332612f.json +230 -0
  95. package/skills/abelien-workspace/workflows/definitions/591a7f21e0b0ad586e340163f7533ac910356091ba8d2a26e250c0acbc80cd8b.json +176 -0
  96. package/skills/abelien-workspace/workflows/definitions/7c69ea0d51099b1ae947f08b056ea67f5cad6b40ae8bcb00efb496fa5b9d5ed3.json +149 -0
  97. package/skills/abelien-workspace/workflows/definitions/9b5c65ad03fdecc0148931a7677cfa684a3fb04ab2738d6f7a1086a2c79ac86c.json +149 -0
  98. package/skills/abelien-workspace/workflows/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +229 -0
  99. package/skills/abelien-workspace/workflows/definitions/d9f8c6cdf0b2c41cdeb1c72ac5d1e37d95a7caac3272d434a3c7e8483fc565de.json +150 -0
  100. package/skills/abelien-workspace/workflows/definitions/e391e14c14cb39408a0a9e226ed54ffab7a36badea305ae4a3eafbac8124fc02.json +175 -0
  101. package/skills/abelien-workspace/workflows/definitions/fd8ce7074e2044985778446e93153864940bb9465471c1c71c41328cefff70bf.json +175 -0
  102. package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +5 -4
  103. package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +5 -4
  104. package/skills/abelien-workspace/workflows/user-journey-hardware.json +5 -4
@@ -0,0 +1,140 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://git.abelien.cn/abelien/abelienharness/skills/abelien-reference/schemas/reference-evidence.v1.json",
4
+ "title": "abelien.reference-evidence.v1",
5
+ "description": "参考研究证据。status=collected 由 reference.research 产出,只有收集结果;status=final 由 reference.finalize 产出,带核验过的识别、相似产品与判定。宿主只展示与转存,不自行改写判定。",
6
+ "type": "object",
7
+ "required": ["id", "schema", "status", "intent", "keep", "changeable", "depth", "text", "user_image", "searches", "pages", "images", "items", "unknowns"],
8
+ "properties": {
9
+ "id": { "type": "string", "pattern": "^ref-[0-9a-f]{16}(-f[0-9a-f]{8})?$" },
10
+ "schema": { "const": "abelien.reference-evidence.v1" },
11
+ "status": { "enum": ["collected", "final"] },
12
+ "request_digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
13
+ "intent": { "enum": ["restore", "create", "modify"] },
14
+ "keep": { "type": "array", "items": { "type": "string", "minLength": 1 } },
15
+ "changeable": { "type": "array", "items": { "type": "string", "minLength": 1 } },
16
+ "depth": { "enum": ["appearance", "assembly", "functional", null] },
17
+ "text": { "type": "string", "minLength": 1 },
18
+ "user_image": {
19
+ "oneOf": [
20
+ { "type": "null" },
21
+ {
22
+ "type": "object",
23
+ "required": ["blob_ref", "sha256", "media_type", "measurement"],
24
+ "properties": {
25
+ "blob_ref": { "$ref": "#/$defs/blobRef" },
26
+ "sha256": { "$ref": "#/$defs/sha256" },
27
+ "media_type": { "const": "image/png" },
28
+ "measurement": { "$ref": "#/$defs/measurement" }
29
+ }
30
+ }
31
+ ]
32
+ },
33
+ "searches": { "type": "array", "items": { "$ref": "#/$defs/search" } },
34
+ "pages": { "type": "array", "items": { "$ref": "#/$defs/page" } },
35
+ "images": { "type": "array", "items": { "$ref": "#/$defs/image" } },
36
+ "items": { "type": "array", "items": { "$ref": "#/$defs/item" } },
37
+ "unknowns": { "type": "array", "items": { "type": "string" } },
38
+ "collected_id": { "type": "string", "description": "status=final 时为来源收集结果的 id" },
39
+ "judgement_digest": { "type": "string" },
40
+ "identity": {
41
+ "description": "status=final;还原与改现成时为识别判断,新创时为 null",
42
+ "oneOf": [
43
+ { "type": "null" },
44
+ {
45
+ "type": "object",
46
+ "required": ["candidates", "chosen_item_id", "conflict"],
47
+ "properties": {
48
+ "candidates": { "type": "array", "items": { "$ref": "#/$defs/candidate" } },
49
+ "chosen_item_id": { "type": ["string", "null"] },
50
+ "conflict": { "type": ["string", "null"] }
51
+ }
52
+ }
53
+ ]
54
+ },
55
+ "similar": { "type": "array", "items": { "$ref": "#/$defs/similar" } }
56
+ },
57
+ "$defs": {
58
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
59
+ "blobRef": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
60
+ "measurement": {
61
+ "type": "object",
62
+ "required": ["status", "method"],
63
+ "properties": {
64
+ "status": { "enum": ["measured", "unknown"] },
65
+ "method": { "const": "silhouette-v1" },
66
+ "reason": { "type": "string" },
67
+ "aspect_h_over_w": { "type": "number" },
68
+ "bbox": { "type": "object" },
69
+ "background": { "type": "object" }
70
+ }
71
+ },
72
+ "search": {
73
+ "type": "object",
74
+ "required": ["id", "purpose", "query", "count", "status", "results"],
75
+ "properties": {
76
+ "purpose": { "enum": ["identify", "teardown", "similar"] },
77
+ "status": { "enum": ["completed", "failed"] },
78
+ "error_code": { "type": ["string", "null"] },
79
+ "results": { "type": "array", "items": { "type": "object", "required": ["rank", "url", "title"] } }
80
+ }
81
+ },
82
+ "page": {
83
+ "type": "object",
84
+ "required": ["id", "url", "purpose", "status"],
85
+ "properties": {
86
+ "status": { "enum": ["read", "unavailable"] },
87
+ "text_blob_ref": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/blobRef" }] },
88
+ "excerpt": { "type": "string" },
89
+ "image_url": { "type": ["string", "null"] }
90
+ }
91
+ },
92
+ "image": {
93
+ "type": "object",
94
+ "required": ["id", "url", "page_id", "status"],
95
+ "properties": {
96
+ "status": { "enum": ["read", "unavailable"] },
97
+ "blob_ref": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/blobRef" }] },
98
+ "media_type": { "type": ["string", "null"] }
99
+ }
100
+ },
101
+ "verdict": {
102
+ "type": "object",
103
+ "required": ["code", "basis"],
104
+ "properties": {
105
+ "code": { "enum": ["closest", "ratio_mismatch", "excluded", "borrowed", "not_copied", "fits", "envelope"] },
106
+ "basis": { "type": "string", "minLength": 1 }
107
+ }
108
+ },
109
+ "item": {
110
+ "type": "object",
111
+ "description": "画布网格中的一格。verdict 只由 reference.finalize 的确定性检查给出。",
112
+ "required": ["id", "kind", "title", "role", "source", "image", "verdict", "status", "query"],
113
+ "properties": {
114
+ "id": { "type": "string", "minLength": 1 },
115
+ "kind": { "enum": ["user_input", "candidate", "teardown", "spec", "similar"] },
116
+ "title": { "type": "string" },
117
+ "role": { "type": "string" },
118
+ "source": { "oneOf": [{ "type": "null" }, { "type": "object", "required": ["url", "site"], "properties": { "url": { "type": "string" }, "site": { "type": "string" } } }] },
119
+ "image": { "oneOf": [{ "type": "null" }, { "type": "object", "required": ["blob_ref", "sha256", "media_type"], "properties": { "blob_ref": { "$ref": "#/$defs/blobRef" }, "sha256": { "$ref": "#/$defs/sha256" }, "media_type": { "type": "string" } } }] },
120
+ "verdict": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/verdict" }] },
121
+ "status": { "enum": ["pending", "ready", "excluded", "unavailable"] },
122
+ "query": { "type": ["string", "null"], "description": "pending 时为真实检索词" }
123
+ }
124
+ },
125
+ "candidate": {
126
+ "type": "object",
127
+ "required": ["item_id", "name", "dimensions_mm", "quote", "ratio_check"],
128
+ "properties": {
129
+ "dimensions_mm": { "description": "页面未给尺寸时为 null,此时比例核对为 unknown", "oneOf": [{ "type": "null" }, { "type": "object", "required": ["width", "height"] }] },
130
+ "quote": { "type": "object", "required": ["page_id", "text"] },
131
+ "ratio_check": { "type": "object", "required": ["status"], "properties": { "status": { "enum": ["consistent", "mismatch", "unknown"] } } }
132
+ }
133
+ },
134
+ "similar": {
135
+ "type": "object",
136
+ "required": ["item_id", "decision", "why"],
137
+ "properties": { "decision": { "enum": ["borrow", "skip"] } }
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,60 @@
1
+ // reference-evidence v1 的唯一字段定义与校验(P2-13.04)。schemas/reference-evidence.v1.json 与本文件的枚举保持一致,
2
+ // 由测试核对;宿主据 schema 与 fixtures 做契约测试,不自行解释字段含义。
3
+ import { createPublicReader } from "../../abelien-harness/scripts/web/public-read.mjs";
4
+ import { createVolcSearch } from "../../abelien-harness/scripts/web/volc.mjs";
5
+ import { redact } from "../../abelien-harness/scripts/web/redact.mjs";
6
+ import { failure, text, record } from "../../abelien-harness/scripts/web/common.mjs";
7
+
8
+ export const SCHEMA = "abelien.reference-evidence.v1";
9
+ export const INTENTS = Object.freeze(["restore", "create", "modify"]);
10
+ export const DEPTHS = Object.freeze(["appearance", "assembly", "functional"]);
11
+ export const PURPOSES = Object.freeze(["identify", "teardown", "similar"]);
12
+ export const ITEM_KINDS = Object.freeze(["user_input", "candidate", "teardown", "spec", "similar"]);
13
+ export const ITEM_STATUSES = Object.freeze(["pending", "ready", "excluded", "unavailable"]);
14
+ export const VERDICT_CODES = Object.freeze(["closest", "ratio_mismatch", "excluded", "borrowed", "not_copied", "fits", "envelope"]);
15
+ export const EVIDENCE_STATUSES = Object.freeze(["collected", "final"]);
16
+ export const KIND_BY_PURPOSE = Object.freeze({ identify: "candidate", teardown: "teardown", similar: "similar" });
17
+
18
+ export const LIMITS = Object.freeze({ queries: 6, count: 5, pages: 12, images: 8, links: 5, textChars: 4000, queryChars: 200,
19
+ pageBytes: 8 * 1024 * 1024, imageBytes: 4 * 1024 * 1024, userImageBytes: 16 * 1024 * 1024, excerptChars: 1200, pageTimeoutMs: 30000, searchTimeoutMs: 30000, concurrency: 4 });
20
+
21
+ export const CODES = Object.freeze({ transport: "REFERENCE_WEB_TRANSPORT", dns: "REFERENCE_WEB_DNS", access: "REFERENCE_WEB_URL", redirect: "REFERENCE_WEB_PAGE",
22
+ timeout: "REFERENCE_WEB_TIMEOUT", url: "REFERENCE_WEB_URL", config: "REFERENCE_WEB_CONFIG", readTimeout: "REFERENCE_PUBLIC_READ_TIMEOUT",
23
+ capability: "REFERENCE_WEB_CAPABILITY", provider: "REFERENCE_WEB_PROVIDER", auth: "REFERENCE_WEB_AUTH", rateLimit: "REFERENCE_WEB_RATE_LIMIT", response: "REFERENCE_WEB_RESPONSE" });
24
+
25
+ export const reader = createPublicReader({ codes: CODES, userAgent: "Abelien-Reference-Evidence/1", accept: "text/html,text/plain,application/xhtml+xml", decodeCharset: true });
26
+ export const imageReader = createPublicReader({ codes: CODES, userAgent: "Abelien-Reference-Evidence/1", accept: "image/png,image/jpeg,image/webp" });
27
+ export const volc = createVolcSearch({ codes: CODES, ownsError: error => Boolean(error?.code?.startsWith("REFERENCE_WEB_")),
28
+ redact: (raw, env = {}) => redact(raw, [env.WEB_SEARCH_API_KEY].filter(text)), timeoutMessage: "reference network request timed out" });
29
+
30
+ export const invalid = message => { throw failure("VALIDATION", message); };
31
+ export const stringList = (value, label, max = 20) => {
32
+ if (!Array.isArray(value) || value.length > max || value.some(item => !text(item) || item.length > 500)) invalid(`${label} must be a list of up to ${max} non-empty strings`);
33
+ return value.map(item => item.trim());
34
+ };
35
+ export const site = url => { try { return new URL(url).hostname; } catch { return null; } };
36
+
37
+ function checkItem(item, index) {
38
+ const at = `items[${index}]`;
39
+ if (!record(item) || !text(item.id) || !ITEM_KINDS.includes(item.kind) || !ITEM_STATUSES.includes(item.status) || typeof item.title !== "string" || typeof item.role !== "string") invalid(`${at} has invalid id, kind, status, title or role`);
40
+ if (item.source !== null && !(record(item.source) && text(item.source.url) && typeof item.source.site === "string")) invalid(`${at}.source must be null or {url, site}`);
41
+ if (item.image !== null && !(record(item.image) && /^sha256:[0-9a-f]{64}$/.test(item.image.blob_ref ?? "") && /^[0-9a-f]{64}$/.test(item.image.sha256 ?? "") && text(item.image.media_type))) invalid(`${at}.image must be null or {blob_ref, sha256, media_type}`);
42
+ if (item.verdict !== null && !(record(item.verdict) && VERDICT_CODES.includes(item.verdict.code) && text(item.verdict.basis))) invalid(`${at}.verdict must be null or {code, basis}`);
43
+ if (item.query !== null && typeof item.query !== "string") invalid(`${at}.query must be null or a string`);
44
+ }
45
+
46
+ /** Strict structural check of a reference-evidence v1 body; returns the body. */
47
+ export function validateEvidence(body) {
48
+ if (!record(body) || body.schema !== SCHEMA || !text(body.id) || !EVIDENCE_STATUSES.includes(body.status)) invalid("not a reference-evidence v1 body");
49
+ if (!INTENTS.includes(body.intent) || (body.depth !== null && !DEPTHS.includes(body.depth))) invalid("intent or depth is invalid");
50
+ stringList(body.keep, "keep"); stringList(body.changeable, "changeable"); stringList(body.unknowns, "unknowns", 200);
51
+ for (const name of ["searches", "pages", "images", "items"]) if (!Array.isArray(body[name])) invalid(`${name} must be an array`);
52
+ body.items.forEach(checkItem);
53
+ const ids = body.items.map(item => item.id); if (new Set(ids).size !== ids.length) invalid("item ids must be unique");
54
+ if (body.status === "final") {
55
+ if (!record(body.identity) && body.identity !== null) invalid("identity must be null or an object");
56
+ if (!Array.isArray(body.similar)) invalid("similar must be an array");
57
+ if (!text(body.collected_id)) invalid("final evidence must name its collected_id");
58
+ }
59
+ return body;
60
+ }
@@ -0,0 +1,116 @@
1
+ // reference.finalize(P2-13.04):核验 Agent 对已收集证据的判断,生成最终的 reference-evidence。
2
+ // Agent 只给判断与原文引用;引用必须逐字出现在已保存的页面正文里,尺寸数字必须出现在引用中,
3
+ // 比例判定由测量值与规格尺寸确定性计算,不接受 Agent 自填的判定。
4
+ import { text, record, digest } from "../../abelien-harness/scripts/web/common.mjs";
5
+ import { compareRatio } from "./measure.mjs";
6
+ import { validateEvidence, invalid } from "./evidence.mjs";
7
+
8
+ const normalize = value => value.replace(/\s+/g, " ").trim();
9
+ const numbersIn = value => new Set((value.match(/\d+(?:\.\d+)?/g) ?? []).map(Number));
10
+ const percent = value => `${Math.round(value * 1000) / 10}%`;
11
+
12
+ function checkKeys(value, keys, label) { if (!record(value) || Object.keys(value).some(key => !keys.includes(key))) invalid(`${label} has unsupported fields`); }
13
+ function words(value, label, max = 300) { if (!text(value) || value.length > max) invalid(`${label} must be 1–${max} chars`); return value.trim(); }
14
+
15
+ export async function finalizeReference({ input, readResource, readBlob }) {
16
+ checkKeys(input, ["evidence_id", "identity", "similar", "excluded", "specs"], "input");
17
+ for (const name of ["similar", "excluded"]) if (input[name] !== undefined && (!Array.isArray(input[name]) || input[name].length > 12)) invalid(`${name} must be up to 12 entries`);
18
+ if (typeof readResource !== "function" || typeof readBlob !== "function") throw Object.assign(new Error("trusted resource and content readers are required"), { code: "REFERENCE_HOST" });
19
+ if (!/^reference-evidence:ref-[0-9a-f]{16}$/.test(input.evidence_id ?? "")) invalid("evidence_id must name a collected reference-evidence resource");
20
+ const resource = await readResource(input.evidence_id);
21
+ if (resource?.kind !== "reference-evidence" || resource.body?.status !== "collected") invalid("evidence_id must name a collected reference-evidence resource");
22
+ const collected = validateEvidence(resource.body), body = structuredClone(collected);
23
+ const pages = new Map(body.pages.map(page => [page.id, page])), items = new Map(body.items.map(item => [item.id, item]));
24
+ const pageText = new Map();
25
+ const quoteOn = (quote, label) => {
26
+ checkKeys(quote, ["page_id", "text"], label);
27
+ const page = pages.get(quote.page_id);
28
+ if (!page || page.status !== "read" || !page.text_blob_ref) invalid(`${label}.page_id must name a page that was read`);
29
+ if (!pageText.has(page.id)) pageText.set(page.id, normalize(String(readBlob(page.text_blob_ref)?.text ?? "")));
30
+ const quoted = words(quote.text, `${label}.text`, 500);
31
+ if (!pageText.get(page.id).includes(normalize(quoted))) invalid(`${label}.text must appear verbatim in page ${page.id}`);
32
+ return { page_id: page.id, text: quoted };
33
+ };
34
+ const itemOf = (id, kinds, label) => {
35
+ const item = items.get(id);
36
+ if (!item || !kinds.includes(item.kind)) invalid(`${label} must name an item of kind ${kinds.join(" or ")}`);
37
+ return item;
38
+ };
39
+ const unknowns = [...body.unknowns];
40
+
41
+ let identity = null;
42
+ if (input.identity !== undefined) {
43
+ checkKeys(input.identity, ["candidates", "chosen_item_id"], "identity");
44
+ const list = input.identity.candidates ?? [];
45
+ if (!Array.isArray(list) || list.length > 6) invalid("identity.candidates must be up to 6 entries");
46
+ const measurement = body.user_image?.measurement ?? null;
47
+ const candidates = list.map((c, i) => {
48
+ const label = `identity.candidates[${i}]`;
49
+ checkKeys(c, ["item_id", "name", "dimensions_mm", "quote"], label);
50
+ itemOf(c.item_id, ["candidate"], `${label}.item_id`);
51
+ // Dimensions are optional: a page may name the model without giving its size. The quote is always required.
52
+ const dims = c.dimensions_mm === undefined || c.dimensions_mm === null ? null : c.dimensions_mm;
53
+ const quote = quoteOn(c.quote, `${label}.quote`);
54
+ if (dims) {
55
+ checkKeys(dims, ["width", "depth", "height"], `${label}.dimensions_mm`);
56
+ const keys = ["width", "height", ...(dims.depth === undefined ? [] : ["depth"])], found = numbersIn(quote.text);
57
+ for (const key of keys) if (!Number.isFinite(dims[key]) || dims[key] <= 0 || dims[key] > 5000) invalid(`${label}.dimensions_mm.${key} must be 0–5000 mm`);
58
+ for (const key of keys) if (!found.has(dims[key])) invalid(`${label}.quote must contain the ${key} value ${dims[key]}`);
59
+ }
60
+ return { item_id: c.item_id, name: words(c.name, `${label}.name`, 120), dimensions_mm: dims ? { ...dims } : null, quote, ratio_check: compareRatio(measurement, dims) };
61
+ });
62
+ const chosen = input.identity.chosen_item_id ?? null;
63
+ if (chosen === null ? candidates.length > 0 : !candidates.some(c => c.item_id === chosen)) invalid("identity.chosen_item_id must name one of the candidates (null only when there are none)");
64
+ const pick = candidates.find(c => c.item_id === chosen) ?? null;
65
+ let conflict = null;
66
+ if (pick?.ratio_check.status === "mismatch") conflict = `所选型号「${pick.name}」与参考图比例不符(实测 ${pick.ratio_check.measured},规格 ${pick.ratio_check.spec})`;
67
+ else {
68
+ const others = candidates.filter(c => c !== pick && c.ratio_check.status === "consistent" && c.name !== pick?.name);
69
+ if (others.length && pick?.ratio_check.status !== "consistent") conflict = `另有比例一致的候选:${others.map(c => c.name).join("、")}`;
70
+ }
71
+ if (!pick) unknowns.push("型号未能识别");
72
+ else if (pick.ratio_check.status === "unknown") unknowns.push(`型号比例未核对(${pick.ratio_check.reason})`);
73
+ for (const c of candidates) {
74
+ const item = items.get(c.item_id), r = c.ratio_check;
75
+ const ratio = r.status === "unknown" ? `比例未核对(${r.reason})` : `实测高宽比 ${r.measured},规格 ${r.spec},相差 ${percent(r.relative_error)}`;
76
+ if (r.status === "mismatch") item.verdict = { code: "ratio_mismatch", basis: ratio };
77
+ else if (c.item_id === chosen) item.verdict = { code: "closest", basis: `选定为「${c.name}」;${ratio}` };
78
+ item.title = c.name;
79
+ }
80
+ identity = { candidates, chosen_item_id: chosen, conflict };
81
+ } else if (body.intent !== "create") unknowns.push("没有提交识别判断,型号保持未知");
82
+
83
+ const similar = (input.similar ?? []).map((s, i) => {
84
+ const label = `similar[${i}]`;
85
+ checkKeys(s, ["item_id", "decision", "borrowed", "why", "not_copied"], label);
86
+ const item = itemOf(s.item_id, ["similar"], `${label}.item_id`);
87
+ if (!["borrow", "skip"].includes(s.decision)) invalid(`${label}.decision must be borrow or skip`);
88
+ const why = words(s.why, `${label}.why`);
89
+ if (s.decision === "borrow") {
90
+ const borrowed = words(s.borrowed, `${label}.borrowed`), notCopied = s.not_copied === undefined ? null : words(s.not_copied, `${label}.not_copied`);
91
+ item.verdict = { code: "borrowed", basis: `借用:${borrowed};原因:${why}${notCopied ? `;不照搬:${notCopied}` : ""}` };
92
+ return { item_id: s.item_id, decision: "borrow", borrowed, why, not_copied: notCopied };
93
+ }
94
+ item.verdict = { code: "not_copied", basis: why };
95
+ return { item_id: s.item_id, decision: "skip", borrowed: null, why, not_copied: null };
96
+ });
97
+
98
+ for (const [i, e] of (input.excluded ?? []).entries()) {
99
+ checkKeys(e, ["item_id", "reason"], `excluded[${i}]`);
100
+ const item = itemOf(e.item_id, ["candidate", "teardown", "similar"], `excluded[${i}].item_id`);
101
+ item.status = "excluded"; item.verdict = { code: "excluded", basis: words(e.reason, `excluded[${i}].reason`) };
102
+ }
103
+
104
+ if (input.specs !== undefined && (!Array.isArray(input.specs) || input.specs.length > 12)) invalid("specs must be up to 12 entries");
105
+ const specs = (input.specs ?? []).map((s, i) => {
106
+ checkKeys(s, ["title", "value", "quote"], `specs[${i}]`);
107
+ const quote = quoteOn(s.quote, `specs[${i}].quote`), page = pages.get(quote.page_id);
108
+ return { id: `sp${i + 1}`, kind: "spec", title: words(s.title, `specs[${i}].title`, 80), role: words(s.value, `specs[${i}].value`, 200),
109
+ source: { url: page.final_url ?? page.url, site: items.get(page.id)?.source?.site ?? "" }, image: null, verdict: null, status: "ready", query: null, quote };
110
+ });
111
+
112
+ const judgementDigest = digest(input);
113
+ return validateEvidence({ ...body, id: `${body.id}-f${judgementDigest.slice(0, 8)}`, status: "final", collected_id: body.id, judgement_digest: judgementDigest,
114
+ identity, similar, items: [...items.values(), ...specs.map(({ quote, ...item }) => ({ ...item, role: `${item.role}(引自 ${quote.page_id})` }))], spec_quotes: specs.map(s => ({ id: s.id, quote: s.quote })),
115
+ unknowns: [...new Set(unknowns)] });
116
+ }
@@ -0,0 +1,62 @@
1
+ // 轮廓测量(P2-13.04):对单色背景或透明背景的产品图取主体包围盒,得到高宽比。
2
+ // 只在背景可判定时给结论;背景杂乱、全空或解码失败都记为 unknown,并写明原因,不估算。
3
+ import { decodePng } from "./png.mjs";
4
+
5
+ export const MEASURE_METHOD = "silhouette-v1";
6
+ const COLOR_DELTA = 24, ALPHA_MIN = 32, BORDER_NOISE_MAX = 0.1;
7
+ const median = values => { const sorted = [...values].sort((a, b) => a - b); return sorted[sorted.length >> 1]; };
8
+
9
+ function borderPixels(width, height) {
10
+ const out = [];
11
+ for (let x = 0; x < width; x++) out.push(x, (height - 1) * width + x);
12
+ for (let y = 1; y < height - 1; y++) out.push(y * width, y * width + width - 1);
13
+ return out;
14
+ }
15
+
16
+ /** rgba: 8-bit RGBA samples. Returns { status: "measured", ... } or { status: "unknown", reason }. */
17
+ export function measureSilhouette({ width, height, rgba }) {
18
+ if (width < 8 || height < 8) return { status: "unknown", reason: "image_too_small", method: MEASURE_METHOD };
19
+ const border = borderPixels(width, height);
20
+ const transparentBorder = border.filter(i => rgba[i * 4 + 3] < ALPHA_MIN).length / border.length;
21
+ let foreground, background;
22
+ if (transparentBorder > 0.9) {
23
+ background = { mode: "alpha" };
24
+ foreground = i => rgba[i * 4 + 3] >= ALPHA_MIN;
25
+ } else {
26
+ const bg = [0, 1, 2].map(c => median(border.map(i => rgba[i * 4 + c])));
27
+ const differs = i => Math.max(Math.abs(rgba[i * 4] - bg[0]), Math.abs(rgba[i * 4 + 1] - bg[1]), Math.abs(rgba[i * 4 + 2] - bg[2])) > COLOR_DELTA || rgba[i * 4 + 3] < ALPHA_MIN;
28
+ if (border.filter(differs).length / border.length > BORDER_NOISE_MAX) return { status: "unknown", reason: "background_not_uniform", method: MEASURE_METHOD };
29
+ background = { mode: "color", rgb: bg };
30
+ foreground = differs;
31
+ }
32
+ const rows = new Uint32Array(height), cols = new Uint32Array(width);
33
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) if (foreground(y * width + x)) { rows[y]++; cols[x]++; }
34
+ const rowMin = Math.max(2, Math.ceil(width * 0.005)), colMin = Math.max(2, Math.ceil(height * 0.005));
35
+ const top = rows.findIndex(n => n >= rowMin), bottom = rows.findLastIndex(n => n >= rowMin);
36
+ const left = cols.findIndex(n => n >= colMin), right = cols.findLastIndex(n => n >= colMin);
37
+ if (top < 0 || left < 0) return { status: "unknown", reason: "no_foreground", method: MEASURE_METHOD };
38
+ const w = right - left + 1, h = bottom - top + 1;
39
+ if (w < 8 || h < 8) return { status: "unknown", reason: "foreground_too_small", method: MEASURE_METHOD };
40
+ return { status: "measured", method: MEASURE_METHOD, image: { width, height }, bbox: { x: left, y: top, width: w, height: h },
41
+ aspect_h_over_w: Math.round(h / w * 10000) / 10000, background };
42
+ }
43
+
44
+ export function measurePng(bytes) {
45
+ let image;
46
+ try { image = decodePng(bytes); } catch (error) { if (error?.code === "PNG_UNSUPPORTED") return { status: "unknown", reason: `png_${error.reason}`, method: MEASURE_METHOD }; throw error; }
47
+ return measureSilhouette(image);
48
+ }
49
+
50
+ /**
51
+ * Front-view check: the visible outline of an upright product is width × height.
52
+ * Returns the relative error between the measured and spec height/width ratios against a tolerance.
53
+ */
54
+ export const RATIO_TOLERANCE = 0.1;
55
+ export function compareRatio(measurement, dimensions, tolerance = RATIO_TOLERANCE) {
56
+ if (measurement?.status !== "measured") return { status: "unknown", reason: "no_measurement" };
57
+ const { width, height } = dimensions ?? {};
58
+ if (!(width > 0) || !(height > 0)) return { status: "unknown", reason: "no_dimensions" };
59
+ const spec = height / width, measured = measurement.aspect_h_over_w, error = Math.abs(measured - spec) / spec;
60
+ return { status: error <= tolerance ? "consistent" : "mismatch", measured, spec: Math.round(spec * 10000) / 10000,
61
+ relative_error: Math.round(error * 10000) / 10000, tolerance };
62
+ }
@@ -0,0 +1,86 @@
1
+ // 零依赖 PNG 解码(P2-13.04):只用 Node 自带 zlib。支持非隔行的灰度、RGB、调色板、灰度+透明、RGBA,
2
+ // 位深按 PNG 规范(16 位取高字节);含 tRNS 透明。隔行(Adam7)与超大图明确拒绝,由调用方记为未测量,不编造。
3
+ import { inflateSync } from "node:zlib";
4
+
5
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
6
+ export const PNG_MAX_PIXELS = 40_000_000;
7
+ export const isPng = bytes => Buffer.isBuffer(bytes) && bytes.length >= 8 && bytes.subarray(0, 8).equals(SIGNATURE);
8
+
9
+ class PngError extends Error { constructor(reason, message) { super(message); this.code = "PNG_UNSUPPORTED"; this.reason = reason; } }
10
+ const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
11
+ const DEPTHS = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
12
+
13
+ function paeth(a, b, c) { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
14
+
15
+ /** Returns { width, height, rgba } with 8-bit RGBA samples. Throws PngError with a stable reason. */
16
+ export function decodePng(bytes) {
17
+ if (!isPng(bytes)) throw new PngError("not_png", "not a PNG file");
18
+ let offset = 8, header = null; const idat = []; let palette = null, trns = null;
19
+ while (offset + 8 <= bytes.length) {
20
+ const length = bytes.readUInt32BE(offset), type = bytes.toString("latin1", offset + 4, offset + 8);
21
+ const start = offset + 8, end = start + length;
22
+ if (end + 4 > bytes.length) throw new PngError("truncated", "PNG chunk is truncated");
23
+ const data = bytes.subarray(start, end);
24
+ if (type === "IHDR") {
25
+ header = { width: data.readUInt32BE(0), height: data.readUInt32BE(4), depth: data[8], color: data[9], interlace: data[12] };
26
+ } else if (type === "PLTE") palette = data;
27
+ else if (type === "tRNS") trns = data;
28
+ else if (type === "IDAT") idat.push(data);
29
+ else if (type === "IEND") break;
30
+ offset = end + 4;
31
+ }
32
+ if (!header) throw new PngError("truncated", "PNG header is missing");
33
+ const { width, height, depth, color, interlace } = header;
34
+ if (!CHANNELS[color] || !DEPTHS[color].includes(depth)) throw new PngError("unsupported_format", `PNG color type ${color} depth ${depth} is not supported`);
35
+ if (interlace !== 0) throw new PngError("interlaced", "interlaced PNG is not supported");
36
+ if (!width || !height || width * height > PNG_MAX_PIXELS) throw new PngError("too_large", "PNG dimensions are out of range");
37
+ if (color === 3 && !palette) throw new PngError("truncated", "palette PNG has no PLTE");
38
+ const raw = inflateSync(Buffer.concat(idat));
39
+ const bitsPerPixel = CHANNELS[color] * depth, stride = Math.ceil(width * bitsPerPixel / 8), bpp = Math.max(1, bitsPerPixel >> 3);
40
+ if (raw.length < height * (stride + 1)) throw new PngError("truncated", "PNG image data is truncated");
41
+ const lines = Buffer.alloc(height * stride);
42
+ for (let y = 0; y < height; y++) {
43
+ const filter = raw[y * (stride + 1)], src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)), row = lines.subarray(y * stride, (y + 1) * stride);
44
+ const prev = y ? lines.subarray((y - 1) * stride, y * stride) : null;
45
+ for (let i = 0; i < stride; i++) {
46
+ const left = i >= bpp ? row[i - bpp] : 0, up = prev ? prev[i] : 0, upLeft = prev && i >= bpp ? prev[i - bpp] : 0;
47
+ const value = filter === 0 ? src[i] : filter === 1 ? src[i] + left : filter === 2 ? src[i] + up
48
+ : filter === 3 ? src[i] + ((left + up) >> 1) : filter === 4 ? src[i] + paeth(left, up, upLeft) : NaN;
49
+ if (Number.isNaN(value)) throw new PngError("corrupt", `unknown PNG filter ${filter}`);
50
+ row[i] = value & 0xff;
51
+ }
52
+ }
53
+ const rgba = new Uint8Array(width * height * 4);
54
+ const sample = (row, index) => {
55
+ if (depth === 8) return row[index];
56
+ if (depth === 16) return row[index * 2];
57
+ const perByte = 8 / depth, byte = row[Math.floor(index / perByte)], shift = 8 - depth * (index % perByte + 1);
58
+ return (byte >> shift) & ((1 << depth) - 1);
59
+ };
60
+ const scale = depth < 8 && color !== 3 ? 255 / ((1 << depth) - 1) : 1;
61
+ const transparentGray = trns && color === 0 ? trns.readUInt16BE(0) : null;
62
+ const transparentRgb = trns && color === 2 ? [trns.readUInt16BE(0), trns.readUInt16BE(2), trns.readUInt16BE(4)] : null;
63
+ for (let y = 0; y < height; y++) {
64
+ const row = lines.subarray(y * stride, (y + 1) * stride);
65
+ for (let x = 0; x < width; x++) {
66
+ const o = (y * width + x) * 4, n = CHANNELS[color];
67
+ if (color === 3) {
68
+ const index = sample(row, x);
69
+ rgba[o] = palette[index * 3]; rgba[o + 1] = palette[index * 3 + 1]; rgba[o + 2] = palette[index * 3 + 2];
70
+ rgba[o + 3] = trns && index < trns.length ? trns[index] : 255;
71
+ } else if (color === 0 || color === 4) {
72
+ const raw16 = depth === 16 ? row.readUInt16BE(x * n * 2) : sample(row, x * n);
73
+ const gray = Math.round(sample(row, x * n) * scale);
74
+ rgba[o] = rgba[o + 1] = rgba[o + 2] = gray;
75
+ rgba[o + 3] = color === 4 ? sample(row, x * n + 1) : transparentGray !== null && raw16 === transparentGray ? 0 : 255;
76
+ } else {
77
+ rgba[o] = sample(row, x * n); rgba[o + 1] = sample(row, x * n + 1); rgba[o + 2] = sample(row, x * n + 2);
78
+ if (color === 6) rgba[o + 3] = sample(row, x * n + 3);
79
+ else if (transparentRgb && depth === 8 && rgba[o] === transparentRgb[0] && rgba[o + 1] === transparentRgb[1] && rgba[o + 2] === transparentRgb[2]) rgba[o + 3] = 0;
80
+ else if (transparentRgb && depth === 16 && row.readUInt16BE(x * 6) === transparentRgb[0] && row.readUInt16BE(x * 6 + 2) === transparentRgb[1] && row.readUInt16BE(x * 6 + 4) === transparentRgb[2]) rgba[o + 3] = 0;
81
+ else rgba[o + 3] = 255;
82
+ }
83
+ }
84
+ }
85
+ return { width, height, rgba };
86
+ }