abelien-harness 0.8.0 → 0.10.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.
- package/.env.example +19 -20
- package/README.md +12 -12
- package/package.json +1 -1
- package/skills/abelien-algorithm/SKILL.md +14 -6
- package/skills/abelien-algorithm/domain.json +59 -5
- package/skills/abelien-algorithm/references/research-policy.md +11 -3
- package/skills/abelien-algorithm/references/selection-service-contract.md +50 -0
- package/skills/abelien-algorithm/schemas/algorithm-bindings.v1.json +414 -0
- package/skills/abelien-algorithm/schemas/algorithm-candidates.v1.json +192 -0
- package/skills/abelien-algorithm/scripts/candidates.mjs +119 -0
- package/skills/abelien-algorithm/scripts/evidence.mjs +69 -0
- package/skills/abelien-algorithm/scripts/finalize-selection.mjs +64 -7
- package/skills/abelien-algorithm/scripts/lane-state.mjs +27 -0
- package/skills/abelien-algorithm/scripts/research.mjs +203 -0
- package/skills/abelien-hardware/SKILL.md +4 -4
- package/skills/abelien-hardware/domain.json +4 -4
- package/skills/abelien-hardware/references/knowledge-recall-service-contract.md +4 -4
- package/skills/abelien-hardware/references/search-index-format.md +5 -5
- package/skills/abelien-hardware/references/search-index-release.md +7 -7
- package/skills/abelien-hardware/references/selection-service-contract.md +3 -3
- package/skills/abelien-hardware/scripts/build-search-index.mjs +1 -1
- package/skills/abelien-hardware/scripts/oss-object-store.mjs +247 -0
- package/skills/abelien-hardware/scripts/{tos-wiki-search.mjs → oss-wiki-search.mjs} +19 -19
- package/skills/abelien-hardware/scripts/search-index-release.mjs +25 -25
- package/skills/abelien-hardware-selection/SKILL.md +2 -2
- package/skills/abelien-hardware-selection/domain.json +2 -2
- package/skills/abelien-hardware-selection/scripts/load-guide.mjs +1 -1
- package/skills/abelien-harness/references/adapter-contract.md +1 -1
- package/skills/abelien-harness/references/m1-integration.md +2 -2
- package/skills/abelien-harness/scripts/ab.mjs +45 -3
- package/skills/abelien-harness/scripts/studio.mjs +15 -14
- package/skills/abelien-reference/domain.json +1 -1
- package/skills/abelien-reference/references/research-contract.md +1 -0
- package/skills/abelien-reference/schemas/reference-evidence.v1.json +1 -1
- package/skills/abelien-reference/scripts/finalize.mjs +3 -1
- package/skills/abelien-workspace/SKILL.md +1 -1
- package/skills/abelien-workspace/assets/journey-stage-plan.json +46 -13
- package/skills/abelien-workspace/assets/lane-plan.json +36 -0
- package/skills/abelien-workspace/domain.json +24 -2
- package/skills/abelien-workspace/references/hardware-journey-orchestration.md +2 -2
- package/skills/abelien-workspace/references/harness-binding.md +3 -3
- package/skills/abelien-workspace/references/user-journey.md +5 -5
- package/skills/abelien-workspace/scripts/journey-next.mjs +10 -2
- package/skills/abelien-workspace/scripts/lanes-next.mjs +38 -0
- package/skills/abelien-workspace/workflows/user-journey.json +1 -1
- package/skills/abelien-hardware/scripts/tos-object-store.mjs +0 -202
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 硬件 Wiki 检索契约
|
|
2
2
|
|
|
3
|
-
本文件只在接入或调试 `hardware.search` 时读取。该能力由 Harness 本地 module adapter 执行:从
|
|
3
|
+
本文件只在接入或调试 `hardware.search` 时读取。该能力由 Harness 本地 module adapter 执行:从 OSS 读取 `hardware_profile_wiki/_catalog.json` 和命中的三级分类索引,在轻量 Meta 上预排序,只下载 Top-K Markdown,再把少量完整 Wiki 交给 Agent 做最终语义比较。索引发布格式见[分类索引格式](search-index-format.md),凭据只从环境变量读取。
|
|
4
4
|
|
|
5
5
|
## 请求
|
|
6
6
|
|
|
@@ -39,7 +39,7 @@ ab search_hardware '低功耗离线视觉机器人主控' \
|
|
|
39
39
|
- `component_id`,等于 Wiki Meta 的真实 `record_id`;
|
|
40
40
|
- 厂商、准确型号、`category_path`、`integration_level`、`hardware_form`;
|
|
41
41
|
- `functional_capabilities[]`、`selection_profile`、轻量匹配分数及匹配词;
|
|
42
|
-
-
|
|
42
|
+
- OSS object key、相对路径、ETag、Manifest SHA-256 和源更新时间;
|
|
43
43
|
- 解析后的选型 Meta,以及在 `include_content=true` 时的完整 Markdown。
|
|
44
44
|
|
|
45
45
|
每槽位只下载并把索引排序后的 `top_k_per_slot` 篇完整 Wiki 放入 Agent 上下文,同时返回 `available_documents`、`indexed_candidates` 和 `fetched_documents`。`source_snapshot` 由 bucket、prefix、Catalog ETag 和所用分类分片 ETag 摘要构成,候选各自保存对象 ETag,因此可以重放证据。不同槽位命中相同索引或 Wiki 时在单次请求内复用缓存。
|
|
@@ -52,6 +52,6 @@ Wiki 中的 `status`/`draft`、`selection_gate`、CAD 状态或创建时间不
|
|
|
52
52
|
|
|
53
53
|
## 配置与失败
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
必填环境变量与 Web 服务共用:`CAD_STORAGE_REGION`、`CAD_STORAGE_ENDPOINT`、`CAD_STORAGE_BUCKET`、`CAD_STORAGE_ACCESS_KEY` 和 `CAD_STORAGE_SECRET_KEY`,旧 `TOS_*` 不再读取。可选 `CAD_STORAGE_ADDRESSING_STYLE=virtual|path`(默认 `virtual`)、`CAD_STORAGE_SESSION_TOKEN`,以及 `OSS_WIKI_PREFIX`、`OSS_SEARCH_CONCURRENCY`、`OSS_MAX_FULL_WIKI_FETCH_PER_SLOT`、`OSS_REQUEST_TIMEOUT_MS`、`OSS_MAX_OBJECT_BYTES`、`OSS_REQUEST_ATTEMPTS`(幂等读遇瞬时故障的总尝试次数,含首次,默认 3,1–5)。后者默认 24,用于防止误把过大的 `top_k_per_slot` 变成批量全文下载。缺配置、Catalog/分类索引无效、索引与 Wiki 身份或 SHA-256 不一致、对象读取失败或 Wiki front matter 无效时显式失败,不允许跳过检索。
|
|
56
56
|
|
|
57
|
-
缺少 `_catalog.json` 默认返回 `WIKI_SEARCH_INDEX_MISSING`,避免退回大量全文下载。仅迁移期可显式设置 `
|
|
57
|
+
缺少 `_catalog.json` 默认返回 `WIKI_SEARCH_INDEX_MISSING`,避免退回大量全文下载。仅迁移期可显式设置 `OSS_ALLOW_LEGACY_MANIFEST_FALLBACK=true`;降级响应使用 `retrieval_mode: legacy_manifest_full_scan` 并写入 `SEARCH_INDEX_FALLBACK`。
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# 硬件 Wiki 分类索引格式
|
|
2
2
|
|
|
3
|
-
本文件只在发布、校验或迁移
|
|
3
|
+
本文件只在发布、校验或迁移 OSS 硬件 Wiki 搜索索引时读取。完整 Wiki 是型号选型证据;尺寸仅以数据库导出快照为编辑源,生成 Wiki 和索引须逐字段同源。索引在 Top-K 前执行保守尺寸筛选,不替代 Wiki。
|
|
4
4
|
|
|
5
5
|
## 对象布局
|
|
6
6
|
|
|
7
|
-
索引与现有 Wiki 共用 `
|
|
7
|
+
索引与现有 Wiki 共用 `OSS_WIKI_PREFIX`,默认布局为:
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
10
|
hardware_profile_wiki/
|
|
@@ -25,7 +25,7 @@ hardware_profile_wiki/
|
|
|
25
25
|
```bash
|
|
26
26
|
npm run build:hardware-search-index -- \
|
|
27
27
|
--source /absolute/path/to/硬件wiki \
|
|
28
|
-
--output /absolute/path/to/
|
|
28
|
+
--output /absolute/path/to/oss-search-index
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
构建器逐页验证 `record_id`、三级 `category_path` 和 Wiki SHA-256与 Manifest 一致,随后输出 `_catalog.json` 和 `indexes/releases/<release_id>/**/*.json.gz`。不带尺寸快照时输出仅有搜索索引;带 `--component-snapshot` 时另生成 Wiki 和 Manifest,见下节。
|
|
@@ -36,7 +36,7 @@ npm run build:hardware-search-index -- \
|
|
|
36
36
|
|
|
37
37
|
正常查询只执行:一个 `_catalog.json` GET、每个不同三级分类一个索引 GET,以及每槽位最多 `top_k_per_slot` 个 Wiki GET。同一请求中相同索引和 Wiki 会复用缓存。
|
|
38
38
|
|
|
39
|
-
默认缺少 `_catalog.json` 时返回 `WIKI_SEARCH_INDEX_MISSING`,不会悄悄下载整个分类。迁移期可显式设置 `
|
|
39
|
+
默认缺少 `_catalog.json` 时返回 `WIKI_SEARCH_INDEX_MISSING`,不会悄悄下载整个分类。迁移期可显式设置 `OSS_ALLOW_LEGACY_MANIFEST_FALLBACK=true`;此时响应的 `retrieval_mode` 为 `legacy_manifest_full_scan`,并在 `unknowns[]` 写入 `SEARCH_INDEX_FALLBACK`。迁移开关不应作为长期生产配置。
|
|
40
40
|
|
|
41
41
|
正常响应的 `retrieval_mode` 为 `category_index`。每个槽位同时返回 `available_documents`、`indexed_candidates` 和 `fetched_documents`,用于核对“索引看了多少、全文实际下载多少”。
|
|
42
42
|
|
|
@@ -58,7 +58,7 @@ source 与 output 须是互不嵌套的目录。每个 Wiki record_id 必须命
|
|
|
58
58
|
|
|
59
59
|
候选和 selected_models 携带 dimensions_mm、dimension_basis、dimension_geometry、dimension_source={component_id,revision,snapshot_id,evidence_ref?}、dimension_check={status,decision_required,reasons,method}。status 只表达粗筛装配结果:clear 为所有粗筛通过,risk 为至少一项失败但不构成证明,incompatible 为用户 hard 上限+measured/datasheet 尺寸下的证明,unknown 为无法判定;依据与上限权威仍看 dimension_basis 和 limit source,不另编码进 status。decision_required 仅在用户 hard 上限+measured/datasheet 尺寸且轴对齐装法全部失败时为 true。索引读入时每条 Wiki sha256 必须存在且合法;即使该行会被尺寸剔除,也不能缺 hash。实际抓取时验证正文 hash、身份、分类及全部尺寸元数据与索引相同。source_snapshot 包含 catalog 和实际分片内容摘要,不以 ETag 代替内容身份。
|
|
60
60
|
|
|
61
|
-
旧 v1 索引缺内容 hash 必须重新构建,迁移开关也不绕过 hash;缺尺寸或缺 geometry 的旧条目仍可读,但 dimension_check=unknown,不获得 hard rejection 或 clear。新索引为 legacy 无尺寸记录显式输出 null/unknown。搜索保留所有 unknown 和 estimate 候选;`dimension_screen` 报 evaluated/rejected/retained/demoted 和逐个硬拒绝原因。按相关度排序后,risk 候选保留但不占 Top-K 名额,因此每个 risk 会多带入一个可能装得下的替补,总抓取数不超过
|
|
61
|
+
旧 v1 索引缺内容 hash 必须重新构建,迁移开关也不绕过 hash;缺尺寸或缺 geometry 的旧条目仍可读,但 dimension_check=unknown,不获得 hard rejection 或 clear。新索引为 legacy 无尺寸记录显式输出 null/unknown。搜索保留所有 unknown 和 estimate 候选;`dimension_screen` 报 evaluated/rejected/retained/demoted 和逐个硬拒绝原因。按相关度排序后,risk 候选保留但不占 Top-K 名额,因此每个 risk 会多带入一个可能装得下的替补,总抓取数不超过 OSS_MAX_FULL_WIKI_FETCH_PER_SLOT;返回列表先列非 risk、后列 risk,各层内保持相关度顺序,unknown 不降级。`all_indexed_candidates_rejected` 仅指该槽位已评估分类索引,不能说所有硬件都不适用。
|
|
62
62
|
|
|
63
63
|
## 尺寸判定与占用边界
|
|
64
64
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# 硬件 Wiki 搜索索引发布
|
|
2
2
|
|
|
3
|
-
本文件在构建、发布、验证或排查
|
|
3
|
+
本文件在构建、发布、验证或排查 OSS 搜索索引时读取。Wiki 正文与 `_manifest.json` 由知识库仓库维护;Harness 不复制数千篇源 Wiki,但提供从一个完整 Wiki 快照到可用搜索服务的闭环命令。
|
|
4
4
|
|
|
5
5
|
## 输入契约
|
|
6
6
|
|
|
7
|
-
`--source` 指向的目录必须包含 `_manifest.json` 及 Manifest 中引用的全部 Markdown。构建器逐页校验 `record_id`、三级 `category_path` 和 SHA-256。远端 `
|
|
7
|
+
`--source` 指向的目录必须包含 `_manifest.json` 及 Manifest 中引用的全部 Markdown。构建器逐页校验 `record_id`、三级 `category_path` 和 SHA-256。远端 `OSS_WIKI_PREFIX/_manifest.json` 必须与本地 Manifest 字节一致,否则发布在写入任何索引对象前失败。
|
|
8
8
|
|
|
9
9
|
仓库内的 `skills/abelien-hardware/references/hardware-selection/` 是自包含的分类规则快照,与 Wiki 数据源是不同工件:前者决定如何分类和比较,后者提供实际型号事实。
|
|
10
10
|
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
|
|
13
13
|
CLI 自动从启动目录依次加载 `.env`、`.env.local`,进程中已存在的变量优先;也可传 `--env-file <path>`。复制仓库根 `.env.example` 后填写以下服务端变量,真实凭据不得提交:
|
|
14
14
|
|
|
15
|
-
-
|
|
16
|
-
- 可选 `
|
|
17
|
-
-
|
|
15
|
+
- 与 Web 部署共用的 OSS 连接:`CAD_STORAGE_REGION`、`CAD_STORAGE_ENDPOINT`、`CAD_STORAGE_BUCKET`、`CAD_STORAGE_ACCESS_KEY`、`CAD_STORAGE_SECRET_KEY`;
|
|
16
|
+
- 可选 `CAD_STORAGE_ADDRESSING_STYLE`、`CAD_STORAGE_SESSION_TOKEN`、`OSS_WIKI_PREFIX`、`OSS_PUBLISH_CONCURRENCY`;
|
|
17
|
+
- 旧 `TOS_*` 名称不再读取。
|
|
18
18
|
|
|
19
19
|
## 命令
|
|
20
20
|
|
|
21
|
-
先只构建和校验,不连接或修改
|
|
21
|
+
先只构建和校验,不连接或修改 OSS:
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
24
|
npm run publish:hardware-search-index -- \
|
|
@@ -73,6 +73,6 @@ Catalog 激活失败只会留下未引用的不可变分片,不影响旧版本
|
|
|
73
73
|
|
|
74
74
|
数据库导出由 Web 仓库显式只读命令产生;Harness 不接收数据库凭据。尺寸发布使用同一命令的 `--component-snapshot /absolute/path/to/component-dimensions.json`,先运行 `--dry-run --output /absolute/path/to/review` 检查生成 Wiki、Manifest、导出和索引。确定发布环境后去掉 dry-run,才会执行远端写入。
|
|
75
75
|
|
|
76
|
-
这条路径按 content-addressed key 上传生成 Wiki,再上传不可变导出和 Manifest、索引分片,最后按上节语义激活 `_catalog.json`;每个对象冲突即失败,不覆盖旧 Wiki、旧清单或旧快照。生成路径从 built.generated_source 读取,不能把原始 source 目录的 Wiki 当成新的正文。无需 bootstrap-manifest,catalog 使用该 release 自己的版本化 Manifest。故障只留下未引用的不可变文件;本地测试的内存
|
|
76
|
+
这条路径按 content-addressed key 上传生成 Wiki,再上传不可变导出和 Manifest、索引分片,最后按上节语义激活 `_catalog.json`;每个对象冲突即失败,不覆盖旧 Wiki、旧清单或旧快照。生成路径从 built.generated_source 读取,不能把原始 source 目录的 Wiki 当成新的正文。无需 bootstrap-manifest,catalog 使用该 release 自己的版本化 Manifest。故障只留下未引用的不可变文件;本地测试的内存 OSS 只证明执行顺序和摘要,不证明线上发布成功。
|
|
77
77
|
|
|
78
78
|
v1 迁移:所有索引记录必须具备 Wiki 内容 SHA-256。旧无 hash 索引重建再发布;旧无尺寸/geometry 记录保持 unknown。dimensions 更新会改变源 revision、snapshot_id、生成 Wiki 内容 hash、Manifest 身份与分片摘要;旧固定产物保持原字节,不原地“补齐”过去的候选。
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# 硬件选型归一化与修订边界
|
|
2
2
|
|
|
3
|
-
本文件在调试候选提案归一化或已有方案修订时读取。
|
|
3
|
+
本文件在调试候选提案归一化或已有方案修订时读取。OSS 候选流程使用本地 `hardware.finalize`,只产生 `hardware-proposal`;正式新建流程必须使用同级 `abelien-hardware-selection` 的本地 `hardware.select`。局部修订仍使用 HTTP `hardware.revise`,未配置 URL 时返回 `ENV_MISSING`。
|
|
4
4
|
|
|
5
5
|
## 候选提案 `hardware.finalize`
|
|
6
6
|
|
|
7
7
|
本地 module adapter 接收 `operation: "create"`、`planning_mode: "auto"`、已澄清的 `requirements`、`slots[]`、上一步 `hardware.search` 的完整 `knowledge_recall` 响应,以及 Agent 阅读最新 Wiki 后形成的 `selection_proposal`。每槽位有稳定 `slot_id`、功能、`quantity`、受控 `category_path[3]` 及本次硬约束;提案只可引用对应槽位检索候选中的 `component_id`。保存器会再次校验这一关系,并以候选 Wiki 中的厂商、型号、分类和对象证据为准,拒绝 Agent 自行生成的型号标识。
|
|
8
8
|
|
|
9
|
-
Agent 按[型号检索契约](hardware-selection/references/query-contract.md)判断分类路径、数量、整机覆盖扣减及关键接口、供电、机械、热和软件依赖;本地能力检查结果结构并生成稳定 ID/版本。
|
|
9
|
+
Agent 按[型号检索契约](hardware-selection/references/query-contract.md)判断分类路径、数量、整机覆盖扣减及关键接口、供电、机械、热和软件依赖;本地能力检查结果结构并生成稳定 ID/版本。OSS 检索结果是本次型号候选和语义证据,不另起一套语义选择。不得按 `draft`、维护选型门禁或 CAD 状态过滤。
|
|
10
10
|
|
|
11
11
|
响应必须具有非空稳定 `id`、`status`、`source_snapshot`、`revision`、`plan_mode`,以及 `selected_models[]`、`coverage_ledger[]`、`remaining_slots[]`、`unknowns[]`、`replacement_plan[]`。`selected_models[]` 每项必须带**显式的 1–128 整数 `quantity`**(缺失、非整数或越界一律 `VALIDATION` 拒绝):这是本仓唯一能独立核对的覆盖依据,缺了它 `coverage_ledger` 的 `verified_allocated_quantity` 就无从对照。`plan_mode` 只能是 `adapt_system` 或 `assemble_new`;前者须返回基线整机及完整继承/修改证据,后者不得假装继承整机。提案 `status` 只能是 `proposed` 或 `needs_confirmation`,即使字段完整也不授予正式 `verified` 权限。
|
|
12
12
|
|
|
@@ -18,7 +18,7 @@ URL 来自 `ABELIEN_HARDWARE_REVISE_URL`。请求包含 `operation: "revise_exis
|
|
|
18
18
|
|
|
19
19
|
所有能力的响应结构不合格都不会保存资源或通过阶段门。本地归一化输入错误返回 `VALIDATION`,能力结果违反声明返回 `CAPABILITY_RESULT_INVALID`。
|
|
20
20
|
|
|
21
|
-
候选工作流将响应保存在 `hardware-proposal` 资源,不创建 `hardware-selection` 状态机实例。正式工作流才把 `hardware.select` 响应保存为 `hardware-selection` 并通过 `hardware-selection` 状态门;候选事实来自本次
|
|
21
|
+
候选工作流将响应保存在 `hardware-proposal` 资源,不创建 `hardware-selection` 状态机实例。正式工作流才把 `hardware.select` 响应保存为 `hardware-selection` 并通过 `hardware-selection` 状态门;候选事实来自本次 OSS Wiki 检索,当前域包不建立另一套数据库候选系统。
|
|
22
22
|
|
|
23
23
|
## Supplemental source binding
|
|
24
24
|
|
|
@@ -7,7 +7,7 @@ import zlib from "node:zlib";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { validateComponentSnapshot, fieldsFromSnapshotRecord, dimensionFields } from './dimensions.mjs';
|
|
9
9
|
import { digest } from './selection-request.mjs';
|
|
10
|
-
import { parseHardwareWiki } from "./
|
|
10
|
+
import { parseHardwareWiki } from "./oss-wiki-search.mjs";
|
|
11
11
|
|
|
12
12
|
const CATALOG_SCHEMA = "abelien.hardware-wiki-search-catalog.v1";
|
|
13
13
|
const INDEX_SCHEMA = "abelien.hardware-wiki-category-index.v1";
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const EMPTY = Buffer.alloc(0);
|
|
4
|
+
|
|
5
|
+
function ossError(code, message, details) {
|
|
6
|
+
const error = new Error(message);
|
|
7
|
+
error.code = code;
|
|
8
|
+
if (details !== undefined) error.details = details;
|
|
9
|
+
return error;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sha256(value) {
|
|
13
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hmac(key, value, encoding) {
|
|
17
|
+
return crypto.createHmac("sha256", key).update(value).digest(encoding);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizePrefix(value) {
|
|
21
|
+
const cleaned = String(value || "hardware_profile_wiki/").replace(/^\/+|\/+$/g, "");
|
|
22
|
+
if (!cleaned || cleaned.split("/").some(part => part === "." || part === "..")) {
|
|
23
|
+
throw ossError("VALIDATION", "invalid OSS Wiki prefix");
|
|
24
|
+
}
|
|
25
|
+
return `${cleaned}/`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MAX_REQUEST_ATTEMPTS = 5;
|
|
29
|
+
|
|
30
|
+
function boundedAttempts(env) {
|
|
31
|
+
const value = positiveIntegerEnv(env, "OSS_REQUEST_ATTEMPTS", 3);
|
|
32
|
+
if (value > MAX_REQUEST_ATTEMPTS) throw ossError("VALIDATION", `OSS_REQUEST_ATTEMPTS must be at most ${MAX_REQUEST_ATTEMPTS}`, { name: "OSS_REQUEST_ATTEMPTS" });
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function positiveIntegerEnv(env, name, fallback) {
|
|
37
|
+
if (env[name] === undefined || env[name] === "") return fallback;
|
|
38
|
+
const value = Number(env[name]);
|
|
39
|
+
if (!Number.isInteger(value) || value <= 0) throw ossError("VALIDATION", `${name} must be a positive integer`, { name });
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 连接参数与 Web 共用同一组 CAD_STORAGE_* 名字(一个事实一个名字,不设别名);
|
|
44
|
+
// OSS_* 只放本仓独有的检索与发布调参。
|
|
45
|
+
export function createOssConfig(env = {}) {
|
|
46
|
+
const required = ["CAD_STORAGE_REGION", "CAD_STORAGE_ENDPOINT", "CAD_STORAGE_BUCKET", "CAD_STORAGE_ACCESS_KEY", "CAD_STORAGE_SECRET_KEY"];
|
|
47
|
+
const missing = required.filter(name => !env[name]);
|
|
48
|
+
if (missing.length) throw ossError("ENV_MISSING", `env var not set: ${missing[0]}`, { name: missing[0], missing });
|
|
49
|
+
let endpoint;
|
|
50
|
+
try { endpoint = new URL(env.CAD_STORAGE_ENDPOINT); }
|
|
51
|
+
catch { throw ossError("VALIDATION", "CAD_STORAGE_ENDPOINT must be an absolute URL"); }
|
|
52
|
+
if (!['http:', 'https:'].includes(endpoint.protocol)) throw ossError("VALIDATION", "CAD_STORAGE_ENDPOINT must use http or https");
|
|
53
|
+
if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
|
54
|
+
throw ossError("VALIDATION", "CAD_STORAGE_ENDPOINT must not contain credentials, query or fragment");
|
|
55
|
+
}
|
|
56
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{1,62}$/.test(env.CAD_STORAGE_BUCKET)) throw ossError("VALIDATION", "invalid CAD_STORAGE_BUCKET");
|
|
57
|
+
const addressingStyle = env.CAD_STORAGE_ADDRESSING_STYLE || "virtual";
|
|
58
|
+
if (!['virtual', 'path'].includes(addressingStyle)) throw ossError("VALIDATION", "CAD_STORAGE_ADDRESSING_STYLE must be virtual or path");
|
|
59
|
+
return {
|
|
60
|
+
region: env.CAD_STORAGE_REGION,
|
|
61
|
+
endpoint,
|
|
62
|
+
bucket: env.CAD_STORAGE_BUCKET,
|
|
63
|
+
accessKeyId: env.CAD_STORAGE_ACCESS_KEY,
|
|
64
|
+
secretAccessKey: env.CAD_STORAGE_SECRET_KEY,
|
|
65
|
+
sessionToken: env.CAD_STORAGE_SESSION_TOKEN || "",
|
|
66
|
+
addressingStyle,
|
|
67
|
+
prefix: normalizePrefix(env.OSS_WIKI_PREFIX),
|
|
68
|
+
// 网络读取的两道边界。此前 module adapter 路径既无超时也无体积上限:一个不响应的
|
|
69
|
+
// 对象存储会把召回挂到进程结束,一个超大对象会被整段读进内存(召回本身还并发 8 路)。
|
|
70
|
+
// 默认值取"够宽但有限"——Wiki 与分类分片都是小对象,写读都要走这两道闸。
|
|
71
|
+
timeoutMs: positiveIntegerEnv(env, "OSS_REQUEST_TIMEOUT_MS", 30000),
|
|
72
|
+
maxObjectBytes: positiveIntegerEnv(env, "OSS_MAX_OBJECT_BYTES", 16 * 1024 * 1024),
|
|
73
|
+
// 幂等读(GET/HEAD)遇到瞬时故障的总尝试次数(含首次)。召回一次要并发读几十个 Wiki,
|
|
74
|
+
// 任一个连接被断都会让整个 hardware.search 失败、选型流程判死(2026-10-13 小时钟 602fd178)。
|
|
75
|
+
requestAttempts: boundedAttempts(env),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 先按声明的 content-length 拦一道(能在读之前拒),再在读的过程中边读边数——
|
|
80
|
+
// arrayBuffer() 是先整段进内存再回看长度,对"超大对象"这个失败形态等于没拦。
|
|
81
|
+
async function readObjectBytes(response, maxBytes, key, method) {
|
|
82
|
+
const declared = Number(response.headers?.get?.("content-length"));
|
|
83
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
84
|
+
throw ossError("OSS_OBJECT_TOO_LARGE", `OSS object exceeds OSS_MAX_OBJECT_BYTES (${maxBytes})`, { key, method, maxBytes, contentLength: declared });
|
|
85
|
+
}
|
|
86
|
+
if (response.body && typeof response.body.getReader === "function") {
|
|
87
|
+
const reader = response.body.getReader();
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let total = 0;
|
|
90
|
+
for (;;) {
|
|
91
|
+
const { done, value } = await reader.read();
|
|
92
|
+
if (done) break;
|
|
93
|
+
total += value.byteLength;
|
|
94
|
+
if (total > maxBytes) {
|
|
95
|
+
await reader.cancel().catch(() => {});
|
|
96
|
+
throw ossError("OSS_OBJECT_TOO_LARGE", `OSS object exceeds OSS_MAX_OBJECT_BYTES (${maxBytes})`, { key, method, maxBytes });
|
|
97
|
+
}
|
|
98
|
+
chunks.push(Buffer.from(value));
|
|
99
|
+
}
|
|
100
|
+
return Buffer.concat(chunks);
|
|
101
|
+
}
|
|
102
|
+
const bytes = typeof response.arrayBuffer === "function"
|
|
103
|
+
? Buffer.from(await response.arrayBuffer())
|
|
104
|
+
: Buffer.from(await response.text(), "utf8");
|
|
105
|
+
if (bytes.length > maxBytes) {
|
|
106
|
+
throw ossError("OSS_OBJECT_TOO_LARGE", `OSS object exceeds OSS_MAX_OBJECT_BYTES (${maxBytes})`, { key, method, maxBytes, contentLength: bytes.length });
|
|
107
|
+
}
|
|
108
|
+
return bytes;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function encodePath(value) {
|
|
112
|
+
return String(value).split("/").map(part => encodeURIComponent(part).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)).join("/");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function objectLocation(config, key) {
|
|
116
|
+
const cleanKey = String(key).replace(/^\/+/, "");
|
|
117
|
+
if (!cleanKey || cleanKey.split("/").some(part => part === "." || part === "..")) throw ossError("VALIDATION", "invalid OSS object key", { key });
|
|
118
|
+
const parts = [config.endpoint.pathname.replace(/^\/+|\/+$/g, "")].filter(Boolean);
|
|
119
|
+
let host = config.endpoint.host;
|
|
120
|
+
if (config.addressingStyle === "path") parts.push(config.bucket);
|
|
121
|
+
else host = `${config.bucket}.${host}`;
|
|
122
|
+
parts.push(cleanKey);
|
|
123
|
+
const uri = `/${encodePath(parts.join("/"))}`;
|
|
124
|
+
return { host, uri, url: `${config.endpoint.protocol}//${host}${uri}` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function signOssObjectRequest(config, key, { method = "GET", body = EMPTY, headers = {}, now = new Date() } = {}) {
|
|
128
|
+
const payload = Buffer.isBuffer(body) ? body : Buffer.from(body ?? "");
|
|
129
|
+
const location = objectLocation(config, key);
|
|
130
|
+
const timestamp = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
131
|
+
const date = timestamp.slice(0, 8);
|
|
132
|
+
const signed = {
|
|
133
|
+
host: location.host,
|
|
134
|
+
"x-amz-content-sha256": sha256(payload),
|
|
135
|
+
"x-amz-date": timestamp,
|
|
136
|
+
};
|
|
137
|
+
for (const [name, value] of Object.entries(headers)) signed[name.toLowerCase()] = String(value).trim();
|
|
138
|
+
if (config.sessionToken) signed["x-amz-security-token"] = config.sessionToken;
|
|
139
|
+
const names = Object.keys(signed).sort();
|
|
140
|
+
const canonicalHeaders = names.map(name => `${name}:${signed[name]}\n`).join("");
|
|
141
|
+
const signedHeaders = names.join(";");
|
|
142
|
+
const canonicalRequest = [method.toUpperCase(), location.uri, "", canonicalHeaders, signedHeaders, sha256(payload)].join("\n");
|
|
143
|
+
const scope = `${date}/${config.region}/s3/aws4_request`;
|
|
144
|
+
const stringToSign = ["AWS4-HMAC-SHA256", timestamp, scope, sha256(canonicalRequest)].join("\n");
|
|
145
|
+
const dateKey = hmac(`AWS4${config.secretAccessKey}`, date);
|
|
146
|
+
const regionKey = hmac(dateKey, config.region);
|
|
147
|
+
const serviceKey = hmac(regionKey, "s3");
|
|
148
|
+
const signingKey = hmac(serviceKey, "aws4_request");
|
|
149
|
+
const signature = hmac(signingKey, stringToSign, "hex");
|
|
150
|
+
signed.authorization = `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
151
|
+
return { url: location.url, method: method.toUpperCase(), headers: signed, body: payload };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 只有这些失败值得原样再读一次:连接被断或没连上、超时、对端限流或暂时不可用。
|
|
155
|
+
// 4xx(鉴权、不存在、请求错误)与超限是确定性的,重试只会更慢地得到同一个结果。
|
|
156
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
157
|
+
|
|
158
|
+
// fetch 规范里网络错误一律以 TypeError 拒绝(undici 的 "fetch failed"、正文中断的 "terminated");
|
|
159
|
+
// 其它异常(桩、编程错误)照旧报 OSS_REQUEST_FAILED,但不当作断线重试。标记不进序列化输出。
|
|
160
|
+
const NETWORK_FAILURE = Symbol("oss.networkFailure");
|
|
161
|
+
|
|
162
|
+
function requestFailed(cause, key, method) {
|
|
163
|
+
const error = ossError("OSS_REQUEST_FAILED", `OSS request failed: ${cause?.message ?? cause}`, { key, method });
|
|
164
|
+
if (cause instanceof TypeError) error[NETWORK_FAILURE] = true;
|
|
165
|
+
return error;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function transient(error) {
|
|
169
|
+
if (error?.[NETWORK_FAILURE] || error?.code === "OSS_REQUEST_TIMEOUT") return true;
|
|
170
|
+
return error?.code === "OSS_HTTP_STATUS" && RETRYABLE_STATUS.has(error.details?.status);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
174
|
+
|
|
175
|
+
// 写(PUT)不重试:结果未知时不能假定没写成;幂等读每次重新签名(时间戳随之更新),
|
|
176
|
+
// 退避 0.5 s → 1 s → 2 s 再加最多 250 ms 抖动,避免并发 8 路同时撞回去。
|
|
177
|
+
export async function requestOssObject(config, key, options = {}) {
|
|
178
|
+
const method = (options.method ?? "GET").toUpperCase();
|
|
179
|
+
const attempts = method === "GET" || method === "HEAD" ? config.requestAttempts ?? 1 : 1;
|
|
180
|
+
const sleep = options.sleep ?? pause;
|
|
181
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
182
|
+
try { return await requestOssObjectOnce(config, key, options); }
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (attempt >= attempts || !transient(error)) {
|
|
185
|
+
if (attempt > 1 && error.details && typeof error.details === "object") error.details.attempts = attempt;
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
await sleep(500 * 2 ** (attempt - 1) + Math.floor(Math.random() * 250));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function requestOssObjectOnce(config, key, { method = "GET", body = EMPTY, headers = {}, optional = false, fetchImpl = fetch } = {}) {
|
|
194
|
+
const request = signOssObjectRequest(config, key, { method, body, headers });
|
|
195
|
+
let response;
|
|
196
|
+
try {
|
|
197
|
+
response = await fetchImpl(request.url, {
|
|
198
|
+
method: request.method,
|
|
199
|
+
headers: request.headers,
|
|
200
|
+
// 超时覆盖到响应体读完(undici 把 signal 一路带到 body 流):否则一个连上就不发完的
|
|
201
|
+
// 对端同样能挂死召回。既有桩 fetch 忽略 signal,行为不变。
|
|
202
|
+
signal: AbortSignal.timeout(config.timeoutMs),
|
|
203
|
+
...(request.method === "GET" || request.method === "HEAD" ? {} : { body: request.body }),
|
|
204
|
+
});
|
|
205
|
+
} catch (cause) {
|
|
206
|
+
if (cause?.name === "TimeoutError" || cause?.code === "ABORT_ERR") {
|
|
207
|
+
throw ossError("OSS_REQUEST_TIMEOUT", `OSS request timed out after ${config.timeoutMs}ms`, { key, method: request.method, timeout_ms: config.timeoutMs });
|
|
208
|
+
}
|
|
209
|
+
throw requestFailed(cause, key, request.method);
|
|
210
|
+
}
|
|
211
|
+
if (!response.ok) {
|
|
212
|
+
if (optional && response.status === 404) return null;
|
|
213
|
+
throw ossError("OSS_HTTP_STATUS", `OSS returned HTTP ${response.status}`, { key, method: request.method, status: response.status });
|
|
214
|
+
}
|
|
215
|
+
let bytes = EMPTY;
|
|
216
|
+
if (request.method !== "HEAD") {
|
|
217
|
+
// 响应头到了、正文读到一半连接断开或超时,与没连上是同一种瞬时故障。
|
|
218
|
+
try { bytes = await readObjectBytes(response, config.maxObjectBytes, key, request.method); }
|
|
219
|
+
catch (cause) {
|
|
220
|
+
if (cause?.code === "OSS_OBJECT_TOO_LARGE") throw cause;
|
|
221
|
+
if (cause?.name === "TimeoutError" || cause?.code === "ABORT_ERR") {
|
|
222
|
+
throw ossError("OSS_REQUEST_TIMEOUT", `OSS request timed out after ${config.timeoutMs}ms`, { key, method: request.method, timeout_ms: config.timeoutMs });
|
|
223
|
+
}
|
|
224
|
+
throw requestFailed(cause, key, request.method);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
key,
|
|
229
|
+
bytes,
|
|
230
|
+
etag: response.headers?.get?.("etag")?.replace(/^"|"$/g, "") || sha256(bytes),
|
|
231
|
+
lastModified: response.headers?.get?.("last-modified") || null,
|
|
232
|
+
contentLength: Number(response.headers?.get?.("content-length")) || null,
|
|
233
|
+
metadataSha256: response.headers?.get?.("x-amz-meta-sha256") || null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getOssObject(config, key, options = {}) {
|
|
238
|
+
return requestOssObject(config, key, { ...options, method: "GET" });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function putOssObject(config, key, body, options = {}) {
|
|
242
|
+
return requestOssObject(config, key, { ...options, method: "PUT", body });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function headOssObject(config, key, options = {}) {
|
|
246
|
+
return requestOssObject(config, key, { ...options, method: "HEAD" });
|
|
247
|
+
}
|
|
@@ -2,7 +2,7 @@ import zlib from "node:zlib";
|
|
|
2
2
|
import { dimensionFields, evaluateDimensions, orderByDimensionTier, sameDimensionMetadata, screenDimensions, shortlistByDimensionTier, validateFitEnvelope, validateRequirementEnvelopes } from "./dimensions.mjs";
|
|
3
3
|
import { electricalFields } from "./electrical.mjs";
|
|
4
4
|
import { selectionRequestDigest, validCategoryPath } from "./selection-request.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { createOssConfig, getOssObject, sha256 } from "./oss-object-store.mjs";
|
|
6
6
|
|
|
7
7
|
const SEARCH_CATALOG_SCHEMA = "abelien.hardware-wiki-search-catalog.v1";
|
|
8
8
|
const SEARCH_INDEX_SCHEMA = "abelien.hardware-wiki-category-index.v1";
|
|
@@ -15,13 +15,13 @@ function error(code, message, details) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
async function getObject(config, key, fetchImpl, { optional = false } = {}) {
|
|
18
|
-
const object = await
|
|
18
|
+
const object = await getOssObject(config, key, { fetchImpl, optional });
|
|
19
19
|
if (!object) return null;
|
|
20
20
|
const bytes = object.bytes;
|
|
21
21
|
let content = bytes;
|
|
22
22
|
if (key.endsWith(".gz") && bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
|
23
23
|
try { content = zlib.gunzipSync(bytes); }
|
|
24
|
-
catch { throw error("WIKI_SEARCH_INDEX_INVALID", "
|
|
24
|
+
catch { throw error("WIKI_SEARCH_INDEX_INVALID", "OSS category index is not valid gzip", { key }); }
|
|
25
25
|
}
|
|
26
26
|
return {
|
|
27
27
|
text: content.toString("utf8"),
|
|
@@ -171,9 +171,9 @@ function parseJsonObject(object, code, message, key) {
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
function parseCatalog(object, key) {
|
|
174
|
-
const catalog = parseJsonObject(object, "WIKI_SEARCH_CATALOG_INVALID", "
|
|
174
|
+
const catalog = parseJsonObject(object, "WIKI_SEARCH_CATALOG_INVALID", "OSS hardware search catalog is not valid JSON", key);
|
|
175
175
|
if (catalog?.schema !== SEARCH_CATALOG_SCHEMA || !Array.isArray(catalog.categories)) {
|
|
176
|
-
throw error("WIKI_SEARCH_CATALOG_INVALID", "
|
|
176
|
+
throw error("WIKI_SEARCH_CATALOG_INVALID", "OSS hardware search catalog has an unsupported shape", { key });
|
|
177
177
|
}
|
|
178
178
|
const categories = new Map();
|
|
179
179
|
for (const descriptor of catalog.categories) {
|
|
@@ -195,9 +195,9 @@ function parseCategoryIndex(object, descriptor, key) {
|
|
|
195
195
|
if (descriptor.sha256 && descriptor.sha256 !== object.raw_sha256) {
|
|
196
196
|
throw error("WIKI_SEARCH_INDEX_INVALID", "category index SHA-256 does not match catalog", { key });
|
|
197
197
|
}
|
|
198
|
-
const index = parseJsonObject(object, "WIKI_SEARCH_INDEX_INVALID", "
|
|
198
|
+
const index = parseJsonObject(object, "WIKI_SEARCH_INDEX_INVALID", "OSS hardware category index is not valid JSON", key);
|
|
199
199
|
if (index?.schema !== SEARCH_INDEX_SCHEMA || !categoryMatches(index.category_path, descriptor.category_path) || !Array.isArray(index.records)) {
|
|
200
|
-
throw error("WIKI_SEARCH_INDEX_INVALID", "
|
|
200
|
+
throw error("WIKI_SEARCH_INDEX_INVALID", "OSS hardware category index has an unsupported shape", { key });
|
|
201
201
|
}
|
|
202
202
|
if (index.records.length !== descriptor.record_count) {
|
|
203
203
|
throw error("WIKI_SEARCH_INDEX_INVALID", "category index record count does not match catalog", { key });
|
|
@@ -327,7 +327,7 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
|
|
|
327
327
|
const results = [];
|
|
328
328
|
const unmatchedSlots = [];
|
|
329
329
|
const shardSnapshots = [];
|
|
330
|
-
const concurrency = envPositiveInteger(env, "
|
|
330
|
+
const concurrency = envPositiveInteger(env, "OSS_SEARCH_CONCURRENCY", 8);
|
|
331
331
|
|
|
332
332
|
async function loadIndex(descriptor) {
|
|
333
333
|
if (!indexCache.has(descriptor.index_key)) {
|
|
@@ -383,7 +383,7 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
|
|
|
383
383
|
results,
|
|
384
384
|
unmatchedSlots,
|
|
385
385
|
unknowns: [],
|
|
386
|
-
sourceSnapshot: `
|
|
386
|
+
sourceSnapshot: `oss:${config.bucket}:${config.prefix}:catalog:${catalogObject.etag}:indexes:${shardsDigest}:content:${catalogObject.raw_sha256}`,
|
|
387
387
|
retrievalMode: "category_index",
|
|
388
388
|
};
|
|
389
389
|
}
|
|
@@ -391,8 +391,8 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
|
|
|
391
391
|
async function searchWithLegacyManifest({ input, env, config, fetchImpl, topK, maxFetch, includeContent }) {
|
|
392
392
|
const manifestKey = `${config.prefix}_manifest.json`;
|
|
393
393
|
const manifestObject = await getObject(config, manifestKey, fetchImpl);
|
|
394
|
-
const manifest = parseJsonObject(manifestObject, "WIKI_MANIFEST_INVALID", "
|
|
395
|
-
if (!Array.isArray(manifest)) throw error("WIKI_MANIFEST_INVALID", "
|
|
394
|
+
const manifest = parseJsonObject(manifestObject, "WIKI_MANIFEST_INVALID", "OSS hardware Wiki manifest is not valid JSON", manifestKey);
|
|
395
|
+
if (!Array.isArray(manifest)) throw error("WIKI_MANIFEST_INVALID", "OSS hardware Wiki manifest must be an array", { key: manifestKey });
|
|
396
396
|
const scanLimit = Number.isInteger(input.policy?.scan_limit_per_slot) && input.policy.scan_limit_per_slot > 0
|
|
397
397
|
? input.policy.scan_limit_per_slot : null;
|
|
398
398
|
const wikiLoader = createWikiLoader(config, fetchImpl, { legacy: true });
|
|
@@ -405,7 +405,7 @@ async function searchWithLegacyManifest({ input, env, config, fetchImpl, topK, m
|
|
|
405
405
|
if (scanLimit && allEntries.length > entries.length) {
|
|
406
406
|
unknowns.push({ slot_id: slot.slot_id, code: "SCAN_LIMIT", available: allEntries.length, scanned: entries.length });
|
|
407
407
|
}
|
|
408
|
-
const loaded = await mapConcurrent(entries, envPositiveInteger(env, "
|
|
408
|
+
const loaded = await mapConcurrent(entries, envPositiveInteger(env, "OSS_SEARCH_CONCURRENCY", 8), wikiLoader);
|
|
409
409
|
const semanticQuery = semanticQueryFor(input, slot);
|
|
410
410
|
const candidates = shortlistByDimensionTier(loaded.map(document => {
|
|
411
411
|
const ranking = scoreDocument(document, semanticQuery, input.policy?.prefer_system_candidates !== false);
|
|
@@ -421,28 +421,28 @@ async function searchWithLegacyManifest({ input, env, config, fetchImpl, topK, m
|
|
|
421
421
|
}
|
|
422
422
|
return {
|
|
423
423
|
results, unmatchedSlots, unknowns,
|
|
424
|
-
sourceSnapshot: `
|
|
424
|
+
sourceSnapshot: `oss:${config.bucket}:${config.prefix}:manifest:${manifestObject.etag}`,
|
|
425
425
|
retrievalMode: "legacy_manifest_full_scan",
|
|
426
426
|
};
|
|
427
427
|
}
|
|
428
428
|
|
|
429
429
|
export async function searchHardware({ input, env, fetchImpl = fetch }) {
|
|
430
430
|
validateInput(input);
|
|
431
|
-
const config =
|
|
431
|
+
const config = createOssConfig(env);
|
|
432
432
|
const policy = input.policy ?? {};
|
|
433
433
|
const topK = Number.isInteger(policy.top_k_per_slot) && policy.top_k_per_slot > 0 ? policy.top_k_per_slot : 8;
|
|
434
|
-
const maxFullWikiFetch = envPositiveInteger(env, "
|
|
434
|
+
const maxFullWikiFetch = envPositiveInteger(env, "OSS_MAX_FULL_WIKI_FETCH_PER_SLOT", 24);
|
|
435
435
|
if (topK > maxFullWikiFetch) {
|
|
436
|
-
throw error("VALIDATION", `top_k_per_slot exceeds
|
|
436
|
+
throw error("VALIDATION", `top_k_per_slot exceeds OSS_MAX_FULL_WIKI_FETCH_PER_SLOT (${maxFullWikiFetch})`, {
|
|
437
437
|
top_k_per_slot: topK, max_full_wiki_fetch_per_slot: maxFullWikiFetch,
|
|
438
438
|
});
|
|
439
439
|
}
|
|
440
440
|
const includeContent = policy.include_content !== false;
|
|
441
441
|
const catalogObject = await getObject(config, `${config.prefix}_catalog.json`, fetchImpl, { optional: true });
|
|
442
|
-
const allowLegacy = policy.allow_legacy_manifest_fallback === true || env.
|
|
442
|
+
const allowLegacy = policy.allow_legacy_manifest_fallback === true || env.OSS_ALLOW_LEGACY_MANIFEST_FALLBACK === "true";
|
|
443
443
|
if (!catalogObject && !allowLegacy) {
|
|
444
|
-
throw error("WIKI_SEARCH_INDEX_MISSING", "
|
|
445
|
-
key: `${config.prefix}_catalog.json`, migration_env: "
|
|
444
|
+
throw error("WIKI_SEARCH_INDEX_MISSING", "OSS hardware category search catalog is missing; publish _catalog.json and indexes before searching", {
|
|
445
|
+
key: `${config.prefix}_catalog.json`, migration_env: "OSS_ALLOW_LEGACY_MANIFEST_FALLBACK=true",
|
|
446
446
|
});
|
|
447
447
|
}
|
|
448
448
|
const searched = catalogObject
|