abelien-harness 0.14.0 → 0.15.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 (27) hide show
  1. package/.env.example +9 -0
  2. package/package.json +1 -1
  3. package/skills/abelien-hardware/SKILL.md +1 -0
  4. package/skills/abelien-hardware/assets/pdf-drawing-prompt.txt +1 -0
  5. package/skills/abelien-hardware/contract/online-supplement-host.md +2 -0
  6. package/skills/abelien-hardware/contract/web-evidence-contract.md +4 -0
  7. package/skills/abelien-hardware/domain.json +1 -1
  8. package/skills/abelien-hardware/scripts/admission.mjs +4 -2
  9. package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +20 -7
  10. package/skills/abelien-hardware/scripts/web-evidence-storage.mjs +6 -2
  11. package/skills/abelien-hardware/scripts/web-integrity.mjs +3 -1
  12. package/skills/abelien-hardware/scripts/web-merge.mjs +32 -3
  13. package/skills/abelien-hardware/scripts/web-pdf.mjs +135 -0
  14. package/skills/abelien-hardware/scripts/web-provider-chain.mjs +55 -6
  15. package/skills/abelien-hardware/workflows/definitions/136ef3d91d059f43e80a91338748f9d26a65be0cae6fc1ac553177f25af1faa5.json +176 -0
  16. package/skills/abelien-hardware/workflows/definitions/857a5b729a5164c4727d281d64b66941281173ebdde975a20f19a2a2acfbff3a.json +150 -0
  17. package/skills/abelien-hardware/workflows/definitions/b63cb14e876b7d274da3ecefa7e3b056a113ace7d7fb97fcd2c74b749673b6e3.json +218 -0
  18. package/skills/abelien-hardware/workflows/user-journey-hardware-candidates.json +1 -1
  19. package/skills/abelien-hardware/workflows/user-journey-hardware-replace.json +1 -1
  20. package/skills/abelien-hardware/workflows/user-journey-hardware.json +1 -1
  21. package/skills/abelien-harness/contract/source-registry.md +5 -2
  22. package/skills/abelien-harness/contract/workflow-spec.md +3 -0
  23. package/skills/abelien-harness/scripts/ab.mjs +1 -1
  24. package/skills/abelien-harness/scripts/receipt.mjs +5 -1
  25. package/skills/abelien-harness/scripts/studio.mjs +1 -1
  26. package/skills/abelien-harness/scripts/web/pdf-reader.mjs +105 -0
  27. package/skills/abelien-harness/scripts/web/vision.mjs +85 -0
package/.env.example CHANGED
@@ -41,6 +41,15 @@ ABELIEN_SOURCE_REGISTRY_TIMEOUT_MS=30000
41
41
  # ... or an absolute local directory for tests, offline runs and a single local host.
42
42
  ABELIEN_SOURCE_REGISTRY_DIR=
43
43
 
44
+ # PDF reading for the web fallback (registry mode): the host installs mupdf.js separately (npm "mupdf") and points
45
+ # at its dist/mupdf.js; drawing pages are read by an Ark vision endpoint (deepseek-v4-1-flash). Leave empty to
46
+ # still fetch and register PDFs, with unreadable parts recorded as unavailable.
47
+ ABELIEN_PDF_ENGINE_MODULE=
48
+ ABELIEN_SOURCE_VISION_ENDPOINT_ID=
49
+ ABELIEN_SOURCE_VISION_API_KEY=
50
+ ABELIEN_SOURCE_VISION_TIMEOUT_MS=60000
51
+ ABELIEN_SOURCE_VISION_CONCURRENCY=4
52
+
44
53
  # Optional loopback HTTP wrapper only; main hardware.select executes locally without a URL.
45
54
  # At least 32 bytes, keep the real token in a private env file. Do not expose this server publicly.
46
55
  ABELIEN_HARDWARE_SELECT_TOKEN=
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abelien-harness",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "npx-installable multi-agent skill harness: generic kernel + pluggable domain packs",
5
5
  "type": "module",
6
6
  "engines": {
@@ -52,6 +52,7 @@ allowed-tools:
52
52
  - 库内召回之后先审查适用性。零候选或候选全部不合适的槽位,用 `hardware.web-search` 做英文检索(每条最多 5 个结果)并直读来源页面,再由 `hardware.web-merge` 合并;合适的槽位不联网、不出现在待补位清单里,不写空查询或数量为 0 的占位,全部合适时回复空清单。
53
53
  - 保留全部用户要求和数量;目录里没有,不是放弃某项要求(比如可充电电池、无线充电)的理由。
54
54
  - 每个物理件单独抽取;缺尺寸、来源权威和工程验证都保持未知;`envelope_only` 不是真组件 CAD。
55
+ - 厂家 PDF 会被直接读取:文本层逐字成为可引用原文;图纸页由视觉模型读出尺寸,列在证据的读图结果里。读图结果只是估算,不是 datasheet,也不能当引文;用它给尺寸时逐轴指明读图条目,挑整体外形,不要拿显示区、PCB、孔距或接插件尺寸充当外形。
55
56
  - 合并后的召回由同一个 `hardware.select` 消费;外部件结果保持待确认,不能成为已验证、已采用或已交付。
56
57
  - 检索计划和候选抽取的回复格式以各暂停点的指令为准;宿主会在提交前预检检索计划,不合格时留在同一暂停点修正。
57
58
  - 读过的厂家资料都登记在一张跨产品共用的厂家资料登记表里:同一条检索、同一个网址不会再发一次,没选中的候选也登记。已经知道某个槽位要的确切零件时,在该槽位的待补位项里写 `models`(厂商与含全部后缀、版本的完整型号):已登记过资料的型号直接用登记的页面,不再检索。
@@ -0,0 +1 @@
1
+ 这是一份电子模组的资料 PDF。列出其中每一个尺寸标注(只抄图纸或表格上真实出现的数字,不要推算、不要补全),输出 JSON 数组,每项 {"page":页码,"what":"它标注的对象,区分整体外形/触摸面板外形/PCB/显示可视区AA/背光BL/安装孔距/孔径或螺纹/厚度/引脚间距/其他","value":数值,"tolerance":公差或null,"unit":"mm"}。只输出 JSON。
@@ -14,4 +14,6 @@ The merged recall is consumed by the same formal `hardware.select`. Select rerea
14
14
 
15
15
  厂家资料登记表:Prismer 关闭(默认)时新证据要求宿主配置登记表(`ABELIEN_SOURCE_REGISTRY_URL`+`ABELIEN_SOURCE_REGISTRY_TOKEN`,或单机的 `ABELIEN_SOURCE_REGISTRY_DIR`),并把这些变量加入调用 `ab` 的环境透传;`hardware.web-merge`、`hardware.select` 与恢复同样需要它来读回证据。宿主端点协议、存储建议、显式刷新与回流导出见内核的[登记表契约](../../abelien-harness/contract/source-registry.md)。
16
16
 
17
+ 读 PDF:宿主安装 mupdf.js(npm `mupdf`,与 harness 分开安装),把 `ABELIEN_PDF_ENGINE_MODULE` 设为其 `dist/mupdf.js` 的绝对路径;配置方舟读图接入点 `ABELIEN_SOURCE_VISION_ENDPOINT_ID`(当前 `ep-20261005152951-fwm66`,deepseek-v4-1-flash)与 `ABELIEN_SOURCE_VISION_API_KEY`,可选 `ABELIEN_SOURCE_VISION_TIMEOUT_MS`(默认 60000)、`ABELIEN_SOURCE_VISION_CONCURRENCY`(默认 4,单个进程内的总并发;多个进程并行时宿主自己再限)。这些变量同样要加入调用 `ab` 的环境透传。缺引擎或缺接入点时 PDF 仍抓取、登记,只是读不出的页记为不可用。
18
+
17
19
  新运行显式开启 `AB_HARNESS_AUDIT_V1=true` 后,正式选型的 needs_confirmation 可进入 selected_pending_confirmation 并结束本次选型,仍待用户确认、不执行算法。普通关闭开关的运行保留原 verified-only 门禁;安装前无摘要/新格式标记的硬件运行保持只读,不能补造摘要续跑。网页页引用通过 `resource get <evidenceId> --page <pageId>` 读取所需完整原文,再做语义判断,不能把整页抄进回复。接口与迁移见 [选型服务契约](../references/selection-service-contract.md)。
@@ -59,4 +59,8 @@ Before advancing review-hardware-candidates, the trusted Web host rereads the or
59
59
  - **检索**:Volc 尝试发送前查 `query` 记录(键:服务商、NFKC 小写折叠空白的查询词、请求的其余字段)。命中时尝试复制登记的回执,`dispatch_state: "registry"`、`registry: {record_id, hit:true}`;未命中时登记 `sent` 后发送,收到回执登记 `completed`,`registry: {record_id, hit:false}`。`record_id` 由请求确定性重算并校验。明确拒绝登记为 `failed` 历史;超时、5xx、结果不明保持 `sent`,所有产品都不重发同一检索(`SOURCE_REGISTRY_UNKNOWN`),对账后显式刷新。登记写入失败不把已收到的回执变成未知,记 `SOURCE_REGISTRY_WRITE_FAILED`。
60
60
  - **直读**:发 GET 前按规范化 URL 查 `source`;命中且不是被截断的版本时按登记字节重建回执,`dispatch_state: "registry"`、`registry: {record_id, version, hit:true}`,零网络。未命中时 GET,脱敏后的安全字节与抓取事实(最终 URL、抓取时间、状态码、类型、重定向、连接地址、原始字节摘要)登记为新版本,再写回执。回执的 `response` 不含 `raw_base64` 与 `text`,只有 `raw_sha256`(登记字节)、`text_sha256` 等摘要;保存的页 `text` 为 `null`。普通读取失败(`public_source_read_failed`)不登记。GET 成功而登记失败时,尝试不落盘、页面保持可续跑。
61
61
  - **读回与校验**:恢复、合并、正式选型重建和 `resource get --page` 都先按 sha256 从登记表读回字节与正文(`hydrateRegistryEvidence`),共享校验器再按原规则核对(字节摘要、`pageText` 派生正文、重定向与连接链、`record_id` 可重算)。读不到或摘要不符是 `SOURCE_REGISTRY_INTEGRITY`,不改用网络。
62
+ - **读 PDF**:新证据在第一次联网时与 `source_registry` 一起固定 `pdf_reading: "pdf-text-vision-v1"`;0.14.0 保存的 registry 证据没有它,PDF 来源仍记 `parse_unavailable`、按原样续跑。带固定的证据里 `.pdf` 来源(以及任何返回 PDF 字节的来源)直接 GET,不再记 `parse_unavailable`;图片后缀来源仍无直读。拿到 PDF 后用受信宿主的 PDF 引擎(`ABELIEN_PDF_ENGINE_MODULE`,mupdf.js 的 `dist/mupdf.js`)逐页抽文本层,按 `text_layer` 键(原始文件 sha256+引擎版本)登记每页原文与 sha256;回执的 `response.document = {format:"pdf", engine, page_count, text_layer_record_id, pages:[{page, chars, text_sha256, drawing}]}`。可引用原文是有文本的页按页序以空行连接;整份没有文本时页 `status: "unavailable"`、`reason: "no_text_layer"`;没有引擎或打不开时 `document: null`、`reason: "pdf_engine_unavailable"`/`"pdf_unreadable"`(文件照样登记)。引文规则不变。
63
+ - **图纸页**:没有文本层(少于 32 个非空白字符),或文本少于 1500 字符且带尺寸图线索(dimension、outline、mechanical、drawing、Unit: mm、尺寸、外形、机械、结构图)的页记 `drawing: true`,每份文档按页序最多 8 页。每页图纸本地渲染成长边 1280 px 的 RGB PNG(不裁空白、不放大,按 `render` 键登记),交给受信宿主配置的方舟接入点(`ABELIEN_SOURCE_VISION_ENDPOINT_ID`、`ABELIEN_SOURCE_VISION_API_KEY`;Responses API,`thinking: disabled`、`temperature: 0`,提示词 `assets/pdf-drawing-prompt.txt` 原样,版本 `pdf-drawing-v1:<sha256 前 12 位>`)。读图键是 PNG sha256+接入点+提示词版本+参数,命中即零模型调用。总并发由 `ABELIEN_SOURCE_VISION_CONCURRENCY`(默认 4)限制。
64
+ - **读图证据**:证据顶层新增 `transcriptions[]`,每条 `{id:"vision-<digest(page_id,pdf_page)>", kind:"vision_transcription", page_id, pdf_page, source_sha256, render_params, png_sha256, png, model:{endpoint_id}, prompt_version, params, registry:{record_id, hit}, status, reason, error_code, receipt_sha256, items, basis:"vision_estimate"}`。调用前先以 `in_progress` 落盘;完成为 `completed`(`items` 由登记的脱敏回执重算核对),明确拒绝为 `failed`(登记为历史,别的产品可再试),超时、传输、5xx 为 `unknown`(登记表保持 `sent`,所有产品都不重发,对账后刷新),没有引擎或没配置接入点为 `unavailable`。续跑看到 `in_progress`:登记表已有完成结果就采用,否则记 `unknown`,不重发。完成的证据里每页图纸都必须有一条结束的转写。
65
+ - **读图尺寸只能是估算**:`hardware.web-merge` 的物理件可用 `vision_dimensions: {transcription_id, items:{<axis>: <item index>}}` 代替引文:转写必须完成且属于同一槽位,每轴的条目单位为 mm、数值与 `dimensions_mm` 相等;与 `dimension_quotes`/`dimensions_quote` 互斥;box 可以没有形状引文(只代表尺寸包络)。结果件 `dimension_basis: "vision_estimate"`、`status: "vision_estimate"`,并记 `VISION_DIMENSIONS_ESTIMATE`;入库时与引文尺寸一样只得 `estimate` + `bounding_box`,绝不写成 datasheet。
62
66
  - **合并登记型号**:`hardware.web-merge` 把每个抽取的候选(不论之后是否选中)以 `role: "web_candidate"` 登记到 `model`;候选 `wiki.source_registry = {record_id, version, sha256}`。`hardware.select` 入库成功后追加 `role: "admitted_web"`、库内 `component_id` 与 `urn`,供索引构建回流(见登记表契约「回流库内召回」)。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abelien-hardware",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "统一硬件域包:三级分类规则快照、OSS Wiki 召回与候选提案、英文 Volc 搜索加来源直读的联网补位(Prismer 默认关闭)、本地 hardware.select 同源校验(可选 loopback HTTP,无 mock)、需求变化后的替换/沿用、基于正式选型的局部修订",
5
5
  "resources": [
6
6
  "hardware-recall",
@@ -20,7 +20,8 @@ const UNKNOWN_SIZE = Object.freeze({ dimensions_mm: null, dimension_basis: "unkn
20
20
  // A third-party page of unknown authority gives an estimate of the outer box, never a datasheet fact.
21
21
  export function admissionEnvelope(geometry) {
22
22
  const parts = Array.isArray(geometry?.parts) ? geometry.parts : [];
23
- if (parts.length !== 1 || parts[0]?.status !== "source_dimensions" || !record(parts[0].dimensions_mm)) return { ...UNKNOWN_SIZE };
23
+ // Quoted page sizes and sizes the vision model read from a drawing are both estimates of the outer box.
24
+ if (parts.length !== 1 || !["source_dimensions", "vision_estimate"].includes(parts[0]?.status) || !record(parts[0].dimensions_mm)) return { ...UNKNOWN_SIZE };
24
25
  const { shape, dimensions_mm: size } = parts[0];
25
26
  const box = shape === "box" ? [size.length, size.width, size.height]
26
27
  : shape === "cylinder" ? [size.diameter, size.diameter, size.height]
@@ -47,7 +48,8 @@ export function admissionItem(model) {
47
48
  manufacturer_quote: source.manufacturer_quote, model_quote: source.model_quote,
48
49
  part: part ? { name: part.name, shape: part.shape, dimensions_mm: part.dimensions_mm,
49
50
  ...(part.dimension_quotes ? { dimension_quotes: part.dimension_quotes } : {}),
50
- ...(part.dimension_tuple ? { dimension_tuple: part.dimension_tuple } : {}) } : null,
51
+ ...(part.dimension_tuple ? { dimension_tuple: part.dimension_tuple } : {}),
52
+ ...(part.vision_dimensions ? { vision_dimensions: part.vision_dimensions } : {}) } : null,
51
53
  },
52
54
  };
53
55
  }
@@ -5,6 +5,8 @@ import { intactReceipt, successfulReceipt } from "./web-receipt.mjs";
5
5
  import { volcQueryRequest, legacyVolcQueryRequest } from "./volc-web-search.mjs";
6
6
  import { isIP } from "node:net";
7
7
  import { REGISTRY_SCHEMA, recordId, keyOf, volcQueryKey } from "../../abelien-harness/scripts/web/source-registry.mjs";
8
+ import { isPdf } from "../../abelien-harness/scripts/web/pdf-reader.mjs";
9
+ import { isPdfUrl, pdfCombinedText, validPdfDocument, validTranscriptions, PDF_READING } from "./web-pdf.mjs";
8
10
  export const chainProtocol = "prismer-volc-source-v2", chainPolicy = "prismer_then_volc";
9
11
  export const receiptFields = ["raw", "raw_sha256", "response", "response_sha256", "request_id", "received_at", "http_status"];
10
12
  export const primaryQuery = item => ({ input: item.query, inputType: "query", search: { topK: item.count ?? 10 }, return: { topK: item.count ?? 10, format: "both" } });
@@ -68,7 +70,7 @@ function validRegistryRef(attempt) {
68
70
  const expected = kind === "source" ? recordId("source", keyOf.source(attempt.request.url)) : recordId("query", queryRegistryKey(attempt.request));
69
71
  return ref.record_id === expected && (kind === "query" || Number.isSafeInteger(ref.version)) && attempt.dispatch_state === (ref.hit ? "registry" : "sent");
70
72
  }
71
- function validDirect(attempt, contents) {
73
+ function validDirect(attempt, contents, readsPdf) {
72
74
  if (Object.hasOwn(attempt, "public_read_failure")) {
73
75
  const value = attempt.public_read_failure;
74
76
  return value && Object.keys(value).length === 3 && Object.keys(value).every(key => ["stage", "code", "failed_at"].includes(key))
@@ -88,16 +90,23 @@ function validDirect(attempt, contents) {
88
90
  if (previous !== value.final_url || value.connections.length !== value.redirects.length + 1 || !value.connections.every((connection, i) => connection.url === (i === 0 ? value.url : value.redirects[i - 1].to) && connection.tls_hostname === new URL(connection.url).hostname && Array.isArray(connection.addresses) && connection.addresses.length && connection.addresses.every(address => publicAddress(address.address) && isIP(address.address) === address.family) && connection.addresses.some(address => address.address === connection.address && address.family === connection.family))) return false;
89
91
  const bytes = registered ? contents.bytes.get(value.raw_sha256) ?? Buffer.alloc(0) : Buffer.from(value.raw_base64 ?? "", "base64");
90
92
  const shown = registered ? registryText(value, contents) : value.text;
91
- const derived = pageText(bytes.toString("utf8"), value.content_type), empty = value.status === "unavailable" && value.reason === "empty_page";
93
+ // A registered PDF carries its text-layer document (null when no engine could open it); its quotable text is the
94
+ // text-layer pages joined in order, each read back from the registry by sha256.
95
+ const pdf = readsPdf && registered && value.http_status >= 200 && value.http_status < 300 && isPdf(bytes, value.content_type);
96
+ if (pdf !== Object.hasOwn(value, "document")) return false;
97
+ if (pdf && (value.document === null ? !["pdf_engine_unavailable", "pdf_unreadable"].includes(value.reason) : !validPdfDocument(value.document, value.raw_sha256, contents))) return false;
98
+ const derived = pdf ? value.document === null ? "" : pdfCombinedText(value.document, contents) : pageText(bytes.toString("utf8"), value.content_type), empty = value.status === "unavailable" && value.reason === "empty_page";
92
99
  const derivedHash = empty ? sha256(derived) : value.text_sha256;
93
100
  return sha256(bytes) === value.raw_sha256 && /^[0-9a-f]{64}$/.test(value.source_raw_sha256) && (!value.raw_redacted ? value.source_raw_sha256 === value.raw_sha256 && value.source_text_sha256 === derivedHash : true)
94
101
  && (value.status === "unavailable" ? (shown ?? null) === null && value.text_sha256 === null && (!empty || (!text(derived) && value.http_status >= 200 && value.http_status < 300))
95
102
  : value.status === "read" && text(shown) && value.reason == null && value.http_status >= 200 && value.http_status < 300 && shown === derived && value.text_sha256 === sha256(shown));
96
103
  }
97
- function validateGroup(group, item, page, partial, contents, registryMode) {
104
+ function validateGroup(group, item, page, partial, contents, registryMode, readsPdf) {
98
105
  const query = !page, attempts = group.attempts;
99
106
  if (!Array.isArray(attempts) || !attempts.length || attempts.length > 2) return false;
100
107
  const operation = page ? sourceOperation(page.source_url) : null;
108
+ // Without Prismer only registry-mode evidence reads PDF sources directly; image sources stay without a direct reader.
109
+ const unreadable = operation?.kind === "parse" && !(registryMode && readsPdf && isPdfUrl(page.source_url));
101
110
  for (const [index, attempt] of attempts.entries()) {
102
111
  const provider = index === 0 ? "prismer" : query ? "volcengine" : "direct_https", kind = query ? "query" : index === 0 ? operation.kind : "direct_page";
103
112
  // Volc receipts saved before the Count-5 default keep their frozen Count-10 request shape.
@@ -108,7 +117,7 @@ function validateGroup(group, item, page, partial, contents, registryMode) {
108
117
  // Registry-mode evidence registers every received Volc query and direct read; Prismer stays off for it.
109
118
  if (registryMode ? (provider === "prismer" && !prismerDisabled(attempt)) || (provider !== "prismer" && ["completed", "pending"].includes(attempt.status) && !Object.hasOwn(attempt, "registry") && !attempt.public_read_failure && attempt.dispatch_state !== "not_sent")
110
119
  : Object.hasOwn(attempt, "registry")) return false;
111
- if (provider === "direct_https") { if (!validDirect(attempt, contents) || (attempt.dispatch_state === "not_sent" && operation.kind !== "parse") || (attempt.public_read_failure && operation.kind === "parse")) return false; continue; }
120
+ if (provider === "direct_https") { if (!validDirect(attempt, contents, readsPdf) || (attempt.dispatch_state === "not_sent") !== unreadable || (attempt.public_read_failure && unreadable)) return false; continue; }
112
121
  if (Object.hasOwn(attempt, "public_read_failure")) return false;
113
122
  if (missingKey(attempt) || knownDenial(attempt) || prismerDisabled(attempt)) continue;
114
123
  if (!(attempt.dispatch_state === "sent" || (attempt.dispatch_state === "registry" && provider === "volcengine")) || !["completed", "pending"].includes(attempt.status) || !successfulReceipt(attempt)) return false;
@@ -146,16 +155,19 @@ export function validateChainIntegrity({ original, source, saved, allowPartial =
146
155
  if (Object.hasOwn(body, "prismer_switch") && (!prismerSwitches.includes(body.prismer_switch) || !switchConsistent(body))) reject("pinned Prismer switch is invalid or contradicts its attempts");
147
156
  const registryMode = Object.hasOwn(body, "source_registry");
148
157
  if (registryMode && (body.source_registry !== REGISTRY_SCHEMA || body.prismer_switch !== "disabled" || !contents)) reject("registry-mode evidence needs the registry schema, Prismer off and registry contents");
149
- if (!registryMode && Object.hasOwn(body, "model_lookups")) reject("model lookups exist only in registry-mode evidence");
158
+ const readsPdf = Object.hasOwn(body, "pdf_reading");
159
+ if (!registryMode && (Object.hasOwn(body, "model_lookups") || readsPdf)) reject("model lookups and PDF reading exist only in registry-mode evidence");
160
+ if ((readsPdf && body.pdf_reading !== PDF_READING) || (!readsPdf && Object.hasOwn(body, "transcriptions"))) reject("vision transcriptions need the pinned PDF reading");
161
+ if (!validTranscriptions(body, contents, allowPartial)) reject("vision transcription drifted from its page, render, model or registered receipt");
150
162
  if (![body.searches, body.pages, body.query_plans, body.unknowns].every(Array.isArray) || !body.query_plans.length || !body.query_plans.every(plan => Array.isArray(plan.unresolved_slots) && plan.plan_digest === digest(plan.unresolved_slots)) || body.query_plans.at(-1).plan_digest !== body.plan_digest || new Set(body.searches.map(entry => entry.id)).size !== body.searches.length || new Set(body.pages.map(page => page.id)).size !== body.pages.length || !validModelLookups(body)) reject("chain history is malformed");
151
163
  for (const entry of body.searches) {
152
164
  const item = body.query_plans.flatMap(plan => plan.unresolved_slots).find(item => item.slot_id === entry.slot_id && item.query === entry.query && item.reason === entry.reason && same(entry.request, primaryQuery(item)));
153
- if (!item || entry.id !== `query-${digest({ slot_id: item.slot_id, body: primaryQuery(item) }).slice(0, 24)}` || !validateGroup(entry, item, null, allowPartial, contents, registryMode)) reject("query attempt/order/selection/receipt drifted or effect unresolved");
165
+ if (!item || entry.id !== `query-${digest({ slot_id: item.slot_id, body: primaryQuery(item) }).slice(0, 24)}` || !validateGroup(entry, item, null, allowPartial, contents, registryMode, readsPdf)) reject("query attempt/order/selection/receipt drifted or effect unresolved");
154
166
  }
155
167
  for (const page of body.pages) {
156
168
  if (page.id !== `page-${digest({ slot: page.slot_id, url: page.url }).slice(0, 24)}` || !Array.isArray(page.discovery_refs) || !page.discovery_refs.length || page.request_id !== page.discovery_refs[0].request_id || !page.discovery_refs.every(ref => { if (Object.hasOwn(ref, "kind")) return modelDiscovery(body, page, ref); const query = body.searches[ref.search_index], attempt = selectedReceipt(query), result = queryResults(attempt)?.[ref.result_index]; return Number.isSafeInteger(ref.search_index) && Number.isSafeInteger(ref.result_index) && query?.slot_id === page.slot_id && attempt?.id === ref.attempt_id && attempt.provider === ref.provider && ref.request_id === attempt.request_id && (result?.url ?? null) === page.url; })) reject("first original discovery provenance drifted");
157
169
  if (!page.attempts?.length) { if (!(["unavailable", ...(allowPartial ? ["in_progress"] : [])].includes(page.status)) || page.attempt != null || page.text !== null || page.text_sha256 !== null || page.selected_attempt_id != null) reject("source attempt omitted"); continue; }
158
- if (page.source_url !== normalizePublicSourceUrl(page.url).href || !validateGroup(page, null, page, allowPartial, contents, registryMode)) reject("source attempt/selection/receipt is unresolved or drifted");
170
+ if (page.source_url !== normalizePublicSourceUrl(page.url).href || !validateGroup(page, null, page, allowPartial, contents, registryMode, readsPdf)) reject("source attempt/selection/receipt is unresolved or drifted");
159
171
  const attempt = selectedReceipt(page);
160
172
  if (!attempt) continue;
161
173
  if (page.status === "pending") { if (!allowPartial || attempt.kind !== "parse" || attempt.status !== "pending" || attempt.task_id !== attempt.response.taskId || page.text !== null) reject("pending source lacks original Parse task"); continue; }
@@ -167,6 +179,7 @@ export function validateChainIntegrity({ original, source, saved, allowPartial =
167
179
  }
168
180
  if (body.status === "completed" && (body.searches.some(entry => entry.status !== "completed") || body.pages.some(page => !["read", "unavailable"].includes(page.status)) || original.unresolved_slots.some(item => !body.searches.some(entry => entry.slot_id === item.slot_id && same(entry.request, primaryQuery(item))) && !body.model_lookups?.some(lookup => lookup.item_digest === digest(item) && lookup.search_skipped))
169
181
  || (body.model_lookups ?? []).some(lookup => lookup.models.some(model => model.documents.some(doc => !body.pages.some(page => page.slot_id === lookup.slot_id && page.url === doc.url)))) || body.searches.some((entry, search_index) => queryResults(selectedReceipt(entry)).some((result, result_index) => !body.pages.some(page => page.discovery_refs.some(ref => ref.search_index === search_index && ref.result_index === result_index)))))) reject("completed chain omitted original work");
182
+ if (body.status === "completed" && body.pages.some(page => page.status !== "pending" && page.attempt?.registry && page.attempt.response?.document?.pages.some(item => item.drawing && !body.transcriptions?.some(entry => entry.page_id === page.id && entry.pdf_page === item.page && entry.status !== "in_progress")))) reject("completed evidence left a drawing page unread");
170
183
  if (body.status !== "completed" && !body.searches.length && !body.model_lookups?.length) reject("no original chain effect is known resumable");
171
184
  } catch (error) { if (error?.code === code) throw error; reject("malformed chain receipt or original provenance"); }
172
185
  return true;
@@ -48,14 +48,18 @@ export async function hydrateRegistryEvidence(saved, registry) {
48
48
  if (!Object.hasOwn(saved?.body ?? {}, "source_registry")) return { saved, contents: null };
49
49
  if (!registry) throw failure("HARDWARE_WEB_CONFIG", "registry-mode web evidence needs its source registry from the trusted host");
50
50
  // The caller passes an already resolved resource; storage_format stays so a resumed evidence keeps its stored format.
51
- const body = structuredClone(saved.body), contents = { bytes: new Map(), text: new Map() };
51
+ const body = structuredClone(saved.body), contents = { bytes: new Map(), text: new Map(), receipts: new Map() };
52
52
  const load = async response => {
53
53
  if (/^[0-9a-f]{64}$/.test(response?.raw_sha256 ?? "") && !contents.bytes.has(response.raw_sha256)) contents.bytes.set(response.raw_sha256, await registry.getBlob(response.raw_sha256));
54
54
  if (/^[0-9a-f]{64}$/.test(response?.text_sha256 ?? "") && !contents.text.has(response.text_sha256)) contents.text.set(response.text_sha256, (await registry.getBlob(response.text_sha256)).toString("utf8"));
55
55
  };
56
56
  for (const page of body.pages ?? []) {
57
- for (const attempt of page.attempts ?? []) if (attempt?.provider === "direct_https" && attempt.registry) await load(attempt.response);
57
+ for (const attempt of page.attempts ?? []) if (attempt?.provider === "direct_https" && attempt.registry) {
58
+ await load(attempt.response);
59
+ for (const item of attempt.response?.document?.pages ?? []) await load({ text_sha256: item?.text_sha256 });
60
+ }
58
61
  if (page.attempt?.registry && page.selected_provider === "direct_https" && page.text === null && page.text_sha256) page.text = contents.text.get(page.text_sha256) ?? null;
59
62
  }
63
+ for (const entry of body.transcriptions ?? []) if (/^[0-9a-f]{64}$/.test(entry?.receipt_sha256 ?? "") && !contents.receipts.has(entry.receipt_sha256)) contents.receipts.set(entry.receipt_sha256, (await registry.getBlob(entry.receipt_sha256)).toString("utf8"));
60
64
  return { saved: { ...saved, body }, contents };
61
65
  }
@@ -117,8 +117,10 @@ export function verifyCheckpointExtension(checkpoint, latest, readBlob, hydrated
117
117
  try {
118
118
  const before = checkpoint?.body, after = latest?.body;
119
119
  if (!before || !after || checkpoint.id !== latest.id || checkpoint.kind !== "hardware-web-evidence" || latest.kind !== checkpoint.kind
120
- || !fixed(before, after, ["id", "provider", "protocol", "schema_version", "provider_policy", "recall_id", "recall_digest", "request_digest", "requirements", "slots", "plan_digest", "unresolved_slots", "prismer_switch", "source_registry"])
120
+ || !fixed(before, after, ["id", "provider", "protocol", "schema_version", "provider_policy", "recall_id", "recall_digest", "request_digest", "requirements", "slots", "plan_digest", "unresolved_slots", "prismer_switch", "source_registry", "pdf_reading"])
121
121
  || (Object.hasOwn(before, "model_lookups") && !prefix(before.model_lookups, after.model_lookups))
122
+ // A finished transcription is fixed; one in flight may only finish (or become unknown) under the same identity.
123
+ || (Object.hasOwn(before, "transcriptions") && !prefix(before.transcriptions, after.transcriptions, (left, right) => left.status === "in_progress" ? right?.id === left.id && right.registry?.record_id === left.registry?.record_id : same(left, right)))
122
124
  || !prefix(before.query_plans, after.query_plans) || !prefix(before.unknowns, after.unknowns)
123
125
  || !prefix(before.searches, after.searches, before.protocol === chainProtocol ? group : attempt) || !prefix(before.pages, after.pages, page)) reject();
124
126
  } catch { reject(); }
@@ -21,14 +21,43 @@ function dimensionTuples(value) {
21
21
  return measurements.map(([number, unit]) => ({ value: Number(number), unit: unit ?? shared, value_mm: Number(number) * unitFactor(unit ?? shared) }));
22
22
  });
23
23
  }
24
- function envelope(parts, page) {
24
+ // Sizes read from a drawing by the vision model: each axis names one item of a completed vision_transcription of a
25
+ // PDF in the same slot, in millimetres, with exactly that value. The result is an estimate, never a datasheet fact.
26
+ function visionPart(part, page, transcriptions) {
27
+ const source = part.vision_dimensions;
28
+ onlyKeys(source, ["transcription_id", "items"], "vision dimensions");
29
+ if (part.dimension_quotes !== undefined || part.dimensions_quote !== undefined || part.dimension_axes !== undefined) invalid("vision dimensions replace source quotes; do not mix them");
30
+ const transcription = transcriptions.find(entry => entry.id === source.transcription_id);
31
+ if (transcription?.status !== "completed" || transcription.slot_id !== page.slot_id || !Array.isArray(transcription.items)) invalid("vision dimensions need a completed transcription of a drawing in the same slot");
32
+ if (!record(part.dimensions_mm) || !record(source.items) || Object.keys(source.items).sort().join() !== Object.keys(part.dimensions_mm).sort().join()) invalid("vision dimensions map every dimension axis to one transcription item");
33
+ const items = {};
34
+ for (const [axis, index] of Object.entries(source.items)) {
35
+ const item = Number.isSafeInteger(index) ? transcription.items[index] : undefined, value = Number(item?.value);
36
+ if (!item || String(item.unit ?? "").trim().toLowerCase() !== "mm" || !Number.isFinite(value) || value <= 0 || Math.abs(value - part.dimensions_mm[axis]) > 1e-9) invalid("each vision dimension must equal its transcription item value in mm");
37
+ items[axis] = { index, what: item.what, value: item.value, tolerance: item.tolerance, unit: item.unit };
38
+ }
39
+ return { transcription_id: transcription.id, page_id: transcription.page_id, pdf_page: transcription.pdf_page, png_sha256: transcription.png_sha256, receipt_sha256: transcription.receipt_sha256, basis: "vision_estimate", items };
40
+ }
41
+ function envelope(parts, page, transcriptions = []) {
25
42
  if (!Array.isArray(parts)) invalid("parts must be an array; absent geometry remains unknown");
26
43
  const names = new Set(), unknowns = [];
27
44
  const normalized = parts.map(part => {
28
- onlyKeys(part, ["name", "name_quote", "shape", "shape_quote", "dimensions_mm", "dimension_quotes", "dimensions_quote", "dimension_axes"], "physical part");
45
+ onlyKeys(part, ["name", "name_quote", "shape", "shape_quote", "dimensions_mm", "dimension_quotes", "dimensions_quote", "dimension_axes", "vision_dimensions"], "physical part");
29
46
  if (!text(part.name) || names.has(part.name) || !quote(part.name_quote, page) || !part.name_quote.includes(part.name)) invalid("physical parts need unique source-bound names");
30
47
  names.add(part.name);
31
48
  if (!["box", "cylinder", "sphere", "unknown"].includes(part.shape)) invalid("part shape must be box, cylinder, sphere or unknown");
49
+ if (part.vision_dimensions !== undefined) {
50
+ const vision = visionPart(part, page, transcriptions);
51
+ const axes = part.shape === "box" ? ["length", "width", "height"] : part.shape === "cylinder" ? ["diameter", "height"] : part.shape === "sphere" ? ["diameter"] : ["length", "width", "height", "diameter"];
52
+ if (Object.keys(part.dimensions_mm).some(axis => !axes.includes(axis))) invalid("dimension axes must match the physical part shape");
53
+ // A box drawn only from drawing sizes is the dimensional envelope; any other shape still needs its source quote.
54
+ if (part.shape !== "unknown" && part.shape_quote === undefined ? part.shape !== "box" : part.shape !== "unknown" && (!quote(part.shape_quote, page) || !part.shape_quote.includes(part.name) || !part.shape_quote.toLowerCase().includes(part.shape))) invalid("part shape must have an attributed quote or be the box envelope of vision dimensions");
55
+ const complete = part.shape !== "unknown" && axes.every(axis => Object.hasOwn(part.dimensions_mm, axis));
56
+ unknowns.push({ code: "VISION_DIMENSIONS_ESTIMATE", part: part.name, transcription_id: vision.transcription_id });
57
+ if (!complete) unknowns.push({ code: "PART_DIMENSIONS_UNKNOWN", part: part.name });
58
+ return { ...part, vision_dimensions: vision, physical_shape_status: "unknown", shape_basis: part.shape_quote === undefined ? "dimension_envelope_not_verified_part_shape" : "source_literal_or_unknown",
59
+ dimension_basis: "vision_estimate", status: complete ? "vision_estimate" : "unknown", source_page_id: page.id };
60
+ }
32
61
  const tupleQuote = part.dimensions_quote;
33
62
  if (tupleQuote === undefined && part.dimension_axes !== undefined) invalid("tuple axis mapping requires its exact source tuple quote");
34
63
  if (tupleQuote !== undefined && (!quote(tupleQuote, page) || !tupleQuote.startsWith(part.name) || !Array.isArray(part.dimension_axes))) invalid("dimension tuples need exact part attribution and explicit original-order axis mapping");
@@ -105,7 +134,7 @@ export async function mergeHardwareWeb({ input, readResource, readBlob, env = {}
105
134
  if (!slot || !evidence.unresolved_slots.some(item => item.slot_id === slot.slot_id) || page?.slot_id !== slot.slot_id || page.status !== "read"
106
135
  || !text(page.text)) invalid("external candidate must use an exact persisted source response for its unresolved slot");
107
136
  for (const field of ["manufacturer", "model"]) if (!text(proposed[field]) || !quote(proposed[`${field}_quote`], page) || !proposed[`${field}_quote`].includes(proposed[field])) invalid("manufacturer and model need exact attributed source quotes");
108
- const geometry = envelope(proposed.parts, page), componentId = `external:${digest({ manufacturer: proposed.manufacturer, model: proposed.model, url: page.source_url }).slice(0, 32)}`;
137
+ const geometry = envelope(proposed.parts, page, (evidence.transcriptions ?? []).map(entry => ({ ...entry, slot_id: evidence.pages.find(item => item.id === entry.page_id)?.slot_id }))), componentId = `external:${digest({ manufacturer: proposed.manufacturer, model: proposed.model, url: page.source_url }).slice(0, 32)}`;
109
138
  if (added.has(`${slot.slot_id}:${componentId}`)) invalid("duplicate external candidate"); added.add(`${slot.slot_id}:${componentId}`);
110
139
  const candidate = { component_id: componentId, manufacturer: proposed.manufacturer, model: proposed.model,
111
140
  resource_urn: `urn:abelien:hardware:${componentId}`, category_path: slot.category_path, source_type: "external_web", content: page.text,
@@ -0,0 +1,135 @@
1
+ // 联网补位读 PDF(registry mode):直读拿到 PDF 后本地抽文本层并按页登记,文本页逐字成为可引用原文;
2
+ // 没有文本层的页渲染成长边 1280 px PNG,交给受信宿主配置的方舟视觉接入点读图,结果是独立的
3
+ // vision_transcription 证据,尺寸 basis 只能是估算。同一张图、同一套参数只读一次(登记表 vision 键)。
4
+ import fs from "node:fs";
5
+ import { digest, sha256 } from "../../abelien-harness/scripts/web/common.mjs";
6
+ import { PDF_RENDER, visibleChars, drawingPages, readTextLayer, renderPage } from "../../abelien-harness/scripts/web/pdf-reader.mjs";
7
+ import { VISION_PARAMS, readImage, outputText, parseTranscription } from "../../abelien-harness/scripts/web/vision.mjs";
8
+ import { keyOf, recordId, lookupDerivative, registerDerivative, beginPaid, finishPaid, currentEntry } from "../../abelien-harness/scripts/web/source-registry.mjs";
9
+
10
+ /** 提示词照搬 2026-10-15 调优用的那份(flash-drawing-bench/prompt.txt),版本带内容摘要。 */
11
+ export const DRAWING_PROMPT = fs.readFileSync(new URL("../assets/pdf-drawing-prompt.txt", import.meta.url), "utf8").replace(/\n+$/, "");
12
+ export const DRAWING_PROMPT_VERSION = `pdf-drawing-v1:${sha256(DRAWING_PROMPT).slice(0, 12)}`;
13
+ export const PAGE_SEPARATOR = "\n\n";
14
+ /** Pinned on new registry-mode evidence at its first effect; registry evidence saved before it keeps PDFs unread. */
15
+ export const PDF_READING = "pdf-text-vision-v1";
16
+ export const isPdfUrl = url => /\.pdf$/i.test(new URL(url).pathname);
17
+ export const renderParams = engineId => ({ ...PDF_RENDER, engine: engineId });
18
+ export const transcriptionId = (pageId, pdfPage) => `vision-${digest({ page_id: pageId, pdf_page: pdfPage }).slice(0, 24)}`;
19
+ export const visionKey = (pngSha256, endpointId) => keyOf.vision(pngSha256, endpointId, DRAWING_PROMPT_VERSION, VISION_PARAMS);
20
+
21
+ /**
22
+ * 文本层:先查登记表(原始文件 sha256 + 引擎);没有就用引擎逐页抽取并登记,每页原文按 sha256 存。
23
+ * 返回回执要带的 document 与可引用的合并原文(有文本的页按页序用空行连接)。
24
+ */
25
+ export async function pdfDocument({ registry, engine, bytes, rawSha256, contents }) {
26
+ if (!engine) return { document: null, reason: "pdf_engine_unavailable", text: null };
27
+ const key = keyOf.text_layer(rawSha256, engine.id);
28
+ let layer = await lookupDerivative(registry, "text_layer", key);
29
+ if (!layer) {
30
+ const read = readTextLayer(engine, bytes);
31
+ if (read.reason) return { document: null, reason: read.reason, text: null };
32
+ const pages = [];
33
+ for (const page of read.pages) {
34
+ const chars = visibleChars(page.text);
35
+ pages.push({ page: page.page, chars, text_sha256: chars ? await registry.putBlob(Buffer.from(page.text)) : null });
36
+ if (chars) contents.text.set(sha256(page.text), page.text);
37
+ }
38
+ layer = await registerDerivative(registry, "text_layer", key, { engine: engine.id, page_count: pages.length, pages });
39
+ }
40
+ for (const page of layer.pages) if (page.text_sha256 && !contents.text.has(page.text_sha256)) contents.text.set(page.text_sha256, (await registry.getBlob(page.text_sha256)).toString("utf8"));
41
+ const drawing = drawingPages(layer.pages.map(page => page.text_sha256 ? contents.text.get(page.text_sha256) : ""));
42
+ const pages = layer.pages.map((page, index) => ({ page: page.page, chars: page.chars, text_sha256: page.text_sha256, drawing: drawing[index] }));
43
+ const combined = pdfCombinedText({ pages }, contents);
44
+ return { document: { format: "pdf", engine: engine.id, page_count: pages.length, text_layer_record_id: layer.record_id, pages }, reason: combined ? null : "no_text_layer", text: combined || null };
45
+ }
46
+ export const pdfCombinedText = (document, contents) => document.pages.filter(page => page.text_sha256).map(page => contents.text.get(page.text_sha256) ?? "").join(PAGE_SEPARATOR);
47
+
48
+ /** 回执里 document 的形状与可重算部分;记录 ID 由原始文件摘要与引擎确定。 */
49
+ export function validPdfDocument(document, rawSha256, contents) {
50
+ if (!document || document.format !== "pdf" || typeof document.engine !== "string" || !Array.isArray(document.pages) || document.page_count !== document.pages.length
51
+ || document.text_layer_record_id !== recordId("text_layer", keyOf.text_layer(rawSha256, document.engine))) return false;
52
+ const drawing = drawingPages(document.pages.map(page => page?.text_sha256 ? contents?.text.get(page.text_sha256) ?? "" : ""));
53
+ return document.pages.every((page, index) => page?.page === index + 1 && Number.isSafeInteger(page.chars) && page.chars >= 0 && page.drawing === drawing[index]
54
+ && (page.chars === 0 ? page.text_sha256 === null : /^[0-9a-f]{64}$/.test(page.text_sha256 ?? "") && contents?.text.has(page.text_sha256) && visibleChars(contents.text.get(page.text_sha256)) === page.chars));
55
+ }
56
+
57
+ /**
58
+ * 读一页图纸。证据里的转写条目先以 in_progress 落盘再调用;进程中断后续跑看到 in_progress 即记为 unknown,
59
+ * 不重发。登记表命中就零模型调用。返回最终条目(completed / failed / unknown / unavailable)。
60
+ */
61
+ export async function transcribeDrawing({ registry, engine, vision, bytes, page, pdfPage, rawSha256, entry, save, fetchImpl, limit }) {
62
+ const params = renderParams(engine?.id ?? null);
63
+ Object.assign(entry, { kind: "vision_transcription", page_id: page.id, pdf_page: pdfPage, source_sha256: rawSha256, render_params: params, png_sha256: null, png: null,
64
+ model: vision ? { endpoint_id: vision.endpointId } : null, prompt_version: DRAWING_PROMPT_VERSION, params: { ...VISION_PARAMS }, registry: null, basis: "vision_estimate",
65
+ receipt_sha256: null, items: null, reason: null, error_code: null });
66
+ if (!engine) { entry.status = "unavailable"; entry.reason = "pdf_engine_unavailable"; await save(); return entry; }
67
+ const renderKey = keyOf.render(rawSha256, pdfPage, params);
68
+ let rendered = await lookupDerivative(registry, "render", renderKey);
69
+ if (!rendered) {
70
+ const image = renderPage(engine, bytes, pdfPage);
71
+ rendered = await registerDerivative(registry, "render", renderKey, { png_sha256: await registry.putBlob(image.png), width: image.width, height: image.height });
72
+ }
73
+ Object.assign(entry, { png_sha256: rendered.png_sha256, png: { width: rendered.width, height: rendered.height } });
74
+ if (!vision) { entry.status = "unavailable"; entry.reason = "vision_unconfigured"; await save(); return entry; }
75
+ const key = visionKey(rendered.png_sha256, vision.endpointId);
76
+ entry.registry = { record_id: recordId("vision", key), hit: false };
77
+ entry.status = "in_progress"; await save();
78
+ let begin;
79
+ try { begin = await beginPaid(registry, "vision", key, { png_sha256: rendered.png_sha256, endpoint_id: vision.endpointId, prompt_version: DRAWING_PROMPT_VERSION, params: VISION_PARAMS }, { waitMs: vision.timeoutMs + 5000 }); }
80
+ catch (error) {
81
+ // The same picture was sent elsewhere and its outcome is unknown: not resent here either.
82
+ if (error?.code !== "SOURCE_REGISTRY_UNKNOWN") throw error;
83
+ Object.assign(entry, { status: "unknown", error_code: error.code }); await save(); return entry;
84
+ }
85
+ if (begin.hit) {
86
+ entry.registry.hit = true;
87
+ Object.assign(entry, { status: "completed", receipt_sha256: begin.hit.receipt_sha256, items: begin.hit.items });
88
+ await save(); return entry;
89
+ }
90
+ let receipt;
91
+ try { receipt = await limit(async () => readImage({ png: await registry.getBlob(rendered.png_sha256), prompt: DRAWING_PROMPT, config: vision, fetchImpl })); }
92
+ catch (error) {
93
+ if (!String(error?.code ?? "").startsWith("SOURCE_VISION_")) throw error;
94
+ if (error.ambiguous) { Object.assign(entry, { status: "unknown", error_code: error.code }); await save(); return entry; }
95
+ const raw = error.providerEvidence?.raw;
96
+ const failed = { state: "failed", error_code: error.code, http_status: error.providerEvidence?.http_status ?? null, receipt_sha256: typeof raw === "string" ? await registry.putBlob(Buffer.from(raw)) : null };
97
+ await finishPaid(registry, "vision", key, begin.token, failed);
98
+ Object.assign(entry, { status: "failed", error_code: error.code, receipt_sha256: failed.receipt_sha256 }); await save(); return entry;
99
+ }
100
+ const receiptSha = await registry.putBlob(Buffer.from(receipt.raw)), items = parseTranscription(outputText(receipt.response));
101
+ await finishPaid(registry, "vision", key, begin.token, { state: "completed", receipt_sha256: receiptSha, items, received_at: receipt.received_at, request_id: receipt.request_id, http_status: receipt.http_status });
102
+ Object.assign(entry, { status: "completed", receipt_sha256: receiptSha, items });
103
+ await save(); return entry;
104
+ }
105
+
106
+ /**
107
+ * 续跑时遇到上次停在 in_progress 的条目:登记表里已经有这张图的完成结果就直接采用(零调用);否则结果未知,不重发。
108
+ */
109
+ export async function settleInterrupted({ registry, entry, save }) {
110
+ const done = entry.registry ? currentEntry(await registry.get("vision", visionKey(entry.png_sha256, entry.model.endpoint_id))) : null;
111
+ if (done?.state === "completed") Object.assign(entry, { status: "completed", receipt_sha256: done.receipt_sha256, items: done.items });
112
+ else Object.assign(entry, { status: "unknown", error_code: "VISION_OUTCOME_UNKNOWN" });
113
+ await save(); return entry;
114
+ }
115
+
116
+ /** 证据里的转写条目:只在 registry mode;每条对应一份 PDF 的一页图纸;完成的条目逐项与登记的回执一致。 */
117
+ export function validTranscriptions(body, contents, allowPartial) {
118
+ if (!Object.hasOwn(body, "transcriptions")) return true;
119
+ if (!Array.isArray(body.transcriptions) || new Set(body.transcriptions.map(entry => entry?.id)).size !== body.transcriptions.length) return false;
120
+ return body.transcriptions.every(entry => {
121
+ const page = body.pages.find(value => value.id === entry?.page_id), attempt = page?.attempts?.find(value => value.id === page.selected_attempt_id), document = attempt?.response?.document;
122
+ if (!document || entry.id !== transcriptionId(page.id, entry.pdf_page) || entry.kind !== "vision_transcription" || !document.pages[entry.pdf_page - 1]?.drawing
123
+ || entry.source_sha256 !== attempt.response.raw_sha256 || entry.basis !== "vision_estimate" || entry.prompt_version !== DRAWING_PROMPT_VERSION
124
+ || digest(entry.params) !== digest(VISION_PARAMS) || digest(entry.render_params) !== digest(renderParams(entry.render_params?.engine ?? null))) return false;
125
+ if (entry.status === "unavailable") return ["pdf_engine_unavailable", "vision_unconfigured"].includes(entry.reason) && entry.items === null && entry.receipt_sha256 === null;
126
+ if (!/^[0-9a-f]{64}$/.test(entry.png_sha256 ?? "") || !entry.model?.endpoint_id || entry.registry?.record_id !== recordId("vision", visionKey(entry.png_sha256, entry.model.endpoint_id))) return false;
127
+ if (entry.status === "in_progress") return allowPartial && entry.items === null;
128
+ if (entry.status === "unknown") return typeof entry.error_code === "string" && entry.items === null;
129
+ if (entry.status === "failed") return typeof entry.error_code === "string" && entry.items === null;
130
+ if (entry.status !== "completed") return false;
131
+ const raw = contents?.receipts.get(entry.receipt_sha256);
132
+ let response; try { response = JSON.parse(raw); } catch { return false; }
133
+ return digest(parseTranscription(outputText(response))) === digest(entry.items);
134
+ });
135
+ }
@@ -8,6 +8,9 @@ import { jsonReceipt } from "./web-receipt.mjs";
8
8
  import { redactHardwareResponse } from "./response-redaction.mjs";
9
9
  import { chainPolicy, chainProtocol, pinPrismerSwitch, receiptFields, primaryQuery, sourceOperation, operationId, permitsFallback, expectedSelection, selectedReceipt, queryResults, projectQuery, sourceProjection, trustedResult, validateChainIntegrity, queryRegistryKey } from "./provider-chain-contract.mjs";
10
10
  import { REGISTRY_SCHEMA, openSourceRegistry, beginPaid, finishPaid, lookupSource, registerSource, lookupModel, keyOf, recordId } from "../../abelien-harness/scripts/web/source-registry.mjs";
11
+ import { loadPdfEngine, isPdf } from "../../abelien-harness/scripts/web/pdf-reader.mjs";
12
+ import { visionConfig, concurrencyLimit } from "../../abelien-harness/scripts/web/vision.mjs";
13
+ import { pdfDocument, isPdfUrl, transcribeDrawing, transcriptionId, settleInterrupted, PDF_READING } from "./web-pdf.mjs";
11
14
 
12
15
  // The source registry is required for every new evidence that reads sources directly (Prismer off, the default).
13
16
  // Prismer-enabled evidence is not covered by the registry and keeps its original path.
@@ -46,8 +49,19 @@ export async function searchProviderChain({ input, source, existing, env, readRe
46
49
  if (config) config.prismer = pinPrismerSwitch(evidence, config.prismer);
47
50
  // Evidence that reads sources directly is registry-mode from its first provider effect; the mode is pinned with
48
51
  // the Prismer switch. Evidence saved before the registry keeps its original path.
49
- if (config && firstEffect && config.prismer === "disabled") { registry = requireSourceRegistry({ env, ports, fetchImpl }); evidence.source_registry = REGISTRY_SCHEMA; }
52
+ if (config && firstEffect && config.prismer === "disabled") { registry = requireSourceRegistry({ env, ports, fetchImpl }); evidence.source_registry = REGISTRY_SCHEMA; evidence.pdf_reading = PDF_READING; }
53
+ const readsPdf = evidence.pdf_reading === PDF_READING;
50
54
  if (registry && config?.prismer === "enabled") throw failure("HARDWARE_WEB_RECONCILIATION_REQUIRED", "registry-mode evidence keeps Prismer off");
55
+ // PDF reading (registry mode): the trusted host's PDF engine and vision endpoint are checked before any IO.
56
+ let pdfEngine = null, vision = null;
57
+ if (registry && config && readsPdf) {
58
+ try { pdfEngine = await loadPdfEngine({ env, ports }); vision = visionConfig(env); }
59
+ catch (error) { throw failure("HARDWARE_WEB_CONFIG", error.message); }
60
+ }
61
+ const visionLimit = concurrencyLimit(vision?.concurrency ?? 1);
62
+ let saving = Promise.resolve();
63
+ // Drawing pages are read in parallel; their checkpoints are written one at a time.
64
+ const serialSave = () => (saving = saving.then(save, save));
51
65
  const request = (path, body, timeoutMs, method = "POST") => prismerRequest(path, { body, env, fetchImpl, timeoutMs, method });
52
66
  const redact = raw => redactHardwareResponse(raw, env);
53
67
  const checkUrl = url => {
@@ -173,13 +187,19 @@ export async function searchProviderChain({ input, source, existing, env, readRe
173
187
  // Registry-mode direct reads: the URL is looked up first and a registered version is reused with zero network.
174
188
  // A new read registers its safe bytes and fetch facts; the receipt keeps only sha256s and the registry reference.
175
189
  const registeredReceipt = async (fetched, bytes, rawRedacted, sourceRawSha256, sourceTextSha256) => {
176
- const verdict = classifyPageBytes({ bytes, http_status: fetched.http_status, content_type: fetched.content_type, truncated: fetched.truncated });
190
+ let verdict = classifyPageBytes({ bytes, http_status: fetched.http_status, content_type: fetched.content_type, truncated: fetched.truncated }), document;
191
+ // A received PDF: its text layer is the quotable text; pages without one are read as drawings afterwards.
192
+ if (readsPdf && verdict.reason === "pdf_parser_unavailable" && isPdf(bytes, fetched.content_type)) {
193
+ const pdf = await pdfDocument({ registry, engine: pdfEngine, bytes, rawSha256: sha256(bytes), contents });
194
+ document = pdf.document; verdict = { status: pdf.text ? "read" : "unavailable", reason: pdf.reason, text: pdf.text };
195
+ if (!rawRedacted) sourceTextSha256 = pdf.text ? sha256(pdf.text) : null;
196
+ }
177
197
  const content = verdict.status === "read" ? verdict.text : null;
178
198
  if (content !== null) contents.text.set(await registry.putBlob(Buffer.from(content)), content);
179
199
  contents.bytes.set(sha256(bytes), Buffer.from(bytes));
180
200
  const meta = { url: fetched.url, final_url: fetched.final_url, fetched_at: fetched.fetched_at, redirects: fetched.redirects, connections: fetched.connections, dns_transport: fetched.dns_transport,
181
201
  http_status: fetched.http_status, raw_sha256: sha256(bytes), content_type: fetched.content_type, status: verdict.status, reason: verdict.reason,
182
- source_raw_sha256: sourceRawSha256, source_text_sha256: sourceTextSha256, raw_redacted: rawRedacted, text_sha256: content === null ? null : sha256(content) };
202
+ source_raw_sha256: sourceRawSha256, source_text_sha256: sourceTextSha256, raw_redacted: rawRedacted, text_sha256: content === null ? null : sha256(content), ...(document !== undefined ? { document } : {}) };
183
203
  return jsonReceipt(meta, fetched.http_status, "direct_https", redact);
184
204
  };
185
205
  const registeredRead = async (attempt, url) => {
@@ -188,6 +208,7 @@ export async function searchProviderChain({ input, source, existing, env, readRe
188
208
  if (hit && !hit.truncated) {
189
209
  const bytes = await registry.getBlob(hit.sha256);
190
210
  const receipt = await registeredReceipt({ ...hit, url }, bytes, hit.raw_redacted, hit.source_raw_sha256, hit.raw_redacted ? null : classifyPageBytes({ bytes, http_status: hit.http_status, content_type: hit.content_type, truncated: hit.truncated }).text_sha256);
211
+ hitBytes.set(receipt.response.raw_sha256, bytes);
191
212
  Object.assign(attempt, receipt, { dispatch_state: "registry", status: receipt.response.status === "unavailable" ? "unavailable" : "completed", registry: { record_id: hit.record_id, version: hit.version, hit: true } });
192
213
  await save(); return;
193
214
  }
@@ -209,10 +230,37 @@ export async function searchProviderChain({ input, source, existing, env, readRe
209
230
  connections: response.connections, dns_transport: response.dns_transport, source_raw_sha256: response.raw_sha256, raw_redacted: changed, truncated: false };
210
231
  const entry = await registerSource(registry, url, { bytes, fetch: fetched, use: "hardware_web" });
211
232
  const receipt = await registeredReceipt({ ...fetched, url }, bytes, changed, response.raw_sha256, response.text_sha256);
233
+ hitBytes.set(receipt.response.raw_sha256, bytes);
212
234
  Object.assign(attempt, receipt, { status: receipt.response.status === "unavailable" ? "unavailable" : "completed", registry: { record_id: entry.record_id, version: entry.version, hit: false } });
213
235
  await save();
214
236
  }
215
237
  };
238
+ const hitBytes = new Map();
239
+ // Every drawing page of a read PDF gets one vision_transcription entry. An entry left in_progress by an interrupted
240
+ // run is unknown and is never resent; a finished entry is kept as it is.
241
+ const transcribe = async page => {
242
+ const response = page.attempt?.response, document = page.selected_provider === "direct_https" && page.attempt?.registry ? response?.document : null;
243
+ if (!document) return;
244
+ const drawings = document.pages.filter(item => item.drawing);
245
+ if (!drawings.length) return;
246
+ evidence.transcriptions ??= [];
247
+ let bytes = hitBytes.get(response.raw_sha256) ?? contents.bytes.get(response.raw_sha256);
248
+ await Promise.all(drawings.map(async item => {
249
+ const id = transcriptionId(page.id, item.page);
250
+ let entry = evidence.transcriptions.find(value => value.id === id);
251
+ if (entry?.status === "in_progress") {
252
+ await settleInterrupted({ registry, entry, save: serialSave });
253
+ if (entry.status !== "completed") { evidence.unknowns.push({ code: "VISION_OUTCOME_UNKNOWN", transcription_id: id }); await serialSave(); }
254
+ return;
255
+ }
256
+ if (entry) return;
257
+ entry = { id }; evidence.transcriptions.push(entry);
258
+ bytes ??= await registry.getBlob(response.raw_sha256);
259
+ await transcribeDrawing({ registry, engine: pdfEngine, vision, bytes, page, pdfPage: item.page, rawSha256: response.raw_sha256, entry, save: serialSave, fetchImpl, limit: visionLimit });
260
+ if (entry.status !== "completed") evidence.unknowns.push({ code: "VISION_TRANSCRIPTION_UNAVAILABLE", transcription_id: id, status: entry.status, reason: entry.reason ?? entry.error_code });
261
+ await serialSave();
262
+ }));
263
+ };
216
264
  const readPage = async page => {
217
265
  if (["read", "unavailable"].includes(page.status)) return;
218
266
  if (!page.source_url) {
@@ -230,7 +278,8 @@ export async function searchProviderChain({ input, source, existing, env, readRe
230
278
  }
231
279
  if (permitsFallback(page.attempts[0], false) && page.attempts.length === 1) {
232
280
  const attempt = makeAttempt(page, "direct_https", "direct_page", { url: page.source_url, method: "GET" });
233
- if (operation.kind === "parse") { Object.assign(attempt, { status: "unavailable", reason: "parse_unavailable", received_at: new Date().toISOString() }); await save(); }
281
+ // Registry-mode evidence reads PDF sources directly; image sources still have no direct reader.
282
+ if (operation.kind === "parse" && !(registry && readsPdf && isPdfUrl(page.source_url))) { Object.assign(attempt, { status: "unavailable", reason: "parse_unavailable", received_at: new Date().toISOString() }); await save(); }
234
283
  else if (registry) await registeredRead(attempt, page.source_url);
235
284
  else await dispatch(attempt, () => directReceipt(page.source_url));
236
285
  }
@@ -269,7 +318,7 @@ export async function searchProviderChain({ input, source, existing, env, readRe
269
318
  let page = evidence.pages.find(value => value.id === pageId);
270
319
  if (!page) { page = { id: pageId, slot_id: item.slot_id, url: doc.url, request_id: null, discovery_refs: [], authority: "unknown", authority_reason: "Search rank, provider labels and Agent assertions do not establish manufacturer authority", status: "in_progress", text: null, text_sha256: null, attempts: [], attempt: null, selected_attempt_id: null, selected_provider: null }; evidence.pages.push(page); }
271
320
  if (!page.discovery_refs.some(ref => ref.kind === "model_index" && ref.model_record_id === model.record_id && ref.source_id === doc.source_id)) page.discovery_refs.push({ kind: "model_index", model_record_id: model.record_id, source_id: doc.source_id, request_id: null });
272
- await readPage(page);
321
+ await readPage(page); if (readsPdf) await transcribe(page);
273
322
  }
274
323
  };
275
324
  evidence.status = "in_progress"; await save();
@@ -288,7 +337,7 @@ export async function searchProviderChain({ input, source, existing, env, readRe
288
337
  let page = evidence.pages.find(page => page.id === pageId);
289
338
  if (!page) { page = { id: pageId, slot_id: item.slot_id, url, request_id: receipt.request_id, discovery_refs: [], authority: "unknown", authority_reason: "Search rank, provider labels and Agent assertions do not establish manufacturer authority", status: "in_progress", text: null, text_sha256: null, attempts: [], attempt: null, selected_attempt_id: null, selected_provider: null }; evidence.pages.push(page); }
290
339
  if (!page.discovery_refs.some(ref => ref.search_index === index && ref.result_index === resultIndex)) page.discovery_refs.push({ search_index: index, result_index: resultIndex, provider: receipt.provider, attempt_id: receipt.id, request_id: receipt.request_id });
291
- await readPage(page);
340
+ await readPage(page); if (readsPdf) await transcribe(page);
292
341
  if (page.status === "pending") { evidence.status = "pending"; await save(); return pack(evidence); }
293
342
  }
294
343
  }