abelien-harness 0.6.1 → 0.6.2
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.
- package/package.json +1 -1
- package/skills/abelien-reference/SKILL.md +3 -3
- package/skills/abelien-reference/domain.json +1 -1
- package/skills/abelien-reference/references/research-contract.md +7 -3
- package/skills/abelien-reference/schemas/reference-judgement.v1.json +117 -0
- package/skills/abelien-reference/scripts/finalize.mjs +30 -16
- package/skills/abelien-reference/scripts/png.mjs +28 -0
- package/skills/abelien-reference/scripts/research.mjs +18 -10
package/package.json
CHANGED
|
@@ -23,7 +23,7 @@ compatibility:
|
|
|
23
23
|
1. **制定检索计划**:依据用户原话和 clarify 的意图判定,写出最多 6 条英文或中文检索,每条注明用途:`identify` 识别、`teardown` 逆向资料、`similar` 相似产品。用户给了链接时,链接优先读取。
|
|
24
24
|
2. **收集**:调用 `reference.research`。它执行 Volc 检索(每条最多 5 个结果)、对公开 HTTPS 页面做安全直读、抓取页面主图、测量用户原图(PNG)的轮廓高宽比,然后把证据保存为 `reference-evidence`(status=collected)。同一请求重复调用直接返回已有结果,不会重复产生检索费用。
|
|
25
25
|
3. **阅读证据并判断**:用 `ab resource get` 读取收集结果,用页面摘录(`excerpt`)和正文判断:哪个候选最可能是用户要的产品、它的尺寸是多少;哪些相似产品可以借用、借用什么、为什么、哪些不照搬;哪些条目应排除。
|
|
26
|
-
4.
|
|
26
|
+
4. **核验**:按 `schemas/reference-judgement.v1.json` 的格式调用 `reference.finalize` 提交判断,生成 status=final 的结果。还原与改现成必须显式给出 `identity.candidates` 和 `chosen_item_id`;定不下型号就把 `chosen_item_id` 设为 `null`,并在 `unidentified_reason` 写明原因。不要用空的 `identity` 试格式。核验规则:
|
|
27
27
|
- 引用必须逐字出现在已保存的页面正文中;
|
|
28
28
|
- 尺寸数字必须出现在引用里;
|
|
29
29
|
- 比例判定由测量值和规格计算得出,不能由 Agent 自填。
|
|
@@ -36,8 +36,8 @@ compatibility:
|
|
|
36
36
|
首版之前不向用户提问;用户可以随时在画布或对话里改变参考。处理方式都是对**同一份收集结果**重新提交判断,不重新检索:
|
|
37
37
|
|
|
38
38
|
- **换成某个候选**(画布「换成这款/就是这款」,或原话点名「参考产品换成 X」):沿用原 `evidence_id`,把 `identity.chosen_item_id` 改成用户点名的候选,重新调用 `reference.finalize`。比例冲突照常由核验算出并写进 `identity.conflict`,如实展示,不再追问。
|
|
39
|
-
- **不要参考某一条**(「不要参考这个」):重新提交判断,把该条目放进 `excluded`,`reason` 写明是用户不采用。被排除的是所选产品时,改选其余候选中比例一致的一个;没有合适的就把 `chosen_item_id` 设为 `null`,并在 `
|
|
40
|
-
- 这两种情况都属于需求修订:clarify 把用户的选择记成 source=user 的需求,后续阶段按 workspace
|
|
39
|
+
- **不要参考某一条**(「不要参考这个」):重新提交判断,把该条目放进 `excluded`,`reason` 写明是用户不采用。被排除的是所选产品时,改选其余候选中比例一致的一个;没有合适的就把 `chosen_item_id` 设为 `null`,并在 `unidentified_reason` 写明「用户排除了所选型号,其余候选不合适」。
|
|
40
|
+
- 这两种情况都属于需求修订:clarify 把用户的选择记成 source=user 的需求,后续阶段按 workspace 规则推进,本包只负责重新提交判断。宿主把定稿登记为当前修订的参考来源,同一修订内不再替换,所以先修订需求、再重新提交判断;Agent 自己发现更好的证据时,也不能在同一修订里覆盖已登记的那一份,要如实告诉用户,由用户决定是否换选。
|
|
41
41
|
|
|
42
42
|
## 调用
|
|
43
43
|
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
- **检索**:只用宿主环境变量 `WEB_SEARCH_API_KEY` 调 Volc。原始回执先脱敏,再计算摘要。单条检索失败时,该条记为 `failed`,并写明错误码,其余照常进行;缺少 key 时,在发出任何请求之前就以 `REFERENCE_WEB_CONFIG` 失败。
|
|
25
25
|
- **页面**:先读链接,再从各条检索结果中轮流取页面,避免某一条检索占满读取预算。读取只走内核的安全直读:只读公网 HTTPS,每一跳都重新解析并钉住地址,限制重定向次数。每页最多读 8MB,超出时记为 `too_large`、不可用。正文存为内容 blob,资源里只保留前 1200 字的摘录。
|
|
26
|
-
- **主图**:取页面的 `og:image`、`twitter:image` 或 `image_src
|
|
26
|
+
- **主图**:取页面的 `og:image`、`twitter:image` 或 `image_src`;路径像头像、logo、图标、二维码的(如 `avatar`、`logo`、`icon`、`qrcode`)跳过,改取该页下一个候选。图片同样走钉住地址的直读,每张不超过 4MB,只接受 PNG、JPEG、WebP,原始字节存为内容 blob。其中的 PNG 会额外测量轮廓。
|
|
27
27
|
- **用户原图**:存为内容 blob 并测量轮廓(`silhouette-v1`),得到包围盒和高宽比;背景必须是单色或透明。背景杂乱、没有主体、隔行 PNG、超大图都记为 `unknown`,并写明原因。
|
|
28
28
|
- **幂等**:结果 id 由完整请求摘要(包括原图哈希)派生。已有同 id 的收集结果时直接返回,不再检索、不再读取。因此单条检索或页面的临时失败不会因为原样重试而自动恢复;要重试,就改写检索计划(比如换个检索词、改结果数),形成新的请求。
|
|
29
29
|
|
|
@@ -39,10 +39,12 @@
|
|
|
39
39
|
|
|
40
40
|
### 输入
|
|
41
41
|
|
|
42
|
+
除 `evidence_id` 外的字段(判断部分)由 `schemas/reference-judgement.v1.json` 定义;该文件自包含、每层都不接受多余字段,宿主可以原样用作工具参数定义。
|
|
43
|
+
|
|
42
44
|
| 字段 | 规则 |
|
|
43
45
|
| --- | --- |
|
|
44
46
|
| `evidence_id` | 必须指向一份 status=collected 的 `reference-evidence:ref-…` |
|
|
45
|
-
| `identity` |
|
|
47
|
+
| `identity` | 还原与改现成**必填**,新创**不得提交**。`{candidates: [{item_id, name, dimensions_mm?: {width, depth?, height}, quote: {page_id, text}}], chosen_item_id, unidentified_reason?}`:`candidates` 与 `chosen_item_id` 必须显式给出(空对象、缺字段一律 `VALIDATION`),最多 6 个候选,没有可用候选时为空列表。`chosen_item_id` 是其中一个候选,或为 `null`;为 `null` 时必须写 `unidentified_reason`(1–300 字),写入 `unknowns`「型号未能识别:…」;选定了型号时不得写。引用是必填的识别依据;页面没有给出尺寸时,可以不填 `dimensions_mm`,此时比例记为未核对 |
|
|
46
48
|
| `similar` | 可选,最多 12 条,每条为 `{item_id, decision: borrow / skip, borrowed?, why, not_copied?}`;`borrow` 时必须写明 `borrowed` |
|
|
47
49
|
| `excluded` | 可选,最多 12 条,每条为 `{item_id, reason}` |
|
|
48
50
|
| `specs` | 可选,最多 12 条,每条为 `{title, value, quote}`,生成 `spec` 格 |
|
|
@@ -57,7 +59,7 @@
|
|
|
57
59
|
|
|
58
60
|
- 正面视角下,比较高宽比:规格比例为 `height / width`,与用户原图的实测值比较,相对误差不超过 10% 记为 `consistent`,否则记为 `mismatch`;没有测量或缺尺寸时记为 `unknown`。
|
|
59
61
|
- 所选候选:比例一致或无法核对时,判定为 `closest`,依据中写明比例情况;比例不符时,判定为 `ratio_mismatch`,并在 `identity.conflict` 中写明。
|
|
60
|
-
- 其他候选:比例不符时判定为 `ratio_mismatch
|
|
62
|
+
- 其他候选:比例不符时判定为 `ratio_mismatch`,其余不加判定,仍保留为候选,供用户切换。没有选定型号、却有比例一致的候选时,也写进 `identity.conflict`。
|
|
61
63
|
- 相似产品:`borrow` 判定为 `borrowed`,依据中写明借用了什么、原因、哪些不照搬;`skip` 判定为 `not_copied`。
|
|
62
64
|
- 排除项:状态改为 `excluded`,判定为 `excluded`。
|
|
63
65
|
- 同一份收集结果可以多次提交不同判断,每次生成一份新的 final 结果(id 带判断摘要),收集结果本身不变。
|
|
@@ -74,5 +76,7 @@
|
|
|
74
76
|
## 宿主责任
|
|
75
77
|
|
|
76
78
|
- 把上传的图片转成 PNG,写入作品目录中的输入文件,再用 `--input-file` 传入。
|
|
79
|
+
- 定稿工具的参数定义直接用 `schemas/reference-judgement.v1.json`,`evidence_id` 由宿主填入,不让模型猜格式。
|
|
80
|
+
- 定稿结果登记为本次需求修订的参考来源后,不再被同一修订的后续定稿替换;更正走需求修订(用户换选或修改需求),见 SKILL 的 K6。
|
|
77
81
|
- 只展示 `items` 和 `unknowns`;`verdict` 原样展示,不重新解释,也不改写。
|
|
78
82
|
- 第三方图片从内容 blob 转存到私有存储,展示时附来源,不进入图鉴或公开分享。
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://git.abelien.cn/abelien/abelienharness/skills/abelien-reference/schemas/reference-judgement.v1.json",
|
|
4
|
+
"title": "abelien.reference-judgement.v1",
|
|
5
|
+
"description": "reference.finalize 的判断部分,即输入去掉 evidence_id(由宿主按本轮收集结果填入)。宿主可以把本文件原样用作工具参数定义;字段与 scripts/finalize.mjs 的 JUDGEMENT_FIELDS 一致,由测试保证。引用、尺寸与比例的核验规则见 references/research-contract.md。",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"identity": {
|
|
10
|
+
"description": "还原(restore)与改现成(modify)必填,新创(create)不得提交。必须显式给出 candidates 与 chosen_item_id;定不下型号时 chosen_item_id 为 null,并在 unidentified_reason 写明原因。",
|
|
11
|
+
"type": "object",
|
|
12
|
+
"additionalProperties": false,
|
|
13
|
+
"required": ["candidates", "chosen_item_id"],
|
|
14
|
+
"properties": {
|
|
15
|
+
"candidates": {
|
|
16
|
+
"description": "收集结果中 kind=candidate 的条目,最多 6 个;没有可用候选时为空列表。",
|
|
17
|
+
"type": "array",
|
|
18
|
+
"maxItems": 6,
|
|
19
|
+
"items": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"required": ["item_id", "name", "quote"],
|
|
23
|
+
"properties": {
|
|
24
|
+
"item_id": { "type": "string", "description": "收集结果中 kind=candidate 的条目 id" },
|
|
25
|
+
"name": { "type": "string", "minLength": 1, "maxLength": 120, "description": "型号名称" },
|
|
26
|
+
"dimensions_mm": {
|
|
27
|
+
"description": "页面给出的外形尺寸(毫米);数字必须出现在 quote 里。页面没有尺寸时省略或为 null,比例记为未核对。",
|
|
28
|
+
"type": ["object", "null"],
|
|
29
|
+
"additionalProperties": false,
|
|
30
|
+
"required": ["width", "height"],
|
|
31
|
+
"properties": {
|
|
32
|
+
"width": { "type": "number", "exclusiveMinimum": 0, "maximum": 5000 },
|
|
33
|
+
"depth": { "type": "number", "exclusiveMinimum": 0, "maximum": 5000 },
|
|
34
|
+
"height": { "type": "number", "exclusiveMinimum": 0, "maximum": 5000 }
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"quote": {
|
|
38
|
+
"description": "识别依据:逐字出现在该页已保存正文中的一段话(空白会归一)。",
|
|
39
|
+
"type": "object",
|
|
40
|
+
"additionalProperties": false,
|
|
41
|
+
"required": ["page_id", "text"],
|
|
42
|
+
"properties": {
|
|
43
|
+
"page_id": { "type": "string", "description": "读取成功的页面 id,如 p1" },
|
|
44
|
+
"text": { "type": "string", "minLength": 1, "maxLength": 500 }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"chosen_item_id": {
|
|
51
|
+
"description": "最可能是用户要的那个候选的 item_id;定不下时为 null。",
|
|
52
|
+
"type": ["string", "null"]
|
|
53
|
+
},
|
|
54
|
+
"unidentified_reason": {
|
|
55
|
+
"description": "chosen_item_id 为 null 时必填:为什么定不下型号(写进 unknowns);选定了型号时不得提交。",
|
|
56
|
+
"type": "string",
|
|
57
|
+
"minLength": 1,
|
|
58
|
+
"maxLength": 300
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"similar": {
|
|
63
|
+
"description": "相似产品的取舍,最多 12 条;只能指向 kind=similar 的条目。",
|
|
64
|
+
"type": "array",
|
|
65
|
+
"maxItems": 12,
|
|
66
|
+
"items": {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"additionalProperties": false,
|
|
69
|
+
"required": ["item_id", "decision", "why"],
|
|
70
|
+
"properties": {
|
|
71
|
+
"item_id": { "type": "string" },
|
|
72
|
+
"decision": { "enum": ["borrow", "skip"] },
|
|
73
|
+
"borrowed": { "type": "string", "minLength": 1, "maxLength": 300, "description": "decision=borrow 时必填:借用了什么" },
|
|
74
|
+
"why": { "type": "string", "minLength": 1, "maxLength": 300 },
|
|
75
|
+
"not_copied": { "type": "string", "minLength": 1, "maxLength": 300, "description": "可选:哪些不照搬" }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"excluded": {
|
|
80
|
+
"description": "排除的条目,最多 12 条;不能排除用户输入(user_input)。",
|
|
81
|
+
"type": "array",
|
|
82
|
+
"maxItems": 12,
|
|
83
|
+
"items": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"additionalProperties": false,
|
|
86
|
+
"required": ["item_id", "reason"],
|
|
87
|
+
"properties": {
|
|
88
|
+
"item_id": { "type": "string" },
|
|
89
|
+
"reason": { "type": "string", "minLength": 1, "maxLength": 300 }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"specs": {
|
|
94
|
+
"description": "关键参数,最多 12 条,每条生成一个 spec 格;quote 规则同识别依据。",
|
|
95
|
+
"type": "array",
|
|
96
|
+
"maxItems": 12,
|
|
97
|
+
"items": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"additionalProperties": false,
|
|
100
|
+
"required": ["title", "value", "quote"],
|
|
101
|
+
"properties": {
|
|
102
|
+
"title": { "type": "string", "minLength": 1, "maxLength": 80 },
|
|
103
|
+
"value": { "type": "string", "minLength": 1, "maxLength": 200 },
|
|
104
|
+
"quote": {
|
|
105
|
+
"type": "object",
|
|
106
|
+
"additionalProperties": false,
|
|
107
|
+
"required": ["page_id", "text"],
|
|
108
|
+
"properties": {
|
|
109
|
+
"page_id": { "type": "string" },
|
|
110
|
+
"text": { "type": "string", "minLength": 1, "maxLength": 500 }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -9,11 +9,18 @@ const normalize = value => value.replace(/\s+/g, " ").trim();
|
|
|
9
9
|
const numbersIn = value => new Set((value.match(/\d+(?:\.\d+)?/g) ?? []).map(Number));
|
|
10
10
|
const percent = value => `${Math.round(value * 1000) / 10}%`;
|
|
11
11
|
|
|
12
|
+
/** Allowed fields of the judgement, level by level; schemas/reference-judgement.v1.json states the same and a test keeps them equal. */
|
|
13
|
+
export const JUDGEMENT_FIELDS = Object.freeze({
|
|
14
|
+
judgement: ["identity", "similar", "excluded", "specs"], identity: ["candidates", "chosen_item_id", "unidentified_reason"],
|
|
15
|
+
candidate: ["item_id", "name", "dimensions_mm", "quote"], dimensions: ["width", "depth", "height"], quote: ["page_id", "text"],
|
|
16
|
+
similar: ["item_id", "decision", "borrowed", "why", "not_copied"], excluded: ["item_id", "reason"], spec: ["title", "value", "quote"],
|
|
17
|
+
});
|
|
18
|
+
|
|
12
19
|
function checkKeys(value, keys, label) { if (!record(value) || Object.keys(value).some(key => !keys.includes(key))) invalid(`${label} has unsupported fields`); }
|
|
13
20
|
function words(value, label, max = 300) { if (!text(value) || value.length > max) invalid(`${label} must be 1–${max} chars`); return value.trim(); }
|
|
14
21
|
|
|
15
22
|
export async function finalizeReference({ input, readResource, readBlob }) {
|
|
16
|
-
checkKeys(input, ["evidence_id",
|
|
23
|
+
checkKeys(input, ["evidence_id", ...JUDGEMENT_FIELDS.judgement], "input");
|
|
17
24
|
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
25
|
if (typeof readResource !== "function" || typeof readBlob !== "function") throw Object.assign(new Error("trusted resource and content readers are required"), { code: "REFERENCE_HOST" });
|
|
19
26
|
if (!/^reference-evidence:ref-[0-9a-f]{16}$/.test(input.evidence_id ?? "")) invalid("evidence_id must name a collected reference-evidence resource");
|
|
@@ -23,7 +30,7 @@ export async function finalizeReference({ input, readResource, readBlob }) {
|
|
|
23
30
|
const pages = new Map(body.pages.map(page => [page.id, page])), items = new Map(body.items.map(item => [item.id, item]));
|
|
24
31
|
const pageText = new Map();
|
|
25
32
|
const quoteOn = (quote, label) => {
|
|
26
|
-
checkKeys(quote,
|
|
33
|
+
checkKeys(quote, JUDGEMENT_FIELDS.quote, label);
|
|
27
34
|
const page = pages.get(quote.page_id);
|
|
28
35
|
if (!page || page.status !== "read" || !page.text_blob_ref) invalid(`${label}.page_id must name a page that was read`);
|
|
29
36
|
if (!pageText.has(page.id)) pageText.set(page.id, normalize(String(readBlob(page.text_blob_ref)?.text ?? "")));
|
|
@@ -38,29 +45,37 @@ export async function finalizeReference({ input, readResource, readBlob }) {
|
|
|
38
45
|
};
|
|
39
46
|
const unknowns = [...body.unknowns];
|
|
40
47
|
|
|
48
|
+
// Restore and modify always state what the product is, explicitly: an empty or missing identity is a probe, not a judgement.
|
|
41
49
|
let identity = null;
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
50
|
+
if (body.intent === "create") {
|
|
51
|
+
if (input.identity !== undefined) invalid("identity is only for restore and modify; create has no product to identify");
|
|
52
|
+
} else {
|
|
53
|
+
if (input.identity === undefined) invalid("identity is required for restore and modify: candidates and chosen_item_id, or chosen_item_id null with unidentified_reason");
|
|
54
|
+
checkKeys(input.identity, JUDGEMENT_FIELDS.identity, "identity");
|
|
55
|
+
if (!Object.hasOwn(input.identity, "candidates") || !Object.hasOwn(input.identity, "chosen_item_id")) invalid("identity must give candidates and chosen_item_id explicitly");
|
|
56
|
+
const list = input.identity.candidates;
|
|
57
|
+
if (!Array.isArray(list) || list.length > 6) invalid("identity.candidates must be up to 6 entries (an empty list when nothing matched)");
|
|
46
58
|
const measurement = body.user_image?.measurement ?? null;
|
|
47
59
|
const candidates = list.map((c, i) => {
|
|
48
60
|
const label = `identity.candidates[${i}]`;
|
|
49
|
-
checkKeys(c,
|
|
61
|
+
checkKeys(c, JUDGEMENT_FIELDS.candidate, label);
|
|
50
62
|
itemOf(c.item_id, ["candidate"], `${label}.item_id`);
|
|
51
63
|
// Dimensions are optional: a page may name the model without giving its size. The quote is always required.
|
|
52
64
|
const dims = c.dimensions_mm === undefined || c.dimensions_mm === null ? null : c.dimensions_mm;
|
|
53
65
|
const quote = quoteOn(c.quote, `${label}.quote`);
|
|
54
66
|
if (dims) {
|
|
55
|
-
checkKeys(dims,
|
|
67
|
+
checkKeys(dims, JUDGEMENT_FIELDS.dimensions, `${label}.dimensions_mm`);
|
|
56
68
|
const keys = ["width", "height", ...(dims.depth === undefined ? [] : ["depth"])], found = numbersIn(quote.text);
|
|
57
69
|
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
70
|
for (const key of keys) if (!found.has(dims[key])) invalid(`${label}.quote must contain the ${key} value ${dims[key]}`);
|
|
59
71
|
}
|
|
60
72
|
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
73
|
});
|
|
62
|
-
const chosen = input.identity.chosen_item_id
|
|
63
|
-
if (chosen
|
|
74
|
+
const chosen = input.identity.chosen_item_id;
|
|
75
|
+
if (chosen !== null && !candidates.some(c => c.item_id === chosen)) invalid("identity.chosen_item_id must name one of the candidates, or be null");
|
|
76
|
+
// Not choosing is a judgement too, so it says why; a chosen product needs no reason.
|
|
77
|
+
if (chosen === null) unknowns.push(`型号未能识别:${words(input.identity.unidentified_reason, "identity.unidentified_reason (required when chosen_item_id is null)")}`);
|
|
78
|
+
else if (input.identity.unidentified_reason !== undefined) invalid("identity.unidentified_reason is only for chosen_item_id null");
|
|
64
79
|
const pick = candidates.find(c => c.item_id === chosen) ?? null;
|
|
65
80
|
let conflict = null;
|
|
66
81
|
if (pick?.ratio_check.status === "mismatch") conflict = `所选型号「${pick.name}」与参考图比例不符(实测 ${pick.ratio_check.measured},规格 ${pick.ratio_check.spec})`;
|
|
@@ -68,8 +83,7 @@ export async function finalizeReference({ input, readResource, readBlob }) {
|
|
|
68
83
|
const others = candidates.filter(c => c !== pick && c.ratio_check.status === "consistent" && c.name !== pick?.name);
|
|
69
84
|
if (others.length && pick?.ratio_check.status !== "consistent") conflict = `另有比例一致的候选:${others.map(c => c.name).join("、")}`;
|
|
70
85
|
}
|
|
71
|
-
if (
|
|
72
|
-
else if (pick.ratio_check.status === "unknown") unknowns.push(`型号比例未核对(${pick.ratio_check.reason})`);
|
|
86
|
+
if (pick?.ratio_check.status === "unknown") unknowns.push(`型号比例未核对(${pick.ratio_check.reason})`);
|
|
73
87
|
for (const c of candidates) {
|
|
74
88
|
const item = items.get(c.item_id), r = c.ratio_check;
|
|
75
89
|
const ratio = r.status === "unknown" ? `比例未核对(${r.reason})` : `实测高宽比 ${r.measured},规格 ${r.spec},相差 ${percent(r.relative_error)}`;
|
|
@@ -78,11 +92,11 @@ export async function finalizeReference({ input, readResource, readBlob }) {
|
|
|
78
92
|
item.title = c.name;
|
|
79
93
|
}
|
|
80
94
|
identity = { candidates, chosen_item_id: chosen, conflict };
|
|
81
|
-
}
|
|
95
|
+
}
|
|
82
96
|
|
|
83
97
|
const similar = (input.similar ?? []).map((s, i) => {
|
|
84
98
|
const label = `similar[${i}]`;
|
|
85
|
-
checkKeys(s,
|
|
99
|
+
checkKeys(s, JUDGEMENT_FIELDS.similar, label);
|
|
86
100
|
const item = itemOf(s.item_id, ["similar"], `${label}.item_id`);
|
|
87
101
|
if (!["borrow", "skip"].includes(s.decision)) invalid(`${label}.decision must be borrow or skip`);
|
|
88
102
|
const why = words(s.why, `${label}.why`);
|
|
@@ -96,14 +110,14 @@ export async function finalizeReference({ input, readResource, readBlob }) {
|
|
|
96
110
|
});
|
|
97
111
|
|
|
98
112
|
for (const [i, e] of (input.excluded ?? []).entries()) {
|
|
99
|
-
checkKeys(e,
|
|
113
|
+
checkKeys(e, JUDGEMENT_FIELDS.excluded, `excluded[${i}]`);
|
|
100
114
|
const item = itemOf(e.item_id, ["candidate", "teardown", "similar"], `excluded[${i}].item_id`);
|
|
101
115
|
item.status = "excluded"; item.verdict = { code: "excluded", basis: words(e.reason, `excluded[${i}].reason`) };
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
if (input.specs !== undefined && (!Array.isArray(input.specs) || input.specs.length > 12)) invalid("specs must be up to 12 entries");
|
|
105
119
|
const specs = (input.specs ?? []).map((s, i) => {
|
|
106
|
-
checkKeys(s,
|
|
120
|
+
checkKeys(s, JUDGEMENT_FIELDS.spec, `specs[${i}]`);
|
|
107
121
|
const quote = quoteOn(s.quote, `specs[${i}].quote`), page = pages.get(quote.page_id);
|
|
108
122
|
return { id: `sp${i + 1}`, kind: "spec", title: words(s.title, `specs[${i}].title`, 80), role: words(s.value, `specs[${i}].value`, 200),
|
|
109
123
|
source: { url: page.final_url ?? page.url, site: items.get(page.id)?.source?.site ?? "" }, image: null, verdict: null, status: "ready", query: null, quote };
|
|
@@ -84,3 +84,31 @@ export function decodePng(bytes) {
|
|
|
84
84
|
}
|
|
85
85
|
return { width, height, rgba };
|
|
86
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pixel size from the header of a PNG, JPEG or WebP, without decoding; null when the header cannot be read.
|
|
90
|
+
* Used to drop page furniture (icons, avatars) before it is saved as a product picture.
|
|
91
|
+
*/
|
|
92
|
+
export function imageSize(bytes) {
|
|
93
|
+
if (!Buffer.isBuffer(bytes)) return null;
|
|
94
|
+
if (isPng(bytes) && bytes.length >= 24) return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
95
|
+
if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
96
|
+
let at = 2;
|
|
97
|
+
while (at + 9 < bytes.length) {
|
|
98
|
+
if (bytes[at] !== 0xff) return null;
|
|
99
|
+
const marker = bytes[at + 1];
|
|
100
|
+
if (marker === 0xff) { at += 1; continue; }
|
|
101
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) { at += 2; continue; }
|
|
102
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return { width: bytes.readUInt16BE(at + 7), height: bytes.readUInt16BE(at + 5) };
|
|
103
|
+
at += 2 + bytes.readUInt16BE(at + 2);
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (bytes.length >= 30 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") {
|
|
108
|
+
const chunk = bytes.toString("ascii", 12, 16);
|
|
109
|
+
if (chunk === "VP8 ") return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
|
|
110
|
+
if (chunk === "VP8L") { const bits = bytes.readUInt32LE(21); return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 }; }
|
|
111
|
+
if (chunk === "VP8X") return { width: 1 + bytes.readUIntLE(24, 3), height: 1 + bytes.readUIntLE(27, 3) };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// 同一请求重复运行直接返回已有结果,不重复产生检索费用。
|
|
4
4
|
import { Buffer } from "node:buffer";
|
|
5
5
|
import { text, record, digest, sha256 } from "../../abelien-harness/scripts/web/common.mjs";
|
|
6
|
-
import { decodeBody } from "../../abelien-harness/scripts/web/public-read.mjs";
|
|
7
|
-
import { isPng } from "./png.mjs";
|
|
6
|
+
import { decodeBody, decodeHtmlEntities } from "../../abelien-harness/scripts/web/public-read.mjs";
|
|
7
|
+
import { isPng, imageSize } from "./png.mjs";
|
|
8
8
|
import { measurePng } from "./measure.mjs";
|
|
9
9
|
import { SCHEMA, INTENTS, DEPTHS, PURPOSES, KIND_BY_PURPOSE, LIMITS, EVIDENCE_SHAPE, reader, imageReader, volc, invalid, stringList, site, validateEvidence } from "./evidence.mjs";
|
|
10
10
|
|
|
@@ -48,15 +48,21 @@ export function validateResearchInput(input) {
|
|
|
48
48
|
|
|
49
49
|
const titleOf = html => {
|
|
50
50
|
const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
|
|
51
|
-
|
|
51
|
+
// Titles are shown as text, so entities are decoded once here (E5 acceptance showed "Hape Glow & Play").
|
|
52
|
+
return match ? decodeHtmlEntities(match[1]).replace(/\s+/g, " ").trim().slice(0, 200) : "";
|
|
52
53
|
};
|
|
54
|
+
// Page furniture is not the product: a CSDN author avatar once became a candidate's picture (P2-13 acceptance, E1).
|
|
55
|
+
export const NOT_PRODUCT_IMAGE = /(?:^|[/_.-])(?:avatar|logo|icon|favicon|sprite|badge|profile|userhead|headimg|portrait|qrcode)(?=$|[/_.\d-])/i;
|
|
56
|
+
/** A main image whose shorter side is below this many pixels is an icon or thumbnail, not a product picture. */
|
|
57
|
+
export const MIN_IMAGE_SIDE = 200;
|
|
53
58
|
function mainImageUrl(html, base) {
|
|
54
|
-
const patterns = [/<meta[^>]+(?:property|name)=["'](?:og:image|og:image:url|twitter:image)["'][^>]*content=["']([^"']+)["']/
|
|
55
|
-
/<meta[^>]+content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|og:image:url|twitter:image)["']/
|
|
56
|
-
/<link[^>]+rel=["']image_src["'][^>]*href=["']([^"']+)["']/
|
|
57
|
-
for (const pattern of patterns) {
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
const patterns = [/<meta[^>]+(?:property|name)=["'](?:og:image|og:image:url|twitter:image)["'][^>]*content=["']([^"']+)["']/gi,
|
|
60
|
+
/<meta[^>]+content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|og:image:url|twitter:image)["']/gi,
|
|
61
|
+
/<link[^>]+rel=["']image_src["'][^>]*href=["']([^"']+)["']/gi];
|
|
62
|
+
for (const pattern of patterns) for (const match of html.matchAll(pattern)) {
|
|
63
|
+
let url;
|
|
64
|
+
try { url = new URL(decodeHtmlEntities(match[1]), base); } catch { continue; }
|
|
65
|
+
if (!NOT_PRODUCT_IMAGE.test(`${url.hostname}${url.pathname}`)) return url.href;
|
|
60
66
|
}
|
|
61
67
|
return null;
|
|
62
68
|
}
|
|
@@ -162,7 +168,9 @@ export async function researchReference({ input, env = {}, fetchImpl, readResour
|
|
|
162
168
|
try {
|
|
163
169
|
const bytes = await imageReader.fetchPublicBytes(page.image_url, { ...readOptions, maxBytes: LIMITS.imageBytes, contentTypes: ["image/png", "image/jpeg", "image/webp"] });
|
|
164
170
|
Object.assign(entryImage, { status: bytes.status, reason: bytes.reason });
|
|
165
|
-
|
|
171
|
+
const size = bytes.status === "read" ? imageSize(Buffer.from(bytes.raw_base64, "base64")) : null;
|
|
172
|
+
if (size && Math.min(size.width, size.height) < MIN_IMAGE_SIDE) Object.assign(entryImage, { status: "unavailable", reason: "too_small" });
|
|
173
|
+
if (entryImage.status === "read") {
|
|
166
174
|
const media = bytes.content_type.split(";")[0].trim();
|
|
167
175
|
entryImage.blob_ref = writeBlob({ media_type: media, base64: bytes.raw_base64 }); entryImage.sha256 = bytes.raw_sha256; entryImage.media_type = media;
|
|
168
176
|
if (media === "image/png") entryImage.measurement = measurePng(Buffer.from(bytes.raw_base64, "base64"));
|