abelien-harness 0.6.0 → 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/README.md +1 -1
- package/package.json +1 -1
- package/skills/abelien-reference/SKILL.md +10 -2
- package/skills/abelien-reference/domain.json +1 -1
- package/skills/abelien-reference/fixtures/create.final.json +3 -1
- package/skills/abelien-reference/fixtures/restore.collected.json +3 -1
- package/skills/abelien-reference/fixtures/restore.final.json +3 -1
- package/skills/abelien-reference/references/research-contract.md +11 -3
- package/skills/abelien-reference/schemas/reference-evidence.v1.json +14 -1
- package/skills/abelien-reference/schemas/reference-judgement.v1.json +117 -0
- package/skills/abelien-reference/scripts/evidence.mjs +8 -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 +39 -19
package/README.md
CHANGED
|
@@ -243,7 +243,7 @@ skill-lint 双向守卫核对声明与实际 import 一致,打包校验再确
|
|
|
243
243
|
|
|
244
244
|
| 层 | 篇目 | 一句话 |
|
|
245
245
|
| --- | --- | --- |
|
|
246
|
-
| architecture | [state-machine](docs/architecture/state-machine.md) / [workflow-persistence](docs/architecture/workflow-persistence.md) / [httl](docs/architecture/httl.md) / [host-adapters](docs/architecture/host-adapters.md) / [generate3d](docs/architecture/generate3d.md) / [structure](docs/architecture/structure.md) / [structure-worker](docs/architecture/structure-worker.md) / [question-card](docs/architecture/question-card.md) / [visualization-rsi](docs/architecture/visualization-rsi.md) | 双层 FSM · 定义即数据/实例即文件 · HTTL 协议 · claude-code/codex 双入口收敛 · 声明式 provider + 异步任务引擎 · 外观/结构协作边界 · 问题卡与 ACL 前端协议(contract-ready) · `ab --visual` 可视化与自进化收口 |
|
|
246
|
+
| architecture | [state-machine](docs/architecture/state-machine.md) / [workflow-persistence](docs/architecture/workflow-persistence.md) / [httl](docs/architecture/httl.md) / [host-adapters](docs/architecture/host-adapters.md) / [generate3d](docs/architecture/generate3d.md) / [structure](docs/architecture/structure.md) / [structure-worker](docs/architecture/structure-worker.md) / [structure-worker-protocol](docs/architecture/structure-worker-protocol.md) / [structure-cad-library](docs/architecture/structure-cad-library.md) / [structure-component-fit](docs/architecture/structure-component-fit.md) / [structure-worker-implementation](docs/architecture/structure-worker-implementation.md) / [question-card](docs/architecture/question-card.md) / [visualization-rsi](docs/architecture/visualization-rsi.md) | 双层 FSM · 定义即数据/实例即文件 · HTTL 协议 · claude-code/codex 双入口收敛 · 声明式 provider + 异步任务引擎 · 外观/结构协作边界 · 问题卡与 ACL 前端协议(contract-ready) · `ab --visual` 可视化与自进化收口 |
|
|
247
247
|
| paradigms | [README](docs/paradigms/README.md) + development / testing / evaluation / self-optimization | 场景工件是唯一事实源:测试测零件、评估测整车、优化答归因 |
|
|
248
248
|
| reference | [product-journey](docs/reference/product-journey.md) / [research-notes](docs/reference/research-notes.md) / [verification-method](docs/reference/verification-method.md) | PRD 旅程转译 · 基准研究与 EaaS 方法论 · 验证方法→场景判定器(每篇带 来源/取舍) |
|
|
249
249
|
| mvp | [journey](docs/mvp/journey.md) / [coverage](docs/mvp/coverage.md) | 历史 MVP(已移除的 25 步及真实实录)· 四维覆盖矩阵 + 缺口账本 |
|
package/package.json
CHANGED
|
@@ -23,13 +23,21 @@ 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 自填。
|
|
30
30
|
|
|
31
31
|
所选候选比例不符时,结果会写明冲突,不会被隐藏。
|
|
32
|
-
5. **交回**:final 结果的 `items` 就是画布网格的格子,`unknowns`
|
|
32
|
+
5. **交回**:final 结果的 `items` 就是画布网格的格子,`unknowns` 是需要如实展示的缺口,`budget` 说明检索有没有因上限停下。宿主展示的是证据,不是作品已确认。
|
|
33
|
+
|
|
34
|
+
## 用户换选与排除(K6)
|
|
35
|
+
|
|
36
|
+
首版之前不向用户提问;用户可以随时在画布或对话里改变参考。处理方式都是对**同一份收集结果**重新提交判断,不重新检索:
|
|
37
|
+
|
|
38
|
+
- **换成某个候选**(画布「换成这款/就是这款」,或原话点名「参考产品换成 X」):沿用原 `evidence_id`,把 `identity.chosen_item_id` 改成用户点名的候选,重新调用 `reference.finalize`。比例冲突照常由核验算出并写进 `identity.conflict`,如实展示,不再追问。
|
|
39
|
+
- **不要参考某一条**(「不要参考这个」):重新提交判断,把该条目放进 `excluded`,`reason` 写明是用户不采用。被排除的是所选产品时,改选其余候选中比例一致的一个;没有合适的就把 `chosen_item_id` 设为 `null`,并在 `unidentified_reason` 写明「用户排除了所选型号,其余候选不合适」。
|
|
40
|
+
- 这两种情况都属于需求修订:clarify 把用户的选择记成 source=user 的需求,后续阶段按 workspace 规则推进,本包只负责重新提交判断。宿主把定稿登记为当前修订的参考来源,同一修订内不再替换,所以先修订需求、再重新提交判断;Agent 自己发现更好的证据时,也不能在同一修订里覆盖已登记的那一份,要如实告诉用户,由用户决定是否换选。
|
|
33
41
|
|
|
34
42
|
## 调用
|
|
35
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
|
|
|
@@ -31,14 +31,20 @@
|
|
|
31
31
|
|
|
32
32
|
输出 `reference-evidence`,status=collected。`items` 中第一项是 `user_input`,其后每个页面对应一格:识别用途为 `candidate`,逆向用途为 `teardown`,相似用途为 `similar`。读不到的页面状态为 `unavailable`。此时还没有任何 `verdict`。
|
|
33
33
|
|
|
34
|
+
`budget` 记录本次用量和截断:`queries`、`pages`、`images` 是已检索条数、已读页面数、已抓主图数;`skipped_pages` 是检索到、但因 `max_pages` 没有读取的去重公开页面数,`skipped_images` 是有主图、但因 `max_images` 没有抓取的页面数。宿主据此说明「已达本阶段检索上限」,不能把没读到当作没有。final 结果原样带上收集时的 `budget`。
|
|
35
|
+
|
|
36
|
+
请求摘要包含证据格式版本(`EVIDENCE_SHAPE`,当前为 2)。旧版本 harness 收集的证据保留原 id,不会被复用,也不会被覆盖;同一请求按新格式重新收集,得到新 id。
|
|
37
|
+
|
|
34
38
|
## reference.finalize
|
|
35
39
|
|
|
36
40
|
### 输入
|
|
37
41
|
|
|
42
|
+
除 `evidence_id` 外的字段(判断部分)由 `schemas/reference-judgement.v1.json` 定义;该文件自包含、每层都不接受多余字段,宿主可以原样用作工具参数定义。
|
|
43
|
+
|
|
38
44
|
| 字段 | 规则 |
|
|
39
45
|
| --- | --- |
|
|
40
46
|
| `evidence_id` | 必须指向一份 status=collected 的 `reference-evidence:ref-…` |
|
|
41
|
-
| `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`,此时比例记为未核对 |
|
|
42
48
|
| `similar` | 可选,最多 12 条,每条为 `{item_id, decision: borrow / skip, borrowed?, why, not_copied?}`;`borrow` 时必须写明 `borrowed` |
|
|
43
49
|
| `excluded` | 可选,最多 12 条,每条为 `{item_id, reason}` |
|
|
44
50
|
| `specs` | 可选,最多 12 条,每条为 `{title, value, quote}`,生成 `spec` 格 |
|
|
@@ -53,7 +59,7 @@
|
|
|
53
59
|
|
|
54
60
|
- 正面视角下,比较高宽比:规格比例为 `height / width`,与用户原图的实测值比较,相对误差不超过 10% 记为 `consistent`,否则记为 `mismatch`;没有测量或缺尺寸时记为 `unknown`。
|
|
55
61
|
- 所选候选:比例一致或无法核对时,判定为 `closest`,依据中写明比例情况;比例不符时,判定为 `ratio_mismatch`,并在 `identity.conflict` 中写明。
|
|
56
|
-
- 其他候选:比例不符时判定为 `ratio_mismatch
|
|
62
|
+
- 其他候选:比例不符时判定为 `ratio_mismatch`,其余不加判定,仍保留为候选,供用户切换。没有选定型号、却有比例一致的候选时,也写进 `identity.conflict`。
|
|
57
63
|
- 相似产品:`borrow` 判定为 `borrowed`,依据中写明借用了什么、原因、哪些不照搬;`skip` 判定为 `not_copied`。
|
|
58
64
|
- 排除项:状态改为 `excluded`,判定为 `excluded`。
|
|
59
65
|
- 同一份收集结果可以多次提交不同判断,每次生成一份新的 final 结果(id 带判断摘要),收集结果本身不变。
|
|
@@ -70,5 +76,7 @@
|
|
|
70
76
|
## 宿主责任
|
|
71
77
|
|
|
72
78
|
- 把上传的图片转成 PNG,写入作品目录中的输入文件,再用 `--input-file` 传入。
|
|
79
|
+
- 定稿工具的参数定义直接用 `schemas/reference-judgement.v1.json`,`evidence_id` 由宿主填入,不让模型猜格式。
|
|
80
|
+
- 定稿结果登记为本次需求修订的参考来源后,不再被同一修订的后续定稿替换;更正走需求修订(用户换选或修改需求),见 SKILL 的 K6。
|
|
73
81
|
- 只展示 `items` 和 `unknowns`;`verdict` 原样展示,不重新解释,也不改写。
|
|
74
82
|
- 第三方图片从内容 blob 转存到私有存储,展示时附来源,不进入图鉴或公开分享。
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"title": "abelien.reference-evidence.v1",
|
|
5
5
|
"description": "参考研究证据。status=collected 由 reference.research 产出,只有收集结果;status=final 由 reference.finalize 产出,带核验过的识别、相似产品与判定。宿主只展示与转存,不自行改写判定。",
|
|
6
6
|
"type": "object",
|
|
7
|
-
"required": ["id", "schema", "status", "intent", "keep", "changeable", "depth", "text", "user_image", "searches", "pages", "images", "items", "unknowns"],
|
|
7
|
+
"required": ["id", "schema", "status", "intent", "keep", "changeable", "depth", "text", "user_image", "searches", "pages", "images", "items", "unknowns", "budget"],
|
|
8
8
|
"properties": {
|
|
9
9
|
"id": { "type": "string", "pattern": "^ref-[0-9a-f]{16}(-f[0-9a-f]{8})?$" },
|
|
10
10
|
"schema": { "const": "abelien.reference-evidence.v1" },
|
|
@@ -35,6 +35,19 @@
|
|
|
35
35
|
"images": { "type": "array", "items": { "$ref": "#/$defs/image" } },
|
|
36
36
|
"items": { "type": "array", "items": { "$ref": "#/$defs/item" } },
|
|
37
37
|
"unknowns": { "type": "array", "items": { "type": "string" } },
|
|
38
|
+
"budget": {
|
|
39
|
+
"description": "本次研究的用量:已检索条数、已读页面数、已抓主图数,以及因计划上限没有读取的去重公开页面数与主图数;宿主据此说明「已达检索上限」,不把没读到当作没有",
|
|
40
|
+
"type": "object",
|
|
41
|
+
"required": ["queries", "pages", "images", "skipped_pages", "skipped_images"],
|
|
42
|
+
"properties": {
|
|
43
|
+
"queries": { "type": "integer", "minimum": 0 },
|
|
44
|
+
"pages": { "type": "integer", "minimum": 0 },
|
|
45
|
+
"images": { "type": "integer", "minimum": 0 },
|
|
46
|
+
"skipped_pages": { "type": "integer", "minimum": 0 },
|
|
47
|
+
"skipped_images": { "type": "integer", "minimum": 0 }
|
|
48
|
+
},
|
|
49
|
+
"additionalProperties": false
|
|
50
|
+
},
|
|
38
51
|
"collected_id": { "type": "string", "description": "status=final 时为来源收集结果的 id" },
|
|
39
52
|
"judgement_digest": { "type": "string" },
|
|
40
53
|
"identity": {
|
|
@@ -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
|
+
}
|
|
@@ -15,6 +15,11 @@ export const VERDICT_CODES = Object.freeze(["closest", "ratio_mismatch", "exclud
|
|
|
15
15
|
export const EVIDENCE_STATUSES = Object.freeze(["collected", "final"]);
|
|
16
16
|
export const KIND_BY_PURPOSE = Object.freeze({ identify: "candidate", teardown: "teardown", similar: "similar" });
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Version of the collected shape, part of every request digest: evidence collected by an older harness (e.g. without
|
|
20
|
+
* budget.skipped_*) keeps its own id and is never reused or overwritten; the same request collects afresh under a new id.
|
|
21
|
+
*/
|
|
22
|
+
export const EVIDENCE_SHAPE = 2;
|
|
18
23
|
export const LIMITS = Object.freeze({ queries: 6, count: 5, pages: 12, images: 8, links: 5, textChars: 4000, queryChars: 200,
|
|
19
24
|
pageBytes: 8 * 1024 * 1024, imageBytes: 4 * 1024 * 1024, userImageBytes: 16 * 1024 * 1024, excerptChars: 1200, pageTimeoutMs: 30000, searchTimeoutMs: 30000, concurrency: 4 });
|
|
20
25
|
|
|
@@ -49,6 +54,9 @@ export function validateEvidence(body) {
|
|
|
49
54
|
if (!INTENTS.includes(body.intent) || (body.depth !== null && !DEPTHS.includes(body.depth))) invalid("intent or depth is invalid");
|
|
50
55
|
stringList(body.keep, "keep"); stringList(body.changeable, "changeable"); stringList(body.unknowns, "unknowns", 200);
|
|
51
56
|
for (const name of ["searches", "pages", "images", "items"]) if (!Array.isArray(body[name])) invalid(`${name} must be an array`);
|
|
57
|
+
const budget = body.budget, counts = ["queries", "pages", "images", "skipped_pages", "skipped_images"];
|
|
58
|
+
if (!record(budget) || Object.keys(budget).length !== counts.length
|
|
59
|
+
|| counts.some(key => !Number.isSafeInteger(budget[key]) || budget[key] < 0)) invalid(`budget must count ${counts.join(", ")}`);
|
|
52
60
|
body.items.forEach(checkItem);
|
|
53
61
|
const ids = body.items.map(item => item.id); if (new Set(ids).size !== ids.length) invalid("item ids must be unique");
|
|
54
62
|
if (body.status === "final") {
|
|
@@ -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,10 +3,10 @@
|
|
|
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
|
-
import { SCHEMA, INTENTS, DEPTHS, PURPOSES, KIND_BY_PURPOSE, LIMITS, reader, imageReader, volc, invalid, stringList, site, validateEvidence } from "./evidence.mjs";
|
|
9
|
+
import { SCHEMA, INTENTS, DEPTHS, PURPOSES, KIND_BY_PURPOSE, LIMITS, EVIDENCE_SHAPE, reader, imageReader, volc, invalid, stringList, site, validateEvidence } from "./evidence.mjs";
|
|
10
10
|
|
|
11
11
|
function normalizeLink(value) {
|
|
12
12
|
try { return reader.normalizePublicSourceUrl(value).href; } catch { invalid("links must be public HTTPS URLs without credentials"); }
|
|
@@ -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
|
}
|
|
@@ -70,12 +76,20 @@ async function pool(list, task, limit = LIMITS.concurrency) {
|
|
|
70
76
|
return out;
|
|
71
77
|
}
|
|
72
78
|
|
|
73
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Interleave search results so one query cannot use the whole page budget. Also counts the distinct public pages the
|
|
81
|
+
* cap left unread, so the host can say the research stopped at its limit instead of implying nothing else was found.
|
|
82
|
+
*/
|
|
74
83
|
function pageQueue(links, intent, searches, max) {
|
|
75
84
|
const queue = [], seen = new Set();
|
|
76
|
-
|
|
85
|
+
let skipped = 0;
|
|
86
|
+
const push = entry => {
|
|
87
|
+
if (seen.has(entry.url)) return;
|
|
88
|
+
seen.add(entry.url);
|
|
89
|
+
if (queue.length < max) queue.push(entry); else skipped++;
|
|
90
|
+
};
|
|
77
91
|
for (const url of links) push({ url, purpose: intent === "create" ? "similar" : "identify", search_id: null, query: null, search_title: null });
|
|
78
|
-
for (let rank = 0;
|
|
92
|
+
for (let rank = 0; ; rank++) {
|
|
79
93
|
let added = false;
|
|
80
94
|
for (const search of searches) {
|
|
81
95
|
const result = search.results[rank];
|
|
@@ -85,16 +99,19 @@ function pageQueue(links, intent, searches, max) {
|
|
|
85
99
|
}
|
|
86
100
|
if (!added) break;
|
|
87
101
|
}
|
|
88
|
-
return queue;
|
|
102
|
+
return Object.assign(queue, { skipped });
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
export async function researchReference({ input, env = {}, fetchImpl, readResource, writeBlob, ports = {} }) {
|
|
92
106
|
const request = validateResearchInput(input);
|
|
93
107
|
if (typeof writeBlob !== "function" || typeof readResource !== "function") throw Object.assign(new Error("trusted content writer and resource reader are required"), { code: "REFERENCE_HOST" });
|
|
94
|
-
const requestDigest = digest({ ...request, userImage: request.userImage?.sha256 ?? null });
|
|
108
|
+
const requestDigest = digest({ ...request, userImage: request.userImage?.sha256 ?? null, shape: EVIDENCE_SHAPE });
|
|
95
109
|
const id = `ref-${requestDigest.slice(0, 16)}`;
|
|
96
110
|
const existing = await readResource(`reference-evidence:${id}`);
|
|
97
|
-
if (existing?.body?.status === "collected" && existing.body.request_digest === requestDigest)
|
|
111
|
+
if (existing?.body?.status === "collected" && existing.body.request_digest === requestDigest) {
|
|
112
|
+
// Never reuse a body that fails today's contract; the kernel then refuses to overwrite it, so the conflict surfaces.
|
|
113
|
+
try { return validateEvidence(existing.body); } catch { /* fall through: the checkpoint reports the conflict */ }
|
|
114
|
+
}
|
|
98
115
|
|
|
99
116
|
const unknowns = [], items = [];
|
|
100
117
|
let userImage = null;
|
|
@@ -145,13 +162,15 @@ export async function researchReference({ input, env = {}, fetchImpl, readResour
|
|
|
145
162
|
});
|
|
146
163
|
for (const page of pages) if (page.status !== "read") unknowns.push(`页面不可用:${page.url}(${page.reason})`);
|
|
147
164
|
// Phase 2: main images for the first pages that have one, up to the image budget, fetched concurrently.
|
|
148
|
-
const
|
|
165
|
+
const withImage = pages.filter(page => page.image_url), wanted = withImage.slice(0, request.plan.max_images);
|
|
149
166
|
const images = await pool(wanted, async (page, index) => {
|
|
150
167
|
const entryImage = { id: `i${index + 1}`, url: page.image_url, page_id: page.id, status: "unavailable", reason: null, blob_ref: null, sha256: null, media_type: null, measurement: null };
|
|
151
168
|
try {
|
|
152
169
|
const bytes = await imageReader.fetchPublicBytes(page.image_url, { ...readOptions, maxBytes: LIMITS.imageBytes, contentTypes: ["image/png", "image/jpeg", "image/webp"] });
|
|
153
170
|
Object.assign(entryImage, { status: bytes.status, reason: bytes.reason });
|
|
154
|
-
|
|
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") {
|
|
155
174
|
const media = bytes.content_type.split(";")[0].trim();
|
|
156
175
|
entryImage.blob_ref = writeBlob({ media_type: media, base64: bytes.raw_base64 }); entryImage.sha256 = bytes.raw_sha256; entryImage.media_type = media;
|
|
157
176
|
if (media === "image/png") entryImage.measurement = measurePng(Buffer.from(bytes.raw_base64, "base64"));
|
|
@@ -174,6 +193,7 @@ export async function researchReference({ input, env = {}, fetchImpl, readResour
|
|
|
174
193
|
return validateEvidence({
|
|
175
194
|
id, schema: SCHEMA, status: "collected", request_digest: requestDigest, intent: request.intent, keep: request.keep, changeable: request.changeable,
|
|
176
195
|
depth: request.depth, text: request.text, user_image: userImage, searches, pages, images, items, unknowns,
|
|
177
|
-
budget: { queries: searches.length, pages: pages.length, images: images.length
|
|
196
|
+
budget: { queries: searches.length, pages: pages.length, images: images.length,
|
|
197
|
+
skipped_pages: queue.skipped, skipped_images: withImage.length - wanted.length },
|
|
178
198
|
});
|
|
179
199
|
}
|